anytimemonitor 0.9.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.
@@ -0,0 +1,21 @@
1
+ """anytimemonitor — adaptive controller for TTS-MOS listening tests.
2
+
3
+ Public API:
4
+ AnytimeMonitor — stateful controller built on an anytime-valid
5
+ confidence sequence (per-item paired diffs).
6
+ Add ratings, query for per-pair verdicts and
7
+ two-lever (+items / +listeners) advice at any
8
+ data-dependent time.
9
+ Decision — dataclass returned by AnytimeMonitor.decision().
10
+
11
+ See README.md for use case and architecture.
12
+ """
13
+ from .controller import Decision, AnytimeMonitor
14
+ from .variance import VarianceEstimate, estimate_components
15
+
16
+ __all__ = [
17
+ "AnytimeMonitor",
18
+ "Decision",
19
+ "VarianceEstimate",
20
+ "estimate_components",
21
+ ]
anytimemonitor/avci.py ADDED
@@ -0,0 +1,387 @@
1
+ """AnytimeCI — confidence sequence on the mean of a per-item paired diff stream.
2
+
3
+ V2 per-item revision — supersedes the per-listener version preserved at
4
+ ``git tag avci-per-listener-v1.1``. See ``Architecture.md`` for the design
5
+ rationale.
6
+
7
+ The observation is the per-item paired difference
8
+ ``d_j = mean_ℓ [Y_{ℓ,A,j} − Y_{ℓ,B,j}]`` over the listeners who rated
9
+ item ``j`` for both systems A and B. Under the §4.5 random-effects
10
+ model, conditional on the listener pool fixed at ``L_pool`` distinct
11
+ listeners, the d_j are i.i.d. with
12
+
13
+ Var(d_j | pool, L_j) = Σ²_int_pair + (Σ²_γαβ + Σ²_eval) / L_j
14
+
15
+ The pool itself contributes a *constant offset* C across all d_j (the
16
+ realised mean of (γα)_ℓA − (γα)_ℓB over the pool). Marginalising over
17
+ the pool adds an irreducible variance term
18
+
19
+ 2 σ²_(γα) / L_pool
20
+
21
+ that does not shrink with N_items. To preserve coverage of the
22
+ marginal effect δ = E[Y_A − Y_B] (and not just the pool-conditional
23
+ effect), the AVCI boundary combines the Howard 2021 sub-Gaussian
24
+ half-width and a deterministic floor in quadrature:
25
+
26
+ Ψ_eff(t) = √( Ψ_Howard(σ̂²_t, t)² + plancher² )
27
+ plancher = √( 2 · c · σ̂²_t / L_pool )
28
+
29
+ where ``c`` (``gamma_alpha_factor``) is a multiplier expressing the
30
+ upper bound assumed for σ²_(γα) as a fraction of σ²_int (default 1.0,
31
+ i.e. σ²_(γα) ≤ σ²_int — see ``Architecture.md`` for the prior
32
+ discussion). σ̂²_t (Welford or Hoeffding fallback) is used as a
33
+ self-contained proxy for σ²_int: this slightly inflates the floor
34
+ (σ̂²_d_j also absorbs listener-noise / k_ℓ terms) but keeps the
35
+ boundary auto-contained — no MoM coupling required.
36
+
37
+ The Howard 2021 line-crossing boundary itself is unchanged
38
+ (eq.14 of arXiv 1810.08240): two proxy variance options remain:
39
+
40
+ - ``hoeffding`` : σ²_proxy = ((b−a)/2)² fixed (worst-case bound).
41
+ - ``ebernstein`` : σ²_proxy = σ̂²_t (Welford running estimator), with
42
+ Hoeffding fallback while t < t_min_ebernstein.
43
+ """
44
+ from __future__ import annotations
45
+
46
+ from dataclasses import dataclass, field
47
+ from typing import Literal, Union
48
+
49
+ import numpy as np
50
+ from numpy.typing import ArrayLike
51
+
52
+
53
+ PROXY = Literal["hoeffding", "ebernstein"]
54
+ VERDICT = Literal["STOP_SEP", "STOP_EQUIV", "CONTINUE"]
55
+
56
+
57
+ def eta_from_n_planned(n_planned: int) -> float:
58
+ """Howard's a-priori tightness rule: η = 1/√(N_planned).
59
+
60
+ ``N_planned`` is the PLANNED item budget; the boundary is tightest near
61
+ t ≈ 1/η² = N_planned items. η is fixed a priori from the plan and is
62
+ never tuned to the observed data (it sets efficiency, not validity).
63
+ Single source of truth for this formula — used by the controller and
64
+ the UI so neither re-implements it.
65
+ """
66
+ if n_planned < 1:
67
+ raise ValueError("n_planned must be >= 1")
68
+ return 1.0 / float(np.sqrt(n_planned))
69
+
70
+
71
+ @dataclass
72
+ class AnytimeCI:
73
+ """Sub-Gaussian anytime-valid confidence sequence on E[X].
74
+
75
+ Streaming API: feed per-item paired differences through ``update``,
76
+ query the current confidence interval and verdict at any time.
77
+
78
+ The boundary is widened by a deterministic floor accounting for the
79
+ listener-pool contribution that does not shrink with t. See module
80
+ docstring for the model rationale.
81
+ """
82
+
83
+ alpha: float = 0.05
84
+ proxy: PROXY = "ebernstein"
85
+ # Range of the *observation* d_j (per-item paired diff). For MOS
86
+ # ∈ [1, 5] this is (-4, 4). Override for other rating scales.
87
+ d_bounds: tuple[float, float] = (-4.0, 4.0)
88
+ # Howard 2021 line-crossing shape parameter. Boundary is tightest near
89
+ # t ≈ 1/η²; set it a priori from the PLANNED item budget as
90
+ # η = 1/√(N_planned) (never tune it to the data — efficiency, not
91
+ # validity). Default 0.07 ≈ 1/√200 from the §7 survey median budget
92
+ # (~400 ratings/system at K=2 ⇒ N_planned ≈ 200); PROVISIONAL until
93
+ # that survey table is validated.
94
+ eta: float = 0.07
95
+ # Below this t, ``ebernstein`` proxy falls back to Hoeffding to avoid
96
+ # exploding bounds when σ̂² is undefined / very noisy.
97
+ t_min_ebernstein: int = 10
98
+ # Relative floor on the empirical variance estimate, expressed as a
99
+ # fraction of the Hoeffding proxy ((b-a)/2)². Prevents anti-conservative
100
+ # bounds when the running σ̂² collapses on near-constant streams.
101
+ ebernstein_floor_frac: float = 0.01
102
+ # Listener-pool size — the number of distinct listeners contributing
103
+ # to the d_j stream. Used in the marginal-coverage floor only ; the
104
+ # Howard 2021 boundary still drives the bulk of Ψ at small/medium t.
105
+ # Set to ``None`` for the rare regime where the listener pool is
106
+ # already effectively infinite (e.g. fresh-listener-per-item
107
+ # crowdsourcing) — the floor then collapses to zero.
108
+ L_pool: int | None = None
109
+ # Upper-bound multiplier for σ²_(γα) as a fraction of σ²_int.
110
+ # Default 1.0: assume σ²_(γα) ≤ σ²_int (cf. Architecture.md ;
111
+ # 1.0 is the conservative baseline, 2.0 the worst-case Blizzard
112
+ # envelope). This is the fallback upper bound used ONLY when no
113
+ # estimate is supplied; the controller's own cold-start instead plugs
114
+ # in a large BTTF-anchored prior (≈2.7·σ²_int) — see
115
+ # `controller.cold_start_ga_ratio`.
116
+ # **Only used when `sigma2_ga_estimate` is None** (no data-driven
117
+ # estimate available, e.g. warm-up).
118
+ gamma_alpha_factor: float = 1.0
119
+ # Optional data-driven estimate of σ²_(γα). When set (typically by
120
+ # the controller from the per-(listener, item) d_ℓj matrix via
121
+ # MS_across − MS_within/k_ℓ_mean), the plancher uses it directly:
122
+ # plancher = √(2·σ̂²_(γα)/L_pool). Avoids the surcoût conservatif of
123
+ # c·σ̂²_proxy on homogeneous palettes (where σ²_(γα) ≪ σ²_int).
124
+ sigma2_ga_estimate: Optional[float] = None
125
+ # Optional override on the ITEM-side (iid) standard error of the
126
+ # mean. When set, half_width() uses
127
+ # ``Ψ_eff = √((se_iid_override · multiplier)² + plancher²)``
128
+ # bypassing the Welford σ̂²_proxy → Ψ_Howard path. The multiplier
129
+ # is the AVCI peeking-penalty factor (cf. _avci_multiplier).
130
+ # Typically set by the controller from a bootstrap-by-items
131
+ # resample, which estimates Var(δ̂ | pool) — the iid component
132
+ # that DOES decay with t and IS subject to peeking penalty.
133
+ se_iid_override: Optional[float] = None
134
+ # Optional override on the listener-pool plancher (the constant-
135
+ # in-N component of the SE). When set, half_width() uses
136
+ # ``plancher_override`` instead of computing it from
137
+ # ``sigma2_ga_estimate`` (or the conservative fallback). Typically
138
+ # set by the controller from a bootstrap-by-listeners resample,
139
+ # which estimates Var(C(pool)) directly and bypasses the MoM
140
+ # σ̂²_(γα) estimation. NOT subject to the peeking penalty (it is
141
+ # a constant-in-t variance component).
142
+ plancher_override: Optional[float] = None
143
+
144
+ _n: int = field(default=0, init=False, repr=False)
145
+ _mean: float = field(default=0.0, init=False, repr=False)
146
+ _M2: float = field(default=0.0, init=False, repr=False)
147
+
148
+ @property
149
+ def n(self) -> int:
150
+ return self._n
151
+
152
+ @property
153
+ def mean(self) -> float:
154
+ return self._mean
155
+
156
+ @property
157
+ def variance(self) -> float:
158
+ """Welford sample variance (unbiased, divisor n−1). 0 if n < 2."""
159
+ if self._n < 2:
160
+ return 0.0
161
+ return self._M2 / (self._n - 1)
162
+
163
+ def update(self, new_obs: Union[ArrayLike, float]) -> None:
164
+ """Ingest one or more per-item paired diffs (Welford streaming update)."""
165
+ arr = np.atleast_1d(np.asarray(new_obs, dtype=float)).ravel()
166
+ for x in arr:
167
+ self._n += 1
168
+ delta = x - self._mean
169
+ self._mean += delta / self._n
170
+ delta2 = x - self._mean
171
+ self._M2 += delta * delta2
172
+
173
+ def reset(self) -> None:
174
+ self._n = 0
175
+ self._mean = 0.0
176
+ self._M2 = 0.0
177
+
178
+ def _sigma2_proxy(self) -> float:
179
+ a, b = self.d_bounds
180
+ sigma2_hoeffding = ((b - a) / 2.0) ** 2
181
+ if self.proxy == "hoeffding":
182
+ return sigma2_hoeffding
183
+ if self.proxy == "ebernstein":
184
+ if self._n < self.t_min_ebernstein:
185
+ return sigma2_hoeffding
186
+ floor = self.ebernstein_floor_frac * sigma2_hoeffding
187
+ return max(self.variance, floor, 1e-12)
188
+ raise ValueError(f"unknown proxy: {self.proxy!r}")
189
+
190
+ def _psi_howard(self, sigma2: float) -> float:
191
+ """Howard et al. 2021 line-crossing half-width.
192
+
193
+ Ψ = σ · √( 2 (t·η² + 1) / (t²·η²) · log(√(t·η² + 1) / α) )
194
+
195
+ Pulled out as a separate method so :meth:`half_width` can compose
196
+ it with the listener-pool floor in quadrature.
197
+ """
198
+ if self._n < 1:
199
+ return float("inf")
200
+ t = self._n
201
+ eta2_t = (self.eta ** 2) * t
202
+ log_term = float(np.log(np.sqrt(eta2_t + 1.0) / self.alpha))
203
+ if log_term <= 0:
204
+ return float("inf")
205
+ rad = 2.0 * (eta2_t + 1.0) / (t * eta2_t) * log_term
206
+ return float(np.sqrt(sigma2) * np.sqrt(rad))
207
+
208
+ def plancher(self) -> float:
209
+ """Listener-pool floor √(2·σ²_(γα)/L_pool).
210
+
211
+ Two paths:
212
+ - If ``sigma2_ga_estimate`` is set (data-driven, from the
213
+ controller's MS_across−MS_within decomposition), use it
214
+ directly: plancher = √(2·σ̂²_(γα)/L_pool).
215
+ - Otherwise fall back to the conservative upper bound
216
+ ``c · σ̂²_proxy`` for σ²_(γα): plancher =
217
+ √(2·c·σ̂²_proxy/L_pool). This is the original V2 design;
218
+ used during warm-up or when the per-listener decomposition
219
+ isn't reliable.
220
+
221
+ Returns 0.0 when ``L_pool`` is None (assumed infinite pool).
222
+ """
223
+ if self.L_pool is None or self.L_pool <= 0:
224
+ return 0.0
225
+ if self.sigma2_ga_estimate is not None:
226
+ sigma2_ga = max(0.0, float(self.sigma2_ga_estimate))
227
+ else:
228
+ sigma2_ga = self.gamma_alpha_factor * self._sigma2_proxy()
229
+ return float(np.sqrt(2.0 * sigma2_ga / self.L_pool))
230
+
231
+ def _avci_multiplier(self) -> float:
232
+ """AVCI peeking-penalty multiplier such that
233
+ ``Ψ_Howard(σ², t) = (σ/√t) · _avci_multiplier(t)``.
234
+
235
+ Used by the ``se_override`` path: given an SE that already
236
+ captures the variance structure (bootstrap, lmer, etc.), the
237
+ AVCI band is ``Ψ = SE · _avci_multiplier(t)``. Asymptotically
238
+ ~ √(2·log(log t / α)) (loi du logarithme itéré).
239
+ """
240
+ if self._n < 1:
241
+ return float("inf")
242
+ t = self._n
243
+ eta2_t = (self.eta ** 2) * t
244
+ log_term = float(np.log(np.sqrt(eta2_t + 1.0) / self.alpha))
245
+ if log_term <= 0:
246
+ return float("inf")
247
+ return float(np.sqrt(2.0 * (eta2_t + 1.0) / eta2_t * log_term))
248
+
249
+ def half_width(self) -> float:
250
+ """Effective half-width.
251
+
252
+ Ψ_eff = √( Ψ_iid² + plancher² )
253
+
254
+ where the iid term gets the AVCI peeking-penalty multiplier and
255
+ the plancher does not (it is constant in t). Each component can
256
+ come from either MoM-Welford or a bootstrap override:
257
+
258
+ - ``se_iid_override`` set ⇒ ``Ψ_iid = se_iid · multiplier(t)``.
259
+ Otherwise ``Ψ_iid = Ψ_Howard(σ̂²_proxy_Welford, t)``.
260
+ - ``plancher_override`` set ⇒ ``plancher = plancher_override``.
261
+ Otherwise ``plancher = plancher()`` (MoM σ̂²_(γα) or fallback).
262
+ """
263
+ # iid component
264
+ if self.se_iid_override is not None:
265
+ multiplier = self._avci_multiplier()
266
+ if not np.isfinite(multiplier):
267
+ return multiplier
268
+ psi_iid = float(self.se_iid_override) * multiplier
269
+ else:
270
+ sigma2_proxy = self._sigma2_proxy()
271
+ psi_iid = self._psi_howard(sigma2_proxy)
272
+ if not np.isfinite(psi_iid):
273
+ return psi_iid
274
+
275
+ # plancher component
276
+ if self.plancher_override is not None:
277
+ plancher = float(self.plancher_override)
278
+ else:
279
+ plancher = self.plancher()
280
+
281
+ return float(np.sqrt(psi_iid * psi_iid + plancher * plancher))
282
+
283
+ def interval(self) -> tuple[float, float]:
284
+ psi = self.half_width()
285
+ return (self._mean - psi, self._mean + psi)
286
+
287
+ def verdict(self, delta_sep: float = 0.0,
288
+ delta_equiv: float | None = None,
289
+ effect_size_guard: bool = False) -> VERDICT:
290
+ """STOP / CONTINUE verdict on the AVCI confidence sequence.
291
+
292
+ Parameters
293
+ ----------
294
+ delta_sep : float, default 0.0
295
+ Used as the default value of ``delta_equiv`` when the latter
296
+ is None (single-threshold TOST behaviour). It is **NOT** a
297
+ STOP_SEP gate by default — that would be a minimum-effect
298
+ (TOST-sup) test, stricter than the classical anytime-valid
299
+ rejection. Use ``effect_size_guard=True`` to activate the
300
+ guard explicitly.
301
+ delta_equiv : float or None, default None
302
+ Equivalence margin for STOP_EQUIV. If None, falls back to
303
+ ``delta_sep`` (single-threshold default).
304
+ effect_size_guard : bool, default False
305
+ If True, STOP_SEP additionally requires
306
+ ``|mean| − Ψ_eff ≥ delta_sep`` (TOST-sup). Default off —
307
+ matches the classical anytime-valid rejection semantics
308
+ (0 ∉ CI_t).
309
+
310
+ Verdicts:
311
+
312
+ - STOP_SEP : 0 ∉ CI_t. With ``effect_size_guard=True``, also
313
+ requires ``|mean| − Ψ_eff ≥ delta_sep``.
314
+ - STOP_EQUIV : CI ⊂ (−delta_equiv, +delta_equiv) AND
315
+ Ψ_eff ≤ delta_equiv/2
316
+ (pair indistinguishable within tolerance)
317
+ - CONTINUE : otherwise.
318
+ """
319
+ if delta_equiv is None:
320
+ delta_equiv = delta_sep
321
+ if self._n < 1:
322
+ return "CONTINUE"
323
+ psi = self.half_width()
324
+ lo, hi = self._mean - psi, self._mean + psi
325
+ # STOP_SEP: classical AVCI rejection — 0 ∉ CI_t.
326
+ # The effect-size guard is opt-in (TOST-sup semantics).
327
+ if lo > 0 or hi < 0:
328
+ if not effect_size_guard or abs(self._mean) - psi >= delta_sep:
329
+ return "STOP_SEP"
330
+ if (-delta_equiv < lo and hi < delta_equiv
331
+ and psi <= delta_equiv / 2.0):
332
+ return "STOP_EQUIV"
333
+ return "CONTINUE"
334
+
335
+
336
+ if __name__ == "__main__":
337
+ rng = np.random.default_rng(42)
338
+
339
+ print("Sanity check — per-item AVCI on simulated d_j streams\n")
340
+
341
+ # H1: true δ = 0.3, σ²_d_j = 1 (MOS-ish scale, mid-effect).
342
+ # No pool floor (L_pool=None) — recovers the pure Howard 2021 boundary.
343
+ print("[H1, no floor] δ_true = 0.30, σ²_d = 1.00, MDE_threshold = 0.20, α = 0.05")
344
+ print(f"{'t':>5} {'mean':>8} {'Ψ':>8} {'CI_lo':>8} {'CI_hi':>8} {'verdict':>10}")
345
+ ci = AnytimeCI(alpha=0.05, proxy="ebernstein", eta=0.1, L_pool=None)
346
+ delta_true, sigma_true, mde_threshold = 0.30, 1.0, 0.20
347
+ for t in range(1, 301):
348
+ ci.update(rng.normal(delta_true, sigma_true))
349
+ if t in (1, 5, 10, 20, 50, 100, 200, 300):
350
+ psi = ci.half_width()
351
+ lo, hi = ci.interval()
352
+ v = ci.verdict(mde_threshold)
353
+ print(f"{t:>5d} {ci.mean:>8.3f} {psi:>8.3f} {lo:>8.3f} {hi:>8.3f} {v:>10}")
354
+
355
+ # H1: same setup but with L_pool=20 — floor should plateau Ψ at √(2·1·σ̂²/20)
356
+ print("\n[H1, L_pool=20, c=1.0] same setup but with listener-pool floor")
357
+ print(f"{'t':>5} {'mean':>8} {'Ψ':>8} {'plancher':>10} {'verdict':>10}")
358
+ ci = AnytimeCI(alpha=0.05, proxy="ebernstein", eta=0.1,
359
+ L_pool=20, gamma_alpha_factor=1.0)
360
+ for t in range(1, 1001):
361
+ ci.update(rng.normal(delta_true, sigma_true))
362
+ if t in (1, 10, 50, 100, 300, 500, 1000):
363
+ psi = ci.half_width()
364
+ plancher = ci.plancher()
365
+ v = ci.verdict(mde_threshold)
366
+ print(f"{t:>5d} {ci.mean:>8.3f} {psi:>8.3f} "
367
+ f"{plancher:>10.3f} {v:>10}")
368
+
369
+ # H0 coverage check
370
+ print("\n[H0 coverage, no floor] δ_true = 0, R = 200 replicates, T = 200")
371
+ print(" Verify: AVCI brackets 0 at every t in ≥ 95 % of replicates.")
372
+ R, T = 200, 200
373
+ n_falses = 0
374
+ for r in range(R):
375
+ ci = AnytimeCI(alpha=0.05, proxy="ebernstein", eta=0.1, L_pool=None)
376
+ crossed = False
377
+ for _ in range(T):
378
+ ci.update(rng.normal(0.0, 1.0))
379
+ lo, hi = ci.interval()
380
+ if lo > 0 or hi < 0:
381
+ crossed = True
382
+ break
383
+ if crossed:
384
+ n_falses += 1
385
+ coverage = 1.0 - n_falses / R
386
+ print(f" Empirical coverage = {coverage:.3f} "
387
+ f"(target ≥ 0.95) — {n_falses} crossings out of {R}")