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 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,4 @@
1
+ from frameprep.core.dtype_inferrer import DTypeInferrer
2
+ from frameprep.core.missing_strategy import MissingValueHandler
3
+
4
+ __all__ = ["DTypeInferrer", "MissingValueHandler"]
@@ -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)}")
@@ -0,0 +1,3 @@
1
+ from frameprep.detectors.leakage import LeakageDetector
2
+
3
+ __all__ = ["LeakageDetector"]