driftfdr 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.
- driftfdr/__init__.py +88 -0
- driftfdr/bootstrap.py +156 -0
- driftfdr/calibration.py +278 -0
- driftfdr/datasets.py +375 -0
- driftfdr/detectors.py +522 -0
- driftfdr/integrations/__init__.py +21 -0
- driftfdr/integrations/mlflow.py +72 -0
- driftfdr/integrations/nannyml.py +31 -0
- driftfdr/integrations/prometheus.py +72 -0
- driftfdr/metrics.py +97 -0
- driftfdr/monitor.py +135 -0
- driftfdr/online_fdr.py +350 -0
- driftfdr/preprocess.py +63 -0
- driftfdr/streaming.py +532 -0
- driftfdr/streams.py +347 -0
- driftfdr-0.1.0.dist-info/METADATA +443 -0
- driftfdr-0.1.0.dist-info/RECORD +21 -0
- driftfdr-0.1.0.dist-info/WHEEL +5 -0
- driftfdr-0.1.0.dist-info/licenses/LICENSE +202 -0
- driftfdr-0.1.0.dist-info/licenses/NOTICE +5 -0
- driftfdr-0.1.0.dist-info/top_level.txt +1 -0
driftfdr/__init__.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Calibrated p-values and multiplicity control on top of practical drift detectors, for fleets of ML models."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .calibration import CalibrationConfig, NullDistribution, calibrate, calibrate_many
|
|
6
|
+
from .detectors import (
|
|
7
|
+
ADWIN,
|
|
8
|
+
DDM,
|
|
9
|
+
ECUSUM,
|
|
10
|
+
Detector,
|
|
11
|
+
KSSliding,
|
|
12
|
+
KSWindow,
|
|
13
|
+
MeanShift,
|
|
14
|
+
PageHinkley,
|
|
15
|
+
Prewhitened,
|
|
16
|
+
ar_whiten,
|
|
17
|
+
default_detectors,
|
|
18
|
+
)
|
|
19
|
+
from .metrics import summarize
|
|
20
|
+
from .monitor import MonitorConfig, MonitorResult, run_monitor
|
|
21
|
+
from .online_fdr import (
|
|
22
|
+
LOND,
|
|
23
|
+
SAFFRON,
|
|
24
|
+
AlphaInvesting,
|
|
25
|
+
BatchBH,
|
|
26
|
+
BHWindow,
|
|
27
|
+
BonferroniWindow,
|
|
28
|
+
EBHWindow,
|
|
29
|
+
LORDpp,
|
|
30
|
+
RawThreshold,
|
|
31
|
+
StoreyBHWindow,
|
|
32
|
+
Uncorrected,
|
|
33
|
+
make_procedure,
|
|
34
|
+
)
|
|
35
|
+
from .preprocess import bucket_means, split_common, tolerance_from_cost
|
|
36
|
+
from .streaming import CalibratedDetector, StreamingMonitor, from_river
|
|
37
|
+
from .streams import Scenario, ScenarioConfig, SupervisedConfig, benchmark_suite, make_scenario, make_supervised_scenario
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"ADWIN",
|
|
41
|
+
"DDM",
|
|
42
|
+
"ECUSUM",
|
|
43
|
+
"LOND",
|
|
44
|
+
"SAFFRON",
|
|
45
|
+
"AlphaInvesting",
|
|
46
|
+
"BHWindow",
|
|
47
|
+
"BatchBH",
|
|
48
|
+
"CalibratedDetector",
|
|
49
|
+
"BonferroniWindow",
|
|
50
|
+
"CalibrationConfig",
|
|
51
|
+
"EBHWindow",
|
|
52
|
+
"Detector",
|
|
53
|
+
"KSSliding",
|
|
54
|
+
"KSWindow",
|
|
55
|
+
"LORDpp",
|
|
56
|
+
"MeanShift",
|
|
57
|
+
"MonitorConfig",
|
|
58
|
+
"MonitorResult",
|
|
59
|
+
"NullDistribution",
|
|
60
|
+
"PageHinkley",
|
|
61
|
+
"Prewhitened",
|
|
62
|
+
"RawThreshold",
|
|
63
|
+
"Scenario",
|
|
64
|
+
"ScenarioConfig",
|
|
65
|
+
"StoreyBHWindow",
|
|
66
|
+
"StreamingMonitor",
|
|
67
|
+
"SupervisedConfig",
|
|
68
|
+
"Uncorrected",
|
|
69
|
+
"calibrate",
|
|
70
|
+
"calibrate_many",
|
|
71
|
+
"default_detectors",
|
|
72
|
+
"from_river",
|
|
73
|
+
"make_procedure",
|
|
74
|
+
"make_scenario",
|
|
75
|
+
"make_supervised_scenario",
|
|
76
|
+
"run_monitor",
|
|
77
|
+
"summarize",
|
|
78
|
+
"ar_whiten",
|
|
79
|
+
"benchmark_suite",
|
|
80
|
+
"bucket_means",
|
|
81
|
+
"tolerance_from_cost",
|
|
82
|
+
"split_common",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
__version__ = version("driftfdr")
|
|
87
|
+
except PackageNotFoundError: # running from a source tree that is not installed
|
|
88
|
+
__version__ = "0.0.0"
|
driftfdr/bootstrap.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Resampling schemes for autocorrelated series.
|
|
2
|
+
|
|
3
|
+
* ``moving``: moving block bootstrap (Künsch, 1989), fixed block length;
|
|
4
|
+
* ``stationary``: stationary bootstrap (Politis & Romano, 1994), geometric
|
|
5
|
+
block lengths with the given mean;
|
|
6
|
+
* ``sieve``: AR-sieve bootstrap (Bühlmann, 1997), an AR(p) fitted by
|
|
7
|
+
Yule–Walker with AIC order selection, driven by resampled residuals; only
|
|
8
|
+
meaningful for continuous signals;
|
|
9
|
+
* ``sieve_pu``: AR-sieve with parameter uncertainty. Each replicate refits the
|
|
10
|
+
AR model on a series simulated from the fitted one and is generated from its
|
|
11
|
+
own refitted coefficients, so the estimation error of the reference model
|
|
12
|
+
(the source of the anti-conservative tails the plain sieve shows) is carried
|
|
13
|
+
into the null distribution. Same idea as the correction of Wu & Apley for
|
|
14
|
+
nested bootstraps.
|
|
15
|
+
* ``iid``: ordinary bootstrap, kept as a baseline that ignores dependence.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
from scipy import signal
|
|
22
|
+
|
|
23
|
+
METHODS = ("moving", "stationary", "sieve", "sieve_pu", "iid")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ar1_block_length(x: np.ndarray, method: str = "moving") -> int:
|
|
27
|
+
"""Plug-in block length for an AR(1) approximation of ``x``.
|
|
28
|
+
|
|
29
|
+
Uses the Politis–White (2004) optimal-rate formula specialised to AR(1):
|
|
30
|
+
``b = c * (2 r / (1 - r^2))^(2/3) * n^(1/3)``, with ``c = (3/2)^(1/3)``
|
|
31
|
+
for block bootstrap and ``c = 1`` for the stationary bootstrap, where ``r``
|
|
32
|
+
is the lag-1 autocorrelation.
|
|
33
|
+
"""
|
|
34
|
+
x = np.asarray(x, dtype=float)
|
|
35
|
+
n = x.size
|
|
36
|
+
xc = x - x.mean()
|
|
37
|
+
denom = float(xc @ xc)
|
|
38
|
+
if n < 8 or denom == 0.0:
|
|
39
|
+
return 1
|
|
40
|
+
r = float(np.clip((xc[1:] @ xc[:-1]) / denom, 0.0, 0.95))
|
|
41
|
+
if r == 0.0:
|
|
42
|
+
return 1
|
|
43
|
+
c = 1.0 if method == "stationary" else 1.5 ** (1 / 3)
|
|
44
|
+
b = c * (2 * r / (1 - r**2)) ** (2 / 3) * n ** (1 / 3)
|
|
45
|
+
return int(np.clip(np.ceil(b), 1, max(1, n // 4)))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def bootstrap_indices(
|
|
49
|
+
n_source: int, n_out: int, n_boot: int, block_length: int, method: str, rng
|
|
50
|
+
) -> np.ndarray:
|
|
51
|
+
"""Indices into a source series of length ``n_source``, shape ``(n_boot, n_out)``."""
|
|
52
|
+
if method not in METHODS:
|
|
53
|
+
raise ValueError(f"method must be one of {METHODS}")
|
|
54
|
+
if method == "iid" or block_length <= 1:
|
|
55
|
+
return rng.integers(0, n_source, size=(n_boot, n_out))
|
|
56
|
+
if method == "moving":
|
|
57
|
+
b = min(block_length, n_source)
|
|
58
|
+
n_blocks = -(-n_out // b)
|
|
59
|
+
starts = rng.integers(0, n_source - b + 1, size=(n_boot, n_blocks))
|
|
60
|
+
idx = starts[:, :, None] + np.arange(b)
|
|
61
|
+
return idx.reshape(n_boot, n_blocks * b)[:, :n_out]
|
|
62
|
+
# stationary bootstrap: start a new block with probability 1/b, wrap around
|
|
63
|
+
new_block = rng.random((n_boot, n_out)) < 1.0 / block_length
|
|
64
|
+
new_block[:, 0] = True
|
|
65
|
+
starts = rng.integers(0, n_source, size=(n_boot, n_out))
|
|
66
|
+
pos = np.arange(n_out)
|
|
67
|
+
last = np.maximum.accumulate(np.where(new_block, pos, 0), axis=1)
|
|
68
|
+
return (np.take_along_axis(starts, last, axis=1) + pos - last) % n_source
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def fit_ar_aic(x: np.ndarray, max_order: int | None = None) -> np.ndarray:
|
|
72
|
+
"""Yule–Walker AR coefficients ``a`` (``x_t = sum_i a_i x_{t-i} + e_t``), order by AIC."""
|
|
73
|
+
x = np.asarray(x, dtype=float) - np.mean(x)
|
|
74
|
+
n = x.size
|
|
75
|
+
if max_order is None:
|
|
76
|
+
max_order = int(min(10 * np.log10(n), n // 10))
|
|
77
|
+
acov = np.array([x[: n - k] @ x[k:] / n for k in range(max_order + 1)])
|
|
78
|
+
if acov[0] <= 0:
|
|
79
|
+
return np.zeros(0)
|
|
80
|
+
# Levinson–Durbin recursion gives every order up to max_order at once
|
|
81
|
+
best_aic, best = n * np.log(acov[0]), np.zeros(0)
|
|
82
|
+
a, err = np.zeros(0), acov[0]
|
|
83
|
+
for p in range(1, max_order + 1):
|
|
84
|
+
k = (acov[p] - a @ acov[p - 1 : 0 : -1]) / err
|
|
85
|
+
a = np.concatenate([a - k * a[::-1], [k]])
|
|
86
|
+
err *= 1.0 - k * k
|
|
87
|
+
if err <= 0:
|
|
88
|
+
break
|
|
89
|
+
aic = n * np.log(err) + 2 * p
|
|
90
|
+
if aic < best_aic:
|
|
91
|
+
best_aic, best = aic, a.copy()
|
|
92
|
+
return best
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def ar_sieve_series(x: np.ndarray, n_out: int, n_boot: int, rng, burn_in: int = 200):
|
|
96
|
+
"""AR-sieve bootstrap replicates of ``x``; returns ``(series, order)``."""
|
|
97
|
+
x = np.asarray(x, dtype=float)
|
|
98
|
+
mu = x.mean()
|
|
99
|
+
a = fit_ar_aic(x)
|
|
100
|
+
p = a.size
|
|
101
|
+
xc = x - mu
|
|
102
|
+
if p:
|
|
103
|
+
resid = xc[p:] - np.stack([xc[p - i : x.size - i] for i in range(1, p + 1)], axis=1) @ a
|
|
104
|
+
else:
|
|
105
|
+
resid = xc
|
|
106
|
+
resid = resid - resid.mean()
|
|
107
|
+
innov = rng.choice(resid, size=(n_boot, burn_in + n_out))
|
|
108
|
+
series = signal.lfilter([1.0], np.concatenate([[1.0], -a]), innov, axis=1)
|
|
109
|
+
return series[:, burn_in:] + mu, p
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _levinson_batch(acov: np.ndarray, order: int):
|
|
113
|
+
"""Yule–Walker coefficients of a fixed order for many autocovariance rows at once."""
|
|
114
|
+
a = np.zeros((acov.shape[0], 0))
|
|
115
|
+
err = acov[:, 0].copy()
|
|
116
|
+
for k in range(1, order + 1):
|
|
117
|
+
refl = (acov[:, k] - np.einsum("bi,bi->b", a, acov[:, k - 1 : 0 : -1])) / err
|
|
118
|
+
a = np.concatenate([a - refl[:, None] * a[:, ::-1], refl[:, None]], axis=1)
|
|
119
|
+
err = err * (1.0 - refl**2)
|
|
120
|
+
return a, err
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def ar_sieve_pu_series(x: np.ndarray, n_out: int, n_boot: int, rng, burn_in: int = 200):
|
|
124
|
+
"""AR-sieve replicates whose coefficients are redrawn per replicate; returns ``(series, order)``.
|
|
125
|
+
|
|
126
|
+
Coefficients of replicate ``b`` are the Yule–Walker fit (same order as the
|
|
127
|
+
original AIC choice) to a series of ``len(x)`` steps simulated from the
|
|
128
|
+
model fitted to ``x``, i.e. a parametric bootstrap draw of the estimator.
|
|
129
|
+
"""
|
|
130
|
+
x = np.asarray(x, dtype=float)
|
|
131
|
+
n = x.size
|
|
132
|
+
mu = x.mean()
|
|
133
|
+
a = fit_ar_aic(x)
|
|
134
|
+
p = a.size
|
|
135
|
+
xc = x - mu
|
|
136
|
+
if p == 0:
|
|
137
|
+
# white noise: parameter uncertainty is only in the variance
|
|
138
|
+
sims = rng.choice(xc, size=(n_boot, n))
|
|
139
|
+
scale = sims.std(axis=1) / xc.std()
|
|
140
|
+
return rng.choice(xc, size=(n_boot, n_out)) * scale[:, None] + mu, 0
|
|
141
|
+
resid = xc[p:] - np.stack([xc[p - i : n - i] for i in range(1, p + 1)], axis=1) @ a
|
|
142
|
+
resid = resid - resid.mean()
|
|
143
|
+
ar = np.concatenate([[1.0], -a])
|
|
144
|
+
sims = signal.lfilter([1.0], ar, rng.choice(resid, size=(n_boot, burn_in + n)), axis=1)[:, burn_in:]
|
|
145
|
+
sims = sims - sims.mean(axis=1, keepdims=True)
|
|
146
|
+
acov = np.stack([np.sum(sims[:, : n - k] * sims[:, k:], axis=1) / n for k in range(p + 1)], axis=1)
|
|
147
|
+
a_b, err_b = _levinson_batch(acov, p)
|
|
148
|
+
scale = np.sqrt(np.maximum(err_b, 1e-12) / np.mean(resid**2))
|
|
149
|
+
innov = rng.choice(resid, size=(n_boot, burn_in + n_out)) * scale[:, None]
|
|
150
|
+
y = np.zeros_like(innov)
|
|
151
|
+
for t in range(innov.shape[1]):
|
|
152
|
+
acc = innov[:, t].copy()
|
|
153
|
+
for i in range(1, min(p, t) + 1):
|
|
154
|
+
acc += a_b[:, i - 1] * y[:, t - i]
|
|
155
|
+
y[:, t] = acc
|
|
156
|
+
return y[:, burn_in:] + mu, p
|
driftfdr/calibration.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Turning detector statistics into p-values by resampling.
|
|
2
|
+
|
|
3
|
+
For a stream with reference segment ``R`` the null distribution of the window
|
|
4
|
+
statistic is estimated by scoring ``B`` bootstrap series resampled from ``R``:
|
|
5
|
+
a pseudo-reference of ``len(R)`` steps followed by a pseudo-window. Both parts
|
|
6
|
+
are resampled, so their mutual variability matches that of a fresh window
|
|
7
|
+
compared with the observed reference. The p-value is the usual
|
|
8
|
+
``(1 + #{T* >= T}) / (B + 1)``.
|
|
9
|
+
|
|
10
|
+
With ``B`` in the hundreds the smallest attainable p-value is ``1 / (B + 1)``,
|
|
11
|
+
far above the per-test levels online FDR procedures reach with hundreds of
|
|
12
|
+
streams. When fewer than ``min_exceedances`` bootstrap statistics exceed the
|
|
13
|
+
observed one, the p-value is extrapolated from a tail model fitted to the top
|
|
14
|
+
``tail_fraction`` of the bootstrap sample (Knijnenburg et al., 2009). The
|
|
15
|
+
default tail is exponential: the statistics here are maxima of sums or
|
|
16
|
+
squared standardised differences, which lie in the Gumbel domain, and fixing
|
|
17
|
+
the shape avoids the large variance of an estimated GPD shape. A GPD fit by
|
|
18
|
+
probability-weighted moments is available as ``tail="gpd"``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import bisect
|
|
24
|
+
import math
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
from .bootstrap import ar1_block_length, ar_sieve_pu_series, ar_sieve_series, bootstrap_indices
|
|
30
|
+
from .detectors import Detector
|
|
31
|
+
|
|
32
|
+
P_FLOOR = 1e-16
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class CalibrationConfig:
|
|
37
|
+
"""Defaults follow the experiments: AR-sieve with parameter uncertainty (moving
|
|
38
|
+
blocks are used automatically for 0/1 signals), 2000 replicates and a GPD tail,
|
|
39
|
+
which bring the per-window FWER of Bonferroni to its nominal level for
|
|
40
|
+
Page-Hinkley and KS (experiment 10). Experiments 1-9 and 11-15 pin the earlier
|
|
41
|
+
defaults (500 replicates, exponential tail) explicitly."""
|
|
42
|
+
|
|
43
|
+
n_boot: int = 2000
|
|
44
|
+
method: str = "sieve_pu"
|
|
45
|
+
block_length: int | str = "auto"
|
|
46
|
+
tail: str = "gpd"
|
|
47
|
+
"""Tail model for small p-values: ``exponential``, ``gpd`` or ``none``."""
|
|
48
|
+
tail_fraction: float = 0.1
|
|
49
|
+
min_exceedances: int = 10
|
|
50
|
+
seed: int = 12345
|
|
51
|
+
tolerance: float = 0.0
|
|
52
|
+
"""Null of *material* change: the new data may exceed the reference level by up to
|
|
53
|
+
this much (signal units). The bootstrap continuation is shifted up by it, the least
|
|
54
|
+
favourable point of that null, so p-values are valid for every smaller increase."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class NullDistribution:
|
|
59
|
+
"""Bootstrap null distribution of a window statistic, with an optional fitted upper tail."""
|
|
60
|
+
|
|
61
|
+
samples: np.ndarray
|
|
62
|
+
"""Sorted bootstrap statistics (may contain ``inf``)."""
|
|
63
|
+
block_length: int
|
|
64
|
+
"""Block length, or the AR order for the sieve bootstrap."""
|
|
65
|
+
min_exceedances: int = 10
|
|
66
|
+
tail_threshold: float = np.nan
|
|
67
|
+
tail_scale: float = np.nan
|
|
68
|
+
tail_shape: float = np.nan
|
|
69
|
+
tail_prob: float = np.nan
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_samples(
|
|
73
|
+
cls, samples: np.ndarray, block_length: int, config: CalibrationConfig
|
|
74
|
+
) -> NullDistribution:
|
|
75
|
+
"""Sort the bootstrap statistics and fit the tail model requested by ``config``."""
|
|
76
|
+
samples = np.sort(np.asarray(samples, dtype=float))
|
|
77
|
+
null = cls(samples, block_length, config.min_exceedances)
|
|
78
|
+
if config.tail != "none":
|
|
79
|
+
fit = fit_tail(samples, config.tail_fraction, config.tail)
|
|
80
|
+
if fit is not None:
|
|
81
|
+
null.tail_threshold, null.tail_scale, null.tail_shape, null.tail_prob = fit
|
|
82
|
+
return null
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def has_tail(self) -> bool:
|
|
86
|
+
"""Whether a tail model was fitted (it is not for degenerate or tiny samples)."""
|
|
87
|
+
return bool(np.isfinite(self.tail_scale))
|
|
88
|
+
|
|
89
|
+
def pvalue(self, statistic) -> np.ndarray:
|
|
90
|
+
"""p-values of one or more observed statistics, as an array.
|
|
91
|
+
|
|
92
|
+
``(1 + #{T* >= T}) / (B + 1)``, replaced by the tail model when fewer than
|
|
93
|
+
``min_exceedances`` bootstrap statistics reach ``T``.
|
|
94
|
+
"""
|
|
95
|
+
stat = np.atleast_1d(np.asarray(statistic, dtype=float))
|
|
96
|
+
B = self.samples.size
|
|
97
|
+
count = B - np.searchsorted(self.samples, stat, side="left")
|
|
98
|
+
p = (1.0 + count) / (B + 1.0)
|
|
99
|
+
if self.has_tail:
|
|
100
|
+
use = (count < self.min_exceedances) & (stat > self.tail_threshold)
|
|
101
|
+
if use.any():
|
|
102
|
+
p[use] = self._tail_sf(stat[use])
|
|
103
|
+
return p
|
|
104
|
+
|
|
105
|
+
def pvalue_scalar(self, statistic: float) -> float:
|
|
106
|
+
"""``pvalue`` for a single statistic without numpy overhead (for per-step use)."""
|
|
107
|
+
samples = self.__dict__.get("_sample_list")
|
|
108
|
+
if samples is None:
|
|
109
|
+
samples = self.__dict__["_sample_list"] = self.samples.tolist()
|
|
110
|
+
B = len(samples)
|
|
111
|
+
count = B - bisect.bisect_left(samples, statistic)
|
|
112
|
+
if count < self.min_exceedances and math.isfinite(self.tail_scale) and statistic > self.tail_threshold:
|
|
113
|
+
y = (statistic - self.tail_threshold) / self.tail_scale
|
|
114
|
+
if self.tail_shape > 1e-12:
|
|
115
|
+
base = 1.0 + self.tail_shape * y
|
|
116
|
+
sf = base ** (-1.0 / self.tail_shape) if base > 0 else 0.0
|
|
117
|
+
else:
|
|
118
|
+
sf = math.exp(-y) if y < 700 else 0.0
|
|
119
|
+
return min(1.0, max(P_FLOOR, self.tail_prob * sf))
|
|
120
|
+
return (1.0 + count) / (B + 1.0)
|
|
121
|
+
|
|
122
|
+
def _tail_sf(self, stat: np.ndarray) -> np.ndarray:
|
|
123
|
+
y = (stat - self.tail_threshold) / self.tail_scale
|
|
124
|
+
with np.errstate(over="ignore", invalid="ignore"):
|
|
125
|
+
if self.tail_shape > 1e-12:
|
|
126
|
+
sf = (1.0 + self.tail_shape * y) ** (-1.0 / self.tail_shape)
|
|
127
|
+
else:
|
|
128
|
+
sf = np.exp(-y)
|
|
129
|
+
sf = np.where(np.isfinite(sf), sf, 0.0)
|
|
130
|
+
return np.clip(self.tail_prob * sf, P_FLOOR, 1.0)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def fit_tail(
|
|
134
|
+
sorted_samples: np.ndarray, tail_fraction: float, model: str = "exponential", min_tail: int = 30
|
|
135
|
+
):
|
|
136
|
+
"""Fit an exponential or GPD tail to the largest bootstrap statistics.
|
|
137
|
+
|
|
138
|
+
The GPD is fitted by probability-weighted moments (Hosking & Wallis, 1987)
|
|
139
|
+
with the shape clipped to ``[0, 0.9]``. Returns
|
|
140
|
+
``(threshold, scale, shape, P(T > threshold))`` or ``None``.
|
|
141
|
+
"""
|
|
142
|
+
if model not in ("exponential", "gpd"):
|
|
143
|
+
raise ValueError("tail model must be 'exponential', 'gpd' or 'none'")
|
|
144
|
+
B = sorted_samples.size
|
|
145
|
+
finite = sorted_samples[np.isfinite(sorted_samples)]
|
|
146
|
+
n_exc = max(min_tail, int(tail_fraction * B))
|
|
147
|
+
if finite.size < n_exc + 1:
|
|
148
|
+
return None
|
|
149
|
+
u = finite[-(n_exc + 1)]
|
|
150
|
+
y = finite[-n_exc:] - u
|
|
151
|
+
if y.max() <= 0:
|
|
152
|
+
return None
|
|
153
|
+
prob = (n_exc + B - finite.size) / B
|
|
154
|
+
shape, scale = 0.0, y.mean()
|
|
155
|
+
if model == "gpd":
|
|
156
|
+
a0 = y.mean()
|
|
157
|
+
a1 = np.mean((1.0 - (np.arange(1, n_exc + 1) - 0.35) / n_exc) * y)
|
|
158
|
+
denom = a0 - 2.0 * a1
|
|
159
|
+
if denom > 0:
|
|
160
|
+
xi, sigma = 2.0 - a0 / denom, 2.0 * a0 * a1 / denom
|
|
161
|
+
if xi > 0 and sigma > 0:
|
|
162
|
+
shape, scale = min(xi, 0.9), sigma
|
|
163
|
+
return float(u), float(scale), float(shape), prob
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def resolve_block_length(reference: np.ndarray, method: str, config: CalibrationConfig) -> int:
|
|
167
|
+
"""Block length for a block method: 1 for iid, the AR(1) plug-in for ``"auto"``, else the given value."""
|
|
168
|
+
if method == "iid":
|
|
169
|
+
return 1
|
|
170
|
+
if config.block_length == "auto":
|
|
171
|
+
return ar1_block_length(reference, method)
|
|
172
|
+
return int(config.block_length)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def bootstrap_series(reference, length: int, config: CalibrationConfig, rng, binary: bool = False):
|
|
176
|
+
"""``B`` pseudo series: a resampled reference followed by ``length`` resampled steps.
|
|
177
|
+
|
|
178
|
+
With ``config.tolerance > 0`` the continuation is raised by the tolerance: added to
|
|
179
|
+
continuous values, or, for binary errors, by turning zeros into ones with the
|
|
180
|
+
probability that raises the error rate by that much.
|
|
181
|
+
"""
|
|
182
|
+
series, param = _resample(reference, length, config, rng, binary)
|
|
183
|
+
if config.tolerance > 0:
|
|
184
|
+
n_ref = reference.size
|
|
185
|
+
new = series[:, n_ref:]
|
|
186
|
+
if binary:
|
|
187
|
+
p0 = float(np.mean(reference))
|
|
188
|
+
flip = rng.random(new.shape) < min(1.0, config.tolerance / max(1e-9, 1.0 - p0))
|
|
189
|
+
series[:, n_ref:] = np.where(flip, 1.0, new)
|
|
190
|
+
else:
|
|
191
|
+
series[:, n_ref:] = new + config.tolerance
|
|
192
|
+
return series, param
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _resample(reference, length: int, config: CalibrationConfig, rng, binary: bool):
|
|
196
|
+
"""Resampled reference plus continuation, before any tolerance shift.
|
|
197
|
+
|
|
198
|
+
Reference and continuation are resampled independently, since the tested
|
|
199
|
+
data are generally not adjacent to the reference. The sieve bootstrap
|
|
200
|
+
produces continuous values, so binary signals fall back to moving blocks.
|
|
201
|
+
Returns ``(series, param)`` where ``param`` is the block length, or the AR
|
|
202
|
+
order for the sieve.
|
|
203
|
+
"""
|
|
204
|
+
n_ref = reference.size
|
|
205
|
+
B = config.n_boot
|
|
206
|
+
method = "moving" if (config.method.startswith("sieve") and binary) else config.method
|
|
207
|
+
if method == "sieve_pu":
|
|
208
|
+
# one path with shared coefficients; the gap decorrelates reference and new data
|
|
209
|
+
gap = 200
|
|
210
|
+
path, order = ar_sieve_pu_series(reference, n_ref + gap + length, B, rng)
|
|
211
|
+
return np.concatenate([path[:, :n_ref], path[:, n_ref + gap :]], axis=1), order
|
|
212
|
+
if method == "sieve":
|
|
213
|
+
ref_part, order = ar_sieve_series(reference, n_ref, B, rng)
|
|
214
|
+
new_part, _ = ar_sieve_series(reference, length, B, rng)
|
|
215
|
+
return np.concatenate([ref_part, new_part], axis=1), order
|
|
216
|
+
b = resolve_block_length(reference, method, config)
|
|
217
|
+
ref_idx = bootstrap_indices(n_ref, n_ref, B, b, method, rng)
|
|
218
|
+
new_idx = bootstrap_indices(n_ref, length, B, b, method, rng)
|
|
219
|
+
return reference[np.concatenate([ref_idx, new_idx], axis=1)].astype(float), b
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def calibrate_many(
|
|
223
|
+
detector: Detector,
|
|
224
|
+
references: np.ndarray,
|
|
225
|
+
window: int,
|
|
226
|
+
horizon: int,
|
|
227
|
+
config: CalibrationConfig,
|
|
228
|
+
seeds,
|
|
229
|
+
max_chunk_elements: int = 4_000_000,
|
|
230
|
+
) -> list[list[NullDistribution]]:
|
|
231
|
+
"""Bootstrap null distributions for several streams at once.
|
|
232
|
+
|
|
233
|
+
``references`` has shape ``(n_streams, n_ref)``. For every stream the
|
|
234
|
+
result holds ``horizon`` null distributions: entry ``h - 1`` is for the
|
|
235
|
+
statistic of the last window when the detector has seen ``h`` windows
|
|
236
|
+
since the reference. Because scores are causal, all of them come from a
|
|
237
|
+
single pass over bootstrap series of ``n_ref + horizon * window`` steps.
|
|
238
|
+
``seeds`` gives one seed (anything ``np.random.default_rng`` accepts) per
|
|
239
|
+
stream, so results do not depend on which streams are calibrated together.
|
|
240
|
+
"""
|
|
241
|
+
references = np.atleast_2d(np.asarray(references, dtype=float))
|
|
242
|
+
n_streams, n_ref = references.shape
|
|
243
|
+
length = horizon * window
|
|
244
|
+
B = config.n_boot
|
|
245
|
+
# binary by declaration (DDM) or by content (any detector fed 0/1 errors)
|
|
246
|
+
binary = detector.input_kind == "errors" or bool(np.isin(references, (0.0, 1.0)).all())
|
|
247
|
+
chunk = max(1, max_chunk_elements // (B * (n_ref + length)))
|
|
248
|
+
out: list[list[NullDistribution]] = []
|
|
249
|
+
for c0 in range(0, n_streams, chunk):
|
|
250
|
+
rows = range(c0, min(n_streams, c0 + chunk))
|
|
251
|
+
series, params = [], []
|
|
252
|
+
for a in rows:
|
|
253
|
+
rng = np.random.default_rng(seeds[a])
|
|
254
|
+
s, param = bootstrap_series(references[a], length, config, rng, binary)
|
|
255
|
+
series.append(s)
|
|
256
|
+
params.append(param)
|
|
257
|
+
stats = detector.window_statistics(np.concatenate(series), n_ref, window)
|
|
258
|
+
stats = stats.reshape(len(rows), B, horizon)
|
|
259
|
+
for a, st, b in zip(rows, stats, params):
|
|
260
|
+
if _untestable(references[a], binary, config.tolerance):
|
|
261
|
+
st = np.full_like(st, np.inf) # every p-value becomes 1
|
|
262
|
+
out.append([NullDistribution.from_samples(st[:, h], b, config) for h in range(horizon)])
|
|
263
|
+
return out
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _untestable(reference: np.ndarray, binary: bool, tolerance: float) -> bool:
|
|
267
|
+
"""A constant reference carries no information on variability, and a binary error
|
|
268
|
+
rate already within ``tolerance`` of 1 cannot rise materially; such streams get p = 1."""
|
|
269
|
+
if np.ptp(reference) == 0:
|
|
270
|
+
return True
|
|
271
|
+
return binary and reference.mean() + tolerance >= 1.0
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def calibrate(
|
|
275
|
+
detector: Detector, reference, window: int, horizon: int = 1, config=CalibrationConfig(), seed=0
|
|
276
|
+
) -> list[NullDistribution]:
|
|
277
|
+
"""Null distributions for one stream, one per number of windows seen."""
|
|
278
|
+
return calibrate_many(detector, np.asarray(reference)[None], window, horizon, config, [seed])[0]
|