Skip to main content

solow_graphics/
lib.rs

1//! # solow-graphics
2//!
3//! Statistical-graphics helpers that compute the data behind a plot and render
4//! it through the [`solow_viz`] SVG backend. Every routine returns *both* the
5//! rendered [`Figure`] and the computed arrays, so the numerics can be tested
6//! independently of the (intentionally un-pixel-exact) SVG output.
7//!
8//! Provided:
9//!
10//! * [`ProbPlot`] / [`qqplot`] — theoretical vs. sample quantiles of a
11//!   probability plot, plus the fitted reference line ([`QqLine`]).
12//! * [`plot_acf`] / [`plot_pacf`] — the (biased) autocorrelation and the
13//!   Yule-Walker partial autocorrelation, with a white-noise confidence band.
14//! * [`plot_resid_fitted`] — a residuals-vs-fitted diagnostic scatter.
15//!
16//! ```
17//! use solow_graphics::ProbPlot;
18//! let data = [-1.2, 0.3, 0.1, 1.4, -0.7, 2.1, -0.2, 0.9];
19//! let pp = ProbPlot::new(&data);
20//! assert_eq!(pp.sample_quantiles().len(), data.len());
21//! let line = pp.qqline_regression();
22//! let svg = pp.qqplot().to_svg();
23//! assert!(svg.starts_with("<svg"));
24//! let _ = line.slope;
25//! ```
26
27use 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/// The fitted reference line of a probability plot, `y = slope * x + intercept`,
35/// where `x` are the theoretical quantiles and `y` the sample quantiles.
36#[derive(Clone, Copy, Debug, PartialEq)]
37pub struct QqLine {
38    /// Slope of the reference line.
39    pub slope: f64,
40    /// Intercept of the reference line.
41    pub intercept: f64,
42}
43
44/// A normal probability plot (Q-Q plot against the standard normal).
45///
46/// Mirrors the reference `ProbPlot` for the default standard-normal
47/// distribution: the theoretical percentiles are the plotting positions
48/// `(i - a) / (n + 1 - 2a)` for `i = 1..=n`, the theoretical quantiles are the
49/// normal inverse-CDF of those percentiles, and the sample quantiles are simply
50/// the sorted data.
51#[derive(Clone, Debug)]
52pub struct ProbPlot {
53    sorted: Vec<f64>,
54    a: f64,
55}
56
57impl ProbPlot {
58    /// Build a probability plot from `data`, using plotting-position parameter
59    /// `a = 0` (the reference default).
60    pub fn new(data: &[f64]) -> Self {
61        Self::with_a(data, 0.0)
62    }
63
64    /// Build a probability plot with an explicit plotting-position parameter `a`.
65    ///
66    /// Common choices: `0.0` (Weibull, the default), `0.375` (Blom),
67    /// `0.5` (Hazen).
68    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    /// Number of observations.
75    pub fn nobs(&self) -> usize {
76        self.sorted.len()
77    }
78
79    /// The theoretical plotting positions (percentiles in `(0, 1)`).
80    ///
81    /// `p_i = (i - a) / (n + 1 - 2a)` for `i = 1..=n`.
82    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    /// The theoretical quantiles: the standard-normal inverse CDF of the
89    /// plotting positions.
90    pub fn theoretical_quantiles(&self) -> Array1<f64> {
91        self.theoretical_percentiles().mapv(norm_ppf)
92    }
93
94    /// The sample quantiles: the sorted data.
95    pub fn sample_quantiles(&self) -> Array1<f64> {
96        Array1::from_vec(self.sorted.clone())
97    }
98
99    /// The regression reference line ("r"): an ordinary least-squares fit of
100    /// the sample quantiles on the theoretical quantiles (with intercept).
101    pub fn qqline_regression(&self) -> QqLine {
102        let x = self.theoretical_quantiles();
103        let y = self.sample_quantiles();
104        // SAFETY: owned contiguous arrays from the quantile helpers.
105        let (slope, intercept) = ols_line(x.as_slice().unwrap_or(&[]), y.as_slice().unwrap_or(&[]));
106        QqLine { slope, intercept }
107    }
108
109    /// The standardized reference line ("s"): slope = sample standard deviation
110    /// (population, `1/n`), intercept = sample mean.
111    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    /// The quartile reference line ("q"): the line through the first and third
123    /// quartiles of the sample versus the theoretical normal quartiles.
124    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    /// Render the Q-Q plot to a [`Figure`]: a scatter of (theoretical, sample)
135    /// quantiles overlaid with the regression reference line.
136    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        // SAFETY: owned contiguous arrays from the quantile helpers.
148        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
162/// Convenience wrapper: build a [`ProbPlot`] and render its Q-Q plot.
163pub fn qqplot(data: &[f64]) -> (Figure, ProbPlot) {
164    let pp = ProbPlot::new(data);
165    let fig = pp.qqplot();
166    (fig, pp)
167}
168
169/// Result of an autocorrelation/partial-autocorrelation computation.
170#[derive(Clone, Debug)]
171pub struct AcfResult {
172    /// The correlation values, lag `0..=nlags` (index 0 is always `1.0`).
173    pub values: Array1<f64>,
174    /// The (symmetric) confidence-band half-width `z_{alpha/2} / sqrt(n)`.
175    pub conf_band: f64,
176}
177
178/// The biased autocorrelation function for lags `0..=nlags`.
179///
180/// Uses the biased (divide-by-`n`) autocovariance estimator
181/// `gamma_k = (1/n) sum_{t=k}^{n-1} (x_t - xbar)(x_{t-k} - xbar)`, then
182/// `acf_k = gamma_k / gamma_0`.
183pub 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
199/// The Yule-Walker partial autocorrelation function (adjusted / unbiased
200/// autocovariances), lags `0..=nlags`.
201///
202/// For each order `k`, solves the Yule-Walker equations using autocovariances
203/// estimated with divisor `n - lag` (the "adjusted" estimator), and takes the
204/// last coefficient as the partial autocorrelation at lag `k`. Index 0 is
205/// `1.0` by convention.
206pub 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    // Adjusted autocovariances: divisor (n - k).
211    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
230/// Solve the order-`k` Yule-Walker system `R phi = r` for autocovariances
231/// `acov[0..=k]` and return the last coefficient `phi_k` (the PACF at lag `k`).
232fn yule_walker_last(acov: &[f64], k: usize) -> f64 {
233    // Toeplitz system: R[i][j] = acov[|i-j|], rhs r[i] = acov[i+1].
234    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    // Gaussian elimination with partial pivoting.
243    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    // Back-substitution; we only need phi[k-1].
264    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
275/// Compute the ACF and a `(1 - alpha)` white-noise confidence band, and render
276/// a stem-style plot. Returns the [`Figure`] and the [`AcfResult`].
277pub 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
290/// Compute the (Yule-Walker) PACF and a `(1 - alpha)` white-noise confidence
291/// band, and render a stem-style plot. Returns the [`Figure`] and the
292/// [`AcfResult`].
293pub 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
306/// The symmetric white-noise confidence half-width `z_{alpha/2} / sqrt(n)`.
307pub 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
312/// A residuals-vs-fitted diagnostic. Takes the model's fitted values and
313/// residuals, returns the rendered [`Figure`] (a zero-reference line is drawn
314/// at `resid = 0`).
315pub 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
337// --- internal helpers ------------------------------------------------------
338
339/// Render a stem-style correlation plot (markers at each lag plus a horizontal
340/// confidence band).
341fn 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    // Stems.
347    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    // SAFETY: owned contiguous correlation array.
351    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
358/// Ordinary least squares of `y` on `x` with an intercept, returning
359/// `(slope, intercept)`.
360fn 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
375/// The score at the given `percentile` of `sorted` data, using linear
376/// interpolation between order statistics (matching the reference
377/// `scoreatpercentile` default, `interpolation_method="fraction"`).
378fn 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}