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.
@@ -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