universal-data-cleaner 2.0.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.
- data_cleaner.py +754 -0
- universal_data_cleaner-2.0.0.dist-info/METADATA +259 -0
- universal_data_cleaner-2.0.0.dist-info/RECORD +6 -0
- universal_data_cleaner-2.0.0.dist-info/WHEEL +5 -0
- universal_data_cleaner-2.0.0.dist-info/licenses/LICENSE +21 -0
- universal_data_cleaner-2.0.0.dist-info/top_level.txt +1 -0
data_cleaner.py
ADDED
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
"""Universal Data Cleaner — production-oriented tabular cleaning transformer.
|
|
2
|
+
|
|
3
|
+
The module is intentionally small and dependency-light:
|
|
4
|
+
|
|
5
|
+
- pandas + numpy for data handling
|
|
6
|
+
- scikit-learn base classes for Pipeline / clone compatibility
|
|
7
|
+
- joblib for artifact persistence
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Literal
|
|
17
|
+
|
|
18
|
+
import joblib
|
|
19
|
+
import numpy as np
|
|
20
|
+
import pandas as pd
|
|
21
|
+
from pandas.api import types as ptypes
|
|
22
|
+
from sklearn.base import BaseEstimator, TransformerMixin
|
|
23
|
+
from sklearn.utils.validation import check_is_fitted
|
|
24
|
+
|
|
25
|
+
LOGGER = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
__version__ = "2.0.0"
|
|
28
|
+
_ARTIFACT_VERSION = 1
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"CleanerConfig",
|
|
32
|
+
"DataCleaner",
|
|
33
|
+
"assert_frame_clean",
|
|
34
|
+
"dataset_summary",
|
|
35
|
+
"drop_duplicate_rows",
|
|
36
|
+
"profile_dataframe",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
_ALLOWED_DATETIME_FEATURES = {
|
|
40
|
+
"year",
|
|
41
|
+
"month",
|
|
42
|
+
"day",
|
|
43
|
+
"dayofweek",
|
|
44
|
+
"quarter",
|
|
45
|
+
"dayofyear",
|
|
46
|
+
"weekofyear",
|
|
47
|
+
"is_weekend",
|
|
48
|
+
"hour",
|
|
49
|
+
"minute",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class CleanerConfig:
|
|
55
|
+
"""Configuration for :class:`DataCleaner`."""
|
|
56
|
+
|
|
57
|
+
numeric_imputation: Literal["median", "mean", "constant"] = "median"
|
|
58
|
+
categorical_imputation: Literal["mode", "constant"] = "mode"
|
|
59
|
+
numeric_fill_value: float = 0.0
|
|
60
|
+
categorical_fill_value: str = "Unknown"
|
|
61
|
+
cap_outliers: bool = True
|
|
62
|
+
outlier_multiplier: float = 1.5
|
|
63
|
+
add_missing_indicators: bool = False
|
|
64
|
+
drop_constant_columns: bool = True
|
|
65
|
+
numeric_columns: tuple[str, ...] = ()
|
|
66
|
+
categorical_columns: tuple[str, ...] = ()
|
|
67
|
+
datetime_columns: tuple[str, ...] = ()
|
|
68
|
+
datetime_features: tuple[str, ...] = (
|
|
69
|
+
"year",
|
|
70
|
+
"month",
|
|
71
|
+
"day",
|
|
72
|
+
"dayofweek",
|
|
73
|
+
"is_weekend",
|
|
74
|
+
)
|
|
75
|
+
unseen_category: Literal["keep", "fill"] = "keep"
|
|
76
|
+
verbose: bool = True
|
|
77
|
+
|
|
78
|
+
def __post_init__(self) -> None:
|
|
79
|
+
for name in (
|
|
80
|
+
"numeric_columns",
|
|
81
|
+
"categorical_columns",
|
|
82
|
+
"datetime_columns",
|
|
83
|
+
"datetime_features",
|
|
84
|
+
):
|
|
85
|
+
object.__setattr__(self, name, tuple(getattr(self, name)))
|
|
86
|
+
|
|
87
|
+
if self.numeric_imputation not in {"median", "mean", "constant"}:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
"numeric_imputation must be one of: median, mean, constant"
|
|
90
|
+
)
|
|
91
|
+
if self.categorical_imputation not in {"mode", "constant"}:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
"categorical_imputation must be one of: mode, constant"
|
|
94
|
+
)
|
|
95
|
+
if self.outlier_multiplier <= 0:
|
|
96
|
+
raise ValueError("outlier_multiplier must be > 0")
|
|
97
|
+
if self.unseen_category not in {"keep", "fill"}:
|
|
98
|
+
raise ValueError("unseen_category must be one of: keep, fill")
|
|
99
|
+
if not self.datetime_features:
|
|
100
|
+
raise ValueError("datetime_features cannot be empty")
|
|
101
|
+
if len(set(self.datetime_features)) != len(self.datetime_features):
|
|
102
|
+
raise ValueError("datetime_features must be unique")
|
|
103
|
+
unknown_features = set(self.datetime_features) - _ALLOWED_DATETIME_FEATURES
|
|
104
|
+
if unknown_features:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"Unsupported datetime features: {sorted(unknown_features)}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
configured_sets = {
|
|
110
|
+
"numeric_columns": set(self.numeric_columns),
|
|
111
|
+
"categorical_columns": set(self.categorical_columns),
|
|
112
|
+
"datetime_columns": set(self.datetime_columns),
|
|
113
|
+
}
|
|
114
|
+
for left_name, left in configured_sets.items():
|
|
115
|
+
for right_name, right in configured_sets.items():
|
|
116
|
+
if left_name < right_name and left & right:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"{left_name} and {right_name} overlap: {sorted(left & right)}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class DataCleaner(TransformerMixin, BaseEstimator):
|
|
123
|
+
"""Fit/transform data cleaner that prevents train/test leakage.
|
|
124
|
+
|
|
125
|
+
The cleaner learns imputers, outlier bounds, category policies and schema
|
|
126
|
+
information on training data, then applies exactly those learned rules to
|
|
127
|
+
validation, test, or inference batches.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(self, config: CleanerConfig | None = None) -> None:
|
|
131
|
+
self.config = config if config is not None else CleanerConfig()
|
|
132
|
+
|
|
133
|
+
def fit(self, X: pd.DataFrame, y: object | None = None) -> "DataCleaner":
|
|
134
|
+
"""Learn cleaning rules from training data."""
|
|
135
|
+
frame = self._validate_frame(X, required=None, allow_empty=False)
|
|
136
|
+
roles = self._infer_roles(frame)
|
|
137
|
+
prepared, missing_flags, generated = self._prepare_frame(frame, roles)
|
|
138
|
+
|
|
139
|
+
self.feature_names_in_ = np.asarray(list(frame.columns), dtype=object)
|
|
140
|
+
self.n_features_in_ = len(frame.columns)
|
|
141
|
+
self.roles_ = roles
|
|
142
|
+
self.datetime_feature_columns_ = generated
|
|
143
|
+
self.missing_counts_ = {
|
|
144
|
+
column: int(flags.sum()) for column, flags in missing_flags.items()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
self.numeric_columns_ = [
|
|
148
|
+
column
|
|
149
|
+
for column, role in roles.items()
|
|
150
|
+
if role == "numeric" and column in prepared.columns
|
|
151
|
+
] + [column for columns in generated.values() for column in columns]
|
|
152
|
+
self.calendar_columns_ = [
|
|
153
|
+
column for columns in generated.values() for column in columns
|
|
154
|
+
]
|
|
155
|
+
self.boolean_columns_ = [
|
|
156
|
+
column for column, role in roles.items() if role == "boolean"
|
|
157
|
+
]
|
|
158
|
+
self.categorical_columns_ = [
|
|
159
|
+
column for column, role in roles.items() if role == "categorical"
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
self.fill_values_: dict[str, object] = {}
|
|
163
|
+
self.outlier_bounds_: dict[str, tuple[float, float]] = {}
|
|
164
|
+
self.outlier_counts_: dict[str, int] = {}
|
|
165
|
+
self.outlier_quartiles_: dict[str, tuple[float, float]] = {}
|
|
166
|
+
self.known_categories_: dict[str, frozenset | None] = {}
|
|
167
|
+
|
|
168
|
+
self._fit_numeric(prepared, roles)
|
|
169
|
+
self._fit_boolean(prepared)
|
|
170
|
+
self._fit_categorical(prepared)
|
|
171
|
+
|
|
172
|
+
self.indicator_columns_: dict[str, str] = {}
|
|
173
|
+
if self.config.add_missing_indicators:
|
|
174
|
+
used = set(prepared.columns)
|
|
175
|
+
for source, flags in missing_flags.items():
|
|
176
|
+
if not bool(flags.any()):
|
|
177
|
+
continue
|
|
178
|
+
base = f"{source}__is_missing"
|
|
179
|
+
name = base
|
|
180
|
+
suffix = 2
|
|
181
|
+
while name in used:
|
|
182
|
+
name = f"{base}_{suffix}"
|
|
183
|
+
suffix += 1
|
|
184
|
+
prepared[name] = flags.astype(bool)
|
|
185
|
+
used.add(name)
|
|
186
|
+
self.indicator_columns_[source] = name
|
|
187
|
+
|
|
188
|
+
self.constant_columns_ = [
|
|
189
|
+
column
|
|
190
|
+
for column in prepared.columns
|
|
191
|
+
if prepared[column].nunique(dropna=False) <= 1
|
|
192
|
+
]
|
|
193
|
+
dropped = (
|
|
194
|
+
set(self.constant_columns_) if self.config.drop_constant_columns else set()
|
|
195
|
+
)
|
|
196
|
+
output_order: list[str] = []
|
|
197
|
+
for source in frame.columns:
|
|
198
|
+
if roles[source] == "datetime":
|
|
199
|
+
output_order.extend(generated[source])
|
|
200
|
+
else:
|
|
201
|
+
output_order.append(source)
|
|
202
|
+
output_order.extend(self.indicator_columns_.values())
|
|
203
|
+
self.columns_ = [
|
|
204
|
+
column
|
|
205
|
+
for column in output_order
|
|
206
|
+
if column in prepared.columns and column not in dropped
|
|
207
|
+
]
|
|
208
|
+
self.dropped_columns_ = sorted(
|
|
209
|
+
set(frame.columns).difference(self.columns_).intersection(roles)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
self._build_summary()
|
|
213
|
+
self.n_samples_fit_ = len(frame)
|
|
214
|
+
self.fitted_at_ = datetime.now(timezone.utc).isoformat()
|
|
215
|
+
if self.config.verbose:
|
|
216
|
+
LOGGER.info(
|
|
217
|
+
"DataCleaner fitted on %d rows, %d input columns, %d output columns",
|
|
218
|
+
len(frame),
|
|
219
|
+
len(self.feature_names_in_),
|
|
220
|
+
len(self.columns_),
|
|
221
|
+
)
|
|
222
|
+
return self
|
|
223
|
+
|
|
224
|
+
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
|
225
|
+
"""Apply learned cleaning rules without fitting anything."""
|
|
226
|
+
self._ensure_fitted()
|
|
227
|
+
frame = self._validate_frame(
|
|
228
|
+
X,
|
|
229
|
+
required=list(self.feature_names_in_),
|
|
230
|
+
allow_empty=True,
|
|
231
|
+
)
|
|
232
|
+
expected = list(self.feature_names_in_)
|
|
233
|
+
extra = [column for column in frame.columns if column not in expected]
|
|
234
|
+
if extra:
|
|
235
|
+
LOGGER.warning(
|
|
236
|
+
"Ignoring %d unexpected column(s) at transform time: %s",
|
|
237
|
+
len(extra),
|
|
238
|
+
extra,
|
|
239
|
+
)
|
|
240
|
+
frame = frame.loc[:, expected]
|
|
241
|
+
prepared, missing_flags, _ = self._prepare_frame(frame, self.roles_)
|
|
242
|
+
|
|
243
|
+
for column in self.numeric_columns_:
|
|
244
|
+
series = prepared[column].astype("float64")
|
|
245
|
+
fill = self.fill_values_[column]
|
|
246
|
+
lower, upper = self.outlier_bounds_[column]
|
|
247
|
+
prepared[column] = (
|
|
248
|
+
series.fillna(fill).clip(lower=lower, upper=upper).astype("float64")
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
for column in self.boolean_columns_:
|
|
252
|
+
series = prepared[column].astype("boolean")
|
|
253
|
+
prepared[column] = (
|
|
254
|
+
series.fillna(self.fill_values_[column]).astype(bool)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
for column in self.categorical_columns_:
|
|
258
|
+
series = prepared[column].astype(object)
|
|
259
|
+
fill = self.fill_values_[column]
|
|
260
|
+
if self.config.unseen_category == "fill":
|
|
261
|
+
known = self.known_categories_.get(column)
|
|
262
|
+
if known is not None:
|
|
263
|
+
unseen = series.notna() & ~series.isin(known)
|
|
264
|
+
series = series.mask(unseen, self.config.categorical_fill_value)
|
|
265
|
+
prepared[column] = series.fillna(fill).astype(object)
|
|
266
|
+
|
|
267
|
+
for source, indicator in self.indicator_columns_.items():
|
|
268
|
+
prepared[indicator] = missing_flags[source].astype(bool)
|
|
269
|
+
|
|
270
|
+
if self.config.drop_constant_columns and self.constant_columns_:
|
|
271
|
+
prepared = prepared.drop(
|
|
272
|
+
columns=[c for c in self.constant_columns_ if c in prepared.columns]
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
missing_output = [c for c in self.columns_ if c not in prepared.columns]
|
|
276
|
+
if missing_output:
|
|
277
|
+
raise RuntimeError(
|
|
278
|
+
f"Internal error: expected cleaned columns missing: {missing_output}"
|
|
279
|
+
)
|
|
280
|
+
return prepared.reindex(columns=self.columns_)
|
|
281
|
+
|
|
282
|
+
def fit_transform(
|
|
283
|
+
self, X: pd.DataFrame, y: object | None = None
|
|
284
|
+
) -> pd.DataFrame:
|
|
285
|
+
"""Fit on ``X`` and return the cleaned training DataFrame."""
|
|
286
|
+
return self.fit(X, y).transform(X)
|
|
287
|
+
|
|
288
|
+
def get_feature_names_out(
|
|
289
|
+
self, input_features: list[str] | None = None
|
|
290
|
+
) -> np.ndarray:
|
|
291
|
+
"""Return cleaned output column names."""
|
|
292
|
+
self._ensure_fitted()
|
|
293
|
+
if input_features is not None and list(input_features) != list(
|
|
294
|
+
self.feature_names_in_
|
|
295
|
+
):
|
|
296
|
+
raise ValueError(
|
|
297
|
+
"input_features must match the columns seen during fit"
|
|
298
|
+
)
|
|
299
|
+
return np.asarray(self.columns_, dtype=object)
|
|
300
|
+
|
|
301
|
+
def cleaning_summary(self) -> pd.DataFrame:
|
|
302
|
+
"""Return a learned cleaning summary for auditing/debugging."""
|
|
303
|
+
self._ensure_fitted()
|
|
304
|
+
return self.cleaning_summary_.copy()
|
|
305
|
+
|
|
306
|
+
def outlier_summary(self) -> pd.DataFrame:
|
|
307
|
+
"""Return outlier bounds learned for numeric columns."""
|
|
308
|
+
self._ensure_fitted()
|
|
309
|
+
rows = []
|
|
310
|
+
for column in self.numeric_columns_:
|
|
311
|
+
lower, upper = self.outlier_bounds_[column]
|
|
312
|
+
q1, q3 = self.outlier_quartiles_.get(column, (np.nan, np.nan))
|
|
313
|
+
rows.append(
|
|
314
|
+
{
|
|
315
|
+
"column": column,
|
|
316
|
+
"q1": q1,
|
|
317
|
+
"q3": q3,
|
|
318
|
+
"lower_bound": lower,
|
|
319
|
+
"upper_bound": upper,
|
|
320
|
+
"outliers_capped": self.outlier_counts_.get(column, 0),
|
|
321
|
+
"method": "iqr"
|
|
322
|
+
if np.isfinite(lower) or np.isfinite(upper)
|
|
323
|
+
else "disabled",
|
|
324
|
+
}
|
|
325
|
+
)
|
|
326
|
+
return pd.DataFrame(rows)
|
|
327
|
+
|
|
328
|
+
def save(self, path: str | Path) -> Path:
|
|
329
|
+
"""Persist the fitted cleaner as a joblib artifact."""
|
|
330
|
+
self._ensure_fitted()
|
|
331
|
+
target = Path(path)
|
|
332
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
333
|
+
payload = {
|
|
334
|
+
"artifact_version": _ARTIFACT_VERSION,
|
|
335
|
+
"library_version": __version__,
|
|
336
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
337
|
+
"cleaner": self,
|
|
338
|
+
}
|
|
339
|
+
joblib.dump(payload, target, compress=3)
|
|
340
|
+
return target
|
|
341
|
+
|
|
342
|
+
@classmethod
|
|
343
|
+
def load(cls, path: str | Path, verify_version: bool = True) -> "DataCleaner":
|
|
344
|
+
"""Load a saved cleaner artifact."""
|
|
345
|
+
payload = joblib.load(Path(path))
|
|
346
|
+
if not isinstance(payload, dict) or "cleaner" not in payload:
|
|
347
|
+
raise ValueError("File is not a valid DataCleaner artifact")
|
|
348
|
+
cleaner = payload["cleaner"]
|
|
349
|
+
if not isinstance(cleaner, cls):
|
|
350
|
+
raise TypeError("Artifact does not contain a DataCleaner")
|
|
351
|
+
artifact_version = payload.get("artifact_version")
|
|
352
|
+
library_version = payload.get("library_version")
|
|
353
|
+
if verify_version and library_version != __version__:
|
|
354
|
+
LOGGER.warning(
|
|
355
|
+
"Artifact was created with library version %s, current version is %s",
|
|
356
|
+
library_version,
|
|
357
|
+
__version__,
|
|
358
|
+
)
|
|
359
|
+
if artifact_version != _ARTIFACT_VERSION:
|
|
360
|
+
LOGGER.warning(
|
|
361
|
+
"Artifact version %s is not the current version %s",
|
|
362
|
+
artifact_version,
|
|
363
|
+
_ARTIFACT_VERSION,
|
|
364
|
+
)
|
|
365
|
+
return cleaner
|
|
366
|
+
|
|
367
|
+
def _ensure_fitted(self) -> None:
|
|
368
|
+
check_is_fitted(self, attributes=["columns_"])
|
|
369
|
+
|
|
370
|
+
def _validate_frame(
|
|
371
|
+
self,
|
|
372
|
+
frame: pd.DataFrame,
|
|
373
|
+
*,
|
|
374
|
+
required: list[str] | None,
|
|
375
|
+
allow_empty: bool,
|
|
376
|
+
) -> pd.DataFrame:
|
|
377
|
+
if not isinstance(frame, pd.DataFrame):
|
|
378
|
+
raise TypeError("X must be a pandas DataFrame")
|
|
379
|
+
if frame.columns.has_duplicates:
|
|
380
|
+
duplicates = frame.columns[frame.columns.duplicated()].tolist()
|
|
381
|
+
raise ValueError(f"Duplicate column names are not allowed: {duplicates}")
|
|
382
|
+
if required:
|
|
383
|
+
missing = [column for column in required if column not in frame.columns]
|
|
384
|
+
if missing:
|
|
385
|
+
raise ValueError(
|
|
386
|
+
"DataFrame is missing required columns: "
|
|
387
|
+
f"{missing}. Fit the cleaner on the training schema first."
|
|
388
|
+
)
|
|
389
|
+
if not allow_empty and frame.empty:
|
|
390
|
+
raise ValueError("Cannot fit DataCleaner on an empty DataFrame")
|
|
391
|
+
return frame.copy(deep=True)
|
|
392
|
+
|
|
393
|
+
def _infer_roles(self, frame: pd.DataFrame) -> dict[str, str]:
|
|
394
|
+
cfg = self.config
|
|
395
|
+
configured = (
|
|
396
|
+
list(cfg.numeric_columns)
|
|
397
|
+
+ list(cfg.categorical_columns)
|
|
398
|
+
+ list(cfg.datetime_columns)
|
|
399
|
+
)
|
|
400
|
+
missing = [column for column in configured if column not in frame.columns]
|
|
401
|
+
if missing:
|
|
402
|
+
raise ValueError(
|
|
403
|
+
f"Configured columns not found in DataFrame: {missing}"
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
roles: dict[str, str] = {}
|
|
407
|
+
for column in frame.columns:
|
|
408
|
+
if column in cfg.datetime_columns:
|
|
409
|
+
roles[column] = "datetime"
|
|
410
|
+
elif column in cfg.numeric_columns:
|
|
411
|
+
roles[column] = "numeric"
|
|
412
|
+
elif column in cfg.categorical_columns:
|
|
413
|
+
roles[column] = "categorical"
|
|
414
|
+
elif ptypes.is_bool_dtype(frame[column]):
|
|
415
|
+
roles[column] = "boolean"
|
|
416
|
+
elif ptypes.is_datetime64_any_dtype(frame[column]):
|
|
417
|
+
raise ValueError(
|
|
418
|
+
f"Column '{column}' is datetime-like but is not configured. "
|
|
419
|
+
"Add it to CleanerConfig.datetime_columns."
|
|
420
|
+
)
|
|
421
|
+
elif ptypes.is_complex_dtype(frame[column]):
|
|
422
|
+
raise TypeError(f"Column '{column}' has unsupported complex dtype")
|
|
423
|
+
elif ptypes.is_numeric_dtype(frame[column]):
|
|
424
|
+
roles[column] = "numeric"
|
|
425
|
+
else:
|
|
426
|
+
roles[column] = "categorical"
|
|
427
|
+
return roles
|
|
428
|
+
|
|
429
|
+
def _parse_datetime(self, series: pd.Series) -> pd.Series:
|
|
430
|
+
try:
|
|
431
|
+
return pd.to_datetime(series, errors="coerce", format="mixed")
|
|
432
|
+
except (TypeError, ValueError):
|
|
433
|
+
return pd.to_datetime(series, errors="coerce")
|
|
434
|
+
|
|
435
|
+
@staticmethod
|
|
436
|
+
def _coerce_numeric(series: pd.Series) -> pd.Series:
|
|
437
|
+
numeric = pd.to_numeric(series, errors="coerce").astype("float64")
|
|
438
|
+
return numeric.replace([np.inf, -np.inf], np.nan)
|
|
439
|
+
|
|
440
|
+
@staticmethod
|
|
441
|
+
def _as_float(values: object) -> pd.Series:
|
|
442
|
+
return pd.to_numeric(values, errors="coerce").astype("float64")
|
|
443
|
+
|
|
444
|
+
def _extract_datetime_features(self, parsed: pd.Series, source: str) -> pd.DataFrame:
|
|
445
|
+
features: dict[str, pd.Series] = {}
|
|
446
|
+
for feature in self.config.datetime_features:
|
|
447
|
+
if feature == "year":
|
|
448
|
+
values = parsed.dt.year
|
|
449
|
+
elif feature == "month":
|
|
450
|
+
values = parsed.dt.month
|
|
451
|
+
elif feature == "day":
|
|
452
|
+
values = parsed.dt.day
|
|
453
|
+
elif feature == "dayofweek":
|
|
454
|
+
values = parsed.dt.dayofweek
|
|
455
|
+
elif feature == "quarter":
|
|
456
|
+
values = parsed.dt.quarter
|
|
457
|
+
elif feature == "dayofyear":
|
|
458
|
+
values = parsed.dt.dayofyear
|
|
459
|
+
elif feature == "weekofyear":
|
|
460
|
+
values = parsed.dt.isocalendar().week
|
|
461
|
+
elif feature == "hour":
|
|
462
|
+
values = parsed.dt.hour
|
|
463
|
+
elif feature == "minute":
|
|
464
|
+
values = parsed.dt.minute
|
|
465
|
+
else: # is_weekend
|
|
466
|
+
day_of_week = parsed.dt.dayofweek
|
|
467
|
+
values = pd.Series(np.nan, index=parsed.index, dtype="float64")
|
|
468
|
+
valid = day_of_week.notna()
|
|
469
|
+
values.loc[valid] = day_of_week.loc[valid].ge(5).astype(float)
|
|
470
|
+
features[f"{source}__{feature}"] = self._as_float(values)
|
|
471
|
+
return pd.DataFrame(features, index=parsed.index)
|
|
472
|
+
|
|
473
|
+
def _prepare_frame(
|
|
474
|
+
self, frame: pd.DataFrame, roles: dict[str, str]
|
|
475
|
+
) -> tuple[pd.DataFrame, dict[str, pd.Series], dict[str, list[str]]]:
|
|
476
|
+
cfg = self.config
|
|
477
|
+
prepared: dict[str, pd.Series] = {}
|
|
478
|
+
missing_flags: dict[str, pd.Series] = {}
|
|
479
|
+
generated: dict[str, list[str]] = {}
|
|
480
|
+
|
|
481
|
+
reserved = set(frame.columns)
|
|
482
|
+
for source in frame.columns:
|
|
483
|
+
if roles[source] != "datetime":
|
|
484
|
+
continue
|
|
485
|
+
names = [f"{source}__{feature}" for feature in cfg.datetime_features]
|
|
486
|
+
collisions = set(names) & reserved
|
|
487
|
+
if collisions:
|
|
488
|
+
raise ValueError(
|
|
489
|
+
f"Generated datetime feature names collide with existing columns: "
|
|
490
|
+
f"{sorted(collisions)}"
|
|
491
|
+
)
|
|
492
|
+
reserved.update(names)
|
|
493
|
+
|
|
494
|
+
for source in frame.columns:
|
|
495
|
+
role = roles[source]
|
|
496
|
+
series = frame[source]
|
|
497
|
+
if role == "datetime":
|
|
498
|
+
parsed = self._parse_datetime(series)
|
|
499
|
+
missing_flags[source] = parsed.isna()
|
|
500
|
+
features = self._extract_datetime_features(parsed, source)
|
|
501
|
+
generated[source] = list(features.columns)
|
|
502
|
+
for name in features.columns:
|
|
503
|
+
prepared[name] = features[name]
|
|
504
|
+
elif role == "numeric":
|
|
505
|
+
numeric = self._coerce_numeric(series)
|
|
506
|
+
missing_flags[source] = numeric.isna()
|
|
507
|
+
prepared[source] = numeric
|
|
508
|
+
elif role == "boolean":
|
|
509
|
+
boolean = series.astype("boolean")
|
|
510
|
+
missing_flags[source] = boolean.isna()
|
|
511
|
+
prepared[source] = boolean
|
|
512
|
+
else:
|
|
513
|
+
categorical = series.astype(object)
|
|
514
|
+
missing_flags[source] = categorical.isna()
|
|
515
|
+
prepared[source] = categorical
|
|
516
|
+
|
|
517
|
+
return pd.DataFrame(prepared, index=frame.index), missing_flags, generated
|
|
518
|
+
|
|
519
|
+
def _fit_numeric(
|
|
520
|
+
self, prepared: pd.DataFrame, roles: dict[str, str]
|
|
521
|
+
) -> None:
|
|
522
|
+
cfg = self.config
|
|
523
|
+
for column in self.numeric_columns_:
|
|
524
|
+
series = prepared[column].astype("float64")
|
|
525
|
+
valid = series.dropna()
|
|
526
|
+
if cfg.numeric_imputation == "median":
|
|
527
|
+
fill = valid.median() if not valid.empty else cfg.numeric_fill_value
|
|
528
|
+
elif cfg.numeric_imputation == "mean":
|
|
529
|
+
fill = valid.mean() if not valid.empty else cfg.numeric_fill_value
|
|
530
|
+
else:
|
|
531
|
+
fill = cfg.numeric_fill_value
|
|
532
|
+
|
|
533
|
+
try:
|
|
534
|
+
fill = float(fill)
|
|
535
|
+
except (TypeError, ValueError):
|
|
536
|
+
fill = cfg.numeric_fill_value
|
|
537
|
+
if not np.isfinite(fill):
|
|
538
|
+
fill = cfg.numeric_fill_value
|
|
539
|
+
|
|
540
|
+
lower, upper = -np.inf, np.inf
|
|
541
|
+
q1 = q3 = np.nan
|
|
542
|
+
outliers = 0
|
|
543
|
+
should_cap = (
|
|
544
|
+
cfg.cap_outliers
|
|
545
|
+
and roles.get(column) == "numeric"
|
|
546
|
+
and not valid.empty
|
|
547
|
+
)
|
|
548
|
+
if should_cap:
|
|
549
|
+
q1 = float(valid.quantile(0.25))
|
|
550
|
+
q3 = float(valid.quantile(0.75))
|
|
551
|
+
iqr = q3 - q1
|
|
552
|
+
if iqr > 0:
|
|
553
|
+
lower = q1 - cfg.outlier_multiplier * iqr
|
|
554
|
+
upper = q3 + cfg.outlier_multiplier * iqr
|
|
555
|
+
outliers = int(((valid < lower) | (valid > upper)).sum())
|
|
556
|
+
|
|
557
|
+
cleaned = series.fillna(fill).clip(lower=lower, upper=upper)
|
|
558
|
+
prepared[column] = cleaned.astype("float64")
|
|
559
|
+
self.fill_values_[column] = fill
|
|
560
|
+
self.outlier_bounds_[column] = (lower, upper)
|
|
561
|
+
self.outlier_counts_[column] = outliers
|
|
562
|
+
self.outlier_quartiles_[column] = (q1, q3)
|
|
563
|
+
|
|
564
|
+
def _fit_boolean(self, prepared: pd.DataFrame) -> None:
|
|
565
|
+
for column in self.boolean_columns_:
|
|
566
|
+
series = prepared[column].astype("boolean")
|
|
567
|
+
valid = series.dropna()
|
|
568
|
+
fill = bool(valid.mode().iloc[0]) if not valid.empty else False
|
|
569
|
+
prepared[column] = series.fillna(fill).astype(bool)
|
|
570
|
+
self.fill_values_[column] = fill
|
|
571
|
+
|
|
572
|
+
def _fit_categorical(self, prepared: pd.DataFrame) -> None:
|
|
573
|
+
cfg = self.config
|
|
574
|
+
for column in self.categorical_columns_:
|
|
575
|
+
series = prepared[column].astype(object)
|
|
576
|
+
valid = series.dropna()
|
|
577
|
+
if cfg.categorical_imputation == "mode":
|
|
578
|
+
modes = valid.mode(dropna=True)
|
|
579
|
+
fill = modes.iloc[0] if not modes.empty else cfg.categorical_fill_value
|
|
580
|
+
else:
|
|
581
|
+
fill = cfg.categorical_fill_value
|
|
582
|
+
|
|
583
|
+
prepared[column] = series.fillna(fill).astype(object)
|
|
584
|
+
self.fill_values_[column] = fill
|
|
585
|
+
if cfg.unseen_category == "fill":
|
|
586
|
+
try:
|
|
587
|
+
self.known_categories_[column] = frozenset(valid.unique())
|
|
588
|
+
except TypeError:
|
|
589
|
+
self.known_categories_[column] = None
|
|
590
|
+
else:
|
|
591
|
+
self.known_categories_[column] = None
|
|
592
|
+
|
|
593
|
+
def _build_summary(self) -> None:
|
|
594
|
+
rows: list[dict[str, object]] = []
|
|
595
|
+
for source, role in self.roles_.items():
|
|
596
|
+
if role == "datetime":
|
|
597
|
+
rows.append(
|
|
598
|
+
{
|
|
599
|
+
"column": source,
|
|
600
|
+
"kind": "datetime",
|
|
601
|
+
"missing_in_fit": self.missing_counts_.get(source, 0),
|
|
602
|
+
"fill_value": "",
|
|
603
|
+
"lower_bound": np.nan,
|
|
604
|
+
"upper_bound": np.nan,
|
|
605
|
+
"outliers_capped": 0,
|
|
606
|
+
"status": "expanded",
|
|
607
|
+
"generated_columns": ", ".join(
|
|
608
|
+
self.datetime_feature_columns_.get(source, [])
|
|
609
|
+
),
|
|
610
|
+
}
|
|
611
|
+
)
|
|
612
|
+
continue
|
|
613
|
+
|
|
614
|
+
if role in {"numeric", "boolean", "categorical"}:
|
|
615
|
+
rows.append(
|
|
616
|
+
{
|
|
617
|
+
"column": source,
|
|
618
|
+
"kind": role,
|
|
619
|
+
"missing_in_fit": self.missing_counts_.get(source, 0),
|
|
620
|
+
"fill_value": self.fill_values_.get(source, ""),
|
|
621
|
+
"lower_bound": self.outlier_bounds_.get(
|
|
622
|
+
source, (np.nan, np.nan)
|
|
623
|
+
)[0],
|
|
624
|
+
"upper_bound": self.outlier_bounds_.get(
|
|
625
|
+
source, (np.nan, np.nan)
|
|
626
|
+
)[1],
|
|
627
|
+
"outliers_capped": self.outlier_counts_.get(source, 0),
|
|
628
|
+
"status": "dropped_constant"
|
|
629
|
+
if source in self.constant_columns_
|
|
630
|
+
else "retained",
|
|
631
|
+
"generated_columns": "",
|
|
632
|
+
}
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
for column in self.calendar_columns_:
|
|
636
|
+
source = next(
|
|
637
|
+
source
|
|
638
|
+
for source, columns in self.datetime_feature_columns_.items()
|
|
639
|
+
if column in columns
|
|
640
|
+
)
|
|
641
|
+
rows.append(
|
|
642
|
+
{
|
|
643
|
+
"column": column,
|
|
644
|
+
"kind": "calendar",
|
|
645
|
+
"missing_in_fit": self.missing_counts_.get(source, 0),
|
|
646
|
+
"fill_value": self.fill_values_[column],
|
|
647
|
+
"lower_bound": np.nan,
|
|
648
|
+
"upper_bound": np.nan,
|
|
649
|
+
"outliers_capped": 0,
|
|
650
|
+
"status": "dropped_constant"
|
|
651
|
+
if column in self.constant_columns_
|
|
652
|
+
else "retained",
|
|
653
|
+
"generated_columns": "",
|
|
654
|
+
}
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
for source, indicator in self.indicator_columns_.items():
|
|
658
|
+
rows.append(
|
|
659
|
+
{
|
|
660
|
+
"column": indicator,
|
|
661
|
+
"kind": "indicator",
|
|
662
|
+
"missing_in_fit": self.missing_counts_.get(source, 0),
|
|
663
|
+
"fill_value": "",
|
|
664
|
+
"lower_bound": np.nan,
|
|
665
|
+
"upper_bound": np.nan,
|
|
666
|
+
"outliers_capped": 0,
|
|
667
|
+
"status": "retained",
|
|
668
|
+
"generated_columns": "",
|
|
669
|
+
}
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
self.cleaning_summary_ = pd.DataFrame(rows)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def profile_dataframe(frame: pd.DataFrame, name: str = "dataset") -> pd.DataFrame:
|
|
676
|
+
"""Create a column-level data quality profile."""
|
|
677
|
+
if not isinstance(frame, pd.DataFrame):
|
|
678
|
+
raise TypeError("frame must be a pandas DataFrame")
|
|
679
|
+
|
|
680
|
+
rows: list[dict[str, object]] = []
|
|
681
|
+
row_count = len(frame)
|
|
682
|
+
for column in frame.columns:
|
|
683
|
+
series = frame[column]
|
|
684
|
+
non_null = int(series.notna().sum())
|
|
685
|
+
missing = int(row_count - non_null)
|
|
686
|
+
mode_values = series.dropna().mode()
|
|
687
|
+
rows.append(
|
|
688
|
+
{
|
|
689
|
+
"dataset": name,
|
|
690
|
+
"column": column,
|
|
691
|
+
"dtype": str(series.dtype),
|
|
692
|
+
"non_null": non_null,
|
|
693
|
+
"missing": missing,
|
|
694
|
+
"missing_pct": round((missing / row_count * 100) if row_count else 0.0, 2),
|
|
695
|
+
"unique": int(series.nunique(dropna=True)),
|
|
696
|
+
"top": mode_values.iloc[0] if not mode_values.empty else None,
|
|
697
|
+
"top_freq": int(series.eq(mode_values.iloc[0]).sum())
|
|
698
|
+
if not mode_values.empty
|
|
699
|
+
else 0,
|
|
700
|
+
}
|
|
701
|
+
)
|
|
702
|
+
return pd.DataFrame(rows)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def dataset_summary(frame: pd.DataFrame) -> dict[str, object]:
|
|
706
|
+
"""Return dataset-level quality metrics."""
|
|
707
|
+
if not isinstance(frame, pd.DataFrame):
|
|
708
|
+
raise TypeError("frame must be a pandas DataFrame")
|
|
709
|
+
total_cells = int(frame.shape[0] * frame.shape[1])
|
|
710
|
+
missing_cells = int(frame.isna().sum().sum())
|
|
711
|
+
return {
|
|
712
|
+
"rows": int(frame.shape[0]),
|
|
713
|
+
"columns": int(frame.shape[1]),
|
|
714
|
+
"duplicate_rows": int(frame.duplicated().sum()),
|
|
715
|
+
"total_cells": total_cells,
|
|
716
|
+
"missing_cells": missing_cells,
|
|
717
|
+
"missing_pct": round(
|
|
718
|
+
(missing_cells / total_cells * 100) if total_cells else 0.0, 2
|
|
719
|
+
),
|
|
720
|
+
"memory_bytes": int(frame.memory_usage(deep=True).sum()),
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def drop_duplicate_rows(
|
|
725
|
+
frame: pd.DataFrame,
|
|
726
|
+
subset: list[str] | None = None,
|
|
727
|
+
keep: str = "first",
|
|
728
|
+
) -> tuple[pd.DataFrame, int]:
|
|
729
|
+
"""Drop exact duplicates and return ``(deduplicated_frame, rows_removed)``."""
|
|
730
|
+
if not isinstance(frame, pd.DataFrame):
|
|
731
|
+
raise TypeError("frame must be a pandas DataFrame")
|
|
732
|
+
deduplicated = frame.drop_duplicates(subset=subset, keep=keep)
|
|
733
|
+
return deduplicated, int(len(frame) - len(deduplicated))
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def assert_frame_clean(frame: pd.DataFrame, *, allow_empty: bool = True) -> None:
|
|
737
|
+
"""Assert that a cleaned frame has no missing/infinite values."""
|
|
738
|
+
if not isinstance(frame, pd.DataFrame):
|
|
739
|
+
raise TypeError("frame must be a pandas DataFrame")
|
|
740
|
+
if frame.columns.has_duplicates:
|
|
741
|
+
raise AssertionError("cleaned frame contains duplicate column names")
|
|
742
|
+
if not allow_empty and frame.empty:
|
|
743
|
+
raise AssertionError("cleaned frame is empty")
|
|
744
|
+
|
|
745
|
+
missing_by_column = frame.isna().sum()
|
|
746
|
+
if bool((missing_by_column > 0).any()):
|
|
747
|
+
details = missing_by_column[missing_by_column > 0].to_dict()
|
|
748
|
+
raise AssertionError(f"cleaned frame still has missing values: {details}")
|
|
749
|
+
|
|
750
|
+
numeric = frame.select_dtypes(include=[np.number])
|
|
751
|
+
if not numeric.empty:
|
|
752
|
+
values = numeric.to_numpy(dtype="float64", na_value=np.nan)
|
|
753
|
+
if not bool(np.isfinite(values).all()):
|
|
754
|
+
raise AssertionError("cleaned frame still has infinite numeric values")
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: universal-data-cleaner
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Production-ready, scikit-learn-compatible data cleaning transformer for pandas.
|
|
5
|
+
Author: Haseeb Khan
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/devlprkhan/universal-data-cleaner
|
|
8
|
+
Project-URL: Repository, https://github.com/devlprkhan/universal-data-cleaner
|
|
9
|
+
Project-URL: Issues, https://github.com/devlprkhan/universal-data-cleaner/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/devlprkhan/universal-data-cleaner/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: data-cleaning,data-quality,data-preprocessing,feature-engineering,machine-learning,pandas,scikit-learn,tabular-data
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Natural Language :: English
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: pandas>=2.2
|
|
29
|
+
Requires-Dist: numpy>=1.26
|
|
30
|
+
Requires-Dist: scikit-learn>=1.4
|
|
31
|
+
Requires-Dist: joblib>=1.3
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# Universal Data Cleaner
|
|
35
|
+
|
|
36
|
+
[](https://github.com/devlprkhan/universal-data-cleaner/actions/workflows/ci.yml)
|
|
37
|
+
[](https://pypi.org/project/universal-data-cleaner/)
|
|
38
|
+
[](https://www.python.org/)
|
|
39
|
+
[](LICENSE)
|
|
40
|
+
[](https://docs.astral.sh/uv/)
|
|
41
|
+
|
|
42
|
+
A small, production-oriented tabular cleaning transformer for pandas + scikit-learn.
|
|
43
|
+
|
|
44
|
+
It is designed for the part of an ML pipeline that is easy to get wrong: cleaning
|
|
45
|
+
training data and inference data with **exactly the same learned rules**.
|
|
46
|
+
|
|
47
|
+
## Why this exists
|
|
48
|
+
|
|
49
|
+
A common notebook pattern is:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
df["Age"] = df["Age"].fillna(df["Age"].median())
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
That leaks information when it is run before the train/test split, and it silently
|
|
56
|
+
breaks at inference time because the median is recomputed on the new batch.
|
|
57
|
+
|
|
58
|
+
`DataCleaner` fits on training data, stores the learned values, and reuses them in
|
|
59
|
+
`transform()` and in saved artifacts.
|
|
60
|
+
|
|
61
|
+
## What it handles
|
|
62
|
+
|
|
63
|
+
- numeric coercion and median / mean / constant imputation
|
|
64
|
+
- optional IQR outlier capping (learned from training data only)
|
|
65
|
+
- categorical mode / constant imputation
|
|
66
|
+
- optional unseen-category policy: keep or replace
|
|
67
|
+
- boolean coercion and imputation
|
|
68
|
+
- datetime parsing plus calendar feature extraction
|
|
69
|
+
- optional missing-value indicator columns
|
|
70
|
+
- optional constant-column dropping
|
|
71
|
+
- stable output schema and column order
|
|
72
|
+
- joblib save / load with artifact versioning
|
|
73
|
+
- scikit-learn `Pipeline`, `clone`, and `get_feature_names_out` compatibility
|
|
74
|
+
|
|
75
|
+
The cleaner never mutates the input DataFrame and never drops inference rows.
|
|
76
|
+
|
|
77
|
+
## Install from PyPI
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pip install universal-data-cleaner
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The distribution name is `universal-data-cleaner`; the import name is
|
|
84
|
+
`data_cleaner`.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from data_cleaner import CleanerConfig, DataCleaner
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Development setup with uv
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
git clone https://github.com/devlprkhan/universal-data-cleaner.git
|
|
94
|
+
cd universal-data-cleaner
|
|
95
|
+
uv sync
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Run the test suite:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
uv run pytest -q
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Open the reference notebook:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
uv run jupyter lab Universal_Data_Cleaner.ipynb
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The notebook is also verified headlessly in CI-style environments with
|
|
111
|
+
`nbclient`; both tools are in the `dev` dependency group.
|
|
112
|
+
|
|
113
|
+
### Install directly from GitHub
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
uv add "git+https://github.com/devlprkhan/universal-data-cleaner.git"
|
|
117
|
+
# or
|
|
118
|
+
pip install "git+https://github.com/devlprkhan/universal-data-cleaner.git"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Quick start
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
import pandas as pd
|
|
125
|
+
from data_cleaner import CleanerConfig, DataCleaner
|
|
126
|
+
|
|
127
|
+
train = pd.DataFrame(
|
|
128
|
+
{
|
|
129
|
+
"age": [34, 41, None, 29],
|
|
130
|
+
"salary": [70_000, 82_000, 91_000, 55_000],
|
|
131
|
+
"dept": ["IT", "HR", "IT", None],
|
|
132
|
+
"joined": ["2022-01-03", "2021-11-20", "2023-04-01", "bad-date"],
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
config = CleanerConfig(
|
|
137
|
+
datetime_columns=("joined",),
|
|
138
|
+
add_missing_indicators=True,
|
|
139
|
+
drop_constant_columns=True,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
cleaner = DataCleaner(config).fit(train)
|
|
143
|
+
train_clean = cleaner.transform(train)
|
|
144
|
+
print(cleaner.cleaning_summary())
|
|
145
|
+
print(cleaner.outlier_summary())
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
For a column that should be numeric even though it arrived as dirty strings:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
config = CleanerConfig(
|
|
152
|
+
numeric_columns=("salary",), # force numeric parsing
|
|
153
|
+
datetime_columns=("joined",), # parse dates instead of treating them as categories
|
|
154
|
+
unseen_category="keep", # or "fill" to replace unseen labels
|
|
155
|
+
)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Leak-safe workflow
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from sklearn.model_selection import train_test_split
|
|
162
|
+
|
|
163
|
+
X_train, X_test, y_train, y_test = train_test_split(
|
|
164
|
+
X, y, test_size=0.2, random_state=42
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
cleaner = DataCleaner(config).fit(X_train)
|
|
168
|
+
|
|
169
|
+
X_train_clean = cleaner.transform(X_train)
|
|
170
|
+
X_test_clean = cleaner.transform(X_test) # uses train statistics only
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Save the fitted cleaner with the model artifact:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
cleaner.save("artifacts/data_cleaner.joblib")
|
|
177
|
+
restored = DataCleaner.load("artifacts/data_cleaner.joblib")
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## scikit-learn Pipeline
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
from sklearn.compose import ColumnTransformer
|
|
184
|
+
from sklearn.linear_model import Ridge
|
|
185
|
+
from sklearn.pipeline import Pipeline
|
|
186
|
+
from sklearn.preprocessing import OneHotEncoder, StandardScaler
|
|
187
|
+
|
|
188
|
+
numeric = ["age", "salary", "joined__year"]
|
|
189
|
+
categorical = ["dept"]
|
|
190
|
+
boolean = []
|
|
191
|
+
|
|
192
|
+
preprocess = ColumnTransformer(
|
|
193
|
+
[
|
|
194
|
+
("num", StandardScaler(), numeric),
|
|
195
|
+
("cat", OneHotEncoder(handle_unknown="ignore"), categorical),
|
|
196
|
+
("bool", "passthrough", boolean),
|
|
197
|
+
],
|
|
198
|
+
remainder="drop",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
pipeline = Pipeline(
|
|
202
|
+
[
|
|
203
|
+
("cleaner", DataCleaner(config)),
|
|
204
|
+
("features", preprocess),
|
|
205
|
+
("model", Ridge(alpha=1.0)),
|
|
206
|
+
]
|
|
207
|
+
)
|
|
208
|
+
pipeline.fit(X_train, y_train)
|
|
209
|
+
predictions = pipeline.predict(X_test)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Public API
|
|
213
|
+
|
|
214
|
+
| Name | Purpose |
|
|
215
|
+
| --- | --- |
|
|
216
|
+
| `CleanerConfig` | Validated cleaning configuration |
|
|
217
|
+
| `DataCleaner` | Fit/transform cleaner with save/load |
|
|
218
|
+
| `profile_dataframe` | Column-level missing/type/cardinality profile |
|
|
219
|
+
| `dataset_summary` | Row/column/missing/duplicate summary |
|
|
220
|
+
| `drop_duplicate_rows` | Explicit duplicate removal helper |
|
|
221
|
+
| `assert_frame_clean` | Guard against missing/infinite output values |
|
|
222
|
+
|
|
223
|
+
## Important production notes
|
|
224
|
+
|
|
225
|
+
- Fit the cleaner **after** splitting and **only** on training data.
|
|
226
|
+
- Do not pass IDs, free-text notes, or the target column to the cleaner.
|
|
227
|
+
- Keep `DataSource`-style constant columns out of the model, or let
|
|
228
|
+
`drop_constant_columns=True` remove them.
|
|
229
|
+
- `unseen_category="keep"` is the default because one-hot encoders with
|
|
230
|
+
`handle_unknown="ignore"` can handle an unseen label safely.
|
|
231
|
+
- Datetime columns are replaced by calendar features. Their missing values are
|
|
232
|
+
represented by the generated feature NaNs plus an optional missing indicator.
|
|
233
|
+
- IQR capping is skipped automatically when the IQR is zero, so constant or
|
|
234
|
+
near-constant numeric columns are not accidentally destroyed.
|
|
235
|
+
|
|
236
|
+
## Project layout
|
|
237
|
+
|
|
238
|
+
```text
|
|
239
|
+
.
|
|
240
|
+
├── data_cleaner.py # reusable library module
|
|
241
|
+
├── Universal_Data_Cleaner.ipynb # end-to-end reference workflow
|
|
242
|
+
├── tests/test_data_cleaner.py # unit and integration tests
|
|
243
|
+
├── pyproject.toml # package metadata and dev tooling
|
|
244
|
+
├── uv.lock # reproducible dependency lock
|
|
245
|
+
├── README.md
|
|
246
|
+
├── CHANGELOG.md
|
|
247
|
+
├── CONTRIBUTING.md
|
|
248
|
+
├── SECURITY.md
|
|
249
|
+
└── LICENSE
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Contributing
|
|
253
|
+
|
|
254
|
+
Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the local
|
|
255
|
+
setup, test command, and pull-request expectations.
|
|
256
|
+
|
|
257
|
+
## License
|
|
258
|
+
|
|
259
|
+
Released under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
data_cleaner.py,sha256=gKAajbF7LlpveJKLn5TgTXY697TZVHRciiI1vRrsdbs,29786
|
|
2
|
+
universal_data_cleaner-2.0.0.dist-info/licenses/LICENSE,sha256=IIjSKCAQ9LD91QeyxpwLtMueH-8-4Pn5Ak6CEVUR9GE,1068
|
|
3
|
+
universal_data_cleaner-2.0.0.dist-info/METADATA,sha256=qHKHjW9-wrmIQYJucrtUlJ5NZtjIKFbvBaCfDGrS0kg,8409
|
|
4
|
+
universal_data_cleaner-2.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
universal_data_cleaner-2.0.0.dist-info/top_level.txt,sha256=XxSHENeGPN6wanFeu6BCXGgSsjhPoLDipCFWT7qyZ30,13
|
|
6
|
+
universal_data_cleaner-2.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Haseeb Khan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
data_cleaner
|