Skip to main content

plotters_statistical/stats/
roc.rs

1//! ROC curve: threshold sweep producing (FPR, TPR) points, and AUC via
2//! trapezoidal integration.
3
4use super::StatsError;
5
6/// A single operating point on an ROC curve.
7#[derive(Debug, Clone, Copy, PartialEq)]
8pub struct RocPoint {
9    /// False positive rate, `FP / N`.
10    pub fpr: f64,
11    /// True positive rate (recall / sensitivity), `TP / P`.
12    pub tpr: f64,
13    /// The score threshold at which this point is reached (predictions with
14    /// score `>= threshold` are called positive). The leading `(0, 0)` point
15    /// carries `+inf`.
16    pub threshold: f64,
17}
18
19/// An ROC curve: its operating points (from `(0,0)` to `(1,1)`) and the area
20/// under it.
21#[derive(Debug, Clone, PartialEq)]
22pub struct RocCurveData {
23    /// Operating points, ascending in FPR.
24    pub points: Vec<RocPoint>,
25    /// Area under the ROC curve, in `[0, 1]`.
26    pub auc: f64,
27}
28
29/// Trapezoidal integration of `y` over `x` for a sequence of `(x, y)` points
30/// assumed sorted by ascending `x`.
31///
32/// Exposed because AUC is just this applied to `(fpr, tpr)`; callers with their
33/// own point sets can reuse it directly.
34pub fn auc_trapezoid(points: &[(f64, f64)]) -> f64 {
35    points
36        .windows(2)
37        .map(|w| {
38            let (x1, y1) = w[0];
39            let (x2, y2) = w[1];
40            (x2 - x1) * (y1 + y2) / 2.0
41        })
42        .sum()
43}
44
45/// Compute an ROC curve from predicted `scores` and true binary `labels`
46/// (`true` = positive class).
47///
48/// Higher scores are treated as "more positive". The sweep visits each distinct
49/// score as a threshold, emitting one point per distinct score (ties handled
50/// together), and always begins at `(0, 0)`.
51///
52/// # Errors
53///
54/// * [`StatsError::LengthMismatch`] if `scores` and `labels` differ in length.
55/// * [`StatsError::EmptyInput`] if empty.
56/// * [`StatsError::NoPositiveLabels`] / [`StatsError::NoNegativeLabels`] if a
57///   class is absent, since TPR or FPR would be `0/0`.
58pub fn roc_curve(scores: &[f64], labels: &[bool]) -> Result<RocCurveData, StatsError> {
59    if scores.len() != labels.len() {
60        return Err(StatsError::LengthMismatch {
61            scores: scores.len(),
62            labels: labels.len(),
63        });
64    }
65    if scores.is_empty() {
66        return Err(StatsError::EmptyInput);
67    }
68    let total_p = labels.iter().filter(|&&l| l).count();
69    let total_n = labels.len() - total_p;
70    if total_p == 0 {
71        return Err(StatsError::NoPositiveLabels);
72    }
73    if total_n == 0 {
74        return Err(StatsError::NoNegativeLabels);
75    }
76
77    // Sort indices by descending score.
78    let mut order: Vec<usize> = (0..scores.len()).collect();
79    order.sort_by(|&a, &b| {
80        scores[b]
81            .partial_cmp(&scores[a])
82            .unwrap_or(std::cmp::Ordering::Equal)
83    });
84
85    let (p, n) = (total_p as f64, total_n as f64);
86    let mut points = Vec::with_capacity(order.len() + 1);
87    points.push(RocPoint {
88        fpr: 0.0,
89        tpr: 0.0,
90        threshold: f64::INFINITY,
91    });
92
93    let mut tp = 0usize;
94    let mut fp = 0usize;
95    let mut i = 0usize;
96    while i < order.len() {
97        let score = scores[order[i]];
98        // Consume every instance sharing this score before emitting a point,
99        // so tied scores can't be split by an arbitrary threshold.
100        while i < order.len() && scores[order[i]] == score {
101            if labels[order[i]] {
102                tp += 1;
103            } else {
104                fp += 1;
105            }
106            i += 1;
107        }
108        points.push(RocPoint {
109            fpr: fp as f64 / n,
110            tpr: tp as f64 / p,
111            threshold: score,
112        });
113    }
114
115    let auc = auc_trapezoid(&points.iter().map(|pt| (pt.fpr, pt.tpr)).collect::<Vec<_>>());
116
117    Ok(RocCurveData { points, auc })
118}