abkit 0.1.0__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.
- abkit/__init__.py +23 -0
- abkit/audit.py +58 -0
- abkit/bootstrap.py +46 -0
- abkit/cli.py +71 -0
- abkit/demo.py +177 -0
- abkit/io.py +38 -0
- abkit/probes.py +428 -0
- abkit/py.typed +0 -0
- abkit/report.py +267 -0
- abkit/result.py +82 -0
- abkit/schema.py +71 -0
- abkit/stats.py +222 -0
- abkit/synthetic.py +144 -0
- abkit-0.1.0.dist-info/METADATA +262 -0
- abkit-0.1.0.dist-info/RECORD +19 -0
- abkit-0.1.0.dist-info/WHEEL +5 -0
- abkit-0.1.0.dist-info/entry_points.txt +2 -0
- abkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- abkit-0.1.0.dist-info/top_level.txt +1 -0
abkit/result.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Probe results and the audit container."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class ProbeResult:
|
|
10
|
+
"""One measured check.
|
|
11
|
+
|
|
12
|
+
A probe triggers in one of two ways: band probes carry an ``innocent``
|
|
13
|
+
interval and trigger when the whole 95% CI lies outside it; exact
|
|
14
|
+
procedures (a chi-square threshold, a count that must be zero) set
|
|
15
|
+
``flagged`` directly and ``innocent`` stays None. Descriptive probes set
|
|
16
|
+
neither and can never trigger.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
value: float
|
|
21
|
+
detail: str
|
|
22
|
+
n: int
|
|
23
|
+
unit: str = ""
|
|
24
|
+
ci: tuple[float, float] | None = None
|
|
25
|
+
innocent: tuple[float, float] | None = None
|
|
26
|
+
flagged: bool | None = None
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def triggered(self) -> bool:
|
|
30
|
+
if self.flagged is not None:
|
|
31
|
+
return self.flagged
|
|
32
|
+
if self.innocent is None or self.ci is None:
|
|
33
|
+
return False
|
|
34
|
+
lo, hi = self.ci
|
|
35
|
+
band_lo, band_hi = self.innocent
|
|
36
|
+
return hi < band_lo or lo > band_hi
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict[str, object]:
|
|
39
|
+
return {
|
|
40
|
+
"name": self.name,
|
|
41
|
+
"value": self.value,
|
|
42
|
+
"unit": self.unit,
|
|
43
|
+
"ci": list(self.ci) if self.ci is not None else None,
|
|
44
|
+
"innocent": list(self.innocent) if self.innocent is not None else None,
|
|
45
|
+
"triggered": self.triggered,
|
|
46
|
+
"detail": self.detail,
|
|
47
|
+
"n": self.n,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class SkippedProbe:
|
|
53
|
+
"""A check that could not run, and exactly what to log to enable it."""
|
|
54
|
+
|
|
55
|
+
name: str
|
|
56
|
+
reason: str
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict[str, object]:
|
|
59
|
+
return {"name": self.name, "skipped": True, "reason": self.reason}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class AuditResult:
|
|
64
|
+
"""Everything the audit measured for one experiment readout."""
|
|
65
|
+
|
|
66
|
+
experiment: str
|
|
67
|
+
summary: dict[str, float | str | None] = field(default_factory=dict)
|
|
68
|
+
probes: list[ProbeResult] = field(default_factory=list)
|
|
69
|
+
skipped: list[SkippedProbe] = field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def flags(self) -> list[ProbeResult]:
|
|
73
|
+
return [probe for probe in self.probes if probe.triggered]
|
|
74
|
+
|
|
75
|
+
def to_dict(self) -> dict[str, object]:
|
|
76
|
+
return {
|
|
77
|
+
"experiment": self.experiment,
|
|
78
|
+
"summary": self.summary,
|
|
79
|
+
"probes": [probe.to_dict() for probe in self.probes],
|
|
80
|
+
"skipped": [skip.to_dict() for skip in self.skipped],
|
|
81
|
+
"flags": [probe.name for probe in self.flags],
|
|
82
|
+
}
|
abkit/schema.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Data model for an experiment readout.
|
|
2
|
+
|
|
3
|
+
The schema accepts the data as it was logged, including the problems an audit
|
|
4
|
+
exists to find: duplicated unit ids, units present in several arms, or arms
|
|
5
|
+
that never appear in the intended split all pass validation and are reported
|
|
6
|
+
by the probes instead of rejected here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Unit(BaseModel):
|
|
15
|
+
"""One randomized unit (user, session, cluster) and what it produced."""
|
|
16
|
+
|
|
17
|
+
model_config = ConfigDict(extra="forbid")
|
|
18
|
+
|
|
19
|
+
unit_id: str
|
|
20
|
+
arm: str
|
|
21
|
+
metrics: dict[str, float] = Field(default_factory=dict)
|
|
22
|
+
pre: dict[str, float] = Field(default_factory=dict)
|
|
23
|
+
t: float | None = None
|
|
24
|
+
"""Assignment order or timestamp; any monotone unit works."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Look(BaseModel):
|
|
28
|
+
"""One interim analysis: cumulative sample size and the z statistic seen."""
|
|
29
|
+
|
|
30
|
+
model_config = ConfigDict(extra="forbid")
|
|
31
|
+
|
|
32
|
+
n: int = Field(gt=0)
|
|
33
|
+
z: float
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Design(BaseModel):
|
|
37
|
+
"""What the experiment intended, against which the data is audited."""
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(extra="forbid")
|
|
40
|
+
|
|
41
|
+
split: dict[str, float]
|
|
42
|
+
control: str
|
|
43
|
+
treatment: str
|
|
44
|
+
primary_metric: str
|
|
45
|
+
alpha: float = Field(default=0.05, gt=0.0, lt=1.0)
|
|
46
|
+
metrics: list[str] | None = None
|
|
47
|
+
"""Metrics that were tested; defaults to every metric observed."""
|
|
48
|
+
mde: float | None = None
|
|
49
|
+
"""Absolute effect the experiment was designed to detect."""
|
|
50
|
+
planned_n: int | None = None
|
|
51
|
+
looks: list[Look] | None = None
|
|
52
|
+
"""Interim analyses actually performed, in order, final look last."""
|
|
53
|
+
|
|
54
|
+
@field_validator("split")
|
|
55
|
+
@classmethod
|
|
56
|
+
def _positive_weights(cls, value: dict[str, float]) -> dict[str, float]:
|
|
57
|
+
if len(value) < 2:
|
|
58
|
+
raise ValueError("split needs at least two arms")
|
|
59
|
+
if any(weight <= 0 for weight in value.values()):
|
|
60
|
+
raise ValueError("split weights must be positive")
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
def model_post_init(self, __context: object) -> None:
|
|
64
|
+
for arm in (self.control, self.treatment):
|
|
65
|
+
if arm not in self.split:
|
|
66
|
+
raise ValueError(f"arm {arm!r} is not in the intended split")
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def normalized_split(self) -> dict[str, float]:
|
|
70
|
+
total = sum(self.split.values())
|
|
71
|
+
return {arm: weight / total for arm, weight in self.split.items()}
|
abkit/stats.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Statistics used by the probes.
|
|
2
|
+
|
|
3
|
+
Everything here is closed-form or an explicit seeded simulation, implemented
|
|
4
|
+
on top of the standard library and elementwise numpy — no scipy at runtime and
|
|
5
|
+
no BLAS in any statistic's path, so the same data gives the same digits on any
|
|
6
|
+
machine. The test suite cross-checks each function against scipy or
|
|
7
|
+
statsmodels.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
from collections.abc import Mapping, Sequence
|
|
14
|
+
from statistics import NormalDist
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
_STANDARD_NORMAL = NormalDist()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normal_cdf(x: float) -> float:
|
|
22
|
+
return _STANDARD_NORMAL.cdf(x)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normal_pdf(x: float) -> float:
|
|
26
|
+
return _STANDARD_NORMAL.pdf(x)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normal_ppf(q: float) -> float:
|
|
30
|
+
return _STANDARD_NORMAL.inv_cdf(q)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _lower_gamma_series(a: float, x: float) -> float:
|
|
34
|
+
"""Regularized lower incomplete gamma P(a, x) by series, for x < a + 1."""
|
|
35
|
+
term = 1.0 / a
|
|
36
|
+
total = term
|
|
37
|
+
denom = a
|
|
38
|
+
for _ in range(1000):
|
|
39
|
+
denom += 1.0
|
|
40
|
+
term *= x / denom
|
|
41
|
+
total += term
|
|
42
|
+
if abs(term) < abs(total) * 1e-16:
|
|
43
|
+
break
|
|
44
|
+
return total * math.exp(-x + a * math.log(x) - math.lgamma(a))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _upper_gamma_cf(a: float, x: float) -> float:
|
|
48
|
+
"""Regularized upper incomplete gamma Q(a, x) by continued fraction."""
|
|
49
|
+
tiny = 1e-300
|
|
50
|
+
b = x + 1.0 - a
|
|
51
|
+
c = 1.0 / tiny
|
|
52
|
+
d = 1.0 / b if b != 0 else 1.0 / tiny
|
|
53
|
+
h = d
|
|
54
|
+
for i in range(1, 1000):
|
|
55
|
+
an = -i * (i - a)
|
|
56
|
+
b += 2.0
|
|
57
|
+
d = an * d + b
|
|
58
|
+
if abs(d) < tiny:
|
|
59
|
+
d = tiny
|
|
60
|
+
c = b + an / c
|
|
61
|
+
if abs(c) < tiny:
|
|
62
|
+
c = tiny
|
|
63
|
+
d = 1.0 / d
|
|
64
|
+
delta = d * c
|
|
65
|
+
h *= delta
|
|
66
|
+
if abs(delta - 1.0) < 1e-16:
|
|
67
|
+
break
|
|
68
|
+
return h * math.exp(-x + a * math.log(x) - math.lgamma(a))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def chi2_sf(x: float, df: float) -> float:
|
|
72
|
+
"""Survival function of the chi-square distribution."""
|
|
73
|
+
if x <= 0:
|
|
74
|
+
return 1.0
|
|
75
|
+
a = df / 2.0
|
|
76
|
+
half = x / 2.0
|
|
77
|
+
if half < a + 1.0:
|
|
78
|
+
p = 1.0 - _lower_gamma_series(a, half)
|
|
79
|
+
else:
|
|
80
|
+
p = _upper_gamma_cf(a, half)
|
|
81
|
+
return min(1.0, max(0.0, p))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def srm_chi2(counts: Mapping[str, int], split: Mapping[str, float]) -> tuple[float, float]:
|
|
85
|
+
"""Chi-square statistic and p-value for observed arm counts vs the intended split."""
|
|
86
|
+
total = sum(counts.get(arm, 0) for arm in split)
|
|
87
|
+
weight_total = sum(split.values())
|
|
88
|
+
stat = 0.0
|
|
89
|
+
for arm, weight in split.items():
|
|
90
|
+
expected = total * weight / weight_total
|
|
91
|
+
observed = counts.get(arm, 0)
|
|
92
|
+
stat += (observed - expected) ** 2 / expected
|
|
93
|
+
return stat, chi2_sf(stat, len(split) - 1)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def mean(values: Sequence[float]) -> float:
|
|
97
|
+
return float(sum(values) / len(values))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def variance(values: Sequence[float]) -> float:
|
|
101
|
+
"""Unbiased sample variance."""
|
|
102
|
+
m = mean(values)
|
|
103
|
+
return float(sum((v - m) ** 2 for v in values) / (len(values) - 1))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def welch(
|
|
107
|
+
treatment: Sequence[float], control: Sequence[float]
|
|
108
|
+
) -> tuple[float, float, float, float]:
|
|
109
|
+
"""Difference in means with its standard error, z statistic and two-sided p.
|
|
110
|
+
|
|
111
|
+
Uses the normal approximation, which is what experimentation platforms
|
|
112
|
+
report at the sample sizes where an audit matters.
|
|
113
|
+
"""
|
|
114
|
+
effect = mean(treatment) - mean(control)
|
|
115
|
+
se = math.sqrt(variance(treatment) / len(treatment) + variance(control) / len(control))
|
|
116
|
+
if se == 0.0:
|
|
117
|
+
return effect, 0.0, math.inf if effect else 0.0, 0.0 if effect else 1.0
|
|
118
|
+
z = effect / se
|
|
119
|
+
p = 2.0 * (1.0 - normal_cdf(abs(z)))
|
|
120
|
+
return effect, se, z, p
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def two_proportion_z(
|
|
124
|
+
successes_a: int, n_a: int, successes_b: int, n_b: int
|
|
125
|
+
) -> tuple[float, float, float]:
|
|
126
|
+
"""Difference in proportions, pooled z statistic and two-sided p."""
|
|
127
|
+
p_a = successes_a / n_a
|
|
128
|
+
p_b = successes_b / n_b
|
|
129
|
+
pooled = (successes_a + successes_b) / (n_a + n_b)
|
|
130
|
+
se = math.sqrt(pooled * (1.0 - pooled) * (1.0 / n_a + 1.0 / n_b))
|
|
131
|
+
if se == 0.0:
|
|
132
|
+
return p_a - p_b, 0.0, 1.0
|
|
133
|
+
z = (p_a - p_b) / se
|
|
134
|
+
return p_a - p_b, z, 2.0 * (1.0 - normal_cdf(abs(z)))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def standardized_mean_difference(treatment: Sequence[float], control: Sequence[float]) -> float:
|
|
138
|
+
"""Cohen-style SMD with the pooled (average-variance) denominator."""
|
|
139
|
+
sd = math.sqrt((variance(treatment) + variance(control)) / 2.0)
|
|
140
|
+
if sd == 0.0:
|
|
141
|
+
return 0.0
|
|
142
|
+
return (mean(treatment) - mean(control)) / sd
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def benjamini_hochberg(pvalues: Sequence[float], alpha: float) -> list[bool]:
|
|
146
|
+
"""Step-up FDR control; returns a rejection flag per input p-value."""
|
|
147
|
+
m = len(pvalues)
|
|
148
|
+
order = sorted(range(m), key=lambda i: pvalues[i])
|
|
149
|
+
threshold_rank = 0
|
|
150
|
+
for rank, idx in enumerate(order, start=1):
|
|
151
|
+
if pvalues[idx] <= rank * alpha / m:
|
|
152
|
+
threshold_rank = rank
|
|
153
|
+
rejected = [False] * m
|
|
154
|
+
for rank, idx in enumerate(order, start=1):
|
|
155
|
+
if rank <= threshold_rank:
|
|
156
|
+
rejected[idx] = True
|
|
157
|
+
return rejected
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def pearson(x: Sequence[float], y: Sequence[float]) -> float:
|
|
161
|
+
mx, my = mean(x), mean(y)
|
|
162
|
+
sxy = sum((a - mx) * (b - my) for a, b in zip(x, y, strict=True))
|
|
163
|
+
sxx = sum((a - mx) ** 2 for a in x)
|
|
164
|
+
syy = sum((b - my) ** 2 for b in y)
|
|
165
|
+
if sxx == 0.0 or syy == 0.0:
|
|
166
|
+
return math.nan
|
|
167
|
+
return sxy / math.sqrt(sxx * syy)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def cuped_reduction(post: Sequence[float], pre: Sequence[float]) -> float:
|
|
171
|
+
"""Share of metric variance CUPED would have removed, rho squared."""
|
|
172
|
+
rho = pearson(post, pre)
|
|
173
|
+
if math.isnan(rho):
|
|
174
|
+
return 0.0
|
|
175
|
+
return rho * rho
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def design_analysis(true_effect: float, se: float, alpha: float) -> tuple[float, float, float]:
|
|
179
|
+
"""Power, Type-S rate and Type-M exaggeration ratio (Gelman and Carlin).
|
|
180
|
+
|
|
181
|
+
For an estimator distributed N(true_effect, se) and a two-sided test at
|
|
182
|
+
``alpha``: the probability of significance, the probability a significant
|
|
183
|
+
result has the wrong sign, and the expected |estimate| among significant
|
|
184
|
+
results divided by |true_effect|.
|
|
185
|
+
"""
|
|
186
|
+
if true_effect == 0.0 or se <= 0.0:
|
|
187
|
+
raise ValueError("design_analysis needs a nonzero effect and positive se")
|
|
188
|
+
crit = normal_ppf(1.0 - alpha / 2.0) * se
|
|
189
|
+
mu = abs(true_effect)
|
|
190
|
+
hi = (crit - mu) / se
|
|
191
|
+
lo = (-crit - mu) / se
|
|
192
|
+
p_hi = 1.0 - normal_cdf(hi)
|
|
193
|
+
p_lo = normal_cdf(lo)
|
|
194
|
+
power = p_hi + p_lo
|
|
195
|
+
upper_mass = mu * p_hi + se * normal_pdf(hi)
|
|
196
|
+
lower_mass = mu * p_lo - se * normal_pdf(lo)
|
|
197
|
+
exaggeration = (upper_mass - lower_mass) / power / mu
|
|
198
|
+
type_s = p_lo / power
|
|
199
|
+
return power, type_s, exaggeration
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def schedule_false_positive_rate(
|
|
203
|
+
look_ns: Sequence[int], alpha: float, seed: int = 0, n_sim: int = 20_000
|
|
204
|
+
) -> float:
|
|
205
|
+
"""False-positive rate of "stop when any look is significant" under H0.
|
|
206
|
+
|
|
207
|
+
Simulates the z-statistic path at the given cumulative sample sizes as a
|
|
208
|
+
Brownian motion observed at those information fractions, and counts null
|
|
209
|
+
experiments in which any look crosses the fixed-horizon critical value.
|
|
210
|
+
Seeded PCG64, elementwise numpy only.
|
|
211
|
+
"""
|
|
212
|
+
if any(b <= a for a, b in zip(look_ns, look_ns[1:], strict=False)):
|
|
213
|
+
raise ValueError("look sample sizes must be strictly increasing")
|
|
214
|
+
z_crit = normal_ppf(1.0 - alpha / 2.0)
|
|
215
|
+
fractions = np.asarray(look_ns, dtype=np.float64) / float(look_ns[-1])
|
|
216
|
+
dt = np.diff(np.concatenate([np.zeros(1), fractions]))
|
|
217
|
+
rng = np.random.Generator(np.random.PCG64(seed))
|
|
218
|
+
increments = rng.standard_normal((n_sim, len(look_ns))) * np.sqrt(dt)
|
|
219
|
+
paths = np.cumsum(increments, axis=1)
|
|
220
|
+
z_paths = paths / np.sqrt(fractions)
|
|
221
|
+
crossed = (np.abs(z_paths) >= z_crit).any(axis=1)
|
|
222
|
+
return float(crossed.sum()) / float(n_sim)
|
abkit/synthetic.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Synthetic experiments with defect dials.
|
|
2
|
+
|
|
3
|
+
Every failure mode abkit probes for can be implanted here at a chosen
|
|
4
|
+
severity, which is how the probes are validated: each must recover its own
|
|
5
|
+
dial, stay monotone in it, and stay silent on the clean experiment.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from .schema import Design, Look, Unit
|
|
15
|
+
from .stats import normal_ppf
|
|
16
|
+
|
|
17
|
+
PRE_CORRELATION = 0.6
|
|
18
|
+
"""Correlation between the pre-experiment covariate and the outcome."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def simulate(
|
|
22
|
+
n: int = 8000,
|
|
23
|
+
effect: float = 0.2,
|
|
24
|
+
seed: int = 0,
|
|
25
|
+
srm: float = 0.0,
|
|
26
|
+
imbalance: float = 0.0,
|
|
27
|
+
whales: int = 0,
|
|
28
|
+
whale_value: float = 15.0,
|
|
29
|
+
novelty: float = 0.0,
|
|
30
|
+
contaminated: int = 0,
|
|
31
|
+
extra_metrics: int = 0,
|
|
32
|
+
mde: float | None = None,
|
|
33
|
+
) -> tuple[list[Unit], Design]:
|
|
34
|
+
"""Simulate a two-arm experiment on a unit outcome with SD 1.
|
|
35
|
+
|
|
36
|
+
Dials, all zero-off:
|
|
37
|
+
|
|
38
|
+
- ``srm``: extra assignment share diverted to treatment (0.03 gives 53/47).
|
|
39
|
+
- ``imbalance``: SMD implanted on the pre-experiment covariate in treatment.
|
|
40
|
+
- ``whales``: units appended to treatment with outcome ``whale_value``.
|
|
41
|
+
- ``novelty``: early-minus-late effect gap in SD units; the effect decays
|
|
42
|
+
linearly from ``effect + novelty`` to ``effect - novelty``, so the
|
|
43
|
+
first-half mean effect exceeds the second-half mean effect by exactly
|
|
44
|
+
``novelty``.
|
|
45
|
+
- ``contaminated``: unit ids duplicated into the other arm.
|
|
46
|
+
- ``extra_metrics``: independent null secondary metrics per unit.
|
|
47
|
+
"""
|
|
48
|
+
rng = np.random.Generator(np.random.PCG64(seed))
|
|
49
|
+
units: list[Unit] = []
|
|
50
|
+
load = math.sqrt(1.0 - PRE_CORRELATION**2)
|
|
51
|
+
for i in range(n):
|
|
52
|
+
in_treatment = rng.random() < 0.5 + srm
|
|
53
|
+
arm = "treatment" if in_treatment else "control"
|
|
54
|
+
pre = float(rng.standard_normal())
|
|
55
|
+
if in_treatment:
|
|
56
|
+
pre += imbalance
|
|
57
|
+
noise = float(rng.standard_normal())
|
|
58
|
+
outcome = PRE_CORRELATION * pre + load * noise
|
|
59
|
+
if in_treatment:
|
|
60
|
+
outcome += effect + novelty * (1.0 - 2.0 * i / n)
|
|
61
|
+
metrics = {"outcome": outcome}
|
|
62
|
+
for j in range(extra_metrics):
|
|
63
|
+
metrics[f"m{j + 1}"] = float(rng.standard_normal())
|
|
64
|
+
units.append(
|
|
65
|
+
Unit(unit_id=f"u{i}", arm=arm, metrics=metrics, pre={"pre_outcome": pre}, t=float(i))
|
|
66
|
+
)
|
|
67
|
+
for w in range(whales):
|
|
68
|
+
units.append(
|
|
69
|
+
Unit(
|
|
70
|
+
unit_id=f"whale{w}",
|
|
71
|
+
arm="treatment",
|
|
72
|
+
metrics={"outcome": whale_value},
|
|
73
|
+
pre={"pre_outcome": float(rng.standard_normal())},
|
|
74
|
+
t=float(rng.integers(0, n)),
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
for c, unit in enumerate(units[:contaminated]):
|
|
78
|
+
other = "control" if unit.arm == "treatment" else "treatment"
|
|
79
|
+
units.append(
|
|
80
|
+
Unit(
|
|
81
|
+
unit_id=unit.unit_id,
|
|
82
|
+
arm=other,
|
|
83
|
+
metrics=dict(unit.metrics),
|
|
84
|
+
pre=dict(unit.pre),
|
|
85
|
+
t=float(n + whales + c),
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
design = Design(
|
|
89
|
+
split={"control": 0.5, "treatment": 0.5},
|
|
90
|
+
control="control",
|
|
91
|
+
treatment="treatment",
|
|
92
|
+
primary_metric="outcome",
|
|
93
|
+
mde=mde,
|
|
94
|
+
)
|
|
95
|
+
return units, design
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def simulate_peeking(
|
|
99
|
+
n_max: int = 5000,
|
|
100
|
+
look_every: int = 250,
|
|
101
|
+
effect: float = 0.0,
|
|
102
|
+
seed: int = 0,
|
|
103
|
+
alpha: float = 0.05,
|
|
104
|
+
) -> tuple[list[Unit], Design]:
|
|
105
|
+
"""A sequential experiment stopped at the first significant interim look.
|
|
106
|
+
|
|
107
|
+
Returns the units accrued up to the stop together with a design whose
|
|
108
|
+
``looks`` record every interim analysis performed — exactly what a peeking
|
|
109
|
+
analyst would have shipped.
|
|
110
|
+
"""
|
|
111
|
+
rng = np.random.Generator(np.random.PCG64(seed))
|
|
112
|
+
z_crit = normal_ppf(1.0 - alpha / 2.0)
|
|
113
|
+
units: list[Unit] = []
|
|
114
|
+
looks: list[Look] = []
|
|
115
|
+
sum_t = sum_c = sumsq_t = sumsq_c = 0.0
|
|
116
|
+
n_t = n_c = 0
|
|
117
|
+
for i in range(n_max):
|
|
118
|
+
in_treatment = bool(rng.random() < 0.5)
|
|
119
|
+
value = float(rng.standard_normal()) + (effect if in_treatment else 0.0)
|
|
120
|
+
arm = "treatment" if in_treatment else "control"
|
|
121
|
+
units.append(Unit(unit_id=f"u{i}", arm=arm, metrics={"outcome": value}, t=float(i)))
|
|
122
|
+
if in_treatment:
|
|
123
|
+
n_t, sum_t, sumsq_t = n_t + 1, sum_t + value, sumsq_t + value * value
|
|
124
|
+
else:
|
|
125
|
+
n_c, sum_c, sumsq_c = n_c + 1, sum_c + value, sumsq_c + value * value
|
|
126
|
+
if (i + 1) % look_every == 0 and n_t > 1 and n_c > 1:
|
|
127
|
+
mean_t, mean_c = sum_t / n_t, sum_c / n_c
|
|
128
|
+
var_t = (sumsq_t - n_t * mean_t**2) / (n_t - 1)
|
|
129
|
+
var_c = (sumsq_c - n_c * mean_c**2) / (n_c - 1)
|
|
130
|
+
se = math.sqrt(var_t / n_t + var_c / n_c)
|
|
131
|
+
z = (mean_t - mean_c) / se if se > 0 else 0.0
|
|
132
|
+
looks.append(Look(n=i + 1, z=z))
|
|
133
|
+
if abs(z) >= z_crit:
|
|
134
|
+
break
|
|
135
|
+
design = Design(
|
|
136
|
+
split={"control": 0.5, "treatment": 0.5},
|
|
137
|
+
control="control",
|
|
138
|
+
treatment="treatment",
|
|
139
|
+
primary_metric="outcome",
|
|
140
|
+
alpha=alpha,
|
|
141
|
+
planned_n=n_max,
|
|
142
|
+
looks=looks,
|
|
143
|
+
)
|
|
144
|
+
return units, design
|