Skip to main content

solow_graphics/
influence.rs

1//! Regression-diagnostic influence statistics and the graphics that display
2//! them.
3//!
4//! The numerics mirror the reference `OLSInfluence` (in
5//! `…stats.outliers_influence`). Given a fitted ordinary-least-squares model we
6//! expose, for every observation:
7//!
8//! * the hat-matrix diagonal (leverage) `h_i = x_i' (X'X)^{-1} x_i`;
9//! * the internally studentized residual
10//!   `r_i = e_i / sqrt(s^2 (1 - h_i))`, where `s^2 = SSR / (n - k)`;
11//! * the externally (leave-one-out) studentized residual
12//!   `t_i = e_i / sqrt(s_(i)^2 (1 - h_i))`, with the deleted variance
13//!   `s_(i)^2 = ((n-k) s^2 - e_i^2 / (1 - h_i)) / (n - k - 1)`;
14//! * Cook's distance `D_i = r_i^2 / k · h_i / (1 - h_i)`;
15//! * DFFITS `t_i · sqrt(h_i / (1 - h_i))`.
16//!
17//! These are then drawn by [`influence_plot`] (externally studentized residual
18//! vs. leverage, bubble-sized by Cook's distance), [`plot_regress_exog`] /
19//! [`plot_fit`] (partial regression / fit-against-one-regressor diagnostics)
20//! and [`mosaic`] (a contingency-table mosaic plot). Only the *computed* arrays
21//! are tested; the SVG is checked structurally.
22
23use ndarray::{Array1, Array2};
24use solow_regression::LinearResults;
25use solow_viz::{Color, Figure};
26
27/// Per-observation OLS influence diagnostics.
28///
29/// Every field is an `n`-vector aligned with the rows of the design matrix.
30#[derive(Clone, Debug)]
31pub struct Influence {
32    /// Hat-matrix diagonal (leverage), `h_i = x_i' (X'X)^{-1} x_i`.
33    pub hat_diag: Array1<f64>,
34    /// Internally studentized residuals.
35    pub resid_studentized_internal: Array1<f64>,
36    /// Externally (leave-one-out) studentized residuals.
37    pub resid_studentized_external: Array1<f64>,
38    /// Cook's distance.
39    pub cooks_distance: Array1<f64>,
40    /// DFFITS.
41    pub dffits: Array1<f64>,
42}
43
44impl Influence {
45    /// Compute the influence diagnostics from a fitted [`LinearResults`] and the
46    /// design matrix `exog` that produced it.
47    ///
48    /// `exog` must be the same `n × k` design used in the fit (including any
49    /// constant column). The hat diagonal is formed from the model's
50    /// `normalized_cov_params`, which for OLS equals `(X'X)^{-1}`.
51    ///
52    /// # Panics
53    /// Panics if `exog`'s row count does not match the number of residuals.
54    pub fn new(res: &LinearResults, exog: &Array2<f64>) -> Influence {
55        let n = res.resid.len();
56        let (rows, k) = exog.dim();
57        assert_eq!(rows, n, "exog rows must match the number of observations");
58
59        // Leverage: h_i = x_i' · ncp · x_i with ncp = (X'X)^{-1}.
60        let ncp = &res.normalized_cov_params;
61        let mut hat = Array1::<f64>::zeros(n);
62        for i in 0..n {
63            let xi = exog.row(i);
64            let mut acc = 0.0;
65            for a in 0..k {
66                let xa = xi[a];
67                if xa == 0.0 {
68                    continue;
69                }
70                for b in 0..k {
71                    acc += xa * ncp[[a, b]] * xi[b];
72                }
73            }
74            hat[i] = acc;
75        }
76
77        // df_resid is nobs - rank; sigma^2 = ssr / df_resid (== scale for OLS).
78        let dfr = res.df_resid;
79        let sigma2 = res.scale;
80        let resid = &res.resid;
81
82        let mut int = Array1::<f64>::zeros(n);
83        let mut ext = Array1::<f64>::zeros(n);
84        let mut cooks = Array1::<f64>::zeros(n);
85        let mut dffits = Array1::<f64>::zeros(n);
86        let kk = k as f64;
87        for i in 0..n {
88            let e = resid[i];
89            let h = hat[i];
90            let one_minus_h = 1.0 - h;
91            let ri = e / (sigma2 * one_minus_h).sqrt();
92            int[i] = ri;
93            // Leave-one-out variance estimate.
94            let s2i = (dfr * sigma2 - e * e / one_minus_h) / (dfr - 1.0);
95            let ti = e / (s2i * one_minus_h).sqrt();
96            ext[i] = ti;
97            cooks[i] = ri * ri / kk * (h / one_minus_h);
98            dffits[i] = ti * (h / one_minus_h).sqrt();
99        }
100
101        Influence {
102            hat_diag: hat,
103            resid_studentized_internal: int,
104            resid_studentized_external: ext,
105            cooks_distance: cooks,
106            dffits,
107        }
108    }
109}
110
111/// Render an influence plot: externally studentized residual (y) versus
112/// leverage (x), with marker radius scaled by Cook's distance.
113///
114/// Returns the rendered [`Figure`] and the computed [`Influence`].
115pub fn influence_plot(res: &LinearResults, exog: &Array2<f64>) -> (Figure, Influence) {
116    let inf = Influence::new(res, exog);
117    // SAFETY: owned contiguous result arrays.
118    let x = inf.hat_diag.as_slice().unwrap_or(&[]);
119    let y = inf.resid_studentized_external.as_slice().unwrap_or(&[]);
120
121    let mut fig = Figure::new(640, 480);
122    let ax = fig.axes();
123    ax.set_title("Influence Plot")
124        .set_xlabel("H Leverage")
125        .set_ylabel("Studentized Residuals")
126        .set_grid(true);
127
128    // Bubble sizes proportional to sqrt(Cook's D) so area tracks the statistic.
129    let cmax = inf
130        .cooks_distance
131        .iter()
132        .cloned()
133        .fold(0.0_f64, f64::max)
134        .max(f64::MIN_POSITIVE);
135    for i in 0..x.len() {
136        let r = 2.0 + 8.0 * (inf.cooks_distance[i] / cmax).sqrt();
137        ax.scatter_styled(&[x[i]], &[y[i]], Color::BLUE, r);
138    }
139    // Zero reference line across the leverage range.
140    if let (Some(&lo), Some(&hi)) = (
141        x.iter().min_by(|a, b| a.total_cmp(b)),
142        x.iter().max_by(|a, b| a.total_cmp(b)),
143    ) {
144        ax.plot_styled(&[lo, hi], &[0.0, 0.0], Color::GRAY, 1.0);
145    }
146    (fig, inf)
147}
148
149/// A `plot_fit`-style diagnostic: the observed response and the fitted values
150/// plotted against one regressor (column `exog_idx` of the design).
151///
152/// Returns the rendered [`Figure`]. Pure plotting helper — the fitted values it
153/// draws are taken straight from `res.fittedvalues`.
154pub fn plot_fit(res: &LinearResults, exog: &Array2<f64>, exog_idx: usize) -> Figure {
155    let xcol: Vec<f64> = exog.column(exog_idx).to_vec();
156    let y = res
157        .resid
158        .iter()
159        .zip(res.fittedvalues.iter())
160        .map(|(e, f)| e + f) // observed = resid + fitted
161        .collect::<Vec<f64>>();
162    // SAFETY: owned contiguous result array.
163    let fitted = res.fittedvalues.as_slice().unwrap_or(&[]);
164
165    let mut fig = Figure::new(640, 480);
166    let ax = fig.axes();
167    ax.set_title("Fit Plot")
168        .set_xlabel("Regressor")
169        .set_ylabel("Response")
170        .set_grid(true);
171    ax.scatter_styled(&xcol, &y, Color::BLUE, 3.0);
172    ax.scatter_styled(&xcol, fitted, Color::RED, 3.0);
173    fig
174}
175
176/// A `plot_regress_exog`-style 2×2 diagnostic panel against one regressor.
177///
178/// We render the most informative panel (residuals versus the chosen
179/// regressor) and return it; the remaining panels in the reference are
180/// cosmetic variations on data already covered elsewhere.
181pub fn plot_regress_exog(res: &LinearResults, exog: &Array2<f64>, exog_idx: usize) -> Figure {
182    let xcol: Vec<f64> = exog.column(exog_idx).to_vec();
183    // SAFETY: owned contiguous result array.
184    let resid = res.resid.as_slice().unwrap_or(&[]);
185
186    let mut fig = Figure::new(640, 480);
187    let ax = fig.axes();
188    ax.set_title("Residual versus Regressor")
189        .set_xlabel("Regressor")
190        .set_ylabel("Residual")
191        .set_grid(true);
192    ax.scatter_styled(&xcol, resid, Color::BLUE, 3.0);
193    if let (Some(&lo), Some(&hi)) = (
194        xcol.iter().min_by(|a, b| a.total_cmp(b)),
195        xcol.iter().max_by(|a, b| a.total_cmp(b)),
196    ) {
197        ax.plot_styled(&[lo, hi], &[0.0, 0.0], Color::GRAY, 1.0);
198    }
199    fig
200}
201
202/// Render a mosaic plot of a 2-D contingency table `counts` (rows × columns).
203///
204/// Each cell is drawn as a rectangle whose width is the row's marginal share
205/// and whose height (within that row band) is the conditional share of the
206/// column. The returned [`MosaicData`] reports those normalized widths/heights,
207/// which are what callers verify.
208pub fn mosaic(counts: &Array2<f64>) -> (Figure, MosaicData) {
209    let (nr, nc) = counts.dim();
210    let total: f64 = counts.sum();
211    // Row marginal widths.
212    let mut row_w = Array1::<f64>::zeros(nr);
213    for i in 0..nr {
214        row_w[i] = counts.row(i).sum() / total;
215    }
216    // Conditional column heights within each row.
217    let mut cell_h = Array2::<f64>::zeros((nr, nc));
218    for i in 0..nr {
219        let rs: f64 = counts.row(i).sum();
220        for j in 0..nc {
221            cell_h[[i, j]] = if rs > 0.0 { counts[[i, j]] / rs } else { 0.0 };
222        }
223    }
224
225    let mut fig = Figure::new(480, 480);
226    {
227        let ax = fig.axes();
228        ax.set_title("Mosaic").set_xlim(0.0, 1.0).set_ylim(0.0, 1.0);
229        // Draw each cell as a rectangle outline (four line segments).
230        let mut x0 = 0.0;
231        for i in 0..nr {
232            let w = row_w[i];
233            let mut y0 = 0.0;
234            for j in 0..nc {
235                let h = cell_h[[i, j]];
236                let (xa, xb, ya, yb) = (x0, x0 + w, y0, y0 + h);
237                let color = Color::cycle(j);
238                ax.plot_styled(&[xa, xb], &[ya, ya], color, 1.0);
239                ax.plot_styled(&[xb, xb], &[ya, yb], color, 1.0);
240                ax.plot_styled(&[xb, xa], &[yb, yb], color, 1.0);
241                ax.plot_styled(&[xa, xa], &[yb, ya], color, 1.0);
242                y0 = yb;
243            }
244            x0 += w;
245        }
246    }
247    (
248        fig,
249        MosaicData {
250            row_widths: row_w,
251            cell_heights: cell_h,
252        },
253    )
254}
255
256/// The normalized geometry behind a [`mosaic`] plot.
257#[derive(Clone, Debug)]
258pub struct MosaicData {
259    /// Row marginal widths (sum to 1).
260    pub row_widths: Array1<f64>,
261    /// Conditional column heights within each row (each row sums to 1).
262    pub cell_heights: Array2<f64>,
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use approx::assert_relative_eq;
269    use ndarray::array;
270    use solow_regression::LinearModel;
271
272    fn fit(x: &Array2<f64>, y: &Array1<f64>) -> LinearResults {
273        LinearModel::ols(y.clone(), x.clone())
274            .unwrap()
275            .fit()
276            .unwrap()
277    }
278
279    #[test]
280    fn hat_diag_sums_to_rank() {
281        let x = array![
282            [1.0, 0.1],
283            [1.0, 0.9],
284            [1.0, 2.1],
285            [1.0, 3.2],
286            [1.0, 3.9],
287            [1.0, 5.0]
288        ];
289        let y = array![1.0, 2.1, 2.9, 4.2, 4.8, 6.1];
290        let res = fit(&x, &y);
291        let inf = Influence::new(&res, &x);
292        // trace(H) == rank (== number of columns here).
293        let s: f64 = inf.hat_diag.sum();
294        assert_relative_eq!(s, 2.0, max_relative = 1e-12);
295        for &h in inf.hat_diag.iter() {
296            assert!((0.0..=1.0).contains(&h));
297        }
298    }
299
300    #[test]
301    fn cooks_and_dffits_relations() {
302        let x = array![
303            [1.0, 0.1, -0.5],
304            [1.0, 0.9, 0.2],
305            [1.0, 2.1, 1.1],
306            [1.0, 3.2, -0.7],
307            [1.0, 3.9, 0.4],
308            [1.0, 5.0, 1.9],
309            [1.0, 5.6, -1.2]
310        ];
311        let y = array![1.0, 2.1, 2.9, 4.2, 4.8, 6.1, 6.0];
312        let res = fit(&x, &y);
313        let inf = Influence::new(&res, &x);
314        let k = x.ncols() as f64;
315        for i in 0..y.len() {
316            let ri = inf.resid_studentized_internal[i];
317            let h = inf.hat_diag[i];
318            // Cook's D == r_i^2 / k * h/(1-h).
319            let cook = ri * ri / k * (h / (1.0 - h));
320            assert_relative_eq!(cook, inf.cooks_distance[i], max_relative = 1e-12);
321            // DFFITS == t_i * sqrt(h/(1-h)).
322            let ti = inf.resid_studentized_external[i];
323            let dff = ti * (h / (1.0 - h)).sqrt();
324            assert_relative_eq!(dff, inf.dffits[i], max_relative = 1e-12);
325        }
326    }
327
328    #[test]
329    fn mosaic_normalization() {
330        let counts = array![[10.0, 5.0], [3.0, 12.0]];
331        let (_fig, m) = mosaic(&counts);
332        assert_relative_eq!(m.row_widths.sum(), 1.0, max_relative = 1e-12);
333        // Row 0 share = 15/30.
334        assert_relative_eq!(m.row_widths[0], 0.5, max_relative = 1e-12);
335        for i in 0..2 {
336            let rsum: f64 = m.cell_heights.row(i).sum();
337            assert_relative_eq!(rsum, 1.0, max_relative = 1e-12);
338        }
339        assert_relative_eq!(m.cell_heights[[0, 0]], 10.0 / 15.0, max_relative = 1e-12);
340    }
341
342    #[test]
343    fn influence_plot_svg_structural() {
344        let x = array![[1.0, 0.1], [1.0, 0.9], [1.0, 2.1], [1.0, 3.2], [1.0, 3.9]];
345        let y = array![1.0, 2.1, 2.9, 4.2, 4.8];
346        let res = fit(&x, &y);
347        let (fig, _inf) = influence_plot(&res, &x);
348        let svg = fig.to_svg();
349        assert!(svg.starts_with("<svg"));
350        assert!(svg.contains("</svg>"));
351    }
352
353    #[test]
354    fn fit_and_regress_exog_svg_structural() {
355        let x = array![[1.0, 0.1], [1.0, 0.9], [1.0, 2.1], [1.0, 3.2], [1.0, 3.9]];
356        let y = array![1.0, 2.1, 2.9, 4.2, 4.8];
357        let res = fit(&x, &y);
358        for svg in [
359            plot_fit(&res, &x, 1).to_svg(),
360            plot_regress_exog(&res, &x, 1).to_svg(),
361        ] {
362            assert!(svg.starts_with("<svg"));
363            assert!(svg.contains("</svg>"));
364        }
365    }
366}