xplainable-preprocessing 0.1.0__py3-none-any.whl
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/__init__.py +20 -0
- xplainable_preprocessing/compiler.py +68 -0
- xplainable_preprocessing/pipeline.py +87 -0
- xplainable_preprocessing/preview.py +137 -0
- xplainable_preprocessing/registry.py +108 -0
- xplainable_preprocessing/sandbox.py +113 -0
- xplainable_preprocessing/schema.py +71 -0
- xplainable_preprocessing/serialization.py +20 -0
- xplainable_preprocessing/transformers/__init__.py +23 -0
- xplainable_preprocessing/transformers/category_condense.py +50 -0
- xplainable_preprocessing/transformers/datetime_extract.py +72 -0
- xplainable_preprocessing/transformers/drop_columns.py +25 -0
- xplainable_preprocessing/transformers/expression.py +35 -0
- xplainable_preprocessing/transformers/fill_missing.py +71 -0
- xplainable_preprocessing/transformers/grouped_lag.py +55 -0
- xplainable_preprocessing/transformers/rename_columns.py +25 -0
- xplainable_preprocessing/transformers/rolling_agg.py +79 -0
- xplainable_preprocessing/transformers/text_clean.py +70 -0
- xplainable_preprocessing/transformers/type_cast.py +42 -0
- xplainable_preprocessing-0.1.0.dist-info/METADATA +13 -0
- xplainable_preprocessing-0.1.0.dist-info/RECORD +22 -0
- xplainable_preprocessing-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from xplainable_preprocessing.schema import PipelineSpec, StepSpec
|
|
2
|
+
from xplainable_preprocessing.compiler import compile_spec
|
|
3
|
+
from xplainable_preprocessing.schema import validate_spec
|
|
4
|
+
from xplainable_preprocessing.serialization import save_pipeline, load_pipeline
|
|
5
|
+
from xplainable_preprocessing.pipeline import DataFramePipeline, DataFrameColumnTransformer
|
|
6
|
+
from xplainable_preprocessing.registry import REGISTRY, register, generate_catalog
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"PipelineSpec",
|
|
10
|
+
"StepSpec",
|
|
11
|
+
"compile_spec",
|
|
12
|
+
"validate_spec",
|
|
13
|
+
"save_pipeline",
|
|
14
|
+
"load_pipeline",
|
|
15
|
+
"DataFramePipeline",
|
|
16
|
+
"DataFrameColumnTransformer",
|
|
17
|
+
"REGISTRY",
|
|
18
|
+
"register",
|
|
19
|
+
"generate_catalog",
|
|
20
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Compile a PipelineSpec into a DataFramePipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
|
|
7
|
+
from xplainable_preprocessing.pipeline import DataFrameColumnTransformer, DataFramePipeline
|
|
8
|
+
from xplainable_preprocessing.registry import REGISTRY
|
|
9
|
+
from xplainable_preprocessing.sandbox import compile_custom
|
|
10
|
+
from xplainable_preprocessing.schema import PipelineSpec
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _coerce_params(cls, params: dict) -> dict:
|
|
14
|
+
"""Convert list params to tuples where the constructor expects them.
|
|
15
|
+
|
|
16
|
+
JSON has no tuple type, so specs always contain lists. sklearn transformers
|
|
17
|
+
like MinMaxScaler expect tuples for params like feature_range.
|
|
18
|
+
"""
|
|
19
|
+
sig = inspect.signature(cls.__init__)
|
|
20
|
+
coerced = {}
|
|
21
|
+
for key, value in params.items():
|
|
22
|
+
if isinstance(value, list) and key in sig.parameters:
|
|
23
|
+
param = sig.parameters[key]
|
|
24
|
+
if param.default is not inspect.Parameter.empty and isinstance(param.default, tuple):
|
|
25
|
+
value = tuple(value)
|
|
26
|
+
coerced[key] = value
|
|
27
|
+
return coerced
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def compile_spec(spec: PipelineSpec) -> DataFramePipeline:
|
|
31
|
+
"""Convert a PipelineSpec into a DataFramePipeline.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
spec : PipelineSpec
|
|
36
|
+
The pipeline specification to compile.
|
|
37
|
+
|
|
38
|
+
Returns
|
|
39
|
+
-------
|
|
40
|
+
DataFramePipeline
|
|
41
|
+
An unfitted pipeline ready for .fit() and .transform().
|
|
42
|
+
|
|
43
|
+
Raises
|
|
44
|
+
------
|
|
45
|
+
ValueError
|
|
46
|
+
If a step type is not found in the registry and is not "custom".
|
|
47
|
+
"""
|
|
48
|
+
steps = []
|
|
49
|
+
|
|
50
|
+
for step in spec.steps:
|
|
51
|
+
if step.type == "custom":
|
|
52
|
+
transformer = compile_custom(step.params)
|
|
53
|
+
else:
|
|
54
|
+
if step.type not in REGISTRY:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"Step '{step.id}': unknown type '{step.type}'. "
|
|
57
|
+
f"Available: {sorted(REGISTRY.keys())}"
|
|
58
|
+
)
|
|
59
|
+
cls = REGISTRY[step.type]
|
|
60
|
+
params = _coerce_params(cls, step.params)
|
|
61
|
+
transformer = cls(**params)
|
|
62
|
+
|
|
63
|
+
if step.columns:
|
|
64
|
+
transformer = DataFrameColumnTransformer(transformer, step.columns)
|
|
65
|
+
|
|
66
|
+
steps.append((step.id, transformer))
|
|
67
|
+
|
|
68
|
+
return DataFramePipeline(steps)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""DataFrame-preserving pipeline and column transformer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from sklearn.base import BaseEstimator, TransformerMixin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DataFrameColumnTransformer(BaseEstimator, TransformerMixin):
|
|
10
|
+
"""Applies a transformer to specific columns, passes the rest through."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, transformer: TransformerMixin, columns: list[str]):
|
|
13
|
+
self.transformer = transformer
|
|
14
|
+
self.columns = columns
|
|
15
|
+
|
|
16
|
+
def fit(self, X: pd.DataFrame, y=None):
|
|
17
|
+
self.transformer.fit(X[self.columns], y)
|
|
18
|
+
return self
|
|
19
|
+
|
|
20
|
+
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
|
21
|
+
Xt = X.copy()
|
|
22
|
+
result = self.transformer.transform(Xt[self.columns])
|
|
23
|
+
if isinstance(result, pd.DataFrame):
|
|
24
|
+
Xt = Xt.drop(columns=self.columns)
|
|
25
|
+
Xt = pd.concat([Xt, result], axis=1)
|
|
26
|
+
elif hasattr(result, 'shape') and len(result.shape) == 2 and result.shape[1] != len(self.columns):
|
|
27
|
+
# Column count changed (e.g., OneHotEncoder) — get proper names
|
|
28
|
+
Xt = Xt.drop(columns=self.columns)
|
|
29
|
+
if hasattr(self.transformer, 'get_feature_names_out'):
|
|
30
|
+
new_cols = list(self.transformer.get_feature_names_out())
|
|
31
|
+
else:
|
|
32
|
+
new_cols = [f"{self.columns[0]}_{i}" for i in range(result.shape[1])]
|
|
33
|
+
result_df = pd.DataFrame(result, columns=new_cols, index=Xt.index)
|
|
34
|
+
Xt = pd.concat([Xt, result_df], axis=1)
|
|
35
|
+
else:
|
|
36
|
+
# numpy array with same column count — replace in place
|
|
37
|
+
Xt[self.columns] = result
|
|
38
|
+
return Xt
|
|
39
|
+
|
|
40
|
+
def fit_transform(self, X: pd.DataFrame, y=None) -> pd.DataFrame:
|
|
41
|
+
self.fit(X, y)
|
|
42
|
+
return self.transform(X)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DataFramePipeline(BaseEstimator, TransformerMixin):
|
|
46
|
+
"""Sequential pipeline that preserves DataFrame structure.
|
|
47
|
+
|
|
48
|
+
Unlike sklearn's Pipeline which converts DataFrames to numpy arrays,
|
|
49
|
+
this pipeline passes DataFrames between steps.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
steps : list of (name, transformer) tuples
|
|
54
|
+
The ordered sequence of transformers to apply.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, steps: list[tuple[str, TransformerMixin]]):
|
|
58
|
+
self.steps = steps
|
|
59
|
+
|
|
60
|
+
def fit(self, X: pd.DataFrame, y=None):
|
|
61
|
+
Xt = X.copy()
|
|
62
|
+
for name, transformer in self.steps:
|
|
63
|
+
Xt = transformer.fit_transform(Xt, y)
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
|
67
|
+
Xt = X.copy()
|
|
68
|
+
for name, transformer in self.steps:
|
|
69
|
+
Xt = transformer.transform(Xt)
|
|
70
|
+
return Xt
|
|
71
|
+
|
|
72
|
+
def fit_transform(self, X: pd.DataFrame, y=None) -> pd.DataFrame:
|
|
73
|
+
Xt = X.copy()
|
|
74
|
+
for name, transformer in self.steps:
|
|
75
|
+
Xt = transformer.fit_transform(Xt, y)
|
|
76
|
+
return Xt
|
|
77
|
+
|
|
78
|
+
def get_step(self, name: str) -> TransformerMixin:
|
|
79
|
+
"""Get a step by name."""
|
|
80
|
+
for step_name, transformer in self.steps:
|
|
81
|
+
if step_name == name:
|
|
82
|
+
return transformer
|
|
83
|
+
raise KeyError(f"Step '{name}' not found")
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def step_names(self) -> list[str]:
|
|
87
|
+
return [name for name, _ in self.steps]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Preview and delta computation for UI display."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from xplainable_preprocessing.pipeline import DataFramePipeline
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _sanitize_records(records: list[dict]) -> list[dict]:
|
|
13
|
+
"""Replace NaN/Inf with None for JSON-safe output."""
|
|
14
|
+
sanitized = []
|
|
15
|
+
for row in records:
|
|
16
|
+
sanitized.append({
|
|
17
|
+
k: None if isinstance(v, float) and (math.isnan(v) or math.isinf(v)) else v
|
|
18
|
+
for k, v in row.items()
|
|
19
|
+
})
|
|
20
|
+
return sanitized
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def infer_schema(df: pd.DataFrame) -> dict:
|
|
24
|
+
"""Infer column schema from a DataFrame.
|
|
25
|
+
|
|
26
|
+
Returns
|
|
27
|
+
-------
|
|
28
|
+
dict
|
|
29
|
+
{"columns": [{"name": str, "dtype": str}, ...],
|
|
30
|
+
"sample": [dict] (first row, JSON-safe)}
|
|
31
|
+
"""
|
|
32
|
+
return {
|
|
33
|
+
"columns": [
|
|
34
|
+
{"name": col, "dtype": str(df[col].dtype)}
|
|
35
|
+
for col in df.columns
|
|
36
|
+
],
|
|
37
|
+
"sample": _sanitize_records(df.head(1).to_dict("records")) if len(df) > 0 else [],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def compute_delta(before: pd.DataFrame, after: pd.DataFrame) -> dict:
|
|
42
|
+
"""Compute the transformation delta between two DataFrames.
|
|
43
|
+
|
|
44
|
+
Returns
|
|
45
|
+
-------
|
|
46
|
+
dict with keys:
|
|
47
|
+
- dropped: list of column names removed
|
|
48
|
+
- added: list of column names added
|
|
49
|
+
- updated: list of column names whose values changed
|
|
50
|
+
- rows_before: int
|
|
51
|
+
- rows_after: int
|
|
52
|
+
"""
|
|
53
|
+
before_cols = set(before.columns)
|
|
54
|
+
after_cols = set(after.columns)
|
|
55
|
+
|
|
56
|
+
dropped = sorted(before_cols - after_cols)
|
|
57
|
+
added = sorted(after_cols - before_cols)
|
|
58
|
+
|
|
59
|
+
common = before_cols & after_cols
|
|
60
|
+
updated = []
|
|
61
|
+
for col in sorted(common):
|
|
62
|
+
try:
|
|
63
|
+
if not before[col].equals(after[col]):
|
|
64
|
+
updated.append(col)
|
|
65
|
+
except Exception:
|
|
66
|
+
updated.append(col)
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
"dropped": dropped,
|
|
70
|
+
"added": added,
|
|
71
|
+
"updated": updated,
|
|
72
|
+
"rows_before": len(before),
|
|
73
|
+
"rows_after": len(after),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def compute_step_deltas(
|
|
78
|
+
pipeline: DataFramePipeline,
|
|
79
|
+
df: pd.DataFrame,
|
|
80
|
+
) -> list[dict]:
|
|
81
|
+
"""Compute per-step deltas for a fitted pipeline.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
pipeline : DataFramePipeline
|
|
86
|
+
A fitted pipeline.
|
|
87
|
+
df : pd.DataFrame
|
|
88
|
+
Sample data to transform.
|
|
89
|
+
|
|
90
|
+
Returns
|
|
91
|
+
-------
|
|
92
|
+
list of dict
|
|
93
|
+
One delta per step, each containing step_id, step_type, and the delta.
|
|
94
|
+
"""
|
|
95
|
+
deltas = []
|
|
96
|
+
Xt = df.copy()
|
|
97
|
+
|
|
98
|
+
for name, transformer in pipeline.steps:
|
|
99
|
+
before = Xt.copy()
|
|
100
|
+
Xt = transformer.transform(Xt)
|
|
101
|
+
delta = compute_delta(before, Xt)
|
|
102
|
+
deltas.append({
|
|
103
|
+
"step_id": name,
|
|
104
|
+
"delta": delta,
|
|
105
|
+
"sample_after": _sanitize_records(Xt.head(5).to_dict("records")),
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
return deltas
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def compute_preview(
|
|
112
|
+
pipeline: DataFramePipeline,
|
|
113
|
+
df: pd.DataFrame,
|
|
114
|
+
) -> dict:
|
|
115
|
+
"""Compute a full preview of pipeline transformation.
|
|
116
|
+
|
|
117
|
+
Returns
|
|
118
|
+
-------
|
|
119
|
+
dict with keys:
|
|
120
|
+
- input_schema: schema of input data
|
|
121
|
+
- output_schema: schema of output data
|
|
122
|
+
- step_deltas: per-step deltas
|
|
123
|
+
- sample_before: first 5 rows of input
|
|
124
|
+
- sample_after: first 5 rows of output
|
|
125
|
+
"""
|
|
126
|
+
input_schema = infer_schema(df)
|
|
127
|
+
result = pipeline.transform(df)
|
|
128
|
+
output_schema = infer_schema(result)
|
|
129
|
+
step_deltas = compute_step_deltas(pipeline, df)
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
"input_schema": input_schema,
|
|
133
|
+
"output_schema": output_schema,
|
|
134
|
+
"step_deltas": step_deltas,
|
|
135
|
+
"sample_before": _sanitize_records(df.head(5).to_dict("records")),
|
|
136
|
+
"sample_after": _sanitize_records(result.head(5).to_dict("records")),
|
|
137
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Transformer registry mapping names to sklearn-compatible classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from sklearn.impute import SimpleImputer
|
|
9
|
+
from sklearn.preprocessing import (
|
|
10
|
+
Binarizer,
|
|
11
|
+
KBinsDiscretizer,
|
|
12
|
+
MinMaxScaler,
|
|
13
|
+
OneHotEncoder,
|
|
14
|
+
OrdinalEncoder,
|
|
15
|
+
PowerTransformer,
|
|
16
|
+
QuantileTransformer,
|
|
17
|
+
RobustScaler,
|
|
18
|
+
StandardScaler,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from xplainable_preprocessing.transformers import (
|
|
22
|
+
CategoryCondenseTransformer,
|
|
23
|
+
DateTimeExtractTransformer,
|
|
24
|
+
DropColumnsTransformer,
|
|
25
|
+
ExpressionTransformer,
|
|
26
|
+
FillMissingTransformer,
|
|
27
|
+
GroupedLagTransformer,
|
|
28
|
+
RenameColumnsTransformer,
|
|
29
|
+
RollingAggTransformer,
|
|
30
|
+
TextCleanTransformer,
|
|
31
|
+
TypeCastTransformer,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Registry: name -> class
|
|
35
|
+
REGISTRY: dict[str, type] = {
|
|
36
|
+
# Standard sklearn
|
|
37
|
+
"SimpleImputer": SimpleImputer,
|
|
38
|
+
"StandardScaler": StandardScaler,
|
|
39
|
+
"MinMaxScaler": MinMaxScaler,
|
|
40
|
+
"RobustScaler": RobustScaler,
|
|
41
|
+
"OneHotEncoder": OneHotEncoder,
|
|
42
|
+
"OrdinalEncoder": OrdinalEncoder,
|
|
43
|
+
"PowerTransformer": PowerTransformer,
|
|
44
|
+
"QuantileTransformer": QuantileTransformer,
|
|
45
|
+
"KBinsDiscretizer": KBinsDiscretizer,
|
|
46
|
+
"Binarizer": Binarizer,
|
|
47
|
+
# Custom transformers
|
|
48
|
+
"ExpressionTransformer": ExpressionTransformer,
|
|
49
|
+
"DropColumnsTransformer": DropColumnsTransformer,
|
|
50
|
+
"RenameColumnsTransformer": RenameColumnsTransformer,
|
|
51
|
+
"TypeCastTransformer": TypeCastTransformer,
|
|
52
|
+
"FillMissingTransformer": FillMissingTransformer,
|
|
53
|
+
"CategoryCondenseTransformer": CategoryCondenseTransformer,
|
|
54
|
+
"TextCleanTransformer": TextCleanTransformer,
|
|
55
|
+
"DateTimeExtractTransformer": DateTimeExtractTransformer,
|
|
56
|
+
"GroupedLagTransformer": GroupedLagTransformer,
|
|
57
|
+
"RollingAggTransformer": RollingAggTransformer,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def generate_catalog() -> str:
|
|
62
|
+
"""Generate a prompt-ready catalog of available transformer types.
|
|
63
|
+
|
|
64
|
+
Introspects the REGISTRY to produce a formatted string listing
|
|
65
|
+
each transformer with its constructor parameters, defaults, and
|
|
66
|
+
a one-line description from the class docstring.
|
|
67
|
+
|
|
68
|
+
Returns a string suitable for embedding in LLM system prompts.
|
|
69
|
+
"""
|
|
70
|
+
lines = []
|
|
71
|
+
for name, cls in REGISTRY.items():
|
|
72
|
+
# First line of docstring as description
|
|
73
|
+
doc = (cls.__doc__ or "").strip().split("\n")[0]
|
|
74
|
+
|
|
75
|
+
# Constructor signature (skip 'self')
|
|
76
|
+
sig = inspect.signature(cls.__init__)
|
|
77
|
+
params = []
|
|
78
|
+
for pname, param in sig.parameters.items():
|
|
79
|
+
if pname == "self":
|
|
80
|
+
continue
|
|
81
|
+
if param.default is inspect.Parameter.empty:
|
|
82
|
+
params.append(pname)
|
|
83
|
+
else:
|
|
84
|
+
params.append(f"{pname}={param.default!r}")
|
|
85
|
+
|
|
86
|
+
param_str = ", ".join(params)
|
|
87
|
+
lines.append(f"- {name}({param_str}) — {doc}")
|
|
88
|
+
|
|
89
|
+
# Add custom code option
|
|
90
|
+
lines.append(
|
|
91
|
+
'- custom(code="...", class_name="...", description="...") '
|
|
92
|
+
"— Write a custom sklearn-compatible transformer when no built-in type fits"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return "\n".join(lines)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def register(name: str, cls: type) -> None:
|
|
99
|
+
"""Register a new transformer class.
|
|
100
|
+
|
|
101
|
+
Parameters
|
|
102
|
+
----------
|
|
103
|
+
name : str
|
|
104
|
+
The name to register the transformer under.
|
|
105
|
+
cls : type
|
|
106
|
+
The transformer class (must be sklearn-compatible).
|
|
107
|
+
"""
|
|
108
|
+
REGISTRY[name] = cls
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""AST-based code validator and restricted executor for custom transformers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
|
|
7
|
+
FORBIDDEN_IMPORTS = {
|
|
8
|
+
"os", "sys", "subprocess", "socket", "shutil", "pathlib",
|
|
9
|
+
"http", "urllib", "requests", "importlib", "ctypes", "signal",
|
|
10
|
+
"multiprocessing", "threading", "asyncio", "pickle", "shelve",
|
|
11
|
+
"tempfile", "glob", "io", "builtins", "code", "codeop",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
FORBIDDEN_CALLS = {
|
|
15
|
+
"exec", "eval", "__import__", "open", "compile",
|
|
16
|
+
"globals", "locals", "breakpoint", "exit", "quit",
|
|
17
|
+
"getattr", "setattr", "delattr",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def validate_code(code: str) -> None:
|
|
22
|
+
"""AST-validate custom transformer code. Raises ValueError if unsafe."""
|
|
23
|
+
try:
|
|
24
|
+
tree = ast.parse(code)
|
|
25
|
+
except SyntaxError as e:
|
|
26
|
+
raise ValueError(f"Syntax error in custom code: {e}") from e
|
|
27
|
+
|
|
28
|
+
for node in ast.walk(tree):
|
|
29
|
+
if isinstance(node, ast.Import):
|
|
30
|
+
for alias in node.names:
|
|
31
|
+
root_module = alias.name.split(".")[0]
|
|
32
|
+
if root_module in FORBIDDEN_IMPORTS:
|
|
33
|
+
raise ValueError(f"Forbidden import: {alias.name}")
|
|
34
|
+
|
|
35
|
+
elif isinstance(node, ast.ImportFrom):
|
|
36
|
+
if node.module:
|
|
37
|
+
root_module = node.module.split(".")[0]
|
|
38
|
+
if root_module in FORBIDDEN_IMPORTS:
|
|
39
|
+
raise ValueError(f"Forbidden import: {node.module}")
|
|
40
|
+
|
|
41
|
+
elif isinstance(node, ast.Call):
|
|
42
|
+
if isinstance(node.func, ast.Name):
|
|
43
|
+
if node.func.id in FORBIDDEN_CALLS:
|
|
44
|
+
raise ValueError(f"Forbidden call: {node.func.id}")
|
|
45
|
+
elif isinstance(node.func, ast.Attribute):
|
|
46
|
+
if node.func.attr in FORBIDDEN_CALLS:
|
|
47
|
+
raise ValueError(f"Forbidden call: {node.func.attr}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def compile_custom(params: dict):
|
|
51
|
+
"""Compile custom transformer code into a class instance.
|
|
52
|
+
|
|
53
|
+
Validates the code via AST, executes it in a restricted namespace,
|
|
54
|
+
and returns an instance of the specified class.
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
params : dict
|
|
59
|
+
Must contain 'code' (str) and 'class_name' (str).
|
|
60
|
+
May contain additional constructor kwargs.
|
|
61
|
+
|
|
62
|
+
Returns
|
|
63
|
+
-------
|
|
64
|
+
transformer : object
|
|
65
|
+
An instance of the custom transformer class.
|
|
66
|
+
"""
|
|
67
|
+
code = params["code"]
|
|
68
|
+
class_name = params["class_name"]
|
|
69
|
+
|
|
70
|
+
validate_code(code)
|
|
71
|
+
|
|
72
|
+
# Execute in a restricted namespace with limited builtins
|
|
73
|
+
# Defense in depth: restrict __builtins__ to prevent class-hierarchy escapes
|
|
74
|
+
safe_builtins = {
|
|
75
|
+
name: __builtins__[name] if isinstance(__builtins__, dict) else getattr(__builtins__, name)
|
|
76
|
+
for name in (
|
|
77
|
+
"__build_class__", # Required for class definitions
|
|
78
|
+
"True", "False", "None", "int", "float", "str", "bool", "list",
|
|
79
|
+
"dict", "tuple", "set", "frozenset", "range", "enumerate", "zip",
|
|
80
|
+
"map", "filter", "sorted", "reversed", "len", "min", "max", "sum",
|
|
81
|
+
"abs", "round", "isinstance", "issubclass", "type", "super",
|
|
82
|
+
"property", "staticmethod", "classmethod", "print", "repr",
|
|
83
|
+
"hasattr", "ValueError", "TypeError", "KeyError", "IndexError",
|
|
84
|
+
"AttributeError", "RuntimeError", "Exception", "NotImplementedError",
|
|
85
|
+
"StopIteration", "object", "slice", "any", "all",
|
|
86
|
+
)
|
|
87
|
+
if (isinstance(__builtins__, dict) and name in __builtins__)
|
|
88
|
+
or (not isinstance(__builtins__, dict) and hasattr(__builtins__, name))
|
|
89
|
+
}
|
|
90
|
+
# Allow __import__ for whitelisted modules only
|
|
91
|
+
safe_builtins["__import__"] = __builtins__["__import__"] if isinstance(__builtins__, dict) else __builtins__.__import__
|
|
92
|
+
|
|
93
|
+
namespace = {
|
|
94
|
+
"__builtins__": safe_builtins,
|
|
95
|
+
"__name__": "__custom_transformer__",
|
|
96
|
+
}
|
|
97
|
+
exec(code, namespace) # noqa: S102 - AST-validated above
|
|
98
|
+
|
|
99
|
+
if class_name not in namespace:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"Class '{class_name}' not found in custom code. "
|
|
102
|
+
f"Available: {[k for k in namespace if not k.startswith('_')]}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
cls = namespace[class_name]
|
|
106
|
+
|
|
107
|
+
# Extract constructor kwargs (exclude code/class_name/description)
|
|
108
|
+
constructor_params = {
|
|
109
|
+
k: v for k, v in params.items()
|
|
110
|
+
if k not in ("code", "class_name", "description")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return cls(**constructor_params)
|
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Cloudpickle-based pipeline serialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import cloudpickle
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from xplainable_preprocessing.pipeline import DataFramePipeline
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def save_pipeline(pipeline: DataFramePipeline) -> bytes:
|
|
14
|
+
"""Serialize a fitted pipeline to bytes."""
|
|
15
|
+
return cloudpickle.dumps(pipeline)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_pipeline(data: bytes) -> DataFramePipeline:
|
|
19
|
+
"""Deserialize a fitted pipeline from bytes."""
|
|
20
|
+
return cloudpickle.loads(data) # noqa: S301 - trusted internal data
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
from xplainable_preprocessing.transformers.category_condense import CategoryCondenseTransformer
|
|
7
|
+
from xplainable_preprocessing.transformers.text_clean import TextCleanTransformer
|
|
8
|
+
from xplainable_preprocessing.transformers.datetime_extract import DateTimeExtractTransformer
|
|
9
|
+
from xplainable_preprocessing.transformers.grouped_lag import GroupedLagTransformer
|
|
10
|
+
from xplainable_preprocessing.transformers.rolling_agg import RollingAggTransformer
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ExpressionTransformer",
|
|
14
|
+
"DropColumnsTransformer",
|
|
15
|
+
"RenameColumnsTransformer",
|
|
16
|
+
"TypeCastTransformer",
|
|
17
|
+
"FillMissingTransformer",
|
|
18
|
+
"CategoryCondenseTransformer",
|
|
19
|
+
"TextCleanTransformer",
|
|
20
|
+
"DateTimeExtractTransformer",
|
|
21
|
+
"GroupedLagTransformer",
|
|
22
|
+
"RollingAggTransformer",
|
|
23
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Category condensing transformer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from sklearn.base import BaseEstimator, TransformerMixin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CategoryCondenseTransformer(BaseEstimator, TransformerMixin):
|
|
10
|
+
"""Collapse low-frequency categories into an 'Other' category.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
max_categories : int
|
|
15
|
+
Maximum number of categories to keep. The rest become `other_label`.
|
|
16
|
+
other_label : str
|
|
17
|
+
Label for condensed categories. Default "Other".
|
|
18
|
+
min_frequency : float | None
|
|
19
|
+
If set, categories with frequency below this fraction are condensed.
|
|
20
|
+
Takes precedence over max_categories.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
max_categories: int = 10,
|
|
26
|
+
other_label: str = "Other",
|
|
27
|
+
min_frequency: float | None = None,
|
|
28
|
+
):
|
|
29
|
+
self.max_categories = max_categories
|
|
30
|
+
self.other_label = other_label
|
|
31
|
+
self.min_frequency = min_frequency
|
|
32
|
+
|
|
33
|
+
def fit(self, X: pd.DataFrame, y=None):
|
|
34
|
+
self.keep_categories_ = {}
|
|
35
|
+
for col in X.columns:
|
|
36
|
+
if X[col].dtype == "object" or isinstance(X[col].dtype, pd.CategoricalDtype):
|
|
37
|
+
counts = X[col].value_counts(normalize=True)
|
|
38
|
+
if self.min_frequency is not None:
|
|
39
|
+
keep = counts[counts >= self.min_frequency].index.tolist()
|
|
40
|
+
else:
|
|
41
|
+
keep = counts.head(self.max_categories).index.tolist()
|
|
42
|
+
self.keep_categories_[col] = set(keep)
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
|
46
|
+
Xt = X.copy()
|
|
47
|
+
for col, keep in self.keep_categories_.items():
|
|
48
|
+
if col in Xt.columns:
|
|
49
|
+
Xt[col] = Xt[col].where(Xt[col].isin(keep), self.other_label)
|
|
50
|
+
return Xt
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Datetime feature extraction transformer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from sklearn.base import BaseEstimator, TransformerMixin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DateTimeExtractTransformer(BaseEstimator, TransformerMixin):
|
|
10
|
+
"""Extract datetime components as new columns.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
components : list[str]
|
|
15
|
+
Components to extract. Options:
|
|
16
|
+
"year", "month", "day", "hour", "minute", "second",
|
|
17
|
+
"dayofweek", "dayofyear", "weekofyear", "quarter",
|
|
18
|
+
"is_weekend", "is_month_start", "is_month_end"
|
|
19
|
+
drop_original : bool
|
|
20
|
+
Whether to drop the original datetime columns. Default True.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
COMPONENT_MAP = {
|
|
24
|
+
"year": lambda s: s.dt.year,
|
|
25
|
+
"month": lambda s: s.dt.month,
|
|
26
|
+
"day": lambda s: s.dt.day,
|
|
27
|
+
"hour": lambda s: s.dt.hour,
|
|
28
|
+
"minute": lambda s: s.dt.minute,
|
|
29
|
+
"second": lambda s: s.dt.second,
|
|
30
|
+
"dayofweek": lambda s: s.dt.dayofweek,
|
|
31
|
+
"dayofyear": lambda s: s.dt.dayofyear,
|
|
32
|
+
"weekofyear": lambda s: s.dt.isocalendar().week.astype(int),
|
|
33
|
+
"quarter": lambda s: s.dt.quarter,
|
|
34
|
+
"is_weekend": lambda s: s.dt.dayofweek.isin([5, 6]).astype(int),
|
|
35
|
+
"is_month_start": lambda s: s.dt.is_month_start.astype(int),
|
|
36
|
+
"is_month_end": lambda s: s.dt.is_month_end.astype(int),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
components: list[str] | None = None,
|
|
42
|
+
drop_original: bool = True,
|
|
43
|
+
):
|
|
44
|
+
self.components = components or ["year", "month", "day"]
|
|
45
|
+
self.drop_original = drop_original
|
|
46
|
+
|
|
47
|
+
def fit(self, X: pd.DataFrame, y=None):
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
|
51
|
+
Xt = X.copy()
|
|
52
|
+
original_cols = list(Xt.columns)
|
|
53
|
+
|
|
54
|
+
for col in original_cols:
|
|
55
|
+
if not pd.api.types.is_datetime64_any_dtype(Xt[col]):
|
|
56
|
+
try:
|
|
57
|
+
Xt[col] = pd.to_datetime(Xt[col], errors="coerce")
|
|
58
|
+
except Exception:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
if not pd.api.types.is_datetime64_any_dtype(Xt[col]):
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
for component in self.components:
|
|
65
|
+
extractor = self.COMPONENT_MAP.get(component)
|
|
66
|
+
if extractor:
|
|
67
|
+
Xt[f"{col}_{component}"] = extractor(Xt[col])
|
|
68
|
+
|
|
69
|
+
if self.drop_original:
|
|
70
|
+
Xt = Xt.drop(columns=[col])
|
|
71
|
+
|
|
72
|
+
return Xt
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Drop columns transformer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from sklearn.base import BaseEstimator, TransformerMixin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DropColumnsTransformer(BaseEstimator, TransformerMixin):
|
|
10
|
+
"""Drop specified columns from a DataFrame.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
columns : list[str]
|
|
15
|
+
Columns to drop.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, columns: list[str] | None = None):
|
|
19
|
+
self.columns = columns or []
|
|
20
|
+
|
|
21
|
+
def fit(self, X: pd.DataFrame, y=None):
|
|
22
|
+
return self
|
|
23
|
+
|
|
24
|
+
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
|
25
|
+
return X.drop(columns=self.columns, errors="ignore")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Expression-based transformer using pandas.eval()."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from sklearn.base import BaseEstimator, TransformerMixin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ExpressionTransformer(BaseEstimator, TransformerMixin):
|
|
10
|
+
"""Create new columns using pandas.eval() expressions.
|
|
11
|
+
|
|
12
|
+
Uses pandas.eval() for safe expression evaluation without exec/eval.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
expression : str
|
|
17
|
+
A pandas-compatible expression (e.g. "age * salary / 1000").
|
|
18
|
+
output_column : str
|
|
19
|
+
Name of the new column to create.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, expression: str = "", output_column: str = ""):
|
|
23
|
+
self.expression = expression
|
|
24
|
+
self.output_column = output_column
|
|
25
|
+
|
|
26
|
+
def fit(self, X: pd.DataFrame, y=None):
|
|
27
|
+
return self
|
|
28
|
+
|
|
29
|
+
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
|
30
|
+
Xt = X.copy()
|
|
31
|
+
# Support both bare column names ("age * salary") and df reference ("df['age'] * df['salary']")
|
|
32
|
+
local_dict = {col: Xt[col] for col in Xt.columns}
|
|
33
|
+
local_dict["df"] = Xt
|
|
34
|
+
Xt[self.output_column] = pd.eval(self.expression, local_dict=local_dict, engine="python")
|
|
35
|
+
return Xt
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""DataFrame-aware missing value fill transformer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from sklearn.base import BaseEstimator, TransformerMixin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FillMissingTransformer(BaseEstimator, TransformerMixin):
|
|
10
|
+
"""Fill missing values with per-column strategies.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
strategies : dict[str, str | int | float]
|
|
15
|
+
Column name -> fill strategy. Strategy can be:
|
|
16
|
+
- "mean": fill with column mean (numeric only)
|
|
17
|
+
- "median": fill with column median (numeric only)
|
|
18
|
+
- "mode": fill with most frequent value
|
|
19
|
+
- "ffill": forward fill
|
|
20
|
+
- "bfill": backward fill
|
|
21
|
+
- Any scalar value: fill with that constant
|
|
22
|
+
default : str | int | float | None
|
|
23
|
+
Default strategy for columns not in strategies dict.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
strategies: dict | None = None,
|
|
29
|
+
default: str | int | float | None = None,
|
|
30
|
+
):
|
|
31
|
+
self.strategies = strategies or {}
|
|
32
|
+
self.default = default
|
|
33
|
+
|
|
34
|
+
def fit(self, X: pd.DataFrame, y=None):
|
|
35
|
+
self.fill_values_ = {}
|
|
36
|
+
for col, strategy in self.strategies.items():
|
|
37
|
+
if col not in X.columns:
|
|
38
|
+
continue
|
|
39
|
+
if strategy == "mean":
|
|
40
|
+
self.fill_values_[col] = X[col].mean()
|
|
41
|
+
elif strategy == "median":
|
|
42
|
+
self.fill_values_[col] = X[col].median()
|
|
43
|
+
elif strategy == "mode":
|
|
44
|
+
mode = X[col].mode()
|
|
45
|
+
self.fill_values_[col] = mode.iloc[0] if len(mode) > 0 else None
|
|
46
|
+
# ffill/bfill and constants don't need fitting
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
|
50
|
+
Xt = X.copy()
|
|
51
|
+
for col, strategy in self.strategies.items():
|
|
52
|
+
if col not in Xt.columns:
|
|
53
|
+
continue
|
|
54
|
+
if strategy in ("mean", "median", "mode"):
|
|
55
|
+
Xt[col] = Xt[col].fillna(self.fill_values_.get(col))
|
|
56
|
+
elif strategy == "ffill":
|
|
57
|
+
Xt[col] = Xt[col].ffill()
|
|
58
|
+
elif strategy == "bfill":
|
|
59
|
+
Xt[col] = Xt[col].bfill()
|
|
60
|
+
else:
|
|
61
|
+
# Treat as constant value
|
|
62
|
+
Xt[col] = Xt[col].fillna(strategy)
|
|
63
|
+
|
|
64
|
+
if self.default is not None:
|
|
65
|
+
for col in Xt.columns:
|
|
66
|
+
if col not in self.strategies:
|
|
67
|
+
if self.default in ("ffill", "bfill"):
|
|
68
|
+
Xt[col] = getattr(Xt[col], self.default)()
|
|
69
|
+
else:
|
|
70
|
+
Xt[col] = Xt[col].fillna(self.default)
|
|
71
|
+
return Xt
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Grouped lag feature transformer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from sklearn.base import BaseEstimator, TransformerMixin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GroupedLagTransformer(BaseEstimator, TransformerMixin):
|
|
10
|
+
"""Create lag features within groups.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
columns : list[str]
|
|
15
|
+
Columns to create lag features for.
|
|
16
|
+
group_by : list[str]
|
|
17
|
+
Columns to group by.
|
|
18
|
+
order_by : str
|
|
19
|
+
Column to sort by within groups before computing lags.
|
|
20
|
+
periods : list[int]
|
|
21
|
+
Lag periods to create. E.g. [1, 7, 30].
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
columns: list[str] | None = None,
|
|
27
|
+
group_by: list[str] | None = None,
|
|
28
|
+
order_by: str = "",
|
|
29
|
+
periods: list[int] | None = None,
|
|
30
|
+
):
|
|
31
|
+
self.columns = columns or []
|
|
32
|
+
self.group_by = group_by or []
|
|
33
|
+
self.order_by = order_by
|
|
34
|
+
self.periods = periods or [1]
|
|
35
|
+
|
|
36
|
+
def fit(self, X: pd.DataFrame, y=None):
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
|
40
|
+
Xt = X.copy()
|
|
41
|
+
|
|
42
|
+
if self.order_by and self.order_by in Xt.columns:
|
|
43
|
+
Xt = Xt.sort_values(self.group_by + [self.order_by])
|
|
44
|
+
|
|
45
|
+
for col in self.columns:
|
|
46
|
+
if col not in Xt.columns:
|
|
47
|
+
continue
|
|
48
|
+
for period in self.periods:
|
|
49
|
+
new_col = f"{col}_lag_{period}"
|
|
50
|
+
if self.group_by:
|
|
51
|
+
Xt[new_col] = Xt.groupby(self.group_by)[col].shift(period)
|
|
52
|
+
else:
|
|
53
|
+
Xt[new_col] = Xt[col].shift(period)
|
|
54
|
+
|
|
55
|
+
return Xt
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Rename columns transformer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from sklearn.base import BaseEstimator, TransformerMixin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RenameColumnsTransformer(BaseEstimator, TransformerMixin):
|
|
10
|
+
"""Rename columns in a DataFrame.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
mapping : dict[str, str]
|
|
15
|
+
Old name -> new name mapping.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, mapping: dict[str, str] | None = None):
|
|
19
|
+
self.mapping = mapping or {}
|
|
20
|
+
|
|
21
|
+
def fit(self, X: pd.DataFrame, y=None):
|
|
22
|
+
return self
|
|
23
|
+
|
|
24
|
+
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
|
25
|
+
return X.rename(columns=self.mapping)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Rolling window aggregation transformer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from sklearn.base import BaseEstimator, TransformerMixin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RollingAggTransformer(BaseEstimator, TransformerMixin):
|
|
10
|
+
"""Create rolling window aggregate features.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
columns : list[str]
|
|
15
|
+
Columns to compute rolling aggregates for.
|
|
16
|
+
group_by : list[str] | None
|
|
17
|
+
Columns to group by. If None, computes globally.
|
|
18
|
+
window : int
|
|
19
|
+
Rolling window size.
|
|
20
|
+
operation : str
|
|
21
|
+
Aggregation operation: "mean", "sum", "min", "max", "std", "count".
|
|
22
|
+
min_periods : int
|
|
23
|
+
Minimum number of observations required. Default 1.
|
|
24
|
+
order_by : str
|
|
25
|
+
Column to sort by before computing rolling stats.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
OPERATIONS = {"mean", "sum", "min", "max", "std", "count", "median"}
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
columns: list[str] | None = None,
|
|
33
|
+
group_by: list[str] | None = None,
|
|
34
|
+
window: int = 7,
|
|
35
|
+
operation: str = "mean",
|
|
36
|
+
min_periods: int = 1,
|
|
37
|
+
order_by: str = "",
|
|
38
|
+
):
|
|
39
|
+
self.columns = columns or []
|
|
40
|
+
self.group_by = group_by
|
|
41
|
+
self.window = window
|
|
42
|
+
self.operation = operation
|
|
43
|
+
self.min_periods = min_periods
|
|
44
|
+
self.order_by = order_by
|
|
45
|
+
|
|
46
|
+
def fit(self, X: pd.DataFrame, y=None):
|
|
47
|
+
if self.operation not in self.OPERATIONS:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"Unknown operation '{self.operation}'. "
|
|
50
|
+
f"Available: {sorted(self.OPERATIONS)}"
|
|
51
|
+
)
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
|
55
|
+
Xt = X.copy()
|
|
56
|
+
|
|
57
|
+
if self.order_by and self.order_by in Xt.columns:
|
|
58
|
+
sort_cols = (self.group_by or []) + [self.order_by]
|
|
59
|
+
Xt = Xt.sort_values(sort_cols)
|
|
60
|
+
|
|
61
|
+
for col in self.columns:
|
|
62
|
+
if col not in Xt.columns:
|
|
63
|
+
continue
|
|
64
|
+
new_col = f"{col}_rolling_{self.operation}_{self.window}"
|
|
65
|
+
if self.group_by:
|
|
66
|
+
rolling = (
|
|
67
|
+
Xt.groupby(self.group_by)[col]
|
|
68
|
+
.rolling(window=self.window, min_periods=self.min_periods)
|
|
69
|
+
)
|
|
70
|
+
Xt[new_col] = getattr(rolling, self.operation)().reset_index(
|
|
71
|
+
level=list(range(len(self.group_by))), drop=True
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
rolling = Xt[col].rolling(
|
|
75
|
+
window=self.window, min_periods=self.min_periods
|
|
76
|
+
)
|
|
77
|
+
Xt[new_col] = getattr(rolling, self.operation)()
|
|
78
|
+
|
|
79
|
+
return Xt
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Text cleaning transformer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from sklearn.base import BaseEstimator, TransformerMixin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TextCleanTransformer(BaseEstimator, TransformerMixin):
|
|
12
|
+
"""Clean text columns with configurable operations.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
operations : list[str]
|
|
17
|
+
Ordered list of operations to apply. Options:
|
|
18
|
+
- "lowercase": convert to lowercase
|
|
19
|
+
- "uppercase": convert to uppercase
|
|
20
|
+
- "strip": strip whitespace
|
|
21
|
+
- "remove_digits": remove all digits
|
|
22
|
+
- "remove_punctuation": remove punctuation
|
|
23
|
+
- "remove_extra_whitespace": collapse multiple spaces
|
|
24
|
+
- "remove_html": strip HTML tags
|
|
25
|
+
regex_pattern : str | None
|
|
26
|
+
Optional regex pattern to remove.
|
|
27
|
+
regex_replacement : str
|
|
28
|
+
Replacement for regex matches. Default "".
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
operations: list[str] | None = None,
|
|
34
|
+
regex_pattern: str | None = None,
|
|
35
|
+
regex_replacement: str = "",
|
|
36
|
+
):
|
|
37
|
+
self.operations = operations or []
|
|
38
|
+
self.regex_pattern = regex_pattern
|
|
39
|
+
self.regex_replacement = regex_replacement
|
|
40
|
+
|
|
41
|
+
def fit(self, X: pd.DataFrame, y=None):
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
|
45
|
+
Xt = X.copy()
|
|
46
|
+
for col in Xt.columns:
|
|
47
|
+
if Xt[col].dtype != "object":
|
|
48
|
+
continue
|
|
49
|
+
series = Xt[col]
|
|
50
|
+
for op in self.operations:
|
|
51
|
+
if op == "lowercase":
|
|
52
|
+
series = series.str.lower()
|
|
53
|
+
elif op == "uppercase":
|
|
54
|
+
series = series.str.upper()
|
|
55
|
+
elif op == "strip":
|
|
56
|
+
series = series.str.strip()
|
|
57
|
+
elif op == "remove_digits":
|
|
58
|
+
series = series.str.replace(r"\d+", "", regex=True)
|
|
59
|
+
elif op == "remove_punctuation":
|
|
60
|
+
series = series.str.replace(r"[^\w\s]", "", regex=True)
|
|
61
|
+
elif op == "remove_extra_whitespace":
|
|
62
|
+
series = series.str.replace(r"\s+", " ", regex=True).str.strip()
|
|
63
|
+
elif op == "remove_html":
|
|
64
|
+
series = series.str.replace(r"<[^>]+>", "", regex=True)
|
|
65
|
+
if self.regex_pattern:
|
|
66
|
+
series = series.str.replace(
|
|
67
|
+
self.regex_pattern, self.regex_replacement, regex=True
|
|
68
|
+
)
|
|
69
|
+
Xt[col] = series
|
|
70
|
+
return Xt
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Type casting transformer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from sklearn.base import BaseEstimator, TransformerMixin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TypeCastTransformer(BaseEstimator, TransformerMixin):
|
|
10
|
+
"""Cast column dtypes in a DataFrame.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
dtypes : dict[str, str]
|
|
15
|
+
Column name -> target dtype mapping.
|
|
16
|
+
E.g. {"age": "float64", "name": "string"}
|
|
17
|
+
errors : str
|
|
18
|
+
How to handle casting errors. Default "coerce" (invalid -> NaN).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, dtypes: dict[str, str] | None = None, errors: str = "coerce"):
|
|
22
|
+
self.dtypes = dtypes or {}
|
|
23
|
+
self.errors = errors
|
|
24
|
+
|
|
25
|
+
def fit(self, X: pd.DataFrame, y=None):
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
|
29
|
+
Xt = X.copy()
|
|
30
|
+
for col, dtype in self.dtypes.items():
|
|
31
|
+
if col in Xt.columns:
|
|
32
|
+
if dtype in ("int", "int64", "int32", "float", "float64", "float32"):
|
|
33
|
+
Xt[col] = pd.to_numeric(Xt[col], errors=self.errors)
|
|
34
|
+
if dtype.startswith("int") and self.errors == "coerce":
|
|
35
|
+
Xt[col] = Xt[col].astype("Int64")
|
|
36
|
+
else:
|
|
37
|
+
Xt[col] = Xt[col].astype(dtype)
|
|
38
|
+
elif dtype in ("datetime", "datetime64[ns]"):
|
|
39
|
+
Xt[col] = pd.to_datetime(Xt[col], errors=self.errors)
|
|
40
|
+
else:
|
|
41
|
+
Xt[col] = Xt[col].astype(dtype)
|
|
42
|
+
return Xt
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xplainable-preprocessing
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared preprocessing pipeline package for xplainable
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Requires-Dist: cloudpickle>=3.0
|
|
7
|
+
Requires-Dist: numpy>=1.24
|
|
8
|
+
Requires-Dist: pandas>=2.0
|
|
9
|
+
Requires-Dist: pydantic>=2.0
|
|
10
|
+
Requires-Dist: scikit-learn>=1.3
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
xplainable_preprocessing/__init__.py,sha256=2y36kqaTg6eZNDqX6inCTOJIg8eh2uB3CIvBM3WBMM4,688
|
|
2
|
+
xplainable_preprocessing/compiler.py,sha256=AlyPGGdrcJ-4zHGkrNnaYRC_NimgrXSL5L82YXrlmBQ,2172
|
|
3
|
+
xplainable_preprocessing/pipeline.py,sha256=Anve7vAeLRGqr1HR1OfHrupNexHedz8GvBlj_3jK028,3161
|
|
4
|
+
xplainable_preprocessing/preview.py,sha256=kOzGPPvNTmH65Je9urLuTKVgEy9lbLWJsYHgqxZs8u8,3662
|
|
5
|
+
xplainable_preprocessing/registry.py,sha256=MYvj7jiBpk3WL6BoWzuJdOTOOy_8CldZo0oJyRaiJOI,3325
|
|
6
|
+
xplainable_preprocessing/sandbox.py,sha256=e0SkK6atbioDw5mcIedije0CC00YdnjlrTgg8Bd-GB4,4308
|
|
7
|
+
xplainable_preprocessing/schema.py,sha256=EhqBJCIKAK7j93tJ3YgjK92qV8budXHzBfH6H04VTAs,2220
|
|
8
|
+
xplainable_preprocessing/serialization.py,sha256=tyom9Wlbpre8ryAjjGUNuwyvh36qeyrv-JJHmlGror8,551
|
|
9
|
+
xplainable_preprocessing/transformers/__init__.py,sha256=eTiTgQu_OeQ3KjmjhH7g4g3T07u67tF2B6CFT1U_ahU,1183
|
|
10
|
+
xplainable_preprocessing/transformers/category_condense.py,sha256=adaXy6_zb4Iq87zar7yHZ8Pb2lR1r3xUo-_h7TvMsDA,1777
|
|
11
|
+
xplainable_preprocessing/transformers/datetime_extract.py,sha256=q2Yp6V8jX3yC1N9mcJRaNuUFOGqF5QEsOxpw8mg36CY,2395
|
|
12
|
+
xplainable_preprocessing/transformers/drop_columns.py,sha256=K8A2b2wWAGtizFs3KPsrP-4TctS_oJ5viRMXSWZMDwY,628
|
|
13
|
+
xplainable_preprocessing/transformers/expression.py,sha256=xjXL-pN8ypqX3bWbTMS-nTKIcjPqYaCj7cr6imutrPM,1168
|
|
14
|
+
xplainable_preprocessing/transformers/fill_missing.py,sha256=3vP-uK5MIIWwRbx4eDmfjocxmfJfDIjliH2eaJOCO5U,2569
|
|
15
|
+
xplainable_preprocessing/transformers/grouped_lag.py,sha256=Q0KCSdLERJZoUPI26oNkwSovEsz5ua8fONYNulmiFow,1581
|
|
16
|
+
xplainable_preprocessing/transformers/rename_columns.py,sha256=HYEurKTxa2n4IZ1Ta9DGwCeV6hsr9NgqJYKAjORoM-s,630
|
|
17
|
+
xplainable_preprocessing/transformers/rolling_agg.py,sha256=y_lz2Qkj2JVTcap4mRkTB_bnfrsing5fCGhxXZ_nnP4,2588
|
|
18
|
+
xplainable_preprocessing/transformers/text_clean.py,sha256=At1olQy0mBSQt65QPJnGlPAUQDEIsWbrBe0rYqloJmg,2461
|
|
19
|
+
xplainable_preprocessing/transformers/type_cast.py,sha256=l7DIrAAI0Ds2Eyng4gIctwy3mn1K3AqZAesa_lW_FMk,1483
|
|
20
|
+
xplainable_preprocessing-0.1.0.dist-info/METADATA,sha256=cNlOGuS5DJQp_geFD3hYnIrKKmFR-EBMa-9df4LrUW4,406
|
|
21
|
+
xplainable_preprocessing-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
22
|
+
xplainable_preprocessing-0.1.0.dist-info/RECORD,,
|