mod common;
use anofox_regression::diagnostics::{
compute_leverage, cooks_distance, dffits, externally_studentized_residuals, generalized_vif,
high_leverage_points, high_vif_predictors, influential_cooks, influential_dffits,
residual_outliers, standardized_residuals, studentized_residuals, variance_inflation_factor,
};
use anofox_regression::solvers::{FittedRegressor, OlsRegressor, Regressor};
use faer::{Col, Mat};
#[test]
fn test_leverage_with_ols() {
let x = Mat::from_fn(30, 2, |i, j| {
if j == 0 {
i as f64
} else {
(i as f64 * 0.5).sin()
}
});
let y = Col::from_fn(30, |i| 1.0 + 2.0 * i as f64);
let model = OlsRegressor::builder().with_intercept(true).build();
let fitted = model.fit(&x, &y).expect("fit should succeed");
let leverage = compute_leverage(&x, true);
let high = high_leverage_points(&leverage, fitted.result().n_parameters, None);
for i in 0..leverage.nrows() {
assert!(leverage[i] >= 0.0 && leverage[i] <= 1.0);
}
for &idx in &high {
assert!(idx < 30);
}
}
#[test]
fn test_residual_diagnostics_with_ols() {
let x = Mat::from_fn(50, 2, |i, j| ((i + j) as f64) * 0.1);
let mut y = Col::zeros(50);
for i in 0..50 {
y[i] = 1.0 + 2.0 * x[(i, 0)] + 3.0 * x[(i, 1)] + (i as f64 * 0.01).sin();
}
let model = OlsRegressor::builder().with_intercept(true).build();
let fitted = model.fit(&x, &y).expect("fit should succeed");
let leverage = compute_leverage(&x, true);
let residuals = &fitted.result().residuals;
let mse = fitted.result().mse;
let std_resid = standardized_residuals(residuals, mse);
let stud_resid = studentized_residuals(residuals, &leverage, mse);
for i in 0..std_resid.nrows() {
assert!(
std_resid[i].abs() < 10.0,
"Standardized residual {} too large",
i
);
}
for i in 0..stud_resid.nrows() {
assert!(
stud_resid[i].abs() < 10.0,
"Studentized residual {} too large",
i
);
}
}
#[test]
fn test_cooks_distance_with_ols() {
let x = Mat::from_fn(30, 1, |i, _| i as f64);
let y = Col::from_fn(30, |i| 1.0 + 2.0 * i as f64);
let model = OlsRegressor::builder().with_intercept(true).build();
let fitted = model.fit(&x, &y).expect("fit should succeed");
let leverage = compute_leverage(&x, true);
let residuals = &fitted.result().residuals;
let mse = fitted.result().mse;
let n_params = fitted.result().n_parameters;
let cooks = cooks_distance(residuals, &leverage, mse, n_params);
for i in 0..cooks.nrows() {
assert!(
cooks[i] >= 0.0 || cooks[i].is_nan(),
"Cook's distance[{}] = {} should be >= 0",
i,
cooks[i]
);
}
let influential = influential_cooks(&cooks, None);
for &idx in &influential {
assert!(idx < 30);
}
}
#[test]
fn test_influential_point_detection() {
let mut x = Mat::from_fn(30, 1, |i, _| i as f64);
let mut y = Col::from_fn(30, |i| 1.0 + 2.0 * i as f64);
x[(29, 0)] = 100.0; y[29] = 300.0;
let model = OlsRegressor::builder().with_intercept(true).build();
let fitted = model.fit(&x, &y).expect("fit should succeed");
let leverage = compute_leverage(&x, true);
let residuals = &fitted.result().residuals;
let mse = fitted.result().mse;
let n_params = fitted.result().n_parameters;
let cooks = cooks_distance(residuals, &leverage, mse, n_params);
let max_cooks_idx = (0..30)
.filter(|&i| cooks[i].is_finite())
.max_by(|&a, &b| cooks[a].partial_cmp(&cooks[b]).unwrap())
.unwrap();
assert_eq!(
max_cooks_idx, 29,
"Point 29 should have highest Cook's distance"
);
}
#[test]
fn test_vif_with_independent_predictors() {
let x = Mat::from_fn(100, 2, |i, j| {
if j == 0 {
(i as f64 * 0.1).sin()
} else {
(i as f64 * 0.1).cos()
}
});
let vif = variance_inflation_factor(&x);
for j in 0..vif.nrows() {
assert!(
vif[j] < 2.0,
"VIF[{}] = {} should be < 2 for independent predictors",
j,
vif[j]
);
}
}
#[test]
fn test_vif_detects_collinearity() {
let x = Mat::from_fn(100, 2, |i, j| {
if j == 0 {
i as f64
} else {
i as f64 * 1.01 + 0.1 }
});
let vif = variance_inflation_factor(&x);
let high = high_vif_predictors(&vif, 5.0);
assert!(
!high.is_empty(),
"Collinear predictors should have high VIF: {:?}",
vif.iter().collect::<Vec<_>>()
);
}
#[test]
fn test_vif_with_multiple_predictors() {
let x = Mat::from_fn(100, 3, |i, j| match j {
0 => i as f64,
1 => (i as f64 * 0.5).sin(),
2 => i as f64 * 2.0 + (i as f64 * 0.3).cos(), _ => 0.0,
});
let vif = variance_inflation_factor(&x);
for j in 0..vif.nrows() {
assert!(vif[j] >= 1.0, "VIF[{}] = {} should be >= 1", j, vif[j]);
}
}
#[test]
fn test_full_diagnostic_workflow() {
let x = Mat::from_fn(50, 2, |i, j| {
if j == 0 {
i as f64
} else {
(i as f64 * 0.2).sin() * 10.0
}
});
let y = Col::from_fn(50, |i| {
5.0 + 2.0 * x[(i, 0)] + 3.0 * x[(i, 1)] + (i as f64 * 0.1).cos()
});
let model = OlsRegressor::builder()
.with_intercept(true)
.compute_inference(true)
.build();
let fitted = model.fit(&x, &y).expect("fit should succeed");
let leverage = compute_leverage(&x, true);
let residuals = &fitted.result().residuals;
let mse = fitted.result().mse;
let n_params = fitted.result().n_parameters;
let std_resid = standardized_residuals(residuals, mse);
let stud_resid = studentized_residuals(residuals, &leverage, mse);
let cooks = cooks_distance(residuals, &leverage, mse, n_params);
let vif = variance_inflation_factor(&x);
assert_eq!(leverage.nrows(), 50);
assert_eq!(std_resid.nrows(), 50);
assert_eq!(stud_resid.nrows(), 50);
assert_eq!(cooks.nrows(), 50);
assert_eq!(vif.nrows(), 2);
let leverage_sum: f64 = leverage.iter().sum();
assert!(
(leverage_sum - n_params as f64).abs() < 1.0,
"Sum of leverage {} should be close to n_params {}",
leverage_sum,
n_params
);
for j in 0..vif.nrows() {
assert!(vif[j] >= 1.0);
}
}
#[test]
fn test_vif_single_predictor() {
let x = Mat::from_fn(50, 1, |i, _| i as f64);
let vif = variance_inflation_factor(&x);
assert_eq!(vif.nrows(), 1);
assert!(
(vif[0] - 1.0).abs() < 1e-10,
"Single predictor VIF should be 1.0"
);
}
#[test]
fn test_vif_too_few_observations() {
let x = Mat::from_fn(2, 3, |i, j| (i + j) as f64);
let vif = variance_inflation_factor(&x);
for j in 0..vif.nrows() {
assert!(
(vif[j] - 1.0).abs() < 1e-10,
"VIF should be 1.0 with few observations"
);
}
}
#[test]
fn test_generalized_vif_basic() {
let x = Mat::from_fn(100, 4, |i, j| match j {
0 => i as f64,
1 => (i as f64 * 0.1).sin(),
2 => (i as f64 * 0.2).cos(),
3 => (i as f64 * 0.3).tan().clamp(-10.0, 10.0),
_ => 0.0,
});
let gvif = generalized_vif(&x, &[1, 1, 2]);
assert_eq!(gvif.len(), 3);
for v in &gvif {
assert!(*v >= 1.0, "GVIF should be >= 1.0");
}
}
#[test]
fn test_generalized_vif_mismatched_sizes() {
let x = Mat::from_fn(50, 3, |i, j| (i + j) as f64);
let gvif = generalized_vif(&x, &[1, 1]); assert_eq!(gvif.len(), 2);
}
#[test]
fn test_generalized_vif_empty_group() {
let x = Mat::from_fn(50, 3, |i, j| (i * j) as f64 + 0.1);
let gvif = generalized_vif(&x, &[1, 0, 2]);
assert_eq!(gvif.len(), 3);
assert!(
(gvif[1] - 1.0).abs() < 1e-10,
"Empty group should have GVIF = 1.0"
);
}
#[test]
fn test_high_vif_various_thresholds() {
let x = Mat::from_fn(100, 3, |i, j| {
match j {
0 => i as f64,
1 => i as f64 * 1.001 + 0.5, 2 => (i as f64 * 0.1).sin(), _ => 0.0,
}
});
let vif = variance_inflation_factor(&x);
let high_5 = high_vif_predictors(&vif, 5.0);
let high_10 = high_vif_predictors(&vif, 10.0);
let high_100 = high_vif_predictors(&vif, 100.0);
assert!(high_10.len() <= high_5.len());
assert!(high_100.len() <= high_10.len());
}
#[test]
fn test_externally_studentized_residuals() {
let x = Mat::from_fn(50, 2, |i, j| ((i + j) as f64) * 0.1);
let mut y = Col::zeros(50);
for i in 0..50 {
y[i] = 1.0 + 2.0 * x[(i, 0)] + 3.0 * x[(i, 1)] + (i as f64 * 0.05).sin();
}
let model = OlsRegressor::builder().with_intercept(true).build();
let fitted = model.fit(&x, &y).expect("fit should succeed");
let leverage = compute_leverage(&x, true);
let residuals = &fitted.result().residuals;
let mse = fitted.result().mse;
let n_params = fitted.result().n_parameters;
let ext_stud = externally_studentized_residuals(residuals, &leverage, mse, n_params);
assert_eq!(ext_stud.nrows(), 50);
let finite_count = ext_stud.iter().filter(|&&r| r.is_finite()).count();
assert!(
finite_count > 40,
"Most externally studentized residuals should be finite"
);
}
#[test]
fn test_standardized_residuals_zero_mse() {
let residuals = Col::from_fn(10, |i| i as f64 - 4.5);
let std_resid_zero = standardized_residuals(&residuals, 0.0);
let std_resid_neg = standardized_residuals(&residuals, -1.0);
for i in 0..10 {
if residuals[i].abs() > 1e-14 {
assert!(std_resid_zero[i].is_nan() || std_resid_zero[i] == 0.0);
assert!(std_resid_neg[i].is_nan() || std_resid_neg[i] == 0.0);
}
}
}
#[test]
fn test_studentized_residuals_zero_mse() {
let residuals = Col::from_fn(10, |i| i as f64 - 4.5);
let leverage = Col::from_fn(10, |_| 0.1);
let stud_resid = studentized_residuals(&residuals, &leverage, 0.0);
for i in 0..10 {
assert!(
stud_resid[i].is_nan(),
"Studentized residual should be NaN when MSE=0"
);
}
}
#[test]
fn test_residual_outliers_various_thresholds() {
let studentized = Col::from_fn(20, |i| {
match i {
5 => 3.5, 10 => 2.5, 15 => 4.0, _ => (i as f64 - 10.0) * 0.1,
}
});
let outliers_2 = residual_outliers(&studentized, 2.0);
let outliers_3 = residual_outliers(&studentized, 3.0);
assert!(outliers_2.contains(&5));
assert!(outliers_2.contains(&10));
assert!(outliers_2.contains(&15));
assert!(outliers_3.contains(&5));
assert!(!outliers_3.contains(&10)); assert!(outliers_3.contains(&15));
}
#[test]
fn test_externally_studentized_insufficient_df() {
let residuals = Col::from_fn(5, |i| i as f64);
let leverage = Col::from_fn(5, |_| 0.2);
let mse = 1.0;
let n_params = 4;
let ext_stud = externally_studentized_residuals(&residuals, &leverage, mse, n_params);
for i in 0..5 {
assert!(ext_stud[i].is_nan(), "Should be NaN with insufficient df");
}
}
#[test]
fn test_dffits_basic() {
let x = Mat::from_fn(30, 1, |i, _| i as f64);
let y = Col::from_fn(30, |i| {
1.0 + 2.0 * i as f64 + if i == 25 { 50.0 } else { 0.0 }
});
let model = OlsRegressor::builder().with_intercept(true).build();
let fitted = model.fit(&x, &y).expect("fit should succeed");
let leverage = compute_leverage(&x, true);
let residuals = &fitted.result().residuals;
let mse = fitted.result().mse;
let n_params = fitted.result().n_parameters;
let dffits_vals = dffits(residuals, &leverage, mse, n_params);
assert_eq!(dffits_vals.nrows(), 30);
let max_idx = (0..30)
.filter(|&i| dffits_vals[i].is_finite())
.max_by(|&a, &b| {
dffits_vals[a]
.abs()
.partial_cmp(&dffits_vals[b].abs())
.unwrap()
});
if let Some(idx) = max_idx {
assert!(
dffits_vals[idx].abs() > 0.5,
"Outlier should have noticeable DFFITS"
);
}
}
#[test]
fn test_influential_dffits() {
let x = Mat::from_fn(50, 2, |i, j| ((i + j * 10) as f64) * 0.1);
let y = Col::from_fn(50, |i| 1.0 + 2.0 * i as f64);
let model = OlsRegressor::builder().with_intercept(true).build();
let fitted = model.fit(&x, &y).expect("fit should succeed");
let leverage = compute_leverage(&x, true);
let residuals = &fitted.result().residuals;
let mse = fitted.result().mse;
let n_params = fitted.result().n_parameters;
let dffits_vals = dffits(residuals, &leverage, mse, n_params);
let influential = influential_dffits(&dffits_vals, n_params, None);
assert!(
influential.len() < 10,
"Clean fit should have few influential points"
);
let influential_strict = influential_dffits(&dffits_vals, n_params, Some(0.5));
assert!(influential_strict.len() <= influential.len());
}