Skip to main content

plotters_statistical/series/
residual_plot.rs

1//! Residual plot series: residuals vs fitted values, with a zero-reference line
2//! and an optional binned moving-average trend line.
3//!
4//! The trend is a moving average over binned x-ranges rather than a full LOESS
5//! fit — enough to reveal whether residuals drift away from zero, which is the
6//! diagnostic point, without the much larger scope of a local-regression
7//! smoother.
8
9use plotters::element::{Drawable, PointCollection};
10use plotters::style::RGBColor;
11use plotters_backend::{BackendCoord, DrawingBackend, DrawingErrorKind};
12
13use crate::stats::StatsError;
14use crate::style::{stroke_style, translucent_fill};
15
16const DEFAULT_POINT_COLOR: RGBColor = RGBColor(0, 114, 178); // Okabe–Ito blue
17const ZERO_LINE_GRAY: RGBColor = RGBColor(120, 120, 120);
18const TREND_COLOR: RGBColor = RGBColor(213, 94, 0); // Okabe–Ito vermillion
19const DEFAULT_BINS: usize = 12;
20
21/// A residual plot as a drawable series (coordinate space `(f64, f64)` =
22/// `(fitted, residual)`).
23#[derive(Debug, Clone)]
24pub struct ResidualPlot {
25    // Layout: [scatter (n_points)] [zero-line anchors: (xmin,0),(xmax,0)]
26    //         [trend points (n_trend)].
27    points: Vec<(f64, f64)>,
28    n_points: usize,
29    n_trend: usize,
30    show_trend: bool,
31    marker_radius: u32,
32    point_color: RGBColor,
33    trend_width: u32,
34}
35
36impl ResidualPlot {
37    /// Build from `fitted` (predicted) values and their `residuals`.
38    pub fn from_residuals(fitted: &[f64], residuals: &[f64]) -> Result<Self, StatsError> {
39        if fitted.len() != residuals.len() {
40            return Err(StatsError::LengthMismatch {
41                scores: fitted.len(),
42                labels: residuals.len(),
43            });
44        }
45        let pairs: Vec<(f64, f64)> = fitted
46            .iter()
47            .zip(residuals)
48            .map(|(&x, &r)| (x, r))
49            .filter(|(x, r)| x.is_finite() && r.is_finite())
50            .collect();
51        Self::from_pairs(pairs)
52    }
53
54    /// Build from `predicted` and `actual` values, computing residuals as
55    /// `actual - predicted` internally.
56    pub fn from_predictions(predicted: &[f64], actual: &[f64]) -> Result<Self, StatsError> {
57        if predicted.len() != actual.len() {
58            return Err(StatsError::LengthMismatch {
59                scores: predicted.len(),
60                labels: actual.len(),
61            });
62        }
63        let residuals: Vec<f64> = predicted.iter().zip(actual).map(|(&p, &a)| a - p).collect();
64        Self::from_residuals(predicted, &residuals)
65    }
66
67    fn from_pairs(pairs: Vec<(f64, f64)>) -> Result<Self, StatsError> {
68        if pairs.is_empty() {
69            return Err(StatsError::EmptyInput);
70        }
71        let n_points = pairs.len();
72        let xmin = pairs.iter().map(|p| p.0).fold(f64::INFINITY, f64::min);
73        let xmax = pairs.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max);
74
75        let trend = binned_trend(&pairs, DEFAULT_BINS);
76
77        let mut points = pairs;
78        points.push((xmin, 0.0));
79        points.push((xmax, 0.0));
80        let n_trend = trend.len();
81        points.extend(trend);
82
83        Ok(Self {
84            points,
85            n_points,
86            n_trend,
87            show_trend: false,
88            marker_radius: 3,
89            point_color: DEFAULT_POINT_COLOR,
90            trend_width: 2,
91        })
92    }
93
94    /// Enable/disable the binned moving-average trend line.
95    pub fn trend(mut self, show: bool) -> Self {
96        self.show_trend = show;
97        self
98    }
99
100    /// Set the scatter marker radius in pixels.
101    pub fn marker_radius(mut self, radius: u32) -> Self {
102        self.marker_radius = radius;
103        self
104    }
105
106    /// Set the scatter point color.
107    pub fn color(mut self, color: RGBColor) -> Self {
108        self.point_color = color;
109        self
110    }
111}
112
113impl<'a> PointCollection<'a, (f64, f64)> for &'a ResidualPlot {
114    type Point = &'a (f64, f64);
115    type IntoIter = &'a [(f64, f64)];
116    fn point_iter(self) -> &'a [(f64, f64)] {
117        &self.points
118    }
119}
120
121impl<DB: DrawingBackend> Drawable<DB> for ResidualPlot {
122    fn draw<I: Iterator<Item = BackendCoord>>(
123        &self,
124        points: I,
125        backend: &mut DB,
126        _parent_dim: (u32, u32),
127    ) -> Result<(), DrawingErrorKind<DB::ErrorType>> {
128        let pix: Vec<BackendCoord> = points.collect();
129        if pix.len() < self.n_points + 2 {
130            return Ok(());
131        }
132        // Zero reference line (drawn first, under the points).
133        let anchor_l = pix[self.n_points];
134        let anchor_r = pix[self.n_points + 1];
135        backend.draw_line(anchor_l, anchor_r, &stroke_style(&ZERO_LINE_GRAY, 1))?;
136
137        // Scatter.
138        let marker = translucent_fill(&self.point_color, 0.7);
139        for p in &pix[..self.n_points] {
140            backend.draw_circle(*p, self.marker_radius, &marker, true)?;
141        }
142
143        // Trend line.
144        if self.show_trend && self.n_trend >= 2 {
145            let start = self.n_points + 2;
146            let trend = &pix[start..start + self.n_trend];
147            backend.draw_path(
148                trend.iter().copied(),
149                &stroke_style(&TREND_COLOR, self.trend_width),
150            )?;
151        }
152        Ok(())
153    }
154}
155
156/// Moving average of residuals over `bins` equal-width x-bins. Returns one
157/// `(bin_center, mean_residual)` point per non-empty bin, in ascending x.
158fn binned_trend(pairs: &[(f64, f64)], bins: usize) -> Vec<(f64, f64)> {
159    let bins = bins.max(1);
160    let xmin = pairs.iter().map(|p| p.0).fold(f64::INFINITY, f64::min);
161    let xmax = pairs.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max);
162    if !(xmin.is_finite() && xmax.is_finite()) || xmax <= xmin {
163        return Vec::new();
164    }
165    let width = (xmax - xmin) / bins as f64;
166    let mut sums = vec![0.0_f64; bins];
167    let mut counts = vec![0usize; bins];
168    for &(x, r) in pairs {
169        let mut idx = ((x - xmin) / width).floor() as usize;
170        if idx >= bins {
171            idx = bins - 1; // the max value lands in the last bin
172        }
173        sums[idx] += r;
174        counts[idx] += 1;
175    }
176    (0..bins)
177        .filter(|&b| counts[b] > 0)
178        .map(|b| {
179            let center = xmin + width * (b as f64 + 0.5);
180            (center, sums[b] / counts[b] as f64)
181        })
182        .collect()
183}