Skip to main content

edge_rs/limma/
toptable.rs

1//! limma's `topTable`: the moderated fit as a ranked table.
2//!
3//! Separate from [`crate::results::top_tags`], which is edgeR's. They differ:
4//! `topTags` breaks p-value ties on descending `|logFC|` and `topTable` does
5//! not; `topTable` offers `AveExpr`, `t` and `B` as sort keys and a `resort_by`
6//! pass; the fold-change threshold is `>=` here, `>` in the F variant and `<`
7//! in `decide_tests`. All three asymmetries are upstream's.
8//!
9//! Sequential: one sort and one scan over the genes.
10
11use crate::limma::marray::MArrayLm;
12use crate::numeric::dist::t_ppf;
13use crate::numeric::stats::p_adjust_bh;
14use crate::prelude::*;
15
16////////////
17// Consts //
18////////////
19
20/// Confidence level limma uses when `confint = TRUE` rather than a number.
21pub const DEFAULT_CONF_LEVEL: f64 = 0.95;
22
23//////////////////
24// Public types //
25//////////////////
26
27/// Which column the table is ranked on.
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
29pub enum TopTableSort {
30    /// Descending log-odds. limma's default, and only available once `eBayes`
31    /// has produced them.
32    #[default]
33    B,
34    /// Descending absolute log fold change.
35    LogFc,
36    /// Descending average expression.
37    AveExpr,
38    /// Ascending p-value.
39    PValue,
40    /// Descending absolute moderated t.
41    T,
42    /// Input order.
43    None,
44}
45
46/// A second ordering applied to the rows that survived selection.
47///
48/// Distinct from [`TopTableSort`]: `LogFc` and `T` order on the **signed**
49/// value here, so a resort by `LogFc` puts the most up-regulated first, not the
50/// most changed (`R/toptable.R:280-286`).
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum TopTableResort {
53    /// Descending signed log fold change.
54    LogFc,
55    /// Descending average expression.
56    AveExpr,
57    /// Ascending p-value.
58    PValue,
59    /// Descending signed moderated t.
60    T,
61    /// Descending log-odds.
62    B,
63}
64
65/// Tuning knobs for [`top_table`] and [`top_table_f`], at limma's defaults
66/// except for `number`.
67#[derive(Clone, Debug)]
68pub struct TopTableParams {
69    /// Maximum rows to return. limma defaults to ten; the default here is every
70    /// row.
71    pub number: usize,
72    /// Ranking column. Ignored by [`top_table_f`], which ranks on the F
73    /// p-value or not at all.
74    pub sort_by: TopTableSort,
75    /// Optional second ordering, applied after selection. Ignored by
76    /// [`top_table_f`].
77    pub resort_by: Option<TopTableResort>,
78    /// Keep only rows whose adjusted p-value is at or below this.
79    pub p_value: f64,
80    /// Keep only rows whose absolute log fold change is at least this.
81    pub lfc: f64,
82    /// Confidence level for an interval on the log fold change, or `None` for
83    /// no interval. [`DEFAULT_CONF_LEVEL`] is limma's `confint = TRUE`.
84    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/// A ranked table for one coefficient.
101///
102/// Every vector is the same length and in the table's order;
103/// [`TopTable::index`] maps back to the original gene.
104#[derive(Clone, Debug)]
105pub struct TopTable {
106    /// Original gene index of each row.
107    pub index: Vec<usize>,
108    /// Log fold change, the coefficient itself.
109    pub log_fc: Vec<f64>,
110    /// Lower end of the confidence interval, when one was asked for.
111    pub ci_lower: Option<Vec<f64>>,
112    /// Upper end of the confidence interval.
113    pub ci_upper: Option<Vec<f64>>,
114    /// Average expression, from [`MArrayLm::amean`]. `None` when the fit has
115    /// none.
116    pub ave_expr: Option<Vec<f64>>,
117    /// Moderated t.
118    pub t: Vec<f64>,
119    /// Two-sided p-value.
120    pub p_value: Vec<f64>,
121    /// Benjamini-Hochberg adjusted p-value, computed over **all** genes before
122    /// any thinning, so it does not depend on `number` or `p_value`.
123    pub adj_p_value: Vec<f64>,
124    /// Log-odds of differential expression.
125    pub b: Vec<f64>,
126}
127
128/// A ranked table across several coefficients, ordered by the moderated F.
129#[derive(Clone, Debug)]
130pub struct TopTableF {
131    /// Original gene index of each row.
132    pub index: Vec<usize>,
133    /// The coefficients tested, row-major `index.len() * n_coef`.
134    pub coefficients: Vec<f64>,
135    /// Number of coefficients per row.
136    pub n_coef: usize,
137    /// Average expression. `None` when the fit has none.
138    pub ave_expr: Option<Vec<f64>>,
139    /// Moderated F.
140    pub f_stat: Vec<f64>,
141    /// P-value of the moderated F.
142    pub p_value: Vec<f64>,
143    /// Benjamini-Hochberg adjusted p-value, over all genes.
144    pub adj_p_value: Vec<f64>,
145}
146
147/////////////
148// Helpers //
149/////////////
150
151/// Checks the two cutoffs.
152///
153/// ### Params
154///
155/// * `params` - Tuning knobs
156///
157/// ### Returns
158///
159/// `Ok(())`, or [`EdgeErrors::InvalidArgument`].
160fn 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
183/// The three per-coefficient matrices `eBayes` leaves on a fit.
184struct Moderated<'a> {
185    /// Moderated t, row-major `n_genes * n_coef`.
186    t: &'a [f64],
187    /// Two-sided p-values, same shape.
188    p: &'a [f64],
189    /// Log-odds, same shape.
190    lods: &'a [f64],
191}
192
193/// Pulls the moderated statistics off a fit, or explains that they are missing.
194///
195/// ### Params
196///
197/// * `fit` - The fit
198///
199/// ### Returns
200///
201/// The three matrices, or [`EdgeErrors::InvalidArgument`].
202fn 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
215/// Stable ordering of a key vector, missing values last.
216///
217/// R's `order` is a stable radix sort on doubles: ties keep input order in both
218/// directions, and `na.last = TRUE` sends `NA` to the end whatever the
219/// direction.
220///
221/// ### Params
222///
223/// * `keys` - Sort keys
224/// * `ascending` - Whether smaller comes first
225///
226/// ### Returns
227///
228/// Indices into `keys`, in sorted order.
229fn 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
249//////////////
250// Frontend //
251//////////////
252
253/// Ranks the genes for one coefficient.
254///
255/// Port of limma's `topTable` on a single coefficient (`.topTableT`,
256/// `R/toptable.R:152-290`). The fit must have been through
257/// [`crate::limma::ebayes::ebayes`].
258///
259/// Order of operations, unlike `top_tags`: adjust over every gene, thin on the
260/// adjusted p-value and fold change, sort, truncate, then optionally resort.
261///
262/// ### Params
263///
264/// * `fit` - A moderated fit
265/// * `coef` - Which coefficient, or contrast, to tabulate
266/// * `params` - Tuning knobs, or `None` for [`TopTableParams::default`]
267///
268/// ### Returns
269///
270/// The table, possibly empty. [`EdgeErrors::CoefOutOfRange`] for a coefficient
271/// outside the fit, or [`EdgeErrors::InvalidArgument`] if `eBayes` has not run,
272/// if a cutoff is out of range, or if a sort key was asked for that the fit
273/// cannot supply.
274pub 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(&params)?;
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    // ---- //
320    // thin //
321    // ---- //
322    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            // R's `sig[is.na(sig)] <- FALSE`.
328            significant && large
329        });
330    }
331
332    // ------------------- //
333    // sort, then truncate //
334    // ------------------- //
335    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    // ------ //
354    // resort //
355    // ------ //
356    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
394/// Ranks the genes across several coefficients on the moderated F.
395///
396/// Port of limma's `topTable` with more than one coefficient (`.topTableF`,
397/// `R/toptable.R:68-150`).
398///
399/// The F statistic is the one already on the fit, over **every** coefficient,
400/// even when `coefs` names a subset. `topTable` subsets the fit at
401/// `R/toptable.R:45`, but `F` belongs to the per-gene group in `[.MArrayLM`
402/// (`R/subsetting.R:117-118`) and is left untouched. So `coefs` selects which
403/// coefficients are tabulated, not which hypothesis is tested. To test a
404/// subset, rotate onto it with `contrasts_fit` and moderate again.
405///
406/// ### Params
407///
408/// * `fit` - A moderated fit whose `f_stat` is set
409/// * `coefs` - Which coefficients to tabulate, at least two
410/// * `params` - Tuning knobs. Only `number`, `p_value`, `lfc` and whether
411///   `sort_by` is [`TopTableSort::None`] are read.
412///
413/// ### Returns
414///
415/// The table, possibly empty. [`EdgeErrors::CoefOutOfRange`] for an index
416/// outside the fit, or [`EdgeErrors::InvalidArgument`] if the fit has no F
417/// statistic, if fewer than two coefficients were asked for, or if a cutoff is
418/// out of range.
419pub 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(&params)?;
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    // --------------------------------------------------------- //
449    // thin: any coefficient over the threshold, and significant //
450    // --------------------------------------------------------- //
451    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///////////
487// Tests //
488///////////
489
490#[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    /// A moderated fit over eight genes, two coefficients, six samples.
498    fn moderated_fit() -> MArrayLm {
499        let design = vec![
500            1.0, 0.0, //
501            1.0, 0.0, //
502            1.0, 0.0, //
503            1.0, 1.0, //
504            1.0, 1.0, //
505            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        // The adjustment runs over every gene, so truncating must not change a
610        // surviving gene's adjusted value.
611        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            // Symmetric about the estimate.
637            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        // The 3.66.0 bug: the interval must belong to the gene it is printed
644        // next to, whether or not a threshold removed anything.
645        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        // Signed, descending, not absolute.
672        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}