Skip to main content

edge_rs/
results.rs

1//! Ranking and calling: edgeR's `topTags` and `decideTests`.
2//!
3//! Both take a finished test table, adjust the p-values and then sort it or
4//! reduce it to a per-gene call. Sequential on purpose: it is one sort of the
5//! table at the end of an analysis, with no gene axis worth fanning out over.
6//!
7//! Only Benjamini-Hochberg ([`crate::numeric::stats::p_adjust_bh`]) is offered;
8//! edgeR accepts seven methods.
9//!
10//! ### Ties
11//!
12//! R's `order` is a stable radix sort for numeric input, so ties keep the
13//! original gene order. Both sorts here are stable too. edgePython's
14//! `np.argsort(-alfc)` is a quicksort and is not.
15
16use crate::errors::EdgeErrors;
17use crate::numeric::stats::p_adjust_bh;
18
19///////////
20// Types //
21///////////
22
23/// Which column [`top_tags`] ranks on.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum SortBy {
26    /// Ascending p-value, breaking ties on descending absolute fold change.
27    /// edgeR's default.
28    PValue,
29    /// Descending absolute fold change.
30    LogFc,
31    /// Leave the genes in the order they arrived.
32    None,
33}
34
35/// A ranked, filtered slice of a test table.
36///
37/// Every vector has the same length and is in sorted order; `index[k]` is the
38/// original gene of row `k`.
39#[derive(Clone, Debug)]
40pub struct TopTags {
41    /// Original gene index of each retained row, in the sorted order.
42    pub index: Vec<usize>,
43    /// Log2 fold change.
44    pub log_fc: Vec<f64>,
45    /// Average log2 counts per million.
46    pub log_cpm: Vec<f64>,
47    /// Test statistic. Empty when the caller supplied none (the exact test has
48    /// none).
49    pub statistic: Vec<f64>,
50    /// Raw p-value.
51    pub p_value: Vec<f64>,
52    /// Benjamini-Hochberg adjusted p-value, computed over the *whole* table
53    /// before any filtering, as in edgeR.
54    pub fdr: Vec<f64>,
55}
56
57////////////////
58// Validation //
59////////////////
60
61/// Checks that a companion column is one value per gene.
62///
63/// ### Params
64///
65/// * `name` - Argument name, used verbatim in the error message
66/// * `values` - Column to check
67/// * `n_genes` - Number of genes the table holds
68///
69/// ### Returns
70///
71/// `Ok(())`, or [`EdgeErrors::LengthMismatch`].
72fn check_length(name: &'static str, values: &[f64], n_genes: usize) -> Result<(), EdgeErrors> {
73    if values.len() != n_genes {
74        return Err(EdgeErrors::LengthMismatch {
75            name,
76            expected: n_genes,
77            got: values.len(),
78        });
79    }
80    Ok(())
81}
82
83/// Checks that a cutoff is a usable probability.
84///
85/// ### Params
86///
87/// * `name` - Argument name, used verbatim in the error message
88/// * `value` - Cutoff supplied by the caller
89///
90/// ### Returns
91///
92/// `Ok(())`, or [`EdgeErrors::InvalidArgument`]. NaN is rejected, since it would
93/// silently return nothing.
94fn check_cutoff(name: &str, value: f64) -> Result<(), EdgeErrors> {
95    if !(value.is_finite() && (0.0..=1.0).contains(&value)) {
96        return Err(EdgeErrors::InvalidArgument(format!(
97            "'{name}' must be a probability in [0, 1]; got {value}."
98        )));
99    }
100    Ok(())
101}
102
103/////////////
104// topTags //
105/////////////
106
107/// The top differentially expressed genes, edgeR's `topTags`.
108///
109/// Adjusts, sorts, filters and truncates, in that order. The adjustment covers
110/// every gene, so `n` and `p_cutoff` do not change it, and the filter compares
111/// the *adjusted* p-value against `p_cutoff`.
112///
113/// ### Params
114///
115/// * `log_fc` - Log2 fold change per gene
116/// * `log_cpm` - Average log2 counts per million per gene
117/// * `statistic` - Test statistic per gene, or `None` ([`TopTags::statistic`] is
118///   then empty)
119/// * `p_value` - Raw p-value per gene, same length as `log_fc`
120/// * `n` - Maximum number of rows, at least one
121/// * `sort_by` - Which column to rank on
122/// * `p_cutoff` - Keep genes with adjusted p-value at most this; 1 keeps
123///   everything, as edgeR's `p.value = 1`
124///
125/// ### Returns
126///
127/// The retained rows in sorted order (possibly empty). Errors are
128/// [`EdgeErrors::EmptyCounts`] for an empty table,
129/// [`EdgeErrors::MustBePositive`] for `n = 0`,
130/// [`EdgeErrors::LengthMismatch`] if the columns disagree, and
131/// [`EdgeErrors::InvalidArgument`] for a `p_cutoff` outside `[0, 1]`.
132pub fn top_tags(
133    log_fc: &[f64],
134    log_cpm: &[f64],
135    statistic: Option<&[f64]>,
136    p_value: &[f64],
137    n: usize,
138    sort_by: SortBy,
139    p_cutoff: f64,
140) -> Result<TopTags, EdgeErrors> {
141    let n_genes = log_fc.len();
142    if n_genes == 0 {
143        return Err(EdgeErrors::EmptyCounts {
144            n_genes: 0,
145            n_samples: 0,
146        });
147    }
148    if n == 0 {
149        return Err(EdgeErrors::MustBePositive("n".to_string()));
150    }
151    check_length("log_cpm", log_cpm, n_genes)?;
152    check_length("p_value", p_value, n_genes)?;
153    if let Some(s) = statistic {
154        check_length("statistic", s, n_genes)?;
155    }
156    check_cutoff("p_cutoff", p_cutoff)?;
157
158    let fdr = p_adjust_bh(p_value);
159
160    let mut order: Vec<usize> = (0..n_genes).collect();
161    match sort_by {
162        SortBy::PValue => order.sort_by(|&a, &b| {
163            p_value[a]
164                .total_cmp(&p_value[b])
165                .then_with(|| log_fc[b].abs().total_cmp(&log_fc[a].abs()))
166        }),
167        SortBy::LogFc => order.sort_by(|&a, &b| log_fc[b].abs().total_cmp(&log_fc[a].abs())),
168        SortBy::None => {}
169    }
170
171    if p_cutoff < 1.0 {
172        order.retain(|&g| fdr[g] <= p_cutoff);
173    }
174    order.truncate(n);
175
176    Ok(TopTags {
177        log_fc: order.iter().map(|&g| log_fc[g]).collect(),
178        log_cpm: order.iter().map(|&g| log_cpm[g]).collect(),
179        statistic: match statistic {
180            Some(s) => order.iter().map(|&g| s[g]).collect(),
181            None => Vec::new(),
182        },
183        p_value: order.iter().map(|&g| p_value[g]).collect(),
184        fdr: order.iter().map(|&g| fdr[g]).collect(),
185        index: order,
186    })
187}
188
189/////////////////
190// decideTests //
191/////////////////
192
193/// Calls each gene up, down or not significant, edgeR's `decideTests`.
194///
195/// A gene is called when its BH-adjusted p-value is *strictly* below `p_cutoff`
196/// (edgeR's `p < p.value`). The sign comes from the fold change, and any gene
197/// with absolute fold change below `lfc` is then reset to zero.
198///
199/// As in edgeR the `lfc` threshold is a filter on an already significant call,
200/// not a joint test like `glmTreat`.
201///
202/// ### Params
203///
204/// * `p_value` - Raw p-value per gene
205/// * `log_fc` - Log2 fold change per gene
206/// * `p_cutoff` - Adjusted p-value threshold in `[0, 1]`; edgeR's default is 0.05
207/// * `lfc` - Absolute log2 fold change threshold, non-negative; zero disables it
208///
209/// ### Returns
210///
211/// One of -1, 0 or 1 per gene, in the input order. Errors are
212/// [`EdgeErrors::LengthMismatch`] if the columns disagree and
213/// [`EdgeErrors::InvalidArgument`] for a `p_cutoff` outside `[0, 1]` or a
214/// negative or non-finite `lfc`.
215pub fn decide_tests(
216    p_value: &[f64],
217    log_fc: &[f64],
218    p_cutoff: f64,
219    lfc: f64,
220) -> Result<Vec<i8>, EdgeErrors> {
221    check_length("log_fc", log_fc, p_value.len())?;
222    check_cutoff("p_cutoff", p_cutoff)?;
223    if !lfc.is_finite() || lfc < 0.0 {
224        return Err(EdgeErrors::InvalidArgument(format!(
225            "'lfc' must be non-negative and finite; got {lfc}."
226        )));
227    }
228
229    let fdr = p_adjust_bh(p_value);
230    Ok(fdr
231        .iter()
232        .zip(log_fc.iter())
233        .map(|(q, fc)| {
234            if *q >= p_cutoff || fc.abs() < lfc {
235                0
236            } else if *fc < 0.0 {
237                -1
238            } else {
239                1
240            }
241        })
242        .collect())
243}
244
245///////////
246// Tests //
247///////////
248
249#[cfg(test)]
250mod tests {
251    // References are pasted verbatim from R's 17-digit output so they stay
252    // checkable against the `Rscript` line above each test.
253    #![allow(clippy::excessive_precision)]
254
255    use super::*;
256    use approx::assert_relative_eq;
257
258    /// The six-gene exact test from `exact::tests`, the source of every fixture
259    /// here.
260    /// ```r
261    /// y <- matrix(c(10,12,11,40,44,38, 50,48,52,49,51,50, 2,0,5,1,3,0,
262    ///               0,0,0,7,9,8, 1200,1100,1300,2400,2500,2300, 5,7,6,5,6,7),
263    ///             nrow = 6, byrow = TRUE)
264    /// d <- DGEList(counts=y, group=c(1,1,1,2,2,2),
265    ///              lib.size=c(1e6,1.2e6,0.9e6,1.1e6,1e6,1.3e6),
266    ///              norm.factors=c(0.95,1.05,1.0,1.1,0.9,1.0))
267    /// r <- exactTest(d, dispersion=c(0.05,0.1,0.2,0.15,0.08,0.12))
268    /// ```
269    const P_VALUE: [f64; 6] = [
270        3.4349237412116053e-06,
271        7.6299000470704081e-01,
272        4.6743523769487572e-01,
273        1.4972774802797630e-05,
274        1.0337909917591625e-02,
275        8.9951524706410912e-01,
276    ];
277
278    const LOG_FC: [f64; 6] = [
279        1.76837964949810367,
280        -0.13190914581416660,
281        -0.89031602850510549,
282        5.98706174338887909,
283        0.86708062515865070,
284        -0.12249104113230144,
285    ];
286
287    const LOG_CPM: [f64; 6] = [
288        4.6828570480490503,
289        5.6094058355452656,
290        1.8369026364109835,
291        2.4631896748603697,
292        10.7199768972231873,
293        2.8848509178259678,
294    ];
295
296    /// `cat(format(p.adjust(r$table$PValue, method="BH"), digits=17), sep=", ")`
297    const FDR: [f64; 6] = [
298        2.0609542447269630e-05,
299        8.9951524706410912e-01,
300        7.0115285654231352e-01,
301        4.4918324408392889e-05,
302        2.0675819835183250e-02,
303        8.9951524706410912e-01,
304    ];
305
306    // -------- //
307    // top_tags //
308    // -------- //
309
310    /// `topTags(r, n=Inf, sort.by="PValue")` gives rows 1, 4, 5, 3, 2, 6 with
311    /// `cat(format(tt$table$FDR, digits=17), sep=", ")` as below.
312    #[test]
313    fn test_top_tags_sorts_by_p_value() {
314        let got = top_tags(
315            &LOG_FC,
316            &LOG_CPM,
317            None,
318            &P_VALUE,
319            usize::MAX,
320            SortBy::PValue,
321            1.0,
322        )
323        .unwrap();
324        assert_eq!(got.index, vec![0, 3, 4, 2, 1, 5]);
325
326        let want_fdr = [
327            2.0609542447269630e-05,
328            4.4918324408392889e-05,
329            2.0675819835183250e-02,
330            7.0115285654231352e-01,
331            8.9951524706410912e-01,
332            8.9951524706410912e-01,
333        ];
334        for (g, w) in got.fdr.iter().zip(want_fdr.iter()) {
335            assert_relative_eq!(g, w, max_relative = 1e-12);
336        }
337
338        let want_fc = [
339            1.76837964949810367,
340            5.98706174338887909,
341            0.86708062515865070,
342            -0.89031602850510549,
343            -0.13190914581416660,
344            -0.12249104113230144,
345        ];
346        for (g, w) in got.log_fc.iter().zip(want_fc.iter()) {
347            assert_relative_eq!(g, w, max_relative = 1e-12);
348        }
349        assert!(got.statistic.is_empty());
350    }
351
352    /// `topTags(r, n=Inf, sort.by="logFC")` gives rows 4, 1, 3, 5, 2, 6.
353    #[test]
354    fn test_top_tags_sorts_by_absolute_fold_change() {
355        let got = top_tags(
356            &LOG_FC,
357            &LOG_CPM,
358            None,
359            &P_VALUE,
360            usize::MAX,
361            SortBy::LogFc,
362            1.0,
363        )
364        .unwrap();
365        assert_eq!(got.index, vec![3, 0, 2, 4, 1, 5]);
366
367        let want_fdr = [
368            4.4918324408392889e-05,
369            2.0609542447269630e-05,
370            7.0115285654231352e-01,
371            2.0675819835183250e-02,
372            8.9951524706410912e-01,
373            8.9951524706410912e-01,
374        ];
375        for (g, w) in got.fdr.iter().zip(want_fdr.iter()) {
376            assert_relative_eq!(g, w, max_relative = 1e-12);
377        }
378    }
379
380    /// `topTags(r, n=Inf, sort.by="none")` leaves the table alone.
381    #[test]
382    fn test_top_tags_leaves_the_order_alone_when_asked() {
383        let got = top_tags(
384            &LOG_FC,
385            &LOG_CPM,
386            None,
387            &P_VALUE,
388            usize::MAX,
389            SortBy::None,
390            1.0,
391        )
392        .unwrap();
393        assert_eq!(got.index, vec![0, 1, 2, 3, 4, 5]);
394        for (g, w) in got.fdr.iter().zip(FDR.iter()) {
395            assert_relative_eq!(g, w, max_relative = 1e-12);
396        }
397    }
398
399    /// `topTags(r, n=Inf, p.value=0.05)` keeps rows 1, 4, 5. The cutoff is on
400    /// the adjusted p-value, so gene 5 survives at an FDR of 0.0207 despite a
401    /// raw p-value of 0.0103.
402    #[test]
403    fn test_top_tags_filters_on_the_adjusted_p_value() {
404        let got = top_tags(
405            &LOG_FC,
406            &LOG_CPM,
407            None,
408            &P_VALUE,
409            usize::MAX,
410            SortBy::PValue,
411            0.05,
412        )
413        .unwrap();
414        assert_eq!(got.index, vec![0, 3, 4]);
415        assert_eq!(got.p_value.len(), 3);
416        assert!(got.fdr.iter().all(|q| *q <= 0.05));
417    }
418
419    /// `topTags(r, n=3)` truncates after sorting, giving rows 1, 4, 5.
420    #[test]
421    fn test_top_tags_truncates_to_n() {
422        let got = top_tags(&LOG_FC, &LOG_CPM, None, &P_VALUE, 3, SortBy::PValue, 1.0).unwrap();
423        assert_eq!(got.index, vec![0, 3, 4]);
424    }
425
426    /// A cutoff nothing passes gives an empty table, as edgeR returns
427    /// `data.frame()`.
428    #[test]
429    fn test_top_tags_can_return_nothing() {
430        let got = top_tags(
431            &LOG_FC,
432            &LOG_CPM,
433            None,
434            &P_VALUE,
435            usize::MAX,
436            SortBy::PValue,
437            1e-9,
438        )
439        .unwrap();
440        assert!(got.index.is_empty());
441        assert!(got.log_fc.is_empty());
442        assert!(got.fdr.is_empty());
443    }
444
445    /// A statistic column is carried through in the sorted order when supplied.
446    #[test]
447    fn test_top_tags_carries_a_statistic_column() {
448        let stat = [10.0, 1.0, 2.0, 9.0, 5.0, 0.5];
449        let got = top_tags(
450            &LOG_FC,
451            &LOG_CPM,
452            Some(&stat),
453            &P_VALUE,
454            usize::MAX,
455            SortBy::PValue,
456            1.0,
457        )
458        .unwrap();
459        assert_eq!(got.statistic, vec![10.0, 9.0, 5.0, 2.0, 1.0, 0.5]);
460    }
461
462    /// Ties in the p-value fall back to descending absolute fold change, and
463    /// ties in both fall back to the original order.
464    #[test]
465    fn test_top_tags_breaks_ties_the_way_r_does() {
466        let p = [0.1, 0.1, 0.1, 0.05];
467        let fc = [1.0, -3.0, 1.0, 0.5];
468        let got = top_tags(&fc, &fc, None, &p, usize::MAX, SortBy::PValue, 1.0).unwrap();
469        assert_eq!(got.index, vec![3, 1, 0, 2]);
470
471        // The two genes tied at |1.0| keep their input order.
472        let got = top_tags(&fc, &fc, None, &p, usize::MAX, SortBy::LogFc, 1.0).unwrap();
473        assert_eq!(got.index, vec![1, 0, 2, 3]);
474    }
475
476    #[test]
477    fn test_top_tags_rejects_bad_input() {
478        assert!(matches!(
479            top_tags(&[], &[], None, &[], 5, SortBy::PValue, 1.0),
480            Err(EdgeErrors::EmptyCounts { .. })
481        ));
482        assert!(matches!(
483            top_tags(&LOG_FC, &LOG_CPM, None, &P_VALUE, 0, SortBy::PValue, 1.0),
484            Err(EdgeErrors::MustBePositive(_))
485        ));
486        assert!(matches!(
487            top_tags(
488                &LOG_FC,
489                &LOG_CPM[..3],
490                None,
491                &P_VALUE,
492                5,
493                SortBy::PValue,
494                1.0
495            ),
496            Err(EdgeErrors::LengthMismatch {
497                name: "log_cpm",
498                ..
499            })
500        ));
501        assert!(matches!(
502            top_tags(
503                &LOG_FC,
504                &LOG_CPM,
505                None,
506                &P_VALUE[..3],
507                5,
508                SortBy::PValue,
509                1.0
510            ),
511            Err(EdgeErrors::LengthMismatch {
512                name: "p_value",
513                ..
514            })
515        ));
516        assert!(matches!(
517            top_tags(
518                &LOG_FC,
519                &LOG_CPM,
520                Some(&[1.0, 2.0]),
521                &P_VALUE,
522                5,
523                SortBy::PValue,
524                1.0
525            ),
526            Err(EdgeErrors::LengthMismatch {
527                name: "statistic",
528                ..
529            })
530        ));
531        assert!(matches!(
532            top_tags(&LOG_FC, &LOG_CPM, None, &P_VALUE, 5, SortBy::PValue, 1.5),
533            Err(EdgeErrors::InvalidArgument(_))
534        ));
535        assert!(matches!(
536            top_tags(
537                &LOG_FC,
538                &LOG_CPM,
539                None,
540                &P_VALUE,
541                5,
542                SortBy::PValue,
543                f64::NAN
544            ),
545            Err(EdgeErrors::InvalidArgument(_))
546        ));
547    }
548
549    // ------------ //
550    // decide_tests //
551    // ------------ //
552
553    /// `cat(decideTests(r, p.value=0.05, lfc=0))` -> 1, 0, 0, 1, 1, 0
554    #[test]
555    fn test_decide_tests_matches_edger_without_a_fold_change_threshold() {
556        let got = decide_tests(&P_VALUE, &LOG_FC, 0.05, 0.0).unwrap();
557        assert_eq!(got, vec![1, 0, 0, 1, 1, 0]);
558    }
559
560    /// `cat(decideTests(r, p.value=0.05, lfc=1))` -> 1, 0, 0, 1, 0, 0. Gene 5 is
561    /// significant but only moves 0.867 on the log2 scale, so it is dropped.
562    #[test]
563    fn test_decide_tests_applies_the_fold_change_threshold() {
564        let got = decide_tests(&P_VALUE, &LOG_FC, 0.05, 1.0).unwrap();
565        assert_eq!(got, vec![1, 0, 0, 1, 0, 0]);
566    }
567
568    /// `cat(decideTests(r, p.value=0.05, lfc=2))` -> 0, 0, 0, 1, 0, 0
569    #[test]
570    fn test_decide_tests_at_a_higher_fold_change_threshold() {
571        let got = decide_tests(&P_VALUE, &LOG_FC, 0.05, 2.0).unwrap();
572        assert_eq!(got, vec![0, 0, 0, 1, 0, 0]);
573    }
574
575    /// `cat(decideTests(r, p.value=1, lfc=0))` -> 1, -1, -1, 1, 1, -1. Everything
576    /// is called, so this pins the sign of the fold change.
577    #[test]
578    fn test_decide_tests_calls_the_down_direction() {
579        let got = decide_tests(&P_VALUE, &LOG_FC, 1.0, 0.0).unwrap();
580        assert_eq!(got, vec![1, -1, -1, 1, 1, -1]);
581    }
582
583    /// The comparison is strict: an adjusted p-value equal to the cutoff is not
584    /// called, as in edgeR's `p < p.value`.
585    #[test]
586    fn test_decide_tests_is_strict_at_the_cutoff() {
587        // A single gene means BH leaves the p-value alone.
588        assert_eq!(decide_tests(&[0.05], &[3.0], 0.05, 0.0).unwrap(), vec![0]);
589        assert_eq!(decide_tests(&[0.05], &[3.0], 0.06, 0.0).unwrap(), vec![1]);
590    }
591
592    /// A cutoff of zero calls nothing, and one of one calls everything with a
593    /// non-zero fold change.
594    #[test]
595    fn test_decide_tests_at_the_extremes_of_the_cutoff() {
596        assert_eq!(
597            decide_tests(&P_VALUE, &LOG_FC, 0.0, 0.0).unwrap(),
598            vec![0, 0, 0, 0, 0, 0]
599        );
600    }
601
602    #[test]
603    fn test_decide_tests_rejects_bad_input() {
604        assert!(matches!(
605            decide_tests(&P_VALUE, &LOG_FC[..3], 0.05, 0.0),
606            Err(EdgeErrors::LengthMismatch { name: "log_fc", .. })
607        ));
608        assert!(matches!(
609            decide_tests(&P_VALUE, &LOG_FC, -0.1, 0.0),
610            Err(EdgeErrors::InvalidArgument(_))
611        ));
612        assert!(matches!(
613            decide_tests(&P_VALUE, &LOG_FC, 1.1, 0.0),
614            Err(EdgeErrors::InvalidArgument(_))
615        ));
616        assert!(matches!(
617            decide_tests(&P_VALUE, &LOG_FC, f64::NAN, 0.0),
618            Err(EdgeErrors::InvalidArgument(_))
619        ));
620        assert!(matches!(
621            decide_tests(&P_VALUE, &LOG_FC, 0.05, -1.0),
622            Err(EdgeErrors::InvalidArgument(_))
623        ));
624        assert!(matches!(
625            decide_tests(&P_VALUE, &LOG_FC, 0.05, f64::INFINITY),
626            Err(EdgeErrors::InvalidArgument(_))
627        ));
628    }
629
630    /// An empty table is not an error: nothing to call.
631    #[test]
632    fn test_decide_tests_on_an_empty_table() {
633        assert!(decide_tests(&[], &[], 0.05, 0.0).unwrap().is_empty());
634    }
635}