evalsize 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- evalsize-0.1.0/PKG-INFO +173 -0
- evalsize-0.1.0/README.md +154 -0
- evalsize-0.1.0/pyproject.toml +36 -0
- evalsize-0.1.0/setup.cfg +4 -0
- evalsize-0.1.0/src/evalsize/__init__.py +36 -0
- evalsize-0.1.0/src/evalsize/_stats.py +53 -0
- evalsize-0.1.0/src/evalsize/cli.py +151 -0
- evalsize-0.1.0/src/evalsize/comparison.py +234 -0
- evalsize-0.1.0/src/evalsize/judge.py +139 -0
- evalsize-0.1.0/src/evalsize/precision.py +78 -0
- evalsize-0.1.0/src/evalsize/reruns.py +74 -0
- evalsize-0.1.0/src/evalsize.egg-info/PKG-INFO +173 -0
- evalsize-0.1.0/src/evalsize.egg-info/SOURCES.txt +19 -0
- evalsize-0.1.0/src/evalsize.egg-info/dependency_links.txt +1 -0
- evalsize-0.1.0/src/evalsize.egg-info/entry_points.txt +2 -0
- evalsize-0.1.0/src/evalsize.egg-info/requires.txt +3 -0
- evalsize-0.1.0/src/evalsize.egg-info/top_level.txt +1 -0
- evalsize-0.1.0/tests/test_cli.py +24 -0
- evalsize-0.1.0/tests/test_formulas.py +153 -0
- evalsize-0.1.0/tests/test_simulation.py +132 -0
evalsize-0.1.0/LICENSE
ADDED
|
@@ -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.
|
evalsize-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
evalsize-0.1.0/README.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# evalsize
|
|
2
|
+
|
|
3
|
+
**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/).
|
|
4
|
+
|
|
5
|
+
[](https://github.com/ai-with-md/evalsize/actions/workflows/tests.yml)
|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
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:
|
|
10
|
+
|
|
11
|
+
1. How many questions do I need to tell whether model B beats model A?
|
|
12
|
+
2. How precise is the score I already have?
|
|
13
|
+
3. Is the difference I measured real, or noise?
|
|
14
|
+
4. Should I add more questions, or run each question several times?
|
|
15
|
+
5. My LLM judge makes mistakes. How much does that move the score, and how many answers should I label by hand?
|
|
16
|
+
|
|
17
|
+
## The number most people get wrong
|
|
18
|
+
|
|
19
|
+
Comparing a 70% model with a 75% model, at the usual 80% power:
|
|
20
|
+
|
|
21
|
+
| Setup | Questions needed |
|
|
22
|
+
|---|---|
|
|
23
|
+
| Each model on its own set of questions | 1,251 per model |
|
|
24
|
+
| Same questions, models make mistakes independently | 1,254 |
|
|
25
|
+
| Same questions, models disagree on 15% of them | **469** |
|
|
26
|
+
|
|
27
|
+
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.
|
|
28
|
+
|
|
29
|
+
## Try it
|
|
30
|
+
|
|
31
|
+
**In the browser:** [ai-with-md.github.io/evalsize](https://ai-with-md.github.io/evalsize/). Move the sliders, nothing to install.
|
|
32
|
+
|
|
33
|
+
**In Python:**
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install evalsize
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
No dependencies beyond the standard library. Python 3.9 or newer.
|
|
40
|
+
|
|
41
|
+
## Use
|
|
42
|
+
|
|
43
|
+
### 1. How many questions to compare two models?
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import evalsize as es
|
|
47
|
+
|
|
48
|
+
es.examples_to_compare(baseline=0.70, improvement=0.05, disagreement=0.15)
|
|
49
|
+
# 469
|
|
50
|
+
|
|
51
|
+
es.smallest_detectable_improvement(n=300, baseline=0.70, disagreement=0.15)
|
|
52
|
+
# 0.062 -> with 300 questions you can reliably detect about 6 points, not less
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
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.
|
|
56
|
+
|
|
57
|
+
### 2. How precise is one score?
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
es.pass_rate_interval(passes=160, n=200)
|
|
61
|
+
# Pass rate 80.0% (160 of 200). 95% confidence interval: 73.9% to 85.0%.
|
|
62
|
+
|
|
63
|
+
es.examples_for_margin(margin=0.03, pass_rate=0.80)
|
|
64
|
+
# 682 questions for plus or minus 3 points
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 3. Is B really better than A?
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
result = es.compare_results(a_passed, b_passed) # two lists of True/False, same question order
|
|
71
|
+
print(result)
|
|
72
|
+
# B minus A: +5.3% over 300 shared questions (95% interval +1.2% to +9.4%, p = 0.0166).
|
|
73
|
+
# B looks better than A. The models disagreed on 40 questions.
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Or from counts: `es.compare_counts(n=300, only_a_right=12, only_b_right=28)`.
|
|
77
|
+
|
|
78
|
+
### 4. More questions, or more runs per question?
|
|
79
|
+
|
|
80
|
+
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.
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
share = es.question_share(pilot_runs) # one row per question, one 0/1 per run
|
|
84
|
+
es.effective_examples(n_questions=200, runs_per_question=5, question_share=share)
|
|
85
|
+
# with share 0.6: worth 294 single-run questions, and no number of reruns can beat 333
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
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.
|
|
89
|
+
|
|
90
|
+
### 5. Can I trust my LLM judge?
|
|
91
|
+
|
|
92
|
+
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:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
check = es.judge_agreement(human_labels, judge_labels)
|
|
96
|
+
print(check)
|
|
97
|
+
# The judge agreed with humans on 87.5% of 200 labels. It passed 95.0% of answers
|
|
98
|
+
# humans passed and failed 70.0% of answers humans failed.
|
|
99
|
+
|
|
100
|
+
es.correct_judge_rate(judge_rate=0.82, n_judged=1000,
|
|
101
|
+
sensitivity=check.sensitivity, specificity=check.specificity,
|
|
102
|
+
human_passes=check.human_passes, human_fails=check.human_fails)
|
|
103
|
+
# The judge says 82.0%. Corrected for the judge's mistakes: 80.0% (95% interval 73.2% to 86.8%).
|
|
104
|
+
|
|
105
|
+
es.labels_to_check_judge(margin=0.05, pass_rate=0.80)
|
|
106
|
+
# how many answers to label by hand before you start
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Command line
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
evalsize compare --baseline 0.7 --improvement 0.05 --disagreement 0.15
|
|
113
|
+
evalsize detect --baseline 0.7 --n 300 --disagreement 0.15
|
|
114
|
+
evalsize margin --rate 0.8 --margin 0.03
|
|
115
|
+
evalsize diff --n 300 --only-a 12 --only-b 28
|
|
116
|
+
evalsize reruns --n 200 --share 0.6
|
|
117
|
+
evalsize judge --judge-rate 0.82 --sensitivity 0.95 --specificity 0.7 --human-passes 140 --human-fails 60
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## How the numbers are checked
|
|
121
|
+
|
|
122
|
+
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).
|
|
123
|
+
|
|
124
|
+
## How it compares
|
|
125
|
+
|
|
126
|
+
- **[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.
|
|
127
|
+
- **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.
|
|
128
|
+
|
|
129
|
+
## Methods
|
|
130
|
+
|
|
131
|
+
| Question | Method |
|
|
132
|
+
|---|---|
|
|
133
|
+
| Precision of one score | Wilson score interval |
|
|
134
|
+
| Comparing two models, planning | McNemar sample size formula (Connor, 1987); two-proportion formula when unpaired |
|
|
135
|
+
| Comparing two models, analysis | Exact McNemar test, Agresti and Min (2005) interval for the paired difference |
|
|
136
|
+
| Reruns | Intraclass correlation (one-way analysis of variance estimator) and the design effect |
|
|
137
|
+
| Imperfect judges | Rogan and Gladen (1978) estimator with a delta method interval that includes the uncertainty in the judge's measured error rates |
|
|
138
|
+
|
|
139
|
+
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.
|
|
140
|
+
|
|
141
|
+
## Development
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
python -m venv .venv
|
|
145
|
+
source .venv/bin/activate
|
|
146
|
+
pip install -e ".[dev]"
|
|
147
|
+
pytest
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
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`.
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
MIT
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "evalsize"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "How many eval examples do I need? Sample size and power planning for LLM evals"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{name = "Meenal Asesh"}]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["llm", "evaluation", "evals", "sample size", "power analysis", "statistics", "llm-as-a-judge", "a/b testing"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = ["pytest"]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
evalsize = "evalsize.cli:main"
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/ai-with-md/evalsize"
|
|
29
|
+
Calculator = "https://ai-with-md.github.io/evalsize/"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["src"]
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
testpaths = ["tests", "src"]
|
|
36
|
+
addopts = "--doctest-modules"
|
evalsize-0.1.0/setup.cfg
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"
|
|
@@ -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)
|
|
@@ -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())
|