evalsuite-python 0.3.0__py3-none-any.whl → 0.4.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.
- evalsuite/__init__.py +5 -1
- evalsuite/benchmarks.py +251 -9
- evalsuite/cli/main.py +31 -1
- evalsuite/core/validation.py +21 -0
- evalsuite/plot.py +146 -0
- evalsuite/regression/metrics.py +9 -1
- evalsuite/stats/_resolve.py +29 -4
- evalsuite/stats/compare.py +7 -3
- evalsuite/text/__init__.py +152 -0
- evalsuite/text/_common.py +94 -0
- evalsuite/text/factuality.py +469 -0
- evalsuite/text/generation.py +1126 -0
- evalsuite/text/judge.py +532 -0
- evalsuite/text/qa.py +108 -0
- evalsuite/text/rag.py +235 -0
- evalsuite/text/reasoning.py +215 -0
- evalsuite/text/report.py +158 -0
- evalsuite/text/retrieval.py +238 -0
- evalsuite/text/semantic.py +465 -0
- evalsuite/text/structured.py +789 -0
- evalsuite/version.py +1 -1
- evalsuite/vision/segmentation.py +9 -1
- {evalsuite_python-0.3.0.dist-info → evalsuite_python-0.4.0.dist-info}/METADATA +63 -22
- {evalsuite_python-0.3.0.dist-info → evalsuite_python-0.4.0.dist-info}/RECORD +27 -15
- {evalsuite_python-0.3.0.dist-info → evalsuite_python-0.4.0.dist-info}/WHEEL +0 -0
- {evalsuite_python-0.3.0.dist-info → evalsuite_python-0.4.0.dist-info}/entry_points.txt +0 -0
- {evalsuite_python-0.3.0.dist-info → evalsuite_python-0.4.0.dist-info}/licenses/LICENSE +0 -0
evalsuite/__init__.py
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
>>> print(result.summary()) # doctest: +SKIP
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from . import calibration, classification, clinical, plot, regression, stats, vision
|
|
8
|
+
from . import calibration, classification, clinical, plot, regression, stats, text, vision
|
|
9
9
|
from .api import evaluate
|
|
10
10
|
from .calibration import (
|
|
11
11
|
CalibrationReport,
|
|
@@ -110,6 +110,8 @@ from .stats import (
|
|
|
110
110
|
t_test,
|
|
111
111
|
wilcoxon_test,
|
|
112
112
|
)
|
|
113
|
+
from .text import * # noqa: F403 (every LLM-evaluation function, listed in text.__all__)
|
|
114
|
+
from .text import __all__ as _text_all
|
|
113
115
|
from .version import __version__
|
|
114
116
|
from .vision import (
|
|
115
117
|
DetectionReport,
|
|
@@ -134,6 +136,7 @@ from .vision import (
|
|
|
134
136
|
)
|
|
135
137
|
|
|
136
138
|
__all__ = [
|
|
139
|
+
"text",
|
|
137
140
|
"SegmentationReport",
|
|
138
141
|
"segmentation_report",
|
|
139
142
|
"detection_pr_curve",
|
|
@@ -258,3 +261,4 @@ __all__ = [
|
|
|
258
261
|
"specificity",
|
|
259
262
|
"top_k_accuracy",
|
|
260
263
|
]
|
|
264
|
+
__all__ += list(_text_all)
|
evalsuite/benchmarks.py
CHANGED
|
@@ -17,8 +17,10 @@ whose reference library is not installed is timed for EvalSuite only.
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import contextlib
|
|
20
|
+
import importlib
|
|
20
21
|
import json
|
|
21
22
|
import platform
|
|
23
|
+
import re
|
|
22
24
|
import time
|
|
23
25
|
import tracemalloc
|
|
24
26
|
import warnings
|
|
@@ -211,8 +213,8 @@ def _vision_cases(n: int, rng: np.random.Generator) -> list[Case]:
|
|
|
211
213
|
import contextlib as _ctx
|
|
212
214
|
import io
|
|
213
215
|
|
|
214
|
-
from pycocotools.coco import COCO
|
|
215
|
-
from pycocotools.cocoeval import COCOeval
|
|
216
|
+
from pycocotools.coco import COCO
|
|
217
|
+
from pycocotools.cocoeval import COCOeval
|
|
216
218
|
|
|
217
219
|
def coco_map() -> list[float]:
|
|
218
220
|
images, anns, dets, aid = [], [], [], 1
|
|
@@ -260,6 +262,155 @@ def _vision_cases(n: int, rng: np.random.Generator) -> list[Case]:
|
|
|
260
262
|
return [(name, ref, es_fn, refs.get(name)) for name, ref, es_fn, _ in cases]
|
|
261
263
|
|
|
262
264
|
|
|
265
|
+
_LLM_VOCAB = ( # noqa: SIM905
|
|
266
|
+
"the a cat dog sat on mat quickly model models data run running ran 2.5 3,000 well-known state-of-the-art "
|
|
267
|
+
"( ) , . ! ? don't U.S. e-mail Hello WORLD naive cafe results show improves baseline evaluation"
|
|
268
|
+
).split()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _llm_cases(n: int, rng: np.random.Generator) -> list[Case]:
|
|
272
|
+
"""v0.4.0: text generation, retrieval, rater agreement and structured output (n / 100 examples)."""
|
|
273
|
+
import evalsuite as es
|
|
274
|
+
|
|
275
|
+
m = max(10, n // 100)
|
|
276
|
+
preds, refs_ = [], []
|
|
277
|
+
for _ in range(m):
|
|
278
|
+
base = list(rng.choice(_LLM_VOCAB, int(rng.integers(5, 25))))
|
|
279
|
+
keep = [w for w in base if rng.random() > 0.2] + list(rng.choice(_LLM_VOCAB, int(rng.integers(0, 4))))
|
|
280
|
+
preds.append(" ".join(keep))
|
|
281
|
+
refs_.append(" ".join(base))
|
|
282
|
+
queries = [
|
|
283
|
+
(set(rng.choice(200, int(rng.integers(1, 6)), replace=False).tolist()), rng.permutation(200)[:20].tolist())
|
|
284
|
+
for _ in range(m)
|
|
285
|
+
]
|
|
286
|
+
rel, ret = [q[0] for q in queries], [q[1] for q in queries]
|
|
287
|
+
ratings = rng.integers(1, 6, (4, m)).astype(float)
|
|
288
|
+
ratings[rng.random(ratings.shape) < 0.1] = np.nan
|
|
289
|
+
schema = {
|
|
290
|
+
"type": "object",
|
|
291
|
+
"properties": {
|
|
292
|
+
"name": {"type": "string", "minLength": 1},
|
|
293
|
+
"age": {"type": "integer", "minimum": 0},
|
|
294
|
+
"tags": {"type": "array", "items": {"type": "string"}, "uniqueItems": True},
|
|
295
|
+
},
|
|
296
|
+
"required": ["name", "age"],
|
|
297
|
+
"additionalProperties": False,
|
|
298
|
+
}
|
|
299
|
+
docs = []
|
|
300
|
+
for i in range(m):
|
|
301
|
+
d: dict[str, Any] = {"name": f"n{i}", "age": int(rng.integers(-2, 90)), "tags": ["a", "b"]}
|
|
302
|
+
if rng.random() < 0.1:
|
|
303
|
+
d["extra"] = 1
|
|
304
|
+
if rng.random() < 0.1:
|
|
305
|
+
del d["name"]
|
|
306
|
+
docs.append(d)
|
|
307
|
+
import json as _json
|
|
308
|
+
|
|
309
|
+
docs_text = [_json.dumps(d) for d in docs]
|
|
310
|
+
|
|
311
|
+
cases: list[Case] = [
|
|
312
|
+
(
|
|
313
|
+
f"text: corpus BLEU and chrF ({m} sentences)",
|
|
314
|
+
"sacreBLEU",
|
|
315
|
+
lambda: [float(es.bleu(refs_, preds)), float(es.chrf(refs_, preds))],
|
|
316
|
+
None,
|
|
317
|
+
),
|
|
318
|
+
(
|
|
319
|
+
f"text: ROUGE-1, ROUGE-2, ROUGE-L ({m} sentences)",
|
|
320
|
+
"rouge-score",
|
|
321
|
+
lambda: [
|
|
322
|
+
float(es.rouge_1(refs_, preds)),
|
|
323
|
+
float(es.rouge_2(refs_, preds)),
|
|
324
|
+
float(es.rouge_l(refs_, preds)),
|
|
325
|
+
],
|
|
326
|
+
None,
|
|
327
|
+
),
|
|
328
|
+
(
|
|
329
|
+
f"text: METEOR, exact and stem matches ({m} sentences)",
|
|
330
|
+
"NLTK",
|
|
331
|
+
lambda: [float(es.meteor(refs_, preds))],
|
|
332
|
+
None,
|
|
333
|
+
),
|
|
334
|
+
(
|
|
335
|
+
f"retrieval: MRR, MAP@20, NDCG@10 ({m} queries)",
|
|
336
|
+
"ranx",
|
|
337
|
+
lambda: [
|
|
338
|
+
float(es.mrr(rel, ret)),
|
|
339
|
+
float(es.mean_average_precision_at_k(rel, ret, k=20)),
|
|
340
|
+
float(es.ndcg_at_k(rel, ret, k=10)),
|
|
341
|
+
],
|
|
342
|
+
None,
|
|
343
|
+
),
|
|
344
|
+
(
|
|
345
|
+
f"agreement: Krippendorff's alpha, interval (4 raters × {m} items)",
|
|
346
|
+
"krippendorff",
|
|
347
|
+
lambda: [float(es.krippendorff_alpha(ratings, level="interval"))],
|
|
348
|
+
None,
|
|
349
|
+
),
|
|
350
|
+
(
|
|
351
|
+
f"structured: JSON Schema compliance ({m} documents)",
|
|
352
|
+
"jsonschema",
|
|
353
|
+
lambda: [float(es.json_schema_compliance(docs_text, schema))],
|
|
354
|
+
None,
|
|
355
|
+
),
|
|
356
|
+
]
|
|
357
|
+
refs: dict[str, Callable[[], Any]] = {}
|
|
358
|
+
with contextlib.suppress(ImportError):
|
|
359
|
+
import sacrebleu
|
|
360
|
+
|
|
361
|
+
refs[cases[0][0]] = lambda: [
|
|
362
|
+
sacrebleu.corpus_bleu(preds, [refs_], force=True).score,
|
|
363
|
+
sacrebleu.corpus_chrf(preds, [refs_]).score,
|
|
364
|
+
]
|
|
365
|
+
with contextlib.suppress(ImportError):
|
|
366
|
+
from rouge_score import rouge_scorer
|
|
367
|
+
|
|
368
|
+
def rouge_ref() -> list[float]:
|
|
369
|
+
sc = rouge_scorer.RougeScorer(["rouge1", "rouge2", "rougeL"])
|
|
370
|
+
out = [sc.score(r, p) for r, p in zip(refs_, preds)]
|
|
371
|
+
return [float(np.mean([o[k].fmeasure for o in out])) for k in ("rouge1", "rouge2", "rougeL")]
|
|
372
|
+
|
|
373
|
+
refs[cases[1][0]] = rouge_ref
|
|
374
|
+
with contextlib.suppress(ImportError):
|
|
375
|
+
from nltk.translate.meteor_score import meteor_score
|
|
376
|
+
|
|
377
|
+
class _NoWordNet:
|
|
378
|
+
def synsets(self, _w: str) -> list[Any]:
|
|
379
|
+
return []
|
|
380
|
+
|
|
381
|
+
refs[cases[2][0]] = lambda: [
|
|
382
|
+
float(
|
|
383
|
+
np.mean([meteor_score([r.split()], p.split(), wordnet=_NoWordNet()) for r, p in zip(refs_, preds)])
|
|
384
|
+
)
|
|
385
|
+
]
|
|
386
|
+
with contextlib.suppress(ImportError):
|
|
387
|
+
from ranx import Qrels, Run
|
|
388
|
+
from ranx import evaluate as ranx_evaluate
|
|
389
|
+
|
|
390
|
+
def ranx_ref() -> list[float]:
|
|
391
|
+
qrels = Qrels({f"q{i}": {f"d{d}": 1 for d in r} for i, r in enumerate(rel)})
|
|
392
|
+
run = Run({f"q{i}": {f"d{d}": float(len(r) - j) for j, d in enumerate(r)} for i, r in enumerate(ret)})
|
|
393
|
+
res = ranx_evaluate(qrels, run, ["mrr", "map@20", "ndcg@10"])
|
|
394
|
+
return [float(res["mrr"]), float(res["map@20"]), float(res["ndcg@10"])]
|
|
395
|
+
|
|
396
|
+
refs[cases[3][0]] = ranx_ref
|
|
397
|
+
with contextlib.suppress(ImportError):
|
|
398
|
+
import krippendorff
|
|
399
|
+
|
|
400
|
+
refs[cases[4][0]] = lambda: [
|
|
401
|
+
float(krippendorff.alpha(reliability_data=ratings, level_of_measurement="interval"))
|
|
402
|
+
]
|
|
403
|
+
with contextlib.suppress(ImportError):
|
|
404
|
+
import jsonschema
|
|
405
|
+
|
|
406
|
+
def jsonschema_ref() -> list[float]:
|
|
407
|
+
v = jsonschema.Draft202012Validator(schema)
|
|
408
|
+
return [float(np.mean([v.is_valid(_json.loads(t)) for t in docs_text]))]
|
|
409
|
+
|
|
410
|
+
refs[cases[5][0]] = jsonschema_ref
|
|
411
|
+
return [(name, ref, es_fn, refs.get(name)) for name, ref, es_fn, _ in cases]
|
|
412
|
+
|
|
413
|
+
|
|
263
414
|
def _clinical_cases(n: int, rng: np.random.Generator) -> list[Case]:
|
|
264
415
|
"""v0.2.0: diagnostic accuracy, calibration, decision curves and statistical tests."""
|
|
265
416
|
import evalsuite as es
|
|
@@ -371,6 +522,29 @@ def _clinical_cases(n: int, rng: np.random.Generator) -> list[Case]:
|
|
|
371
522
|
return [(name, ref, es_fn, own if own is not None else refs.get(name)) for name, ref, es_fn, own in cases]
|
|
372
523
|
|
|
373
524
|
|
|
525
|
+
_GROUPS = (
|
|
526
|
+
("Classification and regression", ("binary", "10 classes", "regression")),
|
|
527
|
+
("Clinical, calibration and statistics", ("clinical", "calibration", "decision", "statistics", "multiple")),
|
|
528
|
+
("Segmentation and object detection", ("segmentation", "detection")),
|
|
529
|
+
("LLM evaluation", ("text", "retrieval", "agreement", "structured")),
|
|
530
|
+
)
|
|
531
|
+
MATCH_TOLERANCE = 1e-9
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def benchmark_group(case: str) -> str:
|
|
535
|
+
"""The suite a benchmark case belongs to, by its name."""
|
|
536
|
+
lowered = case.lower()
|
|
537
|
+
for title, prefixes in _GROUPS:
|
|
538
|
+
if lowered.startswith(prefixes):
|
|
539
|
+
return title
|
|
540
|
+
return "Other"
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _geomean(values: Sequence[float]) -> Optional[float]:
|
|
544
|
+
vals = [v for v in values if v is not None and v > 0]
|
|
545
|
+
return float(np.exp(np.mean(np.log(vals)))) if vals else None
|
|
546
|
+
|
|
547
|
+
|
|
374
548
|
@dataclass(frozen=True, eq=False)
|
|
375
549
|
class BenchmarkResult:
|
|
376
550
|
"""Rows of timings (milliseconds), peak memory (MiB) and agreement, plus the environment they ran in."""
|
|
@@ -382,6 +556,60 @@ class BenchmarkResult:
|
|
|
382
556
|
object.__setattr__(self, "rows", tuple(MappingProxyType(dict(r)) for r in self.rows))
|
|
383
557
|
object.__setattr__(self, "environment", MappingProxyType(dict(self.environment)))
|
|
384
558
|
|
|
559
|
+
def sorted_rows(self) -> list[Any]:
|
|
560
|
+
"""Rows in alphabetical order of case, then by size."""
|
|
561
|
+
return sorted(self.rows, key=lambda r: (r["case"].lower(), r["n"]))
|
|
562
|
+
|
|
563
|
+
def overall(self) -> list[dict[str, Any]]:
|
|
564
|
+
"""One summary row per suite and one for everything: cases, how many agree with the reference
|
|
565
|
+
(max |difference| <= 1e-9), and the geometric-mean, smallest and largest speed-up."""
|
|
566
|
+
|
|
567
|
+
def summarise(name: str, rows: Sequence[Any]) -> dict[str, Any]:
|
|
568
|
+
compared = [r for r in rows if r["speedup"] is not None]
|
|
569
|
+
speedups = [r["speedup"] for r in compared]
|
|
570
|
+
return {
|
|
571
|
+
"group": name,
|
|
572
|
+
"cases": len({re.sub(r"\s*\((?=[^)]*\d)[^)]*\)$", "", r["case"]) for r in rows}),
|
|
573
|
+
"rows": len(rows),
|
|
574
|
+
"compared": len(compared),
|
|
575
|
+
"matching": sum(
|
|
576
|
+
r["max_abs_diff"] is not None and r["max_abs_diff"] <= MATCH_TOLERANCE for r in rows
|
|
577
|
+
),
|
|
578
|
+
"faster": sum(sp > 1 for sp in speedups),
|
|
579
|
+
"geomean_speedup": _geomean(speedups),
|
|
580
|
+
"min_speedup": min(speedups) if speedups else None,
|
|
581
|
+
"max_speedup": max(speedups) if speedups else None,
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
groups = sorted({benchmark_group(r["case"]) for r in self.rows})
|
|
585
|
+
out = [summarise(g, [r for r in self.rows if benchmark_group(r["case"]) == g]) for g in groups]
|
|
586
|
+
out.append(summarise("Overall", self.rows))
|
|
587
|
+
return out
|
|
588
|
+
|
|
589
|
+
_OVERALL_TITLES = ("Suite", "Cases", "Rows", "Match reference", "Faster", "Geo-mean speed-up", "Range")
|
|
590
|
+
|
|
591
|
+
def _overall_cells(self) -> list[list[str]]:
|
|
592
|
+
def x(v: Optional[float]) -> str:
|
|
593
|
+
return "–" if v is None else f"{v:.2f}×"
|
|
594
|
+
|
|
595
|
+
return [
|
|
596
|
+
[
|
|
597
|
+
o["group"],
|
|
598
|
+
str(o["cases"]),
|
|
599
|
+
str(o["rows"]),
|
|
600
|
+
f"{o['matching']}/{o['compared']}",
|
|
601
|
+
f"{o['faster']}/{o['compared']}",
|
|
602
|
+
x(o["geomean_speedup"]),
|
|
603
|
+
"–" if o["min_speedup"] is None else f"{x(o['min_speedup'])}–{x(o['max_speedup'])}",
|
|
604
|
+
]
|
|
605
|
+
for o in self.overall()
|
|
606
|
+
]
|
|
607
|
+
|
|
608
|
+
def overall_markdown(self) -> str:
|
|
609
|
+
"""The overall summary as a Markdown table."""
|
|
610
|
+
lines = ["| " + " | ".join(self._OVERALL_TITLES) + " |", "| --- |" + " ---: |" * 6]
|
|
611
|
+
return "\n".join(lines + ["| " + " | ".join(c) + " |" for c in self._overall_cells()])
|
|
612
|
+
|
|
385
613
|
def _cells(self, digits: int) -> list[list[str]]:
|
|
386
614
|
def f(v: Any, d: int = digits) -> str:
|
|
387
615
|
return "–" if v is None else f"{v:.{d}f}"
|
|
@@ -398,7 +626,7 @@ class BenchmarkResult:
|
|
|
398
626
|
f(r.get("reference_peak_mb"), 2),
|
|
399
627
|
"–" if r["max_abs_diff"] is None else f"{r['max_abs_diff']:.1e}",
|
|
400
628
|
]
|
|
401
|
-
for r in self.
|
|
629
|
+
for r in self.sorted_rows()
|
|
402
630
|
]
|
|
403
631
|
|
|
404
632
|
_TITLES = (
|
|
@@ -430,7 +658,14 @@ class BenchmarkResult:
|
|
|
430
658
|
+ f" | {env['machine']} | fastest of {env['repeat']} runs"
|
|
431
659
|
)
|
|
432
660
|
note = "Speed-up > 1 means EvalSuite is faster. Max |difference| compares EvalSuite with the reference."
|
|
433
|
-
|
|
661
|
+
ocells = self._overall_cells()
|
|
662
|
+
owidths = [max(len(t), *(len(c[i]) for c in ocells)) for i, t in enumerate(self._OVERALL_TITLES)]
|
|
663
|
+
|
|
664
|
+
def oline(c: Sequence[str]) -> str:
|
|
665
|
+
return " ".join(x.ljust(owidths[i]) if i == 0 else x.rjust(owidths[i]) for i, x in enumerate(c))
|
|
666
|
+
|
|
667
|
+
overall = ["Overall", oline(self._OVERALL_TITLES), *(oline(c) for c in ocells), ""]
|
|
668
|
+
return "\n".join([head, "", *overall, line(self._TITLES), *(line(c) for c in cells), "", note])
|
|
434
669
|
|
|
435
670
|
def __repr__(self) -> str:
|
|
436
671
|
return self.summary()
|
|
@@ -438,7 +673,13 @@ class BenchmarkResult:
|
|
|
438
673
|
def to_dict(self) -> dict[str, Any]:
|
|
439
674
|
return cast(
|
|
440
675
|
"dict[str, Any]",
|
|
441
|
-
_json_safe(
|
|
676
|
+
_json_safe(
|
|
677
|
+
{
|
|
678
|
+
"environment": dict(self.environment),
|
|
679
|
+
"overall": self.overall(),
|
|
680
|
+
"rows": [dict(r) for r in self.rows],
|
|
681
|
+
}
|
|
682
|
+
),
|
|
442
683
|
)
|
|
443
684
|
|
|
444
685
|
def to_json(self, *, indent: Optional[int] = 2) -> str:
|
|
@@ -506,15 +747,15 @@ def run_benchmarks(
|
|
|
506
747
|
|
|
507
748
|
if repeat < 1:
|
|
508
749
|
raise ValueError("repeat must be at least 1.")
|
|
509
|
-
if suite not in ("all", "core", "clinical", "vision"):
|
|
510
|
-
raise ValueError("suite must be 'all', 'core', 'clinical' or '
|
|
750
|
+
if suite not in ("all", "core", "clinical", "vision", "llm"):
|
|
751
|
+
raise ValueError("suite must be 'all', 'core', 'clinical', 'vision' or 'llm'.")
|
|
511
752
|
rng = np.random.default_rng(random_state)
|
|
512
753
|
rows: list[dict[str, Any]] = []
|
|
513
754
|
versions: dict[str, Optional[str]] = {"sklearn": None, "statsmodels": None, "pycocotools": None}
|
|
514
755
|
if compare_sklearn:
|
|
515
756
|
for mod in versions:
|
|
516
757
|
with contextlib.suppress(ImportError):
|
|
517
|
-
|
|
758
|
+
importlib.import_module(mod) # fixed names only: sklearn, statsmodels, pycocotools
|
|
518
759
|
from importlib.metadata import version as _dist_version
|
|
519
760
|
|
|
520
761
|
versions[mod] = _dist_version("scikit-learn" if mod == "sklearn" else mod)
|
|
@@ -522,7 +763,8 @@ def run_benchmarks(
|
|
|
522
763
|
"core": [_core_cases],
|
|
523
764
|
"clinical": [_clinical_cases],
|
|
524
765
|
"vision": [_vision_cases],
|
|
525
|
-
"
|
|
766
|
+
"llm": [_llm_cases],
|
|
767
|
+
"all": [_core_cases, _clinical_cases, _vision_cases, _llm_cases],
|
|
526
768
|
}[suite]
|
|
527
769
|
for n in sizes:
|
|
528
770
|
for build in builders:
|
evalsuite/cli/main.py
CHANGED
|
@@ -297,6 +297,24 @@ def cmd_detection(args: argparse.Namespace) -> int:
|
|
|
297
297
|
return 0
|
|
298
298
|
|
|
299
299
|
|
|
300
|
+
def cmd_text(args: argparse.Namespace) -> int:
|
|
301
|
+
import evalsuite as es
|
|
302
|
+
|
|
303
|
+
df = read_table(args.file)
|
|
304
|
+
preds = [str(v) for v in column(df, args.prediction, "--prediction")]
|
|
305
|
+
ref_cols = [column(df, c, "--reference") for c in args.reference]
|
|
306
|
+
refs = [[str(col[i]) for col in ref_cols if not _missing(col[i])] for i in range(len(preds))]
|
|
307
|
+
if any(not r for r in refs):
|
|
308
|
+
raise CLIError("Every row needs at least one non-empty reference.")
|
|
309
|
+
report = es.text_report(refs, preds, metrics=metric_list(args.metrics))
|
|
310
|
+
emit(report, args)
|
|
311
|
+
return 0
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _missing(v: Any) -> bool:
|
|
315
|
+
return v is None or (isinstance(v, float) and v != v) or (isinstance(v, str) and not v.strip())
|
|
316
|
+
|
|
317
|
+
|
|
300
318
|
def cmd_compare(args: argparse.Namespace) -> int:
|
|
301
319
|
import evalsuite as es
|
|
302
320
|
|
|
@@ -506,6 +524,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
506
524
|
add_output(p, digits=3)
|
|
507
525
|
p.set_defaults(func=cmd_detection)
|
|
508
526
|
|
|
527
|
+
p = sub.add_parser(
|
|
528
|
+
"text", help="text generation: BLEU, chrF, chrF++, TER, ROUGE, METEOR, exact match, token F1"
|
|
529
|
+
)
|
|
530
|
+
p.add_argument("file", help="CSV, TSV, Parquet or JSON-lines file with one example per row ('-' for stdin)")
|
|
531
|
+
p.add_argument("--prediction", required=True, help="column with the model output")
|
|
532
|
+
p.add_argument(
|
|
533
|
+
"--reference", required=True, action="append", help="column with a reference (repeat for several)"
|
|
534
|
+
)
|
|
535
|
+
p.add_argument("--metrics", help="comma-separated subset, e.g. bleu,chrf,rouge_l")
|
|
536
|
+
add_output(p)
|
|
537
|
+
p.set_defaults(func=cmd_text)
|
|
538
|
+
|
|
509
539
|
p = sub.add_parser(
|
|
510
540
|
"plot", help="save a ROC, PR, calibration, decision-curve, confusion-matrix or residual plot"
|
|
511
541
|
)
|
|
@@ -529,7 +559,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
529
559
|
p.add_argument("--repeat", type=int, default=5, help="timed repetitions per case; the fastest is reported")
|
|
530
560
|
p.add_argument(
|
|
531
561
|
"--suite",
|
|
532
|
-
choices=("all", "core", "clinical", "vision"),
|
|
562
|
+
choices=("all", "core", "clinical", "vision", "llm"),
|
|
533
563
|
default="all",
|
|
534
564
|
help="core: classification/regression; clinical: clinical, calibration, tests; vision: segmentation, "
|
|
535
565
|
"detection (default all)",
|
evalsuite/core/validation.py
CHANGED
|
@@ -45,9 +45,25 @@ def to_numpy(x: ArrayLike, name: str, *, allow_2d: bool = False) -> NDArray[Any]
|
|
|
45
45
|
raise InputValidationError(f"{name} must be {expected}; received an array with shape {arr.shape}.")
|
|
46
46
|
if arr.shape[0] == 0:
|
|
47
47
|
raise InputValidationError(f"{name} is empty. Provide at least one observation.")
|
|
48
|
+
if (
|
|
49
|
+
arr.ndim == 1
|
|
50
|
+
and arr.dtype.kind in "US"
|
|
51
|
+
and isinstance(x, (list, tuple))
|
|
52
|
+
and not all(isinstance(v, (str, bytes)) for v in x)
|
|
53
|
+
):
|
|
54
|
+
# NumPy silently turns [0, "a"] into ["0", "a"]; a mix of numbers and strings is almost always a bug.
|
|
55
|
+
raise InputValidationError(f"{name} mixes numbers and strings; use one label type throughout.")
|
|
48
56
|
return arr
|
|
49
57
|
|
|
50
58
|
|
|
59
|
+
def _label_kind(arr: NDArray[Any]) -> str:
|
|
60
|
+
if arr.dtype.kind in "US":
|
|
61
|
+
return "string"
|
|
62
|
+
if arr.dtype.kind in "biuf":
|
|
63
|
+
return "number"
|
|
64
|
+
return "other"
|
|
65
|
+
|
|
66
|
+
|
|
51
67
|
def check_finite(arr: NDArray[Any], name: str) -> None:
|
|
52
68
|
if np.issubdtype(arr.dtype, np.number) and not np.all(np.isfinite(arr)):
|
|
53
69
|
n_nan = int(np.isnan(arr).sum())
|
|
@@ -126,6 +142,11 @@ def resolve_labels(
|
|
|
126
142
|
if np.unique(lab).shape[0] != lab.shape[0]:
|
|
127
143
|
raise InputValidationError("labels contains duplicates.")
|
|
128
144
|
return lab
|
|
145
|
+
if y_pred is not None and {_label_kind(y_true), _label_kind(y_pred)} == {"string", "number"}:
|
|
146
|
+
raise InputValidationError(
|
|
147
|
+
f"y_true has {_label_kind(y_true)} labels but y_pred has {_label_kind(y_pred)} labels; "
|
|
148
|
+
"use the same label type in both (for example map class names to integers first)."
|
|
149
|
+
)
|
|
129
150
|
present = y_true if y_pred is None else np.concatenate([y_true, y_pred])
|
|
130
151
|
try:
|
|
131
152
|
return unique_labels(present)
|
evalsuite/plot.py
CHANGED
|
@@ -503,3 +503,149 @@ def detection_pr(
|
|
|
503
503
|
)
|
|
504
504
|
ax.legend(loc="lower left", frameon=False, fontsize="small")
|
|
505
505
|
return ax
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ---------------------------------------------------------------- v0.4.0: preferences and text
|
|
509
|
+
def _rating_fit(method: str, rows: list[Any]) -> Any:
|
|
510
|
+
from .text.judge import bradley_terry, elo_ratings
|
|
511
|
+
|
|
512
|
+
if method == "bradley_terry":
|
|
513
|
+
return bradley_terry(rows, scale="elo")
|
|
514
|
+
if method == "elo":
|
|
515
|
+
return elo_ratings(rows)
|
|
516
|
+
raise InputValidationError("method must be 'bradley_terry' or 'elo'.")
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def ratings(
|
|
520
|
+
comparisons: Any,
|
|
521
|
+
*,
|
|
522
|
+
method: str = "bradley_terry",
|
|
523
|
+
n_resamples: int = 200,
|
|
524
|
+
level: float = 0.95,
|
|
525
|
+
random_state: Optional[int] = 0,
|
|
526
|
+
ax: Optional[Axes] = None,
|
|
527
|
+
) -> Axes:
|
|
528
|
+
"""Leaderboard of model ratings from pairwise ``(model_a, model_b, outcome)`` comparisons, with percentile
|
|
529
|
+
bootstrap intervals over comparisons (Chatbot Arena style). ``method``: ``"bradley_terry"`` (Elo scale) or
|
|
530
|
+
``"elo"`` (online Elo). Resamples on which the Bradley–Terry fit does not exist are skipped and counted in
|
|
531
|
+
the x-axis label."""
|
|
532
|
+
rows = list(comparisons)
|
|
533
|
+
point = _rating_fit(method, rows)
|
|
534
|
+
names = list(point.labels or ())
|
|
535
|
+
est = np.asarray(point.value, dtype=float)
|
|
536
|
+
rng = np.random.default_rng(random_state)
|
|
537
|
+
boot, failed = [], 0
|
|
538
|
+
for _ in range(int(n_resamples)):
|
|
539
|
+
sample = [rows[i] for i in rng.integers(0, len(rows), len(rows))]
|
|
540
|
+
try:
|
|
541
|
+
r = _rating_fit(method, sample)
|
|
542
|
+
except InputValidationError:
|
|
543
|
+
failed += 1
|
|
544
|
+
continue
|
|
545
|
+
lookup = dict(zip(r.labels or (), np.asarray(r.value, dtype=float)))
|
|
546
|
+
boot.append([lookup.get(n, np.nan) for n in names])
|
|
547
|
+
order = np.argsort(est)
|
|
548
|
+
ax = _axes(ax, (5.5, max(2.6, 0.45 * len(names) + 1.2)))
|
|
549
|
+
y = np.arange(len(names))
|
|
550
|
+
if boot:
|
|
551
|
+
b = np.asarray(boot)
|
|
552
|
+
a = (1 - level) / 2
|
|
553
|
+
lo, hi = np.nanquantile(b, a, axis=0), np.nanquantile(b, 1 - a, axis=0)
|
|
554
|
+
ax.errorbar(
|
|
555
|
+
est[order],
|
|
556
|
+
y,
|
|
557
|
+
xerr=[est[order] - lo[order], hi[order] - est[order]],
|
|
558
|
+
fmt="o",
|
|
559
|
+
color="#2a6fb0",
|
|
560
|
+
ecolor="0.45",
|
|
561
|
+
capsize=3,
|
|
562
|
+
)
|
|
563
|
+
else:
|
|
564
|
+
ax.plot(est[order], y, "o", color="#2a6fb0")
|
|
565
|
+
ax.set_yticks(y, [str(names[i]) for i in order])
|
|
566
|
+
title = "Bradley–Terry rating" if method == "bradley_terry" else "Elo rating"
|
|
567
|
+
note = f"{int(level * 100)}% bootstrap CI, {len(boot)} resamples" + (f", {failed} skipped" if failed else "")
|
|
568
|
+
ax.set(xlabel=f"{title} ({note})", title=f"{title}s from {len(rows)} comparisons")
|
|
569
|
+
ax.grid(axis="x", alpha=0.3)
|
|
570
|
+
return ax
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def win_matrix(comparisons: Any, *, ax: Optional[Axes] = None, annotate: bool = True) -> Axes:
|
|
574
|
+
"""Heatmap of pairwise win rates: cell (row, column) is how often the row model beat the column model (ties
|
|
575
|
+
count half); blank where the two were never compared."""
|
|
576
|
+
from .text.judge import _comparisons
|
|
577
|
+
|
|
578
|
+
names, rows = _comparisons(comparisons)
|
|
579
|
+
m = len(names)
|
|
580
|
+
wins, games = np.zeros((m, m)), np.zeros((m, m))
|
|
581
|
+
for a, b, o in rows:
|
|
582
|
+
wins[a, b] += o
|
|
583
|
+
wins[b, a] += 1 - o
|
|
584
|
+
games[a, b] += 1
|
|
585
|
+
games[b, a] += 1
|
|
586
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
587
|
+
rate = np.where(games > 0, wins / np.where(games > 0, games, 1), np.nan)
|
|
588
|
+
order = np.argsort(-np.nanmean(np.where(np.eye(m, dtype=bool), np.nan, rate), axis=1)) # strongest first
|
|
589
|
+
rate = rate[np.ix_(order, order)]
|
|
590
|
+
names = [names[i] for i in order]
|
|
591
|
+
ax = _axes(ax, (1.2 + 0.7 * m, 0.9 + 0.6 * m))
|
|
592
|
+
im = ax.imshow(rate, cmap="RdBu", vmin=0, vmax=1)
|
|
593
|
+
ax.set_xticks(range(m), [str(n) for n in names], rotation=45, ha="right")
|
|
594
|
+
ax.set_yticks(range(m), [str(n) for n in names])
|
|
595
|
+
if annotate:
|
|
596
|
+
for i in range(m):
|
|
597
|
+
for j in range(m):
|
|
598
|
+
if not np.isnan(rate[i, j]):
|
|
599
|
+
ax.text(
|
|
600
|
+
j,
|
|
601
|
+
i,
|
|
602
|
+
f"{rate[i, j]:.2f}",
|
|
603
|
+
ha="center",
|
|
604
|
+
va="center",
|
|
605
|
+
fontsize=8,
|
|
606
|
+
color="white" if abs(rate[i, j] - 0.5) > 0.3 else "black",
|
|
607
|
+
)
|
|
608
|
+
ax.figure.colorbar(im, ax=ax, label="win rate (row vs column)")
|
|
609
|
+
ax.set_title("Pairwise win rates")
|
|
610
|
+
return ax
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def text_scores(
|
|
614
|
+
references: Any,
|
|
615
|
+
predictions: Mapping[str, Any],
|
|
616
|
+
*,
|
|
617
|
+
metric: Any = "token_f1",
|
|
618
|
+
ax: Optional[Axes] = None,
|
|
619
|
+
**metric_kwargs: Any,
|
|
620
|
+
) -> Axes:
|
|
621
|
+
"""Distribution of a per-example text metric for several systems (box plots with the mean marked), e.g.
|
|
622
|
+
``metric="rouge_l"``, ``"token_f1"``, ``"meteor"``, ``"sentence_bleu"`` or ``"bertscore"`` (with
|
|
623
|
+
embeddings). Pair it with ``es.compare`` for intervals and tests."""
|
|
624
|
+
from .stats._resolve import resolve_metric
|
|
625
|
+
|
|
626
|
+
fn, name = resolve_metric(metric)
|
|
627
|
+
import inspect
|
|
628
|
+
|
|
629
|
+
if "average" not in inspect.signature(fn).parameters:
|
|
630
|
+
raise InputValidationError(
|
|
631
|
+
f"{name} is a corpus-level metric without per-example scores; use e.g. sentence_bleu, rouge_l, "
|
|
632
|
+
"token_f1 or meteor, or es.compare for corpus metrics."
|
|
633
|
+
)
|
|
634
|
+
if not isinstance(predictions, Mapping) or not predictions:
|
|
635
|
+
raise InputValidationError("predictions must be a dict of system name -> predictions.")
|
|
636
|
+
data, labels = [], []
|
|
637
|
+
for sys_name, preds in predictions.items():
|
|
638
|
+
res = fn(references, preds, average=None, **metric_kwargs)
|
|
639
|
+
vals = np.asarray(res.value, dtype=float)
|
|
640
|
+
if vals.ndim != 1:
|
|
641
|
+
raise InputValidationError(f"{name} does not give one score per example.")
|
|
642
|
+
data.append(vals[~np.isnan(vals)])
|
|
643
|
+
labels.append(str(sys_name))
|
|
644
|
+
ax = _axes(ax, (1.6 + 1.1 * len(labels), 4.2))
|
|
645
|
+
ax.boxplot(
|
|
646
|
+
data, showmeans=True, meanprops={"marker": "D", "markerfacecolor": "#d9822b", "markeredgecolor": "#d9822b"}
|
|
647
|
+
)
|
|
648
|
+
ax.set_xticks(range(1, len(labels) + 1), labels)
|
|
649
|
+
ax.set(ylabel=f"{res.name} per example", title=f"{res.name} by system (diamond = mean)")
|
|
650
|
+
ax.grid(axis="y", alpha=0.3)
|
|
651
|
+
return ax
|
evalsuite/regression/metrics.py
CHANGED
|
@@ -202,7 +202,15 @@ def rmse(
|
|
|
202
202
|
) -> MetricResult:
|
|
203
203
|
"""Root mean squared error (square root taken per output, then averaged)."""
|
|
204
204
|
inp = _inputs(y_true, y_pred, sample_weight)
|
|
205
|
-
|
|
205
|
+
with np.errstate(over="ignore"):
|
|
206
|
+
per_output = np.sqrt(inp.mean(inp.error**2))
|
|
207
|
+
if not np.all(np.isfinite(per_output)):
|
|
208
|
+
# Errors above ~1e154 overflow when squared although the RMSE itself is representable:
|
|
209
|
+
# rescale by the largest error and square again.
|
|
210
|
+
scale = np.abs(inp.error).max(axis=0)
|
|
211
|
+
scale = np.where(np.isfinite(scale) & (scale > 0), scale, 1.0)
|
|
212
|
+
per_output = scale * np.sqrt(inp.mean((inp.error / scale) ** 2))
|
|
213
|
+
return _finish(per_output, inp, multioutput, "rmse", "RMSE")
|
|
206
214
|
|
|
207
215
|
|
|
208
216
|
def _r2_per_output(inp: _Inputs) -> FloatArray:
|