1use ndarray::{Array1, Array2};
24use solow_regression::LinearResults;
25use solow_viz::{Color, Figure};
26
27#[derive(Clone, Debug)]
31pub struct Influence {
32 pub hat_diag: Array1<f64>,
34 pub resid_studentized_internal: Array1<f64>,
36 pub resid_studentized_external: Array1<f64>,
38 pub cooks_distance: Array1<f64>,
40 pub dffits: Array1<f64>,
42}
43
44impl Influence {
45 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 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 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 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
111pub fn influence_plot(res: &LinearResults, exog: &Array2<f64>) -> (Figure, Influence) {
116 let inf = Influence::new(res, exog);
117 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 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 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
149pub 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) .collect::<Vec<f64>>();
162 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
176pub 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 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
202pub fn mosaic(counts: &Array2<f64>) -> (Figure, MosaicData) {
209 let (nr, nc) = counts.dim();
210 let total: f64 = counts.sum();
211 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 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 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#[derive(Clone, Debug)]
258pub struct MosaicData {
259 pub row_widths: Array1<f64>,
261 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 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 let cook = ri * ri / k * (h / (1.0 - h));
320 assert_relative_eq!(cook, inf.cooks_distance[i], max_relative = 1e-12);
321 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 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}