use crate::error::{Error, Result};
use crate::special::ln_gamma;
use crate::tests_stat::parametric::floor_to_i64;
use crate::tests_stat::{TestResult, chi_squared_upper_log_tail, chi_squared_upper_tail};
pub fn mcnemar(table: &[Vec<f64>], exact: bool, correction: bool) -> Result<TestResult> {
let (b, c) = discordant(table)?;
let total = b + c;
if total == 0.0 {
return Ok(TestResult {
statistic: f64::NAN,
p_value: 1.0,
log_p_value: if exact { None } else { Some(0.0) },
df: if exact { None } else { Some(1.0) },
effect_size: None,
});
}
if exact {
let p_value = exact_binomial_two_sided(b, c);
return Ok(TestResult {
statistic: f64::NAN,
p_value,
log_p_value: None,
df: None,
effect_size: None,
});
}
let diff = (b - c).abs();
let numerator = if correction {
let adj = (diff - 1.0).max(0.0);
adj * adj
} else {
diff * diff
};
let statistic = numerator / total;
let p_value = chi_squared_upper_tail(statistic, 1);
Ok(TestResult {
statistic,
p_value,
log_p_value: Some(chi_squared_upper_log_tail(statistic, 1)),
df: Some(1.0),
effect_size: None,
})
}
fn discordant(table: &[Vec<f64>]) -> Result<(f64, f64)> {
let bad = || Error::InvalidInput("McNemar requires a 2x2 table".to_owned());
if table.len() != 2 {
return Err(bad());
}
let row0 = table.first().ok_or_else(bad)?;
let row1 = table.get(1).ok_or_else(bad)?;
if row0.len() != 2 || row1.len() != 2 {
return Err(bad());
}
let b = *row0.get(1).ok_or_else(bad)?;
let c = *row1.first().ok_or_else(bad)?;
if !b.is_finite() || !c.is_finite() || b < 0.0 || c < 0.0 {
return Err(Error::InvalidInput(
"counts must be finite and non-negative".to_owned(),
));
}
Ok((b, c))
}
fn exact_binomial_two_sided(b: f64, c: f64) -> f64 {
let n = b + c;
let smaller = floor_to_i64(b.min(c));
let n_i = floor_to_i64(n);
let ln_half_n = n * 0.5f64.ln();
let mut tail = 0.0;
for k in 0..=smaller {
tail += (log_choose(n_i, k) + ln_half_n).exp();
}
(2.0 * tail).min(1.0)
}
fn log_choose(n: i64, k: i64) -> f64 {
if k < 0 || k > n {
return f64::NEG_INFINITY;
}
let nf = f64::from(i32::try_from(n).unwrap_or(i32::MAX));
let kf = f64::from(i32::try_from(k).unwrap_or(i32::MAX));
ln_gamma(nf + 1.0) - ln_gamma(kf + 1.0) - ln_gamma(nf - kf + 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn equal_discordant_is_zero_statistic() -> Result<()> {
let table = vec![vec![5.0, 7.0], vec![7.0, 5.0]];
let r = mcnemar(&table, false, false)?;
assert!(r.statistic.abs() < 1e-12, "statistic was {}", r.statistic);
assert!((r.p_value - 1.0).abs() < 1e-9, "p was {}", r.p_value);
Ok(())
}
#[test]
fn non_2x2_is_invalid() {
let table = vec![vec![1.0, 2.0, 3.0]];
assert!(matches!(
mcnemar(&table, false, false),
Err(Error::InvalidInput(_))
));
}
}