use crate::error::{AnalyticsError, Result};
use scirs2_core::ndarray::{Array1, Array2};
#[cfg(feature = "parallel")]
use rayon::prelude::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GwrKernel {
Gaussian,
Bisquare,
Exponential,
}
impl GwrKernel {
#[must_use]
pub fn weight(self, d: f64, b: f64) -> f64 {
if !b.is_finite() || b <= 0.0 {
return if d == 0.0 { 1.0 } else { 0.0 };
}
match self {
GwrKernel::Gaussian => {
let r = d / b;
(-0.5 * r * r).exp()
}
GwrKernel::Bisquare => {
if d < b {
let r = d / b;
let t = 1.0 - r * r;
t * t
} else {
0.0
}
}
GwrKernel::Exponential => (-d / b).exp(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum GwrBandwidth {
Fixed(f64),
AdaptiveKnn(usize),
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GwrOptions {
pub kernel: GwrKernel,
pub bandwidth: GwrBandwidth,
pub optimize_bandwidth: bool,
}
impl Default for GwrOptions {
fn default() -> Self {
Self {
kernel: GwrKernel::Bisquare,
bandwidth: GwrBandwidth::AdaptiveKnn(0),
optimize_bandwidth: false,
}
}
}
#[derive(Debug, Clone)]
pub struct GwrResult {
pub coefficients: Vec<Vec<f64>>,
pub predicted: Vec<f64>,
pub residuals: Vec<f64>,
pub local_r2: Vec<f64>,
pub bandwidth: f64,
pub aicc: f64,
}
#[derive(Debug, Clone, Copy)]
enum ResolvedBandwidth {
Fixed(f64),
AdaptiveKnn(usize),
}
impl ResolvedBandwidth {
fn report_value(self) -> f64 {
match self {
ResolvedBandwidth::Fixed(b) => b,
ResolvedBandwidth::AdaptiveKnn(k) => k as f64,
}
}
}
struct GwrFitArtifacts {
coefficients: Vec<Vec<f64>>,
predicted: Vec<f64>,
residuals: Vec<f64>,
local_r2: Vec<f64>,
trace_s: f64,
}
pub fn gwr_fit(
coords: &[(f64, f64)],
x: &[Vec<f64>],
y: &[f64],
options: &GwrOptions,
) -> Result<GwrResult> {
let n = coords.len();
if n == 0 {
return Err(AnalyticsError::insufficient_data(
"GWR requires at least one observation",
));
}
if x.len() != n {
return Err(AnalyticsError::dimension_mismatch(
format!("{n} predictor rows"),
format!("{} predictor rows", x.len()),
));
}
if y.len() != n {
return Err(AnalyticsError::dimension_mismatch(
format!("{n} response values"),
format!("{} response values", y.len()),
));
}
let n_predictors = x[0].len();
for (i, row) in x.iter().enumerate() {
if row.len() != n_predictors {
return Err(AnalyticsError::dimension_mismatch(
format!("predictor row 0 has {n_predictors} columns"),
format!("predictor row {i} has {} columns", row.len()),
));
}
}
let n_params = n_predictors + 1;
if n < n_params {
return Err(AnalyticsError::insufficient_data(format!(
"GWR with {n_predictors} predictor(s) needs at least {n_params} observations, got {n}"
)));
}
for (name, values) in [("coordinates", None), ("response", Some(y))] {
if let Some(values) = values {
if values.iter().any(|v| !v.is_finite()) {
return Err(AnalyticsError::invalid_input(format!(
"{name} values must be finite"
)));
}
}
}
if coords
.iter()
.any(|(cx, cy)| !cx.is_finite() || !cy.is_finite())
{
return Err(AnalyticsError::invalid_input(
"coordinate values must be finite",
));
}
if x.iter().any(|row| row.iter().any(|v| !v.is_finite())) {
return Err(AnalyticsError::invalid_input(
"predictor values must be finite",
));
}
let design = build_design_matrix(x, n, n_params);
let distances = pairwise_distances(coords);
let resolved = if options.optimize_bandwidth {
optimize_bandwidth(&design, y, &distances, options, n, n_params)?
} else {
resolve_initial_bandwidth(&distances, options, n)?
};
let artifacts = fit_all_locations(&design, y, &distances, options.kernel, resolved, n_params)?;
let aicc = corrected_aic(n, &artifacts.residuals, artifacts.trace_s)?;
Ok(GwrResult {
coefficients: artifacts.coefficients,
predicted: artifacts.predicted,
residuals: artifacts.residuals,
local_r2: artifacts.local_r2,
bandwidth: resolved.report_value(),
aicc,
})
}
fn build_design_matrix(x: &[Vec<f64>], n: usize, n_params: usize) -> Array2<f64> {
let mut design = Array2::<f64>::zeros((n, n_params));
for i in 0..n {
design[[i, 0]] = 1.0;
for (j, value) in x[i].iter().enumerate() {
design[[i, j + 1]] = *value;
}
}
design
}
fn pairwise_distances(coords: &[(f64, f64)]) -> Array2<f64> {
let n = coords.len();
let mut distances = Array2::<f64>::zeros((n, n));
for i in 0..n {
let (xi, yi) = coords[i];
for j in (i + 1)..n {
let (xj, yj) = coords[j];
let dx = xi - xj;
let dy = yi - yj;
let d = (dx * dx + dy * dy).sqrt();
distances[[i, j]] = d;
distances[[j, i]] = d;
}
}
distances
}
fn resolve_initial_bandwidth(
distances: &Array2<f64>,
options: &GwrOptions,
n: usize,
) -> Result<ResolvedBandwidth> {
match options.bandwidth {
GwrBandwidth::Fixed(b) => {
if !b.is_finite() || b <= 0.0 {
return Err(AnalyticsError::invalid_parameter(
"bandwidth",
"fixed bandwidth must be a positive, finite distance",
));
}
Ok(ResolvedBandwidth::Fixed(b))
}
GwrBandwidth::AdaptiveKnn(k) => {
let default_k = ((n as f64).sqrt().ceil() as usize).max(2);
let chosen = if k == 0 { default_k } else { k };
let clamped = chosen.min(n.saturating_sub(1)).max(1);
let _ = distances;
Ok(ResolvedBandwidth::AdaptiveKnn(clamped))
}
}
}
fn local_bandwidth_distance(
distances: &Array2<f64>,
resolved: ResolvedBandwidth,
i: usize,
n: usize,
) -> f64 {
match resolved {
ResolvedBandwidth::Fixed(b) => b,
ResolvedBandwidth::AdaptiveKnn(k) => {
let mut row: Vec<f64> = (0..n)
.filter(|&j| j != i)
.map(|j| distances[[i, j]])
.collect();
row.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let idx = (k - 1).min(row.len().saturating_sub(1));
let b = row.get(idx).copied().unwrap_or(0.0);
if b > 0.0 {
b
} else {
row.iter()
.copied()
.fold(0.0_f64, f64::max)
.max(f64::EPSILON)
}
}
}
}
fn fit_all_locations(
design: &Array2<f64>,
y: &[f64],
distances: &Array2<f64>,
kernel: GwrKernel,
resolved: ResolvedBandwidth,
n_params: usize,
) -> Result<GwrFitArtifacts> {
let n = y.len();
let solve_one = |i: usize| -> Result<(Vec<f64>, f64, f64, f64, f64)> {
let b = local_bandwidth_distance(distances, resolved, i, n);
solve_local(design, y, distances, kernel, i, b, n_params)
};
#[cfg(feature = "parallel")]
let per_location: Vec<(Vec<f64>, f64, f64, f64, f64)> = (0..n)
.into_par_iter()
.map(solve_one)
.collect::<Result<Vec<_>>>()?;
#[cfg(not(feature = "parallel"))]
let per_location: Vec<(Vec<f64>, f64, f64, f64, f64)> =
(0..n).map(solve_one).collect::<Result<Vec<_>>>()?;
let mut coefficients = Vec::with_capacity(n);
let mut predicted = Vec::with_capacity(n);
let mut residuals = Vec::with_capacity(n);
let mut local_r2 = Vec::with_capacity(n);
let mut trace_s = 0.0;
for (beta, pred, resid, r2, leverage) in per_location {
coefficients.push(beta);
predicted.push(pred);
residuals.push(resid);
local_r2.push(r2);
trace_s += leverage;
}
Ok(GwrFitArtifacts {
coefficients,
predicted,
residuals,
local_r2,
trace_s,
})
}
fn solve_local(
design: &Array2<f64>,
y: &[f64],
distances: &Array2<f64>,
kernel: GwrKernel,
i: usize,
bandwidth: f64,
n_params: usize,
) -> Result<(Vec<f64>, f64, f64, f64, f64)> {
let n = y.len();
let mut weights = vec![0.0_f64; n];
let mut weight_sum = 0.0;
for (j, w) in weights.iter_mut().enumerate() {
let d = distances[[i, j]];
let kw = kernel.weight(d, bandwidth);
*w = kw;
weight_sum += kw;
}
if weight_sum <= 0.0 || !weight_sum.is_finite() {
return Err(AnalyticsError::numerical_instability(format!(
"local kernel weights at location {i} sum to a non-positive value; bandwidth too small"
)));
}
let mut a = Array2::<f64>::zeros((n_params, n_params));
let mut rhs = Array1::<f64>::zeros(n_params);
for j in 0..n {
let w = weights[j];
if w == 0.0 {
continue;
}
for p in 0..n_params {
let xjp = design[[j, p]];
let wx = w * xjp;
rhs[p] += wx * y[j];
for q in p..n_params {
a[[p, q]] += wx * design[[j, q]];
}
}
}
for p in 0..n_params {
for q in (p + 1)..n_params {
a[[q, p]] = a[[p, q]];
}
}
let a_inv = invert_matrix(&a)?;
let mut beta = vec![0.0_f64; n_params];
for p in 0..n_params {
let mut acc = 0.0;
for q in 0..n_params {
acc += a_inv[[p, q]] * rhs[q];
}
beta[p] = acc;
}
if beta.iter().any(|c| !c.is_finite()) {
return Err(AnalyticsError::numerical_instability(format!(
"local coefficients at location {i} are not finite; system is ill-conditioned"
)));
}
let mut predicted = 0.0;
for p in 0..n_params {
predicted += beta[p] * design[[i, p]];
}
let residual = y[i] - predicted;
let leverage = hat_diagonal(design, &a_inv, weights[i], i, n_params);
let local_r2 = local_r_squared(design, y, &weights, &beta, weight_sum, n_params);
Ok((beta, predicted, residual, local_r2, leverage))
}
fn hat_diagonal(
design: &Array2<f64>,
a_inv: &Array2<f64>,
w_ii: f64,
i: usize,
n_params: usize,
) -> f64 {
let mut quad = 0.0;
for p in 0..n_params {
let mut t_p = 0.0;
for q in 0..n_params {
t_p += a_inv[[p, q]] * design[[i, q]];
}
quad += design[[i, p]] * t_p;
}
w_ii * quad
}
fn local_r_squared(
design: &Array2<f64>,
y: &[f64],
weights: &[f64],
beta: &[f64],
weight_sum: f64,
n_params: usize,
) -> f64 {
let n = y.len();
let mut weighted_y = 0.0;
for j in 0..n {
weighted_y += weights[j] * y[j];
}
let mean_y = weighted_y / weight_sum;
let mut ss_tot = 0.0;
let mut ss_res = 0.0;
for j in 0..n {
let w = weights[j];
if w == 0.0 {
continue;
}
let mut fitted = 0.0;
for p in 0..n_params {
fitted += beta[p] * design[[j, p]];
}
let dy = y[j] - mean_y;
let dr = y[j] - fitted;
ss_tot += w * dy * dy;
ss_res += w * dr * dr;
}
if ss_tot <= f64::EPSILON {
1.0
} else {
let r2 = 1.0 - ss_res / ss_tot;
r2.clamp(0.0, 1.0)
}
}
fn corrected_aic(n: usize, residuals: &[f64], trace_s: f64) -> Result<f64> {
let nf = n as f64;
let rss: f64 = residuals.iter().map(|r| r * r).sum();
let sigma2 = rss / nf;
let sigma = sigma2.sqrt();
if !sigma.is_finite() {
return Err(AnalyticsError::numerical_instability(
"residual standard deviation is not finite while computing AICc",
));
}
let safe_sigma = if sigma <= 0.0 {
f64::MIN_POSITIVE
} else {
sigma
};
let denom = nf - 2.0 - trace_s;
if denom.abs() <= f64::EPSILON {
return Ok(f64::INFINITY);
}
let aicc = 2.0 * nf * safe_sigma.ln()
+ nf * (2.0 * std::f64::consts::PI).ln()
+ nf * (nf + trace_s) / denom;
Ok(aicc)
}
fn optimize_bandwidth(
design: &Array2<f64>,
y: &[f64],
distances: &Array2<f64>,
options: &GwrOptions,
n: usize,
n_params: usize,
) -> Result<ResolvedBandwidth> {
match options.bandwidth {
GwrBandwidth::Fixed(_) => {
let (lo, hi) = fixed_bandwidth_bounds(distances, n)?;
let score = |b: f64| -> f64 {
evaluate_aicc(
design,
y,
distances,
options.kernel,
ResolvedBandwidth::Fixed(b),
n_params,
)
};
let best = golden_section_min(lo, hi, &score);
Ok(ResolvedBandwidth::Fixed(best))
}
GwrBandwidth::AdaptiveKnn(_) => {
let k_min = n_params.max(2);
let k_max = n.saturating_sub(1).max(k_min);
let score = |k: f64| -> f64 {
let k_round = (k.round() as usize).clamp(k_min, k_max);
evaluate_aicc(
design,
y,
distances,
options.kernel,
ResolvedBandwidth::AdaptiveKnn(k_round),
n_params,
)
};
let best = golden_section_min(k_min as f64, k_max as f64, &score);
let best_k = (best.round() as usize).clamp(k_min, k_max);
Ok(ResolvedBandwidth::AdaptiveKnn(best_k))
}
}
}
fn evaluate_aicc(
design: &Array2<f64>,
y: &[f64],
distances: &Array2<f64>,
kernel: GwrKernel,
resolved: ResolvedBandwidth,
n_params: usize,
) -> f64 {
match fit_all_locations(design, y, distances, kernel, resolved, n_params) {
Ok(artifacts) => match corrected_aic(y.len(), &artifacts.residuals, artifacts.trace_s) {
Ok(value) if value.is_finite() => value,
_ => f64::INFINITY,
},
Err(_) => f64::INFINITY,
}
}
fn fixed_bandwidth_bounds(distances: &Array2<f64>, n: usize) -> Result<(f64, f64)> {
let mut max_d = 0.0_f64;
for i in 0..n {
for j in (i + 1)..n {
max_d = max_d.max(distances[[i, j]]);
}
}
if max_d <= 0.0 || !max_d.is_finite() {
return Err(AnalyticsError::insufficient_data(
"all observation coordinates are coincident; cannot derive a bandwidth range",
));
}
let lo = (max_d / (n as f64)).max(max_d * 1e-3);
let hi = max_d;
Ok((lo.min(hi), hi))
}
fn golden_section_min<F>(mut a: f64, mut b: f64, f: &F) -> f64
where
F: Fn(f64) -> f64,
{
if a >= b {
return a;
}
let inv_phi = (5.0_f64.sqrt() - 1.0) / 2.0;
let inv_phi2 = inv_phi * inv_phi;
let mut h = b - a;
let mut c = a + inv_phi2 * h;
let mut d = a + inv_phi * h;
let mut fc = f(c);
let mut fd = f(d);
let mut best_x = if fc <= fd { c } else { d };
let mut best_f = fc.min(fd);
for _ in 0..100 {
if fc < fd {
b = d;
d = c;
fd = fc;
h = b - a;
c = a + inv_phi2 * h;
fc = f(c);
if fc < best_f {
best_f = fc;
best_x = c;
}
} else {
a = c;
c = d;
fc = fd;
h = b - a;
d = a + inv_phi * h;
fd = f(d);
if fd < best_f {
best_f = fd;
best_x = d;
}
}
if h.abs() <= f64::EPSILON {
break;
}
}
best_x
}
fn invert_matrix(matrix: &Array2<f64>) -> Result<Array2<f64>> {
let n = matrix.nrows();
if n != matrix.ncols() {
return Err(AnalyticsError::matrix_error(
"local normal-equation matrix must be square",
));
}
let mut aug = Array2::<f64>::zeros((n, 2 * n));
for i in 0..n {
for j in 0..n {
aug[[i, j]] = matrix[[i, j]];
}
aug[[i, n + i]] = 1.0;
}
for i in 0..n {
let mut max_row = i;
let mut max_val = aug[[i, i]].abs();
for k in (i + 1)..n {
let v = aug[[k, i]].abs();
if v > max_val {
max_val = v;
max_row = k;
}
}
if max_val < 1e-12 {
return Err(AnalyticsError::matrix_error(
"local weighted regression system is singular or rank-deficient",
));
}
if max_row != i {
for j in 0..(2 * n) {
let tmp = aug[[i, j]];
aug[[i, j]] = aug[[max_row, j]];
aug[[max_row, j]] = tmp;
}
}
let pivot = aug[[i, i]];
for j in 0..(2 * n) {
aug[[i, j]] /= pivot;
}
for k in 0..n {
if k != i {
let factor = aug[[k, i]];
if factor != 0.0 {
for j in 0..(2 * n) {
aug[[k, j]] -= factor * aug[[i, j]];
}
}
}
}
}
let mut inverse = Array2::<f64>::zeros((n, n));
for i in 0..n {
for j in 0..n {
inverse[[i, j]] = aug[[i, n + j]];
}
}
if inverse.iter().any(|v| !v.is_finite()) {
return Err(AnalyticsError::numerical_instability(
"matrix inverse produced non-finite entries (ill-conditioned system)",
));
}
Ok(inverse)
}