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 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)))