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/results.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Result of an analysis: effects first, and saveable."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import xarray as xr
|
|
11
|
+
|
|
12
|
+
FORMAT_VERSION = 1
|
|
13
|
+
PAIRWISE_COLUMNS = ("log_BF10", "BF10", "BFplus0", "BFminus0", "P_better", "contrast_mean", "contrast_sd")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _labels_json(labels) -> str:
|
|
17
|
+
try:
|
|
18
|
+
return json.dumps([v.item() if hasattr(v, "item") else v for v in labels])
|
|
19
|
+
except TypeError:
|
|
20
|
+
return json.dumps([str(v) for v in labels])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _jsonable(obj):
|
|
24
|
+
if isinstance(obj, dict):
|
|
25
|
+
return {k: _jsonable(v) for k, v in obj.items() if not isinstance(v, np.ndarray)}
|
|
26
|
+
if isinstance(obj, (list, tuple)):
|
|
27
|
+
return [_jsonable(v) for v in obj]
|
|
28
|
+
if isinstance(obj, (np.integer, np.floating)):
|
|
29
|
+
return obj.item()
|
|
30
|
+
return obj
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Result:
|
|
35
|
+
"""
|
|
36
|
+
- `contrasts`: draws of each variant's effect minus control on the model scale, dims
|
|
37
|
+
(experiment, variant, sample)
|
|
38
|
+
- `natural`: draws of each arm on the natural scale in the first treated period, with
|
|
39
|
+
any persistent arm offset removed, dims (experiment, arm, sample)
|
|
40
|
+
- `omnibus`: per experiment: log_BF10 ("some arm differs"; given the rest of the batch
|
|
41
|
+
when pooled) and P_effect (spike-and-slab only)
|
|
42
|
+
- `pairwise`: per experiment and variant: log_BF10, BF10, BF+0, BF-0, P(better), the
|
|
43
|
+
contrast's posterior mean and SD
|
|
44
|
+
- `pooling`: batch summaries: w_mean, log_BF_any, posterior over g (grid, weights)
|
|
45
|
+
- `diagnostics`: engine, divergences, warnings, notes
|
|
46
|
+
- `settings`: everything needed to recompute this result: Analysis.from_settings(settings)
|
|
47
|
+
|
|
48
|
+
Save with to_netcdf(path) and load with Result.from_netcdf(path); to_frame() gives
|
|
49
|
+
the per-variant summary as a flat table.
|
|
50
|
+
"""
|
|
51
|
+
metric: str
|
|
52
|
+
scale: str
|
|
53
|
+
control: object
|
|
54
|
+
arms: list
|
|
55
|
+
experiments: list
|
|
56
|
+
contrasts: xr.DataArray
|
|
57
|
+
natural: xr.DataArray
|
|
58
|
+
omnibus: pd.DataFrame
|
|
59
|
+
pairwise: pd.DataFrame
|
|
60
|
+
pooling: dict = field(default_factory=dict)
|
|
61
|
+
diagnostics: dict = field(default_factory=dict)
|
|
62
|
+
settings: dict = field(default_factory=dict)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def variants(self) -> list:
|
|
66
|
+
"""Arm labels other than control."""
|
|
67
|
+
return [a for a in self.arms if a != self.control]
|
|
68
|
+
|
|
69
|
+
def lifts(self) -> xr.DataArray:
|
|
70
|
+
"""Relative lift of each variant over control on the natural scale, (experiment, variant, sample)."""
|
|
71
|
+
ctrl = self.natural.sel(arm=self.control)
|
|
72
|
+
lift = self.natural.sel(arm=self.variants) / ctrl - 1
|
|
73
|
+
return lift.rename(arm="variant")
|
|
74
|
+
|
|
75
|
+
def summary(self, interval: float = 0.9) -> pd.DataFrame:
|
|
76
|
+
"""One row per experiment and variant: evidence, direction and size."""
|
|
77
|
+
lo, hi = (1 - interval) / 2, 1 - (1 - interval) / 2
|
|
78
|
+
q = self.lifts().quantile([0.5, lo, hi], dim="sample")
|
|
79
|
+
om = self.omnibus.set_index("experiment")
|
|
80
|
+
rows = []
|
|
81
|
+
for _, r in self.pairwise.iterrows():
|
|
82
|
+
ql = q.sel(experiment=r["experiment"], variant=r["variant"]).values
|
|
83
|
+
row = dict(r)
|
|
84
|
+
row.update(lift_median=float(ql[0]), lift_lo=float(ql[1]), lift_hi=float(ql[2]))
|
|
85
|
+
row["omnibus_log_BF10"] = float(om.loc[r["experiment"], "log_BF10"])
|
|
86
|
+
if "P_effect" in om and pd.notna(om.loc[r["experiment"], "P_effect"]):
|
|
87
|
+
row["P_effect"] = float(om.loc[r["experiment"], "P_effect"])
|
|
88
|
+
rows.append(row)
|
|
89
|
+
return pd.DataFrame(rows)
|
|
90
|
+
|
|
91
|
+
def to_frame(self, interval: float = 0.9) -> pd.DataFrame:
|
|
92
|
+
"""The summary as a flat table, with the metric and its scale on every row."""
|
|
93
|
+
out = self.summary(interval)
|
|
94
|
+
out.insert(0, "metric", self.metric)
|
|
95
|
+
out.insert(1, "scale", self.scale)
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
# ---- saving -------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def to_dataset(self) -> xr.Dataset:
|
|
101
|
+
"""Everything in the result as one xarray Dataset (labels stored as text,
|
|
102
|
+
their original types kept in the attributes)."""
|
|
103
|
+
E, V = [str(e) for e in self.experiments], [str(v) for v in self.variants]
|
|
104
|
+
ds = xr.Dataset(
|
|
105
|
+
{"natural": (("experiment", "arm", "sample"), self.natural.values),
|
|
106
|
+
"contrasts": (("experiment", "variant", "sample"), self.contrasts.values),
|
|
107
|
+
"omnibus_log_BF10": ("experiment", self.omnibus["log_BF10"].to_numpy(float)),
|
|
108
|
+
"omnibus_P_effect": ("experiment", self.omnibus["P_effect"].to_numpy(float))},
|
|
109
|
+
coords={"experiment": E, "arm": [str(a) for a in self.arms], "variant": V})
|
|
110
|
+
pw = self.pairwise.assign(experiment=self.pairwise["experiment"].map(str),
|
|
111
|
+
variant=self.pairwise["variant"].map(str)).set_index(["experiment", "variant"])
|
|
112
|
+
for col in PAIRWISE_COLUMNS:
|
|
113
|
+
grid = pw[col].astype(float).unstack("variant").reindex(index=E, columns=V)
|
|
114
|
+
ds[f"pairwise_{col}"] = (("experiment", "variant"), grid.to_numpy())
|
|
115
|
+
for key in ("g_grid", "log_g_batch"):
|
|
116
|
+
if self.pooling.get(key) is not None:
|
|
117
|
+
ds[f"pooling_{key}"] = ("g", np.asarray(self.pooling[key], dtype=float))
|
|
118
|
+
ds.attrs.update(
|
|
119
|
+
abfactor_format=FORMAT_VERSION, metric=self.metric, scale=self.scale,
|
|
120
|
+
control=json.dumps(self.control.item() if hasattr(self.control, "item") else self.control),
|
|
121
|
+
experiment_labels=_labels_json(self.experiments), arm_labels=_labels_json(self.arms),
|
|
122
|
+
pooling=json.dumps(_jsonable(self.pooling)), diagnostics=json.dumps(_jsonable(self.diagnostics)),
|
|
123
|
+
settings=json.dumps(self.settings))
|
|
124
|
+
return ds
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def from_dataset(cls, ds: xr.Dataset) -> "Result":
|
|
128
|
+
"""Rebuild a Result from `to_dataset()` output."""
|
|
129
|
+
if int(ds.attrs.get("abfactor_format", 0)) != FORMAT_VERSION:
|
|
130
|
+
raise ValueError("Not a abfactor result, or written by an incompatible version")
|
|
131
|
+
experiments = json.loads(ds.attrs["experiment_labels"])
|
|
132
|
+
arms = json.loads(ds.attrs["arm_labels"])
|
|
133
|
+
control = json.loads(ds.attrs["control"])
|
|
134
|
+
variants = [a for a in arms if a != control]
|
|
135
|
+
natural = xr.DataArray(ds["natural"].values, dims=("experiment", "arm", "sample"),
|
|
136
|
+
coords={"experiment": experiments, "arm": arms})
|
|
137
|
+
contrasts = xr.DataArray(ds["contrasts"].values, dims=("experiment", "variant", "sample"),
|
|
138
|
+
coords={"experiment": experiments, "variant": variants})
|
|
139
|
+
omnibus = pd.DataFrame({"experiment": experiments, "log_BF10": ds["omnibus_log_BF10"].values,
|
|
140
|
+
"P_effect": ds["omnibus_P_effect"].values})
|
|
141
|
+
rows = []
|
|
142
|
+
for i, e in enumerate(experiments):
|
|
143
|
+
for j, v in enumerate(variants):
|
|
144
|
+
row = {"experiment": e, "variant": v}
|
|
145
|
+
row.update({c: float(ds[f"pairwise_{c}"].values[i, j]) for c in PAIRWISE_COLUMNS})
|
|
146
|
+
rows.append(row)
|
|
147
|
+
pooling = json.loads(ds.attrs["pooling"])
|
|
148
|
+
for key in ("g_grid", "log_g_batch"):
|
|
149
|
+
pooling[key] = ds[f"pooling_{key}"].values if f"pooling_{key}" in ds else None
|
|
150
|
+
return cls(metric=ds.attrs["metric"], scale=ds.attrs["scale"], control=control, arms=arms,
|
|
151
|
+
experiments=experiments, contrasts=contrasts, natural=natural, omnibus=omnibus,
|
|
152
|
+
pairwise=pd.DataFrame(rows), pooling=pooling,
|
|
153
|
+
diagnostics=json.loads(ds.attrs["diagnostics"]), settings=json.loads(ds.attrs["settings"]))
|
|
154
|
+
|
|
155
|
+
def to_netcdf(self, path, **kwargs) -> None:
|
|
156
|
+
"""Save to NetCDF. Works with any xarray backend; install h5netcdf or netCDF4 for
|
|
157
|
+
compression of large draws."""
|
|
158
|
+
self.to_dataset().to_netcdf(path, **kwargs)
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def from_netcdf(cls, path) -> "Result":
|
|
162
|
+
"""Load a Result saved with `to_netcdf`."""
|
|
163
|
+
return cls.from_dataset(xr.load_dataset(path))
|
abfactor/sequential.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Sequential monitoring: stop an experiment the first time the evidence crosses a
|
|
2
|
+
threshold, with a false-positive guarantee that survives looking as often as you like.
|
|
3
|
+
|
|
4
|
+
Two methods:
|
|
5
|
+
|
|
6
|
+
method="exact" (Proportion only)
|
|
7
|
+
A conditional e-process. At each look, take the new users in control and in
|
|
8
|
+
variant k and the total number of them who converted. Under the null (equal rates)
|
|
9
|
+
the split of those conversions between the two arms is hypergeometric, whatever the
|
|
10
|
+
common rate is; under a log odds ratio psi it is Fisher's noncentral hypergeometric
|
|
11
|
+
with odds ratio exp(psi), whatever the baseline is. So
|
|
12
|
+
|
|
13
|
+
E_k,t = integral over psi of prod_{looks s <= t} f(split_s; psi) / f(split_s; 0) prior(psi)
|
|
14
|
+
|
|
15
|
+
is a mixture of likelihood ratios whose denominator is the exact null distribution,
|
|
16
|
+
a test martingale under every baseline. The prior on psi is the analysis's own prior
|
|
17
|
+
on a pairwise log odds ratio (Cauchy(0, sqrt(2) r) for DefaultFixedEffects). With
|
|
18
|
+
several variants the omnibus e-process is the average of the E_k, still a test
|
|
19
|
+
martingale, and by Ville's inequality
|
|
20
|
+
|
|
21
|
+
P(E_t >= threshold at any look) <= 1 / threshold for every baseline.
|
|
22
|
+
|
|
23
|
+
This is exact: no normal approximation and no assumption about the baseline. Each
|
|
24
|
+
user must be counted once (cumulative counts of distinct users with one outcome
|
|
25
|
+
each); looks may be scheduled however you like, including on the data so far.
|
|
26
|
+
|
|
27
|
+
"Exact" is relative to that model of the data: users who are independent draws with
|
|
28
|
+
their arm's rate. Real A/A tests can break it (identical arms in the Upworthy archive
|
|
29
|
+
differed by about 12% beyond binomial noise; docs/real_data.md). Check your own A/A
|
|
30
|
+
tests with abfactor.aa before relying on it; if identical arms vary, use
|
|
31
|
+
method="bayes" with the analysis's arm_sd.
|
|
32
|
+
Conditioning on each look's total discards the little information the totals carry
|
|
33
|
+
about the effect, so there is almost no loss of power.
|
|
34
|
+
|
|
35
|
+
method="bayes"
|
|
36
|
+
The Bayes factor BF10 from the analysis. With a prior fixed in advance and a null
|
|
37
|
+
that fixes the data distribution it is a test martingale, so the same bound holds.
|
|
38
|
+
With an unknown baseline the null is composite and the bound is not guaranteed;
|
|
39
|
+
in simulation it held comfortably (calibrate() checks your own settings). It works
|
|
40
|
+
for every metric.
|
|
41
|
+
|
|
42
|
+
Either way the prior must be frozen when the Monitor is created: pool across completed
|
|
43
|
+
experiments with Analysis.learn_prior, never across the running ones.
|
|
44
|
+
"""
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
from dataclasses import dataclass
|
|
48
|
+
from typing import Callable, Optional
|
|
49
|
+
|
|
50
|
+
import numpy as np
|
|
51
|
+
import pandas as pd
|
|
52
|
+
from scipy.special import gammaln, logsumexp
|
|
53
|
+
from scipy.stats import cauchy, norm
|
|
54
|
+
|
|
55
|
+
from .analysis import Analysis
|
|
56
|
+
from .metrics import Proportion
|
|
57
|
+
from .pooling import Independent
|
|
58
|
+
from .priors import DefaultFixedEffects, LearnedScale, NormalEffects
|
|
59
|
+
from .stats import Stats
|
|
60
|
+
|
|
61
|
+
__all__ = ["Monitor", "calibrate", "binomial_aa", "psi_grid", "conditional_log_lr"]
|
|
62
|
+
|
|
63
|
+
METHODS = ("exact", "bayes")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def psi_grid(prior, size: int = 401) -> tuple[np.ndarray, np.ndarray]:
|
|
67
|
+
"""Points and log prior weights for a pairwise log odds ratio psi, equally spaced in
|
|
68
|
+
prior probability (midpoints of `size` equal-mass cells), so the grid is fine where
|
|
69
|
+
the prior is concentrated and every cell carries mass 1/size."""
|
|
70
|
+
q = (np.arange(size) + 0.5) / size
|
|
71
|
+
if isinstance(prior, DefaultFixedEffects):
|
|
72
|
+
psi = cauchy.ppf(q, 0, np.sqrt(2) * prior.r)
|
|
73
|
+
elif isinstance(prior, NormalEffects):
|
|
74
|
+
psi = norm.ppf(q, 0, prior.contrast_sd)
|
|
75
|
+
elif isinstance(prior, LearnedScale):
|
|
76
|
+
u, lm = prior.grid()
|
|
77
|
+
keep = lm > lm.max() - 40 # drop negligible mass
|
|
78
|
+
w = np.exp(lm[keep] - logsumexp(lm[keep]))
|
|
79
|
+
sd = np.sqrt(2 * np.exp(u[keep]))
|
|
80
|
+
x = np.linspace(-12, 12, 48001)
|
|
81
|
+
cdf = np.zeros_like(x) # mixture of normal CDFs,
|
|
82
|
+
for c in range(0, len(w), 64): # in chunks to bound memory
|
|
83
|
+
cdf += w[c:c + 64] @ norm.cdf(x[None, :] / sd[c:c + 64, None])
|
|
84
|
+
psi = np.interp(q, cdf, x)
|
|
85
|
+
else:
|
|
86
|
+
raise ValueError(f"No exact sequential test for prior {type(prior).__name__}")
|
|
87
|
+
return psi, np.full(size, -np.log(size))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _log_choose(n, k):
|
|
91
|
+
return gammaln(n + 1) - gammaln(k + 1) - gammaln(n - k + 1)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def conditional_log_lr(dy0, dn0, dy1, dn1, psi):
|
|
95
|
+
"""log f(split; psi) - log f(split; 0) for one look's new users, where f is Fisher's
|
|
96
|
+
noncentral hypergeometric distribution of the variant's conversions given the total.
|
|
97
|
+
dy0, dn0, dy1, dn1: arrays (E,); psi: (G,). Returns (E, G)."""
|
|
98
|
+
m = dy0 + dy1
|
|
99
|
+
lo, hi = np.maximum(0, m - dn0), np.minimum(dn1, m)
|
|
100
|
+
width = int((hi - lo).max()) + 1 if len(m) else 1
|
|
101
|
+
u = lo[:, None] + np.arange(width)[None, :] # support of the split
|
|
102
|
+
valid = u <= hi[:, None]
|
|
103
|
+
lw = np.where(valid, _log_choose(dn0[:, None], m[:, None] - u) + _log_choose(dn1[:, None], u), -np.inf)
|
|
104
|
+
# log normaliser at each psi: logsumexp_u (lw + u psi), done in chunks of psi
|
|
105
|
+
out = np.empty((len(m), len(psi)))
|
|
106
|
+
step = max(1, int(2e7 // max(1, len(m) * width))) # bound memory (~160 MB)
|
|
107
|
+
for c in range(0, len(psi), step):
|
|
108
|
+
ps = psi[c:c + step]
|
|
109
|
+
z = logsumexp(lw[:, :, None] + u[:, :, None] * ps[None, None, :], axis=1) # (E, chunk)
|
|
110
|
+
out[:, c:c + step] = dy1[:, None] * ps[None, :] - z
|
|
111
|
+
z0 = logsumexp(lw, axis=1) # psi = 0
|
|
112
|
+
return out + z0[:, None] # the binomial terms cancel
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class _State:
|
|
117
|
+
y: np.ndarray # cumulative conversions per arm
|
|
118
|
+
n: np.ndarray # cumulative users per arm
|
|
119
|
+
ll: np.ndarray # accumulated log likelihood ratio per variant and psi, (A-1, G)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Monitor:
|
|
123
|
+
"""Sequential monitoring: look at running experiments as often as you like, and stop
|
|
124
|
+
each one the first time the evidence for an effect reaches `threshold`. The
|
|
125
|
+
false-positive rate stays below 1/threshold (see docs/sequential.md).
|
|
126
|
+
|
|
127
|
+
- `analysis`: an Analysis with pooling=Independent() and the quadrature engine; its
|
|
128
|
+
prior is frozen for the life of the monitor
|
|
129
|
+
- `threshold`: stop for an effect when the evidence first reaches this; false positives
|
|
130
|
+
are then at most 1/threshold (exactly for method="exact")
|
|
131
|
+
- `futility`: optionally stop for no effect when BF01 reaches this (does not affect the
|
|
132
|
+
false-positive guarantee)
|
|
133
|
+
- `method`: "exact" (e-process, Proportion only) or "bayes" (Bayes factor); by default
|
|
134
|
+
"exact" for a Proportion without arm_sd, otherwise "bayes"
|
|
135
|
+
|
|
136
|
+
update(stats) takes the cumulative statistics at a look, one period per experiment.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(self, analysis: Analysis, threshold: float = 20.0, futility: Optional[float] = None,
|
|
140
|
+
method: Optional[str] = None):
|
|
141
|
+
if method is None:
|
|
142
|
+
method = "exact" if isinstance(analysis.metric, Proportion) and analysis.arm_sd == 0 else "bayes"
|
|
143
|
+
if method not in METHODS:
|
|
144
|
+
raise ValueError(f"method must be one of {METHODS}")
|
|
145
|
+
if not isinstance(analysis.pooling, Independent):
|
|
146
|
+
raise ValueError("Sequential monitoring needs pooling=Independent(): pool through a "
|
|
147
|
+
"LearnedScale prior from completed experiments instead")
|
|
148
|
+
if analysis.engine != "quadrature":
|
|
149
|
+
raise ValueError("Monitor uses the quadrature engine")
|
|
150
|
+
if method == "exact" and analysis.arm_sd > 0:
|
|
151
|
+
raise ValueError("method='exact' assumes each user is an independent draw with the arm's rate; "
|
|
152
|
+
"arm_sd > 0 says arms vary beyond that, which the exact test would read as an "
|
|
153
|
+
"effect. Use method='bayes' (which models arm_sd) and check it with calibrate()")
|
|
154
|
+
if method == "exact" and not isinstance(analysis.metric, Proportion):
|
|
155
|
+
raise ValueError("method='exact' is implemented for Proportion; use method='bayes' "
|
|
156
|
+
"and check it with calibrate()")
|
|
157
|
+
self.analysis, self.threshold, self.futility, self.method = analysis, threshold, futility, method
|
|
158
|
+
self.history = pd.DataFrame()
|
|
159
|
+
self._stopped: dict = {}
|
|
160
|
+
self._state: dict = {}
|
|
161
|
+
self._psi = self._log_w = None
|
|
162
|
+
self._last_variant_log_e: dict = {}
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def decisions(self) -> dict:
|
|
166
|
+
"""Experiments stopped so far: {experiment: ("effect" | "no effect", look)}."""
|
|
167
|
+
return dict(self._stopped)
|
|
168
|
+
|
|
169
|
+
def _log_e(self, stats: Stats) -> np.ndarray:
|
|
170
|
+
"""Advance each experiment's conditional e-process to this look; returns the
|
|
171
|
+
omnibus log E_t per experiment (the log of the average over variants)."""
|
|
172
|
+
if self._psi is None:
|
|
173
|
+
self._psi, self._log_w = psi_grid(self.analysis.prior)
|
|
174
|
+
name = self.analysis.metric.name
|
|
175
|
+
y_all, n_all = stats.get(name)[..., 0], stats.get("n")[..., 0]
|
|
176
|
+
E, A = y_all.shape
|
|
177
|
+
prev_y, prev_n, ll = np.empty_like(y_all), np.empty_like(n_all), np.empty((E, A - 1, len(self._psi)))
|
|
178
|
+
for i, e in enumerate(stats.experiments):
|
|
179
|
+
st = self._state.get(e)
|
|
180
|
+
if st is None:
|
|
181
|
+
st = _State(np.zeros(A), np.zeros(A), np.zeros((A - 1, len(self._psi))))
|
|
182
|
+
prev_y[i], prev_n[i], ll[i] = st.y, st.n, st.ll
|
|
183
|
+
dy, dn = y_all - prev_y, n_all - prev_n
|
|
184
|
+
bad = (dn < 0) | (dy < 0) | (dy > dn)
|
|
185
|
+
if bad.any():
|
|
186
|
+
e = stats.experiments[int(np.where(bad.any(axis=1))[0][0])]
|
|
187
|
+
raise ValueError(f"Experiment {e!r}: statistics must be cumulative and non-decreasing")
|
|
188
|
+
for k in range(1, A):
|
|
189
|
+
ll[:, k - 1] += conditional_log_lr(dy[:, 0], dn[:, 0], dy[:, k], dn[:, k], self._psi)
|
|
190
|
+
log_ek = logsumexp(ll + self._log_w[None, None, :], axis=2) # (E, A-1)
|
|
191
|
+
log_e = logsumexp(log_ek, axis=1) - np.log(A - 1)
|
|
192
|
+
for i, e in enumerate(stats.experiments):
|
|
193
|
+
self._state[e] = _State(y_all[i].copy(), n_all[i].copy(), ll[i].copy())
|
|
194
|
+
self._last_variant_log_e = dict(zip(stats.experiments, log_ek))
|
|
195
|
+
return log_e
|
|
196
|
+
|
|
197
|
+
def update(self, stats: Stats, look=None) -> pd.DataFrame:
|
|
198
|
+
"""Assess each experiment on its cumulative statistics at this look. Experiments
|
|
199
|
+
already stopped keep their decision (their e-process stops too)."""
|
|
200
|
+
if stats.shape[2] != 1:
|
|
201
|
+
raise ValueError("Monitor takes one period per experiment: pass the cumulative totals")
|
|
202
|
+
look = (self.history["look"].nunique() if len(self.history) else 0) if look is None else look
|
|
203
|
+
lbf = self.analysis.log_bf10(stats)
|
|
204
|
+
active = np.array([e not in self._stopped for e in stats.experiments])
|
|
205
|
+
log_e = np.full(len(stats), np.nan)
|
|
206
|
+
if self.method == "exact" and active.any():
|
|
207
|
+
sub = stats.sel([e for e, a in zip(stats.experiments, active) if a])
|
|
208
|
+
log_e[active] = self._log_e(sub)
|
|
209
|
+
evidence = log_e if self.method == "exact" else lbf
|
|
210
|
+
rows = []
|
|
211
|
+
for i, e in enumerate(stats.experiments):
|
|
212
|
+
if e in self._stopped:
|
|
213
|
+
decision = self._stopped[e][0]
|
|
214
|
+
elif evidence[i] >= np.log(self.threshold):
|
|
215
|
+
decision = "effect"
|
|
216
|
+
self._stopped[e] = (decision, look)
|
|
217
|
+
elif self.futility is not None and -lbf[i] >= np.log(self.futility):
|
|
218
|
+
decision = "no effect"
|
|
219
|
+
self._stopped[e] = (decision, look)
|
|
220
|
+
else:
|
|
221
|
+
decision = "continue"
|
|
222
|
+
rows.append(dict(look=look, experiment=e, log_BF10=float(lbf[i]),
|
|
223
|
+
log_E=float(log_e[i]), decision=decision))
|
|
224
|
+
out = pd.DataFrame(rows)
|
|
225
|
+
self.history = pd.concat([self.history, out], ignore_index=True) if len(self.history) else out
|
|
226
|
+
return out
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# Calibration by simulation
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def binomial_aa(baseline: float, users_per_look: int, arms: int = 2) -> Callable:
|
|
234
|
+
"""A/A simulator for calibrate(): every arm converts at `baseline`."""
|
|
235
|
+
def simulate(rng, n_experiments, looks):
|
|
236
|
+
y = rng.binomial(users_per_look, baseline, size=(looks, n_experiments, arms)).cumsum(0)
|
|
237
|
+
n = users_per_look * np.arange(1, looks + 1)
|
|
238
|
+
return [Stats.from_arrays(n=np.full((n_experiments, arms), float(n[t])), converted=y[t])
|
|
239
|
+
for t in range(looks)]
|
|
240
|
+
return simulate
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def calibrate(make_monitor: Callable[[], Monitor], simulate: Callable, looks: int = 30,
|
|
244
|
+
n_experiments: int = 1000, seed: int = 0) -> dict:
|
|
245
|
+
"""Share of A/A experiments a monitor stops for an effect, by simulation.
|
|
246
|
+
|
|
247
|
+
- `make_monitor`: returns a fresh Monitor (so each run starts with no state)
|
|
248
|
+
- `simulate`: simulate(rng, n_experiments, looks) -> list of cumulative Stats, one per
|
|
249
|
+
look; binomial_aa(...) gives one for conversion rates
|
|
250
|
+
|
|
251
|
+
Returns the false-positive rate, its 95% interval, and the bound 1/threshold."""
|
|
252
|
+
rng = np.random.default_rng(seed)
|
|
253
|
+
mon = make_monitor()
|
|
254
|
+
for t, st in enumerate(simulate(rng, n_experiments, looks)):
|
|
255
|
+
mon.update(st, look=t)
|
|
256
|
+
fp = sum(d == "effect" for d, _ in mon.decisions.values()) / n_experiments
|
|
257
|
+
se = np.sqrt(max(fp * (1 - fp), 1e-12) / n_experiments)
|
|
258
|
+
return {"false_positive_rate": fp, "interval_95": (max(0.0, fp - 1.96 * se), fp + 1.96 * se),
|
|
259
|
+
"bound": 1 / mon.threshold, "method": mon.method, "looks": looks, "experiments": n_experiments}
|
abfactor/spec.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Turn the pieces of an analysis into plain JSON-able data and back.
|
|
2
|
+
|
|
3
|
+
Every metric, prior and pooling choice is a dataclass; `to_spec` records its type
|
|
4
|
+
and fields, `from_spec` rebuilds it. This is what makes a saved Result reproducible:
|
|
5
|
+
Analysis.from_settings(result.settings).fit(stats) recomputes it exactly.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
from importlib import metadata
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from .brease import BreasePrior
|
|
15
|
+
from .metrics import Count, Mean, PrePeriod, Proportion, Ratio, TimeToEvent
|
|
16
|
+
from .pooling import Independent, SharedScale, SpikeAndSlab
|
|
17
|
+
from .priors import Brease, DefaultFixedEffects, LearnedScale, NormalEffects
|
|
18
|
+
|
|
19
|
+
REGISTRY = {cls.__name__: cls for cls in (
|
|
20
|
+
Proportion, TimeToEvent, Count, Mean, Ratio, PrePeriod,
|
|
21
|
+
DefaultFixedEffects, NormalEffects, LearnedScale, Brease, BreasePrior,
|
|
22
|
+
Independent, SharedScale, SpikeAndSlab)}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def to_spec(obj):
|
|
26
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
27
|
+
name = type(obj).__name__
|
|
28
|
+
if name not in REGISTRY:
|
|
29
|
+
raise TypeError(f"Cannot record {name}: not a known abfactor type")
|
|
30
|
+
out = {"type": name}
|
|
31
|
+
for f in dataclasses.fields(obj):
|
|
32
|
+
if f.init:
|
|
33
|
+
out[f.name] = to_spec(getattr(obj, f.name))
|
|
34
|
+
return out
|
|
35
|
+
if isinstance(obj, np.ndarray):
|
|
36
|
+
return {"type": "ndarray", "values": obj.tolist()}
|
|
37
|
+
if isinstance(obj, (np.integer, np.floating)):
|
|
38
|
+
return obj.item()
|
|
39
|
+
if isinstance(obj, (list, tuple)):
|
|
40
|
+
return [to_spec(x) for x in obj]
|
|
41
|
+
if isinstance(obj, dict):
|
|
42
|
+
return {k: to_spec(v) for k, v in obj.items()}
|
|
43
|
+
return obj
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def from_spec(spec):
|
|
47
|
+
if isinstance(spec, dict) and "type" in spec:
|
|
48
|
+
if spec["type"] == "ndarray":
|
|
49
|
+
return np.asarray(spec["values"], dtype=float)
|
|
50
|
+
cls = REGISTRY[spec["type"]]
|
|
51
|
+
return cls(**{k: from_spec(v) for k, v in spec.items() if k != "type"})
|
|
52
|
+
if isinstance(spec, list):
|
|
53
|
+
return [from_spec(x) for x in spec]
|
|
54
|
+
if isinstance(spec, dict):
|
|
55
|
+
return {k: from_spec(v) for k, v in spec.items()}
|
|
56
|
+
return spec
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def versions() -> dict:
|
|
60
|
+
out = {}
|
|
61
|
+
for pkg in ("abfactor", "numpy", "scipy", "pandas", "xarray", "pymc"):
|
|
62
|
+
try:
|
|
63
|
+
out[pkg] = metadata.version(pkg)
|
|
64
|
+
except metadata.PackageNotFoundError:
|
|
65
|
+
out[pkg] = None
|
|
66
|
+
return out
|