Skip to main content

ggplot_rs/geom/
cooks.rs

1//! Cook's-distance contours for a residuals-vs-leverage plot (R's
2//! `plot.lm(which = 5)`): with `x` = leverage `h` and `y` = standardized
3//! residual `r`, Cook's distance is `D = r² h / (p (1 − h))`, so the points
4//! with `D = level` lie on `r = ±√(level · p · (1 − h) / h)`.
5//!
6//! The curves are drawn dashed across the panel's leverage range (or
7//! `h_range`), clipped to the panel and labelled with their level. They train
8//! no scale — like `plot.lm`, they never widen the axes.
9
10use crate::aes::Aesthetic;
11use crate::coord::Coord;
12use crate::data::{DataFrame, Value};
13use crate::position::identity::PositionIdentity;
14use crate::position::Position;
15use crate::render::backend::{DrawBackend, LineStyle, Linetype, TextAnchor, TextStyle};
16use crate::render::RenderError;
17use crate::scale::ScaleSet;
18use crate::stat::identity::StatIdentity;
19use crate::stat::Stat;
20use crate::theme::Theme;
21
22use super::support::clip_polyline_unit;
23use super::{Geom, GeomParams};
24
25/// The standardized residual on the Cook's-distance contour `level` at
26/// leverage `h` for a model with `p` parameters: `√(level · p · (1 − h) / h)`
27/// (NaN outside `0 < h ≤ 1` or for a non-positive level / `p`).
28pub fn cooks_contour_y(level: f64, p: f64, h: f64) -> f64 {
29    if !(h > 0.0 && h <= 1.0 && level > 0.0 && p > 0.0) {
30        return f64::NAN;
31    }
32    (level * p * (1.0 - h) / h).sqrt()
33}
34
35/// Cook's distance `D = r² h / (p (1 − h))` of a point with standardized
36/// residual `r` and leverage `h` (the inverse of [`cooks_contour_y`]).
37pub fn cooks_distance(r: f64, h: f64, p: f64) -> f64 {
38    r * r * h / (p * (1.0 - h))
39}
40
41/// Cook's-distance contour curves (`±√(level·p·(1−h)/h)`), one pair per level.
42pub struct GeomCooksContour {
43    /// Number of model parameters (incl. the intercept).
44    pub p: f64,
45    /// Contour levels (R's `cook.levels`, default `[0.5, 1.0]`).
46    pub levels: Vec<f64>,
47    /// Leverage range to draw over; `None` = the panel's x range (in `(0, 1]`).
48    pub h_range: Option<(f64, f64)>,
49    pub color: (u8, u8, u8),
50    pub width: f64,
51    pub linetype: Linetype,
52    pub alpha: f64,
53    /// Label each curve with its level at its right end.
54    pub label: bool,
55    pub label_size: f64,
56}
57
58impl GeomCooksContour {
59    pub fn new(p: usize, levels: &[f64]) -> Self {
60        GeomCooksContour {
61            p: p as f64,
62            levels: levels.to_vec(),
63            h_range: None,
64            color: (205, 0, 0),
65            width: 0.8,
66            linetype: Linetype::Dashed,
67            alpha: 1.0,
68            label: true,
69            label_size: 9.0,
70        }
71    }
72
73    /// Restrict the curves to leverages in `[lo, hi]`.
74    pub fn with_h_range(mut self, lo: f64, hi: f64) -> Self {
75        self.h_range = Some((lo, hi));
76        self
77    }
78}
79
80impl Default for GeomCooksContour {
81    fn default() -> Self {
82        GeomCooksContour::new(2, &[0.5, 1.0])
83    }
84}
85
86impl Geom for GeomCooksContour {
87    fn draw(
88        &self,
89        _data: &DataFrame,
90        coord: &dyn Coord,
91        scales: &ScaleSet,
92        _theme: &Theme,
93        backend: &mut dyn DrawBackend,
94    ) -> Result<(), RenderError> {
95        let (Some(xs), Some(ys)) = (scales.get(&Aesthetic::X), scales.get(&Aesthetic::Y)) else {
96            return Ok(());
97        };
98        if xs.is_discrete() || ys.is_discrete() {
99            return Ok(());
100        }
101        let Some((x0, x1)) = xs.expanded_domain() else {
102            return Ok(());
103        };
104        let (lo, hi) = self.h_range.unwrap_or((x0, x1));
105        let lo = lo.max(x0).max(1e-9);
106        let hi = hi.min(x1).min(1.0);
107        if !(lo.is_finite() && hi.is_finite() && hi > lo) {
108            return Ok(());
109        }
110        let plot_area = backend.plot_area();
111        // Log-spaced leverages: the curves are steep near h = 0.
112        const N: usize = 240;
113        let hs: Vec<f64> = (0..=N)
114            .map(|k| lo * (hi / lo).powf(k as f64 / N as f64))
115            .collect();
116        let style = LineStyle {
117            color: self.color,
118            alpha: self.alpha,
119            width: self.width,
120            linetype: self.linetype,
121        };
122        for &level in self.levels.iter().filter(|l| l.is_finite() && **l > 0.0) {
123            let label = super::tip_value(&Value::Float(level));
124            super::set_mark(
125                backend,
126                Some(format!("Cook's distance = {label}")),
127                None,
128                None,
129                super::raw_value(&Value::Float(level)),
130            );
131            for sign in [1.0, -1.0] {
132                let pts: Vec<(f64, f64)> = hs
133                    .iter()
134                    .map(|&h| {
135                        let r = sign * cooks_contour_y(level, self.p, h);
136                        (xs.map(&Value::Float(h)), ys.map(&Value::Float(r)))
137                    })
138                    .collect();
139                let runs = clip_polyline_unit(&pts);
140                for run in &runs {
141                    let px: Vec<(f64, f64)> = run
142                        .iter()
143                        .map(|&q| coord.transform(q, &plot_area))
144                        .collect();
145                    backend.draw_line(&px, &style)?;
146                }
147                // Label at the right end of the rightmost visible run.
148                if self.label {
149                    if let Some(&end) = runs
150                        .iter()
151                        .filter_map(|r| r.last())
152                        .max_by(|a, b| a.0.total_cmp(&b.0))
153                    {
154                        let (px, py) = coord.transform(end, &plot_area);
155                        let dy = if sign > 0.0 { -6.0 } else { 6.0 };
156                        backend.draw_text(
157                            &label,
158                            (px - 2.0, py + dy),
159                            &TextStyle {
160                                color: self.color,
161                                size: self.label_size,
162                                anchor: TextAnchor::End,
163                                ..TextStyle::default()
164                            },
165                        )?;
166                    }
167                }
168            }
169        }
170        super::clear_mark(backend);
171        Ok(())
172    }
173
174    fn required_aes(&self) -> Vec<Aesthetic> {
175        vec![]
176    }
177    fn default_stat(&self) -> Box<dyn Stat> {
178        Box::new(StatIdentity)
179    }
180    fn default_position(&self) -> Box<dyn Position> {
181        Box::new(PositionIdentity)
182    }
183    fn default_params(&self) -> GeomParams {
184        GeomParams::default()
185    }
186    fn name(&self) -> &str {
187        "cooks_contour"
188    }
189    fn inherit_aes(&self) -> bool {
190        false
191    }
192    fn set_series_color(&mut self, _color: (u8, u8, u8)) {}
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn contour_inverts_cooks_distance() {
201        for &(level, p, h) in &[(0.5, 3.0, 0.1), (1.0, 2.0, 0.4), (4.0, 5.0, 0.9)] {
202            let r = cooks_contour_y(level, p, h);
203            assert!((cooks_distance(r, h, p) - level).abs() < 1e-12);
204        }
205        assert!(cooks_contour_y(0.5, 3.0, 0.0).is_nan());
206        assert!(cooks_contour_y(0.5, 3.0, 1.5).is_nan());
207        assert_eq!(cooks_contour_y(0.5, 3.0, 1.0), 0.0);
208    }
209}