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 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
+ [![CI](https://github.com/devlprkhan/universal-data-cleaner/actions/workflows/ci.yml/badge.svg)](https://github.com/devlprkhan/universal-data-cleaner/actions/workflows/ci.yml)
37
+ [![PyPI](https://img.shields.io/pypi/v/universal-data-cleaner.svg)](https://pypi.org/project/universal-data-cleaner/)
38
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/)
39
+ [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](LICENSE)
40
+ [![Managed with uv](https://img.shields.io/badge/managed%20with-uv-6f42c1.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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