-
Notifications
You must be signed in to change notification settings - Fork 0
Description
User request
While working on scikit-learn#26120, I found that roc_curve can return thresholds greater than 1. A non-regression test could be as follows:
import numpy as np
from sklearn.metrics import roc_curve
def test_roc_curve_with_probablity_estimates():
rng = np.random.RandomState(42)
y_true = rng.randint(0, 2, size=10)
y_score = rng.rand(10)
_, _, thresholds = roc_curve(y_true, y_score)
assert np.logical_or(thresholds <= 1, thresholds >= 0).all()This is due to prepending a sentinel threshold in sklearn/metrics/_ranking.py to add the (fpr=0, tpr=0) point; the current logic uses thresholds[0] + 1, which can push the first threshold above 1 even when y_score are probabilities in [0, 1].
Reference (upstream): https://github.com/scikit-learn/scikit-learn/blob/e886ce4e1444c61b865e7839c9cff5464ee20ace/sklearn/metrics/_ranking.py#L1086
Researcher specification (proposed fix)
-
Root cause:
roc_curveprepends a threshold strictly greater than the maximum score to ensure the ROC starts at (0,0). In the current code this isthresholds = np.r_[thresholds[0] + 1, thresholds]. For probability-like scores in [0,1], this yields a threshold > 1. -
Proposed change (preserve semantics, minimize out-of-range values):
- For float scores (probabilities), use the next representable float above max(y_score) via
np.nextafter(dtype-aware). This keeps the threshold strictly greater than any score while minimally exceeding the domain whenmax == 1.0. - For integer scores, keep the current behavior:
max + 1.
Pseudocode:
# File: sklearn/metrics/_ranking.py, in roc_curve if np.issubdtype(thresholds.dtype, np.floating): prepend_thresh = np.nextafter(thresholds[0], np.inf, dtype=thresholds.dtype) else: prepend_thresh = thresholds[0] + 1 thresholds = np.r_[prepend_thresh, thresholds]
- For float scores (probabilities), use the next representable float above max(y_score) via
-
Rationale: Using
np.nextafterpreserves the mapping invariantpredicted = y_score >= thresholds[i]and keeps thresholds strictly decreasing, while avoiding large jumps above 1 for probability scores. Clipping to 1.0 would break semantics whenmax(y_score) == 1.0. -
Tests to add (in
sklearn/metrics/tests/test_ranking.py):- Probabilities with
max < 1: thresholds remain within [0,1], first threshold is > max(y_score). - Probabilities with an exact 1.0: first threshold is
np.nextafter(1.0, +inf); mapping semantics intact. - Non-probability (e.g., decision function): thresholds may exceed 1; first threshold is strictly greater than max(score); monotonic decrease and shapes preserved.
- Probabilities with
-
Docstring tweak: clarify that
thresholds[0]is a sentinel strictly greater than max(y_score); for float scores we use the next representable float; for integers,max + 1.
Acceptance
- Implement the above change and add the non-regression tests.
- Validate locally by running only the affected tests.
- Open a PR targeting branch
scikit-learn__scikit-learn-26194.