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 +36 -0
- evalsize/_stats.py +53 -0
- evalsize/cli.py +151 -0
- evalsize/comparison.py +234 -0
- evalsize/judge.py +139 -0
- evalsize/precision.py +78 -0
- evalsize/reruns.py +74 -0
- evalsize-0.1.0.dist-info/METADATA +173 -0
- evalsize-0.1.0.dist-info/RECORD +13 -0
- evalsize-0.1.0.dist-info/WHEEL +5 -0
- evalsize-0.1.0.dist-info/entry_points.txt +2 -0
- evalsize-0.1.0.dist-info/licenses/LICENSE +21 -0
- evalsize-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
[](https://github.com/ai-with-md/evalsize/actions/workflows/tests.yml)
|
|
25
|
+

|
|
26
|
+

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