frameprep 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.
- frameprep/__init__.py +27 -0
- frameprep/core/__init__.py +4 -0
- frameprep/core/dtype_inferrer.py +238 -0
- frameprep/core/missing_strategy.py +260 -0
- frameprep/detectors/__init__.py +3 -0
- frameprep/detectors/leakage.py +168 -0
- frameprep/encoders/__init__.py +3 -0
- frameprep/encoders/categorical.py +201 -0
- frameprep/pipeline.py +222 -0
- frameprep/reports/__init__.py +3 -0
- frameprep/reports/audit.py +221 -0
- frameprep/scalers/__init__.py +3 -0
- frameprep/scalers/numeric.py +110 -0
- frameprep/utils/__init__.py +4 -0
- frameprep/utils/logger.py +41 -0
- frameprep/utils/validators.py +63 -0
- frameprep-0.1.0.dist-info/METADATA +292 -0
- frameprep-0.1.0.dist-info/RECORD +21 -0
- frameprep-0.1.0.dist-info/WHEEL +5 -0
- frameprep-0.1.0.dist-info/licenses/LICENSE +21 -0
- frameprep-0.1.0.dist-info/top_level.txt +1 -0
frameprep/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
frameprep — Automated ML-Ready Data Preparation Library
|
|
3
|
+
=======================================================
|
|
4
|
+
A single pipeline object that ingests a raw DataFrame and outputs a fully
|
|
5
|
+
ML-ready dataset: dtype inference, missing value imputation, encoding,
|
|
6
|
+
scaling, leakage detection, and audit reporting.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from frameprep.pipeline import frameprepPipeline
|
|
10
|
+
from frameprep.core.dtype_inferrer import DTypeInferrer
|
|
11
|
+
from frameprep.core.missing_strategy import MissingValueHandler
|
|
12
|
+
from frameprep.encoders.categorical import CategoricalEncoder
|
|
13
|
+
from frameprep.scalers.numeric import NumericScaler
|
|
14
|
+
from frameprep.detectors.leakage import LeakageDetector
|
|
15
|
+
from frameprep.reports.audit import AuditReport
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
__author__ = "frameprep contributors"
|
|
19
|
+
__all__ = [
|
|
20
|
+
"frameprepPipeline",
|
|
21
|
+
"DTypeInferrer",
|
|
22
|
+
"MissingValueHandler",
|
|
23
|
+
"CategoricalEncoder",
|
|
24
|
+
"NumericScaler",
|
|
25
|
+
"LeakageDetector",
|
|
26
|
+
"AuditReport",
|
|
27
|
+
]
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module 1 — Smart dtype inference + memory optimisation
|
|
3
|
+
-------------------------------------------------------
|
|
4
|
+
- Detects true column types from raw data (int stored as float, bool stored
|
|
5
|
+
as object, datetime strings, IDs, etc.)
|
|
6
|
+
- Downcasts numerics to the smallest type that fits the data
|
|
7
|
+
- Converts object columns that are actually categorical to pd.Categorical
|
|
8
|
+
- Records every change in self.report_
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from typing import Dict, Any, cast
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
from frameprep.utils.logger import get_logger
|
|
20
|
+
|
|
21
|
+
logger = get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
# Patterns for datetime detection
|
|
24
|
+
_DATETIME_PATTERNS = [
|
|
25
|
+
r"\d{4}-\d{2}-\d{2}", # 2023-01-15
|
|
26
|
+
r"\d{2}/\d{2}/\d{4}", # 15/01/2023
|
|
27
|
+
r"\d{4}/\d{2}/\d{2}", # 2023/01/15
|
|
28
|
+
r"\d{2}-\w{3}-\d{4}", # 15-Jan-2023
|
|
29
|
+
]
|
|
30
|
+
_DT_RE = re.compile("|".join(_DATETIME_PATTERNS))
|
|
31
|
+
|
|
32
|
+
# Fraction of rows that must match pattern to call it datetime
|
|
33
|
+
_DATETIME_SAMPLE_THRESHOLD = 0.8
|
|
34
|
+
# If unique values / total rows < this, treat as categorical
|
|
35
|
+
_CARDINALITY_RATIO = 0.05
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DTypeInferrer:
|
|
39
|
+
"""
|
|
40
|
+
Infer and optimise dtypes for every column in a DataFrame.
|
|
41
|
+
|
|
42
|
+
After fit_transform, self.report_ contains:
|
|
43
|
+
{
|
|
44
|
+
"column_name": {
|
|
45
|
+
"original_dtype": ...,
|
|
46
|
+
"inferred_dtype": ...,
|
|
47
|
+
"memory_before_kb": ...,
|
|
48
|
+
"memory_after_kb": ...,
|
|
49
|
+
},
|
|
50
|
+
...
|
|
51
|
+
"__summary__": {
|
|
52
|
+
"total_memory_before_kb": ...,
|
|
53
|
+
"total_memory_after_kb": ...,
|
|
54
|
+
"savings_pct": ...,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
self.report_: Dict[str, Any] = {}
|
|
61
|
+
self._col_dtypes: Dict[str, str] = {} # for transform()
|
|
62
|
+
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
# Public
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def fit_transform(self, df: pd.DataFrame) -> pd.DataFrame:
|
|
68
|
+
df = df.copy()
|
|
69
|
+
mem_before = self._col_memory_kb(df)
|
|
70
|
+
col_reports: Dict[str, Any] = {}
|
|
71
|
+
|
|
72
|
+
for col in df.columns:
|
|
73
|
+
orig_dtype = str(df[col].dtype)
|
|
74
|
+
# Explicitly cast to pd.Series so the type checker knows the
|
|
75
|
+
# column accessor returns a Series, not Series | DataFrame.
|
|
76
|
+
series: pd.Series = cast(pd.Series, df[col])
|
|
77
|
+
df[col], new_dtype = self._infer_col(series)
|
|
78
|
+
self._col_dtypes[col] = new_dtype
|
|
79
|
+
col_reports[col] = {
|
|
80
|
+
"original_dtype": orig_dtype,
|
|
81
|
+
"inferred_dtype": new_dtype,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
mem_after = self._col_memory_kb(df)
|
|
85
|
+
total_before = sum(mem_before.values())
|
|
86
|
+
total_after = sum(mem_after.values())
|
|
87
|
+
|
|
88
|
+
for col in col_reports:
|
|
89
|
+
col_reports[col]["memory_before_kb"] = round(mem_before.get(col, 0), 3)
|
|
90
|
+
col_reports[col]["memory_after_kb"] = round(mem_after.get(col, 0), 3)
|
|
91
|
+
|
|
92
|
+
savings = (
|
|
93
|
+
(total_before - total_after) / total_before * 100
|
|
94
|
+
if total_before > 0
|
|
95
|
+
else 0.0
|
|
96
|
+
)
|
|
97
|
+
col_reports["__summary__"] = {
|
|
98
|
+
"total_memory_before_kb": round(total_before, 3),
|
|
99
|
+
"total_memory_after_kb": round(total_after, 3),
|
|
100
|
+
"savings_pct": round(savings, 2),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
self.report_ = col_reports
|
|
104
|
+
logger.debug(
|
|
105
|
+
f"Memory optimisation: {total_before:.1f} KB → "
|
|
106
|
+
f"{total_after:.1f} KB ({savings:.1f}% saved)"
|
|
107
|
+
)
|
|
108
|
+
return df
|
|
109
|
+
|
|
110
|
+
def transform(self, df: pd.DataFrame) -> pd.DataFrame:
|
|
111
|
+
"""Apply previously inferred dtypes to new data."""
|
|
112
|
+
df = df.copy()
|
|
113
|
+
for col, dtype in self._col_dtypes.items():
|
|
114
|
+
if col not in df.columns:
|
|
115
|
+
continue
|
|
116
|
+
try:
|
|
117
|
+
if dtype == "datetime64[ns]":
|
|
118
|
+
df[col] = pd.to_datetime(df[col], errors="coerce")
|
|
119
|
+
elif dtype == "category":
|
|
120
|
+
df[col] = df[col].astype("category")
|
|
121
|
+
elif dtype == "boolean":
|
|
122
|
+
df[col] = df[col].astype("boolean")
|
|
123
|
+
else:
|
|
124
|
+
df[col] = df[col].astype(dtype, errors="ignore") # type: ignore[arg-type]
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
return df
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# Private
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def _infer_col(self, series: pd.Series) -> tuple[pd.Series, str]:
|
|
134
|
+
"""Return (transformed_series, dtype_string)."""
|
|
135
|
+
|
|
136
|
+
if pd.api.types.is_datetime64_any_dtype(series):
|
|
137
|
+
return series, "datetime64[ns]"
|
|
138
|
+
|
|
139
|
+
if pd.api.types.is_bool_dtype(series):
|
|
140
|
+
return series.astype("boolean"), "boolean"
|
|
141
|
+
|
|
142
|
+
if pd.api.types.is_float_dtype(series):
|
|
143
|
+
return self._try_downcast_float(series)
|
|
144
|
+
|
|
145
|
+
if pd.api.types.is_integer_dtype(series):
|
|
146
|
+
return self._try_downcast_int(series)
|
|
147
|
+
|
|
148
|
+
# Handle both legacy object dtype and pandas 2.x StringDtype
|
|
149
|
+
if pd.api.types.is_object_dtype(series) or isinstance(
|
|
150
|
+
series.dtype, pd.StringDtype
|
|
151
|
+
):
|
|
152
|
+
return self._infer_object_col(series)
|
|
153
|
+
|
|
154
|
+
return series, str(series.dtype)
|
|
155
|
+
|
|
156
|
+
def _infer_object_col(
|
|
157
|
+
self, series: pd.Series
|
|
158
|
+
) -> tuple[pd.Series, str]:
|
|
159
|
+
"""Handle string/object columns."""
|
|
160
|
+
non_null = series.dropna()
|
|
161
|
+
if len(non_null) == 0:
|
|
162
|
+
return series, "object"
|
|
163
|
+
|
|
164
|
+
# Boolean strings
|
|
165
|
+
bool_map = {
|
|
166
|
+
"true": True, "false": False,
|
|
167
|
+
"yes": True, "no": False,
|
|
168
|
+
"1": True, "0": False,
|
|
169
|
+
}
|
|
170
|
+
lower_vals = non_null.astype(str).str.lower().unique()
|
|
171
|
+
if set(lower_vals).issubset(bool_map.keys()):
|
|
172
|
+
mapped = series.astype(str).str.lower().map(bool_map)
|
|
173
|
+
return mapped.astype("boolean"), "boolean"
|
|
174
|
+
|
|
175
|
+
# Datetime strings
|
|
176
|
+
sample = non_null.astype(str).head(200)
|
|
177
|
+
dt_matches = sample.apply(lambda v: bool(_DT_RE.search(v))).mean()
|
|
178
|
+
if dt_matches >= _DATETIME_SAMPLE_THRESHOLD:
|
|
179
|
+
converted = pd.to_datetime(series, errors="coerce")
|
|
180
|
+
if converted.notna().mean() >= _DATETIME_SAMPLE_THRESHOLD:
|
|
181
|
+
return converted, "datetime64[ns]"
|
|
182
|
+
|
|
183
|
+
# Numeric strings
|
|
184
|
+
# pd.to_numeric return type in the stubs is ambiguous (float | Series |
|
|
185
|
+
# DataFrame), so we cast explicitly to pd.Series to keep the type
|
|
186
|
+
# checker happy and get proper Series method access.
|
|
187
|
+
converted_num: pd.Series = pd.Series(
|
|
188
|
+
pd.to_numeric(series, errors="coerce"), index=series.index
|
|
189
|
+
)
|
|
190
|
+
if converted_num.notna().mean() >= 0.9:
|
|
191
|
+
if bool((converted_num.dropna() % 1 == 0).all()):
|
|
192
|
+
int_series: pd.Series = converted_num.astype("Int64")
|
|
193
|
+
return self._try_downcast_int(int_series)
|
|
194
|
+
return self._try_downcast_float(converted_num)
|
|
195
|
+
|
|
196
|
+
# Low-cardinality → categorical
|
|
197
|
+
ratio = series.nunique(dropna=True) / max(len(series), 1)
|
|
198
|
+
if ratio <= _CARDINALITY_RATIO:
|
|
199
|
+
return series.astype("category"), "category"
|
|
200
|
+
|
|
201
|
+
return series, "object"
|
|
202
|
+
|
|
203
|
+
def _try_downcast_float(
|
|
204
|
+
self, series: pd.Series
|
|
205
|
+
) -> tuple[pd.Series, str]:
|
|
206
|
+
"""Downcast float64 → float32 where safe."""
|
|
207
|
+
if pd.api.types.is_float_dtype(series):
|
|
208
|
+
s32: pd.Series = series.astype("float32")
|
|
209
|
+
# Tolerance check: max relative error < 1e-5
|
|
210
|
+
mask: pd.Series = series.notna()
|
|
211
|
+
if mask.any():
|
|
212
|
+
orig = np.asarray(series[mask], dtype="float64")
|
|
213
|
+
down = np.asarray(s32[mask], dtype="float64")
|
|
214
|
+
rel_err = np.abs(orig - down) / (np.abs(orig) + 1e-10)
|
|
215
|
+
if rel_err.max() < 1e-5:
|
|
216
|
+
return s32, "float32"
|
|
217
|
+
return series, str(series.dtype)
|
|
218
|
+
|
|
219
|
+
def _try_downcast_int(
|
|
220
|
+
self, series: pd.Series
|
|
221
|
+
) -> tuple[pd.Series, str]:
|
|
222
|
+
"""Downcast int to smallest signed type that fits."""
|
|
223
|
+
try:
|
|
224
|
+
# pd.to_numeric downcast returns ambiguous type in stubs;
|
|
225
|
+
# wrap in pd.Series to narrow the type explicitly.
|
|
226
|
+
downcasted: pd.Series = pd.Series(
|
|
227
|
+
pd.to_numeric(series, downcast="integer"), index=series.index
|
|
228
|
+
)
|
|
229
|
+
return downcasted, str(downcasted.dtype)
|
|
230
|
+
except Exception:
|
|
231
|
+
return series, str(series.dtype)
|
|
232
|
+
|
|
233
|
+
@staticmethod
|
|
234
|
+
def _col_memory_kb(df: pd.DataFrame) -> Dict[str, float]:
|
|
235
|
+
return {
|
|
236
|
+
col: df[col].memory_usage(deep=True) / 1024
|
|
237
|
+
for col in df.columns
|
|
238
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module 2 — Missing value strategy selector
|
|
3
|
+
-------------------------------------------
|
|
4
|
+
Uses statistical tests to pick the best imputation method per column:
|
|
5
|
+
|
|
6
|
+
Numeric columns
|
|
7
|
+
---------------
|
|
8
|
+
• If missingness < 1 % → median fill (fast, safe)
|
|
9
|
+
• Run Little's MCAR test proxy (chi-square on missingness indicator vs
|
|
10
|
+
other columns). If MCAR → mean/median. If MAR → KNN imputer.
|
|
11
|
+
• If skewness |skew| > 1 → median, else → mean
|
|
12
|
+
• If high missingness (> 40 %) → flag + constant fill
|
|
13
|
+
|
|
14
|
+
Categorical / boolean columns
|
|
15
|
+
------------------------------
|
|
16
|
+
• Mode fill if missingness < 30 %
|
|
17
|
+
• Otherwise add "__missing__" sentinel category
|
|
18
|
+
|
|
19
|
+
Records every decision in self.report_.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any, Dict, List, Optional, cast
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
import pandas as pd
|
|
28
|
+
from sklearn.impute import KNNImputer, SimpleImputer
|
|
29
|
+
|
|
30
|
+
from frameprep.utils.logger import get_logger
|
|
31
|
+
|
|
32
|
+
logger = get_logger(__name__)
|
|
33
|
+
|
|
34
|
+
_HIGH_MISSING_THRESHOLD = 0.40
|
|
35
|
+
_LOW_MISSING_THRESHOLD = 0.01
|
|
36
|
+
_MAR_CORR_THRESHOLD = 0.10 # missingness indicator correlation
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MissingValueHandler:
|
|
40
|
+
"""
|
|
41
|
+
Automatically selects and applies the best imputation strategy
|
|
42
|
+
for each column.
|
|
43
|
+
|
|
44
|
+
After fit_transform, self.report_ contains per-column decisions:
|
|
45
|
+
{
|
|
46
|
+
"column_name": {
|
|
47
|
+
"missing_pct": 12.3,
|
|
48
|
+
"strategy": "knn",
|
|
49
|
+
"reason": "MAR detected via missingness correlation",
|
|
50
|
+
},
|
|
51
|
+
...
|
|
52
|
+
}
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, knn_neighbors: int = 5) -> None:
|
|
56
|
+
self.knn_neighbors = knn_neighbors
|
|
57
|
+
self.report_: Dict[str, Any] = {}
|
|
58
|
+
self._strategies: Dict[str, str] = {}
|
|
59
|
+
self._imputers: Dict[str, Any] = {}
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
# Public
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def fit_transform(self, df: pd.DataFrame) -> pd.DataFrame:
|
|
66
|
+
df = df.copy()
|
|
67
|
+
|
|
68
|
+
num_cols = df.select_dtypes(include="number").columns.tolist()
|
|
69
|
+
cat_cols = df.select_dtypes(
|
|
70
|
+
exclude="number"
|
|
71
|
+
).columns.tolist()
|
|
72
|
+
|
|
73
|
+
# Fit + transform numeric
|
|
74
|
+
df, num_reports = self._handle_numeric(df, num_cols, fit=True)
|
|
75
|
+
|
|
76
|
+
# Fit + transform categorical
|
|
77
|
+
df, cat_reports = self._handle_categorical(df, cat_cols, fit=True)
|
|
78
|
+
|
|
79
|
+
self.report_ = {**num_reports, **cat_reports}
|
|
80
|
+
self._log_summary()
|
|
81
|
+
return df
|
|
82
|
+
|
|
83
|
+
def transform(self, df: pd.DataFrame) -> pd.DataFrame:
|
|
84
|
+
"""Apply fitted imputers to new data."""
|
|
85
|
+
df = df.copy()
|
|
86
|
+
for col, imputer in self._imputers.items():
|
|
87
|
+
if col not in df.columns:
|
|
88
|
+
continue
|
|
89
|
+
strategy = self._strategies[col]
|
|
90
|
+
if strategy == "sentinel":
|
|
91
|
+
df[col] = df[col].fillna("__missing__")
|
|
92
|
+
elif strategy in ("mean", "median", "mode"):
|
|
93
|
+
df[[col]] = imputer.transform(df[[col]])
|
|
94
|
+
elif strategy == "knn":
|
|
95
|
+
df[[col]] = imputer.transform(df[[col]])
|
|
96
|
+
elif strategy == "constant_flag":
|
|
97
|
+
df[col] = df[col].fillna(-999)
|
|
98
|
+
return df
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# Private — numeric
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def _handle_numeric(
|
|
105
|
+
self,
|
|
106
|
+
df: pd.DataFrame,
|
|
107
|
+
cols: List[str],
|
|
108
|
+
fit: bool = True,
|
|
109
|
+
) -> tuple[pd.DataFrame, Dict[str, Any]]:
|
|
110
|
+
reports: Dict[str, Any] = {}
|
|
111
|
+
|
|
112
|
+
# Identify columns that may need KNN (expensive, batch them)
|
|
113
|
+
knn_cols: List[str] = []
|
|
114
|
+
|
|
115
|
+
for col in cols:
|
|
116
|
+
missing_pct = df[col].isna().mean() * 100
|
|
117
|
+
strategy, reason = self._choose_numeric_strategy(
|
|
118
|
+
cast(pd.Series, df[col]), missing_pct, df, cols
|
|
119
|
+
)
|
|
120
|
+
reports[col] = {
|
|
121
|
+
"missing_pct": round(missing_pct, 2),
|
|
122
|
+
"strategy": strategy,
|
|
123
|
+
"reason": reason,
|
|
124
|
+
}
|
|
125
|
+
self._strategies[col] = strategy
|
|
126
|
+
if strategy == "knn":
|
|
127
|
+
knn_cols.append(col)
|
|
128
|
+
|
|
129
|
+
# Non-KNN imputers
|
|
130
|
+
for col in cols:
|
|
131
|
+
strategy = self._strategies[col]
|
|
132
|
+
if col in knn_cols:
|
|
133
|
+
continue
|
|
134
|
+
if strategy == "constant_flag":
|
|
135
|
+
df[col] = df[col].fillna(-999)
|
|
136
|
+
self._imputers[col] = None
|
|
137
|
+
elif strategy in ("mean", "median"):
|
|
138
|
+
imp = SimpleImputer(strategy=strategy)
|
|
139
|
+
if fit:
|
|
140
|
+
df[[col]] = imp.fit_transform(df[[col]])
|
|
141
|
+
self._imputers[col] = imp
|
|
142
|
+
else:
|
|
143
|
+
df[[col]] = self._imputers[col].transform(df[[col]])
|
|
144
|
+
# zero-missing columns need no imputer
|
|
145
|
+
else:
|
|
146
|
+
self._imputers[col] = None
|
|
147
|
+
|
|
148
|
+
# KNN batch
|
|
149
|
+
if knn_cols:
|
|
150
|
+
knn = KNNImputer(n_neighbors=self.knn_neighbors)
|
|
151
|
+
if fit:
|
|
152
|
+
df[knn_cols] = knn.fit_transform(df[knn_cols])
|
|
153
|
+
for col in knn_cols:
|
|
154
|
+
self._imputers[col] = knn
|
|
155
|
+
else:
|
|
156
|
+
df[knn_cols] = self._imputers[knn_cols[0]].transform(
|
|
157
|
+
df[knn_cols]
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return df, reports
|
|
161
|
+
|
|
162
|
+
def _choose_numeric_strategy(
|
|
163
|
+
self,
|
|
164
|
+
series: pd.Series,
|
|
165
|
+
missing_pct: float,
|
|
166
|
+
df: pd.DataFrame,
|
|
167
|
+
num_cols: List[str],
|
|
168
|
+
) -> tuple[str, str]:
|
|
169
|
+
if missing_pct == 0:
|
|
170
|
+
return "none", "no missing values"
|
|
171
|
+
|
|
172
|
+
if missing_pct >= _HIGH_MISSING_THRESHOLD * 100:
|
|
173
|
+
return (
|
|
174
|
+
"constant_flag",
|
|
175
|
+
f"{missing_pct:.1f}% missing — too high for reliable imputation; flagged with -999",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if missing_pct < _LOW_MISSING_THRESHOLD * 100:
|
|
179
|
+
return "median", "< 1% missing — fast median fill"
|
|
180
|
+
|
|
181
|
+
# Check MAR: correlation between missingness indicator and other numerics
|
|
182
|
+
if self._is_mar(series, df, num_cols):
|
|
183
|
+
return "knn", "MAR detected via missingness correlation — KNN imputer"
|
|
184
|
+
|
|
185
|
+
skew = series.skew()
|
|
186
|
+
if abs(cast(float, skew)) > 1:
|
|
187
|
+
return "median", f"skewness={skew:.2f} > 1 — median is robust to outliers"
|
|
188
|
+
return "mean", f"skewness={skew:.2f} ≤ 1 — mean imputation"
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _is_mar(
|
|
192
|
+
series: pd.Series,
|
|
193
|
+
df: pd.DataFrame,
|
|
194
|
+
num_cols: List[str],
|
|
195
|
+
) -> bool:
|
|
196
|
+
"""
|
|
197
|
+
Proxy for MAR: if the missingness indicator for this column
|
|
198
|
+
correlates with any other numeric column, likely MAR.
|
|
199
|
+
"""
|
|
200
|
+
if series.isna().sum() == 0:
|
|
201
|
+
return False
|
|
202
|
+
indicator = series.isna().astype(int)
|
|
203
|
+
others = [c for c in num_cols if c != series.name]
|
|
204
|
+
for other in others:
|
|
205
|
+
try:
|
|
206
|
+
corr = abs(indicator.corr(cast(pd.Series, df[other].fillna(df[other].median()))))
|
|
207
|
+
if corr > _MAR_CORR_THRESHOLD:
|
|
208
|
+
return True
|
|
209
|
+
except Exception:
|
|
210
|
+
continue
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
# Private — categorical
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def _handle_categorical(
|
|
218
|
+
self,
|
|
219
|
+
df: pd.DataFrame,
|
|
220
|
+
cols: List[str],
|
|
221
|
+
fit: bool = True,
|
|
222
|
+
) -> tuple[pd.DataFrame, Dict[str, Any]]:
|
|
223
|
+
reports: Dict[str, Any] = {}
|
|
224
|
+
for col in cols:
|
|
225
|
+
missing_pct = df[col].isna().mean() * 100
|
|
226
|
+
if missing_pct == 0:
|
|
227
|
+
strategy, reason = "none", "no missing values"
|
|
228
|
+
elif missing_pct < 30:
|
|
229
|
+
strategy, reason = "mode", "< 30% missing — mode fill"
|
|
230
|
+
else:
|
|
231
|
+
strategy, reason = (
|
|
232
|
+
"sentinel",
|
|
233
|
+
f"{missing_pct:.1f}% missing — sentinel '__missing__' added",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
reports[col] = {
|
|
237
|
+
"missing_pct": round(missing_pct, 2),
|
|
238
|
+
"strategy": strategy,
|
|
239
|
+
"reason": reason,
|
|
240
|
+
}
|
|
241
|
+
self._strategies[col] = strategy
|
|
242
|
+
|
|
243
|
+
if strategy == "mode":
|
|
244
|
+
if fit:
|
|
245
|
+
imp = SimpleImputer(strategy="most_frequent")
|
|
246
|
+
df[[col]] = imp.fit_transform(df[[col]])
|
|
247
|
+
self._imputers[col] = imp
|
|
248
|
+
else:
|
|
249
|
+
df[[col]] = self._imputers[col].transform(df[[col]])
|
|
250
|
+
elif strategy == "sentinel":
|
|
251
|
+
df[col] = df[col].fillna("__missing__")
|
|
252
|
+
self._imputers[col] = None
|
|
253
|
+
|
|
254
|
+
return df, reports
|
|
255
|
+
|
|
256
|
+
def _log_summary(self) -> None:
|
|
257
|
+
strategies = [v["strategy"] for v in self.report_.values()]
|
|
258
|
+
from collections import Counter
|
|
259
|
+
counts = Counter(strategies)
|
|
260
|
+
logger.debug(f"Missing value strategies applied: {dict(counts)}")
|