1use rayon::prelude::*;
17
18use crate::glm::fit::{GlmFit, glm_fit};
19use crate::glm::levenberg::{LevenbergParams, mglm_levenberg};
20use crate::numeric::dist::{chisq_sf, f_sf, norm_cdf, norm_ppf, t_sf};
21use crate::prelude::*;
22use crate::utils::design::{contrast_as_coef, design_as_factor, matrix_rank, non_estimable};
23
24const TREAT_INTERVAL_WIDTH: f64 = 1.470402;
32
33const MAX_TREAT_ZSCORE: f64 = 38.0;
38
39const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
41
42struct Resolved {
49 design: Vec<f64>,
52 coef: Vec<usize>,
54 log_fc: Vec<f64>,
56}
57
58fn resolve_tested<T: EdgeFloat>(
74 input: &GlmTestInput<'_, T>,
75 fit: &GlmFit,
76 tested: &Tested,
77) -> Result<Resolved, EdgeErrors> {
78 match tested {
79 Tested::Coef(requested) => {
80 let mut coef: Vec<usize> = Vec::with_capacity(requested.len());
81 for index in requested {
82 if *index >= input.n_coef {
83 return Err(EdgeErrors::CoefOutOfRange {
84 index: *index,
85 n_coef: input.n_coef,
86 });
87 }
88 if !coef.contains(index) {
89 coef.push(*index);
90 }
91 }
92 if coef.is_empty() {
93 return Err(EdgeErrors::InvalidArgument(
94 "no coefficients were selected for testing".to_string(),
95 ));
96 }
97 let first = coef[0];
98 let log_fc = project(
99 &fit.coefficients,
100 input.n_genes,
101 input.n_coef,
102 tested,
103 first,
104 );
105 coef.sort_unstable();
106 Ok(Resolved {
107 design: input.design.to_vec(),
108 coef,
109 log_fc,
110 })
111 }
112 Tested::Contrast {
113 values,
114 n_contrasts,
115 } => {
116 if values.len() != input.n_coef * n_contrasts {
117 return Err(EdgeErrors::LengthMismatch {
118 name: "contrast",
119 expected: input.n_coef * n_contrasts,
120 got: values.len(),
121 });
122 }
123 let rank = matrix_rank(values, *n_contrasts, input.n_coef)?;
126 if rank == 0 {
127 return Err(EdgeErrors::InvalidArgument(
128 "contrasts are all zero".to_string(),
129 ));
130 }
131 if rank < *n_contrasts {
132 return Err(EdgeErrors::InvalidArgument(format!(
133 "contrast matrix has rank {rank} but {n_contrasts} columns; \
134 drop the redundant columns first"
135 )));
136 }
137 let reform = contrast_as_coef(
138 input.design,
139 input.n_samples,
140 input.n_coef,
141 values,
142 *n_contrasts,
143 true,
144 )?;
145 let log_fc = project(&fit.coefficients, input.n_genes, input.n_coef, tested, 0);
148 Ok(Resolved {
149 design: reform.design,
150 coef: reform.coef,
151 log_fc,
152 })
153 }
154 }
155}
156
157fn narrow(tested: &Tested, n_coef: usize) -> Result<Tested, EdgeErrors> {
169 match tested {
170 Tested::Coef(v) => match v.first() {
171 Some(first) => Ok(Tested::Coef(vec![*first])),
172 None => Err(EdgeErrors::InvalidArgument(
173 "no coefficients were selected for testing".to_string(),
174 )),
175 },
176 Tested::Contrast { values, .. } => {
177 if values.len() < n_coef {
178 return Err(EdgeErrors::LengthMismatch {
179 name: "contrast",
180 expected: n_coef,
181 got: values.len(),
182 });
183 }
184 Ok(Tested::Contrast {
185 values: values[..n_coef].to_vec(),
186 n_contrasts: 1,
187 })
188 }
189 }
190}
191
192fn project(
209 coefficients: &[f64],
210 n_genes: usize,
211 n_coef: usize,
212 tested: &Tested,
213 column: usize,
214) -> Vec<f64> {
215 (0..n_genes)
216 .map(|gene| {
217 let beta = &coefficients[gene * n_coef..(gene + 1) * n_coef];
218 let value = match tested {
219 Tested::Coef(_) => beta[column],
220 Tested::Contrast { values, .. } => {
221 beta.iter().zip(values.iter()).map(|(b, c)| b * c).sum()
222 }
223 };
224 value / std::f64::consts::LN_2
225 })
226 .collect()
227}
228
229fn fit_null<T: EdgeFloat>(
250 input: &GlmTestInput<'_, T>,
251 design_null: &[f64],
252 n_coef_null: usize,
253 dispersion: &Recycled<f64>,
254 offset: &Recycled<f64>,
255 start: Option<&[f64]>,
256) -> Result<Vec<f64>, EdgeErrors> {
257 let (_, n_groups) = design_as_factor(design_null, input.n_samples, n_coef_null)?;
258 let one_way = n_groups == n_coef_null;
259
260 match start {
261 Some(values) if !one_way => {
262 if let Some(bad) = non_estimable(design_null, input.n_samples, n_coef_null)? {
263 return Err(EdgeErrors::DesignNotFullRank {
264 n_cols: n_coef_null,
265 rank: n_coef_null - bad.len(),
266 });
267 }
268 let params = LevenbergParams {
270 max_iter: crate::glm::fit::GLM_FIT_MAX_ITER,
271 ..Default::default()
272 };
273 let fit = mglm_levenberg(
274 input.counts,
275 input.n_genes,
276 input.n_samples,
277 design_null,
278 n_coef_null,
279 dispersion,
280 offset,
281 input.weights,
282 Some(values),
283 Some(params),
284 )?;
285 Ok(fit.deviance)
286 }
287 _ => {
288 let fit = glm_fit(
289 input.counts,
290 input.n_genes,
291 input.n_samples,
292 design_null,
293 n_coef_null,
294 dispersion,
295 offset,
296 input.weights,
297 0.0,
298 )?;
299 Ok(fit.deviance)
300 }
301 }
302}
303
304fn threshold_z<T: EdgeFloat>(
323 input: &GlmTestInput<'_, T>,
324 design: &[f64],
325 design_null: &[f64],
326 dispersion: &Recycled<f64>,
327 adjustment: &[f64],
328 sign: f64,
329) -> Result<Vec<f64>, EdgeErrors> {
330 let offset = shift_offset(
331 input.offset,
332 adjustment,
333 input.n_genes,
334 input.n_samples,
335 sign,
336 );
337 let fit_null = glm_fit(
338 input.counts,
339 input.n_genes,
340 input.n_samples,
341 design_null,
342 input.n_coef - 1,
343 dispersion,
344 &offset,
345 input.weights,
346 0.0,
347 )?;
348 let fit_full = glm_fit(
349 input.counts,
350 input.n_genes,
351 input.n_samples,
352 design,
353 input.n_coef,
354 dispersion,
355 &offset,
356 input.weights,
357 0.0,
358 )?;
359 Ok(fit_null
360 .deviance
361 .iter()
362 .zip(fit_full.deviance.iter())
363 .map(|(null, full)| (null - full).max(0.0).sqrt())
364 .collect())
365}
366
367fn treat_p_value(z_left: f64, z_right: f64, null: TreatNull) -> f64 {
379 match null {
380 TreatNull::WorstCase => norm_cdf(-z_right) + norm_cdf(z_left),
381 TreatNull::Interval => {
382 if z_right + z_left > TREAT_INTERVAL_WIDTH {
383 integrate_pnorm(-z_right, -z_right + TREAT_INTERVAL_WIDTH)
384 + integrate_pnorm(z_left - TREAT_INTERVAL_WIDTH, z_left)
385 } else {
386 2.0 * integrate_pnorm(-z_right, z_left)
387 }
388 }
389 }
390}
391
392fn integrate_pnorm(a: f64, b: f64) -> f64 {
406 if a == b {
407 return norm_cdf(a);
408 }
409 (b * norm_cdf(b) + norm_pdf(b) - (a * norm_cdf(a) + norm_pdf(a))) / (b - a)
410}
411
412#[inline]
422fn norm_pdf(x: f64) -> f64 {
423 INV_SQRT_2PI * (-0.5 * x * x).exp()
424}
425
426fn zscore_t(x: f64, df: f64) -> Result<f64, EdgeErrors> {
444 let tail = t_sf(x.abs(), df)?;
445 let z = -norm_ppf(tail.max(f64::MIN_POSITIVE))?;
446 let z = z.min(MAX_TREAT_ZSCORE);
447 Ok(if x < 0.0 { -z } else { z })
448}
449
450fn below_poisson_bound<T: EdgeFloat>(input: &GlmTestInput<'_, T>, ql: &QlSummary<'_>) -> Vec<bool> {
466 (0..input.n_genes)
467 .into_par_iter()
468 .map(|gene| {
469 let disp = input.dispersion.row(gene, input.n_samples);
470 let s2 = ql.s2_post[gene];
471 let mu = &ql.fitted[gene * input.n_samples..(gene + 1) * input.n_samples];
472 mu.iter()
473 .enumerate()
474 .any(|(sample, m)| s2 * (1.0 + disp.get(sample) * m) < 1.0)
475 })
476 .collect()
477}
478
479fn poisson_refit_lrt<T: EdgeFloat>(
493 input: &GlmTestInput<'_, T>,
494 tested: &Tested,
495) -> Result<GlmTest, EdgeErrors> {
496 let dispersion = Recycled::scalar(0.0);
497 let poisson = poisson_input(input, &dispersion);
498 let fit = poisson_fit(&poisson)?;
499 glm_lrt(&poisson, &fit, tested, None)
500}
501
502fn poisson_refit_treat<T: EdgeFloat>(
517 input: &GlmTestInput<'_, T>,
518 tested: &Tested,
519 lfc: f64,
520) -> Result<GlmTest, EdgeErrors> {
521 let dispersion = Recycled::scalar(0.0);
522 let poisson = poisson_input(input, &dispersion);
523 let fit = poisson_fit(&poisson)?;
524 glm_treat(&poisson, &fit, None, tested, lfc, TreatNull::Interval)
525}
526
527fn poisson_input<'a, T: EdgeFloat>(
538 input: &GlmTestInput<'a, T>,
539 dispersion: &'a Recycled<f64>,
540) -> GlmTestInput<'a, T> {
541 GlmTestInput {
542 counts: input.counts,
543 n_genes: input.n_genes,
544 n_samples: input.n_samples,
545 design: input.design,
546 n_coef: input.n_coef,
547 dispersion,
548 offset: input.offset,
549 weights: input.weights,
550 log_cpm: None,
551 }
552}
553
554fn poisson_fit<T: EdgeFloat>(input: &GlmTestInput<'_, T>) -> Result<GlmFit, EdgeErrors> {
564 glm_fit(
565 input.counts,
566 input.n_genes,
567 input.n_samples,
568 input.design,
569 input.n_coef,
570 input.dispersion,
571 input.offset,
572 input.weights,
573 0.0,
574 )
575}
576
577fn drop_columns(design: &[f64], n_rows: usize, n_cols: usize, drop: &[usize]) -> Vec<f64> {
590 let kept: Vec<usize> = (0..n_cols).filter(|c| !drop.contains(c)).collect();
591 let mut out = Vec::with_capacity(n_rows * kept.len());
592 for row in design.chunks_exact(n_cols) {
593 out.extend(kept.iter().map(|c| row[*c]));
594 }
595 out
596}
597
598fn shift_offset(
615 offset: &Recycled<f64>,
616 adjustment: &[f64],
617 n_genes: usize,
618 n_samples: usize,
619 sign: f64,
620) -> Recycled<f64> {
621 match offset {
622 Recycled::Scalar(v) => {
623 Recycled::BySample(adjustment.iter().map(|a| v + sign * a).collect())
624 }
625 Recycled::BySample(values) => Recycled::BySample(
626 values
627 .iter()
628 .zip(adjustment.iter())
629 .map(|(v, a)| v + sign * a)
630 .collect(),
631 ),
632 Recycled::ByGene(values) => {
633 let mut out = Vec::with_capacity(n_genes * n_samples);
634 for value in values {
635 out.extend(adjustment.iter().map(|a| value + sign * a));
636 }
637 Recycled::Full(out)
638 }
639 Recycled::Full(values) => Recycled::Full(
640 values
641 .chunks_exact(n_samples)
642 .flat_map(|row| {
643 row.iter()
644 .zip(adjustment.iter())
645 .map(|(v, a)| v + sign * a)
646 .collect::<Vec<f64>>()
647 })
648 .collect(),
649 ),
650 }
651}
652
653#[inline]
664fn recycle(values: &[f64], gene: usize) -> f64 {
665 if values.len() == 1 {
666 values[0]
667 } else {
668 values[gene]
669 }
670}
671
672fn validate<T: EdgeFloat>(input: &GlmTestInput<'_, T>, fit: &GlmFit) -> Result<(), EdgeErrors> {
683 if input.n_genes == 0 || input.n_samples == 0 {
684 return Err(EdgeErrors::EmptyCounts {
685 n_genes: input.n_genes,
686 n_samples: input.n_samples,
687 });
688 }
689 if input.n_coef < 2 {
690 return Err(EdgeErrors::InvalidArgument(
691 "need at least two design columns; the first is usually the intercept".to_string(),
692 ));
693 }
694 if input.counts.len() != input.n_genes * input.n_samples {
695 return Err(EdgeErrors::LengthMismatch {
696 name: "counts",
697 expected: input.n_genes * input.n_samples,
698 got: input.counts.len(),
699 });
700 }
701 if input.design.len() != input.n_samples * input.n_coef {
702 return Err(EdgeErrors::LengthMismatch {
703 name: "design",
704 expected: input.n_samples * input.n_coef,
705 got: input.design.len(),
706 });
707 }
708 if fit.coefficients.len() != input.n_genes * input.n_coef {
709 return Err(EdgeErrors::LengthMismatch {
710 name: "coefficients",
711 expected: input.n_genes * input.n_coef,
712 got: fit.coefficients.len(),
713 });
714 }
715 if fit.deviance.len() != input.n_genes {
716 return Err(EdgeErrors::LengthMismatch {
717 name: "deviance",
718 expected: input.n_genes,
719 got: fit.deviance.len(),
720 });
721 }
722 if let Some(cpm) = input.log_cpm
723 && cpm.len() != input.n_genes
724 {
725 return Err(EdgeErrors::LengthMismatch {
726 name: "log_cpm",
727 expected: input.n_genes,
728 got: cpm.len(),
729 });
730 }
731 input.dispersion.validate(input.n_genes, input.n_samples)?;
732 input.offset.validate(input.n_genes, input.n_samples)?;
733 if let Some(w) = input.weights {
734 w.validate(input.n_genes, input.n_samples)?;
735 }
736 Ok(())
737}
738
739fn validate_ql<T: EdgeFloat>(
750 input: &GlmTestInput<'_, T>,
751 ql: &QlSummary<'_>,
752) -> Result<(), EdgeErrors> {
753 let shapes: [(&'static str, usize, usize); 3] = [
754 ("s2_post", ql.s2_post.len(), input.n_genes),
755 ("df_residual_adj", ql.df_residual_adj.len(), input.n_genes),
756 ("fitted", ql.fitted.len(), input.n_genes * input.n_samples),
757 ];
758 for (name, got, expected) in shapes {
759 if got != expected {
760 return Err(EdgeErrors::LengthMismatch {
761 name,
762 expected,
763 got,
764 });
765 }
766 }
767 if ql.df_prior.len() != 1 && ql.df_prior.len() != input.n_genes {
768 return Err(EdgeErrors::LengthMismatch {
769 name: "df_prior",
770 expected: input.n_genes,
771 got: ql.df_prior.len(),
772 });
773 }
774 if let Some(zeros) = ql.df_residual_zeros
775 && zeros.len() != input.n_genes
776 {
777 return Err(EdgeErrors::LengthMismatch {
778 name: "df_residual_zeros",
779 expected: input.n_genes,
780 got: zeros.len(),
781 });
782 }
783 Ok(())
784}
785
786#[derive(Clone, Debug)]
792pub enum Tested {
793 Coef(Vec<usize>),
798 Contrast {
800 values: Vec<f64>,
802 n_contrasts: usize,
804 },
805}
806
807#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
809pub enum TreatNull {
810 #[default]
812 Interval,
813 WorstCase,
816}
817
818#[derive(Clone, Debug)]
822pub struct GlmTestInput<'a, T: EdgeFloat> {
823 pub counts: &'a [T],
825 pub n_genes: usize,
827 pub n_samples: usize,
829 pub design: &'a [f64],
831 pub n_coef: usize,
833 pub dispersion: &'a Recycled<f64>,
835 pub offset: &'a Recycled<f64>,
837 pub weights: Option<&'a Recycled<f64>>,
839 pub log_cpm: Option<&'a [f64]>,
841}
842
843#[derive(Clone, Debug)]
848pub struct QlSummary<'a> {
849 pub s2_post: &'a [f64],
851 pub df_prior: &'a [f64],
854 pub df_residual_adj: &'a [f64],
857 pub df_residual_zeros: Option<&'a [f64]>,
863 pub fitted: &'a [f64],
866 pub average_ql_dispersion: Option<f64>,
869}
870
871#[derive(Clone, Debug)]
873pub struct GlmTest {
874 pub log_fc: Vec<f64>,
880 pub log_cpm: Option<Vec<f64>>,
882 pub statistic: Vec<f64>,
888 pub p_value: Vec<f64>,
890 pub df_test: f64,
892 pub df_total: Option<Vec<f64>>,
895}
896
897pub fn glm_lrt<T: EdgeFloat>(
929 input: &GlmTestInput<'_, T>,
930 fit: &GlmFit,
931 tested: &Tested,
932 ql: Option<&QlSummary<'_>>,
933) -> Result<GlmTest, EdgeErrors> {
934 validate(input, fit)?;
935
936 let resolved = resolve_tested(input, fit, tested)?;
937 let n_coef_null = input.n_coef - resolved.coef.len();
938 if n_coef_null == 0 {
939 return Err(EdgeErrors::InvalidArgument(
940 "cannot test every coefficient: the null model would have no columns".to_string(),
941 ));
942 }
943
944 let design_null = drop_columns(
945 &resolved.design,
946 input.n_samples,
947 input.n_coef,
948 &resolved.coef,
949 );
950
951 let scaled = ql
954 .and_then(|q| q.average_ql_dispersion)
955 .map(|a| input.dispersion.map(|d| d / a));
956 let dispersion = scaled.as_ref().unwrap_or(input.dispersion);
957
958 let start = match tested {
961 Tested::Coef(_) => Some(drop_columns(
962 fit.unshrunk_coefficients
963 .as_deref()
964 .unwrap_or(&fit.coefficients),
965 input.n_genes,
966 input.n_coef,
967 &resolved.coef,
968 )),
969 Tested::Contrast { .. } => None,
970 };
971
972 let deviance_null = fit_null(
973 input,
974 &design_null,
975 n_coef_null,
976 dispersion,
977 input.offset,
978 start.as_deref(),
979 )?;
980
981 let df_test = resolved.coef.len() as f64;
982 let statistic: Vec<f64> = deviance_null
983 .iter()
984 .zip(fit.deviance.iter())
985 .map(|(null, full)| null - full)
986 .collect();
987 let p_value = statistic
988 .par_iter()
989 .map(|lr| chisq_sf(lr.max(0.0), df_test))
990 .collect::<Result<Vec<f64>, EdgeErrors>>()?;
991
992 Ok(GlmTest {
993 log_fc: resolved.log_fc,
994 log_cpm: input.log_cpm.map(<[f64]>::to_vec),
995 statistic,
996 p_value,
997 df_test,
998 df_total: None,
999 })
1000}
1001
1002pub fn glm_ql_ftest<T: EdgeFloat>(
1038 input: &GlmTestInput<'_, T>,
1039 fit: &GlmFit,
1040 ql: &QlSummary<'_>,
1041 tested: &Tested,
1042 poisson_bound: bool,
1043) -> Result<GlmTest, EdgeErrors> {
1044 validate_ql(input, ql)?;
1045 let out = glm_lrt(input, fit, tested, Some(ql))?;
1046
1047 let (df_residual, poisson_bound) = match ql.df_residual_zeros {
1048 Some(zeros) => (zeros, poisson_bound),
1049 None => (ql.df_residual_adj, false),
1050 };
1051 let cap = (input.n_genes * fit.df_residual) as f64;
1052
1053 let df_total: Vec<f64> = (0..input.n_genes)
1054 .map(|gene| (recycle(ql.df_prior, gene) + df_residual[gene]).min(cap))
1055 .collect();
1056 let statistic: Vec<f64> = out
1057 .statistic
1058 .iter()
1059 .zip(ql.s2_post.iter())
1060 .map(|(lr, s2)| lr / out.df_test / s2)
1061 .collect();
1062 let mut p_value = statistic
1063 .par_iter()
1064 .zip(df_total.par_iter())
1065 .map(|(f, df2)| f_sf(f.max(0.0), out.df_test, *df2))
1066 .collect::<Result<Vec<f64>, EdgeErrors>>()?;
1067
1068 if poisson_bound {
1069 let below = below_poisson_bound(input, ql);
1070 if below.iter().any(|b| *b) {
1071 let poisson = poisson_refit_lrt(input, tested)?;
1072 for (gene, flagged) in below.iter().enumerate() {
1073 if *flagged {
1074 p_value[gene] = p_value[gene].max(poisson.p_value[gene]);
1075 }
1076 }
1077 }
1078 }
1079
1080 Ok(GlmTest {
1081 log_fc: out.log_fc,
1082 log_cpm: out.log_cpm,
1083 statistic,
1084 p_value,
1085 df_test: out.df_test,
1086 df_total: Some(df_total),
1087 })
1088}
1089
1090pub fn glm_treat<T: EdgeFloat>(
1125 input: &GlmTestInput<'_, T>,
1126 fit: &GlmFit,
1127 ql: Option<&QlSummary<'_>>,
1128 tested: &Tested,
1129 lfc: f64,
1130 null: TreatNull,
1131) -> Result<GlmTest, EdgeErrors> {
1132 if lfc < 0.0 || lfc.is_nan() {
1133 return Err(EdgeErrors::InvalidArgument(format!(
1134 "lfc must be non-negative, got {lfc}"
1135 )));
1136 }
1137 if lfc == 0.0 {
1138 return match ql {
1139 Some(q) => glm_ql_ftest(input, fit, q, tested, true),
1140 None => glm_lrt(input, fit, tested, None),
1141 };
1142 }
1143 validate(input, fit)?;
1144 if let Some(q) = ql {
1145 validate_ql(input, q)?;
1146 }
1147
1148 let narrowed = narrow(tested, input.n_coef)?;
1149 let resolved = resolve_tested(input, fit, &narrowed)?;
1150 let coef = resolved.coef[0];
1151 let design_null = drop_columns(&resolved.design, input.n_samples, input.n_coef, &[coef]);
1152
1153 let unshrunk_log_fc = match fit.unshrunk_coefficients.as_deref() {
1154 Some(unshrunk) => project(unshrunk, input.n_genes, input.n_coef, &narrowed, coef),
1155 None => resolved.log_fc.clone(),
1156 };
1157
1158 let scaled = ql
1159 .and_then(|q| q.average_ql_dispersion)
1160 .map(|a| input.dispersion.map(|d| d / a));
1161 let dispersion = scaled.as_ref().unwrap_or(input.dispersion);
1162
1163 let adjustment: Vec<f64> = (0..input.n_samples)
1165 .map(|sample| lfc * std::f64::consts::LN_2 * resolved.design[sample * input.n_coef + coef])
1166 .collect();
1167
1168 let mut z_left = threshold_z(
1169 input,
1170 &resolved.design,
1171 &design_null,
1172 dispersion,
1173 &adjustment,
1174 1.0,
1175 )?;
1176 let mut z_right = threshold_z(
1177 input,
1178 &resolved.design,
1179 &design_null,
1180 dispersion,
1181 &adjustment,
1182 -1.0,
1183 )?;
1184 for (left, right) in z_left.iter_mut().zip(z_right.iter_mut()) {
1185 if *left > *right {
1186 std::mem::swap(left, right);
1187 }
1188 }
1189
1190 let df_total = ql.map(|q| {
1192 let df_residual = q.df_residual_zeros.unwrap_or(q.df_residual_adj);
1193 let cap = (input.n_genes * (input.n_samples - input.n_coef)) as f64;
1194 (0..input.n_genes)
1195 .map(|gene| (recycle(q.df_prior, gene) + df_residual[gene]).min(cap))
1196 .collect::<Vec<f64>>()
1197 });
1198 if let (Some(q), Some(df)) = (ql, df_total.as_ref()) {
1199 for gene in 0..input.n_genes {
1200 let scale = q.s2_post[gene].sqrt();
1201 z_left[gene] = zscore_t(z_left[gene] / scale, df[gene])?;
1202 z_right[gene] = zscore_t(z_right[gene] / scale, df[gene])?;
1203 }
1204 }
1205
1206 for (gene, left) in z_left.iter_mut().enumerate() {
1207 let within = unshrunk_log_fc[gene].abs() <= lfc;
1208 *left *= if within { 1.0 } else { -1.0 };
1209 }
1210
1211 let mut p_value: Vec<f64> = z_left
1212 .par_iter()
1213 .zip(z_right.par_iter())
1214 .map(|(left, right)| treat_p_value(*left, *right, null))
1215 .collect();
1216
1217 if let Some(q) = ql
1220 && q.df_residual_zeros.is_some()
1221 {
1222 let below = below_poisson_bound(input, q);
1223 if below.iter().any(|b| *b) {
1224 let poisson = poisson_refit_treat(input, &narrowed, lfc)?;
1225 for (gene, flagged) in below.iter().enumerate() {
1226 if *flagged {
1227 p_value[gene] = p_value[gene].max(poisson.p_value[gene]);
1228 }
1229 }
1230 }
1231 }
1232
1233 Ok(GlmTest {
1234 log_fc: resolved.log_fc,
1235 log_cpm: input.log_cpm.map(<[f64]>::to_vec),
1236 statistic: z_right,
1237 p_value,
1238 df_test: 1.0,
1239 df_total,
1240 })
1241}
1242
1243#[cfg(test)]
1248mod tests {
1249 use super::*;
1250 use approx::assert_relative_eq;
1251
1252 fn small() -> (Vec<f64>, Vec<f64>, Recycled<f64>) {
1260 let counts = vec![
1261 10.0, 12.0, 11.0, 40.0, 44.0, 38.0, 50.0, 48.0, 52.0, 49.0, 51.0, 50.0, 2.0, 0.0, 5.0, 1.0, 3.0, 0.0, ];
1265 let design = vec![
1266 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, ];
1273 let libraries: [f64; 6] = [62.0, 60.0, 68.0, 90.0, 98.0, 88.0];
1275 let offset = Recycled::by_sample(libraries.iter().map(|v| v.ln()).collect());
1276 (counts, design, offset)
1277 }
1278
1279 fn wide() -> (Vec<f64>, Vec<f64>, Recycled<f64>) {
1289 let counts = vec![
1290 10.0, 12.0, 11.0, 40.0, 44.0, 38.0, 50.0, 48.0, 52.0, 49.0, 51.0, 50.0, 2.0, 0.0, 5.0, 1.0, 3.0, 0.0, 200.0, 190.0, 210.0, 205.0, 195.0, 215.0, 1.0, 2.0, 0.0, 8.0, 9.0, 7.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 30.0, 33.0, 28.0, 31.0, 29.0, 30.0, 5.0, 6.0, 4.0, 20.0, 22.0, 19.0, ];
1299 let design = vec![
1300 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, ];
1307 let libraries: [f64; 6] = [298.0, 292.0, 310.0, 354.0, 353.0, 360.0];
1308 let offset = Recycled::by_sample(libraries.iter().map(|v| v.ln()).collect());
1309 (counts, design, offset)
1310 }
1311
1312 fn three_group() -> (Vec<f64>, Vec<f64>, Recycled<f64>) {
1320 let counts = vec![
1321 10.0, 12.0, 40.0, 44.0, 100.0, 110.0, 50.0, 48.0, 49.0, 51.0, 52.0, 50.0, 0.0, 0.0, 1.0, 3.0, 20.0, 25.0, 5.0, 7.0, 6.0, 8.0, 7.0, 6.0, ];
1326 let design = vec![
1327 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, ];
1334 let libraries: [f64; 6] = [65.0, 67.0, 96.0, 106.0, 179.0, 191.0];
1335 let offset = Recycled::by_sample(libraries.iter().map(|v| v.ln()).collect());
1336 (counts, design, offset)
1337 }
1338
1339 fn input<'a>(
1341 counts: &'a [f64],
1342 n_genes: usize,
1343 design: &'a [f64],
1344 n_coef: usize,
1345 dispersion: &'a Recycled<f64>,
1346 offset: &'a Recycled<f64>,
1347 ) -> GlmTestInput<'a, f64> {
1348 GlmTestInput {
1349 counts,
1350 n_genes,
1351 n_samples: 6,
1352 design,
1353 n_coef,
1354 dispersion,
1355 offset,
1356 weights: None,
1357 log_cpm: None,
1358 }
1359 }
1360
1361 fn fit_of(input: &GlmTestInput<'_, f64>) -> GlmFit {
1362 glm_fit(
1363 input.counts,
1364 input.n_genes,
1365 input.n_samples,
1366 input.design,
1367 input.n_coef,
1368 input.dispersion,
1369 input.offset,
1370 input.weights,
1371 0.125,
1372 )
1373 .unwrap()
1374 }
1375
1376 #[test]
1385 fn test_glm_lrt_matches_edger_on_a_single_coefficient() {
1386 let (counts, design, offset) = small();
1387 let dispersion = Recycled::scalar(0.1);
1388 let inp = input(&counts, 3, &design, 2, &dispersion, &offset);
1389 let fit = fit_of(&inp);
1390 let out = glm_lrt(&inp, &fit, &Tested::Coef(vec![1]), None).unwrap();
1391
1392 let log_fc = [
1393 1.335_986_011_350_581_5,
1394 -0.536_817_869_447_691_6,
1395 -1.250_904_046_511_814_2,
1396 ];
1397 let lr = [
1398 8.341_979_103_855_252,
1399 1.732_785_607_270_639,
1400 1.956_785_090_352_040_5,
1401 ];
1402 let p = [
1403 0.003_873_937_497_185_523_3,
1404 0.188_055_540_378_233_27,
1405 0.161_857_564_986_522_76,
1406 ];
1407 assert_eq!(out.df_test, 1.0);
1408 assert!(out.df_total.is_none());
1409 for gene in 0..3 {
1410 assert_relative_eq!(out.log_fc[gene], log_fc[gene], max_relative = 1e-10);
1411 assert_relative_eq!(out.statistic[gene], lr[gene], max_relative = 1e-10);
1412 assert_relative_eq!(out.p_value[gene], p[gene], max_relative = 1e-10);
1413 }
1414 }
1415
1416 #[test]
1423 fn test_glm_lrt_on_the_intercept_uses_the_warm_started_branch() {
1424 let (counts, design, offset) = small();
1425 let dispersion = Recycled::scalar(0.1);
1426 let inp = input(&counts, 3, &design, 2, &dispersion, &offset);
1427 let fit = fit_of(&inp);
1428 let out = glm_lrt(&inp, &fit, &Tested::Coef(vec![0]), None).unwrap();
1429
1430 let lr = [
1431 41.921_421_573_734_83,
1432 1.313_819_348_845_321_6,
1433 86.065_935_682_930_47,
1434 ];
1435 let p = [
1436 9.501_597_746_548_692e-11,
1437 2.517_043_040_693_706e-1,
1438 1.740_350_328_216_402_5e-20,
1439 ];
1440 for gene in 0..3 {
1441 assert_relative_eq!(out.statistic[gene], lr[gene], max_relative = 1e-10);
1442 assert_relative_eq!(out.p_value[gene], p[gene], max_relative = 1e-10);
1443 }
1444 }
1445
1446 #[test]
1449 fn test_glm_lrt_contrast_agrees_with_the_coefficient_path() {
1450 let (counts, design, offset) = small();
1451 let dispersion = Recycled::scalar(0.1);
1452 let inp = input(&counts, 3, &design, 2, &dispersion, &offset);
1453 let fit = fit_of(&inp);
1454 let by_coef = glm_lrt(&inp, &fit, &Tested::Coef(vec![1]), None).unwrap();
1455 let by_contrast = glm_lrt(
1456 &inp,
1457 &fit,
1458 &Tested::Contrast {
1459 values: vec![0.0, 1.0],
1460 n_contrasts: 1,
1461 },
1462 None,
1463 )
1464 .unwrap();
1465
1466 for gene in 0..3 {
1467 assert_relative_eq!(
1468 by_contrast.log_fc[gene],
1469 by_coef.log_fc[gene],
1470 max_relative = 1e-12
1471 );
1472 assert_relative_eq!(
1473 by_contrast.statistic[gene],
1474 by_coef.statistic[gene],
1475 max_relative = 1e-10
1476 );
1477 assert_relative_eq!(
1478 by_contrast.p_value[gene],
1479 by_coef.p_value[gene],
1480 max_relative = 1e-10
1481 );
1482 }
1483 }
1484
1485 #[test]
1492 fn test_glm_lrt_matches_edger_on_a_group_contrast() {
1493 let (counts, design, offset) = three_group();
1494 let dispersion = Recycled::scalar(0.15);
1495 let inp = input(&counts, 4, &design, 3, &dispersion, &offset);
1496 let fit = fit_of(&inp);
1497 let out = glm_lrt(
1498 &inp,
1499 &fit,
1500 &Tested::Contrast {
1501 values: vec![-1.0, 1.0, 0.0],
1502 n_contrasts: 1,
1503 },
1504 None,
1505 )
1506 .unwrap();
1507
1508 let log_fc = [
1509 1.314_710_781_299_474,
1510 -0.582_462_832_327_925_1,
1511 4.284_529_874_638_284_5,
1512 -0.388_058_150_000_178_07,
1513 ];
1514 let lr = [
1515 4.032_180_454_464_596,
1516 0.956_446_195_492_408_8,
1517 3.679_427_818_235_808_5,
1518 0.243_122_673_596_927_35,
1519 ];
1520 let p = [
1521 0.044_640_210_361_232_81,
1522 0.328_083_883_367_401_46,
1523 0.055_087_751_666_934_24,
1524 0.621_959_796_729_172_5,
1525 ];
1526 assert_eq!(out.df_test, 1.0);
1527 for gene in 0..4 {
1528 assert_relative_eq!(out.log_fc[gene], log_fc[gene], max_relative = 1e-10);
1529 assert_relative_eq!(out.statistic[gene], lr[gene], max_relative = 1e-10);
1530 assert_relative_eq!(out.p_value[gene], p[gene], max_relative = 1e-10);
1531 }
1532 }
1533
1534 #[test]
1540 fn test_glm_lrt_matches_edger_on_a_two_column_contrast() {
1541 let (counts, design, offset) = three_group();
1542 let dispersion = Recycled::scalar(0.15);
1543 let inp = input(&counts, 4, &design, 3, &dispersion, &offset);
1544 let fit = fit_of(&inp);
1545 let out = glm_lrt(
1546 &inp,
1547 &fit,
1548 &Tested::Contrast {
1549 values: vec![-1.0, 1.0, 0.0, -1.0, 0.0, 1.0],
1550 n_contrasts: 2,
1551 },
1552 None,
1553 )
1554 .unwrap();
1555
1556 let lr = [
1557 7.489_094_796_878_978_5,
1558 5.604_306_004_386_178,
1559 22.625_269_055_571_128,
1560 3.060_853_924_032_122,
1561 ];
1562 let p = [
1563 2.364_632_899_149_205_6e-2,
1564 6.067_927_926_597_326e-2,
1565 1.221_758_161_337_463_6e-5,
1566 2.164_432_345_449_859_9e-1,
1567 ];
1568 assert_eq!(out.df_test, 2.0);
1569 for gene in 0..4 {
1570 assert_relative_eq!(out.statistic[gene], lr[gene], max_relative = 1e-8);
1574 assert_relative_eq!(out.p_value[gene], p[gene], max_relative = 1e-7);
1575 }
1576 assert_relative_eq!(out.log_fc[0], 1.314_710_781_299_474, max_relative = 1e-10);
1578 }
1579
1580 #[test]
1586 fn test_glm_lrt_matches_edger_on_two_coefficients() {
1587 let (counts, design, offset) = three_group();
1588 let dispersion = Recycled::scalar(0.15);
1589 let inp = input(&counts, 4, &design, 3, &dispersion, &offset);
1590 let fit = fit_of(&inp);
1591 let out = glm_lrt(&inp, &fit, &Tested::Coef(vec![1, 2]), None).unwrap();
1592
1593 let lr = [
1594 10.585_850_031_983_432,
1595 19.010_202_865_950_3,
1596 86.152_535_750_910_06,
1597 92.074_489_192_513_13,
1598 ];
1599 let p = [
1600 5.027_034_577_232_391e-3,
1601 7.447_095_063_546_895e-5,
1602 1.959_812_668_604_125_9e-19,
1603 1.014_562_275_310_370_5e-20,
1604 ];
1605 assert_eq!(out.df_test, 2.0);
1606 for gene in 0..4 {
1607 assert_relative_eq!(out.statistic[gene], lr[gene], max_relative = 1e-8);
1611 assert_relative_eq!(out.p_value[gene], p[gene], max_relative = 1e-8);
1612 }
1613 }
1614
1615 #[test]
1624 fn test_glm_lrt_in_the_far_tail_and_on_a_zero_gene() {
1625 let counts = vec![
1626 20.0, 22.0, 18.0, 5000.0, 5200.0, 4800.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 100.0, 110.0, 90.0, 105.0, 95.0, 100.0, ];
1630 let design = vec![1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
1631 let libraries: [f64; 6] = [120.0, 132.0, 108.0, 5105.0, 5295.0, 4900.0];
1632 let offset = Recycled::by_sample(libraries.iter().map(|v| v.ln()).collect());
1633 let dispersion = Recycled::scalar(0.01);
1634 let inp = input(&counts, 3, &design, 2, &dispersion, &offset);
1635 let fit = fit_of(&inp);
1636 let out = glm_lrt(&inp, &fit, &Tested::Coef(vec![1]), None).unwrap();
1637
1638 let lr = [172.284_890_441_011_5, 0.0, 927.674_565_354_018_5];
1639 let p = [2.344_998_823_345_881e-39, 1.0, 9.458_079_718_661_872e-204];
1640 for gene in 0..3 {
1641 assert_relative_eq!(out.statistic[gene], lr[gene], epsilon = 1e-9);
1642 assert_relative_eq!(out.p_value[gene], p[gene], max_relative = 1e-10);
1643 }
1644 assert!(out.log_fc[1].abs() < 1e-12);
1646 assert_eq!(out.p_value[1], 1.0);
1647 }
1648
1649 struct LegacyQl {
1655 s2_post: Vec<f64>,
1657 df_prior: Vec<f64>,
1659 df_residual_zeros: Vec<f64>,
1661 df_residual_adj: Vec<f64>,
1663 fitted: Vec<f64>,
1665 }
1666
1667 impl LegacyQl {
1668 fn summary(&self) -> QlSummary<'_> {
1670 QlSummary {
1671 s2_post: &self.s2_post,
1672 df_prior: &self.df_prior,
1673 df_residual_adj: &self.df_residual_adj,
1674 df_residual_zeros: Some(&self.df_residual_zeros),
1675 fitted: &self.fitted,
1676 average_ql_dispersion: None,
1677 }
1678 }
1679 }
1680
1681 fn ql_legacy() -> LegacyQl {
1687 let s2_post = vec![
1688 0.049_281_563_700_074_56,
1689 0.011_659_677_647_637_47,
1690 1.728_524_387_495_482_8,
1691 0.007_316_196_288_206_033,
1692 0.561_520_580_113_145_8,
1693 2.054_690_133_109_537_5,
1694 0.042_104_651_793_427_66,
1695 0.108_585_097_222_579_13,
1696 ];
1697 let df_prior = vec![4.460_965_388_747_048];
1698 let df_residual_zeros = vec![4.0; 8];
1699 let df_residual_adj = vec![4.0; 8];
1700 let fitted = vec![
1701 10.933_744_628_513_18,
1702 10.713_602_119_214_256,
1703 11.374_029_647_111_028,
1704 40.492_974_243_000_01,
1705 40.378_587_310_110_184,
1706 41.179_295_840_339_01,
1707 49.659_282_993_114_66,
1709 48.659_431_657_682_816,
1710 51.658_985_663_978_34,
1711 49.769_601_759_585_95,
1712 49.629_009_664_219_89,
1713 50.613_154_331_782_35,
1714 2.308_489_259_791_181,
1716 2.262_009_610_265_183,
1717 2.401_448_558_843_174_7,
1718 1.328_158_182_691_277_4,
1719 1.324_406_323_418_138_2,
1720 1.350_669_338_330_113_2,
1721 198.595_261_331_926_4,
1723 194.596_699_023_229_86,
1724 206.592_385_949_319_44,
1725 203.991_992_958_926_5,
1726 203.415_744_391_245_88,
1727 207.449_484_365_010_05,
1728 0.995_197_722_682_345_2,
1730 0.975_160_184_641_760_9,
1731 1.035_272_798_763_513_5,
1732 7.965_675_239_409_282,
1733 7.943_173_331_953_323,
1734 8.100_686_684_145_035,
1735 0.331_402_866_955_448_6,
1737 0.324_730_326_010_036_9,
1738 0.344_747_948_846_272_1,
1739 0.331_641_809_994_310_75,
1740 0.330_704_968_723_140_37,
1741 0.337_262_857_621_333_07,
1742 30.181_552_543_631_07,
1744 29.573_870_277_651_917,
1745 31.396_917_075_589_37,
1746 29.860_397_478_017_26,
1747 29.776_046_072_712_123,
1748 30.366_505_909_848_07,
1749 4.974_310_683_547_974,
1751 4.874_156_777_167_812,
1752 5.174_618_496_308_295,
1753 20.245_100_646_285_735,
1754 20.187_911_096_437_47,
1755 20.588_237_945_375_333,
1756 ];
1757 LegacyQl {
1758 s2_post,
1759 df_prior,
1760 df_residual_zeros,
1761 df_residual_adj,
1762 fitted,
1763 }
1764 }
1765
1766 #[test]
1774 fn test_glm_ql_ftest_matches_edger_with_and_without_the_poisson_bound() {
1775 let (counts, design, offset) = wide();
1776 let dispersion = Recycled::scalar(0.1);
1777 let inp = input(&counts, 8, &design, 2, &dispersion, &offset);
1778 let fit = fit_of(&inp);
1779 let legacy = ql_legacy();
1780 let ql = legacy.summary();
1781
1782 let f_stat = [
1783 2.520_057_973_402_189_2e2,
1784 3.095_392_962_353_460_2e1,
1785 6.853_203_261_465_175e-1,
1786 4.126_759_810_823_103_4e1,
1787 1.979_404_448_269_534e1,
1788 6.917_310_190_199_355e-3,
1789 8.944_754_825_397_515,
1790 9.876_119_655_893_109e1,
1791 ];
1792 let bounded = [
1793 1.364_107_878_599_685_5e-7,
1794 1.407_007_683_799_561e-1,
1795 4.304_904_955_542_642e-1,
1796 1.127_377_026_183_572_2e-2,
1797 1.865_737_655_809_165_6e-3,
1798 9.356_503_273_786_928e-1,
1799 2.229_724_201_299_175_8e-1,
1800 5.944_660_235_862_634e-6,
1801 ];
1802 let unbounded = [
1803 1.364_107_878_599_685_5e-7,
1804 4.366_386_781_336_41e-4,
1805 4.304_904_955_542_642e-1,
1806 1.598_459_203_229_021_3e-4,
1807 1.865_737_655_809_165_6e-3,
1808 9.356_503_273_786_928e-1,
1809 1.625_485_152_929_125_4e-2,
1810 5.944_660_235_862_634e-6,
1811 ];
1812
1813 let with_bound = glm_ql_ftest(&inp, &fit, &ql, &Tested::Coef(vec![1]), true).unwrap();
1814 let without = glm_ql_ftest(&inp, &fit, &ql, &Tested::Coef(vec![1]), false).unwrap();
1815
1816 assert_eq!(with_bound.df_test, 1.0);
1817 let df_total = with_bound.df_total.as_ref().unwrap();
1818 for gene in 0..8 {
1819 assert_relative_eq!(df_total[gene], 8.460_965_388_747_049, max_relative = 1e-12);
1820 assert_relative_eq!(
1821 with_bound.statistic[gene],
1822 f_stat[gene],
1823 max_relative = 1e-10
1824 );
1825 assert_relative_eq!(
1826 with_bound.p_value[gene],
1827 bounded[gene],
1828 max_relative = 1e-10
1829 );
1830 assert_relative_eq!(without.p_value[gene], unbounded[gene], max_relative = 1e-10);
1831 }
1832 }
1833
1834 #[test]
1843 fn test_glm_ql_ftest_without_zero_adjusted_df_ignores_the_bound() {
1844 let (counts, design, offset) = wide();
1845 let dispersion = Recycled::scalar(0.1);
1846 let inp = input(&counts, 8, &design, 2, &dispersion, &offset);
1847 let fit = fit_of(&inp);
1848 let s2_post = [
1849 0.048_188_444_222_375_504,
1850 0.024_446_203_487_533_725,
1851 1.180_185_563_640_951_3,
1852 0.005_271_328_303_740_723,
1853 0.546_740_168_004_454_8,
1854 2.445_938_293_050_726_3,
1855 0.039_977_551_427_882_49,
1856 0.154_447_261_383_920_18,
1857 ];
1858 let df_prior = [8_134.844_780_382_662];
1859 let adj = [
1860 3.973_521_938_241_366,
1861 3.999_098_159_890_601,
1862 5.529_668_600_344_663,
1863 4.000_963_615_960_567_5,
1864 5.978_981_705_096_965,
1865 9.394_040_228_202_032,
1866 3.995_519_515_274_4,
1867 3.929_173_912_871_219,
1868 ];
1869 let ql = QlSummary {
1870 s2_post: &s2_post,
1871 df_prior: &df_prior,
1872 df_residual_adj: &adj,
1873 df_residual_zeros: None,
1874 fitted: &[0.0; 48],
1875 average_ql_dispersion: Some(1.0),
1876 };
1877
1878 let out = glm_ql_ftest(&inp, &fit, &ql, &Tested::Coef(vec![1]), true).unwrap();
1879 let f_stat = [
1880 2.577_223_638_326_844_4e2,
1881 1.476_355_383_862_022_7e1,
1882 1.003_734_440_994_232_6,
1883 5.727_623_678_615_572e1,
1884 2.032_915_083_096_279_2e1,
1885 5.810_828_930_493_346e-3,
1886 9.420_681_703_839_223,
1887 6.943_466_678_578_658e1,
1888 ];
1889 let p = [
1890 7.252_743_073_151_62e-17,
1891 5.442_532_006_559_633e-4,
1892 3.239_246_833_697_202_5e-1,
1893 1.276_727_950_912_218_5e-8,
1894 8.231_984_735_990_331e-5,
1895 9.397_116_797_565_639e-1,
1896 4.348_612_379_217_248e-3,
1897 1.606_882_310_788_258_1e-9,
1898 ];
1899 let df_total = out.df_total.as_ref().unwrap();
1900 for gene in 0..8 {
1901 assert_relative_eq!(df_total[gene], 32.0, max_relative = 1e-12);
1903 assert_relative_eq!(out.statistic[gene], f_stat[gene], max_relative = 1e-10);
1904 assert_relative_eq!(out.p_value[gene], p[gene], max_relative = 1e-10);
1905 }
1906 }
1907
1908 #[test]
1918 fn test_glm_ql_ftest_far_tail() {
1919 let (counts, design, offset) = wide();
1920 let dispersion = Recycled::scalar(0.1);
1921 let inp = input(&counts, 8, &design, 2, &dispersion, &offset);
1922 let fit = fit_of(&inp);
1923 let s2_post = [1e-4; 8];
1924 let df_prior = [1000.0];
1925 let adj = [4.0; 8];
1926 let ql = QlSummary {
1927 s2_post: &s2_post,
1928 df_prior: &df_prior,
1929 df_residual_adj: &adj,
1930 df_residual_zeros: None,
1931 fitted: &[0.0; 48],
1932 average_ql_dispersion: None,
1933 };
1934 let out = glm_ql_ftest(&inp, &fit, &ql, &Tested::Coef(vec![1]), false).unwrap();
1935
1936 let p = [
1937 5.261_561_085_923_352_5e-59,
1938 1.779_918_291_545_363_5e-34,
1939 1.079_125_786_482_700_5e-42,
1940 3.012_683_792_206_176e-33,
1941 3.104_755_317_213_854_3e-58,
1942 2.604_261_088_389_738e-13,
1943 9.055_927_316_446_368e-35,
1944 5.503_704_196_958_632e-58,
1945 ];
1946 for (got, want) in out.p_value.iter().zip(p.iter()) {
1947 assert_relative_eq!(got, want, max_relative = 1e-10);
1948 }
1949 }
1950
1951 #[test]
1961 fn test_glm_treat_lrt_at_lfc_one() {
1962 let (counts, design, offset) = wide();
1963 let dispersion = Recycled::scalar(0.1);
1964 let inp = input(&counts, 8, &design, 2, &dispersion, &offset);
1965 let fit = fit_of(&inp);
1966
1967 let interval = [
1968 0.025_915_419_120_752_61,
1969 0.862_946_855_826_681_2,
1970 0.335_130_846_838_047_5,
1971 0.901_730_557_007_466_8,
1972 0.005_453_587_125_419_520_4,
1973 0.910_944_563_566_415_2,
1974 0.836_150_163_403_213_7,
1975 0.023_915_536_140_601_778,
1976 ];
1977 let worst = [
1978 0.084_605_550_556_336_65,
1979 0.968_617_766_474_667_2,
1980 0.497_500_093_784_695_2,
1981 0.981_164_069_793_248_7,
1982 0.021_394_389_526_003_44,
1983 0.917_471_298_505_725_4,
1984 0.957_971_652_323_224_3,
1985 0.078_981_717_742_023_32,
1986 ];
1987 let log_fc = [
1988 1.630_328_521_653_603_4,
1989 -0.244_625_377_904_330_73,
1990 -0.975_328_979_782_516_8,
1991 -0.209_626_606_575_486_35,
1992 2.620_328_451_924_648,
1993 -0.180_553_480_809_313_55,
1994 -0.262_787_305_134_895_23,
1995 1.753_538_627_526_939,
1996 ];
1997
1998 let a = glm_treat(
1999 &inp,
2000 &fit,
2001 None,
2002 &Tested::Coef(vec![1]),
2003 1.0,
2004 TreatNull::Interval,
2005 )
2006 .unwrap();
2007 let b = glm_treat(
2008 &inp,
2009 &fit,
2010 None,
2011 &Tested::Coef(vec![1]),
2012 1.0,
2013 TreatNull::WorstCase,
2014 )
2015 .unwrap();
2016
2017 assert_eq!(a.df_test, 1.0);
2018 assert!(a.df_total.is_none());
2019 for gene in 0..8 {
2020 assert_relative_eq!(a.log_fc[gene], log_fc[gene], max_relative = 1e-10);
2021 assert_relative_eq!(a.p_value[gene], interval[gene], max_relative = 1e-10);
2022 assert_relative_eq!(b.p_value[gene], worst[gene], max_relative = 1e-10);
2023 }
2024 }
2025
2026 #[test]
2034 fn test_glm_treat_lrt_at_other_thresholds() {
2035 let (counts, design, offset) = wide();
2036 let dispersion = Recycled::scalar(0.1);
2037 let inp = input(&counts, 8, &design, 2, &dispersion, &offset);
2038 let fit = fit_of(&inp);
2039
2040 let half = [
2041 0.001_747_492_463_475_674_3,
2042 0.618_965_088_475_578_5,
2043 0.297_496_484_707_449_94,
2044 0.661_667_162_539_030_5,
2045 0.001_731_384_373_595_051_3,
2046 0.906_630_646_614_269_5,
2047 0.602_748_783_056_843_9,
2048 0.002_830_446_598_910_725,
2049 ];
2050 let default_interval = [
2051 0.000_772_184_486_707_990_3,
2052 0.574_647_381_380_633_3,
2053 0.282_377_207_940_779_64,
2054 0.611_261_200_442_731_6,
2055 0.001_073_803_287_152_239_8,
2056 0.905_530_268_033_189_1,
2057 0.563_833_195_684_254,
2058 0.001_600_134_051_293_08,
2059 ];
2060 let default_worst = [
2061 0.001_557_323_252_030_769_7,
2062 0.624_202_827_105_601_4,
2063 0.294_451_670_100_143_67,
2064 0.663_637_030_156_907_3,
2065 0.001_546_443_189_297_852,
2066 0.906_031_012_374_345_1,
2067 0.609_637_644_920_852_7,
2068 0.002_786_515_725_771_390_3,
2069 ];
2070
2071 let a = glm_treat(
2072 &inp,
2073 &fit,
2074 None,
2075 &Tested::Coef(vec![1]),
2076 0.5,
2077 TreatNull::Interval,
2078 )
2079 .unwrap();
2080 let lfc = 1.2_f64.log2();
2081 let b = glm_treat(
2082 &inp,
2083 &fit,
2084 None,
2085 &Tested::Coef(vec![1]),
2086 lfc,
2087 TreatNull::Interval,
2088 )
2089 .unwrap();
2090 let c = glm_treat(
2091 &inp,
2092 &fit,
2093 None,
2094 &Tested::Coef(vec![1]),
2095 lfc,
2096 TreatNull::WorstCase,
2097 )
2098 .unwrap();
2099 for gene in 0..8 {
2100 assert_relative_eq!(a.p_value[gene], half[gene], max_relative = 1e-10);
2101 assert_relative_eq!(
2102 b.p_value[gene],
2103 default_interval[gene],
2104 max_relative = 1e-10
2105 );
2106 assert_relative_eq!(c.p_value[gene], default_worst[gene], max_relative = 1e-10);
2107 }
2108 }
2109
2110 #[test]
2118 fn test_glm_treat_matches_edger_on_a_contrast() {
2119 let (counts, design, offset) = three_group();
2120 let dispersion = Recycled::scalar(0.15);
2121 let inp = input(&counts, 4, &design, 3, &dispersion, &offset);
2122 let fit = fit_of(&inp);
2123 let contrast = Tested::Contrast {
2124 values: vec![-1.0, 1.0, 0.0],
2125 n_contrasts: 1,
2126 };
2127
2128 let interval = [
2129 0.136_036_070_900_815_65,
2130 0.528_392_553_392_713_7,
2131 0.060_483_368_850_335_27,
2132 0.689_127_004_865_843_3,
2133 ];
2134 let worst = [
2135 0.313_021_256_154_164_54,
2136 0.762_143_061_238_379_5,
2137 0.079_816_920_784_910_93,
2138 0.818_329_089_039_554_8,
2139 ];
2140 let log_fc = [
2141 1.314_710_781_299_474,
2142 -0.582_462_832_327_925_1,
2143 4.284_529_874_638_284_5,
2144 -0.388_058_150_000_178_07,
2145 ];
2146
2147 let a = glm_treat(&inp, &fit, None, &contrast, 1.0, TreatNull::Interval).unwrap();
2148 let b = glm_treat(&inp, &fit, None, &contrast, 1.0, TreatNull::WorstCase).unwrap();
2149 for gene in 0..4 {
2150 assert_relative_eq!(a.log_fc[gene], log_fc[gene], max_relative = 1e-10);
2151 assert_relative_eq!(a.p_value[gene], interval[gene], max_relative = 1e-8);
2154 assert_relative_eq!(b.p_value[gene], worst[gene], max_relative = 1e-8);
2155 }
2156 }
2157
2158 #[test]
2168 fn test_glm_treat_quasi_likelihood_with_the_poisson_bound() {
2169 let (counts, design, offset) = wide();
2170 let dispersion = Recycled::scalar(0.1);
2171 let inp = input(&counts, 8, &design, 2, &dispersion, &offset);
2172 let fit = fit_of(&inp);
2173 let legacy = ql_legacy();
2174 let ql = legacy.summary();
2175
2176 let interval = [
2177 0.002_095_420_937_924_167_8,
2178 0.999_993_018_183_757_4,
2179 0.487_393_893_826_179_1,
2180 1.000_000_000_000_001,
2181 0.005_735_420_883_968_396_5,
2182 0.938_980_124_834_262_6,
2183 0.999_431_608_175_005_4,
2184 0.006_193_294_762_041_081_5,
2185 ];
2186 let worst = [
2187 0.002_095_420_937_924_167_8,
2188 0.999_999_963_126_753_1,
2189 0.556_398_596_920_132_5,
2190 1.000_000_000_000_001,
2191 0.012_897_636_268_926_604,
2192 0.941_055_590_649_069_9,
2193 0.999_988_456_782_335_7,
2194 0.006_193_294_762_041_081_5,
2195 ];
2196
2197 let a = glm_treat(
2198 &inp,
2199 &fit,
2200 Some(&ql),
2201 &Tested::Coef(vec![1]),
2202 1.0,
2203 TreatNull::Interval,
2204 )
2205 .unwrap();
2206 let b = glm_treat(
2207 &inp,
2208 &fit,
2209 Some(&ql),
2210 &Tested::Coef(vec![1]),
2211 1.0,
2212 TreatNull::WorstCase,
2213 )
2214 .unwrap();
2215 for gene in 0..8 {
2216 assert_relative_eq!(a.p_value[gene], interval[gene], max_relative = 1e-10);
2217 assert_relative_eq!(b.p_value[gene], worst[gene], max_relative = 1e-10);
2218 }
2219 assert_eq!(a.p_value[0], b.p_value[0]);
2220 assert_eq!(a.p_value[7], b.p_value[7]);
2221 assert!(a.df_total.is_some());
2222 }
2223
2224 #[test]
2231 fn test_glm_treat_quasi_likelihood_at_half_a_fold() {
2232 let (counts, design, offset) = wide();
2233 let dispersion = Recycled::scalar(0.1);
2234 let inp = input(&counts, 8, &design, 2, &dispersion, &offset);
2235 let fit = fit_of(&inp);
2236 let legacy = ql_legacy();
2237 let ql = legacy.summary();
2238
2239 let interval = [
2240 1.156_930_720_415_820_5e-6,
2241 9.958_190_513_004_438e-1,
2242 4.457_622_253_174_958e-1,
2243 9.995_944_678_440_168e-1,
2244 2.585_377_345_926_488_7e-3,
2245 9.365_161_866_221_516e-1,
2246 9.136_827_161_461_856e-1,
2247 7.667_539_435_702_798e-5,
2248 ];
2249 let worst = [
2250 1.299_077_183_789_172_9e-6,
2251 9.998_319_316_629_676e-1,
2252 4.654_945_529_449_657e-1,
2253 9.999_928_996_412_859e-1,
2254 3.653_055_884_276_585e-3,
2255 9.370_713_782_972_866e-1,
2256 9.866_427_824_263_567e-1,
2257 7.667_539_435_702_798e-5,
2258 ];
2259
2260 let a = glm_treat(
2261 &inp,
2262 &fit,
2263 Some(&ql),
2264 &Tested::Coef(vec![1]),
2265 0.5,
2266 TreatNull::Interval,
2267 )
2268 .unwrap();
2269 let b = glm_treat(
2270 &inp,
2271 &fit,
2272 Some(&ql),
2273 &Tested::Coef(vec![1]),
2274 0.5,
2275 TreatNull::WorstCase,
2276 )
2277 .unwrap();
2278 for gene in 0..8 {
2279 assert_relative_eq!(a.p_value[gene], interval[gene], max_relative = 1e-10);
2280 assert_relative_eq!(b.p_value[gene], worst[gene], max_relative = 1e-10);
2281 }
2282 }
2283
2284 #[test]
2286 fn test_glm_treat_with_zero_lfc_falls_back() {
2287 let (counts, design, offset) = wide();
2288 let dispersion = Recycled::scalar(0.1);
2289 let inp = input(&counts, 8, &design, 2, &dispersion, &offset);
2290 let fit = fit_of(&inp);
2291 let tested = Tested::Coef(vec![1]);
2292
2293 let lrt = glm_lrt(&inp, &fit, &tested, None).unwrap();
2294 let treat = glm_treat(&inp, &fit, None, &tested, 0.0, TreatNull::Interval).unwrap();
2295 for gene in 0..8 {
2296 assert_eq!(treat.p_value[gene], lrt.p_value[gene]);
2297 }
2298
2299 let legacy = ql_legacy();
2300 let ql = legacy.summary();
2301 let ftest = glm_ql_ftest(&inp, &fit, &ql, &tested, true).unwrap();
2302 let treat_ql = glm_treat(&inp, &fit, Some(&ql), &tested, 0.0, TreatNull::Interval).unwrap();
2303 for gene in 0..8 {
2304 assert_eq!(treat_ql.p_value[gene], ftest.p_value[gene]);
2305 }
2306 }
2307
2308 #[test]
2310 fn test_glm_treat_is_monotone_in_the_threshold() {
2311 let (counts, design, offset) = wide();
2312 let dispersion = Recycled::scalar(0.1);
2313 let inp = input(&counts, 8, &design, 2, &dispersion, &offset);
2314 let fit = fit_of(&inp);
2315 let tested = Tested::Coef(vec![1]);
2316
2317 let small = glm_treat(&inp, &fit, None, &tested, 0.25, TreatNull::Interval).unwrap();
2318 let large = glm_treat(&inp, &fit, None, &tested, 1.5, TreatNull::Interval).unwrap();
2319 for gene in 0..8 {
2320 assert!(
2321 large.p_value[gene] >= small.p_value[gene] - 1e-12,
2322 "gene {gene}: {} < {}",
2323 large.p_value[gene],
2324 small.p_value[gene]
2325 );
2326 }
2327 }
2328
2329 #[test]
2337 fn test_zscore_t_matches_limma() {
2338 let df = 8.460_965_388_747_049;
2339 let expected = [
2340 0.481_981_798_779_442_4,
2341 1.759_032_448_322_397_1,
2342 3.653_010_684_919_440_4,
2343 5.609_979_465_791_493,
2344 ];
2345 for (x, want) in [0.5, 2.0, 6.0, 20.0].iter().zip(expected.iter()) {
2346 assert_relative_eq!(zscore_t(*x, df).unwrap(), want, max_relative = 1e-10);
2347 }
2348 assert_relative_eq!(
2350 zscore_t(-2.0, df).unwrap(),
2351 -zscore_t(2.0, df).unwrap(),
2352 max_relative = 1e-12
2353 );
2354 assert_eq!(zscore_t(0.0, df).unwrap(), 0.0);
2355 }
2356
2357 #[test]
2361 fn test_integrate_pnorm_matches_edger() {
2362 let a = [-1.0, -3.0, 0.5, 2.0];
2363 let b = [0.470_402, -1.529_598, 0.5, 3.470_402];
2364 let expected = [
2365 0.404_086_872_171_341_76,
2366 0.018_364_485_997_322_027,
2367 0.691_462_461_274_013,
2368 0.994_270_314_804_512_8,
2369 ];
2370 for gene in 0..4 {
2371 assert_relative_eq!(
2372 integrate_pnorm(a[gene], b[gene]),
2373 expected[gene],
2374 max_relative = 1e-10
2375 );
2376 }
2377 }
2378
2379 #[test]
2384 fn test_log_cpm_is_passed_through() {
2385 let (counts, design, offset) = small();
2386 let dispersion = Recycled::scalar(0.1);
2387 let cpm = [
2388 18.305_644_059_893_947,
2389 19.322_293_068_742_58,
2390 15.526_879_626_455_655,
2391 ];
2392 let mut inp = input(&counts, 3, &design, 2, &dispersion, &offset);
2393 inp.log_cpm = Some(&cpm);
2394 let fit = fit_of(&inp);
2395 let out = glm_lrt(&inp, &fit, &Tested::Coef(vec![1]), None).unwrap();
2396 assert_eq!(out.log_cpm.unwrap(), cpm.to_vec());
2397 }
2398
2399 #[test]
2400 fn test_rejects_an_out_of_range_coefficient() {
2401 let (counts, design, offset) = small();
2402 let dispersion = Recycled::scalar(0.1);
2403 let inp = input(&counts, 3, &design, 2, &dispersion, &offset);
2404 let fit = fit_of(&inp);
2405 let err = glm_lrt(&inp, &fit, &Tested::Coef(vec![7]), None).unwrap_err();
2406 assert!(matches!(err, EdgeErrors::CoefOutOfRange { .. }));
2407 }
2408
2409 #[test]
2410 fn test_rejects_testing_every_coefficient() {
2411 let (counts, design, offset) = small();
2412 let dispersion = Recycled::scalar(0.1);
2413 let inp = input(&counts, 3, &design, 2, &dispersion, &offset);
2414 let fit = fit_of(&inp);
2415 let err = glm_lrt(&inp, &fit, &Tested::Coef(vec![0, 1]), None).unwrap_err();
2416 assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
2417 }
2418
2419 #[test]
2420 fn test_rejects_a_zero_contrast() {
2421 let (counts, design, offset) = small();
2422 let dispersion = Recycled::scalar(0.1);
2423 let inp = input(&counts, 3, &design, 2, &dispersion, &offset);
2424 let fit = fit_of(&inp);
2425 let err = glm_lrt(
2426 &inp,
2427 &fit,
2428 &Tested::Contrast {
2429 values: vec![0.0, 0.0],
2430 n_contrasts: 1,
2431 },
2432 None,
2433 )
2434 .unwrap_err();
2435 assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
2436 }
2437
2438 #[test]
2439 fn test_rejects_a_rank_deficient_contrast() {
2440 let (counts, design, offset) = three_group();
2441 let dispersion = Recycled::scalar(0.15);
2442 let inp = input(&counts, 4, &design, 3, &dispersion, &offset);
2443 let fit = fit_of(&inp);
2444 let err = glm_lrt(
2445 &inp,
2446 &fit,
2447 &Tested::Contrast {
2448 values: vec![-1.0, 1.0, 0.0, -2.0, 2.0, 0.0],
2449 n_contrasts: 2,
2450 },
2451 None,
2452 )
2453 .unwrap_err();
2454 assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
2455 }
2456
2457 #[test]
2458 fn test_rejects_a_negative_lfc() {
2459 let (counts, design, offset) = small();
2460 let dispersion = Recycled::scalar(0.1);
2461 let inp = input(&counts, 3, &design, 2, &dispersion, &offset);
2462 let fit = fit_of(&inp);
2463 let err = glm_treat(
2464 &inp,
2465 &fit,
2466 None,
2467 &Tested::Coef(vec![1]),
2468 -0.5,
2469 TreatNull::Interval,
2470 )
2471 .unwrap_err();
2472 assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
2473 }
2474
2475 #[test]
2476 fn test_rejects_a_mismatched_quasi_likelihood_summary() {
2477 let (counts, design, offset) = small();
2478 let dispersion = Recycled::scalar(0.1);
2479 let inp = input(&counts, 3, &design, 2, &dispersion, &offset);
2480 let fit = fit_of(&inp);
2481 let ql = QlSummary {
2482 s2_post: &[1.0, 1.0],
2483 df_prior: &[4.0],
2484 df_residual_adj: &[4.0; 3],
2485 df_residual_zeros: None,
2486 fitted: &[0.0; 18],
2487 average_ql_dispersion: None,
2488 };
2489 let err = glm_ql_ftest(&inp, &fit, &ql, &Tested::Coef(vec![1]), false).unwrap_err();
2490 assert!(matches!(err, EdgeErrors::LengthMismatch { .. }));
2491 }
2492
2493 #[test]
2496 fn test_treat_is_insensitive_to_the_offset_storage() {
2497 let (counts, design, _) = wide();
2498 let libraries: [f64; 6] = [298.0, 292.0, 310.0, 354.0, 353.0, 360.0];
2499 let by_sample = Recycled::by_sample(libraries.iter().map(|v| v.ln()).collect());
2500 let full = Recycled::Full(
2501 (0..8)
2502 .flat_map(|_| libraries.iter().map(|v| v.ln()))
2503 .collect(),
2504 );
2505 let dispersion = Recycled::scalar(0.1);
2506
2507 let compact = input(&counts, 8, &design, 2, &dispersion, &by_sample);
2508 let dense = input(&counts, 8, &design, 2, &dispersion, &full);
2509 let fit = fit_of(&compact);
2510 let a = glm_treat(
2511 &compact,
2512 &fit,
2513 None,
2514 &Tested::Coef(vec![1]),
2515 1.0,
2516 TreatNull::Interval,
2517 )
2518 .unwrap();
2519 let b = glm_treat(
2520 &dense,
2521 &fit,
2522 None,
2523 &Tested::Coef(vec![1]),
2524 1.0,
2525 TreatNull::Interval,
2526 )
2527 .unwrap();
2528 for gene in 0..8 {
2529 assert_relative_eq!(a.p_value[gene], b.p_value[gene], max_relative = 1e-12);
2530 }
2531 }
2532}