data-drift-lite 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ """Detect whether production data has drifted from training data, column by column.
2
+
3
+ import data_drift_lite
4
+ report = data_drift_lite.detect(reference_df, current_df)
5
+ report.drifted, report.drifted_columns, report.summary(), report.to_dict()
6
+ """
7
+
8
+ from ._core import DriftMonitor, detect
9
+ from ._report import ColumnDrift, DriftReport, SchemaDrift
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = [
14
+ "detect",
15
+ "DriftMonitor",
16
+ "DriftReport",
17
+ "ColumnDrift",
18
+ "SchemaDrift",
19
+ "__version__",
20
+ ]
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m data_drift_lite reference.csv current.csv``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,359 @@
1
+ """``detect()`` and ``DriftMonitor``: profile the reference once, compare any number of batches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Dict, Hashable, Iterable, List, Optional, Tuple, Union
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+ from . import _stats as st
13
+ from ._io import TableLike, load_table
14
+ from ._report import ColumnDrift, DriftReport, SchemaDrift
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ TINY_ROWS = 20 # below this many rows the tests are unreliable; the report says so
19
+ MISSING_SHIFT_NOTE = 0.1 # note when the missing share moves by more than this
20
+ HIGH_CARDINALITY = 100 # note when a categorical column has more categories than this
21
+ _PREVIEW = 5 # how many category names to list in a note
22
+
23
+ ColumnsArg = Optional[Union[Hashable, Iterable[Hashable]]]
24
+
25
+
26
+ @dataclass
27
+ class _Profile:
28
+ """Everything about one reference column that a batch is compared against."""
29
+
30
+ name: Hashable
31
+ family: str
32
+ dtype: str
33
+ values: st.ColumnValues
34
+ stats: Dict[str, Any]
35
+ edges: np.ndarray = field(default_factory=lambda: np.empty(0, dtype="float64"))
36
+ bin_counts: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.int64))
37
+ cat_counts: Dict[str, int] = field(default_factory=dict)
38
+
39
+ @property
40
+ def kind(self) -> str:
41
+ return st.kind_of(self.family)
42
+
43
+
44
+ def _profile_column(name: Hashable, series: pd.Series) -> _Profile:
45
+ cv = st.extract(series)
46
+ dtype = str(series.dtype)
47
+ if cv.family == "category":
48
+ counts = st.value_counts(cv.values)
49
+ return _Profile(name, cv.family, dtype, cv, st.category_stats(cv, counts, dtype), cat_counts=counts)
50
+ edges = st.numeric_edges(cv.values)
51
+ return _Profile(
52
+ name,
53
+ cv.family,
54
+ dtype,
55
+ cv,
56
+ st.numeric_stats(cv, dtype),
57
+ edges=edges,
58
+ bin_counts=st.bin_counts(cv.values, edges),
59
+ )
60
+
61
+
62
+ # --------------------------------------------------------------------------- per-column comparison
63
+
64
+
65
+ def _preview(names: Iterable[str]) -> str:
66
+ listed = list(names)
67
+ shown = ", ".join(repr(n) for n in listed[:_PREVIEW])
68
+ extra = len(listed) - _PREVIEW
69
+ return shown + (f" (+{extra} more)" if extra > 0 else "")
70
+
71
+
72
+ def _skipped_note(test_name: str, sides: List[str]) -> str:
73
+ where = " and ".join(sides) if sides else "reference or current"
74
+ return f"{test_name} skipped: no usable values in {where}"
75
+
76
+
77
+ def _is_drifted(
78
+ p_value: Optional[float],
79
+ psi_value: Optional[float],
80
+ threshold: Optional[float],
81
+ psi_threshold: Optional[float],
82
+ ) -> bool:
83
+ if p_value is not None and threshold is not None and p_value < threshold:
84
+ return True
85
+ if psi_value is not None and psi_threshold is not None and psi_value > psi_threshold:
86
+ return True
87
+ return False
88
+
89
+
90
+ def _compare_numeric(
91
+ profile: _Profile, cv: st.ColumnValues, notes: List[str]
92
+ ) -> Tuple[Optional[float], Optional[float], Optional[float]]:
93
+ ref = profile.values
94
+ statistic, p_value = st.ks_test(ref.values, cv.values)
95
+ ref_counts = profile.bin_counts
96
+ cur_counts = st.bin_counts(cv.values, profile.edges)
97
+ if ref.n_missing or cv.n_missing:
98
+ # missing values form their own bin, so a change in missingness shows up in PSI
99
+ ref_counts = np.append(ref_counts, ref.n_missing)
100
+ cur_counts = np.append(cur_counts, cv.n_missing)
101
+ psi_value = st.psi(ref_counts, cur_counts)
102
+ if statistic is None:
103
+ sides = [label for label, x in (("reference", ref), ("current", cv)) if x.values.size == 0]
104
+ notes.append(_skipped_note("KS test", sides))
105
+ if cv.n_nonfinite:
106
+ notes.append(f"current has {cv.n_nonfinite} non-finite value(s), counted as missing")
107
+ return statistic, p_value, psi_value
108
+
109
+
110
+ def _compare_categorical(
111
+ profile: _Profile, cv: st.ColumnValues, notes: List[str]
112
+ ) -> Tuple[Optional[float], Optional[float], Optional[float], Dict[str, int]]:
113
+ ref = profile.values
114
+ cur_counts = st.value_counts(cv.values)
115
+ seen = list(profile.cat_counts)
116
+ unseen = {cat: n for cat, n in cur_counts.items() if cat not in profile.cat_counts}
117
+ # PSI: the reference categories plus one pooled bin for anything the reference never saw
118
+ ref_psi = [profile.cat_counts[cat] for cat in seen] + [0]
119
+ cur_psi = [cur_counts.get(cat, 0) for cat in seen] + [sum(unseen.values())]
120
+ # chi-square: every category from either side
121
+ union = seen + list(unseen)
122
+ ref_chi = [profile.cat_counts.get(cat, 0) for cat in union]
123
+ cur_chi = [cur_counts.get(cat, 0) for cat in union]
124
+ if ref.n_missing or cv.n_missing:
125
+ ref_psi.append(ref.n_missing)
126
+ cur_psi.append(cv.n_missing)
127
+ ref_chi.append(ref.n_missing)
128
+ cur_chi.append(cv.n_missing)
129
+ statistic, p_value = st.chi2_test(np.array([ref_chi, cur_chi], dtype="float64"))
130
+ psi_value = st.psi(ref_psi, cur_psi)
131
+ if statistic is None:
132
+ sides = [label for label, x in (("reference", ref), ("current", cv)) if x.n_rows == 0]
133
+ notes.append(_skipped_note("chi-square test", sides))
134
+ if unseen:
135
+ word = "category" if len(unseen) == 1 else "categories"
136
+ notes.append(f"{len(unseen)} {word} unseen in reference: {_preview(unseen)}")
137
+ if len(union) > HIGH_CARDINALITY:
138
+ notes.append(
139
+ f"high cardinality ({len(union)} categories); the chi-square p-value may be unreliable"
140
+ )
141
+ return statistic, p_value, psi_value, cur_counts
142
+
143
+
144
+ def _compare(
145
+ profile: _Profile,
146
+ series: pd.Series,
147
+ threshold: Optional[float],
148
+ psi_threshold: Optional[float],
149
+ ) -> ColumnDrift:
150
+ cv = st.extract(series)
151
+ dtype = str(series.dtype)
152
+ notes: List[str] = []
153
+ if profile.kind == "numeric":
154
+ test = "ks"
155
+ statistic, p_value, psi_value = _compare_numeric(profile, cv, notes)
156
+ current_stats = st.numeric_stats(cv, dtype)
157
+ else:
158
+ test = "chi2"
159
+ statistic, p_value, psi_value, cur_counts = _compare_categorical(profile, cv, notes)
160
+ current_stats = st.category_stats(cv, cur_counts, dtype)
161
+ before, after = profile.values.missing_share, cv.missing_share
162
+ if abs(after - before) > MISSING_SHIFT_NOTE:
163
+ notes.append(f"missing share moved from {before:.1%} to {after:.1%}")
164
+ return ColumnDrift(
165
+ kind=profile.kind,
166
+ statistic=statistic,
167
+ p_value=p_value,
168
+ psi=psi_value,
169
+ drifted=_is_drifted(p_value, psi_value, threshold, psi_threshold),
170
+ reference_stats=dict(profile.stats),
171
+ current_stats=current_stats,
172
+ name=str(profile.name),
173
+ test=test,
174
+ notes=notes,
175
+ )
176
+
177
+
178
+ # --------------------------------------------------------------------------- inputs and options
179
+
180
+
181
+ def _check_options(threshold: Optional[float], psi_threshold: Optional[float], sample: Optional[int]) -> None:
182
+ if threshold is not None and not (0.0 <= float(threshold) <= 1.0):
183
+ raise ValueError("threshold must be between 0 and 1, or None to disable the p-value rule")
184
+ if psi_threshold is not None and not (float(psi_threshold) >= 0.0):
185
+ raise ValueError("psi_threshold must be >= 0, or None to disable the PSI rule")
186
+ if sample is not None and (
187
+ isinstance(sample, bool) or not isinstance(sample, (int, np.integer)) or sample < 1
188
+ ):
189
+ raise ValueError("sample must be a positive integer, or None to disable sampling")
190
+
191
+
192
+ def _load(source: TableLike, label: str) -> pd.DataFrame:
193
+ frame = load_table(source, label)
194
+ if frame.columns.has_duplicates:
195
+ dupes = sorted({str(c) for c in frame.columns[frame.columns.duplicated()]})
196
+ raise ValueError(f"{label} has duplicate column names: {', '.join(dupes)}")
197
+ return frame
198
+
199
+
200
+ def _maybe_sample(
201
+ frame: pd.DataFrame, sample: Optional[int], random_state: Any, label: str
202
+ ) -> Tuple[pd.DataFrame, Optional[str]]:
203
+ n_rows = len(frame)
204
+ if sample is None or n_rows <= sample:
205
+ return frame, None
206
+ sampled = frame.sample(n=int(sample), random_state=random_state)
207
+ note = f"{label} sampled down to {int(sample):,} of {n_rows:,} rows (random_state={random_state!r})"
208
+ return sampled, note
209
+
210
+
211
+ def _tiny_note(n_rows: int, label: str) -> Optional[str]:
212
+ if n_rows >= TINY_ROWS:
213
+ return None
214
+ rows = "row" if n_rows == 1 else "rows"
215
+ return f"{label} has only {n_rows} {rows} (fewer than {TINY_ROWS}); test results are unreliable at this size"
216
+
217
+
218
+ def _normalize_columns(columns: ColumnsArg, frame: pd.DataFrame) -> Optional[List[Hashable]]:
219
+ if columns is None:
220
+ return None
221
+ if isinstance(columns, (str, bytes)) or not isinstance(columns, Iterable):
222
+ requested: List[Hashable] = [columns]
223
+ else:
224
+ requested = list(columns)
225
+ if not requested:
226
+ raise ValueError("columns= must name at least one column; pass None to compare every column")
227
+ unknown = [c for c in requested if c not in frame.columns]
228
+ if unknown:
229
+ raise ValueError(f"columns not found in reference: {', '.join(repr(c) for c in unknown)}")
230
+ ordered: List[Hashable] = []
231
+ for c in requested:
232
+ if c not in ordered:
233
+ ordered.append(c)
234
+ return ordered
235
+
236
+
237
+ # --------------------------------------------------------------------------- public API
238
+
239
+
240
+ class DriftMonitor:
241
+ """Profile a reference dataset once, then check any number of batches against it.
242
+
243
+ Takes the same options as :func:`detect`. The reference is loaded, optionally
244
+ sampled and profiled in ``__init__``, so each :meth:`check` only has to look at
245
+ the batch. Use it when scoring many batches against the same training data.
246
+
247
+ Attributes:
248
+ columns: the reference columns that batches are compared on.
249
+ reference_rows: rows in the (possibly sampled) reference.
250
+ """
251
+
252
+ def __init__(
253
+ self,
254
+ reference: TableLike,
255
+ *,
256
+ columns: ColumnsArg = None,
257
+ threshold: Optional[float] = 0.05,
258
+ psi_threshold: Optional[float] = 0.2,
259
+ sample: Optional[int] = 100_000,
260
+ random_state: Any = 0,
261
+ ) -> None:
262
+ _check_options(threshold, psi_threshold, sample)
263
+ frame = _load(reference, "reference")
264
+ frame, sample_note = _maybe_sample(frame, sample, random_state, "reference")
265
+ self.threshold = threshold
266
+ self.psi_threshold = psi_threshold
267
+ self.sample = sample
268
+ self.random_state = random_state
269
+ self._requested = _normalize_columns(columns, frame)
270
+ self.columns: List[Hashable] = (
271
+ list(self._requested) if self._requested is not None else list(frame.columns)
272
+ )
273
+ self.reference_rows = int(len(frame))
274
+ self._reference_columns = set(frame.columns)
275
+ self._profiles = {name: _profile_column(name, frame[name]) for name in self.columns}
276
+ tiny_note = _tiny_note(self.reference_rows, "reference")
277
+ self._notes = [note for note in (sample_note, tiny_note) if note]
278
+ if tiny_note:
279
+ logger.warning(tiny_note)
280
+
281
+ def check(self, batch: TableLike) -> DriftReport:
282
+ """Compare one batch (DataFrame or .csv/.parquet path) against the reference."""
283
+ frame = _load(batch, "current")
284
+ frame, sample_note = _maybe_sample(frame, self.sample, self.random_state, "current")
285
+ notes = list(self._notes)
286
+ if sample_note:
287
+ notes.append(sample_note)
288
+ tiny_note = _tiny_note(len(frame), "current")
289
+ if tiny_note:
290
+ notes.append(tiny_note)
291
+ logger.warning(tiny_note)
292
+
293
+ present = set(frame.columns)
294
+ missing = [name for name in self.columns if name not in present]
295
+ if self._requested is None:
296
+ new = [name for name in frame.columns if name not in self._reference_columns]
297
+ else:
298
+ new = []
299
+ dtype_changed: Dict[Hashable, Tuple[str, str]] = {}
300
+ columns: Dict[Hashable, ColumnDrift] = {}
301
+ for name in self.columns:
302
+ if name not in present:
303
+ continue
304
+ profile = self._profiles[name]
305
+ series = frame[name]
306
+ if st.family_of(series.dtype) != profile.family:
307
+ dtype_changed[name] = (profile.dtype, str(series.dtype))
308
+ continue
309
+ columns[name] = _compare(profile, series, self.threshold, self.psi_threshold)
310
+
311
+ return DriftReport(
312
+ columns=columns,
313
+ schema=SchemaDrift(missing, new, dtype_changed),
314
+ threshold=self.threshold,
315
+ psi_threshold=self.psi_threshold,
316
+ reference_rows=self.reference_rows,
317
+ current_rows=int(len(frame)),
318
+ notes=notes,
319
+ )
320
+
321
+
322
+ def detect(
323
+ reference: TableLike,
324
+ current: TableLike,
325
+ *,
326
+ columns: ColumnsArg = None,
327
+ threshold: Optional[float] = 0.05,
328
+ psi_threshold: Optional[float] = 0.2,
329
+ sample: Optional[int] = 100_000,
330
+ random_state: Any = 0,
331
+ ) -> DriftReport:
332
+ """Detect whether ``current`` has drifted from ``reference``, column by column.
333
+
334
+ Args:
335
+ reference: training-time data - a DataFrame, a Series, or a .csv/.parquet path.
336
+ current: the data to check - same accepted types.
337
+ columns: compare only these columns (default: every reference column).
338
+ threshold: a column is drifted when its test p-value is below this
339
+ (KS test for numeric columns, chi-square for categorical). ``None``
340
+ switches the p-value rule off.
341
+ psi_threshold: a column is drifted when its Population Stability Index is
342
+ above this. ``None`` switches the PSI rule off.
343
+ sample: cap each side at this many random rows to keep large checks fast;
344
+ ``None`` uses every row.
345
+ random_state: seed for that sampling, so results are reproducible.
346
+
347
+ Returns:
348
+ A :class:`DriftReport` with one :class:`ColumnDrift` per compared column
349
+ plus schema differences (missing / new / type-changed columns).
350
+ """
351
+ monitor = DriftMonitor(
352
+ reference,
353
+ columns=columns,
354
+ threshold=threshold,
355
+ psi_threshold=psi_threshold,
356
+ sample=sample,
357
+ random_state=random_state,
358
+ )
359
+ return monitor.check(current)
data_drift_lite/_io.py ADDED
@@ -0,0 +1,47 @@
1
+ """Input loading: a pandas DataFrame, a Series, or a path to a .csv / .parquet file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Union
8
+
9
+ import pandas as pd
10
+
11
+ TableLike = Union[pd.DataFrame, pd.Series, str, "os.PathLike[str]"]
12
+
13
+
14
+ def load_table(source: TableLike, label: str = "data") -> pd.DataFrame:
15
+ """Return ``source`` as a DataFrame, reading .csv/.tsv/.parquet files from disk.
16
+
17
+ DataFrames are returned as-is (never copied or mutated); a Series becomes a
18
+ one-column frame. ``label`` names the argument in error messages.
19
+ """
20
+ if isinstance(source, pd.DataFrame):
21
+ return source
22
+ if isinstance(source, pd.Series):
23
+ return source.to_frame()
24
+ if isinstance(source, (str, os.PathLike)):
25
+ path = Path(source)
26
+ if not path.is_file():
27
+ raise FileNotFoundError(f"{label}: no such file: {path}")
28
+ suffixes = [s.lower() for s in path.suffixes]
29
+ if ".parquet" in suffixes or ".pq" in suffixes:
30
+ try:
31
+ return pd.read_parquet(path)
32
+ except ImportError as exc:
33
+ raise ImportError(
34
+ "reading .parquet files needs pyarrow: "
35
+ 'pip install "data-drift-lite[parquet]"'
36
+ ) from exc
37
+ if ".csv" in suffixes:
38
+ return pd.read_csv(path)
39
+ if ".tsv" in suffixes:
40
+ return pd.read_csv(path, sep="\t")
41
+ raise ValueError(
42
+ f"{label}: unsupported file type {path.suffix!r}; expected .csv, .tsv or .parquet"
43
+ )
44
+ raise TypeError(
45
+ f"{label} must be a pandas DataFrame, a Series, or a path to a .csv/.parquet file, "
46
+ f"not {type(source).__name__}"
47
+ )
@@ -0,0 +1,264 @@
1
+ """Result objects: ``ColumnDrift``, ``SchemaDrift`` and ``DriftReport``.
2
+
3
+ All three are plain dataclasses. ``to_dict()`` is JSON-safe (no numpy scalars,
4
+ no NaN/inf) and ``summary()`` is a short human-readable text.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Dict, List, Optional, Tuple
12
+
13
+ import numpy as np
14
+
15
+ _NAME_WIDTH = 40
16
+
17
+
18
+ def json_safe(obj: Any) -> Any:
19
+ """Recursively turn numpy scalars, tuples and non-finite floats into JSON-safe values."""
20
+ if isinstance(obj, dict):
21
+ return {str(k): json_safe(v) for k, v in obj.items()}
22
+ if isinstance(obj, (list, tuple, set)):
23
+ return [json_safe(v) for v in obj]
24
+ if isinstance(obj, (bool, np.bool_)):
25
+ return bool(obj)
26
+ if isinstance(obj, (int, np.integer)):
27
+ return int(obj)
28
+ if isinstance(obj, (float, np.floating)):
29
+ value = float(obj)
30
+ return value if math.isfinite(value) else None
31
+ if obj is None or isinstance(obj, str):
32
+ return obj
33
+ return str(obj)
34
+
35
+
36
+ def _fmt(value: Optional[float]) -> str:
37
+ return "n/a" if value is None else f"{value:.4f}"
38
+
39
+
40
+ def _fmt_p(value: Optional[float]) -> str:
41
+ if value is None:
42
+ return "n/a"
43
+ return f"{value:.4f}" if value >= 1e-4 else f"{value:.1e}"
44
+
45
+
46
+ def _clip(text: str, width: int = _NAME_WIDTH) -> str:
47
+ return text if len(text) <= width else text[: width - 3] + "..."
48
+
49
+
50
+ @dataclass
51
+ class ColumnDrift:
52
+ """Drift result for one column.
53
+
54
+ ``statistic`` and ``p_value`` come from the KS test (numeric columns) or the
55
+ chi-square test (categorical columns); ``psi`` is the Population Stability
56
+ Index. Each is ``None`` when it could not be computed; ``notes`` says why.
57
+ """
58
+
59
+ kind: str
60
+ statistic: Optional[float]
61
+ p_value: Optional[float]
62
+ psi: Optional[float]
63
+ drifted: bool
64
+ reference_stats: Dict[str, Any]
65
+ current_stats: Dict[str, Any]
66
+ name: str = ""
67
+ test: str = ""
68
+ notes: List[str] = field(default_factory=list)
69
+
70
+ def to_dict(self) -> Dict[str, Any]:
71
+ """JSON-safe dict of this column's result."""
72
+ return json_safe(
73
+ {
74
+ "name": self.name,
75
+ "kind": self.kind,
76
+ "test": self.test,
77
+ "statistic": self.statistic,
78
+ "p_value": self.p_value,
79
+ "psi": self.psi,
80
+ "drifted": self.drifted,
81
+ "reference_stats": self.reference_stats,
82
+ "current_stats": self.current_stats,
83
+ "notes": list(self.notes),
84
+ }
85
+ )
86
+
87
+
88
+ @dataclass
89
+ class SchemaDrift:
90
+ """Structural differences between the reference and the current data."""
91
+
92
+ missing_columns: List[Any] = field(default_factory=list)
93
+ new_columns: List[Any] = field(default_factory=list)
94
+ dtype_changed: Dict[Any, Tuple[str, str]] = field(default_factory=dict)
95
+
96
+ @property
97
+ def drifted(self) -> bool:
98
+ """True when a reference column is missing or changed its type family.
99
+
100
+ New columns are reported but do not count: nothing the reference relied on
101
+ has changed.
102
+ """
103
+ return bool(self.missing_columns or self.dtype_changed)
104
+
105
+ @property
106
+ def changed(self) -> bool:
107
+ """True when anything at all differs, new columns included."""
108
+ return bool(self.missing_columns or self.new_columns or self.dtype_changed)
109
+
110
+ def to_dict(self) -> Dict[str, Any]:
111
+ """JSON-safe dict of the schema differences."""
112
+ return json_safe(
113
+ {
114
+ "missing_columns": list(self.missing_columns),
115
+ "new_columns": list(self.new_columns),
116
+ "dtype_changed": {
117
+ name: {"reference": before, "current": after}
118
+ for name, (before, after) in self.dtype_changed.items()
119
+ },
120
+ "drifted": self.drifted,
121
+ }
122
+ )
123
+
124
+
125
+ @dataclass
126
+ class DriftReport:
127
+ """Everything :func:`data_drift_lite.detect` found.
128
+
129
+ ``columns`` maps each compared column to its :class:`ColumnDrift`, in reference
130
+ order. Columns that are missing from the current data or changed type family are
131
+ not compared; they live in ``schema``.
132
+ """
133
+
134
+ columns: Dict[Any, ColumnDrift]
135
+ schema: SchemaDrift = field(default_factory=SchemaDrift)
136
+ threshold: Optional[float] = 0.05
137
+ psi_threshold: Optional[float] = 0.2
138
+ reference_rows: int = 0
139
+ current_rows: int = 0
140
+ notes: List[str] = field(default_factory=list)
141
+
142
+ # ---------------------------------------------------------------- headline numbers
143
+
144
+ @property
145
+ def drifted_columns(self) -> List[Any]:
146
+ """Names of the compared columns flagged as drifted, in reference order."""
147
+ return [name for name, col in self.columns.items() if col.drifted]
148
+
149
+ @property
150
+ def drift_share(self) -> float:
151
+ """Fraction of compared columns that drifted (0.0 when nothing was compared)."""
152
+ return len(self.drifted_columns) / len(self.columns) if self.columns else 0.0
153
+
154
+ @property
155
+ def drifted(self) -> bool:
156
+ """True when any column drifted, a column went missing, or a type family changed."""
157
+ return bool(self.drifted_columns) or self.schema.drifted
158
+
159
+ @property
160
+ def missing_columns(self) -> List[Any]:
161
+ """Reference columns absent from the current data."""
162
+ return self.schema.missing_columns
163
+
164
+ @property
165
+ def new_columns(self) -> List[Any]:
166
+ """Current columns absent from the reference."""
167
+ return self.schema.new_columns
168
+
169
+ @property
170
+ def dtype_changed(self) -> Dict[Any, Tuple[str, str]]:
171
+ """Columns whose type family changed: name -> (reference dtype, current dtype)."""
172
+ return self.schema.dtype_changed
173
+
174
+ # ---------------------------------------------------------------- output
175
+
176
+ def _rule_text(self) -> str:
177
+ parts = []
178
+ if self.threshold is not None:
179
+ parts.append(f"p < {self.threshold:g}")
180
+ if self.psi_threshold is not None:
181
+ parts.append(f"PSI > {self.psi_threshold:g}")
182
+ return "drifted when " + " or ".join(parts) if parts else "drift rules disabled (both thresholds are None)"
183
+
184
+ def _table_lines(self) -> List[str]:
185
+ headers = ("column", "kind", "test", "statistic", "p-value", "PSI", "status")
186
+ rows = [
187
+ (
188
+ _clip(str(name)),
189
+ col.kind,
190
+ col.test or "-",
191
+ _fmt(col.statistic),
192
+ _fmt_p(col.p_value),
193
+ _fmt(col.psi),
194
+ "DRIFTED" if col.drifted else "ok",
195
+ )
196
+ for name, col in self.columns.items()
197
+ ]
198
+ widths = [max(len(header), *(len(row[i]) for row in rows)) for i, header in enumerate(headers)]
199
+ right = {3, 4, 5}
200
+
201
+ def render(cells: Tuple[str, ...]) -> str:
202
+ padded = [
203
+ cell.rjust(widths[i]) if i in right else cell.ljust(widths[i]) for i, cell in enumerate(cells)
204
+ ]
205
+ return " " + " ".join(padded).rstrip()
206
+
207
+ return [render(headers)] + [render(row) for row in rows]
208
+
209
+ def summary(self) -> str:
210
+ """Human-readable summary: verdict, one line per column, schema changes, notes."""
211
+ n_compared = len(self.columns)
212
+ n_drifted = len(self.drifted_columns)
213
+ if n_drifted:
214
+ verdict = f"DRIFT DETECTED: {n_drifted} of {n_compared} columns drifted ({self.drift_share:.0%})"
215
+ elif self.schema.drifted:
216
+ verdict = f"DRIFT DETECTED: schema changed (0 of {n_compared} columns drifted)"
217
+ elif n_compared:
218
+ verdict = f"no drift detected ({n_compared} columns compared)"
219
+ else:
220
+ verdict = "no columns compared"
221
+ lines = [
222
+ f"data-drift-lite: {verdict}",
223
+ f"reference rows: {self.reference_rows:,} | current rows: {self.current_rows:,} | {self._rule_text()}",
224
+ ]
225
+ if n_compared:
226
+ lines.append("")
227
+ lines.extend(self._table_lines())
228
+ if self.schema.changed:
229
+ lines.append("")
230
+ lines.append("schema:")
231
+ if self.schema.missing_columns:
232
+ names = ", ".join(str(c) for c in self.schema.missing_columns)
233
+ lines.append(f" - missing in current: {names}")
234
+ if self.schema.new_columns:
235
+ names = ", ".join(str(c) for c in self.schema.new_columns)
236
+ lines.append(f" - new in current: {names}")
237
+ for name, (before, after) in self.schema.dtype_changed.items():
238
+ lines.append(f" - dtype changed: {name} ({before} -> {after})")
239
+ column_notes = [f"{name}: {note}" for name, col in self.columns.items() for note in col.notes]
240
+ if self.notes or column_notes:
241
+ lines.append("")
242
+ lines.append("notes:")
243
+ lines.extend(f" - {note}" for note in list(self.notes) + column_notes)
244
+ return "\n".join(lines)
245
+
246
+ def to_dict(self) -> Dict[str, Any]:
247
+ """JSON-safe dict with the verdict, every column, the schema section and notes."""
248
+ return json_safe(
249
+ {
250
+ "drifted": self.drifted,
251
+ "drift_share": self.drift_share,
252
+ "drifted_columns": self.drifted_columns,
253
+ "threshold": self.threshold,
254
+ "psi_threshold": self.psi_threshold,
255
+ "reference_rows": self.reference_rows,
256
+ "current_rows": self.current_rows,
257
+ "columns": {name: col.to_dict() for name, col in self.columns.items()},
258
+ "schema": self.schema.to_dict(),
259
+ "notes": list(self.notes),
260
+ }
261
+ )
262
+
263
+ def __str__(self) -> str:
264
+ return self.summary()
@@ -0,0 +1,223 @@
1
+ """Statistical building blocks: column typing, value extraction, binning, PSI, KS, chi-square.
2
+
3
+ Everything here works on plain numpy arrays and returns plain Python numbers (or
4
+ ``None`` when a quantity cannot be computed), so the report stays JSON-friendly.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from dataclasses import dataclass
11
+ from typing import Any, Dict, Optional, Tuple
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+ from pandas.api import types as pdt
16
+ from scipy import stats as sps
17
+
18
+ PSI_EPS = 1e-4 # floor for bin shares so log(0) never happens
19
+ LOW_CARDINALITY = 10 # numeric columns with this many distinct values or fewer: one bin per value
20
+ QUANTILE_BINS = 10 # otherwise: quantile bins built from the reference
21
+ TOP_CATEGORIES = 5 # categories listed in the per-column stats
22
+
23
+ _NUMERIC_FAMILIES = frozenset({"number", "datetime", "timedelta"})
24
+
25
+
26
+ def family_of(dtype: Any) -> str:
27
+ """Map a pandas dtype to one of ``number``, ``datetime``, ``timedelta``, ``category``."""
28
+ if pdt.is_bool_dtype(dtype):
29
+ return "category"
30
+ if pdt.is_datetime64_any_dtype(dtype):
31
+ return "datetime"
32
+ if pdt.is_timedelta64_dtype(dtype):
33
+ return "timedelta"
34
+ if pdt.is_numeric_dtype(dtype):
35
+ return "number"
36
+ return "category"
37
+
38
+
39
+ def kind_of(family: str) -> str:
40
+ """Public kind label for a family: ``numeric`` or ``categorical``."""
41
+ return "numeric" if family in _NUMERIC_FAMILIES else "categorical"
42
+
43
+
44
+ def share(part: int, whole: int) -> float:
45
+ """``part / whole`` as a float, 0.0 when ``whole`` is 0."""
46
+ return float(part) / float(whole) if whole else 0.0
47
+
48
+
49
+ @dataclass
50
+ class ColumnValues:
51
+ """The usable values of one column plus how much was missing."""
52
+
53
+ family: str
54
+ n_rows: int
55
+ n_missing: int
56
+ values: np.ndarray # number: finite float64; datetime/timedelta: int64 ns; category: object array of str
57
+ n_nonfinite: int = 0 # +/-inf in a number column; counted inside n_missing
58
+
59
+ @property
60
+ def missing_share(self) -> float:
61
+ return share(self.n_missing, self.n_rows)
62
+
63
+
64
+ def extract(series: pd.Series) -> ColumnValues:
65
+ """Pull the usable values out of a Series according to its dtype family."""
66
+ family = family_of(series.dtype)
67
+ n_rows = int(len(series))
68
+ if family == "number":
69
+ arr = np.asarray(series.to_numpy(dtype="float64", na_value=np.nan), dtype="float64")
70
+ finite = np.isfinite(arr)
71
+ n_present = int(finite.sum())
72
+ n_nan = int(np.isnan(arr).sum())
73
+ return ColumnValues(
74
+ family, n_rows, n_rows - n_present, arr[finite], n_nonfinite=n_rows - n_present - n_nan
75
+ )
76
+ if family in ("datetime", "timedelta"):
77
+ s = series
78
+ if family == "datetime" and getattr(s.dtype, "tz", None) is not None:
79
+ s = s.dt.tz_convert(None)
80
+ unit = "datetime64[ns]" if family == "datetime" else "timedelta64[ns]"
81
+ arr = s.to_numpy(dtype=unit)
82
+ present = ~np.isnat(arr)
83
+ return ColumnValues(family, n_rows, n_rows - int(present.sum()), arr[present].astype(np.int64))
84
+ missing = series.isna().to_numpy(dtype=bool)
85
+ present_values = series[~missing].astype(str).to_numpy(dtype=object)
86
+ return ColumnValues("category", n_rows, int(missing.sum()), present_values)
87
+
88
+
89
+ # --------------------------------------------------------------------------- numeric bins
90
+
91
+
92
+ def numeric_edges(values: np.ndarray) -> np.ndarray:
93
+ """Right-closed interior boundaries built from the reference values.
94
+
95
+ Bin ``i`` covers ``(edges[i-1], edges[i]]``; the outer bins extend to -inf and
96
+ +inf so every current value lands somewhere. Columns with at most
97
+ ``LOW_CARDINALITY`` distinct values get one bin per value (boundaries at the
98
+ midpoints), everything else gets ``QUANTILE_BINS`` quantile bins. A constant
99
+ column yields no boundaries, i.e. a single bin, so its PSI is 0 rather than NaN.
100
+ """
101
+ if values.size == 0:
102
+ return np.empty(0, dtype="float64")
103
+ uniq = np.unique(values)
104
+ if uniq.size <= LOW_CARDINALITY:
105
+ lo = uniq[:-1].astype("float64")
106
+ hi = uniq[1:].astype("float64")
107
+ return (lo + hi) / 2.0
108
+ probs = np.linspace(0.0, 1.0, QUANTILE_BINS + 1)[1:-1]
109
+ return np.unique(np.quantile(values, probs))
110
+
111
+
112
+ def bin_counts(values: np.ndarray, edges: np.ndarray) -> np.ndarray:
113
+ """Count values per bin for the boundaries from :func:`numeric_edges`."""
114
+ idx = np.searchsorted(edges, values, side="left")
115
+ return np.bincount(idx, minlength=edges.size + 1)
116
+
117
+
118
+ # --------------------------------------------------------------------------- categories
119
+
120
+
121
+ def value_counts(values: np.ndarray) -> Dict[str, int]:
122
+ """Counts per category, ordered by count descending then name (deterministic)."""
123
+ if values.size == 0:
124
+ return {}
125
+ uniq, counts = np.unique(values, return_counts=True)
126
+ pairs = sorted(zip(uniq.tolist(), counts.tolist()), key=lambda pair: (-pair[1], pair[0]))
127
+ return {str(cat): int(n) for cat, n in pairs}
128
+
129
+
130
+ # --------------------------------------------------------------------------- tests
131
+
132
+
133
+ def psi(reference_counts: Any, current_counts: Any) -> Optional[float]:
134
+ """Population Stability Index between two aligned count vectors.
135
+
136
+ Shares are floored at ``PSI_EPS`` so an empty bin on one side contributes a
137
+ large but finite amount. Returns ``None`` when either side has no counts.
138
+ """
139
+ ref = np.asarray(reference_counts, dtype="float64")
140
+ cur = np.asarray(current_counts, dtype="float64")
141
+ if ref.size == 0 or ref.sum() <= 0 or cur.sum() <= 0:
142
+ return None
143
+ r = np.clip(ref / ref.sum(), PSI_EPS, None)
144
+ c = np.clip(cur / cur.sum(), PSI_EPS, None)
145
+ value = float(np.sum((c - r) * np.log(c / r)))
146
+ if not math.isfinite(value):
147
+ return None
148
+ return max(value, 0.0)
149
+
150
+
151
+ def ks_test(reference: np.ndarray, current: np.ndarray) -> Tuple[Optional[float], Optional[float]]:
152
+ """Two-sample Kolmogorov-Smirnov test; ``(None, None)`` when a side is empty."""
153
+ if reference.size == 0 or current.size == 0:
154
+ return None, None
155
+ result = sps.ks_2samp(reference, current)
156
+ return float(result.statistic), float(result.pvalue)
157
+
158
+
159
+ def chi2_test(table: np.ndarray) -> Tuple[Optional[float], Optional[float]]:
160
+ """Chi-square test of independence on a 2 x K count table.
161
+
162
+ Returns ``(None, None)`` when a row is empty, and ``(0.0, 1.0)`` for a single
163
+ category (zero degrees of freedom).
164
+ """
165
+ table = np.asarray(table, dtype="float64")
166
+ if table.ndim != 2 or table.shape[1] == 0 or np.any(table.sum(axis=1) <= 0):
167
+ return None, None
168
+ if table.shape[1] == 1:
169
+ return 0.0, 1.0
170
+ result = sps.chi2_contingency(table)
171
+ return float(result[0]), float(result[1])
172
+
173
+
174
+ # --------------------------------------------------------------------------- stats
175
+
176
+
177
+ def _format_datetime(ns: Any) -> str:
178
+ return pd.Timestamp(int(ns)).isoformat()
179
+
180
+
181
+ def _format_timedelta(ns: Any) -> str:
182
+ return str(pd.Timedelta(int(ns)))
183
+
184
+
185
+ def numeric_stats(cv: ColumnValues, dtype: str) -> Dict[str, Any]:
186
+ """Descriptive stats for a numeric column (datetimes and timedeltas as strings)."""
187
+ v = cv.values
188
+ out: Dict[str, Any] = {
189
+ "dtype": dtype,
190
+ "count": int(v.size),
191
+ "missing_share": round(cv.missing_share, 6),
192
+ }
193
+ if cv.family == "number":
194
+ keys = ("mean", "std", "min", "median", "max")
195
+ if v.size:
196
+ vals: Tuple[Any, ...] = (
197
+ float(v.mean()),
198
+ float(v.std()),
199
+ float(v.min()),
200
+ float(np.median(v)),
201
+ float(v.max()),
202
+ )
203
+ else:
204
+ vals = (None,) * len(keys)
205
+ else:
206
+ fmt = _format_datetime if cv.family == "datetime" else _format_timedelta
207
+ keys = ("min", "median", "max")
208
+ vals = (fmt(v.min()), fmt(np.median(v)), fmt(v.max())) if v.size else (None,) * len(keys)
209
+ out.update(zip(keys, vals))
210
+ return out
211
+
212
+
213
+ def category_stats(cv: ColumnValues, counts: Dict[str, int], dtype: str) -> Dict[str, Any]:
214
+ """Descriptive stats for a categorical column, including its top categories."""
215
+ total = sum(counts.values())
216
+ top = {cat: round(n / total, 6) for cat, n in list(counts.items())[:TOP_CATEGORIES]} if total else {}
217
+ return {
218
+ "dtype": dtype,
219
+ "count": int(cv.values.size),
220
+ "missing_share": round(cv.missing_share, 6),
221
+ "n_categories": len(counts),
222
+ "top": top,
223
+ }
data_drift_lite/cli.py ADDED
@@ -0,0 +1,108 @@
1
+ """Command line interface: ``data-drift-lite reference.csv current.csv [options]``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Optional, Sequence
10
+
11
+ from . import __version__
12
+ from ._core import detect
13
+
14
+
15
+ def build_parser() -> argparse.ArgumentParser:
16
+ """The argparse parser for the ``data-drift-lite`` command."""
17
+ parser = argparse.ArgumentParser(
18
+ prog="data-drift-lite",
19
+ description="Detect whether current data has drifted from reference data, column by column.",
20
+ )
21
+ parser.add_argument("reference", help="reference dataset (.csv or .parquet), e.g. the training data")
22
+ parser.add_argument("current", help="current dataset (.csv or .parquet), e.g. a production batch")
23
+ parser.add_argument(
24
+ "--columns", nargs="+", metavar="COL", help="compare only these columns (default: every reference column)"
25
+ )
26
+ parser.add_argument(
27
+ "--threshold",
28
+ type=float,
29
+ default=0.05,
30
+ metavar="P",
31
+ help="p-value below which a column counts as drifted (default: 0.05)",
32
+ )
33
+ parser.add_argument(
34
+ "--psi-threshold",
35
+ type=float,
36
+ default=0.2,
37
+ metavar="PSI",
38
+ help="PSI above which a column counts as drifted (default: 0.2)",
39
+ )
40
+ parser.add_argument(
41
+ "--sample",
42
+ type=int,
43
+ default=100_000,
44
+ metavar="N",
45
+ help="cap each dataset at N random rows; 0 disables sampling (default: 100000)",
46
+ )
47
+ parser.add_argument(
48
+ "--random-state", type=int, default=0, metavar="SEED", help="seed for the sampling (default: 0)"
49
+ )
50
+ parser.add_argument("--json", action="store_true", help="print the full report as JSON instead of the summary")
51
+ parser.add_argument("--output", metavar="PATH", help="also write the full report as JSON to PATH")
52
+ parser.add_argument(
53
+ "--fail-on-drift",
54
+ action="store_true",
55
+ help="exit with status 1 when drift is detected (handy in CI and cron jobs)",
56
+ )
57
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
58
+ return parser
59
+
60
+
61
+ def _configure_streams() -> None:
62
+ """Make stdout/stderr UTF-8 tolerant so non-Latin column names never raise."""
63
+ for stream in (sys.stdout, sys.stderr):
64
+ if hasattr(stream, "reconfigure"):
65
+ try:
66
+ stream.reconfigure(encoding="utf-8", errors="replace")
67
+ except (ValueError, OSError): # pragma: no cover - exotic stream
68
+ pass
69
+
70
+
71
+ def _emit(text: str) -> None:
72
+ try:
73
+ print(text)
74
+ except UnicodeEncodeError: # pragma: no cover - only when reconfigure was unavailable
75
+ encoding = getattr(sys.stdout, "encoding", None) or "ascii"
76
+ print(text.encode(encoding, errors="replace").decode(encoding))
77
+
78
+
79
+ def _as_json(payload: object) -> str:
80
+ return json.dumps(payload, indent=2, ensure_ascii=False)
81
+
82
+
83
+ def main(argv: Optional[Sequence[str]] = None) -> int:
84
+ """Entry point. Returns the process exit status."""
85
+ _configure_streams()
86
+ parser = build_parser()
87
+ args = parser.parse_args(argv)
88
+ try:
89
+ report = detect(
90
+ args.reference,
91
+ args.current,
92
+ columns=args.columns,
93
+ threshold=args.threshold,
94
+ psi_threshold=args.psi_threshold,
95
+ sample=args.sample or None,
96
+ random_state=args.random_state,
97
+ )
98
+ except (ValueError, TypeError, ImportError, OSError) as exc:
99
+ parser.error(str(exc))
100
+ payload = report.to_dict()
101
+ _emit(_as_json(payload) if args.json else report.summary())
102
+ if args.output:
103
+ Path(args.output).write_text(_as_json(payload), encoding="utf-8")
104
+ return 1 if (args.fail_on_drift and report.drifted) else 0
105
+
106
+
107
+ if __name__ == "__main__": # pragma: no cover
108
+ raise SystemExit(main())
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.5
2
+ Name: data-drift-lite
3
+ Version: 0.1.0
4
+ Summary: Detect whether production data has drifted from training data, column by column, with a single call
5
+ Project-URL: Homepage, https://pypi.org/project/data-drift-lite/
6
+ Project-URL: Author, https://pypi.org/user/pranaymahendrakar/
7
+ Author: Pranay Mahendrakar
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: data drift,drift detection,kolmogorov-smirnov,mlops,model monitoring,pandas,population stability index,psi
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
+ Requires-Python: >=3.9
19
+ Requires-Dist: numpy>=1.23
20
+ Requires-Dist: pandas>=1.5
21
+ Requires-Dist: scipy>=1.9
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7; extra == 'dev'
24
+ Provides-Extra: parquet
25
+ Requires-Dist: pyarrow>=12; extra == 'parquet'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # data-drift-lite
29
+
30
+ Detect whether production data has drifted from training data, column by column, with a single call.
31
+
32
+ ## Install
33
+
34
+ ```
35
+ pip install data-drift-lite
36
+ ```
37
+
38
+ Reading `.parquet` files needs `pip install "data-drift-lite[parquet]"`.
39
+
40
+ ## Quickstart
41
+
42
+ ```python
43
+ import pandas as pd
44
+ import data_drift_lite
45
+
46
+ reference = pd.DataFrame({"age": list(range(20, 60, 2)), "plan": ["basic", "pro"] * 10})
47
+ current = pd.DataFrame({"age": list(range(50, 90, 2)), "plan": ["pro"] * 18 + ["enterprise"] * 2})
48
+ report = data_drift_lite.detect(reference, current)
49
+ print(report.summary())
50
+ ```
51
+
52
+ Prints:
53
+
54
+ ```
55
+ data-drift-lite: DRIFT DETECTED: 2 of 2 columns drifted (100%)
56
+ reference rows: 20 | current rows: 20 | drifted when p < 0.05 or PSI > 0.2
57
+
58
+ column kind test statistic p-value PSI status
59
+ age numeric ks 0.7500 9.5e-06 6.4703 DRIFTED
60
+ plan categorical chi2 14.2857 0.0008 5.1829 DRIFTED
61
+
62
+ notes:
63
+ - plan: 1 category unseen in reference: 'enterprise'
64
+ ```
65
+
66
+ Then `report.drifted` is `True`, `report.drifted_columns` is `["age", "plan"]`, and
67
+ `report.to_dict()` is ready for `json.dumps`.
68
+
69
+ ## What it checks
70
+
71
+ - **Numeric columns** (ints, floats, nullable ints/floats; datetimes and timedeltas
72
+ are compared as int64 nanoseconds): a two-sample Kolmogorov-Smirnov test
73
+ (`scipy.stats.ks_2samp`) plus the Population Stability Index over 10 quantile
74
+ bins built from the reference.
75
+ - **Categorical columns** (strings, objects, `category`, and bool): a chi-square
76
+ test on category frequencies (`scipy.stats.chi2_contingency`) plus PSI over the
77
+ reference categories, with every category the reference never saw pooled into a
78
+ single extra bin.
79
+ - A column is **drifted** when `p_value < threshold` (default 0.05) **or**
80
+ `psi > psi_threshold` (default 0.2). Pass `None` for either to switch that rule off.
81
+ - **Missing values count.** They form their own bin for PSI (and their own category
82
+ in the chi-square table), so a column whose values start disappearing is flagged
83
+ even when the values that remain look the same. The KS test uses the non-missing
84
+ values. `+inf`/`-inf` are treated as missing.
85
+ - **Schema drift** is reported alongside: columns missing from the current data,
86
+ new columns, and columns whose type family changed (for example `int64 -> object`).
87
+ Missing columns and type changes set `report.drifted`; new columns are listed but
88
+ do not raise the flag.
89
+ - **Guard rails.** Constant columns get PSI 0, never NaN or inf. Numeric columns with
90
+ ten or fewer distinct values get one bin per value, so a 95/5 to 5/95 flip in a
91
+ 0/1 column is caught. A reference or batch with fewer than 20 rows produces a
92
+ warning note in the report (and a `logging` warning) instead of a crash. Each side
93
+ is capped at `sample` random rows (default 100,000, seeded by `random_state`) so a
94
+ check stays fast on big tables.
95
+
96
+ ## API
97
+
98
+ ### `detect(reference, current, *, columns=None, threshold=0.05, psi_threshold=0.2, sample=100_000, random_state=0) -> DriftReport`
99
+
100
+ The one-call path. `reference` and `current` accept a pandas DataFrame, a Series,
101
+ or a path to a `.csv` / `.parquet` file.
102
+
103
+ - `columns`: compare only these columns (default: every reference column).
104
+ - `threshold`: p-value below which a column is drifted; `None` disables the rule.
105
+ - `psi_threshold`: PSI above which a column is drifted; `None` disables the rule.
106
+ - `sample`: cap each side at this many random rows; `None` uses every row.
107
+ - `random_state`: seed for that sampling, so results are reproducible.
108
+
109
+ ### `DriftMonitor(reference, *, columns=None, threshold=0.05, psi_threshold=0.2, sample=100_000, random_state=0)`
110
+
111
+ Profiles the reference once; `monitor.check(batch)` returns a `DriftReport` for
112
+ each batch. Use it when scoring many batches against the same training data.
113
+
114
+ ```python
115
+ monitor = data_drift_lite.DriftMonitor(train_df, psi_threshold=0.1)
116
+ for batch in batches:
117
+ report = monitor.check(batch)
118
+ if report.drifted:
119
+ alert(report.summary())
120
+ ```
121
+
122
+ ### `DriftReport`
123
+
124
+ | attribute / method | meaning |
125
+ | --- | --- |
126
+ | `columns` | `dict[column -> ColumnDrift]` for every compared column, in reference order |
127
+ | `drifted_columns` | `list[str]` of the columns flagged as drifted |
128
+ | `drift_share` | fraction of compared columns that drifted |
129
+ | `drifted` | `True` if any column drifted, a column is missing, or a dtype changed |
130
+ | `missing_columns`, `new_columns`, `dtype_changed` | schema drift; `dtype_changed` maps `column -> (reference dtype, current dtype)` |
131
+ | `schema` | the same three as a `SchemaDrift` dataclass with its own `.drifted` and `.to_dict()` |
132
+ | `reference_rows`, `current_rows`, `threshold`, `psi_threshold`, `notes` | what the check ran on |
133
+ | `summary()` | human-readable text (also what `str(report)` returns) |
134
+ | `to_dict()` | JSON-safe dict: plain Python numbers, `None` for anything not computable |
135
+
136
+ ### `ColumnDrift`
137
+
138
+ Dataclass with `kind` (`"numeric"` or `"categorical"`), `test` (`"ks"` or `"chi2"`),
139
+ `statistic`, `p_value`, `psi`, `drifted`, `reference_stats`, `current_stats`,
140
+ `name`, `notes`, and `to_dict()`. Stats hold `dtype`, `count`, `missing_share`,
141
+ then `mean`/`std`/`min`/`median`/`max` for numeric columns (ISO strings for
142
+ datetimes) or `n_categories` and the `top` category shares for categorical ones.
143
+ Any statistic that could not be computed is `None`, and `notes` says why.
144
+
145
+ ## CLI
146
+
147
+ ```
148
+ data-drift-lite train.csv batch.csv
149
+ data-drift-lite train.parquet batch.parquet --columns age plan --psi-threshold 0.1
150
+ data-drift-lite train.csv batch.csv --json
151
+ data-drift-lite train.csv batch.csv --output report.json --fail-on-drift
152
+ ```
153
+
154
+ `data-drift-lite REFERENCE CURRENT` prints the summary. Options:
155
+
156
+ - `--columns COL [COL ...]`, `--threshold P`, `--psi-threshold PSI`,
157
+ `--sample N` (0 disables sampling), `--random-state SEED` mirror `detect()`.
158
+ - `--json` prints `to_dict()` as JSON instead of the summary.
159
+ - `--output PATH` also writes that JSON to a file.
160
+ - `--fail-on-drift` exits with status 1 when drift is detected, for CI and cron jobs.
161
+ - `--version`, `--help`.
162
+
163
+ ## License
164
+
165
+ MIT
@@ -0,0 +1,12 @@
1
+ data_drift_lite/__init__.py,sha256=ADd1a1sltU8Fe0ISL2kzI2a3lclCbI3rPVOCvM5af7w,505
2
+ data_drift_lite/__main__.py,sha256=Q7_LIfYSfVKNW9ul9YyA6XK-kcZFgE64J9Un9ZlbfGQ,153
3
+ data_drift_lite/_core.py,sha256=r7W67v-W7AyYwVMRivRTso_mCJ4Ddp5Q7MpyWbSmiX4,14162
4
+ data_drift_lite/_io.py,sha256=wbUjnTLF5nfO0yzmoueXzqGJGrqmx73IKSDq9nSa4Ng,1734
5
+ data_drift_lite/_report.py,sha256=yTutRj53gs9SOZo_oF4ta61cZI_XvYDHBrQDuRzyH1s,9782
6
+ data_drift_lite/_stats.py,sha256=hKIyi9A-fjmcsiRvjf9NCU4i91WaCLPfCuZDBpZGNnU,8678
7
+ data_drift_lite/cli.py,sha256=i3m3-UHNMwbWvoVDlbENr0fJeLkD5DAN1drEvjrsrLM,3981
8
+ data_drift_lite-0.1.0.dist-info/METADATA,sha256=aivwYShb3DSa-8qkGbOhzMry93b9xllldde_mvP6OgI,7187
9
+ data_drift_lite-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
+ data_drift_lite-0.1.0.dist-info/entry_points.txt,sha256=IX0EXyuz4u3rl8mBopXDTb1VSMkyqcpoPkTltPhQNes,61
11
+ data_drift_lite-0.1.0.dist-info/licenses/LICENSE,sha256=tAQiayNS3nK2VTEQSvK_ogaTFLAd8pAwIMd15JFkOQ4,1075
12
+ data_drift_lite-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ data-drift-lite = data_drift_lite.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pranay Mahendrakar
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.