use crate::GeostatResult;
use crate::GeostatError;
use serde::{Deserialize, Serialize};
use crate::kriging::OrdinaryKriging;
use crate::variogram::VariogramModel;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CVMetrics {
pub mean_error: f64,
pub rmse: f64,
pub mean_std_error: f64,
pub rmsse: f64,
pub correlation: f64,
pub sample_size: usize,
}
impl CVMetrics {
pub fn summary(&self) -> String {
format!(
"CV: ME={:.4}, RMSE={:.4}, MSE={:.4}, RMSSE={:.4}, r={:.3}, n={}",
self.mean_error,
self.rmse,
(self.rmse * self.rmse),
self.rmsse,
self.correlation,
self.sample_size
)
}
pub fn is_well_calibrated(&self) -> bool {
self.mean_std_error.abs() < 0.1 && self.rmsse > 0.8 && self.rmsse < 1.2
}
}
pub struct LeaveOneOutCV;
impl LeaveOneOutCV {
pub fn validate(
training_coords: &[(f64, f64)],
training_values: &[f64],
variogram: &VariogramModel,
) -> GeostatResult<CVMetrics> {
if training_coords.len() != training_values.len() {
return Err(GeostatError::InvalidParameters(
"coordinates and values must have same length".to_string(),
));
}
if training_coords.len() < 4 {
return Err(GeostatError::InsufficientData(
"at least 4 points required for meaningful LOOCV".to_string(),
));
}
let n = training_coords.len();
let mut predictions = Vec::new();
let mut actuals = Vec::new();
let mut residuals = Vec::new();
let mut std_residuals = Vec::new();
for i in 0..n {
let mut loo_coords = Vec::new();
let mut loo_values = Vec::new();
for j in 0..n {
if i != j {
loo_coords.push(training_coords[j]);
loo_values.push(training_values[j]);
}
}
let ok = match OrdinaryKriging::new(loo_coords, loo_values, variogram.clone()) {
Ok(model) => model,
Err(_) => continue, };
let target = training_coords[i];
let actual = training_values[i];
match ok.predict(target) {
Ok(result) => {
predictions.push(result.prediction);
actuals.push(actual);
let residual = actual - result.prediction;
residuals.push(residual);
if result.std_error > 0.0 {
let std_residual = residual / result.std_error;
std_residuals.push(std_residual);
}
}
Err(_) => continue, }
}
if predictions.is_empty() {
return Err(GeostatError::KrigingSolveFailed(
"All LOOCV folds failed".to_string(),
));
}
let sample_size = predictions.len();
let mean_error = residuals.iter().sum::<f64>() / residuals.len() as f64;
let mse = residuals.iter().map(|r| r * r).sum::<f64>() / residuals.len() as f64;
let rmse = mse.sqrt();
let mean_std_error = if std_residuals.is_empty() {
0.0
} else {
std_residuals.iter().sum::<f64>() / std_residuals.len() as f64
};
let msse = if std_residuals.is_empty() {
1.0
} else {
std_residuals.iter().map(|e| e * e).sum::<f64>() / std_residuals.len() as f64
};
let rmsse = msse.sqrt();
let correlation = Self::pearson_correlation(&predictions, &actuals);
Ok(CVMetrics {
mean_error,
rmse,
mean_std_error,
rmsse,
correlation,
sample_size,
})
}
fn pearson_correlation(x: &[f64], y: &[f64]) -> f64 {
if x.len() != y.len() || x.is_empty() {
return 0.0;
}
let n = x.len() as f64;
let mean_x = x.iter().sum::<f64>() / n;
let mean_y = y.iter().sum::<f64>() / n;
let mut cov = 0.0;
let mut var_x = 0.0;
let mut var_y = 0.0;
for (xi, yi) in x.iter().zip(y.iter()) {
let dx = xi - mean_x;
let dy = yi - mean_y;
cov += dx * dy;
var_x += dx * dx;
var_y += dy * dy;
}
let denom = (var_x * var_y).sqrt();
if denom > 0.0 {
cov / denom
} else {
0.0
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::variogram::{VariogramModel, VariogramModelFamily};
#[test]
fn test_cv_metrics_summary() {
let metrics = CVMetrics {
mean_error: -0.05,
rmse: 0.42,
mean_std_error: 0.01,
rmsse: 1.05,
correlation: 0.95,
sample_size: 100,
};
let summary = metrics.summary();
assert!(summary.contains("RMSE=0.4200"));
assert!(summary.contains("n=100"));
}
#[test]
fn test_cv_metrics_calibration_check() {
let good = CVMetrics {
mean_error: 0.01,
rmse: 0.5,
mean_std_error: 0.05,
rmsse: 0.95,
correlation: 0.9,
sample_size: 50,
};
assert!(good.is_well_calibrated());
let bias = CVMetrics {
mean_error: 0.5,
rmse: 0.5,
mean_std_error: 0.5,
rmsse: 0.95,
correlation: 0.9,
sample_size: 50,
};
assert!(!bias.is_well_calibrated());
let underconfident = CVMetrics {
mean_error: 0.01,
rmse: 0.5,
mean_std_error: 0.01,
rmsse: 0.5,
correlation: 0.9,
sample_size: 50,
};
assert!(!underconfident.is_well_calibrated());
let overconfident = CVMetrics {
mean_error: 0.01,
rmse: 0.5,
mean_std_error: 0.01,
rmsse: 1.5,
correlation: 0.9,
sample_size: 50,
};
assert!(!overconfident.is_well_calibrated());
}
#[test]
fn test_pearson_correlation_perfect() {
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let y = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let r = LeaveOneOutCV::pearson_correlation(&x, &y);
assert!((r - 1.0).abs() < 1e-10);
}
#[test]
fn test_pearson_correlation_perfect_negative() {
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let y = vec![5.0, 4.0, 3.0, 2.0, 1.0];
let r = LeaveOneOutCV::pearson_correlation(&x, &y);
assert!((r + 1.0).abs() < 1e-10);
}
#[test]
fn test_pearson_correlation_no_correlation() {
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let y = vec![2.0, 2.0, 2.0, 2.0, 2.0]; let r = LeaveOneOutCV::pearson_correlation(&x, &y);
assert!(r.is_nan() || r == 0.0);
}
#[test]
fn test_loocv_insufficient_data() {
let coords = vec![(0.0, 0.0), (10.0, 0.0), (0.0, 10.0)];
let values = vec![1.0, 2.0, 1.5];
let vario = VariogramModel {
family: VariogramModelFamily::Spherical,
nugget: 0.1,
partial_sill: 0.9,
range: 100.0,
wrss: 0.01,
condition_number: 5.0,
};
let result = LeaveOneOutCV::validate(&coords, &values, &vario);
assert!(result.is_err());
}
#[test]
fn test_loocv_mismatched_lengths() {
let coords = vec![(0.0, 0.0), (10.0, 0.0), (0.0, 10.0), (10.0, 10.0)];
let values = vec![1.0, 2.0, 1.5];
let vario = VariogramModel {
family: VariogramModelFamily::Spherical,
nugget: 0.0,
partial_sill: 1.0,
range: 100.0,
wrss: 0.01,
condition_number: 5.0,
};
let result = LeaveOneOutCV::validate(&coords, &values, &vario);
assert!(result.is_err());
}
#[test]
fn test_loocv_simple_linear_field() {
let coords = vec![
(0.0, 0.0),
(100.0, 0.0),
(200.0, 0.0),
(0.0, 100.0),
(100.0, 100.0),
];
let values = vec![0.0, 100.0, 200.0, 0.0, 100.0];
let vario = VariogramModel {
family: VariogramModelFamily::Spherical,
nugget: 0.0,
partial_sill: 1.0,
range: 150.0,
wrss: 0.005,
condition_number: 4.0,
};
let result = LeaveOneOutCV::validate(&coords, &values, &vario);
assert!(result.is_ok());
let metrics = result.unwrap();
assert_eq!(metrics.sample_size, 5);
assert!(metrics.rmse >= 0.0); assert!(metrics.rmse.is_finite()); if metrics.sample_size >= 2 {
assert!(metrics.correlation >= -1.0 || metrics.correlation.is_nan());
assert!(metrics.correlation <= 1.0 || metrics.correlation.is_nan());
}
}
#[test]
fn test_loocv_constant_field() {
let coords = vec![
(0.0, 0.0),
(100.0, 0.0),
(200.0, 0.0),
(0.0, 100.0),
(100.0, 100.0),
];
let values = vec![5.0, 5.0, 5.0, 5.0, 5.0];
let vario = VariogramModel {
family: VariogramModelFamily::Exponential,
nugget: 0.0,
partial_sill: 1.0,
range: 100.0,
wrss: 0.001,
condition_number: 3.0,
};
let result = LeaveOneOutCV::validate(&coords, &values, &vario);
assert!(result.is_ok());
let metrics = result.unwrap();
assert!(metrics.mean_error.abs() < 2.0); assert!(metrics.sample_size > 0);
}
#[test]
fn test_loocv_all_model_families() {
let coords = vec![
(0.0, 0.0),
(100.0, 0.0),
(50.0, 50.0),
(0.0, 100.0),
(100.0, 100.0),
];
let values = vec![1.0, 2.5, 2.0, 1.5, 2.8];
for family in [
VariogramModelFamily::Spherical,
VariogramModelFamily::Exponential,
VariogramModelFamily::Gaussian,
] {
let vario = VariogramModel {
family,
nugget: 0.1,
partial_sill: 0.9,
range: 120.0,
wrss: 0.01,
condition_number: 6.0,
};
let result = LeaveOneOutCV::validate(&coords, &values, &vario);
assert!(result.is_ok(), "LOOCV failed for {:?}", family);
let metrics = result.unwrap();
assert_eq!(metrics.sample_size, 5);
assert!(!metrics.rmse.is_nan());
assert!(!metrics.correlation.is_nan());
}
}
#[test]
fn test_loocv_metrics_bounds() {
let coords = vec![
(0.0, 0.0),
(100.0, 0.0),
(50.0, 50.0),
(0.0, 100.0),
(100.0, 100.0),
];
let values = vec![1.0, 2.5, 2.0, 1.5, 2.8];
let vario = VariogramModel {
family: VariogramModelFamily::Spherical,
nugget: 0.05,
partial_sill: 0.95,
range: 100.0,
wrss: 0.01,
condition_number: 5.0,
};
let result = LeaveOneOutCV::validate(&coords, &values, &vario);
assert!(result.is_ok());
let metrics = result.unwrap();
assert!(metrics.rmse >= 0.0);
assert!(metrics.correlation >= -1.0 && metrics.correlation <= 1.0);
assert!(metrics.rmsse >= 0.0);
assert!(metrics.sample_size <= coords.len());
assert!(metrics.sample_size > 0);
}
#[test]
fn test_loocv_reproducibility() {
let coords = vec![
(0.0, 0.0),
(100.0, 0.0),
(50.0, 50.0),
(0.0, 100.0),
];
let values = vec![1.0, 2.5, 2.0, 1.5];
let vario = VariogramModel {
family: VariogramModelFamily::Spherical,
nugget: 0.1,
partial_sill: 0.9,
range: 100.0,
wrss: 0.01,
condition_number: 5.0,
};
let result1 = LeaveOneOutCV::validate(&coords, &values, &vario);
let result2 = LeaveOneOutCV::validate(&coords, &values, &vario);
assert!(result1.is_ok() && result2.is_ok());
let metrics1 = result1.unwrap();
let metrics2 = result2.unwrap();
assert!((metrics1.rmse - metrics2.rmse).abs() < 1e-10);
assert!((metrics1.correlation - metrics2.correlation).abs() < 1e-10);
assert!((metrics1.mean_error - metrics2.mean_error).abs() < 1e-10);
}
}