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 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
@@ -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]