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/__init__.py +28 -0
- errorbars/adapters/__init__.py +1 -0
- errorbars/adapters/inspect_ai.py +83 -0
- errorbars/adapters/lm_eval.py +103 -0
- errorbars/cli.py +426 -0
- errorbars/compare.py +178 -0
- errorbars/io.py +197 -0
- errorbars/leaderboard.py +181 -0
- errorbars/plot.py +141 -0
- errorbars/power.py +137 -0
- errorbars/py.typed +0 -0
- errorbars/stats.py +329 -0
- errorbars-0.1.2.dist-info/METADATA +297 -0
- errorbars-0.1.2.dist-info/RECORD +17 -0
- errorbars-0.1.2.dist-info/WHEEL +4 -0
- errorbars-0.1.2.dist-info/entry_points.txt +2 -0
- errorbars-0.1.2.dist-info/licenses/LICENSE +21 -0
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
|