errorbars 0.1.2__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.
errorbars/power.py ADDED
@@ -0,0 +1,137 @@
1
+ """Power analysis for paired LLM eval comparisons.
2
+
3
+ Model: each question contributes a score whose single-sample variance is
4
+ ``p*(1-p)`` for a binary metric (or a user-supplied ``variance`` for a
5
+ continuous one). Averaging ``samples_per_question`` repeated generations
6
+ per question reduces that variance by a factor of ``samples_per_question``
7
+ (we do not assume a separate item-difficulty/decoding-noise split for
8
+ planning purposes — see docs/formulas.md for the rationale and how this
9
+ differs from the post-hoc decomposition in ``summarize``). Pairing two
10
+ models on the same questions with per-question score correlation ``rho``
11
+ shrinks the variance of the difference to ``2*V*(1-rho)`` relative to
12
+ ``2*V`` for an unpaired design. Clustering of questions (e.g. several
13
+ questions per passage) inflates that further by the Kish design effect.
14
+
15
+ This is a standard normal-approximation power formula (e.g. Fleiss,
16
+ Levin & Paik, "Statistical Methods for Rates and Proportions", 3rd ed.,
17
+ ch. 3) generalized with the pairing and clustering factors above.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import math
23
+ from dataclasses import dataclass
24
+ from statistics import NormalDist
25
+ from typing import Any
26
+
27
+ from errorbars.stats import z_for_confidence
28
+
29
+ __all__ = ["PowerResult", "questions_needed", "minimum_detectable_effect", "per_question_variance"]
30
+
31
+ _NORMAL = NormalDist()
32
+
33
+
34
+ def per_question_variance(
35
+ baseline_accuracy: float | None = None,
36
+ variance: float | None = None,
37
+ samples_per_question: int = 1,
38
+ ) -> float:
39
+ """Per-question score variance after averaging repeated samples.
40
+
41
+ Exactly one of ``baseline_accuracy`` (binary metric, variance =
42
+ p(1-p)) or ``variance`` (continuous metric, raw per-sample variance)
43
+ must be given.
44
+ """
45
+ if (baseline_accuracy is None) == (variance is None):
46
+ raise ValueError("pass exactly one of baseline_accuracy or variance")
47
+ if samples_per_question < 1:
48
+ raise ValueError("samples_per_question must be >= 1")
49
+ if baseline_accuracy is not None and not 0.0 < baseline_accuracy < 1.0:
50
+ # p(1-p) is 0 at either end, which would make any gap look free to detect.
51
+ raise ValueError(f"baseline_accuracy must be strictly between 0 and 1, got {baseline_accuracy}")
52
+ if variance is not None and not variance > 0:
53
+ raise ValueError(f"variance must be positive, got {variance}")
54
+ v = baseline_accuracy * (1 - baseline_accuracy) if baseline_accuracy is not None else variance
55
+ return v / samples_per_question # type: ignore[operator]
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class PowerResult:
60
+ n_questions: int
61
+ delta: float
62
+ alpha: float
63
+ power: float
64
+ rho: float
65
+ samples_per_question: int
66
+ cluster_design_effect: float
67
+ per_question_variance: float
68
+
69
+ def as_dict(self) -> dict[str, Any]:
70
+ return {
71
+ "n_questions": self.n_questions,
72
+ "delta": self.delta,
73
+ "alpha": self.alpha,
74
+ "power": self.power,
75
+ "rho": self.rho,
76
+ "samples_per_question": self.samples_per_question,
77
+ "cluster_design_effect": self.cluster_design_effect,
78
+ "per_question_variance": self.per_question_variance,
79
+ }
80
+
81
+
82
+ def _z_beta(power: float) -> float:
83
+ if not 0.0 < power < 1.0:
84
+ raise ValueError(f"power must be in (0, 1), got {power}")
85
+ return _NORMAL.inv_cdf(power) # one-sided: z such that Phi(z) = power
86
+
87
+
88
+ def questions_needed(
89
+ delta: float,
90
+ baseline_accuracy: float | None = None,
91
+ variance: float | None = None,
92
+ alpha: float = 0.05,
93
+ power: float = 0.8,
94
+ rho: float = 0.0,
95
+ samples_per_question: int = 1,
96
+ cluster_design_effect: float = 1.0,
97
+ ) -> PowerResult:
98
+ """Number of questions needed to detect a paired mean difference ``delta``.
99
+
100
+ ``n = (z_{a/2} + z_b)^2 * 2*V*(1-rho) * deff / delta^2`` where V is the
101
+ per-question variance (see ``per_question_variance``).
102
+ """
103
+ if delta <= 0:
104
+ raise ValueError("delta must be positive")
105
+ if not -1.0 <= rho <= 1.0:
106
+ raise ValueError("rho must be in [-1, 1]")
107
+ if cluster_design_effect < 1.0:
108
+ raise ValueError("cluster_design_effect must be >= 1")
109
+ v = per_question_variance(baseline_accuracy, variance, samples_per_question)
110
+ if baseline_accuracy is not None and baseline_accuracy + delta > 1.0:
111
+ raise ValueError(
112
+ f"baseline_accuracy + delta = {baseline_accuracy + delta:.3g}: an accuracy can't exceed 1"
113
+ )
114
+ z_a = z_for_confidence(1 - alpha)
115
+ z_b = _z_beta(power)
116
+ n = ((z_a + z_b) ** 2) * 2 * v * (1 - rho) * cluster_design_effect / (delta**2)
117
+ n_int = max(2, math.ceil(n))
118
+ return PowerResult(n_int, delta, alpha, power, rho, samples_per_question, cluster_design_effect, v)
119
+
120
+
121
+ def minimum_detectable_effect(
122
+ n_questions: int,
123
+ baseline_accuracy: float | None = None,
124
+ variance: float | None = None,
125
+ alpha: float = 0.05,
126
+ power: float = 0.8,
127
+ rho: float = 0.0,
128
+ samples_per_question: int = 1,
129
+ cluster_design_effect: float = 1.0,
130
+ ) -> float:
131
+ """Smallest paired difference detectable with a given n, alpha, power."""
132
+ if n_questions < 2:
133
+ raise ValueError("n_questions must be >= 2")
134
+ v = per_question_variance(baseline_accuracy, variance, samples_per_question)
135
+ z_a = z_for_confidence(1 - alpha)
136
+ z_b = _z_beta(power)
137
+ return (z_a + z_b) * math.sqrt(2 * v * (1 - rho) * cluster_design_effect / n_questions)
errorbars/py.typed ADDED
File without changes
errorbars/stats.py ADDED
@@ -0,0 +1,329 @@
1
+ """Core statistics: means, confidence intervals, clustering diagnostics.
2
+
3
+ Formulas are derived from scratch (no scipy/statsmodels at runtime) and
4
+ cross-checked in ``tests/`` against statsmodels and scipy, which are used
5
+ only as test-time oracles. See ``docs/formulas.md`` for derivations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from dataclasses import dataclass
12
+ from statistics import NormalDist
13
+
14
+ import numpy as np
15
+ from numpy.typing import ArrayLike
16
+
17
+ __all__ = [
18
+ "MeanEstimate",
19
+ "ClusterDiagnostics",
20
+ "z_for_confidence",
21
+ "mean_ci_clt",
22
+ "wilson_ci",
23
+ "bootstrap_ci",
24
+ "cluster_robust_se",
25
+ "intraclass_correlation",
26
+ "design_effect",
27
+ "within_between_variance",
28
+ "is_binary",
29
+ "t_two_sided_p",
30
+ "t_for_confidence",
31
+ "regularized_incomplete_beta",
32
+ ]
33
+
34
+ _NORMAL = NormalDist()
35
+
36
+
37
+ def z_for_confidence(confidence: float) -> float:
38
+ """Two-sided normal critical value, e.g. 1.959964 for 95% confidence."""
39
+ if not 0.0 < confidence < 1.0:
40
+ raise ValueError(f"confidence must be in (0, 1), got {confidence}")
41
+ return _NORMAL.inv_cdf(0.5 + confidence / 2.0)
42
+
43
+
44
+ def _beta_continued_fraction(a: float, b: float, x: float) -> float:
45
+ """Continued fraction for the incomplete beta function, evaluated with the
46
+ modified Lentz method (Numerical Recipes, 3rd ed., section 6.4)."""
47
+ tiny = 1e-300
48
+ qab, qap, qam = a + b, a + 1.0, a - 1.0
49
+ c, d = 1.0, 1.0 - qab * x / qap
50
+ d = 1.0 / (d if abs(d) > tiny else tiny)
51
+ h = d
52
+ for m in range(1, 1000):
53
+ m2 = 2 * m
54
+ even = m * (b - m) * x / ((qam + m2) * (a + m2))
55
+ odd = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2))
56
+ for aa in (even, odd):
57
+ d = 1.0 + aa * d
58
+ d = 1.0 / (d if abs(d) > tiny else tiny)
59
+ c = 1.0 + aa / c
60
+ c = c if abs(c) > tiny else tiny
61
+ h *= d * c
62
+ if abs(d * c - 1.0) < 1e-15:
63
+ break
64
+ return h
65
+
66
+
67
+ def regularized_incomplete_beta(a: float, b: float, x: float) -> float:
68
+ """I_x(a, b), the regularized incomplete beta function, for a, b > 0."""
69
+ if x <= 0.0:
70
+ return 0.0
71
+ if x >= 1.0:
72
+ return 1.0
73
+ log_front = (
74
+ math.lgamma(a + b) - math.lgamma(a) - math.lgamma(b) + a * math.log(x) + b * math.log1p(-x)
75
+ )
76
+ front = math.exp(log_front)
77
+ # The continued fraction converges fast for x < (a + 1) / (a + b + 2); use the
78
+ # symmetry I_x(a, b) = 1 - I_{1-x}(b, a) on the other side.
79
+ if x < (a + 1.0) / (a + b + 2.0):
80
+ return front * _beta_continued_fraction(a, b, x) / a
81
+ return 1.0 - front * _beta_continued_fraction(b, a, 1.0 - x) / b
82
+
83
+
84
+ def t_two_sided_p(t_stat: float, dof: float) -> float:
85
+ """Two-sided p-value of a Student-t statistic with `dof` degrees of
86
+ freedom: P(|T| >= |t|) = I_{dof/(dof+t^2)}(dof/2, 1/2)."""
87
+ if dof <= 0:
88
+ raise ValueError("degrees of freedom must be positive")
89
+ if math.isinf(t_stat):
90
+ return 0.0
91
+ return regularized_incomplete_beta(dof / 2.0, 0.5, dof / (dof + t_stat * t_stat))
92
+
93
+
94
+ def t_for_confidence(confidence: float, dof: float) -> float:
95
+ """Two-sided Student-t critical value, e.g. 2.0227 for 95% with 39 degrees
96
+ of freedom (1.96 as dof grows). Found by bisection on `t_two_sided_p`."""
97
+ if not 0.0 < confidence < 1.0:
98
+ raise ValueError(f"confidence must be in (0, 1), got {confidence}")
99
+ alpha = 1.0 - confidence
100
+ lo, hi = 0.0, max(10.0, z_for_confidence(confidence) * 4)
101
+ while t_two_sided_p(hi, dof) > alpha:
102
+ hi *= 2.0
103
+ for _ in range(200):
104
+ mid = (lo + hi) / 2.0
105
+ if t_two_sided_p(mid, dof) > alpha:
106
+ lo = mid
107
+ else:
108
+ hi = mid
109
+ if hi - lo < 1e-12:
110
+ break
111
+ return (lo + hi) / 2.0
112
+
113
+
114
+ def is_binary(values: ArrayLike) -> bool:
115
+ """True if every value is (close to) 0 or 1."""
116
+ arr = np.asarray(values, dtype=float)
117
+ if arr.size == 0:
118
+ return False
119
+ return bool(np.all((np.isclose(arr, 0.0)) | (np.isclose(arr, 1.0))))
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class MeanEstimate:
124
+ """A point estimate of a mean with its uncertainty."""
125
+
126
+ mean: float
127
+ se: float
128
+ ci_low: float
129
+ ci_high: float
130
+ confidence: float
131
+ method: str
132
+ n: int
133
+
134
+ def as_dict(self) -> dict[str, float | str | int]:
135
+ return {
136
+ "mean": self.mean,
137
+ "se": self.se,
138
+ "ci_low": self.ci_low,
139
+ "ci_high": self.ci_high,
140
+ "confidence": self.confidence,
141
+ "method": self.method,
142
+ "n": self.n,
143
+ }
144
+
145
+
146
+ def mean_ci_clt(values: ArrayLike, confidence: float = 0.95) -> MeanEstimate:
147
+ """CLT (Wald) confidence interval for a sample mean.
148
+
149
+ SE = s / sqrt(n) using the sample standard deviation (ddof=1). This is
150
+ the default estimator for continuous scores and for binary scores when
151
+ n is reasonably large (see ``wilson_ci`` for the small-n binary case).
152
+ """
153
+ arr = np.asarray(values, dtype=float)
154
+ n = arr.size
155
+ if n < 2:
156
+ raise ValueError("need at least 2 observations for a CLT interval")
157
+ mean = float(arr.mean())
158
+ se = float(arr.std(ddof=1) / np.sqrt(n))
159
+ z = z_for_confidence(confidence)
160
+ return MeanEstimate(mean, se, mean - z * se, mean + z * se, confidence, "clt", n)
161
+
162
+
163
+ def wilson_ci(successes: int, n: int, confidence: float = 0.95) -> MeanEstimate:
164
+ """Wilson score interval for a binomial proportion.
165
+
166
+ Preferred over the CLT/Wald interval for binary scores when n is small
167
+ or the proportion is near 0/1, where the Wald interval can badly
168
+ under-cover or extend outside [0, 1]. Reference: Wilson (1927),
169
+ "Probable Inference, the Law of Succession, and Statistical Inference."
170
+ """
171
+ if n <= 0:
172
+ raise ValueError("n must be positive")
173
+ if not 0 <= successes <= n:
174
+ raise ValueError("successes must be between 0 and n")
175
+ z = z_for_confidence(confidence)
176
+ p_hat = successes / n
177
+ z2 = z * z
178
+ denom = 1.0 + z2 / n
179
+ center = (p_hat + z2 / (2 * n)) / denom
180
+ half_width = (z * np.sqrt(p_hat * (1 - p_hat) / n + z2 / (4 * n * n))) / denom
181
+ se = float(np.sqrt(p_hat * (1 - p_hat) / n)) if 0 < p_hat < 1 else float(np.sqrt(z2) / (2 * n))
182
+ return MeanEstimate(
183
+ p_hat, se, float(center - half_width), float(center + half_width), confidence, "wilson", n
184
+ )
185
+
186
+
187
+ def bootstrap_ci(
188
+ values: ArrayLike,
189
+ confidence: float = 0.95,
190
+ n_boot: int = 10_000,
191
+ seed: int | None = 0,
192
+ ) -> MeanEstimate:
193
+ """Percentile bootstrap confidence interval for the mean."""
194
+ arr = np.asarray(values, dtype=float)
195
+ n = arr.size
196
+ if n < 2:
197
+ raise ValueError("need at least 2 observations to bootstrap")
198
+ rng = np.random.default_rng(seed)
199
+ idx = rng.integers(0, n, size=(n_boot, n))
200
+ boot_means = arr[idx].mean(axis=1)
201
+ alpha = 1 - confidence
202
+ lo, hi = np.quantile(boot_means, [alpha / 2, 1 - alpha / 2])
203
+ mean = float(arr.mean())
204
+ se = float(boot_means.std(ddof=1))
205
+ return MeanEstimate(mean, se, float(lo), float(hi), confidence, "bootstrap", n)
206
+
207
+
208
+ @dataclass(frozen=True)
209
+ class ClusterDiagnostics:
210
+ """Design-effect diagnostics for clustered data."""
211
+
212
+ icc: float
213
+ avg_cluster_size: float
214
+ n_clusters: int
215
+ design_effect: float
216
+
217
+ def as_dict(self) -> dict[str, float | int]:
218
+ return {
219
+ "icc": self.icc,
220
+ "avg_cluster_size": self.avg_cluster_size,
221
+ "n_clusters": self.n_clusters,
222
+ "design_effect": self.design_effect,
223
+ }
224
+
225
+
226
+ def _group_by(values: np.ndarray, clusters: np.ndarray) -> list[np.ndarray]:
227
+ order = np.argsort(clusters, kind="stable")
228
+ values_sorted = values[order]
229
+ clusters_sorted = clusters[order]
230
+ # np.diff doesn't support string/object dtypes; compare neighbors directly.
231
+ boundaries = np.flatnonzero(clusters_sorted[1:] != clusters_sorted[:-1]) + 1
232
+ return np.split(values_sorted, boundaries)
233
+
234
+
235
+ def intraclass_correlation(values: ArrayLike, clusters: ArrayLike) -> float:
236
+ """One-way random-effects ANOVA estimate of the intraclass correlation.
237
+
238
+ ICC = (MSB - MSW) / (MSB + (k0 - 1) * MSW), the classic Fisher/Kish
239
+ estimator for clustered/panel data with possibly unequal cluster sizes
240
+ (k0 is the harmonic-mean-like correction, Kish 1965 eq. 8.4.1).
241
+ Returns 0.0 (no clustering signal) if there is only one cluster or all
242
+ clusters are singletons.
243
+ """
244
+ arr = np.asarray(values, dtype=float)
245
+ clu = np.asarray(clusters)
246
+ groups = _group_by(arr, clu)
247
+ g = len(groups)
248
+ n = arr.size
249
+ if g <= 1 or g == n:
250
+ return 0.0
251
+ grand_mean = arr.mean()
252
+ ssb = sum(len(grp) * (grp.mean() - grand_mean) ** 2 for grp in groups)
253
+ ssw = sum(((grp - grp.mean()) ** 2).sum() for grp in groups)
254
+ msb = ssb / (g - 1)
255
+ dof_w = n - g
256
+ if dof_w <= 0:
257
+ return 0.0
258
+ msw = ssw / dof_w
259
+ sizes = np.array([len(grp) for grp in groups], dtype=float)
260
+ k0 = (n - (sizes**2).sum() / n) / (g - 1)
261
+ if msw == 0 and msb == 0:
262
+ return 0.0
263
+ denom = msb + (k0 - 1) * msw
264
+ if denom == 0:
265
+ return 0.0
266
+ icc = (msb - msw) / denom
267
+ return float(np.clip(icc, -1.0, 1.0))
268
+
269
+
270
+ def design_effect(icc: float, avg_cluster_size: float) -> float:
271
+ """Kish's design effect: 1 + (avg_cluster_size - 1) * ICC."""
272
+ return 1.0 + (avg_cluster_size - 1.0) * icc
273
+
274
+
275
+ def cluster_robust_se(values: ArrayLike, clusters: ArrayLike) -> float:
276
+ """Cluster-robust SE of the sample mean.
277
+
278
+ Equivalent to fitting OLS of ``values`` on a constant with
279
+ ``cov_type="cluster"`` in statsmodels (CR1 sandwich estimator with the
280
+ default small-sample correction ``G/(G-1) * (N-1)/(N-K)``). See
281
+ MacKinnon & White (1985) and Cameron, Gelbach & Miller (2011).
282
+ """
283
+ arr = np.asarray(values, dtype=float)
284
+ clu = np.asarray(clusters)
285
+ n = arr.size
286
+ mean = arr.mean()
287
+ resid = arr - mean
288
+ groups = _group_by(resid, clu)
289
+ g = len(groups)
290
+ meat = sum(float(grp.sum()) ** 2 for grp in groups)
291
+ if g <= 1:
292
+ # Falls back to the (unbiased) heteroskedasticity-robust / CLT SE.
293
+ return float(np.sqrt((resid**2).sum() / (n * (n - 1))))
294
+ # Small-sample correction G/(G-1) * (N-1)/(N-K), with K=1 (the constant),
295
+ # so (N-1)/(N-K) = 1 and only the cluster-count term survives.
296
+ correction = g / (g - 1)
297
+ variance = (meat / n**2) * correction
298
+ return float(np.sqrt(max(variance, 0.0)))
299
+
300
+
301
+ def within_between_variance(
302
+ values: ArrayLike, question_ids: ArrayLike
303
+ ) -> tuple[float, float]:
304
+ """Decompose variance across repeated samples per question.
305
+
306
+ Returns ``(var_within, var_between)`` where ``var_within`` is the mean
307
+ per-question sampling variance (decoding noise across repeated samples
308
+ of the same question) and ``var_between`` is the variance of the
309
+ per-question means (item difficulty). Uses the standard one-way
310
+ random-effects moment estimator: ``var_between = max(0, MSB - MSW) /
311
+ k0`` with the same unequal-group correction as ``intraclass_correlation``.
312
+ """
313
+ arr = np.asarray(values, dtype=float)
314
+ qid = np.asarray(question_ids)
315
+ groups = _group_by(arr, qid)
316
+ q = len(groups)
317
+ n = arr.size
318
+ within_terms = [((grp - grp.mean()) ** 2).sum() for grp in groups if len(grp) > 1]
319
+ within_dof = sum(len(grp) - 1 for grp in groups if len(grp) > 1)
320
+ var_within = float(sum(within_terms) / within_dof) if within_dof > 0 else 0.0
321
+ if q <= 1:
322
+ return var_within, 0.0
323
+ grand_mean = arr.mean()
324
+ ssb = sum(len(grp) * (grp.mean() - grand_mean) ** 2 for grp in groups)
325
+ msb = ssb / (q - 1)
326
+ sizes = np.array([len(grp) for grp in groups], dtype=float)
327
+ k0 = (n - (sizes**2).sum() / n) / (q - 1) if q > 1 else 1.0
328
+ var_between = float(max(0.0, (msb - var_within) / k0)) if k0 > 0 else max(0.0, msb)
329
+ return var_within, var_between