Skip to main content

limma/
decidetests.rs

1//! Multiple-testing decisions. Port of limma's `decideTests` (`decidetests.R`):
2//! the `separate`, `global`, `hierarchical` and `nestedF` methods, plus the
3//! `p.adjust` family limma uses (`none`, `bonferroni`, `holm`, `BH`/`fdr`,
4//! `BY`) and the row-wise `.classifyTestsP` classifier ([`classify_tests_p`]).
5//! For `separate`/`global`, NA p-values (missing-value data) are treated as
6//! NotSig rather than R's `NA` outcome; `nestedF` errors on any NA
7//! `F.p.value`, as in limma. Complete-data inputs match R exactly.
8
9use anyhow::{bail, Result};
10use ndarray::Array2;
11
12use crate::classifytestsf::classify_tests_f;
13use crate::fit::MArrayLM;
14use crate::special::{ln_norm_cdf, t_two_sided_pvalue};
15
16/// p-value adjustment method, matching the subset of R's `p.adjust.methods`
17/// accepted by `decideTests`.
18#[derive(Clone, Copy, PartialEq, Eq, Debug)]
19pub enum Adjust {
20    None,
21    Bonferroni,
22    Holm,
23    BH,
24    BY,
25}
26
27impl Adjust {
28    pub fn parse(s: &str) -> Result<Self> {
29        Ok(match s.to_ascii_lowercase().as_str() {
30            "none" => Adjust::None,
31            "bonferroni" => Adjust::Bonferroni,
32            "holm" => Adjust::Holm,
33            "bh" | "fdr" => Adjust::BH,
34            "by" => Adjust::BY,
35            other => bail!(
36                "invalid adjust.method '{}' (none|bonferroni|holm|BH|fdr|BY)",
37                other
38            ),
39        })
40    }
41}
42
43/// `decideTests` classification method (the full `c("separate","global",
44/// "hierarchical","nestedF")` set). Which methods are supported depends on the
45/// entry point: [`decide_tests`] (MArrayLM) handles `separate`/`global`/
46/// `nestedF` and defers `hierarchical` to the p-value path; whereas
47/// [`decide_tests_pvalues`] (p-value matrix) handles `separate`/`global`/
48/// `hierarchical` and rejects `nestedF` (which requires a fit), mirroring limma.
49#[derive(Clone, Copy, PartialEq, Eq, Debug)]
50pub enum DecideMethod {
51    Separate,
52    Global,
53    Hierarchical,
54    NestedF,
55}
56
57impl DecideMethod {
58    pub fn parse(s: &str) -> Result<Self> {
59        Ok(match s.to_ascii_lowercase().as_str() {
60            "separate" => DecideMethod::Separate,
61            "global" => DecideMethod::Global,
62            "hierarchical" => DecideMethod::Hierarchical,
63            "nestedf" => DecideMethod::NestedF,
64            other => bail!(
65                "decideTests method '{}' not supported (separate|global|hierarchical|nestedF)",
66                other
67            ),
68        })
69    }
70}
71
72/// Outcome matrix from `decideTests`: -1 (down), 0 (not significant), +1 (up).
73/// Port of limma's `TestResults` for the `c(-1, 0, 1)` / `c("Down","NotSig","Up")`
74/// level set.
75#[derive(Clone, Debug)]
76pub struct TestResults {
77    pub data: Array2<i8>, // n_genes x n_coef
78    pub gene_names: Vec<String>,
79    pub coef_names: Vec<String>,
80}
81
82impl TestResults {
83    /// Per-column (down, not-sig, up) counts, matching `summary.TestResults`.
84    pub fn counts(&self) -> Vec<(usize, usize, usize)> {
85        (0..self.data.ncols())
86            .map(|j| {
87                let mut down = 0;
88                let mut up = 0;
89                let mut notsig = 0;
90                for g in 0..self.data.nrows() {
91                    match self.data[[g, j]] {
92                        v if v < 0 => down += 1,
93                        v if v > 0 => up += 1,
94                        _ => notsig += 1,
95                    }
96                }
97                (down, notsig, up)
98            })
99            .collect()
100    }
101
102    /// `summary.TestResults`: the labelled count table with one row per level
103    /// (`Down` = -1, `NotSig` = 0, `Up` = +1) and one column per coefficient,
104    /// giving how many genes fall at each level. Mirrors limma's table with the
105    /// `c("Down","NotSig","Up")` labels attached by `decideTests`.
106    pub fn summary(&self) -> TestResultsSummary {
107        let mut down = Vec::with_capacity(self.coef_names.len());
108        let mut notsig = Vec::with_capacity(self.coef_names.len());
109        let mut up = Vec::with_capacity(self.coef_names.len());
110        for (d, n, u) in self.counts() {
111            down.push(d);
112            notsig.push(n);
113            up.push(u);
114        }
115        TestResultsSummary {
116            coef_names: self.coef_names.clone(),
117            down,
118            notsig,
119            up,
120        }
121    }
122}
123
124/// Labelled count table returned by [`TestResults::summary`], the analog of
125/// `summary.TestResults`. The three count vectors are aligned with
126/// [`Self::LABELS`] / [`Self::LEVELS`] and indexed by coefficient (column).
127#[derive(Clone, Debug)]
128pub struct TestResultsSummary {
129    pub coef_names: Vec<String>,
130    pub down: Vec<usize>,
131    pub notsig: Vec<usize>,
132    pub up: Vec<usize>,
133}
134
135impl TestResultsSummary {
136    /// Row labels, matching `attr(results, "labels")` set by `decideTests`.
137    pub const LABELS: [&'static str; 3] = ["Down", "NotSig", "Up"];
138    /// Row levels, matching `attr(results, "levels")` set by `decideTests`.
139    pub const LEVELS: [i8; 3] = [-1, 0, 1];
140}
141
142/// Adjust a vector of (assumed finite) p-values. Port of `stats::p.adjust` for
143/// the methods limma exposes through `decideTests`. The caller filters NAs, so
144/// `n == p.len()`, matching R's `o <- !is.na(p)` pre-filtering in `decideTests`.
145pub fn p_adjust(p: &[f64], method: Adjust) -> Vec<f64> {
146    let n = p.len();
147    if n == 0 {
148        return Vec::new();
149    }
150    let nf = n as f64;
151    match method {
152        Adjust::None => p.to_vec(),
153        Adjust::Bonferroni => p.iter().map(|&x| (nf * x).min(1.0)).collect(),
154        Adjust::Holm => {
155            // Ascending order; multiply the i-th smallest by (n + 1 - i), take a
156            // running maximum, clamp to 1. Matches `cummax((n+1-i)*p[o])`.
157            let mut o: Vec<usize> = (0..n).collect();
158            o.sort_by(|&a, &b| p[a].partial_cmp(&p[b]).unwrap());
159            let mut out = vec![0.0_f64; n];
160            let mut cummax = f64::NEG_INFINITY;
161            for (rank0, &idx) in o.iter().enumerate() {
162                let i = (rank0 + 1) as f64;
163                let val = (nf + 1.0 - i) * p[idx];
164                cummax = cummax.max(val);
165                out[idx] = cummax.min(1.0);
166            }
167            out
168        }
169        Adjust::BH | Adjust::BY => {
170            // Descending order; for the rank-i value use factor n/i, accumulate
171            // a running minimum, clamp to 1. BY scales by q = sum(1/k, k=1..n).
172            let mut o: Vec<usize> = (0..n).collect();
173            o.sort_by(|&a, &b| p[b].partial_cmp(&p[a]).unwrap());
174            let q = if matches!(method, Adjust::BY) {
175                (1..=n).map(|k| 1.0 / k as f64).sum::<f64>()
176            } else {
177                1.0
178            };
179            let mut out = vec![0.0_f64; n];
180            let mut cummin = f64::INFINITY;
181            for (rank0, &idx) in o.iter().enumerate() {
182                let i = (n - rank0) as f64; // i runs n, n-1, ..., 1
183                let val = q * (nf / i) * p[idx];
184                cummin = cummin.min(val);
185                out[idx] = cummin.min(1.0);
186            }
187            out
188        }
189    }
190}
191
192/// Sign of a coefficient as `-1 / 0 / 1` (R's `sign`, with `0.0 -> 0`).
193fn sign_i8(x: f64) -> i8 {
194    if x > 0.0 {
195        1
196    } else if x < 0.0 {
197        -1
198    } else {
199        0
200    }
201}
202
203/// Correlation matrix of the coefficients for `classifyTestsF`, matching limma's
204/// guard: any coefficient variance exactly zero (e.g. an all-zero contrast) is
205/// reset to 1 before `cov2cor`, avoiding a divide-by-zero in the whitening step.
206fn cor_from_cov(cov: &Array2<f64>) -> Array2<f64> {
207    let mut c = cov.clone();
208    for i in 0..c.nrows() {
209        if c[[i, i]] == 0.0 {
210            c[[i, i]] = 1.0;
211        }
212    }
213    crate::linalg::cov2cor(&c)
214}
215
216/// Classify each (gene, contrast) as up/down/not-significant. Port of
217/// `decideTests.MArrayLM` for `method %in% c("separate","global","nestedF")`.
218/// Requires a fit that has been through `eBayes` (`separate`/`global` need
219/// `p.value`; `nestedF` needs `t` and `F.p.value`).
220pub fn decide_tests(
221    fit: &MArrayLM,
222    method: DecideMethod,
223    adjust: Adjust,
224    p_value: f64,
225    lfc: f64,
226) -> Result<TestResults> {
227    let coef = &fit.coefficients;
228    let (ng, nc) = (coef.nrows(), coef.ncols());
229    let mut data = Array2::<i8>::zeros((ng, nc));
230
231    match method {
232        DecideMethod::Separate => {
233            let p = fit
234                .p_value
235                .as_ref()
236                .ok_or_else(|| anyhow::anyhow!("need to run eBayes first (p.value missing)"))?;
237            for j in 0..nc {
238                let mut idx = Vec::new();
239                let mut vals = Vec::new();
240                for g in 0..ng {
241                    let v = p[[g, j]];
242                    if v.is_finite() {
243                        idx.push(g);
244                        vals.push(v);
245                    }
246                }
247                let adj = p_adjust(&vals, adjust);
248                for (k, &g) in idx.iter().enumerate() {
249                    if adj[k] < p_value {
250                        data[[g, j]] = sign_i8(coef[[g, j]]);
251                    }
252                }
253            }
254        }
255        DecideMethod::Global => {
256            let p = fit
257                .p_value
258                .as_ref()
259                .ok_or_else(|| anyhow::anyhow!("need to run eBayes first (p.value missing)"))?;
260            let mut idx = Vec::new();
261            let mut vals = Vec::new();
262            for j in 0..nc {
263                for g in 0..ng {
264                    let v = p[[g, j]];
265                    if v.is_finite() {
266                        idx.push((g, j));
267                        vals.push(v);
268                    }
269                }
270            }
271            let adj = p_adjust(&vals, adjust);
272            for (k, &(g, j)) in idx.iter().enumerate() {
273                if adj[k] < p_value {
274                    data[[g, j]] = sign_i8(coef[[g, j]]);
275                }
276            }
277        }
278        DecideMethod::Hierarchical => {
279            bail!(
280                "decideTests(method=\"hierarchical\") on a fit needs the stepwise \
281                 .classifyTestsP classifier, which is not yet ported; use \
282                 decide_tests_pvalues for the p-value-matrix hierarchical method"
283            );
284        }
285        DecideMethod::NestedF => {
286            let t = fit
287                .t
288                .as_ref()
289                .ok_or_else(|| anyhow::anyhow!("need to run eBayes first (t missing)"))?;
290            let fp = fit
291                .f_p_value
292                .as_ref()
293                .ok_or_else(|| anyhow::anyhow!("need to run eBayes first (F.p.value missing)"))?;
294            if fp.iter().any(|v| v.is_nan()) {
295                bail!("nestedF method can't handle NA p-values");
296            }
297            // Gene-level selection on the (adjusted) overall F p-value.
298            let adj = p_adjust(&fp.to_vec(), adjust);
299            let sel: Vec<usize> = (0..ng).filter(|&g| adj[g] < p_value).collect();
300            let i = sel.len();
301            if i > 0 {
302                // Adjustment factor `a` shrinks the per-gene threshold so the
303                // family-wise/false-discovery control carries into classifyTestsF.
304                let n = ng as f64;
305                let a = match adjust {
306                    Adjust::None => 1.0,
307                    Adjust::Bonferroni => 1.0 / n,
308                    Adjust::Holm => 1.0 / (n - i as f64 + 1.0),
309                    Adjust::BH => i as f64 / n,
310                    Adjust::BY => {
311                        let harmonic: f64 = (1..=ng).map(|k| 1.0 / k as f64).sum();
312                        i as f64 / n / harmonic
313                    }
314                };
315                let cor = cor_from_cov(&fit.cov_coefficients);
316                let mut tsel = Array2::<f64>::zeros((i, nc));
317                let mut dfsel = Vec::with_capacity(i);
318                for (k, &g) in sel.iter().enumerate() {
319                    for j in 0..nc {
320                        tsel[[k, j]] = t[[g, j]];
321                    }
322                    // df = df.prior + df.residual (per gene); Inf when no prior.
323                    let dfg = match &fit.df_prior {
324                        Some(dp) if dp.len() == 1 => dp[0] + fit.df_residual[g],
325                        Some(dp) => dp[g] + fit.df_residual[g],
326                        None => f64::INFINITY,
327                    };
328                    dfsel.push(dfg);
329                }
330                let rsel = classify_tests_f(&tsel, Some(&cor), &dfsel, p_value * a);
331                for (k, &g) in sel.iter().enumerate() {
332                    for j in 0..nc {
333                        data[[g, j]] = rsel[[k, j]];
334                    }
335                }
336            }
337        }
338    }
339
340    if lfc > 0.0 {
341        for j in 0..nc {
342            for g in 0..ng {
343                if coef[[g, j]].abs() <= lfc {
344                    data[[g, j]] = 0;
345                }
346            }
347        }
348    }
349
350    Ok(TestResults {
351        data,
352        gene_names: fit.gene_names.clone(),
353        coef_names: fit.coef_names.clone(),
354    })
355}
356
357/// Classify a matrix of p-values (genes × contrasts) into up/down/not-sig. Port
358/// of `decideTests.default`. Supports `separate`, `global` and the Simes
359/// `hierarchical` method; `nestedF` is rejected (it requires a fit). When
360/// `coefficients` is supplied its sign sets the Up/Down direction and `lfc`
361/// zeroes entries with `|coef| < lfc`; otherwise significant entries are
362/// reported as `1` (significant, no direction). Uses the `p <= cutoff` rule of
363/// `decideTests.default` (the MArrayLM path uses strict `<`).
364pub fn decide_tests_pvalues(
365    p: &Array2<f64>,
366    method: DecideMethod,
367    adjust: Adjust,
368    p_value: f64,
369    lfc: f64,
370    coefficients: Option<&Array2<f64>>,
371) -> Result<TestResults> {
372    if p.iter().any(|v| v.is_nan()) {
373        bail!("NA p-values not supported");
374    }
375    if p.iter().any(|&v| !(0.0..=1.0).contains(&v)) {
376        bail!("object doesn't appear to be a matrix of p-values");
377    }
378    let (ng, nc) = (p.nrows(), p.ncols());
379    if let Some(co) = coefficients {
380        if co.nrows() != ng || co.ncols() != nc {
381            bail!("dim(object) disagrees with dim(coefficients)");
382        }
383    }
384
385    let mut padj = p.clone();
386    let mut cutoff = p_value;
387
388    match method {
389        DecideMethod::Separate => {
390            for j in 0..nc {
391                let col: Vec<f64> = (0..ng).map(|g| p[[g, j]]).collect();
392                let adj = p_adjust(&col, adjust);
393                for g in 0..ng {
394                    padj[[g, j]] = adj[g];
395                }
396            }
397        }
398        DecideMethod::Global => {
399            let all: Vec<f64> = p.iter().copied().collect();
400            let adj = p_adjust(&all, adjust);
401            for (v, &a) in padj.iter_mut().zip(adj.iter()) {
402                *v = a;
403            }
404        }
405        DecideMethod::Hierarchical => {
406            // Simes' method per row to get gene-wise p-values.
407            let mult: Vec<f64> = (1..=nc).map(|k| nc as f64 / k as f64).collect();
408            let mut genewise = vec![0.0_f64; ng];
409            for g in 0..ng {
410                let mut row: Vec<f64> = (0..nc).map(|j| p[[g, j]]).collect();
411                row.sort_by(|a, b| a.partial_cmp(b).unwrap());
412                genewise[g] = (0..nc)
413                    .map(|k| row[k] * mult[k])
414                    .fold(f64::INFINITY, f64::min);
415            }
416            let gadj = p_adjust(&genewise, adjust);
417            let de: Vec<bool> = gadj.iter().map(|&v| v <= p_value).collect();
418            let nde = de.iter().filter(|&&b| b).count();
419            for g in 0..ng {
420                if de[g] {
421                    let row: Vec<f64> = (0..nc).map(|j| p[[g, j]]).collect();
422                    let adj = p_adjust(&row, adjust);
423                    for j in 0..nc {
424                        padj[[g, j]] = adj[j];
425                    }
426                } else {
427                    for j in 0..nc {
428                        padj[[g, j]] = 1.0;
429                    }
430                }
431            }
432            let a = match adjust {
433                Adjust::None => 1.0,
434                Adjust::Bonferroni => 1.0 / ng as f64,
435                Adjust::Holm => 1.0 / (ng - nde + 1) as f64,
436                Adjust::BH => nde as f64 / ng as f64,
437                Adjust::BY => {
438                    let harmonic: f64 = (1..=ng).map(|k| 1.0 / k as f64).sum();
439                    nde as f64 / ng as f64 / harmonic
440                }
441            };
442            cutoff = a * p_value;
443        }
444        DecideMethod::NestedF => {
445            bail!("nestedF adjust method requires an MArrayLM object");
446        }
447    }
448
449    let mut data = Array2::<i8>::zeros((ng, nc));
450    for g in 0..ng {
451        for j in 0..nc {
452            if padj[[g, j]] <= cutoff {
453                let mut v: i8 = 1;
454                if let Some(co) = coefficients {
455                    if co[[g, j]] < 0.0 {
456                        v = -1;
457                    }
458                    if lfc > 0.0 && co[[g, j]].abs() < lfc {
459                        v = 0;
460                    }
461                }
462                data[[g, j]] = v;
463            }
464        }
465    }
466
467    Ok(TestResults {
468        data,
469        gene_names: vec![String::new(); ng],
470        coef_names: vec![String::new(); nc],
471    })
472}
473
474/// `.classifyTestsP`: classify each gene (row) of a t-statistic matrix as
475/// up (`+1`), down (`-1`) or not significant (`0`) by adjusting the two-sided
476/// p-values *across the contrasts within each gene* and thresholding at
477/// `p_value` (strict `<`).
478///
479/// `df` is the reference degrees of freedom: pass a single value to share it
480/// across genes (limma's default is `Inf`, i.e. a normal reference) or one
481/// value per gene (e.g. `df.residual + df.prior` from a fit). The `method`
482/// adjustment is applied row-wise via [`p_adjust`]. This is the row-wise
483/// companion to [`classify_tests_f`].
484pub fn classify_tests_p(
485    tstat: &Array2<f64>,
486    df: &[f64],
487    p_value: f64,
488    method: Adjust,
489) -> Array2<i8> {
490    let (ng, nc) = tstat.dim();
491    let mut result = Array2::<i8>::zeros((ng, nc));
492    for i in 0..ng {
493        let df_i = if df.len() == 1 { df[0] } else { df[i] };
494        let prow: Vec<f64> = (0..nc).map(|j| two_sided_p(tstat[[i, j]], df_i)).collect();
495        let adj = p_adjust(&prow, method);
496        for j in 0..nc {
497            if adj[j] < p_value {
498                let t = tstat[[i, j]];
499                result[[i, j]] = if t > 0.0 {
500                    1
501                } else if t < 0.0 {
502                    -1
503                } else {
504                    0
505                };
506            }
507        }
508    }
509    result
510}
511
512/// Two-sided Student-t p-value `2*pt(-|t|, df)`, using the normal limit when
513/// `df` is infinite.
514fn two_sided_p(t: f64, df: f64) -> f64 {
515    if t.is_nan() {
516        return f64::NAN;
517    }
518    if df.is_infinite() {
519        2.0 * ln_norm_cdf(-t.abs()).exp()
520    } else {
521        t_two_sided_pvalue(t, df)
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use crate::ebayes::ebayes;
529    use crate::fit::lmfit;
530    use crate::toptable::p_adjust_bh;
531    use ndarray::array;
532
533    /// lmFit + eBayes on `y` (genes × samples) against `design`, defaults.
534    fn fit_of(y: &Array2<f64>, design: &Array2<f64>) -> MArrayLM {
535        let ng = y.nrows();
536        let mut fit = lmfit(
537            y,
538            design,
539            vec![String::new(); ng],
540            vec![String::new(); design.ncols()],
541        )
542        .unwrap();
543        ebayes(&mut fit, 0.01, (0.1, 4.0), false, false).unwrap();
544        fit
545    }
546
547    fn nestedf(fit: &MArrayLM, adjust: Adjust) -> Array2<i8> {
548        decide_tests(fit, DecideMethod::NestedF, adjust, 0.1, 0.0)
549            .unwrap()
550            .data
551    }
552
553    #[test]
554    fn summary_testresults_matches_r() {
555        // Columns C1 = [-1,-1,0,0,1,1], C2 = [1,1,1,0,-1,0]; see
556        // scratch/summary_testresults_ref.R.
557        let data = array![[-1i8, 1], [-1, 1], [0, 1], [0, 0], [1, -1], [1, 0]];
558        let res = TestResults {
559            data,
560            gene_names: vec![String::new(); 6],
561            coef_names: vec!["C1".into(), "C2".into()],
562        };
563        let s = res.summary();
564        assert_eq!(s.down, vec![2, 1]);
565        assert_eq!(s.notsig, vec![2, 2]);
566        assert_eq!(s.up, vec![2, 3]);
567        assert_eq!(s.coef_names, vec!["C1".to_string(), "C2".to_string()]);
568        assert_eq!(TestResultsSummary::LABELS, ["Down", "NotSig", "Up"]);
569        assert_eq!(TestResultsSummary::LEVELS, [-1, 0, 1]);
570    }
571
572    // Case 1: orthogonal group-means design -> cov2cor is the identity and
573    // df.prior is Inf, so classifyTestsF takes the chi-squared limit.
574    #[test]
575    fn nestedf_orthogonal_df_inf_matches_r() {
576        let y = array![
577            [
578                -0.326036490515386,
579                0.526448098873642,
580                -0.163755665941423,
581                0.894937200540396,
582                0.482458809858766,
583                -1.15025528126137,
584                -0.259802107839475,
585                1.50989743811878,
586                1.85214757493023
587            ],
588            [
589                0.552461855419139,
590                -0.794844435415055,
591                0.708522104156376,
592                3.27915199641374,
593                3.758213782399,
594                2.72552883818354,
595                -3.41117304365668,
596                -0.380062992060078,
597                -0.888324730462178
598            ],
599            [
600                -0.67494384395583,
601                1.42775554468304,
602                -0.267980546181617,
603                1.00786575031424,
604                -2.31932736896749,
605                0.577901002861171,
606                -0.641357554081644,
607                1.1531581669848,
608                -0.51137532176584
609            ],
610            [
611                0.214359459043425,
612                -1.46681969416347,
613                -1.46392175987102,
614                -2.07310649057379,
615                -0.459504780196151,
616                -1.39690264657703,
617                0.112457509152448,
618                -0.0776035954486494,
619                -0.54388110381726
620            ],
621            [
622                2.3107692173136,
623                1.7633166213971,
624                2.74443582287532,
625                1.18985338378285,
626                -1.10538366697927,
627                0.74905771616478,
628                -1.57739566886144,
629                -3.81893450079797,
630                -3.72892728363495
631            ],
632            [
633                1.1739662875627,
634                -0.1933379649975,
635                -1.4103901810052,
636                -0.724374218369974,
637                0.402928273406297,
638                -1.05118669692296,
639                0.386835291215675,
640                -1.03744458273607,
641                0.470749538951291
642            ],
643            [
644                0.618789855625968,
645                -0.849754740333862,
646                0.467067606015246,
647                0.167983772348879,
648                0.568934917844047,
649                0.165380870625706,
650                -0.687798326082019,
651                0.302492245643734,
652                0.00538712217177903
653            ],
654            [
655                -3.11273431475421,
656                -2.9415345021505,
657                -3.11932010769425,
658                3.92033515858084,
659                2.29391671757039,
660                4.12980912049522,
661                0.148902488663005,
662                -1.27794616724639,
663                1.34804578555275
664            ],
665            [
666                0.917028289512712,
667                -0.817670355875958,
668                0.467238961765515,
669                -1.67160481202292,
670                -0.290090625815567,
671                1.17372246423572,
672                -0.0576497484824952,
673                0.138339047584972,
674                0.724096713299373
675            ],
676            [
677                -0.223259364627263,
678                -2.05030781563963,
679                0.498135564435914,
680                0.448469069614306,
681                -1.48387807160556,
682                -0.427863231850421,
683                -0.0748233649223828,
684                -0.0509841237610558,
685                1.5525491646626
686            ],
687        ];
688        let d = array![
689            [1.0, 0.0, 0.0],
690            [1.0, 0.0, 0.0],
691            [1.0, 0.0, 0.0],
692            [0.0, 1.0, 0.0],
693            [0.0, 1.0, 0.0],
694            [0.0, 1.0, 0.0],
695            [0.0, 0.0, 1.0],
696            [0.0, 0.0, 1.0],
697            [0.0, 0.0, 1.0],
698        ];
699        let fit = fit_of(&y, &d);
700        let none = array![
701            [0i8, 0, 0],
702            [0, 1, -1],
703            [0, 0, 0],
704            [0, -1, 0],
705            [1, 0, -1],
706            [0, 0, 0],
707            [0, 0, 0],
708            [-1, 1, 0],
709            [0, 0, 0],
710            [0, 0, 0],
711        ];
712        let bh = array![
713            [0i8, 0, 0],
714            [0, 1, -1],
715            [0, 0, 0],
716            [0, 0, 0],
717            [1, 0, -1],
718            [0, 0, 0],
719            [0, 0, 0],
720            [-1, 1, 0],
721            [0, 0, 0],
722            [0, 0, 0],
723        ];
724        assert_eq!(nestedf(&fit, Adjust::None), none);
725        assert_eq!(nestedf(&fit, Adjust::BH), bh);
726        // bonferroni/holm/BY coincide with BH for this dataset.
727        assert_eq!(nestedf(&fit, Adjust::Bonferroni), bh);
728        assert_eq!(nestedf(&fit, Adjust::Holm), bh);
729        assert_eq!(nestedf(&fit, Adjust::BY), bh);
730    }
731
732    // Case 2: treatment-contrast design -> non-diagonal cov.coefficients (so
733    // cov2cor + eigen-whitening run for real) with heteroscedastic genes giving
734    // a finite df.prior; the five adjust methods split into three patterns.
735    #[test]
736    fn nestedf_treatment_finite_df_matches_r() {
737        let y = array![
738            [
739                -0.56589213263584,
740                -0.129111406171985,
741                0.883844216372846,
742                0.165824405266157,
743                0.510226918101866,
744                -0.296387565274696,
745                -0.492528835489312,
746                -0.648942826369362,
747                0.112440116314965
748            ],
749            [
750                -0.440493509393273,
751                1.97452284169111,
752                0.943716053263893,
753                1.85296247687763,
754                0.918879051549637,
755                -1.08806877466116,
756                0.6005901507507,
757                2.44390249684897,
758                0.758510828034637
759            ],
760            [
761                -0.672800308611205,
762                -1.12260239699623,
763                -1.571493652712,
764                0.758358865990787,
765                3.16762861462062,
766                -1.71682047480086,
767                0.335444413313772,
768                -3.25946041926619,
769                -4.68105427064873
770            ],
771            [
772                -0.423524520837027,
773                0.478683534928917,
774                -0.705633425785031,
775                0.125863310320817,
776                -0.90874639309216,
777                -1.00280943895942,
778                -0.501117549685322,
779                0.366958043550944,
780                -0.664545331122461
781            ],
782            [
783                -0.14602743813943,
784                1.44508592518769,
785                -0.358918727489447,
786                -0.66242135593651,
787                0.0751840739467855,
788                -0.0948756636231239,
789                0.210626203050984,
790                -0.501720689149703,
791                -0.550236280526646
792            ],
793            [
794                -2.86109866273184,
795                0.678893878924788,
796                0.991825247632336,
797                -1.79353490652056,
798                -0.455885146494197,
799                -0.173438703743574,
800                -1.08355120304816,
801                -1.76993918388511,
802                1.66298603162855
803            ],
804            [
805                -0.388300259051768,
806                0.440476081563293,
807                -0.429356748501314,
808                3.60592978345959,
809                3.11410184476029,
810                3.47987710551828,
811                -2.49264794186162,
812                -4.16894137418337,
813                -3.0883195139039
814            ],
815            [
816                -1.93331260617645,
817                0.903537424251507,
818                0.51244916074171,
819                0.0196320667605606,
820                -0.977619008137272,
821                -0.288571232816508,
822                0.452976738959059,
823                -0.486617267289267,
824                0.617269628542488
825            ],
826            [
827                0.626091910478746,
828                1.49985602027188,
829                -3.49951251341427,
830                4.4610353076871,
831                -0.860897362008685,
832                -0.326803782997329,
833                -0.412492792768815,
834                2.55297326743676,
835                2.42184097256313
836            ],
837            [
838                0.234326050551141,
839                0.17952148545252,
840                -0.36977969915983,
841                0.254021081017535,
842                0.453273832028051,
843                -1.04934374531001,
844                0.609930131193006,
845                0.760402655291936,
846                0.446361110207894
847            ],
848            [
849                -0.138440069780317,
850                0.252561692089675,
851                1.83598463357393,
852                3.70833902421073,
853                3.23737777957835,
854                4.13127266288387,
855                -3.98118598742833,
856                -3.18961650350616,
857                -4.23194940211263
858            ],
859            [
860                2.35976493177311,
861                1.15449268217921,
862                1.19387673975458,
863                0.180160065150074,
864                -1.36989243177605,
865                2.64622305514602,
866                -0.0689664241349938,
867                -0.929549543153579,
868                -1.06316624908704
869            ],
870            [
871                -0.387941487034651,
872                0.209175055219415,
873                -0.0544107817969502,
874                0.0691119664745673,
875                0.0142895174023705,
876                0.0671937469525734,
877                -0.462459018396492,
878                0.417995706160492,
879                -0.31269377190439
880            ],
881            [
882                -0.107005800520593,
883                -0.661074953263737,
884                0.516719009994363,
885                3.46523024323356,
886                2.90939820969627,
887                3.58148939547231,
888                -3.04144579252648,
889                -4.38912509195675,
890                -3.23472505001669
891            ],
892            [
893                3.33934094640944,
894                5.49246633658689,
895                2.17312161159718,
896                -0.214606718731824,
897                -0.198951093031657,
898                -1.06259138423522,
899                -0.670897848735379,
900                -3.35869824696667,
901                0.697941907044954
902            ],
903        ];
904        let d = array![
905            [1.0, 0.0, 0.0],
906            [1.0, 0.0, 0.0],
907            [1.0, 0.0, 0.0],
908            [1.0, 1.0, 0.0],
909            [1.0, 1.0, 0.0],
910            [1.0, 1.0, 0.0],
911            [1.0, 0.0, 1.0],
912            [1.0, 0.0, 1.0],
913            [1.0, 0.0, 1.0],
914        ];
915        let fit = fit_of(&y, &d);
916        let none_bh = array![
917            [0i8, 0, 0],
918            [0, 0, 0],
919            [0, 0, 0],
920            [0, 0, 0],
921            [0, 0, 0],
922            [0, 0, 0],
923            [0, 1, -1],
924            [0, 0, 0],
925            [0, 0, 0],
926            [0, 0, 0],
927            [1, 1, -1],
928            [0, 0, 0],
929            [0, 0, 0],
930            [0, 1, -1],
931            [1, -1, -1],
932        ];
933        let bonf = array![
934            [0i8, 0, 0],
935            [0, 0, 0],
936            [0, 0, 0],
937            [0, 0, 0],
938            [0, 0, 0],
939            [0, 0, 0],
940            [0, 1, -1],
941            [0, 0, 0],
942            [0, 0, 0],
943            [0, 0, 0],
944            [0, 1, -1],
945            [0, 0, 0],
946            [0, 0, 0],
947            [0, 1, -1],
948            [0, 0, 0],
949        ];
950        let holm_by = array![
951            [0i8, 0, 0],
952            [0, 0, 0],
953            [0, 0, 0],
954            [0, 0, 0],
955            [0, 0, 0],
956            [0, 0, 0],
957            [0, 1, -1],
958            [0, 0, 0],
959            [0, 0, 0],
960            [0, 0, 0],
961            [0, 1, -1],
962            [0, 0, 0],
963            [0, 0, 0],
964            [0, 1, -1],
965            [1, 0, 0],
966        ];
967        assert_eq!(nestedf(&fit, Adjust::None), none_bh);
968        assert_eq!(nestedf(&fit, Adjust::BH), none_bh);
969        assert_eq!(nestedf(&fit, Adjust::Bonferroni), bonf);
970        assert_eq!(nestedf(&fit, Adjust::Holm), holm_by);
971        assert_eq!(nestedf(&fit, Adjust::BY), holm_by);
972    }
973
974    fn pdata() -> (Array2<f64>, Array2<f64>) {
975        let p = array![
976            [0.0008, 0.50, 0.30],
977            [0.0001, 0.002, 0.80],
978            [0.20, 0.40, 0.95],
979            [0.030, 0.045, 0.60],
980            [0.70, 0.65, 0.55],
981            [0.004, 0.009, 0.012],
982            [0.50, 0.0006, 0.40],
983            [0.10, 0.11, 0.0009],
984        ];
985        let coef = array![
986            [1.5, -0.3, 0.8],
987            [-2.1, 1.7, -0.2],
988            [0.4, -0.6, 0.1],
989            [1.2, 1.1, -0.5],
990            [-0.2, 0.3, -0.4],
991            [2.0, -1.8, 1.6],
992            [-0.5, -2.4, 0.6],
993            [0.9, 1.0, -2.2],
994        ];
995        (p, coef)
996    }
997
998    #[test]
999    fn decide_tests_pvalues_matches_r() {
1000        let (p, coef) = pdata();
1001        let dt = |m, a| {
1002            decide_tests_pvalues(&p, m, a, 0.05, 0.0, Some(&coef))
1003                .unwrap()
1004                .data
1005        };
1006        let sep_none = array![
1007            [1i8, 0, 0],
1008            [-1, 1, 0],
1009            [0, 0, 0],
1010            [1, 1, 0],
1011            [0, 0, 0],
1012            [1, -1, 1],
1013            [0, -1, 0],
1014            [0, 0, -1],
1015        ];
1016        let sep_bh = array![
1017            [1i8, 0, 0],
1018            [-1, 1, 0],
1019            [0, 0, 0],
1020            [0, 0, 0],
1021            [0, 0, 0],
1022            [1, -1, 1],
1023            [0, -1, 0],
1024            [0, 0, -1],
1025        ];
1026        let sep_bonf = array![
1027            [1i8, 0, 0],
1028            [-1, 1, 0],
1029            [0, 0, 0],
1030            [0, 0, 0],
1031            [0, 0, 0],
1032            [1, 0, 0],
1033            [0, -1, 0],
1034            [0, 0, -1],
1035        ];
1036        let glob_bonf = array![
1037            [1i8, 0, 0],
1038            [-1, 1, 0],
1039            [0, 0, 0],
1040            [0, 0, 0],
1041            [0, 0, 0],
1042            [0, 0, 0],
1043            [0, -1, 0],
1044            [0, 0, -1],
1045        ];
1046        let hier_none = sep_bh.clone(); // row4 not gene-wise significant
1047        let hier_bonf = glob_bonf.clone();
1048        let hier_holm = sep_bonf.clone();
1049
1050        // separate
1051        assert_eq!(dt(DecideMethod::Separate, Adjust::None), sep_none);
1052        assert_eq!(dt(DecideMethod::Separate, Adjust::BH), sep_bh);
1053        assert_eq!(dt(DecideMethod::Separate, Adjust::Bonferroni), sep_bonf);
1054        assert_eq!(dt(DecideMethod::Separate, Adjust::Holm), sep_bonf);
1055        assert_eq!(dt(DecideMethod::Separate, Adjust::BY), sep_bonf);
1056        // global (none/BH coincide with separate; multiplicity is matrix-wide)
1057        assert_eq!(dt(DecideMethod::Global, Adjust::None), sep_none);
1058        assert_eq!(dt(DecideMethod::Global, Adjust::BH), sep_bh);
1059        assert_eq!(dt(DecideMethod::Global, Adjust::Bonferroni), glob_bonf);
1060        assert_eq!(dt(DecideMethod::Global, Adjust::Holm), glob_bonf);
1061        assert_eq!(dt(DecideMethod::Global, Adjust::BY), glob_bonf);
1062        // hierarchical (Simes)
1063        assert_eq!(dt(DecideMethod::Hierarchical, Adjust::None), hier_none);
1064        assert_eq!(dt(DecideMethod::Hierarchical, Adjust::BH), hier_none);
1065        assert_eq!(
1066            dt(DecideMethod::Hierarchical, Adjust::Bonferroni),
1067            hier_bonf
1068        );
1069        assert_eq!(dt(DecideMethod::Hierarchical, Adjust::Holm), hier_holm);
1070        assert_eq!(dt(DecideMethod::Hierarchical, Adjust::BY), hier_bonf);
1071    }
1072
1073    #[test]
1074    fn decide_tests_pvalues_lfc_and_nocoef_match_r() {
1075        let (p, coef) = pdata();
1076        // lfc = 1.3 zeroes entries with |coef| < 1.3 (row4's 1.2 and 1.1).
1077        let lfc_res = array![
1078            [1i8, 0, 0],
1079            [-1, 1, 0],
1080            [0, 0, 0],
1081            [0, 0, 0],
1082            [0, 0, 0],
1083            [1, -1, 1],
1084            [0, -1, 0],
1085            [0, 0, -1],
1086        ];
1087        let got = decide_tests_pvalues(
1088            &p,
1089            DecideMethod::Separate,
1090            Adjust::None,
1091            0.05,
1092            1.3,
1093            Some(&coef),
1094        )
1095        .unwrap()
1096        .data;
1097        assert_eq!(got, lfc_res);
1098
1099        // No coefficients: significant entries are 1 (no direction).
1100        let nocoef = array![
1101            [1i8, 0, 0],
1102            [1, 1, 0],
1103            [0, 0, 0],
1104            [0, 0, 0],
1105            [0, 0, 0],
1106            [1, 1, 1],
1107            [0, 1, 0],
1108            [0, 0, 1],
1109        ];
1110        let got = decide_tests_pvalues(&p, DecideMethod::Global, Adjust::BH, 0.05, 0.0, None)
1111            .unwrap()
1112            .data;
1113        assert_eq!(got, nocoef);
1114    }
1115
1116    #[test]
1117    fn nestedf_on_pvalue_matrix_errors() {
1118        let (p, _) = pdata();
1119        assert!(
1120            decide_tests_pvalues(&p, DecideMethod::NestedF, Adjust::BH, 0.05, 0.0, None).is_err()
1121        );
1122    }
1123
1124    #[test]
1125    fn bh_matches_toptable_impl() {
1126        let p = [0.01, 0.04, 0.03, 0.20, 0.005, 0.9, 0.5];
1127        let a = p_adjust(&p, Adjust::BH);
1128        let b = p_adjust_bh(&p);
1129        for (x, y) in a.iter().zip(b.iter()) {
1130            assert!((x - y).abs() < 1e-15, "{x} vs {y}");
1131        }
1132    }
1133
1134    #[test]
1135    fn bonferroni_clamps_at_one() {
1136        let p = [0.1, 0.5, 0.9];
1137        let a = p_adjust(&p, Adjust::Bonferroni);
1138        assert!((a[0] - 0.3).abs() < 1e-15);
1139        assert!((a[1] - 1.0).abs() < 1e-15);
1140        assert!((a[2] - 1.0).abs() < 1e-15);
1141    }
1142
1143    #[test]
1144    fn holm_matches_r() {
1145        // R: p.adjust(c(0.01,0.04,0.03,0.005), "holm") =
1146        //    0.03 0.06 0.06 0.02
1147        let p = [0.01, 0.04, 0.03, 0.005];
1148        let a = p_adjust(&p, Adjust::Holm);
1149        let want = [0.03, 0.06, 0.06, 0.02];
1150        for (x, y) in a.iter().zip(want.iter()) {
1151            assert!((x - y).abs() < 1e-12, "{x} vs {y}");
1152        }
1153    }
1154
1155    #[test]
1156    fn by_is_bh_times_harmonic() {
1157        // BY(p) relates to BH via the harmonic factor q = sum(1/k).
1158        let p = [0.01, 0.04, 0.03, 0.20, 0.005];
1159        let by = p_adjust(&p, Adjust::BY);
1160        // R: p.adjust(c(0.01,0.04,0.03,0.20,0.005), "BY") =
1161        //    0.05708333 0.11416667 0.11416667 0.45666667 0.05708333
1162        let want = [
1163            0.0570833333,
1164            0.1141666667,
1165            0.1141666667,
1166            0.4566666667,
1167            0.0570833333,
1168        ];
1169        for (x, y) in by.iter().zip(want.iter()) {
1170            assert!((x - y).abs() < 1e-9, "{x} vs {y}");
1171        }
1172    }
1173
1174    #[test]
1175    fn classify_tests_p_matches_r() {
1176        let tstat = array![
1177            [3.2, -1.1, 0.4],
1178            [2.6, 2.9, -2.7],
1179            [-0.3, 0.8, 1.0],
1180            [4.1, -3.8, 2.2],
1181        ];
1182        // limma:::.classifyTestsP(tstat, df, p.value, method); negative zeros in
1183        // R collapse to 0 here.
1184        let holm_inf = array![[1, 0, 0], [1, 1, -1], [0, 0, 0], [1, -1, 1]];
1185        assert_eq!(
1186            classify_tests_p(&tstat, &[f64::INFINITY], 0.05, Adjust::Holm),
1187            holm_inf
1188        );
1189        assert_eq!(
1190            classify_tests_p(&tstat, &[f64::INFINITY], 0.10, Adjust::BH),
1191            holm_inf
1192        );
1193        let df10 = array![[1, 0, 0], [1, 1, -1], [0, 0, 0], [1, -1, 0]];
1194        assert_eq!(classify_tests_p(&tstat, &[10.0], 0.05, Adjust::Holm), df10);
1195        assert_eq!(classify_tests_p(&tstat, &[5.0], 0.05, Adjust::None), df10);
1196    }
1197}