Skip to main content

plotters_statistical/figures/
pair_plot.rs

1//! Pair plot (scatterplot matrix): every variable against every other, with a
2//! distribution view on the diagonal. Renders onto a drawing area by splitting
3//! it into an n×n grid and reusing this crate's series plus plain `plotters`
4//! scatter marks.
5
6use plotters::coord::Shift;
7use plotters::prelude::*;
8
9use crate::series::Ecdf;
10use crate::stats::{histogram, BinRule};
11use crate::style::{palette_color, translucent_fill};
12
13/// What to draw on the diagonal panels (variable vs itself).
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Diagonal {
16    /// A histogram of the variable.
17    Histogram,
18    /// The variable's empirical CDF.
19    Ecdf,
20}
21
22/// A scatterplot matrix.
23#[derive(Debug, Clone)]
24pub struct PairPlot {
25    columns: Vec<Vec<f64>>,
26    labels: Vec<String>,
27    diagonal: Diagonal,
28    marker_radius: u32,
29    hue: Option<Vec<usize>>,
30    bins: BinRule,
31}
32
33impl PairPlot {
34    /// Build from `columns` (one slice per variable, all the same length) and
35    /// their `labels`. Panics-free: mismatched lengths are simply reflected in
36    /// the empty result of [`PairPlot::draw`] (which returns `Ok(())` when there
37    /// is nothing coherent to plot).
38    pub fn new(columns: Vec<Vec<f64>>, labels: Vec<String>) -> Self {
39        Self {
40            columns,
41            labels,
42            diagonal: Diagonal::Histogram,
43            marker_radius: 2,
44            hue: None,
45            bins: BinRule::Sturges,
46        }
47    }
48
49    /// Choose what the diagonal panels show.
50    pub fn diagonal(mut self, diagonal: Diagonal) -> Self {
51        self.diagonal = diagonal;
52        self
53    }
54
55    /// Set the scatter marker radius in pixels.
56    pub fn marker_radius(mut self, radius: u32) -> Self {
57        self.marker_radius = radius;
58        self
59    }
60
61    /// Color scatter points by a per-observation group index (its length should
62    /// match the column length). Colors cycle the shared palette.
63    pub fn hue(mut self, groups: Vec<usize>) -> Self {
64        self.hue = Some(groups);
65        self
66    }
67
68    /// Set the histogram bin rule used on the diagonal.
69    pub fn bins(mut self, bins: BinRule) -> Self {
70        self.bins = bins;
71        self
72    }
73
74    /// Render the matrix onto `area`.
75    pub fn draw<DB: DrawingBackend>(
76        &self,
77        area: &DrawingArea<DB, Shift>,
78    ) -> Result<(), Box<dyn std::error::Error>>
79    where
80        DB::ErrorType: 'static,
81    {
82        let n = self.columns.len();
83        if n == 0 || self.labels.len() != n {
84            return Ok(());
85        }
86        let len = self.columns[0].len();
87        if self.columns.iter().any(|c| c.len() != len) || len == 0 {
88            return Ok(());
89        }
90
91        // Per-column display range with a little padding.
92        let ranges: Vec<(f64, f64)> = self.columns.iter().map(|c| padded_range(c)).collect();
93
94        let panels = area.split_evenly((n, n));
95        for i in 0..n {
96            for j in 0..n {
97                let panel = &panels[i * n + j];
98                if i == j {
99                    self.draw_diagonal(panel, i, ranges[i], i == n - 1, j == 0)?;
100                } else {
101                    self.draw_scatter(panel, j, i, ranges[j], ranges[i], i == n - 1, j == 0)?;
102                }
103            }
104        }
105        Ok(())
106    }
107
108    #[allow(clippy::too_many_arguments)]
109    fn draw_scatter<DB: DrawingBackend>(
110        &self,
111        panel: &DrawingArea<DB, Shift>,
112        xcol: usize,
113        ycol: usize,
114        xr: (f64, f64),
115        yr: (f64, f64),
116        bottom_row: bool,
117        left_col: bool,
118    ) -> Result<(), Box<dyn std::error::Error>>
119    where
120        DB::ErrorType: 'static,
121    {
122        let mut chart = self.panel_chart(panel, xr, yr, xcol, ycol, bottom_row, left_col)?;
123        let r = self.marker_radius;
124        let default_fill = translucent_fill(&palette_color(0), 0.55);
125        chart.draw_series(
126            self.columns[xcol]
127                .iter()
128                .zip(&self.columns[ycol])
129                .enumerate()
130                .map(|(k, (&x, &y))| {
131                    let style = match &self.hue {
132                        Some(g) if k < g.len() => translucent_fill(&palette_color(g[k]), 0.6),
133                        _ => default_fill,
134                    };
135                    Circle::new((x, y), r, style)
136                }),
137        )?;
138        Ok(())
139    }
140
141    fn draw_diagonal<DB: DrawingBackend>(
142        &self,
143        panel: &DrawingArea<DB, Shift>,
144        col: usize,
145        xr: (f64, f64),
146        bottom_row: bool,
147        left_col: bool,
148    ) -> Result<(), Box<dyn std::error::Error>>
149    where
150        DB::ErrorType: 'static,
151    {
152        let data = &self.columns[col];
153        match self.diagonal {
154            Diagonal::Ecdf => {
155                let mut chart =
156                    self.panel_chart(panel, xr, (0.0, 1.0), col, col, bottom_row, left_col)?;
157                if let Ok(e) = Ecdf::from_data(data) {
158                    chart.draw_series(std::iter::once(e))?;
159                }
160            }
161            Diagonal::Histogram => {
162                if let Ok(h) = histogram(data, self.bins) {
163                    let ymax = h.counts.iter().copied().max().unwrap_or(1).max(1) as f64;
164                    let mut chart = self.panel_chart(
165                        panel,
166                        xr,
167                        (0.0, ymax * 1.05),
168                        col,
169                        col,
170                        bottom_row,
171                        left_col,
172                    )?;
173                    let fill = translucent_fill(&palette_color(0), 0.6);
174                    chart.draw_series(
175                        h.edges
176                            .windows(2)
177                            .zip(&h.counts)
178                            .map(|(e, &c)| Rectangle::new([(e[0], 0.0), (e[1], c as f64)], fill)),
179                    )?;
180                }
181            }
182        }
183        Ok(())
184    }
185
186    #[allow(clippy::too_many_arguments)]
187    fn panel_chart<'b, DB: DrawingBackend>(
188        &self,
189        panel: &'b DrawingArea<DB, Shift>,
190        xr: (f64, f64),
191        yr: (f64, f64),
192        xcol: usize,
193        ycol: usize,
194        bottom_row: bool,
195        left_col: bool,
196    ) -> Result<
197        ChartContext<
198            'b,
199            DB,
200            Cartesian2d<
201                plotters::coord::types::RangedCoordf64,
202                plotters::coord::types::RangedCoordf64,
203            >,
204        >,
205        Box<dyn std::error::Error>,
206    >
207    where
208        DB::ErrorType: 'static,
209    {
210        let mut builder = ChartBuilder::on(panel);
211        builder.margin(3);
212        if left_col {
213            builder.set_label_area_size(LabelAreaPosition::Left, 38);
214        }
215        if bottom_row {
216            builder.set_label_area_size(LabelAreaPosition::Bottom, 26);
217        }
218        let mut chart = builder.build_cartesian_2d(xr.0..xr.1, yr.0..yr.1)?;
219        let x_desc = if bottom_row {
220            self.labels[xcol].clone()
221        } else {
222            String::new()
223        };
224        let y_desc = if left_col {
225            self.labels[ycol].clone()
226        } else {
227            String::new()
228        };
229        chart
230            .configure_mesh()
231            .disable_mesh()
232            .x_labels(if bottom_row { 4 } else { 0 })
233            .y_labels(if left_col { 4 } else { 0 })
234            .x_desc(x_desc)
235            .y_desc(y_desc)
236            .label_style(("sans-serif", 11))
237            .draw()?;
238        Ok(chart)
239    }
240}
241
242fn padded_range(data: &[f64]) -> (f64, f64) {
243    let mut lo = f64::INFINITY;
244    let mut hi = f64::NEG_INFINITY;
245    for &v in data {
246        if v.is_finite() {
247            lo = lo.min(v);
248            hi = hi.max(v);
249        }
250    }
251    if !lo.is_finite() {
252        return (-1.0, 1.0);
253    }
254    if hi <= lo {
255        return (lo - 0.5, lo + 0.5);
256    }
257    let pad = (hi - lo) * 0.05;
258    (lo - pad, hi + pad)
259}