1use crate::errors::EdgeErrors;
10use crate::numeric::dist::chisq_sf;
11use crate::numeric::stats::p_adjust_bh;
12
13const WALD_DF: f64 = 1.0;
19
20#[derive(Clone, Debug)]
26pub enum ScTested {
27 Coef(usize),
29 Contrast(Vec<f64>),
31}
32
33#[derive(Clone, Debug)]
35pub struct ScTestResult {
36 pub log_fc: Vec<f64>,
38 pub se: Vec<f64>,
40 pub z: Vec<f64>,
42 pub p_value: Vec<f64>,
44 pub fdr: Vec<f64>,
46}
47
48#[inline]
58pub const fn packed_len(n_coef: usize) -> usize {
59 n_coef * (n_coef + 1) / 2
60}
61
62#[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
83pub 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 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 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#[cfg(test)]
208mod tests {
209 use super::*;
210 use approx::assert_relative_eq;
211
212 fn fixture() -> (Vec<f64>, Vec<f64>) {
215 let coefficients = vec![
216 -3.625_170_366_404_492,
217 0.751_566_378_617_826, -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 #[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 #[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 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 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 assert!(diagonal_only > out.se[0]);
279 }
280
281 #[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 #[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 #[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}