Skip to main content

plotters_statistical/series/
qq_plot.rs

1//! Normal quantile–quantile plot: sample quantiles vs theoretical standard-normal
2//! quantiles, with a robust reference line through the first and third quartiles.
3
4use plotters::element::{Drawable, PointCollection};
5use plotters::style::RGBColor;
6use plotters_backend::{BackendCoord, DrawingBackend, DrawingErrorKind};
7
8use crate::stats::{norm_ppf, quartiles, sorted_finite, StatsError};
9use crate::style::{stroke_style, translucent_fill};
10
11const DEFAULT_COLOR: RGBColor = RGBColor(0, 114, 178); // Okabe–Ito blue
12const LINE_GRAY: RGBColor = RGBColor(120, 120, 120);
13
14/// A normal Q–Q plot as a drawable series (coordinate space `(f64, f64)` =
15/// `(theoretical quantile, sample quantile)`).
16///
17/// Points that fall on the reference line indicate the sample is consistent with
18/// a normal distribution; systematic departures (curvature, S-shapes) reveal
19/// skew or heavy tails.
20#[derive(Debug, Clone)]
21pub struct QqPlot {
22    // [scatter points (n)] then [2 reference-line endpoints].
23    points: Vec<(f64, f64)>,
24    n: usize,
25    marker_radius: u32,
26    color: RGBColor,
27    line_width: u32,
28    show_line: bool,
29}
30
31impl QqPlot {
32    /// Build from a raw `data` sample. Uses Blom plotting positions
33    /// `((i - 3/8) / (n + 1/4))` for the theoretical quantiles and a reference
34    /// line through the sample/theoretical first and third quartiles (like R's
35    /// `qqline`).
36    ///
37    /// # Errors
38    /// * [`StatsError::EmptyInput`] if no finite values remain.
39    pub fn from_data(data: &[f64]) -> Result<Self, StatsError> {
40        let sorted = sorted_finite(data);
41        if sorted.is_empty() {
42            return Err(StatsError::EmptyInput);
43        }
44        let n = sorted.len();
45        let nf = n as f64;
46        let mut pts: Vec<(f64, f64)> = Vec::with_capacity(n);
47        for (i, &s) in sorted.iter().enumerate() {
48            let p = ((i as f64 + 1.0) - 0.375) / (nf + 0.25);
49            pts.push((norm_ppf(p), s));
50        }
51
52        // Robust reference line through the quartiles.
53        let q = quartiles(&sorted)?;
54        let tx1 = norm_ppf(0.25);
55        let tx3 = norm_ppf(0.75);
56        let (slope, intercept) = if tx3 > tx1 {
57            let m = (q.q3 - q.q1) / (tx3 - tx1);
58            (m, q.q1 - m * tx1)
59        } else {
60            (1.0, 0.0)
61        };
62        let x_lo = pts.first().map(|p| p.0).unwrap_or(-3.0);
63        let x_hi = pts.last().map(|p| p.0).unwrap_or(3.0);
64        pts.push((x_lo, slope * x_lo + intercept));
65        pts.push((x_hi, slope * x_hi + intercept));
66
67        Ok(Self {
68            points: pts,
69            n,
70            marker_radius: 3,
71            color: DEFAULT_COLOR,
72            line_width: 1,
73            show_line: true,
74        })
75    }
76
77    /// Show/hide the reference line.
78    pub fn reference_line(mut self, show: bool) -> Self {
79        self.show_line = show;
80        self
81    }
82
83    /// Set the marker color.
84    pub fn color(mut self, color: RGBColor) -> Self {
85        self.color = color;
86        self
87    }
88
89    /// Set the marker radius in pixels.
90    pub fn marker_radius(mut self, radius: u32) -> Self {
91        self.marker_radius = radius;
92        self
93    }
94
95    /// Set the reference-line width in pixels.
96    pub fn line_width(mut self, width: u32) -> Self {
97        self.line_width = width;
98        self
99    }
100}
101
102impl<'a> PointCollection<'a, (f64, f64)> for &'a QqPlot {
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 QqPlot {
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 + 2 {
119            return Ok(());
120        }
121        if self.show_line {
122            backend.draw_line(
123                pix[self.n],
124                pix[self.n + 1],
125                &stroke_style(&LINE_GRAY, self.line_width),
126            )?;
127        }
128        let fill = translucent_fill(&self.color, 0.7);
129        for p in &pix[..self.n] {
130            backend.draw_circle(*p, self.marker_radius, &fill, true)?;
131        }
132        Ok(())
133    }
134}