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/design.py ADDED
@@ -0,0 +1,168 @@
1
+ """The linear Gaussian reduction every quadrature analysis runs on.
2
+
3
+ Per experiment, with estimates d[a, t] and sampling variances v[a, t] on the model scale:
4
+
5
+ d[a, t] = level[t] + (Q o*)[a] + (Q e*)[a] * on[t] + noise, noise ~ N(0, v[a, t])
6
+
7
+ level[t] ~ N(m0, level_sd^2) baseline per period
8
+ o* ~ N(0, offset_sd^2 I) persistent arm offset (optional)
9
+ e* | g ~ N(0, g I) treatment effect
10
+
11
+ with Q the orthonormal sum-to-zero basis. Given g everything is Gaussian, so
12
+
13
+ S(g) = N(d; mean, B + g K) / N(d; mean, B), B = nuisance + noise covariance,
14
+ K = effect covariance
15
+
16
+ and the posterior of (level, o*, e*) given g is Gaussian in closed form.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+ from typing import Optional
22
+
23
+ import numpy as np
24
+ from scipy.special import logsumexp
25
+ from scipy.stats import norm
26
+
27
+
28
+ def sum_to_zero_basis(a: int) -> np.ndarray:
29
+ """a x (a-1) orthonormal basis of the sum-to-zero subspace: the eigenvectors of
30
+ I - J/a with eigenvalue 1 (Q_a in Rouder et al. 2012, section 7.2)."""
31
+ w, v = np.linalg.eigh(np.eye(a) - np.ones((a, a)) / a)
32
+ return v[:, w > 0.5]
33
+
34
+
35
+ @dataclass
36
+ class Design:
37
+ arms: int
38
+ periods: int
39
+ m0: float
40
+ level_sd: float = 1.0
41
+ treatment_start: int = 0
42
+ offset_sd: Optional[float] = None # None = no offsets
43
+
44
+ def __post_init__(self):
45
+ A, P = self.arms, self.periods
46
+ if A < 2:
47
+ raise ValueError("Need at least two arms")
48
+ if not 0 <= self.treatment_start < P:
49
+ raise ValueError("treatment_start must be one of the periods")
50
+ if self.offset_sd is not None and self.treatment_start == 0:
51
+ raise ValueError("Arm offsets need at least one pre-treatment period")
52
+ self.Q = sum_to_zero_basis(A)
53
+ self.on = (np.arange(P) >= self.treatment_start).astype(float)
54
+ # observation order: arm-major, (a, t) -> a * P + t
55
+ Xl = np.kron(np.ones((A, 1)), np.eye(P))
56
+ Xo = np.kron(self.Q, np.ones((P, 1)))
57
+ Xe = np.kron(self.Q, self.on[:, None])
58
+ self.Xl, self.Xo, self.Xe = Xl, Xo, Xe
59
+ blocks = [Xl] + ([Xo] if self.offset_sd is not None else []) + [Xe]
60
+ self.X = np.hstack(blocks)
61
+ self.n_level, self.n_off, self.n_eff = P, (A - 1 if self.offset_sd is not None else 0), A - 1
62
+ self.prior_mean = np.concatenate([np.full(P, self.m0), np.zeros(self.n_off + self.n_eff)])
63
+ nuis = self.level_sd ** 2 * Xl @ Xl.T
64
+ if self.offset_sd is not None:
65
+ nuis = nuis + self.offset_sd ** 2 * Xo @ Xo.T
66
+ self.nuisance_cov = nuis
67
+ self.K = Xe @ Xe.T
68
+ self.report_period = self.treatment_start
69
+
70
+ # ---- noise ---------------------------------------------------------------
71
+
72
+ def noise(self, v: np.ndarray, cov: Optional[np.ndarray] = None) -> np.ndarray:
73
+ """Sampling covariance of the estimates, shape (E, A*P, A*P) in arm-major order.
74
+ v: variances (E, A, P); cov: optional covariance across arms within each period,
75
+ (E, P, A, A), for estimates that share a term (e.g. a pooled pre-period mean)."""
76
+ E, A, P = v.shape
77
+ N = np.zeros((E, A * P, A * P))
78
+ idx = np.arange(A * P)
79
+ N[:, idx, idx] = v.reshape(E, -1)
80
+ if cov is not None:
81
+ for t in range(P):
82
+ at = np.arange(A) * P + t
83
+ N[:, at[:, None], at[None, :]] = cov[:, t]
84
+ return N
85
+
86
+ # ---- integrand ---------------------------------------------------------
87
+
88
+ def log_S(self, d: np.ndarray, N: np.ndarray, u: np.ndarray) -> np.ndarray:
89
+ """log S_e(g) on g = exp(u) for every experiment. d: (E, A, P); N: noise covariance
90
+ (E, A*P, A*P) from noise(). Returns (E, K)."""
91
+ E = d.shape[0]
92
+ g = np.exp(u)
93
+ out = np.empty((E, len(u)))
94
+ mean = self.Xl @ np.full(self.periods, self.m0)
95
+ for e in range(E):
96
+ B = self.nuisance_cov + N[e]
97
+ Lb = np.linalg.cholesky(np.linalg.inv(B))
98
+ lam, U = np.linalg.eigh(Lb.T @ self.K @ Lb)
99
+ lam = np.clip(lam, 0, None)
100
+ proj = (U.T @ (Lb.T @ (d[e].ravel() - mean))) ** 2
101
+ gl = g[:, None] * lam[None, :]
102
+ out[e] = 0.5 * np.sum(proj * gl / (1 + gl), axis=1) - 0.5 * np.sum(np.log1p(gl), axis=1)
103
+ return out
104
+
105
+ # ---- conditional posterior ----------------------------------------------
106
+
107
+ def posterior_given_g(self, d_e: np.ndarray, N_e: np.ndarray, g: np.ndarray):
108
+ """Gaussian posterior of theta = (level, offset*, effect*) for each g in a 1-D array,
109
+ given estimates d_e (A, P) with noise covariance N_e (A*P, A*P).
110
+ Returns means (G, p) and covariances (G, p, p)."""
111
+ g = np.atleast_1d(g)
112
+ Ninv = np.linalg.inv(N_e)
113
+ XtDX = self.X.T @ Ninv @ self.X
114
+ XtDd = self.X.T @ Ninv @ d_e.ravel()
115
+ base_prec = np.concatenate([np.full(self.n_level, 1 / self.level_sd ** 2),
116
+ np.full(self.n_off, 1 / self.offset_sd ** 2) if self.n_off else np.zeros(0)])
117
+ prec = np.empty((len(g), self.X.shape[1]))
118
+ prec[:, :len(base_prec)] = base_prec
119
+ prec[:, len(base_prec):] = 1 / np.maximum(g, 1e-300)[:, None]
120
+ P = XtDX[None] + np.einsum("gi,ij->gij", prec, np.eye(self.X.shape[1]))
121
+ cov = np.linalg.inv(P)
122
+ rhs = XtDd[None] + prec * self.prior_mean[None]
123
+ mean = np.einsum("gij,gj->gi", cov, rhs)
124
+ return mean, cov
125
+
126
+ def effect_rows(self) -> slice:
127
+ return slice(self.n_level + self.n_off, self.n_level + self.n_off + self.n_eff)
128
+
129
+ def contrast_vector(self, k: int, control: int = 0) -> np.ndarray:
130
+ return self.Q[k] - self.Q[control]
131
+
132
+ def natural_eta(self, theta: np.ndarray) -> np.ndarray:
133
+ """Model-scale value of each arm in the report period, offsets removed.
134
+ theta: (S, p) -> (S, A)."""
135
+ level = theta[:, self.report_period]
136
+ eff = theta[:, self.effect_rows()] @ self.Q.T
137
+ return level[:, None] + eff
138
+
139
+ # ---- exact pairwise Savage-Dickey ------------------------------------------
140
+
141
+ def pairwise(self, d_e, N_e, g, log_w_post, log_w_prior, k, control=0, tol=1e-12):
142
+ """Exact Savage-Dickey BF10 for psi = effect[k] - effect[control]: prior and posterior
143
+ densities of psi at 0 are mixtures of normals over the g grid. Returns
144
+ (log BF10, P(psi > 0 | data), posterior mean, posterior sd)."""
145
+ c = self.contrast_vector(k, control)
146
+ cc = float(c @ c)
147
+ keep = log_w_post > log_w_post.max() + np.log(tol)
148
+ wp = np.exp(log_w_post[keep] - logsumexp(log_w_post[keep]))
149
+ mean, cov = self.posterior_given_g(d_e, N_e, g[keep])
150
+ r = self.effect_rows()
151
+ m = mean[:, r] @ c
152
+ s = np.sqrt(np.einsum("i,gij,j->g", c, cov[:, r, r], c))
153
+ log_post0 = logsumexp(np.log(wp) + norm.logpdf(0, m, s))
154
+ log_prior0 = logsumexp(log_w_prior + norm.logpdf(0, 0, np.sqrt(cc * g)))
155
+ p_pos = float(np.sum(wp * norm.sf(0, m, s)))
156
+ mu = float(np.sum(wp * m))
157
+ sd = float(np.sqrt(np.sum(wp * (s ** 2 + m ** 2)) - mu ** 2))
158
+ return float(log_prior0 - log_post0), p_pos, mu, sd
159
+
160
+ def sample(self, d_e, N_e, g, log_w, size, rng):
161
+ """Exact posterior draws of theta: g from its grid posterior, then the conditional Gaussian."""
162
+ w = np.exp(log_w - logsumexp(log_w))
163
+ idx = rng.choice(len(g), size=size, p=w)
164
+ uniq, inv = np.unique(idx, return_inverse=True)
165
+ mean, cov = self.posterior_given_g(d_e, N_e, g[uniq])
166
+ chol = np.linalg.cholesky(cov + 1e-15 * np.eye(cov.shape[-1]))
167
+ z = rng.standard_normal((size, cov.shape[-1]))
168
+ return mean[inv] + np.einsum("sij,sj->si", chol[inv], z)
abfactor/metrics.py ADDED
@@ -0,0 +1,319 @@
1
+ """Metrics: what is measured, on which scale it is modelled, and its likelihood.
2
+
3
+ Every metric reduces Stats to estimates d[e, a, t] with sampling variances
4
+ v[e, a, t] on its model scale (used by the quadrature engine), and also supplies
5
+ its exact log-likelihood as a function of the linear predictor (used by the NUTS
6
+ and SMC engines).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+ from dataclasses import dataclass
12
+ from typing import Optional
13
+
14
+ import numpy as np
15
+ from scipy.special import expit, logit
16
+
17
+ from .stats import Stats, square
18
+
19
+
20
+ class Metric(ABC):
21
+ """warn_below / note_below: smallest-cell sizes below which the quadrature engine's
22
+ normal approximation is flagged (see docs/accuracy.md for how they were set)."""
23
+ name: str
24
+ scale: str = "raw"
25
+ warn_below: int = 5
26
+ note_below: int = 50
27
+
28
+ def min_cell(self, stats: Stats) -> np.ndarray:
29
+ """Smallest count driving the approximation in each experiment, shape (experiment,)."""
30
+ raise NotImplementedError
31
+
32
+ @abstractmethod
33
+ def estimates(self, stats: Stats) -> tuple[np.ndarray, np.ndarray]:
34
+ """(estimate, variance), each of shape (experiment, arm, period), on the model scale."""
35
+
36
+ def covariance(self, stats: Stats) -> Optional[np.ndarray]:
37
+ """Covariance of the estimates across arms within each period, (experiment, period,
38
+ arm, arm), when they share a term; None when the arms are independent."""
39
+ return None
40
+
41
+ @abstractmethod
42
+ def to_model(self, natural: float) -> float:
43
+ """A natural-scale value (e.g. a baseline rate) on the model scale."""
44
+
45
+ @abstractmethod
46
+ def to_natural(self, eta: np.ndarray) -> np.ndarray:
47
+ """Model-scale values back on the natural scale (numpy)."""
48
+
49
+ @abstractmethod
50
+ def pymc_loglik(self, eta, stats: Stats):
51
+ """Exact log-likelihood per experiment (shape (experiment,)) given the linear
52
+ predictor eta of shape (experiment, arm, period), as a PyTensor expression."""
53
+
54
+ def check(self, stats: Stats) -> None:
55
+ """Raise ValueError if `stats` lacks what this metric needs."""
56
+ pass
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Binary outcomes
61
+ # ---------------------------------------------------------------------------
62
+
63
+ @dataclass
64
+ class Proportion(Metric):
65
+ """A binary outcome per user (converted or not), modelled on the log-odds scale.
66
+ Uses n and the statistic `name` (the number of successes). With several periods,
67
+ each period's users are assumed distinct; for one-off events among the same users
68
+ use TimeToEvent."""
69
+ name: str
70
+ scale: str = "log odds"
71
+
72
+ def estimates(self, stats):
73
+ y, n = stats.get(self.name), stats.get("n")
74
+ if np.any(y > n) or np.any(y < 0):
75
+ raise ValueError(f"{self.name}: successes must be between 0 and n")
76
+ return np.log((y + 0.5) / (n - y + 0.5)), 1 / (y + 0.5) + 1 / (n - y + 0.5)
77
+
78
+ def min_cell(self, stats):
79
+ y, n = stats.get(self.name), stats.get("n")
80
+ return np.minimum(y, n - y).min(axis=(1, 2))
81
+
82
+ def to_model(self, natural):
83
+ return float(logit(natural))
84
+
85
+ def to_natural(self, eta):
86
+ return expit(eta)
87
+
88
+ def pymc_loglik(self, eta, stats):
89
+ import pymc as pm
90
+ y, n = stats.get(self.name), stats.get("n")
91
+ return pm.logp(pm.Binomial.dist(n=n, logit_p=eta), y).sum(axis=(1, 2))
92
+
93
+
94
+ @dataclass
95
+ class TimeToEvent(Metric):
96
+ """A one-off event (a first conversion) over periods: a discrete-time hazard.
97
+
98
+ In each period only users who haven't had the event are at risk:
99
+ events_t ~ Binomial(at_risk_t, h_t), at_risk_t = n_t - events before t
100
+ where n_t is the users assigned by the end of period t. link="cloglog" makes
101
+ effects log hazard ratios (proportional hazards), the natural choice when
102
+ periods differ in length."""
103
+ name: str
104
+ link: str = "logit"
105
+
106
+ def __post_init__(self):
107
+ if self.link not in ("logit", "cloglog"):
108
+ raise ValueError("link must be 'logit' or 'cloglog'")
109
+ self.scale = f"{self.link} hazard"
110
+
111
+ def at_risk(self, stats):
112
+ """Users still at risk at the start of each period (those not yet converted)."""
113
+ c, n = stats.get(self.name), stats.get("n")
114
+ before = np.concatenate([np.zeros_like(c[..., :1]), np.cumsum(c, axis=-1)[..., :-1]], axis=-1)
115
+ r = n - before
116
+ if np.any(c < 0) or np.any(c > r):
117
+ raise ValueError(f"{self.name}: events must be between 0 and the users still at risk")
118
+ return c, r
119
+
120
+ def min_cell(self, stats):
121
+ c, r = self.at_risk(stats)
122
+ return np.minimum(c, r - c).min(axis=(1, 2))
123
+
124
+ def estimates(self, stats):
125
+ c, r = self.at_risk(stats)
126
+ if self.link == "logit":
127
+ return np.log((c + 0.5) / (r - c + 0.5)), 1 / (c + 0.5) + 1 / (r - c + 0.5)
128
+ h = (c + 0.5) / (r + 1)
129
+ l1 = np.log1p(-h)
130
+ return np.log(-l1), h / ((1 - h) * (r + 1) * l1 ** 2)
131
+
132
+ def to_model(self, natural):
133
+ return float(logit(natural)) if self.link == "logit" else float(np.log(-np.log1p(-natural)))
134
+
135
+ def to_natural(self, eta):
136
+ return expit(eta) if self.link == "logit" else -np.expm1(-np.exp(eta))
137
+
138
+ def pymc_loglik(self, eta, stats):
139
+ import pymc as pm
140
+ import pytensor.tensor as pt
141
+ c, r = self.at_risk(stats)
142
+ if self.link == "logit":
143
+ dist = pm.Binomial.dist(n=r, logit_p=eta)
144
+ else:
145
+ dist = pm.Binomial.dist(n=r, p=pt.clip(1 - pt.exp(-pt.exp(eta)), 1e-12, 1 - 1e-12))
146
+ return pm.logp(dist, c).sum(axis=(1, 2))
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Counts
151
+ # ---------------------------------------------------------------------------
152
+
153
+ @dataclass
154
+ class Count(Metric):
155
+ """Events per user (orders, boxes, visits) on the log-rate scale.
156
+
157
+ dispersion: "auto" uses `name__sq` when present to estimate phi = Var/mean of the
158
+ per-user count and inflates the variance by phi (quasi-Poisson); "none" assumes
159
+ Poisson; "require" errors without `name__sq`. Users differ, so the Poisson variance
160
+ is almost always too small: provide `name__sq`."""
161
+ name: str
162
+ dispersion: str = "auto"
163
+ scale: str = "log rate"
164
+
165
+ def phi(self, stats):
166
+ """Quasi-Poisson dispersion per experiment, arm and period (1 when dispersion='none')."""
167
+ y, n = stats.get(self.name), stats.get("n")
168
+ if self.dispersion == "none":
169
+ return np.ones_like(y)
170
+ if not stats.has(square(self.name)):
171
+ if self.dispersion == "require":
172
+ raise KeyError(f"dispersion='require' needs {square(self.name)!r}")
173
+ return np.ones_like(y)
174
+ m = y / n
175
+ var = (stats.get(square(self.name)) - n * m ** 2) / np.maximum(n - 1, 1)
176
+ return np.maximum(1.0, var / np.maximum(m, 1e-12))
177
+
178
+ def min_cell(self, stats):
179
+ return stats.get(self.name).min(axis=(1, 2))
180
+
181
+ def estimates(self, stats):
182
+ y, n = stats.get(self.name), stats.get("n")
183
+ return np.log((y + 0.5) / n), self.phi(stats) / (y + 0.5)
184
+
185
+ def to_model(self, natural):
186
+ return float(np.log(natural))
187
+
188
+ def to_natural(self, eta):
189
+ return np.exp(eta)
190
+
191
+ def pymc_loglik(self, eta, stats):
192
+ import pymc as pm
193
+ import pytensor.tensor as pt
194
+ y, n = stats.get(self.name), stats.get("n")
195
+ ll = pm.logp(pm.Poisson.dist(mu=n * pt.exp(eta)), y)
196
+ return (ll / self.phi(stats)).sum(axis=(1, 2))
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Continuous metrics
201
+ # ---------------------------------------------------------------------------
202
+
203
+ @dataclass
204
+ class PrePeriod:
205
+ """Pre-period adjustment for a Mean, when the same users were observed before
206
+ the intervention. method="cuped" (ANCOVA; most precise under randomisation) or
207
+ "did" (difference in differences; removes a persistent arm difference exactly).
208
+ `pre` is the statistic name of the pre-period metric; needs pre, pre__sq and the
209
+ cross product with the post-period metric."""
210
+ method: str
211
+ pre: str
212
+
213
+ def __post_init__(self):
214
+ if self.method not in ("cuped", "did"):
215
+ raise ValueError("method must be 'cuped' or 'did'")
216
+
217
+
218
+ class _Gaussian(Metric):
219
+ scale = "raw"
220
+ warn_below = 30 # users per arm: a CLT rule of thumb, not measured (docs/accuracy.md)
221
+ note_below = 0
222
+
223
+ def min_cell(self, stats):
224
+ return stats.get("n").min(axis=(1, 2))
225
+
226
+ def to_model(self, natural):
227
+ return float(natural)
228
+
229
+ def to_natural(self, eta):
230
+ return eta
231
+
232
+ def pymc_loglik(self, eta, stats):
233
+ import pymc as pm
234
+ est, var = self.estimates(stats)
235
+ cov = self.covariance(stats)
236
+ if cov is None:
237
+ return pm.logp(pm.Normal.dist(mu=eta, sigma=np.sqrt(var)), est).sum(axis=(1, 2))
238
+ return sum(pm.logp(pm.MvNormal.dist(mu=eta[:, :, t], cov=cov[:, t]), est[:, :, t])
239
+ for t in range(est.shape[2]))
240
+
241
+
242
+ def _moments(stats, name):
243
+ n = stats.get("n")
244
+ if np.any(n < 2):
245
+ raise ValueError("Need at least 2 users per arm")
246
+ s = stats.get(name)
247
+ m = s / n
248
+ var = (stats.get(square(name)) - n * m ** 2) / (n - 1)
249
+ return n, m, var
250
+
251
+
252
+ @dataclass
253
+ class Mean(_Gaussian):
254
+ """A per-user mean (revenue per user, orders per user), normal by the CLT with the
255
+ variance from per-user squares. Needs n, name, name__sq (and pre-period terms for
256
+ `adjust`). Adjusted means stay on the post-period scale."""
257
+ name: str
258
+ adjust: Optional[PrePeriod] = None
259
+
260
+ def _adjusted(self, stats):
261
+ """Adjusted means est_a = r_a + k * xbar, with r_a = m_a - k * mx_a and xbar the
262
+ pre-period mean pooled over arms (k = theta for CUPED, 1 for DiD), and their full
263
+ covariance across arms. xbar is shared, so the arms' estimates are correlated:
264
+ Cov(est_a, est_b) = [a == b] Var(r_a) + k (C_a + C_b) + k^2 Var(xbar),
265
+ C_a = Cov(r_a, xbar) = (cov_a - k varx_a) / N, Var(xbar) = sum_b n_b varx_b / N^2
266
+ with k treated as known. The correlation is entirely in the level direction, so
267
+ contrasts between arms are unaffected; levels get the uncertainty in xbar."""
268
+ n, m, var = _moments(stats, self.name)
269
+ _, mx, varx = _moments(stats, self.adjust.pre)
270
+ cxy = (stats.get_cross(self.name, self.adjust.pre) - n * m * mx) / (n - 1)
271
+ N = n.sum(1, keepdims=True)
272
+ xbar = stats.get(self.adjust.pre).sum(1, keepdims=True) / N
273
+ if self.adjust.method == "cuped":
274
+ k = ((n - 1) * cxy).sum(1, keepdims=True) / np.maximum(((n - 1) * varx).sum(1, keepdims=True), 1e-300)
275
+ else:
276
+ k = np.ones_like(xbar)
277
+ var_r = np.maximum(var - 2 * k * cxy + k ** 2 * varx, 1e-300) / n # (E, A, P)
278
+ C = (cxy - k * varx) / N # (E, A, P)
279
+ V = (n * varx).sum(1, keepdims=True) / N ** 2 # (E, 1, P)
280
+ est = m - k * mx + k * xbar
281
+ Ct = np.moveaxis(C, 1, 2) # (E, P, A)
282
+ kt, Vt = np.moveaxis(k, 1, 2), np.moveaxis(V, 1, 2) # (E, P, 1)
283
+ cov = (kt[..., None] * (Ct[..., :, None] + Ct[..., None, :]) + (kt ** 2 * Vt)[..., None]
284
+ + np.einsum("epa,ab->epab", np.moveaxis(var_r, 1, 2), np.eye(var.shape[1])))
285
+ return est, cov
286
+
287
+ def estimates(self, stats):
288
+ if self.adjust is None:
289
+ n, m, var = _moments(stats, self.name)
290
+ return m, var / n
291
+ est, cov = self._adjusted(stats)
292
+ return est, np.moveaxis(np.diagonal(cov, axis1=2, axis2=3), 1, 2).copy()
293
+
294
+ def covariance(self, stats):
295
+ return None if self.adjust is None else self._adjusted(stats)[1]
296
+
297
+
298
+ @dataclass
299
+ class Ratio(_Gaussian):
300
+ """A ratio of per-user totals, sum(numerator) / sum(denominator), e.g. AOV =
301
+ revenue / orders. The user is the unit of randomisation, so the variance comes
302
+ from the delta method over users. Needs n, both metrics, their squares and their
303
+ cross product."""
304
+ numerator: str
305
+ denominator: str
306
+
307
+ @property
308
+ def name(self):
309
+ """The metric's label in results, "numerator/denominator"."""
310
+ return f"{self.numerator}/{self.denominator}"
311
+
312
+ def estimates(self, stats):
313
+ n, mx, varx = _moments(stats, self.numerator)
314
+ _, my, vary = _moments(stats, self.denominator)
315
+ if np.any(my <= 0):
316
+ raise ValueError(f"Sum of {self.denominator!r} must be positive in every arm")
317
+ cov = (stats.get_cross(self.numerator, self.denominator) - n * mx * my) / (n - 1)
318
+ R = mx / my
319
+ return R, np.maximum((varx - 2 * R * cov + R ** 2 * vary) / (n * my ** 2), 1e-300)
abfactor/pooling.py ADDED
@@ -0,0 +1,87 @@
1
+ """How experiments in a batch inform each other.
2
+
3
+ Each experiment contributes its integrand L[e] = log S_e(g) on a common grid over g
4
+ (the log likelihood ratio of "effects at scale g" against "no effects", with all
5
+ other parameters integrated out). Pooling combines them:
6
+
7
+ Independent each experiment has its own g: BF_e = int S_e(g) pi(g) dg
8
+ SharedScale one g for the batch
9
+ SpikeAndSlab one g, and each experiment has effects with probability w ~ Beta(a_w, b_w)
10
+
11
+ For every experiment the result gives log p(g | the other experiments), the prior for
12
+ its own Bayes factor, and log p(g | everything, this experiment has effects), used to
13
+ draw its effects.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ import numpy as np
20
+ from scipy.special import betaln, logsumexp
21
+
22
+
23
+ @dataclass
24
+ class Independent:
25
+ pass
26
+
27
+
28
+ @dataclass
29
+ class SharedScale:
30
+ pass
31
+
32
+
33
+ @dataclass
34
+ class SpikeAndSlab:
35
+ a_w: float = 1.0
36
+ b_w: float = 1.0
37
+ w_grid_size: int = 201
38
+
39
+
40
+ def _normalise(x, axis=-1):
41
+ return x - logsumexp(x, axis=axis, keepdims=True)
42
+
43
+
44
+ def pool(L: np.ndarray, log_pg: np.ndarray, pooling) -> dict:
45
+ """L: (E, K) log S_e(g); log_pg: (K,) log prior mass per grid cell."""
46
+ E, K = L.shape
47
+ if isinstance(pooling, Independent):
48
+ rest = np.broadcast_to(_normalise(log_pg), (E, K))
49
+ post = _normalise(L + log_pg)
50
+ return dict(log_bf=logsumexp(L + log_pg, axis=1), log_g_rest=rest, log_g_post=post,
51
+ p_effect=None, w_mean=None, log_bf_any=None, log_g_batch=None)
52
+
53
+ if isinstance(pooling, SharedScale):
54
+ total = L.sum(axis=0)
55
+ rest = _normalise(log_pg[None, :] + total[None, :] - L)
56
+ post = np.broadcast_to(_normalise(log_pg + total), (E, K))
57
+ return dict(log_bf=logsumexp(L + rest, axis=1), log_g_rest=rest, log_g_post=post,
58
+ p_effect=None, w_mean=None, log_bf_any=float(logsumexp(log_pg + total)),
59
+ log_g_batch=_normalise(log_pg + total))
60
+
61
+ if isinstance(pooling, SpikeAndSlab):
62
+ W = pooling.w_grid_size
63
+ w = (np.arange(W) + 0.5) / W
64
+ log_pw = ((pooling.a_w - 1) * np.log(w) + (pooling.b_w - 1) * np.log1p(-w)
65
+ - betaln(pooling.a_w, pooling.b_w) - np.log(W))
66
+ lw, l1w = np.log(w)[:, None], np.log1p(-w)[:, None]
67
+ term = lambda e: np.logaddexp(l1w, lw + L[e][None, :]) # (W, K)
68
+ total = np.zeros((W, K))
69
+ for e in range(E):
70
+ total += term(e)
71
+ joint = total + log_pw[:, None] + log_pg[None, :]
72
+ log_bf_any = float(logsumexp(joint))
73
+ post_wg = np.exp(joint - log_bf_any)
74
+ log_bf, p_eff = np.empty(E), np.empty(E)
75
+ rest, post = np.empty((E, K)), np.empty((E, K))
76
+ for e in range(E):
77
+ t_e = term(e)
78
+ r_wg = total - t_e + log_pw[:, None] + log_pg[None, :]
79
+ rest[e] = _normalise(logsumexp(r_wg, axis=0)) # p(g | others), w integrated
80
+ log_bf[e] = logsumexp(L[e] + rest[e])
81
+ # slab posterior for g: p(g | all data, this experiment has effects)
82
+ post[e] = _normalise(logsumexp(r_wg + lw, axis=0) + L[e])
83
+ p_eff[e] = float(np.sum(post_wg * np.exp(lw + L[e][None, :] - t_e)))
84
+ return dict(log_bf=log_bf, log_g_rest=rest, log_g_post=post, p_effect=p_eff,
85
+ w_mean=float(np.sum(post_wg.sum(axis=1) * w)), log_bf_any=log_bf_any,
86
+ log_g_batch=_normalise(logsumexp(joint + lw, axis=0)))
87
+ raise TypeError(f"Unknown pooling {pooling!r}")
abfactor/priors.py ADDED
@@ -0,0 +1,88 @@
1
+ """Priors on the treatment effect.
2
+
3
+ Effects are a* | g ~ N(0, g I) on the sum-to-zero projection (Rouder et al. 2012,
4
+ section 7.2), so the difference between any two arms is N(0, 2g) given g. A prior
5
+ is a distribution over g, discretised on an even grid in u = log g: `grid()`
6
+ returns u and the log prior mass of each cell.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ import numpy as np
13
+
14
+ from .brease import BreasePrior
15
+
16
+
17
+ def log_inv_chi2_density(g: np.ndarray, r: float) -> np.ndarray:
18
+ """log density of g ~ r^2 Inv-chi2(1)."""
19
+ return np.log(r) - 0.5 * np.log(2 * np.pi) - 1.5 * np.log(g) - r ** 2 / (2 * g)
20
+
21
+
22
+ class EffectPrior:
23
+ """Base class for priors on the effect scale g (the variance of each projected effect)."""
24
+
25
+ def grid(self, size: int) -> tuple[np.ndarray, np.ndarray]:
26
+ """(u, log_mass): grid points u = log g and the prior's log probability mass on each."""
27
+ raise NotImplementedError
28
+
29
+ def pymc_scale(self, dims):
30
+ """PyMC expression for sqrt(g), for the NUTS engine."""
31
+ raise NotImplementedError
32
+
33
+
34
+ @dataclass
35
+ class DefaultFixedEffects(EffectPrior):
36
+ """g ~ r^2 Inv-chi2(1): every pairwise contrast is Cauchy(0, sqrt(2) r) on the model scale.
37
+ For conversion rates on log odds, r around 0.1 suits online experiments; Rouder et al.'s
38
+ defaults (0.5 to 1) are for standardised effects and make the null win by default here."""
39
+ r: float = 0.1
40
+
41
+ def grid(self, size=3001):
42
+ u = np.linspace(np.log(self.r ** 2) - 25, np.log(self.r ** 2) + 12, size)
43
+ g = np.exp(u)
44
+ return u, log_inv_chi2_density(g, self.r) + u + np.log(u[1] - u[0])
45
+
46
+ def pymc_scale(self, dims=None):
47
+ import pymc as pm
48
+ g = pm.InverseGamma("g", alpha=0.5, beta=self.r ** 2 / 2, dims=dims)
49
+ return pm.math.sqrt(g)
50
+
51
+
52
+ @dataclass
53
+ class NormalEffects(EffectPrior):
54
+ """g fixed: every pairwise contrast is N(0, contrast_sd^2). Equivalent to an exchangeable
55
+ multivariate normal prior on the arms with that contrast SD."""
56
+ contrast_sd: float
57
+
58
+ def grid(self, size=None):
59
+ return np.array([np.log(self.contrast_sd ** 2 / 2)]), np.array([0.0])
60
+
61
+ def pymc_scale(self, dims=None):
62
+ return self.contrast_sd / np.sqrt(2)
63
+
64
+
65
+ @dataclass
66
+ class LearnedScale(EffectPrior):
67
+ """A distribution over g learned from completed experiments (Analysis.learn_prior).
68
+ Fixed before a new experiment starts, so it keeps sequential guarantees."""
69
+ u: np.ndarray
70
+ log_mass: np.ndarray
71
+ source: str = ""
72
+
73
+ def grid(self, size=None):
74
+ lm = self.log_mass - np.logaddexp.reduce(self.log_mass)
75
+ return self.u, lm
76
+
77
+ def pymc_scale(self, dims=None):
78
+ raise NotImplementedError("LearnedScale is supported by the quadrature engine only")
79
+
80
+
81
+ @dataclass
82
+ class Brease(EffectPrior):
83
+ """The BREASE prior of Irons & Cinelli (2023) for a binary two-arm experiment:
84
+ baseline risk, efficacy and side effects. `outcome="success"` treats the counted
85
+ statistic as a success (converted), so efficacy means preventing non-conversion;
86
+ "adverse" treats it as the adverse outcome directly."""
87
+ prior: BreasePrior = field(default_factory=BreasePrior)
88
+ outcome: str = "success"