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 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))