File size: 12,397 Bytes
9762da0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
"""Anytime-valid sequential inference for paired mean differences.

Why not repeated fixed-sample tests: a CLT/Wilson interval is valid at ONE
pre-committed n. Recomputing it after every batch and stopping on the first
significant look inflates type-I error without bound (the law of the iterated
logarithm guarantees an eventual false crossing). Everything here is therefore
ANYTIME-VALID: the guarantee holds simultaneously over all sample sizes and
data-dependent stopping rules.

Machinery (Waudby-Smith & Ramdas, "Estimating means of bounded random variables
by betting", JRSS-B 2023):

  * `EmpiricalBernsteinCS` — predictable plug-in empirical-Bernstein confidence
    sequence for a mean of [0,1]-bounded observations. Variance-adaptive: for
    paired differences (variance << 1/4) it is far tighter than Hoeffding.
  * `OneSidedEProcess` — a betting supermartingale (e-process) against a
    one-sided null "mean >= m" or "mean <= m". Ville's inequality gives
    P(e-value ever >= 1/alpha) <= alpha under the null, at any stopping time.
  * `PairedSequentialTest` — combines both on paired differences d in [-1,1]
    (mapped to x=(d+1)/2), producing the mandatory THREE-OUTCOME decision
    against a practical-significance margin delta:
        REGRESSION            e-process against  H0: Delta >= -delta  crossed 1/alpha_reg
        PASS                  e-process against  H0: Delta <= -delta  crossed 1/alpha_pass
                              (an equivalence-style claim: the drop, if any, is
                               confidently smaller than the margin)
        INSUFFICIENT_EVIDENCE neither — never interpreted as proven equivalence.
  * A Beta-Binomial posterior P(Delta < -delta | data) is computed alongside as
    an operational, human-readable complement (it is NOT the gate criterion).

Observations fed in are CLUSTER means (see units.py), so temporal/spatial
correlation is handled by construction rather than by post-hoc correction.
"""

from __future__ import annotations

import math
from typing import Dict, List, Optional

from scipy import stats as _sstats

DECISION_REGRESSION = "REGRESSION"
DECISION_PASS = "PASS"
DECISION_INSUFFICIENT = "INSUFFICIENT_EVIDENCE"


class EmpiricalBernsteinCS:
    """Predictable plug-in empirical-Bernstein confidence sequence on [0,1].

    Running-intersection bounds are kept (intersecting a CS over time preserves
    coverage), so the interval never widens as data accrue.
    """

    def __init__(self, alpha: float = 0.05, truncation: float = 0.5):
        self.alpha = alpha
        self.truncation = truncation
        self.t = 0
        self._sum_x = 0.0
        self._mu_hat = 0.5           # mu_hat_0 = 1/2 (prior-regularized running mean)
        self._var_acc = 0.25         # 1/4 + sum_i (x_i - mu_hat_i)^2
        self._sigma2_hat = 0.25
        self._sum_lam = 0.0
        self._sum_lam_x = 0.0
        self._sum_v_psi = 0.0
        self.lower = 0.0
        self.upper = 1.0

    def update(self, x: float) -> None:
        x = min(1.0, max(0.0, float(x)))
        t = self.t + 1
        log2a = math.log(2.0 / self.alpha)
        lam = math.sqrt(2.0 * log2a / (max(self._sigma2_hat, 1e-12) * t * math.log1p(t)))
        lam = min(lam, self.truncation)
        v = 4.0 * (x - self._mu_hat) ** 2
        psi = (-math.log1p(-lam) - lam) / 4.0
        self._sum_lam += lam
        self._sum_lam_x += lam * x
        self._sum_v_psi += v * psi
        # advance predictable estimates AFTER betting with the old ones
        self.t = t
        self._sum_x += x
        self._mu_hat = (0.5 + self._sum_x) / (t + 1)
        self._var_acc += (x - self._mu_hat) ** 2
        self._sigma2_hat = self._var_acc / (t + 1)
        if self._sum_lam > 0:
            center = self._sum_lam_x / self._sum_lam
            radius = (log2a + self._sum_v_psi) / self._sum_lam
            self.lower = max(self.lower, center - radius)
            self.upper = min(self.upper, center + radius)

    def interval(self) -> tuple:
        return (max(0.0, self.lower), min(1.0, self.upper))


class OneSidedEProcess:
    """Betting e-process for a one-sided mean null on [0,1] observations.

    side="below": H0: mu >= m, wealth grows when observations fall below m.
    side="above": H0: mu <= m, wealth grows when observations exceed m.

    Bet sizing: max of the empirical-Bernstein recipe (drives early growth
    while the sample variance estimate is still coarse) and an aGRAPA /
    approximate-Kelly bet lambda ~ drift / (sigma^2 + drift^2) (near-optimal
    asymptotic growth rate ~ KL divergence per observation once a real drift
    emerges). Both are predictable and clipped to the admissible range, so the
    wealth process remains a nonnegative supermartingale under the null — the
    sizing choice affects power only, never validity.
    """

    def __init__(self, m: float, side: str, alpha_ref: float = 0.05,
                 truncation: float = 0.75):
        assert side in ("below", "above")
        self.m = float(m)
        self.side = side
        self.alpha_ref = alpha_ref
        self.truncation = truncation
        self.t = 0
        self.log_e = 0.0
        self._sum_x = 0.0
        self._mu_hat = 0.5
        self._var_acc = 0.25
        self._sigma2_hat = 0.25

    def update(self, x: float) -> None:
        x = min(1.0, max(0.0, float(x)))
        t = self.t + 1
        loga = math.log(1.0 / self.alpha_ref)
        sigma2 = max(self._sigma2_hat, 1e-12)
        lam_eb = math.sqrt(2.0 * loga / (sigma2 * t * math.log1p(t)))
        drift = (self.m - self._mu_hat) if self.side == "below" else (self._mu_hat - self.m)
        lam_kelly = max(drift, 0.0) / (sigma2 + drift * drift)
        lam = max(lam_eb, lam_kelly)
        if self.side == "below":
            lam = min(lam, self.truncation / max(1.0 - self.m, 1e-6))
            factor = 1.0 - lam * (x - self.m)
        else:
            lam = min(lam, self.truncation / max(self.m, 1e-6))
            factor = 1.0 + lam * (x - self.m)
        self.log_e += math.log(max(factor, 1e-12))
        self.t = t
        self._sum_x += x
        self._mu_hat = (0.5 + self._sum_x) / (t + 1)
        self._var_acc += (x - self._mu_hat) ** 2
        self._sigma2_hat = self._var_acc / (t + 1)

    @property
    def e_value(self) -> float:
        return math.exp(min(self.log_e, 700.0))

    def p_analogue(self) -> float:
        """Anytime-valid p-value analogue: min(1, 1/e)."""
        return min(1.0, 1.0 / max(self.e_value, 1e-300))


class PairedSequentialTest:
    """Three-outcome anytime-valid test on paired differences d in [-1, 1].

    Delta = E[d] (candidate minus baseline). Regression margin `delta` in
    absolute metric points: the H0 boundary sits at Delta = -delta.
    """

    def __init__(self, delta: float = 0.005, alpha: float = 0.05,
                 alpha_pass: Optional[float] = None):
        self.delta = float(delta)
        self.alpha = float(alpha)
        self.alpha_pass = float(alpha_pass if alpha_pass is not None else alpha)
        m = (1.0 - self.delta) / 2.0  # Delta = -delta  <=>  mu = (1-delta)/2
        self.cs = EmpiricalBernsteinCS(alpha=self.alpha)
        self.e_reg = OneSidedEProcess(m, side="below", alpha_ref=self.alpha)
        self.e_pass = OneSidedEProcess(m, side="above", alpha_ref=self.alpha_pass)
        # object-level accounting (for reporting + Bayesian complement)
        self.n_objects = 0
        self.n_clusters = 0
        self.sum_baseline = 0.0
        self.sum_candidate = 0.0
        self.n01 = 0  # baseline correct, candidate wrong  (regression pairs)
        self.n10 = 0  # candidate correct, baseline wrong  (improvement pairs)
        self.decision = DECISION_INSUFFICIENT
        self.decided_at_n: Optional[int] = None
        self.trajectory: List[Dict] = []

    # ---- updates -------------------------------------------------------

    def update_clusters(self, cluster_means) -> None:
        """Feed per-cluster mean paired differences (each in [-1, 1])."""
        for d in cluster_means:
            x = (float(d) + 1.0) / 2.0
            self.cs.update(x)
            self.e_reg.update(x)
            self.e_pass.update(x)
            self.n_clusters += 1

    def record_objects(self, b, c) -> None:
        """Track object-level paired outcomes for counts and the posterior."""
        import numpy as np
        b = np.asarray(b, dtype=bool)
        c = np.asarray(c, dtype=bool)
        self.n_objects += int(b.size)
        self.sum_baseline += float(b.sum())
        self.sum_candidate += float(c.sum())
        self.n01 += int(np.sum(b & ~c))
        self.n10 += int(np.sum(~b & c))

    # ---- state ---------------------------------------------------------

    def delta_interval(self) -> tuple:
        lo, hi = self.cs.interval()
        return (2.0 * lo - 1.0, 2.0 * hi - 1.0)

    def delta_estimate(self) -> Optional[float]:
        if self.n_objects == 0:
            return None
        return (self.sum_candidate - self.sum_baseline) / self.n_objects

    def bayes_p_regression(self) -> Optional[float]:
        """Beta-Binomial posterior P(Delta < -delta | data).

        McNemar decomposition: among D = n01 + n10 discordant pairs let
        theta = P(pair is a 01 regression pair). With plug-in discordance rate
        nu = D/n, Delta = nu * (1 - 2*theta), so
            Delta < -delta  <=>  theta > (1 + delta*n/D) / 2.
        Prior theta ~ Beta(1,1).
        """
        d_total = self.n01 + self.n10
        if self.n_objects == 0 or d_total == 0:
            return None
        threshold = (1.0 + self.delta * self.n_objects / d_total) / 2.0
        if threshold >= 1.0:
            return 0.0
        return float(_sstats.beta.sf(threshold, 1 + self.n01, 1 + self.n10))

    def evaluate(self, alpha_reg: Optional[float] = None,
                 alpha_pass: Optional[float] = None) -> str:
        """Update the sticky three-outcome decision at the given levels."""
        if self.decision != DECISION_INSUFFICIENT:
            return self.decision
        a_r = alpha_reg if alpha_reg is not None else self.alpha
        a_p = alpha_pass if alpha_pass is not None else self.alpha_pass
        if self.e_reg.e_value >= 1.0 / a_r:
            self.decision = DECISION_REGRESSION
            self.decided_at_n = self.n_objects
        elif self.e_pass.e_value >= 1.0 / a_p:
            self.decision = DECISION_PASS
            self.decided_at_n = self.n_objects
        return self.decision

    def snapshot(self) -> Dict:
        lo, hi = self.delta_interval()
        est = self.delta_estimate()
        return {
            "n": self.n_objects,
            "n_clusters": self.n_clusters,
            "delta_estimate": None if est is None else round(est, 5),
            "delta_ci": [round(lo, 5), round(hi, 5)],
            "e_regression": round(self.e_reg.e_value, 4),
            "e_pass": round(self.e_pass.e_value, 4),
            "p_analogue": round(self.e_reg.p_analogue(), 6),
            "bayes_p_regression": (None if self.bayes_p_regression() is None
                                   else round(self.bayes_p_regression(), 5)),
            "decision": self.decision,
        }

    def record_trajectory_point(self) -> None:
        """Append an evidence-vs-samples point (for the dashboard chart)."""
        self.trajectory.append({
            "n": self.n_objects,
            "n_clusters": self.n_clusters,
            "delta_estimate": self.delta_estimate(),
            "delta_lower": round(self.delta_interval()[0], 5),
            "delta_upper": round(self.delta_interval()[1], 5),
            "log_e_regression": round(self.e_reg.log_e, 4),
            "log_e_pass": round(self.e_pass.log_e, 4),
            "decision": self.decision,
        })


def approx_mde(var_d: float, n_eff: float, delta: float, alpha: float,
               power: float = 0.9, anytime_inflation: float = 1.4) -> Optional[float]:
    """Approximate minimum detectable absolute effect at the current n_eff.

    Fixed-n normal approximation (z_{1-a} + z_{power}) * sqrt(var/n_eff),
    inflated for the anytime-valid overhead, plus the practical margin delta
    (effects must exceed the margin to be declarable)."""
    if n_eff <= 1 or var_d <= 0:
        return None
    z = _sstats.norm.ppf(1 - alpha) + _sstats.norm.ppf(power)
    return float(delta + anytime_inflation * z * math.sqrt(var_d / n_eff))