Skip to content

Repository files navigation

evalstats

Rigorous statistical analysis for LLM evaluations, from model and prompt comparisons to statistical tests resilient to LLM judge bias, including in small sample data regimes.

evalstats helps you answer questions like:

  • Is Prompt A actually better than Prompt B, or just slightly luckier on this dataset?
  • Does Model A beat Model B, or only under a specific prompt phrasing?
  • How sensitive is model performance to prompt wording?
  • Are my performance differences large enough to be meaningful, or just noise?
  • How stable are scores across runs, evaluators, or inputs?
  • Can I trust my LLM-judge scores, or do they need correcting against human labels first?

You give evalstats your benchmark data, and it runs statistically appropriate analyses that quantify uncertainty and provide confidence bounds on your claims. It does this in two main ways:

  • Comparisons: Comparing models, prompts, or both at once (or any other thing you're comparing, like agent harnesses), and get 95% confidence intervals, pairwise significance tests, and multi-run sensitivity analyses. evalstats guides you toward best practices and choose well-calibrated methods and procedures by default, backed by simulations, and was built specifically to fill the gap of statistical knowledge for small-sample size datasets N<100; it will output stats as long as there are at least 15 samples. See Statistics.
  • PPI-corrected inference: using a small set of human labels to correct bias in noisy LLM-judge scores, so your means, confidence intervals, and hypothesis tests p-values are accurately calibrated in the face of LLM judge bias. This builds on prediction-powered inference (PPI). See PPI-Corrected Inference.

In particular, scientists can use our PPI-corrected statistical tests to analyze data for mixed human-AI subject studies, where some observations are human-labeled and the rest are graded by an LLM judge. Use evalstats.tests directly for LLM-judge-bias-corrected versions of:

  • t-test (ttest, independent or paired; Welch's by default, or Student's equal-variance via equal_var=True)
  • Mann–Whitney U (mannwhitney)
  • Wilcoxon signed-rank (wilcoxon)
  • One-way ANOVA (anova_oneway, independent or repeated-measures)
  • Friedman test (friedman, repeated-measures rank-based)
  • Kruskal-Wallis (kruskalwallis, independent-groups rank-based)

As long as the items for human labeling were sampled at random from the full dataset, p-values will stay calibrated even when the LLM judge is biased or miscalibrated. These corrections are validated via extensive Monte Carlo simulations (see simulations/harness). To the best of our knowledge, evalstats provides the only known implementations of PPI-corrected rank-based nonparametric tests like Wilcoxon.

As well,

Important

We are actively building out this project, both the website/guide and the package. Aside from the package itself, there is a "learning" guide in website/ which I am building out and will return to after writing up the simulations. This will include simulation- and research-backed examples of statistics for LLM evals, as well as example code (which will, obviously, use evalstats, but the lessons hold regardless of implementation). If there's something you'd like to see, or guidance on a specific topic, let us know by raising an Issue.

Sample output

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):

Example terminal output

From this output, we can see that Minimal and Instructive are the most promising candidates, but it is statistically unclear which is better. We also see that Chain-of-thought gives the least consistent outputs across multiple runs for the same inputs, compared to the other methods.

In the most recent version of evalstats, there's also helpful colors to help you see this information. For instance, comparing models and prompts at the same time, evalstats shows a 4-way tie between four combinations of model-prompt:

Example terminal output with colors

You can also plot within notebook environments (although this feature is being actively built out over time and the least developed at the moment). The plot_point_estimates function produces a chart showing each template's absolute mean score with marginal confidence intervals:

Mean advantage plot

Statistics

The specific statistical tests that evalstats.compare() runs (via the lower-level analyze() engine underneath it) are:

  • All pairwise prompt comparisons (paired by input) via all_pairwise(...):

    • Computes mean or median difference (mean by default), bootstrapped 95% confidence interval, and p-value for every prompt template pair.
    • Comparison method defaults to method="auto":
      • 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).
      • Bayesian pairwise from bayes_evals 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.
    • Multiple-comparisons correction for p-values (defaults to Benjamini–Hochberg (fdr_bh)).
    • Also reports Wilcoxon signed-rank test p-value, in case you need it for people familiar with that test, although p-values from bootstrapped CIs are more robust
  • Bootstrap rank distribution via bootstrap_ranks(...):

    • Estimates each prompt template’s P(best) and expected rank among the full list of prompt templates.
  • Point estimates via robustness_metrics(...):

    • Descriptive stats like mean, median, std, CV, IQR, CVaR-10, and key percentiles.
    • Marginal confidence intervals on absolute means/medians.

If your benchmark includes repeated runs (R >= 3), bootstrap-based analyses above use a two-level nested bootstrap (resample inputs, then runs within each input) so run-to-run stochasticity is propagated into CIs and rankings. In that case, analyze() also returns a seed/input variance decomposition via seed_variance_decomposition(...).

If you set method="lmm", analyze() switches to a mixed-effects path (score ~ template + (1|input)) with Wald CIs and parametric rank distributions. By default this uses statsmodels (pure Python, no additional setup required); pass backend="pymer4" to use R's lme4/emmeans instead (requires a separate R installation — see below). Mixed effects model support is more experimental at the moment.

Installation and Quick start CLI

pip install evalstats

For Excel (.xlsx) input support:

pip install "evalstats[xlsx]"

For all optional extras (including mixed-effects/LMM support):

pip install "evalstats[all]"

From the command line, evalstats can read a CSV or Excel file directly and print a statistical summary:

evalstats analyze results.csv

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 section below for recognized names. Run evalstats analyze --help for the full list of options and supported column aliases.

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.

Python API

The main entry point is load_from() + compare(): parse your data once into an EvalResults object, then run comparisons against it.

import pandas as pd
import evalstats as es

df = pd.read_csv("results.csv")  # columns: prompt, item, score (model optional)

evaldata = es.load_from(df)
evaldata.summary()  # inspect detected structure/column assignments before analyzing

result = es.compare(evaldata, factors="prompt")
result.summary()  # full terminal report: CIs, pairwise tests, rank probabilities

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:

Role Canonical name Recognized aliases Required?
model model model_label, model_name Optional — needed to compare models (factors="model")
prompt prompt template, prompt_template Optional — needed to compare prompts (factors="prompt")
item item input, example, id, input_label Yes
score score value, result, metric Yes
run run seed, repeat, run_id, trial Optional — add if you have repeated runs per (model/prompt, item)

For example, a minimal CSV comparing prompts:

prompt item score
Minimal q1 0.82
Instructive q1 0.91
Minimal q2 0.75
Instructive q2 0.88

If your columns don't match any of the aliases above, remap them explicitly with col_map:

evaldata = es.load_from(df, col_map={"llm": "model", "variant": "prompt", "q_id": "item"})

compare() also handles:

  • Comparing models: factors="model"
  • Factorial designs (model × prompt): factors=["model", "prompt"] (routes to an LMM backend)
  • Filtering: any keyword matching a column name acts as a row filter, e.g. es.compare(evaldata, factors="model", split="test")
  • PPI-corrected inference for noisy LLM-judge scores against a smaller human-labeled subset — see PPI-Corrected Inference below

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.

Advanced: raw score arrays (low-level engine)

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.

import numpy as np
import evalstats as estats

# Example raw scores for 4 templates × 3 inputs (single run, single evaluator)
your_scores = [
    [0.91, 0.88, 0.86],
    [0.90, 0.89, 0.84],
    [0.85, 0.82, 0.80],
    [0.79, 0.76, 0.74],
]
n_templates = 4
n_inputs = 3

# scores shape: (n_templates, n_inputs, n_runs, n_evaluators)
# For a single evaluator and single run, shape is (N, M, 1, 1)
scores = np.array(your_scores).reshape(n_templates, n_inputs, 1, 1)

result = estats.BenchmarkResult(
    scores=scores,
    template_labels=["Minimal", "Instructive", "Few-shot", "Chain-of-thought"],
    input_labels=[f"input_{i}" for i in range(n_inputs)],
)

analysis = estats.analyze(result, reference="grand_mean", n_bootstrap=5_000)
analysis.summary()  # same terminal report as ComparisonResult.summary()

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):

import evalstats as estats

benchmark, load_report = estats.from_dataframe(
    df,
    format="auto",                  # auto / wide / long
    repair=True,                     # average duplicate cells + fill partial run slots
    strict_complete_design=True,     # set False to keep NaNs
    return_report=True,
)

for line in load_report.to_lines():
    print(line)

analysis = estats.analyze(benchmark)
analysis.summary()

To visualize absolute prompt performance directly from a BenchmarkResult, bypassing analyze() (use result.plot() above instead if you're on the compare() path):

fig = estats.plot_point_estimates(result)
fig.savefig("mean_performance.png", dpi=150, bbox_inches="tight")

PPI-Corrected Inference (Means, CIs, and Tests)

evalstats supports PPI-corrected inference for means, confidence intervals, and common statistical tests.

PPI (Prediction-Powered Inference) lets you use lots of cheap LLM judgments plus a smaller set of human labels to correct measurement error from the LLM judge. This gives you corrected estimates and uncertainty that better reflect what you would have gotten from a fully human-labeled study (Angelopoulos et al., 2023).

Most PPI correction methods use PPIBoot (bootstrap variant of PPI; Zrnic, 2024). Implemented corrections have been battle-tested via simulations (see simulations/sim_type_i_calibration.py).

Important: which items get a human label must be chosen uniformly at random. PPI correction assumes the labeled subset is representative of the full dataset. If your labeling process instead targets specific items — e.g. "always double-check the borderline or highest-scoring responses," a common real-world review habit — that's missing-not-at-random (MNAR) selection on the outcome, and PPI correction can stay badly miscalibrated no matter how many items you label. This isn't ordinary small-sample noise that more labels fixes; it was confirmed in simulation to persist from 15 up through 300 labeled items out of 400. See evalstats.ppi.correct's docstring for the full analysis. If you can't guarantee random labeling, treat any PPI-corrected result here with caution regardless of the reported CI/p-value.

Example: Comparing models with corrected LLM judge evals via compare(..., alignment=...)

import evalstats as es

# Dataframe columns include:
#  model   item    llm_score  human_score (NaN for unlabeled rows)
evaldata = es.load_from(df)

# Compute alignment between LLM and human judges
alignment = es.validate_alignment(
    evaldata,
    llm_metric="llm_score",
    human_groundtruth="human_score",
)

# Compare models, using PPI to correct for bias/misalignment with human graders
result = es.compare(
    evaldata,
    factors="model",
    metric="llm_score",
    alignment={"llm_score": alignment},
)

result.summary()

Example: T-test PPI-correction via evalstats.tests.ttest

Use this for a t-test of mean differences between two groups (or two paired conditions when paired=True).

import evalstats as es

res = es.tests.ttest(
    a=llm_a,
    b=llm_b,
    a_lab=human_a,  # same length as llm_a, NaN where unlabeled
    b_lab=human_b,  # same length as llm_b, NaN where unlabeled
    paired=False,
    print_result=False,
)

print(res.p_value, res.corrected_p_value, res.corrected_ci)

Example: Mann-Whitney U test PPI-correction via evalstats.tests.mannwhitney

Use this for a Mann-Whitney U test, a nonparametric two-group comparison based on relative ranks rather than assuming normally distributed scores.

import evalstats as es

res = es.tests.mannwhitney(
    x=llm_x,
    y=llm_y,
    x_lab=human_x,
    y_lab=human_y,
    print_result=False,
)

print(res.p_value, res.corrected_p_value, res.corrected_ci)

Example: Wilcoxon signed-ranks test PPI-correction via evalstats.tests.wilcoxon (paired)

Use this for a Wilcoxon signed-rank test, a nonparametric paired test for matched observations (before/after, A/B on the same items, etc.).

import evalstats as es

res = es.tests.wilcoxon(
    x=llm_before,
    y=llm_after,
    x_lab=human_before,
    y_lab=human_after,
    print_result=False,
)

print(res.p_value, res.corrected_p_value, res.corrected_ci)

Example: One-way ANOVA PPI-correction via evalstats.tests.anova_oneway

Use this for one-way ANOVA when comparing more than two groups, with repeated=True for repeated-measures (same subjects across conditions).

import evalstats as es

res = es.tests.anova_oneway(
    llm_g1,
    llm_g2,
    llm_g3,
    groups_lab=[human_g1, human_g2, human_g3],
    repeated=False,
    print_result=False,
)

print(res.p_value, res.corrected_p_value, res.corrected_ci)

Motivation

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?

Relying purely on bar charts and averages can very, very easily lead to erroneous conclusions—B might actually be more robust than A, or B performs well on an important subset of data, or there's not enough data to conclude one way or the other.

Why do people do evals this way? Well, they don't have the time, tools, or knowledge on how to do it better—frequently, they don't even know there's a better way.

evalstats aims to rectify this with simple, powerful defaults—just throw us your data and we'll run the stats and plot the results for you. Upstream applications, like LLM observability platforms, could take evalstats results and plot them in their own front-ends. Prompt optimization tools could also use evalstats to decide, e.g., when to cull a candidate prompt and how to present results to users.

Examples

Is one prompt "better" than others? Quantify uncertainty

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:

Comparing across prompts output

Comparing across models while accounting for prompt sensitivity

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.

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:

Comparing across models output

In this run, multiple prompt template variations were considered, making this result more robust than trying a single prompt and calling it a day.

How stable is the performance across runs?

LLMs are stochastic at temperature>0. Will the performance stay similar, even upon multiple runs for the same inputs? evalstats offers a helpful "noise plot" which visualizes (in)stability across runs:

Per-input noise across runs

Running Example Scripts

We provide multiple standalone example scripts that rig up a simple benchmark, collect LLM responses, and run analyses over them. From the repository root, run any example script directly:

python examples/synthetic_mean_advantage.py

Additional examples:

# OpenAI sentiment benchmark (single run)
python examples/sentiment.py

# Multi-run variant (captures run-to-run variability)
python examples/sentiment_multirun.py

# Multi-model comparison across prompt templates
python examples/compare_models_multirun.py

# Manual API call walkthrough
python examples/sentiment_manual_api_calls.py

OpenAI-powered examples require OPENAI_API_KEY set in your environment. But, you can easily swap out the model calls to whatever model you prefer.

Mixed effects models (LMM)

Important

Mixed effects analysis is experimental, and currently offers only the advantage of gracefully dealing with missing data. In the future, we plan to add factor decomposition across multiple inputs. We recommend only using method="lmm" if you need robustness to missing data (NaN). Keep in mind that missing data must be reasonably random (i.e., like sampling from a larger distribution).

evalstats supports mixed-effects models (score ~ template + (1|input)) for:

  • Missing data in inputs (some score cells are NaN)
  • Factor decomposition when multiple input factors are present

Default backend: statsmodels (pure Python)

No extra setup required — statsmodels is included in the standard pip install evalstats. Simply pass method="lmm":

analysis = estats.analyze(result, method="lmm")

evalstats fits the model with REML, computes Wald CIs via the delta method, and estimates rank distributions by parametric simulation.

Optional backend: pymer4 (requires R)

For Satterthwaite degrees of freedom and emmeans-based pairwise contrasts (R's gold standard for mixed models), pass backend="pymer4":

analysis = estats.analyze(result, method="lmm", backend="pymer4")

This requires a working R installation with the following packages:

install.packages(c(
    "lme4",
    "emmeans",
    "tibble",
    "broom",
    "broom.mixed",
    "lmerTest",
    "report",
    "car"
))

Then install the Python LMM extra:

pip install "evalstats[lmm]"

Note

If your environment needs manual dependency pinning, this is the tested equivalent:

pip install "pymer4>=0.9" great_tables joblib rpy2 polars scikit-learn formulae pyarrow

Installation details may differ on your system.

Reproducibility: Monte Carlo simulations

Claims in this README like "verified in our simulations" are backed by a runnable simulation harness in simulations/harness/ of this package. We engineered these simulations so that you can run these yourself. For instance:

python -m simulations.harness.cli --list-cases
python -m simulations.harness.cli --official-tests
python -m simulations.harness.cli ci_single --reps 50 --sizes 10 20
python -m simulations.harness.cli pvalues --mode ppi --tests ttest wilcoxon anova_rep

--official-tests will bring up a CLI with options to run specific tests. Each runs each case's canonical, full-scale preset and writes results plus a manifest.json(args, output paths, key metrics, pass/fail) tosimulations/out/official_/. See [simulations/harness/README.md`](simulations/harness/README.md) for the full case list, scenario library, and verification methodology against the original standalone scripts. Note that each simulation can take very long to run; even on a MacBook Pro with an M4 Max chip and 64GB RAM, with computation paralellized across 16 CPU cores, it often takes many hours.

  • ci_single / ci_paired — coverage and width of confidence interval methods (bootstrap, smoothed bootstrap, Bayesian, Wilson, etc.) across synthetic distributions and real benchmark data (OpenEval, Inspect AI).
  • pvalues --mode pairwise / --mode multiarm — Type-I error and power for pairwise and multi-arm comparisons, including multiple-comparisons correction strategies.
  • pvalues --mode ppi — Type-I error calibration and power for every PPI-corrected test in evalstats.tests, swept across judge-bias severity, label fraction, and MNAR-labeling scenarios.

Development and Contributions

For package build, release validation, and maintainer workflows, see DEVELOPMENT.md.

We welcome contributions, especially refinements to our statistical methods. If you're proposing a new correction, CI method, or a fix to an existing one, we encourage battle-testing it against the simulation harness first. Please add or extend a scenario and confirm your change holds up on Type-I error and power, not just on the case that motivated it, before opening a PR. The evalstats repository already offers a rigorous, expansive synthetic suite that generally has held up against real data.

License

This repository uses two licenses:

  • evalstats package (everything outside website/) — MIT.
  • Stats for Evals Website (everything in website/) — CC BY-NC-ND 4.0. You may share it with attribution non-commercially, but commercial use and derivative works are not permitted.

About

Statistical analysis for LLM evaluations, from model and prompt comparisons to inference resilient to LLM judge bias, including at small sample sizes. All defaults battle-tested in Monte Carlo simulations.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages