abkit 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.
- abkit/__init__.py +23 -0
- abkit/audit.py +58 -0
- abkit/bootstrap.py +46 -0
- abkit/cli.py +71 -0
- abkit/demo.py +177 -0
- abkit/io.py +38 -0
- abkit/probes.py +428 -0
- abkit/py.typed +0 -0
- abkit/report.py +267 -0
- abkit/result.py +82 -0
- abkit/schema.py +71 -0
- abkit/stats.py +222 -0
- abkit/synthetic.py +144 -0
- abkit-0.1.0.dist-info/METADATA +262 -0
- abkit-0.1.0.dist-info/RECORD +19 -0
- abkit-0.1.0.dist-info/WHEEL +5 -0
- abkit-0.1.0.dist-info/entry_points.txt +2 -0
- abkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- abkit-0.1.0.dist-info/top_level.txt +1 -0
abkit/probes.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""The validity probes.
|
|
2
|
+
|
|
3
|
+
Each probe returns a :class:`~abkit.result.ProbeResult`, or a
|
|
4
|
+
:class:`~abkit.result.SkippedProbe` naming exactly what to log to enable it.
|
|
5
|
+
The innocent bands and thresholds below are opinions, stated in one place so
|
|
6
|
+
they are easy to disagree with.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
|
|
14
|
+
from .bootstrap import bootstrap_ci
|
|
15
|
+
from .result import ProbeResult, SkippedProbe
|
|
16
|
+
from .schema import Design, Unit
|
|
17
|
+
from .stats import (
|
|
18
|
+
cuped_reduction,
|
|
19
|
+
design_analysis,
|
|
20
|
+
mean,
|
|
21
|
+
normal_ppf,
|
|
22
|
+
schedule_false_positive_rate,
|
|
23
|
+
srm_chi2,
|
|
24
|
+
standardized_mean_difference,
|
|
25
|
+
variance,
|
|
26
|
+
welch,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
SRM_P_THRESHOLD = 1e-3
|
|
30
|
+
"""Standard practice for sample-ratio mismatch: flag below this chi-square p."""
|
|
31
|
+
|
|
32
|
+
INNOCENT_SMD = (-0.1, 0.1)
|
|
33
|
+
"""Standardized mean differences a randomized experiment can plausibly show."""
|
|
34
|
+
|
|
35
|
+
INNOCENT_NOVELTY = (-0.1, 0.1)
|
|
36
|
+
"""Early-minus-late effect gap, in pooled-SD units, compatible with a stable effect."""
|
|
37
|
+
|
|
38
|
+
FRAGILE_UNITS = 3
|
|
39
|
+
"""Flag a significant result that at most this many removed units would overturn."""
|
|
40
|
+
|
|
41
|
+
PEEK_FPR_FACTOR = 1.5
|
|
42
|
+
"""Flag a stop-on-significance schedule whose real false-positive rate exceeds
|
|
43
|
+
this multiple of the nominal alpha."""
|
|
44
|
+
|
|
45
|
+
CURSE_POWER = 0.5
|
|
46
|
+
"""Below this power, a significant estimate is more exaggeration than signal."""
|
|
47
|
+
|
|
48
|
+
FRAGILITY_CAP = 20
|
|
49
|
+
"""Stop searching for a flip after removing this many units."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _arm_values(units: Sequence[Unit], arm: str, metric: str) -> list[float]:
|
|
53
|
+
return [u.metrics[metric] for u in units if u.arm == arm and metric in u.metrics]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _arm_pre_values(units: Sequence[Unit], arm: str, key: str) -> list[float]:
|
|
57
|
+
return [u.pre[key] for u in units if u.arm == arm and key in u.pre]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def sample_ratio(units: Sequence[Unit], design: Design) -> ProbeResult:
|
|
61
|
+
"""Chi-square test of the observed arm counts against the intended split."""
|
|
62
|
+
counts = {arm: 0 for arm in design.split}
|
|
63
|
+
for unit in units:
|
|
64
|
+
if unit.arm in counts:
|
|
65
|
+
counts[unit.arm] += 1
|
|
66
|
+
stat, p = srm_chi2(counts, design.split)
|
|
67
|
+
total = sum(counts.values())
|
|
68
|
+
shares = ", ".join(f"{arm} {counts[arm] / total:.4f}" for arm in design.split)
|
|
69
|
+
intended = ", ".join(f"{arm} {w:.4f}" for arm, w in design.normalized_split.items())
|
|
70
|
+
return ProbeResult(
|
|
71
|
+
name="sample ratio mismatch",
|
|
72
|
+
value=p,
|
|
73
|
+
unit="p",
|
|
74
|
+
detail=f"observed {shares} vs intended {intended} (chi2 {stat:.2f}); "
|
|
75
|
+
f"flags below p={SRM_P_THRESHOLD:g}",
|
|
76
|
+
n=total,
|
|
77
|
+
flagged=p < SRM_P_THRESHOLD,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def contamination(units: Sequence[Unit]) -> ProbeResult:
|
|
82
|
+
"""Units that appear in more than one arm; any at all is an assignment bug."""
|
|
83
|
+
arms_seen: dict[str, set[str]] = {}
|
|
84
|
+
for unit in units:
|
|
85
|
+
arms_seen.setdefault(unit.unit_id, set()).add(unit.arm)
|
|
86
|
+
crossed = sorted(uid for uid, arms in arms_seen.items() if len(arms) > 1)
|
|
87
|
+
example = f"; e.g. {', '.join(crossed[:3])}" if crossed else ""
|
|
88
|
+
return ProbeResult(
|
|
89
|
+
name="assignment contamination",
|
|
90
|
+
value=float(len(crossed)),
|
|
91
|
+
unit="units",
|
|
92
|
+
detail=f"{len(crossed)} unit ids appear in more than one arm{example}",
|
|
93
|
+
n=len(arms_seen),
|
|
94
|
+
flagged=len(crossed) > 0,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def covariate_balance(units: Sequence[Unit], design: Design) -> list[ProbeResult] | SkippedProbe:
|
|
99
|
+
"""Standardized mean difference on every pre-experiment covariate."""
|
|
100
|
+
keys = sorted({key for u in units for key in u.pre})
|
|
101
|
+
if not keys:
|
|
102
|
+
return SkippedProbe(
|
|
103
|
+
name="covariate balance",
|
|
104
|
+
reason="no pre-experiment covariates; log them per unit as "
|
|
105
|
+
'pre: {"metric": value} to enable this check',
|
|
106
|
+
)
|
|
107
|
+
results = []
|
|
108
|
+
for key in keys:
|
|
109
|
+
treat = _arm_pre_values(units, design.treatment, key)
|
|
110
|
+
ctrl = _arm_pre_values(units, design.control, key)
|
|
111
|
+
if len(treat) < 2 or len(ctrl) < 2:
|
|
112
|
+
continue
|
|
113
|
+
point, ci = bootstrap_ci(standardized_mean_difference, (treat, ctrl))
|
|
114
|
+
results.append(
|
|
115
|
+
ProbeResult(
|
|
116
|
+
name=f"covariate balance ({key})",
|
|
117
|
+
value=point,
|
|
118
|
+
unit="SMD",
|
|
119
|
+
ci=ci,
|
|
120
|
+
innocent=INNOCENT_SMD,
|
|
121
|
+
detail=f"pre-experiment {key}: treatment minus control in pooled-SD units",
|
|
122
|
+
n=len(treat) + len(ctrl),
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
return results
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def fragility_path(
|
|
129
|
+
treat: Sequence[float], ctrl: Sequence[float], steps: int = FRAGILITY_CAP
|
|
130
|
+
) -> list[tuple[int, float]]:
|
|
131
|
+
"""Greedy removal path: (units removed, primary p) at each step.
|
|
132
|
+
|
|
133
|
+
At every step the single unit whose removal most weakens the effect — the
|
|
134
|
+
top of the favored arm or the bottom of the other — is dropped.
|
|
135
|
+
"""
|
|
136
|
+
t = sorted(treat)
|
|
137
|
+
c = sorted(ctrl)
|
|
138
|
+
path = [(0, welch(t, c)[3])]
|
|
139
|
+
positive = mean(t) - mean(c) > 0
|
|
140
|
+
for k in range(1, steps + 1):
|
|
141
|
+
candidates = []
|
|
142
|
+
if positive:
|
|
143
|
+
if len(t) > 2:
|
|
144
|
+
candidates.append((t[:-1], c))
|
|
145
|
+
if len(c) > 2:
|
|
146
|
+
candidates.append((t, c[1:]))
|
|
147
|
+
else:
|
|
148
|
+
if len(t) > 2:
|
|
149
|
+
candidates.append((t[1:], c))
|
|
150
|
+
if len(c) > 2:
|
|
151
|
+
candidates.append((t, c[:-1]))
|
|
152
|
+
if not candidates:
|
|
153
|
+
break
|
|
154
|
+
t, c = min(candidates, key=lambda tc: abs(welch(tc[0], tc[1])[2]))
|
|
155
|
+
path.append((k, welch(t, c)[3]))
|
|
156
|
+
return path
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _fragility_search(treat: Sequence[float], ctrl: Sequence[float], alpha: float) -> int | None:
|
|
160
|
+
"""Smallest number of removed units that lifts the primary p above alpha."""
|
|
161
|
+
for k, p in fragility_path(treat, ctrl)[1:]:
|
|
162
|
+
if p >= alpha:
|
|
163
|
+
return k
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def outlier_fragility(units: Sequence[Unit], design: Design) -> ProbeResult | SkippedProbe:
|
|
168
|
+
"""How many extreme units the significance of the primary result rests on."""
|
|
169
|
+
treat = _arm_values(units, design.treatment, design.primary_metric)
|
|
170
|
+
ctrl = _arm_values(units, design.control, design.primary_metric)
|
|
171
|
+
if len(treat) < 3 or len(ctrl) < 3:
|
|
172
|
+
return SkippedProbe(
|
|
173
|
+
name="outlier fragility",
|
|
174
|
+
reason=f"needs per-unit values of {design.primary_metric!r} in both arms",
|
|
175
|
+
)
|
|
176
|
+
_, _, _, p = welch(treat, ctrl)
|
|
177
|
+
if p >= design.alpha:
|
|
178
|
+
return ProbeResult(
|
|
179
|
+
name="outlier fragility",
|
|
180
|
+
value=0.0,
|
|
181
|
+
unit="units",
|
|
182
|
+
detail="primary result is not significant, so there is nothing to overturn",
|
|
183
|
+
n=len(treat) + len(ctrl),
|
|
184
|
+
flagged=False,
|
|
185
|
+
)
|
|
186
|
+
flip = _fragility_search(treat, ctrl, design.alpha)
|
|
187
|
+
if flip is None:
|
|
188
|
+
return ProbeResult(
|
|
189
|
+
name="outlier fragility",
|
|
190
|
+
value=float(FRAGILITY_CAP),
|
|
191
|
+
unit="units",
|
|
192
|
+
detail=f"removing {FRAGILITY_CAP} most extreme units does not overturn significance",
|
|
193
|
+
n=len(treat) + len(ctrl),
|
|
194
|
+
flagged=False,
|
|
195
|
+
)
|
|
196
|
+
return ProbeResult(
|
|
197
|
+
name="outlier fragility",
|
|
198
|
+
value=float(flip),
|
|
199
|
+
unit="units",
|
|
200
|
+
detail=f"removing the {flip} most extreme units lifts the primary p above "
|
|
201
|
+
f"alpha; flags at {FRAGILE_UNITS} or fewer",
|
|
202
|
+
n=len(treat) + len(ctrl),
|
|
203
|
+
flagged=flip <= FRAGILE_UNITS,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def uncorrected_winners(units: Sequence[Unit], design: Design) -> ProbeResult | SkippedProbe:
|
|
208
|
+
"""Metrics that are significant raw but not after Benjamini-Hochberg."""
|
|
209
|
+
from .stats import benjamini_hochberg
|
|
210
|
+
|
|
211
|
+
metric_names = design.metrics or sorted({m for u in units for m in u.metrics})
|
|
212
|
+
if len(metric_names) < 2:
|
|
213
|
+
return SkippedProbe(
|
|
214
|
+
name="uncorrected winners",
|
|
215
|
+
reason="fewer than two tested metrics; list them in design.metrics "
|
|
216
|
+
"if more were tested than logged",
|
|
217
|
+
)
|
|
218
|
+
tested = []
|
|
219
|
+
pvalues = []
|
|
220
|
+
for name in metric_names:
|
|
221
|
+
treat = _arm_values(units, design.treatment, name)
|
|
222
|
+
ctrl = _arm_values(units, design.control, name)
|
|
223
|
+
if len(treat) < 2 or len(ctrl) < 2:
|
|
224
|
+
continue
|
|
225
|
+
tested.append(name)
|
|
226
|
+
pvalues.append(welch(treat, ctrl)[3])
|
|
227
|
+
if len(tested) < 2:
|
|
228
|
+
return SkippedProbe(
|
|
229
|
+
name="uncorrected winners",
|
|
230
|
+
reason="fewer than two metrics observed in both arms",
|
|
231
|
+
)
|
|
232
|
+
keep = benjamini_hochberg(pvalues, design.alpha)
|
|
233
|
+
lost = [
|
|
234
|
+
name
|
|
235
|
+
for name, p, kept in zip(tested, pvalues, keep, strict=True)
|
|
236
|
+
if p < design.alpha and not kept
|
|
237
|
+
]
|
|
238
|
+
listing = f": {', '.join(lost)}" if lost else ""
|
|
239
|
+
return ProbeResult(
|
|
240
|
+
name="uncorrected winners",
|
|
241
|
+
value=float(len(lost)),
|
|
242
|
+
unit="metrics",
|
|
243
|
+
detail=f"{len(lost)} of {len(tested)} tested metrics are significant raw "
|
|
244
|
+
f"but not after Benjamini-Hochberg{listing}",
|
|
245
|
+
n=len(tested),
|
|
246
|
+
flagged=len(lost) > 0,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def peeking(design: Design) -> ProbeResult | SkippedProbe:
|
|
251
|
+
"""The real false-positive rate of the interim-look schedule actually used."""
|
|
252
|
+
if not design.looks or len(design.looks) < 2:
|
|
253
|
+
return SkippedProbe(
|
|
254
|
+
name="peeking",
|
|
255
|
+
reason="no interim looks recorded; log each analysis as "
|
|
256
|
+
"design.looks = [{n, z}, ...] to enable this check",
|
|
257
|
+
)
|
|
258
|
+
ns = [look.n for look in design.looks]
|
|
259
|
+
fpr = schedule_false_positive_rate(ns, design.alpha)
|
|
260
|
+
z_crit = normal_ppf(1.0 - design.alpha / 2.0)
|
|
261
|
+
final_significant = abs(design.looks[-1].z) >= z_crit
|
|
262
|
+
return ProbeResult(
|
|
263
|
+
name="peeking",
|
|
264
|
+
value=fpr,
|
|
265
|
+
unit="FPR",
|
|
266
|
+
detail=f"stopping at the first significant look across {len(ns)} looks has a "
|
|
267
|
+
f"true false-positive rate of {fpr:.3f} against a nominal alpha of "
|
|
268
|
+
f"{design.alpha:g}; flags when significant and FPR exceeds "
|
|
269
|
+
f"{PEEK_FPR_FACTOR:g}x alpha",
|
|
270
|
+
n=ns[-1],
|
|
271
|
+
flagged=final_significant and fpr > PEEK_FPR_FACTOR * design.alpha,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _novelty_gap(
|
|
276
|
+
early_t: Sequence[float],
|
|
277
|
+
early_c: Sequence[float],
|
|
278
|
+
late_t: Sequence[float],
|
|
279
|
+
late_c: Sequence[float],
|
|
280
|
+
) -> float:
|
|
281
|
+
pooled = list(early_t) + list(early_c) + list(late_t) + list(late_c)
|
|
282
|
+
sd = math.sqrt(variance(pooled))
|
|
283
|
+
if sd == 0.0:
|
|
284
|
+
return math.nan
|
|
285
|
+
gap = (mean(early_t) - mean(early_c)) - (mean(late_t) - mean(late_c))
|
|
286
|
+
return gap / sd
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def novelty(units: Sequence[Unit], design: Design) -> ProbeResult | SkippedProbe:
|
|
290
|
+
"""Early-half vs late-half effect gap; a stable effect stays near zero."""
|
|
291
|
+
timed = [
|
|
292
|
+
u
|
|
293
|
+
for u in units
|
|
294
|
+
if u.t is not None
|
|
295
|
+
and u.arm in (design.treatment, design.control)
|
|
296
|
+
and design.primary_metric in u.metrics
|
|
297
|
+
]
|
|
298
|
+
if len(timed) < 8:
|
|
299
|
+
return SkippedProbe(
|
|
300
|
+
name="novelty",
|
|
301
|
+
reason="no assignment times; log t per unit to enable this check",
|
|
302
|
+
)
|
|
303
|
+
timed.sort(key=lambda u: u.t if u.t is not None else 0.0)
|
|
304
|
+
half = len(timed) // 2
|
|
305
|
+
early, late = timed[:half], timed[half:]
|
|
306
|
+
groups = (
|
|
307
|
+
[u.metrics[design.primary_metric] for u in early if u.arm == design.treatment],
|
|
308
|
+
[u.metrics[design.primary_metric] for u in early if u.arm == design.control],
|
|
309
|
+
[u.metrics[design.primary_metric] for u in late if u.arm == design.treatment],
|
|
310
|
+
[u.metrics[design.primary_metric] for u in late if u.arm == design.control],
|
|
311
|
+
)
|
|
312
|
+
if any(len(g) < 2 for g in groups):
|
|
313
|
+
return SkippedProbe(
|
|
314
|
+
name="novelty",
|
|
315
|
+
reason="one half of the experiment lacks units in some arm",
|
|
316
|
+
)
|
|
317
|
+
point, ci = bootstrap_ci(_novelty_gap, groups)
|
|
318
|
+
return ProbeResult(
|
|
319
|
+
name="novelty",
|
|
320
|
+
value=point,
|
|
321
|
+
unit="SD",
|
|
322
|
+
ci=ci,
|
|
323
|
+
innocent=INNOCENT_NOVELTY,
|
|
324
|
+
detail="first-half minus second-half effect on the primary metric, in "
|
|
325
|
+
"pooled-SD units; a nonzero gap means the single reported number "
|
|
326
|
+
"averages over a changing effect",
|
|
327
|
+
n=len(timed),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def winners_curse(units: Sequence[Unit], design: Design) -> ProbeResult | SkippedProbe:
|
|
332
|
+
"""Exaggeration ratio of a significant estimate, after Gelman and Carlin."""
|
|
333
|
+
treat = _arm_values(units, design.treatment, design.primary_metric)
|
|
334
|
+
ctrl = _arm_values(units, design.control, design.primary_metric)
|
|
335
|
+
if len(treat) < 2 or len(ctrl) < 2:
|
|
336
|
+
return SkippedProbe(
|
|
337
|
+
name="winner's curse",
|
|
338
|
+
reason=f"needs per-unit values of {design.primary_metric!r} in both arms",
|
|
339
|
+
)
|
|
340
|
+
effect, se, _, p = welch(treat, ctrl)
|
|
341
|
+
if se == 0.0:
|
|
342
|
+
return SkippedProbe(name="winner's curse", reason="zero standard error")
|
|
343
|
+
if design.mde is not None:
|
|
344
|
+
basis, basis_label = design.mde, f"the design MDE of {design.mde:g}"
|
|
345
|
+
elif effect != 0.0:
|
|
346
|
+
basis, basis_label = effect, "the observed effect (an optimistic bound)"
|
|
347
|
+
else:
|
|
348
|
+
return SkippedProbe(
|
|
349
|
+
name="winner's curse",
|
|
350
|
+
reason="observed effect is exactly zero and no design.mde is given",
|
|
351
|
+
)
|
|
352
|
+
power, type_s, exaggeration = design_analysis(basis, se, design.alpha)
|
|
353
|
+
return ProbeResult(
|
|
354
|
+
name="winner's curse",
|
|
355
|
+
value=exaggeration,
|
|
356
|
+
unit="x",
|
|
357
|
+
detail=f"power {power:.2f} at {basis_label}; a significant estimate is "
|
|
358
|
+
f"expected to overstate the true effect by {exaggeration:.1f}x "
|
|
359
|
+
f"(wrong sign with probability {type_s:.3f}); flags when the result "
|
|
360
|
+
f"is significant with power below {CURSE_POWER:g}",
|
|
361
|
+
n=len(treat) + len(ctrl),
|
|
362
|
+
flagged=p < design.alpha and power < CURSE_POWER,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def variance_ratio(units: Sequence[Unit], design: Design) -> ProbeResult | SkippedProbe:
|
|
367
|
+
"""Treatment-to-control variance ratio on the primary metric. Descriptive."""
|
|
368
|
+
treat = _arm_values(units, design.treatment, design.primary_metric)
|
|
369
|
+
ctrl = _arm_values(units, design.control, design.primary_metric)
|
|
370
|
+
if len(treat) < 2 or len(ctrl) < 2:
|
|
371
|
+
return SkippedProbe(
|
|
372
|
+
name="variance ratio",
|
|
373
|
+
reason=f"needs per-unit values of {design.primary_metric!r} in both arms",
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def ratio(t: Sequence[float], c: Sequence[float]) -> float:
|
|
377
|
+
vc = variance(c)
|
|
378
|
+
if vc == 0.0:
|
|
379
|
+
return math.nan
|
|
380
|
+
return variance(t) / vc
|
|
381
|
+
|
|
382
|
+
point, ci = bootstrap_ci(ratio, (treat, ctrl))
|
|
383
|
+
return ProbeResult(
|
|
384
|
+
name="variance ratio",
|
|
385
|
+
value=point,
|
|
386
|
+
unit="ratio",
|
|
387
|
+
ci=ci,
|
|
388
|
+
detail="treatment variance over control variance on the primary metric; "
|
|
389
|
+
"a shift here means the treatment changes the distribution, not "
|
|
390
|
+
"just the mean (descriptive, never flags)",
|
|
391
|
+
n=len(treat) + len(ctrl),
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def cuped_headroom(units: Sequence[Unit], design: Design) -> ProbeResult | SkippedProbe:
|
|
396
|
+
"""Variance reduction CUPED would have bought. Descriptive."""
|
|
397
|
+
keys = sorted({key for u in units for key in u.pre})
|
|
398
|
+
if not keys:
|
|
399
|
+
return SkippedProbe(
|
|
400
|
+
name="CUPED headroom",
|
|
401
|
+
reason="no pre-experiment covariates logged",
|
|
402
|
+
)
|
|
403
|
+
best_key, best = "", 0.0
|
|
404
|
+
for key in keys:
|
|
405
|
+
pairs = [
|
|
406
|
+
(u.metrics[design.primary_metric], u.pre[key])
|
|
407
|
+
for u in units
|
|
408
|
+
if design.primary_metric in u.metrics and key in u.pre
|
|
409
|
+
]
|
|
410
|
+
if len(pairs) < 3:
|
|
411
|
+
continue
|
|
412
|
+
reduction = cuped_reduction([p[0] for p in pairs], [p[1] for p in pairs])
|
|
413
|
+
if reduction > best:
|
|
414
|
+
best_key, best = key, reduction
|
|
415
|
+
if not best_key:
|
|
416
|
+
return SkippedProbe(
|
|
417
|
+
name="CUPED headroom",
|
|
418
|
+
reason="no covariate observed together with the primary metric",
|
|
419
|
+
)
|
|
420
|
+
return ProbeResult(
|
|
421
|
+
name="CUPED headroom",
|
|
422
|
+
value=best,
|
|
423
|
+
unit="share",
|
|
424
|
+
detail=f"CUPED on pre-experiment {best_key!r} would have removed "
|
|
425
|
+
f"{best:.0%} of the primary metric's variance — the confidence "
|
|
426
|
+
"interval could have been that much tighter (descriptive, never flags)",
|
|
427
|
+
n=len(units),
|
|
428
|
+
)
|
abkit/py.typed
ADDED
|
File without changes
|
abkit/report.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Report and figure writers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import matplotlib
|
|
10
|
+
|
|
11
|
+
matplotlib.use("Agg")
|
|
12
|
+
|
|
13
|
+
import matplotlib.pyplot as plt
|
|
14
|
+
|
|
15
|
+
from .probes import fragility_path
|
|
16
|
+
from .result import AuditResult
|
|
17
|
+
from .schema import Design, Look, Unit
|
|
18
|
+
from .stats import design_analysis, normal_ppf
|
|
19
|
+
|
|
20
|
+
ACCENT = "#0f766e"
|
|
21
|
+
ALERT = "#b91c1c"
|
|
22
|
+
NEUTRAL = "#6b7280"
|
|
23
|
+
|
|
24
|
+
_BAND_TEXT = {
|
|
25
|
+
"sample ratio mismatch": "p >= 0.001",
|
|
26
|
+
"assignment contamination": "0 units",
|
|
27
|
+
"outlier fragility": "> 3 units to overturn",
|
|
28
|
+
"uncorrected winners": "0 metrics",
|
|
29
|
+
"peeking": "FPR <= 1.5x alpha",
|
|
30
|
+
"winner's curse": "power >= 0.5",
|
|
31
|
+
"variance ratio": "descriptive",
|
|
32
|
+
"CUPED headroom": "descriptive",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _style(ax: plt.Axes) -> None:
|
|
37
|
+
for side in ("top", "right"):
|
|
38
|
+
ax.spines[side].set_visible(False)
|
|
39
|
+
ax.grid(axis="y", color="#e5e7eb", linewidth=0.8)
|
|
40
|
+
ax.set_axisbelow(True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def peeking_figure(looks: Sequence[Look], alpha: float, path: Path) -> None:
|
|
44
|
+
"""The z statistic at every interim look against the fixed-horizon bound."""
|
|
45
|
+
z_crit = normal_ppf(1.0 - alpha / 2.0)
|
|
46
|
+
ns = [look.n for look in looks]
|
|
47
|
+
zs = [look.z for look in looks]
|
|
48
|
+
fig, ax = plt.subplots(figsize=(7.2, 3.6))
|
|
49
|
+
ax.axhline(z_crit, color=NEUTRAL, linestyle="--", linewidth=1)
|
|
50
|
+
ax.axhline(-z_crit, color=NEUTRAL, linestyle="--", linewidth=1)
|
|
51
|
+
ax.axhline(0, color="#d1d5db", linewidth=0.8)
|
|
52
|
+
ax.plot(ns, zs, color=ACCENT, linewidth=1.8, marker="o", markersize=4)
|
|
53
|
+
top = max(max(zs), z_crit)
|
|
54
|
+
ax.set_ylim(min(min(zs), -z_crit) - 0.5, top + 0.7)
|
|
55
|
+
crossed = [(n, z) for n, z in zip(ns, zs, strict=True) if abs(z) >= z_crit]
|
|
56
|
+
if crossed:
|
|
57
|
+
n_x, z_x = crossed[0]
|
|
58
|
+
ax.plot([n_x], [z_x], marker="o", markersize=9, color=ALERT, zorder=5)
|
|
59
|
+
ax.annotate(
|
|
60
|
+
f"stopped here, z = {z_x:.2f}",
|
|
61
|
+
(n_x, z_x),
|
|
62
|
+
textcoords="offset points",
|
|
63
|
+
xytext=(-14, -4),
|
|
64
|
+
ha="right",
|
|
65
|
+
va="center",
|
|
66
|
+
fontsize=9,
|
|
67
|
+
color=ALERT,
|
|
68
|
+
)
|
|
69
|
+
ax.text(ns[0], z_crit, f"z = {z_crit:.2f}", va="bottom", ha="left", fontsize=9, color=NEUTRAL)
|
|
70
|
+
ax.set_xlabel("units observed")
|
|
71
|
+
ax.set_ylabel("z statistic")
|
|
72
|
+
ax.set_title("A null experiment, analyzed after every batch", fontsize=11)
|
|
73
|
+
_style(ax)
|
|
74
|
+
fig.tight_layout()
|
|
75
|
+
fig.savefig(path, dpi=160)
|
|
76
|
+
plt.close(fig)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def fragility_figure(
|
|
80
|
+
treat: Sequence[float], ctrl: Sequence[float], alpha: float, path: Path
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Primary p-value as the most extreme units are removed one by one."""
|
|
83
|
+
removal = fragility_path(treat, ctrl)
|
|
84
|
+
ks = [k for k, _ in removal]
|
|
85
|
+
ps = [max(p, 1e-6) for _, p in removal]
|
|
86
|
+
fig, ax = plt.subplots(figsize=(7.2, 3.6))
|
|
87
|
+
ax.axhline(alpha, color=NEUTRAL, linestyle="--", linewidth=1)
|
|
88
|
+
ax.text(
|
|
89
|
+
ks[-1], alpha, f" alpha = {alpha:g}", va="bottom", ha="right", fontsize=9, color=NEUTRAL
|
|
90
|
+
)
|
|
91
|
+
ax.plot(ks, ps, color=ACCENT, linewidth=1.8, marker="o", markersize=4)
|
|
92
|
+
flipped = [(k, p) for k, p in zip(ks, ps, strict=True) if p >= alpha]
|
|
93
|
+
if flipped:
|
|
94
|
+
k_x, p_x = flipped[0]
|
|
95
|
+
ax.plot([k_x], [p_x], marker="o", markersize=9, color=ALERT, zorder=5)
|
|
96
|
+
noun = "removal" if k_x == 1 else "removals"
|
|
97
|
+
ax.annotate(
|
|
98
|
+
f"significance gone after {k_x} {noun}",
|
|
99
|
+
(k_x, p_x),
|
|
100
|
+
textcoords="offset points",
|
|
101
|
+
xytext=(6, -14),
|
|
102
|
+
fontsize=9,
|
|
103
|
+
color=ALERT,
|
|
104
|
+
)
|
|
105
|
+
ax.set_yscale("log")
|
|
106
|
+
ax.xaxis.set_major_locator(matplotlib.ticker.MaxNLocator(integer=True))
|
|
107
|
+
ax.set_xlabel("most extreme units removed")
|
|
108
|
+
ax.set_ylabel("primary p-value (log scale)")
|
|
109
|
+
ax.set_title("How many units the significant result rests on", fontsize=11)
|
|
110
|
+
_style(ax)
|
|
111
|
+
fig.tight_layout()
|
|
112
|
+
fig.savefig(path, dpi=160)
|
|
113
|
+
plt.close(fig)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def exaggeration_figure(
|
|
117
|
+
marks: Sequence[tuple[str, float, float]], alpha: float, path: Path
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Type-M exaggeration ratio against power, with experiments marked."""
|
|
120
|
+
powers = []
|
|
121
|
+
ratios = []
|
|
122
|
+
mu = 0.6
|
|
123
|
+
while mu <= 6.0:
|
|
124
|
+
power, _, ratio = design_analysis(mu, 1.0, alpha)
|
|
125
|
+
powers.append(power)
|
|
126
|
+
ratios.append(ratio)
|
|
127
|
+
mu += 0.02
|
|
128
|
+
fig, ax = plt.subplots(figsize=(7.2, 3.6))
|
|
129
|
+
ax.plot(powers, ratios, color=ACCENT, linewidth=1.8)
|
|
130
|
+
ax.axhline(1.0, color="#d1d5db", linewidth=0.8)
|
|
131
|
+
ax.set_ylim(0.0, max(ratios) + 0.6)
|
|
132
|
+
stagger = 0
|
|
133
|
+
for label, power, ratio in marks:
|
|
134
|
+
color = ALERT if power < 0.5 else NEUTRAL
|
|
135
|
+
ax.plot([power], [ratio], marker="o", markersize=7, color=color, zorder=5)
|
|
136
|
+
near_right = power > 0.6
|
|
137
|
+
if near_right:
|
|
138
|
+
offset = (-8, 8 if stagger % 2 == 0 else -16)
|
|
139
|
+
stagger += 1
|
|
140
|
+
else:
|
|
141
|
+
offset = (8, 4)
|
|
142
|
+
ax.annotate(
|
|
143
|
+
f"{label} ({ratio:.1f}x)",
|
|
144
|
+
(power, ratio),
|
|
145
|
+
textcoords="offset points",
|
|
146
|
+
xytext=offset,
|
|
147
|
+
ha="right" if near_right else "left",
|
|
148
|
+
fontsize=9,
|
|
149
|
+
color=color,
|
|
150
|
+
)
|
|
151
|
+
ax.set_xlabel("power at the true effect")
|
|
152
|
+
ax.set_ylabel("expected exaggeration of\na significant estimate")
|
|
153
|
+
ax.set_title("The winner's curse is a function of power", fontsize=11)
|
|
154
|
+
_style(ax)
|
|
155
|
+
fig.tight_layout()
|
|
156
|
+
fig.savefig(path, dpi=160)
|
|
157
|
+
plt.close(fig)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _format_value(value: float, unit: str) -> str:
|
|
161
|
+
if unit == "p":
|
|
162
|
+
return f"{value:.2g}"
|
|
163
|
+
if unit in ("units", "metrics"):
|
|
164
|
+
return f"{value:.0f}"
|
|
165
|
+
if unit == "share":
|
|
166
|
+
return f"{value:.0%}"
|
|
167
|
+
return f"{value:.2f}"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def render_report(audit: AuditResult) -> str:
|
|
171
|
+
lines = [f"# abkit audit: {audit.experiment}", ""]
|
|
172
|
+
if audit.summary:
|
|
173
|
+
s = audit.summary
|
|
174
|
+
effect = float(s["effect"]) # type: ignore[arg-type]
|
|
175
|
+
se = float(s["se"]) # type: ignore[arg-type]
|
|
176
|
+
lines += [
|
|
177
|
+
"## Primary readout",
|
|
178
|
+
"",
|
|
179
|
+
"| metric | effect | 95% CI | z | p | n (treatment/control) |",
|
|
180
|
+
"|---|---|---|---|---|---|",
|
|
181
|
+
f"| {s['primary_metric']} | {effect:.4f} "
|
|
182
|
+
f"| [{effect - 1.96 * se:.4f}, {effect + 1.96 * se:.4f}] "
|
|
183
|
+
f"| {float(s['z']):.2f} | {float(s['p']):.2g} " # type: ignore[arg-type]
|
|
184
|
+
f"| {s['n_treatment']}/{s['n_control']} |",
|
|
185
|
+
"",
|
|
186
|
+
]
|
|
187
|
+
lines += [
|
|
188
|
+
"## Probes",
|
|
189
|
+
"",
|
|
190
|
+
"| probe | value | 95% CI | innocent | flag |",
|
|
191
|
+
"|---|---|---|---|---|",
|
|
192
|
+
]
|
|
193
|
+
for probe in audit.probes:
|
|
194
|
+
value = _format_value(probe.value, probe.unit)
|
|
195
|
+
if probe.unit and probe.unit not in ("p", "share"):
|
|
196
|
+
value = f"{value} {probe.unit}"
|
|
197
|
+
ci = f"[{probe.ci[0]:.2f}, {probe.ci[1]:.2f}]" if probe.ci else "-"
|
|
198
|
+
if probe.innocent is not None:
|
|
199
|
+
band = f"[{probe.innocent[0]:g}, {probe.innocent[1]:g}]"
|
|
200
|
+
else:
|
|
201
|
+
band = _BAND_TEXT.get(probe.name.split(" (")[0], "-")
|
|
202
|
+
flag = "**FLAG**" if probe.triggered else "-"
|
|
203
|
+
lines.append(f"| {probe.name} | {value} | {ci} | {band} | {flag} |")
|
|
204
|
+
lines.append("")
|
|
205
|
+
lines.append("### Details")
|
|
206
|
+
lines.append("")
|
|
207
|
+
for probe in audit.probes:
|
|
208
|
+
lines.append(f"- **{probe.name}** (n={probe.n}): {probe.detail}")
|
|
209
|
+
if audit.skipped:
|
|
210
|
+
lines += ["", "## Skipped checks", ""]
|
|
211
|
+
for skip in audit.skipped:
|
|
212
|
+
lines.append(f"- **{skip.name}**: {skip.reason}")
|
|
213
|
+
flags = audit.flags
|
|
214
|
+
lines += ["", "## Verdict", ""]
|
|
215
|
+
if flags:
|
|
216
|
+
lines.append(f"{len(flags)} flag(s):")
|
|
217
|
+
lines.append("")
|
|
218
|
+
for probe in flags:
|
|
219
|
+
lines.append(f"- **{probe.name}**: {probe.detail}")
|
|
220
|
+
else:
|
|
221
|
+
lines.append("No probe left its innocent range.")
|
|
222
|
+
lines.append("")
|
|
223
|
+
return "\n".join(lines)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def write_report(
|
|
227
|
+
audit: AuditResult,
|
|
228
|
+
out_dir: str | Path,
|
|
229
|
+
design: Design | None = None,
|
|
230
|
+
units: Sequence[Unit] | None = None,
|
|
231
|
+
) -> Path:
|
|
232
|
+
out = Path(out_dir)
|
|
233
|
+
figures = out / "figures"
|
|
234
|
+
figures.mkdir(parents=True, exist_ok=True)
|
|
235
|
+
(out / "report.md").write_text(render_report(audit), encoding="utf-8")
|
|
236
|
+
(out / "report.json").write_text(json.dumps(audit.to_dict(), indent=2) + "\n", encoding="utf-8")
|
|
237
|
+
if design is not None and design.looks and len(design.looks) >= 2:
|
|
238
|
+
peeking_figure(design.looks, design.alpha, figures / "peeking.png")
|
|
239
|
+
if design is not None and units is not None and audit.summary:
|
|
240
|
+
p = float(audit.summary["p"]) # type: ignore[arg-type]
|
|
241
|
+
if p < design.alpha:
|
|
242
|
+
treat = [
|
|
243
|
+
u.metrics[design.primary_metric]
|
|
244
|
+
for u in units
|
|
245
|
+
if u.arm == design.treatment and design.primary_metric in u.metrics
|
|
246
|
+
]
|
|
247
|
+
ctrl = [
|
|
248
|
+
u.metrics[design.primary_metric]
|
|
249
|
+
for u in units
|
|
250
|
+
if u.arm == design.control and design.primary_metric in u.metrics
|
|
251
|
+
]
|
|
252
|
+
fragility_figure(treat, ctrl, design.alpha, figures / "fragility.png")
|
|
253
|
+
return out / "report.md"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def inject_readme(readme_path: str | Path, table: str) -> None:
|
|
257
|
+
"""Replace the fenced demo table in a README with the freshly built one."""
|
|
258
|
+
start = "<!-- abkit:demo -->"
|
|
259
|
+
end = "<!-- /abkit:demo -->"
|
|
260
|
+
text = Path(readme_path).read_text(encoding="utf-8")
|
|
261
|
+
if start not in text or end not in text:
|
|
262
|
+
raise ValueError(f"README markers {start} / {end} not found")
|
|
263
|
+
head, rest = text.split(start, 1)
|
|
264
|
+
_, tail = rest.split(end, 1)
|
|
265
|
+
Path(readme_path).write_text(
|
|
266
|
+
head + start + "\n" + table.rstrip() + "\n" + end + tail, encoding="utf-8"
|
|
267
|
+
)
|