Thanks to visit codestin.com
Credit goes to github.com

Skip to content

ROC curve thresholds can exceed 1 with probability estimates (non-regression tests + fix proposal) #53

@rowan-stein

Description

@rowan-stein

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_curve prepends a threshold strictly greater than the maximum score to ensure the ROC starts at (0,0). In the current code this is thresholds = 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 when max == 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]
  • Rationale: Using np.nextafter preserves the mapping invariant predicted = 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 when max(y_score) == 1.0.

  • Tests to add (in sklearn/metrics/tests/test_ranking.py):

    1. Probabilities with max < 1: thresholds remain within [0,1], first threshold is > max(y_score).
    2. Probabilities with an exact 1.0: first threshold is np.nextafter(1.0, +inf); mapping semantics intact.
    3. Non-probability (e.g., decision function): thresholds may exceed 1; first threshold is strictly greater than max(score); monotonic decrease and shapes preserved.
  • 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions