1use crate::limma::marray::MArrayLm;
12use crate::numeric::dist::t_ppf;
13use crate::numeric::stats::p_adjust_bh;
14use crate::prelude::*;
15
16pub const DEFAULT_CONF_LEVEL: f64 = 0.95;
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
29pub enum TopTableSort {
30 #[default]
33 B,
34 LogFc,
36 AveExpr,
38 PValue,
40 T,
42 None,
44}
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum TopTableResort {
53 LogFc,
55 AveExpr,
57 PValue,
59 T,
61 B,
63}
64
65#[derive(Clone, Debug)]
68pub struct TopTableParams {
69 pub number: usize,
72 pub sort_by: TopTableSort,
75 pub resort_by: Option<TopTableResort>,
78 pub p_value: f64,
80 pub lfc: f64,
82 pub confint: Option<f64>,
85}
86
87impl Default for TopTableParams {
88 fn default() -> Self {
89 Self {
90 number: usize::MAX,
91 sort_by: TopTableSort::B,
92 resort_by: None,
93 p_value: 1.0,
94 lfc: 0.0,
95 confint: None,
96 }
97 }
98}
99
100#[derive(Clone, Debug)]
105pub struct TopTable {
106 pub index: Vec<usize>,
108 pub log_fc: Vec<f64>,
110 pub ci_lower: Option<Vec<f64>>,
112 pub ci_upper: Option<Vec<f64>>,
114 pub ave_expr: Option<Vec<f64>>,
117 pub t: Vec<f64>,
119 pub p_value: Vec<f64>,
121 pub adj_p_value: Vec<f64>,
124 pub b: Vec<f64>,
126}
127
128#[derive(Clone, Debug)]
130pub struct TopTableF {
131 pub index: Vec<usize>,
133 pub coefficients: Vec<f64>,
135 pub n_coef: usize,
137 pub ave_expr: Option<Vec<f64>>,
139 pub f_stat: Vec<f64>,
141 pub p_value: Vec<f64>,
143 pub adj_p_value: Vec<f64>,
145}
146
147fn check_cutoffs(params: &TopTableParams) -> Result<(), EdgeErrors> {
161 if !(params.p_value >= 0.0 && params.p_value <= 1.0) {
162 return Err(EdgeErrors::InvalidArgument(format!(
163 "p_value must lie in [0, 1]; got {}",
164 params.p_value
165 )));
166 }
167 if !(params.lfc >= 0.0 && params.lfc.is_finite()) {
168 return Err(EdgeErrors::InvalidArgument(format!(
169 "lfc must be finite and non-negative; got {}",
170 params.lfc
171 )));
172 }
173 if let Some(level) = params.confint
174 && !(level > 0.0 && level < 1.0)
175 {
176 return Err(EdgeErrors::InvalidArgument(format!(
177 "confint must lie strictly inside (0, 1); got {level}"
178 )));
179 }
180 Ok(())
181}
182
183struct Moderated<'a> {
185 t: &'a [f64],
187 p: &'a [f64],
189 lods: &'a [f64],
191}
192
193fn moderated(fit: &MArrayLm) -> Result<Moderated<'_>, EdgeErrors> {
203 match (
204 fit.t.as_deref(),
205 fit.p_value.as_deref(),
206 fit.lods.as_deref(),
207 ) {
208 (Some(t), Some(p), Some(lods)) => Ok(Moderated { t, p, lods }),
209 _ => Err(EdgeErrors::InvalidArgument(
210 "the fit has no moderated statistics; run `ebayes` first".to_string(),
211 )),
212 }
213}
214
215fn order_by(keys: &[f64], ascending: bool) -> Vec<usize> {
230 let mut order: Vec<usize> = (0..keys.len()).collect();
231 order.sort_by(|&a, &b| {
232 let (x, y) = (keys[a], keys[b]);
233 match (x.is_nan(), y.is_nan()) {
234 (true, true) => std::cmp::Ordering::Equal,
235 (true, false) => std::cmp::Ordering::Greater,
236 (false, true) => std::cmp::Ordering::Less,
237 (false, false) => {
238 if ascending {
239 x.total_cmp(&y)
240 } else {
241 y.total_cmp(&x)
242 }
243 }
244 }
245 });
246 order
247}
248
249pub fn top_table(
275 fit: &MArrayLm,
276 coef: usize,
277 params: Option<TopTableParams>,
278) -> Result<TopTable, EdgeErrors> {
279 let params = params.unwrap_or_default();
280 check_cutoffs(¶ms)?;
281 if coef >= fit.n_coef {
282 return Err(EdgeErrors::CoefOutOfRange {
283 index: coef,
284 n_coef: fit.n_coef,
285 });
286 }
287 let stats = moderated(fit)?;
288 if params.sort_by == TopTableSort::AveExpr && fit.amean.is_none() {
289 return Err(EdgeErrors::InvalidArgument(
290 "cannot sort by average expression: the fit carries no `amean`".to_string(),
291 ));
292 }
293
294 let n_genes = fit.n_genes;
295 let n_coef = fit.n_coef;
296 let column =
297 |src: &[f64]| -> Vec<f64> { (0..n_genes).map(|g| src[g * n_coef + coef]).collect() };
298 let log_fc = column(&fit.coefficients);
299 let tstat = column(stats.t);
300 let p_value = column(stats.p);
301 let b = column(stats.lods);
302 let adj = p_adjust_bh(&p_value);
303
304 let margin = match params.confint {
305 None => None,
306 Some(level) => {
307 let alpha = (1.0 + level) / 2.0;
308 let s2_post = fit.s2_post.as_ref().unwrap();
309 let df_total = fit.df_total.as_ref().unwrap();
310 let stdev = column(&fit.stdev_unscaled);
311 let mut m = Vec::with_capacity(n_genes);
312 for g in 0..n_genes {
313 m.push(s2_post[g].sqrt() * stdev[g] * t_ppf(alpha, df_total[g])?);
314 }
315 Some(m)
316 }
317 };
318
319 let mut kept: Vec<usize> = (0..n_genes).collect();
323 if params.p_value < 1.0 || params.lfc > 0.0 {
324 kept.retain(|&g| {
325 let significant = adj[g] <= params.p_value;
326 let large = log_fc[g].abs() >= params.lfc;
327 significant && large
329 });
330 }
331
332 let keys: Vec<f64> = match params.sort_by {
336 TopTableSort::B => kept.iter().map(|&g| b[g]).collect(),
337 TopTableSort::LogFc => kept.iter().map(|&g| log_fc[g].abs()).collect(),
338 TopTableSort::T => kept.iter().map(|&g| tstat[g].abs()).collect(),
339 TopTableSort::PValue => kept.iter().map(|&g| p_value[g]).collect(),
340 TopTableSort::AveExpr => {
341 let a = fit.amean.as_ref().unwrap();
342 kept.iter().map(|&g| a[g]).collect()
343 }
344 TopTableSort::None => Vec::new(),
345 };
346 if params.sort_by != TopTableSort::None {
347 let ascending = params.sort_by == TopTableSort::PValue;
348 let order = order_by(&keys, ascending);
349 kept = order.into_iter().map(|i| kept[i]).collect();
350 }
351 kept.truncate(params.number);
352
353 if let Some(by) = params.resort_by {
357 let keys: Vec<f64> = match by {
358 TopTableResort::LogFc => kept.iter().map(|&g| log_fc[g]).collect(),
359 TopTableResort::T => kept.iter().map(|&g| tstat[g]).collect(),
360 TopTableResort::B => kept.iter().map(|&g| b[g]).collect(),
361 TopTableResort::PValue => kept.iter().map(|&g| p_value[g]).collect(),
362 TopTableResort::AveExpr => {
363 let a = fit.amean.as_ref().ok_or_else(|| {
364 EdgeErrors::InvalidArgument(
365 "cannot resort by average expression: the fit carries no `amean`"
366 .to_string(),
367 )
368 })?;
369 kept.iter().map(|&g| a[g]).collect()
370 }
371 };
372 let order = order_by(&keys, by == TopTableResort::PValue);
373 kept = order.into_iter().map(|i| kept[i]).collect();
374 }
375
376 let pick = |src: &[f64]| -> Vec<f64> { kept.iter().map(|&g| src[g]).collect() };
377 Ok(TopTable {
378 log_fc: pick(&log_fc),
379 ci_lower: margin
380 .as_ref()
381 .map(|m| kept.iter().map(|&g| log_fc[g] - m[g]).collect()),
382 ci_upper: margin
383 .as_ref()
384 .map(|m| kept.iter().map(|&g| log_fc[g] + m[g]).collect()),
385 ave_expr: fit.amean.as_ref().map(|a| pick(a)),
386 t: pick(&tstat),
387 p_value: pick(&p_value),
388 adj_p_value: pick(&adj),
389 b: pick(&b),
390 index: kept,
391 })
392}
393
394pub fn top_table_f(
420 fit: &MArrayLm,
421 coefs: &[usize],
422 params: Option<TopTableParams>,
423) -> Result<TopTableF, EdgeErrors> {
424 let params = params.unwrap_or_default();
425 check_cutoffs(¶ms)?;
426 if coefs.len() < 2 {
427 return Err(EdgeErrors::InvalidArgument(
428 "top_table_f needs at least two coefficients; use top_table for one".to_string(),
429 ));
430 }
431 if let Some(&c) = coefs.iter().find(|&&c| c >= fit.n_coef) {
432 return Err(EdgeErrors::CoefOutOfRange {
433 index: c,
434 n_coef: fit.n_coef,
435 });
436 }
437 let f_stat = fit.f_stat.as_ref().ok_or_else(|| {
438 EdgeErrors::InvalidArgument(
439 "the fit has no moderated F; run `ebayes` on a full rank design first".to_string(),
440 )
441 })?;
442 let p_value = fit.f_p_value.as_ref().unwrap();
443
444 let n_genes = fit.n_genes;
445 let n_coef = fit.n_coef;
446 let adj = p_adjust_bh(p_value);
447
448 let mut kept: Vec<usize> = (0..n_genes).collect();
452 if params.p_value < 1.0 || params.lfc > 0.0 {
453 kept.retain(|&g| {
454 let large = params.lfc <= 0.0
455 || coefs
456 .iter()
457 .any(|&c| fit.coefficients[g * n_coef + c].abs() > params.lfc);
458 let significant = params.p_value >= 1.0 || adj[g] <= params.p_value;
459 large && significant
460 });
461 }
462
463 if params.sort_by != TopTableSort::None {
464 let keys: Vec<f64> = kept.iter().map(|&g| p_value[g]).collect();
465 let order = order_by(&keys, true);
466 kept = order.into_iter().map(|i| kept[i]).collect();
467 }
468 kept.truncate(params.number);
469
470 let mut coefficients = Vec::with_capacity(kept.len() * coefs.len());
471 for &g in &kept {
472 coefficients.extend(coefs.iter().map(|&c| fit.coefficients[g * n_coef + c]));
473 }
474 let pick = |src: &[f64]| -> Vec<f64> { kept.iter().map(|&g| src[g]).collect() };
475 Ok(TopTableF {
476 coefficients,
477 n_coef: coefs.len(),
478 ave_expr: fit.amean.as_ref().map(|a| pick(a)),
479 f_stat: pick(f_stat),
480 p_value: pick(p_value),
481 adj_p_value: pick(&adj),
482 index: kept,
483 })
484}
485
486#[cfg(test)]
491mod tests {
492 use super::*;
493 use crate::limma::ebayes::ebayes;
494 use crate::limma::lm_fit::lm_fit;
495 use approx::assert_relative_eq;
496
497 fn moderated_fit() -> MArrayLm {
499 let design = vec![
500 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
506 ];
507 let y: Vec<f64> = (0..48).map(|i| ((i * 17) % 41) as f64 / 8.0).collect();
508 let amean: Vec<f64> = y
509 .chunks_exact(6)
510 .map(|r| r.iter().sum::<f64>() / 6.0)
511 .collect();
512 let fit = lm_fit(&y, 8, 6, &design, 2, None, None, None).unwrap();
513 let m = MArrayLm::from_lm_fit(fit, &design, 2, 6, Some(amean)).unwrap();
514 ebayes(m, None).unwrap()
515 }
516
517 #[test]
518 fn test_top_table_returns_every_gene_by_default() {
519 let fit = moderated_fit();
520 let tt = top_table(&fit, 1, None).unwrap();
521 assert_eq!(tt.index.len(), 8);
522 assert_eq!(tt.log_fc.len(), 8);
523 assert!(tt.ave_expr.is_some());
524 assert!(tt.ci_lower.is_none());
525 }
526
527 #[test]
528 fn test_top_table_sorts_by_log_odds_descending() {
529 let fit = moderated_fit();
530 let tt = top_table(&fit, 1, None).unwrap();
531 for w in tt.b.windows(2) {
532 assert!(w[0] >= w[1], "log-odds not descending: {:?}", w);
533 }
534 }
535
536 #[test]
537 fn test_top_table_sorts_by_p_value_ascending() {
538 let fit = moderated_fit();
539 let params = TopTableParams {
540 sort_by: TopTableSort::PValue,
541 ..Default::default()
542 };
543 let tt = top_table(&fit, 1, Some(params)).unwrap();
544 for w in tt.p_value.windows(2) {
545 assert!(w[0] <= w[1]);
546 }
547 }
548
549 #[test]
550 fn test_top_table_sorts_by_absolute_fold_change() {
551 let fit = moderated_fit();
552 let params = TopTableParams {
553 sort_by: TopTableSort::LogFc,
554 ..Default::default()
555 };
556 let tt = top_table(&fit, 1, Some(params)).unwrap();
557 for w in tt.log_fc.windows(2) {
558 assert!(w[0].abs() >= w[1].abs());
559 }
560 }
561
562 #[test]
563 fn test_top_table_leaves_the_order_alone_when_asked() {
564 let fit = moderated_fit();
565 let params = TopTableParams {
566 sort_by: TopTableSort::None,
567 ..Default::default()
568 };
569 let tt = top_table(&fit, 1, Some(params)).unwrap();
570 assert_eq!(tt.index, (0..8).collect::<Vec<_>>());
571 }
572
573 #[test]
574 fn test_top_table_truncates_to_number() {
575 let fit = moderated_fit();
576 let params = TopTableParams {
577 number: 3,
578 ..Default::default()
579 };
580 let tt = top_table(&fit, 1, Some(params)).unwrap();
581 assert_eq!(tt.index.len(), 3);
582 }
583
584 #[test]
585 fn test_top_table_number_larger_than_the_table_is_harmless() {
586 let fit = moderated_fit();
587 let params = TopTableParams {
588 number: 1000,
589 ..Default::default()
590 };
591 assert_eq!(top_table(&fit, 1, Some(params)).unwrap().index.len(), 8);
592 }
593
594 #[test]
595 fn test_top_table_filter_can_keep_nothing() {
596 let fit = moderated_fit();
597 let params = TopTableParams {
598 p_value: 0.0,
599 lfc: 1e6,
600 ..Default::default()
601 };
602 let tt = top_table(&fit, 1, Some(params)).unwrap();
603 assert!(tt.index.is_empty());
604 assert!(tt.log_fc.is_empty());
605 }
606
607 #[test]
608 fn test_top_table_adjusted_p_is_computed_before_thinning() {
609 let fit = moderated_fit();
612 let all = top_table(&fit, 1, None).unwrap();
613 let params = TopTableParams {
614 number: 2,
615 ..Default::default()
616 };
617 let few = top_table(&fit, 1, Some(params)).unwrap();
618 for (k, &g) in few.index.iter().enumerate() {
619 let j = all.index.iter().position(|&x| x == g).unwrap();
620 assert_relative_eq!(few.adj_p_value[k], all.adj_p_value[j], epsilon = 1e-15);
621 }
622 }
623
624 #[test]
625 fn test_top_table_confidence_interval_brackets_the_estimate() {
626 let fit = moderated_fit();
627 let params = TopTableParams {
628 confint: Some(DEFAULT_CONF_LEVEL),
629 ..Default::default()
630 };
631 let tt = top_table(&fit, 1, Some(params)).unwrap();
632 let lo = tt.ci_lower.as_ref().unwrap();
633 let hi = tt.ci_upper.as_ref().unwrap();
634 for k in 0..tt.index.len() {
635 assert!(lo[k] <= tt.log_fc[k] && tt.log_fc[k] <= hi[k]);
636 assert_relative_eq!(tt.log_fc[k] - lo[k], hi[k] - tt.log_fc[k], epsilon = 1e-12);
638 }
639 }
640
641 #[test]
642 fn test_top_table_confidence_interval_survives_thinning() {
643 let fit = moderated_fit();
646 let base = TopTableParams {
647 confint: Some(DEFAULT_CONF_LEVEL),
648 ..Default::default()
649 };
650 let all = top_table(&fit, 1, Some(base.clone())).unwrap();
651 let filtered = top_table(&fit, 1, Some(TopTableParams { lfc: 0.05, ..base })).unwrap();
652 for (k, &g) in filtered.index.iter().enumerate() {
653 let j = all.index.iter().position(|&x| x == g).unwrap();
654 assert_relative_eq!(
655 filtered.ci_lower.as_ref().unwrap()[k],
656 all.ci_lower.as_ref().unwrap()[j],
657 epsilon = 1e-12
658 );
659 }
660 }
661
662 #[test]
663 fn test_resort_by_uses_the_signed_value() {
664 let fit = moderated_fit();
665 let params = TopTableParams {
666 sort_by: TopTableSort::PValue,
667 resort_by: Some(TopTableResort::LogFc),
668 ..Default::default()
669 };
670 let tt = top_table(&fit, 1, Some(params)).unwrap();
671 for w in tt.log_fc.windows(2) {
673 assert!(w[0] >= w[1], "signed fold changes not descending: {:?}", w);
674 }
675 }
676
677 #[test]
678 fn test_order_by_is_stable_on_ties() {
679 let keys = vec![1.0, 1.0, 1.0, 0.0];
680 assert_eq!(order_by(&keys, false), vec![0, 1, 2, 3]);
681 assert_eq!(order_by(&keys, true), vec![3, 0, 1, 2]);
682 }
683
684 #[test]
685 fn test_order_by_sends_missing_values_last() {
686 let keys = vec![1.0, f64::NAN, 2.0];
687 assert_eq!(order_by(&keys, false), vec![2, 0, 1]);
688 assert_eq!(order_by(&keys, true), vec![0, 2, 1]);
689 }
690
691 #[test]
692 fn test_top_table_f_ranks_on_the_moderated_f() {
693 let fit = moderated_fit();
694 let tt = top_table_f(&fit, &[0, 1], None).unwrap();
695 assert_eq!(tt.n_coef, 2);
696 assert_eq!(tt.coefficients.len(), tt.index.len() * 2);
697 for w in tt.p_value.windows(2) {
698 assert!(w[0] <= w[1]);
699 }
700 }
701
702 #[test]
703 fn test_top_table_f_rejects_a_single_coefficient() {
704 let fit = moderated_fit();
705 assert!(top_table_f(&fit, &[0], None).is_err());
706 }
707
708 #[test]
709 fn test_top_table_rejects_a_coefficient_out_of_range() {
710 let fit = moderated_fit();
711 assert!(top_table(&fit, 5, None).is_err());
712 }
713
714 #[test]
715 fn test_top_table_rejects_an_unmoderated_fit() {
716 let design = vec![1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0];
717 let y: Vec<f64> = (0..8).map(|i| i as f64).collect();
718 let fit = lm_fit(&y, 2, 4, &design, 2, None, None, None).unwrap();
719 let m = MArrayLm::from_lm_fit(fit, &design, 2, 4, None).unwrap();
720 assert!(top_table(&m, 0, None).is_err());
721 }
722
723 #[test]
724 fn test_top_table_rejects_bad_cutoffs() {
725 let fit = moderated_fit();
726 let bad_p = TopTableParams {
727 p_value: 2.0,
728 ..Default::default()
729 };
730 assert!(top_table(&fit, 1, Some(bad_p)).is_err());
731 let bad_lfc = TopTableParams {
732 lfc: -1.0,
733 ..Default::default()
734 };
735 assert!(top_table(&fit, 1, Some(bad_lfc)).is_err());
736 let bad_ci = TopTableParams {
737 confint: Some(1.5),
738 ..Default::default()
739 };
740 assert!(top_table(&fit, 1, Some(bad_ci)).is_err());
741 }
742}