xplainable-preprocessing 0.2.1__tar.gz → 0.2.2__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.2}/PKG-INFO +1 -1
  2. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/pyproject.toml +1 -1
  3. xplainable_preprocessing-0.2.2/src/xplainable_preprocessing/transformers/groupby_agg.py +143 -0
  4. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/tests/test_transformers/test_all_transformers.py +126 -0
  5. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/.github/workflows/publish-pypi.yml +0 -0
  6. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/.gitignore +0 -0
  7. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/README.md +0 -0
  8. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/docs/dag-pipeline-proposal.md +0 -0
  9. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/docs/feature-pipeline-architectures.md +0 -0
  10. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/docs/feature-store-proposal.md +0 -0
  11. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/__init__.py +0 -0
  12. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/compiler.py +0 -0
  13. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/pipeline.py +0 -0
  14. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/preview.py +0 -0
  15. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/registry.py +0 -0
  16. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/sandbox.py +0 -0
  17. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/schema.py +0 -0
  18. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/serialization.py +0 -0
  19. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/__init__.py +0 -0
  20. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/category_condense.py +0 -0
  21. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/clip.py +0 -0
  22. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/datetime_extract.py +0 -0
  23. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/drop_columns.py +0 -0
  24. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/expression.py +0 -0
  25. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/fill_missing.py +0 -0
  26. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/grouped_lag.py +0 -0
  27. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/missing_flag.py +0 -0
  28. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/rename_columns.py +0 -0
  29. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/rolling_agg.py +0 -0
  30. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/text_clean.py +0 -0
  31. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/type_cast.py +0 -0
  32. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/tests/__init__.py +0 -0
  33. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/tests/test_compiler.py +0 -0
  34. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/tests/test_preview.py +0 -0
  35. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/tests/test_sandbox.py +0 -0
  36. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/tests/test_schema.py +0 -0
  37. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/tests/test_serialization.py +0 -0
  38. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/tests/test_transformers/__init__.py +0 -0
  39. {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/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.2
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.2"
8
8
  description = "Shared preprocessing pipeline package for xplainable"
9
9
  requires-python = ">=3.9"
10
10
  dependencies = [
@@ -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)
@@ -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
+ }