Spaces:
Paused
Paused
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))
|