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 ADDED
@@ -0,0 +1,23 @@
1
+ """abkit: audit an A/B-test readout before you ship the decision."""
2
+
3
+ from .audit import run_audit
4
+ from .io import dump_design, dump_units, load_design, load_units
5
+ from .result import AuditResult, ProbeResult, SkippedProbe
6
+ from .schema import Design, Look, Unit
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = [
11
+ "AuditResult",
12
+ "Design",
13
+ "Look",
14
+ "ProbeResult",
15
+ "SkippedProbe",
16
+ "Unit",
17
+ "__version__",
18
+ "dump_design",
19
+ "dump_units",
20
+ "load_design",
21
+ "load_units",
22
+ "run_audit",
23
+ ]
abkit/audit.py ADDED
@@ -0,0 +1,58 @@
1
+ """Run every applicable probe against one experiment readout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from . import probes
8
+ from .result import AuditResult, ProbeResult, SkippedProbe
9
+ from .schema import Design, Unit
10
+ from .stats import welch
11
+
12
+
13
+ def run_audit(units: Sequence[Unit], design: Design, name: str = "experiment") -> AuditResult:
14
+ result = AuditResult(experiment=name)
15
+
16
+ treat = [
17
+ u.metrics[design.primary_metric]
18
+ for u in units
19
+ if u.arm == design.treatment and design.primary_metric in u.metrics
20
+ ]
21
+ ctrl = [
22
+ u.metrics[design.primary_metric]
23
+ for u in units
24
+ if u.arm == design.control and design.primary_metric in u.metrics
25
+ ]
26
+ if len(treat) >= 2 and len(ctrl) >= 2:
27
+ effect, se, z, p = welch(treat, ctrl)
28
+ result.summary = {
29
+ "primary_metric": design.primary_metric,
30
+ "effect": effect,
31
+ "se": se,
32
+ "z": z,
33
+ "p": p,
34
+ "n_treatment": len(treat),
35
+ "n_control": len(ctrl),
36
+ "alpha": design.alpha,
37
+ }
38
+
39
+ outcomes: list[ProbeResult | SkippedProbe | list[ProbeResult]] = [
40
+ probes.sample_ratio(units, design),
41
+ probes.contamination(units),
42
+ probes.covariate_balance(units, design),
43
+ probes.outlier_fragility(units, design),
44
+ probes.uncorrected_winners(units, design),
45
+ probes.peeking(design),
46
+ probes.novelty(units, design),
47
+ probes.winners_curse(units, design),
48
+ probes.variance_ratio(units, design),
49
+ probes.cuped_headroom(units, design),
50
+ ]
51
+ for outcome in outcomes:
52
+ if isinstance(outcome, SkippedProbe):
53
+ result.skipped.append(outcome)
54
+ elif isinstance(outcome, list):
55
+ result.probes.extend(outcome)
56
+ else:
57
+ result.probes.append(outcome)
58
+ return result
abkit/bootstrap.py ADDED
@@ -0,0 +1,46 @@
1
+ """Stratified bootstrap confidence intervals.
2
+
3
+ Units are resampled within their arm (or other stratum), never across, so the
4
+ group sizes that determine a statistic's sampling distribution are preserved.
5
+ Percentile intervals, seeded PCG64, no BLAS.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections.abc import Callable, Sequence
12
+
13
+ import numpy as np
14
+
15
+ MAX_UNDEFINED_SHARE = 0.1
16
+ """If more than this share of resamples yields nan, the CI is not reported."""
17
+
18
+
19
+ def bootstrap_ci(
20
+ statistic: Callable[..., float],
21
+ groups: Sequence[Sequence[float]],
22
+ n_resamples: int = 1000,
23
+ seed: int = 0,
24
+ ) -> tuple[float, tuple[float, float] | None]:
25
+ """Point estimate and 95% percentile CI, resampling within each group."""
26
+ point = statistic(*groups)
27
+ rng = np.random.Generator(np.random.PCG64(seed))
28
+ arrays = [list(group) for group in groups]
29
+ values: list[float] = []
30
+ undefined = 0
31
+ for _ in range(n_resamples):
32
+ resampled = []
33
+ for group in arrays:
34
+ indices = rng.integers(0, len(group), size=len(group)).tolist()
35
+ resampled.append([group[i] for i in indices])
36
+ value = statistic(*resampled)
37
+ if math.isnan(value):
38
+ undefined += 1
39
+ else:
40
+ values.append(value)
41
+ if undefined > MAX_UNDEFINED_SHARE * n_resamples or not values:
42
+ return point, None
43
+ values.sort()
44
+ lo = values[int(0.025 * len(values))]
45
+ hi = values[min(len(values) - 1, int(0.975 * len(values)))]
46
+ return point, (lo, hi)
abkit/cli.py ADDED
@@ -0,0 +1,71 @@
1
+ """Command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from . import __version__
9
+ from .audit import run_audit
10
+ from .demo import run_demo
11
+ from .io import load_design, load_units
12
+ from .report import inject_readme, write_report
13
+
14
+
15
+ def main(argv: list[str] | None = None) -> int:
16
+ parser = argparse.ArgumentParser(
17
+ prog="abkit", description="Audit an A/B-test readout before you ship the decision."
18
+ )
19
+ parser.add_argument("--version", action="version", version=f"abkit {__version__}")
20
+ sub = parser.add_subparsers(dest="command", required=True)
21
+
22
+ report_cmd = sub.add_parser("report", help="audit a units JSONL against a design JSON")
23
+ report_cmd.add_argument("units", help="JSONL file, one unit per line")
24
+ report_cmd.add_argument("--design", required=True, help="design JSON file")
25
+ report_cmd.add_argument("--out", default="audit", help="output directory")
26
+ report_cmd.add_argument("--name", default=None, help="experiment name in the report")
27
+ report_cmd.add_argument(
28
+ "--fail-on-flags", action="store_true", help="exit 2 if any probe triggers"
29
+ )
30
+
31
+ demo_cmd = sub.add_parser("demo", help="rebuild the implanted-defect demo")
32
+ demo_cmd.add_argument("--out", default="results", help="output directory")
33
+
34
+ inject_cmd = sub.add_parser(
35
+ "inject-readme", help="refresh the demo table between README markers"
36
+ )
37
+ inject_cmd.add_argument("readme")
38
+ inject_cmd.add_argument("--results", default="results", help="demo output directory")
39
+
40
+ args = parser.parse_args(argv)
41
+
42
+ if args.command == "report":
43
+ units = load_units(args.units)
44
+ design = load_design(args.design)
45
+ name = args.name or Path(args.units).stem
46
+ audit = run_audit(units, design, name=name)
47
+ report_path = write_report(audit, args.out, design=design, units=units)
48
+ print(f"wrote {report_path}")
49
+ for probe in audit.flags:
50
+ print(f"FLAG {probe.name} -- {probe.detail}")
51
+ if args.fail_on_flags and audit.flags:
52
+ return 2
53
+ return 0
54
+
55
+ if args.command == "demo":
56
+ audits = run_demo(args.out)
57
+ flagged = sum(1 for audit in audits if audit.flags)
58
+ print(f"wrote {args.out}/report.md ({flagged}/{len(audits)} experiments flagged)")
59
+ return 0
60
+
61
+ if args.command == "inject-readme":
62
+ table = (Path(args.results) / "table.md").read_text(encoding="utf-8")
63
+ inject_readme(args.readme, table)
64
+ print(f"updated {args.readme}")
65
+ return 0
66
+
67
+ raise AssertionError("unreachable")
68
+
69
+
70
+ if __name__ == "__main__":
71
+ raise SystemExit(main())
abkit/demo.py ADDED
@@ -0,0 +1,177 @@
1
+ """Eight experiments, seven implanted defects, one table.
2
+
3
+ Each demo experiment implants exactly one failure mode at a severity chosen to
4
+ be decisive, then runs the full audit. The table is regenerated from fixed
5
+ seeds; CI diffs it against the committed copy.
6
+
7
+ Two experiments deliberately search over seeds: the peeker keeps trying null
8
+ experiments until one stops early at an interim look, and the underpowered
9
+ winner keeps trying until a true effect of 0.1 comes out significant. That
10
+ selection is not a trick — it is the defect. Peeking and publishing only
11
+ winners are selection processes, and the demo reproduces the selection.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ from .audit import run_audit
19
+ from .probes import uncorrected_winners
20
+ from .report import (
21
+ exaggeration_figure,
22
+ fragility_figure,
23
+ peeking_figure,
24
+ render_report,
25
+ )
26
+ from .result import AuditResult, SkippedProbe
27
+ from .schema import Design, Unit
28
+ from .stats import design_analysis, welch
29
+ from .synthetic import simulate, simulate_peeking
30
+
31
+ DEFECTS = {
32
+ "clean": "nothing",
33
+ "traffic-leak": "3% of traffic diverted to treatment",
34
+ "double-dipper": "25 units assigned to both arms",
35
+ "whale-driven": "3 extreme units carry the significance",
36
+ "peeker": "null effect, stopped at the first significant look",
37
+ "fading-novelty": "early-half effect 0.30, late-half effect 0.00",
38
+ "metric-fisher": "null effect, 40 secondary metrics tested",
39
+ "underpowered-winner": "true effect 0.1, powered for 0.14 of that chance",
40
+ }
41
+
42
+
43
+ def _confirm(
44
+ units: list[Unit], design: Design, name: str, target: set[str]
45
+ ) -> tuple[list[Unit], Design] | None:
46
+ audit = run_audit(units, design, name=name)
47
+ if {probe.name for probe in audit.flags} == target:
48
+ return units, design
49
+ return None
50
+
51
+
52
+ def _find_peeker() -> tuple[list[Unit], Design]:
53
+ for seed in range(500):
54
+ units, design = simulate_peeking(n_max=5000, look_every=250, effect=0.0, seed=seed)
55
+ looks = design.looks or []
56
+ if not looks or looks[-1].n < 1500 or looks[-1].n > 3500 or abs(looks[-1].z) < 2.2:
57
+ continue
58
+ found = _confirm(units, design, "peeker", {"peeking"})
59
+ if found:
60
+ return found
61
+ raise RuntimeError("no early-stopping null experiment found in 500 seeds")
62
+
63
+
64
+ def _find_underpowered() -> tuple[list[Unit], Design]:
65
+ for seed in range(500):
66
+ units, design = simulate(n=300, effect=0.1, mde=0.1, seed=seed)
67
+ treat = [u.metrics["outcome"] for u in units if u.arm == "treatment"]
68
+ ctrl = [u.metrics["outcome"] for u in units if u.arm == "control"]
69
+ effect, _, _, p = welch(treat, ctrl)
70
+ if p >= design.alpha or effect <= 0:
71
+ continue
72
+ found = _confirm(units, design, "underpowered-winner", {"winner's curse"})
73
+ if found:
74
+ return found
75
+ raise RuntimeError("no significant underpowered experiment found in 500 seeds")
76
+
77
+
78
+ def _find_metric_fisher() -> tuple[list[Unit], Design]:
79
+ for seed in range(500):
80
+ units, design = simulate(n=2000, effect=0.0, extra_metrics=40, seed=seed)
81
+ probe = uncorrected_winners(units, design)
82
+ if isinstance(probe, SkippedProbe) or not probe.triggered:
83
+ continue
84
+ found = _confirm(units, design, "metric-fisher", {"uncorrected winners"})
85
+ if found:
86
+ return found
87
+ raise RuntimeError("no experiment with uncorrected winners found in 500 seeds")
88
+
89
+
90
+ def build_experiments() -> list[tuple[str, list[Unit], Design]]:
91
+ return [
92
+ ("clean", *simulate(n=8000, effect=0.2, mde=0.2, seed=11)),
93
+ ("traffic-leak", *simulate(n=8000, effect=0.2, srm=0.03, mde=0.2, seed=12)),
94
+ ("double-dipper", *simulate(n=4000, effect=0.2, contaminated=25, mde=0.2, seed=13)),
95
+ (
96
+ "whale-driven",
97
+ *simulate(n=1000, effect=0.12, whales=3, whale_value=15.0, mde=0.2, seed=2),
98
+ ),
99
+ ("peeker", *_find_peeker()),
100
+ ("fading-novelty", *simulate(n=6000, effect=0.15, novelty=0.3, mde=0.15, seed=16)),
101
+ ("metric-fisher", *_find_metric_fisher()),
102
+ ("underpowered-winner", *_find_underpowered()),
103
+ ]
104
+
105
+
106
+ def _cell(audit: AuditResult, probe_name: str, fmt: str) -> str:
107
+ for probe in audit.probes:
108
+ if probe.name == probe_name:
109
+ text = format(probe.value, fmt)
110
+ return f"**{text}**" if probe.triggered else text
111
+ return "-"
112
+
113
+
114
+ def demo_table(audits: list[AuditResult]) -> str:
115
+ lines = [
116
+ "| experiment | implanted defect | SRM p | contam. | fragility "
117
+ "| look FPR | novelty gap | uncorrected | exagg. | flags |",
118
+ "|---|---|---|---|---|---|---|---|---|---|",
119
+ ]
120
+ curse = "winner's curse"
121
+ for audit in audits:
122
+ flags = ", ".join(p.name for p in audit.flags) or "none"
123
+ lines.append(
124
+ f"| {audit.experiment} | {DEFECTS[audit.experiment]} "
125
+ f"| {_cell(audit, 'sample ratio mismatch', '.2g')} "
126
+ f"| {_cell(audit, 'assignment contamination', '.0f')} "
127
+ f"| {_cell(audit, 'outlier fragility', '.0f')} "
128
+ f"| {_cell(audit, 'peeking', '.2f')} "
129
+ f"| {_cell(audit, 'novelty', '.2f')} "
130
+ f"| {_cell(audit, 'uncorrected winners', '.0f')} "
131
+ f"| {_cell(audit, curse, '.1f')} "
132
+ f"| {flags} |"
133
+ )
134
+ return "\n".join(lines)
135
+
136
+
137
+ def run_demo(out_dir: str | Path) -> list[AuditResult]:
138
+ out = Path(out_dir)
139
+ figures = out / "figures"
140
+ figures.mkdir(parents=True, exist_ok=True)
141
+ experiments = build_experiments()
142
+ audits = []
143
+ sections = []
144
+ marks = []
145
+ for name, units, design in experiments:
146
+ audit = run_audit(units, design, name=name)
147
+ audits.append(audit)
148
+ sections.append(render_report(audit).replace("# abkit audit:", "## "))
149
+ if (
150
+ name in ("clean", "whale-driven", "underpowered-winner")
151
+ and audit.summary
152
+ and design.mde is not None
153
+ ):
154
+ se = float(audit.summary["se"]) # type: ignore[arg-type]
155
+ power, _, ratio = design_analysis(design.mde, se, design.alpha)
156
+ if float(audit.summary["p"]) < design.alpha: # type: ignore[arg-type]
157
+ marks.append((name, power, ratio))
158
+ if name == "peeker" and design.looks:
159
+ peeking_figure(design.looks, design.alpha, figures / "peeking.png")
160
+ if name == "whale-driven":
161
+ treat = [u.metrics["outcome"] for u in units if u.arm == "treatment"]
162
+ ctrl = [u.metrics["outcome"] for u in units if u.arm == "control"]
163
+ fragility_figure(treat, ctrl, design.alpha, figures / "fragility.png")
164
+ exaggeration_figure(marks, 0.05, figures / "exaggeration.png")
165
+
166
+ table = demo_table(audits)
167
+ (out / "table.md").write_text(table + "\n", encoding="utf-8")
168
+ header = (
169
+ "# abkit demo\n\n"
170
+ "Eight synthetic experiments, seven implanted defects. Bold values are\n"
171
+ "flags; each lands on the row where its defect was implanted.\n\n"
172
+ )
173
+ (out / "report.md").write_text(header + table + "\n\n" + "\n".join(sections), encoding="utf-8")
174
+ return audits
175
+
176
+
177
+ __all__ = ["build_experiments", "demo_table", "run_demo", "DEFECTS"]
abkit/io.py ADDED
@@ -0,0 +1,38 @@
1
+ """Read and write experiment data.
2
+
3
+ Units are one JSON object per line; the design is a single JSON document.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+ from .schema import Design, Unit
12
+
13
+
14
+ def load_units(path: str | Path) -> list[Unit]:
15
+ units = []
16
+ with open(path, encoding="utf-8") as handle:
17
+ for line in handle:
18
+ line = line.strip()
19
+ if line:
20
+ units.append(Unit.model_validate_json(line))
21
+ return units
22
+
23
+
24
+ def dump_units(units: list[Unit], path: str | Path) -> None:
25
+ with open(path, "w", encoding="utf-8") as handle:
26
+ for unit in units:
27
+ handle.write(unit.model_dump_json(exclude_defaults=True) + "\n")
28
+
29
+
30
+ def load_design(path: str | Path) -> Design:
31
+ return Design.model_validate_json(Path(path).read_text(encoding="utf-8"))
32
+
33
+
34
+ def dump_design(design: Design, path: str | Path) -> None:
35
+ Path(path).write_text(
36
+ json.dumps(design.model_dump(exclude_none=True), indent=2) + "\n",
37
+ encoding="utf-8",
38
+ )