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/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"
|