use crate::errors::EdgeErrors;
use crate::numeric::dist::chisq_sf;
use crate::numeric::stats::p_adjust_bh;
const WALD_DF: f64 = 1.0;
#[derive(Clone, Debug)]
pub enum ScTested {
Coef(usize),
Contrast(Vec<f64>),
}
#[derive(Clone, Debug)]
pub struct ScTestResult {
pub log_fc: Vec<f64>,
pub se: Vec<f64>,
pub z: Vec<f64>,
pub p_value: Vec<f64>,
pub fdr: Vec<f64>,
}
#[inline]
pub const fn packed_len(n_coef: usize) -> usize {
n_coef * (n_coef + 1) / 2
}
#[inline]
fn packed_get(packed: &[f64], i: usize, j: usize) -> f64 {
let (lo, hi) = if i <= j { (i, j) } else { (j, i) };
packed[hi * (hi + 1) / 2 + lo]
}
pub fn glm_sc_test(
coefficients: &[f64],
covariance: &[f64],
n_genes: usize,
n_coef: usize,
tested: &ScTested,
) -> Result<ScTestResult, EdgeErrors> {
if n_genes == 0 || n_coef == 0 {
return Err(EdgeErrors::MustBePositive("fit dimensions".to_string()));
}
if coefficients.len() != n_genes * n_coef {
return Err(EdgeErrors::LengthMismatch {
name: "coefficients",
expected: n_genes * n_coef,
got: coefficients.len(),
});
}
let packed = packed_len(n_coef);
if covariance.len() != n_genes * packed {
return Err(EdgeErrors::LengthMismatch {
name: "covariance",
expected: n_genes * packed,
got: covariance.len(),
});
}
let weights: Vec<f64> = match tested {
ScTested::Coef(index) => {
if *index >= n_coef {
return Err(EdgeErrors::CoefOutOfRange {
index: *index,
n_coef,
});
}
let mut w = vec![0.0; n_coef];
w[*index] = 1.0;
w
}
ScTested::Contrast(values) => {
if values.len() != n_coef {
return Err(EdgeErrors::LengthMismatch {
name: "contrast",
expected: n_coef,
got: values.len(),
});
}
if values.iter().all(|v| *v == 0.0) {
return Err(EdgeErrors::InvalidArgument(
"contrast is entirely zero".to_string(),
));
}
values.clone()
}
};
let mut log_fc = Vec::with_capacity(n_genes);
let mut se = Vec::with_capacity(n_genes);
let mut z = Vec::with_capacity(n_genes);
let mut p_value = Vec::with_capacity(n_genes);
for gene in 0..n_genes {
let beta = &coefficients[gene * n_coef..(gene + 1) * n_coef];
let cov = &covariance[gene * packed..(gene + 1) * packed];
let effect: f64 = beta.iter().zip(weights.iter()).map(|(b, w)| b * w).sum();
let mut variance = 0.0;
for i in 0..n_coef {
if weights[i] == 0.0 {
continue;
}
for j in 0..n_coef {
if weights[j] == 0.0 {
continue;
}
variance += weights[i] * weights[j] * packed_get(cov, i, j);
}
}
let error = if variance > 0.0 { variance.sqrt() } else { 0.0 };
let statistic = if error > 0.0 { effect / error } else { 0.0 };
let p = if error > 0.0 {
chisq_sf(statistic * statistic, WALD_DF)?
} else {
1.0
};
log_fc.push(effect);
se.push(error);
z.push(statistic);
p_value.push(p);
}
let fdr = p_adjust_bh(&p_value);
Ok(ScTestResult {
log_fc,
se,
z,
p_value,
fdr,
})
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn fixture() -> (Vec<f64>, Vec<f64>) {
let coefficients = vec![
-3.625_170_366_404_492,
0.751_566_378_617_826, -5.046_180_663_686_06,
1.161_381_538_814_725,
];
let covariance = vec![
0.014_446_194_037_806_379,
-0.001_444_153_266_646_014,
0.057_784_776_151_225_514,
0.020_240_376_033_846_74,
-0.009_541_502_189_198_18,
0.080_961_504_135_386_98,
];
(coefficients, covariance)
}
#[test]
fn test_single_coefficient_matches_nebula() {
let (coefficients, covariance) = fixture();
let out = glm_sc_test(&coefficients, &covariance, 2, 2, &ScTested::Coef(1)).unwrap();
assert_relative_eq!(out.se[0], 0.240_384_642_086_855_28, max_relative = 1e-12);
assert_relative_eq!(out.se[1], 0.284_537_351_037_411_23, max_relative = 1e-12);
assert_relative_eq!(out.log_fc[0], 0.751_566_378_617_826, max_relative = 1e-12);
}
#[test]
fn test_contrast_uses_the_off_diagonal_term() {
let (coefficients, covariance) = fixture();
let contrast = vec![1.0, 1.0];
let out = glm_sc_test(
&coefficients,
&covariance,
2,
2,
&ScTested::Contrast(contrast),
)
.unwrap();
let expected: f64 = 0.014_446_194_037_806_379
+ 2.0 * -0.001_444_153_266_646_014
+ 0.057_784_776_151_225_514;
let expected = expected.sqrt();
assert_relative_eq!(out.se[0], expected, max_relative = 1e-12);
let diagonal_only = (0.014_446_194_037_806_379 + 0.057_784_776_151_225_514f64).sqrt();
let relative = (out.se[0] - diagonal_only).abs() / out.se[0];
assert!(
relative > 1e-3,
"the off-diagonal term should move the answer, moved {relative:e}"
);
assert!(diagonal_only > out.se[0]);
}
#[test]
fn test_unit_contrast_agrees_with_the_coefficient() {
let (coefficients, covariance) = fixture();
let direct = glm_sc_test(&coefficients, &covariance, 2, 2, &ScTested::Coef(1)).unwrap();
let via_contrast = glm_sc_test(
&coefficients,
&covariance,
2,
2,
&ScTested::Contrast(vec![0.0, 1.0]),
)
.unwrap();
for (a, b) in direct.se.iter().zip(via_contrast.se.iter()) {
assert_relative_eq!(a, b, max_relative = 1e-15);
}
for (a, b) in direct.p_value.iter().zip(via_contrast.p_value.iter()) {
assert_relative_eq!(a, b, max_relative = 1e-15);
}
}
#[test]
fn test_p_values_match_nebula() {
let (coefficients, covariance) = fixture();
let out = glm_sc_test(&coefficients, &covariance, 2, 2, &ScTested::Coef(1)).unwrap();
assert_relative_eq!(
out.p_value[0],
0.001_768_910_063_991_061_6,
max_relative = 1e-9
);
assert_relative_eq!(
out.p_value[1],
0.000_044_717_332_821_628_2,
max_relative = 1e-9
);
}
#[test]
fn test_packed_get_is_symmetric() {
let packed = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
for i in 0..3 {
for j in 0..3 {
assert_eq!(packed_get(&packed, i, j), packed_get(&packed, j, i));
}
}
assert_eq!(packed_get(&packed, 0, 0), 1.0);
assert_eq!(packed_get(&packed, 0, 1), 2.0);
assert_eq!(packed_get(&packed, 1, 1), 3.0);
assert_eq!(packed_get(&packed, 2, 2), 6.0);
}
#[test]
fn test_packed_len_matches_the_triangle() {
assert_eq!(packed_len(1), 1);
assert_eq!(packed_len(2), 3);
assert_eq!(packed_len(3), 6);
}
#[test]
fn test_rejects_a_zero_contrast() {
let (coefficients, covariance) = fixture();
let err = glm_sc_test(
&coefficients,
&covariance,
2,
2,
&ScTested::Contrast(vec![0.0, 0.0]),
)
.unwrap_err();
assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
}
#[test]
fn test_rejects_an_out_of_range_coefficient() {
let (coefficients, covariance) = fixture();
let err = glm_sc_test(&coefficients, &covariance, 2, 2, &ScTested::Coef(5)).unwrap_err();
assert!(matches!(err, EdgeErrors::CoefOutOfRange { .. }));
}
#[test]
fn test_rejects_a_shape_mismatch() {
let (coefficients, _) = fixture();
let err = glm_sc_test(&coefficients, &[0.0; 2], 2, 2, &ScTested::Coef(0)).unwrap_err();
assert!(matches!(err, EdgeErrors::LengthMismatch { .. }));
}
#[test]
fn test_degenerate_covariance_gives_a_p_value_of_one() {
let coefficients = vec![1.0, 2.0];
let covariance = vec![0.0, 0.0, 0.0];
let out = glm_sc_test(&coefficients, &covariance, 1, 2, &ScTested::Coef(1)).unwrap();
assert_eq!(out.se[0], 0.0);
assert_eq!(out.p_value[0], 1.0);
assert!(out.z[0].is_finite());
}
}