-
Notifications
You must be signed in to change notification settings - Fork 80
perf(groupby): unify scatter kernel over numpy and dask via apply_ufunc #802
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
Changes from all commits
754c074
34d1332
7f3edea
7598180
81d7c23
ac8ec47
f255d42
d564774
73869bf
8f027d4
8864172
30b652e
ed71646
be72394
70aca27
fc3d29e
6834080
e5b10a0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -74,7 +74,6 @@ | |
| FACTOR_DIM, | ||
| GREATER_EQUAL, | ||
| GROUP_DIM, | ||
| GROUPED_TERM_DIM, | ||
| HELPER_DIMS, | ||
| LESS_EQUAL, | ||
| STACKED_TERM_DIM, | ||
|
|
@@ -197,6 +196,33 @@ def _unstack_multikey(ds: Dataset, dim: str) -> Dataset: | |
| return ds.unstack(dim, fill_value=LinearExpression._fill_value) | ||
|
|
||
|
|
||
| def _encode_multikey_group( | ||
| frame: pd.DataFrame, index: pd.Index | ||
| ) -> tuple[pd.Series, tuple[dict, pd.Index]]: | ||
| """ | ||
| Encode a multi-key group frame as a single integer-coded Series. | ||
|
|
||
| ``_grouped_sum`` groups by one key, so each row's tuple of key values is | ||
| mapped to an integer. The returned ``(int_map, columns)`` lets | ||
| :func:`_restore_multikey_index` rebuild the MultiIndex on the result. | ||
| """ | ||
| index_name = frame.index.name | ||
| frame = frame.reindex(index) | ||
| frame.index.name = index_name | ||
| int_map = get_index_map(*frame.values.T) | ||
| coded = frame.apply(tuple, axis=1).map(int_map) | ||
| return coded, (int_map, frame.columns) | ||
|
|
||
|
|
||
| def _restore_multikey_index(ds: Dataset, decode: tuple[dict, pd.Index]) -> Dataset: | ||
| """Rebuild the MultiIndex group dimension encoded by _encode_multikey_group.""" | ||
| int_map, columns = decode | ||
| index = ds.indexes[GROUP_DIM].map({v: k for k, v in int_map.items()}) | ||
| index.names = [str(col) for col in columns] | ||
| index.name = GROUP_DIM | ||
| return ds.assign_coords(Coordinates.from_pandas_multiindex(index, GROUP_DIM)) | ||
|
|
||
|
|
||
| @dataclass | ||
| @forward_as_properties(groupby=["dims", "groups"]) | ||
| class LinearExpressionGroupby: | ||
|
|
@@ -242,7 +268,7 @@ def groupby(self) -> xarray.core.groupby.DatasetGroupBy: | |
| def map( | ||
| self, | ||
| func: Callable[..., Dataset], | ||
| shortcut: bool = False, | ||
|
FBumann marked this conversation as resolved.
|
||
| shortcut: bool | None = None, | ||
| args: tuple[()] = (), | ||
| **kwargs: Any, | ||
| ) -> LinearExpression: | ||
|
|
@@ -254,7 +280,7 @@ def map( | |
| func : callable | ||
| The function to apply. | ||
| shortcut : bool, optional | ||
| Whether to use shortcut or not. | ||
| Deprecated and ignored. Will be removed in linopy v1. | ||
| args : tuple, optional | ||
| The arguments to pass to the function. | ||
| **kwargs | ||
|
|
@@ -265,44 +291,41 @@ def map( | |
| LinearExpression | ||
| The result of applying the function to the groupby object. | ||
| """ | ||
| return LinearExpression( | ||
| self.groupby.map(func, shortcut=shortcut, args=args, **kwargs), self.model | ||
| ) | ||
| if shortcut is not None: | ||
| warn( | ||
| "The `shortcut` argument is deprecated, no longer has any effect " | ||
| "and will be removed in linopy v1.", | ||
| FutureWarning, | ||
| ) | ||
| return LinearExpression(self.groupby.map(func, args=args, **kwargs), self.model) | ||
|
|
||
| def sum( | ||
| self, use_fallback: bool = False, observed: bool = False, **kwargs: Any | ||
| self, use_fallback: bool = False, observed: bool = False | ||
| ) -> LinearExpression: | ||
| """ | ||
| Sum the groupby object. | ||
| Sum the expression over each group. | ||
|
|
||
| There are two options to perform the summation over groups. | ||
| The first and faster option uses an internal reindexing mechanism, which | ||
| however ignores keyword arguments. This will be used when passing a | ||
| pandas object or a DataArray as group, and setting `use_fallack` | ||
| to False (default). | ||
| The second uses a mapping of xarray groups which performs slower but | ||
| also takes into account the keyword arguments. | ||
| Replaces the grouped dimension with one entry per group, each holding | ||
| the sum of its members' terms. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| use_fallback : bool | ||
| Whether to use the fallback implementation, which is a sort of default | ||
| xarray implementation. If set to False, the operation will be much | ||
| faster but keyword arguments are ignored. Defaults to False. | ||
| Fall back to the previous, slower groupby-sum implementation, kept | ||
| as an escape hatch. Leave at False unless the default misbehaves. | ||
| Defaults to False. | ||
| observed : bool | ||
| Only applies when grouping by a list of coordinate names. If True, | ||
| keep the result stacked over the observed key combinations (a | ||
| ``MultiIndex`` ``group`` dimension) instead of unstacking into one | ||
| dimension per key, which materialises the dense cartesian grid. | ||
| Defaults to False, mirroring xarray. Not supported together with | ||
| `use_fallback`. | ||
| **kwargs | ||
| Arbitrary keyword arguments. | ||
|
|
||
| Returns | ||
| ------- | ||
| LinearExpression | ||
| The sum of the groupby object. | ||
| The summed expression, with one entry per group. | ||
| """ | ||
| if observed and use_fallback: | ||
| raise ValueError( | ||
|
|
@@ -311,56 +334,34 @@ def sum( | |
|
|
||
| group = _resolve_group(self.group, self.data) | ||
|
|
||
| # a list of coord names rides the fast path as a value frame | ||
| multikey_frame = ( | ||
| None if use_fallback else _multikey_value_frame(group, self.data) | ||
| ) | ||
| if multikey_frame is not None: | ||
| group = multikey_frame | ||
|
|
||
| non_fallback_types = (pd.Series, pd.DataFrame, xr.DataArray) | ||
| if isinstance(group, non_fallback_types) and not use_fallback: | ||
| if isinstance(group, pd.DataFrame): | ||
| # dataframes do not have a name, so we need to set it | ||
| final_group_name = "group" | ||
| else: | ||
| final_group_name = getattr(group, "name", "group") or "group" | ||
| fast_path_types = (pd.Series, pd.DataFrame, xr.DataArray) | ||
| if isinstance(group, fast_path_types) and not use_fallback: | ||
| final_group_name = ( | ||
| "group" | ||
| if isinstance(group, pd.DataFrame) | ||
| else getattr(group, "name", "group") or "group" | ||
| ) | ||
|
|
||
| if isinstance(group, DataArray): | ||
| group = group.to_pandas() | ||
|
|
||
| int_map = None | ||
| multikey_decode = None | ||
| if isinstance(group, pd.DataFrame): | ||
| index_name = group.index.name | ||
| group = group.reindex(self.data.indexes[group.index.name]) | ||
| group.index.name = index_name # ensure name for multiindex | ||
| int_map = get_index_map(*group.values.T) | ||
| orig_group = group | ||
| group = group.apply(tuple, axis=1).map(int_map) | ||
|
|
||
| # At this point, group is always a pandas Series | ||
| group, multikey_decode = _encode_multikey_group( | ||
| group, self.data.indexes[group.index.name] | ||
| ) | ||
|
|
||
| assert isinstance(group, pd.Series) | ||
| group_dim = group.index.name | ||
|
|
||
| arrays = [group, group.groupby(group).cumcount()] | ||
| idx = pd.MultiIndex.from_arrays(arrays, names=[GROUP_DIM, GROUPED_TERM_DIM]) | ||
| new_coords = Coordinates.from_pandas_multiindex(idx, group_dim) | ||
| # collapsing group_dim invalidates every coordinate aligned to it | ||
| names_to_drop = [ | ||
| name | ||
| for name, coord in self.data.coords.items() | ||
| if group_dim in coord.dims | ||
| ] | ||
| ds = self.data.drop_vars(names_to_drop).assign_coords(new_coords) | ||
| ds = ds.unstack(group_dim, fill_value=LinearExpression._fill_value) | ||
| ds = LinearExpression._sum(ds, dim=GROUPED_TERM_DIM) | ||
|
|
||
| if int_map is not None: | ||
| index = ds.indexes[GROUP_DIM].map({v: k for k, v in int_map.items()}) | ||
| index.names = [str(col) for col in orig_group.columns] | ||
| index.name = GROUP_DIM | ||
| new_coords = Coordinates.from_pandas_multiindex(index, GROUP_DIM) | ||
| ds = ds.assign_coords(new_coords) | ||
| ds = self._grouped_sum(group) | ||
|
|
||
| if multikey_decode is not None: | ||
| ds = _restore_multikey_index(ds, multikey_decode) | ||
|
|
||
| ds = ds.rename({GROUP_DIM: final_group_name}) | ||
| if multikey_frame is not None and not observed: | ||
|
|
@@ -372,7 +373,87 @@ def func(ds: Dataset) -> Dataset: | |
| ds = ds.assign_coords({TERM_DIM: np.arange(len(ds._term))}) | ||
| return ds | ||
|
|
||
| return self.map(func, **kwargs, shortcut=True) | ||
| return self.map(func) | ||
|
Comment on lines
-375
to
+376
Member
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. @FabianHofmann Can you clarify what kwargs were meant to be passed here? Shortcuts was dead code, so fine to remove.
Collaborator
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. yes, it was a no-op that was mainly there for mirroring the signature of the internal xarray groupby.map function. it was never explicitly used as I (guess I) wanted to tackle this later which never happened. so fine to remove. the kwargs seems to be dead anyway and setting it would have let to errors. so this is fixing it |
||
|
|
||
| def _grouped_sum(self, group: pd.Series) -> Dataset: | ||
| """ | ||
| Sum groups by scattering all terms directly into the final padded arrays. | ||
|
|
||
| Every group member keeps its block of ``nterm`` terms, so the resulting | ||
| term dimension has size ``max_group_size * nterm`` and smaller groups are | ||
| padded with fill values. Only the result arrays are allocated, keeping | ||
| peak memory at input + result. | ||
| """ | ||
| data = self.data | ||
| group_dim = group.index.name | ||
| fill_value = LinearExpression._fill_value | ||
|
|
||
| _scatter_core_dims = {group_dim: -1, TERM_DIM: -1} | ||
| if data.chunks: | ||
| data = data.chunk(_scatter_core_dims) | ||
|
|
||
| codes, unique_groups = pd.factorize(group, sort=True) | ||
| if (codes == -1).any(): | ||
| raise ValueError( | ||
| "Cannot group by a pandas object containing NaN values. " | ||
| "Drop or fill the corresponding entries before grouping." | ||
| ) | ||
|
|
||
| n_groups = len(unique_groups) | ||
| max_size = int(np.bincount(codes, minlength=n_groups).max()) if n_groups else 0 | ||
| position_in_group = pd.Series(codes).groupby(codes).cumcount().to_numpy() | ||
| nterm = data.sizes[TERM_DIM] | ||
|
|
||
| def scatter_terms(values: np.ndarray, fill: Any) -> np.ndarray: | ||
| """Place each member's term block into its group's padded slot.""" | ||
| leading = values.shape[:-2] | ||
| blocks = np.full( | ||
| (*leading, n_groups, nterm, max_size), fill, dtype=values.dtype | ||
| ) | ||
| blocks[..., codes, :, position_in_group] = np.moveaxis(values, -2, 0) | ||
| return blocks.reshape((*leading, n_groups, nterm * max_size)) | ||
|
|
||
| def group_sum(values: np.ndarray) -> np.ndarray: | ||
| """Sum values within each group along the last axis, skipping NaNs.""" | ||
| by_element = np.moveaxis(values, -1, 0) | ||
| summed = np.zeros((n_groups, *by_element.shape[1:]), dtype=values.dtype) | ||
| np.add.at(summed, codes, np.where(np.isnan(by_element), 0, by_element)) | ||
| return np.moveaxis(summed, 0, -1) | ||
|
|
||
| def scatter(da: DataArray, fill: Any) -> DataArray: | ||
| """Run the term scatter over the core dims, lazily for dask arrays.""" | ||
| return xr.apply_ufunc( | ||
| scatter_terms, | ||
| da, | ||
| kwargs={"fill": fill}, | ||
| input_core_dims=[[group_dim, TERM_DIM]], | ||
| output_core_dims=[[GROUP_DIM, TERM_DIM]], | ||
| exclude_dims={group_dim, TERM_DIM}, | ||
| dask="parallelized", | ||
| dask_gufunc_kwargs={ | ||
| "output_sizes": {GROUP_DIM: n_groups, TERM_DIM: nterm * max_size} | ||
| }, | ||
| output_dtypes=[da.dtype], | ||
| ) | ||
|
|
||
| const = xr.apply_ufunc( | ||
| group_sum, | ||
| data.const, | ||
| input_core_dims=[[group_dim]], | ||
| output_core_dims=[[GROUP_DIM]], | ||
| exclude_dims={group_dim}, | ||
| dask="parallelized", | ||
| dask_gufunc_kwargs={"output_sizes": {GROUP_DIM: n_groups}}, | ||
| output_dtypes=[data.const.dtype], | ||
| ) | ||
| ds = Dataset( | ||
| { | ||
| "coeffs": scatter(data.coeffs, fill_value["coeffs"]), | ||
| "vars": scatter(data.vars, fill_value["vars"]), | ||
| "const": const, | ||
| } | ||
| ) | ||
| return ds.assign_coords({GROUP_DIM: unique_groups}) | ||
|
|
||
| def roll(self, **kwargs: Any) -> LinearExpression: | ||
| """ | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.