evalsize 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.
evalsize/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """How many eval examples do I need? Sample size planning for LLM evals."""
2
+
3
+ from .comparison import (
4
+ Comparison,
5
+ compare_counts,
6
+ compare_results,
7
+ examples_to_compare,
8
+ power_to_compare,
9
+ smallest_detectable_improvement,
10
+ )
11
+ from .judge import CorrectedRate, JudgeAgreement, correct_judge_rate, judge_agreement, labels_to_check_judge
12
+ from .precision import PassRate, examples_for_margin, margin_for_examples, pass_rate_interval
13
+ from .reruns import effective_examples, question_share, questions_needed
14
+
15
+ __all__ = [
16
+ "examples_for_margin",
17
+ "margin_for_examples",
18
+ "pass_rate_interval",
19
+ "examples_to_compare",
20
+ "power_to_compare",
21
+ "smallest_detectable_improvement",
22
+ "compare_counts",
23
+ "compare_results",
24
+ "effective_examples",
25
+ "questions_needed",
26
+ "question_share",
27
+ "judge_agreement",
28
+ "correct_judge_rate",
29
+ "labels_to_check_judge",
30
+ "PassRate",
31
+ "Comparison",
32
+ "JudgeAgreement",
33
+ "CorrectedRate",
34
+ ]
35
+
36
+ __version__ = "0.1.0"
evalsize/_stats.py ADDED
@@ -0,0 +1,53 @@
1
+ """Small statistics helpers built on the standard library only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from statistics import NormalDist
7
+
8
+ _NORMAL = NormalDist()
9
+
10
+
11
+ def check_rate(value: float, name: str, allow_edges: bool = False) -> None:
12
+ low_ok = value >= 0 if allow_edges else value > 0
13
+ high_ok = value <= 1 if allow_edges else value < 1
14
+ if not (low_ok and high_ok):
15
+ bounds = "between 0 and 1" if allow_edges else "strictly between 0 and 1"
16
+ raise ValueError(f"{name} must be {bounds}, got {value!r}")
17
+
18
+
19
+ def check_level(value: float, name: str) -> None:
20
+ if not 0 < value < 1:
21
+ raise ValueError(f"{name} must be strictly between 0 and 1, got {value!r}")
22
+
23
+
24
+ def z_two_sided(alpha: float) -> float:
25
+ """Critical value for a two-sided test or interval at level alpha."""
26
+ return _NORMAL.inv_cdf(1 - alpha / 2)
27
+
28
+
29
+ def z_power(power: float) -> float:
30
+ return _NORMAL.inv_cdf(power)
31
+
32
+
33
+ def normal_cdf(x: float) -> float:
34
+ return _NORMAL.cdf(x)
35
+
36
+
37
+ def wilson(successes: float, n: float, confidence: float) -> tuple:
38
+ """Wilson score interval for a proportion. Works well near 0 and 1."""
39
+ z = z_two_sided(1 - confidence)
40
+ p = successes / n
41
+ denom = 1 + z * z / n
42
+ centre = (p + z * z / (2 * n)) / denom
43
+ half = z / denom * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
44
+ return max(0.0, centre - half), min(1.0, centre + half)
45
+
46
+
47
+ def binom_two_sided_half(k: int, n: int) -> float:
48
+ """Exact two-sided p-value for k successes out of n when p = 0.5."""
49
+ if n == 0:
50
+ return 1.0
51
+ k = min(k, n - k)
52
+ tail = sum(math.comb(n, i) for i in range(k + 1)) / 2 ** n
53
+ return min(1.0, 2 * tail)
evalsize/cli.py ADDED
@@ -0,0 +1,151 @@
1
+ """Command line: ``evalsize compare``, ``evalsize margin``, ``evalsize detect`` and more."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from typing import List, Optional
8
+
9
+ from . import (
10
+ compare_counts,
11
+ correct_judge_rate,
12
+ effective_examples,
13
+ examples_for_margin,
14
+ examples_to_compare,
15
+ labels_to_check_judge,
16
+ smallest_detectable_improvement,
17
+ )
18
+
19
+
20
+ def _pct(x: float) -> str:
21
+ return f"{x:.1%}"
22
+
23
+
24
+ def _cmd_margin(args: argparse.Namespace) -> str:
25
+ n = examples_for_margin(args.margin, args.rate, args.confidence)
26
+ return (
27
+ f"{n} examples give a score of about {_pct(args.rate)} "
28
+ f"plus or minus {_pct(args.margin)} ({args.confidence:.0%} confidence)."
29
+ )
30
+
31
+
32
+ def _cmd_compare(args: argparse.Namespace) -> str:
33
+ paired = not args.unpaired
34
+ n = examples_to_compare(args.baseline, args.improvement, paired, args.disagreement, args.alpha, args.power)
35
+ target = args.baseline + args.improvement
36
+ lines = [
37
+ f"To detect {_pct(args.baseline)} -> {_pct(target)} with {args.power:.0%} power "
38
+ f"(false alarm rate {args.alpha:g}):",
39
+ f" {n} questions" + (" per model" if not paired else ", both models on the same questions"),
40
+ ]
41
+ if paired and args.disagreement is None:
42
+ lines.append(
43
+ " This assumes the models make mistakes independently. Most model pairs agree more,\n"
44
+ " so measure the disagreement rate on a pilot and pass --disagreement to get a smaller number."
45
+ )
46
+ return "\n".join(lines)
47
+
48
+
49
+ def _cmd_detect(args: argparse.Namespace) -> str:
50
+ gain = smallest_detectable_improvement(
51
+ args.n, args.baseline, not args.unpaired, args.disagreement, args.alpha, args.power
52
+ )
53
+ if gain is None:
54
+ return f"With {args.n} questions no improvement over {_pct(args.baseline)} can be reliably detected."
55
+ return (
56
+ f"With {args.n} questions and a baseline of {_pct(args.baseline)}, the smallest improvement "
57
+ f"you can reliably detect is {gain * 100:.1f} percentage points."
58
+ )
59
+
60
+
61
+ def _cmd_diff(args: argparse.Namespace) -> str:
62
+ return compare_counts(args.n, args.only_a, args.only_b, args.confidence).summary()
63
+
64
+
65
+ def _cmd_reruns(args: argparse.Namespace) -> str:
66
+ lines = [f"{args.n} questions, question share {args.share:g}:"]
67
+ for k in (1, 2, 3, 5, 10):
68
+ lines.append(f" {k:>2} run(s) each = worth {effective_examples(args.n, k, args.share):,.0f} single-run questions")
69
+ lines.append(f" No number of reruns can beat {args.n / args.share:,.0f}." if args.share > 0 else "")
70
+ return "\n".join(line for line in lines if line)
71
+
72
+
73
+ def _cmd_judge(args: argparse.Namespace) -> str:
74
+ if args.judge_rate is None:
75
+ n = labels_to_check_judge(args.margin, args.sensitivity, args.specificity, args.pass_rate)
76
+ return (
77
+ f"Label about {n} answers by hand to measure the judge's error rates "
78
+ f"to within {_pct(args.margin)}."
79
+ )
80
+ return correct_judge_rate(
81
+ args.judge_rate, args.n, args.sensitivity, args.specificity, args.human_passes, args.human_fails
82
+ ).summary()
83
+
84
+
85
+ def build_parser() -> argparse.ArgumentParser:
86
+ parser = argparse.ArgumentParser(
87
+ prog="evalsize",
88
+ description="How many eval examples do I need? Sample size planning for LLM evals.",
89
+ )
90
+ sub = parser.add_subparsers(dest="command", required=True)
91
+
92
+ p = sub.add_parser("margin", help="examples needed to measure one score to within +/- margin")
93
+ p.add_argument("--rate", type=float, default=0.5, help="expected pass rate (default 0.5, the cautious case)")
94
+ p.add_argument("--margin", type=float, required=True, help="for example 0.03 for +/- 3 points")
95
+ p.add_argument("--confidence", type=float, default=0.95)
96
+ p.set_defaults(run=_cmd_margin)
97
+
98
+ def comparison_options(p: argparse.ArgumentParser) -> None:
99
+ p.add_argument("--baseline", type=float, required=True, help="model A's expected pass rate")
100
+ p.add_argument("--disagreement", type=float, help="share of questions where exactly one model is right")
101
+ p.add_argument("--unpaired", action="store_true", help="the two models answer different questions")
102
+ p.add_argument("--alpha", type=float, default=0.05)
103
+ p.add_argument("--power", type=float, default=0.8)
104
+
105
+ p = sub.add_parser("compare", help="questions needed to detect that B beats A")
106
+ comparison_options(p)
107
+ p.add_argument("--improvement", type=float, required=True, help="smallest gain worth detecting, e.g. 0.05")
108
+ p.set_defaults(run=_cmd_compare)
109
+
110
+ p = sub.add_parser("detect", help="smallest improvement a given number of questions can detect")
111
+ comparison_options(p)
112
+ p.add_argument("--n", type=int, required=True)
113
+ p.set_defaults(run=_cmd_detect)
114
+
115
+ p = sub.add_parser("diff", help="is B really better than A? (from disagreement counts)")
116
+ p.add_argument("--n", type=int, required=True, help="questions both models answered")
117
+ p.add_argument("--only-a", type=int, required=True, help="questions only A got right")
118
+ p.add_argument("--only-b", type=int, required=True, help="questions only B got right")
119
+ p.add_argument("--confidence", type=float, default=0.95)
120
+ p.set_defaults(run=_cmd_diff)
121
+
122
+ p = sub.add_parser("reruns", help="what running each question several times is worth")
123
+ p.add_argument("--n", type=int, required=True, help="number of questions")
124
+ p.add_argument("--share", type=float, required=True, help="question share of the variance (0 to 1)")
125
+ p.set_defaults(run=_cmd_reruns)
126
+
127
+ p = sub.add_parser("judge", help="human labels needed for an LLM judge, or correct its score")
128
+ p.add_argument("--sensitivity", type=float, default=0.9)
129
+ p.add_argument("--specificity", type=float, default=0.9)
130
+ p.add_argument("--pass-rate", type=float, default=0.7, help="expected pass rate, for planning labels")
131
+ p.add_argument("--margin", type=float, default=0.05, help="precision wanted for the error rates")
132
+ p.add_argument("--judge-rate", type=float, help="the judge's pass rate, to correct it")
133
+ p.add_argument("--n", type=int, default=1000, help="answers the judge graded")
134
+ p.add_argument("--human-passes", type=int, default=100)
135
+ p.add_argument("--human-fails", type=int, default=50)
136
+ p.set_defaults(run=_cmd_judge)
137
+ return parser
138
+
139
+
140
+ def main(argv: Optional[List[str]] = None) -> int:
141
+ args = build_parser().parse_args(argv)
142
+ try:
143
+ print(args.run(args))
144
+ except ValueError as exc:
145
+ print(f"evalsize: {exc}", file=sys.stderr)
146
+ return 2
147
+ return 0
148
+
149
+
150
+ if __name__ == "__main__":
151
+ sys.exit(main())
evalsize/comparison.py ADDED
@@ -0,0 +1,234 @@
1
+ """Comparing two models (or prompts) on pass/fail evals.
2
+
3
+ "Paired" means both models answer the same questions, which is how most evals
4
+ are run. Pairing usually needs far fewer questions than comparing two separate
5
+ samples, because both models get the easy questions right and the hard ones
6
+ wrong, and only the questions where they disagree carry information.
7
+
8
+ The key planning input for a paired comparison is the **disagreement rate**:
9
+ the share of questions where exactly one of the two models is right. Measure
10
+ it on a small pilot if you can. If you leave it out, the functions assume the
11
+ two models make mistakes independently, which is cautious: real models
12
+ usually agree more than that, so you will often need fewer questions.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ from dataclasses import dataclass
19
+ from typing import Iterable, Optional
20
+
21
+ from ._stats import (
22
+ binom_two_sided_half,
23
+ check_level,
24
+ check_rate,
25
+ normal_cdf,
26
+ z_power,
27
+ z_two_sided,
28
+ )
29
+
30
+
31
+ def _independent_disagreement(p_a: float, p_b: float) -> float:
32
+ return p_a * (1 - p_b) + p_b * (1 - p_a)
33
+
34
+
35
+ def _check_disagreement(p_a: float, p_b: float, disagreement: float) -> None:
36
+ d = p_b - p_a
37
+ only_b = (disagreement + d) / 2
38
+ only_a = (disagreement - d) / 2
39
+ if only_a < -1e-12 or only_b < -1e-12:
40
+ raise ValueError(
41
+ f"disagreement {disagreement:g} is too low: going from {p_a:g} to {p_b:g} "
42
+ f"means the models must disagree on at least {abs(d):g} of questions"
43
+ )
44
+ if only_b > min(p_b, 1 - p_a) + 1e-12 or only_a > min(p_a, 1 - p_b) + 1e-12:
45
+ raise ValueError(
46
+ f"disagreement {disagreement:g} is too high for pass rates {p_a:g} and {p_b:g}"
47
+ )
48
+
49
+
50
+ def _setup(baseline: float, improvement: float, disagreement: Optional[float]):
51
+ check_rate(baseline, "baseline")
52
+ if improvement == 0:
53
+ raise ValueError("improvement must not be zero")
54
+ target = baseline + improvement
55
+ check_rate(target, "baseline + improvement")
56
+ if disagreement is None:
57
+ disagreement = _independent_disagreement(baseline, target)
58
+ else:
59
+ check_rate(disagreement, "disagreement")
60
+ _check_disagreement(baseline, target, disagreement)
61
+ return target, disagreement
62
+
63
+
64
+ def examples_to_compare(
65
+ baseline: float,
66
+ improvement: float,
67
+ paired: bool = True,
68
+ disagreement: Optional[float] = None,
69
+ alpha: float = 0.05,
70
+ power: float = 0.8,
71
+ ) -> int:
72
+ """Questions needed to reliably detect that model B beats model A.
73
+
74
+ Args:
75
+ baseline: model A's expected pass rate, for example 0.70.
76
+ improvement: the smallest gain worth detecting, for example 0.05 for
77
+ five percentage points.
78
+ paired: True if both models answer the same questions. If False, the
79
+ answer is the number of questions for each model.
80
+ disagreement: share of questions where exactly one model is right.
81
+ Only used when paired.
82
+ alpha: false alarm rate (two-sided).
83
+ power: chance of detecting the improvement if it is real.
84
+
85
+ Paired uses the McNemar sample size formula (Connor, 1987). Unpaired uses
86
+ the standard two-proportion formula.
87
+
88
+ >>> examples_to_compare(0.70, 0.05, disagreement=0.15)
89
+ 469
90
+ """
91
+ check_level(alpha, "alpha")
92
+ check_level(power, "power")
93
+ target, psi = _setup(baseline, improvement, disagreement)
94
+ d = abs(improvement)
95
+ za, zb = z_two_sided(alpha), z_power(power)
96
+ if paired:
97
+ n = (za * math.sqrt(psi) + zb * math.sqrt(psi - d * d)) ** 2 / (d * d)
98
+ else:
99
+ p_bar = (baseline + target) / 2
100
+ spread = baseline * (1 - baseline) + target * (1 - target)
101
+ n = (za * math.sqrt(2 * p_bar * (1 - p_bar)) + zb * math.sqrt(spread)) ** 2 / (d * d)
102
+ return math.ceil(n - 1e-9)
103
+
104
+
105
+ def power_to_compare(
106
+ n: int,
107
+ baseline: float,
108
+ improvement: float,
109
+ paired: bool = True,
110
+ disagreement: Optional[float] = None,
111
+ alpha: float = 0.05,
112
+ ) -> float:
113
+ """Chance that an eval with ``n`` questions detects the improvement."""
114
+ if n <= 0:
115
+ raise ValueError("n must be positive")
116
+ check_level(alpha, "alpha")
117
+ target, psi = _setup(baseline, improvement, disagreement)
118
+ d = abs(improvement)
119
+ za = z_two_sided(alpha)
120
+ if paired:
121
+ spread = math.sqrt(max(psi - d * d, 1e-15))
122
+ return normal_cdf((d * math.sqrt(n) - za * math.sqrt(psi)) / spread)
123
+ p_bar = (baseline + target) / 2
124
+ spread = math.sqrt(baseline * (1 - baseline) + target * (1 - target))
125
+ return normal_cdf((d * math.sqrt(n) - za * math.sqrt(2 * p_bar * (1 - p_bar))) / spread)
126
+
127
+
128
+ def smallest_detectable_improvement(
129
+ n: int,
130
+ baseline: float,
131
+ paired: bool = True,
132
+ disagreement: Optional[float] = None,
133
+ alpha: float = 0.05,
134
+ power: float = 0.8,
135
+ ) -> Optional[float]:
136
+ """Smallest gain over ``baseline`` that ``n`` questions can reliably detect.
137
+
138
+ Returns None if no improvement up to a perfect score is detectable.
139
+
140
+ If you give a fixed ``disagreement``, the improvement can never exceed it,
141
+ so the answer is capped there.
142
+ """
143
+ check_level(power, "power")
144
+ check_rate(baseline, "baseline")
145
+ high = 1 - baseline - 1e-9
146
+ if disagreement is not None and paired:
147
+ high = min(high, disagreement)
148
+
149
+ def detectable(gain: float) -> bool:
150
+ try:
151
+ return power_to_compare(n, baseline, gain, paired, disagreement, alpha) >= power
152
+ except ValueError:
153
+ return False
154
+
155
+ if not detectable(high):
156
+ return None
157
+ low = 1e-6
158
+ for _ in range(60):
159
+ mid = (low + high) / 2
160
+ if detectable(mid):
161
+ high = mid
162
+ else:
163
+ low = mid
164
+ return high
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class Comparison:
169
+ n: int
170
+ only_a_right: int
171
+ only_b_right: int
172
+ difference: float
173
+ low: float
174
+ high: float
175
+ p_value: float
176
+ confidence: float
177
+
178
+ def summary(self) -> str:
179
+ direction = "better" if self.difference > 0 else "worse"
180
+ verdict = (
181
+ f"B looks {direction} than A"
182
+ if self.p_value < 1 - self.confidence
183
+ else "The difference could easily be noise"
184
+ )
185
+ return (
186
+ f"B minus A: {self.difference:+.1%} over {self.n} shared questions "
187
+ f"({self.confidence:.0%} interval {self.low:+.1%} to {self.high:+.1%}, p = {self.p_value:.3g}). "
188
+ f"{verdict}. The models disagreed on {self.only_a_right + self.only_b_right} questions."
189
+ )
190
+
191
+ def __str__(self) -> str:
192
+ return self.summary()
193
+
194
+
195
+ def compare_counts(n: int, only_a_right: int, only_b_right: int, confidence: float = 0.95) -> Comparison:
196
+ """Is B really better than A? Uses only the questions where they disagree.
197
+
198
+ Args:
199
+ n: questions both models answered.
200
+ only_a_right: questions A got right and B got wrong.
201
+ only_b_right: questions B got right and A got wrong.
202
+
203
+ The p-value is the exact McNemar test. The interval for the difference
204
+ uses the Agresti and Min (2005) adjustment, which behaves well with small
205
+ counts.
206
+
207
+ >>> compare_counts(300, only_a_right=12, only_b_right=28).p_value < 0.05
208
+ True
209
+ """
210
+ if n <= 0:
211
+ raise ValueError("n must be positive")
212
+ if only_a_right < 0 or only_b_right < 0 or only_a_right + only_b_right > n:
213
+ raise ValueError("disagreement counts must be non-negative and add up to at most n")
214
+ check_level(confidence, "confidence")
215
+ diff = (only_b_right - only_a_right) / n
216
+ a_adj, b_adj, n_adj = only_a_right + 0.5, only_b_right + 0.5, n + 2
217
+ d_adj = (b_adj - a_adj) / n_adj
218
+ var = ((a_adj + b_adj) / n_adj - d_adj ** 2) / n_adj
219
+ half = z_two_sided(1 - confidence) * math.sqrt(max(var, 0.0))
220
+ p_value = binom_two_sided_half(only_b_right, only_a_right + only_b_right)
221
+ return Comparison(
222
+ n, only_a_right, only_b_right, diff,
223
+ max(-1.0, d_adj - half), min(1.0, d_adj + half), p_value, confidence,
224
+ )
225
+
226
+
227
+ def compare_results(a: Iterable, b: Iterable, confidence: float = 0.95) -> Comparison:
228
+ """Same as ``compare_counts`` but from two lists of pass/fail results, in question order."""
229
+ a, b = [bool(x) for x in a], [bool(x) for x in b]
230
+ if len(a) != len(b):
231
+ raise ValueError("a and b must have one result per question, in the same order")
232
+ only_a = sum(1 for x, y in zip(a, b) if x and not y)
233
+ only_b = sum(1 for x, y in zip(a, b) if y and not x)
234
+ return compare_counts(len(a), only_a, only_b, confidence)
evalsize/judge.py ADDED
@@ -0,0 +1,139 @@
1
+ """When an LLM judge grades the eval, its mistakes shift the score.
2
+
3
+ A judge that passes 10% of bad answers and fails 5% of good ones does not
4
+ just add noise: it pushes the score in one direction. If you check the judge
5
+ against human labels on a sample, you can correct the score and widen the
6
+ interval to account for what you learned from only a sample.
7
+
8
+ Terms used here, with humans as the reference:
9
+ sensitivity: share of answers humans pass that the judge also passes.
10
+ specificity: share of answers humans fail that the judge also fails.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ from dataclasses import dataclass
17
+ from typing import Iterable
18
+
19
+ from ._stats import check_level, check_rate, z_two_sided
20
+ from .precision import examples_for_margin
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class JudgeAgreement:
25
+ sensitivity: float
26
+ specificity: float
27
+ human_passes: int
28
+ human_fails: int
29
+ agreement: float
30
+
31
+ def summary(self) -> str:
32
+ return (
33
+ f"The judge agreed with humans on {self.agreement:.1%} of {self.human_passes + self.human_fails} labels. "
34
+ f"It passed {self.sensitivity:.1%} of answers humans passed "
35
+ f"and failed {self.specificity:.1%} of answers humans failed."
36
+ )
37
+
38
+ def __str__(self) -> str:
39
+ return self.summary()
40
+
41
+
42
+ def judge_agreement(human: Iterable, judge: Iterable) -> JudgeAgreement:
43
+ """Measure the judge against human labels for the same answers (True means pass)."""
44
+ human, judge = [bool(x) for x in human], [bool(x) for x in judge]
45
+ if len(human) != len(judge):
46
+ raise ValueError("human and judge need one label per answer, in the same order")
47
+ passes = [j for h, j in zip(human, judge) if h]
48
+ fails = [j for h, j in zip(human, judge) if not h]
49
+ if not passes or not fails:
50
+ raise ValueError("the labelled sample needs at least one human pass and one human fail")
51
+ sensitivity = sum(passes) / len(passes)
52
+ specificity = sum(1 for j in fails if not j) / len(fails)
53
+ agreement = sum(1 for h, j in zip(human, judge) if h == j) / len(human)
54
+ return JudgeAgreement(sensitivity, specificity, len(passes), len(fails), agreement)
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class CorrectedRate:
59
+ judge_rate: float
60
+ corrected: float
61
+ low: float
62
+ high: float
63
+ confidence: float
64
+
65
+ def summary(self) -> str:
66
+ return (
67
+ f"The judge says {self.judge_rate:.1%}. Corrected for the judge's mistakes: "
68
+ f"{self.corrected:.1%} ({self.confidence:.0%} interval {self.low:.1%} to {self.high:.1%})."
69
+ )
70
+
71
+ def __str__(self) -> str:
72
+ return self.summary()
73
+
74
+
75
+ def correct_judge_rate(
76
+ judge_rate: float,
77
+ n_judged: int,
78
+ sensitivity: float,
79
+ specificity: float,
80
+ human_passes: int,
81
+ human_fails: int,
82
+ confidence: float = 0.95,
83
+ ) -> CorrectedRate:
84
+ """Correct a judge-graded pass rate using the judge's measured error rates.
85
+
86
+ Args:
87
+ judge_rate: pass rate the judge gave over the whole eval.
88
+ n_judged: answers the judge graded.
89
+ sensitivity, specificity: from ``judge_agreement``.
90
+ human_passes, human_fails: how many human labels each of those was
91
+ measured on. Fewer labels means a wider interval.
92
+
93
+ Uses the Rogan and Gladen (1978) estimator with a delta method interval
94
+ that includes the uncertainty in all three measured rates.
95
+
96
+ >>> r = correct_judge_rate(0.82, 1000, 0.95, 0.70, 140, 60)
97
+ >>> round(r.corrected, 3)
98
+ 0.8
99
+ """
100
+ check_rate(judge_rate, "judge_rate", allow_edges=True)
101
+ check_rate(sensitivity, "sensitivity", allow_edges=True)
102
+ check_rate(specificity, "specificity", allow_edges=True)
103
+ check_level(confidence, "confidence")
104
+ if n_judged <= 0 or human_passes <= 0 or human_fails <= 0:
105
+ raise ValueError("n_judged, human_passes and human_fails must be positive")
106
+ youden = sensitivity + specificity - 1
107
+ if youden <= 0.05:
108
+ raise ValueError(
109
+ "the judge is barely better than a coin flip against human labels "
110
+ "(sensitivity + specificity must be well above 1), so its score cannot be corrected"
111
+ )
112
+ raw = (judge_rate + specificity - 1) / youden
113
+ p = min(1.0, max(0.0, raw))
114
+ var = (
115
+ judge_rate * (1 - judge_rate) / n_judged
116
+ + p ** 2 * sensitivity * (1 - sensitivity) / human_passes
117
+ + (1 - p) ** 2 * specificity * (1 - specificity) / human_fails
118
+ ) / youden ** 2
119
+ half = z_two_sided(1 - confidence) * math.sqrt(var)
120
+ return CorrectedRate(judge_rate, p, max(0.0, p - half), min(1.0, p + half), confidence)
121
+
122
+
123
+ def labels_to_check_judge(
124
+ margin: float = 0.05,
125
+ expected_sensitivity: float = 0.9,
126
+ expected_specificity: float = 0.9,
127
+ pass_rate: float = 0.7,
128
+ confidence: float = 0.95,
129
+ ) -> int:
130
+ """Human labels needed to measure the judge's error rates to within ``margin``.
131
+
132
+ Both error rates must be measured, and the rarer outcome (usually fails)
133
+ decides the total: if 30% of answers fail, you need to label about three
134
+ answers to find one fail.
135
+ """
136
+ check_rate(pass_rate, "pass_rate")
137
+ need_passes = examples_for_margin(margin, expected_sensitivity, confidence)
138
+ need_fails = examples_for_margin(margin, expected_specificity, confidence)
139
+ return math.ceil(max(need_passes / pass_rate, need_fails / (1 - pass_rate)) - 1e-9)
evalsize/precision.py ADDED
@@ -0,0 +1,78 @@
1
+ """How precise is one pass rate, and how many examples make it precise enough."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from ._stats import check_level, check_rate, wilson
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class PassRate:
12
+ passes: int
13
+ n: int
14
+ rate: float
15
+ low: float
16
+ high: float
17
+ confidence: float
18
+
19
+ def summary(self) -> str:
20
+ return (
21
+ f"Pass rate {self.rate:.1%} ({self.passes} of {self.n}). "
22
+ f"{self.confidence:.0%} confidence interval: {self.low:.1%} to {self.high:.1%}."
23
+ )
24
+
25
+ def __str__(self) -> str:
26
+ return self.summary()
27
+
28
+
29
+ def pass_rate_interval(passes: int, n: int, confidence: float = 0.95) -> PassRate:
30
+ """Confidence interval for a pass rate, using the Wilson score method.
31
+
32
+ >>> pass_rate_interval(160, 200).summary()
33
+ 'Pass rate 80.0% (160 of 200). 95% confidence interval: 73.9% to 85.0%.'
34
+ """
35
+ if n <= 0:
36
+ raise ValueError("n must be positive")
37
+ if not 0 <= passes <= n:
38
+ raise ValueError("passes must be between 0 and n")
39
+ check_level(confidence, "confidence")
40
+ low, high = wilson(passes, n, confidence)
41
+ return PassRate(passes, n, passes / n, low, high, confidence)
42
+
43
+
44
+ def margin_for_examples(n: int, pass_rate: float = 0.5, confidence: float = 0.95) -> float:
45
+ """Half width of the confidence interval you can expect with ``n`` examples.
46
+
47
+ ``pass_rate`` is your best guess of the score. 0.5 gives the widest
48
+ (most cautious) answer.
49
+ """
50
+ if n <= 0:
51
+ raise ValueError("n must be positive")
52
+ check_rate(pass_rate, "pass_rate", allow_edges=True)
53
+ check_level(confidence, "confidence")
54
+ low, high = wilson(pass_rate * n, n, confidence)
55
+ return (high - low) / 2
56
+
57
+
58
+ def examples_for_margin(margin: float, pass_rate: float = 0.5, confidence: float = 0.95) -> int:
59
+ """Smallest number of examples that gives a ``margin`` of plus or minus around the score.
60
+
61
+ >>> examples_for_margin(0.05, pass_rate=0.8)
62
+ 245
63
+ """
64
+ check_level(margin, "margin")
65
+ check_rate(pass_rate, "pass_rate", allow_edges=True)
66
+ check_level(confidence, "confidence")
67
+ low, high = 1, 1
68
+ while margin_for_examples(high, pass_rate, confidence) > margin:
69
+ high *= 2
70
+ if high > 10**9:
71
+ raise ValueError("margin is too small to reach")
72
+ while low < high:
73
+ mid = (low + high) // 2
74
+ if margin_for_examples(mid, pass_rate, confidence) <= margin:
75
+ high = mid
76
+ else:
77
+ low = mid + 1
78
+ return low
evalsize/reruns.py ADDED
@@ -0,0 +1,74 @@
1
+ """More questions, or more runs per question?
2
+
3
+ When a model is not deterministic, the same question can pass on one run and
4
+ fail on the next. Running each question several times averages out that
5
+ randomness, but it cannot fix the other source of noise: which questions you
6
+ happened to pick. This module measures how much reruns are worth.
7
+
8
+ The planning input is the **question share**: the part of the score's
9
+ variance that comes from questions being easy or hard, as opposed to run to
10
+ run randomness. A deterministic model has a question share of 1, and reruns
11
+ add nothing. Estimate it from a pilot with ``question_share``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from typing import Sequence
18
+
19
+ from ._stats import check_rate
20
+
21
+
22
+ def effective_examples(n_questions: int, runs_per_question: int, question_share: float) -> float:
23
+ """How many single-run questions your design is worth.
24
+
25
+ With ``n`` questions and ``k`` runs each, the score is as precise as
26
+ ``n / (s + (1 - s) / k)`` single-run questions, where ``s`` is the question
27
+ share. No number of reruns can do better than ``n / s``.
28
+
29
+ >>> round(effective_examples(200, 5, 0.6))
30
+ 294
31
+ """
32
+ if n_questions <= 0 or runs_per_question <= 0:
33
+ raise ValueError("n_questions and runs_per_question must be positive")
34
+ check_rate(question_share, "question_share", allow_edges=True)
35
+ return n_questions / (question_share + (1 - question_share) / runs_per_question)
36
+
37
+
38
+ def questions_needed(single_run_examples: int, runs_per_question: int, question_share: float) -> int:
39
+ """Questions needed, at ``runs_per_question`` runs each, to match a single-run plan.
40
+
41
+ Use it with ``examples_for_margin`` or ``examples_to_compare``, which assume
42
+ one run per question.
43
+ """
44
+ if single_run_examples <= 0 or runs_per_question <= 0:
45
+ raise ValueError("single_run_examples and runs_per_question must be positive")
46
+ check_rate(question_share, "question_share", allow_edges=True)
47
+ factor = question_share + (1 - question_share) / runs_per_question
48
+ return max(1, math.ceil(single_run_examples * factor - 1e-9))
49
+
50
+
51
+ def question_share(results: Sequence[Sequence[float]]) -> float:
52
+ """Estimate the question share from a pilot where each question was run several times.
53
+
54
+ ``results`` has one row per question and one value per run (1 for pass,
55
+ 0 for fail, or any score). Every question needs the same number of runs,
56
+ at least two. Uses the one-way analysis of variance estimator of the
57
+ intraclass correlation.
58
+ """
59
+ rows = [list(map(float, row)) for row in results]
60
+ if len(rows) < 2:
61
+ raise ValueError("need at least two questions")
62
+ k = len(rows[0])
63
+ if k < 2 or any(len(r) != k for r in rows):
64
+ raise ValueError("every question needs the same number of runs, at least two")
65
+ n = len(rows)
66
+ means = [sum(r) / k for r in rows]
67
+ grand = sum(means) / n
68
+ ms_between = k * sum((m - grand) ** 2 for m in means) / (n - 1)
69
+ ms_within = sum((x - m) ** 2 for r, m in zip(rows, means) for x in r) / (n * (k - 1))
70
+ between = max(0.0, (ms_between - ms_within) / k)
71
+ total = between + ms_within
72
+ if total == 0:
73
+ return 1.0
74
+ return between / total
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.4
2
+ Name: evalsize
3
+ Version: 0.1.0
4
+ Summary: How many eval examples do I need? Sample size and power planning for LLM evals
5
+ Author: Meenal Asesh
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ai-with-md/evalsize
8
+ Project-URL: Calculator, https://ai-with-md.github.io/evalsize/
9
+ Keywords: llm,evaluation,evals,sample size,power analysis,statistics,llm-as-a-judge,a/b testing
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest; extra == "dev"
18
+ Dynamic: license-file
19
+
20
+ # evalsize
21
+
22
+ **How many eval examples do I need?** Sample size and power planning for LLM evals, with a [calculator that runs in your browser](https://ai-with-md.github.io/evalsize/).
23
+
24
+ [![tests](https://github.com/ai-with-md/evalsize/actions/workflows/tests.yml/badge.svg)](https://github.com/ai-with-md/evalsize/actions/workflows/tests.yml)
25
+ ![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue)
26
+ ![License: MIT](https://img.shields.io/badge/license-MIT-green)
27
+
28
+ Most teams pick an eval size by feel: 100 questions, maybe 500. Then a new prompt scores 2 points higher and nobody knows whether that is real. evalsize answers the planning questions before you spend the API budget:
29
+
30
+ 1. How many questions do I need to tell whether model B beats model A?
31
+ 2. How precise is the score I already have?
32
+ 3. Is the difference I measured real, or noise?
33
+ 4. Should I add more questions, or run each question several times?
34
+ 5. My LLM judge makes mistakes. How much does that move the score, and how many answers should I label by hand?
35
+
36
+ ## The number most people get wrong
37
+
38
+ Comparing a 70% model with a 75% model, at the usual 80% power:
39
+
40
+ | Setup | Questions needed |
41
+ |---|---|
42
+ | Each model on its own set of questions | 1,251 per model |
43
+ | Same questions, models make mistakes independently | 1,254 |
44
+ | Same questions, models disagree on 15% of them | **469** |
45
+
46
+ When both models answer the same questions, only the questions where they **disagree** tell you which is better. Models often get the same questions right and the same ones wrong, so they usually disagree far less than chance would suggest, and you need far fewer questions. But only if you measure the disagreement rate. Run a small pilot, count the questions where exactly one model is right, and plan with that number.
47
+
48
+ ## Try it
49
+
50
+ **In the browser:** [ai-with-md.github.io/evalsize](https://ai-with-md.github.io/evalsize/). Move the sliders, nothing to install.
51
+
52
+ **In Python:**
53
+
54
+ ```bash
55
+ pip install evalsize
56
+ ```
57
+
58
+ No dependencies beyond the standard library. Python 3.9 or newer.
59
+
60
+ ## Use
61
+
62
+ ### 1. How many questions to compare two models?
63
+
64
+ ```python
65
+ import evalsize as es
66
+
67
+ es.examples_to_compare(baseline=0.70, improvement=0.05, disagreement=0.15)
68
+ # 469
69
+
70
+ es.smallest_detectable_improvement(n=300, baseline=0.70, disagreement=0.15)
71
+ # 0.062 -> with 300 questions you can reliably detect about 6 points, not less
72
+ ```
73
+
74
+ Leave out `disagreement` and evalsize assumes the models make mistakes independently, which is the cautious answer. Pass `paired=False` if the two models answer different questions.
75
+
76
+ ### 2. How precise is one score?
77
+
78
+ ```python
79
+ es.pass_rate_interval(passes=160, n=200)
80
+ # Pass rate 80.0% (160 of 200). 95% confidence interval: 73.9% to 85.0%.
81
+
82
+ es.examples_for_margin(margin=0.03, pass_rate=0.80)
83
+ # 682 questions for plus or minus 3 points
84
+ ```
85
+
86
+ ### 3. Is B really better than A?
87
+
88
+ ```python
89
+ result = es.compare_results(a_passed, b_passed) # two lists of True/False, same question order
90
+ print(result)
91
+ # B minus A: +5.3% over 300 shared questions (95% interval +1.2% to +9.4%, p = 0.0166).
92
+ # B looks better than A. The models disagreed on 40 questions.
93
+ ```
94
+
95
+ Or from counts: `es.compare_counts(n=300, only_a_right=12, only_b_right=28)`.
96
+
97
+ ### 4. More questions, or more runs per question?
98
+
99
+ If a model gives different answers on different runs, rerunning each question averages out that randomness. It cannot fix the other source of noise: which questions you picked.
100
+
101
+ ```python
102
+ share = es.question_share(pilot_runs) # one row per question, one 0/1 per run
103
+ es.effective_examples(n_questions=200, runs_per_question=5, question_share=share)
104
+ # with share 0.6: worth 294 single-run questions, and no number of reruns can beat 333
105
+ ```
106
+
107
+ A deterministic model has a question share of 1, and reruns add nothing. Per API call, adding questions always buys more precision than adding runs, so reruns make most sense when you have run out of good questions.
108
+
109
+ ### 5. Can I trust my LLM judge?
110
+
111
+ A judge that passes some bad answers or fails some good ones does not just add noise. It moves the score in one direction. Label a random sample by hand, measure the judge, and correct:
112
+
113
+ ```python
114
+ check = es.judge_agreement(human_labels, judge_labels)
115
+ print(check)
116
+ # The judge agreed with humans on 87.5% of 200 labels. It passed 95.0% of answers
117
+ # humans passed and failed 70.0% of answers humans failed.
118
+
119
+ es.correct_judge_rate(judge_rate=0.82, n_judged=1000,
120
+ sensitivity=check.sensitivity, specificity=check.specificity,
121
+ human_passes=check.human_passes, human_fails=check.human_fails)
122
+ # The judge says 82.0%. Corrected for the judge's mistakes: 80.0% (95% interval 73.2% to 86.8%).
123
+
124
+ es.labels_to_check_judge(margin=0.05, pass_rate=0.80)
125
+ # how many answers to label by hand before you start
126
+ ```
127
+
128
+ ## Command line
129
+
130
+ ```bash
131
+ evalsize compare --baseline 0.7 --improvement 0.05 --disagreement 0.15
132
+ evalsize detect --baseline 0.7 --n 300 --disagreement 0.15
133
+ evalsize margin --rate 0.8 --margin 0.03
134
+ evalsize diff --n 300 --only-a 12 --only-b 28
135
+ evalsize reruns --n 200 --share 0.6
136
+ evalsize judge --judge-rate 0.82 --sensitivity 0.95 --specificity 0.7 --human-passes 140 --human-fails 60
137
+ ```
138
+
139
+ ## How the numbers are checked
140
+
141
+ Formulas are easy to get subtly wrong, so every one is tested against simulation. The test suite runs thousands of fake evals with known true pass rates and checks that the promises hold: the sample sizes deliver their stated power, the intervals cover the truth 95% of the time, the false alarm rate stays at 5%, and the rerun and judge corrections match what the simulations show. See [`tests/test_simulation.py`](https://github.com/ai-with-md/evalsize/blob/main/tests/test_simulation.py).
142
+
143
+ ## How it compares
144
+
145
+ - **[evalstats](https://github.com/ianarawjo/evalstats)** analyzes results after you run an eval, with many tests and judge-bias corrections. evalsize is mostly about **planning before you run**: how many questions, how many runs, how many human labels. They work well together.
146
+ - **General power calculators** (statsmodels, G*Power, online calculators) handle two separate groups well, but rarely the paired case with a disagreement rate, which is how most LLM evals are run, and they say nothing about reruns or imperfect judges.
147
+
148
+ ## Methods
149
+
150
+ | Question | Method |
151
+ |---|---|
152
+ | Precision of one score | Wilson score interval |
153
+ | Comparing two models, planning | McNemar sample size formula (Connor, 1987); two-proportion formula when unpaired |
154
+ | Comparing two models, analysis | Exact McNemar test, Agresti and Min (2005) interval for the paired difference |
155
+ | Reruns | Intraclass correlation (one-way analysis of variance estimator) and the design effect |
156
+ | Imperfect judges | Rogan and Gladen (1978) estimator with a delta method interval that includes the uncertainty in the judge's measured error rates |
157
+
158
+ Everything here is for pass/fail scores, which is how most evals are reported. Planning for scores on a scale (1 to 5, or 0 to 1) is a likely next step.
159
+
160
+ ## Development
161
+
162
+ ```bash
163
+ python -m venv .venv
164
+ source .venv/bin/activate
165
+ pip install -e ".[dev]"
166
+ pytest
167
+ ```
168
+
169
+ The calculator is a [marimo](https://marimo.io) notebook in [`app/calculator.py`](https://github.com/ai-with-md/evalsize/blob/main/app/calculator.py). To run it locally: `pip install marimo matplotlib`, then `marimo run app/calculator.py`. It is published to GitHub Pages as a WebAssembly app on every push to `main`.
170
+
171
+ ## License
172
+
173
+ MIT
@@ -0,0 +1,13 @@
1
+ evalsize/__init__.py,sha256=IctaPIANCZFVfnH_90yJ5h5LZDeUZKDXY0WKVoe_FQw,1002
2
+ evalsize/_stats.py,sha256=dwR-G_PM__l4H_5BwYBkYR41MyahCuUx4QYxzdkx9wE,1678
3
+ evalsize/cli.py,sha256=eiZz_i4tsMUXFw-ayFFOIJ1fD-Ai_DusMZZTQs55X7o,6637
4
+ evalsize/comparison.py,sha256=ix4lUftZGg5Ww2HYApzvrn7NqxjqZeJ0vXbqsHUQpmM,8505
5
+ evalsize/judge.py,sha256=_o4TQzpJVYY3uhSgVN5syOvyVNfolUA6RQzcM2e9eFE,5350
6
+ evalsize/precision.py,sha256=vMiRi0mUbQKCwO8NsCIKp_lzhb1Ej7-P93mzQsbhOAg,2538
7
+ evalsize/reruns.py,sha256=sMqPY9wIn_m_DxjCzHGDY7z96ib0g0hjnibu0iR5Xq0,3218
8
+ evalsize-0.1.0.dist-info/licenses/LICENSE,sha256=HS_Pp1J0NNKF4niTKA1MdskkGwByEasM-oJb8NcpaGE,1069
9
+ evalsize-0.1.0.dist-info/METADATA,sha256=IyorDk5WXXc8bWzlD1gUTRNQPvnSAi42FBsZ0jmgR5Y,8182
10
+ evalsize-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
11
+ evalsize-0.1.0.dist-info/entry_points.txt,sha256=u4TlRPvgiG9DXJbokb646Cxc1LkwU5VN42psVAho1Go,47
12
+ evalsize-0.1.0.dist-info/top_level.txt,sha256=7YFrzKbaKFXWS7KSfFFHEYPzJfx4dyCBJ72hwD7IcUs,9
13
+ evalsize-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ evalsize = evalsize.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Meenal Asesh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ evalsize