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.
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/PKG-INFO +1 -1
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/pyproject.toml +1 -1
- xplainable_preprocessing-0.2.2/src/xplainable_preprocessing/transformers/groupby_agg.py +143 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/tests/test_transformers/test_all_transformers.py +126 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/.github/workflows/publish-pypi.yml +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/.gitignore +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/README.md +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/docs/dag-pipeline-proposal.md +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/docs/feature-pipeline-architectures.md +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/docs/feature-store-proposal.md +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/__init__.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/compiler.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/pipeline.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/preview.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/registry.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/sandbox.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/schema.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/serialization.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/__init__.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/category_condense.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/clip.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/datetime_extract.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/drop_columns.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/expression.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/fill_missing.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/grouped_lag.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/missing_flag.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/rename_columns.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/rolling_agg.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/text_clean.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/type_cast.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/tests/__init__.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/tests/test_compiler.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/tests/test_preview.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/tests/test_sandbox.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/tests/test_schema.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/tests/test_serialization.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/tests/test_transformers/__init__.py +0 -0
- {xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/tests/test_transformers/test_expression.py +0 -0
|
@@ -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
|
+
}
|
{xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/.github/workflows/publish-pypi.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/docs/dag-pipeline-proposal.md
RENAMED
|
File without changes
|
|
File without changes
|
{xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/docs/feature-store-proposal.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{xplainable_preprocessing-0.2.1 → xplainable_preprocessing-0.2.2}/tests/test_serialization.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|