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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any