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/anova.py ADDED
@@ -0,0 +1,197 @@
1
+ """
2
+ Default (g-prior) ANOVA Bayes factors, Rouder, Morey, Speckman & Province (2012),
3
+ computed from per-group sufficient statistics and extended to many experiments.
4
+
5
+ Within one experiment with a variations (control + variants), Section 6 of the paper
6
+ gives, with Jeffreys priors on the grand mean and error variance,
7
+
8
+ y = mu 1 + sigma X theta + eps, theta | g ~ N(0, g I), g ~ r^2 Inv-chi2(1)
9
+ BF10 = integral over g of S(g) pi(g),
10
+ S(g) = |G|^{-1/2} |V_g|^{-1/2} (SST / (SST - c' V_g^{-1} c))^{(N-1)/2}
11
+
12
+ theta is a vector of standardised effects (effect / sigma), so the prior on each
13
+ is Cauchy(0, r). Everything in S(g) depends on the data only through the group
14
+ counts, sums and sums of squares, so it can be computed from aggregated data:
15
+ for a per-user metric those are population, sum over users and sum of squares
16
+ over users (the same columns as GaussianMeanModel in models.py).
17
+
18
+ Multiple experiments
19
+ --------------------
20
+ Stacking experiments into one design matrix doesn't work: the model has one mu
21
+ and one sigma for all the data, so it would need experiment as a factor too,
22
+ assume every experiment has the same noise level, and treat "variant 1" in
23
+ different experiments as the same level of a crossed factor when it's nested.
24
+ Instead, each experiment keeps its own mu_e and sigma_e (with Jeffreys priors),
25
+ and so its own S_e(g). Experiments are then connected through what's shared:
26
+
27
+ * independent_bfs: each experiment has its own g. BF_e = int S_e(g) pi(g) dg.
28
+ This is exactly the paper's one-way ANOVA BF, once per experiment.
29
+ * SharedScaleModel: one g for all experiments (standardised effects in your
30
+ experiments are on a common scale), plus a spike-and-slab across
31
+ experiments: each experiment has no effect with probability 1 - w and
32
+ effects of scale g with probability w, w ~ Beta(a_w, b_w). The model learns
33
+ from the batch how big effects usually are and how often there are any, and
34
+ gives, for every experiment, its Bayes factor given all the others and its
35
+ posterior probability of having an effect.
36
+
37
+ Fixed vs random variation effects
38
+ ---------------------------------
39
+ Variations are fixed effects (they're specific treatments, not a sample from a
40
+ population), so by default the paper's sum-to-zero projection (Section 7.2) is
41
+ used: a - 1 effects with Q the eigenvectors of I - J/a. For two groups this
42
+ gives the JZS two-sample t-test Bayes factor with Cauchy scale sqrt(2) * r on
43
+ the standardised difference.
44
+
45
+ Choosing r
46
+ ----------
47
+ The paper's defaults (r = 1, or 0.5 in the BayesFactor package's "medium")
48
+ assume standardised effects of order 0.5. In online experiments a
49
+ standardised effect is (difference in means) / (SD between users), which is
50
+ usually tiny: a 3% lift in revenue per user with a CV of 4 is 0.0075. With
51
+ r = 0.5 the alternative spends almost all its prior mass on effects
52
+ hundreds of times larger than anything plausible, and the Bayes factor
53
+ favours the null almost regardless of the data (Lindley's paradox). Pick r on
54
+ the standardised scale your experiments actually live on, e.g. from history,
55
+ or let SharedScaleModel learn g from the batch.
56
+ """
57
+ from __future__ import annotations
58
+
59
+ from dataclasses import dataclass
60
+ from typing import Optional
61
+
62
+ import numpy as np
63
+ from scipy.special import logsumexp
64
+
65
+ from .pooling import SpikeAndSlab, pool
66
+
67
+ __all__ = ["ExperimentStats", "experiment_stats", "bf10", "independent_bfs", "SharedScaleModel", "stats_from_wide",
68
+ "log_S", "log_prior_g"]
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # One experiment: log S(g) from sufficient statistics
73
+ # ---------------------------------------------------------------------------
74
+
75
+ def _sum_to_zero_basis(a: int) -> np.ndarray:
76
+ """a x (a-1) orthonormal basis of the sum-to-zero subspace (paper's Q_a)."""
77
+ w, v = np.linalg.eigh(np.eye(a) - np.ones((a, a)) / a)
78
+ return v[:, w > 0.5]
79
+
80
+
81
+ @dataclass
82
+ class ExperimentStats:
83
+ """Precomputed pieces of S(g) for one experiment."""
84
+ N: float
85
+ sst: float
86
+ lam: np.ndarray # eigenvalues of X~'X~ (in the effect parameterisation)
87
+ proj2: np.ndarray # squared projections of X~'y~ onto the eigenvectors
88
+ p: int # number of effect parameters
89
+
90
+
91
+ def experiment_stats(n, s, ss, fixed: bool = True) -> ExperimentStats:
92
+ """n, s, ss: per-group counts, sums and sums of squares (length a)."""
93
+ n, s, ss = (np.asarray(x, dtype=float) for x in (n, s, ss))
94
+ N = n.sum()
95
+ ybar = s.sum() / N
96
+ sst = ss.sum() - N * ybar ** 2
97
+ if sst <= 0:
98
+ raise ValueError("Total sum of squares must be positive")
99
+ M = np.diag(n) - np.outer(n, n) / N # X~'X~ for the cell-indicator design
100
+ c = s - n * ybar # X~'y~
101
+ if fixed:
102
+ Q = _sum_to_zero_basis(len(n))
103
+ M, c = Q.T @ M @ Q, Q.T @ c
104
+ lam, U = np.linalg.eigh(M)
105
+ return ExperimentStats(N, sst, np.clip(lam, 0, None), (U.T @ c) ** 2, M.shape[0])
106
+
107
+
108
+ def log_S(st: ExperimentStats, g: np.ndarray) -> np.ndarray:
109
+ """log S(g) on an array of g values (paper eq. 8 integrand, without pi(g))."""
110
+ g = np.asarray(g, dtype=float)[..., None]
111
+ d = st.lam + 1.0 / g # eigenvalues of V_g
112
+ quad = np.sum(st.proj2 / d, axis=-1) # c' V_g^{-1} c
113
+ log_det = np.sum(np.log(d), axis=-1)
114
+ resid = np.maximum(st.sst - quad, st.sst * 1e-300)
115
+ return (-0.5 * st.p * np.log(g[..., 0]) - 0.5 * log_det
116
+ + 0.5 * (st.N - 1) * (np.log(st.sst) - np.log(resid)))
117
+
118
+
119
+ def log_prior_g(g: np.ndarray, r: float) -> np.ndarray:
120
+ """log density of g ~ r^2 Inv-chi2(1), i.e. Cauchy(0, r) on each standardised effect."""
121
+ return np.log(r) - 0.5 * np.log(2 * np.pi) - 1.5 * np.log(g) - r ** 2 / (2 * g)
122
+
123
+
124
+ def _log_g_grid(r: float, stats: list, size: int) -> np.ndarray:
125
+ """Grid on log g covering the prior and every experiment's likelihood peak."""
126
+ lo, hi = np.log(r ** 2) - 30, np.log(r ** 2) + 12
127
+ probe = np.linspace(-60, 20, 2001)
128
+ for st in stats:
129
+ peak = probe[np.argmax(log_S(st, np.exp(probe)))]
130
+ lo, hi = min(lo, peak - 8), max(hi, peak + 8)
131
+ return np.linspace(lo, hi, size)
132
+
133
+
134
+ def bf10(n, s, ss, r: float = 0.5, fixed: bool = True, grid_size: int = 20001) -> float:
135
+ """log BF10 for 'some variation differs' within one experiment."""
136
+ st = experiment_stats(n, s, ss, fixed)
137
+ u = _log_g_grid(r, [st], grid_size)
138
+ g = np.exp(u)
139
+ integrand = log_S(st, g) + log_prior_g(g, r) + u # dg = g du
140
+ return float(logsumexp(integrand) + np.log(u[1] - u[0]))
141
+
142
+
143
+ def independent_bfs(stats_by_expt, r: float = 0.5, fixed: bool = True) -> np.ndarray:
144
+ """log BF10 per experiment, each with its own g. stats_by_expt: list of (n, s, ss)."""
145
+ return np.array([bf10(n, s, ss, r, fixed) for n, s, ss in stats_by_expt])
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Many experiments: shared effect scale + spike-and-slab across experiments
150
+ # ---------------------------------------------------------------------------
151
+
152
+ class SharedScaleModel:
153
+ """One g shared by all experiments, each experiment null with probability 1 - w.
154
+
155
+ p(data | g, w) / p(data | all null) = prod_e [ (1 - w) + w S_e(g) ]
156
+ g ~ r^2 Inv-chi2(1), w ~ Beta(a_w, b_w)
157
+
158
+ fit() returns, per experiment:
159
+
160
+ - `log_BF10_given_rest`: log of int S_e(g) p(g | other experiments) dg, the Bayes
161
+ factor for this experiment's effect, with the effect scale informed by the rest of
162
+ the batch
163
+ - `P_effect`: posterior probability this experiment has an effect
164
+
165
+ and for the batch: posterior mean of w, posterior of g on the grid, and
166
+ log BF of "effects exist somewhere" vs "no experiment has an effect".
167
+ """
168
+
169
+ def __init__(self, r: float = 0.5, a_w: float = 1.0, b_w: float = 1.0,
170
+ fixed: bool = True, g_grid_size: int = 3001, w_grid_size: int = 201):
171
+ self.r, self.a_w, self.b_w, self.fixed = r, a_w, b_w, fixed
172
+ self.g_grid_size, self.w_grid_size = g_grid_size, w_grid_size
173
+
174
+ def fit(self, stats_by_expt) -> dict:
175
+ """Fit the shared-scale model to a list of (n, s, ss) per experiment; returns per-experiment and batch results."""
176
+ stats = [experiment_stats(n, s, ss, self.fixed) for n, s, ss in stats_by_expt]
177
+ u = _log_g_grid(self.r, stats, self.g_grid_size)
178
+ L = np.stack([log_S(st, np.exp(u)) for st in stats]) # (E, K)
179
+ g = np.exp(u)
180
+ log_pg = log_prior_g(g, self.r) + u + np.log(u[1] - u[0])
181
+ r = pool(L, log_pg, SpikeAndSlab(self.a_w, self.b_w, self.w_grid_size))
182
+ return dict(log_BF10_given_rest=r["log_bf"], P_effect=r["p_effect"],
183
+ log_BF_any_effect=r["log_bf_any"], w_mean=r["w_mean"], g_grid=g,
184
+ g_posterior=np.exp(r["log_g_batch"]))
185
+
186
+
187
+ def stats_from_wide(data, numerator: str = "money", population: str = "population",
188
+ n_groups: Optional[int] = None):
189
+ """(n, s, ss) per experiment from the wide frame used by models.py:
190
+ population_<g>, <numerator>_<g>, <numerator>_sq_<g> (per-user sums)."""
191
+ import re
192
+ def cols(prefix):
193
+ pat = re.compile(rf"^{re.escape(prefix)}_(\d+)$")
194
+ found = sorted((int(m.group(1)), c) for c in data.columns if (m := pat.match(str(c))))
195
+ return data[[c for _, c in found]].to_numpy(dtype=float)
196
+ n, s, ss = cols(population), cols(numerator), cols(f"{numerator}_sq")
197
+ return [(n[e], s[e], ss[e]) for e in range(len(data))]
abfactor/baselines.py ADDED
@@ -0,0 +1,142 @@
1
+ """
2
+ Baselines from Gronau, Raj & Wagenmakers (2021), for comparison with BREASE:
3
+ the two approaches in Gronau, Raj & Wagenmakers (2021),
4
+ "Informed Bayesian Inference for the A/B Test":
5
+
6
+ * Independent beta ("separate" priors per arm, as in bayesAB / BayesianFirstAid)
7
+ * abtest's logit-transform model (Kass & Vaidyanathan 1992):
8
+ logit p1 = beta - psi/2, logit p2 = beta + psi/2,
9
+ beta ~ N(mu_b, s_b^2), psi ~ N(mu_psi, s_psi^2) (truncated for H+/H-)
10
+
11
+ LT marginal likelihoods are computed by brute-force 2-D quadrature on a grid
12
+ centred on the posterior mode (abtest uses Laplace + importance sampling).
13
+ All functions take data in abtest's success coding: y1/n1 control, y2/n2 treatment.
14
+ """
15
+ import numpy as np
16
+ from scipy.special import betaln, gammaln, expit, log_expit, logsumexp
17
+ from scipy.stats import norm
18
+ from scipy.optimize import minimize, least_squares
19
+
20
+ from .brease import Data, BreasePrior, log_bayes_factor_10, one_sided_bayes_factors
21
+
22
+ __all__ = ["lt_log_ml", "lt_log_ml_h0", "lt_bfs", "ib_bf10", "ib_one_sided", "brease_bfs", "elicit_lt",
23
+ "elicit_brease"]
24
+
25
+
26
+ def lchoose(n, k):
27
+ return gammaln(n + 1) - gammaln(k + 1) - gammaln(n - k + 1)
28
+
29
+
30
+ # ---------------------------------------------------------------- LT / abtest
31
+ def _lt_loglik(b, psi, y1, n1, y2, n2):
32
+ e1, e2 = b - psi / 2, b + psi / 2
33
+ return (lchoose(n1, y1) + y1 * log_expit(e1) + (n1 - y1) * log_expit(-e1)
34
+ + lchoose(n2, y2) + y2 * log_expit(e2) + (n2 - y2) * log_expit(-e2))
35
+
36
+
37
+ def lt_log_ml(y1, n1, y2, n2, mu_b=0, s_b=1, mu_psi=0, s_psi=1,
38
+ side=None, n_grid=801, width=10):
39
+ """log p(data | H1), or H+ (side=+1: psi>0) / H- (side=-1: psi<0)."""
40
+ f = lambda x: -(_lt_loglik(x[0], x[1], y1, n1, y2, n2)
41
+ + norm.logpdf(x[0], mu_b, s_b) + norm.logpdf(x[1], mu_psi, s_psi))
42
+ opt = minimize(f, [0.0, 0.0], method="BFGS")
43
+ sd = np.sqrt(np.diag(opt.hess_inv)) * width
44
+ bg = np.linspace(opt.x[0] - sd[0], opt.x[0] + sd[0], n_grid)
45
+ pg = np.linspace(opt.x[1] - sd[1], opt.x[1] + sd[1], n_grid)
46
+ if side is not None: # make sure the grid covers the constrained region
47
+ pg = np.linspace(0, max(pg.max(), 4 * s_psi + abs(mu_psi)), n_grid) * side
48
+ pg = np.sort(pg)
49
+ B, P = np.meshgrid(bg, pg, indexing="ij")
50
+ lp = (_lt_loglik(B, P, y1, n1, y2, n2)
51
+ + norm.logpdf(B, mu_b, s_b) + norm.logpdf(P, mu_psi, s_psi))
52
+ if side is not None: # truncated-normal normaliser
53
+ mass = norm.sf(0, mu_psi, s_psi) if side > 0 else norm.cdf(0, mu_psi, s_psi)
54
+ lp = lp - np.log(mass)
55
+ return logsumexp(lp) + np.log(bg[1] - bg[0]) + np.log(abs(pg[1] - pg[0]))
56
+
57
+
58
+ def lt_log_ml_h0(y1, n1, y2, n2, mu_b=0, s_b=1):
59
+ """Log marginal likelihood of the logit-transform model under H0 (psi = 0), by quadrature."""
60
+ b = np.linspace(-15, 15, 200_001)
61
+ lp = _lt_loglik(b, 0.0, y1, n1, y2, n2) + norm.logpdf(b, mu_b, s_b)
62
+ return logsumexp(lp) + np.log(b[1] - b[0])
63
+
64
+
65
+ def lt_bfs(y1, n1, y2, n2, **prior):
66
+ """BF10, BF+0 and BF-0 for the logit-transform (abtest) model."""
67
+ mu_b, s_b = prior.get("mu_b", 0), prior.get("s_b", 1)
68
+ l0 = lt_log_ml_h0(y1, n1, y2, n2, mu_b, s_b)
69
+ return {k: float(np.exp(lt_log_ml(y1, n1, y2, n2, side=s, **prior) - l0))
70
+ for k, s in (("BF10", None), ("BF+0", +1), ("BF-0", -1))}
71
+
72
+
73
+ # ------------------------------------------------------- independent beta (IB)
74
+ def ib_bf10(y1, n1, y2, n2, a=1.0):
75
+ """Two-sided IB Bayes factor via Savage-Dickey (Dablander et al. 2022; BREASE App. F)."""
76
+ lbf = (betaln(2 * a - 1, 2 * a - 1) + betaln(a + y1, a + n1 - y1)
77
+ + betaln(a + y2, a + n2 - y2)
78
+ - betaln(2 * a + y1 + y2 - 1, 2 * a + n1 - y1 + n2 - y2 - 1)
79
+ - 2 * betaln(a, a))
80
+ return float(np.exp(lbf))
81
+
82
+
83
+ def ib_one_sided(y1, n1, y2, n2, a=1.0, size=2_000_000, rng=0):
84
+ """IB one-sided BFs: H+ is the IB prior truncated to p2>p1 (prior mass 1/2)."""
85
+ rng = np.random.default_rng(rng)
86
+ p1 = rng.beta(a + y1, a + n1 - y1, size)
87
+ p2 = rng.beta(a + y2, a + n2 - y2, size)
88
+ post_plus = np.mean(p2 > p1)
89
+ bf10 = ib_bf10(y1, n1, y2, n2, a)
90
+ return {"BF10": bf10, "BF+0": bf10 * post_plus / 0.5, "BF-0": bf10 * (1 - post_plus) / 0.5}
91
+
92
+
93
+ # ------------------------------------------------------------------- BREASE
94
+ def to_brease(y1, n1, y2, n2):
95
+ """abtest success coding -> BREASE adverse-outcome coding (y = failures)."""
96
+ return Data(y0=n1 - y1, N0=n1, y1=n2 - y2, N1=n2)
97
+
98
+
99
+ def brease_bfs(y1, n1, y2, n2, prior=BreasePrior(), rng=0):
100
+ """BF10 and one-sided Bayes factors under a BREASE prior, in the baselines' format."""
101
+ d = to_brease(y1, n1, y2, n2)
102
+ os_ = one_sided_bayes_factors(d, prior, size=400_000, rng=rng)
103
+ # BREASE "benefit" = fewer adverse outcomes = abtest H+
104
+ return {"BF10": float(np.exp(log_bayes_factor_10(d, prior))),
105
+ "BF+0": os_["BF_minus_0"], "BF-0": os_["BF_plus_0"]}
106
+
107
+
108
+ # ------------------------------------------------- prior elicitation helpers
109
+ def elicit_lt(q, prob, mu_b=0, s_b=1, size=200_000, rng=0):
110
+ """Mimic abtest::elicit_prior(what='arisk'): fit (mu_psi, s_psi) so the
111
+ implied prior on p2 - p1 matches the given quantiles (eq. 2)."""
112
+ z = np.random.default_rng(rng).standard_normal((2, size))
113
+ b = mu_b + s_b * z[0]
114
+
115
+ def resid(th):
116
+ psi = th[0] + np.exp(th[1]) * z[1]
117
+ ar = expit(b + psi / 2) - expit(b - psi / 2)
118
+ return [np.mean(ar <= qi) - pi for qi, pi in zip(q, prob)]
119
+ th = least_squares(resid, [0.5, np.log(0.3)], diff_step=1e-2).x
120
+ return th[0], float(np.exp(th[1]))
121
+
122
+
123
+ def elicit_brease(q, prob, mu0=0.5, n0=2, mus=0.02, ns=1, size=200_000, rng=0):
124
+ """Same quantiles, but fit the *efficacy* prior (mu_e, n_e) of BREASE,
125
+ with a small side-effect prior fixed. In abtest coding the absolute risk
126
+ p2 - p1 = theta0 - theta1 (control failure rate minus treated failure rate)."""
127
+ u = np.random.default_rng(rng).random((3, size))
128
+ from scipy.stats import beta as B
129
+ t0 = B.ppf(u[0], mu0 * n0, (1 - mu0) * n0)
130
+ es = B.ppf(u[2], mus * ns, (1 - mus) * ns)
131
+
132
+ def resid(th):
133
+ mue, ne = expit(th[0]), np.exp(th[1])
134
+ ee = B.ppf(u[1], mue * ne, (1 - mue) * ne)
135
+ t1 = (1 - ee) * t0 + es * (1 - t0)
136
+ return [np.mean(t0 - t1 <= qi) - pi for qi, pi in zip(q, prob)]
137
+ th = least_squares(resid, [-1.0, np.log(5.0)], diff_step=1e-2).x
138
+ return BreasePrior(mu0, float(expit(th[0])), mus, n0, float(np.exp(th[1])), ns)
139
+
140
+
141
+ def fmt(d):
142
+ return " ".join(f"{k}={v:8.4f}" for k, v in d.items())
abfactor/brease.py ADDED
@@ -0,0 +1,349 @@
1
+ """
2
+ BREASE: Bayesian analysis of binary experiments with causally sound priors.
3
+
4
+ Implementation of Irons & Cinelli (2023), "Causally Sound Priors for Binary
5
+ Experiments". The likelihood is parameterised by
6
+
7
+ theta0 : baseline risk P(Y(0) = 1)
8
+ eta_e : efficacy P(Y(1) = 0 | Y(0) = 1)
9
+ eta_s : risk of side effects P(Y(1) = 1 | Y(0) = 0)
10
+
11
+ with theta1 = (1 - eta_e) * theta0 + eta_s * (1 - theta0) (eq. 3.3)
12
+
13
+ and independent priors theta0 ~ Beta*(mu0, n0), eta_e ~ Beta*(mue, ne),
14
+ eta_s ~ Beta*(mus, ns), where Beta*(mu, n) = Beta(mu * n, (1 - mu) * n).
15
+
16
+ Everything is computed in log space. The posterior is a finite mixture of
17
+ independent betas indexed by the latent counterfactual counts
18
+
19
+ j = y1(1) treated subjects with the outcome who'd have had it anyway
20
+ k = x1(1) treated subjects without the outcome who'd have had it untreated
21
+
22
+ so sampling is exact (no MCMC) and the marginal likelihood is a finite sum.
23
+ The mixture grid has (y1 + 1) x (N1 - y1 + 1) cells, which is cheap even for
24
+ trials with tens of thousands of participants.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass
30
+
31
+ import numpy as np
32
+ from scipy.special import betaln, gammaln, logsumexp
33
+
34
+ __all__ = ["Data", "BreasePrior", "bayes_factor_10", "log_bayes_factor_10", "one_sided_bayes_factors",
35
+ "sample_posterior", "sample_posterior_no_harm", "sample_posterior_no_benefit",
36
+ "log_marginal_likelihood_M1", "log_marginal_likelihood_M0", "log_marginal_likelihood_no_harm",
37
+ "log_marginal_likelihood_no_benefit", "partial_id_bounds", "theta1_from", "sensitivity_grid",
38
+ "prior_moments", "summarise"]
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Data containers
43
+ # ---------------------------------------------------------------------------
44
+
45
+ @dataclass(frozen=True)
46
+ class Data:
47
+ """Observed trial: y0 events of N0 in control, y1 events of N1 in treatment."""
48
+ y0: int
49
+ N0: int
50
+ y1: int
51
+ N1: int
52
+
53
+ def __post_init__(self):
54
+ if not (0 <= self.y0 <= self.N0 and 0 <= self.y1 <= self.N1):
55
+ raise ValueError("Need 0 <= y <= N in each arm.")
56
+
57
+ @property
58
+ def N(self) -> int:
59
+ """Total participants."""
60
+ return self.N0 + self.N1
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class BreasePrior:
65
+ """BREASE(mu0, mue, mus; n0, ne, ns). Defaults are the paper's default prior."""
66
+ mu0: float = 0.5
67
+ mue: float = 0.3
68
+ mus: float = 0.3
69
+ n0: float = 2.0
70
+ ne: float = 1.0
71
+ ns: float = 1.0
72
+
73
+ # Beta(a, b) shape parameters
74
+ @property
75
+ def a0(self):
76
+ """Beta parameters of the three components: control rate (a0, b0), effect (ae, be),
77
+ side effect (as_, bs), from each mean mu and prior sample size n."""
78
+ return self.mu0 * self.n0
79
+ @property
80
+ def b0(self): return (1 - self.mu0) * self.n0
81
+ @property
82
+ def ae(self): return self.mue * self.ne
83
+ @property
84
+ def be(self): return (1 - self.mue) * self.ne
85
+ @property
86
+ def as_(self): return self.mus * self.ns
87
+ @property
88
+ def bs(self): return (1 - self.mus) * self.ns
89
+
90
+ @classmethod
91
+ def dirichlet(cls, mu0, mue, mus, n0):
92
+ """Dirichlet prior on response types = BREASE with 'equal confidence' (eq. 3.6)."""
93
+ return cls(mu0, mue, mus, n0, mu0 * n0, (1 - mu0) * n0)
94
+
95
+ @classmethod
96
+ def empirical_bayes(cls, data: Data, n: float = 1.0):
97
+ """BREASE-EB(n) from Appendix E.3: prior means at midpoints of the
98
+ partially identified intervals, evaluated at (y+1)/(N+2)."""
99
+ t0 = (data.y0 + 1) / (data.N0 + 2)
100
+ t1 = (data.y1 + 1) / (data.N1 + 2)
101
+ (le, ue), (ls, us) = partial_id_bounds(t0, t1)
102
+ return cls(0.5, (le + ue) / 2, (ls + us) / 2, 2.0, n, n)
103
+
104
+ def sample(self, size, rng=None):
105
+ """Draws from the prior: a tuple of arrays (theta0, eta_e, eta_s)."""
106
+ rng = np.random.default_rng(rng)
107
+ t0 = rng.beta(self.a0, self.b0, size)
108
+ ee = rng.beta(self.ae, self.be, size)
109
+ es = rng.beta(self.as_, self.bs, size)
110
+ return t0, ee, es
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Helpers
115
+ # ---------------------------------------------------------------------------
116
+
117
+ def _lchoose(n, k):
118
+ return gammaln(n + 1) - gammaln(k + 1) - gammaln(n - k + 1)
119
+
120
+
121
+ def theta1_from(theta0, eta_e, eta_s):
122
+ """Eq. (3.3)."""
123
+ return (1 - eta_e) * theta0 + eta_s * (1 - theta0)
124
+
125
+
126
+ def partial_id_bounds(theta0, theta1):
127
+ """Sharp bounds on (eta_e, eta_s) given (theta0, theta1), eqs. (E.3)-(E.4)."""
128
+ theta0 = np.asarray(theta0, float)
129
+ theta1 = np.asarray(theta1, float)
130
+ le = np.maximum(0, (theta0 - theta1) / theta0)
131
+ ue = np.minimum(1, (1 - theta1) / theta0)
132
+ ls = np.maximum(0, (theta1 - theta0) / (1 - theta0))
133
+ us = np.minimum(1, theta1 / (1 - theta0))
134
+ return (le, ue), (ls, us)
135
+
136
+
137
+ def _mixture_log_weights(d: Data, p: BreasePrior):
138
+ """Unnormalised log mixture weights over the (j, k) grid, plus the
139
+ posterior beta parameters for each cell (eqs. 3.11-3.12, with j = y1 - y1(0)).
140
+
141
+ The returned weights already include the prior normalising constants, so
142
+ logsumexp(weights) + binomial coefficients = log marginal likelihood (3.16).
143
+ """
144
+ j = np.arange(d.y1 + 1)[:, None] # y1(1)
145
+ k = np.arange(d.N1 - d.y1 + 1)[None, :] # x1(1)
146
+
147
+ # posterior beta parameters for each mixture component
148
+ A0 = d.y0 + j + k + p.a0
149
+ B0 = d.N - (d.y0 + j + k) + p.b0
150
+ Ae = k + p.ae
151
+ Be = j + p.be
152
+ As = d.y1 - j + p.as_
153
+ Bs = d.N1 - d.y1 - k + p.bs
154
+
155
+ logw = (
156
+ _lchoose(d.y1, j) + _lchoose(d.N1 - d.y1, k)
157
+ + betaln(A0, B0) - betaln(p.a0, p.b0)
158
+ + betaln(Ae, Be) - betaln(p.ae, p.be)
159
+ + betaln(As, Bs) - betaln(p.as_, p.bs)
160
+ )
161
+ params = np.broadcast_arrays(A0, B0, Ae, Be, As, Bs)
162
+ return logw, params
163
+
164
+
165
+ def _log_binom_coef(d: Data):
166
+ return _lchoose(d.N0, d.y0) + _lchoose(d.N1, d.y1)
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Marginal likelihoods (Theorem 3 and Appendix E.2)
171
+ # ---------------------------------------------------------------------------
172
+
173
+ def log_marginal_likelihood_M1(d: Data, p: BreasePrior) -> float:
174
+ """Unconstrained BREASE model M1, eq. (3.16)."""
175
+ logw, _ = _mixture_log_weights(d, p)
176
+ return float(_log_binom_coef(d) + logsumexp(logw))
177
+
178
+
179
+ def log_marginal_likelihood_M0(d: Data, p: BreasePrior) -> float:
180
+ """Null model M0: theta0 ~ Beta*(mu0, n0), theta1 = theta0 (beta-binomial)."""
181
+ s = d.y0 + d.y1
182
+ return float(_log_binom_coef(d)
183
+ + betaln(s + p.a0, d.N - s + p.b0) - betaln(p.a0, p.b0))
184
+
185
+
186
+ def log_marginal_likelihood_no_harm(d: Data, p: BreasePrior) -> float:
187
+ """M'_-: eta_s = 0, so theta1 = (1 - eta_e) theta0 (Appendix E.2)."""
188
+ k = np.arange(d.N1 - d.y1 + 1)
189
+ logw = (_lchoose(d.N1 - d.y1, k)
190
+ + betaln(d.y0 + d.y1 + k + p.a0, d.N - (d.y0 + d.y1 + k) + p.b0)
191
+ - betaln(p.a0, p.b0)
192
+ + betaln(k + p.ae, d.y1 + p.be) - betaln(p.ae, p.be))
193
+ return float(_log_binom_coef(d) + logsumexp(logw))
194
+
195
+
196
+ def log_marginal_likelihood_no_benefit(d: Data, p: BreasePrior) -> float:
197
+ """M'_+: eta_e = 0, so theta1 = theta0 + eta_s (1 - theta0) (Appendix E.2)."""
198
+ j = np.arange(d.y1 + 1)
199
+ logw = (_lchoose(d.y1, j)
200
+ + betaln(d.y0 + j + p.a0, d.N - (d.y0 + j) + p.b0)
201
+ - betaln(p.a0, p.b0)
202
+ + betaln(d.y1 - j + p.as_, d.N1 - d.y1 + p.bs) - betaln(p.as_, p.bs))
203
+ return float(_log_binom_coef(d) + logsumexp(logw))
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Exact posterior sampling (Algorithm 1, Theorems 4-5)
208
+ # ---------------------------------------------------------------------------
209
+
210
+ def sample_posterior(d: Data, p: BreasePrior, size: int = 10_000, rng=None):
211
+ """Exact draws from the BREASE posterior (Algorithm 1).
212
+
213
+ Returns a dict of arrays: theta0, theta1, eta_e, eta_s, risk_ratio,
214
+ risk_diff, and the latent counts j = y1(1), k = x1(1).
215
+ """
216
+ rng = np.random.default_rng(rng)
217
+ logw, (A0, B0, Ae, Be, As, Bs) = _mixture_log_weights(d, p)
218
+ w = np.exp(logw - logsumexp(logw)).ravel()
219
+ idx = rng.choice(w.size, size=size, p=w) # joint draw of (y1(0), x1(1))
220
+
221
+ t0 = rng.beta(A0.ravel()[idx], B0.ravel()[idx])
222
+ ee = rng.beta(Ae.ravel()[idx], Be.ravel()[idx])
223
+ es = rng.beta(As.ravel()[idx], Bs.ravel()[idx])
224
+ return _pack(t0, ee, es, *np.unravel_index(idx, logw.shape))
225
+
226
+
227
+ def sample_posterior_no_harm(d: Data, p: BreasePrior, size=10_000, rng=None):
228
+ """Exact draws under eta_s = 0 (Algorithm 2)."""
229
+ rng = np.random.default_rng(rng)
230
+ k = np.arange(d.N1 - d.y1 + 1)
231
+ A0 = d.y0 + d.y1 + k + p.a0
232
+ B0 = d.N - (d.y0 + d.y1 + k) + p.b0
233
+ Ae, Be = k + p.ae, d.y1 + p.be + 0 * k
234
+ logw = _lchoose(d.N1 - d.y1, k) + betaln(A0, B0) + betaln(Ae, Be)
235
+ idx = rng.choice(k.size, size=size, p=np.exp(logw - logsumexp(logw)))
236
+ t0 = rng.beta(A0[idx], B0[idx])
237
+ ee = rng.beta(Ae[idx], Be[idx])
238
+ return _pack(t0, ee, np.zeros(size), None, k[idx])
239
+
240
+
241
+ def sample_posterior_no_benefit(d: Data, p: BreasePrior, size=10_000, rng=None):
242
+ """Exact draws under eta_e = 0 (Algorithm 3). Here j indexes y1(1)."""
243
+ rng = np.random.default_rng(rng)
244
+ j = np.arange(d.y1 + 1)
245
+ A0 = d.y0 + j + p.a0
246
+ B0 = d.N - (d.y0 + j) + p.b0
247
+ As, Bs = d.y1 - j + p.as_, d.N1 - d.y1 + p.bs + 0 * j
248
+ logw = _lchoose(d.y1, j) + betaln(A0, B0) + betaln(As, Bs)
249
+ idx = rng.choice(j.size, size=size, p=np.exp(logw - logsumexp(logw)))
250
+ t0 = rng.beta(A0[idx], B0[idx])
251
+ es = rng.beta(As[idx], Bs[idx])
252
+ return _pack(t0, np.zeros(size), es, j[idx], None)
253
+
254
+
255
+ def _pack(t0, ee, es, j, k):
256
+ t1 = theta1_from(t0, ee, es)
257
+ out = dict(theta0=t0, theta1=t1, eta_e=ee, eta_s=es,
258
+ risk_ratio=t1 / t0, risk_diff=t1 - t0)
259
+ if j is not None:
260
+ out["j"] = j
261
+ if k is not None:
262
+ out["k"] = k
263
+ return out
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # Bayes factors
268
+ # ---------------------------------------------------------------------------
269
+
270
+ def bayes_factor_10(d: Data, p: BreasePrior = BreasePrior()) -> float:
271
+ """BF10 for H1: theta1 != theta0 (M1) vs H0: theta1 = theta0 (M0)."""
272
+ return float(np.exp(log_marginal_likelihood_M1(d, p)
273
+ - log_marginal_likelihood_M0(d, p)))
274
+
275
+
276
+ def log_bayes_factor_10(d: Data, p: BreasePrior = BreasePrior()) -> float:
277
+ """Use this when BF10 overflows (e.g. the COVID vaccine trial)."""
278
+ return log_marginal_likelihood_M1(d, p) - log_marginal_likelihood_M0(d, p)
279
+
280
+
281
+ def one_sided_bayes_factors(d: Data, p: BreasePrior = BreasePrior(),
282
+ size: int = 200_000, rng=None):
283
+ """Bayes factors against H0 for H-: theta1 < theta0 (benefit) and
284
+ H+: theta1 > theta0 (harm), using the truncated-M1 priors of eq. (3.15)
285
+ and the identity (3.17). Prior/posterior tail probabilities are Monte Carlo
286
+ estimates from exact draws.
287
+ """
288
+ rng = np.random.default_rng(rng)
289
+ t0, ee, es = p.sample(size, rng)
290
+ prior_minus = np.mean(theta1_from(t0, ee, es) < t0)
291
+ post = sample_posterior(d, p, size, rng)
292
+ post_minus = np.mean(post["theta1"] < post["theta0"])
293
+
294
+ lbf10 = log_bayes_factor_10(d, p)
295
+ with np.errstate(divide="ignore"):
296
+ return dict(
297
+ BF_minus_0=float(np.exp(lbf10 + np.log(post_minus) - np.log(prior_minus))),
298
+ BF_plus_0=float(np.exp(lbf10 + np.log1p(-post_minus) - np.log1p(-prior_minus))),
299
+ prior_P_benefit=float(prior_minus),
300
+ post_P_benefit=float(post_minus),
301
+ )
302
+
303
+
304
+ def sensitivity_grid(d: Data, mue_grid, mus_grid, base: BreasePrior = BreasePrior()):
305
+ """log BF10 over a (mu_e, mu_s) grid, holding the other hyperparameters at
306
+ `base` (as in Figures 3b / 4b). Rows index mu_e, columns mu_s."""
307
+ out = np.empty((len(mue_grid), len(mus_grid)))
308
+ for a, me in enumerate(mue_grid):
309
+ for b, ms in enumerate(mus_grid):
310
+ pr = BreasePrior(base.mu0, me, ms, base.n0, base.ne, base.ns)
311
+ out[a, b] = log_bayes_factor_10(d, pr)
312
+ return out
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # Prior moments (Appendix A)
317
+ # ---------------------------------------------------------------------------
318
+
319
+ def prior_moments(p: BreasePrior):
320
+ """Prior mean, variance and correlation of (theta0, theta1)."""
321
+ v0 = p.mu0 * (1 - p.mu0) / (p.n0 + 1)
322
+ ve = p.mue * (1 - p.mue) / (p.ne + 1)
323
+ vs = p.mus * (1 - p.mus) / (p.ns + 1)
324
+ cov = v0 * (1 - p.mue - p.mus)
325
+ var1 = (v0 * (1 - p.mue - p.mus) ** 2
326
+ + ve * (v0 + p.mu0 ** 2)
327
+ + vs * (v0 + (1 - p.mu0) ** 2))
328
+ mean1 = (1 - p.mue) * p.mu0 + p.mus * (1 - p.mu0)
329
+ return dict(mean_theta0=p.mu0, mean_theta1=mean1, var_theta0=v0,
330
+ var_theta1=var1, cov=cov, cor=cov / np.sqrt(v0 * var1))
331
+
332
+
333
+ # ---------------------------------------------------------------------------
334
+ # Convenience summary
335
+ # ---------------------------------------------------------------------------
336
+
337
+ def summarise(d: Data, p: BreasePrior = BreasePrior(), size=50_000, rng=None):
338
+ """Posterior summaries and Bayes factors for one trial, as a dict."""
339
+ post = sample_posterior(d, p, size, rng)
340
+ q = lambda x: np.quantile(x, [0.5, 0.025, 0.975])
341
+ lbf = log_bayes_factor_10(d, p)
342
+ return {
343
+ "risk_ratio (median, 2.5%, 97.5%)": q(post["risk_ratio"]),
344
+ "risk_diff (median, 2.5%, 97.5%)": q(post["risk_diff"]),
345
+ "eta_e (median, 2.5%, 97.5%)": q(post["eta_e"]),
346
+ "eta_s (median, 2.5%, 97.5%)": q(post["eta_s"]),
347
+ "log BF10": lbf,
348
+ "P(H1 | D) at 50:50 prior odds": 1 / (1 + np.exp(-lbf)),
349
+ }