use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RobustCovarianceMode {
ModelBased,
Sandwich,
}
impl RobustCovarianceMode {
pub fn label(self) -> &'static str {
match self {
RobustCovarianceMode::ModelBased => "model-based (A⁻¹)",
RobustCovarianceMode::Sandwich => "sandwich (A⁻¹ J A⁻¹)",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct CompositeLikelihoodCharge {
pub model_based_dof: f64,
pub clic_dof: f64,
}
impl CompositeLikelihoodCharge {
pub fn misspecification_ratio(&self) -> f64 {
if self.model_based_dof.abs() > f64::MIN_POSITIVE {
self.clic_dof / self.model_based_dof
} else {
f64::NAN
}
}
}
pub(crate) fn godambe_sandwich_covariance(
bread: ArrayView2<'_, f64>,
meat: ArrayView2<'_, f64>,
) -> Result<Array2<f64>, String> {
let m = bread.nrows();
if bread.ncols() != m {
return Err(format!(
"godambe_sandwich_covariance: bread must be square, got {:?}",
bread.dim()
));
}
if meat.dim() != (m, m) {
return Err(format!(
"godambe_sandwich_covariance: meat {:?} must match bread ({m},{m})",
meat.dim()
));
}
let sandwich = bread.dot(&meat).dot(&bread);
let mut out = Array2::<f64>::zeros((m, m));
for i in 0..m {
for j in 0..m {
out[[i, j]] = 0.5 * (sandwich[[i, j]] + sandwich[[j, i]]);
}
}
if out.iter().any(|v| !v.is_finite()) {
return Err("godambe_sandwich_covariance: non-finite sandwich entry".to_string());
}
Ok(out)
}
pub(crate) fn clic_effective_dof(
bread: ArrayView2<'_, f64>,
meat: ArrayView2<'_, f64>,
) -> Result<f64, String> {
let m = bread.nrows();
if bread.ncols() != m || meat.dim() != (m, m) {
return Err(format!(
"clic_effective_dof: shape mismatch bread {:?} meat {:?}",
bread.dim(),
meat.dim()
));
}
let mut trace = 0.0_f64;
for i in 0..m {
for j in 0..m {
trace += meat[[i, j]] * bread[[j, i]];
}
}
if !trace.is_finite() {
return Err("clic_effective_dof: non-finite trace".to_string());
}
Ok(trace)
}
pub(crate) fn gaussian_within_channel_meat(
design: ArrayView2<'_, f64>,
residuals: ArrayView2<'_, f64>,
dispersion: f64,
) -> Result<Vec<Array2<f64>>, String> {
let (n, m) = design.dim();
let (n_r, p) = residuals.dim();
if n_r != n {
return Err(format!(
"gaussian_within_channel_meat: design has {n} rows but residuals have {n_r}"
));
}
if !(dispersion.is_finite() && dispersion > 0.0) {
return Err(format!(
"gaussian_within_channel_meat: dispersion must be finite and positive, got {dispersion}"
));
}
let inv_phi2 = 1.0 / (dispersion * dispersion);
let mut blocks = vec![Array2::<f64>::zeros((m, m)); p];
for i in 0..n {
let g = design.row(i);
for c in 0..p {
let r = residuals[[i, c]];
if r == 0.0 {
continue;
}
let w = inv_phi2 * r * r;
let block = &mut blocks[c];
for a in 0..m {
let ga = g[a];
if ga == 0.0 {
continue;
}
let wga = w * ga;
for b in 0..m {
block[[a, b]] += wga * g[b];
}
}
}
}
Ok(blocks)
}
pub(crate) fn gaussian_within_channel_expected_meat(
design: ArrayView2<'_, f64>,
dispersion: f64,
) -> Result<Array2<f64>, String> {
let (n, m) = design.dim();
if !(dispersion.is_finite() && dispersion > 0.0) {
return Err(format!(
"gaussian_within_channel_expected_meat: dispersion must be finite and positive, \
got {dispersion}"
));
}
let inv_phi = 1.0 / dispersion;
let mut gram = Array2::<f64>::zeros((m, m));
for i in 0..n {
let g = design.row(i);
for a in 0..m {
let ga = g[a];
if ga == 0.0 {
continue;
}
for b in 0..m {
gram[[a, b]] += ga * g[b];
}
}
}
gram.mapv_inplace(|v| v * inv_phi);
Ok(gram)
}
pub(crate) fn robust_channel_band_variance(
bread_c: ArrayView2<'_, f64>,
meat_c: ArrayView2<'_, f64>,
phi_t: ArrayView1<'_, f64>,
) -> Result<f64, String> {
let sandwich = godambe_sandwich_covariance(bread_c, meat_c)?;
let m = sandwich.nrows();
if phi_t.len() != m {
return Err(format!(
"robust_channel_band_variance: basis row len {} != block dim {m}",
phi_t.len()
));
}
let sphi = sandwich.dot(&phi_t);
let var: f64 = phi_t.iter().zip(sphi.iter()).map(|(a, b)| a * b).sum();
Ok(var.max(0.0))
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::{Array1, Array2};
use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
fn spd_bread(m: usize, seed: u64) -> Array2<f64> {
let mut rng = StdRng::seed_from_u64(seed);
let mut d = Array2::<f64>::zeros((m, m));
for v in d.iter_mut() {
*v = rng.random_range(-1.0..1.0);
}
let mut a = d.t().dot(&d);
for i in 0..m {
a[[i, i]] += 1.0;
}
invert(&a)
}
fn invert(a: &Array2<f64>) -> Array2<f64> {
let m = a.nrows();
let mut aug = Array2::<f64>::zeros((m, 2 * m));
for i in 0..m {
for j in 0..m {
aug[[i, j]] = a[[i, j]];
}
aug[[i, m + i]] = 1.0;
}
for col in 0..m {
let mut piv = col;
for r in col + 1..m {
if aug[[r, col]].abs() > aug[[piv, col]].abs() {
piv = r;
}
}
if piv != col {
for j in 0..2 * m {
aug.swap([col, j], [piv, j]);
}
}
let d = aug[[col, col]];
for j in 0..2 * m {
aug[[col, j]] /= d;
}
for r in 0..m {
if r == col {
continue;
}
let f = aug[[r, col]];
if f != 0.0 {
for j in 0..2 * m {
aug[[r, j]] -= f * aug[[col, j]];
}
}
}
}
let mut inv = Array2::<f64>::zeros((m, m));
for i in 0..m {
for j in 0..m {
inv[[i, j]] = aug[[i, m + j]];
}
}
inv
}
#[test]
fn sandwich_equals_model_based_under_information_equality() {
let m = 5;
let a_inv = spd_bread(m, 7);
let a = invert(&a_inv); let sw = godambe_sandwich_covariance(a_inv.view(), a.view()).unwrap();
for i in 0..m {
for j in 0..m {
assert!(
(sw[[i, j]] - a_inv[[i, j]]).abs() < 1e-9,
"sandwich must reduce to model-based when J=A: [{i},{j}] {} vs {}",
sw[[i, j]],
a_inv[[i, j]]
);
}
}
let dof = clic_effective_dof(a_inv.view(), a.view()).unwrap();
assert!(
(dof - m as f64).abs() < 1e-9,
"CLIC dof must equal dim under J=A, got {dof}"
);
}
#[test]
fn sandwich_inflates_under_heteroskedastic_residuals() {
let n = 200;
let mut rng = StdRng::seed_from_u64(11);
let design = Array2::<f64>::ones((n, 1));
let mut residuals = Array2::<f64>::zeros((n, 1));
let mut sum_r2 = 0.0;
for i in 0..n {
let scale = if i % 2 == 0 { 1.0 } else { 5.0 };
let r = scale * rng.random_range(-1.0..1.0);
residuals[[i, 0]] = r;
sum_r2 += r * r;
}
let phi = sum_r2 / n as f64;
let bread = Array2::from_elem((1, 1), phi / n as f64);
let meat = gaussian_within_channel_meat(design.view(), residuals.view(), phi).unwrap();
let sw = godambe_sandwich_covariance(bread.view(), meat[0].view()).unwrap();
let robust_var = sw[[0, 0]];
let white = sum_r2 / (n as f64 * n as f64);
let model_based = bread[[0, 0]]; println!(
"[sandwich/heteroskedastic] model_based_var={model_based:.6} sandwich_var={robust_var:.6} white_ref={white:.6}"
);
assert!(
(robust_var - white).abs() < 1e-9 * white.max(1.0),
"sandwich mean-variance {robust_var} must equal White {white}"
);
assert!(robust_var > 0.0 && robust_var.is_finite());
}
#[test]
fn clic_dof_moves_with_misspecification() {
let m = 4;
let a_inv = spd_bread(m, 3);
let a = invert(&a_inv);
for &c in &[0.5_f64, 2.0, 3.5] {
let meat = a.mapv(|v| c * v);
let dof = clic_effective_dof(a_inv.view(), meat.view()).unwrap();
assert!(
(dof - c * m as f64).abs() < 1e-8,
"CLIC dof under J={c}·A must be {}·{m}, got {dof}",
c
);
}
}
#[test]
fn clic_and_model_based_dof_coincide_under_homoskedastic_residuals() {
let n = 4000;
let m = 3;
let mut rng = StdRng::seed_from_u64(123);
let mut design = Array2::<f64>::zeros((n, m));
for v in design.iter_mut() {
*v = rng.random_range(-1.0..1.0);
}
let phi = 0.7_f64;
let sd = phi.sqrt();
let mut residuals = Array2::<f64>::zeros((n, 1));
for i in 0..n {
let u = rng.random_range(-1.0..1.0); residuals[[i, 0]] = u * sd * (3.0_f64).sqrt();
}
let f = gaussian_within_channel_expected_meat(design.view(), phi).unwrap();
let a_inv = invert(&f);
let model_dof = clic_effective_dof(a_inv.view(), f.view()).unwrap();
assert!(
(model_dof - m as f64).abs() < 1e-8,
"model-based dof tr(F A⁻¹) must equal m={m}, got {model_dof}"
);
let meat = gaussian_within_channel_meat(design.view(), residuals.view(), phi).unwrap();
let clic = clic_effective_dof(a_inv.view(), meat[0].view()).unwrap();
assert!(
(clic - m as f64).abs() < 0.3,
"CLIC dof should be ≈ m={m} under homoskedastic residuals, got {clic}"
);
}
#[test]
fn clic_dof_exceeds_model_based_under_overdispersion() {
let n = 3000;
let m = 3;
let mut rng = StdRng::seed_from_u64(7);
let mut design = Array2::<f64>::zeros((n, m));
for v in design.iter_mut() {
*v = rng.random_range(-1.0..1.0);
}
let phi = 1.0_f64;
let s = 2.5_f64;
let mut residuals = Array2::<f64>::zeros((n, 1));
for i in 0..n {
residuals[[i, 0]] = s * rng.random_range(-1.0..1.0) * (3.0_f64).sqrt();
}
let f = gaussian_within_channel_expected_meat(design.view(), phi).unwrap();
let a_inv = invert(&f);
let model_dof = clic_effective_dof(a_inv.view(), f.view()).unwrap();
let meat = gaussian_within_channel_meat(design.view(), residuals.view(), phi).unwrap();
let clic = clic_effective_dof(a_inv.view(), meat[0].view()).unwrap();
println!(
"[clic/overdispersion s={s}] model_based_dof={model_dof:.4} clic_dof={clic:.4} ratio={:.4} (expected≈s²={:.4})",
clic / model_dof,
s * s
);
assert!(
clic > model_dof * 1.5,
"CLIC dof {clic} must clearly exceed model-based dof {model_dof} under s={s} overdispersion"
);
}
#[test]
fn robust_band_variance_matches_manual_quadratic_form() {
let m = 3;
let bread = spd_bread(m, 21);
let mut meat = Array2::<f64>::zeros((m, m));
let g = Array1::from(vec![0.3, -1.1, 0.7]);
for a in 0..m {
for b in 0..m {
meat[[a, b]] = 2.0 * g[a] * g[b];
}
}
let phi_t = Array1::from(vec![1.0, 0.5, -0.25]);
let var = robust_channel_band_variance(bread.view(), meat.view(), phi_t.view()).unwrap();
let sw = godambe_sandwich_covariance(bread.view(), meat.view()).unwrap();
let manual: f64 = phi_t.dot(&sw.dot(&phi_t));
assert!((var - manual.max(0.0)).abs() < 1e-12);
}
}