use ndarray::{Array1, Array2};
use regression_diagnostics::glm::{GlmFit, Poisson};
use regression_diagnostics::mixed::{
GlmmFamily, GlmmFit, LinearMixedModel, Method, MixedModel, RandomEffect,
};
use regression_diagnostics::{OlsFit, RegressionError};
fn balanced() -> (Array2<f64>, Array1<f64>, Vec<usize>, usize, usize) {
let g = 5usize;
let m = 6usize;
let noise = [-1.0, -0.6, -0.2, 0.2, 0.6, 1.0]; let n = g * m;
let x = Array2::<f64>::ones((n, 1)); let mut y = Array1::<f64>::zeros(n);
let mut groups = vec![0usize; n];
let mut r = 0;
for j in 0..g {
for &e in noise.iter() {
y[r] = 10.0 * j as f64 + e;
groups[r] = j;
r += 1;
}
}
(x, y, groups, g, m)
}
fn anova_components(y: &Array1<f64>, groups: &[usize], g: usize, m: usize) -> (f64, f64, f64) {
let n = y.len();
let grand: f64 = y.sum() / n as f64;
let mut group_sum = vec![0.0; g];
for i in 0..n {
group_sum[groups[i]] += y[i];
}
let group_mean: Vec<f64> = group_sum.iter().map(|s| s / m as f64).collect();
let ssb: f64 = m as f64 * group_mean.iter().map(|gm| (gm - grand).powi(2)).sum::<f64>();
let ssw: f64 = (0..n).map(|i| (y[i] - group_mean[groups[i]]).powi(2)).sum();
let msb = ssb / (g - 1) as f64;
let mse = ssw / (n - g) as f64;
let sigma2_b = (msb - mse) / m as f64;
(mse, sigma2_b, grand)
}
#[test]
fn reml_matches_anova_on_balanced_data() {
let (x, y, groups, g, m) = balanced();
let (mse, sigma2_b, grand) = anova_components(&y, &groups, g, m);
let fit = LinearMixedModel::new(x, y, &groups).unwrap();
assert!(
(fit.residual_variance() - mse).abs() < 1e-3,
"σ̂²_e = {}, ANOVA MSE = {mse}",
fit.residual_variance()
);
assert!(
(fit.group_variance() - sigma2_b).abs() < 0.1,
"σ̂²_b = {}, ANOVA = {sigma2_b}",
fit.group_variance()
);
assert!((fit.coefficients()[0] - grand).abs() < 1e-6);
assert!(fit.icc() > 0.9 && fit.icc() < 1.0);
assert_eq!(fit.n_groups(), g);
assert!(fit.random_effects().sum().abs() < 1e-8);
}
#[test]
fn blups_track_group_means() {
let (x, y, groups, g, m) = balanced();
let (_, _, grand) = anova_components(&y, &groups, g, m);
let fit = LinearMixedModel::new(x, y.clone(), &groups).unwrap();
let b = fit.random_effects();
for j in 0..g {
let group_mean = 10.0 * j as f64; assert!(
(b[j] - (group_mean - grand)).abs() < 0.05,
"BLUP[{j}] = {}, expected ≈ {}",
b[j],
group_mean - grand
);
}
}
#[test]
fn collapses_to_ols_without_group_signal() {
let n = 40usize;
let mut x = Array2::<f64>::ones((n, 2));
let mut y = Array1::<f64>::zeros(n);
let mut groups = vec![0usize; n];
for i in 0..n {
let xi = (i as f64) * 0.25 - 5.0;
x[(i, 1)] = xi;
y[i] = 3.0 - 1.5 * xi + 0.4 * ((i % 7) as f64 - 3.0); groups[i] = i % 8; }
let ols = OlsFit::new(x.clone(), y.clone()).unwrap();
let lmm = LinearMixedModel::new(x, y, &groups).unwrap();
assert!(lmm.icc() < 0.15, "ICC = {}", lmm.icc());
for j in 0..ols.n_parameters() {
assert!(
(ols.coefficients()[j] - lmm.coefficients()[j]).abs() < 0.05,
"coef[{j}] OLS vs LMM"
);
}
}
#[test]
fn ml_gives_smaller_group_variance_than_reml() {
let (x, y, groups, _, _) = balanced();
let reml = LinearMixedModel::with_method(x.clone(), y.clone(), &groups, Method::Reml).unwrap();
let ml = LinearMixedModel::with_method(x, y, &groups, Method::Ml).unwrap();
assert!(ml.group_variance() <= reml.group_variance() + 1e-9);
assert!(reml.log_likelihood().is_finite() && ml.log_likelihood().is_finite());
assert!(reml.aic().is_finite());
}
#[test]
fn general_model_reduces_to_random_intercept() {
let (x, y, groups, _, _) = balanced();
let closed = LinearMixedModel::new(x.clone(), y.clone(), &groups).unwrap();
let general = MixedModel::new(x, y, vec![RandomEffect::intercept(&groups)]).unwrap();
assert!(
(general.residual_variance() - closed.residual_variance()).abs() < 1e-2,
"σ²_e: {} vs {}",
general.residual_variance(),
closed.residual_variance()
);
assert!(
(general.term_covariance(0)[(0, 0)] - closed.group_variance()).abs() < 0.5,
"σ²_b: {} vs {}",
general.term_covariance(0)[(0, 0)],
closed.group_variance()
);
assert!((general.coefficients()[0] - closed.coefficients()[0]).abs() < 1e-3);
assert!((general.log_likelihood() - closed.log_likelihood()).abs() < 1e-2);
assert_eq!(general.n_terms(), 1);
}
#[test]
fn random_slope_model_fits() {
let g = 6usize;
let per = 12usize;
let slope_shift = [-1.5, -0.8, 0.2, 0.7, 1.1, 0.3];
let n = g * per;
let mut x = Array2::<f64>::ones((n, 2)); let mut z = Array2::<f64>::ones((n, 2)); let mut y = Array1::<f64>::zeros(n);
let mut groups = vec![0usize; n];
let mut r = 0;
for (j, &shift) in slope_shift.iter().enumerate() {
for i in 0..per {
let cov = (i as f64) * 0.3 - 1.5;
x[(r, 1)] = cov;
z[(r, 1)] = cov;
y[r] = 2.0 + (3.0 + shift) * cov + ((i % 3) as f64 - 1.0) * 0.3;
groups[r] = j;
r += 1;
}
}
let fit = MixedModel::new(x, y, vec![RandomEffect::new(&groups, z)]).unwrap();
let cov = fit.term_covariance(0);
assert_eq!(cov.shape(), [2, 2]);
assert!(cov[(0, 0)] >= 0.0 && cov[(1, 1)] >= 0.0);
assert!(cov.iter().all(|v| v.is_finite()));
assert_eq!(fit.random_effects(0).shape(), [g, 2]);
assert_eq!(fit.n_random_effects(), g * 2);
assert!(fit.residual_variance() > 0.0 && fit.log_likelihood().is_finite());
assert!(cov[(1, 1)] > 0.1, "slope variance = {}", cov[(1, 1)]);
}
#[test]
fn crossed_random_effects_fit() {
let n = 48usize;
let mut x = Array2::<f64>::ones((n, 1));
let mut y = Array1::<f64>::zeros(n);
let mut fa = vec![0usize; n];
let mut fb = vec![0usize; n];
for i in 0..n {
fa[i] = i % 4; fb[i] = i % 3; let a_eff = [2.0, -1.0, 0.5, -1.5][fa[i]];
let b_eff = [1.0, -0.5, -0.5][fb[i]];
x[(i, 0)] = 1.0;
y[i] = 10.0 + a_eff + b_eff + ((i % 5) as f64 - 2.0) * 0.4;
}
let fit = MixedModel::new(
x,
y,
vec![RandomEffect::intercept(&fa), RandomEffect::intercept(&fb)],
)
.unwrap();
assert_eq!(fit.n_terms(), 2);
assert!(fit.term_covariance(0)[(0, 0)] >= 0.0);
assert!(fit.term_covariance(1)[(0, 0)] >= 0.0);
assert_eq!(fit.random_effects(0).shape(), [4, 1]);
assert_eq!(fit.random_effects(1).shape(), [3, 1]);
assert!(fit.log_likelihood().is_finite());
}
#[test]
fn poisson_glmm_approaches_glm_without_group_signal() {
let n = 48usize;
let mut x = Array2::<f64>::ones((n, 2));
let mut y = Array1::<f64>::zeros(n);
let mut groups = vec![0usize; n];
for i in 0..n {
let xi = (i as f64) * 0.08 - 1.9;
x[(i, 1)] = xi;
let mu = (0.6 + 0.5 * xi).exp();
y[i] = (mu + ((i % 3) as f64 - 1.0) * 0.5).round().max(0.0);
groups[i] = i % 8; }
let glm = GlmFit::new(Poisson, x.clone(), y.clone()).unwrap();
let glmm = GlmmFit::new(x, y, &groups, GlmmFamily::Poisson).unwrap();
assert!(glmm.sigma_b() < 0.5, "σ_b = {}", glmm.sigma_b());
for j in 0..glm.n_parameters() {
assert!(
(glm.coefficients()[j] - glmm.coefficients()[j]).abs() < 0.25,
"coef[{j}] GLM {} vs GLMM {}",
glm.coefficients()[j],
glmm.coefficients()[j]
);
}
assert!(glmm.log_likelihood().is_finite());
}
#[test]
fn poisson_glmm_detects_group_variance() {
let g = 8usize;
let per = 8usize;
let offset = [-0.9, 0.8, -0.5, 0.6, -0.7, 0.9, -0.3, 0.4];
let n = g * per;
let mut x = Array2::<f64>::ones((n, 2));
let mut y = Array1::<f64>::zeros(n);
let mut groups = vec![0usize; n];
let mut r = 0;
for (j, &off) in offset.iter().enumerate() {
for i in 0..per {
let xi = (i as f64) * 0.2 - 0.7;
x[(r, 1)] = xi;
let mu = (0.8 + 0.3 * xi + off).exp();
y[r] = mu.round().max(0.0);
groups[r] = j;
r += 1;
}
}
let glmm = GlmmFit::new(x, y, &groups, GlmmFamily::Poisson).unwrap();
assert!(glmm.sigma_b() > 0.25, "σ_b = {}", glmm.sigma_b());
assert!(glmm.coefficient_standard_errors().iter().all(|v| v.is_finite()));
assert!(glmm.aic().is_finite());
assert_eq!(glmm.random_effects().len(), g);
}
#[test]
fn binomial_glmm_fits() {
let g = 10usize;
let per = 10usize;
let offset = [-2.0, 1.5, -1.0, 2.0, -1.5, 1.0, -0.5, 0.5, -2.0, 1.5];
let n = g * per;
let mut x = Array2::<f64>::ones((n, 2));
let mut y = Array1::<f64>::zeros(n);
let mut groups = vec![0usize; n];
let mut r = 0;
for (j, &off) in offset.iter().enumerate() {
for i in 0..per {
let xi = (i as f64) * 0.3 - 1.5;
x[(r, 1)] = xi;
let eta = 0.4 * xi + off;
y[r] = if eta + ((i % 2) as f64 - 0.5) > 0.0 { 1.0 } else { 0.0 };
groups[r] = j;
r += 1;
}
}
let glmm = GlmmFit::new(x, y, &groups, GlmmFamily::Binomial).unwrap();
assert!(glmm.sigma_b() > 0.0);
assert!(glmm.log_likelihood().is_finite());
assert_eq!(glmm.n_groups(), g);
assert!(glmm.p_values().iter().all(|v| v.is_finite()));
}
#[test]
fn glmm_rejects_bad_input() {
let x = Array2::<f64>::ones((4, 1));
let bad = Array1::from(vec![1.0, 2.0, -1.0, 0.0]);
assert!(matches!(
GlmmFit::new(x.clone(), bad, &[0, 1, 0, 1], GlmmFamily::Poisson).unwrap_err(),
RegressionError::InvalidResponse { .. }
));
let ok = Array1::from(vec![0.0, 1.0, 1.0, 0.0]);
assert!(matches!(
GlmmFit::new(x, ok, &[3, 3, 3, 3], GlmmFamily::Binomial).unwrap_err(),
RegressionError::InvalidResponse { .. }
));
}
#[test]
fn rejects_degenerate_input() {
let x = Array2::<f64>::ones((4, 1));
let y = Array1::from(vec![1.0, 2.0, 3.0, 4.0]);
let one_group = vec![0usize, 0, 0, 0];
assert!(matches!(
LinearMixedModel::new(x.clone(), y.clone(), &one_group).unwrap_err(),
RegressionError::InvalidResponse { .. }
));
let x_wide = Array2::<f64>::ones((2, 3));
let y2 = Array1::from(vec![1.0, 2.0]);
let groups2 = vec![0usize, 1];
assert!(matches!(
LinearMixedModel::new(x_wide, y2, &groups2).unwrap_err(),
RegressionError::NoResidualDegreesOfFreedom { .. }
));
}