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}