xplainable-preprocessing 0.2.1__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/PKG-INFO +1 -1
  2. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/pyproject.toml +1 -1
  3. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/src/xplainable_preprocessing/compiler.py +33 -4
  4. xplainable_preprocessing-0.2.3/src/xplainable_preprocessing/transformers/groupby_agg.py +143 -0
  5. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/tests/test_compiler.py +82 -0
  6. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/tests/test_transformers/test_all_transformers.py +126 -0
  7. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/.github/workflows/publish-pypi.yml +0 -0
  8. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/.gitignore +0 -0
  9. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/README.md +0 -0
  10. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/docs/dag-pipeline-proposal.md +0 -0
  11. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/docs/feature-pipeline-architectures.md +0 -0
  12. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/docs/feature-store-proposal.md +0 -0
  13. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/src/xplainable_preprocessing/__init__.py +0 -0
  14. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/src/xplainable_preprocessing/pipeline.py +0 -0
  15. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/src/xplainable_preprocessing/preview.py +0 -0
  16. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/src/xplainable_preprocessing/registry.py +0 -0
  17. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/src/xplainable_preprocessing/sandbox.py +0 -0
  18. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/src/xplainable_preprocessing/schema.py +0 -0
  19. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/src/xplainable_preprocessing/serialization.py +0 -0
  20. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/src/xplainable_preprocessing/transformers/__init__.py +0 -0
  21. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/src/xplainable_preprocessing/transformers/category_condense.py +0 -0
  22. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/src/xplainable_preprocessing/transformers/clip.py +0 -0
  23. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/src/xplainable_preprocessing/transformers/datetime_extract.py +0 -0
  24. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/src/xplainable_preprocessing/transformers/drop_columns.py +0 -0
  25. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/src/xplainable_preprocessing/transformers/expression.py +0 -0
  26. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/src/xplainable_preprocessing/transformers/fill_missing.py +0 -0
  27. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/src/xplainable_preprocessing/transformers/grouped_lag.py +0 -0
  28. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/src/xplainable_preprocessing/transformers/missing_flag.py +0 -0
  29. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/src/xplainable_preprocessing/transformers/rename_columns.py +0 -0
  30. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/src/xplainable_preprocessing/transformers/rolling_agg.py +0 -0
  31. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/src/xplainable_preprocessing/transformers/text_clean.py +0 -0
  32. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/src/xplainable_preprocessing/transformers/type_cast.py +0 -0
  33. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/tests/__init__.py +0 -0
  34. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/tests/test_preview.py +0 -0
  35. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/tests/test_sandbox.py +0 -0
  36. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/tests/test_schema.py +0 -0
  37. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/tests/test_serialization.py +0 -0
  38. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/tests/test_transformers/__init__.py +0 -0
  39. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.3}/tests/test_transformers/test_expression.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xplainable-preprocessing
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Shared preprocessing pipeline package for xplainable
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: cloudpickle>=3.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "xplainable-preprocessing"
7
- version = "0.2.1"
7
+ version = "0.2.3"
8
8
  description = "Shared preprocessing pipeline package for xplainable"
9
9
  requires-python = ">=3.9"
10
10
  dependencies = [
@@ -3,6 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import inspect
6
+ import logging
7
+
8
+ logger = logging.getLogger(__name__)
6
9
 
7
10
  from xplainable_preprocessing.pipeline import DataFrameColumnTransformer, DataFramePipeline
8
11
  from xplainable_preprocessing.registry import REGISTRY
@@ -11,14 +14,29 @@ from xplainable_preprocessing.schema import PipelineSpec
11
14
 
12
15
 
13
16
  def _coerce_params(cls, params: dict) -> dict:
14
- """Convert list params to tuples where the constructor expects them.
17
+ """Adapt spec params to the constructor's signature.
15
18
 
16
- JSON has no tuple type, so specs always contain lists. sklearn transformers
17
- like MinMaxScaler expect tuples for params like feature_range.
19
+ - Converts list params to tuples where the constructor expects them
20
+ (JSON has no tuple type, so specs always contain lists; sklearn
21
+ transformers like MinMaxScaler expect tuples for feature_range).
22
+ - Drops params the constructor does not accept, with a warning. Specs
23
+ may be generated in an env with a newer sklearn than the one
24
+ compiling them (2026-07: sklearn 1.7 emitted
25
+ KBinsDiscretizer(quantile_method=...), which 1.6 rejects).
18
26
  """
19
27
  sig = inspect.signature(cls.__init__)
28
+ accepts_kwargs = any(
29
+ p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
30
+ )
20
31
  coerced = {}
21
32
  for key, value in params.items():
33
+ if key not in sig.parameters and not accepts_kwargs:
34
+ logger.warning(
35
+ "%s does not accept param '%s' in this environment "
36
+ "(likely a library-version mismatch with the spec's "
37
+ "generator); dropping it", cls.__name__, key
38
+ )
39
+ continue
22
40
  if isinstance(value, list) and key in sig.parameters:
23
41
  param = sig.parameters[key]
24
42
  if param.default is not inspect.Parameter.empty and isinstance(param.default, tuple):
@@ -48,6 +66,8 @@ def compile_spec(spec: PipelineSpec) -> DataFramePipeline:
48
66
  steps = []
49
67
 
50
68
  for step in spec.steps:
69
+ wrap = bool(step.columns)
70
+
51
71
  if step.type == "custom":
52
72
  transformer = compile_custom(step.params)
53
73
  else:
@@ -58,9 +78,18 @@ def compile_spec(spec: PipelineSpec) -> DataFramePipeline:
58
78
  )
59
79
  cls = REGISTRY[step.type]
60
80
  params = _coerce_params(cls, step.params)
81
+
82
+ # Column-aware transformers (constructor takes `columns`) must
83
+ # receive the step-level columns directly and must NOT be wrapped:
84
+ # DataFrameColumnTransformer feeds them only their declared columns
85
+ # and re-attaches the result, which turns e.g. a drop into a no-op.
86
+ if step.columns and "columns" in inspect.signature(cls.__init__).parameters:
87
+ params.setdefault("columns", step.columns)
88
+ wrap = False
89
+
61
90
  transformer = cls(**params)
62
91
 
63
- if step.columns:
92
+ if wrap:
64
93
  transformer = DataFrameColumnTransformer(transformer, step.columns)
65
94
 
66
95
  steps.append((step.id, transformer))
@@ -0,0 +1,143 @@
1
+ """GroupBy aggregation transformer for collapsing rows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pandas as pd
6
+ from sklearn.base import BaseEstimator, TransformerMixin
7
+
8
+
9
+ class GroupByAggTransformer(BaseEstimator, TransformerMixin):
10
+ """Aggregate rows by grouping columns, collapsing many rows into one per group.
11
+
12
+ Supports standard pandas aggregation functions plus ``mode`` and ``nunique``.
13
+ Each aggregation entry maps a source column to one or more operations, with
14
+ optional output column renaming.
15
+
16
+ Parameters
17
+ ----------
18
+ group_by : list[str]
19
+ Columns to group by. These become the index of the result and are
20
+ always retained in the output.
21
+ aggregations : dict
22
+ Mapping of ``source_column`` to aggregation specification(s).
23
+
24
+ Each value can be:
25
+
26
+ - A string: a single function name (e.g. ``"sum"``, ``"mean"``).
27
+ The output column keeps the source name.
28
+ - A dict with ``func`` and optional ``output``:
29
+ ``{"func": "sum", "output": "total_revenue"}``.
30
+ - A list of the above, producing multiple output columns from one
31
+ source.
32
+
33
+ Supported functions: ``sum``, ``mean``, ``median``, ``min``, ``max``,
34
+ ``std``, ``count``, ``first``, ``last``, ``nunique``, ``mode``.
35
+
36
+ Examples
37
+ --------
38
+ >>> spec_params = {
39
+ ... "group_by": ["customer_id"],
40
+ ... "aggregations": {
41
+ ... "country": "first",
42
+ ... "order_id": {"func": "count", "output": "orders_count"},
43
+ ... "revenue": [
44
+ ... {"func": "sum", "output": "total_spent"},
45
+ ... {"func": "mean", "output": "avg_order_value"},
46
+ ... ],
47
+ ... "product_id": {"func": "nunique", "output": "unique_products"},
48
+ ... "category": {"func": "mode", "output": "primary_category"},
49
+ ... },
50
+ ... }
51
+ """
52
+
53
+ FUNCTIONS = {
54
+ "sum", "mean", "median", "min", "max", "std",
55
+ "count", "first", "last", "nunique", "mode",
56
+ }
57
+
58
+ def __init__(
59
+ self,
60
+ group_by: list[str] | None = None,
61
+ aggregations: dict | None = None,
62
+ ):
63
+ self.group_by = group_by or []
64
+ self.aggregations = aggregations or {}
65
+
66
+ def fit(self, X: pd.DataFrame, y=None):
67
+ # Validate all requested functions
68
+ for col, specs in self._iter_specs():
69
+ for func, _ in specs:
70
+ if func not in self.FUNCTIONS:
71
+ raise ValueError(
72
+ f"Column '{col}': unknown function '{func}'. "
73
+ f"Available: {sorted(self.FUNCTIONS)}"
74
+ )
75
+ return self
76
+
77
+ def transform(self, X: pd.DataFrame) -> pd.DataFrame:
78
+ if not self.group_by:
79
+ raise ValueError("group_by must be specified")
80
+ if not self.aggregations:
81
+ raise ValueError("aggregations must be specified")
82
+
83
+ grouped = X.groupby(self.group_by, sort=False)
84
+
85
+ result_parts: dict[str, pd.Series] = {}
86
+
87
+ for col, specs in self._iter_specs():
88
+ if col not in X.columns:
89
+ continue
90
+ for func, output_name in specs:
91
+ result_parts[output_name] = self._apply_func(grouped, col, func)
92
+
93
+ result = pd.DataFrame(result_parts)
94
+
95
+ # Restore group_by columns from the index
96
+ for i, col in enumerate(self.group_by):
97
+ if len(self.group_by) == 1:
98
+ result.insert(i, col, result.index)
99
+ else:
100
+ result.insert(i, col, result.index.get_level_values(i))
101
+
102
+ return result.reset_index(drop=True)
103
+
104
+ def _iter_specs(self):
105
+ """Yield (source_column, [(func, output_name), ...]) tuples."""
106
+ for col, spec in self.aggregations.items():
107
+ yield col, self._normalise(col, spec)
108
+
109
+ @staticmethod
110
+ def _normalise(col: str, spec) -> list[tuple[str, str]]:
111
+ """Normalise the various spec formats into [(func, output), ...]."""
112
+ if isinstance(spec, str):
113
+ return [(spec, col)]
114
+
115
+ if isinstance(spec, dict):
116
+ func = spec["func"]
117
+ output = spec.get("output", col)
118
+ return [(func, output)]
119
+
120
+ if isinstance(spec, list):
121
+ result = []
122
+ for entry in spec:
123
+ if isinstance(entry, str):
124
+ result.append((entry, col))
125
+ elif isinstance(entry, dict):
126
+ func = entry["func"]
127
+ output = entry.get("output", col)
128
+ result.append((func, output))
129
+ return result
130
+
131
+ raise ValueError(
132
+ f"Column '{col}': spec must be a string, dict, or list, "
133
+ f"got {type(spec).__name__}"
134
+ )
135
+
136
+ @staticmethod
137
+ def _apply_func(grouped, col: str, func: str) -> pd.Series:
138
+ """Apply a single aggregation function to a grouped column."""
139
+ if func == "mode":
140
+ return grouped[col].agg(lambda x: x.mode().iloc[0] if len(x.mode()) > 0 else None)
141
+ if func == "nunique":
142
+ return grouped[col].nunique()
143
+ return grouped[col].agg(func)
@@ -34,6 +34,40 @@ class TestCompileSpec:
34
34
  name, transformer = pipeline.steps[0]
35
35
  assert not isinstance(transformer, DataFrameColumnTransformer)
36
36
 
37
+ def test_step_level_columns_on_drop_actually_drops(self):
38
+ # Specs commonly declare columns at the step level with empty params.
39
+ # DropColumnsTransformer is column-aware: wrapping it in
40
+ # DataFrameColumnTransformer turns the drop into a no-op (the wrapper
41
+ # re-attaches the "transformed" — i.e. untouched — columns).
42
+ spec = PipelineSpec(steps=[
43
+ StepSpec(id="drop", type="DropColumnsTransformer",
44
+ columns=["id", "count"], params={}),
45
+ ])
46
+ pipeline = compile_spec(spec)
47
+ df = pd.DataFrame({"id": [1, 2], "count": [3, 4], "value": [5.0, 6.0]})
48
+ result = pipeline.fit_transform(df)
49
+ assert list(result.columns) == ["value"]
50
+
51
+ def test_step_level_columns_injected_into_column_aware_transformer(self):
52
+ spec = PipelineSpec(steps=[
53
+ StepSpec(id="drop", type="DropColumnsTransformer",
54
+ columns=["id"], params={}),
55
+ ])
56
+ pipeline = compile_spec(spec)
57
+ _, transformer = pipeline.steps[0]
58
+ assert not isinstance(transformer, DataFrameColumnTransformer)
59
+ assert transformer.columns == ["id"]
60
+
61
+ def test_params_columns_take_precedence_over_step_columns(self):
62
+ spec = PipelineSpec(steps=[
63
+ StepSpec(id="drop", type="DropColumnsTransformer",
64
+ columns=["id"], params={"columns": ["count"]}),
65
+ ])
66
+ pipeline = compile_spec(spec)
67
+ _, transformer = pipeline.steps[0]
68
+ assert not isinstance(transformer, DataFrameColumnTransformer)
69
+ assert transformer.columns == ["count"]
70
+
37
71
  def test_unknown_type_raises(self):
38
72
  spec = PipelineSpec(steps=[
39
73
  StepSpec(id="bad", type="FakeTransformer"),
@@ -118,3 +152,51 @@ class TestCompileSpec:
118
152
  assert "id" not in result.columns
119
153
  assert "value" in result.columns
120
154
  assert not result["value"].isna().any()
155
+
156
+
157
+ class TestUnsupportedParamFiltering:
158
+ """Specs may be generated in an env with a newer sklearn than the one
159
+ compiling them (2026-07: autotrain on sklearn 1.7 emitted
160
+ KBinsDiscretizer(quantile_method=...), which the API's sklearn 1.6
161
+ constructor rejects). Unknown constructor params must be dropped with a
162
+ warning instead of crashing compilation."""
163
+
164
+ def test_unknown_param_is_dropped_with_warning(self, caplog):
165
+ import logging
166
+
167
+ spec = PipelineSpec(steps=[
168
+ StepSpec(
169
+ id="bin",
170
+ type="KBinsDiscretizer",
171
+ columns=["age"],
172
+ params={
173
+ "n_bins": 4,
174
+ "encode": "ordinal",
175
+ "strategy": "quantile",
176
+ "definitely_not_a_real_sklearn_param": "x",
177
+ },
178
+ ),
179
+ ])
180
+ with caplog.at_level(logging.WARNING):
181
+ pipeline = compile_spec(spec)
182
+
183
+ _, transformer = pipeline.steps[0]
184
+ inner = transformer.transformer
185
+ assert inner.n_bins == 4
186
+ assert not hasattr(inner, "definitely_not_a_real_sklearn_param")
187
+ assert any("definitely_not_a_real_sklearn_param" in r.message
188
+ for r in caplog.records)
189
+
190
+ def test_supported_params_pass_through_unchanged(self):
191
+ spec = PipelineSpec(steps=[
192
+ StepSpec(
193
+ id="bin",
194
+ type="KBinsDiscretizer",
195
+ columns=["age"],
196
+ params={"n_bins": 3, "encode": "ordinal"},
197
+ ),
198
+ ])
199
+ pipeline = compile_spec(spec)
200
+ _, transformer = pipeline.steps[0]
201
+ assert transformer.transformer.n_bins == 3
202
+ assert transformer.transformer.encode == "ordinal"
@@ -9,6 +9,7 @@ from xplainable_preprocessing.transformers.datetime_extract import DateTimeExtra
9
9
  from xplainable_preprocessing.transformers.drop_columns import DropColumnsTransformer
10
10
  from xplainable_preprocessing.transformers.fill_missing import FillMissingTransformer
11
11
  from xplainable_preprocessing.transformers.grouped_lag import GroupedLagTransformer
12
+ from xplainable_preprocessing.transformers.groupby_agg import GroupByAggTransformer
12
13
  from xplainable_preprocessing.transformers.rename_columns import RenameColumnsTransformer
13
14
  from xplainable_preprocessing.transformers.rolling_agg import RollingAggTransformer
14
15
  from xplainable_preprocessing.transformers.text_clean import TextCleanTransformer
@@ -226,3 +227,128 @@ class TestRollingAggTransformer:
226
227
  t = RollingAggTransformer(columns=["v"], operation="invalid")
227
228
  with pytest.raises(ValueError, match="Unknown operation"):
228
229
  t.fit(pd.DataFrame({"v": [1]}))
230
+
231
+
232
+ class TestGroupByAggTransformer:
233
+ @pytest.fixture
234
+ def orders_df(self):
235
+ return pd.DataFrame({
236
+ "customer_id": [1, 1, 1, 2, 2, 3],
237
+ "country": ["US", "US", "US", "UK", "UK", "CA"],
238
+ "revenue": [100.0, 200.0, 150.0, 300.0, 250.0, 50.0],
239
+ "quantity": [2, 3, 1, 4, 2, 1],
240
+ "product_id": [10, 20, 10, 30, 30, 40],
241
+ "category": ["A", "B", "A", "C", "C", "A"],
242
+ "returned": [0, 1, 0, 0, 0, 1],
243
+ })
244
+
245
+ def test_basic_string_agg(self, orders_df):
246
+ t = GroupByAggTransformer(
247
+ group_by=["customer_id"],
248
+ aggregations={"country": "first"},
249
+ )
250
+ result = t.fit_transform(orders_df)
251
+ assert len(result) == 3
252
+ assert "customer_id" in result.columns
253
+ assert "country" in result.columns
254
+ assert result.loc[result["customer_id"] == 1, "country"].iloc[0] == "US"
255
+
256
+ def test_dict_with_rename(self, orders_df):
257
+ t = GroupByAggTransformer(
258
+ group_by=["customer_id"],
259
+ aggregations={
260
+ "revenue": {"func": "sum", "output": "total_spent"},
261
+ },
262
+ )
263
+ result = t.fit_transform(orders_df)
264
+ assert "total_spent" in result.columns
265
+ assert "revenue" not in result.columns
266
+ assert result.loc[result["customer_id"] == 1, "total_spent"].iloc[0] == 450.0
267
+
268
+ def test_list_multiple_aggs(self, orders_df):
269
+ t = GroupByAggTransformer(
270
+ group_by=["customer_id"],
271
+ aggregations={
272
+ "revenue": [
273
+ {"func": "sum", "output": "total_revenue"},
274
+ {"func": "mean", "output": "avg_revenue"},
275
+ ],
276
+ },
277
+ )
278
+ result = t.fit_transform(orders_df)
279
+ assert "total_revenue" in result.columns
280
+ assert "avg_revenue" in result.columns
281
+ assert result.loc[result["customer_id"] == 1, "total_revenue"].iloc[0] == 450.0
282
+ assert result.loc[result["customer_id"] == 1, "avg_revenue"].iloc[0] == 150.0
283
+
284
+ def test_nunique(self, orders_df):
285
+ t = GroupByAggTransformer(
286
+ group_by=["customer_id"],
287
+ aggregations={
288
+ "product_id": {"func": "nunique", "output": "unique_products"},
289
+ },
290
+ )
291
+ result = t.fit_transform(orders_df)
292
+ assert result.loc[result["customer_id"] == 1, "unique_products"].iloc[0] == 2
293
+ assert result.loc[result["customer_id"] == 2, "unique_products"].iloc[0] == 1
294
+
295
+ def test_mode(self, orders_df):
296
+ t = GroupByAggTransformer(
297
+ group_by=["customer_id"],
298
+ aggregations={
299
+ "category": {"func": "mode", "output": "primary_category"},
300
+ },
301
+ )
302
+ result = t.fit_transform(orders_df)
303
+ assert result.loc[result["customer_id"] == 1, "primary_category"].iloc[0] == "A"
304
+ assert result.loc[result["customer_id"] == 2, "primary_category"].iloc[0] == "C"
305
+
306
+ def test_count(self, orders_df):
307
+ t = GroupByAggTransformer(
308
+ group_by=["customer_id"],
309
+ aggregations={
310
+ "revenue": {"func": "count", "output": "orders_count"},
311
+ },
312
+ )
313
+ result = t.fit_transform(orders_df)
314
+ assert result.loc[result["customer_id"] == 1, "orders_count"].iloc[0] == 3
315
+ assert result.loc[result["customer_id"] == 3, "orders_count"].iloc[0] == 1
316
+
317
+ def test_invalid_function(self, orders_df):
318
+ t = GroupByAggTransformer(
319
+ group_by=["customer_id"],
320
+ aggregations={"revenue": "bogus"},
321
+ )
322
+ with pytest.raises(ValueError, match="unknown function"):
323
+ t.fit(orders_df)
324
+
325
+ def test_empty_group_by_raises(self, orders_df):
326
+ t = GroupByAggTransformer(
327
+ group_by=[],
328
+ aggregations={"revenue": "sum"},
329
+ )
330
+ t.fit(orders_df)
331
+ with pytest.raises(ValueError, match="group_by must be specified"):
332
+ t.transform(orders_df)
333
+
334
+ def test_combined_spec(self, orders_df):
335
+ """Full integration: multiple agg types in one spec."""
336
+ t = GroupByAggTransformer(
337
+ group_by=["customer_id"],
338
+ aggregations={
339
+ "country": "first",
340
+ "revenue": [
341
+ {"func": "sum", "output": "total_spent"},
342
+ {"func": "mean", "output": "avg_order_value"},
343
+ ],
344
+ "product_id": {"func": "nunique", "output": "unique_products"},
345
+ "category": {"func": "mode", "output": "top_category"},
346
+ "returned": {"func": "sum", "output": "return_count"},
347
+ },
348
+ )
349
+ result = t.fit_transform(orders_df)
350
+ assert len(result) == 3
351
+ assert set(result.columns) == {
352
+ "customer_id", "country", "total_spent", "avg_order_value",
353
+ "unique_products", "top_category", "return_count",
354
+ }