abkit 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- abkit-0.1.0/PKG-INFO +262 -0
- abkit-0.1.0/README.md +232 -0
- abkit-0.1.0/pyproject.toml +60 -0
- abkit-0.1.0/setup.cfg +4 -0
- abkit-0.1.0/src/abkit/__init__.py +23 -0
- abkit-0.1.0/src/abkit/audit.py +58 -0
- abkit-0.1.0/src/abkit/bootstrap.py +46 -0
- abkit-0.1.0/src/abkit/cli.py +71 -0
- abkit-0.1.0/src/abkit/demo.py +177 -0
- abkit-0.1.0/src/abkit/io.py +38 -0
- abkit-0.1.0/src/abkit/probes.py +428 -0
- abkit-0.1.0/src/abkit/py.typed +0 -0
- abkit-0.1.0/src/abkit/report.py +267 -0
- abkit-0.1.0/src/abkit/result.py +82 -0
- abkit-0.1.0/src/abkit/schema.py +71 -0
- abkit-0.1.0/src/abkit/stats.py +222 -0
- abkit-0.1.0/src/abkit/synthetic.py +144 -0
- abkit-0.1.0/src/abkit.egg-info/PKG-INFO +262 -0
- abkit-0.1.0/src/abkit.egg-info/SOURCES.txt +29 -0
- abkit-0.1.0/src/abkit.egg-info/dependency_links.txt +1 -0
- abkit-0.1.0/src/abkit.egg-info/entry_points.txt +2 -0
- abkit-0.1.0/src/abkit.egg-info/requires.txt +10 -0
- abkit-0.1.0/src/abkit.egg-info/top_level.txt +1 -0
- abkit-0.1.0/tests/test_bootstrap.py +43 -0
- abkit-0.1.0/tests/test_cli.py +99 -0
- abkit-0.1.0/tests/test_probes.py +263 -0
- abkit-0.1.0/tests/test_result.py +47 -0
- abkit-0.1.0/tests/test_schema.py +59 -0
- abkit-0.1.0/tests/test_stats.py +163 -0
- abkit-0.1.0/tests/test_synthetic.py +70 -0
abkit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hadi Mohammadi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
abkit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: abkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Audit an A/B-test readout before you ship the decision
|
|
5
|
+
Author: Hadi Mohammadi
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mohammadi-hadi/abkit
|
|
8
|
+
Project-URL: Issues, https://github.com/mohammadi-hadi/abkit/issues
|
|
9
|
+
Keywords: ab-testing,experimentation,srm,statistics,audit
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: numpy>=1.26
|
|
21
|
+
Requires-Dist: pydantic>=2.5
|
|
22
|
+
Requires-Dist: matplotlib>=3.8
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
25
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
26
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
27
|
+
Requires-Dist: scipy>=1.12; extra == "dev"
|
|
28
|
+
Requires-Dist: statsmodels>=0.14; extra == "dev"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# abkit
|
|
32
|
+
|
|
33
|
+
Audit an A/B-test readout before you ship the decision.
|
|
34
|
+
|
|
35
|
+
[](https://github.com/mohammadi-hadi/abkit/actions/workflows/ci.yml)
|
|
36
|
+
|
|
37
|
+
An experiment readout can say "B wins, p = 0.03, ship it" while the traffic
|
|
38
|
+
split was broken, three whale customers carried the whole effect, the analyst
|
|
39
|
+
stopped at the first significant refresh, the winning metric was one of forty
|
|
40
|
+
tried, or the experiment never had the power to estimate what it now claims.
|
|
41
|
+
None of that shows up in the p-value. abkit reads the per-unit data and the
|
|
42
|
+
intended design, measures each of these failure modes, and flags whatever
|
|
43
|
+
leaves the range a healthy experiment could occupy.
|
|
44
|
+
|
|
45
|
+
## The audit in one table
|
|
46
|
+
|
|
47
|
+
Eight synthetic experiments, seven implanted defects. Bold values are flags;
|
|
48
|
+
each lands on the row where its defect was implanted, and the clean row
|
|
49
|
+
carries none.
|
|
50
|
+
|
|
51
|
+
<!-- abkit:demo -->
|
|
52
|
+
| experiment | implanted defect | SRM p | contam. | fragility | look FPR | novelty gap | uncorrected | exagg. | flags |
|
|
53
|
+
|---|---|---|---|---|---|---|---|---|---|
|
|
54
|
+
| clean | nothing | 0.89 | 0 | 20 | - | -0.02 | - | 1.0 | none |
|
|
55
|
+
| traffic-leak | 3% of traffic diverted to treatment | **4.1e-14** | 0 | 20 | - | -0.02 | - | 1.0 | sample ratio mismatch |
|
|
56
|
+
| double-dipper | 25 units assigned to both arms | 0.67 | **25** | 20 | - | -0.03 | - | 1.0 | assignment contamination |
|
|
57
|
+
| whale-driven | 3 extreme units carry the significance | 0.11 | 0 | **1** | - | 0.26 | - | 1.2 | outlier fragility |
|
|
58
|
+
| peeker | null effect, stopped at the first significant look | 0.55 | 0 | 8 | **0.19** | -0.17 | - | 1.2 | peeking |
|
|
59
|
+
| fading-novelty | early-half effect 0.30, late-half effect 0.00 | 0.041 | 0 | 20 | - | **0.31** | - | 1.0 | novelty |
|
|
60
|
+
| metric-fisher | null effect, 40 secondary metrics tested | 0.054 | 0 | 0 | - | -0.11 | **2** | 3.4 | uncorrected winners |
|
|
61
|
+
| underpowered-winner | true effect 0.1, powered for 0.14 of that chance | 0.91 | 0 | 8 | - | -0.38 | - | **2.7** | winner's curse |
|
|
62
|
+
<!-- /abkit:demo -->
|
|
63
|
+
|
|
64
|
+
The row to sit with is `peeker`: a true null effect, analyzed after every
|
|
65
|
+
batch of 250 units and stopped the moment z crossed 1.96. Its final readout
|
|
66
|
+
is indistinguishable from a real win — p < 0.05, decent sample size — but the
|
|
67
|
+
schedule it followed has a 19% false-positive rate, not 5%. The audit flags
|
|
68
|
+
the process, which is the only place that defect lives.
|
|
69
|
+
|
|
70
|
+

|
|
71
|
+
|
|
72
|
+

|
|
73
|
+
|
|
74
|
+
`make demo` regenerates the table, the [full report](results/report.md) and
|
|
75
|
+
the figures from fixed seeds; CI rebuilds them from pinned dependencies and
|
|
76
|
+
fails if a committed number differs from what the code produces.
|
|
77
|
+
|
|
78
|
+
## 4,873 real experiments, audited
|
|
79
|
+
|
|
80
|
+
[`examples/upworthy/`](examples/upworthy/) runs abkit's aggregate checks over
|
|
81
|
+
the exploratory split of the [Upworthy Research Archive](https://doi.org/10.1038/s41597-021-00934-7)
|
|
82
|
+
— 4,873 real headline A/B tests from 2013–2015. Committed results:
|
|
83
|
+
[examples/upworthy/results/upworthy.md](examples/upworthy/results/upworthy.md).
|
|
84
|
+
|
|
85
|
+
- **16% of real tests fail the sample-ratio check** at p < 0.001 against the
|
|
86
|
+
platform's equal-allocation design — and not because packages were added
|
|
87
|
+
mid-test; the rate is, if anything, higher in tests whose variants all
|
|
88
|
+
launched together. Those readouts carried unknown exposure bias.
|
|
89
|
+
- **10.9% of tests have a "significant winner"** (best headline beats the
|
|
90
|
+
runner-up at raw p < 0.05), but Benjamini–Hochberg across all 4,873
|
|
91
|
+
winner comparisons keeps only **2.7%** of them.
|
|
92
|
+
- The median significant winning lift (0.85 CTR points, power 0.62) is
|
|
93
|
+
expected to **overstate its true effect by 1.27x** even when the winner is
|
|
94
|
+
real — the winner's curse, measured on real experiments.
|
|
95
|
+
|
|
96
|
+
## Install
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
pip install git+https://github.com/mohammadi-hadi/abkit
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Python 3.11+. Runtime dependencies: numpy, pydantic, matplotlib.
|
|
103
|
+
|
|
104
|
+
## Quickstart
|
|
105
|
+
|
|
106
|
+
Log one JSON object per randomized unit, and the design the experiment was
|
|
107
|
+
supposed to follow:
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{"unit_id": "u1", "arm": "treatment", "metrics": {"revenue": 12.4, "conversion": 1.0}, "pre": {"revenue": 9.1}, "t": 1718040000}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"split": {"control": 0.5, "treatment": 0.5},
|
|
116
|
+
"control": "control",
|
|
117
|
+
"treatment": "treatment",
|
|
118
|
+
"primary_metric": "revenue",
|
|
119
|
+
"alpha": 0.05,
|
|
120
|
+
"mde": 0.5,
|
|
121
|
+
"looks": [{"n": 20000, "z": 1.31}, {"n": 40000, "z": 2.11}]
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from abkit import load_units, load_design, run_audit
|
|
127
|
+
|
|
128
|
+
audit = run_audit(load_units("units.jsonl"), load_design("design.json"))
|
|
129
|
+
for flag in audit.flags:
|
|
130
|
+
print(flag.name, "--", flag.detail)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Or from the shell, with an exit code CI can gate on:
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
abkit report units.jsonl --design design.json --out audit --fail-on-flags
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Alongside `report.md` the audit writes `report.json` for pipelines that gate
|
|
140
|
+
on specific numbers. Any check that could not run is listed with the exact
|
|
141
|
+
fields to log to enable it — a skip describes the log file, not the
|
|
142
|
+
experiment.
|
|
143
|
+
|
|
144
|
+
## What it checks
|
|
145
|
+
|
|
146
|
+
| check | question it answers | needs |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| sample ratio mismatch | did the split the readout assumes actually happen? | arms + intended split |
|
|
149
|
+
| assignment contamination | is any unit in more than one arm? | unit ids |
|
|
150
|
+
| covariate balance | were the groups equivalent before the treatment? | `pre` covariates |
|
|
151
|
+
| outlier fragility | how many extreme units does significance rest on? | per-unit primary metric |
|
|
152
|
+
| uncorrected winners | do the claimed wins survive multiple-testing control? | 2+ metrics |
|
|
153
|
+
| peeking | what false-positive rate did the stopping rule really have? | `design.looks` |
|
|
154
|
+
| novelty | is the single reported effect averaging over a changing one? | `t` per unit |
|
|
155
|
+
| winner's curse | how exaggerated is a significant estimate at this power? | `design.mde` (or observed) |
|
|
156
|
+
| variance ratio | does the treatment move the distribution, not just the mean? | per-unit primary metric |
|
|
157
|
+
| CUPED headroom | how much tighter could the CI have been? | `pre` covariates |
|
|
158
|
+
|
|
159
|
+
## How the numbers are defended
|
|
160
|
+
|
|
161
|
+
- **Cross-checked implementations.** The chi-square survival function matches
|
|
162
|
+
scipy to 1e-10 across df 1–10, Welch's statistic matches
|
|
163
|
+
`scipy.stats.ttest_ind`, the two-proportion z matches statsmodels
|
|
164
|
+
`proportions_ztest`, and Benjamini–Hochberg reproduces statsmodels
|
|
165
|
+
`multipletests` decisions exactly. The library itself depends on none of
|
|
166
|
+
them.
|
|
167
|
+
- **Known closed-form results.** The Gelman–Carlin power / Type-S /
|
|
168
|
+
exaggeration triple is checked against numerical integration to 1e-6, and
|
|
169
|
+
the peeking simulator reproduces the classical repeated-significance rates
|
|
170
|
+
(0.083 at 2 looks, 0.142 at 5, 0.193 at 10) within Monte-Carlo tolerance.
|
|
171
|
+
- **Validation by implantation.** The synthetic experiments have defect
|
|
172
|
+
dials, and the tests require each probe to recover its dial — an implanted
|
|
173
|
+
0.3 covariate shift must read as SMD 0.3 — to stay monotone in it, and to
|
|
174
|
+
stay silent on the clean experiment.
|
|
175
|
+
- **Drift-checked results.** The demo table, report and README numbers are
|
|
176
|
+
regenerated by CI from pinned dependencies and diffed against the
|
|
177
|
+
committed copy.
|
|
178
|
+
|
|
179
|
+
## Design notes
|
|
180
|
+
|
|
181
|
+
- **Innocent bands, not point nulls.** With enough data an SMD of 0.02
|
|
182
|
+
excludes zero while meaning nothing. Band probes declare the interval a
|
|
183
|
+
healthy experiment could plausibly occupy — SMD and novelty gap within
|
|
184
|
+
±0.10 — and flag only when the whole 95% bootstrap CI leaves it. Exact
|
|
185
|
+
procedures use the field's conventional thresholds: SRM at p < 0.001,
|
|
186
|
+
contamination at zero tolerance. All constants live in one place
|
|
187
|
+
([`probes.py`](src/abkit/probes.py)) and are easy to disagree with.
|
|
188
|
+
- **The stopping rule is audited, not the final p.** A peeked experiment's
|
|
189
|
+
final test statistic looks ordinary; what is broken is the procedure that
|
|
190
|
+
produced it. The peeking probe Monte-Carlos the analyst's actual look
|
|
191
|
+
schedule under the null and reports the false-positive rate that schedule
|
|
192
|
+
had.
|
|
193
|
+
- **Fragility is counted in units, not sigmas.** "Removing two customers
|
|
194
|
+
overturns the decision" is a statement a stakeholder can act on; a
|
|
195
|
+
leave-one-out influence statistic is not.
|
|
196
|
+
- **Exaggeration needs a basis effect.** With `design.mde` the winner's-curse
|
|
197
|
+
probe reports power and exaggeration at the effect the experiment was
|
|
198
|
+
designed for; without it, the observed effect is used and labeled an
|
|
199
|
+
optimistic bound, since conditioning on significance inflates it.
|
|
200
|
+
- **Stratified bootstrap.** Units are resampled within their arm, never
|
|
201
|
+
across, so group sizes — which determine every statistic's sampling
|
|
202
|
+
distribution — are preserved.
|
|
203
|
+
- **Deterministic to the digit.** Seeded PCG64 everywhere, no BLAS in any
|
|
204
|
+
statistic's path, and a pinned drift environment: the same data gives the
|
|
205
|
+
same report on any machine.
|
|
206
|
+
|
|
207
|
+
## Limitations
|
|
208
|
+
|
|
209
|
+
- An audit is bounded by what was logged. No pre-period covariates, no
|
|
210
|
+
balance or CUPED checks; no look history, no peeking check; no per-unit
|
|
211
|
+
data at all, and only the aggregate checks (`abkit.stats`) apply.
|
|
212
|
+
- The innocent bands are judgment calls, not derivations.
|
|
213
|
+
- The peeking probe assumes the recorded looks are all the looks there were.
|
|
214
|
+
- Fragility uses a greedy removal path; it upper-bounds, and in adversarial
|
|
215
|
+
cases may not find, the true minimal flip set.
|
|
216
|
+
- Implantation shows the probes detect the mechanisms simulated; real
|
|
217
|
+
experiments can fail in ways not simulated here.
|
|
218
|
+
- The Upworthy analysis treats the archive's documented equal allocation as
|
|
219
|
+
the intended split; if some tests intentionally used unequal splits, their
|
|
220
|
+
SRM flags are misattributed.
|
|
221
|
+
|
|
222
|
+
## Related work
|
|
223
|
+
|
|
224
|
+
The failure modes measured here are the standard ones from the online
|
|
225
|
+
experimentation literature: sample-ratio mismatch and its taxonomy
|
|
226
|
+
([Fabijan et al., 2019](https://doi.org/10.1145/3292500.3330722)), trustworthy
|
|
227
|
+
experimentation practice ([Kohavi, Tang & Xu, 2020](https://experimentguide.com/)),
|
|
228
|
+
peeking and always-valid inference ([Johari et al., 2017](https://doi.org/10.1145/3097983.3097992)),
|
|
229
|
+
variance reduction with pre-experiment data — CUPED
|
|
230
|
+
([Deng et al., 2013](https://doi.org/10.1145/2433396.2433413)),
|
|
231
|
+
Type-M/Type-S errors ([Gelman & Carlin, 2014](https://doi.org/10.1177/1745691614551642)),
|
|
232
|
+
the winner's curse in A/B testing ([Lee & Shen, 2018](https://doi.org/10.1145/3219819.3219905)),
|
|
233
|
+
and experimentation culture at scale
|
|
234
|
+
([Kaufman, Pitchforth & Vermeer, 2017](https://arxiv.org/abs/1710.08217)).
|
|
235
|
+
The real-data case study uses the Upworthy Research Archive
|
|
236
|
+
([Matias, Munger & Watts, 2021](https://doi.org/10.1038/s41597-021-00934-7)).
|
|
237
|
+
abkit's contribution is packaging the checks as one auditable tool with
|
|
238
|
+
uncertainty on every number and validation by implantation.
|
|
239
|
+
|
|
240
|
+
Companion projects: [judgekit](https://github.com/mohammadi-hadi/judgekit)
|
|
241
|
+
applies the same audit-before-you-trust pattern to LLM judges;
|
|
242
|
+
[trajectory-judge](https://github.com/mohammadi-hadi/trajectory-judge)
|
|
243
|
+
measures what outcome-only judges miss on agent trajectories.
|
|
244
|
+
|
|
245
|
+
## Citation
|
|
246
|
+
|
|
247
|
+
If abkit is useful in your work, please cite it (see
|
|
248
|
+
[CITATION.cff](CITATION.cff)):
|
|
249
|
+
|
|
250
|
+
```bibtex
|
|
251
|
+
@software{mohammadi_abkit,
|
|
252
|
+
author = {Mohammadi, Hadi},
|
|
253
|
+
title = {abkit: audit an A/B-test readout before you ship the decision},
|
|
254
|
+
url = {https://github.com/mohammadi-hadi/abkit},
|
|
255
|
+
version = {0.1.0},
|
|
256
|
+
year = {2026}
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## License
|
|
261
|
+
|
|
262
|
+
MIT
|
abkit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# abkit
|
|
2
|
+
|
|
3
|
+
Audit an A/B-test readout before you ship the decision.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/mohammadi-hadi/abkit/actions/workflows/ci.yml)
|
|
6
|
+
|
|
7
|
+
An experiment readout can say "B wins, p = 0.03, ship it" while the traffic
|
|
8
|
+
split was broken, three whale customers carried the whole effect, the analyst
|
|
9
|
+
stopped at the first significant refresh, the winning metric was one of forty
|
|
10
|
+
tried, or the experiment never had the power to estimate what it now claims.
|
|
11
|
+
None of that shows up in the p-value. abkit reads the per-unit data and the
|
|
12
|
+
intended design, measures each of these failure modes, and flags whatever
|
|
13
|
+
leaves the range a healthy experiment could occupy.
|
|
14
|
+
|
|
15
|
+
## The audit in one table
|
|
16
|
+
|
|
17
|
+
Eight synthetic experiments, seven implanted defects. Bold values are flags;
|
|
18
|
+
each lands on the row where its defect was implanted, and the clean row
|
|
19
|
+
carries none.
|
|
20
|
+
|
|
21
|
+
<!-- abkit:demo -->
|
|
22
|
+
| experiment | implanted defect | SRM p | contam. | fragility | look FPR | novelty gap | uncorrected | exagg. | flags |
|
|
23
|
+
|---|---|---|---|---|---|---|---|---|---|
|
|
24
|
+
| clean | nothing | 0.89 | 0 | 20 | - | -0.02 | - | 1.0 | none |
|
|
25
|
+
| traffic-leak | 3% of traffic diverted to treatment | **4.1e-14** | 0 | 20 | - | -0.02 | - | 1.0 | sample ratio mismatch |
|
|
26
|
+
| double-dipper | 25 units assigned to both arms | 0.67 | **25** | 20 | - | -0.03 | - | 1.0 | assignment contamination |
|
|
27
|
+
| whale-driven | 3 extreme units carry the significance | 0.11 | 0 | **1** | - | 0.26 | - | 1.2 | outlier fragility |
|
|
28
|
+
| peeker | null effect, stopped at the first significant look | 0.55 | 0 | 8 | **0.19** | -0.17 | - | 1.2 | peeking |
|
|
29
|
+
| fading-novelty | early-half effect 0.30, late-half effect 0.00 | 0.041 | 0 | 20 | - | **0.31** | - | 1.0 | novelty |
|
|
30
|
+
| metric-fisher | null effect, 40 secondary metrics tested | 0.054 | 0 | 0 | - | -0.11 | **2** | 3.4 | uncorrected winners |
|
|
31
|
+
| underpowered-winner | true effect 0.1, powered for 0.14 of that chance | 0.91 | 0 | 8 | - | -0.38 | - | **2.7** | winner's curse |
|
|
32
|
+
<!-- /abkit:demo -->
|
|
33
|
+
|
|
34
|
+
The row to sit with is `peeker`: a true null effect, analyzed after every
|
|
35
|
+
batch of 250 units and stopped the moment z crossed 1.96. Its final readout
|
|
36
|
+
is indistinguishable from a real win — p < 0.05, decent sample size — but the
|
|
37
|
+
schedule it followed has a 19% false-positive rate, not 5%. The audit flags
|
|
38
|
+
the process, which is the only place that defect lives.
|
|
39
|
+
|
|
40
|
+

|
|
41
|
+
|
|
42
|
+

|
|
43
|
+
|
|
44
|
+
`make demo` regenerates the table, the [full report](results/report.md) and
|
|
45
|
+
the figures from fixed seeds; CI rebuilds them from pinned dependencies and
|
|
46
|
+
fails if a committed number differs from what the code produces.
|
|
47
|
+
|
|
48
|
+
## 4,873 real experiments, audited
|
|
49
|
+
|
|
50
|
+
[`examples/upworthy/`](examples/upworthy/) runs abkit's aggregate checks over
|
|
51
|
+
the exploratory split of the [Upworthy Research Archive](https://doi.org/10.1038/s41597-021-00934-7)
|
|
52
|
+
— 4,873 real headline A/B tests from 2013–2015. Committed results:
|
|
53
|
+
[examples/upworthy/results/upworthy.md](examples/upworthy/results/upworthy.md).
|
|
54
|
+
|
|
55
|
+
- **16% of real tests fail the sample-ratio check** at p < 0.001 against the
|
|
56
|
+
platform's equal-allocation design — and not because packages were added
|
|
57
|
+
mid-test; the rate is, if anything, higher in tests whose variants all
|
|
58
|
+
launched together. Those readouts carried unknown exposure bias.
|
|
59
|
+
- **10.9% of tests have a "significant winner"** (best headline beats the
|
|
60
|
+
runner-up at raw p < 0.05), but Benjamini–Hochberg across all 4,873
|
|
61
|
+
winner comparisons keeps only **2.7%** of them.
|
|
62
|
+
- The median significant winning lift (0.85 CTR points, power 0.62) is
|
|
63
|
+
expected to **overstate its true effect by 1.27x** even when the winner is
|
|
64
|
+
real — the winner's curse, measured on real experiments.
|
|
65
|
+
|
|
66
|
+
## Install
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
pip install git+https://github.com/mohammadi-hadi/abkit
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Python 3.11+. Runtime dependencies: numpy, pydantic, matplotlib.
|
|
73
|
+
|
|
74
|
+
## Quickstart
|
|
75
|
+
|
|
76
|
+
Log one JSON object per randomized unit, and the design the experiment was
|
|
77
|
+
supposed to follow:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{"unit_id": "u1", "arm": "treatment", "metrics": {"revenue": 12.4, "conversion": 1.0}, "pre": {"revenue": 9.1}, "t": 1718040000}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"split": {"control": 0.5, "treatment": 0.5},
|
|
86
|
+
"control": "control",
|
|
87
|
+
"treatment": "treatment",
|
|
88
|
+
"primary_metric": "revenue",
|
|
89
|
+
"alpha": 0.05,
|
|
90
|
+
"mde": 0.5,
|
|
91
|
+
"looks": [{"n": 20000, "z": 1.31}, {"n": 40000, "z": 2.11}]
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from abkit import load_units, load_design, run_audit
|
|
97
|
+
|
|
98
|
+
audit = run_audit(load_units("units.jsonl"), load_design("design.json"))
|
|
99
|
+
for flag in audit.flags:
|
|
100
|
+
print(flag.name, "--", flag.detail)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Or from the shell, with an exit code CI can gate on:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
abkit report units.jsonl --design design.json --out audit --fail-on-flags
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Alongside `report.md` the audit writes `report.json` for pipelines that gate
|
|
110
|
+
on specific numbers. Any check that could not run is listed with the exact
|
|
111
|
+
fields to log to enable it — a skip describes the log file, not the
|
|
112
|
+
experiment.
|
|
113
|
+
|
|
114
|
+
## What it checks
|
|
115
|
+
|
|
116
|
+
| check | question it answers | needs |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| sample ratio mismatch | did the split the readout assumes actually happen? | arms + intended split |
|
|
119
|
+
| assignment contamination | is any unit in more than one arm? | unit ids |
|
|
120
|
+
| covariate balance | were the groups equivalent before the treatment? | `pre` covariates |
|
|
121
|
+
| outlier fragility | how many extreme units does significance rest on? | per-unit primary metric |
|
|
122
|
+
| uncorrected winners | do the claimed wins survive multiple-testing control? | 2+ metrics |
|
|
123
|
+
| peeking | what false-positive rate did the stopping rule really have? | `design.looks` |
|
|
124
|
+
| novelty | is the single reported effect averaging over a changing one? | `t` per unit |
|
|
125
|
+
| winner's curse | how exaggerated is a significant estimate at this power? | `design.mde` (or observed) |
|
|
126
|
+
| variance ratio | does the treatment move the distribution, not just the mean? | per-unit primary metric |
|
|
127
|
+
| CUPED headroom | how much tighter could the CI have been? | `pre` covariates |
|
|
128
|
+
|
|
129
|
+
## How the numbers are defended
|
|
130
|
+
|
|
131
|
+
- **Cross-checked implementations.** The chi-square survival function matches
|
|
132
|
+
scipy to 1e-10 across df 1–10, Welch's statistic matches
|
|
133
|
+
`scipy.stats.ttest_ind`, the two-proportion z matches statsmodels
|
|
134
|
+
`proportions_ztest`, and Benjamini–Hochberg reproduces statsmodels
|
|
135
|
+
`multipletests` decisions exactly. The library itself depends on none of
|
|
136
|
+
them.
|
|
137
|
+
- **Known closed-form results.** The Gelman–Carlin power / Type-S /
|
|
138
|
+
exaggeration triple is checked against numerical integration to 1e-6, and
|
|
139
|
+
the peeking simulator reproduces the classical repeated-significance rates
|
|
140
|
+
(0.083 at 2 looks, 0.142 at 5, 0.193 at 10) within Monte-Carlo tolerance.
|
|
141
|
+
- **Validation by implantation.** The synthetic experiments have defect
|
|
142
|
+
dials, and the tests require each probe to recover its dial — an implanted
|
|
143
|
+
0.3 covariate shift must read as SMD 0.3 — to stay monotone in it, and to
|
|
144
|
+
stay silent on the clean experiment.
|
|
145
|
+
- **Drift-checked results.** The demo table, report and README numbers are
|
|
146
|
+
regenerated by CI from pinned dependencies and diffed against the
|
|
147
|
+
committed copy.
|
|
148
|
+
|
|
149
|
+
## Design notes
|
|
150
|
+
|
|
151
|
+
- **Innocent bands, not point nulls.** With enough data an SMD of 0.02
|
|
152
|
+
excludes zero while meaning nothing. Band probes declare the interval a
|
|
153
|
+
healthy experiment could plausibly occupy — SMD and novelty gap within
|
|
154
|
+
±0.10 — and flag only when the whole 95% bootstrap CI leaves it. Exact
|
|
155
|
+
procedures use the field's conventional thresholds: SRM at p < 0.001,
|
|
156
|
+
contamination at zero tolerance. All constants live in one place
|
|
157
|
+
([`probes.py`](src/abkit/probes.py)) and are easy to disagree with.
|
|
158
|
+
- **The stopping rule is audited, not the final p.** A peeked experiment's
|
|
159
|
+
final test statistic looks ordinary; what is broken is the procedure that
|
|
160
|
+
produced it. The peeking probe Monte-Carlos the analyst's actual look
|
|
161
|
+
schedule under the null and reports the false-positive rate that schedule
|
|
162
|
+
had.
|
|
163
|
+
- **Fragility is counted in units, not sigmas.** "Removing two customers
|
|
164
|
+
overturns the decision" is a statement a stakeholder can act on; a
|
|
165
|
+
leave-one-out influence statistic is not.
|
|
166
|
+
- **Exaggeration needs a basis effect.** With `design.mde` the winner's-curse
|
|
167
|
+
probe reports power and exaggeration at the effect the experiment was
|
|
168
|
+
designed for; without it, the observed effect is used and labeled an
|
|
169
|
+
optimistic bound, since conditioning on significance inflates it.
|
|
170
|
+
- **Stratified bootstrap.** Units are resampled within their arm, never
|
|
171
|
+
across, so group sizes — which determine every statistic's sampling
|
|
172
|
+
distribution — are preserved.
|
|
173
|
+
- **Deterministic to the digit.** Seeded PCG64 everywhere, no BLAS in any
|
|
174
|
+
statistic's path, and a pinned drift environment: the same data gives the
|
|
175
|
+
same report on any machine.
|
|
176
|
+
|
|
177
|
+
## Limitations
|
|
178
|
+
|
|
179
|
+
- An audit is bounded by what was logged. No pre-period covariates, no
|
|
180
|
+
balance or CUPED checks; no look history, no peeking check; no per-unit
|
|
181
|
+
data at all, and only the aggregate checks (`abkit.stats`) apply.
|
|
182
|
+
- The innocent bands are judgment calls, not derivations.
|
|
183
|
+
- The peeking probe assumes the recorded looks are all the looks there were.
|
|
184
|
+
- Fragility uses a greedy removal path; it upper-bounds, and in adversarial
|
|
185
|
+
cases may not find, the true minimal flip set.
|
|
186
|
+
- Implantation shows the probes detect the mechanisms simulated; real
|
|
187
|
+
experiments can fail in ways not simulated here.
|
|
188
|
+
- The Upworthy analysis treats the archive's documented equal allocation as
|
|
189
|
+
the intended split; if some tests intentionally used unequal splits, their
|
|
190
|
+
SRM flags are misattributed.
|
|
191
|
+
|
|
192
|
+
## Related work
|
|
193
|
+
|
|
194
|
+
The failure modes measured here are the standard ones from the online
|
|
195
|
+
experimentation literature: sample-ratio mismatch and its taxonomy
|
|
196
|
+
([Fabijan et al., 2019](https://doi.org/10.1145/3292500.3330722)), trustworthy
|
|
197
|
+
experimentation practice ([Kohavi, Tang & Xu, 2020](https://experimentguide.com/)),
|
|
198
|
+
peeking and always-valid inference ([Johari et al., 2017](https://doi.org/10.1145/3097983.3097992)),
|
|
199
|
+
variance reduction with pre-experiment data — CUPED
|
|
200
|
+
([Deng et al., 2013](https://doi.org/10.1145/2433396.2433413)),
|
|
201
|
+
Type-M/Type-S errors ([Gelman & Carlin, 2014](https://doi.org/10.1177/1745691614551642)),
|
|
202
|
+
the winner's curse in A/B testing ([Lee & Shen, 2018](https://doi.org/10.1145/3219819.3219905)),
|
|
203
|
+
and experimentation culture at scale
|
|
204
|
+
([Kaufman, Pitchforth & Vermeer, 2017](https://arxiv.org/abs/1710.08217)).
|
|
205
|
+
The real-data case study uses the Upworthy Research Archive
|
|
206
|
+
([Matias, Munger & Watts, 2021](https://doi.org/10.1038/s41597-021-00934-7)).
|
|
207
|
+
abkit's contribution is packaging the checks as one auditable tool with
|
|
208
|
+
uncertainty on every number and validation by implantation.
|
|
209
|
+
|
|
210
|
+
Companion projects: [judgekit](https://github.com/mohammadi-hadi/judgekit)
|
|
211
|
+
applies the same audit-before-you-trust pattern to LLM judges;
|
|
212
|
+
[trajectory-judge](https://github.com/mohammadi-hadi/trajectory-judge)
|
|
213
|
+
measures what outcome-only judges miss on agent trajectories.
|
|
214
|
+
|
|
215
|
+
## Citation
|
|
216
|
+
|
|
217
|
+
If abkit is useful in your work, please cite it (see
|
|
218
|
+
[CITATION.cff](CITATION.cff)):
|
|
219
|
+
|
|
220
|
+
```bibtex
|
|
221
|
+
@software{mohammadi_abkit,
|
|
222
|
+
author = {Mohammadi, Hadi},
|
|
223
|
+
title = {abkit: audit an A/B-test readout before you ship the decision},
|
|
224
|
+
url = {https://github.com/mohammadi-hadi/abkit},
|
|
225
|
+
version = {0.1.0},
|
|
226
|
+
year = {2026}
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## License
|
|
231
|
+
|
|
232
|
+
MIT
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "abkit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Audit an A/B-test readout before you ship the decision"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
authors = [{name = "Hadi Mohammadi"}]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"numpy>=1.26",
|
|
15
|
+
"pydantic>=2.5",
|
|
16
|
+
"matplotlib>=3.8",
|
|
17
|
+
]
|
|
18
|
+
keywords = ["ab-testing", "experimentation", "srm", "statistics", "audit"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Intended Audience :: Science/Research",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Scientific/Engineering :: Information Analysis",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/mohammadi-hadi/abkit"
|
|
31
|
+
Issues = "https://github.com/mohammadi-hadi/abkit/issues"
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
abkit = "abkit.cli:main"
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
dev = ["pytest>=8", "ruff>=0.4", "mypy>=1.10", "scipy>=1.12", "statsmodels>=0.14"]
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.package-data]
|
|
40
|
+
abkit = ["py.typed"]
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 100
|
|
44
|
+
target-version = "py311"
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
48
|
+
|
|
49
|
+
[tool.mypy]
|
|
50
|
+
disallow_untyped_defs = true
|
|
51
|
+
warn_return_any = true
|
|
52
|
+
warn_unused_ignores = true
|
|
53
|
+
no_implicit_optional = true
|
|
54
|
+
|
|
55
|
+
[[tool.mypy.overrides]]
|
|
56
|
+
module = "matplotlib.*"
|
|
57
|
+
ignore_missing_imports = true
|
|
58
|
+
|
|
59
|
+
[tool.pytest.ini_options]
|
|
60
|
+
testpaths = ["tests"]
|
abkit-0.1.0/setup.cfg
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
|
+
]
|
|
@@ -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
|