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/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
+ )