-
-
Notifications
You must be signed in to change notification settings - Fork 416
enh: Support selector and ds.summary for geom_aggregator
#6743
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
a7d5486
enh: Support selector for geom_aggregator
hoxbro a211b13
Update tests
hoxbro c7d6e9b
Add NotImplemetedError to datashader
hoxbro b3ddbd4
Fix UI tests
hoxbro bbbc4af
Add test for setting hover_tooltips and still have filtering work
hoxbro d76d01e
Update transpose
hoxbro e259826
Add tests for geom_aggregate
hoxbro 8758382
support ds.by as aggregator for geom_aggregate
hoxbro 5150c9a
Update holoviews/operation/datashader.py
hoxbro 589b61a
Merge branch 'main' into enh_support_selector_for_geom_aggregate
hoxbro 0fbc2b0
Merge branch 'main' into enh_support_selector_for_geom_aggregate
hoxbro File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -90,6 +90,25 @@ def __getattr__(attr): | |
| raise AttributeError(f"module {__name__!r} has no attribute {attr!r}") | ||
|
|
||
|
|
||
| class AggState(enum.Enum): | ||
| AGG_ONLY = 0 # Only aggregator | ||
| AGG_BY = 1 # Aggregator where the aggregator is ds.by | ||
| AGG_SEL = 2 # Selector and aggregator | ||
| AGG_SEL_BY = 3 # Selector and aggregator, where the aggregator is ds.by | ||
|
|
||
| def get_state(agg_fn, sel_fn): | ||
| if isinstance(agg_fn, ds.by): | ||
| return AggState.AGG_SEL_BY if sel_fn else AggState.AGG_BY | ||
| else: | ||
| return AggState.AGG_SEL if sel_fn else AggState.AGG_ONLY | ||
|
|
||
| def has_sel(state): | ||
| return state in (AggState.AGG_SEL, AggState.AGG_SEL_BY) | ||
|
|
||
| def has_by(state): | ||
| return state in (AggState.AGG_BY, AggState.AGG_SEL_BY) | ||
|
|
||
|
|
||
| class AggregationOperation(ResampleOperation2D): | ||
| """AggregationOperation extends the ResampleOperation2D defining an | ||
| aggregator parameter used to define a datashader Reduction. | ||
|
|
@@ -228,6 +247,92 @@ def _get_agg_params(self, element, x, y, agg_fn, bounds): | |
| params['vdims'] = vdims | ||
| return params | ||
|
|
||
| def _get_agg_state(self, element): | ||
| agg_fn = self._get_aggregator(element, self.p.aggregator) | ||
| sel_fn = getattr(self.p, "selector", None) | ||
| agg_state = AggState.get_state(agg_fn, sel_fn) | ||
|
|
||
| if DATASHADER_GE_0_15_1 and sel_fn and sel_fn.column is None: | ||
| sel_fn = type(sel_fn)(column=rd.SpecialColumn.RowIndex) | ||
| if AggState.has_by(agg_state) and self.p.element_type is Image: | ||
| self.p.element_type = ImageStack | ||
|
|
||
| return agg_fn, sel_fn, agg_state | ||
|
|
||
| def _apply_aggregate_with_agg_state(self, dfdata, cvs_fn, agg_fn, x, y, agg_state, sel_fn, params): | ||
| if AggState.has_sel(agg_state): | ||
| if isinstance(params["vdims"], (Dimension, str)): | ||
| params["vdims"] = [params["vdims"]] | ||
| sum_agg = ds.summary(**{str(params["vdims"][0]): agg_fn, "__index__": ds.where(sel_fn)}) | ||
| agg = self._apply_datashader(dfdata, cvs_fn, sum_agg, x, y, agg_state) | ||
| agg.attrs["selector"] = ( | ||
| str(sel_fn) | ||
| if DATASHADER_GE_0_18_1 | ||
| else f"{type(sel_fn).__name__}({getattr(sel_fn, 'column', '...')!r})" | ||
| ).replace(repr(rd.SpecialColumn.RowIndex), "") | ||
| else: | ||
| agg = self._apply_datashader(dfdata, cvs_fn, agg_fn, x, y, agg_state) | ||
|
|
||
| return agg | ||
|
|
||
| def _apply_where_summary(self, dfdata, x_name, y_name, agg_fn, agg, agg_state): | ||
| is_where_index = DATASHADER_GE_0_15_1 and isinstance(agg_fn, ds.where) and isinstance(agg_fn.column, rd.SpecialColumn) | ||
| is_summary_index = AggState.has_sel(agg_state) | ||
| if is_where_index or is_summary_index: | ||
| if is_where_index: | ||
| index = agg.data | ||
| agg = agg.to_dataset(name="__index__") | ||
| else: # summary index | ||
| index = agg["__index__"].data | ||
| if agg_state == AggState.AGG_SEL_BY: | ||
| main_dim = next(k for k in agg if k != "__index__") | ||
| # Taking values from the main dimension expanding it to | ||
| # a new dataset | ||
| agg = agg[main_dim].to_dataset(dim=list(agg.sizes)[2]) | ||
| agg["__index__"] = ((y_name, x_name), index) | ||
|
|
||
| neg1 = index == -1 | ||
| agg.attrs["selector_columns"] = sel_cols = ["__index__"] | ||
| for col in dfdata.columns: | ||
| if col in agg.coords: | ||
| continue | ||
| ser = dfdata[col] | ||
| kind = dtype_kind(ser) | ||
| if kind == "O" and getattr(ser.dtype, "storage", None) == "pyarrow": | ||
| # pyarrow can only handle 1-dimensional | ||
| val = np.asarray(ser)[index] | ||
| else: | ||
| val = ser.values[index] | ||
| if kind == 'f': | ||
| val[neg1] = np.nan | ||
| elif isinstance(val.dtype, pd.CategoricalDtype): | ||
| val = val.to_numpy() | ||
| val[neg1] = "-" | ||
| elif kind == "O": | ||
| val[neg1] = "-" | ||
| elif kind == "M": | ||
| val[neg1] = np.datetime64("NaT") | ||
| else: | ||
| val = val.astype(np.float64) | ||
| val[neg1] = np.nan | ||
| agg[col] = ((y_name, x_name), val) | ||
| sel_cols.append(col) | ||
|
|
||
| if agg_state == AggState.AGG_BY: | ||
| col = agg_fn.column | ||
| if '' in agg.coords[col]: | ||
| agg = agg.drop_sel(**{col: ''}) | ||
|
|
||
| return agg | ||
|
|
||
| def _apply_datashader(self, dfdata, cvs_fn, agg_fn, x, y, agg_state: AggState): | ||
| raise NotImplementedError | ||
|
|
||
| def _get_category_column_name(self, agg_fn) -> str | None: | ||
| if hasattr(agg_fn, 'cat_column'): | ||
| return agg_fn.cat_column | ||
| else: | ||
| return agg_fn.column if isinstance(agg_fn, ds.count_cat) else None | ||
|
|
||
|
|
||
| class LineAggregationOperation(AggregationOperation): | ||
|
|
@@ -246,26 +351,6 @@ class LineAggregationOperation(AggregationOperation): | |
| appearance of a subpixel line width.""") | ||
|
|
||
|
|
||
|
|
||
| class AggState(enum.Enum): | ||
| AGG_ONLY = 0 # Only aggregator | ||
| AGG_BY = 1 # Aggregator where the aggregator is ds.by | ||
| AGG_SEL = 2 # Selector and aggregator | ||
| AGG_SEL_BY = 3 # Selector and aggregator, where the aggregator is ds.by | ||
|
|
||
| def get_state(agg_fn, sel_fn): | ||
| if isinstance(agg_fn, ds.by): | ||
| return AggState.AGG_SEL_BY if sel_fn else AggState.AGG_BY | ||
| else: | ||
| return AggState.AGG_SEL if sel_fn else AggState.AGG_ONLY | ||
|
|
||
| def has_sel(state): | ||
| return state in (AggState.AGG_SEL, AggState.AGG_SEL_BY) | ||
|
|
||
| def has_by(state): | ||
| return state in (AggState.AGG_BY, AggState.AGG_SEL_BY) | ||
|
|
||
|
|
||
| class aggregate(LineAggregationOperation): | ||
| """aggregate implements 2D binning for any valid HoloViews Element | ||
| type using datashader. I.e., this operation turns a HoloViews | ||
|
|
@@ -381,20 +466,9 @@ def get_agg_data(cls, obj, category=None): | |
| df[d.name] = cast_array_to_int64(vals) | ||
| return x, y, Dataset(df, kdims=kdims, vdims=vdims), glyph | ||
|
|
||
|
|
||
| def _process(self, element, key=None): | ||
| agg_fn = self._get_aggregator(element, self.p.aggregator) | ||
| sel_fn = getattr(self.p, "selector", None) | ||
| if hasattr(agg_fn, 'cat_column'): | ||
| category = agg_fn.cat_column | ||
| else: | ||
| category = agg_fn.column if isinstance(agg_fn, ds.count_cat) else None | ||
| if DATASHADER_GE_0_15_1 and sel_fn and sel_fn.column is None: | ||
| sel_fn = type(sel_fn)(column=rd.SpecialColumn.RowIndex) | ||
| agg_state = AggState.get_state(agg_fn, sel_fn) | ||
|
|
||
| if AggState.has_by(agg_state) and self.p.element_type is Image: | ||
| self.p.element_type = ImageStack | ||
| agg_fn, sel_fn, agg_state = self._get_agg_state(element) | ||
| category_name = self._get_category_column_name(agg_fn) | ||
|
|
||
| if overlay_aggregate.applies(element, agg_fn, line_width=self.p.line_width, sel_fn=sel_fn): | ||
| params = dict( | ||
|
|
@@ -406,7 +480,7 @@ def _process(self, element, key=None): | |
| if element._plot_id in self._precomputed: | ||
| x, y, data, glyph = self._precomputed[element._plot_id] | ||
| else: | ||
| x, y, data, glyph = self.get_agg_data(element, category) | ||
| x, y, data, glyph = self.get_agg_data(element, category_name) | ||
|
|
||
| if self.p.precompute: | ||
| self._precomputed[element._plot_id] = x, y, data, glyph | ||
|
|
@@ -426,25 +500,9 @@ def _process(self, element, key=None): | |
| cvs = ds.Canvas(plot_width=width, plot_height=height, | ||
| x_range=x_range, y_range=y_range) | ||
|
|
||
| agg_kwargs = {} | ||
| if self.p.line_width and glyph == 'line' and DATASHADER_GE_0_14_0: | ||
| agg_kwargs['line_width'] = self.p.line_width | ||
|
|
||
| dfdata = PandasInterface.as_dframe(data) | ||
| cvs_fn = getattr(cvs, glyph) | ||
|
|
||
| if AggState.has_sel(agg_state): | ||
| if isinstance(params["vdims"], (Dimension, str)): | ||
| params["vdims"] = [params["vdims"]] | ||
| sum_agg = ds.summary(**{str(params["vdims"][0]): agg_fn, "__index__": ds.where(sel_fn)}) | ||
| agg = self._apply_datashader(dfdata, cvs_fn, sum_agg, agg_kwargs, x, y, agg_state) | ||
| agg.attrs["selector"] = ( | ||
| str(sel_fn) | ||
| if DATASHADER_GE_0_18_1 | ||
| else f"{type(sel_fn).__name__}({getattr(sel_fn, 'column', '...')!r})" | ||
| ).replace(repr(rd.SpecialColumn.RowIndex), "") | ||
| else: | ||
| agg = self._apply_datashader(dfdata, cvs_fn, agg_fn, agg_kwargs, x, y, agg_state) | ||
| agg = self._apply_aggregate_with_agg_state(dfdata, cvs_fn, agg_fn, x, y, agg_state, sel_fn, params) | ||
|
|
||
| if 'x_axis' in agg.coords and 'y_axis' in agg.coords: | ||
| agg = agg.rename({'x_axis': x, 'y_axis': y}) | ||
|
|
@@ -459,7 +517,11 @@ def _process(self, element, key=None): | |
| params['vdims'] = [d for d in agg.data_vars if d not in agg.attrs["selector_columns"]] | ||
| return self.p.element_type(agg, **params) | ||
|
|
||
| def _apply_datashader(self, dfdata, cvs_fn, agg_fn, agg_kwargs, x, y, agg_state: AggState): | ||
| def _apply_datashader(self, dfdata, cvs_fn, agg_fn, x, y, agg_state: AggState): | ||
| agg_kwargs = {} | ||
| if getattr(cvs_fn, "__name__", None) == "line" and DATASHADER_GE_0_14_0: | ||
| agg_kwargs['line_width'] = self.p.line_width | ||
|
|
||
| # Suppress numpy warning emitted by dask: | ||
| # https://github.com/dask/dask/issues/8439 | ||
| with warnings.catch_warnings(): | ||
|
|
@@ -468,55 +530,8 @@ def _apply_datashader(self, dfdata, cvs_fn, agg_fn, agg_kwargs, x, y, agg_state: | |
| category=FutureWarning | ||
| ) | ||
| agg = cvs_fn(dfdata, x.name, y.name, agg_fn, **agg_kwargs) | ||
| return self._apply_where_summary(dfdata, x.name, y.name, agg_fn, agg, agg_state) | ||
|
|
||
| is_where_index = DATASHADER_GE_0_15_1 and isinstance(agg_fn, ds.where) and isinstance(agg_fn.column, rd.SpecialColumn) | ||
| is_summary_index = AggState.has_sel(agg_state) | ||
| if is_where_index or is_summary_index: | ||
| if is_where_index: | ||
| index = agg.data | ||
| agg = agg.to_dataset(name="__index__") | ||
| else: # summary index | ||
| index = agg["__index__"].data | ||
| if agg_state == AggState.AGG_SEL_BY: | ||
| main_dim = next(k for k in agg if k != "__index__") | ||
| # Taking values from the main dimension expanding it to | ||
| # a new dataset | ||
| agg = agg[main_dim].to_dataset(dim=list(agg.sizes)[2]) | ||
| agg["__index__"] = ((y.name, x.name), index) | ||
|
|
||
| neg1 = index == -1 | ||
| agg.attrs["selector_columns"] = sel_cols = ["__index__"] | ||
| for col in dfdata.columns: | ||
| if col in agg.coords: | ||
| continue | ||
| ser = dfdata[col] | ||
| kind = dtype_kind(ser) | ||
| if kind == "O" and getattr(ser.dtype, "storage", None) == "pyarrow": | ||
| # pyarrow can only handle 1-dimensional | ||
| val = np.asarray(ser)[index] | ||
| else: | ||
| val = ser.values[index] | ||
| if kind == 'f': | ||
| val[neg1] = np.nan | ||
| elif isinstance(val.dtype, pd.CategoricalDtype): | ||
| val = val.to_numpy() | ||
| val[neg1] = "-" | ||
| elif kind == "O": | ||
| val[neg1] = "-" | ||
| elif kind == "M": | ||
| val[neg1] = np.datetime64("NaT") | ||
| else: | ||
| val = val.astype(np.float64) | ||
| val[neg1] = np.nan | ||
| agg[col] = ((y.name, x.name), val) | ||
| sel_cols.append(col) | ||
|
|
||
| if agg_state == AggState.AGG_BY: | ||
| col = agg_fn.column | ||
| if '' in agg.coords[col]: | ||
| agg = agg.drop_sel(**{col: ''}) | ||
|
|
||
| return agg | ||
|
|
||
| class curve_aggregate(aggregate): | ||
| """Optimized aggregation for Curve objects by setting the default | ||
|
|
@@ -784,8 +799,16 @@ class geom_aggregate(AggregationOperation): | |
| def _aggregate(self, cvs, df, x0, y0, x1, y1, agg): | ||
| raise NotImplementedError | ||
|
|
||
| def _apply_datashader(self, df, cvs, agg_fn, x, y, agg_state: AggState): | ||
| (x0, x1), (y0, y1) = x, y | ||
| agg = self._aggregate(cvs, df, x0, y0, x1, y1, agg_fn) | ||
| if agg.coords == ["y", "x"]: | ||
| agg = agg.transpose("x", "y", ...) | ||
| x_name, y_name = list(agg.coords)[:2] | ||
| return self._apply_where_summary(df, x_name, y_name, agg_fn, agg, agg_state) | ||
|
|
||
| def _process(self, element, key=None): | ||
| agg_fn = self._get_aggregator(element, self.p.aggregator) | ||
| agg_fn, sel_fn, agg_state = self._get_agg_state(element) | ||
| x0d, y0d, x1d, y1d = element.kdims | ||
| info = self._get_sampling(element, [x0d, x1d], [y0d, y1d], ndim=1) | ||
| (x_range, y_range), (xs, ys), (width, height), (xtype, ytype) = info | ||
|
|
@@ -801,8 +824,9 @@ def _process(self, element, key=None): | |
| df[y0d.name] = cast_array_to_int64(df[y0d.name].astype('datetime64[ns]')) | ||
| df[y1d.name] = cast_array_to_int64(df[y1d.name].astype('datetime64[ns]')) | ||
|
|
||
| if isinstance(agg_fn, ds.count_cat) and df[agg_fn.column].dtype.name != 'category': | ||
| df[agg_fn.column] = df[agg_fn.column].astype('category') | ||
| category_name = self._get_category_column_name(agg_fn) | ||
| if category_name and df[category_name].dtype.name != 'category': | ||
| df[category_name] = df[category_name].astype('category') | ||
|
|
||
| params = self._get_agg_params(element, x0d, y0d, agg_fn, (x0, y0, x1, y1)) | ||
|
|
||
|
|
@@ -812,25 +836,30 @@ def _process(self, element, key=None): | |
| cvs = ds.Canvas(plot_width=width, plot_height=height, | ||
| x_range=x_range, y_range=y_range) | ||
|
|
||
| agg = self._aggregate(cvs, df, x0d.name, y0d.name, x1d.name, y1d.name, agg_fn) | ||
| agg = self._apply_aggregate_with_agg_state( | ||
| df, | ||
| cvs, | ||
| agg_fn, | ||
| (x0d.name, x1d.name), | ||
| (y0d.name, y1d.name), | ||
| agg_state, | ||
| sel_fn, | ||
| params | ||
| ) | ||
|
|
||
| xdim, ydim = list(agg.dims)[:2][::-1] | ||
| xdim, ydim = list(agg.coords)[:2] | ||
| if xtype == "datetime": | ||
| agg[xdim] = agg[xdim].astype('datetime64[ns]') | ||
| if ytype == "datetime": | ||
| agg[ydim] = agg[ydim].astype('datetime64[ns]') | ||
|
|
||
| params['kdims'] = [xdim, ydim] | ||
|
|
||
| if agg.ndim == 2: | ||
| # Replacing x and y coordinates to avoid numerical precision issues | ||
| return self.p.element_type(agg, **params) | ||
| else: | ||
| layers = {} | ||
| for c in agg.coords[agg_fn.column].data: | ||
| cagg = agg.sel(**{agg_fn.column: c}) | ||
| layers[c] = self.p.element_type(cagg, **params) | ||
| return NdOverlay(layers, kdims=[element.get_dimension(agg_fn.column)]) | ||
| if agg_state == AggState.AGG_BY: | ||
| params['vdims'] = list(map(str, agg.coords[agg_fn.column].data)) | ||
| elif agg_state == AggState.AGG_SEL_BY: | ||
| params['vdims'] = [d for d in agg.data_vars if d not in agg.attrs["selector_columns"]] | ||
|
Comment on lines
+858
to
+861
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is duplicated, but I think it is OK. |
||
| return self.p.element_type(agg, **params) | ||
|
|
||
|
|
||
| class segments_aggregate(geom_aggregate, LineAggregationOperation): | ||
|
|
@@ -1505,8 +1534,9 @@ def _process(self, element, key=None): | |
| if self.p.precompute: | ||
| self._precomputed[element._plot_id] = (data, col) | ||
|
|
||
| if isinstance(agg_fn, ds.count_cat) and data[agg_fn.column].dtype.name != 'category': | ||
| data[agg_fn.column] = data[agg_fn.column].astype('category') | ||
| category_name = self._get_category_column_name(agg_fn) | ||
| if category_name and data[category_name].dtype.name != 'category': | ||
| data[category_name] = data[category_name].astype('category') | ||
|
|
||
| agg_kwargs = dict(geometry=col, agg=agg_fn) | ||
| if isinstance(element, Polygons): | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.