evalsuite-python 0.1.0a1__tar.gz → 0.1.0a2__tar.gz

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.
Files changed (45) hide show
  1. evalsuite_python-0.1.0a2/CHANGELOG.md +43 -0
  2. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/PKG-INFO +25 -1
  3. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/README.md +22 -0
  4. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/pyproject.toml +3 -2
  5. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/__init__.py +34 -1
  6. evalsuite_python-0.1.0a2/src/evalsuite/stats/__init__.py +25 -0
  7. evalsuite_python-0.1.0a2/src/evalsuite/stats/_resolve.py +90 -0
  8. evalsuite_python-0.1.0a2/src/evalsuite/stats/compare.py +323 -0
  9. evalsuite_python-0.1.0a2/src/evalsuite/stats/effect.py +113 -0
  10. evalsuite_python-0.1.0a2/src/evalsuite/stats/intervals.py +320 -0
  11. evalsuite_python-0.1.0a2/src/evalsuite/stats/paired.py +207 -0
  12. evalsuite_python-0.1.0a2/src/evalsuite/stats/results.py +129 -0
  13. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/version.py +1 -1
  14. evalsuite_python-0.1.0a2/tests/stats/test_branches.py +86 -0
  15. evalsuite_python-0.1.0a2/tests/stats/test_compare.py +152 -0
  16. evalsuite_python-0.1.0a2/tests/stats/test_reference.py +258 -0
  17. evalsuite_python-0.1.0a2/tests/unit/__init__.py +0 -0
  18. evalsuite_python-0.1.0a1/CHANGELOG.md +0 -23
  19. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/.gitignore +0 -0
  20. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/CONTRIBUTING.md +0 -0
  21. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/LICENSE +0 -0
  22. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/api.py +0 -0
  23. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/classification/__init__.py +0 -0
  24. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/classification/_common.py +0 -0
  25. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/classification/metrics.py +0 -0
  26. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/core/__init__.py +0 -0
  27. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/core/context.py +0 -0
  28. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/core/exceptions.py +0 -0
  29. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/core/registry.py +0 -0
  30. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/core/result.py +0 -0
  31. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/core/types.py +0 -0
  32. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/core/validation.py +0 -0
  33. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/py.typed +0 -0
  34. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/regression/__init__.py +0 -0
  35. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/regression/metrics.py +0 -0
  36. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/tests/__init__.py +0 -0
  37. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/tests/classification/__init__.py +0 -0
  38. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/tests/classification/test_against_sklearn.py +0 -0
  39. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/tests/conftest.py +0 -0
  40. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/tests/integration/__init__.py +0 -0
  41. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/tests/regression/__init__.py +0 -0
  42. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/tests/regression/test_against_sklearn.py +0 -0
  43. {evalsuite_python-0.1.0a1/tests/unit → evalsuite_python-0.1.0a2/tests/stats}/__init__.py +0 -0
  44. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/tests/unit/test_core.py +0 -0
  45. {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/tests/unit/test_edges.py +0 -0
@@ -0,0 +1,43 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0a2]
10
+
11
+ ### Added
12
+
13
+ - Model comparison (`es.compare`): per-model confidence intervals from paired bootstrap resamples, pairwise
14
+ tests (McNemar for accuracy, DeLong for binary ROC AUC, paired bootstrap otherwise), multiple-comparison
15
+ correction, best model per metric, and summary/pandas/Markdown/LaTeX/JSON export.
16
+ - Confidence intervals: `bootstrap_ci` (percentile, basic, BCa; stratified and reproducible),
17
+ `proportion_ci` and `accuracy_ci` (Wilson, Clopper-Pearson, normal), `roc_auc_ci` (DeLong).
18
+ - Paired tests: `mcnemar_test`, `delong_test`, `paired_bootstrap_test`.
19
+ - Effect sizes: `cohens_d` (independent and paired), `hedges_g`, `cliffs_delta`; `adjust_pvalues`
20
+ (Holm, Bonferroni, Benjamini-Hochberg, Benjamini-Yekutieli).
21
+ - Reference tests against statsmodels and SciPy, brute-force DeLong checks and coverage simulations.
22
+ - Python 3.14 support and CI.
23
+
24
+ ### Fixed
25
+
26
+ - 0.1.0a1 installed an `evalsuite` console command although the CLI is not implemented yet, so the command
27
+ failed with `ModuleNotFoundError`. The entry point is removed until the CLI ships.
28
+
29
+ ## [0.1.0a1]
30
+
31
+ First alpha, published to reserve the name and test the release pipeline. Distribution name
32
+ `evalsuite-python` (`pip install --pre evalsuite-python`), imported as `evalsuite`.
33
+
34
+ ### Added
35
+
36
+ - Core: input validation with actionable errors, exception hierarchy, immutable result objects with
37
+ JSON/pandas/Markdown/LaTeX export, metric registry (`list_metrics`, `metric_info`), and an evaluation
38
+ context that computes the confusion matrix once per evaluation.
39
+ - Classification metrics for binary, multiclass and multilabel targets with all averaging modes and
40
+ sample weights.
41
+ - Regression metrics for single- and multi-output targets with sample weights.
42
+ - `evaluate()` high-level API with task inference and default metric sets.
43
+ - Reference tests against scikit-learn and property-based tests; Python 3.9 to 3.13.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: evalsuite-python
3
- Version: 0.1.0a1
3
+ Version: 0.1.0a2
4
4
  Summary: A unified Python framework for machine-learning, clinical, statistical, segmentation, object-detection, uncertainty, and model evaluation.
5
5
  Project-URL: Homepage, https://evalsuite-nine.vercel.app
6
6
  Project-URL: Documentation, https://evalsuite-nine.vercel.app/docs
@@ -23,6 +23,7 @@ Classifier: Programming Language :: Python :: 3.10
23
23
  Classifier: Programming Language :: Python :: 3.11
24
24
  Classifier: Programming Language :: Python :: 3.12
25
25
  Classifier: Programming Language :: Python :: 3.13
26
+ Classifier: Programming Language :: Python :: 3.14
26
27
  Classifier: Topic :: Scientific/Engineering
27
28
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
28
29
  Classifier: Typing :: Typed
@@ -42,6 +43,7 @@ Requires-Dist: pytest-cov>=4; extra == 'dev'
42
43
  Requires-Dist: pytest>=7; extra == 'dev'
43
44
  Requires-Dist: ruff>=0.6; extra == 'dev'
44
45
  Requires-Dist: scikit-learn>=1.2; extra == 'dev'
46
+ Requires-Dist: statsmodels>=0.13; extra == 'dev'
45
47
  Requires-Dist: twine; extra == 'dev'
46
48
  Provides-Extra: plot
47
49
  Requires-Dist: matplotlib>=3.5; extra == 'plot'
@@ -109,6 +111,28 @@ es.metric_info("classification.mcc").formula # documentation
109
111
  es.list_metrics("regression")
110
112
  ```
111
113
 
114
+ ## Comparing models
115
+
116
+ ```python
117
+ result = es.compare(
118
+ y_true,
119
+ {"logistic": pred_lr, "forest": pred_rf, "boosting": pred_gb},
120
+ probabilities={"logistic": prob_lr, "forest": prob_rf, "boosting": prob_gb},
121
+ random_state=0,
122
+ )
123
+ print(result.summary()) # estimates with 95% CIs, paired tests, Holm-adjusted p-values
124
+ result.to_latex(label="tab:models")
125
+
126
+ es.bootstrap_ci("f1", y_true, y_pred, average="macro", random_state=0) # BCa interval for any metric
127
+ es.accuracy_ci(y_true, y_pred) # Wilson interval
128
+ es.delong_test(y_true, prob_a, prob_b) # two correlated AUCs
129
+ es.mcnemar_test(y_true, pred_a, pred_b)
130
+ ```
131
+
132
+ Every model is evaluated on the same bootstrap resamples, so differences are paired. Accuracy is compared
133
+ with McNemar's test, binary ROC AUC with DeLong's test and other metrics with a paired bootstrap test;
134
+ p-values are adjusted for multiple comparisons (Holm by default).
135
+
112
136
  ## Metrics in this release
113
137
 
114
138
  **Classification** (binary, multiclass, multilabel; micro/macro/weighted/samples/per-class averaging;
@@ -60,6 +60,28 @@ es.metric_info("classification.mcc").formula # documentation
60
60
  es.list_metrics("regression")
61
61
  ```
62
62
 
63
+ ## Comparing models
64
+
65
+ ```python
66
+ result = es.compare(
67
+ y_true,
68
+ {"logistic": pred_lr, "forest": pred_rf, "boosting": pred_gb},
69
+ probabilities={"logistic": prob_lr, "forest": prob_rf, "boosting": prob_gb},
70
+ random_state=0,
71
+ )
72
+ print(result.summary()) # estimates with 95% CIs, paired tests, Holm-adjusted p-values
73
+ result.to_latex(label="tab:models")
74
+
75
+ es.bootstrap_ci("f1", y_true, y_pred, average="macro", random_state=0) # BCa interval for any metric
76
+ es.accuracy_ci(y_true, y_pred) # Wilson interval
77
+ es.delong_test(y_true, prob_a, prob_b) # two correlated AUCs
78
+ es.mcnemar_test(y_true, pred_a, pred_b)
79
+ ```
80
+
81
+ Every model is evaluated on the same bootstrap resamples, so differences are paired. Accuracy is compared
82
+ with McNemar's test, binary ROC AUC with DeLong's test and other metrics with a paired bootstrap test;
83
+ p-values are adjusted for multiple comparisons (Holm by default).
84
+
63
85
  ## Metrics in this release
64
86
 
65
87
  **Classification** (binary, multiclass, multilabel; micro/macro/weighted/samples/per-class averaging;
@@ -25,6 +25,7 @@ classifiers = [
25
25
  "Programming Language :: Python :: 3.11",
26
26
  "Programming Language :: Python :: 3.12",
27
27
  "Programming Language :: Python :: 3.13",
28
+ "Programming Language :: Python :: 3.14",
28
29
  "Topic :: Scientific/Engineering",
29
30
  "Topic :: Scientific/Engineering :: Artificial Intelligence",
30
31
  "Typing :: Typed",
@@ -39,6 +40,7 @@ dev = [
39
40
  "pytest-cov>=4",
40
41
  "hypothesis>=6.80",
41
42
  "scikit-learn>=1.2",
43
+ "statsmodels>=0.13",
42
44
  "ruff>=0.6",
43
45
  "mypy>=1.10",
44
46
  "pandas-stubs",
@@ -54,8 +56,7 @@ Source = "https://github.com/mkcs28/evalsuite-python"
54
56
  Issues = "https://github.com/mkcs28/evalsuite-python/issues"
55
57
  Changelog = "https://github.com/mkcs28/evalsuite-python/blob/main/CHANGELOG.md"
56
58
 
57
- [project.scripts]
58
- evalsuite = "evalsuite.cli.main:main"
59
+ # [project.scripts] evalsuite = "evalsuite.cli.main:main" is added together with the CLI itself.
59
60
 
60
61
  [tool.hatch.version]
61
62
  path = "src/evalsuite/version.py"
@@ -5,7 +5,7 @@
5
5
  >>> print(result.summary()) # doctest: +SKIP
6
6
  """
7
7
 
8
- from . import classification, regression
8
+ from . import classification, regression, stats
9
9
  from .api import evaluate
10
10
  from .classification import (
11
11
  accuracy,
@@ -59,9 +59,42 @@ from .regression import (
59
59
  rse,
60
60
  smape,
61
61
  )
62
+ from .stats import (
63
+ ComparisonResult,
64
+ ConfidenceInterval,
65
+ TestResult,
66
+ accuracy_ci,
67
+ adjust_pvalues,
68
+ bootstrap_ci,
69
+ cliffs_delta,
70
+ cohens_d,
71
+ compare,
72
+ delong_test,
73
+ hedges_g,
74
+ mcnemar_test,
75
+ paired_bootstrap_test,
76
+ proportion_ci,
77
+ roc_auc_ci,
78
+ )
62
79
  from .version import __version__
63
80
 
64
81
  __all__ = [
82
+ "stats",
83
+ "roc_auc_ci",
84
+ "proportion_ci",
85
+ "paired_bootstrap_test",
86
+ "mcnemar_test",
87
+ "hedges_g",
88
+ "delong_test",
89
+ "compare",
90
+ "cohens_d",
91
+ "cliffs_delta",
92
+ "bootstrap_ci",
93
+ "adjust_pvalues",
94
+ "accuracy_ci",
95
+ "TestResult",
96
+ "ConfidenceInterval",
97
+ "ComparisonResult",
65
98
  "EvalSuiteError",
66
99
  "EvaluationResult",
67
100
  "InputValidationError",
@@ -0,0 +1,25 @@
1
+ """Statistical evaluation: confidence intervals, paired tests, effect sizes, multiple comparisons, compare()."""
2
+
3
+ from .compare import ComparisonResult, compare
4
+ from .effect import adjust_pvalues, cliffs_delta, cohens_d, hedges_g
5
+ from .intervals import accuracy_ci, bootstrap_ci, proportion_ci, roc_auc_ci
6
+ from .paired import delong_test, mcnemar_test, paired_bootstrap_test
7
+ from .results import ConfidenceInterval, TestResult
8
+
9
+ __all__ = [
10
+ "ComparisonResult",
11
+ "ConfidenceInterval",
12
+ "TestResult",
13
+ "accuracy_ci",
14
+ "adjust_pvalues",
15
+ "bootstrap_ci",
16
+ "cliffs_delta",
17
+ "cohens_d",
18
+ "compare",
19
+ "delong_test",
20
+ "hedges_g",
21
+ "mcnemar_test",
22
+ "paired_bootstrap_test",
23
+ "proportion_ci",
24
+ "roc_auc_ci",
25
+ ]
@@ -0,0 +1,90 @@
1
+ """Resolve metric names to functions and call them on index subsets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import inspect
7
+ from collections.abc import Callable
8
+ from typing import Any, Optional
9
+
10
+ import numpy as np
11
+ from numpy.typing import NDArray
12
+
13
+ from ..core.exceptions import InputValidationError
14
+ from ..core.validation import target_type, to_numpy
15
+
16
+ MetricFn = Callable[..., Any]
17
+
18
+
19
+ def resolve_metric(metric: Any) -> tuple[MetricFn, str]:
20
+ """A metric function (``es.f1``) or its name (``"f1"``, ``"classification.f1"``)."""
21
+ if callable(metric):
22
+ return metric, getattr(metric, "__name__", "metric")
23
+ if isinstance(metric, str):
24
+ import evalsuite
25
+
26
+ name = metric.split(".")[-1]
27
+ fn = getattr(evalsuite, name, None)
28
+ if callable(fn) and name in {m.split(".")[-1] for m in evalsuite.list_metrics()}:
29
+ return fn, name
30
+ raise InputValidationError(
31
+ f"Unknown metric '{metric}'. Use a metric function or a name from list_metrics()."
32
+ )
33
+ raise InputValidationError("metric must be a metric function (e.g. es.f1) or its name (e.g. 'f1').")
34
+
35
+
36
+ def is_categorical(y: NDArray[Any]) -> bool:
37
+ try:
38
+ return target_type(y) in ("binary", "multiclass", "multilabel")
39
+ except Exception:
40
+ return False
41
+
42
+
43
+ class MetricCall:
44
+ """Evaluate ``fn(y_true[idx], second[idx], **kwargs)`` repeatedly on index subsets."""
45
+
46
+ def __init__(
47
+ self,
48
+ fn: MetricFn,
49
+ y_true: Any,
50
+ y_pred: Any = None,
51
+ y_prob: Any = None,
52
+ sample_weight: Any = None,
53
+ kwargs: Optional[dict[str, Any]] = None,
54
+ ) -> None:
55
+ if (y_pred is None) == (y_prob is None):
56
+ raise InputValidationError(
57
+ "Pass exactly one of y_pred (label/value metrics) or y_prob (probability metrics)."
58
+ )
59
+ self.fn = fn
60
+ self.y_true = to_numpy(y_true, "y_true", allow_2d=True)
61
+ second = y_pred if y_pred is not None else y_prob
62
+ self.second = to_numpy(second, "y_pred" if y_pred is not None else "y_prob", allow_2d=True)
63
+ if self.second.shape[0] != self.y_true.shape[0]:
64
+ raise InputValidationError(
65
+ f"y_true and {'y_pred' if y_pred is not None else 'y_prob'} must contain the same number of "
66
+ f"observations. Received {self.y_true.shape[0]} and {self.second.shape[0]}."
67
+ )
68
+ self.n = self.y_true.shape[0]
69
+ self.weight = None if sample_weight is None else to_numpy(sample_weight, "sample_weight")
70
+ self.kwargs = dict(kwargs or {})
71
+ params = inspect.signature(fn).parameters
72
+ self.categorical = is_categorical(self.y_true)
73
+ # Keep every class in every resample so per-class and averaged metrics stay comparable.
74
+ if self.categorical and "labels" in params and "labels" not in self.kwargs and self.y_true.ndim == 1:
75
+ present = self.y_true if y_pred is None else np.concatenate([self.y_true, self.second])
76
+ with contextlib.suppress(TypeError): # incomparable labels: the metric reports it
77
+ self.kwargs["labels"] = np.unique(present)
78
+ if self.weight is not None and "sample_weight" not in params:
79
+ raise InputValidationError(f"{getattr(fn, '__name__', 'This metric')} does not accept sample_weight.")
80
+
81
+ def __call__(self, idx: Optional[NDArray[np.int64]] = None) -> float:
82
+ if idx is None:
83
+ yt, s, w = self.y_true, self.second, self.weight
84
+ else:
85
+ yt, s = self.y_true[idx], self.second[idx]
86
+ w = None if self.weight is None else self.weight[idx]
87
+ kw = dict(self.kwargs)
88
+ if w is not None:
89
+ kw["sample_weight"] = w
90
+ return float(self.fn(yt, s, **kw))
@@ -0,0 +1,323 @@
1
+ """compare(): evaluate several models on the same test set with intervals and paired tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import itertools
6
+ import json
7
+ from collections.abc import Mapping, Sequence
8
+ from dataclasses import dataclass, field
9
+ from types import MappingProxyType
10
+ from typing import TYPE_CHECKING, Any, Optional, cast
11
+
12
+ import numpy as np
13
+
14
+ from ..core.exceptions import InputValidationError
15
+ from ..core.registry import metric_info
16
+ from ..core.result import _json_safe, _latex_escape, _latex_table
17
+ from ..core.types import ArrayLike
18
+ from ..core.validation import to_numpy
19
+ from ._resolve import MetricCall, is_categorical, resolve_metric
20
+ from .effect import adjust_pvalues
21
+ from .intervals import _check_level, _check_resamples, bootstrap_distribution, resample_indices
22
+ from .paired import delong_test, mcnemar_test
23
+
24
+ if TYPE_CHECKING:
25
+ import pandas as pd
26
+
27
+ __all__ = ["ComparisonResult", "compare"]
28
+
29
+ _DEFAULT_CLF = ["accuracy", "f1", "mcc"]
30
+ _DEFAULT_CLF_PROB = ["roc_auc", "brier_score"]
31
+ _DEFAULT_REG = ["mae", "rmse", "r2"]
32
+
33
+
34
+ def _p(value: float) -> str:
35
+ return "<0.0001" if value < 1e-4 else f"={value:.4f}"
36
+
37
+
38
+ def _info(name: str) -> Any:
39
+ for cat in ("classification", "regression"):
40
+ try:
41
+ return metric_info(f"{cat}.{name}")
42
+ except KeyError:
43
+ continue
44
+ return None
45
+
46
+
47
+ def _needs_prob(name: str) -> bool:
48
+ info = _info(name)
49
+ return info is not None and "y_prob" in info.input_requirements
50
+
51
+
52
+ @dataclass(frozen=True, eq=False)
53
+ class ComparisonResult:
54
+ """Per-model estimates with confidence intervals, and pairwise tests with adjusted p-values."""
55
+
56
+ models: tuple[str, ...]
57
+ metrics: tuple[str, ...]
58
+ estimates: tuple[Mapping[str, Any], ...]
59
+ tests: tuple[Mapping[str, Any], ...]
60
+ settings: Mapping[str, Any] = field(default_factory=dict)
61
+
62
+ def __post_init__(self) -> None:
63
+ object.__setattr__(self, "estimates", tuple(MappingProxyType(dict(r)) for r in self.estimates))
64
+ object.__setattr__(self, "tests", tuple(MappingProxyType(dict(r)) for r in self.tests))
65
+ object.__setattr__(self, "settings", MappingProxyType(dict(self.settings)))
66
+
67
+ def estimate(self, model: str, metric: str) -> Mapping[str, Any]:
68
+ for row in self.estimates:
69
+ if row["model"] == model and row["metric"] == metric:
70
+ return row
71
+ raise KeyError(f"No estimate for model={model!r}, metric={metric!r}.")
72
+
73
+ def best(self, metric: str) -> str:
74
+ """Model with the best point estimate (direction from the metric registry)."""
75
+ rows = [r for r in self.estimates if r["metric"] == metric]
76
+ if not rows:
77
+ raise KeyError(f"Metric {metric!r} was not compared. Compared: {', '.join(self.metrics)}.")
78
+ info = _info(metric)
79
+ higher = True if info is None or info.higher_is_better is None else info.higher_is_better
80
+ pick = max if higher else min
81
+ return str(pick(rows, key=lambda r: r["estimate"])["model"])
82
+
83
+ def summary(self, *, digits: int = 3) -> str:
84
+ level = round(self.settings["level"] * 100, 6)
85
+ lines = [
86
+ f"EvalSuite model comparison (n={self.settings['n_samples']}, {level:g}% CIs, "
87
+ f"{self.settings['n_resamples']} paired bootstrap resamples, {self.settings['correction']} correction)"
88
+ ]
89
+ width = max(len(m) for m in self.models)
90
+ for metric in self.metrics:
91
+ lines.append(f"\n{metric} (best: {self.best(metric)})")
92
+ for row in (r for r in self.estimates if r["metric"] == metric):
93
+ lines.append(
94
+ f" {row['model']:<{width}} {row['estimate']:.{digits}f} "
95
+ f"({row['low']:.{digits}f}–{row['high']:.{digits}f})"
96
+ )
97
+ for t in (t for t in self.tests if t["metric"] == metric):
98
+ mark = " *" if t["significant"] else ""
99
+ lines.append(
100
+ f" {t['model_a']} − {t['model_b']}: {t['difference']:+.{digits}f} "
101
+ f"[{t['test']}, p={t['p_value']:.4f}, adjusted p={t['p_adjusted']:.4f}]{mark}"
102
+ )
103
+ lines.append(f"\n* significant at α = {self.settings['alpha']} after correction")
104
+ return "\n".join(lines)
105
+
106
+ def __repr__(self) -> str:
107
+ return self.summary()
108
+
109
+ def to_dict(self) -> dict[str, Any]:
110
+ return cast(
111
+ "dict[str, Any]",
112
+ _json_safe(
113
+ {
114
+ "models": list(self.models),
115
+ "metrics": list(self.metrics),
116
+ "estimates": [dict(r) for r in self.estimates],
117
+ "tests": [dict(r) for r in self.tests],
118
+ "settings": dict(self.settings),
119
+ }
120
+ ),
121
+ )
122
+
123
+ def to_json(self, *, indent: Optional[int] = 2) -> str:
124
+ return json.dumps(self.to_dict(), indent=indent, allow_nan=False)
125
+
126
+ def to_dataframe(self, which: str = "estimates") -> pd.DataFrame:
127
+ """``"estimates"`` (one row per model and metric) or ``"tests"`` (one row per comparison)."""
128
+ import pandas as pd
129
+
130
+ if which not in ("estimates", "tests"):
131
+ raise InputValidationError("which must be 'estimates' or 'tests'.")
132
+ frame: pd.DataFrame = pd.DataFrame(
133
+ [dict(r) for r in (self.estimates if which == "estimates" else self.tests)]
134
+ )
135
+ return frame
136
+
137
+ def _cell(self, model: str, metric: str, digits: int) -> str:
138
+ r = self.estimate(model, metric)
139
+ return f"{r['estimate']:.{digits}f} ({r['low']:.{digits}f}–{r['high']:.{digits}f})"
140
+
141
+ def to_markdown(self, *, digits: int = 3) -> str:
142
+ """Models as rows, metrics as columns: ``estimate (low–high)``; best value per metric in bold."""
143
+ header = "| Model | " + " | ".join(self.metrics) + " |"
144
+ sep = "| --- |" + " ---: |" * len(self.metrics)
145
+ rows = []
146
+ for model in self.models:
147
+ cells = []
148
+ for metric in self.metrics:
149
+ c = self._cell(model, metric, digits)
150
+ cells.append(f"**{c}**" if self.best(metric) == model else c)
151
+ rows.append(f"| {model} | " + " | ".join(cells) + " |")
152
+ return "\n".join([header, sep, *rows])
153
+
154
+ def to_latex(self, *, digits: int = 3, caption: Optional[str] = None, label: Optional[str] = None) -> str:
155
+ """Publication table: models × metrics with confidence intervals; best value per metric in bold."""
156
+ header = ["Model", *(_latex_escape(m) for m in self.metrics)]
157
+ rows = []
158
+ for model in self.models:
159
+ cells = [_latex_escape(model)]
160
+ for metric in self.metrics:
161
+ c = self._cell(model, metric, digits).replace("–", "--")
162
+ cells.append(rf"\textbf{{{c}}}" if self.best(metric) == model else c)
163
+ rows.append(cells)
164
+ level = round(self.settings["level"] * 100, 6)
165
+ cap = caption or f"Model comparison: estimate ({level:g}% CI)." # escaped once by _latex_table
166
+ return _latex_table(header, rows, caption=cap, label=label)
167
+
168
+
169
+ def compare(
170
+ y_true: ArrayLike,
171
+ predictions: Optional[Mapping[str, ArrayLike]] = None,
172
+ *,
173
+ probabilities: Optional[Mapping[str, ArrayLike]] = None,
174
+ metrics: Optional[Sequence[str]] = None,
175
+ baseline: Optional[str] = None,
176
+ level: float = 0.95,
177
+ alpha: float = 0.05,
178
+ n_resamples: int = 1000,
179
+ correction: str = "holm",
180
+ random_state: Optional[int] = None,
181
+ stratify: Optional[bool] = None,
182
+ sample_weight: Optional[ArrayLike] = None,
183
+ metric_kwargs: Optional[Mapping[str, Mapping[str, Any]]] = None,
184
+ ) -> ComparisonResult:
185
+ """Compare models evaluated on the same test set.
186
+
187
+ ``predictions`` and/or ``probabilities`` map model names to that model's predicted labels/values or
188
+ probabilities. Every model is resampled on the same bootstrap indices (paired), giving a percentile
189
+ confidence interval per model and metric and a paired test per pair of models:
190
+
191
+ * accuracy: McNemar's test (exact when there are fewer than 25 discordant pairs);
192
+ * binary ROC AUC: DeLong's test;
193
+ * everything else: paired bootstrap test.
194
+
195
+ Pairs are every model against ``baseline`` if given, otherwise all pairs. p-values are adjusted within
196
+ each metric (``correction``: "holm", "bonferroni", "bh", "by"); ``significant`` uses the adjusted
197
+ p-value and ``alpha``. ``metric_kwargs`` passes options per metric, e.g. ``{"f1": {"average": "macro"}}``.
198
+ """
199
+ level = _check_level(level)
200
+ n_resamples = _check_resamples(n_resamples)
201
+ predictions = dict(predictions or {})
202
+ probabilities = dict(probabilities or {})
203
+ names = list(dict.fromkeys([*predictions, *probabilities]))
204
+ if len(names) < 2:
205
+ raise InputValidationError("compare() needs at least two models (a dict of name -> predictions).")
206
+ if baseline is not None and baseline not in names:
207
+ raise InputValidationError(f"baseline={baseline!r} is not one of the models: {', '.join(names)}.")
208
+ yt = to_numpy(y_true, "y_true", allow_2d=True)
209
+ categorical = is_categorical(yt)
210
+ if metrics is None:
211
+ if categorical:
212
+ metric_names = list(_DEFAULT_CLF) if predictions else []
213
+ if probabilities:
214
+ metric_names += _DEFAULT_CLF_PROB if np.unique(yt).shape[0] <= 2 and yt.ndim == 1 else ["roc_auc"]
215
+ else:
216
+ metric_names = list(_DEFAULT_REG)
217
+ else:
218
+ metric_names = [resolve_metric(m)[1] for m in metrics]
219
+ kwargs_by_metric = {k: dict(v) for k, v in (metric_kwargs or {}).items()}
220
+
221
+ calls: dict[tuple[str, str], MetricCall] = {}
222
+ for metric in metric_names:
223
+ fn, _ = resolve_metric(metric)
224
+ prob = _needs_prob(metric)
225
+ source = probabilities if prob else predictions
226
+ for model in names:
227
+ if model not in source:
228
+ kind = "probabilities" if prob else "predictions"
229
+ raise InputValidationError(f"Metric '{metric}' needs {kind} for model '{model}'.")
230
+ calls[(model, metric)] = MetricCall(
231
+ fn,
232
+ yt,
233
+ None if prob else source[model],
234
+ source[model] if prob else None,
235
+ sample_weight,
236
+ kwargs_by_metric.get(metric),
237
+ )
238
+ # identical label set for every model of this metric
239
+ labs = [calls[(m, metric)].kwargs.get("labels") for m in names]
240
+ if all(lab is not None for lab in labs):
241
+ shared = labs[0]
242
+ for lab in labs[1:]:
243
+ shared = np.union1d(shared, lab)
244
+ for m in names:
245
+ calls[(m, metric)].kwargs["labels"] = shared
246
+
247
+ rng = np.random.default_rng(random_state)
248
+ use_strata = categorical if stratify is None else bool(stratify)
249
+ idx = resample_indices(yt.shape[0], n_resamples, rng, yt if use_strata else None)
250
+ a = (1 - level) / 2
251
+ boot: dict[tuple[str, str], np.ndarray] = {}
252
+ estimates: list[dict[str, Any]] = []
253
+ for metric in metric_names:
254
+ for model in names:
255
+ call = calls[(model, metric)]
256
+ dist, failed = bootstrap_distribution(call, idx)
257
+ boot[(model, metric)] = dist
258
+ low, high = np.nanquantile(dist, [a, 1 - a])
259
+ estimates.append(
260
+ {
261
+ "model": model,
262
+ "metric": metric,
263
+ "estimate": call(),
264
+ "low": float(low),
265
+ "high": float(high),
266
+ "failed_resamples": int(failed),
267
+ }
268
+ )
269
+
270
+ pairs = (
271
+ [(m, baseline) for m in names if m != baseline]
272
+ if baseline is not None
273
+ else list(itertools.combinations(names, 2))
274
+ )
275
+ tests: list[dict[str, Any]] = []
276
+ for metric in metric_names:
277
+ block: list[dict[str, Any]] = []
278
+ for ma, mb in pairs:
279
+ diff_dist = boot[(ma, metric)] - boot[(mb, metric)]
280
+ diff_dist = diff_dist[~np.isnan(diff_dist)]
281
+ observed = calls[(ma, metric)]() - calls[(mb, metric)]()
282
+ low, high = np.quantile(diff_dist, [a, 1 - a])
283
+ if metric == "accuracy" and sample_weight is None:
284
+ t = mcnemar_test(yt, predictions[ma], predictions[mb])
285
+ test_name, stat, p = t.test, t.statistic, t.p_value
286
+ elif metric == "roc_auc" and sample_weight is None and yt.ndim == 1 and np.unique(yt).shape[0] == 2:
287
+ t = delong_test(yt, probabilities[ma], probabilities[mb])
288
+ test_name, stat, p = t.test, t.statistic, t.p_value
289
+ else:
290
+ extreme = np.sum(np.abs(diff_dist - observed) >= abs(observed))
291
+ test_name, stat, p = "paired-bootstrap", observed, (1 + extreme) / (diff_dist.shape[0] + 1)
292
+ block.append(
293
+ {
294
+ "metric": metric,
295
+ "model_a": ma,
296
+ "model_b": mb,
297
+ "difference": float(observed),
298
+ "ci_low": float(low),
299
+ "ci_high": float(high),
300
+ "test": test_name,
301
+ "statistic": float(stat),
302
+ "p_value": float(p),
303
+ }
304
+ )
305
+ if block:
306
+ adjusted = adjust_pvalues([r["p_value"] for r in block], method=correction) # type: ignore[arg-type]
307
+ for r, pa in zip(block, adjusted):
308
+ r["p_adjusted"] = float(pa)
309
+ r["significant"] = bool(pa < alpha)
310
+ tests.extend(block)
311
+
312
+ settings = {
313
+ "n_samples": int(yt.shape[0]),
314
+ "level": level,
315
+ "alpha": alpha,
316
+ "n_resamples": n_resamples,
317
+ "correction": correction,
318
+ "random_state": random_state,
319
+ "stratified": use_strata,
320
+ "baseline": baseline,
321
+ "ci_method": "bootstrap-percentile (paired resamples)",
322
+ }
323
+ return ComparisonResult(tuple(names), tuple(metric_names), tuple(estimates), tuple(tests), settings)