Skip to main content

plotters_statistical/figures/
missingness_heatmap.rs

1//! Missingness heatmap figure: a present/absent map across observations (rows)
2//! and variables (columns), for EDA data-quality checks. Column labels carry the
3//! per-variable missing percentage.
4
5use plotters::coord::Shift;
6use plotters::prelude::*;
7use plotters::style::text_anchor::{HPos, VPos};
8
9use super::text_style;
10use crate::stats::StatsError;
11
12const PRESENT_COLOR: RGBColor = RGBColor(38, 70, 108); // dark blue
13const MISSING_COLOR: RGBColor = RGBColor(232, 232, 232); // light gray
14
15/// A present/absent map: one column per variable, observations down the rows.
16#[derive(Debug, Clone)]
17pub struct MissingnessHeatmap {
18    // present[row][col] — true if the value is present.
19    present: Vec<Vec<bool>>,
20    labels: Vec<String>,
21    nrows: usize,
22    present_color: RGBColor,
23    missing_color: RGBColor,
24    show_percent: bool,
25    title: Option<String>,
26}
27
28impl MissingnessHeatmap {
29    /// Build from `columns` of `Option<f64>` (one column per variable; `None` =
30    /// missing). Columns may differ in length; shorter columns are treated as
31    /// missing past their end. `labels` names the variables.
32    ///
33    /// # Errors
34    /// * [`StatsError::EmptyInput`] if there are no columns.
35    /// * [`StatsError::LengthMismatch`] if `labels` and `columns` differ in count.
36    pub fn from_columns(
37        columns: &[Vec<Option<f64>>],
38        labels: Vec<String>,
39    ) -> Result<Self, StatsError> {
40        if columns.is_empty() {
41            return Err(StatsError::EmptyInput);
42        }
43        if labels.len() != columns.len() {
44            return Err(StatsError::LengthMismatch {
45                scores: columns.len(),
46                labels: labels.len(),
47            });
48        }
49        let nrows = columns.iter().map(|c| c.len()).max().unwrap_or(0);
50        let ncols = columns.len();
51        let mut present = vec![vec![false; ncols]; nrows];
52        for (j, col) in columns.iter().enumerate() {
53            for (i, v) in col.iter().enumerate() {
54                present[i][j] = v.is_some();
55            }
56        }
57        Ok(Self {
58            present,
59            labels,
60            nrows,
61            present_color: PRESENT_COLOR,
62            missing_color: MISSING_COLOR,
63            show_percent: true,
64            title: None,
65        })
66    }
67
68    /// Set the color used for present values.
69    pub fn present_color(mut self, color: RGBColor) -> Self {
70        self.present_color = color;
71        self
72    }
73
74    /// Set the color used for missing values.
75    pub fn missing_color(mut self, color: RGBColor) -> Self {
76        self.missing_color = color;
77        self
78    }
79
80    /// Toggle the per-column missing-percentage labels.
81    pub fn show_percent(mut self, on: bool) -> Self {
82        self.show_percent = on;
83        self
84    }
85
86    /// Set a title drawn above the grid.
87    pub fn title(mut self, title: impl Into<String>) -> Self {
88        self.title = Some(title.into());
89        self
90    }
91
92    fn missing_fraction(&self, col: usize) -> f64 {
93        if self.nrows == 0 {
94            return 0.0;
95        }
96        let missing = self.present.iter().filter(|row| !row[col]).count();
97        missing as f64 / self.nrows as f64
98    }
99
100    /// Render the heatmap onto `area`.
101    pub fn draw<DB: DrawingBackend>(
102        &self,
103        area: &DrawingArea<DB, Shift>,
104    ) -> Result<(), Box<dyn std::error::Error>>
105    where
106        DB::ErrorType: 'static,
107    {
108        let ncols = self.labels.len();
109        if ncols == 0 || self.nrows == 0 {
110            return Ok(());
111        }
112        let (w, h) = area.dim_in_pixel();
113        let (w, h) = (w as i32, h as i32);
114        let top = if self.title.is_some() { 34 } else { 12 };
115        let left = 12;
116        let bottom = 70;
117        let gx0 = left;
118        let gy0 = top;
119        let gx1 = (w - 12).max(gx0 + 1);
120        let gy1 = (h - bottom).max(gy0 + 1);
121        let gw = gx1 - gx0;
122        let gh = gy1 - gy0;
123        let nc = ncols as i32;
124
125        if let Some(t) = &self.title {
126            area.draw(&Text::new(
127                t.clone(),
128                ((gx0 + gx1) / 2, 8),
129                text_style(HPos::Center, VPos::Top, 18, &RGBColor(0, 0, 0)),
130            ))?;
131        }
132
133        // Render by pixel row: map each screen row to a data observation. This
134        // bounds work at ~gh*ncols regardless of how many observations there are.
135        for py in 0..gh {
136            let row = (py as usize * self.nrows) / gh as usize;
137            let y0 = gy0 + py;
138            for j in 0..ncols {
139                let x0 = gx0 + gw * j as i32 / nc;
140                let x1 = gx0 + gw * (j as i32 + 1) / nc;
141                let color = if self.present[row][j] {
142                    self.present_color
143                } else {
144                    self.missing_color
145                };
146                area.draw(&Rectangle::new([(x0, y0), (x1, y0 + 1)], color.filled()))?;
147            }
148        }
149
150        // Column labels + missing percentage.
151        for (j, label) in self.labels.iter().enumerate() {
152            let xc = gx0 + gw * (2 * j as i32 + 1) / (2 * nc);
153            area.draw(&Text::new(
154                label.clone(),
155                (xc, gy1 + 8),
156                text_style(HPos::Center, VPos::Top, 12, &RGBColor(0, 0, 0)),
157            ))?;
158            if self.show_percent {
159                area.draw(&Text::new(
160                    format!("{:.0}%", 100.0 * self.missing_fraction(j)),
161                    (xc, gy1 + 26),
162                    text_style(HPos::Center, VPos::Top, 11, &RGBColor(120, 120, 120)),
163                ))?;
164            }
165        }
166        Ok(())
167    }
168}