iyipada 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iyipada-0.1.0/LICENSE +21 -0
- iyipada-0.1.0/PKG-INFO +242 -0
- iyipada-0.1.0/README.md +225 -0
- iyipada-0.1.0/iyipada/__init__.py +32 -0
- iyipada-0.1.0/iyipada/detect.py +201 -0
- iyipada-0.1.0/iyipada/divergence.py +155 -0
- iyipada-0.1.0/iyipada/null.py +134 -0
- iyipada-0.1.0/iyipada/sequential.py +201 -0
- iyipada-0.1.0/iyipada.egg-info/PKG-INFO +242 -0
- iyipada-0.1.0/iyipada.egg-info/SOURCES.txt +16 -0
- iyipada-0.1.0/iyipada.egg-info/dependency_links.txt +1 -0
- iyipada-0.1.0/iyipada.egg-info/requires.txt +5 -0
- iyipada-0.1.0/iyipada.egg-info/top_level.txt +1 -0
- iyipada-0.1.0/pyproject.toml +23 -0
- iyipada-0.1.0/setup.cfg +4 -0
- iyipada-0.1.0/tests/test_detect.py +133 -0
- iyipada-0.1.0/tests/test_null.py +132 -0
- iyipada-0.1.0/tests/test_sequential.py +120 -0
iyipada-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kenny Obidele
|
|
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.
|
iyipada-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: iyipada
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drift detection with calibrated thresholds instead of folklore ones
|
|
5
|
+
Author: Kenny Obidele
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Kenny0bi/iyipada
|
|
8
|
+
Keywords: drift,monitoring,mlops,statistics,psi,data-drift
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: numpy>=1.24
|
|
13
|
+
Requires-Dist: scipy>=1.10
|
|
14
|
+
Provides-Extra: benchmarks
|
|
15
|
+
Requires-Dist: pandas>=2.0; extra == "benchmarks"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# iyipada
|
|
19
|
+
|
|
20
|
+
Drift detection where the threshold is calculated instead of quoted.
|
|
21
|
+
|
|
22
|
+
Production drift monitoring runs on numbers that came from a blog post. The
|
|
23
|
+
most common rule in the industry is "PSI above 0.10 means watch it, above 0.25
|
|
24
|
+
means act". Those numbers are stated as though they were properties of the
|
|
25
|
+
statistic. They are not. On data where **nothing has changed at all**, that
|
|
26
|
+
first rule fires 85% of the time at 100 samples per batch, and it is
|
|
27
|
+
mathematically incapable of firing at 1,000.
|
|
28
|
+
|
|
29
|
+
This library computes the threshold from the null distribution, so a 1%
|
|
30
|
+
false-alarm rate is a 1% false-alarm rate.
|
|
31
|
+
|
|
32
|
+

|
|
33
|
+
|
|
34
|
+
*iyipada* is Yoruba for change.
|
|
35
|
+
|
|
36
|
+
## The three ways a drift monitor lies to you
|
|
37
|
+
|
|
38
|
+
Each of these is measured in this repository, not asserted.
|
|
39
|
+
|
|
40
|
+
**One. A fixed threshold means different things at different sample sizes.**
|
|
41
|
+
PSI under the null is distributed as `(1/n + 1/m) * chi2_{B-1}`, so it depends
|
|
42
|
+
on both sample sizes and the bin count. The industry rule depends on neither.
|
|
43
|
+
False-alarm rate of the "watch" rule on identically distributed data:
|
|
44
|
+
|
|
45
|
+
| samples per batch | 5 bins | 10 bins | 20 bins |
|
|
46
|
+
|---|---|---|---|
|
|
47
|
+
| 50 | 64.3% | 98.2% | **100.0%** |
|
|
48
|
+
| 100 | 29.5% | 85.1% | 100.0% |
|
|
49
|
+
| 200 | 4.1% | 37.0% | 96.0% |
|
|
50
|
+
| 500 | 0.0% | 0.3% | 17.6% |
|
|
51
|
+
| 1,000 and up | 0.0% | 0.0% | 0.0% |
|
|
52
|
+
|
|
53
|
+
The calibrated 1% threshold across that same grid runs from 1.4476 down to
|
|
54
|
+
0.0004, a span of about 5,400. One number cannot serve all of it.
|
|
55
|
+
|
|
56
|
+
**Two. Twenty features is twenty chances to be wrong.** Twenty clean features
|
|
57
|
+
tested independently at alpha = 0.05 produce at least one alarm on 73% of clean
|
|
58
|
+
batches. With Benjamini-Hochberg, 5%. Monitoring is a multiple-testing problem
|
|
59
|
+
before it is a drift problem.
|
|
60
|
+
|
|
61
|
+
**Three. Fifty-two weekly checks is not one check.** A test calibrated to 5%,
|
|
62
|
+
run weekly, is wrong at least once during a year with probability 93%. Getting
|
|
63
|
+
the single check right and then scheduling it re-creates the problem it solved.
|
|
64
|
+
|
|
65
|
+

|
|
66
|
+
|
|
67
|
+
## Using it
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from iyipada import Detector, Monitor
|
|
71
|
+
|
|
72
|
+
d = Detector(reference_values, statistic="psi", alpha=0.01)
|
|
73
|
+
r = d.check(this_weeks_values)
|
|
74
|
+
|
|
75
|
+
r.drifted # bool, decided against a calibrated threshold
|
|
76
|
+
r.pvalue # what you actually want to look at
|
|
77
|
+
r.threshold # computed from your sample sizes, not quoted from anywhere
|
|
78
|
+
r.warning # set when the batch is too small for the test to mean much
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Nothing in the API accepts a raw divergence threshold, because a raw divergence
|
|
82
|
+
threshold cannot be interpreted without knowing the sample sizes and the bin
|
|
83
|
+
count.
|
|
84
|
+
|
|
85
|
+
For a table of features, with the multiple testing handled:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
m = Monitor(reference_dataframe_as_dict, alpha=0.05, correction="fdr")
|
|
89
|
+
result = m.check(live_dataframe_as_dict)
|
|
90
|
+
result.drifted # the features that moved, after FDR control
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
For a monitor that runs on a schedule, stop re-testing and accumulate:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from iyipada import sequential as sq
|
|
97
|
+
|
|
98
|
+
h, arl = sq.calibrate_run_length("cusum", target_arl=260) # weekly checks
|
|
99
|
+
# h = 3.75, one false alarm every 4.8 years of weekly monitoring
|
|
100
|
+
c = sq.CUSUM(h=h)
|
|
101
|
+
c.update(this_weeks_statistic) # True when the evidence has accumulated
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Which detector should you actually use
|
|
105
|
+
|
|
106
|
+
Wasserstein, in most cases. Every statistic below was first calibrated to the
|
|
107
|
+
same 1% false-alarm rate, so this compares power and nothing else. Detection
|
|
108
|
+
rate at 1,000 samples a side:
|
|
109
|
+
|
|
110
|
+
| | mean shift | variance | skew | discretisation | mixture | tail only |
|
|
111
|
+
|---|---|---|---|---|---|---|
|
|
112
|
+
| **Wasserstein** | 100% | 100% | 100% | 100% | **99.5%** | **15.7%** |
|
|
113
|
+
| PSI | 99.5% | 100% | 100% | 100% | 86.2% | 3.5% |
|
|
114
|
+
| Jensen-Shannon | 99.5% | 100% | 100% | 100% | 85.8% | 3.5% |
|
|
115
|
+
| chi-square | 99.5% | 100% | 100% | 100% | 85.7% | 3.5% |
|
|
116
|
+
| Kolmogorov-Smirnov | 100% | 99.8% | 88.7% | 100% | 83.0% | 2.3% |
|
|
117
|
+
| total variation | 98.3% | 100% | 99.8% | 100% | 59.7% | 2.8% |
|
|
118
|
+
|
|
119
|
+

|
|
120
|
+
|
|
121
|
+
Wasserstein wins or ties everywhere, and it reports in the units of the
|
|
122
|
+
variable, so a result reads as "the distribution moved by 0.3 mg/dL" rather
|
|
123
|
+
than as an abstract divergence. PSI, the industry default, is beaten on the two
|
|
124
|
+
hardest cases.
|
|
125
|
+
|
|
126
|
+
### The blind spot
|
|
127
|
+
|
|
128
|
+

|
|
129
|
+
|
|
130
|
+
A small fraction of values jumping to the far tail is close to invisible. At 5%
|
|
131
|
+
contamination Wasserstein catches it every time, PSI 29% of the time and
|
|
132
|
+
Kolmogorov-Smirnov 18%. At 1.5% nothing is reliable.
|
|
133
|
+
|
|
134
|
+
The binned statistics fail because quantile binning puts the reference's entire
|
|
135
|
+
top decile into one bin, so moving mass from +2 to +6 does not change the
|
|
136
|
+
histogram at all. KS fails because a few per cent in the far tail cannot move
|
|
137
|
+
the largest vertical gap between two cumulative curves. Wasserstein survives
|
|
138
|
+
because it measures horizontal distance.
|
|
139
|
+
|
|
140
|
+
If rare extreme values are the failure you fear, monitor the tail directly with
|
|
141
|
+
a quantile or an exceedance count. A distributional distance is the wrong
|
|
142
|
+
instrument, however well calibrated.
|
|
143
|
+
|
|
144
|
+
## What is verified, and how
|
|
145
|
+
|
|
146
|
+

|
|
147
|
+
|
|
148
|
+
The asymptotic null is not taken on faith. Simulated 95th percentile divided by
|
|
149
|
+
theory, across ten sample sizes and three bin counts, sits within 2% of 1.0 from
|
|
150
|
+
200 samples upward. It is 6 to 9% optimistic around n = 100 and it breaks down
|
|
151
|
+
at n = 50 with 20 bins, where each bin holds two or three points and the
|
|
152
|
+
chi-square approximation has nothing to stand on.
|
|
153
|
+
|
|
154
|
+
So the library uses the closed form above 300 samples and simulates the null
|
|
155
|
+
below it, rather than trusting one answer everywhere. With that switch, the
|
|
156
|
+
calibrated threshold holds between 0.0% and 2.2% actual false-alarm rate across
|
|
157
|
+
the entire grid, against 0% to 100% for the fixed rule.
|
|
158
|
+
|
|
159
|
+

|
|
160
|
+
|
|
161
|
+
The ringed cell is 112 samples at 10 bins. That is a real case from
|
|
162
|
+
[loom](https://github.com/Kenny0bi/loom), an earlier project of mine, where a
|
|
163
|
+
fixed 0.10 rule fired a spurious retrain on identically distributed data. It
|
|
164
|
+
fires there 78.6% of the time. That bug is why this library exists.
|
|
165
|
+
|
|
166
|
+
## The score, animated
|
|
167
|
+
|
|
168
|
+

|
|
169
|
+
|
|
170
|
+
The null distribution of PSI collapsing as the sample grows, while the fixed
|
|
171
|
+
rule stays exactly where it is. Every number in the animation comes from
|
|
172
|
+
`benchmarks/false_alarms.py`. (Source:
|
|
173
|
+
[assets/manim_null.py](assets/manim_null.py), video in
|
|
174
|
+
[assets/null.mp4](assets/null.mp4).)
|
|
175
|
+
|
|
176
|
+
## What is in here
|
|
177
|
+
|
|
178
|
+
- [iyipada/divergence.py](iyipada/divergence.py) the statistics: PSI,
|
|
179
|
+
Jensen-Shannon, total variation, Kolmogorov-Smirnov, Wasserstein,
|
|
180
|
+
chi-square. None of them decides anything.
|
|
181
|
+
- [iyipada/null.py](iyipada/null.py) the null distributions: analytic for PSI,
|
|
182
|
+
chi-square and KS, permutation for everything else, plus
|
|
183
|
+
`false_alarm_rate()` for measuring what a threshold you already use is
|
|
184
|
+
actually doing.
|
|
185
|
+
- [iyipada/detect.py](iyipada/detect.py) `Detector` and `Monitor`, the latter
|
|
186
|
+
with FDR or Bonferroni control.
|
|
187
|
+
- [iyipada/sequential.py](iyipada/sequential.py) CUSUM and Page-Hinkley
|
|
188
|
+
calibrated to an average run length, alpha spending across a known horizon,
|
|
189
|
+
and `detection_delay()` so a monitor that never false-alarms can be checked
|
|
190
|
+
for being merely deaf.
|
|
191
|
+
- [benchmarks/](benchmarks/) the three studies behind every number above.
|
|
192
|
+
|
|
193
|
+
## Honest limits
|
|
194
|
+
|
|
195
|
+
- **Univariate only.** Correlation between features can shift while every
|
|
196
|
+
marginal stays put, and nothing here would see it. Multivariate drift needs a
|
|
197
|
+
different tool.
|
|
198
|
+
- **The permutation path is slow.** Wasserstein and total variation have no
|
|
199
|
+
closed-form null here, so their thresholds are simulated. That is a few
|
|
200
|
+
seconds per feature, cached per sample size.
|
|
201
|
+
- **Independence is assumed across checks.** The Sidak correction and the run
|
|
202
|
+
length calibration both assume successive batches are independent. Weekly
|
|
203
|
+
batches of the same seasonal process are not, and the true false-alarm rate
|
|
204
|
+
will be somewhat higher than advertised.
|
|
205
|
+
- **Detecting drift is not detecting harm.** A feature can move a long way
|
|
206
|
+
without the model caring, and can stay still while the relationship between
|
|
207
|
+
it and the target rots. This library measures the first thing. Watching the
|
|
208
|
+
second needs labels.
|
|
209
|
+
- **Below about 100 samples, nothing works.** The thresholds are honest there,
|
|
210
|
+
which mostly means they are honestly enormous. A detector that cannot fire is
|
|
211
|
+
correctly calibrated and useless. `Detector` says so in `r.warning`.
|
|
212
|
+
|
|
213
|
+
## Running it
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
pip install numpy scipy pandas
|
|
217
|
+
|
|
218
|
+
python tests/test_null.py # 9 contracts on the null distributions
|
|
219
|
+
python tests/test_detect.py # 10 on the detector and monitor APIs
|
|
220
|
+
python tests/test_sequential.py # 8 on run lengths and detection delay
|
|
221
|
+
|
|
222
|
+
python benchmarks/false_alarms.py # the headline table, about 7 minutes
|
|
223
|
+
python benchmarks/power.py # detector against drift type, about 4 minutes
|
|
224
|
+
python assets/make_visuals.py # the six figures
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
27 tests. The interesting ones assert the findings themselves, so if the
|
|
228
|
+
industry thresholds ever stop being miscalibrated, the suite will say so.
|
|
229
|
+
|
|
230
|
+
## Papers
|
|
231
|
+
|
|
232
|
+
- B. Yurdakul (2018), *Statistical properties of population stability index*,
|
|
233
|
+
Western Michigan University dissertation. Derives the asymptotic
|
|
234
|
+
distribution, and states plainly that the 0.10 and 0.25 traffic-light values
|
|
235
|
+
have no support in the literature.
|
|
236
|
+
- Bracher et al. and the wider proper-scoring literature for why a decision
|
|
237
|
+
rule needs a stated error rate rather than a threshold.
|
|
238
|
+
- Page (1954), *Continuous inspection schemes*, Biometrika. The original CUSUM,
|
|
239
|
+
and the reason average run length is the right quantity for a monitor that
|
|
240
|
+
runs forever.
|
|
241
|
+
- Benjamini and Hochberg (1995), *Controlling the false discovery rate*, JRSS-B.
|
|
242
|
+
What `Monitor` uses when you hand it fifty features.
|
iyipada-0.1.0/README.md
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# iyipada
|
|
2
|
+
|
|
3
|
+
Drift detection where the threshold is calculated instead of quoted.
|
|
4
|
+
|
|
5
|
+
Production drift monitoring runs on numbers that came from a blog post. The
|
|
6
|
+
most common rule in the industry is "PSI above 0.10 means watch it, above 0.25
|
|
7
|
+
means act". Those numbers are stated as though they were properties of the
|
|
8
|
+
statistic. They are not. On data where **nothing has changed at all**, that
|
|
9
|
+
first rule fires 85% of the time at 100 samples per batch, and it is
|
|
10
|
+
mathematically incapable of firing at 1,000.
|
|
11
|
+
|
|
12
|
+
This library computes the threshold from the null distribution, so a 1%
|
|
13
|
+
false-alarm rate is a 1% false-alarm rate.
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
*iyipada* is Yoruba for change.
|
|
18
|
+
|
|
19
|
+
## The three ways a drift monitor lies to you
|
|
20
|
+
|
|
21
|
+
Each of these is measured in this repository, not asserted.
|
|
22
|
+
|
|
23
|
+
**One. A fixed threshold means different things at different sample sizes.**
|
|
24
|
+
PSI under the null is distributed as `(1/n + 1/m) * chi2_{B-1}`, so it depends
|
|
25
|
+
on both sample sizes and the bin count. The industry rule depends on neither.
|
|
26
|
+
False-alarm rate of the "watch" rule on identically distributed data:
|
|
27
|
+
|
|
28
|
+
| samples per batch | 5 bins | 10 bins | 20 bins |
|
|
29
|
+
|---|---|---|---|
|
|
30
|
+
| 50 | 64.3% | 98.2% | **100.0%** |
|
|
31
|
+
| 100 | 29.5% | 85.1% | 100.0% |
|
|
32
|
+
| 200 | 4.1% | 37.0% | 96.0% |
|
|
33
|
+
| 500 | 0.0% | 0.3% | 17.6% |
|
|
34
|
+
| 1,000 and up | 0.0% | 0.0% | 0.0% |
|
|
35
|
+
|
|
36
|
+
The calibrated 1% threshold across that same grid runs from 1.4476 down to
|
|
37
|
+
0.0004, a span of about 5,400. One number cannot serve all of it.
|
|
38
|
+
|
|
39
|
+
**Two. Twenty features is twenty chances to be wrong.** Twenty clean features
|
|
40
|
+
tested independently at alpha = 0.05 produce at least one alarm on 73% of clean
|
|
41
|
+
batches. With Benjamini-Hochberg, 5%. Monitoring is a multiple-testing problem
|
|
42
|
+
before it is a drift problem.
|
|
43
|
+
|
|
44
|
+
**Three. Fifty-two weekly checks is not one check.** A test calibrated to 5%,
|
|
45
|
+
run weekly, is wrong at least once during a year with probability 93%. Getting
|
|
46
|
+
the single check right and then scheduling it re-creates the problem it solved.
|
|
47
|
+
|
|
48
|
+

|
|
49
|
+
|
|
50
|
+
## Using it
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from iyipada import Detector, Monitor
|
|
54
|
+
|
|
55
|
+
d = Detector(reference_values, statistic="psi", alpha=0.01)
|
|
56
|
+
r = d.check(this_weeks_values)
|
|
57
|
+
|
|
58
|
+
r.drifted # bool, decided against a calibrated threshold
|
|
59
|
+
r.pvalue # what you actually want to look at
|
|
60
|
+
r.threshold # computed from your sample sizes, not quoted from anywhere
|
|
61
|
+
r.warning # set when the batch is too small for the test to mean much
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Nothing in the API accepts a raw divergence threshold, because a raw divergence
|
|
65
|
+
threshold cannot be interpreted without knowing the sample sizes and the bin
|
|
66
|
+
count.
|
|
67
|
+
|
|
68
|
+
For a table of features, with the multiple testing handled:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
m = Monitor(reference_dataframe_as_dict, alpha=0.05, correction="fdr")
|
|
72
|
+
result = m.check(live_dataframe_as_dict)
|
|
73
|
+
result.drifted # the features that moved, after FDR control
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
For a monitor that runs on a schedule, stop re-testing and accumulate:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from iyipada import sequential as sq
|
|
80
|
+
|
|
81
|
+
h, arl = sq.calibrate_run_length("cusum", target_arl=260) # weekly checks
|
|
82
|
+
# h = 3.75, one false alarm every 4.8 years of weekly monitoring
|
|
83
|
+
c = sq.CUSUM(h=h)
|
|
84
|
+
c.update(this_weeks_statistic) # True when the evidence has accumulated
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Which detector should you actually use
|
|
88
|
+
|
|
89
|
+
Wasserstein, in most cases. Every statistic below was first calibrated to the
|
|
90
|
+
same 1% false-alarm rate, so this compares power and nothing else. Detection
|
|
91
|
+
rate at 1,000 samples a side:
|
|
92
|
+
|
|
93
|
+
| | mean shift | variance | skew | discretisation | mixture | tail only |
|
|
94
|
+
|---|---|---|---|---|---|---|
|
|
95
|
+
| **Wasserstein** | 100% | 100% | 100% | 100% | **99.5%** | **15.7%** |
|
|
96
|
+
| PSI | 99.5% | 100% | 100% | 100% | 86.2% | 3.5% |
|
|
97
|
+
| Jensen-Shannon | 99.5% | 100% | 100% | 100% | 85.8% | 3.5% |
|
|
98
|
+
| chi-square | 99.5% | 100% | 100% | 100% | 85.7% | 3.5% |
|
|
99
|
+
| Kolmogorov-Smirnov | 100% | 99.8% | 88.7% | 100% | 83.0% | 2.3% |
|
|
100
|
+
| total variation | 98.3% | 100% | 99.8% | 100% | 59.7% | 2.8% |
|
|
101
|
+
|
|
102
|
+

|
|
103
|
+
|
|
104
|
+
Wasserstein wins or ties everywhere, and it reports in the units of the
|
|
105
|
+
variable, so a result reads as "the distribution moved by 0.3 mg/dL" rather
|
|
106
|
+
than as an abstract divergence. PSI, the industry default, is beaten on the two
|
|
107
|
+
hardest cases.
|
|
108
|
+
|
|
109
|
+
### The blind spot
|
|
110
|
+
|
|
111
|
+

|
|
112
|
+
|
|
113
|
+
A small fraction of values jumping to the far tail is close to invisible. At 5%
|
|
114
|
+
contamination Wasserstein catches it every time, PSI 29% of the time and
|
|
115
|
+
Kolmogorov-Smirnov 18%. At 1.5% nothing is reliable.
|
|
116
|
+
|
|
117
|
+
The binned statistics fail because quantile binning puts the reference's entire
|
|
118
|
+
top decile into one bin, so moving mass from +2 to +6 does not change the
|
|
119
|
+
histogram at all. KS fails because a few per cent in the far tail cannot move
|
|
120
|
+
the largest vertical gap between two cumulative curves. Wasserstein survives
|
|
121
|
+
because it measures horizontal distance.
|
|
122
|
+
|
|
123
|
+
If rare extreme values are the failure you fear, monitor the tail directly with
|
|
124
|
+
a quantile or an exceedance count. A distributional distance is the wrong
|
|
125
|
+
instrument, however well calibrated.
|
|
126
|
+
|
|
127
|
+
## What is verified, and how
|
|
128
|
+
|
|
129
|
+

|
|
130
|
+
|
|
131
|
+
The asymptotic null is not taken on faith. Simulated 95th percentile divided by
|
|
132
|
+
theory, across ten sample sizes and three bin counts, sits within 2% of 1.0 from
|
|
133
|
+
200 samples upward. It is 6 to 9% optimistic around n = 100 and it breaks down
|
|
134
|
+
at n = 50 with 20 bins, where each bin holds two or three points and the
|
|
135
|
+
chi-square approximation has nothing to stand on.
|
|
136
|
+
|
|
137
|
+
So the library uses the closed form above 300 samples and simulates the null
|
|
138
|
+
below it, rather than trusting one answer everywhere. With that switch, the
|
|
139
|
+
calibrated threshold holds between 0.0% and 2.2% actual false-alarm rate across
|
|
140
|
+
the entire grid, against 0% to 100% for the fixed rule.
|
|
141
|
+
|
|
142
|
+

|
|
143
|
+
|
|
144
|
+
The ringed cell is 112 samples at 10 bins. That is a real case from
|
|
145
|
+
[loom](https://github.com/Kenny0bi/loom), an earlier project of mine, where a
|
|
146
|
+
fixed 0.10 rule fired a spurious retrain on identically distributed data. It
|
|
147
|
+
fires there 78.6% of the time. That bug is why this library exists.
|
|
148
|
+
|
|
149
|
+
## The score, animated
|
|
150
|
+
|
|
151
|
+

|
|
152
|
+
|
|
153
|
+
The null distribution of PSI collapsing as the sample grows, while the fixed
|
|
154
|
+
rule stays exactly where it is. Every number in the animation comes from
|
|
155
|
+
`benchmarks/false_alarms.py`. (Source:
|
|
156
|
+
[assets/manim_null.py](assets/manim_null.py), video in
|
|
157
|
+
[assets/null.mp4](assets/null.mp4).)
|
|
158
|
+
|
|
159
|
+
## What is in here
|
|
160
|
+
|
|
161
|
+
- [iyipada/divergence.py](iyipada/divergence.py) the statistics: PSI,
|
|
162
|
+
Jensen-Shannon, total variation, Kolmogorov-Smirnov, Wasserstein,
|
|
163
|
+
chi-square. None of them decides anything.
|
|
164
|
+
- [iyipada/null.py](iyipada/null.py) the null distributions: analytic for PSI,
|
|
165
|
+
chi-square and KS, permutation for everything else, plus
|
|
166
|
+
`false_alarm_rate()` for measuring what a threshold you already use is
|
|
167
|
+
actually doing.
|
|
168
|
+
- [iyipada/detect.py](iyipada/detect.py) `Detector` and `Monitor`, the latter
|
|
169
|
+
with FDR or Bonferroni control.
|
|
170
|
+
- [iyipada/sequential.py](iyipada/sequential.py) CUSUM and Page-Hinkley
|
|
171
|
+
calibrated to an average run length, alpha spending across a known horizon,
|
|
172
|
+
and `detection_delay()` so a monitor that never false-alarms can be checked
|
|
173
|
+
for being merely deaf.
|
|
174
|
+
- [benchmarks/](benchmarks/) the three studies behind every number above.
|
|
175
|
+
|
|
176
|
+
## Honest limits
|
|
177
|
+
|
|
178
|
+
- **Univariate only.** Correlation between features can shift while every
|
|
179
|
+
marginal stays put, and nothing here would see it. Multivariate drift needs a
|
|
180
|
+
different tool.
|
|
181
|
+
- **The permutation path is slow.** Wasserstein and total variation have no
|
|
182
|
+
closed-form null here, so their thresholds are simulated. That is a few
|
|
183
|
+
seconds per feature, cached per sample size.
|
|
184
|
+
- **Independence is assumed across checks.** The Sidak correction and the run
|
|
185
|
+
length calibration both assume successive batches are independent. Weekly
|
|
186
|
+
batches of the same seasonal process are not, and the true false-alarm rate
|
|
187
|
+
will be somewhat higher than advertised.
|
|
188
|
+
- **Detecting drift is not detecting harm.** A feature can move a long way
|
|
189
|
+
without the model caring, and can stay still while the relationship between
|
|
190
|
+
it and the target rots. This library measures the first thing. Watching the
|
|
191
|
+
second needs labels.
|
|
192
|
+
- **Below about 100 samples, nothing works.** The thresholds are honest there,
|
|
193
|
+
which mostly means they are honestly enormous. A detector that cannot fire is
|
|
194
|
+
correctly calibrated and useless. `Detector` says so in `r.warning`.
|
|
195
|
+
|
|
196
|
+
## Running it
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
pip install numpy scipy pandas
|
|
200
|
+
|
|
201
|
+
python tests/test_null.py # 9 contracts on the null distributions
|
|
202
|
+
python tests/test_detect.py # 10 on the detector and monitor APIs
|
|
203
|
+
python tests/test_sequential.py # 8 on run lengths and detection delay
|
|
204
|
+
|
|
205
|
+
python benchmarks/false_alarms.py # the headline table, about 7 minutes
|
|
206
|
+
python benchmarks/power.py # detector against drift type, about 4 minutes
|
|
207
|
+
python assets/make_visuals.py # the six figures
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
27 tests. The interesting ones assert the findings themselves, so if the
|
|
211
|
+
industry thresholds ever stop being miscalibrated, the suite will say so.
|
|
212
|
+
|
|
213
|
+
## Papers
|
|
214
|
+
|
|
215
|
+
- B. Yurdakul (2018), *Statistical properties of population stability index*,
|
|
216
|
+
Western Michigan University dissertation. Derives the asymptotic
|
|
217
|
+
distribution, and states plainly that the 0.10 and 0.25 traffic-light values
|
|
218
|
+
have no support in the literature.
|
|
219
|
+
- Bracher et al. and the wider proper-scoring literature for why a decision
|
|
220
|
+
rule needs a stated error rate rather than a threshold.
|
|
221
|
+
- Page (1954), *Continuous inspection schemes*, Biometrika. The original CUSUM,
|
|
222
|
+
and the reason average run length is the right quantity for a monitor that
|
|
223
|
+
runs forever.
|
|
224
|
+
- Benjamini and Hochberg (1995), *Controlling the false discovery rate*, JRSS-B.
|
|
225
|
+
What `Monitor` uses when you hand it fifty features.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Drift detection where the threshold is calculated instead of quoted.
|
|
2
|
+
|
|
3
|
+
from iyipada import Detector
|
|
4
|
+
d = Detector(reference, statistic="wasserstein", alpha=0.01)
|
|
5
|
+
r = d.check(live)
|
|
6
|
+
r.drifted, r.pvalue, r.threshold
|
|
7
|
+
|
|
8
|
+
The point of the library, in one sentence: a drift threshold is not a property
|
|
9
|
+
of the statistic, it is a property of how much data you have, and every
|
|
10
|
+
published rule of thumb ignores that.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .detect import Detector, Monitor, Result, TableResult
|
|
14
|
+
from .divergence import (chi_square, jensen_shannon, ks_statistic, psi,
|
|
15
|
+
quantile_edges, total_variation, wasserstein)
|
|
16
|
+
from .null import (analytic_threshold, false_alarm_rate, permutation_null,
|
|
17
|
+
psi_null, simulated_threshold, threshold)
|
|
18
|
+
from .sequential import (CUSUM, PageHinkley, any_alarm_probability,
|
|
19
|
+
calibrate_run_length, detection_delay,
|
|
20
|
+
family_wise_alpha, run_length)
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Detector", "Monitor", "Result", "TableResult",
|
|
26
|
+
"psi", "jensen_shannon", "total_variation", "ks_statistic",
|
|
27
|
+
"wasserstein", "chi_square", "quantile_edges",
|
|
28
|
+
"threshold", "analytic_threshold", "simulated_threshold",
|
|
29
|
+
"permutation_null", "psi_null", "false_alarm_rate",
|
|
30
|
+
"CUSUM", "PageHinkley", "calibrate_run_length", "run_length",
|
|
31
|
+
"detection_delay", "family_wise_alpha", "any_alarm_probability",
|
|
32
|
+
]
|