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