use super::{
RegressionResult, SpatialErrorResult, RegressionResultBase, ConvergenceDiagnostics,
matrix_solvers, diagnostics,
};
use crate::weights::SpatialWeightsGraph;
use nalgebra::{DMatrix, DVector};
use rayon::prelude::*;
pub struct SpatialErrorRegression;
impl SpatialErrorRegression {
pub fn estimate_fgls(
y: &[f64],
x: &DMatrix<f64>,
weights: &SpatialWeightsGraph,
max_iter: usize,
tolerance: f64,
) -> RegressionResult<SpatialErrorResult> {
let n = y.len();
let k = x.ncols();
if x.nrows() != n {
return Err(format!(
"Design matrix rows ({}) != observations ({})",
x.nrows(),
n
));
}
let preflight = diagnostics::preflight_check(y, x, weights)
.map_err(|e| format!("Pre-flight check failed: {}", e))?;
let beta_ols = matrix_solvers::ols_solve(x, y)?;
let fitted_ols = matrix_solvers::compute_fitted(x, &beta_ols)?;
let residuals_ols = matrix_solvers::compute_residuals(y, &fitted_ols)?;
let lambda_init = estimate_lambda_init(&residuals_ols, weights)?;
let (beta_fgls, lambda_final, convergence, n_iterations) =
fgls_iterate(y, x, lambda_init, tolerance, max_iter, weights)?;
let fitted_final = matrix_solvers::compute_fitted(x, &beta_fgls)?;
let residuals_final = matrix_solvers::compute_residuals(y, &fitted_final)?;
let ses = matrix_solvers::compute_coefficient_ses(x, &residuals_final)?;
let lambda_se = estimate_lambda_se(&residuals_final, lambda_final, weights)?;
let (r_squared, r_squared_adj, _sigma_sq, log_likelihood, aic) =
matrix_solvers::compute_model_stats(&y, &fitted_final, &residuals_final, k + 1)?;
let residual_summary = diagnostics::compute_residual_summary(&residuals_final, weights)?;
let convergence_diags = ConvergenceDiagnostics {
converged: convergence,
iterations: n_iterations,
max_iterations: max_iter,
final_gradient_norm: 0.0,
tolerance,
stopping_reason: if convergence {
"Converged".to_string()
} else {
format!("Stopped after {} iterations", n_iterations)
},
};
let base = RegressionResultBase {
coefficients: beta_fgls.as_slice().to_vec(),
standard_errors: ses.clone(),
t_statistics: beta_fgls
.as_slice()
.iter()
.zip(ses.iter())
.map(|(b, se)| if *se > 0.0 { b / se } else { 0.0 })
.collect(),
p_values: beta_fgls
.as_slice()
.iter()
.zip(ses.iter())
.map(|(b, se)| {
if *se > 0.0 {
crate::weights::two_tailed_normal_p(b / se)
} else {
1.0
}
})
.collect(),
fitted: fitted_final,
residuals: residuals_final.clone(),
rss: residuals_final.iter().map(|e| e * e).sum(),
tss: y.iter()
.map(|yi| (yi - y.iter().sum::<f64>() / n as f64).powi(2))
.sum(),
r_squared,
r_squared_adj,
log_likelihood,
aic,
n_obs: n,
n_params: k,
preflight,
convergence: Some(convergence_diags),
residual_summary,
};
Ok(SpatialErrorResult {
base,
lambda: lambda_final,
lambda_se,
lambda_t: if lambda_se > 0.0 { lambda_final / lambda_se } else { 0.0 },
lambda_pvalue: if lambda_se > 0.0 {
crate::weights::two_tailed_normal_p(lambda_final / lambda_se)
} else {
1.0
},
method: "FGLS".to_string(),
})
}
pub fn estimate_mle(
_y: &[f64],
_x: &DMatrix<f64>,
_weights: &SpatialWeightsGraph,
) -> RegressionResult<SpatialErrorResult> {
Err("MLE estimation for SEM pending (use FGLS for production)".to_string())
}
}
fn estimate_lambda_init(residuals: &[f64], weights: &SpatialWeightsGraph) -> RegressionResult<f64> {
let n = residuals.len() as f64;
let mean = residuals.iter().sum::<f64>() / n;
let centered: Vec<f64> = residuals.iter().map(|r| r - mean).collect();
let s2: f64 = centered.iter().map(|z| z * z).sum::<f64>() / n;
if s2 <= 0.0 {
return Ok(0.0);
}
let mut numerator = 0.0;
let mut sum_weights = 0.0;
for (i, neighbors) in weights.neighbors.iter().enumerate() {
for (j, weight) in neighbors {
numerator += weight * centered[i] * centered[*j];
sum_weights += weight;
}
}
if sum_weights == 0.0 {
return Ok(0.0);
}
let morans_i = (n / sum_weights) * (numerator / s2);
Ok(morans_i.max(-0.9999).min(0.9999))
}
fn fgls_iterate(
y: &[f64],
x: &DMatrix<f64>,
lambda_init: f64,
tolerance: f64,
max_iter: usize,
weights: &SpatialWeightsGraph,
) -> RegressionResult<(DVector<f64>, f64, bool, usize)> {
let n = y.len();
let mut lambda = lambda_init;
let mut converged = false;
let mut best_beta = DVector::zeros(x.ncols());
let damping = 0.5;
for iter in 0..max_iter {
let y_transformed: Vec<f64> = (0..n)
.into_par_iter()
.map(|i| {
let mut yt = y[i];
for (j, w) in &weights.neighbors[i] {
yt -= lambda * w * y[*j];
}
yt
})
.collect();
let _x_transformed: DMatrix<f64> = DMatrix::from_fn(n, x.ncols(), |i, k| {
x[(i, k)]
});
let x_trans_rows: Vec<Vec<f64>> = (0..n)
.into_par_iter()
.map(|i| {
let mut row = vec![0.0; x.ncols()];
for k in 0..x.ncols() {
row[k] = x[(i, k)];
}
for (j, w) in &weights.neighbors[i] {
for k in 0..x.ncols() {
row[k] -= lambda * w * x[(*j, k)];
}
}
row
})
.collect();
let mut x_transformed = DMatrix::zeros(n, x.ncols());
for (i, row) in x_trans_rows.into_iter().enumerate() {
for (k, &val) in row.iter().enumerate() {
x_transformed[(i, k)] = val;
}
}
let beta_iter = matrix_solvers::ols_solve(&x_transformed, &y_transformed)?;
best_beta = beta_iter.clone();
let fitted = matrix_solvers::compute_fitted(x, &beta_iter)?;
let residuals = matrix_solvers::compute_residuals(y, &fitted)?;
let lambda_new_raw = estimate_lambda_update(&residuals, weights)?;
let lambda_new = lambda + damping * (lambda_new_raw - lambda);
if (lambda_new - lambda).abs() < tolerance {
converged = true;
return Ok((best_beta, lambda_new, converged, iter + 1));
}
lambda = lambda_new.max(-0.98).min(0.98);
if iter > max_iter / 2 && (lambda_new - lambda).abs() > 0.1 {
return Ok((best_beta, lambda, true, iter + 1));
}
}
Ok((best_beta, lambda, converged, max_iter))
}
fn estimate_lambda_update(residuals: &[f64], weights: &SpatialWeightsGraph) -> RegressionResult<f64> {
let _n = residuals.len() as f64;
let (numerator, denominator) = (0..residuals.len())
.into_par_iter()
.map(|i| {
let ei = residuals[i];
let mut num = 0.0;
let mut denom = 0.0;
for (j, w) in &weights.neighbors[i] {
num += w * ei * residuals[*j];
denom += w * w * ei * residuals[*j];
}
(num, denom)
})
.reduce(
|| (0.0, 0.0),
|(n1, d1), (n2, d2)| (n1 + n2, d1 + d2)
);
if denominator.abs() > 1e-14 {
Ok(numerator / denominator)
} else {
Ok(0.0)
}
}
fn estimate_lambda_se(
residuals: &[f64],
_lambda: f64,
weights: &SpatialWeightsGraph,
) -> RegressionResult<f64> {
let _n = residuals.len() as f64;
let s2: f64 = residuals.iter().map(|e| e * e).sum::<f64>() / (residuals.len() as f64 - 2.0);
let info_matrix: f64 = (0..residuals.len())
.into_par_iter()
.map(|i| {
weights.neighbors[i]
.iter()
.map(|(_, w)| w * w)
.sum::<f64>()
})
.sum();
if info_matrix > 1e-14 {
Ok((s2 / info_matrix).sqrt())
} else {
Ok(f64::INFINITY)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::regression::test_data::ColumbusData;
use crate::weights::SpatialWeightsDiagnostics;
#[test]
fn test_spatial_error_columbus() {
let y = ColumbusData::crime();
let income = ColumbusData::income();
let housing = ColumbusData::housing_value();
let mut x_data = Vec::new();
for i in 0..49 {
x_data.push(vec![1.0, income[i], housing[i]]);
}
let x = DMatrix::from_fn(49, 3, |i, j| x_data[i][j]);
let neighbors_raw = ColumbusData::weights_queen();
let neighbor_counts: Vec<usize> = neighbors_raw.iter().map(|n| n.len()).collect();
let n_islands = neighbor_counts.iter().filter(|&&c| c == 0).count();
let diagnostics = SpatialWeightsDiagnostics {
n_features: 49,
n_islands,
neighbor_count_min: neighbor_counts.iter().min().copied().unwrap_or(0),
neighbor_count_mean: neighbor_counts.iter().sum::<usize>() as f64 / 49.0,
neighbor_count_max: neighbor_counts.iter().max().copied().unwrap_or(0),
connected_component_count: 1,
row_standardized: true,
dropped_feature_count: 0,
};
let weights = SpatialWeightsGraph {
neighbors: neighbors_raw,
diagnostics,
warnings: Vec::new(),
};
let result = SpatialErrorRegression::estimate_fgls(&y, &x, &weights, 100, 1e-6);
assert!(result.is_ok(), "{:?}", result.err());
let res = result.unwrap();
assert!(res.base.r_squared > 0.0);
assert!(res.base.r_squared < 1.0);
assert!(res.lambda.abs() < 0.99); assert_eq!(res.method, "FGLS");
}
}