#![allow(clippy::cast_precision_loss, clippy::many_single_char_names)]
use crate::StatsError;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LocalPolynomialPoint {
pub value: f64,
pub first_derivative: f64,
pub second_derivative: f64,
pub standard_error: f64,
pub first_derivative_standard_error: f64,
pub second_derivative_standard_error: f64,
pub local_ess: f64,
pub weight_sum: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct LocalPolynomialInfluence {
pub point: LocalPolynomialPoint,
pub influences: Vec<f64>,
pub robust_standard_error: f64,
}
pub fn silverman_bandwidth(x: &[f64]) -> Result<f64, StatsError> {
if x.len() < 2 || x.iter().any(|v| !v.is_finite()) {
return Err(StatsError::Shape { message: "bandwidth requires finite x with n >= 2" });
}
let n = x.len() as f64;
let mean = x.iter().sum::<f64>() / n;
let variance = x.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
let (minimum, maximum) =
x.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| (lo.min(v), hi.max(v)));
let range = maximum - minimum;
if variance <= 0.0 || range <= 0.0 {
return Err(StatsError::Shape { message: "bandwidth requires non-degenerate x" });
}
Ok((1.06 * variance.sqrt() * n.powf(-0.2)).max(range * 1e-6))
}
#[must_use]
pub fn gaussian_density(value: f64, mean: f64, standard_deviation: f64) -> f64 {
if !value.is_finite()
|| !mean.is_finite()
|| !standard_deviation.is_finite()
|| standard_deviation <= 0.0
{
return f64::NAN;
}
let z = (value - mean) / standard_deviation;
(-0.5 * z * z).exp() / (standard_deviation * (2.0 * std::f64::consts::PI).sqrt())
}
pub fn gaussian_local_quadratic(
x: &[f64],
y: &[f64],
at: f64,
bandwidth: f64,
) -> Result<LocalPolynomialPoint, StatsError> {
Ok(gaussian_local_quadratic_influence(x, y, at, bandwidth)?.point)
}
pub fn gaussian_local_quadratic_influence(
x: &[f64],
y: &[f64],
at: f64,
bandwidth: f64,
) -> Result<LocalPolynomialInfluence, StatsError> {
if x.len() != y.len() || x.len() < 3 {
return Err(StatsError::Shape {
message: "local quadratic requires aligned x/y with n >= 3",
});
}
if !at.is_finite()
|| !bandwidth.is_finite()
|| bandwidth <= 0.0
|| x.iter().chain(y).any(|v| !v.is_finite())
{
return Err(StatsError::Shape { message: "local quadratic inputs must be finite" });
}
let mut gram = [[0.0; 3]; 3];
let mut squared_gram = [[0.0; 3]; 3];
let mut rhs = [0.0; 3];
let mut weights = Vec::with_capacity(x.len());
let mut weight_sum = 0.0;
let mut weight_sq_sum = 0.0;
for (&xi, &yi) in x.iter().zip(y) {
let dx = xi - at;
let z = dx / bandwidth;
let w = (-0.5 * z * z).exp();
let row = [1.0, dx, dx * dx];
for j in 0..3 {
rhs[j] += w * row[j] * yi;
for k in 0..3 {
gram[j][k] += w * row[j] * row[k];
squared_gram[j][k] += w * w * row[j] * row[k];
}
}
weights.push((w, row, yi));
weight_sum += w;
weight_sq_sum += w * w;
}
if weight_sum <= 3.0 || weight_sq_sum <= f64::EPSILON {
return Err(StatsError::Backend(
"local response window has too little effective weight for a local quadratic".into(),
));
}
let inverse = inverse_3x3(gram)
.ok_or_else(|| StatsError::Backend("singular local response design".into()))?;
let beta = matvec(inverse, rhs);
let mut weighted_rss = 0.0;
for (w, row, yi) in weights {
let residual = yi - row.iter().zip(beta).map(|(a, b)| a * b).sum::<f64>();
weighted_rss += w * residual * residual;
}
let local_ess = weight_sum * weight_sum / weight_sq_sum;
let sigma2 = weighted_rss / (weight_sum - 3.0);
let sandwich = matmul3(matmul3(inverse, squared_gram), inverse);
let coefficient_se = |index: usize| (sigma2 * sandwich[index][index]).max(0.0).sqrt();
let point = LocalPolynomialPoint {
value: beta[0],
first_derivative: beta[1],
second_derivative: 2.0 * beta[2],
standard_error: coefficient_se(0),
first_derivative_standard_error: coefficient_se(1),
second_derivative_standard_error: 2.0 * coefficient_se(2),
local_ess,
weight_sum,
};
let influences: Vec<f64> = weights_for_influence(x, y, at, bandwidth, beta, inverse);
let robust_standard_error = influences.iter().map(|value| value * value).sum::<f64>().sqrt();
Ok(LocalPolynomialInfluence { point, influences, robust_standard_error })
}
fn weights_for_influence(
x: &[f64],
y: &[f64],
at: f64,
bandwidth: f64,
beta: [f64; 3],
inverse: [[f64; 3]; 3],
) -> Vec<f64> {
x.iter()
.zip(y)
.map(|(&xi, &yi)| {
let dx = xi - at;
let row = [1.0, dx, dx * dx];
let weight = (-0.5 * (dx / bandwidth).powi(2)).exp();
let residual = yi - row.iter().zip(beta).map(|(a, b)| a * b).sum::<f64>();
weight
* inverse[0]
.iter()
.zip(row)
.map(|(coefficient, value)| coefficient * value)
.sum::<f64>()
* residual
})
.collect()
}
fn matvec(a: [[f64; 3]; 3], b: [f64; 3]) -> [f64; 3] {
std::array::from_fn(|i| (0..3).map(|j| a[i][j] * b[j]).sum())
}
fn matmul3(a: [[f64; 3]; 3], b: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| a[i][k] * b[k][j]).sum()))
}
fn inverse_3x3(a: [[f64; 3]; 3]) -> Option<[[f64; 3]; 3]> {
let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
- a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
+ a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
let scale = a.iter().flatten().fold(0.0_f64, |m, v| m.max(v.abs())).max(1.0);
if !det.is_finite() || det.abs() <= f64::EPSILON * scale.powi(3) * 64.0 {
return None;
}
let mut out = [[0.0; 3]; 3];
out[0][0] = a[1][1] * a[2][2] - a[1][2] * a[2][1];
out[0][1] = a[0][2] * a[2][1] - a[0][1] * a[2][2];
out[0][2] = a[0][1] * a[1][2] - a[0][2] * a[1][1];
out[1][0] = a[1][2] * a[2][0] - a[1][0] * a[2][2];
out[1][1] = a[0][0] * a[2][2] - a[0][2] * a[2][0];
out[1][2] = a[0][2] * a[1][0] - a[0][0] * a[1][2];
out[2][0] = a[1][0] * a[2][1] - a[1][1] * a[2][0];
out[2][1] = a[0][1] * a[2][0] - a[0][0] * a[2][1];
out[2][2] = a[0][0] * a[1][1] - a[0][1] * a[1][0];
for row in &mut out {
for value in row {
*value /= det;
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_quadratic_recovers_level_and_derivatives() {
let x: Vec<f64> = (0..101).map(|i| -2.0 + 4.0 * f64::from(i) / 100.0).collect();
let y: Vec<f64> = x.iter().map(|v| 1.0 + 2.0 * v + 3.0 * v * v).collect();
let fit = gaussian_local_quadratic(&x, &y, 0.4, 0.5).unwrap();
assert!((fit.value - 2.28).abs() < 1e-10);
assert!((fit.first_derivative - 4.4).abs() < 1e-10);
assert!((fit.second_derivative - 6.0).abs() < 1e-10);
assert!(fit.local_ess > 20.0);
}
#[test]
fn density_is_normalized_at_mean() {
let got = gaussian_density(2.0, 2.0, 0.5);
let expected = 1.0 / (0.5 * (2.0 * std::f64::consts::PI).sqrt());
assert!((got - expected).abs() < 1e-14);
}
#[test]
fn plugin_level_standard_error_matches_the_sandwich_not_the_bread() {
let x: Vec<f64> = (0..500).map(|i| -2.0 + 4.0 * f64::from(i) / 499.0).collect();
let y: Vec<f64> =
x.iter().enumerate().map(|(i, v)| 1.0 + 2.0 * v + 0.2 * (i as f64).sin()).collect();
let fit = gaussian_local_quadratic_influence(&x, &y, 0.0, 0.5).unwrap();
let plugin = fit.point.standard_error;
let robust = fit.robust_standard_error;
assert!(plugin > 0.0 && robust > 0.0);
assert!(
(plugin - robust).abs() / robust < 0.05,
"plugin={plugin} robust={robust} — sandwich and robust SEs should agree"
);
}
#[test]
fn local_quadratic_refuses_a_window_without_enough_effective_weight() {
let x = vec![-10.0, 0.0, 10.0];
let y = vec![1.0, 2.0, 3.0];
assert!(gaussian_local_quadratic_influence(&x, &y, 0.0, 0.05).is_err());
}
#[test]
fn local_quadratic_influences_are_centered_and_finite() {
let x: Vec<f64> = (0..101).map(|i| -2.0 + 4.0 * f64::from(i) / 100.0).collect();
let y: Vec<f64> =
x.iter().enumerate().map(|(i, value)| 1.0 + value + 0.1 * (i as f64).sin()).collect();
let fit = gaussian_local_quadratic_influence(&x, &y, 0.0, 0.5).unwrap();
assert_eq!(fit.influences.len(), x.len());
assert!(fit.robust_standard_error.is_finite() && fit.robust_standard_error > 0.0);
assert!(fit.influences.iter().sum::<f64>().abs() < 1e-10);
}
}