pyfastauc 0.1.0__cp39-abi3-win_amd64.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.
- fastauc/__init__.py +1389 -0
- fastauc/_core.pyd +0 -0
- fastauc/_core.pyi +48 -0
- fastauc/py.typed +0 -0
- pyfastauc-0.1.0.dist-info/METADATA +455 -0
- pyfastauc-0.1.0.dist-info/RECORD +9 -0
- pyfastauc-0.1.0.dist-info/WHEEL +4 -0
- pyfastauc-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyfastauc-0.1.0.dist-info/sboms/fastauc.cyclonedx.json +1111 -0
fastauc/__init__.py
ADDED
|
@@ -0,0 +1,1389 @@
|
|
|
1
|
+
"""fastauc: fast, multithreaded classification, calibration and ranking metrics.
|
|
2
|
+
|
|
3
|
+
Results match ``sklearn.metrics.roc_auc_score`` and
|
|
4
|
+
``sklearn.metrics.average_precision_score`` for binary targets, including
|
|
5
|
+
tied scores and sample weights.
|
|
6
|
+
|
|
7
|
+
Two summaries of the precision-recall curve are kept apart by name:
|
|
8
|
+
|
|
9
|
+
* **average precision** (:func:`average_precision`): the step-wise area
|
|
10
|
+
``sum(dR * P)``. This is the default everywhere a PR summary is reported.
|
|
11
|
+
* **PR-AUC** (:func:`pr_auc`): the trapezoidal area
|
|
12
|
+
``sum(dR * (P + P_prev) / 2)``, starting from ``(R, P) = (0, 1)``, as
|
|
13
|
+
computed by ``sklearn.metrics.auc(recall, precision)``.
|
|
14
|
+
|
|
15
|
+
Conventions
|
|
16
|
+
-----------
|
|
17
|
+
* Labels are binary. Pass ``bool`` or ``{0, 1}`` arrays, or name the positive
|
|
18
|
+
class with ``pos_label``.
|
|
19
|
+
* Batched inputs are laid out as ``(n_tasks, n_samples)``: one **row** per
|
|
20
|
+
model / target. (scikit-learn uses columns; pass ``arr.T`` if needed.)
|
|
21
|
+
* Undefined metrics are returned as ``NaN`` instead of raising, so one
|
|
22
|
+
degenerate row never kills a batch: ROC-AUC needs both classes, average
|
|
23
|
+
precision needs at least one positive.
|
|
24
|
+
* ``NaN`` scores raise ``ValueError``.
|
|
25
|
+
* A threshold ``t`` always means "predict positive when ``score >= t``".
|
|
26
|
+
* No result depends on the order of the input rows. Samples with equal
|
|
27
|
+
scores are treated as interchangeable everywhere: threshold metrics only
|
|
28
|
+
cut between distinct scores, and top-k ranking metrics are averaged over
|
|
29
|
+
all orderings of the tied items (exactly, in closed form, with no random
|
|
30
|
+
tie-breaking).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import math
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from statistics import NormalDist
|
|
38
|
+
from typing import Any, Dict, Optional, Sequence, Tuple, Union
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
|
|
42
|
+
from . import _core
|
|
43
|
+
|
|
44
|
+
__version__ = "0.1.0"
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"roc_auc",
|
|
48
|
+
"average_precision",
|
|
49
|
+
"pr_auc",
|
|
50
|
+
"roc_auc_ap",
|
|
51
|
+
"roc_auc_ap_batch",
|
|
52
|
+
"roc_auc_ap_grouped",
|
|
53
|
+
"bootstrap_roc_auc_ap",
|
|
54
|
+
"binary_metrics",
|
|
55
|
+
"ks_statistic",
|
|
56
|
+
"max_f1",
|
|
57
|
+
"precision_at_recall",
|
|
58
|
+
"recall_at_precision",
|
|
59
|
+
"tpr_at_fpr",
|
|
60
|
+
"lift_at",
|
|
61
|
+
"roc_curve",
|
|
62
|
+
"precision_recall_curve",
|
|
63
|
+
"calibration_curve",
|
|
64
|
+
"delong_roc_auc",
|
|
65
|
+
"DeLongResult",
|
|
66
|
+
"calibration_metrics",
|
|
67
|
+
"log_loss",
|
|
68
|
+
"brier_score",
|
|
69
|
+
"normalized_entropy",
|
|
70
|
+
"calibration_ratio",
|
|
71
|
+
"expected_calibration_error",
|
|
72
|
+
"ranking_metrics",
|
|
73
|
+
"bootstrap_ranking_metrics",
|
|
74
|
+
"RankingBootstrapResult",
|
|
75
|
+
"set_num_threads",
|
|
76
|
+
"BootstrapResult",
|
|
77
|
+
"GroupedResult",
|
|
78
|
+
"num_threads",
|
|
79
|
+
"__version__",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
ArrayLike = Any
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Input normalisation
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _labels(y_true: ArrayLike, pos_label: Any = None) -> np.ndarray:
|
|
91
|
+
a = np.asarray(y_true)
|
|
92
|
+
if pos_label is not None:
|
|
93
|
+
return np.ascontiguousarray(a == pos_label, dtype=np.uint8)
|
|
94
|
+
if a.dtype == np.bool_:
|
|
95
|
+
return np.ascontiguousarray(a).view(np.uint8)
|
|
96
|
+
if a.dtype.kind not in "iuf":
|
|
97
|
+
raise TypeError(
|
|
98
|
+
f"y_true has dtype {a.dtype}; pass bool or 0/1 values, or set pos_label"
|
|
99
|
+
)
|
|
100
|
+
if a.size and not ((a == 0) | (a == 1)).all():
|
|
101
|
+
raise ValueError(
|
|
102
|
+
"y_true must contain only 0 and 1 (or bool); set pos_label to "
|
|
103
|
+
"choose the positive class for other label encodings"
|
|
104
|
+
)
|
|
105
|
+
return np.ascontiguousarray(a, dtype=np.uint8)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _scores(y_score: ArrayLike) -> np.ndarray:
|
|
109
|
+
a = np.asarray(y_score)
|
|
110
|
+
if a.dtype.kind not in "iufb":
|
|
111
|
+
raise TypeError(f"y_score has non-numeric dtype {a.dtype}")
|
|
112
|
+
return np.ascontiguousarray(a, dtype=np.float64)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _weights(sample_weight: Optional[ArrayLike]) -> Optional[np.ndarray]:
|
|
116
|
+
if sample_weight is None:
|
|
117
|
+
return None
|
|
118
|
+
w = np.ascontiguousarray(sample_weight, dtype=np.float64)
|
|
119
|
+
if w.ndim != 1:
|
|
120
|
+
raise ValueError("sample_weight must be 1-D")
|
|
121
|
+
return w
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _require_1d(name: str, a: np.ndarray) -> None:
|
|
125
|
+
if a.ndim != 1:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"{name} must be 1-D, got shape {a.shape}; "
|
|
128
|
+
"use roc_auc_ap_batch for 2-D inputs"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _samples_last(a: np.ndarray, axis: int) -> np.ndarray:
|
|
133
|
+
"""Return 2-D ``a`` as C-contiguous (n_tasks, n_samples)."""
|
|
134
|
+
if axis not in (0, 1, -1, -2):
|
|
135
|
+
raise ValueError("axis must be 0 or 1 (-1)")
|
|
136
|
+
if a.ndim == 2 and axis in (0, -2):
|
|
137
|
+
return np.ascontiguousarray(a.T)
|
|
138
|
+
return a
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _group_codes(groups: ArrayLike, n: int) -> Tuple[np.ndarray, np.ndarray]:
|
|
142
|
+
"""Map group ids to dense codes. Returns (sorted unique ids, int64 codes)."""
|
|
143
|
+
g = np.asarray(groups)
|
|
144
|
+
_require_1d("groups", g)
|
|
145
|
+
if len(g) != n:
|
|
146
|
+
raise ValueError(f"groups has length {len(g)}, expected {n}")
|
|
147
|
+
if g.dtype.kind in "iu" and g.size:
|
|
148
|
+
# Integer ids in a compact range: O(N), no sort.
|
|
149
|
+
lo, hi = int(g.min()), int(g.max())
|
|
150
|
+
width = hi - lo + 1
|
|
151
|
+
if width <= 2 * g.size + 1024:
|
|
152
|
+
raw = (g - lo).astype(np.int64)
|
|
153
|
+
present = np.bincount(raw, minlength=width) > 0
|
|
154
|
+
if present.all():
|
|
155
|
+
return np.arange(lo, hi + 1).astype(g.dtype), np.ascontiguousarray(raw)
|
|
156
|
+
remap = np.cumsum(present) - 1
|
|
157
|
+
ids = (np.flatnonzero(present) + lo).astype(g.dtype)
|
|
158
|
+
return ids, np.ascontiguousarray(remap[raw], dtype=np.int64)
|
|
159
|
+
ids, codes = np.unique(g, return_inverse=True)
|
|
160
|
+
return ids, np.ascontiguousarray(codes.reshape(-1), dtype=np.int64)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _percentile_ci(s: np.ndarray, level: float) -> Any:
|
|
164
|
+
if not 0.0 < level < 1.0:
|
|
165
|
+
raise ValueError("level must be in (0, 1)")
|
|
166
|
+
tail = (1.0 - level) / 2.0 * 100.0
|
|
167
|
+
lo, hi = np.nanpercentile(s, [tail, 100.0 - tail], axis=-1)
|
|
168
|
+
if s.ndim == 1:
|
|
169
|
+
return float(lo), float(hi)
|
|
170
|
+
return np.stack([lo, hi], axis=-1)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _paired_compare(s: np.ndarray, est: Any, a: int, b: int, level: float):
|
|
174
|
+
if s.ndim != 2:
|
|
175
|
+
raise ValueError("compare() needs a result computed from 2-D scores")
|
|
176
|
+
if not 0.0 < level < 1.0:
|
|
177
|
+
raise ValueError("level must be in (0, 1)")
|
|
178
|
+
est = np.asarray(est)
|
|
179
|
+
d = s[a] - s[b]
|
|
180
|
+
d = d[~np.isnan(d)]
|
|
181
|
+
if d.size == 0:
|
|
182
|
+
return float(est[a] - est[b]), (float("nan"), float("nan")), float("nan")
|
|
183
|
+
tail = (1.0 - level) / 2.0 * 100.0
|
|
184
|
+
lo, hi = np.percentile(d, [tail, 100.0 - tail])
|
|
185
|
+
# Ties at zero count half; +1 smoothing keeps p > 0.
|
|
186
|
+
below = (d < 0).sum() + 0.5 * (d == 0).sum()
|
|
187
|
+
p = 2.0 * min(below + 1.0, d.size - below + 1.0) / (d.size + 1.0)
|
|
188
|
+
return float(est[a] - est[b]), (float(lo), float(hi)), float(min(p, 1.0))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
# Single pair
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def roc_auc_ap(
|
|
197
|
+
y_true: ArrayLike,
|
|
198
|
+
y_score: ArrayLike,
|
|
199
|
+
*,
|
|
200
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
201
|
+
pos_label: Any = None,
|
|
202
|
+
) -> Tuple[float, float]:
|
|
203
|
+
"""ROC-AUC and average precision from a single sort.
|
|
204
|
+
|
|
205
|
+
Returns
|
|
206
|
+
-------
|
|
207
|
+
(roc_auc, average_precision) : tuple of float
|
|
208
|
+
"""
|
|
209
|
+
yt, ys = _labels(y_true, pos_label), _scores(y_score)
|
|
210
|
+
_require_1d("y_true", yt)
|
|
211
|
+
_require_1d("y_score", ys)
|
|
212
|
+
return _core.roc_auc_ap(yt, ys, _weights(sample_weight))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def roc_auc(
|
|
216
|
+
y_true: ArrayLike,
|
|
217
|
+
y_score: ArrayLike,
|
|
218
|
+
*,
|
|
219
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
220
|
+
pos_label: Any = None,
|
|
221
|
+
) -> float:
|
|
222
|
+
"""Area under the ROC curve. Equivalent to ``sklearn.metrics.roc_auc_score``."""
|
|
223
|
+
return roc_auc_ap(y_true, y_score, sample_weight=sample_weight, pos_label=pos_label)[0]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def average_precision(
|
|
227
|
+
y_true: ArrayLike,
|
|
228
|
+
y_score: ArrayLike,
|
|
229
|
+
*,
|
|
230
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
231
|
+
pos_label: Any = None,
|
|
232
|
+
) -> float:
|
|
233
|
+
"""Average precision: the step-wise area under the precision-recall curve,
|
|
234
|
+
``sum over thresholds of (R_k - R_{k-1}) * P_k``.
|
|
235
|
+
|
|
236
|
+
Equivalent to ``sklearn.metrics.average_precision_score``. Each recall
|
|
237
|
+
step is credited with the precision reached at its end, so no point that
|
|
238
|
+
no threshold achieves is ever used. Prefer this over :func:`pr_auc`.
|
|
239
|
+
"""
|
|
240
|
+
return roc_auc_ap(y_true, y_score, sample_weight=sample_weight, pos_label=pos_label)[1]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def pr_auc(
|
|
244
|
+
y_true: ArrayLike,
|
|
245
|
+
y_score: ArrayLike,
|
|
246
|
+
*,
|
|
247
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
248
|
+
pos_label: Any = None,
|
|
249
|
+
) -> float:
|
|
250
|
+
"""PR-AUC: the trapezoidal area under the precision-recall curve,
|
|
251
|
+
``sum over thresholds of (R_k - R_{k-1}) * (P_k + P_{k-1}) / 2``, starting
|
|
252
|
+
from ``(R, P) = (0, 1)``.
|
|
253
|
+
|
|
254
|
+
Equivalent to ``sklearn.metrics.auc(recall, precision)`` on the output of
|
|
255
|
+
``sklearn.metrics.precision_recall_curve`` (and of
|
|
256
|
+
:func:`precision_recall_curve`). It walks the same curve as
|
|
257
|
+
:func:`average_precision` and differs only in joining consecutive points
|
|
258
|
+
with straight lines. Straight lines in precision-recall space are not
|
|
259
|
+
achievable by any classifier (Davis and Goadrich, 2006), and the starting
|
|
260
|
+
point ``(0, 1)`` is credited even when the top-scored item is negative, so
|
|
261
|
+
this can overstate performance. Use it to reproduce numbers from pipelines
|
|
262
|
+
that compute "PR-AUC" with the trapezoidal rule; report
|
|
263
|
+
:func:`average_precision` otherwise. ``NaN`` without positives.
|
|
264
|
+
"""
|
|
265
|
+
return _core.binary_summary(*_pair(y_true, y_score, sample_weight, pos_label))[6]
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
# Batched
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def roc_auc_ap_batch(
|
|
274
|
+
y_true: ArrayLike,
|
|
275
|
+
y_score: ArrayLike,
|
|
276
|
+
*,
|
|
277
|
+
axis: int = -1,
|
|
278
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
279
|
+
pos_label: Any = None,
|
|
280
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
281
|
+
"""Evaluate many (labels, scores) pairs in parallel.
|
|
282
|
+
|
|
283
|
+
The shapes decide what is computed:
|
|
284
|
+
|
|
285
|
+
====================== ====================== =================================
|
|
286
|
+
``y_true`` ``y_score`` meaning
|
|
287
|
+
====================== ====================== =================================
|
|
288
|
+
``(n_samples,)`` ``(m, n_samples)`` m models, one target
|
|
289
|
+
``(m, n_samples)`` ``(n_samples,)`` one model, m targets (sorts once)
|
|
290
|
+
``(m, n_samples)`` ``(m, n_samples)`` row i against row i
|
|
291
|
+
====================== ====================== =================================
|
|
292
|
+
|
|
293
|
+
``axis`` is the axis of 2-D inputs that runs over samples. The default
|
|
294
|
+
``-1`` is the layout above; pass ``axis=0`` for the scikit-learn layout
|
|
295
|
+
``(n_samples, m)``.
|
|
296
|
+
|
|
297
|
+
``sample_weight``, if given, is ``(n_samples,)`` and shared by all tasks.
|
|
298
|
+
|
|
299
|
+
Returns
|
|
300
|
+
-------
|
|
301
|
+
(roc_auc, average_precision) : tuple of ndarray, each of shape ``(m,)``
|
|
302
|
+
"""
|
|
303
|
+
yt, ys = _labels(y_true, pos_label), _scores(y_score)
|
|
304
|
+
yt, ys = _samples_last(yt, axis), _samples_last(ys, axis)
|
|
305
|
+
w = _weights(sample_weight)
|
|
306
|
+
if yt.ndim > 2 or ys.ndim > 2 or (yt.ndim == 1 and ys.ndim == 1):
|
|
307
|
+
raise ValueError(
|
|
308
|
+
f"unsupported shapes: y_true {yt.shape}, y_score {ys.shape}; "
|
|
309
|
+
"at least one must be 2-D and neither more than 2-D"
|
|
310
|
+
)
|
|
311
|
+
if yt.shape[-1] != ys.shape[-1]:
|
|
312
|
+
other = 0 if axis in (1, -1) else -1
|
|
313
|
+
raise ValueError(
|
|
314
|
+
f"length mismatch along the sample axis: y_true {np.shape(y_true)}, "
|
|
315
|
+
f"y_score {np.shape(y_score)}. If samples run along the other axis, "
|
|
316
|
+
f"pass axis={other}"
|
|
317
|
+
)
|
|
318
|
+
if yt.ndim == 1:
|
|
319
|
+
return _core.many_scores(yt, ys, w)
|
|
320
|
+
if ys.ndim == 1:
|
|
321
|
+
return _core.many_targets(yt, ys, w)
|
|
322
|
+
return _core.pairwise(yt, ys, w)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ---------------------------------------------------------------------------
|
|
326
|
+
# Grouped
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@dataclass(frozen=True)
|
|
331
|
+
class GroupedResult:
|
|
332
|
+
"""Per-group metrics from :func:`roc_auc_ap_grouped`.
|
|
333
|
+
|
|
334
|
+
All arrays are aligned with ``groups`` (the sorted unique group ids).
|
|
335
|
+
"""
|
|
336
|
+
|
|
337
|
+
groups: np.ndarray
|
|
338
|
+
roc_auc: np.ndarray
|
|
339
|
+
average_precision: np.ndarray
|
|
340
|
+
n_pos: np.ndarray
|
|
341
|
+
n_neg: np.ndarray
|
|
342
|
+
|
|
343
|
+
def mean(self, metric: str = "roc_auc", *, weight: str = "size") -> float:
|
|
344
|
+
"""Average a metric over groups where it is defined.
|
|
345
|
+
|
|
346
|
+
Parameters
|
|
347
|
+
----------
|
|
348
|
+
metric : ``"roc_auc"`` or ``"average_precision"``
|
|
349
|
+
weight : ``"size"`` weights each group by its sample count (the usual
|
|
350
|
+
"GAUC"); ``"uniform"`` gives every group equal weight.
|
|
351
|
+
"""
|
|
352
|
+
if metric not in ("roc_auc", "average_precision"):
|
|
353
|
+
raise ValueError("metric must be 'roc_auc' or 'average_precision'")
|
|
354
|
+
values = getattr(self, metric)
|
|
355
|
+
if weight == "size":
|
|
356
|
+
w = (self.n_pos + self.n_neg).astype(np.float64)
|
|
357
|
+
elif weight == "uniform":
|
|
358
|
+
w = np.ones(len(values))
|
|
359
|
+
else:
|
|
360
|
+
raise ValueError("weight must be 'size' or 'uniform'")
|
|
361
|
+
ok = ~np.isnan(values)
|
|
362
|
+
if not ok.any():
|
|
363
|
+
return float("nan")
|
|
364
|
+
return float(np.average(values[ok], weights=w[ok]))
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def roc_auc_ap_grouped(
|
|
368
|
+
y_true: ArrayLike,
|
|
369
|
+
y_score: ArrayLike,
|
|
370
|
+
groups: ArrayLike,
|
|
371
|
+
*,
|
|
372
|
+
pos_label: Any = None,
|
|
373
|
+
) -> GroupedResult:
|
|
374
|
+
"""ROC-AUC and average precision within each group, in parallel.
|
|
375
|
+
|
|
376
|
+
Typical use: per-query or per-user AUC for ranking and recommendation
|
|
377
|
+
models. ``groups`` is any 1-D array of ids (ints, strings, ...); samples
|
|
378
|
+
need not be sorted by group.
|
|
379
|
+
"""
|
|
380
|
+
yt, ys = _labels(y_true, pos_label), _scores(y_score)
|
|
381
|
+
_require_1d("y_true", yt)
|
|
382
|
+
_require_1d("y_score", ys)
|
|
383
|
+
uniq, codes = _group_codes(groups, len(yt))
|
|
384
|
+
roc, ap = _core.grouped(yt, ys, codes, len(uniq))
|
|
385
|
+
n_pos = np.bincount(codes, weights=yt, minlength=len(uniq)).astype(np.int64)
|
|
386
|
+
n_all = np.bincount(codes, minlength=len(uniq)).astype(np.int64)
|
|
387
|
+
return GroupedResult(uniq, roc, ap, n_pos, n_all - n_pos)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# ---------------------------------------------------------------------------
|
|
391
|
+
# Bootstrap
|
|
392
|
+
# ---------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@dataclass(frozen=True)
|
|
396
|
+
class BootstrapResult:
|
|
397
|
+
"""Bootstrap distribution from :func:`bootstrap_roc_auc_ap`.
|
|
398
|
+
|
|
399
|
+
Attributes
|
|
400
|
+
----------
|
|
401
|
+
roc_auc, average_precision : ndarray
|
|
402
|
+
Replicates. Shape ``(n_bootstraps,)`` for one model, or
|
|
403
|
+
``(n_models, n_bootstraps)`` when 2-D scores were passed. Column ``b``
|
|
404
|
+
uses the same resample for every model, so differences between rows
|
|
405
|
+
are paired.
|
|
406
|
+
roc_auc_estimate, average_precision_estimate : float or ndarray
|
|
407
|
+
The metrics on the original (un-resampled) data.
|
|
408
|
+
"""
|
|
409
|
+
|
|
410
|
+
roc_auc: np.ndarray
|
|
411
|
+
average_precision: np.ndarray
|
|
412
|
+
roc_auc_estimate: Any
|
|
413
|
+
average_precision_estimate: Any
|
|
414
|
+
|
|
415
|
+
def _samples(self, metric: str) -> np.ndarray:
|
|
416
|
+
if metric not in ("roc_auc", "average_precision"):
|
|
417
|
+
raise ValueError("metric must be 'roc_auc' or 'average_precision'")
|
|
418
|
+
return getattr(self, metric)
|
|
419
|
+
|
|
420
|
+
def ci(self, metric: str = "roc_auc", level: float = 0.95) -> Any:
|
|
421
|
+
"""Percentile confidence interval.
|
|
422
|
+
|
|
423
|
+
Returns ``(low, high)`` floats for one model, or an array of shape
|
|
424
|
+
``(n_models, 2)``. Replicates where the metric is undefined are ignored.
|
|
425
|
+
"""
|
|
426
|
+
return _percentile_ci(self._samples(metric), level)
|
|
427
|
+
|
|
428
|
+
def std(self, metric: str = "roc_auc") -> Any:
|
|
429
|
+
"""Bootstrap standard error."""
|
|
430
|
+
s = np.nanstd(self._samples(metric), axis=-1, ddof=1)
|
|
431
|
+
return float(s) if np.ndim(s) == 0 else s
|
|
432
|
+
|
|
433
|
+
def compare(self, a: int, b: int, metric: str = "roc_auc", level: float = 0.95):
|
|
434
|
+
"""Paired comparison of model ``a`` minus model ``b``.
|
|
435
|
+
|
|
436
|
+
Returns ``(difference, (low, high), p_value)`` where the interval is a
|
|
437
|
+
percentile CI of the paired difference and ``p_value`` is a two-sided
|
|
438
|
+
bootstrap p-value for "no difference".
|
|
439
|
+
"""
|
|
440
|
+
return _paired_compare(
|
|
441
|
+
self._samples(metric), getattr(self, metric + "_estimate"), a, b, level
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def bootstrap_roc_auc_ap(
|
|
446
|
+
y_true: ArrayLike,
|
|
447
|
+
y_score: ArrayLike,
|
|
448
|
+
*,
|
|
449
|
+
n_bootstraps: int = 1000,
|
|
450
|
+
seed: int = 0,
|
|
451
|
+
stratified: bool = False,
|
|
452
|
+
groups: Optional[ArrayLike] = None,
|
|
453
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
454
|
+
axis: int = -1,
|
|
455
|
+
pos_label: Any = None,
|
|
456
|
+
) -> BootstrapResult:
|
|
457
|
+
"""Bootstrap ROC-AUC and average precision without re-sorting.
|
|
458
|
+
|
|
459
|
+
Scores are sorted once; every replicate is then a linear scan with
|
|
460
|
+
resampling multiplicities as weights, run across all cores.
|
|
461
|
+
|
|
462
|
+
Parameters
|
|
463
|
+
----------
|
|
464
|
+
y_true : (n_samples,)
|
|
465
|
+
y_score : (n_samples,) or (n_models, n_samples)
|
|
466
|
+
With 2-D scores, every replicate uses the same resample for all
|
|
467
|
+
models, which is what you want for comparing them (see
|
|
468
|
+
:meth:`BootstrapResult.compare`). Pass ``axis=0`` if the array is
|
|
469
|
+
``(n_samples, n_models)``.
|
|
470
|
+
n_bootstraps : number of replicates.
|
|
471
|
+
seed : results are reproducible for a given seed, independent of the
|
|
472
|
+
number of threads.
|
|
473
|
+
stratified : resample positives and negatives separately, keeping the
|
|
474
|
+
class balance fixed. Recommended for rare positives, where plain
|
|
475
|
+
resampling can produce replicates with no positives (``NaN``).
|
|
476
|
+
groups : (n_samples,), optional
|
|
477
|
+
Cluster bootstrap: resample whole groups (users, queries, sessions)
|
|
478
|
+
instead of rows. Use this whenever rows within a group are
|
|
479
|
+
correlated, which is the norm for search, ads and recommendation
|
|
480
|
+
logs; row-level resampling then gives intervals that are too narrow.
|
|
481
|
+
Cannot be combined with ``stratified``.
|
|
482
|
+
sample_weight : (n_samples,), optional
|
|
483
|
+
"""
|
|
484
|
+
yt, ys = _labels(y_true, pos_label), _scores(y_score)
|
|
485
|
+
_require_1d("y_true", yt)
|
|
486
|
+
if ys.ndim not in (1, 2):
|
|
487
|
+
raise ValueError(f"y_score must be 1-D or 2-D, got shape {ys.shape}")
|
|
488
|
+
ys = _samples_last(ys, axis)
|
|
489
|
+
if n_bootstraps < 1:
|
|
490
|
+
raise ValueError("n_bootstraps must be >= 1")
|
|
491
|
+
single = ys.ndim == 1
|
|
492
|
+
ys2 = ys[None, :] if single else ys
|
|
493
|
+
k = ys2.shape[0]
|
|
494
|
+
w = _weights(sample_weight)
|
|
495
|
+
codes, n_groups = None, 0
|
|
496
|
+
if groups is not None:
|
|
497
|
+
if stratified:
|
|
498
|
+
raise ValueError("stratified resampling cannot be combined with groups")
|
|
499
|
+
ids, codes = _group_codes(groups, len(yt))
|
|
500
|
+
n_groups = len(ids)
|
|
501
|
+
|
|
502
|
+
roc_est, ap_est = _core.many_scores(yt, ys2, w)
|
|
503
|
+
roc, ap = _core.bootstrap(
|
|
504
|
+
yt, ys2, int(n_bootstraps), int(seed) % (1 << 64), bool(stratified),
|
|
505
|
+
w, codes, n_groups,
|
|
506
|
+
)
|
|
507
|
+
roc = np.ascontiguousarray(roc.reshape(n_bootstraps, k).T)
|
|
508
|
+
ap = np.ascontiguousarray(ap.reshape(n_bootstraps, k).T)
|
|
509
|
+
if single:
|
|
510
|
+
return BootstrapResult(roc[0], ap[0], float(roc_est[0]), float(ap_est[0]))
|
|
511
|
+
return BootstrapResult(roc, ap, roc_est, ap_est)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# ---------------------------------------------------------------------------
|
|
515
|
+
# Threshold metrics (global sort)
|
|
516
|
+
# ---------------------------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
_NAN = float("nan")
|
|
519
|
+
_INF = float("inf")
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _pair(y_true, y_score, sample_weight, pos_label):
|
|
523
|
+
yt, ys = _labels(y_true, pos_label), _scores(y_score)
|
|
524
|
+
_require_1d("y_true", yt)
|
|
525
|
+
_require_1d("y_score", ys)
|
|
526
|
+
return yt, ys, _weights(sample_weight)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _curve(y_true, y_score, sample_weight, pos_label):
|
|
530
|
+
return _core.curve(*_pair(y_true, y_score, sample_weight, pos_label))
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _target(name: str, value: float) -> float:
|
|
534
|
+
value = float(value)
|
|
535
|
+
if not 0.0 <= value <= 1.0:
|
|
536
|
+
raise ValueError(f"{name} must be in [0, 1]")
|
|
537
|
+
return value
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def ks_statistic(
|
|
541
|
+
y_true: ArrayLike,
|
|
542
|
+
y_score: ArrayLike,
|
|
543
|
+
*,
|
|
544
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
545
|
+
pos_label: Any = None,
|
|
546
|
+
) -> Tuple[float, float]:
|
|
547
|
+
"""Kolmogorov-Smirnov statistic, ``max |TPR - FPR|``.
|
|
548
|
+
|
|
549
|
+
Returns ``(ks, threshold)``.
|
|
550
|
+
"""
|
|
551
|
+
r = _core.binary_summary(*_pair(y_true, y_score, sample_weight, pos_label))
|
|
552
|
+
return r[2], r[3]
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def max_f1(
|
|
556
|
+
y_true: ArrayLike,
|
|
557
|
+
y_score: ArrayLike,
|
|
558
|
+
*,
|
|
559
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
560
|
+
pos_label: Any = None,
|
|
561
|
+
) -> Tuple[float, float]:
|
|
562
|
+
"""Best F1 over all thresholds. Returns ``(f1, threshold)``."""
|
|
563
|
+
r = _core.binary_summary(*_pair(y_true, y_score, sample_weight, pos_label))
|
|
564
|
+
return r[4], r[5]
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def precision_at_recall(
|
|
568
|
+
y_true: ArrayLike,
|
|
569
|
+
y_score: ArrayLike,
|
|
570
|
+
recall: float,
|
|
571
|
+
*,
|
|
572
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
573
|
+
pos_label: Any = None,
|
|
574
|
+
) -> Tuple[float, float]:
|
|
575
|
+
"""Highest precision among thresholds with recall >= ``recall``.
|
|
576
|
+
|
|
577
|
+
Returns ``(precision, threshold)``; ``(nan, nan)`` without positives.
|
|
578
|
+
"""
|
|
579
|
+
recall = _target("recall", recall)
|
|
580
|
+
thr, tps, fps = _curve(y_true, y_score, sample_weight, pos_label)
|
|
581
|
+
if len(thr) == 0 or tps[-1] <= 0:
|
|
582
|
+
return _NAN, _NAN
|
|
583
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
584
|
+
prec = np.where(tps + fps > 0, tps / (tps + fps), 0.0)
|
|
585
|
+
ok = tps / tps[-1] >= recall
|
|
586
|
+
i = int(np.argmax(np.where(ok, prec, -1.0)))
|
|
587
|
+
return float(prec[i]), float(thr[i])
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def recall_at_precision(
|
|
591
|
+
y_true: ArrayLike,
|
|
592
|
+
y_score: ArrayLike,
|
|
593
|
+
precision: float,
|
|
594
|
+
*,
|
|
595
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
596
|
+
pos_label: Any = None,
|
|
597
|
+
) -> Tuple[float, float]:
|
|
598
|
+
"""Highest recall among thresholds with precision >= ``precision``.
|
|
599
|
+
|
|
600
|
+
Returns ``(recall, threshold)``; ``(0.0, inf)`` if no threshold qualifies.
|
|
601
|
+
"""
|
|
602
|
+
precision = _target("precision", precision)
|
|
603
|
+
thr, tps, fps = _curve(y_true, y_score, sample_weight, pos_label)
|
|
604
|
+
if len(thr) == 0 or tps[-1] <= 0:
|
|
605
|
+
return _NAN, _NAN
|
|
606
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
607
|
+
prec = np.where(tps + fps > 0, tps / (tps + fps), 0.0)
|
|
608
|
+
ok = np.flatnonzero((prec >= precision) & (tps > 0))
|
|
609
|
+
if len(ok) == 0:
|
|
610
|
+
return 0.0, _INF
|
|
611
|
+
i = int(ok[-1]) # recall is non-decreasing along the curve
|
|
612
|
+
return float(tps[i] / tps[-1]), float(thr[i])
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def tpr_at_fpr(
|
|
616
|
+
y_true: ArrayLike,
|
|
617
|
+
y_score: ArrayLike,
|
|
618
|
+
fpr: float,
|
|
619
|
+
*,
|
|
620
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
621
|
+
pos_label: Any = None,
|
|
622
|
+
) -> Tuple[float, float]:
|
|
623
|
+
"""Highest true positive rate among thresholds with FPR <= ``fpr``.
|
|
624
|
+
|
|
625
|
+
Returns ``(tpr, threshold)``; ``(0.0, inf)`` if only the empty
|
|
626
|
+
prediction set qualifies.
|
|
627
|
+
"""
|
|
628
|
+
fpr = _target("fpr", fpr)
|
|
629
|
+
thr, tps, fps = _curve(y_true, y_score, sample_weight, pos_label)
|
|
630
|
+
if len(thr) == 0 or tps[-1] <= 0 or fps[-1] <= 0:
|
|
631
|
+
return _NAN, _NAN
|
|
632
|
+
ok = np.flatnonzero(fps / fps[-1] <= fpr)
|
|
633
|
+
if len(ok) == 0:
|
|
634
|
+
return 0.0, _INF
|
|
635
|
+
i = int(ok[-1])
|
|
636
|
+
return float(tps[i] / tps[-1]), float(thr[i])
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def lift_at(
|
|
640
|
+
y_true: ArrayLike,
|
|
641
|
+
y_score: ArrayLike,
|
|
642
|
+
fraction: float = 0.1,
|
|
643
|
+
*,
|
|
644
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
645
|
+
pos_label: Any = None,
|
|
646
|
+
) -> float:
|
|
647
|
+
"""Lift in the top ``fraction`` of scores (0.1 = top-decile lift).
|
|
648
|
+
|
|
649
|
+
Positive rate among the highest-scored ``fraction`` of samples divided by
|
|
650
|
+
the overall positive rate. Ties across the cut are split proportionally.
|
|
651
|
+
"""
|
|
652
|
+
fraction = float(fraction)
|
|
653
|
+
if not 0.0 < fraction <= 1.0:
|
|
654
|
+
raise ValueError("fraction must be in (0, 1]")
|
|
655
|
+
thr, tps, fps = _curve(y_true, y_score, sample_weight, pos_label)
|
|
656
|
+
if len(thr) == 0 or tps[-1] <= 0:
|
|
657
|
+
return _NAN
|
|
658
|
+
cum = tps + fps
|
|
659
|
+
cut = fraction * cum[-1]
|
|
660
|
+
tp_cut = np.interp(cut, np.concatenate([[0.0], cum]), np.concatenate([[0.0], tps]))
|
|
661
|
+
return float((tp_cut / cut) / (tps[-1] / cum[-1]))
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _nonzero_curve(y_true, y_score, sample_weight, pos_label):
|
|
665
|
+
yt, ys, w = _pair(y_true, y_score, sample_weight, pos_label)
|
|
666
|
+
if w is not None:
|
|
667
|
+
keep = w != 0 # as scikit-learn: zero-weight samples add no thresholds
|
|
668
|
+
if not keep.all():
|
|
669
|
+
yt, ys, w = yt[keep], ys[keep], w[keep]
|
|
670
|
+
return _core.curve(yt, ys, w)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def roc_curve(
|
|
674
|
+
y_true: ArrayLike,
|
|
675
|
+
y_score: ArrayLike,
|
|
676
|
+
*,
|
|
677
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
678
|
+
pos_label: Any = None,
|
|
679
|
+
drop_intermediate: bool = True,
|
|
680
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
681
|
+
"""ROC curve. Same output as ``sklearn.metrics.roc_curve``.
|
|
682
|
+
|
|
683
|
+
Returns ``(fpr, tpr, thresholds)`` with thresholds decreasing and
|
|
684
|
+
``thresholds[0] = inf`` (the empty prediction set). Tied scores form a
|
|
685
|
+
single point. ``drop_intermediate`` removes points that lie on a straight
|
|
686
|
+
segment, which does not change the curve's shape or area. ``fpr`` (or
|
|
687
|
+
``tpr``) is all ``NaN`` when there are no negatives (or positives).
|
|
688
|
+
"""
|
|
689
|
+
thr, tps, fps = _nonzero_curve(y_true, y_score, sample_weight, pos_label)
|
|
690
|
+
if drop_intermediate and len(fps) > 2:
|
|
691
|
+
keep = np.flatnonzero(
|
|
692
|
+
np.concatenate([[True], (np.diff(fps, 2) != 0) | (np.diff(tps, 2) != 0), [True]])
|
|
693
|
+
)
|
|
694
|
+
thr, tps, fps = thr[keep], tps[keep], fps[keep]
|
|
695
|
+
tps = np.concatenate([[0.0], tps])
|
|
696
|
+
fps = np.concatenate([[0.0], fps])
|
|
697
|
+
thr = np.concatenate([[np.inf], thr])
|
|
698
|
+
fpr = fps / fps[-1] if fps[-1] > 0 else np.full(len(fps), _NAN)
|
|
699
|
+
tpr = tps / tps[-1] if tps[-1] > 0 else np.full(len(tps), _NAN)
|
|
700
|
+
return fpr, tpr, thr
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def precision_recall_curve(
|
|
704
|
+
y_true: ArrayLike,
|
|
705
|
+
y_score: ArrayLike,
|
|
706
|
+
*,
|
|
707
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
708
|
+
pos_label: Any = None,
|
|
709
|
+
drop_intermediate: bool = False,
|
|
710
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
711
|
+
"""Precision-recall curve. Same output as
|
|
712
|
+
``sklearn.metrics.precision_recall_curve``.
|
|
713
|
+
|
|
714
|
+
Returns ``(precision, recall, thresholds)``: thresholds increasing, and
|
|
715
|
+
the arrays ending in the point ``precision=1, recall=0`` (which has no
|
|
716
|
+
threshold). Tied scores form a single point. ``drop_intermediate`` keeps
|
|
717
|
+
only the end points of runs with equal recall. Without positives, recall
|
|
718
|
+
is ``NaN`` (scikit-learn sets it to 1 and warns).
|
|
719
|
+
"""
|
|
720
|
+
thr, tps, fps = _nonzero_curve(y_true, y_score, sample_weight, pos_label)
|
|
721
|
+
if drop_intermediate and len(fps) > 2:
|
|
722
|
+
keep = np.flatnonzero(
|
|
723
|
+
np.concatenate([[True], (np.diff(tps[:-1]) != 0) | (np.diff(tps[1:]) != 0), [True]])
|
|
724
|
+
)
|
|
725
|
+
thr, tps, fps = thr[keep], tps[keep], fps[keep]
|
|
726
|
+
ps = tps + fps
|
|
727
|
+
precision = np.divide(tps, ps, out=np.zeros_like(tps), where=ps != 0)
|
|
728
|
+
if len(tps) and tps[-1] > 0:
|
|
729
|
+
recall = tps / tps[-1]
|
|
730
|
+
else:
|
|
731
|
+
recall = np.full(len(tps), _NAN)
|
|
732
|
+
return (
|
|
733
|
+
np.concatenate([precision[::-1], [1.0]]),
|
|
734
|
+
np.concatenate([recall[::-1], [0.0]]),
|
|
735
|
+
np.ascontiguousarray(thr[::-1]),
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def calibration_curve(
|
|
740
|
+
y_true: ArrayLike,
|
|
741
|
+
y_prob: ArrayLike,
|
|
742
|
+
*,
|
|
743
|
+
n_bins: int = 5,
|
|
744
|
+
strategy: str = "uniform",
|
|
745
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
746
|
+
pos_label: Any = None,
|
|
747
|
+
return_weights: bool = False,
|
|
748
|
+
):
|
|
749
|
+
"""Reliability diagram data: observed positive rate against mean
|
|
750
|
+
predicted probability, per bin.
|
|
751
|
+
|
|
752
|
+
Returns ``(prob_true, prob_pred)`` for non-empty bins, like
|
|
753
|
+
``sklearn.calibration.calibration_curve`` (identical output for
|
|
754
|
+
``strategy="uniform"``). With ``return_weights=True`` also returns each
|
|
755
|
+
bin's total weight, for sizing points or error bars.
|
|
756
|
+
|
|
757
|
+
``strategy="quantile"`` makes equal-weight bins; tied probabilities that
|
|
758
|
+
straddle a bin edge are split proportionally, so every bin has the same
|
|
759
|
+
weight (scikit-learn instead cuts at percentile values, which leaves
|
|
760
|
+
bins uneven when there are ties). The weighted mean of
|
|
761
|
+
``|prob_true - prob_pred|`` over bins is :func:`expected_calibration_error`.
|
|
762
|
+
"""
|
|
763
|
+
yt, ps, w = _pair(y_true, y_prob, sample_weight, pos_label)
|
|
764
|
+
_core.calibration_sums(yt, ps, w, 1e-15) # validates the [0, 1] range
|
|
765
|
+
tot, sum_p, sum_y = _calibration_bins(yt, ps, w, n_bins, strategy)
|
|
766
|
+
ok = tot > 0
|
|
767
|
+
prob_true, prob_pred = sum_y[ok] / tot[ok], sum_p[ok] / tot[ok]
|
|
768
|
+
if return_weights:
|
|
769
|
+
return prob_true, prob_pred, tot[ok]
|
|
770
|
+
return prob_true, prob_pred
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
# ---------------------------------------------------------------------------
|
|
774
|
+
# Calibration (no sort)
|
|
775
|
+
# ---------------------------------------------------------------------------
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _calibration_bins(yt, ps, w, n_bins: int, strategy: str):
|
|
779
|
+
"""Per-bin (total weight, weighted sum of p, weighted sum of y)."""
|
|
780
|
+
if int(n_bins) != n_bins or n_bins < 1:
|
|
781
|
+
raise ValueError("n_bins must be a positive integer")
|
|
782
|
+
n_bins = int(n_bins)
|
|
783
|
+
ww = np.ones(len(ps)) if w is None else w
|
|
784
|
+
if strategy == "uniform":
|
|
785
|
+
# Same edges as sklearn.calibration.calibration_curve: a value exactly
|
|
786
|
+
# on an inner edge goes to the lower bin.
|
|
787
|
+
bins = np.searchsorted(np.linspace(0.0, 1.0, n_bins + 1)[1:-1], ps)
|
|
788
|
+
tot = np.bincount(bins, weights=ww, minlength=n_bins)
|
|
789
|
+
sum_p = np.bincount(bins, weights=ww * ps, minlength=n_bins)
|
|
790
|
+
sum_y = np.bincount(bins, weights=ww * yt, minlength=n_bins)
|
|
791
|
+
elif strategy == "quantile":
|
|
792
|
+
# Equal-weight bins. Samples sharing a probability are interchangeable,
|
|
793
|
+
# so a run of tied values that straddles a bin edge is split across the
|
|
794
|
+
# bins in proportion, carrying its average label. Summing each run and
|
|
795
|
+
# interpolating the cumulative sums at the bin edges does exactly that,
|
|
796
|
+
# and makes the result independent of row order.
|
|
797
|
+
values, inv = np.unique(ps, return_inverse=True)
|
|
798
|
+
inv = inv.reshape(-1)
|
|
799
|
+
run_w = np.bincount(inv, weights=ww, minlength=len(values))
|
|
800
|
+
run_y = np.bincount(inv, weights=ww * yt, minlength=len(values))
|
|
801
|
+
cum_w = np.concatenate([[0.0], np.cumsum(run_w)])
|
|
802
|
+
cum_p = np.concatenate([[0.0], np.cumsum(run_w * values)])
|
|
803
|
+
cum_y = np.concatenate([[0.0], np.cumsum(run_y)])
|
|
804
|
+
edges = np.linspace(0.0, cum_w[-1], n_bins + 1)
|
|
805
|
+
tot = np.diff(edges)
|
|
806
|
+
sum_p = np.diff(np.interp(edges, cum_w, cum_p))
|
|
807
|
+
sum_y = np.diff(np.interp(edges, cum_w, cum_y))
|
|
808
|
+
else:
|
|
809
|
+
raise ValueError("strategy must be 'uniform' or 'quantile'")
|
|
810
|
+
return tot, sum_p, sum_y
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _ece(yt, ps, w, n_bins: int, strategy: str) -> float:
|
|
814
|
+
tot, sum_p, sum_y = _calibration_bins(yt, ps, w, n_bins, strategy)
|
|
815
|
+
if len(ps) == 0 or tot.sum() <= 0:
|
|
816
|
+
return _NAN
|
|
817
|
+
# sum_b (n_b / N) * |mean_p_b - mean_y_b|
|
|
818
|
+
return float(np.abs(sum_p - sum_y).sum() / tot.sum())
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _calibration(yt, ps, w, eps, n_bins, strategy) -> Dict[str, float]:
|
|
822
|
+
sw, sy, sp, sll, sbr = _core.calibration_sums(yt, ps, w, eps)
|
|
823
|
+
out = dict.fromkeys(
|
|
824
|
+
("log_loss", "normalized_entropy", "brier", "calibration_ratio", "ece"), _NAN
|
|
825
|
+
)
|
|
826
|
+
if sw <= 0:
|
|
827
|
+
return out
|
|
828
|
+
out["log_loss"] = sll / sw
|
|
829
|
+
out["brier"] = sbr / sw
|
|
830
|
+
rate = sy / sw
|
|
831
|
+
if 0.0 < rate < 1.0:
|
|
832
|
+
base = -(rate * np.log(rate) + (1.0 - rate) * np.log(1.0 - rate))
|
|
833
|
+
out["normalized_entropy"] = float(out["log_loss"] / base)
|
|
834
|
+
if sy > 0:
|
|
835
|
+
out["calibration_ratio"] = sp / sy
|
|
836
|
+
out["ece"] = _ece(yt, ps, w, n_bins, strategy)
|
|
837
|
+
return out
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def calibration_metrics(
|
|
841
|
+
y_true: ArrayLike,
|
|
842
|
+
y_prob: ArrayLike,
|
|
843
|
+
*,
|
|
844
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
845
|
+
pos_label: Any = None,
|
|
846
|
+
n_bins: int = 10,
|
|
847
|
+
strategy: str = "uniform",
|
|
848
|
+
eps: float = 1e-15,
|
|
849
|
+
) -> Dict[str, float]:
|
|
850
|
+
"""Pointwise calibration metrics for probabilities in ``[0, 1]``.
|
|
851
|
+
|
|
852
|
+
Returns a dict with
|
|
853
|
+
|
|
854
|
+
* ``log_loss``: binary cross-entropy (probabilities clipped to
|
|
855
|
+
``[eps, 1 - eps]``).
|
|
856
|
+
* ``normalized_entropy``: log loss divided by the entropy of the base
|
|
857
|
+
rate. Below 1 beats predicting the base rate; ``1 - NE`` is the
|
|
858
|
+
relative information gain (RIG).
|
|
859
|
+
* ``brier``: mean squared error.
|
|
860
|
+
* ``calibration_ratio``: sum of predictions over sum of labels (also
|
|
861
|
+
called COPC or E/O). 1.0 is calibrated on average; above 1
|
|
862
|
+
over-predicts.
|
|
863
|
+
* ``ece``: expected calibration error over ``n_bins`` bins;
|
|
864
|
+
``strategy`` is ``"uniform"`` (equal width) or ``"quantile"``
|
|
865
|
+
(equal weight; tied probabilities that straddle a bin edge are split
|
|
866
|
+
proportionally).
|
|
867
|
+
"""
|
|
868
|
+
yt, ps, w = _pair(y_true, y_prob, sample_weight, pos_label)
|
|
869
|
+
return _calibration(yt, ps, w, eps, n_bins, strategy)
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def log_loss(y_true, y_prob, *, sample_weight=None, pos_label=None, eps: float = 1e-15) -> float:
|
|
873
|
+
"""Binary cross-entropy. Equivalent to ``sklearn.metrics.log_loss``."""
|
|
874
|
+
yt, ps, w = _pair(y_true, y_prob, sample_weight, pos_label)
|
|
875
|
+
sw, _, _, sll, _ = _core.calibration_sums(yt, ps, w, eps)
|
|
876
|
+
return sll / sw if sw > 0 else _NAN
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def brier_score(y_true, y_prob, *, sample_weight=None, pos_label=None) -> float:
|
|
880
|
+
"""Brier score. Equivalent to ``sklearn.metrics.brier_score_loss``."""
|
|
881
|
+
yt, ps, w = _pair(y_true, y_prob, sample_weight, pos_label)
|
|
882
|
+
sw, _, _, _, sbr = _core.calibration_sums(yt, ps, w, 1e-15)
|
|
883
|
+
return sbr / sw if sw > 0 else _NAN
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def normalized_entropy(
|
|
887
|
+
y_true, y_prob, *, sample_weight=None, pos_label=None, eps: float = 1e-15
|
|
888
|
+
) -> float:
|
|
889
|
+
"""Log loss divided by the entropy of the empirical positive rate."""
|
|
890
|
+
return calibration_metrics(
|
|
891
|
+
y_true, y_prob, sample_weight=sample_weight, pos_label=pos_label, n_bins=1, eps=eps
|
|
892
|
+
)["normalized_entropy"]
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def calibration_ratio(y_true, y_prob, *, sample_weight=None, pos_label=None) -> float:
|
|
896
|
+
"""Sum of predicted probabilities over the number of positives (COPC, E/O)."""
|
|
897
|
+
yt, ps, w = _pair(y_true, y_prob, sample_weight, pos_label)
|
|
898
|
+
sw, sy, sp, _, _ = _core.calibration_sums(yt, ps, w, 1e-15)
|
|
899
|
+
return sp / sy if sy > 0 else _NAN
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def expected_calibration_error(
|
|
903
|
+
y_true,
|
|
904
|
+
y_prob,
|
|
905
|
+
*,
|
|
906
|
+
sample_weight=None,
|
|
907
|
+
pos_label=None,
|
|
908
|
+
n_bins: int = 10,
|
|
909
|
+
strategy: str = "uniform",
|
|
910
|
+
) -> float:
|
|
911
|
+
"""Expected calibration error with equal-width or equal-count bins."""
|
|
912
|
+
yt, ps, w = _pair(y_true, y_prob, sample_weight, pos_label)
|
|
913
|
+
_core.calibration_sums(yt, ps, w, 1e-15) # validates the [0, 1] range
|
|
914
|
+
return _ece(yt, ps, w, n_bins, strategy)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
# ---------------------------------------------------------------------------
|
|
918
|
+
# Everything for one binary model
|
|
919
|
+
# ---------------------------------------------------------------------------
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def binary_metrics(
|
|
923
|
+
y_true: ArrayLike,
|
|
924
|
+
y_score: ArrayLike,
|
|
925
|
+
*,
|
|
926
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
927
|
+
pos_label: Any = None,
|
|
928
|
+
calibration: Optional[bool] = None,
|
|
929
|
+
n_bins: int = 10,
|
|
930
|
+
strategy: str = "uniform",
|
|
931
|
+
eps: float = 1e-15,
|
|
932
|
+
) -> Dict[str, float]:
|
|
933
|
+
"""Discrimination and calibration metrics for one model, one sort.
|
|
934
|
+
|
|
935
|
+
Returns a dict with ``roc_auc``, ``average_precision``, ``pr_auc``
|
|
936
|
+
(trapezoidal; see :func:`pr_auc`), ``ks``, ``ks_threshold``, ``f1_max``,
|
|
937
|
+
``f1_threshold``, ``prevalence`` and the
|
|
938
|
+
keys of :func:`calibration_metrics`.
|
|
939
|
+
|
|
940
|
+
``calibration`` controls the calibration block: ``None`` (default)
|
|
941
|
+
computes it when every score lies in ``[0, 1]`` and returns ``NaN`` for
|
|
942
|
+
those keys otherwise; ``True`` requires probabilities and raises if they
|
|
943
|
+
are not; ``False`` skips it.
|
|
944
|
+
"""
|
|
945
|
+
yt, ys, w = _pair(y_true, y_score, sample_weight, pos_label)
|
|
946
|
+
roc, ap, ks, ks_thr, f1, f1_thr, pr = _core.binary_summary(yt, ys, w)
|
|
947
|
+
out: Dict[str, float] = {
|
|
948
|
+
"roc_auc": roc,
|
|
949
|
+
"average_precision": ap,
|
|
950
|
+
"pr_auc": pr,
|
|
951
|
+
"ks": ks,
|
|
952
|
+
"ks_threshold": ks_thr,
|
|
953
|
+
"f1_max": f1,
|
|
954
|
+
"f1_threshold": f1_thr,
|
|
955
|
+
}
|
|
956
|
+
if w is None:
|
|
957
|
+
out["prevalence"] = float(yt.mean()) if len(yt) else _NAN
|
|
958
|
+
else:
|
|
959
|
+
out["prevalence"] = float(np.dot(w, yt) / w.sum()) if w.sum() > 0 else _NAN
|
|
960
|
+
if calibration is None:
|
|
961
|
+
calibration = bool(len(ys)) and bool(ys.min() >= 0.0 and ys.max() <= 1.0)
|
|
962
|
+
if calibration:
|
|
963
|
+
out.update(_calibration(yt, ys, w, eps, n_bins, strategy))
|
|
964
|
+
else:
|
|
965
|
+
out.update(
|
|
966
|
+
dict.fromkeys(
|
|
967
|
+
("log_loss", "normalized_entropy", "brier", "calibration_ratio", "ece"), _NAN
|
|
968
|
+
)
|
|
969
|
+
)
|
|
970
|
+
return out
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
# ---------------------------------------------------------------------------
|
|
974
|
+
# Grouped ranking metrics
|
|
975
|
+
# ---------------------------------------------------------------------------
|
|
976
|
+
|
|
977
|
+
_RANK_NAMES = ("ndcg", "mrr", "map", "recall", "precision", "hit_rate")
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _ranking_setup(y_true, n, groups, group_sizes, ks, gain, no_relevant, sample_weight):
|
|
981
|
+
"""Shared input handling for ranking_metrics and its bootstrap."""
|
|
982
|
+
rel = np.asarray(y_true)
|
|
983
|
+
_require_1d("y_true", rel)
|
|
984
|
+
if len(rel) != n:
|
|
985
|
+
raise ValueError(f"y_true has length {len(rel)}, y_score has {n} samples")
|
|
986
|
+
if rel.dtype.kind not in "iufb":
|
|
987
|
+
raise TypeError(f"y_true has non-numeric dtype {rel.dtype}")
|
|
988
|
+
rel = rel.astype(np.float64)
|
|
989
|
+
if rel.size and not (np.isfinite(rel).all() and rel.min() >= 0):
|
|
990
|
+
raise ValueError("relevance must be finite and non-negative")
|
|
991
|
+
yt = np.ascontiguousarray(rel > 0, dtype=np.uint8)
|
|
992
|
+
if gain == "exponential":
|
|
993
|
+
if rel.size and rel.max() > 1000:
|
|
994
|
+
raise ValueError("relevance too large for gain='exponential'; use gain='linear'")
|
|
995
|
+
gains = np.exp2(rel) - 1.0
|
|
996
|
+
elif gain == "linear":
|
|
997
|
+
gains = rel
|
|
998
|
+
else:
|
|
999
|
+
raise ValueError("gain must be 'exponential' or 'linear'")
|
|
1000
|
+
if no_relevant not in ("skip", "zero"):
|
|
1001
|
+
raise ValueError("no_relevant must be 'skip' or 'zero'")
|
|
1002
|
+
|
|
1003
|
+
if (groups is None) == (group_sizes is None):
|
|
1004
|
+
raise ValueError("pass exactly one of groups or group_sizes")
|
|
1005
|
+
if groups is not None:
|
|
1006
|
+
ids, codes = _group_codes(groups, n)
|
|
1007
|
+
else:
|
|
1008
|
+
sizes = np.asarray(group_sizes)
|
|
1009
|
+
_require_1d("group_sizes", sizes)
|
|
1010
|
+
if sizes.dtype.kind not in "iu" or (sizes.size and sizes.min() < 0):
|
|
1011
|
+
raise ValueError("group_sizes must be non-negative integers")
|
|
1012
|
+
if int(sizes.sum()) != n:
|
|
1013
|
+
raise ValueError(f"group_sizes sums to {int(sizes.sum())}, y_score has {n} samples")
|
|
1014
|
+
ids = np.arange(len(sizes))
|
|
1015
|
+
codes = np.ascontiguousarray(np.repeat(ids, sizes), dtype=np.int64)
|
|
1016
|
+
size = np.bincount(codes, minlength=len(ids))
|
|
1017
|
+
|
|
1018
|
+
ks = list(ks)
|
|
1019
|
+
if not ks:
|
|
1020
|
+
raise ValueError("ks must not be empty")
|
|
1021
|
+
core_ks, suffixes = [], []
|
|
1022
|
+
for k in ks:
|
|
1023
|
+
if k is None:
|
|
1024
|
+
core_ks.append(int(np.iinfo(np.uintp).max))
|
|
1025
|
+
suffixes.append("")
|
|
1026
|
+
else:
|
|
1027
|
+
if int(k) != k or k < 1:
|
|
1028
|
+
raise ValueError("cutoffs must be positive integers or None")
|
|
1029
|
+
core_ks.append(int(k))
|
|
1030
|
+
suffixes.append(f"@{int(k)}")
|
|
1031
|
+
if len(set(suffixes)) != len(suffixes):
|
|
1032
|
+
raise ValueError("duplicate cutoffs in ks")
|
|
1033
|
+
names = ["roc_auc"] + [m + s for s in suffixes for m in _RANK_NAMES]
|
|
1034
|
+
|
|
1035
|
+
group_weight = None
|
|
1036
|
+
w = _weights(sample_weight)
|
|
1037
|
+
if w is not None:
|
|
1038
|
+
if len(w) != n:
|
|
1039
|
+
raise ValueError(f"sample_weight has length {len(w)}, expected {n}")
|
|
1040
|
+
if w.size and not (np.isfinite(w).all() and w.min() >= 0):
|
|
1041
|
+
raise ValueError("sample_weight must be finite and non-negative")
|
|
1042
|
+
# A query's weight is the mean weight of its rows.
|
|
1043
|
+
group_weight = np.bincount(codes, weights=w, minlength=len(ids)) / np.maximum(size, 1)
|
|
1044
|
+
return yt, np.ascontiguousarray(gains), ids, codes, size, core_ks, names, group_weight
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _ranking_summary(sums, wts, extra, names, no_relevant) -> Dict[str, float]:
|
|
1048
|
+
auc_sum, auc_w, n_nonempty, n_ranked, n_auc, w_nonempty = extra
|
|
1049
|
+
summary: Dict[str, float] = {
|
|
1050
|
+
"gauc": float(auc_sum / auc_w) if auc_w > 0 else _NAN,
|
|
1051
|
+
"gauc_macro": float(sums[0] / wts[0]) if wts[0] > 0 else _NAN,
|
|
1052
|
+
}
|
|
1053
|
+
for c in range(1, len(names)):
|
|
1054
|
+
denom = wts[c] if no_relevant == "skip" else w_nonempty
|
|
1055
|
+
summary[names[c]] = float(sums[c] / denom) if denom > 0 else _NAN
|
|
1056
|
+
summary["n_groups"] = int(n_nonempty)
|
|
1057
|
+
summary["n_groups_ranked"] = int(n_ranked)
|
|
1058
|
+
summary["n_groups_auc"] = int(n_auc)
|
|
1059
|
+
return summary
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def ranking_metrics(
|
|
1063
|
+
y_true: ArrayLike,
|
|
1064
|
+
y_score: ArrayLike,
|
|
1065
|
+
groups: Optional[ArrayLike] = None,
|
|
1066
|
+
*,
|
|
1067
|
+
group_sizes: Optional[ArrayLike] = None,
|
|
1068
|
+
ks: Sequence[Optional[int]] = (1, 5, 10),
|
|
1069
|
+
gain: str = "exponential",
|
|
1070
|
+
no_relevant: str = "skip",
|
|
1071
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
1072
|
+
per_group: bool = False,
|
|
1073
|
+
) -> Union[Dict[str, float], Tuple[Dict[str, float], Dict[str, np.ndarray]]]:
|
|
1074
|
+
"""GAUC, NDCG, MRR, MAP, recall, precision and hit rate at several
|
|
1075
|
+
cutoffs, with one sort per group, in parallel.
|
|
1076
|
+
|
|
1077
|
+
Parameters
|
|
1078
|
+
----------
|
|
1079
|
+
y_true : (n_samples,)
|
|
1080
|
+
Relevance. Binary labels, or non-negative graded relevance. An item
|
|
1081
|
+
is "relevant" when its value is > 0; grades only affect NDCG.
|
|
1082
|
+
y_score : (n_samples,)
|
|
1083
|
+
groups : (n_samples,), optional
|
|
1084
|
+
Query / user / session id per row, in any order. Integer ids in a
|
|
1085
|
+
compact range take an O(N) fast path; other ids are sorted first.
|
|
1086
|
+
group_sizes : (n_groups,), optional
|
|
1087
|
+
Alternative to ``groups`` for data already laid out group by group
|
|
1088
|
+
(the LightGBM / XGBoost convention). Exactly one of the two.
|
|
1089
|
+
ks : cutoffs. ``None`` means the whole list and produces keys without a
|
|
1090
|
+
suffix (``"ndcg"``, ``"map"``, ...).
|
|
1091
|
+
gain : NDCG gain, ``"exponential"`` (``2**rel - 1``) or ``"linear"``.
|
|
1092
|
+
no_relevant : groups without any relevant item have undefined ranking
|
|
1093
|
+
metrics. ``"skip"`` (default) leaves them out of the averages;
|
|
1094
|
+
``"zero"`` counts them as 0.
|
|
1095
|
+
sample_weight : (n_samples,), optional
|
|
1096
|
+
Weights the *averages over groups*: each group's weight is the mean
|
|
1097
|
+
weight of its rows (the LightGBM convention), so give every row of a
|
|
1098
|
+
query that query's weight. Values inside a group are not reweighted.
|
|
1099
|
+
per_group : also return the per-group values.
|
|
1100
|
+
|
|
1101
|
+
Returns
|
|
1102
|
+
-------
|
|
1103
|
+
summary : dict
|
|
1104
|
+
``gauc`` (ROC-AUC per group, weighted by group size), ``gauc_macro``
|
|
1105
|
+
(unweighted), ``<metric>@<k>`` means over groups, and the counts
|
|
1106
|
+
``n_groups``, ``n_groups_ranked`` (at least one relevant item),
|
|
1107
|
+
``n_groups_auc`` (both classes present).
|
|
1108
|
+
per_group : dict of arrays, only if ``per_group=True``
|
|
1109
|
+
``group``, ``size``, ``roc_auc`` and every ``<metric>@<k>``.
|
|
1110
|
+
|
|
1111
|
+
Notes
|
|
1112
|
+
-----
|
|
1113
|
+
Tied scores are averaged exactly over all orderings of the tied items,
|
|
1114
|
+
so a model that outputs a constant score gets chance-level metrics no
|
|
1115
|
+
matter how the rows are ordered. MAP@k divides by ``min(n_relevant, k)``;
|
|
1116
|
+
precision@k divides by ``k``.
|
|
1117
|
+
"""
|
|
1118
|
+
ys = _scores(y_score)
|
|
1119
|
+
_require_1d("y_score", ys)
|
|
1120
|
+
yt, gains, ids, codes, size, core_ks, names, gw = _ranking_setup(
|
|
1121
|
+
y_true, len(ys), groups, group_sizes, ks, gain, no_relevant, sample_weight
|
|
1122
|
+
)
|
|
1123
|
+
sums, wts, extra, rows = _core.ranking(
|
|
1124
|
+
yt, gains, ys, codes, len(ids), core_ks, bool(per_group), gw
|
|
1125
|
+
)
|
|
1126
|
+
summary = _ranking_summary(sums, wts, extra, names, no_relevant)
|
|
1127
|
+
if not per_group:
|
|
1128
|
+
return summary
|
|
1129
|
+
|
|
1130
|
+
mat = rows.reshape(len(ids), len(names))
|
|
1131
|
+
detail: Dict[str, np.ndarray] = {"group": ids, "size": size}
|
|
1132
|
+
for c, name in enumerate(names):
|
|
1133
|
+
detail[name] = np.ascontiguousarray(mat[:, c])
|
|
1134
|
+
return summary, detail
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
@dataclass(frozen=True)
|
|
1138
|
+
class RankingBootstrapResult:
|
|
1139
|
+
"""Bootstrap distribution from :func:`bootstrap_ranking_metrics`.
|
|
1140
|
+
|
|
1141
|
+
Attributes
|
|
1142
|
+
----------
|
|
1143
|
+
estimate : dict
|
|
1144
|
+
Metric name -> value on the original data (float, or an array of
|
|
1145
|
+
shape ``(n_models,)``).
|
|
1146
|
+
samples : dict
|
|
1147
|
+
Metric name -> replicates, shape ``(n_bootstraps,)`` or
|
|
1148
|
+
``(n_models, n_bootstraps)``. Column ``b`` uses the same resampled
|
|
1149
|
+
set of groups for every metric and every model.
|
|
1150
|
+
"""
|
|
1151
|
+
|
|
1152
|
+
estimate: Dict[str, Any]
|
|
1153
|
+
samples: Dict[str, np.ndarray]
|
|
1154
|
+
|
|
1155
|
+
def _samples(self, metric: str) -> np.ndarray:
|
|
1156
|
+
if metric not in self.samples:
|
|
1157
|
+
raise ValueError(f"unknown metric {metric!r}; available: {sorted(self.samples)}")
|
|
1158
|
+
return self.samples[metric]
|
|
1159
|
+
|
|
1160
|
+
def ci(self, metric: str, level: float = 0.95) -> Any:
|
|
1161
|
+
"""Percentile confidence interval: ``(low, high)`` or ``(n_models, 2)``."""
|
|
1162
|
+
return _percentile_ci(self._samples(metric), level)
|
|
1163
|
+
|
|
1164
|
+
def std(self, metric: str) -> Any:
|
|
1165
|
+
"""Bootstrap standard error."""
|
|
1166
|
+
s = np.nanstd(self._samples(metric), axis=-1, ddof=1)
|
|
1167
|
+
return float(s) if np.ndim(s) == 0 else s
|
|
1168
|
+
|
|
1169
|
+
def compare(self, a: int, b: int, metric: str, level: float = 0.95):
|
|
1170
|
+
"""Paired comparison of model ``a`` minus model ``b``.
|
|
1171
|
+
|
|
1172
|
+
Returns ``(difference, (low, high), p_value)``.
|
|
1173
|
+
"""
|
|
1174
|
+
return _paired_compare(self._samples(metric), self.estimate[metric], a, b, level)
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def bootstrap_ranking_metrics(
|
|
1178
|
+
y_true: ArrayLike,
|
|
1179
|
+
y_score: ArrayLike,
|
|
1180
|
+
groups: Optional[ArrayLike] = None,
|
|
1181
|
+
*,
|
|
1182
|
+
group_sizes: Optional[ArrayLike] = None,
|
|
1183
|
+
ks: Sequence[Optional[int]] = (1, 5, 10),
|
|
1184
|
+
gain: str = "exponential",
|
|
1185
|
+
no_relevant: str = "skip",
|
|
1186
|
+
sample_weight: Optional[ArrayLike] = None,
|
|
1187
|
+
n_bootstraps: int = 1000,
|
|
1188
|
+
seed: int = 0,
|
|
1189
|
+
axis: int = -1,
|
|
1190
|
+
) -> RankingBootstrapResult:
|
|
1191
|
+
"""Confidence intervals for :func:`ranking_metrics` by resampling groups.
|
|
1192
|
+
|
|
1193
|
+
Queries (not rows) are the resampling unit. Per-query metrics are
|
|
1194
|
+
computed once; each replicate is then a weighted mean over the drawn
|
|
1195
|
+
queries, so thousands of replicates cost little more than one evaluation.
|
|
1196
|
+
|
|
1197
|
+
``y_score`` may be ``(n_models, n_samples)`` (or ``(n_samples,
|
|
1198
|
+
n_models)`` with ``axis=0``) to compare rankers on identical resamples;
|
|
1199
|
+
see :meth:`RankingBootstrapResult.compare`. Other parameters are as in
|
|
1200
|
+
:func:`ranking_metrics`.
|
|
1201
|
+
"""
|
|
1202
|
+
ys = _scores(y_score)
|
|
1203
|
+
if ys.ndim not in (1, 2):
|
|
1204
|
+
raise ValueError(f"y_score must be 1-D or 2-D, got shape {ys.shape}")
|
|
1205
|
+
ys = _samples_last(ys, axis)
|
|
1206
|
+
if n_bootstraps < 1:
|
|
1207
|
+
raise ValueError("n_bootstraps must be >= 1")
|
|
1208
|
+
single = ys.ndim == 1
|
|
1209
|
+
ys2 = ys[None, :] if single else ys
|
|
1210
|
+
yt, gains, ids, codes, size, core_ks, names, gw = _ranking_setup(
|
|
1211
|
+
y_true, ys2.shape[1], groups, group_sizes, ks, gain, no_relevant, sample_weight
|
|
1212
|
+
)
|
|
1213
|
+
n_groups, n_cols = len(ids), len(names)
|
|
1214
|
+
w_group = np.ones(n_groups) if gw is None else gw
|
|
1215
|
+
w_auc = np.ascontiguousarray(w_group * size)
|
|
1216
|
+
seed = int(seed) % (1 << 64)
|
|
1217
|
+
keys = ["gauc", "gauc_macro"] + names[1:]
|
|
1218
|
+
|
|
1219
|
+
est_rows, sample_rows = [], []
|
|
1220
|
+
for row in ys2:
|
|
1221
|
+
sums, wts, extra, flat = _core.ranking(
|
|
1222
|
+
yt, gains, np.ascontiguousarray(row), codes, n_groups, core_ks, True, gw
|
|
1223
|
+
)
|
|
1224
|
+
summary = _ranking_summary(sums, wts, extra, names, no_relevant)
|
|
1225
|
+
est_rows.append([summary[k] for k in keys])
|
|
1226
|
+
mat = flat.reshape(n_groups, n_cols)
|
|
1227
|
+
if no_relevant == "zero":
|
|
1228
|
+
mat = mat.copy()
|
|
1229
|
+
block = mat[:, 1:]
|
|
1230
|
+
block[np.isnan(block) & (size > 0)[:, None]] = 0.0
|
|
1231
|
+
# Same seed and group count -> the same draws in both calls.
|
|
1232
|
+
means = _core.bootstrap_group_means(
|
|
1233
|
+
np.ascontiguousarray(mat).reshape(-1), n_cols, w_group, int(n_bootstraps), seed
|
|
1234
|
+
).reshape(n_bootstraps, n_cols)
|
|
1235
|
+
gauc = _core.bootstrap_group_means(
|
|
1236
|
+
np.ascontiguousarray(mat[:, 0]), 1, w_auc, int(n_bootstraps), seed
|
|
1237
|
+
)
|
|
1238
|
+
sample_rows.append(np.column_stack([gauc, means])) # (B, 1 + n_cols)
|
|
1239
|
+
|
|
1240
|
+
est = np.array(est_rows) # (K, n_keys)
|
|
1241
|
+
smp = np.stack(sample_rows) # (K, B, n_keys)
|
|
1242
|
+
if single:
|
|
1243
|
+
return RankingBootstrapResult(
|
|
1244
|
+
{k: float(est[0, j]) for j, k in enumerate(keys)},
|
|
1245
|
+
{k: np.ascontiguousarray(smp[0, :, j]) for j, k in enumerate(keys)},
|
|
1246
|
+
)
|
|
1247
|
+
return RankingBootstrapResult(
|
|
1248
|
+
{k: est[:, j].copy() for j, k in enumerate(keys)},
|
|
1249
|
+
{k: np.ascontiguousarray(smp[:, :, j]) for j, k in enumerate(keys)},
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
@dataclass(frozen=True)
|
|
1254
|
+
class DeLongResult:
|
|
1255
|
+
"""ROC-AUC with DeLong's analytic covariance, from :func:`delong_roc_auc`.
|
|
1256
|
+
|
|
1257
|
+
Attributes
|
|
1258
|
+
----------
|
|
1259
|
+
roc_auc : float, or ndarray of shape ``(n_models,)``
|
|
1260
|
+
covariance : ndarray of shape ``(n_models, n_models)``
|
|
1261
|
+
Estimated covariance of the AUC estimates (``NaN`` with fewer than
|
|
1262
|
+
two samples in either class).
|
|
1263
|
+
n_pos, n_neg : int
|
|
1264
|
+
"""
|
|
1265
|
+
|
|
1266
|
+
roc_auc: Any
|
|
1267
|
+
covariance: np.ndarray
|
|
1268
|
+
n_pos: int
|
|
1269
|
+
n_neg: int
|
|
1270
|
+
|
|
1271
|
+
@property
|
|
1272
|
+
def _single(self) -> bool:
|
|
1273
|
+
return np.ndim(self.roc_auc) == 0
|
|
1274
|
+
|
|
1275
|
+
def std(self) -> Any:
|
|
1276
|
+
"""Standard error of the AUC."""
|
|
1277
|
+
se = np.sqrt(np.maximum(np.diag(self.covariance), 0.0))
|
|
1278
|
+
return float(se[0]) if self._single else se
|
|
1279
|
+
|
|
1280
|
+
def ci(self, level: float = 0.95) -> Any:
|
|
1281
|
+
"""Normal-approximation confidence interval, clipped to ``[0, 1]``.
|
|
1282
|
+
|
|
1283
|
+
Returns ``(low, high)`` for one model, or an array of shape
|
|
1284
|
+
``(n_models, 2)``.
|
|
1285
|
+
"""
|
|
1286
|
+
if not 0.0 < level < 1.0:
|
|
1287
|
+
raise ValueError("level must be in (0, 1)")
|
|
1288
|
+
z = NormalDist().inv_cdf(0.5 + level / 2.0)
|
|
1289
|
+
auc, se = np.atleast_1d(self.roc_auc), np.atleast_1d(self.std())
|
|
1290
|
+
out = np.clip(np.stack([auc - z * se, auc + z * se], axis=-1), 0.0, 1.0)
|
|
1291
|
+
return (float(out[0, 0]), float(out[0, 1])) if self._single else out
|
|
1292
|
+
|
|
1293
|
+
def compare(self, a: int, b: int, level: float = 0.95):
|
|
1294
|
+
"""DeLong test of model ``a`` against model ``b`` on the same samples.
|
|
1295
|
+
|
|
1296
|
+
Returns ``(difference, (low, high), p_value)``: the AUC difference
|
|
1297
|
+
``a - b``, its confidence interval, and the two-sided p-value for
|
|
1298
|
+
"equal AUC".
|
|
1299
|
+
"""
|
|
1300
|
+
if self._single:
|
|
1301
|
+
raise ValueError("compare() needs a result computed from 2-D scores")
|
|
1302
|
+
if not 0.0 < level < 1.0:
|
|
1303
|
+
raise ValueError("level must be in (0, 1)")
|
|
1304
|
+
auc, c = np.asarray(self.roc_auc), self.covariance
|
|
1305
|
+
diff = float(auc[a] - auc[b])
|
|
1306
|
+
var = c[a, a] + c[b, b] - 2.0 * c[a, b]
|
|
1307
|
+
if math.isnan(var) or math.isnan(diff):
|
|
1308
|
+
return diff, (_NAN, _NAN), _NAN
|
|
1309
|
+
se = math.sqrt(max(var, 0.0))
|
|
1310
|
+
if se == 0.0:
|
|
1311
|
+
return diff, (diff, diff), 1.0 if diff == 0.0 else 0.0
|
|
1312
|
+
z = NormalDist().inv_cdf(0.5 + level / 2.0)
|
|
1313
|
+
p = math.erfc(abs(diff) / se / math.sqrt(2.0))
|
|
1314
|
+
return diff, (diff - z * se, diff + z * se), float(p)
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def delong_roc_auc(
|
|
1318
|
+
y_true: ArrayLike,
|
|
1319
|
+
y_score: ArrayLike,
|
|
1320
|
+
*,
|
|
1321
|
+
axis: int = -1,
|
|
1322
|
+
pos_label: Any = None,
|
|
1323
|
+
) -> DeLongResult:
|
|
1324
|
+
"""ROC-AUC with DeLong's analytic variance, confidence intervals and
|
|
1325
|
+
paired test, with no resampling.
|
|
1326
|
+
|
|
1327
|
+
Uses the fast algorithm of Sun and Xu (2014): one sort per model, then
|
|
1328
|
+
linear work. Ties count half, as in :func:`roc_auc`.
|
|
1329
|
+
|
|
1330
|
+
Parameters
|
|
1331
|
+
----------
|
|
1332
|
+
y_true : (n_samples,)
|
|
1333
|
+
y_score : (n_samples,) or (n_models, n_samples)
|
|
1334
|
+
Several models evaluated on the same samples give the full covariance
|
|
1335
|
+
matrix, so :meth:`DeLongResult.compare` is the classic DeLong test for
|
|
1336
|
+
two correlated ROC curves. Pass ``axis=0`` for ``(n_samples, n_models)``.
|
|
1337
|
+
|
|
1338
|
+
Notes
|
|
1339
|
+
-----
|
|
1340
|
+
DeLong assumes independent samples. When rows are clustered (several
|
|
1341
|
+
impressions per user or query), use
|
|
1342
|
+
:func:`bootstrap_roc_auc_ap` with ``groups=`` instead. Sample weights are
|
|
1343
|
+
not supported here.
|
|
1344
|
+
"""
|
|
1345
|
+
yt, ys = _labels(y_true, pos_label), _scores(y_score)
|
|
1346
|
+
_require_1d("y_true", yt)
|
|
1347
|
+
if ys.ndim not in (1, 2):
|
|
1348
|
+
raise ValueError(f"y_score must be 1-D or 2-D, got shape {ys.shape}")
|
|
1349
|
+
ys = _samples_last(ys, axis)
|
|
1350
|
+
single = ys.ndim == 1
|
|
1351
|
+
ys2 = ys[None, :] if single else ys
|
|
1352
|
+
if ys2.shape[1] != len(yt):
|
|
1353
|
+
raise ValueError(f"y_true / y_score: length mismatch ({len(yt)} vs {ys2.shape[1]})")
|
|
1354
|
+
k = ys2.shape[0]
|
|
1355
|
+
m = int(np.count_nonzero(yt))
|
|
1356
|
+
n = len(yt) - m
|
|
1357
|
+
if m == 0 or n == 0:
|
|
1358
|
+
auc = np.full(k, _NAN)
|
|
1359
|
+
cov = np.full((k, k), _NAN)
|
|
1360
|
+
else:
|
|
1361
|
+
v10, v01 = _core.delong_components(yt, ys2)
|
|
1362
|
+
v10, v01 = v10.reshape(k, m), v01.reshape(k, n)
|
|
1363
|
+
auc = v10.mean(axis=1)
|
|
1364
|
+
if m >= 2 and n >= 2:
|
|
1365
|
+
cov = np.atleast_2d(np.cov(v10)) / m + np.atleast_2d(np.cov(v01)) / n
|
|
1366
|
+
else:
|
|
1367
|
+
cov = np.full((k, k), _NAN)
|
|
1368
|
+
return DeLongResult(float(auc[0]) if single else auc, cov, m, n)
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
def num_threads() -> int:
|
|
1372
|
+
"""Number of worker threads fastauc will use."""
|
|
1373
|
+
return _core.num_threads()
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def set_num_threads(n: Optional[int]) -> None:
|
|
1377
|
+
"""Limit fastauc to ``n`` threads, process-wide.
|
|
1378
|
+
|
|
1379
|
+
Use ``1`` inside data-loader workers, joblib jobs or any code that is
|
|
1380
|
+
already parallel, to avoid oversubscribing cores. ``None`` restores the
|
|
1381
|
+
default: all cores, or the ``RAYON_NUM_THREADS`` environment variable.
|
|
1382
|
+
Results never depend on the thread count.
|
|
1383
|
+
"""
|
|
1384
|
+
if n is None:
|
|
1385
|
+
_core.set_num_threads(0)
|
|
1386
|
+
return
|
|
1387
|
+
if int(n) != n or n < 1:
|
|
1388
|
+
raise ValueError("n must be a positive integer or None")
|
|
1389
|
+
_core.set_num_threads(int(n))
|