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.
- evalsuite_python-0.1.0a2/CHANGELOG.md +43 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/PKG-INFO +25 -1
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/README.md +22 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/pyproject.toml +3 -2
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/__init__.py +34 -1
- evalsuite_python-0.1.0a2/src/evalsuite/stats/__init__.py +25 -0
- evalsuite_python-0.1.0a2/src/evalsuite/stats/_resolve.py +90 -0
- evalsuite_python-0.1.0a2/src/evalsuite/stats/compare.py +323 -0
- evalsuite_python-0.1.0a2/src/evalsuite/stats/effect.py +113 -0
- evalsuite_python-0.1.0a2/src/evalsuite/stats/intervals.py +320 -0
- evalsuite_python-0.1.0a2/src/evalsuite/stats/paired.py +207 -0
- evalsuite_python-0.1.0a2/src/evalsuite/stats/results.py +129 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/version.py +1 -1
- evalsuite_python-0.1.0a2/tests/stats/test_branches.py +86 -0
- evalsuite_python-0.1.0a2/tests/stats/test_compare.py +152 -0
- evalsuite_python-0.1.0a2/tests/stats/test_reference.py +258 -0
- evalsuite_python-0.1.0a2/tests/unit/__init__.py +0 -0
- evalsuite_python-0.1.0a1/CHANGELOG.md +0 -23
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/.gitignore +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/CONTRIBUTING.md +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/LICENSE +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/api.py +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/classification/__init__.py +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/classification/_common.py +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/classification/metrics.py +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/core/__init__.py +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/core/context.py +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/core/exceptions.py +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/core/registry.py +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/core/result.py +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/core/types.py +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/core/validation.py +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/py.typed +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/regression/__init__.py +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/src/evalsuite/regression/metrics.py +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/tests/__init__.py +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/tests/classification/__init__.py +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/tests/classification/test_against_sklearn.py +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/tests/conftest.py +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/tests/integration/__init__.py +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/tests/regression/__init__.py +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/tests/regression/test_against_sklearn.py +0 -0
- {evalsuite_python-0.1.0a1/tests/unit → evalsuite_python-0.1.0a2/tests/stats}/__init__.py +0 -0
- {evalsuite_python-0.1.0a1 → evalsuite_python-0.1.0a2}/tests/unit/test_core.py +0 -0
- {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.
|
|
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)
|