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.
- data_drift_lite/__init__.py +20 -0
- data_drift_lite/__main__.py +8 -0
- data_drift_lite/_core.py +359 -0
- data_drift_lite/_io.py +47 -0
- data_drift_lite/_report.py +264 -0
- data_drift_lite/_stats.py +223 -0
- data_drift_lite/cli.py +108 -0
- data_drift_lite-0.1.0.dist-info/METADATA +165 -0
- data_drift_lite-0.1.0.dist-info/RECORD +12 -0
- data_drift_lite-0.1.0.dist-info/WHEEL +4 -0
- data_drift_lite-0.1.0.dist-info/entry_points.txt +2 -0
- data_drift_lite-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
]
|
data_drift_lite/_core.py
ADDED
|
@@ -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,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.
|