Skip to main content

edge_rs/glm/
test.rs

1//! Turning a fitted negative binomial GLM into p-values.
2//!
3//! Three nested-model tests against the same fit:
4//!
5//! * [`glm_lrt`] (`glmLRT`) refits the null and reads the deviance difference
6//!   off a chi-squared.
7//! * [`glm_ql_ftest`] (`glmQLFTest`) divides that difference by a squeezed
8//!   quasi-likelihood dispersion and reads an F.
9//! * [`glm_treat`] (`glmTreat`) shifts the offsets by a fold-change threshold
10//!   and tests against an interval null.
11//!
12//! The quasi-likelihood quantities arrive in a [`QlSummary`]; the squeezing
13//! belongs to `glmQLFit`. The data behind the fit travels in one
14//! [`GlmTestInput`].
15
16use rayon::prelude::*;
17
18use crate::glm::fit::{GlmFit, glm_fit};
19use crate::glm::levenberg::{LevenbergParams, mglm_levenberg};
20use crate::numeric::dist::{chisq_sf, f_sf, norm_cdf, norm_ppf, t_sf};
21use crate::prelude::*;
22use crate::utils::design::{contrast_as_coef, design_as_factor, matrix_rank, non_estimable};
23
24////////////
25// Consts //
26////////////
27
28/// Half-width of the interval null in [`glm_treat`], on the z scale.
29///
30/// Hard-coded in edgeR's `glmTreat` (McCarthy and Smyth, 2009).
31const TREAT_INTERVAL_WIDTH: f64 = 1.470402;
32
33/// Largest magnitude a treat z-score is allowed to reach.
34///
35/// `norm_sf(38)` is about 2.9e-316, so the tail is zero past this. The clamp
36/// stops an infinite z turning the interval null's `b - a` division into a NaN.
37const MAX_TREAT_ZSCORE: f64 = 38.0;
38
39/// `1 / sqrt(2 * pi)`.
40const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
41
42/////////////
43// Helpers //
44/////////////
45
46/// The design a test works on, the columns it drops, and the fold change it
47/// reports.
48struct Resolved {
49    /// Design to test against, row-major `n_samples * n_coef`. The input design
50    /// for a coefficient test, the rotated one for a contrast.
51    design: Vec<f64>,
52    /// Columns the null model drops, ascending.
53    coef: Vec<usize>,
54    /// Log2 fold change per gene.
55    log_fc: Vec<f64>,
56}
57
58/// Works out which columns a test drops and what fold change it reports.
59///
60/// For a contrast this runs [`contrast_as_coef`] with `first = true`, so the
61/// contrasts land in the leading columns, as in edgeR's `glmLRT` and `glmTreat`.
62///
63/// ### Params
64///
65/// * `input` - Test input
66/// * `fit` - The full-model fit, for its coefficients
67/// * `tested` - Coefficients or contrast under test
68///
69/// ### Returns
70///
71/// The resolved design, dropped columns and log-fold-changes, or
72/// [`EdgeErrors`] if an index is out of range or the contrast is degenerate.
73fn resolve_tested<T: EdgeFloat>(
74    input: &GlmTestInput<'_, T>,
75    fit: &GlmFit,
76    tested: &Tested,
77) -> Result<Resolved, EdgeErrors> {
78    match tested {
79        Tested::Coef(requested) => {
80            let mut coef: Vec<usize> = Vec::with_capacity(requested.len());
81            for index in requested {
82                if *index >= input.n_coef {
83                    return Err(EdgeErrors::CoefOutOfRange {
84                        index: *index,
85                        n_coef: input.n_coef,
86                    });
87                }
88                if !coef.contains(index) {
89                    coef.push(*index);
90                }
91            }
92            if coef.is_empty() {
93                return Err(EdgeErrors::InvalidArgument(
94                    "no coefficients were selected for testing".to_string(),
95                ));
96            }
97            let first = coef[0];
98            let log_fc = project(
99                &fit.coefficients,
100                input.n_genes,
101                input.n_coef,
102                tested,
103                first,
104            );
105            coef.sort_unstable();
106            Ok(Resolved {
107                design: input.design.to_vec(),
108                coef,
109                log_fc,
110            })
111        }
112        Tested::Contrast {
113            values,
114            n_contrasts,
115        } => {
116            if values.len() != input.n_coef * n_contrasts {
117                return Err(EdgeErrors::LengthMismatch {
118                    name: "contrast",
119                    expected: input.n_coef * n_contrasts,
120                    got: values.len(),
121                });
122            }
123            // Column-major `n_coef * n_contrasts` read as row-major is the
124            // transpose, which has the same rank.
125            let rank = matrix_rank(values, *n_contrasts, input.n_coef)?;
126            if rank == 0 {
127                return Err(EdgeErrors::InvalidArgument(
128                    "contrasts are all zero".to_string(),
129                ));
130            }
131            if rank < *n_contrasts {
132                return Err(EdgeErrors::InvalidArgument(format!(
133                    "contrast matrix has rank {rank} but {n_contrasts} columns; \
134                     drop the redundant columns first"
135                )));
136            }
137            let reform = contrast_as_coef(
138                input.design,
139                input.n_samples,
140                input.n_coef,
141                values,
142                *n_contrasts,
143                true,
144            )?;
145            // `project` zips against `values`, so it stops at `n_coef` elements
146            // and reads only the first contrast column of a multi-column `tested`.
147            let log_fc = project(&fit.coefficients, input.n_genes, input.n_coef, tested, 0);
148            Ok(Resolved {
149                design: reform.design,
150                coef: reform.coef,
151                log_fc,
152            })
153        }
154    }
155}
156
157/// Cuts a [`Tested`] down to the single column [`glm_treat`] can handle.
158///
159/// ### Params
160///
161/// * `tested` - Coefficients or contrast under test
162/// * `n_coef` - Number of coefficients in the design
163///
164/// ### Returns
165///
166/// The first coefficient, or the first contrast column, or
167/// [`EdgeErrors::InvalidArgument`] if nothing was selected.
168fn narrow(tested: &Tested, n_coef: usize) -> Result<Tested, EdgeErrors> {
169    match tested {
170        Tested::Coef(v) => match v.first() {
171            Some(first) => Ok(Tested::Coef(vec![*first])),
172            None => Err(EdgeErrors::InvalidArgument(
173                "no coefficients were selected for testing".to_string(),
174            )),
175        },
176        Tested::Contrast { values, .. } => {
177            if values.len() < n_coef {
178                return Err(EdgeErrors::LengthMismatch {
179                    name: "contrast",
180                    expected: n_coef,
181                    got: values.len(),
182                });
183            }
184            Ok(Tested::Contrast {
185                values: values[..n_coef].to_vec(),
186                n_contrasts: 1,
187            })
188        }
189    }
190}
191
192/// Log2 fold change of one column of a coefficient matrix, or of a contrast.
193///
194/// Gives the unshrunk fold change [`glm_treat`] compares against `lfc`; the
195/// reported one comes from the shrunk coefficients.
196///
197/// ### Params
198///
199/// * `coefficients` - Row-major `n_genes * n_coef`
200/// * `n_genes` - Number of genes
201/// * `n_coef` - Number of coefficients
202/// * `tested` - The narrowed selection, which decides column or contrast
203/// * `column` - Column index to read for the coefficient case
204///
205/// ### Returns
206///
207/// One log2 fold change per gene.
208fn project(
209    coefficients: &[f64],
210    n_genes: usize,
211    n_coef: usize,
212    tested: &Tested,
213    column: usize,
214) -> Vec<f64> {
215    (0..n_genes)
216        .map(|gene| {
217            let beta = &coefficients[gene * n_coef..(gene + 1) * n_coef];
218            let value = match tested {
219                Tested::Coef(_) => beta[column],
220                Tested::Contrast { values, .. } => {
221                    beta.iter().zip(values.iter()).map(|(b, c)| b * c).sum()
222                }
223            };
224            value / std::f64::consts::LN_2
225        })
226        .collect()
227}
228
229/// Refits the null model, warm-starting it where that is safe.
230///
231/// As in edgePython, a non-one-way null starts the Levenberg fit from the full
232/// fit's coefficients on the kept columns (about twenty iterations down to two
233/// on wide designs). A one-way null goes through [`glm_fit`] cold, as a warm
234/// start from a near-zero gene can derail the Fisher scoring.
235///
236/// ### Params
237///
238/// * `input` - Test input, for the counts and weights
239/// * `design_null` - Null design, row-major `n_samples * n_coef_null`
240/// * `n_coef_null` - Columns the null keeps
241/// * `dispersion` - Dispersion to fit at
242/// * `offset` - Offsets to fit at
243/// * `start` - Warm start, row-major `n_genes * n_coef_null`, if one is
244///   available
245///
246/// ### Returns
247///
248/// One residual deviance per gene.
249fn fit_null<T: EdgeFloat>(
250    input: &GlmTestInput<'_, T>,
251    design_null: &[f64],
252    n_coef_null: usize,
253    dispersion: &Recycled<f64>,
254    offset: &Recycled<f64>,
255    start: Option<&[f64]>,
256) -> Result<Vec<f64>, EdgeErrors> {
257    let (_, n_groups) = design_as_factor(design_null, input.n_samples, n_coef_null)?;
258    let one_way = n_groups == n_coef_null;
259
260    match start {
261        Some(values) if !one_way => {
262            if let Some(bad) = non_estimable(design_null, input.n_samples, n_coef_null)? {
263                return Err(EdgeErrors::DesignNotFullRank {
264                    n_cols: n_coef_null,
265                    rank: n_coef_null - bad.len(),
266                });
267            }
268            // Same iteration budget as `glm_fit`, not the default of 200.
269            let params = LevenbergParams {
270                max_iter: crate::glm::fit::GLM_FIT_MAX_ITER,
271                ..Default::default()
272            };
273            let fit = mglm_levenberg(
274                input.counts,
275                input.n_genes,
276                input.n_samples,
277                design_null,
278                n_coef_null,
279                dispersion,
280                offset,
281                input.weights,
282                Some(values),
283                Some(params),
284            )?;
285            Ok(fit.deviance)
286        }
287        _ => {
288            let fit = glm_fit(
289                input.counts,
290                input.n_genes,
291                input.n_samples,
292                design_null,
293                n_coef_null,
294                dispersion,
295                offset,
296                input.weights,
297                0.0,
298            )?;
299            Ok(fit.deviance)
300        }
301    }
302}
303
304/// One bound of the threshold test.
305///
306/// Fits the full and null models at offsets shifted by `sign * adjustment` and
307/// returns the root of the deviance difference, the z-score for the shifted
308/// null.
309///
310/// ### Params
311///
312/// * `input` - Test input
313/// * `design` - Full design, row-major `n_samples * n_coef`
314/// * `design_null` - Null design, row-major `n_samples * (n_coef - 1)`
315/// * `dispersion` - Dispersion to fit at
316/// * `adjustment` - Per-sample offset shift, `lfc * ln 2 * design[, coef]`
317/// * `sign` - `+1` for the left bound, `-1` for the right
318///
319/// ### Returns
320///
321/// One non-negative z-score per gene.
322fn threshold_z<T: EdgeFloat>(
323    input: &GlmTestInput<'_, T>,
324    design: &[f64],
325    design_null: &[f64],
326    dispersion: &Recycled<f64>,
327    adjustment: &[f64],
328    sign: f64,
329) -> Result<Vec<f64>, EdgeErrors> {
330    let offset = shift_offset(
331        input.offset,
332        adjustment,
333        input.n_genes,
334        input.n_samples,
335        sign,
336    );
337    let fit_null = glm_fit(
338        input.counts,
339        input.n_genes,
340        input.n_samples,
341        design_null,
342        input.n_coef - 1,
343        dispersion,
344        &offset,
345        input.weights,
346        0.0,
347    )?;
348    let fit_full = glm_fit(
349        input.counts,
350        input.n_genes,
351        input.n_samples,
352        design,
353        input.n_coef,
354        dispersion,
355        &offset,
356        input.weights,
357        0.0,
358    )?;
359    Ok(fit_null
360        .deviance
361        .iter()
362        .zip(fit_full.deviance.iter())
363        .map(|(null, full)| (null - full).max(0.0).sqrt())
364        .collect())
365}
366
367/// The treat p-value for one gene.
368///
369/// ### Params
370///
371/// * `z_left` - Signed z-score of the nearer bound
372/// * `z_right` - z-score of the further bound
373/// * `null` - Which null to integrate against
374///
375/// ### Returns
376///
377/// The p-value, in `[0, 1]` up to rounding in the integral.
378fn treat_p_value(z_left: f64, z_right: f64, null: TreatNull) -> f64 {
379    match null {
380        TreatNull::WorstCase => norm_cdf(-z_right) + norm_cdf(z_left),
381        TreatNull::Interval => {
382            if z_right + z_left > TREAT_INTERVAL_WIDTH {
383                integrate_pnorm(-z_right, -z_right + TREAT_INTERVAL_WIDTH)
384                    + integrate_pnorm(z_left - TREAT_INTERVAL_WIDTH, z_left)
385            } else {
386                2.0 * integrate_pnorm(-z_right, z_left)
387            }
388        }
389    }
390}
391
392/// Mean of the standard normal CDF over `[a, b]`.
393///
394/// Ports edgeR's `.integratepnorm`: `(b F(b) + f(b) - a F(a) - f(a)) / (b - a)`,
395/// collapsing to `F(a)` when `a == b` (exact test, as in edgeR).
396///
397/// ### Params
398///
399/// * `a` - Lower limit
400/// * `b` - Upper limit
401///
402/// ### Returns
403///
404/// The average of `F` over the interval.
405fn integrate_pnorm(a: f64, b: f64) -> f64 {
406    if a == b {
407        return norm_cdf(a);
408    }
409    (b * norm_cdf(b) + norm_pdf(b) - (a * norm_cdf(a) + norm_pdf(a))) / (b - a)
410}
411
412/// Standard normal density.
413///
414/// ### Params
415///
416/// * `x` - Quantile
417///
418/// ### Returns
419///
420/// `exp(-x^2 / 2) / sqrt(2 pi)`.
421#[inline]
422fn norm_pdf(x: f64) -> f64 {
423    INV_SQRT_2PI * (-0.5 * x * x).exp()
424}
425
426/// Converts a t statistic to the z with the same tail probability.
427///
428/// Ports limma's `zscoreT` (exact setting): the upper t tail is evaluated
429/// directly and inverted through the normal quantile, never as `1 - cdf`. limma
430/// works in logs; here the tail probability is floored and the answer clamped
431/// to [`MAX_TREAT_ZSCORE`], where the normal tail is already 1e-316 and the
432/// treat integral would otherwise divide by an infinity.
433///
434/// ### Params
435///
436/// * `x` - t statistic
437/// * `df` - Degrees of freedom, finite and strictly positive
438///
439/// ### Returns
440///
441/// The matching z-score, or [`EdgeErrors::InvalidArgument`] for a non-positive
442/// `df`.
443fn zscore_t(x: f64, df: f64) -> Result<f64, EdgeErrors> {
444    let tail = t_sf(x.abs(), df)?;
445    let z = -norm_ppf(tail.max(f64::MIN_POSITIVE))?;
446    let z = z.min(MAX_TREAT_ZSCORE);
447    Ok(if x < 0.0 { -z } else { z })
448}
449
450/// Genes whose quasi-likelihood variance has dropped below the Poisson
451/// variance.
452///
453/// Ports edgeR's `check_poisson_bound`: a gene is flagged once one library has
454/// `s2_post * (1 + dispersion * mu) < 1`. The dispersion is the one stored on
455/// the fit, undivided by `average_ql_dispersion`, as in edgeR.
456///
457/// ### Params
458///
459/// * `input` - Test input, for the dispersion
460/// * `ql` - Quasi-likelihood summary, for `s2_post` and the fitted means
461///
462/// ### Returns
463///
464/// One flag per gene.
465fn below_poisson_bound<T: EdgeFloat>(input: &GlmTestInput<'_, T>, ql: &QlSummary<'_>) -> Vec<bool> {
466    (0..input.n_genes)
467        .into_par_iter()
468        .map(|gene| {
469            let disp = input.dispersion.row(gene, input.n_samples);
470            let s2 = ql.s2_post[gene];
471            let mu = &ql.fitted[gene * input.n_samples..(gene + 1) * input.n_samples];
472            mu.iter()
473                .enumerate()
474                .any(|(sample, m)| s2 * (1.0 + disp.get(sample) * m) < 1.0)
475        })
476        .collect()
477}
478
479/// Refits at zero dispersion and runs the likelihood ratio test on that fit.
480///
481/// edgeR subsets to the flagged genes first; this fits all of them, to avoid
482/// rebuilding every recycled matrix.
483///
484/// ### Params
485///
486/// * `input` - Test input
487/// * `tested` - Coefficients or contrast under test
488///
489/// ### Returns
490///
491/// The Poisson likelihood ratio test, whose p-values are the bound.
492fn poisson_refit_lrt<T: EdgeFloat>(
493    input: &GlmTestInput<'_, T>,
494    tested: &Tested,
495) -> Result<GlmTest, EdgeErrors> {
496    let dispersion = Recycled::scalar(0.0);
497    let poisson = poisson_input(input, &dispersion);
498    let fit = poisson_fit(&poisson)?;
499    glm_lrt(&poisson, &fit, tested, None)
500}
501
502/// The same bound for [`glm_treat`].
503///
504/// edgeR re-enters `glmTreat` here without forwarding `null`, so the bound is
505/// always against the interval null. Reproduced.
506///
507/// ### Params
508///
509/// * `input` - Test input
510/// * `tested` - The narrowed selection under test
511/// * `lfc` - Log2 fold-change threshold
512///
513/// ### Returns
514///
515/// The Poisson threshold test, whose p-values are the bound.
516fn poisson_refit_treat<T: EdgeFloat>(
517    input: &GlmTestInput<'_, T>,
518    tested: &Tested,
519    lfc: f64,
520) -> Result<GlmTest, EdgeErrors> {
521    let dispersion = Recycled::scalar(0.0);
522    let poisson = poisson_input(input, &dispersion);
523    let fit = poisson_fit(&poisson)?;
524    glm_treat(&poisson, &fit, None, tested, lfc, TreatNull::Interval)
525}
526
527/// Rewrites a test input to sit at zero dispersion.
528///
529/// ### Params
530///
531/// * `input` - Test input
532/// * `dispersion` - The zero dispersion to borrow
533///
534/// ### Returns
535///
536/// A copy of the input with the dispersion replaced and the log-CPM dropped.
537fn poisson_input<'a, T: EdgeFloat>(
538    input: &GlmTestInput<'a, T>,
539    dispersion: &'a Recycled<f64>,
540) -> GlmTestInput<'a, T> {
541    GlmTestInput {
542        counts: input.counts,
543        n_genes: input.n_genes,
544        n_samples: input.n_samples,
545        design: input.design,
546        n_coef: input.n_coef,
547        dispersion,
548        offset: input.offset,
549        weights: input.weights,
550        log_cpm: None,
551    }
552}
553
554/// Fits the full model at zero dispersion.
555///
556/// ### Params
557///
558/// * `input` - Test input already carrying a zero dispersion
559///
560/// ### Returns
561///
562/// The Poisson fit. Shrinkage is off, since only the deviance is read.
563fn poisson_fit<T: EdgeFloat>(input: &GlmTestInput<'_, T>) -> Result<GlmFit, EdgeErrors> {
564    glm_fit(
565        input.counts,
566        input.n_genes,
567        input.n_samples,
568        input.design,
569        input.n_coef,
570        input.dispersion,
571        input.offset,
572        input.weights,
573        0.0,
574    )
575}
576
577/// Drops columns from a row-major design.
578///
579/// ### Params
580///
581/// * `design` - Row-major `n_rows * n_cols`
582/// * `n_rows` - Number of samples
583/// * `n_cols` - Number of coefficients
584/// * `drop` - Column indices to remove
585///
586/// ### Returns
587///
588/// The design without those columns, still row-major.
589fn drop_columns(design: &[f64], n_rows: usize, n_cols: usize, drop: &[usize]) -> Vec<f64> {
590    let kept: Vec<usize> = (0..n_cols).filter(|c| !drop.contains(c)).collect();
591    let mut out = Vec::with_capacity(n_rows * kept.len());
592    for row in design.chunks_exact(n_cols) {
593        out.extend(kept.iter().map(|c| row[*c]));
594    }
595    out
596}
597
598/// Adds a per-sample shift to a recycled offset matrix.
599///
600/// A scalar or per-sample offset stays per-sample; a per-gene or full offset
601/// expands, as the result varies along both axes.
602///
603/// ### Params
604///
605/// * `offset` - Offsets to shift
606/// * `adjustment` - Per-sample shift, length `n_samples`
607/// * `n_genes` - Number of genes
608/// * `n_samples` - Number of samples
609/// * `sign` - `+1` to add the shift, `-1` to subtract it
610///
611/// ### Returns
612///
613/// The shifted offsets.
614fn shift_offset(
615    offset: &Recycled<f64>,
616    adjustment: &[f64],
617    n_genes: usize,
618    n_samples: usize,
619    sign: f64,
620) -> Recycled<f64> {
621    match offset {
622        Recycled::Scalar(v) => {
623            Recycled::BySample(adjustment.iter().map(|a| v + sign * a).collect())
624        }
625        Recycled::BySample(values) => Recycled::BySample(
626            values
627                .iter()
628                .zip(adjustment.iter())
629                .map(|(v, a)| v + sign * a)
630                .collect(),
631        ),
632        Recycled::ByGene(values) => {
633            let mut out = Vec::with_capacity(n_genes * n_samples);
634            for value in values {
635                out.extend(adjustment.iter().map(|a| value + sign * a));
636            }
637            Recycled::Full(out)
638        }
639        Recycled::Full(values) => Recycled::Full(
640            values
641                .chunks_exact(n_samples)
642                .flat_map(|row| {
643                    row.iter()
644                        .zip(adjustment.iter())
645                        .map(|(v, a)| v + sign * a)
646                        .collect::<Vec<f64>>()
647                })
648                .collect(),
649        ),
650    }
651}
652
653/// Reads a per-gene quantity that may have been supplied as a single value.
654///
655/// ### Params
656///
657/// * `values` - Either one value or one per gene
658/// * `gene` - Gene index
659///
660/// ### Returns
661///
662/// The value for that gene.
663#[inline]
664fn recycle(values: &[f64], gene: usize) -> f64 {
665    if values.len() == 1 {
666        values[0]
667    } else {
668        values[gene]
669    }
670}
671
672/// Checks the shapes a test relies on.
673///
674/// ### Params
675///
676/// * `input` - Test input
677/// * `fit` - The full-model fit
678///
679/// ### Returns
680///
681/// `Ok(())`, or the first [`EdgeErrors`] the shapes trip.
682fn validate<T: EdgeFloat>(input: &GlmTestInput<'_, T>, fit: &GlmFit) -> Result<(), EdgeErrors> {
683    if input.n_genes == 0 || input.n_samples == 0 {
684        return Err(EdgeErrors::EmptyCounts {
685            n_genes: input.n_genes,
686            n_samples: input.n_samples,
687        });
688    }
689    if input.n_coef < 2 {
690        return Err(EdgeErrors::InvalidArgument(
691            "need at least two design columns; the first is usually the intercept".to_string(),
692        ));
693    }
694    if input.counts.len() != input.n_genes * input.n_samples {
695        return Err(EdgeErrors::LengthMismatch {
696            name: "counts",
697            expected: input.n_genes * input.n_samples,
698            got: input.counts.len(),
699        });
700    }
701    if input.design.len() != input.n_samples * input.n_coef {
702        return Err(EdgeErrors::LengthMismatch {
703            name: "design",
704            expected: input.n_samples * input.n_coef,
705            got: input.design.len(),
706        });
707    }
708    if fit.coefficients.len() != input.n_genes * input.n_coef {
709        return Err(EdgeErrors::LengthMismatch {
710            name: "coefficients",
711            expected: input.n_genes * input.n_coef,
712            got: fit.coefficients.len(),
713        });
714    }
715    if fit.deviance.len() != input.n_genes {
716        return Err(EdgeErrors::LengthMismatch {
717            name: "deviance",
718            expected: input.n_genes,
719            got: fit.deviance.len(),
720        });
721    }
722    if let Some(cpm) = input.log_cpm
723        && cpm.len() != input.n_genes
724    {
725        return Err(EdgeErrors::LengthMismatch {
726            name: "log_cpm",
727            expected: input.n_genes,
728            got: cpm.len(),
729        });
730    }
731    input.dispersion.validate(input.n_genes, input.n_samples)?;
732    input.offset.validate(input.n_genes, input.n_samples)?;
733    if let Some(w) = input.weights {
734        w.validate(input.n_genes, input.n_samples)?;
735    }
736    Ok(())
737}
738
739/// Checks the shapes of a quasi-likelihood summary.
740///
741/// ### Params
742///
743/// * `input` - Test input
744/// * `ql` - The summary to check
745///
746/// ### Returns
747///
748/// `Ok(())`, or the first [`EdgeErrors`] the shapes trip.
749fn validate_ql<T: EdgeFloat>(
750    input: &GlmTestInput<'_, T>,
751    ql: &QlSummary<'_>,
752) -> Result<(), EdgeErrors> {
753    let shapes: [(&'static str, usize, usize); 3] = [
754        ("s2_post", ql.s2_post.len(), input.n_genes),
755        ("df_residual_adj", ql.df_residual_adj.len(), input.n_genes),
756        ("fitted", ql.fitted.len(), input.n_genes * input.n_samples),
757    ];
758    for (name, got, expected) in shapes {
759        if got != expected {
760            return Err(EdgeErrors::LengthMismatch {
761                name,
762                expected,
763                got,
764            });
765        }
766    }
767    if ql.df_prior.len() != 1 && ql.df_prior.len() != input.n_genes {
768        return Err(EdgeErrors::LengthMismatch {
769            name: "df_prior",
770            expected: input.n_genes,
771            got: ql.df_prior.len(),
772        });
773    }
774    if let Some(zeros) = ql.df_residual_zeros
775        && zeros.len() != input.n_genes
776    {
777        return Err(EdgeErrors::LengthMismatch {
778            name: "df_residual_zeros",
779            expected: input.n_genes,
780            got: zeros.len(),
781        });
782    }
783    Ok(())
784}
785
786//////////////////////
787// Public interface //
788//////////////////////
789
790/// What a test is aimed at.
791#[derive(Clone, Debug)]
792pub enum Tested {
793    /// Design columns to drop from the null model, as zero-based indices.
794    ///
795    /// Repeats are collapsed to the first occurrence, as edgeR's `unique` does.
796    /// The first surviving index gives the reported log-fold-change.
797    Coef(Vec<usize>),
798    /// A contrast, or several, over the coefficients.
799    Contrast {
800        /// Column-major `n_coef * n_contrasts` values.
801        values: Vec<f64>,
802        /// Number of contrast columns. Must have full column rank.
803        n_contrasts: usize,
804    },
805}
806
807/// Which null [`glm_treat`] tests against.
808#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
809pub enum TreatNull {
810    /// The whole interval `[-lfc, lfc]` is null. edgeR's default.
811    #[default]
812    Interval,
813    /// Only the worst point of the interval is null, giving the conservative
814    /// `pnorm(-z_right) + pnorm(z_left)`.
815    WorstCase,
816}
817
818/// Everything a test needs to know about the data behind the fit.
819///
820/// The recycled matrices are logically `n_genes * n_samples`.
821#[derive(Clone, Debug)]
822pub struct GlmTestInput<'a, T: EdgeFloat> {
823    /// Counts, row-major `n_genes * n_samples`.
824    pub counts: &'a [T],
825    /// Number of genes.
826    pub n_genes: usize,
827    /// Number of samples.
828    pub n_samples: usize,
829    /// Design matrix, row-major `n_samples * n_coef`. The same one the fit used.
830    pub design: &'a [f64],
831    /// Number of coefficients. At least two, since the null model must keep one.
832    pub n_coef: usize,
833    /// Dispersion the fit used, recycled over genes and samples.
834    pub dispersion: &'a Recycled<f64>,
835    /// Log-scale offsets, recycled over genes and samples.
836    pub offset: &'a Recycled<f64>,
837    /// Optional observation weights.
838    pub weights: Option<&'a Recycled<f64>>,
839    /// Optional average log-CPM per gene, copied straight into the result.
840    pub log_cpm: Option<&'a [f64]>,
841}
842
843/// The quasi-likelihood quantities `glmQLFit` produces.
844///
845/// Read by [`glm_ql_ftest`] and the quasi-likelihood flavour of [`glm_treat`],
846/// as edgeR reads them off the `DGEGLM`.
847#[derive(Clone, Debug)]
848pub struct QlSummary<'a> {
849    /// Posterior quasi-likelihood dispersion per gene, `s2.post`.
850    pub s2_post: &'a [f64],
851    /// Prior degrees of freedom, either one value shared by every gene or one
852    /// per gene when the squeezing was robust.
853    pub df_prior: &'a [f64],
854    /// Residual degrees of freedom adjusted for the effective number of
855    /// observations, `df.residual.adj`. One per gene.
856    pub df_residual_adj: &'a [f64],
857    /// Residual degrees of freedom after dropping structural zeros,
858    /// `df.residual.zeros`. One per gene.
859    ///
860    /// `Some` marks the legacy pipeline, the only one where the Poisson bound
861    /// applies; `None` switches the bound off, as in edgeR.
862    pub df_residual_zeros: Option<&'a [f64]>,
863    /// Fitted means from the quasi-likelihood fit, row-major
864    /// `n_genes * n_samples`. Only read by the Poisson bound.
865    pub fitted: &'a [f64],
866    /// Average quasi-likelihood dispersion the fit was divided by (current
867    /// pipeline only). The null refit divides by it too, to match scales.
868    pub average_ql_dispersion: Option<f64>,
869}
870
871/// Result of a genewise test.
872#[derive(Clone, Debug)]
873pub struct GlmTest {
874    /// Log2 fold change per gene.
875    ///
876    /// The shrunk coefficient of the first tested column, or the contrast applied
877    /// to the shrunk coefficients, divided by `ln 2`. With several coefficients
878    /// only the first is reported, as in edgeR's first `logFC` column.
879    pub log_fc: Vec<f64>,
880    /// Average log-CPM per gene, passed through from the input.
881    pub log_cpm: Option<Vec<f64>>,
882    /// Test statistic per gene.
883    ///
884    /// The likelihood ratio for [`glm_lrt`], the F statistic for
885    /// [`glm_ql_ftest`], and `z_right` (the larger threshold z-score) for
886    /// [`glm_treat`]. edgeR reports no `glmTreat` statistic; this is our choice.
887    pub statistic: Vec<f64>,
888    /// P-value per gene.
889    pub p_value: Vec<f64>,
890    /// Degrees of freedom under test: the number of columns the null drops.
891    pub df_test: f64,
892    /// Denominator degrees of freedom per gene. Only the quasi-likelihood tests
893    /// set this.
894    pub df_total: Option<Vec<f64>>,
895}
896
897///////////////////////////
898// Likelihood ratio test //
899///////////////////////////
900
901/// Genewise likelihood ratio test.
902///
903/// Ports edgeR's `glmLRT`. Drops the tested columns, refits the null at the
904/// fit's own dispersion and offsets, and reads the deviance difference off a
905/// chi-squared on as many degrees of freedom as columns dropped. A contrast
906/// first rotates the design through [`contrast_as_coef`]; the p-value depends
907/// only on the column space the null keeps, so the rotation's signs do not reach
908/// it.
909///
910/// ### Params
911///
912/// * `input` - Counts, design and the recycled matrices behind the fit
913/// * `fit` - The full-model fit, from [`glm_fit`]
914/// * `tested` - Coefficients or contrast under test
915/// * `ql` - Quasi-likelihood summary when the fit came from `glmQLFit`, so
916///   `average_ql_dispersion` can be divided out before the null refit. `None` for
917///   a plain `glmFit`.
918///
919/// ### Returns
920///
921/// The likelihood ratios and their p-values, or [`EdgeErrors`] if the shapes
922/// disagree, the design has fewer than two columns, the contrast is rank
923/// deficient, or every column is under test.
924///
925/// ### References
926///
927/// McCarthy, Chen and Smyth, Nucleic Acids Research, 2012
928pub fn glm_lrt<T: EdgeFloat>(
929    input: &GlmTestInput<'_, T>,
930    fit: &GlmFit,
931    tested: &Tested,
932    ql: Option<&QlSummary<'_>>,
933) -> Result<GlmTest, EdgeErrors> {
934    validate(input, fit)?;
935
936    let resolved = resolve_tested(input, fit, tested)?;
937    let n_coef_null = input.n_coef - resolved.coef.len();
938    if n_coef_null == 0 {
939        return Err(EdgeErrors::InvalidArgument(
940            "cannot test every coefficient: the null model would have no columns".to_string(),
941        ));
942    }
943
944    let design_null = drop_columns(
945        &resolved.design,
946        input.n_samples,
947        input.n_coef,
948        &resolved.coef,
949    );
950
951    // edgeR stores the undivided dispersion on a quasi-likelihood fit but fits at
952    // `dispersion / average.ql.dispersion`; the null must match.
953    let scaled = ql
954        .and_then(|q| q.average_ql_dispersion)
955        .map(|a| input.dispersion.map(|d| d / a));
956    let dispersion = scaled.as_ref().unwrap_or(input.dispersion);
957
958    // Warm start only in the original basis: `contrast_as_coef` does not return
959    // its rotation, so a contrast has nothing to project the full fit through.
960    let start = match tested {
961        Tested::Coef(_) => Some(drop_columns(
962            fit.unshrunk_coefficients
963                .as_deref()
964                .unwrap_or(&fit.coefficients),
965            input.n_genes,
966            input.n_coef,
967            &resolved.coef,
968        )),
969        Tested::Contrast { .. } => None,
970    };
971
972    let deviance_null = fit_null(
973        input,
974        &design_null,
975        n_coef_null,
976        dispersion,
977        input.offset,
978        start.as_deref(),
979    )?;
980
981    let df_test = resolved.coef.len() as f64;
982    let statistic: Vec<f64> = deviance_null
983        .iter()
984        .zip(fit.deviance.iter())
985        .map(|(null, full)| null - full)
986        .collect();
987    let p_value = statistic
988        .par_iter()
989        .map(|lr| chisq_sf(lr.max(0.0), df_test))
990        .collect::<Result<Vec<f64>, EdgeErrors>>()?;
991
992    Ok(GlmTest {
993        log_fc: resolved.log_fc,
994        log_cpm: input.log_cpm.map(<[f64]>::to_vec),
995        statistic,
996        p_value,
997        df_test,
998        df_total: None,
999    })
1000}
1001
1002/////////////////////////////
1003// Quasi-likelihood F test //
1004/////////////////////////////
1005
1006/// Genewise quasi-likelihood F test.
1007///
1008/// Ports edgeR's `glmQLFTest`. Runs [`glm_lrt`], then divides the deviance
1009/// difference by the tested degrees of freedom and the posterior
1010/// quasi-likelihood dispersion to get an F on `df_test` and
1011/// `df_prior + df_residual` degrees of freedom. The denominator is capped at
1012/// the total residual degrees of freedom, so a large `df_prior` cannot claim
1013/// more information than the data hold.
1014///
1015/// The Poisson bound refits at zero dispersion and raises the p-value of any
1016/// gene whose `s2_post * (1 + dispersion * mu)` has fallen below the Poisson
1017/// variance for some library.
1018///
1019/// ### Params
1020///
1021/// * `input` - Counts, design and the recycled matrices behind the fit
1022/// * `fit` - The quasi-likelihood fit, from `glmQLFit`
1023/// * `ql` - The squeezed quantities that fit produced
1024/// * `tested` - Coefficients or contrast under test
1025/// * `poisson_bound` - Apply the Poisson bound. Silently ignored, as in edgeR,
1026///   when `ql.df_residual_zeros` is `None` (current pipeline).
1027///
1028/// ### Returns
1029///
1030/// The F statistics, their p-values and the denominator degrees of freedom, or
1031/// [`EdgeErrors`] if a shape disagrees or the null refit fails.
1032///
1033/// ### References
1034///
1035/// Lund, Nettleton, McCarthy and Smyth, Statistical Applications in Genetics and
1036/// Molecular Biology, 2012
1037pub fn glm_ql_ftest<T: EdgeFloat>(
1038    input: &GlmTestInput<'_, T>,
1039    fit: &GlmFit,
1040    ql: &QlSummary<'_>,
1041    tested: &Tested,
1042    poisson_bound: bool,
1043) -> Result<GlmTest, EdgeErrors> {
1044    validate_ql(input, ql)?;
1045    let out = glm_lrt(input, fit, tested, Some(ql))?;
1046
1047    let (df_residual, poisson_bound) = match ql.df_residual_zeros {
1048        Some(zeros) => (zeros, poisson_bound),
1049        None => (ql.df_residual_adj, false),
1050    };
1051    let cap = (input.n_genes * fit.df_residual) as f64;
1052
1053    let df_total: Vec<f64> = (0..input.n_genes)
1054        .map(|gene| (recycle(ql.df_prior, gene) + df_residual[gene]).min(cap))
1055        .collect();
1056    let statistic: Vec<f64> = out
1057        .statistic
1058        .iter()
1059        .zip(ql.s2_post.iter())
1060        .map(|(lr, s2)| lr / out.df_test / s2)
1061        .collect();
1062    let mut p_value = statistic
1063        .par_iter()
1064        .zip(df_total.par_iter())
1065        .map(|(f, df2)| f_sf(f.max(0.0), out.df_test, *df2))
1066        .collect::<Result<Vec<f64>, EdgeErrors>>()?;
1067
1068    if poisson_bound {
1069        let below = below_poisson_bound(input, ql);
1070        if below.iter().any(|b| *b) {
1071            let poisson = poisson_refit_lrt(input, tested)?;
1072            for (gene, flagged) in below.iter().enumerate() {
1073                if *flagged {
1074                    p_value[gene] = p_value[gene].max(poisson.p_value[gene]);
1075                }
1076            }
1077        }
1078    }
1079
1080    Ok(GlmTest {
1081        log_fc: out.log_fc,
1082        log_cpm: out.log_cpm,
1083        statistic,
1084        p_value,
1085        df_test: out.df_test,
1086        df_total: Some(df_total),
1087    })
1088}
1089
1090///////////
1091// Treat //
1092///////////
1093
1094/// Genewise test against a fold-change threshold.
1095///
1096/// Ports edgeR's `glmTreat`: tests whether the log-fold-change lies outside
1097/// `[-lfc, lfc]`. Both bounds are refitted with offsets shifted by
1098/// `lfc * ln 2 * design[, coef]`, giving two deviance-difference z-scores. The
1099/// smaller is signed negative when the estimate lies inside the interval, and
1100/// the pair feeds the interval null or the worst-case null.
1101///
1102/// A zero `lfc` falls through to [`glm_lrt`] or [`glm_ql_ftest`], as in edgeR.
1103/// Only one coefficient is tested: extra entries in `tested` are dropped (edgeR
1104/// warns).
1105///
1106/// ### Params
1107///
1108/// * `input` - Counts, design and the recycled matrices behind the fit
1109/// * `fit` - The fit, from [`glm_fit`] or `glmQLFit`
1110/// * `ql` - The squeezed quantities for a quasi-likelihood fit, which switches
1111///   to the moderated t flavour. `None` for a plain `glmFit`.
1112/// * `tested` - Coefficient or contrast under test
1113/// * `lfc` - Log2 fold-change threshold, non-negative
1114/// * `null` - Which null to test against
1115///
1116/// ### Returns
1117///
1118/// The threshold z-scores and their p-values, or [`EdgeErrors`] for a negative
1119/// `lfc`, a shape mismatch, or a failed refit.
1120///
1121/// ### References
1122///
1123/// McCarthy and Smyth, Bioinformatics, 2009
1124pub fn glm_treat<T: EdgeFloat>(
1125    input: &GlmTestInput<'_, T>,
1126    fit: &GlmFit,
1127    ql: Option<&QlSummary<'_>>,
1128    tested: &Tested,
1129    lfc: f64,
1130    null: TreatNull,
1131) -> Result<GlmTest, EdgeErrors> {
1132    if lfc < 0.0 || lfc.is_nan() {
1133        return Err(EdgeErrors::InvalidArgument(format!(
1134            "lfc must be non-negative, got {lfc}"
1135        )));
1136    }
1137    if lfc == 0.0 {
1138        return match ql {
1139            Some(q) => glm_ql_ftest(input, fit, q, tested, true),
1140            None => glm_lrt(input, fit, tested, None),
1141        };
1142    }
1143    validate(input, fit)?;
1144    if let Some(q) = ql {
1145        validate_ql(input, q)?;
1146    }
1147
1148    let narrowed = narrow(tested, input.n_coef)?;
1149    let resolved = resolve_tested(input, fit, &narrowed)?;
1150    let coef = resolved.coef[0];
1151    let design_null = drop_columns(&resolved.design, input.n_samples, input.n_coef, &[coef]);
1152
1153    let unshrunk_log_fc = match fit.unshrunk_coefficients.as_deref() {
1154        Some(unshrunk) => project(unshrunk, input.n_genes, input.n_coef, &narrowed, coef),
1155        None => resolved.log_fc.clone(),
1156    };
1157
1158    let scaled = ql
1159        .and_then(|q| q.average_ql_dispersion)
1160        .map(|a| input.dispersion.map(|d| d / a));
1161    let dispersion = scaled.as_ref().unwrap_or(input.dispersion);
1162
1163    // Both bounds are ordinary fits of the same two models at shifted offsets.
1164    let adjustment: Vec<f64> = (0..input.n_samples)
1165        .map(|sample| lfc * std::f64::consts::LN_2 * resolved.design[sample * input.n_coef + coef])
1166        .collect();
1167
1168    let mut z_left = threshold_z(
1169        input,
1170        &resolved.design,
1171        &design_null,
1172        dispersion,
1173        &adjustment,
1174        1.0,
1175    )?;
1176    let mut z_right = threshold_z(
1177        input,
1178        &resolved.design,
1179        &design_null,
1180        dispersion,
1181        &adjustment,
1182        -1.0,
1183    )?;
1184    for (left, right) in z_left.iter_mut().zip(z_right.iter_mut()) {
1185        if *left > *right {
1186            std::mem::swap(left, right);
1187        }
1188    }
1189
1190    // Under quasi-likelihood the deviance roots are moderated t, not z.
1191    let df_total = ql.map(|q| {
1192        let df_residual = q.df_residual_zeros.unwrap_or(q.df_residual_adj);
1193        let cap = (input.n_genes * (input.n_samples - input.n_coef)) as f64;
1194        (0..input.n_genes)
1195            .map(|gene| (recycle(q.df_prior, gene) + df_residual[gene]).min(cap))
1196            .collect::<Vec<f64>>()
1197    });
1198    if let (Some(q), Some(df)) = (ql, df_total.as_ref()) {
1199        for gene in 0..input.n_genes {
1200            let scale = q.s2_post[gene].sqrt();
1201            z_left[gene] = zscore_t(z_left[gene] / scale, df[gene])?;
1202            z_right[gene] = zscore_t(z_right[gene] / scale, df[gene])?;
1203        }
1204    }
1205
1206    for (gene, left) in z_left.iter_mut().enumerate() {
1207        let within = unshrunk_log_fc[gene].abs() <= lfc;
1208        *left *= if within { 1.0 } else { -1.0 };
1209    }
1210
1211    let mut p_value: Vec<f64> = z_left
1212        .par_iter()
1213        .zip(z_right.par_iter())
1214        .map(|(left, right)| treat_p_value(*left, *right, null))
1215        .collect();
1216
1217    // edgeR's recursive call drops `null`: the bound is always against the
1218    // interval null.
1219    if let Some(q) = ql
1220        && q.df_residual_zeros.is_some()
1221    {
1222        let below = below_poisson_bound(input, q);
1223        if below.iter().any(|b| *b) {
1224            let poisson = poisson_refit_treat(input, &narrowed, lfc)?;
1225            for (gene, flagged) in below.iter().enumerate() {
1226                if *flagged {
1227                    p_value[gene] = p_value[gene].max(poisson.p_value[gene]);
1228                }
1229            }
1230        }
1231    }
1232
1233    Ok(GlmTest {
1234        log_fc: resolved.log_fc,
1235        log_cpm: input.log_cpm.map(<[f64]>::to_vec),
1236        statistic: z_right,
1237        p_value,
1238        df_test: 1.0,
1239        df_total,
1240    })
1241}
1242
1243///////////
1244// Tests //
1245///////////
1246
1247#[cfg(test)]
1248mod tests {
1249    use super::*;
1250    use approx::assert_relative_eq;
1251
1252    /// Three genes, two groups of three. The same fixture `glm::fit` uses.
1253    ///
1254    /// ```r
1255    /// y <- matrix(c(10,12,11,40,44,38, 50,48,52,49,51,50, 2,0,5,1,3,0),
1256    ///             nrow = 3, byrow = TRUE)
1257    /// X <- cbind(1, c(0,0,0,1,1,1))
1258    /// ```
1259    fn small() -> (Vec<f64>, Vec<f64>, Recycled<f64>) {
1260        let counts = vec![
1261            10.0, 12.0, 11.0, 40.0, 44.0, 38.0, //
1262            50.0, 48.0, 52.0, 49.0, 51.0, 50.0, //
1263            2.0, 0.0, 5.0, 1.0, 3.0, 0.0, //
1264        ];
1265        let design = vec![
1266            1.0, 0.0, //
1267            1.0, 0.0, //
1268            1.0, 0.0, //
1269            1.0, 1.0, //
1270            1.0, 1.0, //
1271            1.0, 1.0, //
1272        ];
1273        // glmFit defaults to lib.size = colSums(y).
1274        let libraries: [f64; 6] = [62.0, 60.0, 68.0, 90.0, 98.0, 88.0];
1275        let offset = Recycled::by_sample(libraries.iter().map(|v| v.ln()).collect());
1276        (counts, design, offset)
1277    }
1278
1279    /// Eight genes, two groups of three, used for the quasi-likelihood and
1280    /// threshold tests.
1281    ///
1282    /// ```r
1283    /// y <- matrix(c(10,12,11,40,44,38, 50,48,52,49,51,50, 2,0,5,1,3,0,
1284    ///               200,190,210,205,195,215, 1,2,0,8,9,7, 0,1,0,0,0,1,
1285    ///               30,33,28,31,29,30, 5,6,4,20,22,19), nrow = 8, byrow = TRUE)
1286    /// X <- cbind(1, c(0,0,0,1,1,1))
1287    /// ```
1288    fn wide() -> (Vec<f64>, Vec<f64>, Recycled<f64>) {
1289        let counts = vec![
1290            10.0, 12.0, 11.0, 40.0, 44.0, 38.0, //
1291            50.0, 48.0, 52.0, 49.0, 51.0, 50.0, //
1292            2.0, 0.0, 5.0, 1.0, 3.0, 0.0, //
1293            200.0, 190.0, 210.0, 205.0, 195.0, 215.0, //
1294            1.0, 2.0, 0.0, 8.0, 9.0, 7.0, //
1295            0.0, 1.0, 0.0, 0.0, 0.0, 1.0, //
1296            30.0, 33.0, 28.0, 31.0, 29.0, 30.0, //
1297            5.0, 6.0, 4.0, 20.0, 22.0, 19.0, //
1298        ];
1299        let design = vec![
1300            1.0, 0.0, //
1301            1.0, 0.0, //
1302            1.0, 0.0, //
1303            1.0, 1.0, //
1304            1.0, 1.0, //
1305            1.0, 1.0, //
1306        ];
1307        let libraries: [f64; 6] = [298.0, 292.0, 310.0, 354.0, 353.0, 360.0];
1308        let offset = Recycled::by_sample(libraries.iter().map(|v| v.ln()).collect());
1309        (counts, design, offset)
1310    }
1311
1312    /// Four genes over three groups of two, for the contrast paths.
1313    ///
1314    /// ```r
1315    /// y2 <- matrix(c(10,12,40,44,100,110, 50,48,49,51,52,50,
1316    ///                0,0,1,3,20,25, 5,7,6,8,7,6), nrow = 4, byrow = TRUE)
1317    /// X2 <- model.matrix(~ 0 + factor(c("A","A","B","B","C","C")))
1318    /// ```
1319    fn three_group() -> (Vec<f64>, Vec<f64>, Recycled<f64>) {
1320        let counts = vec![
1321            10.0, 12.0, 40.0, 44.0, 100.0, 110.0, //
1322            50.0, 48.0, 49.0, 51.0, 52.0, 50.0, //
1323            0.0, 0.0, 1.0, 3.0, 20.0, 25.0, //
1324            5.0, 7.0, 6.0, 8.0, 7.0, 6.0, //
1325        ];
1326        let design = vec![
1327            1.0, 0.0, 0.0, //
1328            1.0, 0.0, 0.0, //
1329            0.0, 1.0, 0.0, //
1330            0.0, 1.0, 0.0, //
1331            0.0, 0.0, 1.0, //
1332            0.0, 0.0, 1.0, //
1333        ];
1334        let libraries: [f64; 6] = [65.0, 67.0, 96.0, 106.0, 179.0, 191.0];
1335        let offset = Recycled::by_sample(libraries.iter().map(|v| v.ln()).collect());
1336        (counts, design, offset)
1337    }
1338
1339    /// Builds a test input over borrowed pieces.
1340    fn input<'a>(
1341        counts: &'a [f64],
1342        n_genes: usize,
1343        design: &'a [f64],
1344        n_coef: usize,
1345        dispersion: &'a Recycled<f64>,
1346        offset: &'a Recycled<f64>,
1347    ) -> GlmTestInput<'a, f64> {
1348        GlmTestInput {
1349            counts,
1350            n_genes,
1351            n_samples: 6,
1352            design,
1353            n_coef,
1354            dispersion,
1355            offset,
1356            weights: None,
1357            log_cpm: None,
1358        }
1359    }
1360
1361    fn fit_of(input: &GlmTestInput<'_, f64>) -> GlmFit {
1362        glm_fit(
1363            input.counts,
1364            input.n_genes,
1365            input.n_samples,
1366            input.design,
1367            input.n_coef,
1368            input.dispersion,
1369            input.offset,
1370            input.weights,
1371            0.125,
1372        )
1373        .unwrap()
1374    }
1375
1376    ///////////////
1377    // `glm_lrt` //
1378    ///////////////
1379
1380    /// ```r
1381    /// f <- glmFit(y, X, dispersion = 0.1)
1382    /// glmLRT(f, coef = 2)$table
1383    /// ```
1384    #[test]
1385    fn test_glm_lrt_matches_edger_on_a_single_coefficient() {
1386        let (counts, design, offset) = small();
1387        let dispersion = Recycled::scalar(0.1);
1388        let inp = input(&counts, 3, &design, 2, &dispersion, &offset);
1389        let fit = fit_of(&inp);
1390        let out = glm_lrt(&inp, &fit, &Tested::Coef(vec![1]), None).unwrap();
1391
1392        let log_fc = [
1393            1.335_986_011_350_581_5,
1394            -0.536_817_869_447_691_6,
1395            -1.250_904_046_511_814_2,
1396        ];
1397        let lr = [
1398            8.341_979_103_855_252,
1399            1.732_785_607_270_639,
1400            1.956_785_090_352_040_5,
1401        ];
1402        let p = [
1403            0.003_873_937_497_185_523_3,
1404            0.188_055_540_378_233_27,
1405            0.161_857_564_986_522_76,
1406        ];
1407        assert_eq!(out.df_test, 1.0);
1408        assert!(out.df_total.is_none());
1409        for gene in 0..3 {
1410            assert_relative_eq!(out.log_fc[gene], log_fc[gene], max_relative = 1e-10);
1411            assert_relative_eq!(out.statistic[gene], lr[gene], max_relative = 1e-10);
1412            assert_relative_eq!(out.p_value[gene], p[gene], max_relative = 1e-10);
1413        }
1414    }
1415
1416    /// Testing the intercept: the null design is the group indicator alone, not
1417    /// a one-way layout, so the null fit takes the warm-started Levenberg branch.
1418    ///
1419    /// ```r
1420    /// glmLRT(f, coef = 1)$table
1421    /// ```
1422    #[test]
1423    fn test_glm_lrt_on_the_intercept_uses_the_warm_started_branch() {
1424        let (counts, design, offset) = small();
1425        let dispersion = Recycled::scalar(0.1);
1426        let inp = input(&counts, 3, &design, 2, &dispersion, &offset);
1427        let fit = fit_of(&inp);
1428        let out = glm_lrt(&inp, &fit, &Tested::Coef(vec![0]), None).unwrap();
1429
1430        let lr = [
1431            41.921_421_573_734_83,
1432            1.313_819_348_845_321_6,
1433            86.065_935_682_930_47,
1434        ];
1435        let p = [
1436            9.501_597_746_548_692e-11,
1437            2.517_043_040_693_706e-1,
1438            1.740_350_328_216_402_5e-20,
1439        ];
1440        for gene in 0..3 {
1441            assert_relative_eq!(out.statistic[gene], lr[gene], max_relative = 1e-10);
1442            assert_relative_eq!(out.p_value[gene], p[gene], max_relative = 1e-10);
1443        }
1444    }
1445
1446    /// `glmLRT(f, contrast = c(0, 1))` is the same test as `coef = 2`, but it
1447    /// arrives through `contrast_as_coef` and a rotated design.
1448    #[test]
1449    fn test_glm_lrt_contrast_agrees_with_the_coefficient_path() {
1450        let (counts, design, offset) = small();
1451        let dispersion = Recycled::scalar(0.1);
1452        let inp = input(&counts, 3, &design, 2, &dispersion, &offset);
1453        let fit = fit_of(&inp);
1454        let by_coef = glm_lrt(&inp, &fit, &Tested::Coef(vec![1]), None).unwrap();
1455        let by_contrast = glm_lrt(
1456            &inp,
1457            &fit,
1458            &Tested::Contrast {
1459                values: vec![0.0, 1.0],
1460                n_contrasts: 1,
1461            },
1462            None,
1463        )
1464        .unwrap();
1465
1466        for gene in 0..3 {
1467            assert_relative_eq!(
1468                by_contrast.log_fc[gene],
1469                by_coef.log_fc[gene],
1470                max_relative = 1e-12
1471            );
1472            assert_relative_eq!(
1473                by_contrast.statistic[gene],
1474                by_coef.statistic[gene],
1475                max_relative = 1e-10
1476            );
1477            assert_relative_eq!(
1478                by_contrast.p_value[gene],
1479                by_coef.p_value[gene],
1480                max_relative = 1e-10
1481            );
1482        }
1483    }
1484
1485    /// A genuine group-versus-group contrast on a three-level factor.
1486    ///
1487    /// ```r
1488    /// f2 <- glmFit(y2, X2, dispersion = 0.15)
1489    /// glmLRT(f2, contrast = c(-1, 1, 0))$table
1490    /// ```
1491    #[test]
1492    fn test_glm_lrt_matches_edger_on_a_group_contrast() {
1493        let (counts, design, offset) = three_group();
1494        let dispersion = Recycled::scalar(0.15);
1495        let inp = input(&counts, 4, &design, 3, &dispersion, &offset);
1496        let fit = fit_of(&inp);
1497        let out = glm_lrt(
1498            &inp,
1499            &fit,
1500            &Tested::Contrast {
1501                values: vec![-1.0, 1.0, 0.0],
1502                n_contrasts: 1,
1503            },
1504            None,
1505        )
1506        .unwrap();
1507
1508        let log_fc = [
1509            1.314_710_781_299_474,
1510            -0.582_462_832_327_925_1,
1511            4.284_529_874_638_284_5,
1512            -0.388_058_150_000_178_07,
1513        ];
1514        let lr = [
1515            4.032_180_454_464_596,
1516            0.956_446_195_492_408_8,
1517            3.679_427_818_235_808_5,
1518            0.243_122_673_596_927_35,
1519        ];
1520        let p = [
1521            0.044_640_210_361_232_81,
1522            0.328_083_883_367_401_46,
1523            0.055_087_751_666_934_24,
1524            0.621_959_796_729_172_5,
1525        ];
1526        assert_eq!(out.df_test, 1.0);
1527        for gene in 0..4 {
1528            assert_relative_eq!(out.log_fc[gene], log_fc[gene], max_relative = 1e-10);
1529            assert_relative_eq!(out.statistic[gene], lr[gene], max_relative = 1e-10);
1530            assert_relative_eq!(out.p_value[gene], p[gene], max_relative = 1e-10);
1531        }
1532    }
1533
1534    /// A two-column contrast, which is the two-degree-of-freedom test.
1535    ///
1536    /// ```r
1537    /// glmLRT(f2, contrast = cbind(c(-1, 1, 0), c(-1, 0, 1)))$table
1538    /// ```
1539    #[test]
1540    fn test_glm_lrt_matches_edger_on_a_two_column_contrast() {
1541        let (counts, design, offset) = three_group();
1542        let dispersion = Recycled::scalar(0.15);
1543        let inp = input(&counts, 4, &design, 3, &dispersion, &offset);
1544        let fit = fit_of(&inp);
1545        let out = glm_lrt(
1546            &inp,
1547            &fit,
1548            &Tested::Contrast {
1549                values: vec![-1.0, 1.0, 0.0, -1.0, 0.0, 1.0],
1550                n_contrasts: 2,
1551            },
1552            None,
1553        )
1554        .unwrap();
1555
1556        let lr = [
1557            7.489_094_796_878_978_5,
1558            5.604_306_004_386_178,
1559            22.625_269_055_571_128,
1560            3.060_853_924_032_122,
1561        ];
1562        let p = [
1563            2.364_632_899_149_205_6e-2,
1564            6.067_927_926_597_326e-2,
1565            1.221_758_161_337_463_6e-5,
1566            2.164_432_345_449_859_9e-1,
1567        ];
1568        assert_eq!(out.df_test, 2.0);
1569        for gene in 0..4 {
1570            // Gene 3 has an empty group: its null maximum is at minus infinity
1571            // and each fitter stops at its own tolerance. Agreement drops to 1e-9
1572            // here, which the chi-squared tail turns into 1e-8 on the p-value.
1573            assert_relative_eq!(out.statistic[gene], lr[gene], max_relative = 1e-8);
1574            assert_relative_eq!(out.p_value[gene], p[gene], max_relative = 1e-7);
1575        }
1576        // The reported fold change is the first contrast, as in edgeR's table.
1577        assert_relative_eq!(out.log_fc[0], 1.314_710_781_299_474, max_relative = 1e-10);
1578    }
1579
1580    /// Two coefficients at once, dropping both from the null.
1581    ///
1582    /// ```r
1583    /// glmLRT(f2, coef = c(2, 3))$table
1584    /// ```
1585    #[test]
1586    fn test_glm_lrt_matches_edger_on_two_coefficients() {
1587        let (counts, design, offset) = three_group();
1588        let dispersion = Recycled::scalar(0.15);
1589        let inp = input(&counts, 4, &design, 3, &dispersion, &offset);
1590        let fit = fit_of(&inp);
1591        let out = glm_lrt(&inp, &fit, &Tested::Coef(vec![1, 2]), None).unwrap();
1592
1593        let lr = [
1594            10.585_850_031_983_432,
1595            19.010_202_865_950_3,
1596            86.152_535_750_910_06,
1597            92.074_489_192_513_13,
1598        ];
1599        let p = [
1600            5.027_034_577_232_391e-3,
1601            7.447_095_063_546_895e-5,
1602            1.959_812_668_604_125_9e-19,
1603            1.014_562_275_310_370_5e-20,
1604        ];
1605        assert_eq!(out.df_test, 2.0);
1606        for gene in 0..4 {
1607            // The null design is a single indicator and gene 3 has no counts in
1608            // that group: its null coefficient diverges and the fitters differ
1609            // in the ninth digit.
1610            assert_relative_eq!(out.statistic[gene], lr[gene], max_relative = 1e-8);
1611            assert_relative_eq!(out.p_value[gene], p[gene], max_relative = 1e-8);
1612        }
1613    }
1614
1615    /// The far tail and an all-zero gene in one fixture. Gene 3's p-value is
1616    /// 9.46e-204, which `1 - pchisq` would return as a flat zero.
1617    ///
1618    /// ```r
1619    /// y3 <- matrix(c(20,22,18,5000,5200,4800, 0,0,0,0,0,0,
1620    ///                100,110,90,105,95,100), nrow = 3, byrow = TRUE)
1621    /// glmLRT(glmFit(y3, X, dispersion = 0.01), coef = 2)$table
1622    /// ```
1623    #[test]
1624    fn test_glm_lrt_in_the_far_tail_and_on_a_zero_gene() {
1625        let counts = vec![
1626            20.0, 22.0, 18.0, 5000.0, 5200.0, 4800.0, //
1627            0.0, 0.0, 0.0, 0.0, 0.0, 0.0, //
1628            100.0, 110.0, 90.0, 105.0, 95.0, 100.0, //
1629        ];
1630        let design = vec![1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
1631        let libraries: [f64; 6] = [120.0, 132.0, 108.0, 5105.0, 5295.0, 4900.0];
1632        let offset = Recycled::by_sample(libraries.iter().map(|v| v.ln()).collect());
1633        let dispersion = Recycled::scalar(0.01);
1634        let inp = input(&counts, 3, &design, 2, &dispersion, &offset);
1635        let fit = fit_of(&inp);
1636        let out = glm_lrt(&inp, &fit, &Tested::Coef(vec![1]), None).unwrap();
1637
1638        let lr = [172.284_890_441_011_5, 0.0, 927.674_565_354_018_5];
1639        let p = [2.344_998_823_345_881e-39, 1.0, 9.458_079_718_661_872e-204];
1640        for gene in 0..3 {
1641            assert_relative_eq!(out.statistic[gene], lr[gene], epsilon = 1e-9);
1642            assert_relative_eq!(out.p_value[gene], p[gene], max_relative = 1e-10);
1643        }
1644        // The zero gene: fold change zero to rounding, p-value exactly one.
1645        assert!(out.log_fc[1].abs() < 1e-12);
1646        assert_eq!(out.p_value[1], 1.0);
1647    }
1648
1649    ////////////////////
1650    // `glm_ql_ftest` //
1651    ////////////////////
1652
1653    /// Owned storage for the legacy fixture, which a [`QlSummary`] borrows.
1654    struct LegacyQl {
1655        /// Posterior quasi-likelihood dispersions.
1656        s2_post: Vec<f64>,
1657        /// Prior degrees of freedom, one value shared by every gene.
1658        df_prior: Vec<f64>,
1659        /// Zero-adjusted residual degrees of freedom.
1660        df_residual_zeros: Vec<f64>,
1661        /// Adjusted residual degrees of freedom.
1662        df_residual_adj: Vec<f64>,
1663        /// Fitted means, row-major.
1664        fitted: Vec<f64>,
1665    }
1666
1667    impl LegacyQl {
1668        /// Borrows the fixture as a summary the tests can pass in.
1669        fn summary(&self) -> QlSummary<'_> {
1670            QlSummary {
1671                s2_post: &self.s2_post,
1672                df_prior: &self.df_prior,
1673                df_residual_adj: &self.df_residual_adj,
1674                df_residual_zeros: Some(&self.df_residual_zeros),
1675                fitted: &self.fitted,
1676                average_ql_dispersion: None,
1677            }
1678        }
1679    }
1680
1681    /// The legacy quasi-likelihood fit of the eight-gene fixture.
1682    ///
1683    /// ```r
1684    /// q <- glmQLFit(y, X, dispersion = 0.1, legacy = TRUE)
1685    /// ```
1686    fn ql_legacy() -> LegacyQl {
1687        let s2_post = vec![
1688            0.049_281_563_700_074_56,
1689            0.011_659_677_647_637_47,
1690            1.728_524_387_495_482_8,
1691            0.007_316_196_288_206_033,
1692            0.561_520_580_113_145_8,
1693            2.054_690_133_109_537_5,
1694            0.042_104_651_793_427_66,
1695            0.108_585_097_222_579_13,
1696        ];
1697        let df_prior = vec![4.460_965_388_747_048];
1698        let df_residual_zeros = vec![4.0; 8];
1699        let df_residual_adj = vec![4.0; 8];
1700        let fitted = vec![
1701            10.933_744_628_513_18,
1702            10.713_602_119_214_256,
1703            11.374_029_647_111_028,
1704            40.492_974_243_000_01,
1705            40.378_587_310_110_184,
1706            41.179_295_840_339_01,
1707            //
1708            49.659_282_993_114_66,
1709            48.659_431_657_682_816,
1710            51.658_985_663_978_34,
1711            49.769_601_759_585_95,
1712            49.629_009_664_219_89,
1713            50.613_154_331_782_35,
1714            //
1715            2.308_489_259_791_181,
1716            2.262_009_610_265_183,
1717            2.401_448_558_843_174_7,
1718            1.328_158_182_691_277_4,
1719            1.324_406_323_418_138_2,
1720            1.350_669_338_330_113_2,
1721            //
1722            198.595_261_331_926_4,
1723            194.596_699_023_229_86,
1724            206.592_385_949_319_44,
1725            203.991_992_958_926_5,
1726            203.415_744_391_245_88,
1727            207.449_484_365_010_05,
1728            //
1729            0.995_197_722_682_345_2,
1730            0.975_160_184_641_760_9,
1731            1.035_272_798_763_513_5,
1732            7.965_675_239_409_282,
1733            7.943_173_331_953_323,
1734            8.100_686_684_145_035,
1735            //
1736            0.331_402_866_955_448_6,
1737            0.324_730_326_010_036_9,
1738            0.344_747_948_846_272_1,
1739            0.331_641_809_994_310_75,
1740            0.330_704_968_723_140_37,
1741            0.337_262_857_621_333_07,
1742            //
1743            30.181_552_543_631_07,
1744            29.573_870_277_651_917,
1745            31.396_917_075_589_37,
1746            29.860_397_478_017_26,
1747            29.776_046_072_712_123,
1748            30.366_505_909_848_07,
1749            //
1750            4.974_310_683_547_974,
1751            4.874_156_777_167_812,
1752            5.174_618_496_308_295,
1753            20.245_100_646_285_735,
1754            20.187_911_096_437_47,
1755            20.588_237_945_375_333,
1756        ];
1757        LegacyQl {
1758            s2_post,
1759            df_prior,
1760            df_residual_zeros,
1761            df_residual_adj,
1762            fitted,
1763        }
1764    }
1765
1766    /// ```r
1767    /// glmQLFTest(q, coef = 2)$table       # poisson.bound = TRUE
1768    /// glmQLFTest(q, coef = 2, poisson.bound = FALSE)$table
1769    /// ```
1770    ///
1771    /// Genes 2, 4 and 7 sit below the Poisson bound, so their p-values are
1772    /// raised; the others are untouched.
1773    #[test]
1774    fn test_glm_ql_ftest_matches_edger_with_and_without_the_poisson_bound() {
1775        let (counts, design, offset) = wide();
1776        let dispersion = Recycled::scalar(0.1);
1777        let inp = input(&counts, 8, &design, 2, &dispersion, &offset);
1778        let fit = fit_of(&inp);
1779        let legacy = ql_legacy();
1780        let ql = legacy.summary();
1781
1782        let f_stat = [
1783            2.520_057_973_402_189_2e2,
1784            3.095_392_962_353_460_2e1,
1785            6.853_203_261_465_175e-1,
1786            4.126_759_810_823_103_4e1,
1787            1.979_404_448_269_534e1,
1788            6.917_310_190_199_355e-3,
1789            8.944_754_825_397_515,
1790            9.876_119_655_893_109e1,
1791        ];
1792        let bounded = [
1793            1.364_107_878_599_685_5e-7,
1794            1.407_007_683_799_561e-1,
1795            4.304_904_955_542_642e-1,
1796            1.127_377_026_183_572_2e-2,
1797            1.865_737_655_809_165_6e-3,
1798            9.356_503_273_786_928e-1,
1799            2.229_724_201_299_175_8e-1,
1800            5.944_660_235_862_634e-6,
1801        ];
1802        let unbounded = [
1803            1.364_107_878_599_685_5e-7,
1804            4.366_386_781_336_41e-4,
1805            4.304_904_955_542_642e-1,
1806            1.598_459_203_229_021_3e-4,
1807            1.865_737_655_809_165_6e-3,
1808            9.356_503_273_786_928e-1,
1809            1.625_485_152_929_125_4e-2,
1810            5.944_660_235_862_634e-6,
1811        ];
1812
1813        let with_bound = glm_ql_ftest(&inp, &fit, &ql, &Tested::Coef(vec![1]), true).unwrap();
1814        let without = glm_ql_ftest(&inp, &fit, &ql, &Tested::Coef(vec![1]), false).unwrap();
1815
1816        assert_eq!(with_bound.df_test, 1.0);
1817        let df_total = with_bound.df_total.as_ref().unwrap();
1818        for gene in 0..8 {
1819            assert_relative_eq!(df_total[gene], 8.460_965_388_747_049, max_relative = 1e-12);
1820            assert_relative_eq!(
1821                with_bound.statistic[gene],
1822                f_stat[gene],
1823                max_relative = 1e-10
1824            );
1825            assert_relative_eq!(
1826                with_bound.p_value[gene],
1827                bounded[gene],
1828                max_relative = 1e-10
1829            );
1830            assert_relative_eq!(without.p_value[gene], unbounded[gene], max_relative = 1e-10);
1831        }
1832    }
1833
1834    /// The current pipeline has no zero-adjusted degrees of freedom, so edgeR
1835    /// turns the bound off whatever the caller asked, and the denominator degrees
1836    /// of freedom hit the cap at the experiment's total.
1837    ///
1838    /// ```r
1839    /// q2 <- glmQLFit(y, X, dispersion = 0.1)
1840    /// glmQLFTest(q2, coef = 2)$table
1841    /// ```
1842    #[test]
1843    fn test_glm_ql_ftest_without_zero_adjusted_df_ignores_the_bound() {
1844        let (counts, design, offset) = wide();
1845        let dispersion = Recycled::scalar(0.1);
1846        let inp = input(&counts, 8, &design, 2, &dispersion, &offset);
1847        let fit = fit_of(&inp);
1848        let s2_post = [
1849            0.048_188_444_222_375_504,
1850            0.024_446_203_487_533_725,
1851            1.180_185_563_640_951_3,
1852            0.005_271_328_303_740_723,
1853            0.546_740_168_004_454_8,
1854            2.445_938_293_050_726_3,
1855            0.039_977_551_427_882_49,
1856            0.154_447_261_383_920_18,
1857        ];
1858        let df_prior = [8_134.844_780_382_662];
1859        let adj = [
1860            3.973_521_938_241_366,
1861            3.999_098_159_890_601,
1862            5.529_668_600_344_663,
1863            4.000_963_615_960_567_5,
1864            5.978_981_705_096_965,
1865            9.394_040_228_202_032,
1866            3.995_519_515_274_4,
1867            3.929_173_912_871_219,
1868        ];
1869        let ql = QlSummary {
1870            s2_post: &s2_post,
1871            df_prior: &df_prior,
1872            df_residual_adj: &adj,
1873            df_residual_zeros: None,
1874            fitted: &[0.0; 48],
1875            average_ql_dispersion: Some(1.0),
1876        };
1877
1878        let out = glm_ql_ftest(&inp, &fit, &ql, &Tested::Coef(vec![1]), true).unwrap();
1879        let f_stat = [
1880            2.577_223_638_326_844_4e2,
1881            1.476_355_383_862_022_7e1,
1882            1.003_734_440_994_232_6,
1883            5.727_623_678_615_572e1,
1884            2.032_915_083_096_279_2e1,
1885            5.810_828_930_493_346e-3,
1886            9.420_681_703_839_223,
1887            6.943_466_678_578_658e1,
1888        ];
1889        let p = [
1890            7.252_743_073_151_62e-17,
1891            5.442_532_006_559_633e-4,
1892            3.239_246_833_697_202_5e-1,
1893            1.276_727_950_912_218_5e-8,
1894            8.231_984_735_990_331e-5,
1895            9.397_116_797_565_639e-1,
1896            4.348_612_379_217_248e-3,
1897            1.606_882_310_788_258_1e-9,
1898        ];
1899        let df_total = out.df_total.as_ref().unwrap();
1900        for gene in 0..8 {
1901            // pmin(df.prior + df.residual, sum(df.residual)) = min(8138.8, 32).
1902            assert_relative_eq!(df_total[gene], 32.0, max_relative = 1e-12);
1903            assert_relative_eq!(out.statistic[gene], f_stat[gene], max_relative = 1e-10);
1904            assert_relative_eq!(out.p_value[gene], p[gene], max_relative = 1e-10);
1905        }
1906    }
1907
1908    /// The F tail, past where `1 - pf` collapses. The squeezed quantities are
1909    /// chosen, not fitted.
1910    ///
1911    /// ```r
1912    /// LR <- c(12.419239754410078547, 0.360912841338069723, 1.184592896990613653,
1913    ///         0.301921848102618240, 11.114763340708499584, 0.014212928995460672,
1914    ///         0.376615787300944194, 10.723994130169780092)
1915    /// pf(LR / 1e-4, df1 = 1, df2 = 32, lower.tail = FALSE)
1916    /// ```
1917    #[test]
1918    fn test_glm_ql_ftest_far_tail() {
1919        let (counts, design, offset) = wide();
1920        let dispersion = Recycled::scalar(0.1);
1921        let inp = input(&counts, 8, &design, 2, &dispersion, &offset);
1922        let fit = fit_of(&inp);
1923        let s2_post = [1e-4; 8];
1924        let df_prior = [1000.0];
1925        let adj = [4.0; 8];
1926        let ql = QlSummary {
1927            s2_post: &s2_post,
1928            df_prior: &df_prior,
1929            df_residual_adj: &adj,
1930            df_residual_zeros: None,
1931            fitted: &[0.0; 48],
1932            average_ql_dispersion: None,
1933        };
1934        let out = glm_ql_ftest(&inp, &fit, &ql, &Tested::Coef(vec![1]), false).unwrap();
1935
1936        let p = [
1937            5.261_561_085_923_352_5e-59,
1938            1.779_918_291_545_363_5e-34,
1939            1.079_125_786_482_700_5e-42,
1940            3.012_683_792_206_176e-33,
1941            3.104_755_317_213_854_3e-58,
1942            2.604_261_088_389_738e-13,
1943            9.055_927_316_446_368e-35,
1944            5.503_704_196_958_632e-58,
1945        ];
1946        for (got, want) in out.p_value.iter().zip(p.iter()) {
1947            assert_relative_eq!(got, want, max_relative = 1e-10);
1948        }
1949    }
1950
1951    /////////////////
1952    // `glm_treat` //
1953    /////////////////
1954
1955    /// ```r
1956    /// f <- glmFit(y, X, dispersion = 0.1)
1957    /// glmTreat(f, coef = 2, lfc = 1)$table$PValue
1958    /// glmTreat(f, coef = 2, lfc = 1, null = "worst.case")$table$PValue
1959    /// ```
1960    #[test]
1961    fn test_glm_treat_lrt_at_lfc_one() {
1962        let (counts, design, offset) = wide();
1963        let dispersion = Recycled::scalar(0.1);
1964        let inp = input(&counts, 8, &design, 2, &dispersion, &offset);
1965        let fit = fit_of(&inp);
1966
1967        let interval = [
1968            0.025_915_419_120_752_61,
1969            0.862_946_855_826_681_2,
1970            0.335_130_846_838_047_5,
1971            0.901_730_557_007_466_8,
1972            0.005_453_587_125_419_520_4,
1973            0.910_944_563_566_415_2,
1974            0.836_150_163_403_213_7,
1975            0.023_915_536_140_601_778,
1976        ];
1977        let worst = [
1978            0.084_605_550_556_336_65,
1979            0.968_617_766_474_667_2,
1980            0.497_500_093_784_695_2,
1981            0.981_164_069_793_248_7,
1982            0.021_394_389_526_003_44,
1983            0.917_471_298_505_725_4,
1984            0.957_971_652_323_224_3,
1985            0.078_981_717_742_023_32,
1986        ];
1987        let log_fc = [
1988            1.630_328_521_653_603_4,
1989            -0.244_625_377_904_330_73,
1990            -0.975_328_979_782_516_8,
1991            -0.209_626_606_575_486_35,
1992            2.620_328_451_924_648,
1993            -0.180_553_480_809_313_55,
1994            -0.262_787_305_134_895_23,
1995            1.753_538_627_526_939,
1996        ];
1997
1998        let a = glm_treat(
1999            &inp,
2000            &fit,
2001            None,
2002            &Tested::Coef(vec![1]),
2003            1.0,
2004            TreatNull::Interval,
2005        )
2006        .unwrap();
2007        let b = glm_treat(
2008            &inp,
2009            &fit,
2010            None,
2011            &Tested::Coef(vec![1]),
2012            1.0,
2013            TreatNull::WorstCase,
2014        )
2015        .unwrap();
2016
2017        assert_eq!(a.df_test, 1.0);
2018        assert!(a.df_total.is_none());
2019        for gene in 0..8 {
2020            assert_relative_eq!(a.log_fc[gene], log_fc[gene], max_relative = 1e-10);
2021            assert_relative_eq!(a.p_value[gene], interval[gene], max_relative = 1e-10);
2022            assert_relative_eq!(b.p_value[gene], worst[gene], max_relative = 1e-10);
2023        }
2024    }
2025
2026    /// Two more thresholds, including edgeR's own default of `log2(1.2)`.
2027    ///
2028    /// ```r
2029    /// glmTreat(f, coef = 2, lfc = 0.5)$table$PValue
2030    /// glmTreat(f, coef = 2, lfc = log2(1.2))$table$PValue
2031    /// glmTreat(f, coef = 2, lfc = log2(1.2), null = "worst.case")$table$PValue
2032    /// ```
2033    #[test]
2034    fn test_glm_treat_lrt_at_other_thresholds() {
2035        let (counts, design, offset) = wide();
2036        let dispersion = Recycled::scalar(0.1);
2037        let inp = input(&counts, 8, &design, 2, &dispersion, &offset);
2038        let fit = fit_of(&inp);
2039
2040        let half = [
2041            0.001_747_492_463_475_674_3,
2042            0.618_965_088_475_578_5,
2043            0.297_496_484_707_449_94,
2044            0.661_667_162_539_030_5,
2045            0.001_731_384_373_595_051_3,
2046            0.906_630_646_614_269_5,
2047            0.602_748_783_056_843_9,
2048            0.002_830_446_598_910_725,
2049        ];
2050        let default_interval = [
2051            0.000_772_184_486_707_990_3,
2052            0.574_647_381_380_633_3,
2053            0.282_377_207_940_779_64,
2054            0.611_261_200_442_731_6,
2055            0.001_073_803_287_152_239_8,
2056            0.905_530_268_033_189_1,
2057            0.563_833_195_684_254,
2058            0.001_600_134_051_293_08,
2059        ];
2060        let default_worst = [
2061            0.001_557_323_252_030_769_7,
2062            0.624_202_827_105_601_4,
2063            0.294_451_670_100_143_67,
2064            0.663_637_030_156_907_3,
2065            0.001_546_443_189_297_852,
2066            0.906_031_012_374_345_1,
2067            0.609_637_644_920_852_7,
2068            0.002_786_515_725_771_390_3,
2069        ];
2070
2071        let a = glm_treat(
2072            &inp,
2073            &fit,
2074            None,
2075            &Tested::Coef(vec![1]),
2076            0.5,
2077            TreatNull::Interval,
2078        )
2079        .unwrap();
2080        let lfc = 1.2_f64.log2();
2081        let b = glm_treat(
2082            &inp,
2083            &fit,
2084            None,
2085            &Tested::Coef(vec![1]),
2086            lfc,
2087            TreatNull::Interval,
2088        )
2089        .unwrap();
2090        let c = glm_treat(
2091            &inp,
2092            &fit,
2093            None,
2094            &Tested::Coef(vec![1]),
2095            lfc,
2096            TreatNull::WorstCase,
2097        )
2098        .unwrap();
2099        for gene in 0..8 {
2100            assert_relative_eq!(a.p_value[gene], half[gene], max_relative = 1e-10);
2101            assert_relative_eq!(
2102                b.p_value[gene],
2103                default_interval[gene],
2104                max_relative = 1e-10
2105            );
2106            assert_relative_eq!(c.p_value[gene], default_worst[gene], max_relative = 1e-10);
2107        }
2108    }
2109
2110    /// The contrast path, including a gene with an effectively infinite unshrunk
2111    /// fold change (one group all zeros).
2112    ///
2113    /// ```r
2114    /// glmTreat(f2, contrast = c(-1, 1, 0), lfc = 1)$table$PValue
2115    /// glmTreat(f2, contrast = c(-1, 1, 0), lfc = 1, null = "worst.case")$table$PValue
2116    /// ```
2117    #[test]
2118    fn test_glm_treat_matches_edger_on_a_contrast() {
2119        let (counts, design, offset) = three_group();
2120        let dispersion = Recycled::scalar(0.15);
2121        let inp = input(&counts, 4, &design, 3, &dispersion, &offset);
2122        let fit = fit_of(&inp);
2123        let contrast = Tested::Contrast {
2124            values: vec![-1.0, 1.0, 0.0],
2125            n_contrasts: 1,
2126        };
2127
2128        let interval = [
2129            0.136_036_070_900_815_65,
2130            0.528_392_553_392_713_7,
2131            0.060_483_368_850_335_27,
2132            0.689_127_004_865_843_3,
2133        ];
2134        let worst = [
2135            0.313_021_256_154_164_54,
2136            0.762_143_061_238_379_5,
2137            0.079_816_920_784_910_93,
2138            0.818_329_089_039_554_8,
2139        ];
2140        let log_fc = [
2141            1.314_710_781_299_474,
2142            -0.582_462_832_327_925_1,
2143            4.284_529_874_638_284_5,
2144            -0.388_058_150_000_178_07,
2145        ];
2146
2147        let a = glm_treat(&inp, &fit, None, &contrast, 1.0, TreatNull::Interval).unwrap();
2148        let b = glm_treat(&inp, &fit, None, &contrast, 1.0, TreatNull::WorstCase).unwrap();
2149        for gene in 0..4 {
2150            assert_relative_eq!(a.log_fc[gene], log_fc[gene], max_relative = 1e-10);
2151            // Gene 3's unshrunk fold change is infinite, so agreement is only as
2152            // good as the stopping rule: 1e-9.
2153            assert_relative_eq!(a.p_value[gene], interval[gene], max_relative = 1e-8);
2154            assert_relative_eq!(b.p_value[gene], worst[gene], max_relative = 1e-8);
2155        }
2156    }
2157
2158    /// The quasi-likelihood flavour: moderated t statistics and the Poisson
2159    /// bound. Genes 1 and 8 get the same p-value under both nulls, as the bound
2160    /// catches them and edgeR's recursive call always uses the interval null.
2161    ///
2162    /// ```r
2163    /// q <- glmQLFit(y, X, dispersion = 0.1, legacy = TRUE)
2164    /// glmTreat(q, coef = 2, lfc = 1)$table$PValue
2165    /// glmTreat(q, coef = 2, lfc = 1, null = "worst.case")$table$PValue
2166    /// ```
2167    #[test]
2168    fn test_glm_treat_quasi_likelihood_with_the_poisson_bound() {
2169        let (counts, design, offset) = wide();
2170        let dispersion = Recycled::scalar(0.1);
2171        let inp = input(&counts, 8, &design, 2, &dispersion, &offset);
2172        let fit = fit_of(&inp);
2173        let legacy = ql_legacy();
2174        let ql = legacy.summary();
2175
2176        let interval = [
2177            0.002_095_420_937_924_167_8,
2178            0.999_993_018_183_757_4,
2179            0.487_393_893_826_179_1,
2180            1.000_000_000_000_001,
2181            0.005_735_420_883_968_396_5,
2182            0.938_980_124_834_262_6,
2183            0.999_431_608_175_005_4,
2184            0.006_193_294_762_041_081_5,
2185        ];
2186        let worst = [
2187            0.002_095_420_937_924_167_8,
2188            0.999_999_963_126_753_1,
2189            0.556_398_596_920_132_5,
2190            1.000_000_000_000_001,
2191            0.012_897_636_268_926_604,
2192            0.941_055_590_649_069_9,
2193            0.999_988_456_782_335_7,
2194            0.006_193_294_762_041_081_5,
2195        ];
2196
2197        let a = glm_treat(
2198            &inp,
2199            &fit,
2200            Some(&ql),
2201            &Tested::Coef(vec![1]),
2202            1.0,
2203            TreatNull::Interval,
2204        )
2205        .unwrap();
2206        let b = glm_treat(
2207            &inp,
2208            &fit,
2209            Some(&ql),
2210            &Tested::Coef(vec![1]),
2211            1.0,
2212            TreatNull::WorstCase,
2213        )
2214        .unwrap();
2215        for gene in 0..8 {
2216            assert_relative_eq!(a.p_value[gene], interval[gene], max_relative = 1e-10);
2217            assert_relative_eq!(b.p_value[gene], worst[gene], max_relative = 1e-10);
2218        }
2219        assert_eq!(a.p_value[0], b.p_value[0]);
2220        assert_eq!(a.p_value[7], b.p_value[7]);
2221        assert!(a.df_total.is_some());
2222    }
2223
2224    /// The quasi-likelihood flavour at a second threshold.
2225    ///
2226    /// ```r
2227    /// glmTreat(q, coef = 2, lfc = 0.5)$table$PValue
2228    /// glmTreat(q, coef = 2, lfc = 0.5, null = "worst.case")$table$PValue
2229    /// ```
2230    #[test]
2231    fn test_glm_treat_quasi_likelihood_at_half_a_fold() {
2232        let (counts, design, offset) = wide();
2233        let dispersion = Recycled::scalar(0.1);
2234        let inp = input(&counts, 8, &design, 2, &dispersion, &offset);
2235        let fit = fit_of(&inp);
2236        let legacy = ql_legacy();
2237        let ql = legacy.summary();
2238
2239        let interval = [
2240            1.156_930_720_415_820_5e-6,
2241            9.958_190_513_004_438e-1,
2242            4.457_622_253_174_958e-1,
2243            9.995_944_678_440_168e-1,
2244            2.585_377_345_926_488_7e-3,
2245            9.365_161_866_221_516e-1,
2246            9.136_827_161_461_856e-1,
2247            7.667_539_435_702_798e-5,
2248        ];
2249        let worst = [
2250            1.299_077_183_789_172_9e-6,
2251            9.998_319_316_629_676e-1,
2252            4.654_945_529_449_657e-1,
2253            9.999_928_996_412_859e-1,
2254            3.653_055_884_276_585e-3,
2255            9.370_713_782_972_866e-1,
2256            9.866_427_824_263_567e-1,
2257            7.667_539_435_702_798e-5,
2258        ];
2259
2260        let a = glm_treat(
2261            &inp,
2262            &fit,
2263            Some(&ql),
2264            &Tested::Coef(vec![1]),
2265            0.5,
2266            TreatNull::Interval,
2267        )
2268        .unwrap();
2269        let b = glm_treat(
2270            &inp,
2271            &fit,
2272            Some(&ql),
2273            &Tested::Coef(vec![1]),
2274            0.5,
2275            TreatNull::WorstCase,
2276        )
2277        .unwrap();
2278        for gene in 0..8 {
2279            assert_relative_eq!(a.p_value[gene], interval[gene], max_relative = 1e-10);
2280            assert_relative_eq!(b.p_value[gene], worst[gene], max_relative = 1e-10);
2281        }
2282    }
2283
2284    /// A zero threshold falls through to the ordinary test, in both flavours.
2285    #[test]
2286    fn test_glm_treat_with_zero_lfc_falls_back() {
2287        let (counts, design, offset) = wide();
2288        let dispersion = Recycled::scalar(0.1);
2289        let inp = input(&counts, 8, &design, 2, &dispersion, &offset);
2290        let fit = fit_of(&inp);
2291        let tested = Tested::Coef(vec![1]);
2292
2293        let lrt = glm_lrt(&inp, &fit, &tested, None).unwrap();
2294        let treat = glm_treat(&inp, &fit, None, &tested, 0.0, TreatNull::Interval).unwrap();
2295        for gene in 0..8 {
2296            assert_eq!(treat.p_value[gene], lrt.p_value[gene]);
2297        }
2298
2299        let legacy = ql_legacy();
2300        let ql = legacy.summary();
2301        let ftest = glm_ql_ftest(&inp, &fit, &ql, &tested, true).unwrap();
2302        let treat_ql = glm_treat(&inp, &fit, Some(&ql), &tested, 0.0, TreatNull::Interval).unwrap();
2303        for gene in 0..8 {
2304            assert_eq!(treat_ql.p_value[gene], ftest.p_value[gene]);
2305        }
2306    }
2307
2308    /// A larger threshold must make every gene less significant.
2309    #[test]
2310    fn test_glm_treat_is_monotone_in_the_threshold() {
2311        let (counts, design, offset) = wide();
2312        let dispersion = Recycled::scalar(0.1);
2313        let inp = input(&counts, 8, &design, 2, &dispersion, &offset);
2314        let fit = fit_of(&inp);
2315        let tested = Tested::Coef(vec![1]);
2316
2317        let small = glm_treat(&inp, &fit, None, &tested, 0.25, TreatNull::Interval).unwrap();
2318        let large = glm_treat(&inp, &fit, None, &tested, 1.5, TreatNull::Interval).unwrap();
2319        for gene in 0..8 {
2320            assert!(
2321                large.p_value[gene] >= small.p_value[gene] - 1e-12,
2322                "gene {gene}: {} < {}",
2323                large.p_value[gene],
2324                small.p_value[gene]
2325            );
2326        }
2327    }
2328
2329    //////////////
2330    // Numerics //
2331    //////////////
2332
2333    /// ```r
2334    /// limma::zscoreT(c(0.5, 2, 6, 20), df = 8.4609653887470486)
2335    /// ```
2336    #[test]
2337    fn test_zscore_t_matches_limma() {
2338        let df = 8.460_965_388_747_049;
2339        let expected = [
2340            0.481_981_798_779_442_4,
2341            1.759_032_448_322_397_1,
2342            3.653_010_684_919_440_4,
2343            5.609_979_465_791_493,
2344        ];
2345        for (x, want) in [0.5, 2.0, 6.0, 20.0].iter().zip(expected.iter()) {
2346            assert_relative_eq!(zscore_t(*x, df).unwrap(), want, max_relative = 1e-10);
2347        }
2348        // Odd symmetry, and zero maps to zero.
2349        assert_relative_eq!(
2350            zscore_t(-2.0, df).unwrap(),
2351            -zscore_t(2.0, df).unwrap(),
2352            max_relative = 1e-12
2353        );
2354        assert_eq!(zscore_t(0.0, df).unwrap(), 0.0);
2355    }
2356
2357    /// ```r
2358    /// edgeR:::.integratepnorm(c(-1, -3, 0.5, 2), c(0.470402, -1.529598, 0.5, 3.470402))
2359    /// ```
2360    #[test]
2361    fn test_integrate_pnorm_matches_edger() {
2362        let a = [-1.0, -3.0, 0.5, 2.0];
2363        let b = [0.470_402, -1.529_598, 0.5, 3.470_402];
2364        let expected = [
2365            0.404_086_872_171_341_76,
2366            0.018_364_485_997_322_027,
2367            0.691_462_461_274_013,
2368            0.994_270_314_804_512_8,
2369        ];
2370        for gene in 0..4 {
2371            assert_relative_eq!(
2372                integrate_pnorm(a[gene], b[gene]),
2373                expected[gene],
2374                max_relative = 1e-10
2375            );
2376        }
2377    }
2378
2379    //////////////////
2380    // Argument use //
2381    //////////////////
2382
2383    #[test]
2384    fn test_log_cpm_is_passed_through() {
2385        let (counts, design, offset) = small();
2386        let dispersion = Recycled::scalar(0.1);
2387        let cpm = [
2388            18.305_644_059_893_947,
2389            19.322_293_068_742_58,
2390            15.526_879_626_455_655,
2391        ];
2392        let mut inp = input(&counts, 3, &design, 2, &dispersion, &offset);
2393        inp.log_cpm = Some(&cpm);
2394        let fit = fit_of(&inp);
2395        let out = glm_lrt(&inp, &fit, &Tested::Coef(vec![1]), None).unwrap();
2396        assert_eq!(out.log_cpm.unwrap(), cpm.to_vec());
2397    }
2398
2399    #[test]
2400    fn test_rejects_an_out_of_range_coefficient() {
2401        let (counts, design, offset) = small();
2402        let dispersion = Recycled::scalar(0.1);
2403        let inp = input(&counts, 3, &design, 2, &dispersion, &offset);
2404        let fit = fit_of(&inp);
2405        let err = glm_lrt(&inp, &fit, &Tested::Coef(vec![7]), None).unwrap_err();
2406        assert!(matches!(err, EdgeErrors::CoefOutOfRange { .. }));
2407    }
2408
2409    #[test]
2410    fn test_rejects_testing_every_coefficient() {
2411        let (counts, design, offset) = small();
2412        let dispersion = Recycled::scalar(0.1);
2413        let inp = input(&counts, 3, &design, 2, &dispersion, &offset);
2414        let fit = fit_of(&inp);
2415        let err = glm_lrt(&inp, &fit, &Tested::Coef(vec![0, 1]), None).unwrap_err();
2416        assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
2417    }
2418
2419    #[test]
2420    fn test_rejects_a_zero_contrast() {
2421        let (counts, design, offset) = small();
2422        let dispersion = Recycled::scalar(0.1);
2423        let inp = input(&counts, 3, &design, 2, &dispersion, &offset);
2424        let fit = fit_of(&inp);
2425        let err = glm_lrt(
2426            &inp,
2427            &fit,
2428            &Tested::Contrast {
2429                values: vec![0.0, 0.0],
2430                n_contrasts: 1,
2431            },
2432            None,
2433        )
2434        .unwrap_err();
2435        assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
2436    }
2437
2438    #[test]
2439    fn test_rejects_a_rank_deficient_contrast() {
2440        let (counts, design, offset) = three_group();
2441        let dispersion = Recycled::scalar(0.15);
2442        let inp = input(&counts, 4, &design, 3, &dispersion, &offset);
2443        let fit = fit_of(&inp);
2444        let err = glm_lrt(
2445            &inp,
2446            &fit,
2447            &Tested::Contrast {
2448                values: vec![-1.0, 1.0, 0.0, -2.0, 2.0, 0.0],
2449                n_contrasts: 2,
2450            },
2451            None,
2452        )
2453        .unwrap_err();
2454        assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
2455    }
2456
2457    #[test]
2458    fn test_rejects_a_negative_lfc() {
2459        let (counts, design, offset) = small();
2460        let dispersion = Recycled::scalar(0.1);
2461        let inp = input(&counts, 3, &design, 2, &dispersion, &offset);
2462        let fit = fit_of(&inp);
2463        let err = glm_treat(
2464            &inp,
2465            &fit,
2466            None,
2467            &Tested::Coef(vec![1]),
2468            -0.5,
2469            TreatNull::Interval,
2470        )
2471        .unwrap_err();
2472        assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
2473    }
2474
2475    #[test]
2476    fn test_rejects_a_mismatched_quasi_likelihood_summary() {
2477        let (counts, design, offset) = small();
2478        let dispersion = Recycled::scalar(0.1);
2479        let inp = input(&counts, 3, &design, 2, &dispersion, &offset);
2480        let fit = fit_of(&inp);
2481        let ql = QlSummary {
2482            s2_post: &[1.0, 1.0],
2483            df_prior: &[4.0],
2484            df_residual_adj: &[4.0; 3],
2485            df_residual_zeros: None,
2486            fitted: &[0.0; 18],
2487            average_ql_dispersion: None,
2488        };
2489        let err = glm_ql_ftest(&inp, &fit, &ql, &Tested::Coef(vec![1]), false).unwrap_err();
2490        assert!(matches!(err, EdgeErrors::LengthMismatch { .. }));
2491    }
2492
2493    /// A per-gene offset expands under the threshold shift, but the answer must
2494    /// not depend on how the offsets were stored.
2495    #[test]
2496    fn test_treat_is_insensitive_to_the_offset_storage() {
2497        let (counts, design, _) = wide();
2498        let libraries: [f64; 6] = [298.0, 292.0, 310.0, 354.0, 353.0, 360.0];
2499        let by_sample = Recycled::by_sample(libraries.iter().map(|v| v.ln()).collect());
2500        let full = Recycled::Full(
2501            (0..8)
2502                .flat_map(|_| libraries.iter().map(|v| v.ln()))
2503                .collect(),
2504        );
2505        let dispersion = Recycled::scalar(0.1);
2506
2507        let compact = input(&counts, 8, &design, 2, &dispersion, &by_sample);
2508        let dense = input(&counts, 8, &design, 2, &dispersion, &full);
2509        let fit = fit_of(&compact);
2510        let a = glm_treat(
2511            &compact,
2512            &fit,
2513            None,
2514            &Tested::Coef(vec![1]),
2515            1.0,
2516            TreatNull::Interval,
2517        )
2518        .unwrap();
2519        let b = glm_treat(
2520            &dense,
2521            &fit,
2522            None,
2523            &Tested::Coef(vec![1]),
2524            1.0,
2525            TreatNull::Interval,
2526        )
2527        .unwrap();
2528        for gene in 0..8 {
2529            assert_relative_eq!(a.p_value[gene], b.p_value[gene], max_relative = 1e-12);
2530        }
2531    }
2532}