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/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
|
+
}
|