universal-data-cleaner 2.2.0__tar.gz → 2.3.0__tar.gz

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.
Files changed (21) hide show
  1. {universal_data_cleaner-2.2.0/src/universal_data_cleaner.egg-info → universal_data_cleaner-2.3.0}/PKG-INFO +68 -1
  2. {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/README.md +67 -0
  3. {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/pyproject.toml +1 -1
  4. {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/data_cleaner/__init__.py +10 -0
  5. {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/data_cleaner/_version.py +2 -2
  6. universal_data_cleaner-2.3.0/src/data_cleaner/artifacts.py +171 -0
  7. {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/data_cleaner/cleaner.py +336 -4
  8. {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/data_cleaner/config.py +1 -0
  9. universal_data_cleaner-2.3.0/src/data_cleaner/decisions.py +50 -0
  10. {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0/src/universal_data_cleaner.egg-info}/PKG-INFO +68 -1
  11. {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/universal_data_cleaner.egg-info/SOURCES.txt +2 -0
  12. {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/tests/test_data_cleaner.py +223 -14
  13. {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/LICENSE +0 -0
  14. {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/setup.cfg +0 -0
  15. {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/data_cleaner/_roles.py +0 -0
  16. {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/data_cleaner/contract.py +0 -0
  17. {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/data_cleaner/profiling.py +0 -0
  18. {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/data_cleaner/py.typed +0 -0
  19. {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/universal_data_cleaner.egg-info/dependency_links.txt +0 -0
  20. {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/universal_data_cleaner.egg-info/requires.txt +0 -0
  21. {universal_data_cleaner-2.2.0 → universal_data_cleaner-2.3.0}/src/universal_data_cleaner.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: universal-data-cleaner
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Production-ready, scikit-learn-compatible data cleaning transformer for pandas.
5
5
  Author: Haseeb Khan
6
6
  License-Expression: MIT
@@ -241,6 +241,61 @@ restored = DataContract.from_json(payload)
241
241
  # restored = DataContract.from_yaml(contract.to_yaml())
242
242
  ```
243
243
 
244
+ ## Audit trail, manifests, and fingerprints
245
+
246
+ Every learned or configured decision is recorded during `fit()` and exposed as
247
+ a tidy DataFrame:
248
+
249
+ ```python
250
+ log = cleaner.decision_log()
251
+ # columns: column, action, reason, source, before, after
252
+ log[log["action"] == "cap_outliers"]
253
+ ```
254
+
255
+ `source` says whether a rule was `learned` from the training data, supplied via
256
+ `config`, declared in a `contract`, or `inferred` by the library. Imputation
257
+ values, outlier bounds, datetime expansion, missing indicators, dropped
258
+ constants, and identifier handling are all explained instead of hidden.
259
+
260
+ Build a JSON-serializable manifest for logs or a model registry:
261
+
262
+ ```python
263
+ import json
264
+
265
+ manifest = cleaner.to_manifest(X_train, include_fingerprint=True)
266
+ # library_version, artifact_version, environment, config, contract,
267
+ # input_schema, output_columns, fitted, decisions, fingerprint
268
+
269
+ json.dumps(manifest) # safe to ship to a registry or a log line
270
+ ```
271
+
272
+ `save()` embeds the manifest and the training fingerprint in the artifact, and
273
+ `load()` restores them:
274
+
275
+ ```python
276
+ cleaner.save("cleaner.joblib")
277
+ restored = DataCleaner.load("cleaner.joblib")
278
+ restored.artifact_manifest()
279
+ ```
280
+
281
+ Fingerprints are stable SHA-256 hashes of the schema plus summary statistics.
282
+ They never hash raw values, so they are safe to log:
283
+
284
+ ```python
285
+ from data_cleaner import (
286
+ assert_schema_stable,
287
+ compare_fingerprints,
288
+ fingerprint_dataframe,
289
+ )
290
+
291
+ expected = fingerprint_dataframe(X_train)
292
+ same = compare_fingerprints(expected, fingerprint_dataframe(X_new))
293
+ assert_schema_stable(X_new, expected) # raises ValueError on schema/stat drift
294
+ ```
295
+
296
+ Set `CleanerConfig(fingerprint_on_fit=True)` to compute the training fingerprint
297
+ once during `fit()`, so `to_manifest(include_fingerprint=True)` needs no data.
298
+
244
299
  ## scikit-learn Pipeline
245
300
 
246
301
  ```python
@@ -279,11 +334,19 @@ predictions = pipeline.predict(X_test)
279
334
  | --- | --- |
280
335
  | `CleanerConfig` | Validated cleaning configuration |
281
336
  | `DataCleaner` | Fit/transform cleaner with save/load and drift reporting |
337
+ | `CleaningDecision` | One auditable learned or configured decision |
282
338
  | `ColumnSpec` | Declarative expectations and semantics for one column |
283
339
  | `DataContract` | A set of column specs, with inference and (de)serialization |
284
340
  | `DriftReport` | Findings from a contract check, with a Markdown summary |
285
341
  | `DataCleaner.drift_report()` | Drift findings from the last `transform` |
286
342
  | `DataCleaner.fitted_contract()` | The contract used by the fitted cleaner |
343
+ | `DataCleaner.decision_log()` | Tidy DataFrame of cleaning decisions |
344
+ | `DataCleaner.to_manifest()` | JSON-serializable artifact manifest |
345
+ | `DataCleaner.artifact_manifest()` | Manifest stored in a loaded artifact |
346
+ | `DataCleaner.fingerprint()` | Stable schema + statistics fingerprint |
347
+ | `fingerprint_dataframe` | Fingerprint any DataFrame for lineage |
348
+ | `compare_fingerprints` | Compare two fingerprints |
349
+ | `assert_schema_stable` | Raise when a frame's fingerprint changed |
287
350
  | `profile_dataframe` | Column-level missing/type/cardinality profile |
288
351
  | `dataset_summary` | Row/column/missing/duplicate summary |
289
352
  | `drop_duplicate_rows` | Explicit duplicate removal helper |
@@ -305,6 +368,10 @@ predictions = pipeline.predict(X_test)
305
368
  represented by the generated feature NaNs plus an optional missing indicator.
306
369
  - IQR capping is skipped automatically when the IQR is zero, so constant or
307
370
  near-constant numeric columns are not accidentally destroyed.
371
+ - Persist `cleaner.to_manifest(X_train, include_fingerprint=True)` alongside the
372
+ model so you can audit exactly which cleaning rules produced an artifact.
373
+ - `decision_log()` explains every learned or configured rule; ship it to your
374
+ experiment tracker when a model's preprocessing changes.
308
375
 
309
376
  ## Project layout
310
377
 
@@ -208,6 +208,61 @@ restored = DataContract.from_json(payload)
208
208
  # restored = DataContract.from_yaml(contract.to_yaml())
209
209
  ```
210
210
 
211
+ ## Audit trail, manifests, and fingerprints
212
+
213
+ Every learned or configured decision is recorded during `fit()` and exposed as
214
+ a tidy DataFrame:
215
+
216
+ ```python
217
+ log = cleaner.decision_log()
218
+ # columns: column, action, reason, source, before, after
219
+ log[log["action"] == "cap_outliers"]
220
+ ```
221
+
222
+ `source` says whether a rule was `learned` from the training data, supplied via
223
+ `config`, declared in a `contract`, or `inferred` by the library. Imputation
224
+ values, outlier bounds, datetime expansion, missing indicators, dropped
225
+ constants, and identifier handling are all explained instead of hidden.
226
+
227
+ Build a JSON-serializable manifest for logs or a model registry:
228
+
229
+ ```python
230
+ import json
231
+
232
+ manifest = cleaner.to_manifest(X_train, include_fingerprint=True)
233
+ # library_version, artifact_version, environment, config, contract,
234
+ # input_schema, output_columns, fitted, decisions, fingerprint
235
+
236
+ json.dumps(manifest) # safe to ship to a registry or a log line
237
+ ```
238
+
239
+ `save()` embeds the manifest and the training fingerprint in the artifact, and
240
+ `load()` restores them:
241
+
242
+ ```python
243
+ cleaner.save("cleaner.joblib")
244
+ restored = DataCleaner.load("cleaner.joblib")
245
+ restored.artifact_manifest()
246
+ ```
247
+
248
+ Fingerprints are stable SHA-256 hashes of the schema plus summary statistics.
249
+ They never hash raw values, so they are safe to log:
250
+
251
+ ```python
252
+ from data_cleaner import (
253
+ assert_schema_stable,
254
+ compare_fingerprints,
255
+ fingerprint_dataframe,
256
+ )
257
+
258
+ expected = fingerprint_dataframe(X_train)
259
+ same = compare_fingerprints(expected, fingerprint_dataframe(X_new))
260
+ assert_schema_stable(X_new, expected) # raises ValueError on schema/stat drift
261
+ ```
262
+
263
+ Set `CleanerConfig(fingerprint_on_fit=True)` to compute the training fingerprint
264
+ once during `fit()`, so `to_manifest(include_fingerprint=True)` needs no data.
265
+
211
266
  ## scikit-learn Pipeline
212
267
 
213
268
  ```python
@@ -246,11 +301,19 @@ predictions = pipeline.predict(X_test)
246
301
  | --- | --- |
247
302
  | `CleanerConfig` | Validated cleaning configuration |
248
303
  | `DataCleaner` | Fit/transform cleaner with save/load and drift reporting |
304
+ | `CleaningDecision` | One auditable learned or configured decision |
249
305
  | `ColumnSpec` | Declarative expectations and semantics for one column |
250
306
  | `DataContract` | A set of column specs, with inference and (de)serialization |
251
307
  | `DriftReport` | Findings from a contract check, with a Markdown summary |
252
308
  | `DataCleaner.drift_report()` | Drift findings from the last `transform` |
253
309
  | `DataCleaner.fitted_contract()` | The contract used by the fitted cleaner |
310
+ | `DataCleaner.decision_log()` | Tidy DataFrame of cleaning decisions |
311
+ | `DataCleaner.to_manifest()` | JSON-serializable artifact manifest |
312
+ | `DataCleaner.artifact_manifest()` | Manifest stored in a loaded artifact |
313
+ | `DataCleaner.fingerprint()` | Stable schema + statistics fingerprint |
314
+ | `fingerprint_dataframe` | Fingerprint any DataFrame for lineage |
315
+ | `compare_fingerprints` | Compare two fingerprints |
316
+ | `assert_schema_stable` | Raise when a frame's fingerprint changed |
254
317
  | `profile_dataframe` | Column-level missing/type/cardinality profile |
255
318
  | `dataset_summary` | Row/column/missing/duplicate summary |
256
319
  | `drop_duplicate_rows` | Explicit duplicate removal helper |
@@ -272,6 +335,10 @@ predictions = pipeline.predict(X_test)
272
335
  represented by the generated feature NaNs plus an optional missing indicator.
273
336
  - IQR capping is skipped automatically when the IQR is zero, so constant or
274
337
  near-constant numeric columns are not accidentally destroyed.
338
+ - Persist `cleaner.to_manifest(X_train, include_fingerprint=True)` alongside the
339
+ model so you can audit exactly which cleaning rules produced an artifact.
340
+ - `decision_log()` explains every learned or configured rule; ship it to your
341
+ experiment tracker when a model's preprocessing changes.
275
342
 
276
343
  ## Project layout
277
344
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "universal-data-cleaner"
7
- version = "2.2.0"
7
+ version = "2.3.0"
8
8
  description = "Production-ready, scikit-learn-compatible data cleaning transformer for pandas."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -13,9 +13,15 @@ exactly as it did when the library shipped as a single module.
13
13
  from __future__ import annotations
14
14
 
15
15
  from ._version import __version__
16
+ from .artifacts import (
17
+ assert_schema_stable,
18
+ compare_fingerprints,
19
+ fingerprint_dataframe,
20
+ )
16
21
  from .cleaner import DataCleaner
17
22
  from .config import CleanerConfig
18
23
  from .contract import ColumnSpec, DataContract, DriftReport
24
+ from .decisions import CleaningDecision
19
25
  from .profiling import (
20
26
  assert_frame_clean,
21
27
  dataset_summary,
@@ -25,13 +31,17 @@ from .profiling import (
25
31
 
26
32
  __all__ = [
27
33
  "CleanerConfig",
34
+ "CleaningDecision",
28
35
  "ColumnSpec",
29
36
  "DataCleaner",
30
37
  "DataContract",
31
38
  "DriftReport",
32
39
  "__version__",
33
40
  "assert_frame_clean",
41
+ "assert_schema_stable",
42
+ "compare_fingerprints",
34
43
  "dataset_summary",
35
44
  "drop_duplicate_rows",
45
+ "fingerprint_dataframe",
36
46
  "profile_dataframe",
37
47
  ]
@@ -1,4 +1,4 @@
1
1
  """Single source of truth for the package version."""
2
2
 
3
- __version__ = "2.2.0"
4
- _ARTIFACT_VERSION = 1
3
+ __version__ = "2.3.0"
4
+ _ARTIFACT_VERSION = 2
@@ -0,0 +1,171 @@
1
+ """Reproducibility helpers: data fingerprints and environment metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import platform
9
+ from collections.abc import Sequence
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+ import sklearn
15
+
16
+ from ._roles import _series_kind
17
+ from ._version import __version__
18
+
19
+
20
+ def package_versions() -> dict[str, str]:
21
+ """Return the versions of the runtime that produced an artifact."""
22
+ return {
23
+ "python": platform.python_version(),
24
+ "pandas": pd.__version__,
25
+ "numpy": np.__version__,
26
+ "scikit_learn": sklearn.__version__,
27
+ "universal_data_cleaner": __version__,
28
+ }
29
+
30
+
31
+ def _json_default(value: object) -> str:
32
+ """Render unknown scalar types (timestamps, decimals) for JSON encoding."""
33
+ return str(value)
34
+
35
+
36
+ def _canonical_number(value: Any) -> float | str | None:
37
+ """Normalize a number so JSON hashing is stable and NaN-safe."""
38
+ if value is None:
39
+ return None
40
+ try:
41
+ number = float(value)
42
+ except (TypeError, ValueError):
43
+ return str(value)
44
+ if math.isnan(number):
45
+ return "nan"
46
+ if math.isinf(number):
47
+ return "inf" if number > 0 else "-inf"
48
+ return round(number, 10)
49
+
50
+
51
+ def _column_stats(series: pd.Series) -> dict[str, object]:
52
+ kind = _series_kind(series)
53
+ stats: dict[str, object] = {
54
+ "name": str(series.name),
55
+ "kind": kind,
56
+ "nulls": int(series.isna().sum()),
57
+ }
58
+
59
+ if kind == "numeric":
60
+ numeric = pd.to_numeric(series, errors="coerce")
61
+ numeric = numeric.replace([np.inf, -np.inf], np.nan).dropna()
62
+ stats.update(
63
+ {
64
+ "count": int(numeric.size),
65
+ "mean": _canonical_number(numeric.mean()) if numeric.size else None,
66
+ "std": _canonical_number(numeric.std()) if numeric.size > 1 else None,
67
+ "min": _canonical_number(numeric.min()) if numeric.size else None,
68
+ "max": _canonical_number(numeric.max()) if numeric.size else None,
69
+ }
70
+ )
71
+ elif kind == "boolean":
72
+ valid = series.dropna()
73
+ flags = valid.astype(bool)
74
+ stats.update(
75
+ {
76
+ "count": int(valid.size),
77
+ "true": int(flags.sum()),
78
+ "false": int((~flags).sum()),
79
+ }
80
+ )
81
+ elif kind == "datetime":
82
+ valid = series.dropna()
83
+ stats.update(
84
+ {
85
+ "count": int(valid.size),
86
+ "min": str(valid.min()) if not valid.empty else None,
87
+ "max": str(valid.max()) if not valid.empty else None,
88
+ }
89
+ )
90
+ else:
91
+ try:
92
+ unique: int | None = int(series.nunique(dropna=True))
93
+ except TypeError:
94
+ unique = None
95
+ stats.update({"count": int(series.notna().sum()), "unique": unique})
96
+
97
+ return stats
98
+
99
+
100
+ def fingerprint_dataframe(
101
+ frame: pd.DataFrame, columns: Sequence[str] | None = None
102
+ ) -> str:
103
+ """Return a stable SHA-256 fingerprint of a frame's schema and summary stats.
104
+
105
+ The hash deliberately excludes raw values: it is a lineage fingerprint, not
106
+ a row-level checksum. Column order participates in the hash, and summary
107
+ statistics are rounded so the result is stable across platforms.
108
+
109
+ Parameters
110
+ ----------
111
+ frame:
112
+ DataFrame to fingerprint.
113
+ columns:
114
+ Optional subset/order of columns. Defaults to all columns in frame
115
+ order.
116
+
117
+ Raises
118
+ ------
119
+ TypeError
120
+ If ``frame`` is not a pandas DataFrame.
121
+ ValueError
122
+ If ``frame`` has duplicate column names.
123
+ KeyError
124
+ If a requested column is missing from ``frame``.
125
+ """
126
+ if not isinstance(frame, pd.DataFrame):
127
+ raise TypeError("frame must be a pandas DataFrame")
128
+ if frame.columns.has_duplicates:
129
+ duplicates = frame.columns[frame.columns.duplicated()].tolist()
130
+ raise ValueError(f"Duplicate column names are not allowed: {duplicates}")
131
+
132
+ if columns is None:
133
+ selected = list(frame.columns)
134
+ else:
135
+ selected = [str(column) for column in columns]
136
+ missing = [column for column in selected if column not in frame.columns]
137
+ if missing:
138
+ raise KeyError(f"Columns not found in frame: {missing}")
139
+
140
+ payload = {
141
+ "n_rows": len(frame),
142
+ "n_columns": len(selected),
143
+ "columns": [_column_stats(frame[column]) for column in selected],
144
+ }
145
+ encoded = json.dumps(
146
+ payload,
147
+ sort_keys=True,
148
+ separators=(",", ":"),
149
+ default=_json_default,
150
+ )
151
+ return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
152
+
153
+
154
+ def compare_fingerprints(left: str, right: str) -> bool:
155
+ """Return ``True`` when two fingerprints are identical."""
156
+ return left == right
157
+
158
+
159
+ def assert_schema_stable(
160
+ frame: pd.DataFrame, expected: str, *, name: str = "frame"
161
+ ) -> None:
162
+ """Raise ``ValueError`` when ``frame`` does not match ``expected``.
163
+
164
+ ``expected`` is a fingerprint produced by :func:`fingerprint_dataframe`.
165
+ """
166
+ actual = fingerprint_dataframe(frame)
167
+ if not compare_fingerprints(actual, expected):
168
+ raise ValueError(
169
+ f"{name} fingerprint {actual} does not match expected {expected}. "
170
+ "The schema or summary statistics changed."
171
+ )