evalsuite-python 0.4.0__py3-none-any.whl → 0.4.1__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.
@@ -0,0 +1,1391 @@
1
+ """Per-metric benchmark cases: one row for every registered metric and every statistics function.
2
+
3
+ Used by :func:`evalsuite.benchmarks.run_benchmarks` with ``suite="metrics"``. Each case is named after the
4
+ metric's registry id (``classification.f1``, ``text.bleu``, ``statistics.t_test``) and times one call on
5
+ data of the requested size. Where an independent implementation exists it is timed on the same data and the
6
+ largest absolute difference is reported:
7
+
8
+ * a reference library (scikit-learn, SciPy, statsmodels, pycocotools, sacreBLEU, rouge-score, NLTK,
9
+ pycocoevalcap, ranx, krippendorff, choix, POT, jsonschema) when it is installed;
10
+ * otherwise, for metrics with a closed-form definition, a direct NumPy / standard-library implementation of
11
+ the textbook formula (labelled ``NumPy formula`` or ``Python stdlib``).
12
+
13
+ Metrics with neither (learned or judge-dependent scores, randomised procedures) are timed for EvalSuite only.
14
+
15
+ Sizes: ``n`` observations for classification, regression, clinical and statistics; ``n`` pixels for
16
+ segmentation; ``n / 1000`` images for detection; ``n / 100`` examples for text, retrieval, RAG, judge and
17
+ structured-output metrics; ``n / 1000`` examples for the token-embedding metrics (BERTScore, MoverScore)
18
+ and MAUVE.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import contextlib
24
+ import importlib
25
+ import json
26
+ import math
27
+ import re
28
+ import xml.etree.ElementTree as ET
29
+ from collections import Counter
30
+ from collections.abc import Callable
31
+ from typing import Any, Optional
32
+
33
+ import numpy as np
34
+
35
+ Case = tuple[str, str, Callable[[], Any], Optional[Callable[[], Any]]]
36
+
37
+ _VOCAB = ( # noqa: SIM905
38
+ "the a cat dog sat on mat quickly model models data run running ran results show improves baseline "
39
+ "evaluation paper method score test train large small new old good bad"
40
+ ).split()
41
+
42
+
43
+ def _opt(module: str) -> Any:
44
+ """An optional reference module, or None when it is not installed (fixed names only)."""
45
+ try:
46
+ return importlib.import_module(module)
47
+ except ImportError:
48
+ return None
49
+
50
+
51
+ def _f(x: Any) -> float:
52
+ return float(x)
53
+
54
+
55
+ def _numeric(x: Any) -> list[float]:
56
+ """Flatten a result (MetricResult, array, scalar, test result) to a list of floats for comparison."""
57
+ if hasattr(x, "p_value"):
58
+ return [float(x.statistic), float(x.p_value)]
59
+ if hasattr(x, "low") and hasattr(x, "high"):
60
+ return [float(x.low), float(x.high)]
61
+ v = getattr(x, "value", x)
62
+ return [float(t) for t in np.ravel(np.asarray(v, dtype=float))]
63
+
64
+
65
+ class _Cases:
66
+ """Collects (name, reference label, EvalSuite call, reference call)."""
67
+
68
+ def __init__(self) -> None:
69
+ self.rows: list[Case] = []
70
+
71
+ def add(
72
+ self,
73
+ name: str,
74
+ es_fn: Callable[[], Any],
75
+ ref: str = "",
76
+ ref_fn: Optional[Callable[[], Any]] = None,
77
+ ) -> None:
78
+ self.rows.append((name, ref if ref_fn is not None else "", es_fn, ref_fn))
79
+
80
+
81
+ # --------------------------------------------------------------------------------------------- core
82
+ def _classification(c: _Cases, n: int, rng: np.random.Generator) -> None:
83
+ import evalsuite as es
84
+
85
+ y = rng.integers(0, 2, n)
86
+ p = np.where(rng.random(n) < 0.8, y, 1 - y)
87
+ prob = 1 / (1 + np.exp(-(2.0 * (y - 0.5) + rng.normal(0, 1, n))))
88
+ yk = rng.integers(0, 10, n)
89
+ logits = rng.normal(size=(n, 10))
90
+ logits[np.arange(n), yk] += 1.5
91
+ pk = np.exp(logits) / np.exp(logits).sum(1, keepdims=True)
92
+ Y = (rng.random((n, 5)) < 0.3).astype(int)
93
+ P = np.where(rng.random((n, 5)) < 0.85, Y, 1 - Y)
94
+
95
+ def ece_np() -> float:
96
+ bins = np.minimum((prob * 10).astype(int), 9)
97
+ total = 0.0
98
+ for b in range(10):
99
+ m = bins == b
100
+ if m.any():
101
+ total += m.mean() * abs(prob[m].mean() - y[m].mean())
102
+ return total
103
+
104
+ skm = _opt("sklearn.metrics")
105
+
106
+ def sk(fn: Callable[[], Any]) -> Optional[Callable[[], Any]]:
107
+ return fn if skm is not None else None
108
+
109
+ c.add(
110
+ "classification.accuracy",
111
+ lambda: _f(es.accuracy(y, p)),
112
+ "scikit-learn",
113
+ sk(lambda: skm.accuracy_score(y, p)),
114
+ )
115
+ c.add(
116
+ "classification.average_precision",
117
+ lambda: _f(es.average_precision(y, prob)),
118
+ "scikit-learn",
119
+ sk(lambda: skm.average_precision_score(y, prob)),
120
+ )
121
+ c.add(
122
+ "classification.balanced_accuracy",
123
+ lambda: _f(es.balanced_accuracy(yk, pk.argmax(1))),
124
+ "scikit-learn",
125
+ sk(lambda: skm.balanced_accuracy_score(yk, pk.argmax(1))),
126
+ )
127
+ c.add(
128
+ "classification.brier_score",
129
+ lambda: _f(es.brier_score(y, prob)),
130
+ "scikit-learn",
131
+ sk(lambda: skm.brier_score_loss(y, prob)),
132
+ )
133
+ c.add(
134
+ "classification.cohen_kappa",
135
+ lambda: _f(es.cohen_kappa(y, p)),
136
+ "scikit-learn",
137
+ sk(lambda: skm.cohen_kappa_score(y, p)),
138
+ )
139
+ c.add(
140
+ "classification.expected_calibration_error",
141
+ lambda: _f(es.expected_calibration_error(y, prob, n_bins=10)),
142
+ "NumPy formula",
143
+ ece_np,
144
+ )
145
+ c.add(
146
+ "classification.f1",
147
+ lambda: _f(es.f1(yk, pk.argmax(1), average="macro")),
148
+ "scikit-learn",
149
+ sk(lambda: skm.f1_score(yk, pk.argmax(1), average="macro")),
150
+ )
151
+ c.add(
152
+ "classification.fbeta",
153
+ lambda: _f(es.fbeta(y, p, beta=2)),
154
+ "scikit-learn",
155
+ sk(lambda: skm.fbeta_score(y, p, beta=2)),
156
+ )
157
+ c.add(
158
+ "classification.hamming_loss",
159
+ lambda: _f(es.hamming_loss(Y, P)),
160
+ "scikit-learn",
161
+ sk(lambda: skm.hamming_loss(Y, P)),
162
+ )
163
+ c.add(
164
+ "classification.jaccard",
165
+ lambda: _f(es.jaccard(y, p)),
166
+ "scikit-learn",
167
+ sk(lambda: skm.jaccard_score(y, p)),
168
+ )
169
+ c.add(
170
+ "classification.log_loss",
171
+ lambda: _f(es.log_loss(yk, pk)),
172
+ "scikit-learn",
173
+ sk(lambda: skm.log_loss(yk, pk)),
174
+ )
175
+ c.add(
176
+ "classification.mcc",
177
+ lambda: _f(es.mcc(yk, pk.argmax(1))),
178
+ "scikit-learn",
179
+ sk(lambda: skm.matthews_corrcoef(yk, pk.argmax(1))),
180
+ )
181
+ c.add(
182
+ "classification.npv",
183
+ lambda: _f(es.npv(y, p)),
184
+ "scikit-learn",
185
+ sk(lambda: skm.precision_score(y, p, pos_label=0)),
186
+ )
187
+ c.add(
188
+ "classification.precision",
189
+ lambda: _f(es.precision(yk, pk.argmax(1), average="weighted")),
190
+ "scikit-learn",
191
+ sk(lambda: skm.precision_score(yk, pk.argmax(1), average="weighted")),
192
+ )
193
+ c.add(
194
+ "classification.recall",
195
+ lambda: _f(es.recall(yk, pk.argmax(1), average="micro")),
196
+ "scikit-learn",
197
+ sk(lambda: skm.recall_score(yk, pk.argmax(1), average="micro")),
198
+ )
199
+ c.add(
200
+ "classification.roc_auc",
201
+ lambda: _f(es.roc_auc(yk, pk, multi_class="ovr")),
202
+ "scikit-learn",
203
+ sk(lambda: skm.roc_auc_score(yk, pk, multi_class="ovr")),
204
+ )
205
+ c.add(
206
+ "classification.specificity",
207
+ lambda: _f(es.specificity(y, p)),
208
+ "scikit-learn",
209
+ sk(lambda: skm.recall_score(y, p, pos_label=0)),
210
+ )
211
+ c.add(
212
+ "classification.top_k_accuracy",
213
+ lambda: _f(es.top_k_accuracy(yk, pk, k=3)),
214
+ "scikit-learn",
215
+ sk(lambda: skm.top_k_accuracy_score(yk, pk, k=3)),
216
+ )
217
+
218
+
219
+ def _regression(c: _Cases, n: int, rng: np.random.Generator) -> None:
220
+ import evalsuite as es
221
+
222
+ yt = rng.gamma(3.0, 3.0, n) + 0.5
223
+ yp = np.abs(yt + rng.normal(0, 1, n)) + 0.1
224
+ e = yt - yp
225
+ skm = _opt("sklearn.metrics")
226
+
227
+ def sk(fn: Callable[[], Any]) -> Optional[Callable[[], Any]]:
228
+ return fn if skm is not None else None
229
+
230
+ def huber_np() -> float:
231
+ a = np.abs(e)
232
+ return float(np.mean(np.where(a <= 1.0, 0.5 * e**2, a - 0.5)))
233
+
234
+ def r2_np() -> float:
235
+ return float(1 - np.sum(e**2) / np.sum((yt - yt.mean()) ** 2))
236
+
237
+ c.add(
238
+ "regression.adjusted_r2",
239
+ lambda: _f(es.adjusted_r2(yt, yp, n_features=5)),
240
+ "NumPy formula",
241
+ lambda: 1 - (1 - r2_np()) * (n - 1) / (n - 5 - 1),
242
+ )
243
+ c.add(
244
+ "regression.explained_variance",
245
+ lambda: _f(es.explained_variance(yt, yp)),
246
+ "scikit-learn",
247
+ sk(lambda: skm.explained_variance_score(yt, yp)),
248
+ )
249
+ c.add("regression.huber_loss", lambda: _f(es.huber_loss(yt, yp)), "NumPy formula", huber_np)
250
+ c.add(
251
+ "regression.mae", lambda: _f(es.mae(yt, yp)), "scikit-learn", sk(lambda: skm.mean_absolute_error(yt, yp))
252
+ )
253
+ c.add(
254
+ "regression.mape",
255
+ lambda: _f(es.mape(yt, yp)),
256
+ "scikit-learn",
257
+ sk(lambda: skm.mean_absolute_percentage_error(yt, yp)),
258
+ )
259
+ c.add(
260
+ "regression.max_error", lambda: _f(es.max_error(yt, yp)), "scikit-learn", sk(lambda: skm.max_error(yt, yp))
261
+ )
262
+ c.add(
263
+ "regression.mean_bias_error",
264
+ lambda: _f(es.mean_bias_error(yt, yp)),
265
+ "NumPy formula",
266
+ lambda: float(np.mean(yp - yt)),
267
+ )
268
+ c.add(
269
+ "regression.median_absolute_error",
270
+ lambda: _f(es.median_absolute_error(yt, yp)),
271
+ "scikit-learn",
272
+ sk(lambda: skm.median_absolute_error(yt, yp)),
273
+ )
274
+ c.add("regression.mse", lambda: _f(es.mse(yt, yp)), "scikit-learn", sk(lambda: skm.mean_squared_error(yt, yp)))
275
+ c.add(
276
+ "regression.msle",
277
+ lambda: _f(es.msle(yt, yp)),
278
+ "scikit-learn",
279
+ sk(lambda: skm.mean_squared_log_error(yt, yp)),
280
+ )
281
+ c.add(
282
+ "regression.quantile_loss",
283
+ lambda: _f(es.quantile_loss(yt, yp, alpha=0.9)),
284
+ "scikit-learn",
285
+ sk(lambda: skm.mean_pinball_loss(yt, yp, alpha=0.9)),
286
+ )
287
+ c.add("regression.r2", lambda: _f(es.r2(yt, yp)), "scikit-learn", sk(lambda: skm.r2_score(yt, yp)))
288
+ c.add(
289
+ "regression.rae",
290
+ lambda: _f(es.rae(yt, yp)),
291
+ "NumPy formula",
292
+ lambda: float(np.sum(np.abs(e)) / np.sum(np.abs(yt - yt.mean()))),
293
+ )
294
+ c.add(
295
+ "regression.rmse",
296
+ lambda: _f(es.rmse(yt, yp)),
297
+ "NumPy formula",
298
+ lambda: float(np.sqrt(np.mean(e**2))),
299
+ )
300
+ c.add(
301
+ "regression.rmsle",
302
+ lambda: _f(es.rmsle(yt, yp)),
303
+ "NumPy formula",
304
+ lambda: float(np.sqrt(np.mean((np.log1p(yt) - np.log1p(yp)) ** 2))),
305
+ )
306
+ c.add(
307
+ "regression.rse",
308
+ lambda: _f(es.rse(yt, yp)),
309
+ "NumPy formula",
310
+ lambda: float(np.sum(e**2) / np.sum((yt - yt.mean()) ** 2)),
311
+ )
312
+ c.add(
313
+ "regression.smape",
314
+ lambda: _f(es.smape(yt, yp)),
315
+ "NumPy formula",
316
+ lambda: float(np.mean(2 * np.abs(e) / (np.abs(yt) + np.abs(yp)))),
317
+ )
318
+
319
+
320
+ # --------------------------------------------------------------------------------------------- clinical
321
+ def _clinical(c: _Cases, n: int, rng: np.random.Generator) -> None:
322
+ import evalsuite as es
323
+
324
+ y = rng.integers(0, 2, n)
325
+ p = np.where(rng.random(n) < 0.8, y, 1 - y)
326
+ x = rng.normal(size=n)
327
+ yc = (rng.random(n) < 1 / (1 + np.exp(-(0.4 + 1.3 * x)))).astype(int)
328
+ risk = 1 / (1 + np.exp(-(0.1 + 2.0 * x)))
329
+ tp = int(np.sum((y == 1) & (p == 1)))
330
+ fn = int(np.sum((y == 1) & (p == 0)))
331
+ fp = int(np.sum((y == 0) & (p == 1)))
332
+ tn = int(np.sum((y == 0) & (p == 0)))
333
+ sens, spec = tp / (tp + fn), tn / (tn + fp)
334
+
335
+ def counts() -> tuple[int, int, int, int]:
336
+ return (
337
+ int(np.sum((y == 1) & (p == 1))),
338
+ int(np.sum((y == 1) & (p == 0))),
339
+ int(np.sum((y == 0) & (p == 1))),
340
+ int(np.sum((y == 0) & (p == 0))),
341
+ )
342
+
343
+ def mce_np() -> float:
344
+ bins = np.minimum((risk * 10).astype(int), 9)
345
+ return float(
346
+ max(abs(risk[bins == b].mean() - yc[bins == b].mean()) for b in range(10) if (bins == b).any())
347
+ )
348
+
349
+ def dor_np() -> float:
350
+ t, f_n, f_p, t_n = counts()
351
+ return (t * t_n) / (f_p * f_n)
352
+
353
+ def youden_np() -> float:
354
+ t, f_n, f_p, t_n = counts()
355
+ return t / (t + f_n) + t_n / (t_n + f_p) - 1
356
+
357
+ skm = _opt("sklearn.metrics")
358
+
359
+ def lrs() -> tuple[float, float]:
360
+ if skm is not None:
361
+ a, b = skm.class_likelihood_ratios(y, p)
362
+ return float(a), float(b)
363
+ t, f_n, f_p, t_n = counts()
364
+ se, sp = t / (t + f_n), t_n / (t_n + f_p)
365
+ return se / (1 - sp), (1 - se) / sp
366
+
367
+ sm = _opt("statsmodels.api")
368
+
369
+ def cal_slope() -> float:
370
+ lp = np.log(risk / (1 - risk))
371
+ return float(sm.GLM(yc, sm.add_constant(lp), family=sm.families.Binomial()).fit().params[1])
372
+
373
+ def cal_int() -> float:
374
+ lp = np.log(risk / (1 - risk))
375
+ return float(sm.GLM(yc, np.ones((n, 1)), family=sm.families.Binomial(), offset=lp).fit().params[0])
376
+
377
+ c.add(
378
+ "calibration.calibration_intercept",
379
+ lambda: _f(es.calibration_intercept(yc, risk)),
380
+ "statsmodels",
381
+ cal_int if sm is not None else None,
382
+ )
383
+ c.add(
384
+ "calibration.calibration_slope",
385
+ lambda: _f(es.calibration_slope(yc, risk)),
386
+ "statsmodels",
387
+ cal_slope if sm is not None else None,
388
+ )
389
+ c.add(
390
+ "calibration.maximum_calibration_error",
391
+ lambda: _f(es.maximum_calibration_error(yc, risk, n_bins=10)),
392
+ "NumPy formula",
393
+ mce_np,
394
+ )
395
+ c.add(
396
+ "clinical.diagnostic_odds_ratio",
397
+ lambda: _f(es.diagnostic_odds_ratio(y, p)),
398
+ "NumPy formula",
399
+ dor_np,
400
+ )
401
+ c.add(
402
+ "clinical.lr_negative",
403
+ lambda: _f(es.lr_negative(y, p)),
404
+ "scikit-learn" if skm else "NumPy formula",
405
+ lambda: lrs()[1],
406
+ )
407
+ c.add(
408
+ "clinical.lr_positive",
409
+ lambda: _f(es.lr_positive(y, p)),
410
+ "scikit-learn" if skm else "NumPy formula",
411
+ lambda: lrs()[0],
412
+ )
413
+ c.add(
414
+ "clinical.net_benefit",
415
+ lambda: _f(es.net_benefit(yc, risk, threshold=0.2)),
416
+ "NumPy formula",
417
+ lambda: float(np.sum((risk >= 0.2) & (yc == 1)) / n - np.sum((risk >= 0.2) & (yc == 0)) / n * 0.25),
418
+ )
419
+ c.add(
420
+ "clinical.ppv",
421
+ lambda: _f(es.ppv(y, p)),
422
+ "scikit-learn",
423
+ (lambda: skm.precision_score(y, p)) if skm is not None else None,
424
+ )
425
+ c.add(
426
+ "clinical.sensitivity",
427
+ lambda: _f(es.sensitivity(y, p)),
428
+ "scikit-learn",
429
+ (lambda: skm.recall_score(y, p)) if skm is not None else None,
430
+ )
431
+ c.add(
432
+ "clinical.youden_j",
433
+ lambda: _f(es.youden_j(y, p)),
434
+ "NumPy formula",
435
+ youden_np,
436
+ )
437
+ del sens, spec, tp, fn, fp, tn
438
+
439
+
440
+ # --------------------------------------------------------------------------------------------- statistics
441
+ def _statistics(c: _Cases, n: int, rng: np.random.Generator) -> None:
442
+ from scipy import stats
443
+
444
+ import evalsuite as es
445
+
446
+ a, b = rng.normal(0, 1, n), rng.normal(0.05, 1.2, n)
447
+ d = a + rng.normal(0.02, 0.5, n)
448
+ g3 = [rng.normal(mu, 1, n) for mu in (0.0, 0.05, 0.1)]
449
+ y = rng.integers(0, 2, n)
450
+ pa = np.where(rng.random(n) < 0.8, y, 1 - y)
451
+ pb = np.where(rng.random(n) < 0.78, y, 1 - y)
452
+ prob_a = 1 / (1 + np.exp(-(2.0 * (y - 0.5) + rng.normal(0, 1, n))))
453
+ prob_b = 1 / (1 + np.exp(-(1.6 * (y - 0.5) + rng.normal(0, 1, n))))
454
+ ga, gb = rng.integers(0, 5, n), rng.integers(0, 5, n)
455
+ table = np.zeros((5, 5), dtype=np.int64)
456
+ np.add.at(table, (ga, gb), 1)
457
+ t2 = np.array([[int(np.sum((y == 1) & (pa == 1))), int(np.sum((y == 1) & (pa == 0)))],
458
+ [int(np.sum((y == 0) & (pa == 1))), int(np.sum((y == 0) & (pa == 0)))]]) # fmt: skip
459
+ pvals = rng.random(n) ** 2
460
+ xs = rng.normal(size=min(n, 5000))
461
+ k = int(np.sum(y == pa))
462
+ n_boot = 200
463
+
464
+ sm_prop = _opt("statsmodels.stats.proportion")
465
+ sm_mt = _opt("statsmodels.stats.multitest")
466
+ sm_ct = _opt("statsmodels.stats.contingency_tables")
467
+
468
+ def cohens_d_np() -> float:
469
+ sp = math.sqrt(((n - 1) * a.var(ddof=1) + (n - 1) * b.var(ddof=1)) / (2 * n - 2))
470
+ return float((a.mean() - b.mean()) / sp)
471
+
472
+ c.add(
473
+ "statistics.accuracy_ci",
474
+ lambda: _numeric(es.accuracy_ci(y, pa)),
475
+ "statsmodels",
476
+ (lambda: list(sm_prop.proportion_confint(k, n, method="wilson"))) if sm_prop is not None else None,
477
+ )
478
+ c.add(
479
+ "statistics.adjust_pvalues",
480
+ lambda: es.adjust_pvalues(pvals, method="bh").tolist(),
481
+ "statsmodels",
482
+ (lambda: sm_mt.multipletests(pvals, method="fdr_bh")[1].tolist()) if sm_mt is not None else None,
483
+ )
484
+ c.add(
485
+ "statistics.bootstrap_ci",
486
+ lambda: _numeric(es.bootstrap_ci(es.accuracy, y, pa, n_resamples=n_boot, random_state=0)),
487
+ )
488
+ c.add(
489
+ "statistics.chi_square_test",
490
+ lambda: _numeric(es.chi_square_test(table)),
491
+ "SciPy",
492
+ lambda: list(stats.chi2_contingency(table)[:2]),
493
+ )
494
+ c.add(
495
+ "statistics.cliffs_delta",
496
+ lambda: _f(es.cliffs_delta(a, b)),
497
+ "SciPy",
498
+ lambda: 2 * stats.mannwhitneyu(a, b).statistic / (n * n) - 1,
499
+ )
500
+ c.add("statistics.cohens_d", lambda: _f(es.cohens_d(a, b)), "NumPy formula", cohens_d_np)
501
+ c.add(
502
+ "statistics.compare",
503
+ lambda: es.compare(y, {"a": pa, "b": pb}, metrics=["accuracy"], n_resamples=n_boot, random_state=0),
504
+ )
505
+ c.add(
506
+ "statistics.cramers_v",
507
+ lambda: _f(es.cramers_v(table)),
508
+ "SciPy",
509
+ lambda: stats.contingency.association(table, method="cramer"),
510
+ )
511
+ c.add(
512
+ "statistics.delong_test",
513
+ lambda: [float(es.delong_test(y, prob_a, prob_b).p_value)],
514
+ )
515
+ c.add(
516
+ "statistics.fisher_exact_test",
517
+ lambda: [float(es.fisher_exact_test(t2).p_value)],
518
+ "SciPy",
519
+ lambda: [stats.fisher_exact(t2).pvalue],
520
+ )
521
+ c.add(
522
+ "statistics.friedman_test",
523
+ lambda: _numeric(es.friedman_test(*g3)),
524
+ "SciPy",
525
+ lambda: list(stats.friedmanchisquare(*g3)),
526
+ )
527
+ c.add(
528
+ "statistics.hedges_g",
529
+ lambda: _f(es.hedges_g(a, b)),
530
+ "NumPy formula",
531
+ lambda: cohens_d_np() * (1 - 3 / (4 * (2 * n) - 9)),
532
+ )
533
+ c.add(
534
+ "statistics.kruskal_wallis_test",
535
+ lambda: _numeric(es.kruskal_wallis_test(*g3)),
536
+ "SciPy",
537
+ lambda: list(stats.kruskal(*g3)),
538
+ )
539
+ c.add(
540
+ "statistics.mann_whitney_test",
541
+ lambda: _numeric(es.mann_whitney_test(a, b)),
542
+ "SciPy",
543
+ lambda: list(stats.mannwhitneyu(a, b)),
544
+ )
545
+
546
+ def mcnemar_ref() -> list[float]:
547
+ b01 = int(np.sum((pa == y) & (pb != y)))
548
+ b10 = int(np.sum((pa != y) & (pb == y)))
549
+ r = sm_ct.mcnemar(np.array([[0, b01], [b10, 0]]), exact=False, correction=True)
550
+ return [float(r.statistic), float(r.pvalue)]
551
+
552
+ c.add(
553
+ "statistics.mcnemar_test",
554
+ lambda: _numeric(es.mcnemar_test(y, pa, pb, exact=False)),
555
+ "statsmodels",
556
+ mcnemar_ref if sm_ct is not None else None,
557
+ )
558
+ c.add(
559
+ "statistics.paired_bootstrap_test",
560
+ lambda: [
561
+ float(es.paired_bootstrap_test(es.accuracy, y, pa, pb, n_resamples=n_boot, random_state=0).p_value)
562
+ ],
563
+ )
564
+ c.add(
565
+ "statistics.paired_t_test",
566
+ lambda: _numeric(es.paired_t_test(a, d)),
567
+ "SciPy",
568
+ lambda: list(stats.ttest_rel(a, d)),
569
+ )
570
+ c.add(
571
+ "statistics.proportion_ci",
572
+ lambda: _numeric(es.proportion_ci(k, n, method="clopper-pearson")),
573
+ "statsmodels",
574
+ (lambda: list(sm_prop.proportion_confint(k, n, method="beta"))) if sm_prop is not None else None,
575
+ )
576
+ c.add("statistics.roc_auc_ci", lambda: _numeric(es.roc_auc_ci(y, prob_a)))
577
+ c.add(
578
+ "statistics.shapiro_wilk_test",
579
+ lambda: _numeric(es.shapiro_wilk_test(xs)),
580
+ "SciPy",
581
+ lambda: list(stats.shapiro(xs)),
582
+ )
583
+ c.add(
584
+ "statistics.t_test",
585
+ lambda: _numeric(es.t_test(a, b)),
586
+ "SciPy",
587
+ lambda: list(stats.ttest_ind(a, b, equal_var=False)),
588
+ )
589
+ c.add(
590
+ "statistics.wilcoxon_test",
591
+ lambda: _numeric(es.wilcoxon_test(a, d)),
592
+ "SciPy",
593
+ lambda: list(stats.wilcoxon(a, d)),
594
+ )
595
+
596
+
597
+ # --------------------------------------------------------------------------------------------- vision
598
+ def _vision(c: _Cases, n: int, rng: np.random.Generator) -> None:
599
+ import evalsuite as es
600
+
601
+ side = 64
602
+ n_img = max(1, n // (side * side))
603
+ k = 5
604
+ yy, xx = np.ogrid[:side, :side]
605
+ true = np.zeros((n_img, side, side), dtype=np.int64)
606
+ for i in range(n_img):
607
+ for cl in range(1, k):
608
+ cy, cx, r = rng.integers(8, side - 8), rng.integers(8, side - 8), rng.integers(4, 14)
609
+ true[i][(yy - cy) ** 2 + (xx - cx) ** 2 <= r * r] = cl
610
+ pred = np.roll(true, 1, axis=2)
611
+ noise = rng.random(pred.shape) < 0.02
612
+ pred[noise] = rng.integers(0, k, int(noise.sum()))
613
+ labels = list(range(k))
614
+ n_sd = min(n_img, 50)
615
+ bt, bp = true[:n_sd] == k - 1, pred[:n_sd] == k - 1
616
+ ft, fp_ = true.ravel(), pred.ravel()
617
+
618
+ skm = _opt("sklearn.metrics")
619
+ from scipy import ndimage
620
+ from scipy.spatial.distance import directed_hausdorff
621
+
622
+ def surface(m: Any) -> Any:
623
+ er = ndimage.binary_erosion(m, structure=ndimage.generate_binary_structure(2, 1), border_value=0)
624
+ return np.argwhere(m & ~er).astype(float)
625
+
626
+ def hd_ref() -> list[float]:
627
+ out = []
628
+ for i in range(n_sd):
629
+ s1, s2 = surface(bt[i]), surface(bp[i])
630
+ out.append(max(directed_hausdorff(s1, s2)[0], directed_hausdorff(s2, s1)[0]))
631
+ return out
632
+
633
+ def asd_ref() -> list[float]:
634
+ from scipy.spatial import cKDTree
635
+
636
+ out = []
637
+ for i in range(n_sd):
638
+ s1, s2 = surface(bt[i]), surface(bp[i])
639
+ d1, _ = cKDTree(s2).query(s1)
640
+ d2, _ = cKDTree(s1).query(s2)
641
+ out.append(float((d1.sum() + d2.sum()) / (len(d1) + len(d2))))
642
+ return out
643
+
644
+ def miou_ref() -> float:
645
+ return float(np.mean(skm.jaccard_score(ft, fp_, labels=labels, average=None)))
646
+
647
+ def mpa_ref() -> float:
648
+ return float(np.mean(skm.recall_score(ft, fp_, labels=labels, average=None)))
649
+
650
+ c.add(
651
+ "segmentation.average_surface_distance",
652
+ lambda: [
653
+ float(es.average_surface_distance(bt[i].astype(int), bp[i].astype(int), labels=[1]))
654
+ for i in range(n_sd)
655
+ ],
656
+ "SciPy",
657
+ asd_ref,
658
+ )
659
+ c.add("segmentation.boundary_iou", lambda: _f(es.boundary_iou(true, pred, labels=[k - 1])))
660
+ c.add(
661
+ "segmentation.dice",
662
+ lambda: _numeric(es.dice(true, pred, average=None)),
663
+ "scikit-learn",
664
+ (lambda: list(skm.f1_score(ft, fp_, labels=labels, average=None))) if skm is not None else None,
665
+ )
666
+ c.add(
667
+ "segmentation.hausdorff_distance",
668
+ lambda: [
669
+ float(es.hausdorff_distance(bt[i].astype(int), bp[i].astype(int), labels=[1])) for i in range(n_sd)
670
+ ],
671
+ "SciPy",
672
+ hd_ref,
673
+ )
674
+ c.add(
675
+ "segmentation.iou",
676
+ lambda: _numeric(es.iou(true, pred, average=None)),
677
+ "scikit-learn",
678
+ (lambda: list(skm.jaccard_score(ft, fp_, labels=labels, average=None))) if skm is not None else None,
679
+ )
680
+ c.add(
681
+ "segmentation.mean_pixel_accuracy",
682
+ lambda: _f(es.mean_pixel_accuracy(true, pred, num_classes=k)),
683
+ "scikit-learn",
684
+ mpa_ref if skm is not None else None,
685
+ )
686
+ c.add(
687
+ "segmentation.miou",
688
+ lambda: _f(es.miou(true, pred, num_classes=k)),
689
+ "scikit-learn",
690
+ miou_ref if skm is not None else None,
691
+ )
692
+ c.add(
693
+ "segmentation.pixel_accuracy",
694
+ lambda: _f(es.pixel_accuracy(true, pred)),
695
+ "scikit-learn",
696
+ (lambda: skm.accuracy_score(ft, fp_)) if skm is not None else None,
697
+ )
698
+
699
+ # detection
700
+ n_det = max(10, n // 1000)
701
+ y_true, y_pred = [], []
702
+ for _ in range(n_det):
703
+ m = int(rng.integers(1, 8))
704
+ xy = rng.uniform(0, 500, (m, 2))
705
+ wh = rng.uniform(8, 160, (m, 2))
706
+ boxes = np.column_stack([xy, xy + wh])
707
+ lab = rng.integers(1, 6, m)
708
+ y_true.append({"boxes": boxes, "labels": lab})
709
+ jitter = boxes + rng.normal(0, 4, boxes.shape)
710
+ jitter[:, 2:] = np.maximum(jitter[:, 2:], jitter[:, :2] + 1)
711
+ extra = rng.uniform(0, 500, (3, 2))
712
+ fpb = np.column_stack([extra, extra + rng.uniform(10, 80, (3, 2))])
713
+ y_pred.append(
714
+ {
715
+ "boxes": np.vstack([jitter, fpb]),
716
+ "labels": np.r_[lab, rng.integers(1, 6, 3)],
717
+ "scores": rng.random(m + 3),
718
+ }
719
+ )
720
+ ba = np.vstack([t["boxes"] for t in y_true])
721
+ bb = np.vstack([p["boxes"] for p in y_pred])[: max(1, min(len(ba), 2000))]
722
+ ba = ba[:2000]
723
+
724
+ def iou_np() -> Any:
725
+ x1 = np.maximum(ba[:, None, 0], bb[None, :, 0])
726
+ y1 = np.maximum(ba[:, None, 1], bb[None, :, 1])
727
+ x2 = np.minimum(ba[:, None, 2], bb[None, :, 2])
728
+ y2 = np.minimum(ba[:, None, 3], bb[None, :, 3])
729
+ inter = np.clip(x2 - x1, 0, None) * np.clip(y2 - y1, 0, None)
730
+ aa = (ba[:, 2] - ba[:, 0]) * (ba[:, 3] - ba[:, 1])
731
+ ab = (bb[:, 2] - bb[:, 0]) * (bb[:, 3] - bb[:, 1])
732
+ return (inter / (aa[:, None] + ab[None, :] - inter)).ravel().tolist()
733
+
734
+ coco: Optional[Callable[[], Any]] = None
735
+ with contextlib.suppress(ImportError):
736
+ import io
737
+
738
+ from pycocotools.coco import COCO
739
+ from pycocotools.cocoeval import COCOeval
740
+
741
+ def coco_eval() -> Any:
742
+ images, anns, dets, aid = [], [], [], 1
743
+ for i, (t, p) in enumerate(zip(y_true, y_pred)):
744
+ images.append({"id": i + 1})
745
+ for bx, cl in zip(t["boxes"], t["labels"]):
746
+ w, h = bx[2] - bx[0], bx[3] - bx[1]
747
+ anns.append(
748
+ {
749
+ "id": aid,
750
+ "image_id": i + 1,
751
+ "category_id": int(cl),
752
+ "bbox": [bx[0], bx[1], w, h],
753
+ "area": w * h,
754
+ "iscrowd": 0,
755
+ }
756
+ )
757
+ aid += 1
758
+ for bx, cl, sc in zip(p["boxes"], p["labels"], p["scores"]):
759
+ dets.append(
760
+ {
761
+ "image_id": i + 1,
762
+ "category_id": int(cl),
763
+ "bbox": [bx[0], bx[1], bx[2] - bx[0], bx[3] - bx[1]],
764
+ "score": float(sc),
765
+ }
766
+ )
767
+ with contextlib.redirect_stdout(io.StringIO()):
768
+ gt = COCO()
769
+ gt.dataset = {
770
+ "images": images,
771
+ "annotations": anns,
772
+ "categories": [{"id": cl} for cl in range(1, 6)],
773
+ }
774
+ gt.createIndex()
775
+ ev = COCOeval(gt, gt.loadRes(dets), "bbox")
776
+ ev.evaluate()
777
+ ev.accumulate()
778
+ ev.summarize()
779
+ return ev
780
+
781
+ coco = coco_eval
782
+
783
+ c.add(
784
+ "detection.average_precision_detection",
785
+ lambda: _f(es.average_precision_detection(y_true, y_pred, iou_threshold=0.5, average="macro")),
786
+ "pycocotools",
787
+ (lambda: float(coco().stats[1])) if coco is not None else None,
788
+ )
789
+ c.add("detection.box_iou", lambda: es.box_iou(ba, bb).ravel().tolist(), "NumPy formula", iou_np)
790
+ c.add(
791
+ "detection.mean_average_precision",
792
+ lambda: _f(es.mean_average_precision(y_true, y_pred)),
793
+ "pycocotools",
794
+ (lambda: float(coco().stats[0])) if coco is not None else None,
795
+ )
796
+
797
+
798
+ # --------------------------------------------------------------------------------------------- LLM
799
+ def _sentence(rng: np.random.Generator, lo: int = 5, hi: int = 25) -> list[str]:
800
+ return list(rng.choice(_VOCAB, int(rng.integers(lo, hi))))
801
+
802
+
803
+ def _llm(c: _Cases, n: int, rng: np.random.Generator) -> None:
804
+ import evalsuite as es
805
+
806
+ m = max(20, n // 100)
807
+ me = max(10, n // 1000)
808
+ refs, preds, multi = [], [], []
809
+ for _ in range(m):
810
+ base = _sentence(rng)
811
+ keep = [w for w in base if rng.random() > 0.2] + list(rng.choice(_VOCAB, int(rng.integers(0, 4))))
812
+ preds.append(" ".join(keep))
813
+ refs.append(" ".join(base))
814
+ multi.append([" ".join(base), " ".join(_sentence(rng, 3, 12)), " ".join(_sentence(rng, 3, 12))])
815
+ lsum_refs = [r.replace(" mat ", " mat\n") for r in refs]
816
+ lsum_preds = [p.replace(" mat ", " mat\n") for p in preds]
817
+
818
+ # ---- text generation (sacreBLEU, rouge-score, NLTK, pycocoevalcap)
819
+ sacre = _opt("sacrebleu")
820
+
821
+ c.add(
822
+ "text.bleu",
823
+ lambda: _f(es.bleu(refs, preds)),
824
+ "sacreBLEU",
825
+ (lambda: sacre.corpus_bleu(preds, [refs], force=True).score) if sacre else None,
826
+ )
827
+ c.add(
828
+ "text.chrf",
829
+ lambda: _f(es.chrf(refs, preds)),
830
+ "sacreBLEU",
831
+ (lambda: sacre.corpus_chrf(preds, [refs]).score) if sacre else None,
832
+ )
833
+ c.add(
834
+ "text.sentence_bleu",
835
+ lambda: _numeric(es.sentence_bleu(refs, preds, average=None)),
836
+ "sacreBLEU",
837
+ (lambda: [sacre.sentence_bleu(p, [r]).score for r, p in zip(refs, preds)]) if sacre else None,
838
+ )
839
+ c.add(
840
+ "text.ter",
841
+ lambda: _f(es.ter(refs, preds)),
842
+ "sacreBLEU",
843
+ (lambda: sacre.corpus_ter(preds, [refs]).score) if sacre else None,
844
+ )
845
+ ms = refs[: max(20, m // 10)]
846
+ msp = preds[: len(ms)]
847
+
848
+ def self_bleu_ref() -> float:
849
+ out = []
850
+ for i, p in enumerate(msp):
851
+ others = [msp[j] for j in range(len(msp)) if j != i]
852
+ out.append(sacre.sentence_bleu(p, others).score)
853
+ return float(np.mean(out))
854
+
855
+ c.add("text.self_bleu", lambda: _f(es.self_bleu(msp)), "sacreBLEU", self_bleu_ref if sacre else None)
856
+ del ms
857
+
858
+ rs = _opt("rouge_score.rouge_scorer")
859
+
860
+ def rouge_ref(key: str, r_: list[str], p_: list[str]) -> Callable[[], float]:
861
+ def run() -> float:
862
+ sc = rs.RougeScorer([key])
863
+ return float(np.mean([sc.score(a, b)[key].fmeasure for a, b in zip(r_, p_)]))
864
+
865
+ return run
866
+
867
+ for name, key in (("rouge_1", "rouge1"), ("rouge_2", "rouge2"), ("rouge_l", "rougeL")):
868
+ fn = getattr(es, name)
869
+ c.add(
870
+ f"text.{name}",
871
+ lambda fn=fn: _f(fn(refs, preds)), # type: ignore[misc]
872
+ "rouge-score",
873
+ rouge_ref(key, refs, preds) if rs else None,
874
+ )
875
+ c.add(
876
+ "text.rouge_lsum",
877
+ lambda: _f(es.rouge_lsum(lsum_refs, lsum_preds)),
878
+ "rouge-score",
879
+ rouge_ref("rougeLsum", lsum_refs, lsum_preds) if rs else None,
880
+ )
881
+
882
+ meteor_ref: Optional[Callable[[], float]] = None
883
+ with contextlib.suppress(ImportError):
884
+ from nltk.stem.porter import PorterStemmer
885
+ from nltk.translate.meteor_score import meteor_score
886
+
887
+ class _NoWordNet:
888
+ def synsets(self, _w: str) -> list[Any]:
889
+ return []
890
+
891
+ def _meteor() -> float:
892
+ st, wn = PorterStemmer(), _NoWordNet()
893
+ return float(
894
+ np.mean(
895
+ [meteor_score([r.split()], p.split(), stemmer=st, wordnet=wn) for r, p in zip(refs, preds)]
896
+ )
897
+ )
898
+
899
+ meteor_ref = _meteor
900
+ c.add("text.meteor", lambda: _f(es.meteor(refs, preds)), "NLTK", meteor_ref)
901
+
902
+ cider_ref: Optional[Callable[[], float]] = None
903
+ with contextlib.suppress(ImportError):
904
+ from pycocoevalcap.cider.cider import Cider # type: ignore[import-untyped]
905
+
906
+ def _cider() -> float:
907
+ res = {i: [p] for i, p in enumerate(preds)}
908
+ return float(Cider().compute_score(dict(enumerate(multi)), res)[0])
909
+
910
+ cider_ref = _cider
911
+ c.add("text.cider", lambda: _f(es.cider(multi, preds)), "pycocoevalcap", cider_ref)
912
+
913
+ def distinct_ref() -> float:
914
+ grams = [tuple(t[i : i + 2]) for p in preds for t in [p.split()] for i in range(len(t) - 1)]
915
+ return len(set(grams)) / len(grams)
916
+
917
+ c.add("text.distinct_n", lambda: _f(es.distinct_n(preds, n=2)), "Python stdlib", distinct_ref)
918
+
919
+ lps = [np.log(rng.uniform(0.05, 1.0, int(rng.integers(5, 40)))) for _ in range(m)]
920
+ c.add(
921
+ "text.perplexity",
922
+ lambda: _f(es.perplexity(lps)),
923
+ "NumPy formula",
924
+ lambda: float(np.exp(-np.concatenate(lps).mean())),
925
+ )
926
+ c.add(
927
+ "text.cross_entropy",
928
+ lambda: _f(es.cross_entropy(lps, base=2)),
929
+ "NumPy formula",
930
+ lambda: float(-np.concatenate(lps).mean() / np.log(2)),
931
+ )
932
+
933
+ # ---- semantic
934
+ dim = 32
935
+ re_ = rng.normal(size=(m, dim))
936
+ pe = re_ + rng.normal(0, 0.5, (m, dim))
937
+ tok_r = [rng.normal(size=(int(rng.integers(5, 20)), dim)) for _ in range(me)]
938
+ tok_p = [rng.normal(size=(int(rng.integers(5, 20)), dim)) for _ in range(me)]
939
+
940
+ def unit(x: Any) -> Any:
941
+ return x / np.linalg.norm(x, axis=-1, keepdims=True)
942
+
943
+ def bert_np() -> float:
944
+ out = []
945
+ for r_, p_ in zip(tok_r, tok_p):
946
+ s = unit(r_) @ unit(p_).T
947
+ rec, prec = s.max(1).mean(), s.max(0).mean()
948
+ out.append(2 * prec * rec / (prec + rec))
949
+ return float(np.mean(out))
950
+
951
+ c.add("text.bertscore", lambda: _f(es.bertscore(tok_r, tok_p)), "NumPy formula", bert_np)
952
+ c.add(
953
+ "text.embedding_similarity",
954
+ lambda: _f(es.embedding_similarity(re_, pe)),
955
+ "NumPy formula",
956
+ lambda: float(np.mean(np.sum(unit(re_) * unit(pe), 1))),
957
+ )
958
+ ot = _opt("ot")
959
+
960
+ def mover_ref() -> float:
961
+ out = []
962
+ for x, y_ in zip(tok_r, tok_p):
963
+ a_, b_ = np.full(len(x), 1 / len(x)), np.full(len(y_), 1 / len(y_))
964
+ out.append(1 - ot.emd2(a_, b_, ot.dist(unit(x), unit(y_), metric="euclidean")))
965
+ return float(np.mean(out))
966
+
967
+ c.add("text.moverscore", lambda: _f(es.moverscore(tok_r, tok_p)), "POT", mover_ref if ot else None)
968
+ hf = rng.normal(size=(me * 5, 16))
969
+ gf = rng.normal(0.3, 1, size=(me * 5, 16))
970
+ c.add("text.mauve", lambda: _f(es.mauve(hf, gf, random_state=0)))
971
+ scores = rng.random(m)
972
+ c.add(
973
+ "text.model_score",
974
+ lambda: _f(es.model_score(refs, preds, scorer=lambda r, p: scores[: len(p)].tolist(), batch_size=10**9)),
975
+ )
976
+
977
+ # ---- QA and factuality
978
+ def squad_norm(s: str) -> list[str]:
979
+ s = s.lower()
980
+ s = "".join(ch for ch in s if ch not in set("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"))
981
+ s = re.sub(r"\b(a|an|the)\b", " ", s)
982
+ return s.split()
983
+
984
+ def em_ref() -> float:
985
+ return float(np.mean([squad_norm(r) == squad_norm(p) for r, p in zip(refs, preds)]))
986
+
987
+ def f1_ref() -> float:
988
+ out = []
989
+ for r, p in zip(refs, preds):
990
+ rt, pt = squad_norm(r), squad_norm(p)
991
+ common = sum((Counter(rt) & Counter(pt)).values())
992
+ if not rt or not pt:
993
+ out.append(float(rt == pt))
994
+ elif common == 0:
995
+ out.append(0.0)
996
+ else:
997
+ pr, rc = common / len(pt), common / len(rt)
998
+ out.append(2 * pr * rc / (pr + rc))
999
+ return float(np.mean(out))
1000
+
1001
+ c.add("qa.exact_match", lambda: _f(es.exact_match(refs, preds)), "SQuAD formula", em_ref)
1002
+ c.add("qa.token_f1", lambda: _f(es.token_f1(refs, preds)), "SQuAD formula", f1_ref)
1003
+
1004
+ verdict_vals = np.array(["supported", "contradicted", "unsupported"])
1005
+ claims = [list(rng.choice(verdict_vals, int(rng.integers(1, 8)), p=[0.7, 0.1, 0.2])) for _ in range(m)]
1006
+ c.add(
1007
+ "text.faithfulness",
1008
+ lambda: _f(es.faithfulness(claims)),
1009
+ "NumPy formula",
1010
+ lambda: float(np.mean([np.mean(np.asarray(v) == "supported") for v in claims])),
1011
+ )
1012
+ c.add(
1013
+ "text.hallucination_rate",
1014
+ lambda: _f(es.hallucination_rate(claims)),
1015
+ "NumPy formula",
1016
+ lambda: float(np.mean(np.concatenate([np.asarray(v) for v in claims]) != "supported")),
1017
+ )
1018
+ support = [rng.random(int(rng.integers(1, 6))) for _ in range(m)]
1019
+ c.add(
1020
+ "text.groundedness",
1021
+ lambda: _f(es.groundedness(support)),
1022
+ "NumPy formula",
1023
+ lambda: float(np.mean([s.mean() for s in support])),
1024
+ )
1025
+ cites = [
1026
+ [
1027
+ {
1028
+ "supported": bool(rng.random() < 0.7),
1029
+ "citations": [bool(t) for t in rng.random(int(rng.integers(0, 4))) < 0.6],
1030
+ }
1031
+ for _ in range(int(rng.integers(1, 5)))
1032
+ ]
1033
+ for _ in range(m)
1034
+ ]
1035
+ c.add("text.citation_precision", lambda: _f(es.citation_precision(cites)))
1036
+ c.add("text.citation_recall", lambda: _f(es.citation_recall(cites)))
1037
+ gold = rng.choice(verdict_vals, m)
1038
+ predv = np.where(rng.random(m) < 0.8, gold, rng.choice(verdict_vals, m))
1039
+ c.add(
1040
+ "text.claim_verification_accuracy",
1041
+ lambda: _f(es.claim_verification_accuracy(gold, predv)),
1042
+ "NumPy formula",
1043
+ lambda: float(np.mean(gold == predv)),
1044
+ )
1045
+ kb = {f"f{i}" for i in range(0, 500, 2)}
1046
+ facts = [{f"f{int(t)}" for t in rng.integers(0, 500, int(rng.integers(1, 6)))} for _ in range(m)]
1047
+ c.add(
1048
+ "text.knowledge_consistency",
1049
+ lambda: _f(es.knowledge_consistency(facts, kb)),
1050
+ "Python stdlib",
1051
+ lambda: sum(len(f & kb) for f in facts) / sum(len(f) for f in facts),
1052
+ )
1053
+ tp, fp, fn = rng.integers(0, 6, m), rng.integers(0, 3, m), rng.integers(0, 3, m)
1054
+ tp[tp + fp + fn == 0] = 1
1055
+
1056
+ def ac_ref() -> float:
1057
+ return float(np.mean(tp / (tp + 0.5 * (fp + fn))))
1058
+
1059
+ c.add("text.answer_correctness", lambda: _f(es.answer_correctness(tp, fp, fn)), "NumPy formula", ac_ref)
1060
+ qe = rng.normal(size=(m, dim))
1061
+ ge = [qe[i] + rng.normal(0, 0.7, (3, dim)) for i in range(m)]
1062
+ c.add(
1063
+ "text.answer_relevance",
1064
+ lambda: _f(es.answer_relevance(qe, ge)),
1065
+ "NumPy formula",
1066
+ lambda: float(np.mean([np.mean(unit(g) @ unit(q)) for q, g in zip(qe, ge)])),
1067
+ )
1068
+ sa = rng.random(m) < 0.2
1069
+ ab = np.where(rng.random(m) < 0.85, sa, ~sa)
1070
+ c.add(
1071
+ "text.abstention_accuracy",
1072
+ lambda: _f(es.abstention_accuracy(sa, ab)),
1073
+ "NumPy formula",
1074
+ lambda: float(np.mean(sa == ab)),
1075
+ )
1076
+
1077
+ # ---- judges and preferences
1078
+ out_vals = np.array(["win", "tie", "loss"])
1079
+ outcomes = rng.choice(out_vals, m, p=[0.45, 0.15, 0.4])
1080
+ c.add(
1081
+ "text.win_rate",
1082
+ lambda: _f(es.win_rate(outcomes)),
1083
+ "NumPy formula",
1084
+ lambda: float(np.mean((outcomes == "win") + 0.5 * (outcomes == "tie"))),
1085
+ )
1086
+ strength = rng.normal(size=8)
1087
+ comps, pairs = [], []
1088
+ for _ in range(max(m, 400)): # enough games that every model wins and loses (the MLE exists)
1089
+ i, j = rng.choice(8, 2, replace=False)
1090
+ win = rng.random() < 1 / (1 + np.exp(strength[j] - strength[i]))
1091
+ w, lo = (i, j) if win else (j, i)
1092
+ comps.append((f"m{w}", f"m{lo}", "win"))
1093
+ pairs.append((int(w), int(lo)))
1094
+
1095
+ choix = _opt("choix")
1096
+
1097
+ def bt_es() -> list[float]:
1098
+ r = es.bradley_terry(comps)
1099
+ order = np.argsort([int(s[1:]) for s in (r.labels or ())])
1100
+ return [float(v) for v in np.asarray(r.value, float)[order]]
1101
+
1102
+ def bt_ref() -> list[float]:
1103
+ v = choix.mm_pairwise(8, pairs, max_iter=100_000, tol=1e-12)
1104
+ return [float(t) for t in v - v.mean()]
1105
+
1106
+ c.add("text.bradley_terry", bt_es, "choix", bt_ref if choix else None)
1107
+
1108
+ def elo_ref() -> list[float]:
1109
+ rating = {f"m{i}": 1000.0 for i in range(8)}
1110
+ for a_, b_, _ in comps:
1111
+ ea = 1 / (1 + 10 ** ((rating[b_] - rating[a_]) / 400))
1112
+ rating[a_] += 4 * (1 - ea)
1113
+ rating[b_] -= 4 * (1 - ea)
1114
+ return [rating[f"m{i}"] for i in range(8)]
1115
+
1116
+ def elo_es() -> list[float]:
1117
+ r = es.elo_ratings(comps)
1118
+ d = dict(zip(r.labels or (), np.asarray(r.value, float)))
1119
+ return [d[f"m{i}"] for i in range(8)]
1120
+
1121
+ c.add("text.elo_ratings", elo_es, "Python stdlib", elo_ref)
1122
+
1123
+ ratings = rng.integers(1, 6, (4, m)).astype(float)
1124
+ ratings[rng.random(ratings.shape) < 0.1] = np.nan
1125
+ kd = _opt("krippendorff")
1126
+ c.add(
1127
+ "text.krippendorff_alpha",
1128
+ lambda: _f(es.krippendorff_alpha(ratings, level="ordinal")),
1129
+ "krippendorff",
1130
+ (lambda: float(kd.alpha(reliability_data=ratings, level_of_measurement="ordinal"))) if kd else None,
1131
+ )
1132
+ item_r = rng.integers(0, 3, (m, 4))
1133
+ ir = _opt("statsmodels.stats.inter_rater")
1134
+ c.add(
1135
+ "text.fleiss_kappa",
1136
+ lambda: _f(es.fleiss_kappa(item_r)),
1137
+ "statsmodels",
1138
+ (lambda: float(ir.fleiss_kappa(ir.aggregate_raters(item_r)[0]))) if ir else None,
1139
+ )
1140
+ js = rng.integers(1, 6, m)
1141
+ hs = np.clip(js + rng.integers(-1, 2, m), 1, 5)
1142
+ skm = _opt("sklearn.metrics")
1143
+ c.add(
1144
+ "text.judge_agreement",
1145
+ lambda: _f(es.judge_agreement(js, hs)),
1146
+ "scikit-learn",
1147
+ (lambda: skm.cohen_kappa_score(js, hs)) if skm is not None else None,
1148
+ )
1149
+ vo = rng.choice(np.array(["A", "B"]), m)
1150
+ vs = np.where(rng.random(m) < 0.8, vo, rng.choice(np.array(["A", "B"]), m))
1151
+ c.add(
1152
+ "text.position_consistency",
1153
+ lambda: _f(es.position_consistency(vo, vs)),
1154
+ "NumPy formula",
1155
+ lambda: float(np.mean(vo == vs)),
1156
+ )
1157
+ la, lb = rng.integers(20, 400, m), rng.integers(20, 400, m)
1158
+ c.add("text.verbosity_bias", lambda: _f(es.verbosity_bias(vo, la, lb)))
1159
+ jp, hp = rng.random(m) < 0.6, rng.random(m) < 0.5
1160
+ c.add("text.self_preference_bias", lambda: _f(es.self_preference_bias(jp, hp)))
1161
+ rub = rng.integers(1, 6, (m, 3))
1162
+ c.add(
1163
+ "text.rubric_score",
1164
+ lambda: _f(es.rubric_score(rub)),
1165
+ "NumPy formula",
1166
+ lambda: float(np.mean((rub - 1) / 4)),
1167
+ )
1168
+
1169
+ # ---- reasoning
1170
+ ns = np.full(m, 20)
1171
+ nc = rng.integers(0, 21, m)
1172
+
1173
+ def pass_ref() -> float:
1174
+ return float(
1175
+ np.mean([1 - math.comb(int(a_) - int(b_), 5) / math.comb(int(a_), 5) for a_, b_ in zip(ns, nc)])
1176
+ )
1177
+
1178
+ c.add("reasoning.pass_at_k", lambda: _f(es.pass_at_k(ns, nc, k=5)), "Python stdlib", pass_ref)
1179
+ answers = [str(int(t)) for t in rng.integers(0, 50, m)]
1180
+ samples = [[a_ if rng.random() < 0.6 else str(int(rng.integers(0, 50))) for _ in range(5)] for a_ in answers]
1181
+
1182
+ def mv_ref() -> float:
1183
+ return float(np.mean([Counter(s).most_common(1)[0][0] == a_ for a_, s in zip(answers, samples)]))
1184
+
1185
+ c.add(
1186
+ "reasoning.majority_vote_accuracy",
1187
+ lambda: _f(es.majority_vote_accuracy(answers, samples)),
1188
+ "Python stdlib",
1189
+ mv_ref,
1190
+ )
1191
+ sols = [f"step one ... #### {a_}" for a_ in answers]
1192
+ outs = [f"so the answer is {s[0]}" for s in samples]
1193
+ c.add("reasoning.benchmark_accuracy", lambda: _f(es.benchmark_accuracy(sols, outs, style="gsm8k")))
1194
+
1195
+ # ---- retrieval (ranx)
1196
+ rel = [set(rng.choice(200, int(rng.integers(1, 6)), replace=False).tolist()) for _ in range(m)]
1197
+ ret = [rng.permutation(200)[:20].tolist() for _ in range(m)]
1198
+ ranx = _opt("ranx")
1199
+
1200
+ def ranx_ref(metric: str) -> Callable[[], float]:
1201
+ def run() -> float:
1202
+ qrels = ranx.Qrels({f"q{i}": {f"d{d}": 1 for d in r} for i, r in enumerate(rel)})
1203
+ run_ = ranx.Run(
1204
+ {f"q{i}": {f"d{d}": float(len(r) - j) for j, d in enumerate(r)} for i, r in enumerate(ret)}
1205
+ )
1206
+ return float(ranx.evaluate(qrels, run_, metric))
1207
+
1208
+ return run
1209
+
1210
+ for name, call, metric in (
1211
+ ("hit_rate_at_k", lambda: es.hit_rate_at_k(rel, ret, k=10), "hit_rate@10"),
1212
+ ("mean_average_precision_at_k", lambda: es.mean_average_precision_at_k(rel, ret, k=20), "map@20"),
1213
+ ("mrr", lambda: es.mrr(rel, ret), "mrr"),
1214
+ ("ndcg_at_k", lambda: es.ndcg_at_k(rel, ret, k=10), "ndcg@10"),
1215
+ ("precision_at_k", lambda: es.precision_at_k(rel, ret, k=10), "precision@10"),
1216
+ ("recall_at_k", lambda: es.recall_at_k(rel, ret, k=10), "recall@10"),
1217
+ ):
1218
+ c.add(
1219
+ f"retrieval.{name}",
1220
+ lambda call=call: _f(call()), # type: ignore[misc]
1221
+ "ranx",
1222
+ ranx_ref(metric) if ranx else None,
1223
+ )
1224
+
1225
+ # ---- RAG and operations
1226
+ chunk_rel = [list(rng.random(int(rng.integers(1, 10))) < 0.5) for _ in range(m)]
1227
+
1228
+ def cp_ref() -> float:
1229
+ out = []
1230
+ for ch in chunk_rel:
1231
+ arr = np.asarray(ch, float)
1232
+ if arr.sum() == 0:
1233
+ out.append(0.0)
1234
+ continue
1235
+ prec = np.cumsum(arr) / np.arange(1, len(arr) + 1)
1236
+ out.append(float(np.sum(prec * arr) / arr.sum()))
1237
+ return float(np.mean(out))
1238
+
1239
+ c.add("rag.context_precision", lambda: _f(es.context_precision(chunk_rel)), "NumPy formula", cp_ref)
1240
+ attributed = [list(rng.random(int(rng.integers(1, 8))) < 0.7) for _ in range(m)]
1241
+ c.add(
1242
+ "rag.context_recall",
1243
+ lambda: _f(es.context_recall(attributed)),
1244
+ "NumPy formula",
1245
+ lambda: float(np.mean([np.mean(a_) for a_ in attributed])),
1246
+ )
1247
+ c.add(
1248
+ "rag.context_relevance",
1249
+ lambda: _f(es.context_relevance(chunk_rel)),
1250
+ "NumPy formula",
1251
+ lambda: float(np.mean([np.mean(a_) for a_ in chunk_rel])),
1252
+ )
1253
+ stage_vals = np.array(["retrieval", "generation", "evidence", "orchestration", "other", ""])
1254
+ stages = [s or None for s in rng.choice(stage_vals, m)]
1255
+ c.add("rag.failure_attribution", lambda: _numeric(es.failure_attribution(stages)))
1256
+ lat = rng.lognormal(5, 0.5, m)
1257
+ c.add(
1258
+ "rag.latency_summary",
1259
+ lambda: _f(es.latency_summary(lat, statistic="p95")),
1260
+ "NumPy formula",
1261
+ lambda: float(np.percentile(lat, 95)),
1262
+ )
1263
+ succ = rng.random(m) < 0.8
1264
+ c.add(
1265
+ "rag.task_success_rate",
1266
+ lambda: _f(es.task_success_rate(succ)),
1267
+ "NumPy formula",
1268
+ lambda: float(np.mean(succ)),
1269
+ )
1270
+
1271
+ # ---- structured output
1272
+ schema = {
1273
+ "type": "object",
1274
+ "properties": {
1275
+ "name": {"type": "string", "minLength": 1},
1276
+ "age": {"type": "integer", "minimum": 0},
1277
+ "tags": {"type": "array", "items": {"type": "string"}, "uniqueItems": True},
1278
+ },
1279
+ "required": ["name", "age"],
1280
+ "additionalProperties": False,
1281
+ }
1282
+ docs = []
1283
+ for i in range(m):
1284
+ doc: dict[str, Any] = {"name": f"n{i}", "age": int(rng.integers(-2, 90)), "tags": ["a", "b"]}
1285
+ if rng.random() < 0.1:
1286
+ doc["extra"] = 1
1287
+ if rng.random() < 0.1:
1288
+ del doc["name"]
1289
+ docs.append(doc)
1290
+ docs_text = [json.dumps(d_) if rng.random() > 0.05 else json.dumps(d_)[:-1] for d_ in docs]
1291
+
1292
+ def valid_json(t: str) -> bool:
1293
+ try:
1294
+ json.loads(t)
1295
+ except ValueError:
1296
+ return False
1297
+ return True
1298
+
1299
+ c.add(
1300
+ "text.json_validity",
1301
+ lambda: _f(es.json_validity(docs_text)),
1302
+ "Python stdlib",
1303
+ lambda: float(np.mean([valid_json(t) for t in docs_text])),
1304
+ )
1305
+ jsch = _opt("jsonschema")
1306
+
1307
+ def jsch_ref() -> float:
1308
+ v = jsch.Draft202012Validator(schema)
1309
+ return float(np.mean([valid_json(t) and v.is_valid(json.loads(t)) for t in docs_text]))
1310
+
1311
+ c.add(
1312
+ "text.json_schema_compliance",
1313
+ lambda: _f(es.json_schema_compliance(docs_text, schema)),
1314
+ "jsonschema",
1315
+ jsch_ref if jsch else None,
1316
+ )
1317
+ xmls = [f"<a><b>{i}</b></a>" if rng.random() < 0.8 else f"<a><b>{i}</a>" for i in range(m)]
1318
+
1319
+ def valid_xml(t: str) -> bool:
1320
+ try:
1321
+ ET.fromstring(t) # noqa: S314 - benchmark data generated above, not user input
1322
+ except ET.ParseError:
1323
+ return False
1324
+ return True
1325
+
1326
+ c.add(
1327
+ "text.xml_validity",
1328
+ lambda: _f(es.xml_validity(xmls)),
1329
+ "Python stdlib",
1330
+ lambda: float(np.mean([valid_xml(t) for t in xmls])),
1331
+ )
1332
+ expected = [{"name": d_.get("name", "x"), "age": d_["age"]} for d_ in docs]
1333
+ c.add("text.required_field_accuracy", lambda: _f(es.required_field_accuracy(expected, docs_text)))
1334
+ tools = ["search", "lookup", "calc", "email"]
1335
+ exp_calls = [
1336
+ [{"name": str(rng.choice(tools)), "arguments": {"q": f"x{int(rng.integers(0, 5))}"}}] for _ in range(m)
1337
+ ]
1338
+ pred_calls = [
1339
+ [c_ if rng.random() < 0.8 else {"name": str(rng.choice(tools)), "arguments": {"q": "x0"}} for c_ in e_]
1340
+ for e_ in exp_calls
1341
+ ]
1342
+ c.add(
1343
+ "text.tool_selection_accuracy",
1344
+ lambda: _f(es.tool_selection_accuracy(exp_calls, pred_calls)),
1345
+ "Python stdlib",
1346
+ lambda: float(
1347
+ np.mean([[x["name"] for x in e_] == [x["name"] for x in p_] for e_, p_ in zip(exp_calls, pred_calls)])
1348
+ ),
1349
+ )
1350
+ c.add("text.tool_argument_accuracy", lambda: _f(es.tool_argument_accuracy(exp_calls, pred_calls)))
1351
+ c.add("text.tool_call_f1", lambda: _f(es.tool_call_f1(exp_calls, pred_calls)))
1352
+ api = [bool(t) for t in rng.random(m) < 0.9]
1353
+ c.add(
1354
+ "text.api_call_success_rate",
1355
+ lambda: _f(es.api_call_success_rate(api)),
1356
+ "NumPy formula",
1357
+ lambda: float(np.mean(api)),
1358
+ )
1359
+ checks = [list(rng.random(int(rng.integers(1, 6))) < 0.8) for _ in range(m)]
1360
+ c.add(
1361
+ "text.instruction_compliance_rate",
1362
+ lambda: _f(es.instruction_compliance_rate(checks)),
1363
+ "NumPy formula",
1364
+ lambda: float(np.mean([all(ch) for ch in checks])),
1365
+ )
1366
+ c.add(
1367
+ "text.constraint_satisfaction_rate",
1368
+ lambda: _f(es.constraint_satisfaction_rate(checks)),
1369
+ "NumPy formula",
1370
+ lambda: float(np.mean(np.concatenate([np.asarray(ch, float) for ch in checks]))),
1371
+ )
1372
+ fmt = [f"Answer: {rng.choice(list('ABCDE'))}" for _ in range(m)]
1373
+ pat = re.compile(r"Answer: [A-D]", re.DOTALL)
1374
+ c.add(
1375
+ "text.format_compliance",
1376
+ lambda: _f(es.format_compliance(fmt, r"Answer: [A-D]")),
1377
+ "Python stdlib",
1378
+ lambda: float(np.mean([pat.fullmatch(t) is not None for t in fmt])),
1379
+ )
1380
+ turns = [[list(rng.random(3) < 0.8) for _ in range(int(rng.integers(1, 4)))] for _ in range(m)]
1381
+ c.add("text.instruction_retention", lambda: _f(es.instruction_retention(turns)))
1382
+ wrapped = [t if rng.random() < 0.8 else f"Sure! {t}" for t in docs_text]
1383
+ c.add("text.extra_content_rate", lambda: _f(es.extra_content_rate(wrapped)))
1384
+
1385
+
1386
+ def metric_cases(n: int, rng: np.random.Generator) -> list[Case]:
1387
+ """One benchmark case for every registered metric and every statistics function."""
1388
+ c = _Cases()
1389
+ for build in (_classification, _regression, _clinical, _statistics, _vision, _llm):
1390
+ build(c, n, rng)
1391
+ return sorted(c.rows, key=lambda r: r[0])