xplainable-preprocessing 0.1.0__tar.gz → 0.2.1__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/.github/workflows/publish-pypi.yml +42 -0
  2. xplainable_preprocessing-0.2.1/.gitignore +1 -0
  3. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/PKG-INFO +1 -1
  4. xplainable_preprocessing-0.2.1/README.md +164 -0
  5. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/pyproject.toml +1 -1
  6. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/src/xplainable_preprocessing/registry.py +6 -0
  7. xplainable_preprocessing-0.2.1/src/xplainable_preprocessing/schema.py +372 -0
  8. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/src/xplainable_preprocessing/transformers/__init__.py +18 -12
  9. xplainable_preprocessing-0.2.1/src/xplainable_preprocessing/transformers/clip.py +45 -0
  10. xplainable_preprocessing-0.2.1/src/xplainable_preprocessing/transformers/missing_flag.py +48 -0
  11. xplainable_preprocessing-0.1.0/src/xplainable_preprocessing/schema.py +0 -71
  12. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/docs/dag-pipeline-proposal.md +0 -0
  13. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/docs/feature-pipeline-architectures.md +0 -0
  14. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/docs/feature-store-proposal.md +0 -0
  15. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/src/xplainable_preprocessing/__init__.py +0 -0
  16. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/src/xplainable_preprocessing/compiler.py +0 -0
  17. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/src/xplainable_preprocessing/pipeline.py +0 -0
  18. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/src/xplainable_preprocessing/preview.py +0 -0
  19. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/src/xplainable_preprocessing/sandbox.py +0 -0
  20. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/src/xplainable_preprocessing/serialization.py +0 -0
  21. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/src/xplainable_preprocessing/transformers/category_condense.py +0 -0
  22. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/src/xplainable_preprocessing/transformers/datetime_extract.py +0 -0
  23. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/src/xplainable_preprocessing/transformers/drop_columns.py +0 -0
  24. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/src/xplainable_preprocessing/transformers/expression.py +0 -0
  25. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/src/xplainable_preprocessing/transformers/fill_missing.py +0 -0
  26. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/src/xplainable_preprocessing/transformers/grouped_lag.py +0 -0
  27. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/src/xplainable_preprocessing/transformers/rename_columns.py +0 -0
  28. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/src/xplainable_preprocessing/transformers/rolling_agg.py +0 -0
  29. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/src/xplainable_preprocessing/transformers/text_clean.py +0 -0
  30. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/src/xplainable_preprocessing/transformers/type_cast.py +0 -0
  31. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/tests/__init__.py +0 -0
  32. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/tests/test_compiler.py +0 -0
  33. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/tests/test_preview.py +0 -0
  34. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/tests/test_sandbox.py +0 -0
  35. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/tests/test_schema.py +0 -0
  36. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/tests/test_serialization.py +0 -0
  37. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/tests/test_transformers/__init__.py +0 -0
  38. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/tests/test_transformers/test_all_transformers.py +0 -0
  39. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.1}/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.1.0
3
+ Version: 0.2.1
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.1.0"
7
+ version = "0.2.1"
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
 
@@ -0,0 +1,372 @@
1
+ """Pydantic models for pipeline specification with mutation support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict, List, Optional
6
+
7
+ from pydantic import BaseModel, field_validator
8
+
9
+
10
+ class StepSpec(BaseModel):
11
+ """A single preprocessing step in the pipeline.
12
+
13
+ The ``outputs``, ``modifies``, and ``drops`` fields form the step's
14
+ **column contract**. They are populated after the first fit via
15
+ ``PipelineSpec.enrich_from_deltas()`` and enable cascade analysis
16
+ for step mutations (remove, reorder) without recompiling the pipeline.
17
+ """
18
+
19
+ id: str
20
+ type: str
21
+ columns: Optional[List[str]] = None
22
+ params: Dict = {}
23
+ description: Optional[str] = None
24
+
25
+ # Column contract — populated after first fit via enrich_from_deltas()
26
+ outputs: Optional[List[str]] = None # columns this step CREATES
27
+ modifies: Optional[List[str]] = None # columns this step CHANGES in place
28
+ drops: Optional[List[str]] = None # columns this step REMOVES
29
+
30
+ @field_validator("id")
31
+ @classmethod
32
+ def id_must_be_non_empty(cls, v: str) -> str:
33
+ if not v.strip():
34
+ raise ValueError("Step id must be non-empty")
35
+ return v
36
+
37
+ @field_validator("type")
38
+ @classmethod
39
+ def type_must_be_non_empty(cls, v: str) -> str:
40
+ if not v.strip():
41
+ raise ValueError("Step type must be non-empty")
42
+ return v
43
+
44
+
45
+ class RemovalAnalysis(BaseModel):
46
+ """Result of analyzing what happens when a step is removed."""
47
+
48
+ step_id: str
49
+ cascade_remove: List[str] = [] # step IDs that must also be removed
50
+ columns_lost: List[str] = [] # columns that would disappear
51
+ columns_restored: List[str] = [] # columns that were dropped and would reappear
52
+ safe: bool = True # True if no downstream steps are affected
53
+
54
+
55
+ class PipelineSpec(BaseModel):
56
+ """Full pipeline specification containing ordered steps.
57
+
58
+ Supports mutation methods (``remove_step``, ``insert_step``,
59
+ ``reorder_step``) that return new ``PipelineSpec`` instances.
60
+ Cascade analysis via ``analyze_removal`` works from the column
61
+ contract without needing to recompile the pipeline.
62
+ """
63
+
64
+ version: str = "2.0"
65
+ steps: List[StepSpec] = []
66
+
67
+ @field_validator("steps")
68
+ @classmethod
69
+ def step_ids_must_be_unique(cls, v: List[StepSpec]) -> List[StepSpec]:
70
+ ids = [step.id for step in v]
71
+ if len(ids) != len(set(ids)):
72
+ duplicates = [id_ for id_ in ids if ids.count(id_) > 1]
73
+ raise ValueError(f"Duplicate step ids: {set(duplicates)}")
74
+ return v
75
+
76
+ # ------------------------------------------------------------------
77
+ # Helpers
78
+ # ------------------------------------------------------------------
79
+
80
+ def get_step(self, step_id: str) -> Optional[StepSpec]:
81
+ """Return a step by ID, or None."""
82
+ for step in self.steps:
83
+ if step.id == step_id:
84
+ return step
85
+ return None
86
+
87
+ def step_index(self, step_id: str) -> int:
88
+ """Return the index of a step by ID. Raises ValueError if not found."""
89
+ for i, step in enumerate(self.steps):
90
+ if step.id == step_id:
91
+ return i
92
+ raise ValueError(f"Step '{step_id}' not found in spec")
93
+
94
+ def steps_after(self, step_id: str) -> List[StepSpec]:
95
+ """Return all steps that come after the given step."""
96
+ idx = self.step_index(step_id)
97
+ return self.steps[idx + 1:]
98
+
99
+ @property
100
+ def is_enriched(self) -> bool:
101
+ """True if column contracts have been populated on all steps."""
102
+ return all(
103
+ step.outputs is not None
104
+ for step in self.steps
105
+ )
106
+
107
+ # ------------------------------------------------------------------
108
+ # Enrichment — write column contracts from deltas
109
+ # ------------------------------------------------------------------
110
+
111
+ def enrich_from_deltas(self, step_deltas: list[dict]) -> "PipelineSpec":
112
+ """Return a new spec with column contracts populated from step deltas.
113
+
114
+ Parameters
115
+ ----------
116
+ step_deltas : list[dict]
117
+ Output of ``preview.compute_step_deltas()``. Each entry has
118
+ ``step_id`` and ``delta: {added, updated, dropped}``.
119
+
120
+ Returns
121
+ -------
122
+ PipelineSpec
123
+ A new spec instance with ``outputs``/``modifies``/``drops``
124
+ populated on each step.
125
+ """
126
+ delta_map = {d["step_id"]: d for d in step_deltas}
127
+ enriched_steps = []
128
+ for step in self.steps:
129
+ entry = delta_map.get(step.id, {})
130
+ delta = entry.get("delta", {})
131
+ updates = {
132
+ "outputs": delta.get("added", []),
133
+ "modifies": delta.get("updated", []),
134
+ "drops": delta.get("dropped", []),
135
+ }
136
+ # columns (inputs) is left as-is — only the LLM-declared value
137
+ # is used for dependency resolution. Outputs/modifies/drops are
138
+ # sufficient for cascade analysis.
139
+ enriched_steps.append(step.model_copy(update=updates))
140
+ return PipelineSpec(version=self.version, steps=enriched_steps)
141
+
142
+ # ------------------------------------------------------------------
143
+ # Cascade analysis
144
+ # ------------------------------------------------------------------
145
+
146
+ def analyze_removal(self, step_id: str) -> RemovalAnalysis:
147
+ """Analyze what happens if a step is removed.
148
+
149
+ Uses the column contract (``outputs``/``drops``) to determine
150
+ which downstream steps would lose their input columns and must
151
+ also be removed (cascade).
152
+
153
+ Works without recompiling the pipeline — purely from the spec
154
+ metadata. Requires the spec to be enriched first.
155
+
156
+ Parameters
157
+ ----------
158
+ step_id : str
159
+ The ID of the step to analyze removing.
160
+
161
+ Returns
162
+ -------
163
+ RemovalAnalysis
164
+ """
165
+ step = self.get_step(step_id)
166
+ if step is None:
167
+ raise ValueError(f"Step '{step_id}' not found")
168
+
169
+ # Columns that would disappear if this step is removed
170
+ lost_columns = set(step.outputs or [])
171
+
172
+ # Walk downstream and find cascade
173
+ cascade = []
174
+ for other in self.steps_after(step_id):
175
+ inputs = set(other.columns or [])
176
+ if inputs & lost_columns:
177
+ cascade.append(other.id)
178
+ # This step's outputs are also lost in the cascade
179
+ lost_columns.update(other.outputs or [])
180
+
181
+ # Columns that this step drops would be restored
182
+ restored = list(step.drops or [])
183
+
184
+ return RemovalAnalysis(
185
+ step_id=step_id,
186
+ cascade_remove=cascade,
187
+ columns_lost=sorted(lost_columns),
188
+ columns_restored=sorted(restored),
189
+ safe=len(cascade) == 0,
190
+ )
191
+
192
+ # ------------------------------------------------------------------
193
+ # Mutations — all return NEW PipelineSpec instances (immutable pattern)
194
+ # ------------------------------------------------------------------
195
+
196
+ def remove_step(self, step_id: str, cascade: bool = True) -> "PipelineSpec":
197
+ """Return a new spec with the step (and optionally its cascade) removed.
198
+
199
+ Parameters
200
+ ----------
201
+ step_id : str
202
+ The step to remove.
203
+ cascade : bool
204
+ If True, also remove downstream steps that depend on columns
205
+ created by this step. If False, only remove this single step
206
+ (caller is responsible for ensuring validity).
207
+
208
+ Returns
209
+ -------
210
+ PipelineSpec
211
+ """
212
+ analysis = self.analyze_removal(step_id)
213
+ remove_ids = {step_id}
214
+ if cascade:
215
+ remove_ids.update(analysis.cascade_remove)
216
+ return PipelineSpec(
217
+ version=self.version,
218
+ steps=[s for s in self.steps if s.id not in remove_ids],
219
+ )
220
+
221
+ def insert_step(
222
+ self,
223
+ step: StepSpec,
224
+ after: Optional[str] = None,
225
+ ) -> "PipelineSpec":
226
+ """Return a new spec with a step inserted.
227
+
228
+ Parameters
229
+ ----------
230
+ step : StepSpec
231
+ The step to insert.
232
+ after : str or None
233
+ Insert after this step ID. If None, append to the end.
234
+
235
+ Returns
236
+ -------
237
+ PipelineSpec
238
+ """
239
+ new_steps = list(self.steps)
240
+ if after is None:
241
+ new_steps.append(step)
242
+ else:
243
+ idx = self.step_index(after)
244
+ new_steps.insert(idx + 1, step)
245
+ return PipelineSpec(version=self.version, steps=new_steps)
246
+
247
+ def reorder_step(self, step_id: str, new_index: int) -> "PipelineSpec":
248
+ """Return a new spec with a step moved to a new position.
249
+
250
+ Parameters
251
+ ----------
252
+ step_id : str
253
+ The step to move.
254
+ new_index : int
255
+ The target index (0-based).
256
+
257
+ Returns
258
+ -------
259
+ PipelineSpec
260
+ """
261
+ idx = self.step_index(step_id)
262
+ step = self.steps[idx]
263
+ remaining = [s for s in self.steps if s.id != step_id]
264
+ clamped = max(0, min(new_index, len(remaining)))
265
+ remaining.insert(clamped, step)
266
+ return PipelineSpec(version=self.version, steps=remaining)
267
+
268
+ def update_step_params(self, step_id: str, params: Dict) -> "PipelineSpec":
269
+ """Return a new spec with a step's params updated.
270
+
271
+ Parameters
272
+ ----------
273
+ step_id : str
274
+ The step to update.
275
+ params : dict
276
+ New params to merge into the step's existing params.
277
+
278
+ Returns
279
+ -------
280
+ PipelineSpec
281
+ """
282
+ new_steps = []
283
+ for step in self.steps:
284
+ if step.id == step_id:
285
+ merged = {**step.params, **params}
286
+ new_steps.append(step.model_copy(update={"params": merged}))
287
+ else:
288
+ new_steps.append(step)
289
+ return PipelineSpec(version=self.version, steps=new_steps)
290
+
291
+
292
+ def optimize(self) -> "PipelineSpec":
293
+ """Return a new spec with steps in optimal execution order.
294
+
295
+ Topologically sorts steps so that any step producing a column
296
+ comes before steps that consume it. Independent steps retain
297
+ their relative order.
298
+
299
+ Requires column contracts (``outputs``) and declared ``columns``
300
+ (inputs) on each step. If the spec is not enriched, returns
301
+ a copy with the original order.
302
+ """
303
+ if not self.is_enriched:
304
+ return self.model_copy()
305
+
306
+ from collections import defaultdict
307
+
308
+ step_map = {s.id: s for s in self.steps}
309
+ ids = [s.id for s in self.steps]
310
+
311
+ # Build producer map: column → step that creates it
312
+ producers: dict[str, str] = {}
313
+ for s in self.steps:
314
+ for col in s.outputs or []:
315
+ if col not in producers:
316
+ producers[col] = s.id
317
+
318
+ # Build adjacency from declared inputs
319
+ adj: dict[str, set[str]] = defaultdict(set)
320
+ in_deg: dict[str, int] = {sid: 0 for sid in ids}
321
+ for s in self.steps:
322
+ for col in s.columns or []:
323
+ prod = producers.get(col)
324
+ if prod and prod != s.id and prod in step_map:
325
+ if prod not in adj or s.id not in adj[prod]:
326
+ adj[prod].add(s.id)
327
+ in_deg[s.id] += 1
328
+
329
+ # Kahn's algorithm — stable (preserves original order within tiers)
330
+ ordered: list[str] = []
331
+ remaining = set(ids)
332
+ while remaining:
333
+ ready = [sid for sid in ids if sid in remaining and in_deg.get(sid, 0) == 0]
334
+ if not ready:
335
+ ordered.extend(sorted(remaining))
336
+ break
337
+ ordered.extend(ready)
338
+ for sid in ready:
339
+ remaining.discard(sid)
340
+ for nb in adj.get(sid, set()):
341
+ in_deg[nb] = max(in_deg.get(nb, 1) - 1, 0)
342
+
343
+ return PipelineSpec(
344
+ version=self.version,
345
+ steps=[step_map[sid] for sid in ordered if sid in step_map],
346
+ )
347
+
348
+
349
+ def validate_spec(spec: PipelineSpec) -> None:
350
+ """Validate a PipelineSpec. Raises ValueError if invalid.
351
+
352
+ Checks beyond Pydantic validation:
353
+ - All non-custom types must exist in the registry
354
+ - Custom steps must have 'code' and 'class_name' in params
355
+ """
356
+ from xplainable_preprocessing.registry import REGISTRY
357
+
358
+ for step in spec.steps:
359
+ if step.type == "custom":
360
+ if "code" not in step.params:
361
+ raise ValueError(
362
+ f"Step '{step.id}': custom type requires 'code' in params"
363
+ )
364
+ if "class_name" not in step.params:
365
+ raise ValueError(
366
+ f"Step '{step.id}': custom type requires 'class_name' in params"
367
+ )
368
+ elif step.type not in REGISTRY:
369
+ raise ValueError(
370
+ f"Step '{step.id}': unknown type '{step.type}'. "
371
+ f"Available: {sorted(REGISTRY.keys())}"
372
+ )
@@ -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,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
@@ -1,71 +0,0 @@
1
- """Pydantic models for pipeline specification."""
2
-
3
- from typing import Dict, List, Optional
4
-
5
- from pydantic import BaseModel, field_validator
6
-
7
-
8
- class StepSpec(BaseModel):
9
- """A single preprocessing step in the pipeline."""
10
-
11
- id: str
12
- type: str
13
- columns: Optional[List[str]] = None
14
- params: Dict = {}
15
- description: Optional[str] = None
16
-
17
- @field_validator("id")
18
- @classmethod
19
- def id_must_be_non_empty(cls, v: str) -> str:
20
- if not v.strip():
21
- raise ValueError("Step id must be non-empty")
22
- return v
23
-
24
- @field_validator("type")
25
- @classmethod
26
- def type_must_be_non_empty(cls, v: str) -> str:
27
- if not v.strip():
28
- raise ValueError("Step type must be non-empty")
29
- return v
30
-
31
-
32
- class PipelineSpec(BaseModel):
33
- """Full pipeline specification containing ordered steps."""
34
-
35
- version: str = "2.0"
36
- steps: List[StepSpec] = []
37
-
38
- @field_validator("steps")
39
- @classmethod
40
- def step_ids_must_be_unique(cls, v: List[StepSpec]) -> List[StepSpec]:
41
- ids = [step.id for step in v]
42
- if len(ids) != len(set(ids)):
43
- duplicates = [id_ for id_ in ids if ids.count(id_) > 1]
44
- raise ValueError(f"Duplicate step ids: {set(duplicates)}")
45
- return v
46
-
47
-
48
- def validate_spec(spec: PipelineSpec) -> None:
49
- """Validate a PipelineSpec. Raises ValueError if invalid.
50
-
51
- Checks beyond Pydantic validation:
52
- - All non-custom types must exist in the registry
53
- - Custom steps must have 'code' and 'class_name' in params
54
- """
55
- from xplainable_preprocessing.registry import REGISTRY
56
-
57
- for step in spec.steps:
58
- if step.type == "custom":
59
- if "code" not in step.params:
60
- raise ValueError(
61
- f"Step '{step.id}': custom type requires 'code' in params"
62
- )
63
- if "class_name" not in step.params:
64
- raise ValueError(
65
- f"Step '{step.id}': custom type requires 'class_name' in params"
66
- )
67
- elif step.type not in REGISTRY:
68
- raise ValueError(
69
- f"Step '{step.id}': unknown type '{step.type}'. "
70
- f"Available: {sorted(REGISTRY.keys())}"
71
- )