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 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 # type: ignore[import-untyped]
215
- from pycocotools.cocoeval import COCOeval # type: ignore[import-untyped]
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.rows
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
- return "\n".join([head, "", line(self._TITLES), *(line(c) for c in cells), "", note])
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({"environment": dict(self.environment), "rows": [dict(r) for r in self.rows]}),
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 'vision'.")
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
- __import__(mod)
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
- "all": [_core_cases, _clinical_cases, _vision_cases],
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)",
@@ -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
@@ -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
- return _finish(np.sqrt(inp.mean(inp.error**2)), inp, multioutput, "rmse", "RMSE")
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: