use super::*;
use super::core::{build_workspace, fit_on};
use crate::lmm::{fit_lmm, LmmWorkspace};
use crate::{
Family, GroupIds, Grouping, GroupingRelation, ModelSpec, ReStructure, Sizing, StartValues,
};
use faer::Mat;
#[cfg(feature = "loop_advanced")]
use super::common_tests::lmm_hand_dataset;
use super::common_tests::{assert_pinned, dense_str, lcg, PIN_REL_ITER};
#[cfg(feature = "loop_advanced")]
use super::loop_advanced_seam::{build_lmm_workspace, refit_lmm};
use super::lmm::{lmm_run_on, lmm_view_to_fit};
use crate::test_support::{assert_near, intercept_only_spec};
#[test]
fn lmm_run_on_view_maps_to_same_fit_as_fit_cold() {
let n_clusters = 6usize;
let per = 8usize;
let n = n_clusters * per;
let p = 2usize;
let mut st = 13u64;
let mut x = vec![0.0f64; n * p];
let mut y = vec![0.0f64; n];
let mut ids_v = vec![0u32; n];
for i in 0..n {
ids_v[i] = (i % n_clusters) as u32;
let x1 = lcg(&mut st);
x[i * 2] = 1.0;
x[i * 2 + 1] = x1;
let re = 0.3 * ((ids_v[i] as f64) - (n_clusters as f64) / 2.0);
y[i] = 0.5 + 0.4 * x1 + re + 0.2 * lcg(&mut st);
}
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 1 },
slopes: vec![],
extra_groupings: vec![],
}),
};
let ids = GroupIds {
primary: ids_v,
extra: vec![],
};
let opts = FitOptions {
target_indices: vec![0, 1],
..FitOptions::default()
};
let cold = fit_cold(&x, &y, n, p, &model, &ids, &opts);
let (sized, ids, _perm) = spec_sized_from_ids(&model, &ids);
let mut ws = LmmWorkspace::for_cluster_spec_ext(p, &sized, n, &[], &[]);
let mut x_mat = Mat::<f64>::zeros(n, p);
for i in 0..n {
for j in 0..p {
x_mat[(i, j)] = x[i * p + j];
}
}
ws.suff.reset();
ws.suff
.add_rows_multi(x_mat.as_ref(), &y, &ids.primary, &[], None);
let via = {
let v = lmm_run_on(&mut ws, &opts.target_indices, None);
lmm_view_to_fit(&v, &x, &ids, n, p, &opts)
};
assert_near(&cold.beta, &via.beta, "beta");
assert_near(&cold.tau2, &via.tau2, "tau2");
assert_near(&[cold.dispersion], &[via.dispersion], "dispersion");
assert_near(&cold.se, &via.se, "se");
}
#[test]
fn fit_lmm_rank_deficient_drops_the_aliased_column() {
const REF_BETA: [f64; 3] = [0.8576729942296913, 0.6993983638391031, -0.4068182431411529];
const REF_SE: [f64; 3] = [
0.24654045945855108,
0.041312856909260794,
0.042805742152106876,
];
const REF_G_TAU2: f64 = 0.7113844334703112;
const REF_SIGMA2: f64 = 0.26968316460592023;
let csv = include_str!("../../validation/data/simulated/sim_collinear_lmm.csv");
let mut y = Vec::<f64>::new();
let mut cols: Vec<[f64; 3]> = Vec::new();
let mut g_raw = Vec::<String>::new();
for line in csv.lines().skip(1).filter(|l| !l.trim().is_empty()) {
let f: Vec<&str> = line.split(',').map(|s| s.trim_matches('"')).collect();
y.push(f[0].parse().unwrap());
cols.push([
f[1].parse().unwrap(),
f[2].parse().unwrap(),
f[3].parse().unwrap(),
]);
g_raw.push(f[4].to_string());
}
let n = y.len();
let p = 4; let mut x = vec![0.0f64; n * p];
for i in 0..n {
x[i * p] = 1.0;
x[i * p + 1] = cols[i][0];
x[i * p + 2] = cols[i][1];
x[i * p + 3] = cols[i][2];
}
let (g, _n_g) = dense_str(&g_raw);
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 1 }, slopes: vec![],
extra_groupings: vec![],
}),
};
let f = fit_cold(
&x,
&y,
n,
p,
&model,
&GroupIds {
primary: g,
extra: vec![],
},
&FitOptions {
target_indices: vec![0, 1, 2, 3],
..FitOptions::default()
},
);
assert!(f.converged(), "reduced LMM must converge");
assert_eq!(
f.aliased(),
vec![false, false, false, true],
"x3 is the dependent column and the only one dropped"
);
assert!(f.beta[3].is_nan(), "aliased β = NaN");
assert!(f.se[3].is_nan(), "aliased se = NaN");
assert_pinned(&f.beta[..3], &REF_BETA, PIN_REL_ITER, "beta");
assert_pinned(&f.se[..3], &REF_SE, PIN_REL_ITER, "se");
assert_pinned(&f.tau2, &[REF_G_TAU2], PIN_REL_ITER, "tau2");
assert_pinned(&[f.dispersion], &[REF_SIGMA2], PIN_REL_ITER, "sigma2");
}
fn gap_a_stream(k: usize) -> Vec<f64> {
let mut s = 1u64;
(0..k)
.map(|_| {
s = (75 * s + 74) % 65537;
s as f64 / 65537.0 - 0.5
})
.collect()
}
fn intercept_only_lmm() -> ModelSpec {
intercept_only_spec(Sizing::FixedClusters { n_clusters: 1 })
}
#[test]
fn lmm_pure_dynamic_range_design_fits_in_full() {
let (jn, m, c, s_scale, tau, sigma) = (25usize, 40usize, 3e-7f64, 20.0f64, 16.0f64, 1.0f64);
let (n, p) = (jn * m, 3usize);
let g = gap_a_stream(jn);
let h = gap_a_stream(n);
let mut x = vec![0.0f64; n * p];
let mut y = vec![0.0f64; n];
let mut ids = vec![0u32; n];
for (j, &g_j) in g.iter().enumerate().take(jn) {
let u_j = c * ((j + 1) as f64 / jn as f64);
let b_j = tau * g_j;
for i in 0..m {
let r = j * m + i;
let w_i = s_scale * (i as f64 / m as f64 - (m as f64 - 1.0) / (2.0 * m as f64));
x[r * p] = 1.0;
x[r * p + 1] = u_j;
x[r * p + 2] = w_i;
y[r] = 1.0 + 2.0 * (u_j / c) + 0.5 * w_i + b_j + sigma * h[r];
ids[r] = j as u32;
}
}
let ids = GroupIds {
primary: ids,
extra: vec![],
};
let opts = FitOptions {
target_indices: vec![0, 1, 2],
..FitOptions::default()
};
let f = fit_cold(&x, &y, n, p, &intercept_only_lmm(), &ids, &opts);
assert!(
f.converged(),
"nothing is collinear here — the design is computable and must fit"
);
assert_eq!(
f.aliased(),
vec![false; p],
"nothing was dropped, so nothing may be flagged aliased"
);
assert!(
f.beta.iter().all(|b| b.is_finite()) && f.se.iter().all(|s| s.is_finite()),
"the full fit must be finite throughout, got β = {:?}, se = {:?}",
f.beta,
f.se
);
assert!(
f.se[1] > 1e6,
"u's SE must carry the imprecision, got {}",
f.se[1]
);
assert!(
f.se[0] < 10.0 && f.se[2] < 1.0,
"the well-scaled columns keep ordinary SEs, got {:?}",
f.se
);
assert!(
(f.beta[1] - 2.0 / c).abs() < f.se[1],
"β_u = {} must sit within one SE ({}) of the truth {}",
f.beta[1],
f.se[1],
2.0 / c
);
assert!(
(f.beta[2] - 0.5).abs() < 0.01,
"β_w = {} must recover the truth 0.5",
f.beta[2]
);
assert!(f.df > 0, "a converged fit reports its parameter count");
assert!(
f.diagnostics.notes.is_empty(),
"no column is entangled here, so no note may be raised: {:?}",
f.diagnostics.notes
);
let (sized, ids, perm) = spec_sized_from_ids_pub(&intercept_only_lmm(), &ids);
let mut ws = build_workspace(&sized, perm, n, p, &opts);
let d = fit_on(&mut ws, &x, &y, &ids, None, &opts).diagnostics();
assert!(!d.ill_conditioned, "pivot {} must clear the floor", d.pivot);
assert!(
(0.2..0.3).contains(&d.pivot),
"min pivot ratio must stay at the quoted 0.235, got {}",
d.pivot
);
}
fn build_gap_a_salvage_design(
with_exact_alias: bool,
) -> (Vec<f64>, Vec<f64>, Vec<u32>, usize, usize) {
let (jn, m, s_scale, tau, sigma) = (25usize, 40usize, 20.0f64, 16.0f64, 1.0f64);
let (d, rho) = (3e-6f64, 1e-5f64);
let n = jn * m;
let p = if with_exact_alias { 5 } else { 4 };
let s_small = s_scale * rho;
let g = gap_a_stream(jn);
let h = gap_a_stream(2 * n);
let mut x = vec![0.0f64; n * p];
let mut y = vec![0.0f64; n];
let mut ids = vec![0u32; n];
for (j, &g_j) in g.iter().enumerate().take(jn) {
let b_j = tau * g_j;
let t_bar = h[j * m..j * m + m].iter().sum::<f64>() / m as f64;
for i in 0..m {
let r = j * m + i;
let z_i = s_scale * (i as f64 / m as f64 - (m as f64 - 1.0) / (2.0 * m as f64));
let t_i = s_small * (h[r] - t_bar);
let v_i = t_i * (1.0 + d * if i % 2 == 0 { 1.0 } else { -1.0 });
x[r * p] = 1.0;
x[r * p + 1] = t_i;
x[r * p + 2] = v_i;
x[r * p + 3] = z_i;
if with_exact_alias {
x[r * p + 4] = 1.0 + z_i;
}
y[r] = 1.0 + (1.0 / s_small) * t_i + 0.5 * z_i + b_j + sigma * h[n + r];
ids[r] = j as u32;
}
}
(x, y, ids, n, p)
}
#[test]
fn lmm_entangled_pair_fits_in_full_with_honest_ses() {
const LME4_BETA: [f64; 4] = [
-0.7054541628205219,
-38288906.83665362,
38293871.58172187,
0.5016368546595636,
];
const LME4_SE: [f64; 4] = [
0.8424257561779566,
52060999.05491157,
52060993.35174688,
0.0015371530739338938,
];
const LME4_SD_G: f64 = 4.211895307851695;
const LME4_SIGMA: f64 = 0.2803066654730708;
const BETA_REL: f64 = 1e-3;
const SE_REL: f64 = 1e-3;
const STDDEV_REL: f64 = 1e-3;
let (x, y, ids, n, p) = build_gap_a_salvage_design(false);
let opts = FitOptions {
target_indices: (0..p as u32).collect(),
..FitOptions::default()
};
let ids = GroupIds {
primary: ids,
extra: vec![],
};
let f = fit_cold(&x, &y, n, p, &intercept_only_lmm(), &ids, &opts);
assert!(
f.converged(),
"the design is ill-conditioned, not rank-deficient — it must fit"
);
assert_eq!(
f.aliased(),
vec![false; 4],
"nothing is redundant at ALIAS_EPS, so no column may be dropped"
);
assert!(
f.beta.iter().all(|b| b.is_finite()) && f.se.iter().all(|s| s.is_finite()),
"the full fit must be finite throughout, got β = {:?}, se = {:?}",
f.beta,
f.se
);
for j in [1usize, 2] {
assert!(
f.se[j] > 0.5 * f.beta[j].abs(),
"β[{j}] = {} must carry an SE of its own size, got {}",
f.beta[j],
f.se[j]
);
}
assert_pinned(&f.beta, &LME4_BETA, BETA_REL, "beta vs lme4 full design");
assert_pinned(&f.se, &LME4_SE, SE_REL, "se vs lme4 full design");
assert_pinned(
&[f.beta[1] + f.beta[2]],
&[LME4_BETA[1] + LME4_BETA[2]],
BETA_REL,
"β_t + β_v vs lme4 full design",
);
assert_eq!(f.tau2.len(), 1, "one variance component, got {:?}", f.tau2);
assert_pinned(
&[f.tau2[0].sqrt(), f.dispersion.sqrt()],
&[LME4_SD_G, LME4_SIGMA],
STDDEV_REL,
"stddevs vs lme4 full design",
);
assert!(
f.diagnostics.notes.is_empty(),
"the pair is distinguishable in f64, so no note may be raised: {:?}",
f.diagnostics.notes
);
let (sized, ids, perm) = spec_sized_from_ids_pub(&intercept_only_lmm(), &ids);
let mut ws = build_workspace(&sized, perm, n, p, &opts);
let d = fit_on(&mut ws, &x, &y, &ids, None, &opts).diagnostics();
assert!(!d.ill_conditioned, "pivot {} must clear the floor", d.pivot);
assert!(
(4.4e-12..1.8e-11).contains(&d.pivot),
"min pivot ratio must stay at the quoted 8.76e-12, got {}",
d.pivot
);
for flip in [false, true] {
let y_eps: Vec<f64> = y
.iter()
.enumerate()
.map(|(i, &v)| {
let up = (i % 2 == 0) != flip;
let bits = v.to_bits();
if v == 0.0 {
v
} else if v.is_sign_positive() == up {
f64::from_bits(bits + 1)
} else {
f64::from_bits(bits - 1)
}
})
.collect();
let g = fit_cold(&x, &y_eps, n, p, &intercept_only_lmm(), &ids, &opts);
assert!(
g.converged(),
"flip={flip}: the perturbed fit must also converge"
);
const ULP_REL: f64 = 1e-6;
for j in [0usize, 3] {
let rel = (g.beta[j] - f.beta[j]).abs() / f.beta[j].abs();
assert!(
rel < ULP_REL,
"flip={flip}: β[{j}] moved {rel} under a 1-ULP re-rounding of y"
);
}
let sum = f.beta[1] + f.beta[2];
let rel = ((g.beta[1] + g.beta[2]) - sum).abs() / sum.abs();
assert!(
rel < ULP_REL,
"flip={flip}: β_t + β_v moved {rel} under a 1-ULP re-rounding of y"
);
}
}
#[test]
fn exact_alias_is_dropped_and_the_entangled_pair_is_kept() {
const LME4_BETA: [f64; 4] = [
-0.7054541628205219,
-38288906.83665362,
38293871.58172187,
0.5016368546595636,
];
const LME4_SE: [f64; 4] = [
0.8424257561779566,
52060999.05491157,
52060993.35174688,
0.0015371530739338938,
];
const LME4_SD_G: f64 = 4.211895307851695;
const LME4_SIGMA: f64 = 0.2803066654730708;
const BETA_REL: f64 = 1e-3;
const SE_REL: f64 = 1e-3;
const STDDEV_REL: f64 = 1e-3;
let (x, y, ids, n, p) = build_gap_a_salvage_design(true);
assert_eq!(p, 5);
let f = fit_cold(
&x,
&y,
n,
p,
&intercept_only_lmm(),
&GroupIds {
primary: ids,
extra: vec![],
},
&FitOptions {
target_indices: (0..p as u32).collect(),
..FitOptions::default()
},
);
assert!(f.converged(), "the reduced fit must converge");
assert_eq!(
f.aliased(),
vec![false, false, false, false, true],
"only the EXACT dependency (s, index 4) is dropped; the near-collinear \
pair (t, v) is entangled, not redundant, and both columns stay"
);
for j in 0..p {
assert_eq!(
f.beta[j].is_nan(),
f.aliased()[j],
"β[{j}] = {} but aliased[{j}] = {}",
f.beta[j],
f.aliased()[j]
);
assert_eq!(
f.se[j].is_nan(),
f.aliased()[j],
"se[{j}] = {} but aliased[{j}] = {}",
f.se[j],
f.aliased()[j]
);
}
assert_pinned(
&f.beta[..4],
&LME4_BETA,
BETA_REL,
"reduced beta vs lme4 full design",
);
assert_pinned(
&f.se[..4],
&LME4_SE,
SE_REL,
"reduced se vs lme4 full design",
);
assert_pinned(
&[f.beta[1] + f.beta[2]],
&[LME4_BETA[1] + LME4_BETA[2]],
BETA_REL,
"β_t + β_v vs lme4 full design",
);
assert_eq!(f.tau2.len(), 1, "one variance component, got {:?}", f.tau2);
assert_pinned(
&[f.tau2[0].sqrt(), f.dispersion.sqrt()],
&[LME4_SD_G, LME4_SIGMA],
STDDEV_REL,
"nested stddevs vs lme4 full design",
);
}
#[test]
fn fit_warm_sleepstudy_slope_matches_cold_optimum() {
let csv = include_str!("../../validation/data/empirical/sleepstudy.csv");
let mut y = Vec::<f64>::new();
let mut days = Vec::<f64>::new();
let mut subj_raw = Vec::<String>::new();
for line in csv.lines().skip(1).filter(|l| !l.trim().is_empty()) {
let f: Vec<&str> = line.split(',').map(|s| s.trim_matches('"')).collect();
y.push(f[0].parse().unwrap()); days.push(f[1].parse().unwrap()); subj_raw.push(f[2].to_string()); }
let n = y.len();
let p = 2;
let mut x = vec![0.0f64; n * p];
for i in 0..n {
x[i * p] = 1.0;
x[i * p + 1] = days[i];
}
let (subject, _n_subj) = dense_str(&subj_raw);
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 1 }, slopes: vec![1],
extra_groupings: vec![],
}),
};
let ids = GroupIds {
primary: subject,
extra: vec![],
};
let opts = FitOptions {
target_indices: vec![0, 1],
..FitOptions::default()
};
let cold = fit_cold(&x, &y, n, p, &model, &ids, &opts);
assert!(cold.converged(), "cold sleepstudy fit must converge");
const REF_SD0: f64 = 24.7406579949841;
const REF_SD1: f64 = 5.92213765889808;
const REF_CORR: f64 = 0.0655512382381282;
const REF_SIGMA: f64 = 25.5917957216753;
let truth = vec![
REF_SD0 / REF_SIGMA,
REF_CORR * REF_SD1 / REF_SIGMA,
REF_SD1 / REF_SIGMA * (1.0 - REF_CORR * REF_CORR).sqrt(),
];
let starts = [
(
"truth",
StartValues {
beta: cold.beta.clone(),
theta: truth,
},
),
(
"perturbed",
StartValues {
beta: vec![0.0; p],
theta: vec![3.0, 0.5, 1.5],
},
),
];
for (label, start) in &starts {
let warm = fit_warm(&x, &y, n, p, &model, &ids, Some(start), &opts);
assert!(
warm.converged(),
"{label}: warm must not degrade convergence"
);
for j in 0..p {
let rel = (warm.beta[j] - cold.beta[j]).abs() / cold.beta[j].abs();
assert!(
rel < 1e-3,
"{label}: β[{j}] warm {} vs cold {} (rel {rel})",
warm.beta[j],
cold.beta[j]
);
let rel = (warm.se[j] - cold.se[j]).abs() / cold.se[j];
assert!(
rel < 1e-3,
"{label}: se[{j}] warm {} vs cold {} (rel {rel})",
warm.se[j],
cold.se[j]
);
}
for off in [0usize, 2] {
let (w, c) = (warm.varcorr[0][off].sqrt(), cold.varcorr[0][off].sqrt());
let rel = (w - c).abs() / c;
assert!(
rel < 1e-3,
"{label}: RE stddev (vech {off}) warm {w} vs cold {c} (rel {rel})"
);
}
}
}
#[test]
fn fit_sleepstudy_slope_varcorr_matches_lme4() {
const REF_B0: f64 = 251.405104848485;
const REF_B1: f64 = 10.467285959596;
const REF_SE0: f64 = 6.82459669495491;
const REF_SE1: f64 = 1.54578964390598;
const REF_SD0: f64 = 24.7406579949841; const REF_SD1: f64 = 5.92213765889808; const REF_CORR: f64 = 0.0655512382381282;
const REF_SIGMA: f64 = 25.5917957216753;
let csv = include_str!("../../validation/data/empirical/sleepstudy.csv");
let mut y = Vec::<f64>::new();
let mut days = Vec::<f64>::new();
let mut subj_raw = Vec::<String>::new();
for line in csv.lines().skip(1).filter(|l| !l.trim().is_empty()) {
let f: Vec<&str> = line.split(',').map(|s| s.trim_matches('"')).collect();
y.push(f[0].parse().unwrap()); days.push(f[1].parse().unwrap()); subj_raw.push(f[2].to_string()); }
let n = y.len();
let p = 2;
let mut x = vec![0.0f64; n * p];
for i in 0..n {
x[i * p] = 1.0; x[i * p + 1] = days[i]; }
let (subject, _n_subj) = dense_str(&subj_raw);
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 1 }, slopes: vec![1], extra_groupings: vec![],
}),
};
let ids = GroupIds {
primary: subject,
extra: vec![],
};
let f = fit_cold(
&x,
&y,
n,
p,
&model,
&ids,
&FitOptions {
target_indices: vec![0, 1],
..FitOptions::default()
},
);
assert!(f.converged(), "sleepstudy slope LMM must converge");
assert!(
(f.beta[0] - REF_B0).abs() / REF_B0 < 1e-3,
"β0 {} vs {REF_B0}",
f.beta[0]
);
assert!(
(f.beta[1] - REF_B1).abs() / REF_B1 < 1e-3,
"β1 {} vs {REF_B1}",
f.beta[1]
);
assert!(
(f.se[0] - REF_SE0).abs() / REF_SE0 < 2e-2,
"se0 {} vs {REF_SE0}",
f.se[0]
);
assert!(
(f.se[1] - REF_SE1).abs() / REF_SE1 < 2e-2,
"se1 {} vs {REF_SE1}",
f.se[1]
);
assert!(
(f.dispersion.sqrt() - REF_SIGMA).abs() / REF_SIGMA < 1e-3,
"σ̂ {} vs {REF_SIGMA}",
f.dispersion.sqrt()
);
let d00 = REF_SD0 * REF_SD0;
let d11 = REF_SD1 * REF_SD1;
let d10 = REF_CORR * REF_SD0 * REF_SD1;
assert_eq!(f.varcorr.len(), 1, "one grouping block");
let vc = &f.varcorr[0];
assert_eq!(vc.len(), 3, "q=2 vech has 3 entries");
assert!(
(vc[0].sqrt() - REF_SD0).abs() / REF_SD0 < 1e-2,
"sd0 {} vs {REF_SD0}",
vc[0].sqrt()
);
assert!(
(vc[2].sqrt() - REF_SD1).abs() / REF_SD1 < 1e-2,
"sd1 {} vs {REF_SD1}",
vc[2].sqrt()
);
assert!((vc[0] - d00).abs() / d00 < 1e-3, "D00 {} vs {d00}", vc[0]);
assert!((vc[2] - d11).abs() / d11 < 1e-3, "D11 {} vs {d11}", vc[2]);
assert!(
(vc[1] - d10).abs() / d10.abs() < 1e-3,
"D10 {} vs {d10}",
vc[1]
);
}
#[test]
fn fit_exposes_n_eval_deviance_singular() {
let csv = include_str!("../../validation/data/empirical/sleepstudy.csv");
let mut y = Vec::<f64>::new();
let mut days = Vec::<f64>::new();
let mut subj_raw = Vec::<String>::new();
for line in csv.lines().skip(1).filter(|l| !l.trim().is_empty()) {
let f: Vec<&str> = line.split(',').map(|s| s.trim_matches('"')).collect();
y.push(f[0].parse().unwrap()); days.push(f[1].parse().unwrap()); subj_raw.push(f[2].to_string()); }
let n = y.len();
let p = 2;
let mut x = vec![0.0f64; n * p];
for i in 0..n {
x[i * p] = 1.0; x[i * p + 1] = days[i]; }
let (subject, _n_subj) = dense_str(&subj_raw);
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 1 }, slopes: vec![1], extra_groupings: vec![],
}),
};
let ids = GroupIds {
primary: subject,
extra: vec![],
};
let f = fit_cold(
&x,
&y,
n,
p,
&model,
&ids,
&FitOptions {
target_indices: vec![0, 1],
..FitOptions::default()
},
);
assert!(f.n_eval > 0, "BOBYQA ran, evals must be counted");
assert!(f.deviance.is_finite());
assert!(!f.singular(), "sleepstudy is an interior optimum");
let n = 180.0_f64;
let p = 2.0_f64; let df = n - p;
let lme4_loglik = -871.814135979976; let remlcrit = -2.0 * lme4_loglik;
let expected = remlcrit - df * (1.0 + (2.0 * std::f64::consts::PI).ln());
assert!(
(f.deviance - expected).abs() < 1e-6,
"deviance {} vs lme4-derived {expected}",
f.deviance
);
assert!(
(f.loglik - lme4_loglik).abs() < 1e-6,
"loglik {} vs lme4 {lme4_loglik}",
f.loglik
);
assert!(f.reml, "Gaussian LMM loglik is the REML criterion");
assert_eq!(f.df, 6); }
#[test]
fn fit_lmm_offset_matches_lme4() {
const REF_BETA: [f64; 2] = [244.5869230303025, 10.3157708080802];
const REF_REMLCRIT: f64 = 1756.8758930064;
const REF_LOGLIK: f64 = -878.437946503201;
let csv = include_str!("../../validation/data/empirical/sleepstudy.csv");
let mut y = Vec::<f64>::new();
let mut days = Vec::<f64>::new();
let mut subj_raw = Vec::<String>::new();
for line in csv.lines().skip(1).filter(|l| !l.trim().is_empty()) {
let f: Vec<&str> = line.split(',').map(|s| s.trim_matches('"')).collect();
y.push(f[0].parse().unwrap()); days.push(f[1].parse().unwrap()); subj_raw.push(f[2].to_string()); }
let n = y.len();
let p = 2;
let mut x = vec![0.0f64; n * p];
for i in 0..n {
x[i * p] = 1.0;
x[i * p + 1] = days[i];
}
let (subject, _n_subj) = dense_str(&subj_raw);
let o: Vec<f64> = (0..n).map(|i| 5.0 * (i % 4) as f64).collect();
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 1 }, slopes: vec![1],
extra_groupings: vec![],
}),
};
let f = fit_cold(
&x,
&y,
n,
p,
&model,
&GroupIds {
primary: subject,
extra: vec![],
},
&FitOptions {
target_indices: vec![0, 1],
offset: Some(o),
..FitOptions::default()
},
);
assert!(f.converged(), "offset LMM must converge");
for (j, (&b, &r)) in f.beta.iter().zip(&REF_BETA).enumerate() {
assert!((b - r).abs() / r.abs() < 1e-3, "β[{j}] = {b} vs lme4 {r}");
}
let df = (n - p) as f64;
let expected = REF_REMLCRIT - df * (1.0 + (2.0 * std::f64::consts::PI).ln());
assert!(
(f.deviance - expected).abs() < 1e-6,
"deviance {} vs lme4-derived {expected}",
f.deviance
);
assert!(
(f.loglik - REF_LOGLIK).abs() < 1e-6,
"loglik {} vs lme4 {REF_LOGLIK}",
f.loglik
);
}
#[test]
fn fit_lmm_weighted_matches_lme4() {
const REF_B0: f64 = 251.804_690_405_274;
const REF_B1: f64 = 10.4358707468765;
const REF_SE0: f64 = 6.44698545564581;
const REF_SE1: f64 = 1.57363056312657;
const REF_SD0: f64 = 22.09852363841438; const REF_SD1: f64 = 5.95218759898762; const REF_CORR: f64 = 0.16395038320169;
const REF_SIGMA: f64 = 38.62892535113247;
const REF_REMLCRIT: f64 = 1778.29146275691;
let csv = include_str!("../../validation/data/empirical/sleepstudy.csv");
let mut y = Vec::<f64>::new();
let mut days = Vec::<f64>::new();
let mut subj_raw = Vec::<String>::new();
for line in csv.lines().skip(1).filter(|l| !l.trim().is_empty()) {
let f: Vec<&str> = line.split(',').map(|s| s.trim_matches('"')).collect();
y.push(f[0].parse().unwrap()); days.push(f[1].parse().unwrap()); subj_raw.push(f[2].to_string()); }
let n = y.len();
let p = 2;
let mut x = vec![0.0f64; n * p];
for i in 0..n {
x[i * p] = 1.0; x[i * p + 1] = days[i]; }
let (subject, _n_subj) = dense_str(&subj_raw);
let w: Vec<f64> = (0..n).map(|i| 1.0 + (i % 3) as f64).collect();
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 1 }, slopes: vec![1], extra_groupings: vec![],
}),
};
let ids = GroupIds {
primary: subject,
extra: vec![],
};
let f = fit_cold(
&x,
&y,
n,
p,
&model,
&ids,
&FitOptions {
target_indices: vec![0, 1],
weights: Some(w.clone()),
..FitOptions::default()
},
);
assert!(f.converged(), "weighted sleepstudy slope LMM must converge");
assert!(
(f.beta[0] - REF_B0).abs() / REF_B0 < 1e-6,
"β0 {} vs {REF_B0}",
f.beta[0]
);
assert!(
(f.beta[1] - REF_B1).abs() / REF_B1 < 1e-6,
"β1 {} vs {REF_B1}",
f.beta[1]
);
assert!(
(f.se[0] - REF_SE0).abs() / REF_SE0 < 1e-4,
"se0 {} vs {REF_SE0}",
f.se[0]
);
assert!(
(f.se[1] - REF_SE1).abs() / REF_SE1 < 1e-4,
"se1 {} vs {REF_SE1}",
f.se[1]
);
assert_eq!(f.varcorr.len(), 1, "one grouping block");
let vc = &f.varcorr[0];
assert_eq!(vc.len(), 3, "q=2 vech has 3 entries");
let sd0 = vc[0].sqrt();
let sd1 = vc[2].sqrt();
let corr = vc[1] / (sd0 * sd1);
assert!(
(sd0 - REF_SD0).abs() / REF_SD0 < 1e-4,
"sd0 {sd0} vs {REF_SD0}"
);
assert!(
(sd1 - REF_SD1).abs() / REF_SD1 < 1e-4,
"sd1 {sd1} vs {REF_SD1}"
);
assert!((corr - REF_CORR).abs() < 4e-3, "corr {corr} vs {REF_CORR}");
let df = (n - p) as f64;
let expected = REF_REMLCRIT - df * (1.0 + (2.0 * std::f64::consts::PI).ln());
assert!(
(f.deviance - expected).abs() < 1e-6,
"deviance {} vs lme4-derived {expected}",
f.deviance
);
assert!(
(f.loglik - (-REF_REMLCRIT / 2.0)).abs() < 1e-6,
"weighted loglik {} vs lme4 {}",
f.loglik,
-REF_REMLCRIT / 2.0
);
assert!(f.reml);
let (sized, ids, _perm) = spec_sized_from_ids(&model, &ids);
let mut ws = LmmWorkspace::for_cluster_spec_ext(p, &sized, n, &[1], &[]);
let mut x_mat = Mat::<f64>::zeros(n, p);
for i in 0..n {
for j in 0..p {
x_mat[(i, j)] = x[i * p + j];
}
}
ws.suff
.add_rows_multi(x_mat.as_ref(), &y, &ids.primary, &[], Some(&w));
let lmm_fit = fit_lmm(&mut ws, &[0, 1], None);
let sigma = lmm_fit.sigma_sq.sqrt();
assert!(
(sigma - REF_SIGMA).abs() / REF_SIGMA < 1e-4,
"sigma {sigma} vs {REF_SIGMA}"
);
}
#[test]
fn fit_lmm_constant_weights_invariant() {
let n_clusters = 6usize;
let per = 8usize;
let n = n_clusters * per;
let mut st = 13u64;
let mut x = vec![0.0f64; n * 2];
let mut y = vec![0.0f64; n];
let mut ids_v = vec![0u32; n];
for i in 0..n {
ids_v[i] = (i % n_clusters) as u32;
let x1 = lcg(&mut st);
x[i * 2] = 1.0;
x[i * 2 + 1] = x1;
let re = 0.3 * ((ids_v[i] as f64) - (n_clusters as f64) / 2.0);
y[i] = 0.5 + 0.4 * x1 + re + 0.2 * lcg(&mut st);
}
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 1 },
slopes: vec![],
extra_groupings: vec![],
}),
};
let ids = GroupIds {
primary: ids_v,
extra: vec![],
};
let unweighted = fit_cold(
&x,
&y,
n,
2,
&model,
&ids,
&FitOptions {
target_indices: vec![0, 1],
..FitOptions::default()
},
);
let weighted = fit_cold(
&x,
&y,
n,
2,
&model,
&ids,
&FitOptions {
target_indices: vec![0, 1],
weights: Some(vec![2.0; n]),
..FitOptions::default()
},
);
assert!(unweighted.converged() && weighted.converged());
for j in 0..2 {
assert!(
(unweighted.beta[j] - weighted.beta[j]).abs() / unweighted.beta[j].abs() < 1e-6,
"β[{j}] unweighted {} vs w≡2 {}",
unweighted.beta[j],
weighted.beta[j]
);
assert!(
(unweighted.se[j] - weighted.se[j]).abs() / unweighted.se[j] < 1e-6,
"se[{j}] unweighted {} vs w≡2 {}",
unweighted.se[j],
weighted.se[j]
);
}
assert_eq!(unweighted.tau2.len(), weighted.tau2.len());
for k in 0..unweighted.tau2.len() {
assert!(
(unweighted.tau2[k] - weighted.tau2[k]).abs() / unweighted.tau2[k] < 1e-6,
"tau2[{k}] unweighted {} vs w≡2 {}",
unweighted.tau2[k],
weighted.tau2[k]
);
}
}
#[test]
fn fit_lmm_crossed_constant_weights_invariant() {
let csv = include_str!("../../validation/data/simulated/sim_slope.csv");
let mut y = Vec::<f64>::new();
let mut xcol = Vec::<f64>::new();
let mut g1_raw = Vec::<String>::new();
let mut g2_raw = Vec::<String>::new();
for line in csv.lines().skip(1).filter(|l| !l.trim().is_empty()) {
let f: Vec<&str> = line.split(',').map(|s| s.trim_matches('"')).collect();
y.push(f[0].parse().unwrap()); xcol.push(f[1].parse().unwrap()); g1_raw.push(f[2].to_string());
g2_raw.push(f[3].to_string());
}
let n = y.len();
let p = 2;
let mut x = vec![0.0f64; n * p];
for i in 0..n {
x[i * p] = 1.0;
x[i * p + 1] = xcol[i];
}
let (g1, _n1) = dense_str(&g1_raw);
let (g2, _n2) = dense_str(&g2_raw);
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 1 },
slopes: vec![1], extra_groupings: vec![Grouping {
relation: GroupingRelation::Crossed { n_clusters: 1 },
slopes: vec![], }],
}),
};
let ids = GroupIds {
primary: g1,
extra: vec![g2],
};
let base_opts = FitOptions {
target_indices: vec![0, 1],
..FitOptions::default()
};
let unweighted = fit_cold(&x, &y, n, p, &model, &ids, &base_opts);
let weighted = fit_cold(
&x,
&y,
n,
p,
&model,
&ids,
&FitOptions {
weights: Some(vec![2.0; n]),
..base_opts
},
);
assert!(unweighted.converged() && weighted.converged());
for j in 0..p {
assert!(
(unweighted.beta[j] - weighted.beta[j]).abs() / unweighted.beta[j].abs() < 1e-6,
"β[{j}] unweighted {} vs w≡2 {}",
unweighted.beta[j],
weighted.beta[j]
);
assert!(
(unweighted.se[j] - weighted.se[j]).abs() / unweighted.se[j] < 1e-6,
"se[{j}] unweighted {} vs w≡2 {}",
unweighted.se[j],
weighted.se[j]
);
}
assert_eq!(unweighted.varcorr.len(), weighted.varcorr.len());
for (gi, (vu, vw)) in unweighted
.varcorr
.iter()
.zip(weighted.varcorr.iter())
.enumerate()
{
assert_eq!(vu.len(), vw.len());
for k in 0..vu.len() {
let scale = vu[k].abs().max(1e-3);
assert!(
(vu[k] - vw[k]).abs() / scale < 1e-5,
"varcorr[{gi}][{k}] unweighted {} vs w≡2 {}",
vu[k],
vw[k]
);
}
}
}
#[test]
fn fit_lmm_weighted_boundary_matches_wls() {
let n = 48usize;
let n_clusters = 6usize;
let mut st = 7u64;
let mut x = vec![0.0f64; n * 2];
let mut y = vec![0.0f64; n];
let mut ids = vec![0u32; n];
let mut w = vec![0.0f64; n];
for i in 0..n {
ids[i] = (i % n_clusters) as u32;
let x1 = lcg(&mut st);
x[i * 2] = 1.0;
x[i * 2 + 1] = x1;
let e = if (i / n_clusters) % 2 == 0 { 0.8 } else { -0.8 };
y[i] = 0.5 + 0.4 * x1 + e;
w[i] = 1.0 + (i % 3) as f64;
}
let mixed_model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 1 },
slopes: vec![],
extra_groupings: vec![],
}),
};
let mixed_ids = GroupIds {
primary: ids,
extra: vec![],
};
let mixed = fit_cold(
&x,
&y,
n,
2,
&mixed_model,
&mixed_ids,
&FitOptions {
target_indices: vec![0, 1],
weights: Some(w.clone()),
..FitOptions::default()
},
);
assert!(mixed.converged(), "boundary pin still counts as converged");
assert!(mixed.singular(), "must pin at the τ=0 boundary");
let fixed_only = ModelSpec {
family: Family::Gaussian,
re: None,
};
let wls = fit_cold(
&x,
&y,
n,
2,
&fixed_only,
&GroupIds::default(),
&FitOptions {
target_indices: vec![0, 1],
weights: Some(w),
..FitOptions::default()
},
);
assert!(wls.converged());
for j in 0..2 {
assert!(
(mixed.beta[j] - wls.beta[j]).abs() / wls.beta[j].abs() < 1e-6,
"β[{j}] mixed {} vs WLS {}",
mixed.beta[j],
wls.beta[j]
);
assert!(
(mixed.se[j] - wls.se[j]).abs() / wls.se[j] < 1e-3,
"se[{j}] mixed {} vs WLS {}",
mixed.se[j],
wls.se[j]
);
}
}
#[test]
fn fit_sim_slope_varcorr_is_pinned() {
const REF_BETA: [f64; 2] = [1.0380272349025235, 0.8009679281627348];
const REF_SE: [f64; 2] = [0.33893200546608876, 0.17067327664798077];
const REF_VC_G1: [f64; 3] = [
0.8994006925131315,
-0.11776410058933363,
0.39740702504465475,
];
const REF_VC_G2: f64 = 0.5081867068546223;
const REF_SIGMA2: f64 = 0.5717627431388194;
let csv = include_str!("../../validation/data/simulated/sim_slope.csv");
let mut y = Vec::<f64>::new();
let mut xcol = Vec::<f64>::new();
let mut g1_raw = Vec::<String>::new();
let mut g2_raw = Vec::<String>::new();
for line in csv.lines().skip(1).filter(|l| !l.trim().is_empty()) {
let f: Vec<&str> = line.split(',').map(|s| s.trim_matches('"')).collect();
y.push(f[0].parse().unwrap()); xcol.push(f[1].parse().unwrap()); g1_raw.push(f[2].to_string());
g2_raw.push(f[3].to_string());
}
let n = y.len();
let p = 2;
let mut x = vec![0.0f64; n * p];
for i in 0..n {
x[i * p] = 1.0;
x[i * p + 1] = xcol[i];
}
let (g1, _n1) = dense_str(&g1_raw);
let (g2, _n2) = dense_str(&g2_raw);
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 1 },
slopes: vec![1], extra_groupings: vec![Grouping {
relation: GroupingRelation::Crossed { n_clusters: 1 },
slopes: vec![], }],
}),
};
let ids = GroupIds {
primary: g1,
extra: vec![g2],
};
let f = fit_cold(
&x,
&y,
n,
p,
&model,
&ids,
&FitOptions {
target_indices: vec![0, 1],
..FitOptions::default()
},
);
assert!(f.converged());
assert_pinned(&f.beta, &REF_BETA, PIN_REL_ITER, "beta");
assert_pinned(&f.se, &REF_SE, PIN_REL_ITER, "se");
assert_eq!(f.varcorr.len(), 2, "one block per grouping, g1 then g2");
assert_pinned(&f.varcorr[0], &REF_VC_G1, PIN_REL_ITER, "g1 varcorr");
assert_pinned(&f.varcorr[1], &[REF_VC_G2], PIN_REL_ITER, "g2 varcorr");
assert_pinned(&[f.dispersion], &[REF_SIGMA2], PIN_REL_ITER, "sigma2");
}
#[test]
fn fit_penicillin_crossed_matches_lme4() {
const REF_BETA: f64 = 22.9722222222;
const REF_SE: f64 = 0.808595361386;
const REF_PLATE_SD: f64 = 0.846702;
const REF_SAMPLE_SD: f64 = 1.931614;
let csv = include_str!("../../validation/data/empirical/Penicillin.csv");
let mut y = Vec::<f64>::new();
let mut plate_raw = Vec::<String>::new();
let mut sample_raw = Vec::<String>::new();
for line in csv.lines().skip(1).filter(|l| !l.trim().is_empty()) {
let f: Vec<&str> = line.split(',').map(|s| s.trim_matches('"')).collect();
y.push(f[0].parse().unwrap()); plate_raw.push(f[1].to_string());
sample_raw.push(f[2].to_string());
}
let n = y.len();
let p = 1;
let x = vec![1.0f64; n]; let (plate, _n_plate) = dense_str(&plate_raw);
let (sample, _n_sample) = dense_str(&sample_raw);
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 1 }, slopes: vec![],
extra_groupings: vec![Grouping {
relation: GroupingRelation::Crossed { n_clusters: 1 }, slopes: vec![],
}],
}),
};
let ids = GroupIds {
primary: plate,
extra: vec![sample],
};
let f = fit_cold(
&x,
&y,
n,
p,
&model,
&ids,
&FitOptions {
target_indices: vec![0],
..FitOptions::default()
},
);
assert!(f.converged(), "Penicillin crossed LMM must converge");
assert!(
(f.beta[0] - REF_BETA).abs() / REF_BETA < 1e-4,
"β0 = {} vs lme4 {REF_BETA}",
f.beta[0]
);
let se_rel = (f.se[0] - REF_SE).abs() / REF_SE;
assert!(
se_rel < 2e-2,
"se0 = {} vs lme4 {REF_SE} (rel {se_rel})",
f.se[0]
);
let plate_sd = f.tau2[0].sqrt();
let sample_sd = f.tau2[1].sqrt();
assert!(
(plate_sd - REF_PLATE_SD).abs() / REF_PLATE_SD < 5e-3,
"plate sd = {plate_sd} vs lme4 {REF_PLATE_SD}"
);
assert!(
(sample_sd - REF_SAMPLE_SD).abs() / REF_SAMPLE_SD < 5e-3,
"sample sd = {sample_sd} vs lme4 {REF_SAMPLE_SD}"
);
}
#[test]
fn fit_pastes_nested_matches_lme4() {
const REF_BETA: f64 = 60.0533333333;
const REF_SE: f64 = 0.676870215074;
const REF_BATCH_SD: f64 = 1.287366;
const REF_CASK_SD: f64 = 2.904077;
let csv = include_str!("../../validation/data/empirical/Pastes.csv");
let mut y = Vec::<f64>::new();
let mut batch_raw = Vec::<String>::new();
let mut cask_raw = Vec::<String>::new();
for line in csv.lines().skip(1).filter(|l| !l.trim().is_empty()) {
let f: Vec<&str> = line.split(',').map(|s| s.trim_matches('"')).collect();
y.push(f[0].parse().unwrap()); batch_raw.push(f[1].to_string()); cask_raw.push(f[3].to_string()); }
let n = y.len();
let p = 1;
let x = vec![1.0f64; n];
let (batch, _n_batch) = dense_str(&batch_raw);
let (cask, _n_cask) = dense_str(&cask_raw);
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 1 }, slopes: vec![],
extra_groupings: vec![Grouping {
relation: GroupingRelation::NestedWithin { n_per_parent: 1 }, slopes: vec![],
}],
}),
};
let ids = GroupIds {
primary: batch,
extra: vec![cask],
};
let f = fit_cold(
&x,
&y,
n,
p,
&model,
&ids,
&FitOptions {
target_indices: vec![0],
..FitOptions::default()
},
);
assert!(f.converged(), "Pastes nested LMM must converge");
assert!(
(f.beta[0] - REF_BETA).abs() / REF_BETA < 1e-4,
"β0 = {} vs lme4 {REF_BETA}",
f.beta[0]
);
let se_rel = (f.se[0] - REF_SE).abs() / REF_SE;
assert!(
se_rel < 2e-2,
"se0 = {} vs lme4 {REF_SE} (rel {se_rel})",
f.se[0]
);
let batch_sd = f.tau2[0].sqrt();
let cask_sd = f.tau2[1].sqrt();
assert!(
(batch_sd - REF_BATCH_SD).abs() / REF_BATCH_SD < 1e-2,
"batch sd = {batch_sd} vs lme4 {REF_BATCH_SD}"
);
assert!(
(cask_sd - REF_CASK_SD).abs() / REF_CASK_SD < 5e-3,
"cask sd = {cask_sd} vs lme4 {REF_CASK_SD}"
);
}
#[test]
fn scalar_crossed_lmm_is_grouping_order_insensitive() {
const BIG: usize = 15;
const SMALL: usize = 4;
let n = 180usize;
let p = 2usize;
let mut st = 20_260_807u64;
let big_eff: Vec<f64> = (0..BIG).map(|_| 0.9 * lcg(&mut st)).collect();
let small_eff: Vec<f64> = (0..SMALL).map(|_| 0.3 * lcg(&mut st)).collect();
let mut x = vec![0.0f64; n * p];
let mut y = vec![0.0f64; n];
let mut big = vec![0u32; n];
let mut small = vec![0u32; n];
for i in 0..n {
big[i] = (i % BIG) as u32;
small[i] = ((i / BIG) % SMALL) as u32;
let cov = lcg(&mut st);
x[i * p] = 1.0;
x[i * p + 1] = cov;
y[i] = 0.7
+ 0.4 * cov
+ big_eff[big[i] as usize]
+ small_eff[small[i] as usize]
+ 0.25 * lcg(&mut st);
}
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 1 },
slopes: vec![],
extra_groupings: vec![Grouping {
relation: GroupingRelation::Crossed { n_clusters: 1 },
slopes: vec![],
}],
}),
};
let opts = FitOptions {
target_indices: vec![0, 1],
..FitOptions::default()
};
let fit_of = |primary: &[u32], extra: &[u32]| {
let ids = GroupIds {
primary: primary.to_vec(),
extra: vec![extra.to_vec()],
};
fit_cold(&x, &y, n, p, &model, &ids, &opts)
};
let a = fit_of(&big, &small);
let b = fit_of(&small, &big);
assert!(a.converged() && b.converged(), "both orders must converge");
assert_eq!(a.deviance.to_bits(), b.deviance.to_bits(), "deviance");
assert_eq!(a.n_eval, b.n_eval, "objective evaluations");
for j in 0..p {
assert_eq!(a.beta[j].to_bits(), b.beta[j].to_bits(), "beta[{j}]");
assert_eq!(a.se[j].to_bits(), b.se[j].to_bits(), "se[{j}]");
}
assert_eq!(a.ranef_levels, vec![BIG, SMALL], "a declares big first");
assert_eq!(b.ranef_levels, vec![SMALL, BIG], "b declares small first");
assert_eq!(a.varcorr.len(), 2, "one block per grouping");
assert_eq!(b.varcorr.len(), 2, "one block per grouping");
for (g, h) in [(0usize, 1usize), (1, 0)] {
assert_eq!(
a.varcorr[g].iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
b.varcorr[h].iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
"varcorr block for the same grouping (a[{g}] vs b[{h}])"
);
assert_eq!(
a.tau2[g].to_bits(),
b.tau2[h].to_bits(),
"tau2[{g}] vs tau2[{h}]"
);
}
assert_eq!(
a.diagnostics.pinned.len(),
b.diagnostics.pinned.len(),
"pinned block count"
);
for (g, h) in [(0usize, 1usize), (1, 0)] {
if let (Some(pa), Some(pb)) = (a.diagnostics.pinned.get(g), b.diagnostics.pinned.get(h)) {
assert_eq!(pa, pb, "pinned[{g}] vs pinned[{h}]");
}
}
assert_eq!(a.ranef.len(), BIG + SMALL, "one mode per level");
let bits = |v: &[f64]| v.iter().map(|x| x.to_bits()).collect::<Vec<_>>();
assert_eq!(bits(&a.ranef[..BIG]), bits(&b.ranef[SMALL..]), "big modes");
assert_eq!(
bits(&a.ranef[BIG..]),
bits(&b.ranef[..SMALL]),
"small modes"
);
for i in 0..n {
assert_eq!(a.fitted[i].to_bits(), b.fitted[i].to_bits(), "fitted[{i}]");
}
}
#[cfg(feature = "loop_advanced")]
fn assert_sweep_outcomes_bit_equal(a: &LmmSweepOutcome, b: &LmmSweepOutcome, label: &str) {
assert_eq!(
a.deviance.to_bits(),
b.deviance.to_bits(),
"{label}: deviance mismatch ({} vs {})",
a.deviance,
b.deviance
);
assert_eq!(
a.theta.len(),
b.theta.len(),
"{label}: theta length mismatch"
);
for (i, (x, y)) in a.theta.iter().zip(&b.theta).enumerate() {
assert_eq!(
x.to_bits(),
y.to_bits(),
"{label}: theta[{i}] mismatch ({x} vs {y})"
);
}
assert_eq!(a.n_eval, b.n_eval, "{label}: n_eval mismatch");
assert_eq!(a.converged, b.converged, "{label}: converged mismatch");
}
#[cfg(feature = "loop_advanced")]
#[test]
fn lmm_sweep_fit_on_matches_lmm_sweep_fit_dense() {
let (x, y, n, p) = lmm_hand_dataset();
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 6 },
slopes: vec![],
extra_groupings: vec![],
}),
};
let ids = GroupIds::from_sizing(model.re.as_ref().unwrap(), n);
assert!(matches!(classify_design(&model, 1), Solver::NoZ));
let (mut ws, g) = build_lmm_seam_ws(&x, &y, n, p, &model, &ids);
let (blind, lower, upper) = g.blind_theta_and_bounds();
let theta_a = blind.clone();
let theta_b: Vec<f64> = lower
.iter()
.zip(&upper)
.map(|(&lo, &hi)| lo + 0.25 * (hi - lo))
.collect();
let on_a = lmm_sweep_fit_on(&mut ws, &g, Some(&theta_a), 1e-6, None, None);
let on_b = lmm_sweep_fit_on(&mut ws, &g, Some(&theta_b), 1e-6, None, None);
let standalone_a = lmm_sweep_fit(&x, &y, n, p, &model, &ids, Some(&theta_a), 1e-6, None, None);
let standalone_b = lmm_sweep_fit(&x, &y, n, p, &model, &ids, Some(&theta_b), 1e-6, None, None);
assert_sweep_outcomes_bit_equal(&on_a, &standalone_a, "dense theta_a");
assert_sweep_outcomes_bit_equal(&on_b, &standalone_b, "dense theta_b");
}
#[cfg(feature = "loop_advanced")]
#[test]
fn lmm_sweep_fit_on_matches_lmm_sweep_fit_sparse() {
let n = 32usize;
let p = 3usize;
let mut st = 7u64;
let mut x = vec![0.0f64; n * p];
let mut y = vec![0.0f64; n];
let mut primary = vec![0u32; n];
let mut extra = vec![0u32; n];
for i in 0..n {
let x1 = lcg(&mut st);
let x2 = lcg(&mut st);
x[i * p] = 1.0;
x[i * p + 1] = x1;
x[i * p + 2] = x2;
primary[i] = (i % 4) as u32;
extra[i] = ((i / 4) % 4) as u32;
y[i] = 0.5 + 0.4 * x1 - 0.2 * x2 + 0.3 * lcg(&mut st);
}
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 4 },
slopes: vec![],
extra_groupings: vec![Grouping {
relation: GroupingRelation::Crossed { n_clusters: 4 },
slopes: vec![1],
}],
}),
};
let ids = GroupIds {
primary,
extra: vec![extra],
};
assert!(matches!(classify_design(&model, 1), Solver::Sparse));
let (mut ws, g) = build_lmm_seam_ws(&x, &y, n, p, &model, &ids);
let (blind, lower, upper) = g.blind_theta_and_bounds();
let theta_a = blind.clone();
let theta_b: Vec<f64> = lower
.iter()
.zip(&upper)
.map(|(&lo, &hi)| lo + 0.25 * (hi - lo))
.collect();
let on_a = lmm_sweep_fit_on(&mut ws, &g, Some(&theta_a), 1e-6, None, None);
let on_b = lmm_sweep_fit_on(&mut ws, &g, Some(&theta_b), 1e-6, None, None);
let standalone_a = lmm_sweep_fit(&x, &y, n, p, &model, &ids, Some(&theta_a), 1e-6, None, None);
let standalone_b = lmm_sweep_fit(&x, &y, n, p, &model, &ids, Some(&theta_b), 1e-6, None, None);
assert_sweep_outcomes_bit_equal(&on_a, &standalone_a, "sparse theta_a");
assert_sweep_outcomes_bit_equal(&on_b, &standalone_b, "sparse theta_b");
}
#[cfg(feature = "loop_advanced")]
#[test]
fn lmm_objective_at_matches_lmm_sweep_fit_deviance_dense() {
let (x, y, n, p) = lmm_hand_dataset();
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 6 },
slopes: vec![],
extra_groupings: vec![],
}),
};
let ids = GroupIds::from_sizing(model.re.as_ref().unwrap(), n);
assert!(matches!(classify_design(&model, 1), Solver::NoZ));
let outcome = lmm_sweep_fit(&x, &y, n, p, &model, &ids, None, 1e-6, None, None);
assert!(outcome.converged, "dense sweep fit must converge");
let obj = lmm_objective_at(&x, &y, n, p, &model, &ids, &outcome.theta);
let rel = (obj - outcome.deviance).abs() / outcome.deviance.abs();
assert!(
rel < 1e-10,
"dense: lmm_objective_at {obj} vs sweep deviance {} (rel {rel})",
outcome.deviance
);
}
#[cfg(feature = "loop_advanced")]
#[test]
fn lmm_objective_at_matches_lmm_sweep_fit_deviance_sparse() {
let n = 32usize;
let p = 3usize;
let mut st = 7u64;
let mut x = vec![0.0f64; n * p];
let mut y = vec![0.0f64; n];
let mut primary = vec![0u32; n];
let mut extra = vec![0u32; n];
for i in 0..n {
let x1 = lcg(&mut st);
let x2 = lcg(&mut st);
x[i * p] = 1.0;
x[i * p + 1] = x1;
x[i * p + 2] = x2;
primary[i] = (i % 4) as u32;
extra[i] = ((i / 4) % 4) as u32;
y[i] = 0.5 + 0.4 * x1 - 0.2 * x2 + 0.3 * lcg(&mut st);
}
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 4 },
slopes: vec![],
extra_groupings: vec![Grouping {
relation: GroupingRelation::Crossed { n_clusters: 4 },
slopes: vec![1],
}],
}),
};
let ids = GroupIds {
primary,
extra: vec![extra],
};
assert!(matches!(classify_design(&model, 1), Solver::Sparse));
let outcome = lmm_sweep_fit(&x, &y, n, p, &model, &ids, None, 1e-6, None, None);
assert!(outcome.converged, "sparse sweep fit must converge");
let obj = lmm_objective_at(&x, &y, n, p, &model, &ids, &outcome.theta);
let rel = (obj - outcome.deviance).abs() / outcome.deviance.abs();
assert!(
rel < 1e-10,
"sparse: lmm_objective_at {obj} vs sweep deviance {} (rel {rel})",
outcome.deviance
);
}
#[cfg(feature = "loop_advanced")]
#[test]
fn refit_lmm_matches_fresh_fit_cold() {
let n = 48usize;
let p = 3usize;
let n_clusters = 6usize;
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters {
n_clusters: n_clusters as u32,
},
slopes: vec![],
extra_groupings: vec![],
}),
};
let ids = GroupIds::from_sizing(model.re.as_ref().unwrap(), n);
let dataset = |seed: u64| -> (Vec<f64>, Vec<f64>) {
let mut st = seed;
let u_c: Vec<f64> = (0..n_clusters).map(|_| 0.6 * lcg(&mut st)).collect();
let mut x = vec![0.0f64; n * p];
let mut y = vec![0.0f64; n];
for i in 0..n {
let c = i % n_clusters;
let x1 = lcg(&mut st);
let x2 = lcg(&mut st);
x[i * p] = 1.0;
x[i * p + 1] = x1;
x[i * p + 2] = x2;
y[i] = 0.5 + 0.4 * x1 - 0.2 * x2 + u_c[c] + 0.8 * lcg(&mut st);
}
(x, y)
};
let (xa, ya) = dataset(42);
let (xb, yb) = dataset(99);
let wb: Vec<f64> = (0..n).map(|i| 1.0 + (i % 3) as f64 * 0.5).collect();
let opts_a = FitOptions {
target_indices: vec![1, 2],
..FitOptions::default()
};
let opts_b = FitOptions {
target_indices: vec![1, 2],
weights: Some(wb.clone()),
..FitOptions::default()
};
let mut ws = build_lmm_workspace(p, &model, n);
let refit_a = refit_lmm(&mut ws, &xa, &ya, n, p, &ids, &opts_a, None);
let refit_b = refit_lmm(&mut ws, &xb, &yb, n, p, &ids, &opts_b, None);
let cold_a = fit_cold(&xa, &ya, n, p, &model, &ids, &opts_a);
let cold_b = fit_cold(&xb, &yb, n, p, &model, &ids, &opts_b);
assert!(
cold_a.converged() && cold_b.converged(),
"oracle fits must converge"
);
let bits = |v: &[f64]| v.iter().map(|x| x.to_bits()).collect::<Vec<_>>();
for (label, refit, cold) in [
("A (unweighted)", &refit_a, &cold_a),
("B (weighted)", &refit_b, &cold_b),
] {
assert_eq!(refit.converged(), cold.converged(), "{label}: converged");
assert_eq!(bits(&refit.beta), bits(&cold.beta), "{label}: beta");
assert_eq!(bits(&refit.se), bits(&cold.se), "{label}: se");
assert_eq!(bits(&refit.tau2), bits(&cold.tau2), "{label}: tau2");
assert_eq!(
refit.varcorr.len(),
cold.varcorr.len(),
"{label}: varcorr len"
);
for (a, b) in refit.varcorr.iter().zip(&cold.varcorr) {
assert_eq!(bits(a), bits(b), "{label}: varcorr block");
}
assert_eq!(
refit.deviance.to_bits(),
cold.deviance.to_bits(),
"{label}: deviance"
);
assert_eq!(refit.n_eval, cold.n_eval, "{label}: n_eval");
assert_eq!(refit.singular(), cold.singular(), "{label}: singular");
}
}
fn sleepstudy_slope_design() -> (Vec<f64>, Vec<f64>, usize, usize, ModelSpec, GroupIds) {
let csv = include_str!("../../validation/data/empirical/sleepstudy.csv");
let mut y = Vec::<f64>::new();
let mut days = Vec::<f64>::new();
let mut subj_raw = Vec::<String>::new();
for line in csv.lines().skip(1).filter(|l| !l.trim().is_empty()) {
let f: Vec<&str> = line.split(',').map(|s| s.trim_matches('"')).collect();
y.push(f[0].parse().unwrap()); days.push(f[1].parse().unwrap()); subj_raw.push(f[2].to_string()); }
let n = y.len();
let p = 2;
let mut x = vec![0.0f64; n * p];
for i in 0..n {
x[i * p] = 1.0;
x[i * p + 1] = days[i];
}
let (subject, _n_subj) = dense_str(&subj_raw);
let model = ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 1 }, slopes: vec![1], extra_groupings: vec![],
}),
};
let ids = GroupIds {
primary: subject,
extra: vec![],
};
(x, y, n, p, model, ids)
}
#[test]
fn lmm_rescaling_slope_column_moves_every_quantity_by_the_predicted_power_of_c() {
const C: f64 = 1024.0;
const BAND: f64 = 3e-6;
const DEV_ABS: f64 = 1e-10;
let (x, y, n, p, model, ids) = sleepstudy_slope_design();
let opts = FitOptions {
target_indices: vec![0, 1],
..FitOptions::default()
};
let base = fit_cold(&x, &y, n, p, &model, &ids, &opts);
assert!(base.converged(), "base sleepstudy slope LMM must converge");
let mut x_c = x.clone();
for i in 0..n {
x_c[i * p + 1] *= C;
}
let scaled = fit_cold(&x_c, &y, n, p, &model, &ids, &opts);
assert!(scaled.converged(), "column-scaled fit must converge");
assert!(
!base.singular() && !scaled.singular(),
"neither fit is degenerate: base singular {} scaled singular {}",
base.singular(),
scaled.singular()
);
assert_pinned(&[scaled.beta[0]], &[base.beta[0]], BAND, "beta[0]");
assert_pinned(&[scaled.beta[1]], &[base.beta[1] / C], BAND, "beta[1]");
assert_pinned(&[scaled.se[0]], &[base.se[0]], BAND, "se[0]");
assert_pinned(&[scaled.se[1]], &[base.se[1] / C], BAND, "se[1]");
assert_pinned(&[scaled.vcov[0][0]], &[base.vcov[0][0]], BAND, "vcov[0][0]");
assert_pinned(
&[scaled.vcov[1][1]],
&[base.vcov[1][1] / (C * C)],
BAND,
"vcov[1][1]",
);
assert_pinned(
&[scaled.vcov[0][1]],
&[base.vcov[0][1] / C],
BAND,
"vcov[0][1]",
);
assert_pinned(
&[scaled.vcov[1][0]],
&[base.vcov[1][0] / C],
BAND,
"vcov[1][0]",
);
assert_eq!(scaled.varcorr.len(), 1, "one grouping block");
assert_pinned(
&scaled.varcorr[0],
&[
base.varcorr[0][0],
base.varcorr[0][1] / C,
base.varcorr[0][2] / (C * C),
],
BAND,
"varcorr vech",
);
assert_pinned(
&scaled.tau2,
&[base.tau2[0], base.tau2[1] / (C * C), base.tau2[2] / (C * C)],
BAND,
"tau2",
);
assert_eq!(scaled.ranef.len(), base.ranef.len());
assert_eq!(scaled.ranef_levels, base.ranef_levels);
let n_levels = scaled.ranef_levels[0];
let mut want_ranef = Vec::with_capacity(scaled.ranef.len());
for l in 0..n_levels {
want_ranef.push(base.ranef[l * 2]);
want_ranef.push(base.ranef[l * 2 + 1] / C);
}
assert_pinned(&scaled.ranef, &want_ranef, BAND, "ranef");
assert_eq!(scaled.fitted.len(), base.fitted.len());
assert_pinned(&scaled.fitted, &base.fitted, BAND, "fitted");
assert_pinned(&[scaled.dispersion], &[base.dispersion], BAND, "dispersion");
let dev_shift = scaled.deviance - base.deviance;
let expected_dev_shift = 2.0 * C.ln();
assert!(
(dev_shift - expected_dev_shift).abs() < DEV_ABS,
"deviance shift {dev_shift} vs predicted {expected_dev_shift}"
);
let loglik_shift = scaled.loglik - base.loglik;
let expected_loglik_shift = -C.ln();
assert!(
(loglik_shift - expected_loglik_shift).abs() < DEV_ABS,
"loglik shift {loglik_shift} vs predicted {expected_loglik_shift}"
);
}
#[test]
fn lmm_warm_start_theta_is_a_fixed_point_of_the_forward_map() {
const BAND: f64 = 1e-9;
let (x, y, n, p, model, ids) = sleepstudy_slope_design();
let opts = FitOptions {
target_indices: vec![0, 1],
..FitOptions::default()
};
let (sized, sized_ids, perm) = spec_sized_from_ids_pub(&model, &ids);
let mut ws = build_workspace(&sized, perm, n, p, &opts);
let cold_view = fit_on(&mut ws, &x, &y, &sized_ids, None, &opts);
let cold_theta = cold_view.theta().to_vec();
let cold_n_eval = cold_view.n_eval();
let cold_fit = cold_view.into_fit(&x, &y, &sized_ids, n, p, &model, &opts);
assert!(
cold_fit.converged(),
"cold sleepstudy slope LMM must converge"
);
let start = StartValues {
beta: cold_fit.beta.clone(),
theta: cold_theta.clone(),
};
let warm_view = fit_on(&mut ws, &x, &y, &sized_ids, Some(&start), &opts);
let warm_theta = warm_view.theta().to_vec();
let warm_n_eval = warm_view.n_eval();
let warm_fit = warm_view.into_fit(&x, &y, &sized_ids, n, p, &model, &opts);
assert!(warm_fit.converged(), "warm-started fit must converge");
assert_pinned(&warm_theta, &cold_theta, BAND, "theta fixed point");
assert!(
(warm_fit.deviance - cold_fit.deviance).abs() < 1e-10,
"deviance moved under a fixed-point warm start: {} vs {}",
warm_fit.deviance,
cold_fit.deviance
);
assert!(
warm_n_eval < cold_n_eval,
"warm start at the true optimum must need fewer evals than the blind \
cold start: warm {warm_n_eval} vs cold {cold_n_eval}"
);
}
#[test]
fn rms_column_scale_is_exactly_one_on_a_constant_column() {
use crate::lmm::rms_column_scale;
let n = 7;
let x = faer::Mat::<f64>::from_fn(n, 1, |_, _| 1.0);
assert_eq!(rms_column_scale(x.as_ref(), 0, None), 1.0);
let w: Vec<f64> = (0..n).map(|i| 0.3 + 1.7 * (i as f64)).collect();
assert_eq!(rms_column_scale(x.as_ref(), 0, Some(&w)), 1.0);
}
#[test]
fn theta_row_scales_reads_off_the_hand_built_grouping() {
use crate::lmm::{CrossedFactor, LmmGroupings};
const S_P: f64 = 3.5;
const S_E: f64 = 0.25;
let mut g = LmmGroupings::from_cluster_spec_ext(
&ModelSpec {
family: Family::Gaussian,
re: Some(ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 4 },
slopes: vec![1], extra_groupings: vec![Grouping {
relation: GroupingRelation::Crossed { n_clusters: 3 },
slopes: vec![2], }],
}),
},
4, &[1],
&[vec![2]],
);
assert_eq!(g.primary_q, 2, "primary block must be q_p = 2");
assert_eq!(g.extra_q, vec![2], "extra block must be q_g = 2");
assert_eq!(
g.crossed,
vec![CrossedFactor {
vech_start: 3, q: 2,
n_levels: 3,
decl: 0,
}]
);
g.primary_slope_scales = vec![S_P];
g.extra_slope_scales = vec![vec![S_E]];
assert_eq!(
g.theta_row_scales(),
vec![1.0, S_P, S_P, 1.0, S_E, S_E],
"column-major vech: [primary (0,0),(1,0),(1,1)] then [extra (0,0),(1,0),(1,1)]"
);
}