use ndarray::Array1;
use solow_distributions::norm_ppf;
use solow_viz::{Color, Figure};
mod influence;
pub use influence::{influence_plot, mosaic, plot_fit, plot_regress_exog, Influence, MosaicData};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct QqLine {
pub slope: f64,
pub intercept: f64,
}
#[derive(Clone, Debug)]
pub struct ProbPlot {
sorted: Vec<f64>,
a: f64,
}
impl ProbPlot {
pub fn new(data: &[f64]) -> Self {
Self::with_a(data, 0.0)
}
pub fn with_a(data: &[f64], a: f64) -> Self {
let mut sorted: Vec<f64> = data.to_vec();
sorted.sort_by(|x, y| x.partial_cmp(y).expect("data must not contain NaN"));
ProbPlot { sorted, a }
}
pub fn nobs(&self) -> usize {
self.sorted.len()
}
pub fn theoretical_percentiles(&self) -> Array1<f64> {
let n = self.sorted.len() as f64;
let denom = n + 1.0 - 2.0 * self.a;
Array1::from_iter((1..=self.sorted.len()).map(|i| (i as f64 - self.a) / denom))
}
pub fn theoretical_quantiles(&self) -> Array1<f64> {
self.theoretical_percentiles().mapv(norm_ppf)
}
pub fn sample_quantiles(&self) -> Array1<f64> {
Array1::from_vec(self.sorted.clone())
}
pub fn qqline_regression(&self) -> QqLine {
let x = self.theoretical_quantiles();
let y = self.sample_quantiles();
let (slope, intercept) = ols_line(x.as_slice().unwrap_or(&[]), y.as_slice().unwrap_or(&[]));
QqLine { slope, intercept }
}
pub fn qqline_standardized(&self) -> QqLine {
let y = &self.sorted;
let n = y.len() as f64;
let mean = y.iter().sum::<f64>() / n;
let var = y.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / n;
QqLine {
slope: var.sqrt(),
intercept: mean,
}
}
pub fn qqline_quartile(&self) -> QqLine {
let q25 = score_at_percentile(&self.sorted, 25.0);
let q75 = score_at_percentile(&self.sorted, 75.0);
let t25 = norm_ppf(0.25);
let t75 = norm_ppf(0.75);
let slope = (q75 - q25) / (t75 - t25);
let intercept = q25 - slope * t25;
QqLine { slope, intercept }
}
pub fn qqplot(&self) -> Figure {
let theo = self.theoretical_quantiles();
let samp = self.sample_quantiles();
let line = self.qqline_regression();
let mut fig = Figure::new(640, 480);
let ax = fig.axes();
ax.set_title("Q-Q plot")
.set_xlabel("Theoretical quantiles")
.set_ylabel("Sample quantiles")
.set_grid(true);
let theo_s = theo.as_slice().unwrap_or(&[]);
ax.scatter(theo_s, samp.as_slice().unwrap_or(&[]));
if let (Some(&lo), Some(&hi)) = (theo_s.first(), theo_s.last()) {
let xs = [lo, hi];
let ys = [
line.slope * lo + line.intercept,
line.slope * hi + line.intercept,
];
ax.plot_styled(&xs, &ys, Color::RED, 1.6);
}
fig
}
}
pub fn qqplot(data: &[f64]) -> (Figure, ProbPlot) {
let pp = ProbPlot::new(data);
let fig = pp.qqplot();
(fig, pp)
}
#[derive(Clone, Debug)]
pub struct AcfResult {
pub values: Array1<f64>,
pub conf_band: f64,
}
pub fn acf(x: &[f64], nlags: usize) -> Array1<f64> {
let n = x.len();
let mean = x.iter().sum::<f64>() / n as f64;
let xc: Vec<f64> = x.iter().map(|v| v - mean).collect();
let g0: f64 = xc.iter().map(|v| v * v).sum::<f64>() / n as f64;
let mut out = Vec::with_capacity(nlags + 1);
for k in 0..=nlags {
let mut s = 0.0;
for t in k..n {
s += xc[t] * xc[t - k];
}
out.push((s / n as f64) / g0);
}
Array1::from_vec(out)
}
pub fn pacf_yw(x: &[f64], nlags: usize) -> Array1<f64> {
let n = x.len();
let mean = x.iter().sum::<f64>() / n as f64;
let xc: Vec<f64> = x.iter().map(|v| v - mean).collect();
let mut acov = vec![0.0_f64; nlags + 1];
for (k, ac) in acov.iter_mut().enumerate() {
let mut s = 0.0;
for t in k..n {
s += xc[t] * xc[t - k];
}
*ac = s / (n - k) as f64;
}
let mut out = vec![0.0_f64; nlags + 1];
for (k, slot) in out.iter_mut().enumerate() {
*slot = if k == 0 {
1.0
} else {
yule_walker_last(&acov, k)
};
}
Array1::from_vec(out)
}
fn yule_walker_last(acov: &[f64], k: usize) -> f64 {
let mut r = vec![vec![0.0_f64; k]; k];
let mut rhs = vec![0.0_f64; k];
for i in 0..k {
for j in 0..k {
r[i][j] = acov[i.abs_diff(j)];
}
rhs[i] = acov[i + 1];
}
for col in 0..k {
let mut piv = col;
for row in (col + 1)..k {
if r[row][col].abs() > r[piv][col].abs() {
piv = row;
}
}
r.swap(col, piv);
rhs.swap(col, piv);
let pivot_row = r[col].clone();
let d = pivot_row[col];
let pivot_rhs = rhs[col];
for row in (col + 1)..k {
let f = r[row][col] / d;
for (rc, &pc) in r[row].iter_mut().zip(pivot_row.iter()).skip(col) {
*rc -= f * pc;
}
rhs[row] -= f * pivot_rhs;
}
}
let mut phi = vec![0.0_f64; k];
for row in (0..k).rev() {
let mut s = rhs[row];
for c in (row + 1)..k {
s -= r[row][c] * phi[c];
}
phi[row] = s / r[row][row];
}
phi[k - 1]
}
pub fn plot_acf(x: &[f64], nlags: usize, alpha: f64) -> (Figure, AcfResult) {
let values = acf(x, nlags);
let band = conf_band(x.len(), alpha);
let fig = render_corr(&values, band, "Autocorrelation");
(
fig,
AcfResult {
values,
conf_band: band,
},
)
}
pub fn plot_pacf(x: &[f64], nlags: usize, alpha: f64) -> (Figure, AcfResult) {
let values = pacf_yw(x, nlags);
let band = conf_band(x.len(), alpha);
let fig = render_corr(&values, band, "Partial Autocorrelation");
(
fig,
AcfResult {
values,
conf_band: band,
},
)
}
pub fn conf_band(n: usize, alpha: f64) -> f64 {
let z = norm_ppf(1.0 - alpha / 2.0);
z / (n as f64).sqrt()
}
pub fn plot_resid_fitted(fitted: &[f64], resid: &[f64]) -> Figure {
assert_eq!(
fitted.len(),
resid.len(),
"fitted and resid length mismatch"
);
let mut fig = Figure::new(640, 480);
let ax = fig.axes();
ax.set_title("Residuals vs Fitted")
.set_xlabel("Fitted values")
.set_ylabel("Residuals")
.set_grid(true);
ax.scatter(fitted, resid);
if let (Some(&lo), Some(&hi)) = (
fitted.iter().min_by(|a, b| a.total_cmp(b)),
fitted.iter().max_by(|a, b| a.total_cmp(b)),
) {
ax.plot_styled(&[lo, hi], &[0.0, 0.0], Color::GRAY, 1.0);
}
fig
}
fn render_corr(values: &Array1<f64>, band: f64, title: &str) -> Figure {
let lags: Vec<f64> = (0..values.len()).map(|i| i as f64).collect();
let mut fig = Figure::new(640, 480);
let ax = fig.axes();
ax.set_title(title).set_xlabel("Lag").set_grid(true);
for (i, &v) in values.iter().enumerate() {
ax.plot_styled(&[i as f64, i as f64], &[0.0, v], Color::BLUE, 1.2);
}
ax.scatter_styled(&lags, values.as_slice().unwrap_or(&[]), Color::BLUE, 3.0);
let xmax = (values.len() - 1) as f64;
ax.plot_styled(&[0.0, xmax], &[band, band], Color::GRAY, 1.0);
ax.plot_styled(&[0.0, xmax], &[-band, -band], Color::GRAY, 1.0);
fig
}
fn ols_line(x: &[f64], y: &[f64]) -> (f64, f64) {
let n = x.len() as f64;
let mx = x.iter().sum::<f64>() / n;
let my = y.iter().sum::<f64>() / n;
let mut sxx = 0.0;
let mut sxy = 0.0;
for (&xi, &yi) in x.iter().zip(y.iter()) {
sxx += (xi - mx) * (xi - mx);
sxy += (xi - mx) * (yi - my);
}
let slope = sxy / sxx;
let intercept = my - slope * mx;
(slope, intercept)
}
fn score_at_percentile(sorted: &[f64], percentile: f64) -> f64 {
let n = sorted.len();
if n == 0 {
return f64::NAN;
}
if n == 1 {
return sorted[0];
}
let idx = percentile / 100.0 * (n as f64 - 1.0);
let lo = idx.floor() as usize;
let frac = idx - lo as f64;
if lo + 1 >= n {
sorted[n - 1]
} else {
sorted[lo] + frac * (sorted[lo + 1] - sorted[lo])
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn acf_lag0_is_one() {
let x = [1.0, 2.0, 3.0, 2.0, 1.0, 0.0, 1.0, 2.0];
let a = acf(&x, 4);
assert_relative_eq!(a[0], 1.0, max_relative = 1e-15);
for &v in a.iter() {
assert!((-1.0..=1.0).contains(&v));
}
}
#[test]
fn acf_matches_manual() {
let x = [0.5, -1.0, 2.0, 0.3, -0.7, 1.1];
let n = x.len();
let mean = x.iter().sum::<f64>() / n as f64;
let xc: Vec<f64> = x.iter().map(|v| v - mean).collect();
let g0: f64 = xc.iter().map(|v| v * v).sum::<f64>() / n as f64;
let g1: f64 = (1..n).map(|t| xc[t] * xc[t - 1]).sum::<f64>() / n as f64;
let a = acf(&x, 1);
assert_relative_eq!(a[1], g1 / g0, max_relative = 1e-13);
}
#[test]
fn pacf_lag1_matches_adjusted_acov_ratio() {
let x = [0.5, -1.0, 2.0, 0.3, -0.7, 1.1, 0.9, -0.2];
let p = pacf_yw(&x, 3);
assert_relative_eq!(p[0], 1.0, max_relative = 1e-15);
let n = x.len();
let mean = x.iter().sum::<f64>() / n as f64;
let xc: Vec<f64> = x.iter().map(|v| v - mean).collect();
let a0: f64 = xc.iter().map(|v| v * v).sum::<f64>() / n as f64;
let a1: f64 = (1..n).map(|t| xc[t] * xc[t - 1]).sum::<f64>() / (n - 1) as f64;
assert_relative_eq!(p[1], a1 / a0, max_relative = 1e-12);
}
#[test]
fn probplot_sorts_and_sizes() {
let data = [3.0, 1.0, 2.0, -1.0];
let pp = ProbPlot::new(&data);
let s = pp.sample_quantiles();
assert_eq!(s.as_slice().unwrap(), &[-1.0, 1.0, 2.0, 3.0]);
assert_eq!(pp.theoretical_quantiles().len(), 4);
let t = pp.theoretical_quantiles();
assert_relative_eq!(t[0], -t[3], max_relative = 1e-12);
}
#[test]
fn conf_band_known_value() {
let b = conf_band(100, 0.05);
assert_relative_eq!(b, 1.959963984540054 / 10.0, max_relative = 1e-12);
}
#[test]
fn qqplot_svg_structural() {
let data = [-1.0, 0.0, 0.5, 1.5, -0.3, 0.8];
let (fig, _pp) = qqplot(&data);
let svg = fig.to_svg();
assert!(svg.starts_with("<svg"));
assert!(svg.contains("</svg>"));
assert!(svg.contains("circle") || svg.contains("<line"));
}
#[test]
fn ols_line_recovers_exact() {
let x = [0.0, 1.0, 2.0, 3.0, 4.0];
let y = [3.0, 5.0, 7.0, 9.0, 11.0];
let (m, b) = ols_line(&x, &y);
assert_relative_eq!(m, 2.0, max_relative = 1e-12);
assert_relative_eq!(b, 3.0, max_relative = 1e-12);
}
#[test]
fn resid_fitted_svg_structural() {
let fitted = [1.0, 2.0, 3.0, 4.0];
let resid = [0.1, -0.2, 0.05, -0.1];
let svg = plot_resid_fitted(&fitted, &resid).to_svg();
assert!(svg.starts_with("<svg"));
assert!(svg.contains("</svg>"));
}
}