abfactor 0.5.1__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.
- abfactor-0.5.1/LICENSE +21 -0
- abfactor-0.5.1/PKG-INFO +250 -0
- abfactor-0.5.1/README.md +218 -0
- abfactor-0.5.1/pyproject.toml +48 -0
- abfactor-0.5.1/setup.cfg +4 -0
- abfactor-0.5.1/src/abfactor/__init__.py +27 -0
- abfactor-0.5.1/src/abfactor/aa.py +75 -0
- abfactor-0.5.1/src/abfactor/analysis.py +455 -0
- abfactor-0.5.1/src/abfactor/anova.py +197 -0
- abfactor-0.5.1/src/abfactor/baselines.py +142 -0
- abfactor-0.5.1/src/abfactor/brease.py +349 -0
- abfactor-0.5.1/src/abfactor/design.py +168 -0
- abfactor-0.5.1/src/abfactor/metrics.py +319 -0
- abfactor-0.5.1/src/abfactor/pooling.py +87 -0
- abfactor-0.5.1/src/abfactor/priors.py +88 -0
- abfactor-0.5.1/src/abfactor/results.py +163 -0
- abfactor-0.5.1/src/abfactor/sequential.py +259 -0
- abfactor-0.5.1/src/abfactor/spec.py +66 -0
- abfactor-0.5.1/src/abfactor/stats.py +209 -0
- abfactor-0.5.1/src/abfactor.egg-info/PKG-INFO +250 -0
- abfactor-0.5.1/src/abfactor.egg-info/SOURCES.txt +30 -0
- abfactor-0.5.1/src/abfactor.egg-info/dependency_links.txt +1 -0
- abfactor-0.5.1/src/abfactor.egg-info/requires.txt +12 -0
- abfactor-0.5.1/src/abfactor.egg-info/top_level.txt +1 -0
- abfactor-0.5.1/tests/test_aa.py +63 -0
- abfactor-0.5.1/tests/test_accuracy.py +65 -0
- abfactor-0.5.1/tests/test_analysis.py +252 -0
- abfactor-0.5.1/tests/test_anova.py +54 -0
- abfactor-0.5.1/tests/test_brease.py +37 -0
- abfactor-0.5.1/tests/test_results.py +83 -0
- abfactor-0.5.1/tests/test_sequential.py +107 -0
- abfactor-0.5.1/tests/test_stats.py +67 -0
abfactor-0.5.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thomas Kealy
|
|
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.
|
abfactor-0.5.1/PKG-INFO
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: abfactor
|
|
3
|
+
Version: 0.5.1
|
|
4
|
+
Summary: Bayesian A/B testing models with Bayes factors: informed priors, default (Rouder) priors, BREASE, pooling across experiments, sequential and pre-period designs.
|
|
5
|
+
Author: Thomas Kealy
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/TomKealy/abfactor
|
|
8
|
+
Project-URL: Changelog, https://github.com/TomKealy/abfactor/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/TomKealy/abfactor/issues
|
|
10
|
+
Keywords: a/b testing,bayes factor,experimentation,bayesian,pymc,sequential testing
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: numpy>=2.1
|
|
22
|
+
Requires-Dist: scipy>=1.14
|
|
23
|
+
Requires-Dist: pandas>=2.2
|
|
24
|
+
Requires-Dist: xarray>=2025.1
|
|
25
|
+
Requires-Dist: pymc>=6.0
|
|
26
|
+
Requires-Dist: arviz>=1.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
29
|
+
Provides-Extra: docs
|
|
30
|
+
Requires-Dist: pdoc>=15; extra == "docs"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# abfactor
|
|
34
|
+
|
|
35
|
+
Bayesian A/B testing with Bayes factors, built on the aggregated data experimentation
|
|
36
|
+
platforms already produce, and checked by simulation.
|
|
37
|
+
|
|
38
|
+
- **Bayes factors, not just P(B > A).** Evidence for *and against* "no effect", directional
|
|
39
|
+
tests, and exact pairwise comparisons in multi-arm tests.
|
|
40
|
+
- **Default priors that suit online experiments.** The fixed-effects construction of
|
|
41
|
+
Rouder et al. (2012), with the prior scale on the model scale (log odds, log rate) so tiny
|
|
42
|
+
real effects aren't swamped by Lindley's paradox.
|
|
43
|
+
- **Experiments that learn from each other.** A shared effect scale and a spike-and-slab
|
|
44
|
+
across a batch, with each experiment's Bayes factor computed given the rest.
|
|
45
|
+
- **Aggregated data done properly.** Per-user squares and cross products give dispersion
|
|
46
|
+
for counts, real variances for per-user means and delta-method SEs for ratios.
|
|
47
|
+
- **Time and pre-periods.** One-off conversions as a discrete-time hazard on the risk set,
|
|
48
|
+
A/A periods that catch broken buckets, CUPED and difference-in-differences.
|
|
49
|
+
- **Sequential monitoring** that keeps its false-positive guarantee.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install -e ".[dev]"
|
|
55
|
+
pytest # fast checks, ~25 s
|
|
56
|
+
pytest -m slow # also checks the quadrature engine against NUTS and SMC
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## How it fits together
|
|
60
|
+
|
|
61
|
+
An analysis is four choices (see [`docs/design.md`](docs/design.md)):
|
|
62
|
+
|
|
63
|
+
| Piece | Options |
|
|
64
|
+
|---|---|
|
|
65
|
+
| **Metric** | `Proportion`, `TimeToEvent(link="logit" \| "cloglog")`, `Count(dispersion=...)`, `Mean(adjust=PrePeriod(...))`, `Ratio(num, den)` |
|
|
66
|
+
| **Prior** | `DefaultFixedEffects(r)`, `NormalEffects(sd)`, `LearnedScale` (from completed experiments), `Brease(...)` |
|
|
67
|
+
| **Pooling** | `Independent()`, `SharedScale()`, `SpikeAndSlab()` |
|
|
68
|
+
| **Engine** | `"quadrature"` (default, fast, exact on the Gaussian reduction), `"nuts"`, `"smc"` (exact likelihoods) |
|
|
69
|
+
|
|
70
|
+
Data come in as `Stats`: sufficient statistics with dimensions experiment × arm × period
|
|
71
|
+
(`n`, `x`, `x__sq`, `x__x__y`: plain column names any warehouse accepts), built from
|
|
72
|
+
user rows, event times, a long frame of query output, or arrays.
|
|
73
|
+
|
|
74
|
+
## Quick start
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from abfactor import Analysis, Stats, Proportion, DefaultFixedEffects
|
|
78
|
+
|
|
79
|
+
stats = Stats.from_arrays(arms=["control", "b", "c"], n=[[20000, 20000, 20000]],
|
|
80
|
+
converted=[[1061, 1102, 1131]])
|
|
81
|
+
result = Analysis(Proportion("converted"), baseline=0.05, prior=DefaultFixedEffects(r=0.1)).fit(stats)
|
|
82
|
+
|
|
83
|
+
result.omnibus # log BF10: does any arm differ?
|
|
84
|
+
result.pairwise # each variant vs control: BF10, BF+0, P(better), contrast
|
|
85
|
+
result.summary() # plus the relative lift with a 90% interval
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
From user-level data, with a ratio metric:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from abfactor import Ratio, NormalEffects
|
|
92
|
+
|
|
93
|
+
stats = Stats.from_users(users, metrics={"revenue": "revenue", "orders": "boxes"}, control="control")
|
|
94
|
+
aov = Analysis(Ratio("revenue", "orders"), baseline=50, prior=NormalEffects(5)).fit(stats)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A batch of experiments that learn from each other:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from abfactor import SpikeAndSlab
|
|
101
|
+
|
|
102
|
+
result = Analysis(Proportion("converted"), 0.05, prior=DefaultFixedEffects(0.5),
|
|
103
|
+
pooling=SpikeAndSlab()).fit(batch)
|
|
104
|
+
result.omnibus["P_effect"] # each experiment's probability of having any effect
|
|
105
|
+
result.pooling["w_mean"] # share of experiments with effects
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
One-off conversions with an A/A period before the treatment, guarding against broken buckets:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from abfactor import TimeToEvent
|
|
112
|
+
|
|
113
|
+
stats = Stats.from_events(users, event_period="converted_period", n_periods=2, control="control")
|
|
114
|
+
result = Analysis(TimeToEvent("conversion"), 0.05, treatment_start=1, offsets="free").fit(stats)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Straight from a warehouse query, one row per experiment and arm:
|
|
118
|
+
|
|
119
|
+
```sql
|
|
120
|
+
SELECT experiment_id, variation,
|
|
121
|
+
COUNT(*) AS n,
|
|
122
|
+
SUM(revenue) AS revenue, SUM(revenue * revenue) AS revenue__sq,
|
|
123
|
+
SUM(orders) AS orders, SUM(orders * orders) AS orders__sq,
|
|
124
|
+
SUM(revenue * orders) AS revenue__x__orders
|
|
125
|
+
FROM user_level GROUP BY 1, 2
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
stats = Stats.from_frame(query_result, control="control", experiment="experiment_id", arm="variation")
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Sequential monitoring, pooling across *completed* experiments only:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
from abfactor import Monitor
|
|
136
|
+
|
|
137
|
+
prior = Analysis(Proportion("converted"), 0.05, prior=DefaultFixedEffects(0.5)).learn_prior(completed)
|
|
138
|
+
monitor = Monitor(Analysis(Proportion("converted"), 0.05, prior=prior), threshold=20)
|
|
139
|
+
monitor.update(stats_today) # cumulative counts; stops the first time the evidence reaches 20
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
For conversion rates the monitor uses an exact e-process (`method="exact"`): the
|
|
143
|
+
false-positive rate stays below 1/threshold however often you look and whatever the
|
|
144
|
+
baseline. Other metrics use the Bayes factor (`method="bayes"`), checked by simulation.
|
|
145
|
+
See [`docs/sequential.md`](docs/sequential.md).
|
|
146
|
+
|
|
147
|
+
## Noise between identical arms
|
|
148
|
+
|
|
149
|
+
In real experiments identical arms often differ by more than sampling noise allows. In
|
|
150
|
+
the Upworthy headline archive the excess was about 13% of the click-through rate, and
|
|
151
|
+
ignoring it made one A/A test in 16–40 show strong evidence of an effect
|
|
152
|
+
([`docs/real_data.md`](docs/real_data.md)). Measure it from your own A/A tests:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from abfactor.aa import estimate_arm_sd, aa_check
|
|
156
|
+
|
|
157
|
+
est = estimate_arm_sd(Proportion("converted"), aa_stats) # SD on the model scale, with a 95% interval
|
|
158
|
+
analysis = Analysis(Proportion("converted"), 0.05, arm_sd=est["arm_sd"])
|
|
159
|
+
aa_check(analysis, aa_stats) # share of A/A tests with BF10 > 3, > 20, < 1/3
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Saving and reproducing results
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
result.to_netcdf("checkout_test.nc") # draws, Bayes factors, settings, package versions
|
|
166
|
+
result = Result.from_netcdf("checkout_test.nc")
|
|
167
|
+
result.to_frame() # flat table: one row per experiment and variant
|
|
168
|
+
|
|
169
|
+
Analysis.from_settings(result.settings).fit(stats) # recomputes the same draws exactly
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Small samples
|
|
173
|
+
|
|
174
|
+
The default engine approximates each metric's likelihood by a normal on its model scale.
|
|
175
|
+
Measured against exact integration ([`docs/accuracy.md`](docs/accuracy.md)), it never
|
|
176
|
+
overstated the evidence for an effect, but:
|
|
177
|
+
|
|
178
|
+
- with fewer than 5 conversions (or non-conversions) in an arm, intervals can be far too
|
|
179
|
+
narrow: `fit` raises a `SmallSampleWarning`, and `engine="smc"` or `"nuts"` should be used;
|
|
180
|
+
- with 5 to 49, Bayes factors can understate the evidence by up to a factor of about 2.3
|
|
181
|
+
near the decision thresholds: the experiment is listed in
|
|
182
|
+
`result.diagnostics["small_sample"]["conservative_bf"]`, so borderline calls can be
|
|
183
|
+
confirmed with `engine="smc"`.
|
|
184
|
+
|
|
185
|
+
## Choosing the prior scale
|
|
186
|
+
|
|
187
|
+
Bayes factors depend on how big the alternative expects effects to be. A 10% lift on a
|
|
188
|
+
5% conversion rate is a log odds ratio of about 0.1. With `r = 0.5` (Rouder et al.'s
|
|
189
|
+
standardised default) the alternative bets on effects around 0.7 and the null wins almost
|
|
190
|
+
regardless of the data. Use `r` around 0.1 for conversion rates, or pool across experiments
|
|
191
|
+
and let the batch learn the scale.
|
|
192
|
+
|
|
193
|
+
## What the simulations show
|
|
194
|
+
|
|
195
|
+
From `python harness/harness.py --quick` (60 experiments per scenario; full results in
|
|
196
|
+
`harness/results/`):
|
|
197
|
+
|
|
198
|
+
- **Sequential monitoring:** stopping at BF10 ≥ 20 gave 0.8% false positives over 30 looks
|
|
199
|
+
(the guarantee is 5%), against 33% for peeking at a z-test.
|
|
200
|
+
- **Overdispersed counts:** a Poisson model gave false positives of 22% in one tail; the
|
|
201
|
+
quasi-Poisson correction and the per-user mean brought it back to 3–12%.
|
|
202
|
+
- **AOV:** treating orders as independent gave intervals that held the true lift 62–68% of
|
|
203
|
+
the time; the delta method 85–92%.
|
|
204
|
+
- **Broken buckets:** with an A/A period, modelling arm offsets cut false positives from
|
|
205
|
+
62% to 2–5%.
|
|
206
|
+
- **CUPED:** at a pre/post correlation of 0.62 it cut the SD of the lift by 20% (theory:
|
|
207
|
+
21%), with level intervals now calibrated too (89–91% coverage at 200 experiments).
|
|
208
|
+
|
|
209
|
+
## Known limitations
|
|
210
|
+
|
|
211
|
+
- The exact sequential guarantee covers conversion rates with binomial arms. For other
|
|
212
|
+
metrics, and with `arm_sd`, the Bayes-factor monitor's error rate is checked by
|
|
213
|
+
simulation, not proven.
|
|
214
|
+
- The quadrature engine's normal approximation is miscalibrated for the control level when
|
|
215
|
+
arms have few events (about 10–50); contrasts hold up better
|
|
216
|
+
([`docs/calibration.md`](docs/calibration.md)).
|
|
217
|
+
- Learned arm offsets (`offsets="shrunk"`) need `engine="nuts"`; spike-and-slab under NUTS
|
|
218
|
+
gives P(effect) but not pairwise Bayes factors.
|
|
219
|
+
- `Ratio` has no pre-period adjustment yet.
|
|
220
|
+
- Small-sample thresholds were measured for binary outcomes only (see `docs/accuracy.md`).
|
|
221
|
+
|
|
222
|
+
## Also included
|
|
223
|
+
|
|
224
|
+
- `abfactor.brease`: the BREASE framework (Irons & Cinelli 2023) in full; also usable as
|
|
225
|
+
`prior=Brease(...)` for a two-arm binary experiment.
|
|
226
|
+
- `abfactor.anova`: Rouder et al.'s standardised-effects ANOVA Bayes factors, for reference.
|
|
227
|
+
- `abfactor.baselines`: independent-beta and abtest-style logit-transform Bayes factors.
|
|
228
|
+
- `examples/`: a tour of the API (`quickstart.py`) and reproductions of the BREASE and abtest papers.
|
|
229
|
+
- `harness/sbc.py`: simulation-based calibration of every engine and metric
|
|
230
|
+
([`docs/calibration.md`](docs/calibration.md)); `harness/upworthy.py`: the real-data checks.
|
|
231
|
+
- `docs/posts/`: three posts explaining the methods with plain PyMC, each ending with the
|
|
232
|
+
same analysis in abfactor.
|
|
233
|
+
|
|
234
|
+
## Licence, stability and roadmap
|
|
235
|
+
|
|
236
|
+
MIT licensed (`LICENSE`). What counts as public API, and what 1.0 will promise about it, is in
|
|
237
|
+
[`docs/api.md`](docs/api.md); the API reference is built from the docstrings (`pip install -e ".[docs]"`,
|
|
238
|
+
then `pdoc abfactor`). The remaining work before 1.0 is in [`docs/roadmap.md`](docs/roadmap.md).
|
|
239
|
+
|
|
240
|
+
## References
|
|
241
|
+
|
|
242
|
+
- Rouder, Morey, Speckman & Province (2012). Default Bayes factors for ANOVA designs. *Journal of Mathematical Psychology* 56(5).
|
|
243
|
+
- Rouder, Speckman, Sun, Morey & Iverson (2009). Bayesian t tests for accepting and rejecting the null hypothesis. *Psychonomic Bulletin & Review* 16(2).
|
|
244
|
+
- Irons & Cinelli (2023). Causally sound priors for binary experiments. arXiv.
|
|
245
|
+
- Gronau, Raj K. N. & Wagenmakers (2021). Informed Bayesian inference for the A/B test. *Journal of Statistical Software* 100(17).
|
|
246
|
+
- Deng, Knoblich & Lu (2018). Applying the delta method in metric analytics. *KDD*.
|
|
247
|
+
- Wedderburn (1974). Quasi-likelihood functions. *Biometrika* 61(3).
|
|
248
|
+
- Dickey & Lientz (1970); Wagenmakers et al. (2010): the Savage–Dickey density ratio.
|
|
249
|
+
|
|
250
|
+
Full bibliography: `docs/references.bib`.
|
abfactor-0.5.1/README.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# abfactor
|
|
2
|
+
|
|
3
|
+
Bayesian A/B testing with Bayes factors, built on the aggregated data experimentation
|
|
4
|
+
platforms already produce, and checked by simulation.
|
|
5
|
+
|
|
6
|
+
- **Bayes factors, not just P(B > A).** Evidence for *and against* "no effect", directional
|
|
7
|
+
tests, and exact pairwise comparisons in multi-arm tests.
|
|
8
|
+
- **Default priors that suit online experiments.** The fixed-effects construction of
|
|
9
|
+
Rouder et al. (2012), with the prior scale on the model scale (log odds, log rate) so tiny
|
|
10
|
+
real effects aren't swamped by Lindley's paradox.
|
|
11
|
+
- **Experiments that learn from each other.** A shared effect scale and a spike-and-slab
|
|
12
|
+
across a batch, with each experiment's Bayes factor computed given the rest.
|
|
13
|
+
- **Aggregated data done properly.** Per-user squares and cross products give dispersion
|
|
14
|
+
for counts, real variances for per-user means and delta-method SEs for ratios.
|
|
15
|
+
- **Time and pre-periods.** One-off conversions as a discrete-time hazard on the risk set,
|
|
16
|
+
A/A periods that catch broken buckets, CUPED and difference-in-differences.
|
|
17
|
+
- **Sequential monitoring** that keeps its false-positive guarantee.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install -e ".[dev]"
|
|
23
|
+
pytest # fast checks, ~25 s
|
|
24
|
+
pytest -m slow # also checks the quadrature engine against NUTS and SMC
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## How it fits together
|
|
28
|
+
|
|
29
|
+
An analysis is four choices (see [`docs/design.md`](docs/design.md)):
|
|
30
|
+
|
|
31
|
+
| Piece | Options |
|
|
32
|
+
|---|---|
|
|
33
|
+
| **Metric** | `Proportion`, `TimeToEvent(link="logit" \| "cloglog")`, `Count(dispersion=...)`, `Mean(adjust=PrePeriod(...))`, `Ratio(num, den)` |
|
|
34
|
+
| **Prior** | `DefaultFixedEffects(r)`, `NormalEffects(sd)`, `LearnedScale` (from completed experiments), `Brease(...)` |
|
|
35
|
+
| **Pooling** | `Independent()`, `SharedScale()`, `SpikeAndSlab()` |
|
|
36
|
+
| **Engine** | `"quadrature"` (default, fast, exact on the Gaussian reduction), `"nuts"`, `"smc"` (exact likelihoods) |
|
|
37
|
+
|
|
38
|
+
Data come in as `Stats`: sufficient statistics with dimensions experiment × arm × period
|
|
39
|
+
(`n`, `x`, `x__sq`, `x__x__y`: plain column names any warehouse accepts), built from
|
|
40
|
+
user rows, event times, a long frame of query output, or arrays.
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from abfactor import Analysis, Stats, Proportion, DefaultFixedEffects
|
|
46
|
+
|
|
47
|
+
stats = Stats.from_arrays(arms=["control", "b", "c"], n=[[20000, 20000, 20000]],
|
|
48
|
+
converted=[[1061, 1102, 1131]])
|
|
49
|
+
result = Analysis(Proportion("converted"), baseline=0.05, prior=DefaultFixedEffects(r=0.1)).fit(stats)
|
|
50
|
+
|
|
51
|
+
result.omnibus # log BF10: does any arm differ?
|
|
52
|
+
result.pairwise # each variant vs control: BF10, BF+0, P(better), contrast
|
|
53
|
+
result.summary() # plus the relative lift with a 90% interval
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
From user-level data, with a ratio metric:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from abfactor import Ratio, NormalEffects
|
|
60
|
+
|
|
61
|
+
stats = Stats.from_users(users, metrics={"revenue": "revenue", "orders": "boxes"}, control="control")
|
|
62
|
+
aov = Analysis(Ratio("revenue", "orders"), baseline=50, prior=NormalEffects(5)).fit(stats)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A batch of experiments that learn from each other:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from abfactor import SpikeAndSlab
|
|
69
|
+
|
|
70
|
+
result = Analysis(Proportion("converted"), 0.05, prior=DefaultFixedEffects(0.5),
|
|
71
|
+
pooling=SpikeAndSlab()).fit(batch)
|
|
72
|
+
result.omnibus["P_effect"] # each experiment's probability of having any effect
|
|
73
|
+
result.pooling["w_mean"] # share of experiments with effects
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
One-off conversions with an A/A period before the treatment, guarding against broken buckets:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from abfactor import TimeToEvent
|
|
80
|
+
|
|
81
|
+
stats = Stats.from_events(users, event_period="converted_period", n_periods=2, control="control")
|
|
82
|
+
result = Analysis(TimeToEvent("conversion"), 0.05, treatment_start=1, offsets="free").fit(stats)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Straight from a warehouse query, one row per experiment and arm:
|
|
86
|
+
|
|
87
|
+
```sql
|
|
88
|
+
SELECT experiment_id, variation,
|
|
89
|
+
COUNT(*) AS n,
|
|
90
|
+
SUM(revenue) AS revenue, SUM(revenue * revenue) AS revenue__sq,
|
|
91
|
+
SUM(orders) AS orders, SUM(orders * orders) AS orders__sq,
|
|
92
|
+
SUM(revenue * orders) AS revenue__x__orders
|
|
93
|
+
FROM user_level GROUP BY 1, 2
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
stats = Stats.from_frame(query_result, control="control", experiment="experiment_id", arm="variation")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Sequential monitoring, pooling across *completed* experiments only:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from abfactor import Monitor
|
|
104
|
+
|
|
105
|
+
prior = Analysis(Proportion("converted"), 0.05, prior=DefaultFixedEffects(0.5)).learn_prior(completed)
|
|
106
|
+
monitor = Monitor(Analysis(Proportion("converted"), 0.05, prior=prior), threshold=20)
|
|
107
|
+
monitor.update(stats_today) # cumulative counts; stops the first time the evidence reaches 20
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
For conversion rates the monitor uses an exact e-process (`method="exact"`): the
|
|
111
|
+
false-positive rate stays below 1/threshold however often you look and whatever the
|
|
112
|
+
baseline. Other metrics use the Bayes factor (`method="bayes"`), checked by simulation.
|
|
113
|
+
See [`docs/sequential.md`](docs/sequential.md).
|
|
114
|
+
|
|
115
|
+
## Noise between identical arms
|
|
116
|
+
|
|
117
|
+
In real experiments identical arms often differ by more than sampling noise allows. In
|
|
118
|
+
the Upworthy headline archive the excess was about 13% of the click-through rate, and
|
|
119
|
+
ignoring it made one A/A test in 16–40 show strong evidence of an effect
|
|
120
|
+
([`docs/real_data.md`](docs/real_data.md)). Measure it from your own A/A tests:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from abfactor.aa import estimate_arm_sd, aa_check
|
|
124
|
+
|
|
125
|
+
est = estimate_arm_sd(Proportion("converted"), aa_stats) # SD on the model scale, with a 95% interval
|
|
126
|
+
analysis = Analysis(Proportion("converted"), 0.05, arm_sd=est["arm_sd"])
|
|
127
|
+
aa_check(analysis, aa_stats) # share of A/A tests with BF10 > 3, > 20, < 1/3
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Saving and reproducing results
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
result.to_netcdf("checkout_test.nc") # draws, Bayes factors, settings, package versions
|
|
134
|
+
result = Result.from_netcdf("checkout_test.nc")
|
|
135
|
+
result.to_frame() # flat table: one row per experiment and variant
|
|
136
|
+
|
|
137
|
+
Analysis.from_settings(result.settings).fit(stats) # recomputes the same draws exactly
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Small samples
|
|
141
|
+
|
|
142
|
+
The default engine approximates each metric's likelihood by a normal on its model scale.
|
|
143
|
+
Measured against exact integration ([`docs/accuracy.md`](docs/accuracy.md)), it never
|
|
144
|
+
overstated the evidence for an effect, but:
|
|
145
|
+
|
|
146
|
+
- with fewer than 5 conversions (or non-conversions) in an arm, intervals can be far too
|
|
147
|
+
narrow: `fit` raises a `SmallSampleWarning`, and `engine="smc"` or `"nuts"` should be used;
|
|
148
|
+
- with 5 to 49, Bayes factors can understate the evidence by up to a factor of about 2.3
|
|
149
|
+
near the decision thresholds: the experiment is listed in
|
|
150
|
+
`result.diagnostics["small_sample"]["conservative_bf"]`, so borderline calls can be
|
|
151
|
+
confirmed with `engine="smc"`.
|
|
152
|
+
|
|
153
|
+
## Choosing the prior scale
|
|
154
|
+
|
|
155
|
+
Bayes factors depend on how big the alternative expects effects to be. A 10% lift on a
|
|
156
|
+
5% conversion rate is a log odds ratio of about 0.1. With `r = 0.5` (Rouder et al.'s
|
|
157
|
+
standardised default) the alternative bets on effects around 0.7 and the null wins almost
|
|
158
|
+
regardless of the data. Use `r` around 0.1 for conversion rates, or pool across experiments
|
|
159
|
+
and let the batch learn the scale.
|
|
160
|
+
|
|
161
|
+
## What the simulations show
|
|
162
|
+
|
|
163
|
+
From `python harness/harness.py --quick` (60 experiments per scenario; full results in
|
|
164
|
+
`harness/results/`):
|
|
165
|
+
|
|
166
|
+
- **Sequential monitoring:** stopping at BF10 ≥ 20 gave 0.8% false positives over 30 looks
|
|
167
|
+
(the guarantee is 5%), against 33% for peeking at a z-test.
|
|
168
|
+
- **Overdispersed counts:** a Poisson model gave false positives of 22% in one tail; the
|
|
169
|
+
quasi-Poisson correction and the per-user mean brought it back to 3–12%.
|
|
170
|
+
- **AOV:** treating orders as independent gave intervals that held the true lift 62–68% of
|
|
171
|
+
the time; the delta method 85–92%.
|
|
172
|
+
- **Broken buckets:** with an A/A period, modelling arm offsets cut false positives from
|
|
173
|
+
62% to 2–5%.
|
|
174
|
+
- **CUPED:** at a pre/post correlation of 0.62 it cut the SD of the lift by 20% (theory:
|
|
175
|
+
21%), with level intervals now calibrated too (89–91% coverage at 200 experiments).
|
|
176
|
+
|
|
177
|
+
## Known limitations
|
|
178
|
+
|
|
179
|
+
- The exact sequential guarantee covers conversion rates with binomial arms. For other
|
|
180
|
+
metrics, and with `arm_sd`, the Bayes-factor monitor's error rate is checked by
|
|
181
|
+
simulation, not proven.
|
|
182
|
+
- The quadrature engine's normal approximation is miscalibrated for the control level when
|
|
183
|
+
arms have few events (about 10–50); contrasts hold up better
|
|
184
|
+
([`docs/calibration.md`](docs/calibration.md)).
|
|
185
|
+
- Learned arm offsets (`offsets="shrunk"`) need `engine="nuts"`; spike-and-slab under NUTS
|
|
186
|
+
gives P(effect) but not pairwise Bayes factors.
|
|
187
|
+
- `Ratio` has no pre-period adjustment yet.
|
|
188
|
+
- Small-sample thresholds were measured for binary outcomes only (see `docs/accuracy.md`).
|
|
189
|
+
|
|
190
|
+
## Also included
|
|
191
|
+
|
|
192
|
+
- `abfactor.brease`: the BREASE framework (Irons & Cinelli 2023) in full; also usable as
|
|
193
|
+
`prior=Brease(...)` for a two-arm binary experiment.
|
|
194
|
+
- `abfactor.anova`: Rouder et al.'s standardised-effects ANOVA Bayes factors, for reference.
|
|
195
|
+
- `abfactor.baselines`: independent-beta and abtest-style logit-transform Bayes factors.
|
|
196
|
+
- `examples/`: a tour of the API (`quickstart.py`) and reproductions of the BREASE and abtest papers.
|
|
197
|
+
- `harness/sbc.py`: simulation-based calibration of every engine and metric
|
|
198
|
+
([`docs/calibration.md`](docs/calibration.md)); `harness/upworthy.py`: the real-data checks.
|
|
199
|
+
- `docs/posts/`: three posts explaining the methods with plain PyMC, each ending with the
|
|
200
|
+
same analysis in abfactor.
|
|
201
|
+
|
|
202
|
+
## Licence, stability and roadmap
|
|
203
|
+
|
|
204
|
+
MIT licensed (`LICENSE`). What counts as public API, and what 1.0 will promise about it, is in
|
|
205
|
+
[`docs/api.md`](docs/api.md); the API reference is built from the docstrings (`pip install -e ".[docs]"`,
|
|
206
|
+
then `pdoc abfactor`). The remaining work before 1.0 is in [`docs/roadmap.md`](docs/roadmap.md).
|
|
207
|
+
|
|
208
|
+
## References
|
|
209
|
+
|
|
210
|
+
- Rouder, Morey, Speckman & Province (2012). Default Bayes factors for ANOVA designs. *Journal of Mathematical Psychology* 56(5).
|
|
211
|
+
- Rouder, Speckman, Sun, Morey & Iverson (2009). Bayesian t tests for accepting and rejecting the null hypothesis. *Psychonomic Bulletin & Review* 16(2).
|
|
212
|
+
- Irons & Cinelli (2023). Causally sound priors for binary experiments. arXiv.
|
|
213
|
+
- Gronau, Raj K. N. & Wagenmakers (2021). Informed Bayesian inference for the A/B test. *Journal of Statistical Software* 100(17).
|
|
214
|
+
- Deng, Knoblich & Lu (2018). Applying the delta method in metric analytics. *KDD*.
|
|
215
|
+
- Wedderburn (1974). Quasi-likelihood functions. *Biometrika* 61(3).
|
|
216
|
+
- Dickey & Lientz (1970); Wagenmakers et al. (2010): the Savage–Dickey density ratio.
|
|
217
|
+
|
|
218
|
+
Full bibliography: `docs/references.bib`.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "abfactor"
|
|
7
|
+
version = "0.5.1"
|
|
8
|
+
description = "Bayesian A/B testing models with Bayes factors: informed priors, default (Rouder) priors, BREASE, pooling across experiments, sequential and pre-period designs."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Thomas Kealy" }]
|
|
14
|
+
keywords = ["a/b testing", "bayes factor", "experimentation", "bayesian", "pymc", "sequential testing"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Mathematics",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"numpy>=2.1",
|
|
26
|
+
"scipy>=1.14",
|
|
27
|
+
"pandas>=2.2",
|
|
28
|
+
"xarray>=2025.1",
|
|
29
|
+
"pymc>=6.0",
|
|
30
|
+
"arviz>=1.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/TomKealy/abfactor"
|
|
35
|
+
Changelog = "https://github.com/TomKealy/abfactor/blob/main/CHANGELOG.md"
|
|
36
|
+
Issues = "https://github.com/TomKealy/abfactor/issues"
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
dev = ["pytest>=8"]
|
|
40
|
+
docs = ["pdoc>=15"]
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.packages.find]
|
|
43
|
+
where = ["src"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
47
|
+
addopts = "-m 'not slow'"
|
|
48
|
+
markers = ["slow: runs MCMC or SMC (deselected by default; run with -m slow)"]
|
abfactor-0.5.1/setup.cfg
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Bayesian A/B testing with Bayes factors.
|
|
2
|
+
|
|
3
|
+
from abfactor import Analysis, Stats, Proportion, DefaultFixedEffects, SpikeAndSlab
|
|
4
|
+
|
|
5
|
+
stats = Stats.from_users(users, metrics={"converted": "converted"}, control="control")
|
|
6
|
+
result = Analysis(Proportion("converted"), baseline=0.05,
|
|
7
|
+
prior=DefaultFixedEffects(r=0.1)).fit(stats)
|
|
8
|
+
result.summary()
|
|
9
|
+
|
|
10
|
+
Pieces: Stats (sufficient statistics) x Metric (Proportion, TimeToEvent, Count, Mean,
|
|
11
|
+
Ratio) x prior (DefaultFixedEffects, NormalEffects, LearnedScale, Brease) x pooling
|
|
12
|
+
(Independent, SharedScale, SpikeAndSlab) x engine ("quadrature", "nuts", "smc").
|
|
13
|
+
Monitor runs sequential tests. See docs/design.md.
|
|
14
|
+
"""
|
|
15
|
+
from .analysis import Analysis, SmallSampleWarning
|
|
16
|
+
from .metrics import Count, Mean, PrePeriod, Proportion, Ratio, TimeToEvent
|
|
17
|
+
from .pooling import Independent, SharedScale, SpikeAndSlab
|
|
18
|
+
from .priors import Brease, DefaultFixedEffects, LearnedScale, NormalEffects
|
|
19
|
+
from .results import Result
|
|
20
|
+
from .sequential import Monitor
|
|
21
|
+
from .stats import Stats, cross, square
|
|
22
|
+
|
|
23
|
+
__version__ = "0.5.1"
|
|
24
|
+
|
|
25
|
+
__all__ = ["Analysis", "SmallSampleWarning", "Stats", "Result", "Monitor", "Proportion", "TimeToEvent", "Count", "Mean",
|
|
26
|
+
"Ratio", "PrePeriod", "DefaultFixedEffects", "NormalEffects", "LearnedScale", "Brease",
|
|
27
|
+
"Independent", "SharedScale", "SpikeAndSlab", "square", "cross"]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Learning from A/A tests: how much identical arms really differ.
|
|
2
|
+
|
|
3
|
+
In real experiments, arms that show users exactly the same thing still differ by more
|
|
4
|
+
than sampling noise allows: traffic shifts during a test, repeat visitors, bots and
|
|
5
|
+
logging all add variation between arms that more data does not average away. In the
|
|
6
|
+
Upworthy Research Archive's exploratory set, identical arms differed by about 12% in
|
|
7
|
+
click-through rate beyond binomial noise (docs/real_data.md). A model that ignores it
|
|
8
|
+
reports effects that are not there.
|
|
9
|
+
|
|
10
|
+
estimate_arm_sd measures that extra noise from your own A/A tests, as an SD on the
|
|
11
|
+
metric's model scale; pass it to Analysis(arm_sd=...). aa_check reports how often an
|
|
12
|
+
analysis finds evidence of an effect in A/A tests, which should be rare.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
from scipy.optimize import minimize_scalar
|
|
19
|
+
|
|
20
|
+
from .analysis import Analysis
|
|
21
|
+
from .design import sum_to_zero_basis
|
|
22
|
+
from .metrics import Metric
|
|
23
|
+
from .stats import Stats
|
|
24
|
+
|
|
25
|
+
__all__ = ["estimate_arm_sd", "aa_check", "arm_noise_loglik"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _contrasts(metric: Metric, stats: Stats):
|
|
29
|
+
d, v = metric.estimates(stats)
|
|
30
|
+
E, A, P = d.shape
|
|
31
|
+
Q = sum_to_zero_basis(A)
|
|
32
|
+
c = np.einsum("eap,ak->ekp", d, Q) # within-period contrasts, (E, A-1, P)
|
|
33
|
+
return c, v, Q
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def arm_noise_loglik(tau: float, metric: Metric, stats: Stats) -> float:
|
|
37
|
+
"""Restricted log-likelihood of an extra per-arm SD tau: the contrasts Q'd within each
|
|
38
|
+
experiment and period are N(0, Q'(diag(v) + tau^2 I)Q) = N(0, Q' diag(v) Q + tau^2 I)."""
|
|
39
|
+
c, v, Q = _contrasts(metric, stats)
|
|
40
|
+
total = 0.0
|
|
41
|
+
E, K, P = c.shape
|
|
42
|
+
for p in range(P):
|
|
43
|
+
S = np.einsum("ak,ea,al->ekl", Q, v[..., p], Q) + tau ** 2 * np.eye(K)[None]
|
|
44
|
+
sign, logdet = np.linalg.slogdet(S)
|
|
45
|
+
quad = np.einsum("ek,ekl,el->e", c[..., p], np.linalg.inv(S), c[..., p])
|
|
46
|
+
total += float(-0.5 * (logdet + quad + K * np.log(2 * np.pi)).sum())
|
|
47
|
+
return total
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def estimate_arm_sd(metric: Metric, aa_stats: Stats, upper: float = 2.0) -> dict:
|
|
51
|
+
"""Estimate the extra per-arm SD from A/A tests (all arms identical), by restricted
|
|
52
|
+
maximum likelihood on the contrasts, so each test's unknown common level drops out.
|
|
53
|
+
|
|
54
|
+
aa_stats may contain experiments with any number of identical arms as separate Stats;
|
|
55
|
+
pass a list to combine them. Returns the estimate, an approximate 95% interval from
|
|
56
|
+
the likelihood ratio, and the number of tests and arms used."""
|
|
57
|
+
parts = aa_stats if isinstance(aa_stats, (list, tuple)) else [aa_stats]
|
|
58
|
+
f = lambda t: -sum(arm_noise_loglik(t, metric, s) for s in parts)
|
|
59
|
+
best = minimize_scalar(f, bounds=(0.0, upper), method="bounded", options={"xatol": 1e-4})
|
|
60
|
+
tau, lmax = float(best.x), -float(best.fun)
|
|
61
|
+
grid = np.linspace(0, upper, 801)
|
|
62
|
+
ll = np.array([-f(t) for t in grid])
|
|
63
|
+
inside = grid[ll >= lmax - 1.92] # chi2(1) 95% / 2
|
|
64
|
+
return {"arm_sd": tau, "interval_95": (float(inside.min()), float(inside.max())),
|
|
65
|
+
"loglik_gain_vs_zero": lmax - (-f(0.0)),
|
|
66
|
+
"tests": int(sum(len(s) for s in parts)), "arms": int(sum(len(s) * s.shape[1] for s in parts))}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def aa_check(analysis: Analysis, aa_stats: Stats) -> dict:
|
|
70
|
+
"""How often an analysis finds evidence of a difference in A/A tests. For a calibrated
|
|
71
|
+
analysis, Bayes factors above 20 should be very rare, and evidence for no difference
|
|
72
|
+
(BF10 below 1/3) common."""
|
|
73
|
+
lbf = analysis.log_bf10(aa_stats)
|
|
74
|
+
return {"tests": len(lbf), "BF10>3": float(np.mean(lbf > np.log(3))),
|
|
75
|
+
"BF10>20": float(np.mean(lbf > np.log(20))), "BF10<1/3": float(np.mean(lbf < -np.log(3)))}
|