evalstats 0.2.0__tar.gz → 0.2.2__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.
- {evalstats-0.2.0 → evalstats-0.2.2}/PKG-INFO +180 -11
- {evalstats-0.2.0 → evalstats-0.2.2}/README.md +179 -10
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats/__init__.py +25 -11
- evalstats-0.2.2/evalstats/alignment.py +560 -0
- evalstats-0.2.2/evalstats/api.py +1881 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats/cli.py +19 -5
- evalstats-0.2.2/evalstats/config.py +251 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats/core/bundles.py +21 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats/core/mixed_effects.py +330 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats/core/paired.py +529 -278
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats/core/ranking.py +92 -3
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats/core/resampling.py +116 -4
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats/core/router.py +89 -33
- evalstats-0.2.2/evalstats/core/stats_utils.py +128 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats/core/summary.py +492 -106
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats/core/types.py +0 -1
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats/core/variance.py +79 -13
- evalstats-0.2.2/evalstats/loader.py +697 -0
- evalstats-0.2.2/evalstats/ppi.py +375 -0
- evalstats-0.2.2/evalstats/tests/__init__.py +3141 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats/vis/critical_difference.py +0 -1
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats/vis/forest.py +3 -5
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats/vis/scoreboard.py +8 -9
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats.egg-info/PKG-INFO +180 -11
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats.egg-info/SOURCES.txt +10 -3
- {evalstats-0.2.0 → evalstats-0.2.2}/pyproject.toml +1 -1
- evalstats-0.2.2/tests/test_alignment.py +995 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/tests/test_analyze.py +24 -1
- {evalstats-0.2.0 → evalstats-0.2.2}/tests/test_bayes_binary_routing.py +19 -36
- evalstats-0.2.2/tests/test_bootstrap_t_pairwise_ranking.py +39 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/tests/test_cli.py +93 -10
- evalstats-0.2.2/tests/test_compare.py +409 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/tests/test_critical_difference_plot.py +10 -9
- evalstats-0.2.2/tests/test_ppi_core.py +151 -0
- evalstats-0.2.2/tests/test_ppi_corrections.py +2510 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/tests/test_resampling.py +2 -2
- {evalstats-0.2.0 → evalstats-0.2.2}/tests/test_scoreboard_plot.py +23 -17
- {evalstats-0.2.0 → evalstats-0.2.2}/tests/test_set_alpha_ci.py +2 -2
- {evalstats-0.2.0 → evalstats-0.2.2}/tests/test_simultaneous_ci.py +39 -20
- {evalstats-0.2.0 → evalstats-0.2.2}/tests/test_wilson_newcombe.py +67 -56
- evalstats-0.2.0/evalstats/compare.py +0 -900
- evalstats-0.2.0/evalstats/config.py +0 -29
- evalstats-0.2.0/evalstats/core/stats_utils.py +0 -47
- evalstats-0.2.0/tests/test_compare_models.py +0 -385
- evalstats-0.2.0/tests/test_compare_prompts.py +0 -567
- {evalstats-0.2.0 → evalstats-0.2.2}/LICENSE +0 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats/core/__init__.py +0 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats/core/bayes_evals.py +0 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats/io.py +0 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats/vis/__init__.py +0 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats/vis/heatmap.py +0 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats/vis/point_estimates.py +0 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats.egg-info/dependency_links.txt +0 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats.egg-info/entry_points.txt +0 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats.egg-info/requires.txt +0 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/evalstats.egg-info/top_level.txt +0 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/setup.cfg +0 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/tests/test_analyze_factorial.py +0 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/tests/test_auto_ci_routing.py +0 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/tests/test_io.py +0 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/tests/test_lmm.py +0 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/tests/test_lmm_backend_parity.py +0 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/tests/test_lmm_statsmodels.py +0 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/tests/test_nig_ci_methods.py +0 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/tests/test_p_values.py +0 -0
- {evalstats-0.2.0 → evalstats-0.2.2}/tests/test_permutation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: evalstats
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Statistically sane analysis methods for comparing AI model and prompt performance.
|
|
5
5
|
Author: Ian Arawjo
|
|
6
6
|
License-Expression: MIT
|
|
@@ -81,7 +81,7 @@ the lessons hold regardless of implementation).
|
|
|
81
81
|
|
|
82
82
|
## Sample output
|
|
83
83
|
|
|
84
|
-
Running `
|
|
84
|
+
Running `es.compare(evaldata, factors="prompt")` and then `result.summary()` prints a full statistical report to the terminal, including confidence interval line plots, pairwise comparisons between prompt templates, and per-input stability across runs (how stable the model is across multiple runs for the same input). Below is example excerpt from an analysis of a 4-template sentiment-classification benchmark (GPT-4.1-nano, 27 inputs, 3 runs, 3 evaluators):
|
|
85
85
|
|
|
86
86
|

|
|
87
87
|
|
|
@@ -99,10 +99,10 @@ You can also plot within notebook environments (although this feature is being a
|
|
|
99
99
|
|
|
100
100
|
## Statistics
|
|
101
101
|
|
|
102
|
-
The specific statistical tests
|
|
102
|
+
The specific statistical tests that `evalstats.compare()` runs (via the lower-level `analyze()` engine underneath it) are:
|
|
103
103
|
|
|
104
104
|
- **All pairwise prompt comparisons (paired by input)** via `all_pairwise(...)`:
|
|
105
|
-
- Computes mean or median difference (mean by default), bootstrapped
|
|
105
|
+
- Computes mean or median difference (mean by default), bootstrapped 95% confidence interval, and p-value for every prompt template pair.
|
|
106
106
|
- Comparison method defaults to `method="auto"`:
|
|
107
107
|
- **Smoothed bootstrap with a Gaussian KDE** (`method="smooth_bootstrap"`) in situations of non-binary data. It has been verified in our simulations that for eval-type data and small sample sizes especially, smoothed is superior to the other bootstrap methods considered (percentile, BCa, Bayesian).
|
|
108
108
|
- **Bayesian pairwise from [`bayes_evals`](https://github.com/sambowyer/bayes_evals/tree/main) and McNemar's test**: Default methods for binary scores (0 or 1 only). Our simulations showed Bayesian pairwise was superior to bootstrap at small N. Note that Bayesian methods should technically be called credible intervals, but they estimate the confidence interval very closely.
|
|
@@ -144,13 +144,64 @@ From the command line, `evalstats` can read a CSV or Excel file directly and pri
|
|
|
144
144
|
evalstats analyze results.csv
|
|
145
145
|
```
|
|
146
146
|
|
|
147
|
-
The input file should have
|
|
147
|
+
The input file should have a prompt/template column, an item/input column, and a score column (model, run, and evaluator columns are optional) — see the column alias table in the [Python API](#python-api) section below for recognized names. Run `evalstats analyze --help` for the full list of options and supported column aliases.
|
|
148
148
|
|
|
149
149
|
For more complex statistical analysis with mixed effects models, use `method="lmm"`. The default `statsmodels` backend works out of the box; for the optional R-based backend, see below.
|
|
150
150
|
|
|
151
151
|
## Python API
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
The main entry point is `load_from()` + `compare()`: parse your data once into an `EvalResults` object, then run comparisons against it.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
import pandas as pd
|
|
157
|
+
import evalstats as es
|
|
158
|
+
|
|
159
|
+
df = pd.read_csv("results.csv") # columns: prompt, item, score (model optional)
|
|
160
|
+
|
|
161
|
+
evaldata = es.load_from(df)
|
|
162
|
+
evaldata.summary() # inspect detected structure/column assignments before analyzing
|
|
163
|
+
|
|
164
|
+
result = es.compare(evaldata, factors="prompt")
|
|
165
|
+
result.summary() # full terminal report: CIs, pairwise tests, rank probabilities
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`evalstats` expects **long-format** data: one row per (item, score) observation, plus whichever axis you want to compare — `model`, `prompt`, or both — and optionally `run` for repeated runs. Only `item` and `score` are strictly required; you need at least one of `model`/`prompt` too, whichever you pass to `compare(factors=...)`. `load_from()` auto-detects each column's role by matching its name (case-insensitively) against this table:
|
|
169
|
+
|
|
170
|
+
| Role | Canonical name | Recognized aliases | Required? |
|
|
171
|
+
|----------|-----------------|----------------------------------------|------------------------------------------------------|
|
|
172
|
+
| model | `model` | `model_label`, `model_name` | Optional — needed to compare models (`factors="model"`) |
|
|
173
|
+
| prompt | `prompt` | `template`, `prompt_template` | Optional — needed to compare prompts (`factors="prompt"`) |
|
|
174
|
+
| item | `item` | `input`, `example`, `id`, `input_label`| Yes |
|
|
175
|
+
| score | `score` | `value`, `result`, `metric` | Yes |
|
|
176
|
+
| run | `run` | `seed`, `repeat`, `run_id`, `trial` | Optional — add if you have repeated runs per (model/prompt, item) |
|
|
177
|
+
|
|
178
|
+
For example, a minimal CSV comparing prompts:
|
|
179
|
+
|
|
180
|
+
| prompt | item | score |
|
|
181
|
+
|-------------|------|-------|
|
|
182
|
+
| Minimal | q1 | 0.82 |
|
|
183
|
+
| Instructive | q1 | 0.91 |
|
|
184
|
+
| Minimal | q2 | 0.75 |
|
|
185
|
+
| Instructive | q2 | 0.88 |
|
|
186
|
+
|
|
187
|
+
If your columns don't match any of the aliases above, remap them explicitly with `col_map`:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
evaldata = es.load_from(df, col_map={"llm": "model", "variant": "prompt", "q_id": "item"})
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`compare()` also handles:
|
|
194
|
+
|
|
195
|
+
- **Comparing models**: `factors="model"`
|
|
196
|
+
- **Factorial designs** (model × prompt): `factors=["model", "prompt"]` (routes to an LMM backend)
|
|
197
|
+
- **Filtering**: any keyword matching a column name acts as a row filter, e.g. `es.compare(evaldata, factors="model", split="test")`
|
|
198
|
+
- **PPI-corrected inference** for noisy LLM-judge scores against a smaller human-labeled subset — see [PPI-Corrected Inference](#ppi-corrected-inference-means-cis-and-tests) below
|
|
199
|
+
|
|
200
|
+
The returned `result` is a `ComparisonResult`. Besides `.summary()`, it has `.to_frame()` / `.to_dict()` for programmatic access, `.plot(method="bar" | "forest" | "cd")` for charts, and `.disagreements()` to surface the items entities disagree on most.
|
|
201
|
+
|
|
202
|
+
### Advanced: raw score arrays (low-level engine)
|
|
203
|
+
|
|
204
|
+
`compare()` is a wrapper around a lower-level engine, `analyze()`, which operates directly on `BenchmarkResult` / `MultiModelBenchmark` objects (numpy score arrays) rather than a DataFrame. Reach for this path only if you already have scores as arrays and don't want to build a DataFrame first — most use cases should use `compare()` above.
|
|
154
205
|
|
|
155
206
|
```python
|
|
156
207
|
import numpy as np
|
|
@@ -177,10 +228,10 @@ result = estats.BenchmarkResult(
|
|
|
177
228
|
)
|
|
178
229
|
|
|
179
230
|
analysis = estats.analyze(result, reference="grand_mean", n_bootstrap=5_000)
|
|
180
|
-
|
|
231
|
+
analysis.summary() # same terminal report as ComparisonResult.summary()
|
|
181
232
|
```
|
|
182
233
|
|
|
183
|
-
If
|
|
234
|
+
If you want this lower-level path from a DataFrame (e.g. to inspect the raw `BenchmarkResult` object, or to fine-tune `strict_complete_design`), use `from_dataframe()` instead of `load_from()`. It returns the array-based `BenchmarkResult` / `MultiModelBenchmark` that `analyze()` expects, plus an optional `DataLoadReport` — a data-quality log of coercions/repairs made while parsing (not a statistical report):
|
|
184
235
|
|
|
185
236
|
```python
|
|
186
237
|
import evalstats as estats
|
|
@@ -197,15 +248,133 @@ for line in load_report.to_lines():
|
|
|
197
248
|
print(line)
|
|
198
249
|
|
|
199
250
|
analysis = estats.analyze(benchmark)
|
|
251
|
+
analysis.summary()
|
|
200
252
|
```
|
|
201
253
|
|
|
202
|
-
To visualize absolute prompt performance
|
|
254
|
+
To visualize absolute prompt performance directly from a `BenchmarkResult`, bypassing `analyze()` (use `result.plot()` above instead if you're on the `compare()` path):
|
|
203
255
|
|
|
204
256
|
```python
|
|
205
257
|
fig = estats.plot_point_estimates(result)
|
|
206
258
|
fig.savefig("mean_performance.png", dpi=150, bbox_inches="tight")
|
|
207
259
|
```
|
|
208
260
|
|
|
261
|
+
## PPI-Corrected Inference (Means, CIs, and Tests)
|
|
262
|
+
|
|
263
|
+
`evalstats` supports PPI-corrected inference for means, confidence intervals, and common statistical tests.
|
|
264
|
+
|
|
265
|
+
PPI (Prediction-Powered Inference) lets you use lots of cheap LLM
|
|
266
|
+
judgments plus a smaller set of human labels to correct measurement error from the LLM
|
|
267
|
+
judge. This gives you corrected estimates and uncertainty that better reflect what you
|
|
268
|
+
would have gotten from a fully human-labeled study (Angelopoulos et al., 2023).
|
|
269
|
+
|
|
270
|
+
Most PPI correction methods use PPIBoot (bootstrap variant of PPI; Zrnic, 2024).
|
|
271
|
+
Implemented corrections have been battle-tested via simulations (see `simulations/sim_type_i_calibration.py`).
|
|
272
|
+
|
|
273
|
+
### Example: Comparing models with corrected LLM judge evals via `compare(..., alignment=...)`
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
import evalstats as es
|
|
277
|
+
|
|
278
|
+
# Dataframe columns include:
|
|
279
|
+
# model item llm_score human_score (NaN for unlabeled rows)
|
|
280
|
+
evaldata = es.load_from(df)
|
|
281
|
+
|
|
282
|
+
# Compute alignment between LLM and human judges
|
|
283
|
+
alignment = es.validate_alignment(
|
|
284
|
+
evaldata,
|
|
285
|
+
llm_metric="llm_score",
|
|
286
|
+
human_groundtruth="human_score",
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# Compare models, using PPI to correct for bias/misalignment with human graders
|
|
290
|
+
result = es.compare(
|
|
291
|
+
evaldata,
|
|
292
|
+
factors="model",
|
|
293
|
+
metric="llm_score",
|
|
294
|
+
alignment={"llm_score": alignment},
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
result.summary()
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### Example: T-test PPI-correction via `evalstats.tests.ttest`
|
|
301
|
+
|
|
302
|
+
Use this for a t-test of mean differences between two groups (or two paired
|
|
303
|
+
conditions when `paired=True`).
|
|
304
|
+
|
|
305
|
+
```python
|
|
306
|
+
import evalstats as es
|
|
307
|
+
|
|
308
|
+
res = es.tests.ttest(
|
|
309
|
+
a=llm_a,
|
|
310
|
+
b=llm_b,
|
|
311
|
+
a_lab=human_a, # same length as llm_a, NaN where unlabeled
|
|
312
|
+
b_lab=human_b, # same length as llm_b, NaN where unlabeled
|
|
313
|
+
paired=False,
|
|
314
|
+
print_result=False,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
print(res.p_value, res.corrected_p_value, res.corrected_ci)
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### Example: Mann-Whitney U test PPI-correction via `evalstats.tests.mannwhitney`
|
|
321
|
+
|
|
322
|
+
Use this for a Mann-Whitney U test, a nonparametric two-group comparison based
|
|
323
|
+
on relative ranks rather than assuming normally distributed scores.
|
|
324
|
+
|
|
325
|
+
```python
|
|
326
|
+
import evalstats as es
|
|
327
|
+
|
|
328
|
+
res = es.tests.mannwhitney(
|
|
329
|
+
x=llm_x,
|
|
330
|
+
y=llm_y,
|
|
331
|
+
x_lab=human_x,
|
|
332
|
+
y_lab=human_y,
|
|
333
|
+
print_result=False,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
print(res.p_value, res.corrected_p_value, res.corrected_ci)
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### Example: Wilcoxon signed-ranks test PPI-correction via `evalstats.tests.wilcoxon` (paired)
|
|
340
|
+
|
|
341
|
+
Use this for a Wilcoxon signed-rank test, a nonparametric paired test for
|
|
342
|
+
matched observations (before/after, A/B on the same items, etc.).
|
|
343
|
+
|
|
344
|
+
```python
|
|
345
|
+
import evalstats as es
|
|
346
|
+
|
|
347
|
+
res = es.tests.wilcoxon(
|
|
348
|
+
x=llm_before,
|
|
349
|
+
y=llm_after,
|
|
350
|
+
x_lab=human_before,
|
|
351
|
+
y_lab=human_after,
|
|
352
|
+
print_result=False,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
print(res.p_value, res.corrected_p_value, res.corrected_ci)
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Example: One-way ANOVA PPI-correction via `evalstats.tests.anova_oneway`
|
|
359
|
+
|
|
360
|
+
Use this for one-way ANOVA when comparing more than two groups, with
|
|
361
|
+
`repeated=True` for repeated-measures (same subjects across conditions).
|
|
362
|
+
|
|
363
|
+
```python
|
|
364
|
+
import evalstats as es
|
|
365
|
+
|
|
366
|
+
res = es.tests.anova_oneway(
|
|
367
|
+
llm_g1,
|
|
368
|
+
llm_g2,
|
|
369
|
+
llm_g3,
|
|
370
|
+
groups_lab=[human_g1, human_g2, human_g3],
|
|
371
|
+
repeated=False,
|
|
372
|
+
print_result=False,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
print(res.p_value, res.corrected_p_value, res.corrected_ci)
|
|
376
|
+
```
|
|
377
|
+
|
|
209
378
|
## Motivation
|
|
210
379
|
|
|
211
380
|
Most eval tools in the LLM evaluation space don't help users perform _any_ statistical tests, let alone showcase variances in performance between prompts or models. They instead present bar charts of average performance. Developers then glance at the bar chart and decide that "prompt/model A is better than B." But was it really?
|
|
@@ -220,7 +389,7 @@ Why do people do evals this way? Well, they don't have the time, tools, or knowl
|
|
|
220
389
|
|
|
221
390
|
### Is one prompt "better" than others? Quantify uncertainty
|
|
222
391
|
|
|
223
|
-
When you have scores for multiple prompt templates across a set of inputs, `evalstats` computes bootstrapped
|
|
392
|
+
When you have scores for multiple prompt templates across a set of inputs, `evalstats` computes bootstrapped 95% confidence intervals and pairwise significance tests so you can see not just which prompt scored highest on average, but how certain you can be about that ranking. It plots these to the terminal so you can check at a glance:
|
|
224
393
|
|
|
225
394
|

|
|
226
395
|
|
|
@@ -228,7 +397,7 @@ When you have scores for multiple prompt templates across a set of inputs, `eval
|
|
|
228
397
|
|
|
229
398
|
A common failure mode in LLM benchmarking, both in academic papers and practitioner evaluations, is testing each model with a single prompt template and reporting the resulting scores as if they reflect stable model capabilities. In reality, model rankings can flip under semantically equivalent paraphrases of the same instruction. A benchmark result that says "Model A beats Model B" may be an artifact of prompt phrasing, not a meaningful capability difference.
|
|
230
399
|
|
|
231
|
-
Here, we can see the difference between OpenAI's `gpt-4.1-nano` and MistralAI's `ministral-8b-2512` on a small sentiment classification benchmark, quantified by bootstrapped
|
|
400
|
+
Here, we can see the difference between OpenAI's `gpt-4.1-nano` and MistralAI's `ministral-8b-2512` on a small sentiment classification benchmark, quantified by bootstrapped 95% confidence intervals:
|
|
232
401
|
|
|
233
402
|

|
|
234
403
|
|
|
@@ -27,7 +27,7 @@ the lessons hold regardless of implementation).
|
|
|
27
27
|
|
|
28
28
|
## Sample output
|
|
29
29
|
|
|
30
|
-
Running `
|
|
30
|
+
Running `es.compare(evaldata, factors="prompt")` and then `result.summary()` prints a full statistical report to the terminal, including confidence interval line plots, pairwise comparisons between prompt templates, and per-input stability across runs (how stable the model is across multiple runs for the same input). Below is example excerpt from an analysis of a 4-template sentiment-classification benchmark (GPT-4.1-nano, 27 inputs, 3 runs, 3 evaluators):
|
|
31
31
|
|
|
32
32
|

|
|
33
33
|
|
|
@@ -45,10 +45,10 @@ You can also plot within notebook environments (although this feature is being a
|
|
|
45
45
|
|
|
46
46
|
## Statistics
|
|
47
47
|
|
|
48
|
-
The specific statistical tests
|
|
48
|
+
The specific statistical tests that `evalstats.compare()` runs (via the lower-level `analyze()` engine underneath it) are:
|
|
49
49
|
|
|
50
50
|
- **All pairwise prompt comparisons (paired by input)** via `all_pairwise(...)`:
|
|
51
|
-
- Computes mean or median difference (mean by default), bootstrapped
|
|
51
|
+
- Computes mean or median difference (mean by default), bootstrapped 95% confidence interval, and p-value for every prompt template pair.
|
|
52
52
|
- Comparison method defaults to `method="auto"`:
|
|
53
53
|
- **Smoothed bootstrap with a Gaussian KDE** (`method="smooth_bootstrap"`) in situations of non-binary data. It has been verified in our simulations that for eval-type data and small sample sizes especially, smoothed is superior to the other bootstrap methods considered (percentile, BCa, Bayesian).
|
|
54
54
|
- **Bayesian pairwise from [`bayes_evals`](https://github.com/sambowyer/bayes_evals/tree/main) and McNemar's test**: Default methods for binary scores (0 or 1 only). Our simulations showed Bayesian pairwise was superior to bootstrap at small N. Note that Bayesian methods should technically be called credible intervals, but they estimate the confidence interval very closely.
|
|
@@ -90,13 +90,64 @@ From the command line, `evalstats` can read a CSV or Excel file directly and pri
|
|
|
90
90
|
evalstats analyze results.csv
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
-
The input file should have
|
|
93
|
+
The input file should have a prompt/template column, an item/input column, and a score column (model, run, and evaluator columns are optional) — see the column alias table in the [Python API](#python-api) section below for recognized names. Run `evalstats analyze --help` for the full list of options and supported column aliases.
|
|
94
94
|
|
|
95
95
|
For more complex statistical analysis with mixed effects models, use `method="lmm"`. The default `statsmodels` backend works out of the box; for the optional R-based backend, see below.
|
|
96
96
|
|
|
97
97
|
## Python API
|
|
98
98
|
|
|
99
|
-
|
|
99
|
+
The main entry point is `load_from()` + `compare()`: parse your data once into an `EvalResults` object, then run comparisons against it.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
import pandas as pd
|
|
103
|
+
import evalstats as es
|
|
104
|
+
|
|
105
|
+
df = pd.read_csv("results.csv") # columns: prompt, item, score (model optional)
|
|
106
|
+
|
|
107
|
+
evaldata = es.load_from(df)
|
|
108
|
+
evaldata.summary() # inspect detected structure/column assignments before analyzing
|
|
109
|
+
|
|
110
|
+
result = es.compare(evaldata, factors="prompt")
|
|
111
|
+
result.summary() # full terminal report: CIs, pairwise tests, rank probabilities
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`evalstats` expects **long-format** data: one row per (item, score) observation, plus whichever axis you want to compare — `model`, `prompt`, or both — and optionally `run` for repeated runs. Only `item` and `score` are strictly required; you need at least one of `model`/`prompt` too, whichever you pass to `compare(factors=...)`. `load_from()` auto-detects each column's role by matching its name (case-insensitively) against this table:
|
|
115
|
+
|
|
116
|
+
| Role | Canonical name | Recognized aliases | Required? |
|
|
117
|
+
|----------|-----------------|----------------------------------------|------------------------------------------------------|
|
|
118
|
+
| model | `model` | `model_label`, `model_name` | Optional — needed to compare models (`factors="model"`) |
|
|
119
|
+
| prompt | `prompt` | `template`, `prompt_template` | Optional — needed to compare prompts (`factors="prompt"`) |
|
|
120
|
+
| item | `item` | `input`, `example`, `id`, `input_label`| Yes |
|
|
121
|
+
| score | `score` | `value`, `result`, `metric` | Yes |
|
|
122
|
+
| run | `run` | `seed`, `repeat`, `run_id`, `trial` | Optional — add if you have repeated runs per (model/prompt, item) |
|
|
123
|
+
|
|
124
|
+
For example, a minimal CSV comparing prompts:
|
|
125
|
+
|
|
126
|
+
| prompt | item | score |
|
|
127
|
+
|-------------|------|-------|
|
|
128
|
+
| Minimal | q1 | 0.82 |
|
|
129
|
+
| Instructive | q1 | 0.91 |
|
|
130
|
+
| Minimal | q2 | 0.75 |
|
|
131
|
+
| Instructive | q2 | 0.88 |
|
|
132
|
+
|
|
133
|
+
If your columns don't match any of the aliases above, remap them explicitly with `col_map`:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
evaldata = es.load_from(df, col_map={"llm": "model", "variant": "prompt", "q_id": "item"})
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`compare()` also handles:
|
|
140
|
+
|
|
141
|
+
- **Comparing models**: `factors="model"`
|
|
142
|
+
- **Factorial designs** (model × prompt): `factors=["model", "prompt"]` (routes to an LMM backend)
|
|
143
|
+
- **Filtering**: any keyword matching a column name acts as a row filter, e.g. `es.compare(evaldata, factors="model", split="test")`
|
|
144
|
+
- **PPI-corrected inference** for noisy LLM-judge scores against a smaller human-labeled subset — see [PPI-Corrected Inference](#ppi-corrected-inference-means-cis-and-tests) below
|
|
145
|
+
|
|
146
|
+
The returned `result` is a `ComparisonResult`. Besides `.summary()`, it has `.to_frame()` / `.to_dict()` for programmatic access, `.plot(method="bar" | "forest" | "cd")` for charts, and `.disagreements()` to surface the items entities disagree on most.
|
|
147
|
+
|
|
148
|
+
### Advanced: raw score arrays (low-level engine)
|
|
149
|
+
|
|
150
|
+
`compare()` is a wrapper around a lower-level engine, `analyze()`, which operates directly on `BenchmarkResult` / `MultiModelBenchmark` objects (numpy score arrays) rather than a DataFrame. Reach for this path only if you already have scores as arrays and don't want to build a DataFrame first — most use cases should use `compare()` above.
|
|
100
151
|
|
|
101
152
|
```python
|
|
102
153
|
import numpy as np
|
|
@@ -123,10 +174,10 @@ result = estats.BenchmarkResult(
|
|
|
123
174
|
)
|
|
124
175
|
|
|
125
176
|
analysis = estats.analyze(result, reference="grand_mean", n_bootstrap=5_000)
|
|
126
|
-
|
|
177
|
+
analysis.summary() # same terminal report as ComparisonResult.summary()
|
|
127
178
|
```
|
|
128
179
|
|
|
129
|
-
If
|
|
180
|
+
If you want this lower-level path from a DataFrame (e.g. to inspect the raw `BenchmarkResult` object, or to fine-tune `strict_complete_design`), use `from_dataframe()` instead of `load_from()`. It returns the array-based `BenchmarkResult` / `MultiModelBenchmark` that `analyze()` expects, plus an optional `DataLoadReport` — a data-quality log of coercions/repairs made while parsing (not a statistical report):
|
|
130
181
|
|
|
131
182
|
```python
|
|
132
183
|
import evalstats as estats
|
|
@@ -143,15 +194,133 @@ for line in load_report.to_lines():
|
|
|
143
194
|
print(line)
|
|
144
195
|
|
|
145
196
|
analysis = estats.analyze(benchmark)
|
|
197
|
+
analysis.summary()
|
|
146
198
|
```
|
|
147
199
|
|
|
148
|
-
To visualize absolute prompt performance
|
|
200
|
+
To visualize absolute prompt performance directly from a `BenchmarkResult`, bypassing `analyze()` (use `result.plot()` above instead if you're on the `compare()` path):
|
|
149
201
|
|
|
150
202
|
```python
|
|
151
203
|
fig = estats.plot_point_estimates(result)
|
|
152
204
|
fig.savefig("mean_performance.png", dpi=150, bbox_inches="tight")
|
|
153
205
|
```
|
|
154
206
|
|
|
207
|
+
## PPI-Corrected Inference (Means, CIs, and Tests)
|
|
208
|
+
|
|
209
|
+
`evalstats` supports PPI-corrected inference for means, confidence intervals, and common statistical tests.
|
|
210
|
+
|
|
211
|
+
PPI (Prediction-Powered Inference) lets you use lots of cheap LLM
|
|
212
|
+
judgments plus a smaller set of human labels to correct measurement error from the LLM
|
|
213
|
+
judge. This gives you corrected estimates and uncertainty that better reflect what you
|
|
214
|
+
would have gotten from a fully human-labeled study (Angelopoulos et al., 2023).
|
|
215
|
+
|
|
216
|
+
Most PPI correction methods use PPIBoot (bootstrap variant of PPI; Zrnic, 2024).
|
|
217
|
+
Implemented corrections have been battle-tested via simulations (see `simulations/sim_type_i_calibration.py`).
|
|
218
|
+
|
|
219
|
+
### Example: Comparing models with corrected LLM judge evals via `compare(..., alignment=...)`
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
import evalstats as es
|
|
223
|
+
|
|
224
|
+
# Dataframe columns include:
|
|
225
|
+
# model item llm_score human_score (NaN for unlabeled rows)
|
|
226
|
+
evaldata = es.load_from(df)
|
|
227
|
+
|
|
228
|
+
# Compute alignment between LLM and human judges
|
|
229
|
+
alignment = es.validate_alignment(
|
|
230
|
+
evaldata,
|
|
231
|
+
llm_metric="llm_score",
|
|
232
|
+
human_groundtruth="human_score",
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Compare models, using PPI to correct for bias/misalignment with human graders
|
|
236
|
+
result = es.compare(
|
|
237
|
+
evaldata,
|
|
238
|
+
factors="model",
|
|
239
|
+
metric="llm_score",
|
|
240
|
+
alignment={"llm_score": alignment},
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
result.summary()
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### Example: T-test PPI-correction via `evalstats.tests.ttest`
|
|
247
|
+
|
|
248
|
+
Use this for a t-test of mean differences between two groups (or two paired
|
|
249
|
+
conditions when `paired=True`).
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
import evalstats as es
|
|
253
|
+
|
|
254
|
+
res = es.tests.ttest(
|
|
255
|
+
a=llm_a,
|
|
256
|
+
b=llm_b,
|
|
257
|
+
a_lab=human_a, # same length as llm_a, NaN where unlabeled
|
|
258
|
+
b_lab=human_b, # same length as llm_b, NaN where unlabeled
|
|
259
|
+
paired=False,
|
|
260
|
+
print_result=False,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
print(res.p_value, res.corrected_p_value, res.corrected_ci)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Example: Mann-Whitney U test PPI-correction via `evalstats.tests.mannwhitney`
|
|
267
|
+
|
|
268
|
+
Use this for a Mann-Whitney U test, a nonparametric two-group comparison based
|
|
269
|
+
on relative ranks rather than assuming normally distributed scores.
|
|
270
|
+
|
|
271
|
+
```python
|
|
272
|
+
import evalstats as es
|
|
273
|
+
|
|
274
|
+
res = es.tests.mannwhitney(
|
|
275
|
+
x=llm_x,
|
|
276
|
+
y=llm_y,
|
|
277
|
+
x_lab=human_x,
|
|
278
|
+
y_lab=human_y,
|
|
279
|
+
print_result=False,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
print(res.p_value, res.corrected_p_value, res.corrected_ci)
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Example: Wilcoxon signed-ranks test PPI-correction via `evalstats.tests.wilcoxon` (paired)
|
|
286
|
+
|
|
287
|
+
Use this for a Wilcoxon signed-rank test, a nonparametric paired test for
|
|
288
|
+
matched observations (before/after, A/B on the same items, etc.).
|
|
289
|
+
|
|
290
|
+
```python
|
|
291
|
+
import evalstats as es
|
|
292
|
+
|
|
293
|
+
res = es.tests.wilcoxon(
|
|
294
|
+
x=llm_before,
|
|
295
|
+
y=llm_after,
|
|
296
|
+
x_lab=human_before,
|
|
297
|
+
y_lab=human_after,
|
|
298
|
+
print_result=False,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
print(res.p_value, res.corrected_p_value, res.corrected_ci)
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Example: One-way ANOVA PPI-correction via `evalstats.tests.anova_oneway`
|
|
305
|
+
|
|
306
|
+
Use this for one-way ANOVA when comparing more than two groups, with
|
|
307
|
+
`repeated=True` for repeated-measures (same subjects across conditions).
|
|
308
|
+
|
|
309
|
+
```python
|
|
310
|
+
import evalstats as es
|
|
311
|
+
|
|
312
|
+
res = es.tests.anova_oneway(
|
|
313
|
+
llm_g1,
|
|
314
|
+
llm_g2,
|
|
315
|
+
llm_g3,
|
|
316
|
+
groups_lab=[human_g1, human_g2, human_g3],
|
|
317
|
+
repeated=False,
|
|
318
|
+
print_result=False,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
print(res.p_value, res.corrected_p_value, res.corrected_ci)
|
|
322
|
+
```
|
|
323
|
+
|
|
155
324
|
## Motivation
|
|
156
325
|
|
|
157
326
|
Most eval tools in the LLM evaluation space don't help users perform _any_ statistical tests, let alone showcase variances in performance between prompts or models. They instead present bar charts of average performance. Developers then glance at the bar chart and decide that "prompt/model A is better than B." But was it really?
|
|
@@ -166,7 +335,7 @@ Why do people do evals this way? Well, they don't have the time, tools, or knowl
|
|
|
166
335
|
|
|
167
336
|
### Is one prompt "better" than others? Quantify uncertainty
|
|
168
337
|
|
|
169
|
-
When you have scores for multiple prompt templates across a set of inputs, `evalstats` computes bootstrapped
|
|
338
|
+
When you have scores for multiple prompt templates across a set of inputs, `evalstats` computes bootstrapped 95% confidence intervals and pairwise significance tests so you can see not just which prompt scored highest on average, but how certain you can be about that ranking. It plots these to the terminal so you can check at a glance:
|
|
170
339
|
|
|
171
340
|

|
|
172
341
|
|
|
@@ -174,7 +343,7 @@ When you have scores for multiple prompt templates across a set of inputs, `eval
|
|
|
174
343
|
|
|
175
344
|
A common failure mode in LLM benchmarking, both in academic papers and practitioner evaluations, is testing each model with a single prompt template and reporting the resulting scores as if they reflect stable model capabilities. In reality, model rankings can flip under semantically equivalent paraphrases of the same instruction. A benchmark result that says "Model A beats Model B" may be an artifact of prompt phrasing, not a meaningful capability difference.
|
|
176
345
|
|
|
177
|
-
Here, we can see the difference between OpenAI's `gpt-4.1-nano` and MistralAI's `ministral-8b-2512` on a small sentiment classification benchmark, quantified by bootstrapped
|
|
346
|
+
Here, we can see the difference between OpenAI's `gpt-4.1-nano` and MistralAI's `ministral-8b-2512` on a small sentiment classification benchmark, quantified by bootstrapped 95% confidence intervals:
|
|
178
347
|
|
|
179
348
|

|
|
180
349
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""evalstats: Statistical analysis and visualization for prompt benchmarking."""
|
|
2
2
|
|
|
3
|
+
# ── Core types and analysis engine ───────────────────────────────────────────
|
|
3
4
|
from evalstats.core.types import BenchmarkResult, MultiModelBenchmark
|
|
4
5
|
from evalstats.core.paired import pairwise_differences, all_pairwise, vs_baseline, friedman_nemenyi, FriedmanResult
|
|
5
6
|
from evalstats.core.ranking import bootstrap_ranks
|
|
@@ -24,17 +25,34 @@ from evalstats.vis.scoreboard import plot_accuracy_bar
|
|
|
24
25
|
from evalstats.io import from_dataframe, DataLoadReport
|
|
25
26
|
from evalstats.core.resampling import bayes_binary_ci_1d, bayes_paired_diff_ci
|
|
26
27
|
from evalstats.core import bayes_evals
|
|
27
|
-
from evalstats.compare import (
|
|
28
|
-
compare_prompts,
|
|
29
|
-
compare_models,
|
|
30
|
-
CompareReport,
|
|
31
|
-
EntityStats,
|
|
32
|
-
)
|
|
33
28
|
from evalstats.config import set_alpha_ci, get_alpha_ci
|
|
34
29
|
|
|
35
|
-
|
|
30
|
+
# ── High-level spec API ───────────────────────────────────────────────────────
|
|
31
|
+
# Must come after all other imports: importing evalstats.api triggers
|
|
32
|
+
# evalstats.compare submodule registration, which would shadow a bare
|
|
33
|
+
# "compare" name if it were imported before the submodule.
|
|
34
|
+
from evalstats.loader import load_from, EvalResults, EvalLoadError
|
|
35
|
+
from evalstats.api import compare, compare_models, compare_prompts, ComparisonResult
|
|
36
|
+
from evalstats.alignment import validate_alignment, AlignmentResult
|
|
37
|
+
from evalstats import ppi
|
|
38
|
+
from evalstats import tests
|
|
39
|
+
|
|
40
|
+
__version__ = "0.2.2"
|
|
36
41
|
|
|
37
42
|
__all__ = [
|
|
43
|
+
# High-level spec API
|
|
44
|
+
"load_from",
|
|
45
|
+
"validate_alignment",
|
|
46
|
+
"AlignmentResult",
|
|
47
|
+
"ppi",
|
|
48
|
+
"tests",
|
|
49
|
+
"EvalResults",
|
|
50
|
+
"EvalLoadError",
|
|
51
|
+
"compare",
|
|
52
|
+
"compare_models",
|
|
53
|
+
"compare_prompts",
|
|
54
|
+
"ComparisonResult",
|
|
55
|
+
# Core types
|
|
38
56
|
"BenchmarkResult",
|
|
39
57
|
"MultiModelBenchmark",
|
|
40
58
|
"pairwise_differences",
|
|
@@ -62,10 +80,6 @@ __all__ = [
|
|
|
62
80
|
"bayes_binary_ci_1d",
|
|
63
81
|
"bayes_paired_diff_ci",
|
|
64
82
|
"bayes_evals",
|
|
65
|
-
"compare_prompts",
|
|
66
|
-
"compare_models",
|
|
67
|
-
"CompareReport",
|
|
68
|
-
"EntityStats",
|
|
69
83
|
"analyze_factorial",
|
|
70
84
|
"set_alpha_ci",
|
|
71
85
|
"get_alpha_ci",
|