evalsuite-python 0.3.1__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
@@ -213,8 +213,8 @@ def _vision_cases(n: int, rng: np.random.Generator) -> list[Case]:
213
213
  import contextlib as _ctx
214
214
  import io
215
215
 
216
- from pycocotools.coco import COCO # type: ignore[import-untyped]
217
- from pycocotools.cocoeval import COCOeval # type: ignore[import-untyped]
216
+ from pycocotools.coco import COCO
217
+ from pycocotools.cocoeval import COCOeval
218
218
 
219
219
  def coco_map() -> list[float]:
220
220
  images, anns, dets, aid = [], [], [], 1
@@ -262,6 +262,155 @@ def _vision_cases(n: int, rng: np.random.Generator) -> list[Case]:
262
262
  return [(name, ref, es_fn, refs.get(name)) for name, ref, es_fn, _ in cases]
263
263
 
264
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
+
265
414
  def _clinical_cases(n: int, rng: np.random.Generator) -> list[Case]:
266
415
  """v0.2.0: diagnostic accuracy, calibration, decision curves and statistical tests."""
267
416
  import evalsuite as es
@@ -377,6 +526,7 @@ _GROUPS = (
377
526
  ("Classification and regression", ("binary", "10 classes", "regression")),
378
527
  ("Clinical, calibration and statistics", ("clinical", "calibration", "decision", "statistics", "multiple")),
379
528
  ("Segmentation and object detection", ("segmentation", "detection")),
529
+ ("LLM evaluation", ("text", "retrieval", "agreement", "structured")),
380
530
  )
381
531
  MATCH_TOLERANCE = 1e-9
382
532
 
@@ -419,7 +569,7 @@ class BenchmarkResult:
419
569
  speedups = [r["speedup"] for r in compared]
420
570
  return {
421
571
  "group": name,
422
- "cases": len({re.sub(r"\s*\(\d+ images?\)", "", r["case"]) for r in rows}),
572
+ "cases": len({re.sub(r"\s*\((?=[^)]*\d)[^)]*\)$", "", r["case"]) for r in rows}),
423
573
  "rows": len(rows),
424
574
  "compared": len(compared),
425
575
  "matching": sum(
@@ -597,8 +747,8 @@ def run_benchmarks(
597
747
 
598
748
  if repeat < 1:
599
749
  raise ValueError("repeat must be at least 1.")
600
- if suite not in ("all", "core", "clinical", "vision"):
601
- 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'.")
602
752
  rng = np.random.default_rng(random_state)
603
753
  rows: list[dict[str, Any]] = []
604
754
  versions: dict[str, Optional[str]] = {"sklearn": None, "statsmodels": None, "pycocotools": None}
@@ -613,7 +763,8 @@ def run_benchmarks(
613
763
  "core": [_core_cases],
614
764
  "clinical": [_clinical_cases],
615
765
  "vision": [_vision_cases],
616
- "all": [_core_cases, _clinical_cases, _vision_cases],
766
+ "llm": [_llm_cases],
767
+ "all": [_core_cases, _clinical_cases, _vision_cases, _llm_cases],
617
768
  }[suite]
618
769
  for n in sizes:
619
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/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
@@ -54,6 +54,19 @@ def as_observations(x: Any, name: str) -> NDArray[Any]:
54
54
  return to_numpy(x, name, allow_2d=True)
55
55
 
56
56
 
57
+ def as_items(x: Any, name: str) -> NDArray[Any]:
58
+ """A 1-D object array holding one item per observation, whatever each item is."""
59
+ if x is None or isinstance(x, (str, bytes)):
60
+ raise InputValidationError(f"{name} must be a list with one item per example.")
61
+ seq = list(x.tolist() if isinstance(x, np.ndarray) and x.dtype != object and x.ndim == 1 else x)
62
+ if not seq:
63
+ raise InputValidationError(f"{name} is empty. Provide at least one observation.")
64
+ out = np.empty(len(seq), dtype=object)
65
+ for i, v in enumerate(seq):
66
+ out[i] = v
67
+ return out
68
+
69
+
57
70
  def is_categorical(y: NDArray[Any]) -> bool:
58
71
  if y.ndim > 2 or (y.dtype == object and y.size and not isinstance(y.flat[0], (str, bytes))):
59
72
  return False
@@ -80,9 +93,16 @@ class MetricCall:
80
93
  "Pass exactly one of y_pred (label/value metrics) or y_prob (probability metrics)."
81
94
  )
82
95
  self.fn = fn
83
- self.y_true = as_observations(y_true, "y_true")
84
96
  second = y_pred if y_pred is not None else y_prob
85
- self.second = as_observations(second, "y_pred" if y_pred is not None else "y_prob")
97
+ self.items = bool(getattr(fn, "__evalsuite_items__", False))
98
+ if self.items:
99
+ # Text, retrieval and other item-level metrics: each observation is a whole item (a string, a list
100
+ # of references, a ranked list, an embedding matrix); resample items, never stratify by value.
101
+ self.y_true = as_items(y_true, "y_true")
102
+ self.second = as_items(second, "y_pred" if y_pred is not None else "y_prob")
103
+ else:
104
+ self.y_true = as_observations(y_true, "y_true")
105
+ self.second = as_observations(second, "y_pred" if y_pred is not None else "y_prob")
86
106
  if self.second.shape[0] != self.y_true.shape[0]:
87
107
  raise InputValidationError(
88
108
  f"y_true and {'y_pred' if y_pred is not None else 'y_prob'} must contain the same number of "
@@ -97,7 +117,7 @@ class MetricCall:
97
117
  self.weight = None if sample_weight is None else to_numpy(sample_weight, "sample_weight")
98
118
  self.kwargs = dict(kwargs or {})
99
119
  params = inspect.signature(fn).parameters
100
- self.categorical = is_categorical(self.y_true)
120
+ self.categorical = not self.items and is_categorical(self.y_true)
101
121
  # Keep every class in every resample so per-class and averaged metrics stay comparable.
102
122
  if self.categorical and "labels" in params and "labels" not in self.kwargs and self.y_true.ndim == 1:
103
123
  present = self.y_true if y_pred is None else np.concatenate([self.y_true, self.second])
@@ -15,7 +15,7 @@ from ..core.exceptions import InputValidationError
15
15
  from ..core.registry import metric_info
16
16
  from ..core.result import _json_safe, _latex_escape, _latex_table
17
17
  from ..core.types import ArrayLike
18
- from ._resolve import MetricCall, as_observations, is_categorical, resolve_metric
18
+ from ._resolve import MetricCall, as_items, as_observations, is_categorical, resolve_metric
19
19
  from .effect import adjust_pvalues
20
20
  from .intervals import _check_level, _check_resamples, bootstrap_distribution, resample_indices
21
21
  from .paired import delong_test, mcnemar_test
@@ -295,8 +295,12 @@ def compare(
295
295
  raise InputValidationError("compare() needs at least two models (a dict of name -> predictions).")
296
296
  if baseline is not None and baseline not in names:
297
297
  raise InputValidationError(f"baseline={baseline!r} is not one of the models: {', '.join(names)}.")
298
- yt = as_observations(y_true, "y_true")
299
- categorical = is_categorical(yt)
298
+ item_level = metrics is not None and any(
299
+ getattr(resolve_metric(m)[0], "__evalsuite_items__", False) for m in metrics
300
+ )
301
+ # Text, retrieval and other item-level metrics resample whole items and never stratify by value.
302
+ yt = as_items(y_true, "y_true") if item_level else as_observations(y_true, "y_true")
303
+ categorical = not item_level and is_categorical(yt)
300
304
  if metrics is None:
301
305
  if yt.ndim > 2 or (yt.dtype == object and yt.size and not isinstance(yt[0], (str, bytes))):
302
306
  is_detection = yt.dtype == object and isinstance(yt[0], Mapping)
@@ -0,0 +1,152 @@
1
+ """Language-model evaluation (v0.4.0): text generation, semantic similarity, factuality and hallucination,
2
+ LLM-as-a-judge and preferences, reasoning benchmarks, retrieval-augmented generation, and instruction following
3
+ and structured output."""
4
+
5
+ from .factuality import (
6
+ abstention_accuracy,
7
+ answer_correctness,
8
+ answer_relevance,
9
+ citation_precision,
10
+ citation_recall,
11
+ claim_verification_accuracy,
12
+ faithfulness,
13
+ groundedness,
14
+ hallucination_rate,
15
+ knowledge_consistency,
16
+ )
17
+ from .generation import (
18
+ bleu,
19
+ chrf,
20
+ cider,
21
+ cross_entropy,
22
+ distinct_n,
23
+ meteor,
24
+ perplexity,
25
+ rouge_1,
26
+ rouge_2,
27
+ rouge_l,
28
+ rouge_lsum,
29
+ self_bleu,
30
+ sentence_bleu,
31
+ ter,
32
+ )
33
+ from .judge import (
34
+ bradley_terry,
35
+ elo_ratings,
36
+ fleiss_kappa,
37
+ judge_agreement,
38
+ krippendorff_alpha,
39
+ position_consistency,
40
+ rubric_score,
41
+ self_preference_bias,
42
+ verbosity_bias,
43
+ win_rate,
44
+ )
45
+ from .qa import exact_match, normalize_answer, token_f1
46
+ from .rag import (
47
+ context_precision,
48
+ context_recall,
49
+ context_relevance,
50
+ failure_attribution,
51
+ latency_summary,
52
+ task_success_rate,
53
+ )
54
+ from .reasoning import benchmark_accuracy, extract_answer, majority_vote_accuracy, pass_at_k
55
+ from .report import TextReport, text_report
56
+ from .retrieval import hit_rate_at_k, mean_average_precision_at_k, mrr, ndcg_at_k, precision_at_k, recall_at_k
57
+ from .semantic import bertscore, embedding_similarity, mauve, model_score, moverscore
58
+ from .structured import (
59
+ api_call_success_rate,
60
+ constraint_satisfaction_rate,
61
+ extra_content_rate,
62
+ extract_json,
63
+ format_compliance,
64
+ instruction_compliance_rate,
65
+ instruction_retention,
66
+ json_schema_compliance,
67
+ json_validity,
68
+ required_field_accuracy,
69
+ tool_argument_accuracy,
70
+ tool_call_f1,
71
+ tool_selection_accuracy,
72
+ validate_json_schema,
73
+ xml_validity,
74
+ )
75
+
76
+ __all__ = [
77
+ "TextReport",
78
+ "text_report",
79
+ "abstention_accuracy",
80
+ "answer_correctness",
81
+ "answer_relevance",
82
+ "api_call_success_rate",
83
+ "benchmark_accuracy",
84
+ "bertscore",
85
+ "bleu",
86
+ "bradley_terry",
87
+ "chrf",
88
+ "cider",
89
+ "citation_precision",
90
+ "citation_recall",
91
+ "claim_verification_accuracy",
92
+ "constraint_satisfaction_rate",
93
+ "context_precision",
94
+ "context_recall",
95
+ "context_relevance",
96
+ "cross_entropy",
97
+ "distinct_n",
98
+ "elo_ratings",
99
+ "embedding_similarity",
100
+ "exact_match",
101
+ "extra_content_rate",
102
+ "extract_answer",
103
+ "extract_json",
104
+ "failure_attribution",
105
+ "faithfulness",
106
+ "fleiss_kappa",
107
+ "format_compliance",
108
+ "groundedness",
109
+ "hallucination_rate",
110
+ "hit_rate_at_k",
111
+ "instruction_compliance_rate",
112
+ "instruction_retention",
113
+ "json_schema_compliance",
114
+ "json_validity",
115
+ "judge_agreement",
116
+ "knowledge_consistency",
117
+ "krippendorff_alpha",
118
+ "latency_summary",
119
+ "majority_vote_accuracy",
120
+ "mauve",
121
+ "mean_average_precision_at_k",
122
+ "meteor",
123
+ "model_score",
124
+ "moverscore",
125
+ "mrr",
126
+ "ndcg_at_k",
127
+ "normalize_answer",
128
+ "pass_at_k",
129
+ "perplexity",
130
+ "position_consistency",
131
+ "precision_at_k",
132
+ "recall_at_k",
133
+ "required_field_accuracy",
134
+ "rouge_1",
135
+ "rouge_2",
136
+ "rouge_l",
137
+ "rouge_lsum",
138
+ "rubric_score",
139
+ "self_bleu",
140
+ "self_preference_bias",
141
+ "sentence_bleu",
142
+ "task_success_rate",
143
+ "ter",
144
+ "token_f1",
145
+ "tool_argument_accuracy",
146
+ "tool_call_f1",
147
+ "tool_selection_accuracy",
148
+ "validate_json_schema",
149
+ "verbosity_bias",
150
+ "win_rate",
151
+ "xml_validity",
152
+ ]