Skip to main content

edge_rs/sc/
test.rs

1//! Wald tests on a NEBULA fit.
2//!
3//! A single coefficient needs only the diagonal of the covariance. A contrast
4//! has variance `c' V c`, so the off-diagonal terms matter whenever design
5//! columns are correlated. edgePython assumes `V` is diagonal; this crate keeps
6//! the full covariance per gene, as `nebula` does with `covariance = TRUE`. See
7//! `UPSTREAM_DEVIATIONS.md` A19.
8
9use crate::errors::EdgeErrors;
10use crate::numeric::dist::chisq_sf;
11use crate::numeric::stats::p_adjust_bh;
12
13////////////
14// Consts //
15////////////
16
17/// Degrees of freedom of a single-contrast Wald test.
18const WALD_DF: f64 = 1.0;
19
20/////////////
21// ScTests //
22/////////////
23
24/// What to test.
25#[derive(Clone, Debug)]
26pub enum ScTested {
27    /// One coefficient, by index.
28    Coef(usize),
29    /// A linear combination of coefficients, one weight per coefficient.
30    Contrast(Vec<f64>),
31}
32
33/// Outcome of a Wald test, one entry per gene.
34#[derive(Clone, Debug)]
35pub struct ScTestResult {
36    /// Estimated effect on the natural log scale.
37    pub log_fc: Vec<f64>,
38    /// Standard error of the effect.
39    pub se: Vec<f64>,
40    /// Wald statistic, `log_fc / se`.
41    pub z: Vec<f64>,
42    /// Two-sided p-value.
43    pub p_value: Vec<f64>,
44    /// Benjamini-Hochberg adjusted p-value.
45    pub fdr: Vec<f64>,
46}
47
48/// Number of stored values in a packed symmetric matrix.
49///
50/// ### Params
51///
52/// * `n_coef` - Side length of the matrix
53///
54/// ### Returns
55///
56/// `n_coef * (n_coef + 1) / 2`.
57#[inline]
58pub const fn packed_len(n_coef: usize) -> usize {
59    n_coef * (n_coef + 1) / 2
60}
61
62/// Reads one entry of a packed upper-triangular covariance.
63///
64/// Packed upper triangle, column-major: `(i, j)` with `i <= j` sits at
65/// `j (j + 1) / 2 + i`, independent of `n_coef`. For three coefficients:
66/// `V11, V12, V22, V13, V23, V33`.
67///
68/// ### Params
69///
70/// * `packed` - One gene's packed covariance
71/// * `i` - Row
72/// * `j` - Column
73///
74/// ### Returns
75///
76/// `V[i, j]`, using symmetry when `i > j`.
77#[inline]
78fn packed_get(packed: &[f64], i: usize, j: usize) -> f64 {
79    let (lo, hi) = if i <= j { (i, j) } else { (j, i) };
80    packed[hi * (hi + 1) / 2 + lo]
81}
82
83/// Tests a coefficient or a contrast on a NEBULA fit.
84///
85/// ### Params
86///
87/// * `coefficients` - Fitted coefficients, row-major `n_genes * n_coef`
88/// * `covariance` - Packed upper-triangular covariance per gene, row-major
89///   `n_genes * packed_len(n_coef)`
90/// * `n_genes` - Number of genes
91/// * `n_coef` - Number of coefficients
92/// * `tested` - Which coefficient or contrast to test
93///
94/// ### Returns
95///
96/// The effect, its standard error, the Wald statistic, and raw and adjusted
97/// p-values, or [`EdgeErrors`] if the shapes disagree or the contrast is empty.
98pub fn glm_sc_test(
99    coefficients: &[f64],
100    covariance: &[f64],
101    n_genes: usize,
102    n_coef: usize,
103    tested: &ScTested,
104) -> Result<ScTestResult, EdgeErrors> {
105    if n_genes == 0 || n_coef == 0 {
106        return Err(EdgeErrors::MustBePositive("fit dimensions".to_string()));
107    }
108    if coefficients.len() != n_genes * n_coef {
109        return Err(EdgeErrors::LengthMismatch {
110            name: "coefficients",
111            expected: n_genes * n_coef,
112            got: coefficients.len(),
113        });
114    }
115    let packed = packed_len(n_coef);
116    if covariance.len() != n_genes * packed {
117        return Err(EdgeErrors::LengthMismatch {
118            name: "covariance",
119            expected: n_genes * packed,
120            got: covariance.len(),
121        });
122    }
123
124    // Both cases reduce to a weight vector, so there is one code path.
125    let weights: Vec<f64> = match tested {
126        ScTested::Coef(index) => {
127            if *index >= n_coef {
128                return Err(EdgeErrors::CoefOutOfRange {
129                    index: *index,
130                    n_coef,
131                });
132            }
133            let mut w = vec![0.0; n_coef];
134            w[*index] = 1.0;
135            w
136        }
137        ScTested::Contrast(values) => {
138            if values.len() != n_coef {
139                return Err(EdgeErrors::LengthMismatch {
140                    name: "contrast",
141                    expected: n_coef,
142                    got: values.len(),
143                });
144            }
145            if values.iter().all(|v| *v == 0.0) {
146                return Err(EdgeErrors::InvalidArgument(
147                    "contrast is entirely zero".to_string(),
148                ));
149            }
150            values.clone()
151        }
152    };
153
154    let mut log_fc = Vec::with_capacity(n_genes);
155    let mut se = Vec::with_capacity(n_genes);
156    let mut z = Vec::with_capacity(n_genes);
157    let mut p_value = Vec::with_capacity(n_genes);
158
159    for gene in 0..n_genes {
160        let beta = &coefficients[gene * n_coef..(gene + 1) * n_coef];
161        let cov = &covariance[gene * packed..(gene + 1) * packed];
162
163        let effect: f64 = beta.iter().zip(weights.iter()).map(|(b, w)| b * w).sum();
164
165        // c' V c, with the off-diagonal terms that edgePython drops.
166        let mut variance = 0.0;
167        for i in 0..n_coef {
168            if weights[i] == 0.0 {
169                continue;
170            }
171            for j in 0..n_coef {
172                if weights[j] == 0.0 {
173                    continue;
174                }
175                variance += weights[i] * weights[j] * packed_get(cov, i, j);
176            }
177        }
178
179        let error = if variance > 0.0 { variance.sqrt() } else { 0.0 };
180        let statistic = if error > 0.0 { effect / error } else { 0.0 };
181        let p = if error > 0.0 {
182            chisq_sf(statistic * statistic, WALD_DF)?
183        } else {
184            1.0
185        };
186
187        log_fc.push(effect);
188        se.push(error);
189        z.push(statistic);
190        p_value.push(p);
191    }
192
193    let fdr = p_adjust_bh(&p_value);
194    Ok(ScTestResult {
195        log_fc,
196        se,
197        z,
198        p_value,
199        fdr,
200    })
201}
202
203///////////
204// Tests //
205///////////
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use approx::assert_relative_eq;
211
212    /// Two genes, two coefficients, covariance packed as `V11, V12, V22` (the
213    /// one width where this layout and the R package's coincide).
214    fn fixture() -> (Vec<f64>, Vec<f64>) {
215        let coefficients = vec![
216            -3.625_170_366_404_492,
217            0.751_566_378_617_826, //
218            -5.046_180_663_686_06,
219            1.161_381_538_814_725,
220        ];
221        let covariance = vec![
222            0.014_446_194_037_806_379,
223            -0.001_444_153_266_646_014,
224            0.057_784_776_151_225_514,
225            0.020_240_376_033_846_74,
226            -0.009_541_502_189_198_18,
227            0.080_961_504_135_386_98,
228        ];
229        (coefficients, covariance)
230    }
231
232    /// A single coefficient reproduces the R package's standard errors (the
233    /// square roots of the covariance diagonal).
234    ///
235    /// ```r
236    /// nebula(..., covariance = TRUE)$summary$se_grp   # 0.24038464208685528, 0.28453735103741123
237    /// ```
238    #[test]
239    fn test_single_coefficient_matches_nebula() {
240        let (coefficients, covariance) = fixture();
241        let out = glm_sc_test(&coefficients, &covariance, 2, 2, &ScTested::Coef(1)).unwrap();
242
243        assert_relative_eq!(out.se[0], 0.240_384_642_086_855_28, max_relative = 1e-12);
244        assert_relative_eq!(out.se[1], 0.284_537_351_037_411_23, max_relative = 1e-12);
245        assert_relative_eq!(out.log_fc[0], 0.751_566_378_617_826, max_relative = 1e-12);
246    }
247
248    /// A contrast mixing two correlated coefficients must pick up the
249    /// off-diagonal term, which edgePython's diagonal-only formula misses.
250    #[test]
251    fn test_contrast_uses_the_off_diagonal_term() {
252        let (coefficients, covariance) = fixture();
253        let contrast = vec![1.0, 1.0];
254        let out = glm_sc_test(
255            &coefficients,
256            &covariance,
257            2,
258            2,
259            &ScTested::Contrast(contrast),
260        )
261        .unwrap();
262
263        // c' V c = V11 + 2 V12 + V22.
264        let expected: f64 = 0.014_446_194_037_806_379
265            + 2.0 * -0.001_444_153_266_646_014
266            + 0.057_784_776_151_225_514;
267        let expected = expected.sqrt();
268        assert_relative_eq!(out.se[0], expected, max_relative = 1e-12);
269
270        // edgePython: sqrt(se1^2 + se2^2), dropping 2 V12.
271        let diagonal_only = (0.014_446_194_037_806_379 + 0.057_784_776_151_225_514f64).sqrt();
272        let relative = (out.se[0] - diagonal_only).abs() / out.se[0];
273        assert!(
274            relative > 1e-3,
275            "the off-diagonal term should move the answer, moved {relative:e}"
276        );
277        // Here the covariance is negative, so ignoring it overstates the error.
278        assert!(diagonal_only > out.se[0]);
279    }
280
281    /// A contrast isolating one coefficient agrees with testing it directly.
282    #[test]
283    fn test_unit_contrast_agrees_with_the_coefficient() {
284        let (coefficients, covariance) = fixture();
285        let direct = glm_sc_test(&coefficients, &covariance, 2, 2, &ScTested::Coef(1)).unwrap();
286        let via_contrast = glm_sc_test(
287            &coefficients,
288            &covariance,
289            2,
290            2,
291            &ScTested::Contrast(vec![0.0, 1.0]),
292        )
293        .unwrap();
294        for (a, b) in direct.se.iter().zip(via_contrast.se.iter()) {
295            assert_relative_eq!(a, b, max_relative = 1e-15);
296        }
297        for (a, b) in direct.p_value.iter().zip(via_contrast.p_value.iter()) {
298            assert_relative_eq!(a, b, max_relative = 1e-15);
299        }
300    }
301
302    /// nebula's reported p-values for the group coefficient.
303    ///
304    /// ```r
305    /// nebula(...)$summary$p_grp   # 0.00176891006399106164, 0.00004471733282162820
306    /// ```
307    #[test]
308    fn test_p_values_match_nebula() {
309        let (coefficients, covariance) = fixture();
310        let out = glm_sc_test(&coefficients, &covariance, 2, 2, &ScTested::Coef(1)).unwrap();
311        assert_relative_eq!(
312            out.p_value[0],
313            0.001_768_910_063_991_061_6,
314            max_relative = 1e-9
315        );
316        assert_relative_eq!(
317            out.p_value[1],
318            0.000_044_717_332_821_628_2,
319            max_relative = 1e-9
320        );
321    }
322
323    #[test]
324    fn test_packed_get_is_symmetric() {
325        let packed = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
326        for i in 0..3 {
327            for j in 0..3 {
328                assert_eq!(packed_get(&packed, i, j), packed_get(&packed, j, i));
329            }
330        }
331        assert_eq!(packed_get(&packed, 0, 0), 1.0);
332        assert_eq!(packed_get(&packed, 0, 1), 2.0);
333        assert_eq!(packed_get(&packed, 1, 1), 3.0);
334        assert_eq!(packed_get(&packed, 2, 2), 6.0);
335    }
336
337    #[test]
338    fn test_packed_len_matches_the_triangle() {
339        assert_eq!(packed_len(1), 1);
340        assert_eq!(packed_len(2), 3);
341        assert_eq!(packed_len(3), 6);
342    }
343
344    #[test]
345    fn test_rejects_a_zero_contrast() {
346        let (coefficients, covariance) = fixture();
347        let err = glm_sc_test(
348            &coefficients,
349            &covariance,
350            2,
351            2,
352            &ScTested::Contrast(vec![0.0, 0.0]),
353        )
354        .unwrap_err();
355        assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
356    }
357
358    #[test]
359    fn test_rejects_an_out_of_range_coefficient() {
360        let (coefficients, covariance) = fixture();
361        let err = glm_sc_test(&coefficients, &covariance, 2, 2, &ScTested::Coef(5)).unwrap_err();
362        assert!(matches!(err, EdgeErrors::CoefOutOfRange { .. }));
363    }
364
365    #[test]
366    fn test_rejects_a_shape_mismatch() {
367        let (coefficients, _) = fixture();
368        let err = glm_sc_test(&coefficients, &[0.0; 2], 2, 2, &ScTested::Coef(0)).unwrap_err();
369        assert!(matches!(err, EdgeErrors::LengthMismatch { .. }));
370    }
371
372    /// A gene with a collapsed covariance reports p = 1, not NaN.
373    #[test]
374    fn test_degenerate_covariance_gives_a_p_value_of_one() {
375        let coefficients = vec![1.0, 2.0];
376        let covariance = vec![0.0, 0.0, 0.0];
377        let out = glm_sc_test(&coefficients, &covariance, 1, 2, &ScTested::Coef(1)).unwrap();
378        assert_eq!(out.se[0], 0.0);
379        assert_eq!(out.p_value[0], 1.0);
380        assert!(out.z[0].is_finite());
381    }
382}