1use ndarray::Array1;
28use solow_distributions::norm_ppf;
29use solow_viz::{Color, Figure};
30
31mod influence;
32pub use influence::{influence_plot, mosaic, plot_fit, plot_regress_exog, Influence, MosaicData};
33
34#[derive(Clone, Copy, Debug, PartialEq)]
37pub struct QqLine {
38 pub slope: f64,
40 pub intercept: f64,
42}
43
44#[derive(Clone, Debug)]
52pub struct ProbPlot {
53 sorted: Vec<f64>,
54 a: f64,
55}
56
57impl ProbPlot {
58 pub fn new(data: &[f64]) -> Self {
61 Self::with_a(data, 0.0)
62 }
63
64 pub fn with_a(data: &[f64], a: f64) -> Self {
69 let mut sorted: Vec<f64> = data.to_vec();
70 sorted.sort_by(|x, y| x.partial_cmp(y).expect("data must not contain NaN"));
71 ProbPlot { sorted, a }
72 }
73
74 pub fn nobs(&self) -> usize {
76 self.sorted.len()
77 }
78
79 pub fn theoretical_percentiles(&self) -> Array1<f64> {
83 let n = self.sorted.len() as f64;
84 let denom = n + 1.0 - 2.0 * self.a;
85 Array1::from_iter((1..=self.sorted.len()).map(|i| (i as f64 - self.a) / denom))
86 }
87
88 pub fn theoretical_quantiles(&self) -> Array1<f64> {
91 self.theoretical_percentiles().mapv(norm_ppf)
92 }
93
94 pub fn sample_quantiles(&self) -> Array1<f64> {
96 Array1::from_vec(self.sorted.clone())
97 }
98
99 pub fn qqline_regression(&self) -> QqLine {
102 let x = self.theoretical_quantiles();
103 let y = self.sample_quantiles();
104 let (slope, intercept) = ols_line(x.as_slice().unwrap_or(&[]), y.as_slice().unwrap_or(&[]));
106 QqLine { slope, intercept }
107 }
108
109 pub fn qqline_standardized(&self) -> QqLine {
112 let y = &self.sorted;
113 let n = y.len() as f64;
114 let mean = y.iter().sum::<f64>() / n;
115 let var = y.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / n;
116 QqLine {
117 slope: var.sqrt(),
118 intercept: mean,
119 }
120 }
121
122 pub fn qqline_quartile(&self) -> QqLine {
125 let q25 = score_at_percentile(&self.sorted, 25.0);
126 let q75 = score_at_percentile(&self.sorted, 75.0);
127 let t25 = norm_ppf(0.25);
128 let t75 = norm_ppf(0.75);
129 let slope = (q75 - q25) / (t75 - t25);
130 let intercept = q25 - slope * t25;
131 QqLine { slope, intercept }
132 }
133
134 pub fn qqplot(&self) -> Figure {
137 let theo = self.theoretical_quantiles();
138 let samp = self.sample_quantiles();
139 let line = self.qqline_regression();
140
141 let mut fig = Figure::new(640, 480);
142 let ax = fig.axes();
143 ax.set_title("Q-Q plot")
144 .set_xlabel("Theoretical quantiles")
145 .set_ylabel("Sample quantiles")
146 .set_grid(true);
147 let theo_s = theo.as_slice().unwrap_or(&[]);
149 ax.scatter(theo_s, samp.as_slice().unwrap_or(&[]));
150 if let (Some(&lo), Some(&hi)) = (theo_s.first(), theo_s.last()) {
151 let xs = [lo, hi];
152 let ys = [
153 line.slope * lo + line.intercept,
154 line.slope * hi + line.intercept,
155 ];
156 ax.plot_styled(&xs, &ys, Color::RED, 1.6);
157 }
158 fig
159 }
160}
161
162pub fn qqplot(data: &[f64]) -> (Figure, ProbPlot) {
164 let pp = ProbPlot::new(data);
165 let fig = pp.qqplot();
166 (fig, pp)
167}
168
169#[derive(Clone, Debug)]
171pub struct AcfResult {
172 pub values: Array1<f64>,
174 pub conf_band: f64,
176}
177
178pub fn acf(x: &[f64], nlags: usize) -> Array1<f64> {
184 let n = x.len();
185 let mean = x.iter().sum::<f64>() / n as f64;
186 let xc: Vec<f64> = x.iter().map(|v| v - mean).collect();
187 let g0: f64 = xc.iter().map(|v| v * v).sum::<f64>() / n as f64;
188 let mut out = Vec::with_capacity(nlags + 1);
189 for k in 0..=nlags {
190 let mut s = 0.0;
191 for t in k..n {
192 s += xc[t] * xc[t - k];
193 }
194 out.push((s / n as f64) / g0);
195 }
196 Array1::from_vec(out)
197}
198
199pub fn pacf_yw(x: &[f64], nlags: usize) -> Array1<f64> {
207 let n = x.len();
208 let mean = x.iter().sum::<f64>() / n as f64;
209 let xc: Vec<f64> = x.iter().map(|v| v - mean).collect();
210 let mut acov = vec![0.0_f64; nlags + 1];
212 for (k, ac) in acov.iter_mut().enumerate() {
213 let mut s = 0.0;
214 for t in k..n {
215 s += xc[t] * xc[t - k];
216 }
217 *ac = s / (n - k) as f64;
218 }
219 let mut out = vec![0.0_f64; nlags + 1];
220 for (k, slot) in out.iter_mut().enumerate() {
221 *slot = if k == 0 {
222 1.0
223 } else {
224 yule_walker_last(&acov, k)
225 };
226 }
227 Array1::from_vec(out)
228}
229
230fn yule_walker_last(acov: &[f64], k: usize) -> f64 {
233 let mut r = vec![vec![0.0_f64; k]; k];
235 let mut rhs = vec![0.0_f64; k];
236 for i in 0..k {
237 for j in 0..k {
238 r[i][j] = acov[i.abs_diff(j)];
239 }
240 rhs[i] = acov[i + 1];
241 }
242 for col in 0..k {
244 let mut piv = col;
245 for row in (col + 1)..k {
246 if r[row][col].abs() > r[piv][col].abs() {
247 piv = row;
248 }
249 }
250 r.swap(col, piv);
251 rhs.swap(col, piv);
252 let pivot_row = r[col].clone();
253 let d = pivot_row[col];
254 let pivot_rhs = rhs[col];
255 for row in (col + 1)..k {
256 let f = r[row][col] / d;
257 for (rc, &pc) in r[row].iter_mut().zip(pivot_row.iter()).skip(col) {
258 *rc -= f * pc;
259 }
260 rhs[row] -= f * pivot_rhs;
261 }
262 }
263 let mut phi = vec![0.0_f64; k];
265 for row in (0..k).rev() {
266 let mut s = rhs[row];
267 for c in (row + 1)..k {
268 s -= r[row][c] * phi[c];
269 }
270 phi[row] = s / r[row][row];
271 }
272 phi[k - 1]
273}
274
275pub fn plot_acf(x: &[f64], nlags: usize, alpha: f64) -> (Figure, AcfResult) {
278 let values = acf(x, nlags);
279 let band = conf_band(x.len(), alpha);
280 let fig = render_corr(&values, band, "Autocorrelation");
281 (
282 fig,
283 AcfResult {
284 values,
285 conf_band: band,
286 },
287 )
288}
289
290pub fn plot_pacf(x: &[f64], nlags: usize, alpha: f64) -> (Figure, AcfResult) {
294 let values = pacf_yw(x, nlags);
295 let band = conf_band(x.len(), alpha);
296 let fig = render_corr(&values, band, "Partial Autocorrelation");
297 (
298 fig,
299 AcfResult {
300 values,
301 conf_band: band,
302 },
303 )
304}
305
306pub fn conf_band(n: usize, alpha: f64) -> f64 {
308 let z = norm_ppf(1.0 - alpha / 2.0);
309 z / (n as f64).sqrt()
310}
311
312pub fn plot_resid_fitted(fitted: &[f64], resid: &[f64]) -> Figure {
316 assert_eq!(
317 fitted.len(),
318 resid.len(),
319 "fitted and resid length mismatch"
320 );
321 let mut fig = Figure::new(640, 480);
322 let ax = fig.axes();
323 ax.set_title("Residuals vs Fitted")
324 .set_xlabel("Fitted values")
325 .set_ylabel("Residuals")
326 .set_grid(true);
327 ax.scatter(fitted, resid);
328 if let (Some(&lo), Some(&hi)) = (
329 fitted.iter().min_by(|a, b| a.total_cmp(b)),
330 fitted.iter().max_by(|a, b| a.total_cmp(b)),
331 ) {
332 ax.plot_styled(&[lo, hi], &[0.0, 0.0], Color::GRAY, 1.0);
333 }
334 fig
335}
336
337fn render_corr(values: &Array1<f64>, band: f64, title: &str) -> Figure {
342 let lags: Vec<f64> = (0..values.len()).map(|i| i as f64).collect();
343 let mut fig = Figure::new(640, 480);
344 let ax = fig.axes();
345 ax.set_title(title).set_xlabel("Lag").set_grid(true);
346 for (i, &v) in values.iter().enumerate() {
348 ax.plot_styled(&[i as f64, i as f64], &[0.0, v], Color::BLUE, 1.2);
349 }
350 ax.scatter_styled(&lags, values.as_slice().unwrap_or(&[]), Color::BLUE, 3.0);
352 let xmax = (values.len() - 1) as f64;
353 ax.plot_styled(&[0.0, xmax], &[band, band], Color::GRAY, 1.0);
354 ax.plot_styled(&[0.0, xmax], &[-band, -band], Color::GRAY, 1.0);
355 fig
356}
357
358fn ols_line(x: &[f64], y: &[f64]) -> (f64, f64) {
361 let n = x.len() as f64;
362 let mx = x.iter().sum::<f64>() / n;
363 let my = y.iter().sum::<f64>() / n;
364 let mut sxx = 0.0;
365 let mut sxy = 0.0;
366 for (&xi, &yi) in x.iter().zip(y.iter()) {
367 sxx += (xi - mx) * (xi - mx);
368 sxy += (xi - mx) * (yi - my);
369 }
370 let slope = sxy / sxx;
371 let intercept = my - slope * mx;
372 (slope, intercept)
373}
374
375fn score_at_percentile(sorted: &[f64], percentile: f64) -> f64 {
379 let n = sorted.len();
380 if n == 0 {
381 return f64::NAN;
382 }
383 if n == 1 {
384 return sorted[0];
385 }
386 let idx = percentile / 100.0 * (n as f64 - 1.0);
387 let lo = idx.floor() as usize;
388 let frac = idx - lo as f64;
389 if lo + 1 >= n {
390 sorted[n - 1]
391 } else {
392 sorted[lo] + frac * (sorted[lo + 1] - sorted[lo])
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use approx::assert_relative_eq;
400
401 #[test]
402 fn acf_lag0_is_one() {
403 let x = [1.0, 2.0, 3.0, 2.0, 1.0, 0.0, 1.0, 2.0];
404 let a = acf(&x, 4);
405 assert_relative_eq!(a[0], 1.0, max_relative = 1e-15);
406 for &v in a.iter() {
407 assert!((-1.0..=1.0).contains(&v));
408 }
409 }
410
411 #[test]
412 fn acf_matches_manual() {
413 let x = [0.5, -1.0, 2.0, 0.3, -0.7, 1.1];
414 let n = x.len();
415 let mean = x.iter().sum::<f64>() / n as f64;
416 let xc: Vec<f64> = x.iter().map(|v| v - mean).collect();
417 let g0: f64 = xc.iter().map(|v| v * v).sum::<f64>() / n as f64;
418 let g1: f64 = (1..n).map(|t| xc[t] * xc[t - 1]).sum::<f64>() / n as f64;
419 let a = acf(&x, 1);
420 assert_relative_eq!(a[1], g1 / g0, max_relative = 1e-13);
421 }
422
423 #[test]
424 fn pacf_lag1_matches_adjusted_acov_ratio() {
425 let x = [0.5, -1.0, 2.0, 0.3, -0.7, 1.1, 0.9, -0.2];
426 let p = pacf_yw(&x, 3);
427 assert_relative_eq!(p[0], 1.0, max_relative = 1e-15);
428 let n = x.len();
429 let mean = x.iter().sum::<f64>() / n as f64;
430 let xc: Vec<f64> = x.iter().map(|v| v - mean).collect();
431 let a0: f64 = xc.iter().map(|v| v * v).sum::<f64>() / n as f64;
432 let a1: f64 = (1..n).map(|t| xc[t] * xc[t - 1]).sum::<f64>() / (n - 1) as f64;
433 assert_relative_eq!(p[1], a1 / a0, max_relative = 1e-12);
434 }
435
436 #[test]
437 fn probplot_sorts_and_sizes() {
438 let data = [3.0, 1.0, 2.0, -1.0];
439 let pp = ProbPlot::new(&data);
440 let s = pp.sample_quantiles();
441 assert_eq!(s.as_slice().unwrap(), &[-1.0, 1.0, 2.0, 3.0]);
442 assert_eq!(pp.theoretical_quantiles().len(), 4);
443 let t = pp.theoretical_quantiles();
444 assert_relative_eq!(t[0], -t[3], max_relative = 1e-12);
445 }
446
447 #[test]
448 fn conf_band_known_value() {
449 let b = conf_band(100, 0.05);
450 assert_relative_eq!(b, 1.959963984540054 / 10.0, max_relative = 1e-12);
451 }
452
453 #[test]
454 fn qqplot_svg_structural() {
455 let data = [-1.0, 0.0, 0.5, 1.5, -0.3, 0.8];
456 let (fig, _pp) = qqplot(&data);
457 let svg = fig.to_svg();
458 assert!(svg.starts_with("<svg"));
459 assert!(svg.contains("</svg>"));
460 assert!(svg.contains("circle") || svg.contains("<line"));
461 }
462
463 #[test]
464 fn ols_line_recovers_exact() {
465 let x = [0.0, 1.0, 2.0, 3.0, 4.0];
466 let y = [3.0, 5.0, 7.0, 9.0, 11.0];
467 let (m, b) = ols_line(&x, &y);
468 assert_relative_eq!(m, 2.0, max_relative = 1e-12);
469 assert_relative_eq!(b, 3.0, max_relative = 1e-12);
470 }
471
472 #[test]
473 fn resid_fitted_svg_structural() {
474 let fitted = [1.0, 2.0, 3.0, 4.0];
475 let resid = [0.1, -0.2, 0.05, -0.1];
476 let svg = plot_resid_fitted(&fitted, &resid).to_svg();
477 assert!(svg.starts_with("<svg"));
478 assert!(svg.contains("</svg>"));
479 }
480}