Skip to main content

plotters_statistical/series/
roc_curve.rs

1//! ROC curve series.
2//!
3//! Construct from raw `(scores, labels)` or from precomputed `(FPR, TPR)`
4//! points. Renders as a line with an optional area fill (AUC shading) and an
5//! optional random-chance diagonal. The computed AUC is exposed so it can be
6//! folded into a legend label — see [`RocCurve::legend_label`].
7
8use plotters::element::{Drawable, PointCollection};
9use plotters::style::{RGBColor, ShapeStyle};
10use plotters_backend::{BackendCoord, DrawingBackend, DrawingErrorKind};
11
12use crate::stats::roc::auc_trapezoid;
13use crate::stats::{roc_curve as compute_roc, StatsError};
14use crate::style::{stroke_style, translucent_fill};
15
16const DEFAULT_COLOR: RGBColor = RGBColor(0, 114, 178); // Okabe–Ito blue
17const BASELINE_GRAY: RGBColor = RGBColor(150, 150, 150);
18
19/// A single ROC curve as a drawable series (coordinate space `(f64, f64)` =
20/// `(FPR, TPR)`).
21#[derive(Debug, Clone)]
22pub struct RocCurve {
23    // [curve points (n_curve)] then one baseline anchor at (1.0, 0.0) used to
24    // close the AUC shading polygon down to the x-axis.
25    points: Vec<(f64, f64)>,
26    n_curve: usize,
27    auc: f64,
28    color: RGBColor,
29    stroke_width: u32,
30    shade: bool,
31    shade_alpha: f64,
32    baseline: bool,
33}
34
35impl RocCurve {
36    /// Build from raw predicted `scores` and true binary `labels`.
37    pub fn from_scores(scores: &[f64], labels: &[bool]) -> Result<Self, StatsError> {
38        let data = compute_roc(scores, labels)?;
39        let pts: Vec<(f64, f64)> = data.points.iter().map(|p| (p.fpr, p.tpr)).collect();
40        Ok(Self::new(pts, data.auc))
41    }
42
43    /// Build directly from precomputed `(FPR, TPR)` points (assumed ascending in
44    /// FPR). AUC is computed from them by trapezoidal integration.
45    pub fn from_points(points: Vec<(f64, f64)>) -> Self {
46        let auc = auc_trapezoid(&points);
47        Self::new(points, auc)
48    }
49
50    fn new(mut points: Vec<(f64, f64)>, auc: f64) -> Self {
51        let n_curve = points.len();
52        points.push((1.0, 0.0)); // baseline anchor for shading
53        Self {
54            points,
55            n_curve,
56            auc,
57            color: DEFAULT_COLOR,
58            stroke_width: 2,
59            shade: false,
60            shade_alpha: 0.15,
61            baseline: false,
62        }
63    }
64
65    /// The area under this ROC curve.
66    pub fn auc(&self) -> f64 {
67        self.auc
68    }
69
70    /// A legend label of the form `"{name} (AUC = 0.87)"`. Build this *before*
71    /// moving the curve into `draw_series`, since that consumes it.
72    pub fn legend_label(&self, name: &str) -> String {
73        format!("{name} (AUC = {:.2})", self.auc)
74    }
75
76    /// Set the line color (also used for the shading and legend key).
77    pub fn color(mut self, color: RGBColor) -> Self {
78        self.color = color;
79        self
80    }
81
82    /// Set the line stroke width in pixels.
83    pub fn stroke_width(mut self, width: u32) -> Self {
84        self.stroke_width = width;
85        self
86    }
87
88    /// Fill the area under the curve (AUC shading).
89    pub fn shade_area(mut self, shade: bool) -> Self {
90        self.shade = shade;
91        self
92    }
93
94    /// Draw the `y = x` random-chance diagonal. A one-line opt-in, since this
95    /// reference line is standard on ROC charts.
96    pub fn with_baseline(mut self) -> Self {
97        self.baseline = true;
98        self
99    }
100}
101
102impl<'a> PointCollection<'a, (f64, f64)> for &'a RocCurve {
103    type Point = &'a (f64, f64);
104    type IntoIter = &'a [(f64, f64)];
105    fn point_iter(self) -> &'a [(f64, f64)] {
106        &self.points
107    }
108}
109
110impl<DB: DrawingBackend> Drawable<DB> for RocCurve {
111    fn draw<I: Iterator<Item = BackendCoord>>(
112        &self,
113        points: I,
114        backend: &mut DB,
115        _parent_dim: (u32, u32),
116    ) -> Result<(), DrawingErrorKind<DB::ErrorType>> {
117        let pix: Vec<BackendCoord> = points.collect();
118        if pix.len() < self.n_curve + 1 || self.n_curve < 2 {
119            return Ok(());
120        }
121        let curve = &pix[..self.n_curve];
122        let anchor = pix[self.n_curve]; // pixel of (1.0, 0.0)
123
124        // AUC shading: curve, down to the baseline anchor, back to the origin.
125        if self.shade {
126            let mut poly = curve.to_vec();
127            poly.push(anchor);
128            poly.push(curve[0]); // (0,0)
129            backend.fill_polygon(poly, &translucent_fill(&self.color, self.shade_alpha))?;
130        }
131
132        // Random-chance diagonal from (0,0) to (1,1).
133        if self.baseline {
134            let dashed: ShapeStyle = stroke_style(&BASELINE_GRAY, 1);
135            backend.draw_line(curve[0], curve[self.n_curve - 1], &dashed)?;
136        }
137
138        backend.draw_path(
139            curve.iter().copied(),
140            &stroke_style(&self.color, self.stroke_width),
141        )?;
142        Ok(())
143    }
144}