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 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.
@@ -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`.
@@ -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)"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)))}