evalproof 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- evalproof/__init__.py +69 -0
- evalproof/_version.py +3 -0
- evalproof/cache.py +89 -0
- evalproof/calibration.py +266 -0
- evalproof/cli.py +265 -0
- evalproof/compare.py +169 -0
- evalproof/config.py +158 -0
- evalproof/dataset.py +66 -0
- evalproof/gate.py +107 -0
- evalproof/models.py +115 -0
- evalproof/providers/__init__.py +54 -0
- evalproof/providers/anthropic.py +84 -0
- evalproof/providers/base.py +75 -0
- evalproof/providers/ollama.py +70 -0
- evalproof/providers/openai.py +76 -0
- evalproof/py.typed +0 -0
- evalproof/report.py +149 -0
- evalproof/runner.py +135 -0
- evalproof/scorers/__init__.py +80 -0
- evalproof/scorers/deterministic.py +227 -0
- evalproof/scorers/judge.py +187 -0
- evalproof/scorers/retrieval.py +64 -0
- evalproof/stats.py +145 -0
- evalproof/systems.py +154 -0
- evalproof-0.2.0.dist-info/METADATA +279 -0
- evalproof-0.2.0.dist-info/RECORD +30 -0
- evalproof-0.2.0.dist-info/WHEEL +4 -0
- evalproof-0.2.0.dist-info/entry_points.txt +2 -0
- evalproof-0.2.0.dist-info/licenses/LICENSE +202 -0
- evalproof-0.2.0.dist-info/licenses/NOTICE +4 -0
evalproof/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""evalproof: treat LLM evaluation as measurement.
|
|
2
|
+
|
|
3
|
+
An LLM judge is a classifier with error rates. evalproof calibrates it against a
|
|
4
|
+
small gold-labelled sample, reports prevalence-corrected pass rates with
|
|
5
|
+
confidence intervals, compares runs on paired cases, and gates CI on
|
|
6
|
+
statistically meaningful regressions rather than on noise.
|
|
7
|
+
|
|
8
|
+
The names below are the supported Python API; everything else is internal and may
|
|
9
|
+
change between minor versions.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from evalproof._version import __version__
|
|
13
|
+
from evalproof.calibration import CalibrationReport, Confusion, Estimate, calibrate
|
|
14
|
+
from evalproof.compare import Comparison, MetricComparison, RunStats, compare_runs
|
|
15
|
+
from evalproof.config import ConfigError, GateConfig, SuiteConfig, load_config
|
|
16
|
+
from evalproof.dataset import DatasetError, load_dataset
|
|
17
|
+
from evalproof.gate import (
|
|
18
|
+
EXIT_BUDGET,
|
|
19
|
+
EXIT_CANNOT_GATE,
|
|
20
|
+
EXIT_PASS,
|
|
21
|
+
EXIT_REGRESSION,
|
|
22
|
+
EXIT_USAGE,
|
|
23
|
+
GateDecision,
|
|
24
|
+
decide,
|
|
25
|
+
)
|
|
26
|
+
from evalproof.models import Case, CaseResult, Run, RunInfo, Score, SystemOutput
|
|
27
|
+
from evalproof.report import calibration_markdown, comparison_markdown, run_markdown
|
|
28
|
+
from evalproof.runner import load_run, run_suite, save_run
|
|
29
|
+
from evalproof.stats import mcnemar_exact, paired_bootstrap, wilson
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"EXIT_BUDGET",
|
|
33
|
+
"EXIT_CANNOT_GATE",
|
|
34
|
+
"EXIT_PASS",
|
|
35
|
+
"EXIT_REGRESSION",
|
|
36
|
+
"EXIT_USAGE",
|
|
37
|
+
"CalibrationReport",
|
|
38
|
+
"Case",
|
|
39
|
+
"CaseResult",
|
|
40
|
+
"Comparison",
|
|
41
|
+
"ConfigError",
|
|
42
|
+
"Confusion",
|
|
43
|
+
"DatasetError",
|
|
44
|
+
"Estimate",
|
|
45
|
+
"GateConfig",
|
|
46
|
+
"GateDecision",
|
|
47
|
+
"MetricComparison",
|
|
48
|
+
"Run",
|
|
49
|
+
"RunInfo",
|
|
50
|
+
"RunStats",
|
|
51
|
+
"Score",
|
|
52
|
+
"SuiteConfig",
|
|
53
|
+
"SystemOutput",
|
|
54
|
+
"__version__",
|
|
55
|
+
"calibrate",
|
|
56
|
+
"calibration_markdown",
|
|
57
|
+
"compare_runs",
|
|
58
|
+
"comparison_markdown",
|
|
59
|
+
"decide",
|
|
60
|
+
"load_config",
|
|
61
|
+
"load_dataset",
|
|
62
|
+
"load_run",
|
|
63
|
+
"mcnemar_exact",
|
|
64
|
+
"paired_bootstrap",
|
|
65
|
+
"run_markdown",
|
|
66
|
+
"run_suite",
|
|
67
|
+
"save_run",
|
|
68
|
+
"wilson",
|
|
69
|
+
]
|
evalproof/_version.py
ADDED
evalproof/cache.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Content-addressed response cache.
|
|
2
|
+
|
|
3
|
+
The key is everything that determines a model's reply: provider, model, endpoint,
|
|
4
|
+
messages and generation parameters. Re-running an unchanged suite costs nothing and
|
|
5
|
+
returns byte-identical results; changing one prompt re-runs only the calls it touches.
|
|
6
|
+
|
|
7
|
+
A hit returns the *original* latency and token counts, flagged ``cached=True``, so a
|
|
8
|
+
re-run reproduces the run artifact; the run's own wall time shows the real speed-up.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from evalproof.config import GenerationParams
|
|
18
|
+
from evalproof.providers.base import ChatResponse, Message, Provider
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cache_key(
|
|
22
|
+
provider: str,
|
|
23
|
+
model: str,
|
|
24
|
+
base_url: str | None,
|
|
25
|
+
messages: list[Message],
|
|
26
|
+
params: GenerationParams,
|
|
27
|
+
) -> str:
|
|
28
|
+
material = {
|
|
29
|
+
"provider": provider,
|
|
30
|
+
"model": model,
|
|
31
|
+
"base_url": base_url,
|
|
32
|
+
"messages": [m.model_dump() for m in messages],
|
|
33
|
+
"params": params.model_dump(mode="json"),
|
|
34
|
+
}
|
|
35
|
+
blob = json.dumps(material, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
|
36
|
+
return hashlib.sha256(blob.encode()).hexdigest()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ResponseCache:
|
|
40
|
+
def __init__(self, root: str | Path) -> None:
|
|
41
|
+
self.root = Path(root)
|
|
42
|
+
|
|
43
|
+
def _path(self, key: str) -> Path:
|
|
44
|
+
return self.root / key[:2] / f"{key}.json"
|
|
45
|
+
|
|
46
|
+
def get(self, key: str) -> ChatResponse | None:
|
|
47
|
+
path = self._path(key)
|
|
48
|
+
if not path.exists():
|
|
49
|
+
return None
|
|
50
|
+
try:
|
|
51
|
+
resp = ChatResponse.model_validate_json(path.read_text(encoding="utf-8"))
|
|
52
|
+
except ValueError:
|
|
53
|
+
return None # a torn write is a miss, not a crash
|
|
54
|
+
return resp.model_copy(update={"cached": True})
|
|
55
|
+
|
|
56
|
+
def put(self, key: str, response: ChatResponse) -> None:
|
|
57
|
+
path = self._path(key)
|
|
58
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
tmp = path.with_suffix(".tmp")
|
|
60
|
+
tmp.write_text(response.model_copy(update={"cached": False}).model_dump_json(), "utf-8")
|
|
61
|
+
tmp.replace(path)
|
|
62
|
+
|
|
63
|
+
def stats(self) -> dict[str, int]:
|
|
64
|
+
files = list(self.root.glob("*/*.json")) if self.root.exists() else []
|
|
65
|
+
return {"entries": len(files), "bytes": sum(f.stat().st_size for f in files)}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CachedProvider:
|
|
69
|
+
"""Wraps any provider; transparent on a miss, free on a hit."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, inner: Provider, cache: ResponseCache, base_url: str | None = None) -> None:
|
|
72
|
+
self.inner = inner
|
|
73
|
+
self.cache = cache
|
|
74
|
+
self.name = inner.name
|
|
75
|
+
self.model = inner.model
|
|
76
|
+
self.base_url = base_url
|
|
77
|
+
self.hits = 0
|
|
78
|
+
self.misses = 0
|
|
79
|
+
|
|
80
|
+
def chat(self, messages: list[Message], params: GenerationParams) -> ChatResponse:
|
|
81
|
+
key = cache_key(self.name, self.model, self.base_url, messages, params)
|
|
82
|
+
hit = self.cache.get(key)
|
|
83
|
+
if hit is not None:
|
|
84
|
+
self.hits += 1
|
|
85
|
+
return hit
|
|
86
|
+
self.misses += 1
|
|
87
|
+
resp = self.inner.chat(messages, params)
|
|
88
|
+
self.cache.put(key, resp)
|
|
89
|
+
return resp
|
evalproof/calibration.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Judge calibration: measure the judge against gold labels, then correct its pass rate.
|
|
2
|
+
|
|
3
|
+
A judge's raw pass rate is classify-and-count. If the judge passes bad answers at
|
|
4
|
+
rate FPR and passes good answers at rate TPR, the raw rate converges to
|
|
5
|
+
``q = FPR + (TPR - FPR) * p``, not to the true pass rate ``p``. Two corrections:
|
|
6
|
+
|
|
7
|
+
* **ACC** (adjusted classify-and-count, Rogan–Gladen): ``p = (q - FPR) / (TPR - FPR)``,
|
|
8
|
+
with TPR and FPR estimated on the gold sample. Bootstrap interval.
|
|
9
|
+
* **PPI** (prediction-powered inference, Angelopoulos et al., 2023): the judge's mean on
|
|
10
|
+
the unlabelled cases plus the mean label-minus-judge residual on the gold sample.
|
|
11
|
+
Needs no assumption about the judge; unbiased whenever the gold sample is random.
|
|
12
|
+
|
|
13
|
+
Background: https://basantbhattarai.com.np/writing/count-prevalence-not-classifications/
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import random
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from evalproof.models import Run
|
|
24
|
+
from evalproof.stats import FloatArray, wilson, z_for
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Confusion:
|
|
29
|
+
tp: int # judge pass, gold pass
|
|
30
|
+
fp: int # judge pass, gold fail
|
|
31
|
+
fn: int # judge fail, gold pass
|
|
32
|
+
tn: int # judge fail, gold fail
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def n(self) -> int:
|
|
36
|
+
return self.tp + self.fp + self.fn + self.tn
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def tpr(self) -> float | None:
|
|
40
|
+
pos = self.tp + self.fn
|
|
41
|
+
return self.tp / pos if pos else None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def fpr(self) -> float | None:
|
|
45
|
+
neg = self.fp + self.tn
|
|
46
|
+
return self.fp / neg if neg else None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def accuracy(self) -> float | None:
|
|
50
|
+
return (self.tp + self.tn) / self.n if self.n else None
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def kappa(self) -> float | None:
|
|
54
|
+
"""Cohen's kappa: agreement beyond what the two marginals give by chance."""
|
|
55
|
+
n = self.n
|
|
56
|
+
if not n:
|
|
57
|
+
return None
|
|
58
|
+
po = (self.tp + self.tn) / n
|
|
59
|
+
pe = (
|
|
60
|
+
(self.tp + self.fp) * (self.tp + self.fn) + (self.fn + self.tn) * (self.fp + self.tn)
|
|
61
|
+
) / (n * n)
|
|
62
|
+
return None if pe == 1 else (po - pe) / (1 - pe)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def confusion(judge: list[bool], gold: list[bool]) -> Confusion:
|
|
66
|
+
if len(judge) != len(gold):
|
|
67
|
+
raise ValueError("judge and gold must be paired")
|
|
68
|
+
tp = sum(j and g for j, g in zip(judge, gold, strict=True))
|
|
69
|
+
fp = sum(j and not g for j, g in zip(judge, gold, strict=True))
|
|
70
|
+
fn = sum((not j) and g for j, g in zip(judge, gold, strict=True))
|
|
71
|
+
tn = sum((not j) and (not g) for j, g in zip(judge, gold, strict=True))
|
|
72
|
+
return Confusion(tp, fp, fn, tn)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def acc_point(q: float, tpr: float, fpr: float) -> float | None:
|
|
76
|
+
"""Rogan–Gladen. None when the judge is no better than chance (TPR <= FPR)."""
|
|
77
|
+
if tpr - fpr <= 1e-12:
|
|
78
|
+
return None
|
|
79
|
+
return min(1.0, max(0.0, (q - fpr) / (tpr - fpr)))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class Estimate:
|
|
84
|
+
method: str
|
|
85
|
+
point: float | None
|
|
86
|
+
low: float | None
|
|
87
|
+
high: float | None
|
|
88
|
+
n: int
|
|
89
|
+
note: str = ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def raw_estimate(judge_all: list[bool], alpha: float = 0.05) -> Estimate:
|
|
93
|
+
k, n = sum(judge_all), len(judge_all)
|
|
94
|
+
lo, hi = wilson(k, n, alpha)
|
|
95
|
+
return Estimate("raw judge pass rate", k / n if n else None, lo, hi, n)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def gold_only_estimate(gold: list[bool], alpha: float = 0.05) -> Estimate:
|
|
99
|
+
k, n = sum(gold), len(gold)
|
|
100
|
+
lo, hi = wilson(k, n, alpha)
|
|
101
|
+
return Estimate("gold labels only", k / n if n else None, lo, hi, n)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def acc_estimate(
|
|
105
|
+
judge_all: list[bool],
|
|
106
|
+
judge_gold: list[bool],
|
|
107
|
+
gold: list[bool],
|
|
108
|
+
*,
|
|
109
|
+
alpha: float = 0.05,
|
|
110
|
+
n_boot: int = 5000,
|
|
111
|
+
seed: int = 0,
|
|
112
|
+
) -> Estimate:
|
|
113
|
+
"""ACC point estimate with a bootstrap interval that resamples both the full judged set
|
|
114
|
+
(uncertainty in q) and the gold sample (uncertainty in TPR and FPR)."""
|
|
115
|
+
cm = confusion(judge_gold, gold)
|
|
116
|
+
q = sum(judge_all) / len(judge_all)
|
|
117
|
+
if cm.tpr is None or cm.fpr is None:
|
|
118
|
+
return Estimate("ACC", None, None, None, len(judge_all), "gold sample has only one class")
|
|
119
|
+
point = acc_point(q, cm.tpr, cm.fpr)
|
|
120
|
+
if point is None:
|
|
121
|
+
return Estimate("ACC", None, None, None, len(judge_all), "judge no better than chance")
|
|
122
|
+
rng = np.random.default_rng(seed)
|
|
123
|
+
ja = np.asarray(judge_all, dtype=np.float64)
|
|
124
|
+
jg = np.asarray(judge_gold, dtype=bool)
|
|
125
|
+
gg = np.asarray(gold, dtype=bool)
|
|
126
|
+
reps: list[float] = []
|
|
127
|
+
undefined = 0
|
|
128
|
+
for _ in range(n_boot):
|
|
129
|
+
qa = float(ja[rng.integers(0, ja.size, ja.size)].mean())
|
|
130
|
+
idx = rng.integers(0, gg.size, gg.size)
|
|
131
|
+
j, g = jg[idx], gg[idx]
|
|
132
|
+
pos, neg = int(g.sum()), int((~g).sum())
|
|
133
|
+
if pos == 0 or neg == 0:
|
|
134
|
+
undefined += 1
|
|
135
|
+
continue
|
|
136
|
+
est = acc_point(qa, float((j & g).sum() / pos), float((j & ~g).sum() / neg))
|
|
137
|
+
if est is None:
|
|
138
|
+
undefined += 1
|
|
139
|
+
continue
|
|
140
|
+
reps.append(est)
|
|
141
|
+
if not reps:
|
|
142
|
+
return Estimate("ACC", point, None, None, len(judge_all), "no defined bootstrap replicates")
|
|
143
|
+
lo, hi = np.quantile(np.asarray(reps), [alpha / 2, 1 - alpha / 2])
|
|
144
|
+
note = f"{undefined}/{n_boot} replicates undefined" if undefined else ""
|
|
145
|
+
return Estimate("ACC", point, float(lo), float(hi), len(judge_all), note)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def ppi_estimate(
|
|
149
|
+
judge_unlabelled: list[bool],
|
|
150
|
+
judge_labelled: list[bool],
|
|
151
|
+
gold: list[bool],
|
|
152
|
+
*,
|
|
153
|
+
alpha: float = 0.05,
|
|
154
|
+
) -> Estimate:
|
|
155
|
+
"""Prediction-powered mean: ``mean(f_unlabelled) + mean(y - f_labelled)``, normal CI."""
|
|
156
|
+
if not judge_labelled or not judge_unlabelled:
|
|
157
|
+
return Estimate("PPI", None, None, None, 0, "needs labelled and unlabelled cases")
|
|
158
|
+
fu: FloatArray = np.asarray(judge_unlabelled, dtype=np.float64)
|
|
159
|
+
fl: FloatArray = np.asarray(judge_labelled, dtype=np.float64)
|
|
160
|
+
y: FloatArray = np.asarray(gold, dtype=np.float64)
|
|
161
|
+
resid = y - fl
|
|
162
|
+
point = float(fu.mean() + resid.mean())
|
|
163
|
+
var_u = float(fu.var(ddof=1)) if fu.size > 1 else 0.0
|
|
164
|
+
var_r = float(resid.var(ddof=1)) if resid.size > 1 else 0.0
|
|
165
|
+
se = (var_u / fu.size + var_r / resid.size) ** 0.5
|
|
166
|
+
z = z_for(alpha)
|
|
167
|
+
n = int(fu.size + fl.size)
|
|
168
|
+
return Estimate(
|
|
169
|
+
"PPI", min(1.0, max(0.0, point)), max(0.0, point - z * se), min(1.0, point + z * se), n
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class CalibrationReport:
|
|
175
|
+
judge_scorer: str
|
|
176
|
+
gold_source: str
|
|
177
|
+
n_judged: int
|
|
178
|
+
n_gold: int
|
|
179
|
+
undecided_judge: int
|
|
180
|
+
confusion: Confusion
|
|
181
|
+
estimates: list[Estimate]
|
|
182
|
+
truth: Estimate | None = None
|
|
183
|
+
disagreements: list[str] = field(default_factory=list)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def calibrate(
|
|
187
|
+
run: Run,
|
|
188
|
+
judge_scorer: str,
|
|
189
|
+
*,
|
|
190
|
+
labels: dict[str, bool] | None = None,
|
|
191
|
+
gold_scorer: str | None = None,
|
|
192
|
+
gold_sample: int | None = None,
|
|
193
|
+
seed: int = 0,
|
|
194
|
+
alpha: float = 0.05,
|
|
195
|
+
n_boot: int = 5000,
|
|
196
|
+
) -> CalibrationReport:
|
|
197
|
+
"""Calibrate ``judge_scorer`` against human ``labels`` or a trusted ``gold_scorer``.
|
|
198
|
+
|
|
199
|
+
With ``gold_scorer`` and ``gold_sample``, a random sample of that size stands in for a
|
|
200
|
+
human-reviewed set; the remaining gold labels are used only to report the truth the
|
|
201
|
+
estimates are trying to recover.
|
|
202
|
+
"""
|
|
203
|
+
if (labels is None) == (gold_scorer is None):
|
|
204
|
+
raise ValueError("pass exactly one of labels or gold_scorer")
|
|
205
|
+
judged: dict[str, bool] = {}
|
|
206
|
+
undecided = 0
|
|
207
|
+
for r in run.results:
|
|
208
|
+
s = r.scores.get(judge_scorer)
|
|
209
|
+
if s is None:
|
|
210
|
+
continue
|
|
211
|
+
if s.passed is None:
|
|
212
|
+
undecided += 1
|
|
213
|
+
else:
|
|
214
|
+
judged[r.case_id] = s.passed
|
|
215
|
+
if not judged:
|
|
216
|
+
raise ValueError(f"no decided verdicts from scorer {judge_scorer!r}")
|
|
217
|
+
|
|
218
|
+
truth_all: dict[str, bool] = {}
|
|
219
|
+
if labels is not None:
|
|
220
|
+
gold_ids = [cid for cid in labels if cid in judged]
|
|
221
|
+
gold_map = {cid: labels[cid] for cid in gold_ids}
|
|
222
|
+
source = "labels file"
|
|
223
|
+
else:
|
|
224
|
+
assert gold_scorer is not None
|
|
225
|
+
for r in run.results:
|
|
226
|
+
s = r.scores.get(gold_scorer)
|
|
227
|
+
if s is not None and s.passed is not None and r.case_id in judged:
|
|
228
|
+
truth_all[r.case_id] = s.passed
|
|
229
|
+
ids = sorted(truth_all)
|
|
230
|
+
if gold_sample is not None and gold_sample < len(ids):
|
|
231
|
+
ids = sorted(random.Random(seed).sample(ids, gold_sample))
|
|
232
|
+
source = f"{gold_scorer} (random sample of {gold_sample})"
|
|
233
|
+
else:
|
|
234
|
+
source = gold_scorer
|
|
235
|
+
gold_map = {cid: truth_all[cid] for cid in ids}
|
|
236
|
+
|
|
237
|
+
if not gold_map:
|
|
238
|
+
raise ValueError("no overlap between gold labels and judged cases")
|
|
239
|
+
lab_ids = sorted(gold_map)
|
|
240
|
+
unl_ids = sorted(set(judged) - set(lab_ids))
|
|
241
|
+
jl = [judged[c] for c in lab_ids]
|
|
242
|
+
gl = [gold_map[c] for c in lab_ids]
|
|
243
|
+
ja = [judged[c] for c in sorted(judged)]
|
|
244
|
+
estimates = [
|
|
245
|
+
raw_estimate(ja, alpha),
|
|
246
|
+
gold_only_estimate(gl, alpha),
|
|
247
|
+
acc_estimate(ja, jl, gl, alpha=alpha, n_boot=n_boot, seed=seed),
|
|
248
|
+
ppi_estimate([judged[c] for c in unl_ids], jl, gl, alpha=alpha),
|
|
249
|
+
]
|
|
250
|
+
truth = None
|
|
251
|
+
if truth_all and len(truth_all) > len(gold_map):
|
|
252
|
+
k = sum(truth_all.values())
|
|
253
|
+
lo, hi = wilson(k, len(truth_all), alpha)
|
|
254
|
+
truth = Estimate("truth (all gold labels)", k / len(truth_all), lo, hi, len(truth_all))
|
|
255
|
+
disagreements = [c for c in lab_ids if judged[c] != gold_map[c]]
|
|
256
|
+
return CalibrationReport(
|
|
257
|
+
judge_scorer=judge_scorer,
|
|
258
|
+
gold_source=source,
|
|
259
|
+
n_judged=len(judged),
|
|
260
|
+
n_gold=len(gold_map),
|
|
261
|
+
undecided_judge=undecided,
|
|
262
|
+
confusion=confusion(jl, gl),
|
|
263
|
+
estimates=estimates,
|
|
264
|
+
truth=truth,
|
|
265
|
+
disagreements=disagreements,
|
|
266
|
+
)
|
evalproof/cli.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Command line: ``evalproof run | compare | calibrate | report | pairwise``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from evalproof._version import __version__
|
|
12
|
+
from evalproof.cache import CachedProvider, ResponseCache
|
|
13
|
+
from evalproof.calibration import calibrate
|
|
14
|
+
from evalproof.compare import compare_runs
|
|
15
|
+
from evalproof.config import ConfigError, GateConfig, load_config
|
|
16
|
+
from evalproof.dataset import DatasetError, load_dataset
|
|
17
|
+
from evalproof.gate import EXIT_PASS, EXIT_USAGE, decide
|
|
18
|
+
from evalproof.models import CaseResult
|
|
19
|
+
from evalproof.providers import ProviderError, make_provider
|
|
20
|
+
from evalproof.report import (
|
|
21
|
+
calibration_markdown,
|
|
22
|
+
comparison_markdown,
|
|
23
|
+
run_markdown,
|
|
24
|
+
to_json,
|
|
25
|
+
)
|
|
26
|
+
from evalproof.runner import load_run, run_suite, save_run
|
|
27
|
+
from evalproof.scorers import pairwise
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _emit(markdown: str, path: str | None) -> None:
|
|
31
|
+
sys.stdout.write(markdown)
|
|
32
|
+
if path:
|
|
33
|
+
# Append, so it can target $GITHUB_STEP_SUMMARY alongside other steps.
|
|
34
|
+
with Path(path).open("a", encoding="utf-8") as fh:
|
|
35
|
+
fh.write(markdown + "\n")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
39
|
+
cfg = load_config(args.suite)
|
|
40
|
+
override = None
|
|
41
|
+
if args.system or args.system_prompt_file or args.label:
|
|
42
|
+
override = cfg.system.model_copy(deep=True)
|
|
43
|
+
if args.system:
|
|
44
|
+
override.target = args.system
|
|
45
|
+
if args.label:
|
|
46
|
+
override.label = args.label
|
|
47
|
+
if args.system_prompt_file:
|
|
48
|
+
override.system_prompt = Path(args.system_prompt_file).read_text(encoding="utf-8")
|
|
49
|
+
|
|
50
|
+
def progress(i: int, n: int, res: CaseResult) -> None:
|
|
51
|
+
if not args.quiet:
|
|
52
|
+
flag = " (cached)" if res.output.cached else ""
|
|
53
|
+
err = f" ERROR {res.output.error[:60]}" if res.output.error else ""
|
|
54
|
+
sys.stderr.write(f"\r[{i}/{n}] {res.case_id}{flag}{err} ")
|
|
55
|
+
if i == n:
|
|
56
|
+
sys.stderr.write("\n")
|
|
57
|
+
|
|
58
|
+
run = run_suite(
|
|
59
|
+
cfg,
|
|
60
|
+
limit=args.limit,
|
|
61
|
+
use_cache=not args.no_cache,
|
|
62
|
+
progress=progress,
|
|
63
|
+
system_override=override,
|
|
64
|
+
)
|
|
65
|
+
out = args.out or f"runs/{cfg.name}-{run.info.started_at.replace(':', '')}.json"
|
|
66
|
+
save_run(run, out)
|
|
67
|
+
sys.stderr.write(f"wrote {out}\n")
|
|
68
|
+
_emit(run_markdown(run), args.markdown)
|
|
69
|
+
return EXIT_PASS
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _gate_config(args: argparse.Namespace) -> GateConfig | None:
|
|
73
|
+
base: GateConfig | None = None
|
|
74
|
+
if args.suite:
|
|
75
|
+
base = load_config(args.suite).gate
|
|
76
|
+
overrides = {
|
|
77
|
+
k: v
|
|
78
|
+
for k, v in {
|
|
79
|
+
"metric": args.metric,
|
|
80
|
+
"max_drop": args.max_drop,
|
|
81
|
+
"alpha": args.alpha,
|
|
82
|
+
"min_cases": args.min_cases,
|
|
83
|
+
"max_p95_latency_ms": args.max_p95_ms,
|
|
84
|
+
"max_cost_usd": args.max_cost,
|
|
85
|
+
}.items()
|
|
86
|
+
if v is not None
|
|
87
|
+
}
|
|
88
|
+
if base is None and "metric" not in overrides:
|
|
89
|
+
return None
|
|
90
|
+
if base is None:
|
|
91
|
+
return GateConfig(**overrides)
|
|
92
|
+
return base.model_copy(update=overrides)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cmd_compare(args: argparse.Namespace) -> int:
|
|
96
|
+
baseline, candidate = load_run(args.baseline), load_run(args.candidate)
|
|
97
|
+
gate = _gate_config(args)
|
|
98
|
+
alpha = gate.alpha if gate else (args.alpha or 0.05)
|
|
99
|
+
cmp = compare_runs(baseline, candidate, alpha=alpha, seed=args.seed)
|
|
100
|
+
decision = decide(cmp, gate) if gate else None
|
|
101
|
+
if args.gate and decision is None:
|
|
102
|
+
raise ConfigError("--gate needs a gate: pass --suite with a gate block, or --metric")
|
|
103
|
+
_emit(comparison_markdown(cmp, decision, gate.metric if gate else None), args.markdown)
|
|
104
|
+
if args.json:
|
|
105
|
+
payload = {"comparison": json.loads(to_json(cmp))}
|
|
106
|
+
if decision:
|
|
107
|
+
payload["gate"] = json.loads(to_json(decision))
|
|
108
|
+
Path(args.json).write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
109
|
+
if args.gate and decision is not None:
|
|
110
|
+
return decision.exit_code
|
|
111
|
+
return EXIT_PASS
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_calibrate(args: argparse.Namespace) -> int:
|
|
115
|
+
run = load_run(args.run)
|
|
116
|
+
labels = None
|
|
117
|
+
if args.labels:
|
|
118
|
+
labels = {}
|
|
119
|
+
for line in Path(args.labels).read_text(encoding="utf-8").splitlines():
|
|
120
|
+
if line.strip():
|
|
121
|
+
rec = json.loads(line)
|
|
122
|
+
labels[str(rec["id"])] = bool(rec["label"])
|
|
123
|
+
rep = calibrate(
|
|
124
|
+
run,
|
|
125
|
+
args.judge_scorer,
|
|
126
|
+
labels=labels,
|
|
127
|
+
gold_scorer=args.gold_scorer,
|
|
128
|
+
gold_sample=args.gold_sample,
|
|
129
|
+
seed=args.seed,
|
|
130
|
+
alpha=args.alpha,
|
|
131
|
+
)
|
|
132
|
+
_emit(calibration_markdown(rep), args.markdown)
|
|
133
|
+
if args.json:
|
|
134
|
+
Path(args.json).write_text(to_json(rep), encoding="utf-8")
|
|
135
|
+
return EXIT_PASS
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cmd_report(args: argparse.Namespace) -> int:
|
|
139
|
+
_emit(run_markdown(load_run(args.run)), args.markdown)
|
|
140
|
+
return EXIT_PASS
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cmd_pairwise(args: argparse.Namespace) -> int:
|
|
144
|
+
cfg = load_config(args.suite)
|
|
145
|
+
if cfg.judge is None:
|
|
146
|
+
raise ConfigError("pairwise needs a 'judge' in the suite")
|
|
147
|
+
judge = make_provider(cfg.judge)
|
|
148
|
+
if not args.no_cache:
|
|
149
|
+
judge = CachedProvider(judge, ResponseCache(cfg.resolve(cfg.cache_dir)), cfg.judge.base_url)
|
|
150
|
+
cases = {c.id: c for c in load_dataset(cfg.resolve(cfg.dataset))}
|
|
151
|
+
a_by, b_by = load_run(args.baseline).by_id(), load_run(args.candidate).by_id()
|
|
152
|
+
ids = [cid for cid in sorted(set(a_by) & set(b_by)) if cid in cases][: args.limit]
|
|
153
|
+
tally = {"a": 0, "b": 0, "tie": 0}
|
|
154
|
+
inconsistent = 0
|
|
155
|
+
rows = []
|
|
156
|
+
for cid in ids:
|
|
157
|
+
res = pairwise(
|
|
158
|
+
judge,
|
|
159
|
+
cfg.judge.params,
|
|
160
|
+
cases[cid],
|
|
161
|
+
a_by[cid].output.output,
|
|
162
|
+
b_by[cid].output.output,
|
|
163
|
+
swap=not args.no_swap,
|
|
164
|
+
)
|
|
165
|
+
tally[res.winner] += 1
|
|
166
|
+
inconsistent += 0 if res.consistent else 1
|
|
167
|
+
rows.append({"id": cid, **res.__dict__})
|
|
168
|
+
n = len(ids)
|
|
169
|
+
md = [
|
|
170
|
+
"## Pairwise preference",
|
|
171
|
+
"",
|
|
172
|
+
f"{n} cases · baseline wins {tally['a']} · candidate wins {tally['b']} · "
|
|
173
|
+
f"ties {tally['tie']}",
|
|
174
|
+
]
|
|
175
|
+
if not args.no_swap:
|
|
176
|
+
md.append(
|
|
177
|
+
f"Position-inconsistent verdicts (flipped when the order was swapped): "
|
|
178
|
+
f"{inconsistent}/{n} ({100 * inconsistent / n if n else 0:.1f}%) — reported as ties"
|
|
179
|
+
)
|
|
180
|
+
_emit("\n".join(md) + "\n", args.markdown)
|
|
181
|
+
if args.json:
|
|
182
|
+
Path(args.json).write_text(json.dumps(rows, indent=2), encoding="utf-8")
|
|
183
|
+
return EXIT_PASS
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
187
|
+
p = argparse.ArgumentParser(prog="evalproof", description=__doc__)
|
|
188
|
+
p.add_argument("--version", action="version", version=f"evalproof {__version__}")
|
|
189
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
190
|
+
|
|
191
|
+
r = sub.add_parser("run", help="run a suite and write a run artifact")
|
|
192
|
+
r.add_argument("suite")
|
|
193
|
+
r.add_argument("--out")
|
|
194
|
+
r.add_argument("--limit", type=int)
|
|
195
|
+
r.add_argument("--no-cache", action="store_true")
|
|
196
|
+
r.add_argument("--system", help="override the system target")
|
|
197
|
+
r.add_argument("--system-prompt-file", help="override the system prompt")
|
|
198
|
+
r.add_argument("--label", help="name this system in reports")
|
|
199
|
+
r.add_argument("--markdown", help="also append the report to this file")
|
|
200
|
+
r.add_argument("--quiet", action="store_true")
|
|
201
|
+
r.set_defaults(func=cmd_run)
|
|
202
|
+
|
|
203
|
+
c = sub.add_parser(
|
|
204
|
+
"compare", help="compare two runs; --gate turns the result into an exit code"
|
|
205
|
+
)
|
|
206
|
+
c.add_argument("baseline")
|
|
207
|
+
c.add_argument("candidate")
|
|
208
|
+
c.add_argument("--suite", help="take gate settings from this suite")
|
|
209
|
+
c.add_argument("--metric")
|
|
210
|
+
c.add_argument("--max-drop", type=float)
|
|
211
|
+
c.add_argument("--alpha", type=float)
|
|
212
|
+
c.add_argument("--min-cases", type=int)
|
|
213
|
+
c.add_argument("--max-p95-ms", type=float)
|
|
214
|
+
c.add_argument("--max-cost", type=float)
|
|
215
|
+
c.add_argument("--gate", action="store_true", help="exit non-zero when the gate fails")
|
|
216
|
+
c.add_argument("--seed", type=int, default=0)
|
|
217
|
+
c.add_argument("--markdown")
|
|
218
|
+
c.add_argument("--json")
|
|
219
|
+
c.set_defaults(func=cmd_compare)
|
|
220
|
+
|
|
221
|
+
k = sub.add_parser("calibrate", help="measure a judge against gold labels and correct its rate")
|
|
222
|
+
k.add_argument("run")
|
|
223
|
+
k.add_argument("--judge-scorer", required=True)
|
|
224
|
+
src = k.add_mutually_exclusive_group(required=True)
|
|
225
|
+
src.add_argument("--labels", help="JSONL of {id, label} human labels")
|
|
226
|
+
src.add_argument("--gold-scorer", help="a trusted scorer in the same run")
|
|
227
|
+
k.add_argument(
|
|
228
|
+
"--gold-sample", type=int, help="use a random sample of this size as the gold set"
|
|
229
|
+
)
|
|
230
|
+
k.add_argument("--seed", type=int, default=0)
|
|
231
|
+
k.add_argument("--alpha", type=float, default=0.05)
|
|
232
|
+
k.add_argument("--markdown")
|
|
233
|
+
k.add_argument("--json")
|
|
234
|
+
k.set_defaults(func=cmd_calibrate)
|
|
235
|
+
|
|
236
|
+
rep = sub.add_parser("report", help="summarise one run")
|
|
237
|
+
rep.add_argument("run")
|
|
238
|
+
rep.add_argument("--markdown")
|
|
239
|
+
rep.set_defaults(func=cmd_report)
|
|
240
|
+
|
|
241
|
+
pw = sub.add_parser("pairwise", help="judge baseline vs candidate outputs head to head")
|
|
242
|
+
pw.add_argument("baseline")
|
|
243
|
+
pw.add_argument("candidate")
|
|
244
|
+
pw.add_argument("--suite", required=True)
|
|
245
|
+
pw.add_argument("--limit", type=int)
|
|
246
|
+
pw.add_argument("--no-swap", action="store_true")
|
|
247
|
+
pw.add_argument("--no-cache", action="store_true")
|
|
248
|
+
pw.add_argument("--markdown")
|
|
249
|
+
pw.add_argument("--json")
|
|
250
|
+
pw.set_defaults(func=cmd_pairwise)
|
|
251
|
+
return p
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
255
|
+
args = build_parser().parse_args(argv)
|
|
256
|
+
try:
|
|
257
|
+
code: int = args.func(args)
|
|
258
|
+
except (ConfigError, DatasetError, ProviderError, FileNotFoundError, ValueError) as exc:
|
|
259
|
+
sys.stderr.write(f"evalproof: error: {exc}\n")
|
|
260
|
+
return EXIT_USAGE
|
|
261
|
+
return code
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
if __name__ == "__main__": # pragma: no cover
|
|
265
|
+
raise SystemExit(main())
|