use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub enum ResidualType {
Raw,
Standardized,
Pearson,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PointProcessResiduals {
pub locations: Vec<(f64, f64)>,
pub observed: Vec<f64>,
pub predicted: Vec<f64>,
pub residuals: Vec<f64>,
pub residual_type: ResidualType,
pub deviance: Vec<f64>,
pub total_deviance: f64,
pub aic: f64,
}
impl PointProcessResiduals {
pub fn compute(
locations: Vec<(f64, f64)>,
observed: Vec<f64>,
predicted: Vec<f64>,
residual_type: ResidualType,
) -> Result<Self, String> {
if locations.len() != observed.len() || observed.len() != predicted.len() {
return Err("locations, observed, and predicted must have same length".to_string());
}
let n = observed.len();
let mut residuals = Vec::with_capacity(n);
let mut deviance = Vec::with_capacity(n);
match residual_type {
ResidualType::Raw => {
for i in 0..n {
residuals.push(observed[i] - predicted[i]);
deviance.push((observed[i] - predicted[i]).powi(2));
}
}
ResidualType::Standardized => {
for i in 0..n {
let denom = predicted[i].sqrt().max(1e-10);
residuals.push((observed[i] - predicted[i]) / denom);
deviance.push(((observed[i] - predicted[i]) / denom).powi(2));
}
}
ResidualType::Pearson => {
for i in 0..n {
let denom = (predicted[i] + 0.25).sqrt();
residuals.push((observed[i] - predicted[i]) / denom);
let o = observed[i].max(1e-10);
let e = predicted[i].max(1e-10);
let dev = 2.0 * (o * (o / e).ln() - (o - e));
deviance.push(dev);
}
}
}
let total_deviance: f64 = deviance.iter().sum();
let df = (n - 1).max(1) as f64;
let aic = total_deviance + 2.0 * df;
Ok(PointProcessResiduals {
locations,
observed,
predicted,
residuals,
residual_type,
deviance,
total_deviance,
aic,
})
}
pub fn residual_clustering_score(&self) -> (f64, f64) {
let mean_abs_residual = self
.residuals
.iter()
.map(|r| r.abs())
.sum::<f64>() / self.residuals.len() as f64;
let n = self.locations.len();
let mut spatial_var = 0.0;
for i in 0..n {
for j in i + 1..n {
let dx = self.locations[i].0 - self.locations[j].0;
let dy = self.locations[i].1 - self.locations[j].1;
let dist = (dx * dx + dy * dy).sqrt();
if dist > 0.0 && dist < 0.5 {
spatial_var += self.residuals[i] * self.residuals[j] / (n as f64 * n as f64);
}
}
}
(mean_abs_residual, spatial_var)
}
pub fn adequacy_check(&self) -> (bool, String) {
let (mar, spatial_cov) = self.residual_clustering_score();
let mean_residual = self.residuals.iter().sum::<f64>() / self.residuals.len() as f64;
let mut diagnostics = String::new();
let mut is_adequate = true;
if mean_residual.abs() > 0.1 {
diagnostics.push_str(&format!(
"WARNING: Mean residual = {:.4} (should be near 0)\n",
mean_residual
));
is_adequate = false;
}
if spatial_cov.abs() > 0.05 {
diagnostics.push_str(&format!(
"WARNING: Spatial autocorrelation in residuals = {:.4} (suggests model misfit)\n",
spatial_cov
));
is_adequate = false;
}
if mar > 0.5 {
diagnostics.push_str(&format!(
"WARNING: Large residuals (MAR = {:.4})\n",
mar
));
is_adequate = false;
}
if diagnostics.is_empty() {
diagnostics.push_str("Model adequacy: PASS (residuals appear random and centered at 0)\n");
}
(is_adequate, diagnostics)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_residual_computation_raw() {
let locations = vec![(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)];
let observed = vec![10.0, 15.0, 20.0];
let predicted = vec![9.0, 16.0, 19.0];
let result = PointProcessResiduals::compute(
locations,
observed,
predicted,
ResidualType::Raw,
);
assert!(result.is_ok());
let residuals = result.unwrap();
assert_eq!(residuals.residuals[0], 1.0);
assert_eq!(residuals.residuals[1], -1.0);
assert_eq!(residuals.residuals[2], 1.0);
}
#[test]
fn test_residual_computation_standardized() {
let locations = vec![(0.0, 0.0), (1.0, 1.0)];
let observed = vec![10.0, 20.0];
let predicted = vec![9.0, 16.0];
let result = PointProcessResiduals::compute(
locations,
observed,
predicted,
ResidualType::Standardized,
);
assert!(result.is_ok());
let residuals = result.unwrap();
assert!(residuals.residuals[0].abs() > 0.0);
assert!(residuals.residuals[1].abs() > 0.0);
}
#[test]
fn test_residual_length_mismatch() {
let locations = vec![(0.0, 0.0), (1.0, 1.0)];
let observed = vec![10.0];
let predicted = vec![9.0, 16.0];
let result = PointProcessResiduals::compute(
locations,
observed,
predicted,
ResidualType::Raw,
);
assert!(result.is_err());
}
#[test]
fn test_residual_clustering_score() {
let locations = vec![
(0.0, 0.0),
(0.1, 0.1),
(1.0, 1.0),
(1.1, 1.1),
];
let observed = vec![10.0, 12.0, 15.0, 14.0];
let predicted = vec![10.0, 10.0, 15.0, 15.0];
let residuals = PointProcessResiduals::compute(
locations,
observed,
predicted,
ResidualType::Raw,
)
.unwrap();
let (mar, _spatial_cov) = residuals.residual_clustering_score();
assert!(mar > 0.0);
}
#[test]
fn test_adequacy_check() {
let locations = vec![(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)];
let observed = vec![10.0, 15.0, 20.0];
let predicted = vec![10.1, 15.1, 19.9];
let residuals = PointProcessResiduals::compute(
locations,
observed,
predicted,
ResidualType::Raw,
)
.unwrap();
let (is_adequate, _diagnostics) = residuals.adequacy_check();
assert!(is_adequate);
}
}