evalsuite-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- evalsuite/__init__.py +159 -0
- evalsuite/__main__.py +5 -0
- evalsuite/api.py +264 -0
- evalsuite/benchmarks.py +272 -0
- evalsuite/classification/__init__.py +51 -0
- evalsuite/classification/_common.py +111 -0
- evalsuite/classification/metrics.py +943 -0
- evalsuite/cli/__init__.py +5 -0
- evalsuite/cli/main.py +389 -0
- evalsuite/core/__init__.py +1 -0
- evalsuite/core/context.py +181 -0
- evalsuite/core/exceptions.py +52 -0
- evalsuite/core/export.py +100 -0
- evalsuite/core/registry.py +90 -0
- evalsuite/core/result.py +379 -0
- evalsuite/core/types.py +23 -0
- evalsuite/core/validation.py +202 -0
- evalsuite/plot.py +296 -0
- evalsuite/py.typed +0 -0
- evalsuite/regression/__init__.py +41 -0
- evalsuite/regression/metrics.py +604 -0
- evalsuite/reporting.py +241 -0
- evalsuite/stats/__init__.py +25 -0
- evalsuite/stats/_resolve.py +90 -0
- evalsuite/stats/compare.py +414 -0
- evalsuite/stats/effect.py +113 -0
- evalsuite/stats/intervals.py +320 -0
- evalsuite/stats/paired.py +207 -0
- evalsuite/stats/results.py +129 -0
- evalsuite/version.py +3 -0
- evalsuite_python-0.1.0.dist-info/METADATA +247 -0
- evalsuite_python-0.1.0.dist-info/RECORD +35 -0
- evalsuite_python-0.1.0.dist-info/WHEEL +4 -0
- evalsuite_python-0.1.0.dist-info/entry_points.txt +2 -0
- evalsuite_python-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,414 @@
|
|
|
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
|
+
def _csv_rows(self) -> tuple[list[str], list[list[Any]]]:
|
|
169
|
+
header = ["model", "metric", "estimate", "low", "high"]
|
|
170
|
+
return header, [[r["model"], r["metric"], r["estimate"], r["low"], r["high"]] for r in self.estimates]
|
|
171
|
+
|
|
172
|
+
def to_csv(self, path: Optional[str] = None, *, which: str = "estimates") -> str:
|
|
173
|
+
"""CSV of the estimates (``which="estimates"``) or the pairwise tests (``which="tests"``)."""
|
|
174
|
+
from ..core.export import csv_text
|
|
175
|
+
|
|
176
|
+
if which == "estimates":
|
|
177
|
+
header, rows = self._csv_rows()
|
|
178
|
+
elif which == "tests":
|
|
179
|
+
header = [
|
|
180
|
+
"metric",
|
|
181
|
+
"model_a",
|
|
182
|
+
"model_b",
|
|
183
|
+
"difference",
|
|
184
|
+
"ci_low",
|
|
185
|
+
"ci_high",
|
|
186
|
+
"test",
|
|
187
|
+
"statistic",
|
|
188
|
+
"p_value",
|
|
189
|
+
"p_adjusted",
|
|
190
|
+
"significant",
|
|
191
|
+
]
|
|
192
|
+
rows = [[t[h] for h in header] for t in self.tests]
|
|
193
|
+
else:
|
|
194
|
+
raise InputValidationError("which must be 'estimates' or 'tests'.")
|
|
195
|
+
text = csv_text(header, rows)
|
|
196
|
+
if path is not None:
|
|
197
|
+
with open(path, "w", encoding="utf-8", newline="") as fh:
|
|
198
|
+
fh.write(text)
|
|
199
|
+
return text
|
|
200
|
+
|
|
201
|
+
def to_html(self, *, digits: int = 3, full: bool = False) -> str:
|
|
202
|
+
"""Estimates table (best value per metric in bold) and the pairwise tests table."""
|
|
203
|
+
from ..core.export import html_document, html_table
|
|
204
|
+
|
|
205
|
+
rows = [[m, *(self._cell(m, metric, digits) for metric in self.metrics)] for m in self.models]
|
|
206
|
+
bold = {
|
|
207
|
+
(i, j + 1)
|
|
208
|
+
for i, m in enumerate(self.models)
|
|
209
|
+
for j, metric in enumerate(self.metrics)
|
|
210
|
+
if self.best(metric) == m
|
|
211
|
+
}
|
|
212
|
+
level = round(self.settings["level"] * 100, 6)
|
|
213
|
+
parts = [
|
|
214
|
+
html_table(
|
|
215
|
+
["Model", *self.metrics], rows, caption=f"Estimate ({level:g}% CI); best in bold", bold=bold
|
|
216
|
+
)
|
|
217
|
+
]
|
|
218
|
+
test_rows = [
|
|
219
|
+
[
|
|
220
|
+
t["metric"],
|
|
221
|
+
f"{t['model_a']} − {t['model_b']}",
|
|
222
|
+
f"{t['difference']:+.{digits}f}",
|
|
223
|
+
f"{t['ci_low']:.{digits}f}–{t['ci_high']:.{digits}f}",
|
|
224
|
+
t["test"],
|
|
225
|
+
f"{t['p_value']:.4g}",
|
|
226
|
+
f"{t['p_adjusted']:.4g}",
|
|
227
|
+
"yes" if t["significant"] else "no",
|
|
228
|
+
]
|
|
229
|
+
for t in self.tests
|
|
230
|
+
]
|
|
231
|
+
parts.append(
|
|
232
|
+
html_table(
|
|
233
|
+
["Metric", "Comparison", "Difference", f"{level:g}% CI", "Test", "p", "Adjusted p", "Significant"],
|
|
234
|
+
test_rows,
|
|
235
|
+
caption=f"Pairwise tests ({self.settings['correction']} correction, α = {self.settings['alpha']})",
|
|
236
|
+
numeric=[False, False, True, True, False, True, True, False],
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
body = "\n".join(parts)
|
|
240
|
+
meta = f"n = {self.settings['n_samples']}, {self.settings['n_resamples']} paired bootstrap resamples"
|
|
241
|
+
return html_document("EvalSuite model comparison", body, meta) if full else body
|
|
242
|
+
|
|
243
|
+
def save(self, path: str, *, digits: int = 3) -> str:
|
|
244
|
+
"""Save as .json .csv .md .tex .html or .txt (chosen by the extension)."""
|
|
245
|
+
from ..core.export import save_as
|
|
246
|
+
|
|
247
|
+
return save_as(
|
|
248
|
+
path,
|
|
249
|
+
{
|
|
250
|
+
"json": self.to_json,
|
|
251
|
+
"csv": self.to_csv,
|
|
252
|
+
"markdown": lambda: self.to_markdown(digits=digits) + "\n",
|
|
253
|
+
"latex": lambda: self.to_latex(digits=digits) + "\n",
|
|
254
|
+
"html": lambda: self.to_html(digits=digits, full=True),
|
|
255
|
+
"text": lambda: self.summary(digits=digits) + "\n",
|
|
256
|
+
},
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def compare(
|
|
261
|
+
y_true: ArrayLike,
|
|
262
|
+
predictions: Optional[Mapping[str, ArrayLike]] = None,
|
|
263
|
+
*,
|
|
264
|
+
probabilities: Optional[Mapping[str, ArrayLike]] = None,
|
|
265
|
+
metrics: Optional[Sequence[str]] = None,
|
|
266
|
+
baseline: Optional[str] = None,
|
|
267
|
+
level: float = 0.95,
|
|
268
|
+
alpha: float = 0.05,
|
|
269
|
+
n_resamples: int = 1000,
|
|
270
|
+
correction: str = "holm",
|
|
271
|
+
random_state: Optional[int] = None,
|
|
272
|
+
stratify: Optional[bool] = None,
|
|
273
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
274
|
+
metric_kwargs: Optional[Mapping[str, Mapping[str, Any]]] = None,
|
|
275
|
+
) -> ComparisonResult:
|
|
276
|
+
"""Compare models evaluated on the same test set.
|
|
277
|
+
|
|
278
|
+
``predictions`` and/or ``probabilities`` map model names to that model's predicted labels/values or
|
|
279
|
+
probabilities. Every model is resampled on the same bootstrap indices (paired), giving a percentile
|
|
280
|
+
confidence interval per model and metric and a paired test per pair of models:
|
|
281
|
+
|
|
282
|
+
* accuracy: McNemar's test (exact when there are fewer than 25 discordant pairs);
|
|
283
|
+
* binary ROC AUC: DeLong's test;
|
|
284
|
+
* everything else: paired bootstrap test.
|
|
285
|
+
|
|
286
|
+
Pairs are every model against ``baseline`` if given, otherwise all pairs. p-values are adjusted within
|
|
287
|
+
each metric (``correction``: "holm", "bonferroni", "bh", "by"); ``significant`` uses the adjusted
|
|
288
|
+
p-value and ``alpha``. ``metric_kwargs`` passes options per metric, e.g. ``{"f1": {"average": "macro"}}``.
|
|
289
|
+
"""
|
|
290
|
+
level = _check_level(level)
|
|
291
|
+
n_resamples = _check_resamples(n_resamples)
|
|
292
|
+
predictions = dict(predictions or {})
|
|
293
|
+
probabilities = dict(probabilities or {})
|
|
294
|
+
names = list(dict.fromkeys([*predictions, *probabilities]))
|
|
295
|
+
if len(names) < 2:
|
|
296
|
+
raise InputValidationError("compare() needs at least two models (a dict of name -> predictions).")
|
|
297
|
+
if baseline is not None and baseline not in names:
|
|
298
|
+
raise InputValidationError(f"baseline={baseline!r} is not one of the models: {', '.join(names)}.")
|
|
299
|
+
yt = to_numpy(y_true, "y_true", allow_2d=True)
|
|
300
|
+
categorical = is_categorical(yt)
|
|
301
|
+
if metrics is None:
|
|
302
|
+
if categorical:
|
|
303
|
+
metric_names = list(_DEFAULT_CLF) if predictions else []
|
|
304
|
+
if probabilities:
|
|
305
|
+
metric_names += _DEFAULT_CLF_PROB if np.unique(yt).shape[0] <= 2 and yt.ndim == 1 else ["roc_auc"]
|
|
306
|
+
else:
|
|
307
|
+
metric_names = list(_DEFAULT_REG)
|
|
308
|
+
else:
|
|
309
|
+
metric_names = [resolve_metric(m)[1] for m in metrics]
|
|
310
|
+
kwargs_by_metric = {k: dict(v) for k, v in (metric_kwargs or {}).items()}
|
|
311
|
+
|
|
312
|
+
calls: dict[tuple[str, str], MetricCall] = {}
|
|
313
|
+
for metric in metric_names:
|
|
314
|
+
fn, _ = resolve_metric(metric)
|
|
315
|
+
prob = _needs_prob(metric)
|
|
316
|
+
source = probabilities if prob else predictions
|
|
317
|
+
for model in names:
|
|
318
|
+
if model not in source:
|
|
319
|
+
kind = "probabilities" if prob else "predictions"
|
|
320
|
+
raise InputValidationError(f"Metric '{metric}' needs {kind} for model '{model}'.")
|
|
321
|
+
calls[(model, metric)] = MetricCall(
|
|
322
|
+
fn,
|
|
323
|
+
yt,
|
|
324
|
+
None if prob else source[model],
|
|
325
|
+
source[model] if prob else None,
|
|
326
|
+
sample_weight,
|
|
327
|
+
kwargs_by_metric.get(metric),
|
|
328
|
+
)
|
|
329
|
+
# identical label set for every model of this metric
|
|
330
|
+
labs = [calls[(m, metric)].kwargs.get("labels") for m in names]
|
|
331
|
+
if all(lab is not None for lab in labs):
|
|
332
|
+
shared = labs[0]
|
|
333
|
+
for lab in labs[1:]:
|
|
334
|
+
shared = np.union1d(shared, lab)
|
|
335
|
+
for m in names:
|
|
336
|
+
calls[(m, metric)].kwargs["labels"] = shared
|
|
337
|
+
|
|
338
|
+
rng = np.random.default_rng(random_state)
|
|
339
|
+
use_strata = categorical if stratify is None else bool(stratify)
|
|
340
|
+
idx = resample_indices(yt.shape[0], n_resamples, rng, yt if use_strata else None)
|
|
341
|
+
a = (1 - level) / 2
|
|
342
|
+
boot: dict[tuple[str, str], np.ndarray] = {}
|
|
343
|
+
estimates: list[dict[str, Any]] = []
|
|
344
|
+
for metric in metric_names:
|
|
345
|
+
for model in names:
|
|
346
|
+
call = calls[(model, metric)]
|
|
347
|
+
dist, failed = bootstrap_distribution(call, idx)
|
|
348
|
+
boot[(model, metric)] = dist
|
|
349
|
+
low, high = np.nanquantile(dist, [a, 1 - a])
|
|
350
|
+
estimates.append(
|
|
351
|
+
{
|
|
352
|
+
"model": model,
|
|
353
|
+
"metric": metric,
|
|
354
|
+
"estimate": call(),
|
|
355
|
+
"low": float(low),
|
|
356
|
+
"high": float(high),
|
|
357
|
+
"failed_resamples": int(failed),
|
|
358
|
+
}
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
pairs = (
|
|
362
|
+
[(m, baseline) for m in names if m != baseline]
|
|
363
|
+
if baseline is not None
|
|
364
|
+
else list(itertools.combinations(names, 2))
|
|
365
|
+
)
|
|
366
|
+
tests: list[dict[str, Any]] = []
|
|
367
|
+
for metric in metric_names:
|
|
368
|
+
block: list[dict[str, Any]] = []
|
|
369
|
+
for ma, mb in pairs:
|
|
370
|
+
diff_dist = boot[(ma, metric)] - boot[(mb, metric)]
|
|
371
|
+
diff_dist = diff_dist[~np.isnan(diff_dist)]
|
|
372
|
+
observed = calls[(ma, metric)]() - calls[(mb, metric)]()
|
|
373
|
+
low, high = np.quantile(diff_dist, [a, 1 - a])
|
|
374
|
+
if metric == "accuracy" and sample_weight is None:
|
|
375
|
+
t = mcnemar_test(yt, predictions[ma], predictions[mb])
|
|
376
|
+
test_name, stat, p = t.test, t.statistic, t.p_value
|
|
377
|
+
elif metric == "roc_auc" and sample_weight is None and yt.ndim == 1 and np.unique(yt).shape[0] == 2:
|
|
378
|
+
t = delong_test(yt, probabilities[ma], probabilities[mb])
|
|
379
|
+
test_name, stat, p = t.test, t.statistic, t.p_value
|
|
380
|
+
else:
|
|
381
|
+
extreme = np.sum(np.abs(diff_dist - observed) >= abs(observed))
|
|
382
|
+
test_name, stat, p = "paired-bootstrap", observed, (1 + extreme) / (diff_dist.shape[0] + 1)
|
|
383
|
+
block.append(
|
|
384
|
+
{
|
|
385
|
+
"metric": metric,
|
|
386
|
+
"model_a": ma,
|
|
387
|
+
"model_b": mb,
|
|
388
|
+
"difference": float(observed),
|
|
389
|
+
"ci_low": float(low),
|
|
390
|
+
"ci_high": float(high),
|
|
391
|
+
"test": test_name,
|
|
392
|
+
"statistic": float(stat),
|
|
393
|
+
"p_value": float(p),
|
|
394
|
+
}
|
|
395
|
+
)
|
|
396
|
+
if block:
|
|
397
|
+
adjusted = adjust_pvalues([r["p_value"] for r in block], method=correction) # type: ignore[arg-type]
|
|
398
|
+
for r, pa in zip(block, adjusted):
|
|
399
|
+
r["p_adjusted"] = float(pa)
|
|
400
|
+
r["significant"] = bool(pa < alpha)
|
|
401
|
+
tests.extend(block)
|
|
402
|
+
|
|
403
|
+
settings = {
|
|
404
|
+
"n_samples": int(yt.shape[0]),
|
|
405
|
+
"level": level,
|
|
406
|
+
"alpha": alpha,
|
|
407
|
+
"n_resamples": n_resamples,
|
|
408
|
+
"correction": correction,
|
|
409
|
+
"random_state": random_state,
|
|
410
|
+
"stratified": use_strata,
|
|
411
|
+
"baseline": baseline,
|
|
412
|
+
"ci_method": "bootstrap-percentile (paired resamples)",
|
|
413
|
+
}
|
|
414
|
+
return ComparisonResult(tuple(names), tuple(metric_names), tuple(estimates), tuple(tests), settings)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Effect sizes and multiple-comparison correction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from numpy.typing import NDArray
|
|
9
|
+
from scipy import special
|
|
10
|
+
|
|
11
|
+
from ..core.exceptions import InputValidationError, StatisticalTestError
|
|
12
|
+
from ..core.types import ArrayLike
|
|
13
|
+
from ..core.validation import check_finite, to_numpy
|
|
14
|
+
|
|
15
|
+
__all__ = ["adjust_pvalues", "cliffs_delta", "cohens_d", "hedges_g"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _samples(a: ArrayLike, b: ArrayLike) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
19
|
+
x = to_numpy(a, "a").astype(np.float64)
|
|
20
|
+
y = to_numpy(b, "b").astype(np.float64)
|
|
21
|
+
check_finite(x, "a")
|
|
22
|
+
check_finite(y, "b")
|
|
23
|
+
return x, y
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cohens_d(a: ArrayLike, b: ArrayLike, *, paired: bool = False) -> float:
|
|
27
|
+
"""Cohen's d for mean(a) − mean(b).
|
|
28
|
+
|
|
29
|
+
Independent samples: difference over the pooled standard deviation. ``paired=True``: mean difference over
|
|
30
|
+
the standard deviation of the differences (d_z), for per-fold or per-subject scores of two models.
|
|
31
|
+
|
|
32
|
+
Reference: Cohen J. Statistical Power Analysis for the Behavioral Sciences. 2nd ed. Erlbaum; 1988.
|
|
33
|
+
Lakens D. Calculating and reporting effect sizes. Front Psychol. 2013;4:863.
|
|
34
|
+
"""
|
|
35
|
+
x, y = _samples(a, b)
|
|
36
|
+
if paired:
|
|
37
|
+
if x.shape != y.shape:
|
|
38
|
+
raise InputValidationError("Paired samples must have the same length.")
|
|
39
|
+
d = x - y
|
|
40
|
+
if d.shape[0] < 2:
|
|
41
|
+
raise StatisticalTestError("Cohen's d needs at least two pairs.")
|
|
42
|
+
sd = d.std(ddof=1)
|
|
43
|
+
if sd == 0:
|
|
44
|
+
raise StatisticalTestError("Cohen's d is undefined: the paired differences are all identical.")
|
|
45
|
+
return float(d.mean() / sd)
|
|
46
|
+
n1, n2 = x.shape[0], y.shape[0]
|
|
47
|
+
if n1 < 2 or n2 < 2:
|
|
48
|
+
raise StatisticalTestError("Cohen's d needs at least two observations per group.")
|
|
49
|
+
pooled = np.sqrt(((n1 - 1) * x.var(ddof=1) + (n2 - 1) * y.var(ddof=1)) / (n1 + n2 - 2))
|
|
50
|
+
if pooled == 0:
|
|
51
|
+
raise StatisticalTestError("Cohen's d is undefined: both groups have zero variance.")
|
|
52
|
+
return float((x.mean() - y.mean()) / pooled)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def hedges_g(a: ArrayLike, b: ArrayLike, *, paired: bool = False) -> float:
|
|
56
|
+
"""Hedges' g: Cohen's d with the exact small-sample bias correction J(df) = Γ(df/2) / (√(df/2) Γ((df−1)/2)).
|
|
57
|
+
|
|
58
|
+
Reference: Hedges LV. Distribution theory for Glass's estimator of effect size and related estimators.
|
|
59
|
+
J Educ Stat. 1981;6(2):107-128.
|
|
60
|
+
"""
|
|
61
|
+
x, y = _samples(a, b)
|
|
62
|
+
df = (x.shape[0] - 1) if paired else (x.shape[0] + y.shape[0] - 2)
|
|
63
|
+
if df < 2:
|
|
64
|
+
raise StatisticalTestError("Hedges' g needs at least three observations.")
|
|
65
|
+
j = np.exp(special.gammaln(df / 2) - special.gammaln((df - 1) / 2)) / np.sqrt(df / 2)
|
|
66
|
+
return float(j * cohens_d(a, b, paired=paired))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cliffs_delta(a: ArrayLike, b: ArrayLike) -> float:
|
|
70
|
+
"""Cliff's delta: P(a > b) − P(a < b) over all pairs; a non-parametric effect size in [−1, 1].
|
|
71
|
+
|
|
72
|
+
Computed in O((n + m) log m). Reference: Cliff N. Dominance statistics: ordinal analyses to answer ordinal
|
|
73
|
+
questions. Psychol Bull. 1993;114(3):494-509.
|
|
74
|
+
"""
|
|
75
|
+
x, y = _samples(a, b)
|
|
76
|
+
ys = np.sort(y)
|
|
77
|
+
less = np.searchsorted(ys, x, side="left") # y values below each x
|
|
78
|
+
greater = y.shape[0] - np.searchsorted(ys, x, side="right") # y values above each x
|
|
79
|
+
return float((less.sum() - greater.sum()) / (x.shape[0] * y.shape[0]))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def adjust_pvalues(
|
|
83
|
+
p_values: ArrayLike, *, method: Literal["bonferroni", "holm", "bh", "by"] = "holm"
|
|
84
|
+
) -> NDArray[np.float64]:
|
|
85
|
+
"""Adjust p-values for multiple comparisons (same order as the input).
|
|
86
|
+
|
|
87
|
+
``"holm"`` (default; controls the family-wise error rate, uniformly more powerful than Bonferroni),
|
|
88
|
+
``"bonferroni"``, ``"bh"`` (Benjamini-Hochberg false discovery rate) or ``"by"``
|
|
89
|
+
(Benjamini-Yekutieli, FDR under arbitrary dependence).
|
|
90
|
+
|
|
91
|
+
References: Holm S. Scand J Stat. 1979;6(2):65-70. Benjamini Y, Hochberg Y. J R Stat Soc B.
|
|
92
|
+
1995;57(1):289-300. Benjamini Y, Yekutieli D. Ann Stat. 2001;29(4):1165-1188.
|
|
93
|
+
"""
|
|
94
|
+
p = to_numpy(p_values, "p_values").astype(np.float64)
|
|
95
|
+
check_finite(p, "p_values")
|
|
96
|
+
if np.any((p < 0) | (p > 1)):
|
|
97
|
+
raise InputValidationError("p-values must be between 0 and 1.")
|
|
98
|
+
m = p.shape[0]
|
|
99
|
+
order = np.argsort(p, kind="mergesort")
|
|
100
|
+
ranked = p[order]
|
|
101
|
+
if method == "bonferroni":
|
|
102
|
+
adj = np.minimum(ranked * m, 1)
|
|
103
|
+
elif method == "holm":
|
|
104
|
+
adj = np.minimum(np.maximum.accumulate(ranked * (m - np.arange(m))), 1)
|
|
105
|
+
elif method in ("bh", "by"):
|
|
106
|
+
factor = np.sum(1.0 / np.arange(1, m + 1)) if method == "by" else 1.0
|
|
107
|
+
scaled = ranked * m * factor / np.arange(1, m + 1)
|
|
108
|
+
adj = np.minimum(np.minimum.accumulate(scaled[::-1])[::-1], 1)
|
|
109
|
+
else:
|
|
110
|
+
raise InputValidationError("method must be 'holm', 'bonferroni', 'bh' or 'by'.")
|
|
111
|
+
out = np.empty(m)
|
|
112
|
+
out[order] = adj
|
|
113
|
+
return out
|