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