xplainable-preprocessing 0.2.0__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 (40) hide show
  1. xplainable_preprocessing-0.2.2/.github/workflows/publish-pypi.yml +42 -0
  2. xplainable_preprocessing-0.2.2/.gitignore +1 -0
  3. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/PKG-INFO +1 -1
  4. xplainable_preprocessing-0.2.2/README.md +164 -0
  5. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/pyproject.toml +1 -1
  6. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/registry.py +6 -0
  7. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/__init__.py +18 -12
  8. xplainable_preprocessing-0.2.2/src/xplainable_preprocessing/transformers/clip.py +45 -0
  9. xplainable_preprocessing-0.2.2/src/xplainable_preprocessing/transformers/groupby_agg.py +143 -0
  10. xplainable_preprocessing-0.2.2/src/xplainable_preprocessing/transformers/missing_flag.py +48 -0
  11. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/tests/test_transformers/test_all_transformers.py +126 -0
  12. xplainable_preprocessing-0.2.0/scripts/release.sh +0 -292
  13. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/docs/dag-pipeline-proposal.md +0 -0
  14. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/docs/feature-pipeline-architectures.md +0 -0
  15. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/docs/feature-store-proposal.md +0 -0
  16. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/__init__.py +0 -0
  17. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/compiler.py +0 -0
  18. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/pipeline.py +0 -0
  19. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/preview.py +0 -0
  20. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/sandbox.py +0 -0
  21. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/schema.py +0 -0
  22. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/serialization.py +0 -0
  23. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/category_condense.py +0 -0
  24. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/datetime_extract.py +0 -0
  25. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/drop_columns.py +0 -0
  26. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/expression.py +0 -0
  27. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/fill_missing.py +0 -0
  28. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/grouped_lag.py +0 -0
  29. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/rename_columns.py +0 -0
  30. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/rolling_agg.py +0 -0
  31. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/text_clean.py +0 -0
  32. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/src/xplainable_preprocessing/transformers/type_cast.py +0 -0
  33. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/tests/__init__.py +0 -0
  34. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/tests/test_compiler.py +0 -0
  35. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/tests/test_preview.py +0 -0
  36. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/tests/test_sandbox.py +0 -0
  37. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/tests/test_schema.py +0 -0
  38. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/tests/test_serialization.py +0 -0
  39. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/tests/test_transformers/__init__.py +0 -0
  40. {xplainable_preprocessing-0.2.0 → xplainable_preprocessing-0.2.2}/tests/test_transformers/test_expression.py +0 -0
@@ -0,0 +1,42 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ id-token: write
13
+ contents: write
14
+
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - name: Set up Python
19
+ uses: actions/setup-python@v5
20
+ with:
21
+ python-version: '3.11'
22
+
23
+ - name: Install build tools
24
+ run: pip install build
25
+
26
+ - name: Build package
27
+ run: python -m build
28
+
29
+ - name: Publish to PyPI
30
+ uses: pypa/gh-action-pypi-publish@release/v1
31
+ with:
32
+ password: ${{ secrets.PYPI_API_TOKEN }}
33
+
34
+ - name: Create GitHub Release
35
+ env:
36
+ GH_TOKEN: ${{ github.token }}
37
+ run: |
38
+ VERSION=${GITHUB_REF_NAME}
39
+ gh release create "$VERSION" dist/* \
40
+ --title "$VERSION" \
41
+ --generate-notes \
42
+ --latest
@@ -0,0 +1 @@
1
+ .env
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xplainable-preprocessing
3
- Version: 0.2.0
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
@@ -0,0 +1,164 @@
1
+ # xplainable-preprocessing
2
+
3
+ Shared preprocessing pipeline library for [xplainable](https://xplainable.io). Define, compile, fit, serialise, and apply data transformation pipelines using a declarative JSON spec.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install xplainable-preprocessing
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ### Define a pipeline as JSON
14
+
15
+ ```python
16
+ from xplainable_preprocessing.schema import PipelineSpec
17
+ from xplainable_preprocessing.compiler import compile_spec
18
+
19
+ spec = PipelineSpec(
20
+ version="2.0",
21
+ steps=[
22
+ {"id": "drop_ids", "type": "DropColumnsTransformer", "params": {"columns": ["customer_id", "row_id"]}},
23
+ {"id": "fill_numeric", "type": "FillMissingTransformer", "columns": ["tenure", "charges"], "params": {"strategies": {"tenure": "median", "charges": "median"}}},
24
+ {"id": "clip_outliers", "type": "ClipTransformer", "columns": ["charges"], "params": {"min_val": 0, "max_val": 500}},
25
+ {"id": "missing_flags", "type": "MissingFlagTransformer", "columns": ["tenure", "charges"]},
26
+ {"id": "condense_plan", "type": "CategoryCondenseTransformer", "columns": ["plan_type"], "params": {"max_categories": 10}},
27
+ {"id": "extract_dates", "type": "DateTimeExtractTransformer", "columns": ["signup_date"], "params": {"components": ["month", "dayofweek"], "drop_original": true}},
28
+ ]
29
+ )
30
+
31
+ pipeline = compile_spec(spec)
32
+ ```
33
+
34
+ ### Fit and transform
35
+
36
+ ```python
37
+ import pandas as pd
38
+
39
+ df = pd.read_csv("data.csv")
40
+ pipeline.fit(df)
41
+ transformed = pipeline.transform(df)
42
+ ```
43
+
44
+ ### Serialise and load
45
+
46
+ ```python
47
+ from xplainable_preprocessing.serialization import save_pipeline, load_pipeline
48
+
49
+ # Save
50
+ binary = save_pipeline(pipeline)
51
+
52
+ # Load
53
+ loaded = load_pipeline(binary)
54
+ result = loaded.transform(new_data)
55
+ ```
56
+
57
+ ## Available Transformers
58
+
59
+ ### Data Cleaning
60
+ | Transformer | Description |
61
+ |-------------|-------------|
62
+ | `DropColumnsTransformer` | Drop specified columns |
63
+ | `RenameColumnsTransformer` | Rename columns |
64
+ | `TypeCastTransformer` | Cast column dtypes (e.g. str to float64, with error coercion) |
65
+ | `ClipTransformer` | Clip numeric values outside (min, max) to NaN |
66
+
67
+ ### Missing Values
68
+ | Transformer | Description |
69
+ |-------------|-------------|
70
+ | `FillMissingTransformer` | Fill nulls with median, mode, constant, or other strategies |
71
+ | `MissingFlagTransformer` | Create binary 0/1 indicator columns for nulls |
72
+
73
+ ### Feature Engineering
74
+ | Transformer | Description |
75
+ |-------------|-------------|
76
+ | `ExpressionTransformer` | Create derived columns via pandas expressions |
77
+ | `DateTimeExtractTransformer` | Extract year, month, dayofweek, etc. from datetime columns |
78
+ | `GroupByAggTransformer` | Aggregate features by group (count, sum, mean) |
79
+ | `GroupedLagTransformer` | Create lagged features within groups |
80
+ | `RollingAggTransformer` | Rolling window aggregations |
81
+
82
+ ### Categorical
83
+ | Transformer | Description |
84
+ |-------------|-------------|
85
+ | `CategoryCondenseTransformer` | Condense high-cardinality categoricals to top N + "Other" |
86
+ | `TextCleanTransformer` | Clean text: lowercase, strip, remove HTML/extra whitespace |
87
+
88
+ ### Scaling (use with caution -- breaks xplainable model explainability)
89
+ | Transformer | Description |
90
+ |-------------|-------------|
91
+ | `StandardScaler` | Standardise to zero mean, unit variance |
92
+ | `MinMaxScaler` | Scale to [0, 1] range |
93
+ | `RobustScaler` | Scale using median and IQR |
94
+ | `PowerTransformer` | Apply power transform for normality |
95
+ | `QuantileTransformer` | Transform to uniform or normal distribution |
96
+
97
+ > **Note:** Scaling transformers are available but should NOT be used with xplainable models. xplainable models are inherently explainable and require raw feature values. Scaling destroys interpretability.
98
+
99
+ ### Other sklearn
100
+ | Transformer | Description |
101
+ |-------------|-------------|
102
+ | `SimpleImputer` | sklearn's imputer |
103
+ | `OneHotEncoder` | One-hot encode categoricals |
104
+ | `OrdinalEncoder` | Ordinal encode categoricals |
105
+ | `KBinsDiscretizer` | Discretise continuous features into bins |
106
+ | `Binarizer` | Threshold features to binary |
107
+
108
+ ## Pipeline Spec Format
109
+
110
+ Pipelines are defined as JSON for portability across the xplainable platform, MCP tools, and API:
111
+
112
+ ```json
113
+ {
114
+ "version": "2.0",
115
+ "steps": [
116
+ {
117
+ "id": "step_name",
118
+ "type": "TransformerName",
119
+ "columns": ["col1", "col2"],
120
+ "params": {"param1": "value1"},
121
+ "description": "Optional description"
122
+ }
123
+ ]
124
+ }
125
+ ```
126
+
127
+ - `id`: unique step identifier
128
+ - `type`: transformer class name (from the registry)
129
+ - `columns`: optional list of columns to apply to (wraps in `DataFrameColumnTransformer`)
130
+ - `params`: constructor arguments for the transformer
131
+ - `description`: optional human-readable description
132
+
133
+ ## Architecture
134
+
135
+ ```
136
+ PipelineSpec (JSON)
137
+ ↓ compile_spec()
138
+ DataFramePipeline (sklearn Pipeline of transformers)
139
+ ↓ fit() / transform()
140
+ Transformed DataFrame
141
+ ↓ save_pipeline()
142
+ Binary (cloudpickle)
143
+ ↓ load_pipeline()
144
+ DataFramePipeline (restored)
145
+ ```
146
+
147
+ Key modules:
148
+ - `schema.py` -- Pydantic models for `PipelineSpec` and `StepSpec`
149
+ - `compiler.py` -- compiles JSON spec into sklearn pipeline
150
+ - `pipeline.py` -- `DataFramePipeline` wrapper around sklearn `Pipeline`
151
+ - `registry.py` -- maps transformer names to classes, generates LLM-ready catalog
152
+ - `serialization.py` -- cloudpickle save/load
153
+ - `preview.py` -- before/after preview with schema deltas
154
+
155
+ ## Development
156
+
157
+ ```bash
158
+ pip install -e ".[dev]"
159
+ pytest
160
+ ```
161
+
162
+ ## License
163
+
164
+ MIT
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "xplainable-preprocessing"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Shared preprocessing pipeline package for xplainable"
9
9
  requires-python = ">=3.9"
10
10
  dependencies = [
@@ -20,11 +20,14 @@ from sklearn.preprocessing import (
20
20
 
21
21
  from xplainable_preprocessing.transformers import (
22
22
  CategoryCondenseTransformer,
23
+ ClipTransformer,
23
24
  DateTimeExtractTransformer,
24
25
  DropColumnsTransformer,
25
26
  ExpressionTransformer,
26
27
  FillMissingTransformer,
28
+ GroupByAggTransformer,
27
29
  GroupedLagTransformer,
30
+ MissingFlagTransformer,
28
31
  RenameColumnsTransformer,
29
32
  RollingAggTransformer,
30
33
  TextCleanTransformer,
@@ -55,6 +58,9 @@ REGISTRY: dict[str, type] = {
55
58
  "DateTimeExtractTransformer": DateTimeExtractTransformer,
56
59
  "GroupedLagTransformer": GroupedLagTransformer,
57
60
  "RollingAggTransformer": RollingAggTransformer,
61
+ "GroupByAggTransformer": GroupByAggTransformer,
62
+ "ClipTransformer": ClipTransformer,
63
+ "MissingFlagTransformer": MissingFlagTransformer,
58
64
  }
59
65
 
60
66
 
@@ -1,23 +1,29 @@
1
- from xplainable_preprocessing.transformers.expression import ExpressionTransformer
2
- from xplainable_preprocessing.transformers.drop_columns import DropColumnsTransformer
3
- from xplainable_preprocessing.transformers.rename_columns import RenameColumnsTransformer
4
- from xplainable_preprocessing.transformers.type_cast import TypeCastTransformer
5
- from xplainable_preprocessing.transformers.fill_missing import FillMissingTransformer
6
1
  from xplainable_preprocessing.transformers.category_condense import CategoryCondenseTransformer
7
- from xplainable_preprocessing.transformers.text_clean import TextCleanTransformer
2
+ from xplainable_preprocessing.transformers.clip import ClipTransformer
8
3
  from xplainable_preprocessing.transformers.datetime_extract import DateTimeExtractTransformer
4
+ from xplainable_preprocessing.transformers.drop_columns import DropColumnsTransformer
5
+ from xplainable_preprocessing.transformers.expression import ExpressionTransformer
6
+ from xplainable_preprocessing.transformers.fill_missing import FillMissingTransformer
7
+ from xplainable_preprocessing.transformers.groupby_agg import GroupByAggTransformer
9
8
  from xplainable_preprocessing.transformers.grouped_lag import GroupedLagTransformer
9
+ from xplainable_preprocessing.transformers.missing_flag import MissingFlagTransformer
10
+ from xplainable_preprocessing.transformers.rename_columns import RenameColumnsTransformer
10
11
  from xplainable_preprocessing.transformers.rolling_agg import RollingAggTransformer
12
+ from xplainable_preprocessing.transformers.text_clean import TextCleanTransformer
13
+ from xplainable_preprocessing.transformers.type_cast import TypeCastTransformer
11
14
 
12
15
  __all__ = [
13
- "ExpressionTransformer",
14
- "DropColumnsTransformer",
15
- "RenameColumnsTransformer",
16
- "TypeCastTransformer",
17
- "FillMissingTransformer",
18
16
  "CategoryCondenseTransformer",
19
- "TextCleanTransformer",
17
+ "ClipTransformer",
20
18
  "DateTimeExtractTransformer",
19
+ "DropColumnsTransformer",
20
+ "ExpressionTransformer",
21
+ "FillMissingTransformer",
22
+ "GroupByAggTransformer",
21
23
  "GroupedLagTransformer",
24
+ "MissingFlagTransformer",
25
+ "RenameColumnsTransformer",
22
26
  "RollingAggTransformer",
27
+ "TextCleanTransformer",
28
+ "TypeCastTransformer",
23
29
  ]
@@ -0,0 +1,45 @@
1
+ """Clip transformer -- clips numeric values outside a range to NaN."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from sklearn.base import BaseEstimator, TransformerMixin
8
+
9
+
10
+ class ClipTransformer(BaseEstimator, TransformerMixin):
11
+ """Clip numeric values outside (min_val, max_val) to NaN.
12
+
13
+ Useful for nulling out physically impossible values
14
+ (e.g. BMI of 0, 2090 push-ups) so they don't create
15
+ junk bins in the model.
16
+
17
+ Parameters
18
+ ----------
19
+ min_val : float | None
20
+ Values strictly below this are set to NaN. None means no lower bound.
21
+ max_val : float | None
22
+ Values strictly above this are set to NaN. None means no upper bound.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ min_val: float | None = None,
28
+ max_val: float | None = None,
29
+ ):
30
+ self.min_val = min_val
31
+ self.max_val = max_val
32
+
33
+ def fit(self, X: pd.DataFrame, y=None):
34
+ return self
35
+
36
+ def transform(self, X: pd.DataFrame) -> pd.DataFrame:
37
+ X = X.copy()
38
+ for col in X.columns:
39
+ if not pd.api.types.is_numeric_dtype(X[col]):
40
+ continue
41
+ if self.min_val is not None:
42
+ X.loc[X[col] < self.min_val, col] = np.nan
43
+ if self.max_val is not None:
44
+ X.loc[X[col] > self.max_val, col] = np.nan
45
+ return X
@@ -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)
@@ -0,0 +1,48 @@
1
+ """Missing flag transformer -- creates binary indicators for null values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pandas as pd
6
+ from sklearn.base import BaseEstimator, TransformerMixin
7
+
8
+
9
+ class MissingFlagTransformer(BaseEstimator, TransformerMixin):
10
+ """Create binary 0/1 indicator columns for null values.
11
+
12
+ For each specified column, adds a new column ``{col}_missing``
13
+ that is 1 where the original value is null and 0 otherwise.
14
+ Useful when missingness itself is informative (e.g. "physical
15
+ testing not completed" predicts injury risk).
16
+
17
+ Parameters
18
+ ----------
19
+ suffix : str
20
+ Suffix appended to column names for the flag columns.
21
+ Default ``"_missing"``.
22
+ drop_original : bool
23
+ If True, drop the original columns after creating flags.
24
+ Default False.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ suffix: str = "_missing",
30
+ drop_original: bool = False,
31
+ ):
32
+ self.suffix = suffix
33
+ self.drop_original = drop_original
34
+
35
+ def fit(self, X: pd.DataFrame, y=None):
36
+ return self
37
+
38
+ def transform(self, X: pd.DataFrame) -> pd.DataFrame:
39
+ X = X.copy()
40
+ flag_cols = {}
41
+ for col in X.columns:
42
+ flag_cols[f"{col}{self.suffix}"] = X[col].isna().astype(int)
43
+ flags = pd.DataFrame(flag_cols, index=X.index)
44
+ if self.drop_original:
45
+ X = pd.concat([flags], axis=1)
46
+ else:
47
+ X = pd.concat([X, flags], axis=1)
48
+ return X
@@ -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
+ }
@@ -1,292 +0,0 @@
1
- #!/bin/bash
2
- # Simple release script for xplainable-preprocessing
3
-
4
- set -e
5
-
6
- # Colors for output
7
- RED='\033[0;31m'
8
- GREEN='\033[0;32m'
9
- YELLOW='\033[1;33m'
10
- BLUE='\033[0;34m'
11
- NC='\033[0m' # No Color
12
-
13
- # Functions
14
- print_header() {
15
- echo -e "${BLUE}$1${NC}"
16
- echo "=================================="
17
- }
18
-
19
- print_success() {
20
- echo -e "${GREEN}✅ $1${NC}"
21
- }
22
-
23
- print_warning() {
24
- echo -e "${YELLOW}⚠️ $1${NC}"
25
- }
26
-
27
- print_error() {
28
- echo -e "${RED}❌ $1${NC}"
29
- }
30
-
31
- # Parse arguments
32
- VERSION=""
33
- INCREMENT=""
34
- DRY_RUN=false
35
- PUBLISH_PYPI=false
36
-
37
- while [[ $# -gt 0 ]]; do
38
- case $1 in
39
- -v|--version)
40
- VERSION="$2"
41
- shift 2
42
- ;;
43
- -i|--increment)
44
- INCREMENT="$2"
45
- shift 2
46
- ;;
47
- --dry-run)
48
- DRY_RUN=true
49
- shift
50
- ;;
51
- --publish)
52
- PUBLISH_PYPI=true
53
- shift
54
- ;;
55
- -h|--help)
56
- echo "Usage: $0 [OPTIONS]"
57
- echo ""
58
- echo "Options:"
59
- echo " -v, --version VERSION Specific version (e.g., 1.2.3)"
60
- echo " -i, --increment TYPE Auto-increment: major, minor, or patch"
61
- echo " --dry-run Show what would be done"
62
- echo " --publish Publish to PyPI after building"
63
- echo " -h, --help Show this help"
64
- echo ""
65
- echo "Examples:"
66
- echo " $0 --version 0.2.0"
67
- echo " $0 --increment patch"
68
- echo " $0 --increment minor --publish"
69
- echo " $0 --increment minor --dry-run"
70
- exit 0
71
- ;;
72
- *)
73
- print_error "Unknown option: $1"
74
- exit 1
75
- ;;
76
- esac
77
- done
78
-
79
- # Validate arguments
80
- if [[ -z "$VERSION" && -z "$INCREMENT" ]]; then
81
- print_error "Must specify either --version or --increment"
82
- echo "Run $0 --help for usage"
83
- exit 1
84
- fi
85
-
86
- if [[ -n "$VERSION" && -n "$INCREMENT" ]]; then
87
- print_error "Cannot specify both --version and --increment"
88
- exit 1
89
- fi
90
-
91
- print_header "🚀 xplainable-preprocessing Release"
92
-
93
- # Check if we're in the right directory
94
- if [[ ! -f "pyproject.toml" ]]; then
95
- print_error "Not in project root (no pyproject.toml found)"
96
- exit 1
97
- fi
98
-
99
- # Check git status
100
- print_header "📋 Pre-release Checks"
101
-
102
- if ! git rev-parse --git-dir > /dev/null 2>&1; then
103
- print_error "Not in a git repository"
104
- exit 1
105
- fi
106
-
107
- if [[ -n $(git status --porcelain) ]]; then
108
- print_error "Uncommitted changes detected"
109
- git status --short
110
- exit 1
111
- fi
112
-
113
- CURRENT_BRANCH=$(git branch --show-current)
114
- if [[ "$CURRENT_BRANCH" != "main" && "$CURRENT_BRANCH" != "master" ]]; then
115
- print_warning "Currently on branch '$CURRENT_BRANCH'"
116
- read -p "Continue anyway? (y/N): " -n 1 -r
117
- echo
118
- if [[ ! $REPLY =~ ^[Yy]$ ]]; then
119
- exit 1
120
- fi
121
- fi
122
-
123
- print_success "Git repository is clean"
124
-
125
- # Run tests before release
126
- print_header "🧪 Running Tests"
127
- if python -m pytest tests/ -q; then
128
- print_success "All tests passed"
129
- else
130
- print_error "Tests failed — aborting release"
131
- exit 1
132
- fi
133
-
134
- # Get current version from pyproject.toml
135
- CURRENT_VERSION=$(grep '^version =' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
136
-
137
- if [[ -z "$CURRENT_VERSION" ]]; then
138
- print_error "Could not determine current version"
139
- exit 1
140
- fi
141
-
142
- print_success "Current version: $CURRENT_VERSION"
143
-
144
- # Determine new version
145
- if [[ -n "$VERSION" ]]; then
146
- NEW_VERSION="${VERSION#v}" # Remove 'v' prefix if present
147
- else
148
- # Auto-increment
149
- IFS='.' read -ra VERSION_PARTS <<< "$CURRENT_VERSION"
150
- MAJOR=${VERSION_PARTS[0]}
151
- MINOR=${VERSION_PARTS[1]}
152
- PATCH=${VERSION_PARTS[2]}
153
-
154
- case $INCREMENT in
155
- major)
156
- MAJOR=$((MAJOR + 1))
157
- MINOR=0
158
- PATCH=0
159
- ;;
160
- minor)
161
- MINOR=$((MINOR + 1))
162
- PATCH=0
163
- ;;
164
- patch)
165
- PATCH=$((PATCH + 1))
166
- ;;
167
- *)
168
- print_error "Invalid increment type: $INCREMENT"
169
- exit 1
170
- ;;
171
- esac
172
-
173
- NEW_VERSION="$MAJOR.$MINOR.$PATCH"
174
- fi
175
-
176
- TAG="v$NEW_VERSION"
177
-
178
- print_header "📦 Release Plan"
179
- echo "Current: $CURRENT_VERSION"
180
- echo "New: $NEW_VERSION"
181
- echo "Tag: $TAG"
182
-
183
- if [[ "$DRY_RUN" == true ]]; then
184
- print_header "🔍 DRY RUN - Would perform:"
185
- echo "1. Clean build artifacts"
186
- echo "2. Update version in pyproject.toml"
187
- echo "3. Commit version changes"
188
- echo "4. Create git tag $TAG"
189
- echo "5. Build package (wheel and sdist)"
190
- echo "6. Push changes and tag"
191
- if [[ "$PUBLISH_PYPI" == true ]]; then
192
- echo "7. Publish package to PyPI"
193
- echo "8. Create GitHub release (if gh CLI available)"
194
- else
195
- echo "7. Create GitHub release (if gh CLI available)"
196
- fi
197
- exit 0
198
- fi
199
-
200
- # Confirm
201
- read -p "Create release v$NEW_VERSION? (y/N): " -n 1 -r
202
- echo
203
- if [[ ! $REPLY =~ ^[Yy]$ ]]; then
204
- print_error "Release cancelled"
205
- exit 1
206
- fi
207
-
208
- print_header "🔨 Creating Release"
209
-
210
- # Clean up build artifacts
211
- print_success "Cleaning up build artifacts..."
212
- rm -rf build/ dist/ *.egg-info/ src/*.egg-info/ || true
213
-
214
- # Update version in pyproject.toml
215
- print_success "Updating version..."
216
- sed -i.bak "s/^version = .*/version = \"$NEW_VERSION\"/" pyproject.toml && rm pyproject.toml.bak
217
- print_success "Updated pyproject.toml"
218
-
219
- # Commit changes
220
- print_success "Committing version changes..."
221
- git add .
222
- git commit -m "chore: bump version to $NEW_VERSION"
223
-
224
- # Create tag
225
- print_success "Creating tag $TAG..."
226
- RELEASE_DATE=$(date +%Y-%m-%d)
227
- git tag -a "$TAG" -m "Release $TAG — $RELEASE_DATE"
228
-
229
- # Build package
230
- print_success "Building package..."
231
- python -m build
232
-
233
- # Push changes
234
- print_success "Pushing changes..."
235
- git push
236
- git push --tags
237
-
238
- # Publish to PyPI if requested
239
- if [[ "$PUBLISH_PYPI" == true ]]; then
240
- print_success "Publishing to PyPI..."
241
-
242
- if ! command -v twine &> /dev/null; then
243
- print_error "twine not found"
244
- echo "Install with: pip install twine"
245
- exit 1
246
- fi
247
-
248
- if [[ -z "$TWINE_PASSWORD" && ! -f ~/.pypirc ]]; then
249
- print_error "PyPI credentials not configured"
250
- echo "Set up credentials with one of:"
251
- echo " 1. Environment: export TWINE_USERNAME=__token__ TWINE_PASSWORD=pypi-..."
252
- echo " 2. Config file: ~/.pypirc"
253
- exit 1
254
- fi
255
-
256
- if twine upload dist/*; then
257
- print_success "Package published to PyPI!"
258
- echo "🔗 View at: https://pypi.org/project/xplainable-preprocessing/$NEW_VERSION/"
259
- else
260
- print_error "PyPI upload failed"
261
- exit 1
262
- fi
263
- fi
264
-
265
- # Create GitHub release if gh CLI is available
266
- if command -v gh &> /dev/null; then
267
- if gh auth status &> /dev/null; then
268
- print_success "Creating GitHub release..."
269
-
270
- gh release create "$TAG" \
271
- --title "v$NEW_VERSION" \
272
- --notes "## xplainable-preprocessing v$NEW_VERSION — $RELEASE_DATE
273
-
274
- ### Installation
275
-
276
- \`\`\`bash
277
- pip install xplainable-preprocessing==$NEW_VERSION
278
- \`\`\`" \
279
- --latest
280
-
281
- print_success "GitHub release created!"
282
- else
283
- print_warning "GitHub CLI not authenticated, skipping release creation"
284
- fi
285
- else
286
- print_warning "GitHub CLI not found, skipping release creation"
287
- fi
288
-
289
- print_header "🎉 RELEASE COMPLETE!"
290
- echo "Version: $NEW_VERSION"
291
- echo "Tag: $TAG"
292
- echo "PyPI: https://pypi.org/project/xplainable-preprocessing/$NEW_VERSION/"