abfactor 0.5.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- abfactor/__init__.py +27 -0
- abfactor/aa.py +75 -0
- abfactor/analysis.py +455 -0
- abfactor/anova.py +197 -0
- abfactor/baselines.py +142 -0
- abfactor/brease.py +349 -0
- abfactor/design.py +168 -0
- abfactor/metrics.py +319 -0
- abfactor/pooling.py +87 -0
- abfactor/priors.py +88 -0
- abfactor/results.py +163 -0
- abfactor/sequential.py +259 -0
- abfactor/spec.py +66 -0
- abfactor/stats.py +209 -0
- abfactor-0.5.1.dist-info/METADATA +250 -0
- abfactor-0.5.1.dist-info/RECORD +19 -0
- abfactor-0.5.1.dist-info/WHEEL +5 -0
- abfactor-0.5.1.dist-info/licenses/LICENSE +21 -0
- abfactor-0.5.1.dist-info/top_level.txt +1 -0
abfactor/__init__.py
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"]
|
abfactor/aa.py
ADDED
|
@@ -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)))}
|
abfactor/analysis.py
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""Analysis: metric x prior x pooling x engine."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import warnings
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import xarray as xr
|
|
11
|
+
from scipy.special import logsumexp
|
|
12
|
+
from scipy.stats import norm, skewnorm
|
|
13
|
+
|
|
14
|
+
from . import brease as _brease
|
|
15
|
+
from .design import Design
|
|
16
|
+
from .metrics import Metric, Proportion
|
|
17
|
+
from .pooling import Independent, SharedScale, SpikeAndSlab, pool
|
|
18
|
+
from .priors import Brease, DefaultFixedEffects, EffectPrior, LearnedScale, NormalEffects
|
|
19
|
+
from .results import Result
|
|
20
|
+
from .stats import Stats
|
|
21
|
+
|
|
22
|
+
OFFSETS = ("none", "fixed", "free", "shrunk")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SmallSampleWarning(UserWarning):
|
|
26
|
+
"""The quadrature engine's normal approximation is unreliable for some experiments."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def small_sample_check(metric, stats) -> dict:
|
|
30
|
+
"""Flag experiments whose smallest cell is too small for the normal approximation.
|
|
31
|
+
|
|
32
|
+
Below metric.warn_below, posterior intervals can be far too narrow: warn, and point to
|
|
33
|
+
the exact-likelihood engines. Below metric.note_below, intervals are fine but Bayes
|
|
34
|
+
factors understate the evidence for an effect (never overstate it, in the measurements
|
|
35
|
+
behind docs/accuracy.md): record a note for borderline calls."""
|
|
36
|
+
try:
|
|
37
|
+
m = metric.min_cell(stats)
|
|
38
|
+
except NotImplementedError:
|
|
39
|
+
return {}
|
|
40
|
+
exps = stats.experiments
|
|
41
|
+
unreliable = [e for e, v in zip(exps, m) if v < metric.warn_below]
|
|
42
|
+
conservative = [e for e, v in zip(exps, m) if metric.warn_below <= v < metric.note_below]
|
|
43
|
+
if unreliable:
|
|
44
|
+
warnings.warn(f"{len(unreliable)} experiment(s) have fewer than {metric.warn_below} in their "
|
|
45
|
+
f"smallest cell ({unreliable[:5]}{'...' if len(unreliable) > 5 else ''}): the normal "
|
|
46
|
+
"approximation is unreliable there; use engine='smc' or 'nuts'", SmallSampleWarning,
|
|
47
|
+
stacklevel=3)
|
|
48
|
+
out = {"min_cell": dict(zip(map(str, exps), map(float, m)))}
|
|
49
|
+
if unreliable:
|
|
50
|
+
out["unreliable"] = unreliable
|
|
51
|
+
if conservative:
|
|
52
|
+
out["conservative_bf"] = conservative
|
|
53
|
+
return out
|
|
54
|
+
ENGINES = ("quadrature", "nuts", "smc")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Analysis:
|
|
58
|
+
"""An analysis of one kind of metric: what is measured, the prior on effects, how
|
|
59
|
+
experiments in a batch share information, and how the posterior is computed. `fit`
|
|
60
|
+
returns a `Result`; `log_bf10` returns Bayes factors alone; `learn_prior` turns completed
|
|
61
|
+
experiments into a prior for new ones.
|
|
62
|
+
|
|
63
|
+
- `metric`: what is measured (Proportion, TimeToEvent, Count, Mean, Ratio)
|
|
64
|
+
- `baseline`: prior centre for the control arm, on the natural scale (a conversion rate,
|
|
65
|
+
a rate per user, a mean)
|
|
66
|
+
- `prior`: DefaultFixedEffects(r) | NormalEffects(sd) | LearnedScale | Brease
|
|
67
|
+
- `pooling`: Independent() | SharedScale() | SpikeAndSlab()
|
|
68
|
+
- `level_sd`: prior SD of each period's baseline on the model scale. Default: 1.0 on the
|
|
69
|
+
log-odds, log-rate and hazard scales; for raw-scale metrics (Mean, Ratio) the
|
|
70
|
+
baseline's magnitude, so a revenue baseline of 50 gets SD 50
|
|
71
|
+
- `treatment_start`: first treated period; earlier periods are A/A periods
|
|
72
|
+
- `arm_sd`: SD of extra noise per arm and period on the model scale, beyond sampling
|
|
73
|
+
noise: identical arms in real A/A tests differ by more than the binomial allows
|
|
74
|
+
(time-varying traffic, repeat visitors, bots). Estimate it from your own A/A tests
|
|
75
|
+
with abfactor.aa.estimate_arm_sd. Default 0 (none).
|
|
76
|
+
- `offsets`: persistent arm offsets, identified by the A/A periods: "none", "fixed"
|
|
77
|
+
(N(0, offset_sd^2) on each projected offset), "free" (as wide as the level prior) or
|
|
78
|
+
"shrunk" (scale learned across the batch from prior scale offset_sd; NUTS only)
|
|
79
|
+
- `engine`: "quadrature" (default: fast, exact draws and pairwise BFs on the Gaussian
|
|
80
|
+
reduction), "nuts" (exact likelihood), "smc" (exact likelihood, omnibus BFs by
|
|
81
|
+
marginal likelihood; Independent only)
|
|
82
|
+
- `offset_sd`: prior SD of the projected arm offsets for offsets="fixed" (prior scale
|
|
83
|
+
for "shrunk")
|
|
84
|
+
- `grid_size`: points on the log g grid for quadrature
|
|
85
|
+
- `draws`: posterior draws per experiment (quadrature)
|
|
86
|
+
- `seed`: random seed; the same settings and data give the same draws
|
|
87
|
+
- `nuts`: options for engine="nuts" and "smc": draws, tune, chains, target_accept
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, metric: Metric, baseline: Optional[float] = None,
|
|
91
|
+
prior: EffectPrior = DefaultFixedEffects(0.1), pooling=Independent(),
|
|
92
|
+
level_sd: Optional[float] = None, treatment_start: int = 0, offsets: str = "none",
|
|
93
|
+
offset_sd: float = 0.05, engine: str = "quadrature", grid_size: int = 3001,
|
|
94
|
+
draws: int = 4000, seed: int = 0, nuts: Optional[dict] = None, arm_sd: float = 0.0):
|
|
95
|
+
if arm_sd < 0:
|
|
96
|
+
raise ValueError("arm_sd must be non-negative")
|
|
97
|
+
self.arm_sd = float(arm_sd)
|
|
98
|
+
if offsets not in OFFSETS:
|
|
99
|
+
raise ValueError(f"offsets must be one of {OFFSETS}")
|
|
100
|
+
if engine not in ENGINES:
|
|
101
|
+
raise ValueError(f"engine must be one of {ENGINES}")
|
|
102
|
+
if offsets == "shrunk" and engine != "nuts":
|
|
103
|
+
raise ValueError("offsets='shrunk' needs engine='nuts'; use 'fixed' or 'free' with quadrature")
|
|
104
|
+
if baseline is None and not isinstance(prior, Brease):
|
|
105
|
+
raise ValueError("baseline is required")
|
|
106
|
+
self.metric, self.baseline, self.prior, self.pooling = metric, baseline, prior, pooling
|
|
107
|
+
if level_sd is None:
|
|
108
|
+
level_sd = max(abs(float(baseline)), 1e-6) if metric.scale == "raw" and baseline is not None else 1.0
|
|
109
|
+
self.level_sd, self.treatment_start = level_sd, treatment_start
|
|
110
|
+
self.offsets, self.offset_sd = offsets, offset_sd
|
|
111
|
+
self.engine, self.grid_size, self.draws, self.seed = engine, grid_size, draws, seed
|
|
112
|
+
self.nuts = {"draws": 1000, "tune": 1000, "chains": 4, "target_accept": 0.95, **(nuts or {})}
|
|
113
|
+
|
|
114
|
+
# ---- settings ------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def settings(self) -> dict:
|
|
118
|
+
"""Everything needed to recompute a result, as plain JSON-able data."""
|
|
119
|
+
from .spec import to_spec, versions
|
|
120
|
+
return {"metric": to_spec(self.metric), "baseline": self.baseline, "prior": to_spec(self.prior),
|
|
121
|
+
"pooling": to_spec(self.pooling), "level_sd": self.level_sd,
|
|
122
|
+
"treatment_start": self.treatment_start, "offsets": self.offsets,
|
|
123
|
+
"offset_sd": self.offset_sd, "arm_sd": self.arm_sd, "engine": self.engine, "grid_size": self.grid_size,
|
|
124
|
+
"draws": self.draws, "seed": self.seed, "nuts": dict(self.nuts), "versions": versions()}
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def from_settings(cls, settings: dict) -> "Analysis":
|
|
128
|
+
"""Rebuild an Analysis from `Analysis.settings` or `Result.settings`."""
|
|
129
|
+
from .spec import from_spec
|
|
130
|
+
kw = {k: v for k, v in settings.items() if k != "versions"}
|
|
131
|
+
for key in ("metric", "prior", "pooling"):
|
|
132
|
+
kw[key] = from_spec(kw[key])
|
|
133
|
+
return cls(**kw)
|
|
134
|
+
|
|
135
|
+
# ---- public --------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def fit(self, stats):
|
|
138
|
+
"""Fit the analysis. `stats` is a Stats (experiments with the same number of arms and
|
|
139
|
+
periods), or a list of them, which returns a list of Results. With a list, pooling
|
|
140
|
+
spans every experiment in every part: a batch can mix two-arm and three-arm tests."""
|
|
141
|
+
if isinstance(stats, (list, tuple)):
|
|
142
|
+
return self._fit_parts(list(stats))
|
|
143
|
+
if isinstance(self.prior, Brease):
|
|
144
|
+
return self._fit_brease(stats)
|
|
145
|
+
design = self._design(stats)
|
|
146
|
+
if self.engine == "quadrature":
|
|
147
|
+
return self._fit_quadrature([stats], [design])[0]
|
|
148
|
+
if self.engine == "nuts":
|
|
149
|
+
return self._fit_nuts(stats, design)
|
|
150
|
+
return self._fit_smc(stats, design)
|
|
151
|
+
|
|
152
|
+
def _fit_parts(self, parts):
|
|
153
|
+
if not parts:
|
|
154
|
+
raise ValueError("fit needs at least one Stats")
|
|
155
|
+
if self.engine == "quadrature" and not isinstance(self.prior, Brease):
|
|
156
|
+
return self._fit_quadrature(parts, [self._design(st) for st in parts])
|
|
157
|
+
if isinstance(self.pooling, Independent):
|
|
158
|
+
return [self.fit(st) for st in parts]
|
|
159
|
+
raise NotImplementedError("pooling across a list of Stats needs engine='quadrature'")
|
|
160
|
+
|
|
161
|
+
def learn_prior(self, completed, pooling=None) -> LearnedScale:
|
|
162
|
+
"""The effect scale learned from completed experiments, as a prior for new ones.
|
|
163
|
+
`completed` is a Stats, or a list of them when experiments have different numbers of
|
|
164
|
+
arms. Uses the slab of a spike-and-slab by default, so experiments with no effect
|
|
165
|
+
don't drag the scale towards zero. Set arm_sd first if your A/A tests show extra
|
|
166
|
+
noise between identical arms: otherwise that noise is learned as effects."""
|
|
167
|
+
if isinstance(self.prior, (Brease, LearnedScale)):
|
|
168
|
+
raise ValueError("learn_prior needs a DefaultFixedEffects or NormalEffects prior")
|
|
169
|
+
pooling = pooling or SpikeAndSlab()
|
|
170
|
+
parts = completed if isinstance(completed, (list, tuple)) else [completed]
|
|
171
|
+
u, log_pg = self.prior.grid(self.grid_size)
|
|
172
|
+
L = []
|
|
173
|
+
for st in parts:
|
|
174
|
+
design = self._design(st)
|
|
175
|
+
d, N = self._estimates(st, design)
|
|
176
|
+
L.append(design.log_S(d, N, u))
|
|
177
|
+
res = pool(np.vstack(L), log_pg, pooling)
|
|
178
|
+
n = sum(len(st) for st in parts)
|
|
179
|
+
return LearnedScale(u=u, log_mass=res["log_g_batch"],
|
|
180
|
+
source=f"{n} completed experiments, {type(pooling).__name__}")
|
|
181
|
+
|
|
182
|
+
def log_bf10(self, stats) -> np.ndarray:
|
|
183
|
+
"""Omnibus log BF10 per experiment only, without posterior draws (quadrature).
|
|
184
|
+
`stats` may be a list of Stats; the result is then concatenated in order."""
|
|
185
|
+
parts = stats if isinstance(stats, (list, tuple)) else [stats]
|
|
186
|
+
u, log_pg = self.prior.grid(self.grid_size)
|
|
187
|
+
L = []
|
|
188
|
+
for st in parts:
|
|
189
|
+
small_sample_check(self.metric, st)
|
|
190
|
+
design = self._design(st)
|
|
191
|
+
d, N = self._estimates(st, design)
|
|
192
|
+
L.append(design.log_S(d, N, u))
|
|
193
|
+
return pool(np.vstack(L), log_pg, self.pooling)["log_bf"]
|
|
194
|
+
|
|
195
|
+
# ---- shared ---------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def _design(self, stats: Stats) -> Design:
|
|
198
|
+
E, A, P = stats.shape
|
|
199
|
+
offset_sd = {"none": None, "fixed": self.offset_sd, "free": self.level_sd,
|
|
200
|
+
"shrunk": None}[self.offsets]
|
|
201
|
+
if self.offsets != "none" and self.treatment_start == 0:
|
|
202
|
+
raise ValueError("offsets need at least one pre-treatment period (treatment_start >= 1)")
|
|
203
|
+
return Design(A, P, m0=self.metric.to_model(self.baseline), level_sd=self.level_sd,
|
|
204
|
+
treatment_start=self.treatment_start, offset_sd=offset_sd)
|
|
205
|
+
|
|
206
|
+
def _estimates(self, stats: Stats, design: Design):
|
|
207
|
+
"""Estimates (E, A, P) and their full noise covariance (E, A*P, A*P)."""
|
|
208
|
+
d, v = self.metric.estimates(stats)
|
|
209
|
+
N = design.noise(v, self.metric.covariance(stats))
|
|
210
|
+
if self.arm_sd > 0: # independent extra noise per arm and period
|
|
211
|
+
N = N + self.arm_sd ** 2 * np.eye(N.shape[-1])[None]
|
|
212
|
+
return d, N
|
|
213
|
+
|
|
214
|
+
def _prior_density_at_zero(self, cc: float = 2.0) -> float:
|
|
215
|
+
"""Marginal prior density of a pairwise contrast at 0."""
|
|
216
|
+
if isinstance(self.prior, DefaultFixedEffects):
|
|
217
|
+
return 1 / (np.pi * np.sqrt(cc) * self.prior.r) # Cauchy(0, sqrt(2) r)
|
|
218
|
+
if isinstance(self.prior, NormalEffects):
|
|
219
|
+
return float(norm.pdf(0, 0, self.prior.contrast_sd))
|
|
220
|
+
u, lm = self.prior.grid()
|
|
221
|
+
return float(np.exp(logsumexp(lm + norm.logpdf(0, 0, np.sqrt(cc * np.exp(u))))))
|
|
222
|
+
|
|
223
|
+
def _assemble(self, stats, design, theta_level, effect, omnibus, pairwise, pooling_info, diag):
|
|
224
|
+
"""theta_level: (E, S) report-period level; effect: (E, A, S) arm effects (Q e*)."""
|
|
225
|
+
arms, ctrl = stats.arms, stats.control
|
|
226
|
+
eta = theta_level[:, None, :] + effect
|
|
227
|
+
natural = xr.DataArray(self.metric.to_natural(eta), dims=("experiment", "arm", "sample"),
|
|
228
|
+
coords={"experiment": stats.experiments, "arm": arms})
|
|
229
|
+
contrasts = xr.DataArray(effect[:, 1:, :] - effect[:, :1, :], dims=("experiment", "variant", "sample"),
|
|
230
|
+
coords={"experiment": stats.experiments, "variant": arms[1:]})
|
|
231
|
+
return Result(metric=getattr(self.metric, "name", type(self.metric).__name__),
|
|
232
|
+
scale=self.metric.scale, control=ctrl, arms=arms, experiments=stats.experiments,
|
|
233
|
+
contrasts=contrasts, natural=natural, omnibus=omnibus, pairwise=pairwise,
|
|
234
|
+
pooling=pooling_info, diagnostics=diag, settings=self.settings)
|
|
235
|
+
|
|
236
|
+
# ---- quadrature -------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
def _fit_quadrature(self, parts, designs):
|
|
239
|
+
"""One pooled quadrature over every experiment in `parts`; one Result per part."""
|
|
240
|
+
rng = np.random.default_rng(self.seed)
|
|
241
|
+
u, log_pg = self.prior.grid(self.grid_size)
|
|
242
|
+
g = np.exp(u)
|
|
243
|
+
est = [self._estimates(st, design) for st, design in zip(parts, designs)]
|
|
244
|
+
res = pool(np.vstack([design.log_S(d, v, u) for (d, v), design in zip(est, designs)]), log_pg, self.pooling)
|
|
245
|
+
pooling_info = {"kind": type(self.pooling).__name__, "w_mean": res["w_mean"],
|
|
246
|
+
"log_BF_any": res["log_bf_any"], "g_grid": g, "log_g_batch": res["log_g_batch"]}
|
|
247
|
+
out, start = [], 0
|
|
248
|
+
for stats, design, (d, v) in zip(parts, designs, est):
|
|
249
|
+
E, A, P = stats.shape
|
|
250
|
+
idx = slice(start, start + E)
|
|
251
|
+
start += E
|
|
252
|
+
r = {k: (res[k][idx] if res[k] is not None else None)
|
|
253
|
+
for k in ("log_bf", "log_g_rest", "log_g_post", "p_effect")}
|
|
254
|
+
out.append(self._quadrature_result(stats, design, d, v, g, r, pooling_info, rng))
|
|
255
|
+
return out
|
|
256
|
+
|
|
257
|
+
def _quadrature_result(self, stats, design, d, v, g, res, pooling_info, rng):
|
|
258
|
+
E, A, P = stats.shape
|
|
259
|
+
S = self.draws
|
|
260
|
+
level, effect = np.empty((E, S)), np.empty((E, A, S))
|
|
261
|
+
rows = []
|
|
262
|
+
for e in range(E):
|
|
263
|
+
if res["p_effect"] is None:
|
|
264
|
+
theta = design.sample(d[e], v[e], g, res["log_g_post"][e], S, rng)
|
|
265
|
+
else:
|
|
266
|
+
slab = rng.random(S) < res["p_effect"][e]
|
|
267
|
+
theta = np.empty((S, design.X.shape[1]))
|
|
268
|
+
if slab.any():
|
|
269
|
+
theta[slab] = design.sample(d[e], v[e], g, res["log_g_post"][e], int(slab.sum()), rng)
|
|
270
|
+
if (~slab).any():
|
|
271
|
+
theta[~slab] = design.sample(d[e], v[e], np.array([1e-14]), np.array([0.0]),
|
|
272
|
+
int((~slab).sum()), rng)
|
|
273
|
+
theta[np.ix_(~slab, np.arange(theta.shape[1])[design.effect_rows()])] = 0.0 # no effect: exactly zero
|
|
274
|
+
level[e] = theta[:, design.report_period]
|
|
275
|
+
effect[e] = (theta[:, design.effect_rows()] @ design.Q.T).T
|
|
276
|
+
for k in range(1, A):
|
|
277
|
+
lbf, p_pos, mu, sd = design.pairwise(d[e], v[e], g, res["log_g_post"][e],
|
|
278
|
+
res["log_g_rest"][e], k)
|
|
279
|
+
bf = np.exp(min(lbf, 700.0)) # very strong evidence: BF capped, log_BF10 exact
|
|
280
|
+
rows.append(dict(experiment=stats.experiments[e], variant=stats.arms[k], log_BF10=lbf,
|
|
281
|
+
BF10=bf, BFplus0=2 * bf * p_pos, BFminus0=2 * bf * (1 - p_pos),
|
|
282
|
+
P_better=p_pos, contrast_mean=mu, contrast_sd=sd))
|
|
283
|
+
omnibus = pd.DataFrame({"experiment": stats.experiments, "log_BF10": res["log_bf"],
|
|
284
|
+
"P_effect": res["p_effect"] if res["p_effect"] is not None else np.nan})
|
|
285
|
+
small = small_sample_check(self.metric, stats)
|
|
286
|
+
notes = ["Gaussian reduction of the likelihood on the model scale"]
|
|
287
|
+
if small.get("conservative_bf"):
|
|
288
|
+
notes.append(f"{len(small['conservative_bf'])} experiment(s) with smallest cell under "
|
|
289
|
+
f"{self.metric.note_below}: Bayes factors there may understate the evidence for an "
|
|
290
|
+
"effect; confirm borderline calls with engine='smc'")
|
|
291
|
+
diag = {"engine": "quadrature", "small_sample": small, "notes": notes}
|
|
292
|
+
return self._assemble(stats, design, level, effect, omnibus, pd.DataFrame(rows), dict(pooling_info), diag)
|
|
293
|
+
|
|
294
|
+
# ---- NUTS / SMC ----------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
def _pymc_model(self, stats, design, null=False, pooling=None):
|
|
297
|
+
import pymc as pm
|
|
298
|
+
import pytensor.tensor as pt
|
|
299
|
+
pooling = self.pooling if pooling is None else pooling
|
|
300
|
+
E, A, P = stats.shape
|
|
301
|
+
Q, on = design.Q, design.on
|
|
302
|
+
coords = {"experiment": range(E), "arm": range(A), "period": range(P), "contrast": range(A - 1)}
|
|
303
|
+
with pm.Model(coords=coords) as model:
|
|
304
|
+
level = pm.Normal("level", design.m0, self.level_sd, dims=("experiment", "period"))
|
|
305
|
+
eta0 = level[:, None, :]
|
|
306
|
+
if self.arm_sd > 0: # latent extra noise per arm and period
|
|
307
|
+
eta0 = eta0 + pm.Normal("arm_noise", 0, self.arm_sd, dims=("experiment", "arm", "period"))
|
|
308
|
+
if self.offsets != "none":
|
|
309
|
+
zo = pm.Normal("zo", 0, 1, dims=("experiment", "contrast"))
|
|
310
|
+
if self.offsets == "shrunk":
|
|
311
|
+
so = pm.math.sqrt(pm.InverseGamma("g_offset", alpha=0.5, beta=self.offset_sd ** 2 / 2))
|
|
312
|
+
else:
|
|
313
|
+
so = design.offset_sd
|
|
314
|
+
offset = pm.Deterministic("offset", (so * zo) @ Q.T, dims=("experiment", "arm"))
|
|
315
|
+
eta0 = eta0 + offset[:, :, None]
|
|
316
|
+
if null:
|
|
317
|
+
effect = pt.zeros((E, A))
|
|
318
|
+
else:
|
|
319
|
+
if isinstance(pooling, Independent):
|
|
320
|
+
sc = self.prior.pymc_scale(dims="experiment")
|
|
321
|
+
sc = sc[:, None] if hasattr(sc, "ndim") and getattr(sc, "ndim", 0) == 1 else sc
|
|
322
|
+
else:
|
|
323
|
+
sc = self.prior.pymc_scale(dims=None)
|
|
324
|
+
ze = pm.Normal("ze", 0, 1, dims=("experiment", "contrast"))
|
|
325
|
+
effect = (sc * ze) @ Q.T
|
|
326
|
+
effect = pm.Deterministic("effect", effect, dims=("experiment", "arm"))
|
|
327
|
+
eta = eta0 + effect[:, :, None] * on[None, None, :]
|
|
328
|
+
ll = self.metric.pymc_loglik(eta, stats)
|
|
329
|
+
if isinstance(pooling, SpikeAndSlab) and not null:
|
|
330
|
+
w = pm.Beta("w", pooling.a_w, pooling.b_w)
|
|
331
|
+
ll0 = self.metric.pymc_loglik(eta0, stats)
|
|
332
|
+
slab, spike = pt.log(w) + ll, pt.log1p(-w) + ll0
|
|
333
|
+
pm.Potential("loglik", pt.logaddexp(slab, spike).sum())
|
|
334
|
+
pm.Deterministic("resp", pm.math.sigmoid(slab - spike), dims="experiment")
|
|
335
|
+
else:
|
|
336
|
+
pm.Potential("loglik", ll.sum())
|
|
337
|
+
return model
|
|
338
|
+
|
|
339
|
+
def _from_draws(self, stats, design, post, rng, omnibus_bf, diag):
|
|
340
|
+
st = lambda name: post[name].stack(sample=("chain", "draw"))
|
|
341
|
+
level = st("level").isel(period=design.report_period).transpose("experiment", "sample").values
|
|
342
|
+
effect = st("effect").transpose("experiment", "arm", "sample").values
|
|
343
|
+
E, A, S = effect.shape
|
|
344
|
+
p_eff = None
|
|
345
|
+
if "resp" in post.data_vars:
|
|
346
|
+
resp = st("resp").transpose("experiment", "sample").values
|
|
347
|
+
p_eff = resp.mean(axis=1)
|
|
348
|
+
effect = np.where((rng.random(resp.shape) < resp)[:, None, :], effect, 0.0)
|
|
349
|
+
rows = []
|
|
350
|
+
prior0 = self._prior_density_at_zero()
|
|
351
|
+
for e in range(E):
|
|
352
|
+
for k in range(1, A):
|
|
353
|
+
psi = effect[e, k] - effect[e, 0]
|
|
354
|
+
p_pos = float(np.clip((psi > 0).mean(), 1 / S, 1 - 1 / S))
|
|
355
|
+
if p_eff is None:
|
|
356
|
+
bf = prior0 / skewnorm.pdf(0.0, *skewnorm.fit(psi))
|
|
357
|
+
lbf = float(np.log(bf))
|
|
358
|
+
else:
|
|
359
|
+
bf = lbf = np.nan # mixture with a point mass: use quadrature
|
|
360
|
+
rows.append(dict(experiment=stats.experiments[e], variant=stats.arms[k], log_BF10=lbf,
|
|
361
|
+
BF10=bf, BFplus0=2 * bf * p_pos, BFminus0=2 * bf * (1 - p_pos),
|
|
362
|
+
P_better=p_pos, contrast_mean=float(psi.mean()), contrast_sd=float(psi.std())))
|
|
363
|
+
omnibus = pd.DataFrame({"experiment": stats.experiments,
|
|
364
|
+
"log_BF10": omnibus_bf if omnibus_bf is not None else np.nan,
|
|
365
|
+
"P_effect": p_eff if p_eff is not None else np.nan})
|
|
366
|
+
pooling_info = {"kind": type(self.pooling).__name__,
|
|
367
|
+
"w_mean": float(st("w").mean()) if "w" in post.data_vars else None}
|
|
368
|
+
return self._assemble(stats, design, level, effect, omnibus, pd.DataFrame(rows), pooling_info, diag)
|
|
369
|
+
|
|
370
|
+
def _fit_nuts(self, stats, design):
|
|
371
|
+
import pymc as pm
|
|
372
|
+
if isinstance(self.prior, LearnedScale):
|
|
373
|
+
raise NotImplementedError("LearnedScale priors need engine='quadrature'")
|
|
374
|
+
kw = dict(self.nuts)
|
|
375
|
+
with self._pymc_model(stats, design):
|
|
376
|
+
idata = pm.sample(kw.pop("draws"), tune=kw.pop("tune"), chains=kw.pop("chains"),
|
|
377
|
+
random_seed=self.seed, progressbar=False, **kw)
|
|
378
|
+
div = int(idata.sample_stats["diverging"].sum())
|
|
379
|
+
if div:
|
|
380
|
+
logging.warning(f"{div} divergent transitions")
|
|
381
|
+
diag = {"engine": "nuts", "divergences": div,
|
|
382
|
+
"notes": ["omnibus BFs are not computed by NUTS: use engine='quadrature' or 'smc'"]}
|
|
383
|
+
self.idata_ = idata
|
|
384
|
+
return self._from_draws(stats, design, idata.posterior, np.random.default_rng(self.seed), None, diag)
|
|
385
|
+
|
|
386
|
+
def _fit_smc(self, stats, design):
|
|
387
|
+
import pymc as pm
|
|
388
|
+
if not isinstance(self.pooling, Independent):
|
|
389
|
+
raise ValueError("engine='smc' supports pooling=Independent() only")
|
|
390
|
+
if isinstance(self.prior, LearnedScale):
|
|
391
|
+
raise NotImplementedError("LearnedScale priors need engine='quadrature'")
|
|
392
|
+
lbf, posts = [], []
|
|
393
|
+
for e in stats.experiments:
|
|
394
|
+
sub = stats.sel([e])
|
|
395
|
+
lml = {}
|
|
396
|
+
for null in (False, True):
|
|
397
|
+
with self._pymc_model(sub, design, null=null, pooling=Independent()):
|
|
398
|
+
smc = pm.sample_smc(self.nuts["draws"], chains=self.nuts["chains"],
|
|
399
|
+
random_seed=self.seed, progressbar=False)
|
|
400
|
+
lml[null] = _smc_log_ml(smc)
|
|
401
|
+
if not null:
|
|
402
|
+
posts.append(smc.posterior)
|
|
403
|
+
lbf.append(lml[False] - lml[True])
|
|
404
|
+
post = xr.concat([p.to_dataset() if hasattr(p, "to_dataset") else p for p in posts], dim="experiment")
|
|
405
|
+
post = post.assign_coords(experiment=range(len(stats)))
|
|
406
|
+
diag = {"engine": "smc", "notes": ["omnibus BFs from SMC marginal likelihoods of the exact likelihood"]}
|
|
407
|
+
return self._from_draws(stats, design, post, np.random.default_rng(self.seed), np.array(lbf), diag)
|
|
408
|
+
|
|
409
|
+
# ---- BREASE ---------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
def _fit_brease(self, stats):
|
|
412
|
+
E, A, P = stats.shape
|
|
413
|
+
if not isinstance(self.metric, Proportion) or A != 2 or P != 1:
|
|
414
|
+
raise ValueError("The BREASE prior needs a Proportion metric, two arms and one period")
|
|
415
|
+
y, n = stats.get(self.metric.name)[..., 0], stats.get("n")[..., 0]
|
|
416
|
+
adverse = (n - y) if self.prior.outcome == "success" else y
|
|
417
|
+
rng = np.random.default_rng(self.seed)
|
|
418
|
+
S = self.draws
|
|
419
|
+
level, effect, rows, om = np.empty((E, S)), np.empty((E, 2, S)), [], []
|
|
420
|
+
for e in range(E):
|
|
421
|
+
data = _brease.Data(y0=int(adverse[e, 0]), N0=int(n[e, 0]), y1=int(adverse[e, 1]), N1=int(n[e, 1]))
|
|
422
|
+
lbf = _brease.log_bayes_factor_10(data, self.prior.prior)
|
|
423
|
+
post = _brease.sample_posterior(data, self.prior.prior, S, rng)
|
|
424
|
+
p0, p1 = post["theta0"], post["theta1"]
|
|
425
|
+
if self.prior.outcome == "success":
|
|
426
|
+
p0, p1 = 1 - p0, 1 - p1
|
|
427
|
+
lo0, lo1 = np.log(p0 / (1 - p0)), np.log(p1 / (1 - p1))
|
|
428
|
+
level[e] = (lo0 + lo1) / 2
|
|
429
|
+
effect[e] = np.stack([lo0 - level[e], lo1 - level[e]])
|
|
430
|
+
os_ = _brease.one_sided_bayes_factors(data, self.prior.prior, size=200_000, rng=rng)
|
|
431
|
+
# "better" = the counted statistic is higher in the variant
|
|
432
|
+
up, down = ("BF_minus_0", "BF_plus_0") if self.prior.outcome == "success" else ("BF_plus_0", "BF_minus_0")
|
|
433
|
+
psi = lo1 - lo0
|
|
434
|
+
rows.append(dict(experiment=stats.experiments[e], variant=stats.arms[1], log_BF10=lbf,
|
|
435
|
+
BF10=float(np.exp(lbf)), BFplus0=os_[up], BFminus0=os_[down],
|
|
436
|
+
P_better=float((p1 > p0).mean()), contrast_mean=float(psi.mean()),
|
|
437
|
+
contrast_sd=float(psi.std())))
|
|
438
|
+
om.append(lbf)
|
|
439
|
+
omnibus = pd.DataFrame({"experiment": stats.experiments, "log_BF10": om, "P_effect": np.nan})
|
|
440
|
+
design = Design(2, 1, m0=0.0)
|
|
441
|
+
diag = {"engine": "brease", "notes": ["exact BREASE posterior and marginal likelihoods"]}
|
|
442
|
+
return self._assemble(stats, design, level, effect, omnibus, pd.DataFrame(rows),
|
|
443
|
+
{"kind": "Independent"}, diag)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _smc_log_ml(idata) -> float:
|
|
447
|
+
"""Log marginal likelihood from pm.sample_smc output, averaged over chains. Stored per
|
|
448
|
+
chain as per-stage values, NaN-padded; the last finite value is the total."""
|
|
449
|
+
v = idata.sample_stats["log_marginal_likelihood"].values
|
|
450
|
+
per_chain = []
|
|
451
|
+
for chain in v:
|
|
452
|
+
flat = np.concatenate([np.atleast_1d(np.asarray(x, dtype=float)).ravel()
|
|
453
|
+
for x in np.atleast_1d(chain).ravel()])
|
|
454
|
+
per_chain.append(flat[np.isfinite(flat)][-1])
|
|
455
|
+
return float(logsumexp(per_chain) - np.log(len(per_chain)))
|