Skip to content

Commit 3ebbc55

Browse files
AR21SMhoxbrophilippjfr
authored
feat: Add categorical_agg operation for linked selections with categorical Bars (#6804)
Co-authored-by: Simon Høxbro Hansen <hoxbro@protonmail.com> Co-authored-by: Philipp Rudiger <prudiger@anaconda.com>
1 parent ce3b2e0 commit 3ebbc55

9 files changed

Lines changed: 353 additions & 9 deletions

File tree

examples/user_guide/Linked_Brushing.ipynb

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"import holoviews as hv\n",
1313
"from holoviews import opts\n",
1414
"from holoviews.operation import gridmatrix\n",
15-
"from holoviews.operation.element import histogram\n",
15+
"from holoviews.operation.element import categorical_agg, histogram\n",
1616
"from holoviews.selection import link_selections\n",
1717
"from holoviews.util.transform import dim\n",
1818
"\n",
@@ -475,6 +475,74 @@
475475
"mpg_ls.unlink(w_accel_scatter)"
476476
]
477477
},
478+
{
479+
"cell_type": "markdown",
480+
"metadata": {},
481+
"source": [
482+
"### Categorical Dimensions with `categorical_agg`\n",
483+
"\n",
484+
"The `histogram` operation only works with numeric dimensions. For categorical \n",
485+
"dimensions, use the built-in `categorical_agg` operation, which aggregates \n",
486+
"a categorical dimension into a `Bars` element while preserving data lineage \n",
487+
"for `link_selections`.\n"
488+
]
489+
},
490+
{
491+
"cell_type": "markdown",
492+
"metadata": {},
493+
"source": [
494+
"#### Why `categorical_agg` works with `link_selections`\n",
495+
"\n",
496+
"`categorical_agg` is an `Operation` subclass, so it remembers its source \n",
497+
"element. When a selection is made, `link_selections` traces back through the \n",
498+
"operation to the source (e.g. `Points`), applies the filter there, then \n",
499+
"re-runs `categorical_agg` on the filtered subset — re-aggregating correctly.\n",
500+
"\n",
501+
"Pre-aggregated `hv.Bars(df.groupby(...))` does **not** work because it has \n",
502+
"no source reference.\n"
503+
]
504+
},
505+
{
506+
"cell_type": "code",
507+
"execution_count": null,
508+
"metadata": {},
509+
"outputs": [],
510+
"source": [
511+
"hv.extension('bokeh')\n",
512+
"\n",
513+
"rng = np.random.default_rng(42)\n",
514+
"df = pd.DataFrame({\n",
515+
" 'x': rng.normal(0, 1, 200),\n",
516+
" 'y': rng.normal(0, 1, 200),\n",
517+
" 'category': rng.choice(['A', 'B', 'C', 'D'], 200),\n",
518+
"})\n",
519+
"\n",
520+
"ls = hv.link_selections.instance()\n",
521+
"points = hv.Points(df, kdims=['x', 'y'], vdims=['category'])\n",
522+
"\n",
523+
"bars_count = categorical_agg(points, dimension='category')\n",
524+
"bars_mean = categorical_agg(points, dimension='category', value_dimension='y', function=np.mean)\n",
525+
"hist_x = histogram(points, dimension='x', num_bins=20)\n",
526+
"\n",
527+
"ls(points) + ls(bars_count) + ls(bars_mean) + ls(hist_x)"
528+
]
529+
},
530+
{
531+
"cell_type": "markdown",
532+
"metadata": {},
533+
"source": [
534+
"### Supported aggregation functions\n",
535+
"\n",
536+
"| Call | Result |\n",
537+
"|------|--------|\n",
538+
"| `categorical_agg(points, dimension='cat')` | Count per category |\n",
539+
"| `categorical_agg(points, dimension='cat', value_dimension='y', function=np.sum)` | Sum of `y` per category |\n",
540+
"| `categorical_agg(points, dimension='cat', value_dimension='y', function=np.mean)` | Mean of `y` |\n",
541+
"| `categorical_agg(points, dimension='cat', value_dimension='y', function=np.std)` | Std deviation |\n",
542+
"\n",
543+
"You can also click individual bars to cross-filter (tap-to-select).\n"
544+
]
545+
},
478546
{
479547
"cell_type": "markdown",
480548
"metadata": {},
@@ -558,8 +626,8 @@
558626
"\n",
559627
"<tr><td class=\"sG\">Area</td><td class=\"sY\">Yes</td><td class=\"sY\">Yes</td><td class=\"sY\">Yes</td>\n",
560628
" <td class=\"sN\">No </td></tr>\n",
561-
"<tr><td class=\"sG\">Bars</td><td class=\"sN\">No </td><td class=\"sN\">No </td><td class=\"sN\">No </td>\n",
562-
" <td class=\"sN\">No </td><td>Complicated to support stacked and multi-level bars</td></tr>\n",
629+
"<tr><td class=\"sG\">Bars</td><td class=\"sN\">Yes </td><td class=\"sN\">Yes </td><td class=\"sN\">No </td>\n",
630+
" <td class=\"sN\">No </td><td>Use categorical_agg with link_selections for tap-to-select on categorical bars</td></tr>\n",
563631
"<tr><td class=\"sG\">Bivariate</td><td class=\"sY\">Yes</td><td class=\"sY\">Yes</td><td class=\"sY\">Yes</td>\n",
564632
" <td class=\"sY\">Yes </td></tr>\n",
565633
"<tr><td class=\"sG\">BoxWhisker</td><td class=\"sY\">Yes</td><td class=\"sY\">Yes</td><td class=\"sY\">Yes</td>\n",

holoviews/element/chart.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
Rectangles,
1111
VectorField,
1212
)
13-
from .selection import Selection1DExpr
13+
from .selection import Selection1DExpr, SelectionBarsExpr
1414

1515

1616
class Chart(Dataset, Element2D):
@@ -188,7 +188,7 @@ class Spread(ErrorBars):
188188
group = param.String(default="Spread", constant=True)
189189

190190

191-
class Bars(Selection1DExpr, Chart):
191+
class Bars(SelectionBarsExpr, Chart):
192192
"""Bars is a Chart element representing categorical observations
193193
using the height of rectangular bars. The key dimensions represent
194194
the categorical groupings of the data, but may also be used to

holoviews/element/selection.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,3 +569,32 @@ def _merge_regions(region1, region2, operation):
569569
new = len(contiguous)
570570
data = contiguous
571571
return NdOverlay([(i, region1.last.clone(l, u)) for i, (l, u) in enumerate(data)])
572+
573+
574+
class SelectionBarsExpr(Selection1DExpr):
575+
"""Mixin class for Bars adding tap/click-to-select on top of box-select.
576+
577+
Translates Selection1D tap events (integer row indices) into an
578+
isin expression over the bar's key dimension for link_selections.
579+
"""
580+
581+
_selection_streams = (SelectionXY, Selection1D)
582+
583+
_selection_uses_selection1d_without_index_cols = True
584+
585+
def _get_selection_expr_for_stream_value(self, **kwargs):
586+
index = kwargs.get("index")
587+
index_cols = kwargs.get("index_cols")
588+
589+
if "index" in kwargs and index_cols is None:
590+
if not index:
591+
return None, None, None
592+
kdim = self.kdims[0]
593+
cat_vals = self.dimension_values(kdim, expanded=False)
594+
clicked = [cat_vals[i] for i in index if 0 <= i < len(cat_vals)]
595+
if clicked:
596+
expr = dim(kdim).isin(list(util.unique_iterator(clicked)))
597+
return expr, None, None
598+
return None, None, None
599+
600+
return super()._get_selection_expr_for_stream_value(**kwargs)

holoviews/operation/element.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
label_sanitizer,
4545
)
4646
from ..core.util.dependencies import _no_import_version, cp
47-
from ..element.chart import Histogram, Scatter
47+
from ..element.chart import Bars, Histogram, Scatter
4848
from ..element.path import Contours, Dendrogram, Polygons
4949
from ..element.raster import RGB, HeatMap, Image
5050
from ..element.util import categorical_aggregate2d # noqa: F401
@@ -1101,6 +1101,69 @@ def _process(self, element, key=None, groupby=False):
11011101
return Histogram((edges, hist), kdims=[dim], label=element.label, **params)
11021102

11031103

1104+
class categorical_agg(Operation):
1105+
"""Aggregates a categorical dimension of an element into a Bars element.
1106+
1107+
Because it is an Operation subclass it preserves data lineage,
1108+
allowing link_selections to cross-filter through the aggregation.
1109+
"""
1110+
1111+
dimension = param.String(
1112+
default=None,
1113+
allow_None=True,
1114+
doc="Categorical dimension to group by.",
1115+
)
1116+
1117+
value_dimension = param.String(
1118+
default=None,
1119+
allow_None=True,
1120+
doc="Numeric dimension to aggregate. If None, counts rows per category.",
1121+
)
1122+
1123+
function = param.Callable(
1124+
default=np.size,
1125+
doc="Aggregation function applied to value_dimension values.",
1126+
)
1127+
1128+
label = param.String(
1129+
default=None,
1130+
allow_None=True,
1131+
doc="Label for the value axis. Auto-generated from function name if None.",
1132+
)
1133+
1134+
def _process(self, element, key=None):
1135+
if self.p.dimension is None:
1136+
raise ValueError("The dimension parameter is required for categorical_agg.")
1137+
1138+
cat_dim = element.get_dimension(self.p.dimension)
1139+
if cat_dim is None:
1140+
raise ValueError(f"Dimension '{self.p.dimension}' not found on the input element.")
1141+
1142+
cat_vals = element.dimension_values(self.p.dimension, expanded=True)
1143+
unique_cats = np.unique(cat_vals)
1144+
1145+
if self.p.value_dimension is None:
1146+
_, counts = np.unique(cat_vals, return_counts=True)
1147+
agg_label = self.p.label or "Count"
1148+
data = list(zip(unique_cats, counts, strict=True))
1149+
else:
1150+
val_dim = element.get_dimension(self.p.value_dimension)
1151+
if val_dim is None:
1152+
raise ValueError(
1153+
f"Value dimension '{self.p.value_dimension}' not found on the input element."
1154+
)
1155+
num_vals = element.dimension_values(self.p.value_dimension, expanded=True)
1156+
results = []
1157+
for cat in unique_cats:
1158+
mask = cat_vals == cat
1159+
results.append(self.p.function(num_vals[mask]))
1160+
func_name = getattr(self.p.function, "__name__", "agg")
1161+
agg_label = self.p.label or f"{func_name}({self.p.value_dimension})"
1162+
data = list(zip(unique_cats, results, strict=True))
1163+
1164+
return Bars(data, kdims=[self.p.dimension], vdims=[agg_label])
1165+
1166+
11041167
class decimate(Operation):
11051168
"""Decimates any column based Element to a specified number of random
11061169
rows if the current element defined by the x_range and y_range

holoviews/plotting/bokeh/chart.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,11 @@ class BarPlot(BarsMixin, ColorbarPlot, LegendPlot):
10171017

10181018
selection_display = BokehOverlaySelectionDisplay()
10191019

1020+
default_tools = param.List(
1021+
default=["save", "pan", "wheel_zoom", "box_zoom", "reset", "tap"],
1022+
doc="Default tools for BarPlot. Includes tap for click-to-select with link_selections.",
1023+
)
1024+
10201025
style_opts = base_properties + fill_properties + line_properties + ["bar_width", "cmap"]
10211026

10221027
_nonvectorized_styles = [*base_properties, "bar_width", "cmap"]

holoviews/plotting/bokeh/element.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3259,7 +3259,11 @@ def _get_colormapper(
32593259

32603260
cmapper = self.handles.get(name)
32613261
if cmapper is not None:
3262-
if cmapper.palette != palette:
3262+
try:
3263+
palette_changed = bool(cmapper.palette != palette)
3264+
except Exception:
3265+
palette_changed = True
3266+
if palette_changed:
32633267
cmapper.palette = palette
32643268
opts = {k: opt for k, opt in opts.items() if getattr(cmapper, k) != opt}
32653269
if opts:

holoviews/streams.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,9 +1173,26 @@ def constants(self):
11731173
}
11741174

11751175
def transform(self):
1176-
# Skip index streams if no index_cols are provided
1176+
# Skip Selection1D when no index_cols, unless element opts in.
1177+
from .core.spaces import DynamicMap
1178+
1179+
source = self.source
1180+
if isinstance(source, DynamicMap):
1181+
element_type = source.type
1182+
else:
1183+
element_type = type(source)
1184+
1185+
uses_selection1d_standalone = getattr(
1186+
element_type, "_selection_uses_selection1d_without_index_cols", False
1187+
)
1188+
11771189
for stream in self.input_streams:
1178-
if isinstance(stream, Selection1D) and stream._triggering and not self._index_cols:
1190+
if (
1191+
isinstance(stream, Selection1D)
1192+
and stream._triggering
1193+
and not self._index_cols
1194+
and not uses_selection1d_standalone
1195+
):
11791196
return
11801197
return super().transform()
11811198

holoviews/tests/element/test_selection.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import holoviews as hv
1212
from holoviews.element.selection import spatial_select_columnar
13+
from holoviews.streams import Selection1D, SelectionXY
1314
from holoviews.testing import assert_data_equal, assert_dict_equal, assert_element_equal
1415

1516
from .._deps import dd, dd_skip, ds_skip, shapely_skip, spd_skip
@@ -221,6 +222,71 @@ def test_distribution_single_inverted(self):
221222
assert_element_equal(region, hv.NdOverlay({0: hv.HSpan(3, 7)}))
222223

223224

225+
class TestSelectionBarsExpr:
226+
def setup_method(self):
227+
import holoviews.plotting.bokeh # noqa: F401
228+
229+
self._backend = hv.Store.current_backend
230+
hv.Store.set_current_backend("bokeh")
231+
232+
def teardown_method(self):
233+
hv.Store.current_backend = self._backend
234+
235+
def _make_bars(self):
236+
return hv.Bars(
237+
(["A", "B", "C", "D"], [10, 20, 5, 15]), kdims=["category"], vdims=["count"]
238+
)
239+
240+
def test_bars_tap_single_category(self):
241+
bars = self._make_bars()
242+
expr, bbox, region = bars._get_selection_expr_for_stream_value(index=[1])
243+
assert bbox is None
244+
assert region is None
245+
assert expr == hv.dim("category").isin(["B"])
246+
assert_data_equal(
247+
expr.apply(bars),
248+
np.array([False, True, False, False]),
249+
)
250+
251+
def test_bars_tap_multiple_categories(self):
252+
bars = self._make_bars()
253+
expr, bbox, region = bars._get_selection_expr_for_stream_value(index=[0, 2])
254+
assert bbox is None
255+
assert region is None
256+
assert expr == hv.dim("category").isin(["A", "C"])
257+
assert_data_equal(
258+
expr.apply(bars),
259+
np.array([True, False, True, False]),
260+
)
261+
262+
def test_bars_tap_empty_index(self):
263+
bars = self._make_bars()
264+
expr, bbox, region = bars._get_selection_expr_for_stream_value(index=[])
265+
assert expr is None
266+
assert bbox is None
267+
assert region is None
268+
269+
def test_bars_tap_no_index_kwarg(self):
270+
bars = self._make_bars()
271+
expr, _bbox, _region = bars._get_selection_expr_for_stream_value()
272+
assert expr is None
273+
274+
def test_bars_box_select_categorical(self):
275+
bars = self._make_bars()
276+
expr, bbox, _region = bars._get_selection_expr_for_stream_value(
277+
bounds=(0, 0, 2, 25), x_selection=["A", "B", "C"]
278+
)
279+
assert bbox == {"category": ["A", "B", "C"]}
280+
assert_data_equal(
281+
expr.apply(bars),
282+
np.array([True, True, True, False]),
283+
)
284+
285+
def test_bars_selection_streams_include_selection1d(self):
286+
assert Selection1D in hv.Bars._selection_streams
287+
assert SelectionXY in hv.Bars._selection_streams
288+
289+
224290
class TestSelection2DExpr:
225291
def setup_method(self):
226292
import holoviews.plotting.bokeh # noqa: F401

0 commit comments

Comments
 (0)