driftdep 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.
- driftdep/__init__.py +470 -0
- driftdep/_blocklen.py +42 -0
- driftdep/_blockperm.py +69 -0
- driftdep/_ess.py +120 -0
- driftdep/_stats.py +124 -0
- driftdep/py.typed +0 -0
- driftdep-0.1.0.dist-info/METADATA +155 -0
- driftdep-0.1.0.dist-info/RECORD +10 -0
- driftdep-0.1.0.dist-info/WHEEL +4 -0
- driftdep-0.1.0.dist-info/licenses/LICENSE +21 -0
driftdep/__init__.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""driftdep — dependence-robust two-sample drift tests.
|
|
2
|
+
|
|
3
|
+
Drop-in replacement for scipy.stats.ks_2samp and friends. Corrects for
|
|
4
|
+
serial dependence via ESS-adjusted block permutation by default.
|
|
5
|
+
|
|
6
|
+
Framing: this package *operationalizes* existing corrections (block permutation,
|
|
7
|
+
bootstrap, ESS adjustment). All corrections exist in the literature; the
|
|
8
|
+
contribution is a unified comparison + a validated default + working code.
|
|
9
|
+
|
|
10
|
+
Quick start::
|
|
11
|
+
|
|
12
|
+
import driftdep
|
|
13
|
+
stat, p = driftdep.ks_2samp(x_ref, x_new) # drop-in for scipy
|
|
14
|
+
|
|
15
|
+
result = driftdep.drift_test(x_ref, x_new, statistic="ks")
|
|
16
|
+
print(result.pvalue, result.n_eff, result.block_length)
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import warnings
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Iterator
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
import scipy.stats as _st
|
|
26
|
+
|
|
27
|
+
from driftdep._stats import STAT_REGISTRY, TwoSampleStatFn
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── Result type ───────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class DriftResult:
|
|
34
|
+
"""Result returned by all drift test functions.
|
|
35
|
+
|
|
36
|
+
Supports ``stat, p = drift_test(x, y)`` unpacking for drop-in
|
|
37
|
+
compatibility with ``scipy.stats.ks_2samp``.
|
|
38
|
+
|
|
39
|
+
Attributes
|
|
40
|
+
----------
|
|
41
|
+
statistic:
|
|
42
|
+
Observed value of the two-sample test statistic.
|
|
43
|
+
pvalue:
|
|
44
|
+
Dependence-corrected p-value.
|
|
45
|
+
block_length:
|
|
46
|
+
Block length used (None for non-block corrections).
|
|
47
|
+
n_eff:
|
|
48
|
+
Effective sample size estimate (None if ESS not computed).
|
|
49
|
+
correction:
|
|
50
|
+
Name of the correction that produced this p-value.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
statistic: float
|
|
54
|
+
pvalue: float
|
|
55
|
+
block_length: int | None
|
|
56
|
+
n_eff: float | None
|
|
57
|
+
correction: str
|
|
58
|
+
|
|
59
|
+
def __iter__(self) -> Iterator[float]:
|
|
60
|
+
"""Yield (statistic, pvalue) for drop-in unpacking."""
|
|
61
|
+
yield self.statistic
|
|
62
|
+
yield self.pvalue
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── Internal correction helpers ───────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
def _naive_pvalue(
|
|
68
|
+
x: np.ndarray,
|
|
69
|
+
y: np.ndarray,
|
|
70
|
+
stat_fn: TwoSampleStatFn,
|
|
71
|
+
stat_name: str,
|
|
72
|
+
n_resamples: int,
|
|
73
|
+
rng: np.random.Generator,
|
|
74
|
+
) -> float:
|
|
75
|
+
"""i.i.d. analytic p-value (KS/CvM/AD) or ordinary permutation."""
|
|
76
|
+
if stat_name == "ks":
|
|
77
|
+
return float(_st.ks_2samp(x, y).pvalue)
|
|
78
|
+
if stat_name == "cvm":
|
|
79
|
+
return float(_st.cramervonmises_2samp(x, y).pvalue)
|
|
80
|
+
if stat_name == "ad":
|
|
81
|
+
result = _st.anderson_ksamp([x, y], variant="midrank")
|
|
82
|
+
if hasattr(result, "pvalue"):
|
|
83
|
+
return float(result.pvalue)
|
|
84
|
+
sl = float(result.significance_level)
|
|
85
|
+
return sl / 100.0 if sl > 1.0 else sl
|
|
86
|
+
# Permutation null for everything else
|
|
87
|
+
m = len(x)
|
|
88
|
+
pooled = np.concatenate([x, y])
|
|
89
|
+
obs = stat_fn(x, y)
|
|
90
|
+
count = 0
|
|
91
|
+
for _ in range(n_resamples):
|
|
92
|
+
p = rng.permutation(pooled)
|
|
93
|
+
if stat_fn(p[:m], p[m:]) >= obs:
|
|
94
|
+
count += 1
|
|
95
|
+
return (count + 1) / (n_resamples + 1)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _ess_adjust_pvalue(
|
|
99
|
+
x: np.ndarray,
|
|
100
|
+
y: np.ndarray,
|
|
101
|
+
stat_fn: TwoSampleStatFn,
|
|
102
|
+
stat_name: str,
|
|
103
|
+
n_eff_x: float,
|
|
104
|
+
n_eff_y: float,
|
|
105
|
+
n_resamples: int,
|
|
106
|
+
rng: np.random.Generator,
|
|
107
|
+
) -> float:
|
|
108
|
+
"""ESS-adjusted analytic p-value.
|
|
109
|
+
|
|
110
|
+
For KS: plug n_eff into the asymptotic Kolmogorov distribution.
|
|
111
|
+
For CvM/AD: thin to n_eff and call scipy.
|
|
112
|
+
Others: thin to n_eff then permute.
|
|
113
|
+
"""
|
|
114
|
+
if stat_name == "ks":
|
|
115
|
+
D = float(_st.ks_2samp(x, y).statistic)
|
|
116
|
+
ne_x = max(1.0, n_eff_x)
|
|
117
|
+
ne_y = max(1.0, n_eff_y)
|
|
118
|
+
t = D * np.sqrt(ne_x * ne_y / (ne_x + ne_y))
|
|
119
|
+
return float(_st.kstwobign.sf(t))
|
|
120
|
+
|
|
121
|
+
kx = max(1, round(len(x) / max(1.0, n_eff_x)))
|
|
122
|
+
ky = max(1, round(len(y) / max(1.0, n_eff_y)))
|
|
123
|
+
xt, yt = x[::kx], y[::ky]
|
|
124
|
+
|
|
125
|
+
if stat_name == "cvm":
|
|
126
|
+
return float(_st.cramervonmises_2samp(xt, yt).pvalue)
|
|
127
|
+
if stat_name == "ad":
|
|
128
|
+
result = _st.anderson_ksamp([xt, yt], variant="midrank")
|
|
129
|
+
if hasattr(result, "pvalue"):
|
|
130
|
+
return float(result.pvalue)
|
|
131
|
+
sl = float(result.significance_level)
|
|
132
|
+
return sl / 100.0 if sl > 1.0 else sl
|
|
133
|
+
|
|
134
|
+
# Permutation on thinned data
|
|
135
|
+
mt = len(xt)
|
|
136
|
+
pooled = np.concatenate([xt, yt])
|
|
137
|
+
obs = stat_fn(xt, yt)
|
|
138
|
+
count = 0
|
|
139
|
+
for _ in range(n_resamples):
|
|
140
|
+
p = rng.permutation(pooled)
|
|
141
|
+
if stat_fn(p[:mt], p[mt:]) >= obs:
|
|
142
|
+
count += 1
|
|
143
|
+
return (count + 1) / (n_resamples + 1)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _thinning_pvalue(
|
|
147
|
+
x: np.ndarray,
|
|
148
|
+
y: np.ndarray,
|
|
149
|
+
stat_fn: TwoSampleStatFn,
|
|
150
|
+
stat_name: str,
|
|
151
|
+
n_eff_x: float,
|
|
152
|
+
n_eff_y: float,
|
|
153
|
+
n_resamples: int,
|
|
154
|
+
rng: np.random.Generator,
|
|
155
|
+
) -> float:
|
|
156
|
+
"""Thin to n_eff then apply naive correction."""
|
|
157
|
+
kx = max(1, round(len(x) / max(1.0, n_eff_x)))
|
|
158
|
+
ky = max(1, round(len(y) / max(1.0, n_eff_y)))
|
|
159
|
+
return _naive_pvalue(x[::kx], y[::ky], stat_fn, stat_name, n_resamples, rng)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _dep_wild_pvalue(
|
|
163
|
+
x: np.ndarray,
|
|
164
|
+
y: np.ndarray,
|
|
165
|
+
stat_fn: TwoSampleStatFn,
|
|
166
|
+
n_resamples: int,
|
|
167
|
+
rng: np.random.Generator,
|
|
168
|
+
) -> float:
|
|
169
|
+
"""Dependent wild bootstrap (Shao 2010).
|
|
170
|
+
|
|
171
|
+
Generates AR(1) correlated weights with φ = exp(−1/l) where
|
|
172
|
+
l = max(1, n^{1/3}). Weights are assigned to pooled observations;
|
|
173
|
+
the first m weights go to x*, the rest to y*.
|
|
174
|
+
|
|
175
|
+
Reference: Shao (2010), JRSSB 72(2):269-301.
|
|
176
|
+
"""
|
|
177
|
+
m, n = len(x), len(y)
|
|
178
|
+
N = m + n
|
|
179
|
+
l = max(1, int(N ** (1.0 / 3.0)))
|
|
180
|
+
phi = np.exp(-1.0 / l)
|
|
181
|
+
sigma_eps = np.sqrt(max(1e-12, 1.0 - phi ** 2))
|
|
182
|
+
|
|
183
|
+
obs = stat_fn(x, y)
|
|
184
|
+
pooled = np.concatenate([x, y])
|
|
185
|
+
x_mean = x.mean()
|
|
186
|
+
y_mean = y.mean()
|
|
187
|
+
|
|
188
|
+
count = 0
|
|
189
|
+
for _ in range(n_resamples):
|
|
190
|
+
eps = rng.standard_normal(N)
|
|
191
|
+
w = np.empty(N)
|
|
192
|
+
w[0] = eps[0]
|
|
193
|
+
for t in range(1, N):
|
|
194
|
+
w[t] = phi * w[t - 1] + sigma_eps * eps[t]
|
|
195
|
+
# Sign of w determines assignment: positive → x*, negative → y*
|
|
196
|
+
# (standard Shao partition: top m by |w| rank assigned to x*)
|
|
197
|
+
order = np.argsort(w)[::-1]
|
|
198
|
+
x_star = pooled[np.sort(order[:m])]
|
|
199
|
+
y_star = pooled[np.sort(order[m:])]
|
|
200
|
+
if stat_fn(x_star, y_star) >= obs:
|
|
201
|
+
count += 1
|
|
202
|
+
return (count + 1) / (n_resamples + 1)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _mbb_pvalue(
|
|
206
|
+
x: np.ndarray,
|
|
207
|
+
y: np.ndarray,
|
|
208
|
+
stat_fn: TwoSampleStatFn,
|
|
209
|
+
block_length: int,
|
|
210
|
+
n_resamples: int,
|
|
211
|
+
rng: np.random.Generator,
|
|
212
|
+
mode: str = "moving",
|
|
213
|
+
) -> float:
|
|
214
|
+
"""MBB / CBB / Stationary bootstrap p-value via arch."""
|
|
215
|
+
try:
|
|
216
|
+
from arch.bootstrap import ( # noqa: PLC0415
|
|
217
|
+
CircularBlockBootstrap,
|
|
218
|
+
MovingBlockBootstrap,
|
|
219
|
+
StationaryBootstrap,
|
|
220
|
+
)
|
|
221
|
+
except ImportError as e:
|
|
222
|
+
raise ImportError(
|
|
223
|
+
f"correction='{mode}' requires arch. "
|
|
224
|
+
"Install with: pip install driftdep[research]"
|
|
225
|
+
) from e
|
|
226
|
+
|
|
227
|
+
m = len(x)
|
|
228
|
+
pooled = np.concatenate([x, y])
|
|
229
|
+
seed_int = int(rng.integers(2 ** 31 - 1))
|
|
230
|
+
cls = {"moving": MovingBlockBootstrap, "circular": CircularBlockBootstrap,
|
|
231
|
+
"stationary": StationaryBootstrap}[mode]
|
|
232
|
+
bs = cls(block_length, pooled, seed=seed_int)
|
|
233
|
+
obs = stat_fn(x, y)
|
|
234
|
+
count = 0
|
|
235
|
+
for data, _ in bs.bootstrap(n_resamples):
|
|
236
|
+
s = data[0].ravel()
|
|
237
|
+
if stat_fn(s[:m], s[m:]) >= obs:
|
|
238
|
+
count += 1
|
|
239
|
+
return (count + 1) / (n_resamples + 1)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _prewhiten_pvalue(
|
|
243
|
+
x: np.ndarray,
|
|
244
|
+
y: np.ndarray,
|
|
245
|
+
stat_fn: TwoSampleStatFn,
|
|
246
|
+
stat_name: str,
|
|
247
|
+
n_resamples: int,
|
|
248
|
+
rng: np.random.Generator,
|
|
249
|
+
) -> float:
|
|
250
|
+
"""Fit AR on x, apply to y, test residuals.
|
|
251
|
+
|
|
252
|
+
WARNING: This changes the hypothesis from marginal drift to innovation
|
|
253
|
+
drift and can MASK REAL MARGINAL SHIFTS. Never use in production.
|
|
254
|
+
"""
|
|
255
|
+
try:
|
|
256
|
+
from statsmodels.tsa.ar_model import AutoReg # noqa: PLC0415
|
|
257
|
+
except ImportError as e:
|
|
258
|
+
raise ImportError(
|
|
259
|
+
"correction='prewhiten' requires statsmodels. "
|
|
260
|
+
"Install with: pip install driftdep[research]"
|
|
261
|
+
) from e
|
|
262
|
+
|
|
263
|
+
max_lag = max(1, min(10, len(x) // 10))
|
|
264
|
+
try:
|
|
265
|
+
fit = AutoReg(x, lags=max_lag, old_names=False).fit()
|
|
266
|
+
lags = fit.model.lags
|
|
267
|
+
coeffs = fit.params
|
|
268
|
+
intercept = coeffs[0]
|
|
269
|
+
ar_coeffs = coeffs[1:]
|
|
270
|
+
|
|
271
|
+
def residuals(z: np.ndarray) -> np.ndarray:
|
|
272
|
+
res = np.empty(len(z) - lags)
|
|
273
|
+
for i in range(lags, len(z)):
|
|
274
|
+
res[i - lags] = z[i] - intercept - float(ar_coeffs @ z[i - lags:i][::-1])
|
|
275
|
+
return res
|
|
276
|
+
|
|
277
|
+
xr = residuals(x)
|
|
278
|
+
yr = residuals(y)
|
|
279
|
+
except Exception:
|
|
280
|
+
xr, yr = x, y
|
|
281
|
+
|
|
282
|
+
return _naive_pvalue(xr, yr, stat_fn, stat_name, n_resamples, rng)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ── Block-length resolution ───────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
def _resolve_block_length(
|
|
288
|
+
x: np.ndarray,
|
|
289
|
+
y: np.ndarray,
|
|
290
|
+
block_length: int | None,
|
|
291
|
+
use_pw: bool,
|
|
292
|
+
) -> int:
|
|
293
|
+
if block_length is not None:
|
|
294
|
+
return max(1, int(block_length))
|
|
295
|
+
n = max(len(x), len(y))
|
|
296
|
+
if use_pw:
|
|
297
|
+
from driftdep._blocklen import block_length_politis_white # noqa: PLC0415
|
|
298
|
+
try:
|
|
299
|
+
return block_length_politis_white(np.concatenate([x, y]))
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
from driftdep._blocklen import block_length_fixed # noqa: PLC0415
|
|
303
|
+
return block_length_fixed(n)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ── Public API ────────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
def drift_test(
|
|
309
|
+
x: np.ndarray,
|
|
310
|
+
y: np.ndarray,
|
|
311
|
+
*,
|
|
312
|
+
statistic: str = "ks",
|
|
313
|
+
correction: str = "block_perm",
|
|
314
|
+
block_length: int | None = None,
|
|
315
|
+
ess_adjust: bool = True,
|
|
316
|
+
n_resamples: int = 999,
|
|
317
|
+
alpha: float = 0.05,
|
|
318
|
+
rng: np.random.Generator | int | None = None,
|
|
319
|
+
) -> DriftResult:
|
|
320
|
+
"""Two-sample drift test with serial-dependence correction.
|
|
321
|
+
|
|
322
|
+
Parameters
|
|
323
|
+
----------
|
|
324
|
+
x, y:
|
|
325
|
+
Reference and detection windows (1-D arrays).
|
|
326
|
+
statistic:
|
|
327
|
+
One of ``"ks"``, ``"cvm"``, ``"ad"``, ``"energy"``, ``"mmd"``,
|
|
328
|
+
``"wasserstein"``, ``"psi"``, ``"js"``. Default ``"ks"``.
|
|
329
|
+
correction:
|
|
330
|
+
One of ``"block_perm"`` (default), ``"ess_adjust"``, ``"naive"``,
|
|
331
|
+
``"mbb"``, ``"cbb"``, ``"stationary"``, ``"dep_wild"``,
|
|
332
|
+
``"thinning"``, ``"prewhiten"`` (cautionary only).
|
|
333
|
+
block_length:
|
|
334
|
+
Block length for block-based corrections. ``None`` → automatic
|
|
335
|
+
selection (Politis–White if arch is installed, else n^{1/3}).
|
|
336
|
+
ess_adjust:
|
|
337
|
+
When ``True`` (default), compute the indicator-transform ESS and
|
|
338
|
+
store it in the result. For ``correction="block_perm"`` this also
|
|
339
|
+
informs block-length selection when ``block_length`` is None.
|
|
340
|
+
n_resamples:
|
|
341
|
+
Permutation/bootstrap resamples.
|
|
342
|
+
alpha:
|
|
343
|
+
Nominal significance level (stored in result; not used for p-value).
|
|
344
|
+
rng:
|
|
345
|
+
``numpy.random.Generator``, integer seed, or ``None`` (random seed).
|
|
346
|
+
|
|
347
|
+
Returns
|
|
348
|
+
-------
|
|
349
|
+
DriftResult
|
|
350
|
+
Unpacks as ``(statistic, pvalue)`` for drop-in compatibility.
|
|
351
|
+
|
|
352
|
+
Examples
|
|
353
|
+
--------
|
|
354
|
+
>>> import numpy as np, driftdep
|
|
355
|
+
>>> rng = np.random.default_rng(0)
|
|
356
|
+
>>> x = rng.standard_normal(500)
|
|
357
|
+
>>> y = rng.standard_normal(500)
|
|
358
|
+
>>> stat, p = driftdep.ks_2samp(x, y)
|
|
359
|
+
"""
|
|
360
|
+
x = np.asarray(x, dtype=float).ravel()
|
|
361
|
+
y = np.asarray(y, dtype=float).ravel()
|
|
362
|
+
if x.ndim != 1 or y.ndim != 1:
|
|
363
|
+
raise ValueError("x and y must be 1-D arrays.")
|
|
364
|
+
if len(x) < 4 or len(y) < 4:
|
|
365
|
+
raise ValueError("x and y must each have at least 4 observations.")
|
|
366
|
+
|
|
367
|
+
if isinstance(rng, int):
|
|
368
|
+
rng = np.random.default_rng(rng)
|
|
369
|
+
elif rng is None:
|
|
370
|
+
rng = np.random.default_rng()
|
|
371
|
+
|
|
372
|
+
if statistic not in STAT_REGISTRY:
|
|
373
|
+
raise ValueError(
|
|
374
|
+
f"Unknown statistic {statistic!r}. "
|
|
375
|
+
f"Choose from: {sorted(STAT_REGISTRY)}"
|
|
376
|
+
)
|
|
377
|
+
stat_fn = STAT_REGISTRY[statistic]
|
|
378
|
+
stat_val = stat_fn(x, y)
|
|
379
|
+
|
|
380
|
+
# ESS computation
|
|
381
|
+
n_eff: float | None = None
|
|
382
|
+
if ess_adjust or correction in ("ess_adjust", "thinning"):
|
|
383
|
+
from driftdep._ess import ess_from_series # noqa: PLC0415
|
|
384
|
+
n_eff = (ess_from_series(x) + ess_from_series(y)) / 2.0
|
|
385
|
+
|
|
386
|
+
# Block length (needed for block-based corrections)
|
|
387
|
+
b: int | None = None
|
|
388
|
+
if correction in ("block_perm", "mbb", "cbb", "stationary"):
|
|
389
|
+
use_pw = block_length is None # try PW when not user-specified
|
|
390
|
+
b = _resolve_block_length(x, y, block_length, use_pw=use_pw)
|
|
391
|
+
|
|
392
|
+
# Dispatch to correction
|
|
393
|
+
if correction == "block_perm":
|
|
394
|
+
from driftdep._blockperm import block_perm_pvalue # noqa: PLC0415
|
|
395
|
+
assert b is not None
|
|
396
|
+
pvalue = block_perm_pvalue(x, y, stat_fn, b, n_resamples, rng)
|
|
397
|
+
|
|
398
|
+
elif correction == "ess_adjust":
|
|
399
|
+
ne_x: float
|
|
400
|
+
ne_y: float
|
|
401
|
+
from driftdep._ess import ess_from_series # noqa: PLC0415
|
|
402
|
+
ne_x = ess_from_series(x)
|
|
403
|
+
ne_y = ess_from_series(y)
|
|
404
|
+
n_eff = (ne_x + ne_y) / 2.0
|
|
405
|
+
pvalue = _ess_adjust_pvalue(x, y, stat_fn, statistic, ne_x, ne_y, n_resamples, rng)
|
|
406
|
+
|
|
407
|
+
elif correction == "naive":
|
|
408
|
+
pvalue = _naive_pvalue(x, y, stat_fn, statistic, n_resamples, rng)
|
|
409
|
+
|
|
410
|
+
elif correction == "thinning":
|
|
411
|
+
assert n_eff is not None
|
|
412
|
+
from driftdep._ess import ess_from_series # noqa: PLC0415
|
|
413
|
+
ne_x = ess_from_series(x)
|
|
414
|
+
ne_y = ess_from_series(y)
|
|
415
|
+
n_eff = (ne_x + ne_y) / 2.0
|
|
416
|
+
pvalue = _thinning_pvalue(x, y, stat_fn, statistic, ne_x, ne_y, n_resamples, rng)
|
|
417
|
+
|
|
418
|
+
elif correction == "dep_wild":
|
|
419
|
+
pvalue = _dep_wild_pvalue(x, y, stat_fn, n_resamples, rng)
|
|
420
|
+
|
|
421
|
+
elif correction in ("mbb", "cbb", "stationary"):
|
|
422
|
+
assert b is not None
|
|
423
|
+
mode_map = {"mbb": "moving", "cbb": "circular", "stationary": "stationary"}
|
|
424
|
+
pvalue = _mbb_pvalue(x, y, stat_fn, b, n_resamples, rng, mode=mode_map[correction])
|
|
425
|
+
|
|
426
|
+
elif correction == "prewhiten":
|
|
427
|
+
warnings.warn(
|
|
428
|
+
"correction='prewhiten' is a CAUTIONARY COMPARATOR ONLY. It changes the "
|
|
429
|
+
"hypothesis from marginal drift to innovation drift and can MASK REAL "
|
|
430
|
+
"MARGINAL SHIFTS. Do not use in production drift monitoring.",
|
|
431
|
+
UserWarning,
|
|
432
|
+
stacklevel=2,
|
|
433
|
+
)
|
|
434
|
+
pvalue = _prewhiten_pvalue(x, y, stat_fn, statistic, n_resamples, rng)
|
|
435
|
+
|
|
436
|
+
else:
|
|
437
|
+
raise ValueError(
|
|
438
|
+
f"Unknown correction {correction!r}. "
|
|
439
|
+
f"Choose from: block_perm, ess_adjust, naive, mbb, cbb, "
|
|
440
|
+
f"stationary, dep_wild, thinning, prewhiten"
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
return DriftResult(
|
|
444
|
+
statistic=stat_val,
|
|
445
|
+
pvalue=float(pvalue),
|
|
446
|
+
block_length=b,
|
|
447
|
+
n_eff=n_eff,
|
|
448
|
+
correction=correction,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def ks_2samp(x: np.ndarray, y: np.ndarray, **kwargs: object) -> DriftResult:
|
|
453
|
+
"""Dependence-robust drop-in replacement for ``scipy.stats.ks_2samp``.
|
|
454
|
+
|
|
455
|
+
Defaults to ESS-adjusted block permutation. Existing code using::
|
|
456
|
+
|
|
457
|
+
stat, p = scipy.stats.ks_2samp(x, y)
|
|
458
|
+
|
|
459
|
+
can switch to::
|
|
460
|
+
|
|
461
|
+
stat, p = driftdep.ks_2samp(x, y)
|
|
462
|
+
|
|
463
|
+
with no other changes. The result also carries ``result.pvalue``,
|
|
464
|
+
``result.n_eff``, and ``result.block_length`` for diagnostics.
|
|
465
|
+
"""
|
|
466
|
+
return drift_test(x, y, statistic="ks", **kwargs) # type: ignore[arg-type]
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
__all__ = ["DriftResult", "drift_test", "ks_2samp"]
|
|
470
|
+
__version__ = "0.1.0"
|
driftdep/_blocklen.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Automatic block-length selection for block-based corrections.
|
|
2
|
+
|
|
3
|
+
Two methods are exposed:
|
|
4
|
+
- Politis–White (2004) automatic selector, delegating to
|
|
5
|
+
``arch.bootstrap.optimal_block_length`` (research dep; lazy import).
|
|
6
|
+
- Fixed n^(1/3) rule as a fast, dependency-free fallback.
|
|
7
|
+
|
|
8
|
+
Block length is a first-class experimental factor per the paper plan (§8,
|
|
9
|
+
CLAUDE.md §4.3). Both methods are available in the research harness; the
|
|
10
|
+
package default uses the fixed rule to avoid requiring arch at runtime.
|
|
11
|
+
|
|
12
|
+
Implemented in M1. Skeleton only for M0.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def block_length_fixed(n: int) -> int:
|
|
20
|
+
"""b = max(1, floor(n^(1/3))). Simple, dependency-free."""
|
|
21
|
+
return max(1, int(n ** (1 / 3)))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def block_length_politis_white(x: np.ndarray) -> int:
|
|
25
|
+
"""Politis–White (2004) automatic block-length selector.
|
|
26
|
+
|
|
27
|
+
Delegates to ``arch.bootstrap.optimal_block_length``.
|
|
28
|
+
Requires the ``research`` optional extra (arch package).
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
x:
|
|
33
|
+
Univariate time series from one window. Uses the stationary bootstrap
|
|
34
|
+
optimal length (b_star_sb column) as the default because it tends to be
|
|
35
|
+
more conservative (larger) and thus safer for size control.
|
|
36
|
+
"""
|
|
37
|
+
from arch.bootstrap import optimal_block_length # lazy: research dep only
|
|
38
|
+
df = optimal_block_length(x)
|
|
39
|
+
# arch >= 5.x returns columns ["stationary", "circular"]
|
|
40
|
+
col = "stationary" if "stationary" in df.columns else df.columns[0]
|
|
41
|
+
b = float(df[col].iloc[0])
|
|
42
|
+
return max(1, int(round(b)))
|
driftdep/_blockperm.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Block permutation test — the default correction engine.
|
|
2
|
+
|
|
3
|
+
Permutes contiguous non-overlapping blocks of length b when building the
|
|
4
|
+
permutation null, preserving within-block autocorrelation structure while
|
|
5
|
+
destroying between-window structure under H0.
|
|
6
|
+
|
|
7
|
+
Reference: Davison & Hinkley (1997) §8; Lahiri (2003) ch. 4.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from driftdep._stats import TwoSampleStatFn
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def block_perm_pvalue(
|
|
17
|
+
x: np.ndarray,
|
|
18
|
+
y: np.ndarray,
|
|
19
|
+
stat_fn: TwoSampleStatFn,
|
|
20
|
+
block_length: int,
|
|
21
|
+
n_resamples: int,
|
|
22
|
+
rng: np.random.Generator,
|
|
23
|
+
) -> float:
|
|
24
|
+
"""Block permutation p-value.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
x, y:
|
|
29
|
+
Reference and detection windows (1-D arrays).
|
|
30
|
+
stat_fn:
|
|
31
|
+
Callable (x, y) -> float; larger = more different.
|
|
32
|
+
block_length:
|
|
33
|
+
Number of consecutive observations per permutation block.
|
|
34
|
+
n_resamples:
|
|
35
|
+
Number of permutation draws.
|
|
36
|
+
rng:
|
|
37
|
+
Seeded Generator for reproducibility.
|
|
38
|
+
|
|
39
|
+
Returns
|
|
40
|
+
-------
|
|
41
|
+
float
|
|
42
|
+
p-value under the block-permutation null.
|
|
43
|
+
"""
|
|
44
|
+
m, n = len(x), len(y)
|
|
45
|
+
b = max(1, block_length)
|
|
46
|
+
|
|
47
|
+
n_bx = max(1, m // b)
|
|
48
|
+
n_by = max(1, n // b)
|
|
49
|
+
|
|
50
|
+
# Degenerate case: fall back to ordinary permutation
|
|
51
|
+
if n_bx + n_by < 2:
|
|
52
|
+
b = 1
|
|
53
|
+
n_bx, n_by = m, n
|
|
54
|
+
|
|
55
|
+
blocks_x = x[: n_bx * b].reshape(n_bx, b)
|
|
56
|
+
blocks_y = y[: n_by * b].reshape(n_by, b)
|
|
57
|
+
all_blocks = np.concatenate([blocks_x, blocks_y], axis=0)
|
|
58
|
+
n_pool = n_bx + n_by
|
|
59
|
+
|
|
60
|
+
obs = stat_fn(x, y)
|
|
61
|
+
count = 0
|
|
62
|
+
for _ in range(n_resamples):
|
|
63
|
+
idx = rng.permutation(n_pool)
|
|
64
|
+
perm = all_blocks[idx]
|
|
65
|
+
x_star = perm[:n_bx].ravel()[:m]
|
|
66
|
+
y_star = perm[n_bx:].ravel()[:n]
|
|
67
|
+
if stat_fn(x_star, y_star) >= obs:
|
|
68
|
+
count += 1
|
|
69
|
+
return (count + 1) / (n_resamples + 1)
|
driftdep/_ess.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Effective sample size (ESS) from indicator-transform autocorrelations.
|
|
2
|
+
|
|
3
|
+
Scientific note: for distributional drift tests the relevant variance inflation
|
|
4
|
+
is over the indicator transforms 1{X ≤ t}, whose autocorrelation is generally
|
|
5
|
+
weaker than the raw series and varies with the quantile t. This module provides:
|
|
6
|
+
|
|
7
|
+
- A fast AR(1) heuristic: n_eff ≈ n·(1−ρ)/(1+ρ).
|
|
8
|
+
- A general estimator based on the Bartlett/Newey–West long-run variance of
|
|
9
|
+
1{X ≤ t} at a grid of quantiles t.
|
|
10
|
+
|
|
11
|
+
The general estimator powers Fig 3 (indicator vs raw ACF across quantiles)
|
|
12
|
+
and the "ESS over/under-corrects" empirical result.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ess_ar1(n: int, rho: float) -> float:
|
|
20
|
+
"""AR(1) heuristic: n·(1−ρ)/(1+ρ).
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
n:
|
|
25
|
+
Nominal sample size.
|
|
26
|
+
rho:
|
|
27
|
+
Lag-1 autocorrelation coefficient.
|
|
28
|
+
"""
|
|
29
|
+
rho = float(np.clip(rho, -0.9999, 0.9999))
|
|
30
|
+
return max(1.0, n * (1.0 - rho) / (1.0 + rho))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _bartlett_lrv(z: np.ndarray, bandwidth: int) -> float:
|
|
34
|
+
"""Newey–West Bartlett-kernel long-run variance estimate of z."""
|
|
35
|
+
n = len(z)
|
|
36
|
+
z_c = z - z.mean()
|
|
37
|
+
lrv = float(np.dot(z_c, z_c) / n)
|
|
38
|
+
for lag in range(1, bandwidth + 1):
|
|
39
|
+
weight = 1.0 - lag / (bandwidth + 1.0)
|
|
40
|
+
cov = float(np.dot(z_c[: n - lag], z_c[lag:])) / n
|
|
41
|
+
lrv += 2.0 * weight * cov
|
|
42
|
+
return max(lrv, 1e-14)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def ess_from_series(x: np.ndarray, bandwidth: int | None = None) -> float:
|
|
46
|
+
"""Estimate scalar n_eff from a single series using indicator transforms.
|
|
47
|
+
|
|
48
|
+
Averages the HAC-based n_eff across a grid of 20 quantiles. This is the
|
|
49
|
+
correction used by ESSAdjustCorrection.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
x:
|
|
54
|
+
Univariate time series.
|
|
55
|
+
bandwidth:
|
|
56
|
+
Bartlett kernel bandwidth. None → Andrews (1991) rule: floor(1.75·n^{1/3}).
|
|
57
|
+
"""
|
|
58
|
+
n = len(x)
|
|
59
|
+
if bandwidth is None:
|
|
60
|
+
bandwidth = max(1, int(np.floor(1.75 * n ** (1.0 / 3.0))))
|
|
61
|
+
|
|
62
|
+
q_grid = np.linspace(0.05, 0.95, 20)
|
|
63
|
+
thresholds = np.quantile(x, q_grid)
|
|
64
|
+
n_effs: list[float] = []
|
|
65
|
+
|
|
66
|
+
for t in thresholds:
|
|
67
|
+
ind = (x <= t).astype(float)
|
|
68
|
+
p_hat = ind.mean()
|
|
69
|
+
iid_var = p_hat * (1.0 - p_hat)
|
|
70
|
+
if iid_var < 1e-12:
|
|
71
|
+
continue # near-degenerate quantile — skip
|
|
72
|
+
lrv = _bartlett_lrv(ind, bandwidth)
|
|
73
|
+
n_effs.append(float(np.clip(n * iid_var / lrv, 1.0, float(n))))
|
|
74
|
+
|
|
75
|
+
if not n_effs:
|
|
76
|
+
return float(n)
|
|
77
|
+
return float(np.mean(n_effs))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def ess_indicator(
|
|
81
|
+
x: np.ndarray,
|
|
82
|
+
quantile_grid: np.ndarray | None = None,
|
|
83
|
+
bandwidth: int | None = None,
|
|
84
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
85
|
+
"""General ESS from the Bartlett-kernel HAC of indicator transforms.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
x:
|
|
90
|
+
Univariate time series.
|
|
91
|
+
quantile_grid:
|
|
92
|
+
Evaluation quantiles in (0, 1). Defaults to 20 evenly spaced points.
|
|
93
|
+
bandwidth:
|
|
94
|
+
Bartlett kernel bandwidth. None → automatic (Andrews 1991 rule).
|
|
95
|
+
|
|
96
|
+
Returns
|
|
97
|
+
-------
|
|
98
|
+
quantiles, n_eff_per_quantile:
|
|
99
|
+
Arrays of shape ``(len(quantile_grid),)``.
|
|
100
|
+
"""
|
|
101
|
+
n = len(x)
|
|
102
|
+
if quantile_grid is None:
|
|
103
|
+
quantile_grid = np.linspace(0.05, 0.95, 20)
|
|
104
|
+
if bandwidth is None:
|
|
105
|
+
bandwidth = max(1, int(np.floor(1.75 * n ** (1.0 / 3.0))))
|
|
106
|
+
|
|
107
|
+
thresholds = np.quantile(x, quantile_grid)
|
|
108
|
+
n_effs = np.empty(len(quantile_grid))
|
|
109
|
+
|
|
110
|
+
for i, t in enumerate(thresholds):
|
|
111
|
+
ind = (x <= t).astype(float)
|
|
112
|
+
p_hat = ind.mean()
|
|
113
|
+
iid_var = p_hat * (1.0 - p_hat)
|
|
114
|
+
if iid_var < 1e-12:
|
|
115
|
+
n_effs[i] = float(n)
|
|
116
|
+
continue
|
|
117
|
+
lrv = _bartlett_lrv(ind, bandwidth)
|
|
118
|
+
n_effs[i] = float(np.clip(n * iid_var / lrv, 1.0, float(n)))
|
|
119
|
+
|
|
120
|
+
return np.asarray(quantile_grid), n_effs
|
driftdep/_stats.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Two-sample statistic functions for the driftdep package.
|
|
2
|
+
|
|
3
|
+
Each function maps (x, y) -> float; larger means more different distributions.
|
|
4
|
+
No p-values are computed here — that is the correction's job.
|
|
5
|
+
|
|
6
|
+
Statistics with no analytic null distribution (energy, MMD) must be used with
|
|
7
|
+
a permutation-based correction. PSI and JS have no formal test; they are
|
|
8
|
+
included for threshold-based monitoring compatibility only.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import scipy.stats as st
|
|
16
|
+
|
|
17
|
+
TwoSampleStatFn = Callable[[np.ndarray, np.ndarray], float]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── Kolmogorov–Smirnov ────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
def ks_stat(x: np.ndarray, y: np.ndarray) -> float:
|
|
23
|
+
"""KS two-sample statistic: sup|F_n(t) − G_m(t)|."""
|
|
24
|
+
return float(st.ks_2samp(x, y).statistic)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── Cramér–von Mises ──────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
def cvm_stat(x: np.ndarray, y: np.ndarray) -> float:
|
|
30
|
+
"""Cramér–von Mises integrated-squared statistic."""
|
|
31
|
+
return float(st.cramervonmises_2samp(x, y).statistic)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── Anderson–Darling ──────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
def ad_stat(x: np.ndarray, y: np.ndarray) -> float:
|
|
37
|
+
"""Anderson–Darling tail-weighted statistic (midrank variant)."""
|
|
38
|
+
return float(st.anderson_ksamp([x, y], variant="midrank").statistic)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── Energy distance ───────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
def energy_stat(x: np.ndarray, y: np.ndarray) -> float:
|
|
44
|
+
"""Energy distance. Requires the ``research`` extra (dcor package)."""
|
|
45
|
+
try:
|
|
46
|
+
import dcor # noqa: PLC0415 lazy: optional dep
|
|
47
|
+
return float(dcor.energy_distance(x, y))
|
|
48
|
+
except ImportError as e:
|
|
49
|
+
raise ImportError(
|
|
50
|
+
"energy_stat requires dcor. Install with: pip install driftdep[research]"
|
|
51
|
+
) from e
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── Unbiased MMD² with RBF kernel ─────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
def mmd_rbf_stat(x: np.ndarray, y: np.ndarray) -> float:
|
|
57
|
+
"""Unbiased MMD² with median-heuristic RBF bandwidth.
|
|
58
|
+
|
|
59
|
+
Bandwidth: σ² = median of all pairwise squared distances in the pooled
|
|
60
|
+
sample (Gretton et al. 2012, JMLR).
|
|
61
|
+
"""
|
|
62
|
+
m, n = len(x), len(y)
|
|
63
|
+
pooled = np.concatenate([x, y])
|
|
64
|
+
n_pool = len(pooled)
|
|
65
|
+
sq = (pooled[:, None] - pooled[None, :]) ** 2
|
|
66
|
+
h2 = float(np.median(sq[np.triu_indices(n_pool, k=1)]))
|
|
67
|
+
if h2 == 0.0:
|
|
68
|
+
h2 = 1.0
|
|
69
|
+
def rbf(a: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
70
|
+
return np.exp(-((a[:, None] - b[None, :]) ** 2) / h2)
|
|
71
|
+
Kxx = rbf(x, x); np.fill_diagonal(Kxx, 0.0)
|
|
72
|
+
Kyy = rbf(y, y); np.fill_diagonal(Kyy, 0.0)
|
|
73
|
+
Kxy = rbf(x, y)
|
|
74
|
+
return float(
|
|
75
|
+
Kxx.sum() / (m * (m - 1))
|
|
76
|
+
+ Kyy.sum() / (n * (n - 1))
|
|
77
|
+
- 2.0 * Kxy.sum() / (m * n)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── Wasserstein-1 ─────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
def wasserstein_stat(x: np.ndarray, y: np.ndarray) -> float:
|
|
84
|
+
"""Wasserstein-1 (Earth Mover's) distance."""
|
|
85
|
+
return float(st.wasserstein_distance(x, y))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ── Population Stability Index ────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def psi_stat(x: np.ndarray, y: np.ndarray, n_bins: int = 10) -> float:
|
|
91
|
+
"""PSI = Σ (P_i − Q_i) · ln(P_i/Q_i). No formal null; threshold-based."""
|
|
92
|
+
eps = 1e-10
|
|
93
|
+
bins = np.histogram_bin_edges(np.concatenate([x, y]), bins=n_bins)
|
|
94
|
+
px = np.histogram(x, bins=bins)[0].astype(float); px = px / px.sum() + eps
|
|
95
|
+
py = np.histogram(y, bins=bins)[0].astype(float); py = py / py.sum() + eps
|
|
96
|
+
return float(np.sum((px - py) * np.log(px / py)))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── Jensen–Shannon divergence ─────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
def js_stat(x: np.ndarray, y: np.ndarray, n_bins: int = 10) -> float:
|
|
102
|
+
"""JS divergence (binned). Returns divergence, not distance."""
|
|
103
|
+
from scipy.spatial.distance import jensenshannon # noqa: PLC0415
|
|
104
|
+
eps = 1e-10
|
|
105
|
+
bins = np.histogram_bin_edges(np.concatenate([x, y]), bins=n_bins)
|
|
106
|
+
px = np.histogram(x, bins=bins)[0].astype(float); px = px / px.sum() + eps
|
|
107
|
+
py = np.histogram(y, bins=bins)[0].astype(float); py = py / py.sum() + eps
|
|
108
|
+
return float(jensenshannon(px, py) ** 2)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ── Registry ──────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
STAT_REGISTRY: dict[str, TwoSampleStatFn] = {
|
|
114
|
+
"ks": ks_stat,
|
|
115
|
+
"cvm": cvm_stat,
|
|
116
|
+
"ad": ad_stat,
|
|
117
|
+
"energy": energy_stat,
|
|
118
|
+
"mmd": mmd_rbf_stat,
|
|
119
|
+
"mmd_rbf": mmd_rbf_stat,
|
|
120
|
+
"wasserstein": wasserstein_stat,
|
|
121
|
+
"wass": wasserstein_stat,
|
|
122
|
+
"psi": psi_stat,
|
|
123
|
+
"js": js_stat,
|
|
124
|
+
}
|
driftdep/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: driftdep
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Dependence-robust two-sample drift tests for serially correlated feature streams
|
|
5
|
+
Author-email: Vivek Chaudhary <vivekch2018@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: MLOps,drift detection,monitoring,serial dependence,two-sample test
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: numpy>=1.24
|
|
11
|
+
Requires-Dist: scipy>=1.10
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: build; extra == 'dev'
|
|
14
|
+
Requires-Dist: mypy>=1.5; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
17
|
+
Requires-Dist: twine; extra == 'dev'
|
|
18
|
+
Provides-Extra: research
|
|
19
|
+
Requires-Dist: arch>=6.0; extra == 'research'
|
|
20
|
+
Requires-Dist: dcor>=0.6; extra == 'research'
|
|
21
|
+
Requires-Dist: joblib>=1.3; extra == 'research'
|
|
22
|
+
Requires-Dist: matplotlib>=3.7; extra == 'research'
|
|
23
|
+
Requires-Dist: pandas>=2.0; extra == 'research'
|
|
24
|
+
Requires-Dist: statsmodels>=0.14; extra == 'research'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# driftdep
|
|
28
|
+
|
|
29
|
+
**Your drift monitor is lying to you.**
|
|
30
|
+
If your feature windows are autocorrelated — and they almost always are —
|
|
31
|
+
standard two-sample tests (KS, CvM, AD, MMD) over-reject by 2–10× at realistic
|
|
32
|
+
dependencies. A single AR(1) coefficient of ρ=0.7 inflates the KS false-alarm
|
|
33
|
+
rate from 5% to 35%. Long-memory processes (ARFIMA) push it even higher.
|
|
34
|
+
|
|
35
|
+
`driftdep` is a drop-in, dependence-robust replacement that corrects for serial
|
|
36
|
+
dependence via ESS-adjusted block permutation by default.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
# Before — unreliable under serial dependence
|
|
40
|
+
from scipy.stats import ks_2samp
|
|
41
|
+
stat, p = ks_2samp(x_ref, x_new) # p-value too small when data is autocorrelated
|
|
42
|
+
|
|
43
|
+
# After — calibrated by default
|
|
44
|
+
from driftdep import ks_2samp
|
|
45
|
+
stat, p = ks_2samp(x_ref, x_new) # same call, same unpacking, correct p-value
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install driftdep
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Requires Python ≥ 3.10, numpy ≥ 1.24, scipy ≥ 1.10 only.
|
|
55
|
+
Heavy research dependencies (arch, statsmodels, dcor) are optional:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install "driftdep[research]" # for all corrections and download scripts
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Quick start
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import numpy as np
|
|
65
|
+
import driftdep
|
|
66
|
+
|
|
67
|
+
rng = np.random.default_rng(0)
|
|
68
|
+
x_ref = rng.standard_normal(500)
|
|
69
|
+
x_new = rng.standard_normal(500) # same distribution; should not alarm
|
|
70
|
+
|
|
71
|
+
stat, p = driftdep.ks_2samp(x_ref, x_new)
|
|
72
|
+
# p ≈ 0.6 — no false alarm (correct even if the series is autocorrelated)
|
|
73
|
+
|
|
74
|
+
# Full result with diagnostics
|
|
75
|
+
result = driftdep.drift_test(x_ref, x_new, statistic="ks")
|
|
76
|
+
print(result.pvalue) # dependence-corrected p-value
|
|
77
|
+
print(result.n_eff) # effective sample size estimate
|
|
78
|
+
print(result.block_length) # block length used by block permutation
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## API
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
driftdep.drift_test(
|
|
85
|
+
x, y, *,
|
|
86
|
+
statistic="ks", # "ks", "cvm", "ad", "energy", "mmd",
|
|
87
|
+
# "wasserstein", "psi", "js"
|
|
88
|
+
correction="block_perm", # default engine; see table below
|
|
89
|
+
block_length=None, # None → auto (Politis–White 2004)
|
|
90
|
+
ess_adjust=True, # compute and report indicator-transform ESS
|
|
91
|
+
n_resamples=999, # permutation resamples
|
|
92
|
+
alpha=0.05,
|
|
93
|
+
rng=None, # numpy Generator, int seed, or None
|
|
94
|
+
) -> DriftResult
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`DriftResult` unpacks as `(statistic, pvalue)` for drop-in compatibility and
|
|
98
|
+
also exposes `.pvalue`, `.n_eff`, `.block_length`, `.correction`.
|
|
99
|
+
|
|
100
|
+
### Available corrections
|
|
101
|
+
|
|
102
|
+
| `correction=` | Description | Extra deps |
|
|
103
|
+
|-----------------|-----------------------------------------------------|----------------|
|
|
104
|
+
| `block_perm` | Block permutation, Politis–White block length | — |
|
|
105
|
+
| `ess_adjust` | ESS-adjusted analytic null (cheapest; KS/CvM/AD) | — |
|
|
106
|
+
| `naive` | i.i.d. null — baseline / reference only | — |
|
|
107
|
+
| `thinning` | Keep every k-th obs to approximate independence | — |
|
|
108
|
+
| `dep_wild` | Dependent wild bootstrap (Shao 2010) | — |
|
|
109
|
+
| `mbb` | Moving block bootstrap (Künsch 1989) | `[research]` |
|
|
110
|
+
| `cbb` | Circular block bootstrap | `[research]` |
|
|
111
|
+
| `stationary` | Stationary bootstrap (Politis–Romano 1994) | `[research]` |
|
|
112
|
+
| `prewhiten` | **Cautionary only** — changes the hypothesis | `[research]` |
|
|
113
|
+
|
|
114
|
+
## Method
|
|
115
|
+
|
|
116
|
+
`driftdep` benchmarks and operationalizes dependence corrections for two-sample
|
|
117
|
+
distributional drift tests. The core insight: when observations within a
|
|
118
|
+
monitoring window are autocorrelated, the i.i.d. null distribution of KS, CvM,
|
|
119
|
+
and related statistics is stochastically dominated, causing severe over-rejection.
|
|
120
|
+
|
|
121
|
+
The recommended default — ESS-adjusted block permutation — permutes contiguous
|
|
122
|
+
blocks of length *b* (preserving within-block dependence) and selects *b*
|
|
123
|
+
automatically via Politis–White (2004). Empirical size recovers to ≈ α across
|
|
124
|
+
AR(1), ARFIMA, and GARCH dependence structures. Size-adjusted power is within
|
|
125
|
+
5–7 percentage points of the uncalibrated naive test at moderate effect sizes.
|
|
126
|
+
|
|
127
|
+
All corrections already exist in the literature; this package operationalizes
|
|
128
|
+
them in a unified interface with a validated default.
|
|
129
|
+
|
|
130
|
+
## Reproduce paper results
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
git clone https://github.com/vivekch2018/driftdep
|
|
134
|
+
cd driftdep
|
|
135
|
+
pip install -e ".[research]"
|
|
136
|
+
make data # download public datasets (requires internet)
|
|
137
|
+
make reproduce # regenerate all figures and tables from seeds
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Citation
|
|
141
|
+
|
|
142
|
+
```bibtex
|
|
143
|
+
@misc{chaudhary2025driftdep,
|
|
144
|
+
author = {Chaudhary, Vivek},
|
|
145
|
+
title = {Two-Sample Drift Tests Break Under Serial Dependence:
|
|
146
|
+
A Benchmark and Deployable Correction},
|
|
147
|
+
year = {2025},
|
|
148
|
+
note = {Preprint},
|
|
149
|
+
url = {https://github.com/vivekch2018/driftdep},
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
driftdep/__init__.py,sha256=RB4z0TDY1a6ZSqu6BMKB5FuoOcTIU1-pAYm06XPLDY8,15555
|
|
2
|
+
driftdep/_blocklen.py,sha256=xsQwbCPgPDF3J8iMunk5u1LfndFvIFN-3xbpZfqyfy8,1569
|
|
3
|
+
driftdep/_blockperm.py,sha256=AhbSVIiB3SKCoa89EyVU3lUDwNKTzzskeg-yqT7VLWM,1831
|
|
4
|
+
driftdep/_ess.py,sha256=KaBZbPbcYOYeHoQix8rK0nqy1KABIsgDMZrFmdKwt2w,3766
|
|
5
|
+
driftdep/_stats.py,sha256=Lqi7rMW1QXmDjqmnGle78YYaX1IRI83HhlWzS3iiE9c,5635
|
|
6
|
+
driftdep/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
driftdep-0.1.0.dist-info/METADATA,sha256=h-8P2Z8DqWpt7dOOARyb53hv46a5jr7li2eObiDuLS0,5854
|
|
8
|
+
driftdep-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
9
|
+
driftdep-0.1.0.dist-info/licenses/LICENSE,sha256=4ouoHFjOm0NzQi0FZc5aKhnJV7Hnelk231OWaRvfCco,1072
|
|
10
|
+
driftdep-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Vivek Chaudhary
|
|
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.
|