Skip to main content

edge_rs/core/
normalisation.rs

1//! `calcNormFactors`: TMM, TMMwsp, RLE and upper-quartile scaling factors.
2//!
3//! A port of edgeR's `normLibSizes.default` and the four `.calcFactor*` helpers
4//! it dispatches to. Where edgePython and edgeR disagree, edgeR wins; see
5//! `calc_factor_tmmwsp`.
6//!
7//! ### Access pattern
8//!
9//! Every method works *down a column*, unlike the rest of the crate. The module
10//! therefore drops the all-zero genes and transposes the survivors once into a
11//! column-major `f64` buffer. That costs one `n_kept * n_samples` allocation
12//! and makes the per-sample rank sorts that dominate TMM, and the RLE geometric
13//! means, read sequentially. Counts are converted from `T` to `f64` on read.
14
15use rayon::prelude::*;
16
17use crate::core::expression::column_sums;
18use crate::errors::EdgeErrors;
19use crate::numeric::stats::{quantile_type7, rank_average};
20use crate::utils::traits::EdgeFloat;
21
22////////////
23// Consts //
24////////////
25
26/// Below this the observed and reference libraries are proportional.
27///
28/// edgeR short-circuits to a factor of exactly 1 when `max(|M|)` falls under
29/// this, so proportional samples do not pick up a factor of `1 + 1e-17`.
30const TMM_NULL_TOLERANCE: f64 = 1e-6;
31
32/// Counts at or below this count as zero for TMMwsp's singleton pairing.
33///
34/// edgeR uses a threshold rather than `> 0` because fractional counts from
35/// transcript-level quantification can carry a denormal "zero".
36const TMMWSP_ZERO_EPS: f64 = 1e-14;
37
38/// Ridge added to the TMMwsp asymptotic variance before inverting it.
39///
40/// TMMwsp keeps genes that are zero in one library, whose approximate variance
41/// is unbounded. The ridge caps the weight at `(1 + eps) / eps`.
42const TMMWSP_WEIGHT_RIDGE: f64 = 1e-6;
43
44/// Median upper-quartile factor below which the reference column is picked by
45/// root-count totals instead.
46///
47/// If more than half the samples have a zero upper quartile, edgeR falls back to
48/// `which.max(colSums(sqrt(x)))`, the rule TMMwsp always uses.
49const F75_DEGENERATE: f64 = 1e-20;
50
51/// Probability of the quantile used to choose the TMM reference column.
52///
53/// Hardcoded as in edgeR; [`NormParams::quantile`] only affects the
54/// `upperquartile` method.
55const REF_COLUMN_QUANTILE: f64 = 0.75;
56
57/// The strings edgeR accepts, for the error message.
58const NORM_METHOD_NAMES: &str = "TMM, TMMwsp, RLE, upperquartile, none";
59
60/////////////
61// Methods //
62/////////////
63
64/// Which scaling rule [`calc_norm_factors`] applies.
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub enum NormMethod {
67    /// Trimmed mean of M-values, edgeR's default.
68    Tmm,
69    /// TMM with singleton pairing, for data with many zeros.
70    TmmwSp,
71    /// Relative log expression, the median ratio to the gene-wise geometric mean.
72    Rle,
73    /// Ratio of a column quantile to the library size.
74    UpperQuartile,
75    /// No scaling; every factor is 1.
76    None,
77}
78
79/// Parses edgeR's `method` string.
80///
81/// Case-insensitive. `TMMwzp` is accepted as the old spelling of `TMMwsp`, as in
82/// edgeR.
83///
84/// ### Params
85///
86/// * `s` - Method name, e.g. `"TMM"` or `"upperquartile"`
87///
88/// ### Returns
89///
90/// The variant, or `None`. `NormMethod::try_from` returns the error instead.
91pub fn parse_norm_method(s: &str) -> Option<NormMethod> {
92    match s.to_ascii_lowercase().as_str() {
93        "tmm" => Some(NormMethod::Tmm),
94        "tmmwsp" | "tmmwzp" => Some(NormMethod::TmmwSp),
95        "rle" => Some(NormMethod::Rle),
96        "upperquartile" => Some(NormMethod::UpperQuartile),
97        "none" => Some(NormMethod::None),
98        _ => None,
99    }
100}
101
102impl TryFrom<&str> for NormMethod {
103    type Error = EdgeErrors;
104
105    /// Fallible form of [`parse_norm_method`].
106    ///
107    /// ### Params
108    ///
109    /// * `s` - Method name
110    ///
111    /// ### Returns
112    ///
113    /// The variant, or [`EdgeErrors::UnknownMethod`] naming the accepted set.
114    fn try_from(s: &str) -> Result<Self, Self::Error> {
115        parse_norm_method(s).ok_or_else(|| EdgeErrors::UnknownMethod {
116            kind: "normalisation",
117            name: s.to_string(),
118            expected: NORM_METHOD_NAMES,
119        })
120    }
121}
122
123////////////////
124// NormParams //
125////////////////
126
127/// Tuning knobs for [`calc_norm_factors`].
128///
129/// Names mirror edgeR's: `logratio_trim` is `logratioTrim`, `sum_trim` is
130/// `sumTrim`, `do_weighting` is `doWeighting`, `a_cutoff` is `Acutoff` and
131/// `quantile` is `p`.
132#[derive(Clone, Copy, Debug)]
133pub struct NormParams {
134    /// Fraction trimmed from each tail of the log-ratios. TMM and TMMwsp only.
135    pub logratio_trim: f64,
136    /// Fraction trimmed from each tail of the abundances. TMM and TMMwsp only.
137    pub sum_trim: f64,
138    /// Weight each retained log-ratio by the inverse of its approximate
139    /// asymptotic variance. TMM and TMMwsp only.
140    pub do_weighting: bool,
141    /// Abundance floor; genes with a mean log-abundance at or below this are
142    /// dropped before trimming. TMM only; edgeR's TMMwsp ignores it.
143    pub a_cutoff: f64,
144    /// Probability of the column quantile. [`NormMethod::UpperQuartile`] only.
145    pub quantile: f64,
146}
147
148impl NormParams {
149    /// Builds a parameter set.
150    ///
151    /// No validation here; [`calc_norm_factors`] checks the domains.
152    ///
153    /// ### Params
154    ///
155    /// * `logratio_trim` - Tail fraction trimmed from the log-ratios, in `[0, 1)`
156    /// * `sum_trim` - Tail fraction trimmed from the abundances, in `[0, 1)`
157    /// * `do_weighting` - Whether to use inverse-variance weights
158    /// * `a_cutoff` - Abundance floor for TMM
159    /// * `quantile` - Column quantile probability, in `[0, 1]`
160    ///
161    /// ### Returns
162    ///
163    /// The parameter set.
164    pub fn new(
165        logratio_trim: f64,
166        sum_trim: f64,
167        do_weighting: bool,
168        a_cutoff: f64,
169        quantile: f64,
170    ) -> Self {
171        Self {
172            logratio_trim,
173            sum_trim,
174            do_weighting,
175            a_cutoff,
176            quantile,
177        }
178    }
179}
180
181impl Default for NormParams {
182    /// edgeR's defaults.
183    fn default() -> Self {
184        Self {
185            logratio_trim: 0.3,
186            sum_trim: 0.05,
187            do_weighting: true,
188            a_cutoff: -1e10,
189            quantile: 0.75,
190        }
191    }
192}
193
194/// Rejects parameter values outside their domain.
195///
196/// ### Params
197///
198/// * `params` - The resolved parameter set
199///
200/// ### Returns
201///
202/// `Ok(())`, or [`EdgeErrors::InvalidArgument`] naming the offending knob.
203fn validate_params(params: &NormParams) -> Result<(), EdgeErrors> {
204    for (name, trim) in [
205        ("logratio_trim", params.logratio_trim),
206        ("sum_trim", params.sum_trim),
207    ] {
208        if !(0.0..1.0).contains(&trim) {
209            return Err(EdgeErrors::InvalidArgument(format!(
210                "{name} must lie in [0, 1); got {trim}."
211            )));
212        }
213    }
214    if !(0.0..=1.0).contains(&params.quantile) {
215        return Err(EdgeErrors::InvalidArgument(format!(
216            "quantile must lie in [0, 1]; got {}.",
217            params.quantile
218        )));
219    }
220    if params.a_cutoff.is_nan() {
221        return Err(EdgeErrors::InvalidArgument(
222            "a_cutoff must not be NaN.".to_string(),
223        ));
224    }
225    Ok(())
226}
227
228/////////////
229// Helpers //
230/////////////
231
232/// Resolves the library sizes, defaulting to the column sums.
233///
234/// edgeR recycles a mismatched `lib.size` with a warning. This errors instead.
235///
236/// ### Params
237///
238/// * `counts` - Row-major counts, `n_genes * n_samples`
239/// * `n_samples` - Number of samples
240/// * `lib_size` - Supplied library sizes, or `None` for the column sums
241///
242/// ### Returns
243///
244/// One library size per sample, each finite and strictly positive, or an
245/// [`EdgeErrors`] if the length is wrong or an entry is unusable.
246fn resolve_lib_size<T: EdgeFloat>(
247    counts: &[T],
248    n_samples: usize,
249    lib_size: Option<&[f64]>,
250) -> Result<Vec<f64>, EdgeErrors> {
251    let lib = match lib_size {
252        Some(ls) => {
253            if ls.len() != n_samples {
254                return Err(EdgeErrors::LengthMismatch {
255                    name: "lib_size",
256                    expected: n_samples,
257                    got: ls.len(),
258                });
259            }
260            ls.to_vec()
261        }
262        None => column_sums(counts, n_samples),
263    };
264
265    // edgeR carries a zero library size through to a NaN factor; refuse early.
266    if let Some(j) = lib.iter().position(|v| !v.is_finite() || *v <= 0.0) {
267        return Err(EdgeErrors::InvalidArgument(format!(
268            "library size for sample {j} must be finite and positive; got {}.",
269            lib[j]
270        )));
271    }
272    Ok(lib)
273}
274
275/// Drops the all-zero genes and transposes the survivors to column-major `f64`.
276///
277/// Two passes: mark the genes with a positive count anywhere, then scatter the
278/// survivors into the transposed buffer (sequential reads, strided writes, since
279/// the rest of the module reads the buffer repeatedly).
280///
281/// ### Params
282///
283/// * `counts` - Row-major counts, `n_genes * n_samples`
284/// * `n_genes` - Number of genes
285/// * `n_samples` - Number of samples
286///
287/// ### Returns
288///
289/// The sample-major `n_kept * n_samples` buffer and `n_kept`, or
290/// [`EdgeErrors::InvalidArgument`] on a negative or non-finite count.
291fn to_column_major_nonzero<T: EdgeFloat>(
292    counts: &[T],
293    n_genes: usize,
294    n_samples: usize,
295) -> Result<(Vec<f64>, usize), EdgeErrors> {
296    let mut keep = vec![false; n_genes];
297    let mut n_kept = 0_usize;
298    for (g, row) in counts.chunks_exact(n_samples).enumerate() {
299        let mut any_positive = false;
300        for v in row {
301            if !v.is_finite() || *v < T::zero() {
302                return Err(EdgeErrors::InvalidArgument(format!(
303                    "counts must be non-negative and finite, found {v} for gene {g}"
304                )));
305            }
306            any_positive |= *v > T::zero();
307        }
308        keep[g] = any_positive;
309        n_kept += usize::from(any_positive);
310    }
311
312    let mut x = vec![0.0_f64; n_kept * n_samples];
313    let mut k = 0_usize;
314    for (g, row) in counts.chunks_exact(n_samples).enumerate() {
315        if !keep[g] {
316            continue;
317        }
318        for (j, v) in row.iter().enumerate() {
319            x[j * n_kept + k] = v.to_f64().unwrap_or(f64::NAN);
320        }
321        k += 1;
322    }
323    Ok((x, n_kept))
324}
325
326/// Rescales factors to a geometric mean of one.
327///
328/// `f / exp(mean(log(f)))`, edgeR's final line.
329///
330/// ### Params
331///
332/// * `f` - Factors, rescaled in place
333///
334/// ### Returns
335///
336/// `Ok(())`, or [`EdgeErrors::InvalidArgument`] if a factor is not finite and
337/// strictly positive.
338fn normalise_to_unit_product(f: &mut [f64]) -> Result<(), EdgeErrors> {
339    if let Some(j) = f.iter().position(|v| !v.is_finite() || *v <= 0.0) {
340        return Err(EdgeErrors::InvalidArgument(format!(
341            "normalisation factor for sample {j} is not finite and positive ({}); \
342             the counts are too sparse for this method",
343            f[j]
344        )));
345    }
346    let log_mean = f.iter().map(|v| v.ln()).sum::<f64>() / f.len() as f64;
347    let scale = log_mean.exp();
348    for v in f.iter_mut() {
349        *v /= scale;
350    }
351    Ok(())
352}
353
354///////////////////////
355// Reference columns //
356///////////////////////
357
358/// Picks the reference sample when the caller does not name one.
359///
360/// TMM takes `which.min(abs(f75 - mean(f75)))`, ties to the first sample. If
361/// more than half the samples have a zero upper quartile it falls back to
362/// `which.max(colSums(sqrt(x)))`. TMMwsp always uses the root-count rule.
363///
364/// ### Params
365///
366/// * `x` - Column-major filtered counts, `n_kept * n_samples`
367/// * `n_kept` - Genes surviving the all-zero filter
368/// * `n_samples` - Number of samples
369/// * `lib_size` - Library size per sample
370/// * `method` - [`NormMethod::Tmm`] or [`NormMethod::TmmwSp`]
371///
372/// ### Returns
373///
374/// The zero-based reference column, or an [`EdgeErrors`] if the quantile fails.
375fn resolve_ref_column(
376    x: &[f64],
377    n_kept: usize,
378    n_samples: usize,
379    lib_size: &[f64],
380    method: NormMethod,
381) -> Result<usize, EdgeErrors> {
382    if method == NormMethod::TmmwSp {
383        return Ok(arg_max_sqrt_colsum(x, n_kept, n_samples));
384    }
385
386    let mut f75 = vec![0.0_f64; n_samples];
387    for (j, f) in f75.iter_mut().enumerate() {
388        *f = quantile_type7(&x[j * n_kept..(j + 1) * n_kept], REF_COLUMN_QUANTILE)? / lib_size[j];
389    }
390
391    let median = quantile_type7(&f75, 0.5)?;
392    if median < F75_DEGENERATE {
393        return Ok(arg_max_sqrt_colsum(x, n_kept, n_samples));
394    }
395
396    let mean = f75.iter().sum::<f64>() / n_samples as f64;
397    // `which.min` keeps the first minimum, so the comparison must be strict.
398    let mut best = 0_usize;
399    let mut best_gap = f64::INFINITY;
400    for (j, f) in f75.iter().enumerate() {
401        let gap = (f - mean).abs();
402        if gap < best_gap {
403            best_gap = gap;
404            best = j;
405        }
406    }
407    if !best_gap.is_finite() {
408        return Err(EdgeErrors::InvalidReferenceColumn(
409            "upper-quartile factors are not finite for any sample".to_string(),
410        ));
411    }
412    Ok(best)
413}
414
415/// Index of the sample with the largest sum of square-rooted counts.
416///
417/// edgeR's sparse-data proxy for the deepest library. Ties go to the first
418/// sample, as `which.max` does.
419///
420/// ### Params
421///
422/// * `x` - Column-major filtered counts, `n_kept * n_samples`
423/// * `n_kept` - Genes surviving the all-zero filter
424/// * `n_samples` - Number of samples
425///
426/// ### Returns
427///
428/// The zero-based column index.
429fn arg_max_sqrt_colsum(x: &[f64], n_kept: usize, n_samples: usize) -> usize {
430    let mut best = 0_usize;
431    let mut best_sum = f64::NEG_INFINITY;
432    for j in 0..n_samples {
433        let s: f64 = x[j * n_kept..(j + 1) * n_kept]
434            .iter()
435            .map(|v| v.sqrt())
436            .sum();
437        if s > best_sum {
438            best_sum = s;
439            best = j;
440        }
441    }
442    best
443}
444
445/////////////////////////
446// Column-wise kernels //
447/////////////////////////
448
449/// Upper-quartile factors: a column quantile over the library size.
450///
451/// `.calcFactorQuantile`, with R's type 7 quantile ([`quantile_type7`]).
452///
453/// ### Params
454///
455/// * `x` - Column-major filtered counts, `n_kept * n_samples`
456/// * `n_kept` - Genes surviving the all-zero filter
457/// * `n_samples` - Number of samples
458/// * `lib_size` - Library size per sample
459/// * `p` - Quantile probability
460///
461/// ### Returns
462///
463/// One unnormalised factor per sample, or an [`EdgeErrors`] if a column
464/// quantile is zero (edgeR warns and returns NaN).
465fn calc_factor_quantile(
466    x: &[f64],
467    n_kept: usize,
468    n_samples: usize,
469    lib_size: &[f64],
470    p: f64,
471) -> Result<Vec<f64>, EdgeErrors> {
472    let mut f = vec![0.0_f64; n_samples];
473    for (j, out) in f.iter_mut().enumerate() {
474        let q = quantile_type7(&x[j * n_kept..(j + 1) * n_kept], p)?;
475        if q <= 0.0 {
476            return Err(EdgeErrors::InvalidArgument(format!(
477                "the {p} quantile of sample {j} is zero; upper-quartile \
478                 normalisation is undefined for counts this sparse"
479            )));
480        }
481        *out = q / lib_size[j];
482    }
483    Ok(f)
484}
485
486/// RLE factors: the median ratio to the gene-wise geometric mean.
487///
488/// `.calcFactorRLE(x) / lib.size`. Genes with a zero geometric mean (any sample
489/// without a count) contribute nothing, as `[gm > 0]` does in the R.
490///
491/// Geometric means are accumulated samples-outside, genes-inside, so reads and
492/// accumulator writes are sequential.
493///
494/// ### Params
495///
496/// * `x` - Column-major filtered counts, `n_kept * n_samples`
497/// * `n_kept` - Genes surviving the all-zero filter
498/// * `n_samples` - Number of samples
499/// * `lib_size` - Library size per sample
500///
501/// ### Returns
502///
503/// One unnormalised factor per sample, or [`EdgeErrors::NoGenesAfterFiltering`]
504/// if no gene is positive in every sample.
505///
506/// ### References
507///
508/// Anders and Huber, Genome Biology, 2010
509fn calc_factor_rle(
510    x: &[f64],
511    n_kept: usize,
512    n_samples: usize,
513    lib_size: &[f64],
514) -> Result<Vec<f64>, EdgeErrors> {
515    let mut log_gm = vec![0.0_f64; n_kept];
516    for j in 0..n_samples {
517        for (acc, v) in log_gm.iter_mut().zip(&x[j * n_kept..(j + 1) * n_kept]) {
518            *acc += v.ln();
519        }
520    }
521    let positive: Vec<usize> = log_gm
522        .iter()
523        .enumerate()
524        .filter(|(_, v)| (*v / n_samples as f64).exp() > 0.0)
525        .map(|(g, _)| g)
526        .collect();
527    if positive.is_empty() {
528        return Err(EdgeErrors::NoGenesAfterFiltering { n_genes: n_kept });
529    }
530
531    let gm: Vec<f64> = positive
532        .iter()
533        .map(|&g| (log_gm[g] / n_samples as f64).exp())
534        .collect();
535
536    let mut f = vec![0.0_f64; n_samples];
537    let mut ratios = vec![0.0_f64; positive.len()];
538    for (j, out) in f.iter_mut().enumerate() {
539        let col = &x[j * n_kept..(j + 1) * n_kept];
540        for (r, (&g, &m)) in ratios.iter_mut().zip(positive.iter().zip(gm.iter())) {
541            *r = col[g] / m;
542        }
543        *out = quantile_type7(&ratios, 0.5)? / lib_size[j];
544    }
545    Ok(f)
546}
547
548///////////////////////
549// TMM between pairs //
550///////////////////////
551
552/// TMM factor for one sample against the reference.
553///
554/// `.calcFactorTMM`. Genes zero in *either* library give a non-finite `M` or
555/// `A` and are dropped; TMMwsp exists to keep them.
556///
557/// Trimming is by rank on both axes. With `n` usable genes the kept set is the
558/// rank window `[floor(n * logratio_trim) + 1, n + 1 - that]` on the
559/// log-ratios, intersected with the same window at `sum_trim` on the
560/// abundances. The `floor(...) + 1` must be exact: `ceil` or dropping the `+ 1`
561/// shifts the window by one gene and moves every factor.
562///
563/// Ranks are averaged over ties, as in R's `rank()`.
564///
565/// ### Params
566///
567/// * `obs` - Observed sample's filtered counts, contiguous
568/// * `reference` - Reference sample's filtered counts, contiguous
569/// * `lib_obs` - Library size of the observed sample
570/// * `lib_ref` - Library size of the reference sample
571/// * `params` - Trim fractions, weighting flag and abundance cutoff
572///
573/// ### Returns
574///
575/// The unnormalised factor, `2^TMM`. Exactly 1 when the two libraries are
576/// proportional or nothing survives the finite filter.
577///
578/// ### References
579///
580/// Robinson and Oshlack, Genome Biology, 2010
581fn calc_factor_tmm(
582    obs: &[f64],
583    reference: &[f64],
584    lib_obs: f64,
585    lib_ref: f64,
586    params: &NormParams,
587) -> f64 {
588    let n_all = obs.len();
589    let mut log_r = Vec::with_capacity(n_all);
590    let mut abs_e = Vec::with_capacity(n_all);
591    let mut var = Vec::with_capacity(n_all);
592
593    for (&o, &r) in obs.iter().zip(reference.iter()) {
594        let p_obs = o / lib_obs;
595        let p_ref = r / lib_ref;
596        let lr = (p_obs / p_ref).log2();
597        let ae = (p_obs.log2() + p_ref.log2()) / 2.0;
598        if lr.is_finite() && ae.is_finite() && ae > params.a_cutoff {
599            log_r.push(lr);
600            abs_e.push(ae);
601            var.push((lib_obs - o) / lib_obs / o + (lib_ref - r) / lib_ref / r);
602        }
603    }
604
605    let n = log_r.len();
606    if n == 0 || log_r.iter().fold(0.0_f64, |m, v| m.max(v.abs())) < TMM_NULL_TOLERANCE {
607        return 1.0;
608    }
609
610    let lo_l = (n as f64 * params.logratio_trim).floor() + 1.0;
611    let hi_l = n as f64 + 1.0 - lo_l;
612    let lo_s = (n as f64 * params.sum_trim).floor() + 1.0;
613    let hi_s = n as f64 + 1.0 - lo_s;
614
615    let rank_r = rank_average(&log_r);
616    let rank_e = rank_average(&abs_e);
617
618    let mut num = 0.0_f64;
619    let mut den = 0.0_f64;
620    let mut kept = 0_usize;
621    for i in 0..n {
622        let keep = rank_r[i] >= lo_l && rank_r[i] <= hi_l && rank_e[i] >= lo_s && rank_e[i] <= hi_s;
623        if !keep {
624            continue;
625        }
626        kept += 1;
627        if params.do_weighting {
628            num += log_r[i] / var[i];
629            den += 1.0 / var[i];
630        } else {
631            num += log_r[i];
632        }
633    }
634
635    let f = if params.do_weighting {
636        num / den
637    } else if kept == 0 {
638        f64::NAN
639    } else {
640        num / kept as f64
641    };
642
643    // edgeR maps a missing weighted mean to a factor of one.
644    let f = if f.is_nan() { 0.0 } else { f };
645
646    f.exp2()
647}
648
649/// TMMwsp factor for one sample against the reference.
650///
651/// `.calcFactorTMMwsp`, TMM with singleton pairing. Instead of dropping every
652/// gene that is zero in one library, it pairs the largest counts among the
653/// observed-only positives with the largest among the reference-only positives.
654/// Genes zero in both are still dropped.
655///
656/// The trim window is `[floor(n * trim) + 1, n + 1 - that]` in 1-based
657/// positions, as in [`calc_factor_tmm`]. edgePython trims one gene too many at
658/// each end here. See `UPSTREAM_DEVIATIONS.md` A1.
659///
660/// ### Notes
661///
662/// **Tie-breaking:** M-values are ordered by `order(M, M.shrunk)`, a stable sort
663/// on `M` with the add-0.5 shrunk log-ratio as tiebreak. On sparse integer
664/// counts many pairs share an `M` exactly and the trim boundary lands inside a
665/// tied run, so without the tiebreak the result would depend on the sort's
666/// internal order. Ties in both keys keep input order, so the sort must be
667/// stable and the paired singletons are appended in edgeR's order.
668///
669/// ### Params
670///
671/// * `obs` - Observed sample's filtered counts, contiguous
672/// * `reference` - Reference sample's filtered counts, contiguous
673/// * `lib_obs` - Library size of the observed sample
674/// * `lib_ref` - Library size of the reference sample
675/// * `params` - Trim fractions and weighting flag. `a_cutoff` is ignored, as in
676///   edgeR.
677///
678/// ### Returns
679///
680/// The unnormalised factor, `2^TMM`. Exactly 1 when no pair survives or the two
681/// libraries are proportional.
682///
683/// ### References
684///
685/// Chen et al., F1000Research, 2016
686fn calc_factor_tmmwsp(
687    obs: &[f64],
688    reference: &[f64],
689    lib_obs: f64,
690    lib_ref: f64,
691    params: &NormParams,
692) -> f64 {
693    let n_all = obs.len();
694    let mut pair_obs = Vec::with_capacity(n_all);
695    let mut pair_ref = Vec::with_capacity(n_all);
696    // Positive in the reference only, and positive in the observed only.
697    let mut single_ref = Vec::new();
698    let mut single_obs = Vec::new();
699
700    for (&o, &r) in obs.iter().zip(reference.iter()) {
701        match (o > TMMWSP_ZERO_EPS, r > TMMWSP_ZERO_EPS) {
702            (true, true) => {
703                pair_obs.push(o);
704                pair_ref.push(r);
705            }
706            (true, false) => single_obs.push(o),
707            (false, true) => single_ref.push(r),
708            (false, false) => {}
709        }
710    }
711
712    let n_eligible = single_obs.len().min(single_ref.len());
713    if n_eligible > 0 {
714        single_obs.sort_unstable_by(|a, b| b.total_cmp(a));
715        single_ref.sort_unstable_by(|a, b| b.total_cmp(a));
716        pair_obs.extend_from_slice(&single_obs[..n_eligible]);
717        pair_ref.extend_from_slice(&single_ref[..n_eligible]);
718    }
719
720    let n = pair_obs.len();
721    if n == 0 {
722        return 1.0;
723    }
724
725    let p_obs: Vec<f64> = pair_obs.iter().map(|o| o / lib_obs).collect();
726    let p_ref: Vec<f64> = pair_ref.iter().map(|r| r / lib_ref).collect();
727    let m: Vec<f64> = p_obs
728        .iter()
729        .zip(p_ref.iter())
730        .map(|(o, r)| (o / r).log2())
731        .collect();
732    let a: Vec<f64> = p_obs
733        .iter()
734        .zip(p_ref.iter())
735        .map(|(o, r)| 0.5 * (o * r).log2())
736        .collect();
737
738    if m.iter().fold(0.0_f64, |acc, v| acc.max(v.abs())) < TMM_NULL_TOLERANCE {
739        return 1.0;
740    }
741
742    let m_shrunk: Vec<f64> = pair_obs
743        .iter()
744        .zip(pair_ref.iter())
745        .map(|(o, r)| (((o + 0.5) / (lib_obs + 0.5)) / ((r + 0.5) / (lib_ref + 0.5))).log2())
746        .collect();
747
748    let mut order_m: Vec<usize> = (0..n).collect();
749    // Stable, so a tie in both keys keeps the input order, as R's `order` does.
750    order_m.sort_by(|&i, &j| {
751        m[i].total_cmp(&m[j])
752            .then(m_shrunk[i].total_cmp(&m_shrunk[j]))
753    });
754    let mut order_a: Vec<usize> = (0..n).collect();
755    order_a.sort_by(|&i, &j| a[i].total_cmp(&a[j]));
756
757    let keep_m = trim_window(&order_m, n, params.logratio_trim);
758    let keep_a = trim_window(&order_a, n, params.sum_trim);
759
760    let mut num = 0.0_f64;
761    let mut den = 0.0_f64;
762    let mut kept = 0_usize;
763    for i in 0..n {
764        if !(keep_m[i] && keep_a[i]) {
765            continue;
766        }
767        kept += 1;
768        if params.do_weighting {
769            let v = (1.0 - p_obs[i]) / p_obs[i] / lib_obs + (1.0 - p_ref[i]) / p_ref[i] / lib_ref;
770            let w = (1.0 + TMMWSP_WEIGHT_RIDGE) / (v + TMMWSP_WEIGHT_RIDGE);
771            num += w * m[i];
772            den += w;
773        } else {
774            num += m[i];
775        }
776    }
777
778    if kept == 0 {
779        return 1.0;
780    }
781    let tmm = if params.do_weighting {
782        num / den
783    } else {
784        num / kept as f64
785    };
786    if tmm.is_nan() { 1.0 } else { tmm.exp2() }
787}
788
789/// Marks the central `n + 2 - 2 * (floor(n * trim) + 1)` entries of an
790/// ordering.
791///
792/// The window is R's 1-based `[lo, n + 1 - lo]` with `lo = floor(n * trim) + 1`,
793/// as a half-open 0-based slice. R would count *down* if `lo` exceeded the upper
794/// bound (trims at or above 0.5 on small `n`); this keeps nothing instead.
795///
796/// ### Params
797///
798/// * `order` - Indices sorted by the statistic being trimmed
799/// * `n` - Length of `order`
800/// * `trim` - Fraction removed from each tail
801///
802/// ### Returns
803///
804/// A mask over the *original* positions, true for the retained entries.
805fn trim_window(order: &[usize], n: usize, trim: f64) -> Vec<bool> {
806    let mut keep = vec![false; n];
807    let lo = (n as f64 * trim) as usize + 1;
808    let hi = n + 1 - lo.min(n + 1);
809    if lo > hi {
810        return keep;
811    }
812    for &idx in &order[lo - 1..hi] {
813        keep[idx] = true;
814    }
815    keep
816}
817
818///////////////
819// Front end //
820///////////////
821
822/// Normalisation factors for a count matrix, as edgeR's `calcNormFactors`.
823///
824/// Genes zero in every sample are dropped first, as in edgeR; library sizes are
825/// *not* recomputed afterwards. If no gene survives, or there is one sample, the
826/// method degrades to [`NormMethod::None`], as in edgeR.
827///
828/// The returned factors have geometric mean one.
829///
830/// ### Params
831///
832/// * `counts` - Row-major counts, `n_genes * n_samples`. Must be non-negative
833///   and finite.
834/// * `n_genes` - Number of genes, that is, rows
835/// * `n_samples` - Number of samples, that is, columns
836/// * `lib_size` - Library size per sample. Defaults to the column sums. Each
837///   entry must be finite and strictly positive.
838/// * `method` - Which scaling rule to apply
839/// * `ref_column` - Zero-based reference sample for TMM and TMMwsp. `None`
840///   uses `resolve_ref_column`. Ignored by the other methods.
841/// * `params` - Tuning knobs, or `None` for [`NormParams::default`]
842///
843/// ### Returns
844///
845/// One factor per sample, with geometric mean one, or an [`EdgeErrors`] on an
846/// inconsistent shape, a non-positive library size, an out-of-range reference
847/// column, a parameter outside its domain, or a method that cannot be computed
848/// on this matrix.
849///
850/// ### References
851///
852/// Robinson and Oshlack, Genome Biology, 2010 (TMM); Anders and Huber, Genome
853/// Biology, 2010 (RLE); Bullard et al., BMC Bioinformatics, 2010 (upper
854/// quartile)
855pub fn calc_norm_factors<T: EdgeFloat>(
856    counts: &[T],
857    n_genes: usize,
858    n_samples: usize,
859    lib_size: Option<&[f64]>,
860    method: NormMethod,
861    ref_column: Option<usize>,
862    params: Option<NormParams>,
863) -> Result<Vec<f64>, EdgeErrors> {
864    let params = params.unwrap_or_default();
865    validate_params(&params)?;
866
867    if n_genes == 0 || n_samples == 0 {
868        return Err(EdgeErrors::EmptyCounts { n_genes, n_samples });
869    }
870    if counts.len() != n_genes * n_samples {
871        return Err(EdgeErrors::LengthMismatch {
872            name: "counts",
873            expected: n_genes * n_samples,
874            got: counts.len(),
875        });
876    }
877    if let Some(r) = ref_column
878        && r >= n_samples
879    {
880        return Err(EdgeErrors::InvalidReferenceColumn(format!(
881            "column {r} requested but there are only {n_samples} samples"
882        )));
883    }
884
885    let lib = resolve_lib_size(counts, n_samples, lib_size)?;
886    let (x, n_kept) = to_column_major_nonzero(counts, n_genes, n_samples)?;
887
888    // edgeR degrades rather than failing: nothing to normalise against.
889    let method = if n_kept == 0 || n_samples == 1 {
890        NormMethod::None
891    } else {
892        method
893    };
894
895    let mut f = match method {
896        NormMethod::Tmm | NormMethod::TmmwSp => {
897            let reference = match ref_column {
898                Some(r) => r,
899                None => resolve_ref_column(&x, n_kept, n_samples, &lib, method)?,
900            };
901            let kernel = if method == NormMethod::Tmm {
902                calc_factor_tmm
903            } else {
904                calc_factor_tmmwsp
905            };
906            let ref_col = &x[reference * n_kept..(reference + 1) * n_kept];
907            (0..n_samples)
908                .into_par_iter()
909                .map(|j| {
910                    kernel(
911                        &x[j * n_kept..(j + 1) * n_kept],
912                        ref_col,
913                        lib[j],
914                        lib[reference],
915                        &params,
916                    )
917                })
918                .collect()
919        }
920        NormMethod::Rle => calc_factor_rle(&x, n_kept, n_samples, &lib)?,
921        NormMethod::UpperQuartile => {
922            calc_factor_quantile(&x, n_kept, n_samples, &lib, params.quantile)?
923        }
924        NormMethod::None => vec![1.0; n_samples],
925    };
926
927    normalise_to_unit_product(&mut f)?;
928    Ok(f)
929}
930
931///////////
932// Tests //
933///////////
934
935#[cfg(test)]
936mod tests {
937    use super::*;
938    use approx::assert_relative_eq;
939    use std::path::PathBuf;
940
941    /// Relative tolerance against the R reference values.
942    const TOL: f64 = 1e-14;
943
944    /// Reads a headed, all-numeric CSV from `tests/data` into a row-major matrix.
945    ///
946    /// The fixtures are R `write.csv` output without row names: one quoted
947    /// header line, then one record per row.
948    ///
949    /// ### Params
950    ///
951    /// * `name` - File name inside `tests/data`
952    ///
953    /// ### Returns
954    ///
955    /// The values row-major, the row count and the column count.
956    fn read_csv(name: &str) -> (Vec<f64>, usize, usize) {
957        let path: PathBuf = [env!("CARGO_MANIFEST_DIR"), "tests", "data", name]
958            .iter()
959            .collect();
960        let text = std::fs::read_to_string(&path)
961            .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
962
963        let mut lines = text.lines().filter(|l| !l.trim().is_empty());
964        let header = lines.next().expect("fixture has no header");
965        let n_cols = header.split(',').count();
966
967        let mut values = Vec::new();
968        let mut n_rows = 0_usize;
969        for line in lines {
970            for field in line.split(',') {
971                values.push(
972                    field
973                        .trim()
974                        .trim_matches('"')
975                        .parse::<f64>()
976                        .unwrap_or_else(|e| panic!("bad field {field:?} in {name}: {e}")),
977                );
978            }
979            n_rows += 1;
980        }
981        assert_eq!(values.len(), n_rows * n_cols, "ragged fixture {name}");
982        (values, n_rows, n_cols)
983    }
984
985    /// Runs [`calc_norm_factors`] and compares against R, element by element.
986    ///
987    /// ### Params
988    ///
989    /// * `counts` - Row-major counts
990    /// * `n_genes` - Number of genes
991    /// * `n_samples` - Number of samples
992    /// * `lib_size` - Library sizes, or `None` for column sums
993    /// * `method` - Method under test
994    /// * `ref_column` - Zero-based reference column, or `None`
995    /// * `params` - Tuning knobs, or `None`
996    /// * `expected` - `calcNormFactors` output from edgeR 4.8.2
997    #[allow(clippy::too_many_arguments)]
998    fn check(
999        counts: &[f64],
1000        n_genes: usize,
1001        n_samples: usize,
1002        lib_size: Option<&[f64]>,
1003        method: NormMethod,
1004        ref_column: Option<usize>,
1005        params: Option<NormParams>,
1006        expected: &[f64],
1007    ) {
1008        let got = calc_norm_factors(
1009            counts, n_genes, n_samples, lib_size, method, ref_column, params,
1010        )
1011        .expect("calc_norm_factors failed");
1012        assert_eq!(got.len(), expected.len());
1013        for (g, e) in got.iter().zip(expected.iter()) {
1014            assert_relative_eq!(g, e, max_relative = TOL, epsilon = 0.0);
1015        }
1016    }
1017
1018    // ----------------------------- //
1019    // method parsing and parameters //
1020    // ----------------------------- //
1021
1022    #[test]
1023    fn test_parse_norm_method_accepts_every_edger_spelling() {
1024        assert_eq!(parse_norm_method("TMM"), Some(NormMethod::Tmm));
1025        assert_eq!(parse_norm_method("tmm"), Some(NormMethod::Tmm));
1026        assert_eq!(parse_norm_method("TMMwsp"), Some(NormMethod::TmmwSp));
1027        assert_eq!(parse_norm_method("TMMwzp"), Some(NormMethod::TmmwSp));
1028        assert_eq!(parse_norm_method("RLE"), Some(NormMethod::Rle));
1029        assert_eq!(
1030            parse_norm_method("upperquartile"),
1031            Some(NormMethod::UpperQuartile)
1032        );
1033        assert_eq!(parse_norm_method("none"), Some(NormMethod::None));
1034        assert_eq!(parse_norm_method("quantile"), None);
1035    }
1036
1037    #[test]
1038    fn test_try_from_reports_the_unknown_method() {
1039        let err = NormMethod::try_from("wibble").unwrap_err();
1040        assert!(matches!(err, EdgeErrors::UnknownMethod { .. }));
1041    }
1042
1043    #[test]
1044    fn test_default_params_are_edgers() {
1045        let p = NormParams::default();
1046        assert_eq!(p.logratio_trim, 0.3);
1047        assert_eq!(p.sum_trim, 0.05);
1048        assert!(p.do_weighting);
1049        assert_eq!(p.a_cutoff, -1e10);
1050        assert_eq!(p.quantile, 0.75);
1051        let q = NormParams::new(0.2, 0.1, false, -5.0, 0.5);
1052        assert_eq!(q.logratio_trim, 0.2);
1053        assert!(!q.do_weighting);
1054    }
1055
1056    // --------------------------------------------------------------- //
1057    // R parity, tests/data/test_data_part1.csv, 22 genes by 4 samples //
1058    // --------------------------------------------------------------- //
1059    //
1060    // Row one is all zeros (exercises the all-zero filter). Every expected
1061    // vector below is `calcNormFactors(x, ...)` from edgeR 4.8.2.
1062
1063    #[test]
1064    fn test_part1_tmm_matches_r() {
1065        let (x, g, s) = read_csv("test_data_part1.csv");
1066        check(
1067            &x,
1068            g,
1069            s,
1070            None,
1071            NormMethod::Tmm,
1072            None,
1073            None,
1074            &[
1075                1.09214845928324,
1076                0.881664551382546,
1077                1.03839386653584,
1078                1.0001216498394,
1079            ],
1080        );
1081    }
1082
1083    #[test]
1084    fn test_part1_tmmwsp_matches_r() {
1085        let (x, g, s) = read_csv("test_data_part1.csv");
1086        check(
1087            &x,
1088            g,
1089            s,
1090            None,
1091            NormMethod::TmmwSp,
1092            None,
1093            None,
1094            &[
1095                1.11707573778036,
1096                0.875057108000525,
1097                1.03061167368031,
1098                0.992626639707476,
1099            ],
1100        );
1101    }
1102
1103    #[test]
1104    fn test_part1_rle_matches_r() {
1105        let (x, g, s) = read_csv("test_data_part1.csv");
1106        check(
1107            &x,
1108            g,
1109            s,
1110            None,
1111            NormMethod::Rle,
1112            None,
1113            None,
1114            &[
1115                1.08551277222965,
1116                0.956217074652334,
1117                0.91748548770939,
1118                1.05004851312078,
1119            ],
1120        );
1121    }
1122
1123    #[test]
1124    fn test_part1_upper_quartile_matches_r() {
1125        let (x, g, s) = read_csv("test_data_part1.csv");
1126        check(
1127            &x,
1128            g,
1129            s,
1130            None,
1131            NormMethod::UpperQuartile,
1132            None,
1133            None,
1134            &[
1135                0.917795511372727,
1136                1.20110064867884,
1137                0.913136650908906,
1138                0.993433714094184,
1139            ],
1140        );
1141    }
1142
1143    #[test]
1144    fn test_part1_upper_quartile_at_the_median_matches_r() {
1145        let (x, g, s) = read_csv("test_data_part1.csv");
1146        let params = NormParams {
1147            quantile: 0.5,
1148            ..Default::default()
1149        };
1150        check(
1151            &x,
1152            g,
1153            s,
1154            None,
1155            NormMethod::UpperQuartile,
1156            None,
1157            Some(params),
1158            &[
1159                1.04104095814556,
1160                0.864593338120892,
1161                1.0357564862768,
1162                1.07266136107989,
1163            ],
1164        );
1165    }
1166
1167    #[test]
1168    fn test_part1_none_is_all_ones() {
1169        let (x, g, s) = read_csv("test_data_part1.csv");
1170        let f = calc_norm_factors(&x, g, s, None, NormMethod::None, None, None).unwrap();
1171        assert_eq!(f, vec![1.0; 4]);
1172    }
1173
1174    #[test]
1175    fn test_part1_tmm_with_an_explicit_reference_matches_r() {
1176        let (x, g, s) = read_csv("test_data_part1.csv");
1177        // R's refColumn = 1 and 3, zero-based here.
1178        check(
1179            &x,
1180            g,
1181            s,
1182            None,
1183            NormMethod::Tmm,
1184            Some(0),
1185            None,
1186            &[
1187                1.12488124746577,
1188                0.866533389465521,
1189                0.995933161741123,
1190                1.03009630195075,
1191            ],
1192        );
1193        check(
1194            &x,
1195            g,
1196            s,
1197            None,
1198            NormMethod::Tmm,
1199            Some(2),
1200            None,
1201            &[
1202                1.04654677428291,
1203                1.15554356186379,
1204                0.926578374534831,
1205                0.892427355851779,
1206            ],
1207        );
1208    }
1209
1210    #[test]
1211    fn test_part1_tmm_with_custom_trims_matches_r() {
1212        let (x, g, s) = read_csv("test_data_part1.csv");
1213        let params = NormParams {
1214            logratio_trim: 0.2,
1215            sum_trim: 0.1,
1216            ..Default::default()
1217        };
1218        check(
1219            &x,
1220            g,
1221            s,
1222            None,
1223            NormMethod::Tmm,
1224            None,
1225            Some(params),
1226            &[
1227                1.13220983123328,
1228                0.969765698086251,
1229                0.916554640595877,
1230                0.993683089958524,
1231            ],
1232        );
1233    }
1234
1235    #[test]
1236    fn test_part1_tmm_without_weighting_matches_r() {
1237        let (x, g, s) = read_csv("test_data_part1.csv");
1238        let params = NormParams {
1239            do_weighting: false,
1240            ..Default::default()
1241        };
1242        check(
1243            &x,
1244            g,
1245            s,
1246            None,
1247            NormMethod::Tmm,
1248            None,
1249            Some(params),
1250            &[
1251                1.11187880471527,
1252                0.87234553380242,
1253                0.996922506288526,
1254                1.03417159694611,
1255            ],
1256        );
1257    }
1258
1259    #[test]
1260    fn test_part1_tmm_with_supplied_library_sizes_matches_r() {
1261        let (x, g, s) = read_csv("test_data_part1.csv");
1262        check(
1263            &x,
1264            g,
1265            s,
1266            Some(&[1000.0, 2000.0, 1500.0, 1200.0]),
1267            NormMethod::Tmm,
1268            None,
1269            None,
1270            &[
1271                1.44466458873241,
1272                0.720343929088734,
1273                0.855991617053877,
1274                1.12259618110301,
1275            ],
1276        );
1277    }
1278
1279    #[test]
1280    fn test_part1_tmmwsp_variants_match_r() {
1281        let (x, g, s) = read_csv("test_data_part1.csv");
1282        let params = NormParams {
1283            do_weighting: false,
1284            ..Default::default()
1285        };
1286        check(
1287            &x,
1288            g,
1289            s,
1290            None,
1291            NormMethod::TmmwSp,
1292            None,
1293            Some(params),
1294            &[
1295                1.14857890712681,
1296                0.862953537941521,
1297                0.986189268494682,
1298                1.02303732161413,
1299            ],
1300        );
1301        check(
1302            &x,
1303            g,
1304            s,
1305            None,
1306            NormMethod::TmmwSp,
1307            Some(1),
1308            None,
1309            &[
1310                1.24531629866136,
1311                0.95930860926261,
1312                0.769226281876988,
1313                1.08819787022886,
1314            ],
1315        );
1316    }
1317
1318    // ----------------------------------------------------------------- //
1319    // R parity, tests/data/test_data_part2.csv, 1000 genes by 3 samples //
1320    // ----------------------------------------------------------------- //
1321
1322    #[test]
1323    fn test_part2_all_methods_match_r() {
1324        let (x, g, s) = read_csv("test_data_part2.csv");
1325        check(
1326            &x,
1327            g,
1328            s,
1329            None,
1330            NormMethod::Tmm,
1331            None,
1332            None,
1333            &[1.00087299037568, 1.00141716151291, 0.997713849403509],
1334        );
1335        check(
1336            &x,
1337            g,
1338            s,
1339            None,
1340            NormMethod::TmmwSp,
1341            None,
1342            None,
1343            &[0.999243678236565, 1.00770066012757, 0.993109297052868],
1344        );
1345        check(
1346            &x,
1347            g,
1348            s,
1349            None,
1350            NormMethod::Rle,
1351            None,
1352            None,
1353            &[1.00777567277904, 0.990633895481675, 1.00166603054436],
1354        );
1355        check(
1356            &x,
1357            g,
1358            s,
1359            None,
1360            NormMethod::UpperQuartile,
1361            None,
1362            None,
1363            &[0.952168937473405, 1.09472066633077, 0.959362356794534],
1364        );
1365    }
1366
1367    #[test]
1368    fn test_part2_trim_and_weight_variants_match_r() {
1369        let (x, g, s) = read_csv("test_data_part2.csv");
1370        check(
1371            &x,
1372            g,
1373            s,
1374            None,
1375            NormMethod::Tmm,
1376            None,
1377            Some(NormParams {
1378                do_weighting: false,
1379                ..Default::default()
1380            }),
1381            &[0.998440905548369, 1.00708634636271, 0.99451405794568],
1382        );
1383        check(
1384            &x,
1385            g,
1386            s,
1387            None,
1388            NormMethod::TmmwSp,
1389            None,
1390            Some(NormParams {
1391                logratio_trim: 0.2,
1392                sum_trim: 0.1,
1393                ..Default::default()
1394            }),
1395            &[1.0147225769176, 0.998674566234331, 0.986798969146451],
1396        );
1397    }
1398
1399    // ------------------------------------------------------------------ //
1400    // R parity, small and sparse matrices generated for this test module //
1401    // ------------------------------------------------------------------ //
1402
1403    /// A 3 by 3 matrix with one dominant count, `matrix(1:9 with x[3,3] = 900)`.
1404    fn tiny() -> Vec<f64> {
1405        // Column-major in R, row-major here.
1406        vec![1.0, 4.0, 7.0, 2.0, 5.0, 8.0, 3.0, 6.0, 900.0]
1407    }
1408
1409    #[test]
1410    fn test_tiny_matrix_matches_r() {
1411        let x = tiny();
1412        check(
1413            &x,
1414            3,
1415            3,
1416            None,
1417            NormMethod::Tmm,
1418            None,
1419            None,
1420            &[1.31487541222201, 1.24518272726712, 0.610776484663282],
1421        );
1422        check(
1423            &x,
1424            3,
1425            3,
1426            None,
1427            NormMethod::TmmwSp,
1428            None,
1429            None,
1430            &[1.21548697491053, 1.45714699120165, 0.56460708913077],
1431        );
1432        check(
1433            &x,
1434            3,
1435            3,
1436            None,
1437            NormMethod::Rle,
1438            None,
1439            None,
1440            &[2.49099606900441, 3.51095868773469, 0.114340803167416],
1441        );
1442        check(
1443            &x,
1444            3,
1445            3,
1446            None,
1447            NormMethod::UpperQuartile,
1448            None,
1449            None,
1450            &[0.984518260707848, 0.866376069422906, 1.17238371242325],
1451        );
1452    }
1453
1454    /// 20 genes by 3 samples of `rpois(60, 0.6)`, R `set.seed(7)`, row-major.
1455    ///
1456    /// Two thirds of the entries are zero and the rest are mostly 1, so this
1457    /// exercises TMMwsp's singleton pairing and the M-value tie-break.
1458    fn sparse() -> Vec<f64> {
1459        vec![
1460            3.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 4.0, 1.0, 0.0, 2.0, 0.0, 0.0, 3.0, 2.0, 1.0, 0.0,
1461            0.0, 0.0, 1.0, 0.0, 2.0, 0.0, 1.0, 0.0, 2.0, 2.0, 0.0, 0.0, 2.0, 0.0, 1.0, 1.0, 0.0,
1462            0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0,
1463            0.0, 1.0, 0.0, 3.0, 1.0, 0.0, 0.0, 0.0, 0.0,
1464        ]
1465    }
1466
1467    #[test]
1468    fn test_sparse_matrix_matches_r() {
1469        let x = sparse();
1470        check(
1471            &x,
1472            20,
1473            3,
1474            None,
1475            NormMethod::Tmm,
1476            None,
1477            None,
1478            &[1.86860983013238, 0.718566958550494, 0.744756200069697],
1479        );
1480        check(
1481            &x,
1482            20,
1483            3,
1484            None,
1485            NormMethod::TmmwSp,
1486            None,
1487            None,
1488            &[1.01633695449891, 0.900162048144607, 1.09305391560417],
1489        );
1490        check(
1491            &x,
1492            20,
1493            3,
1494            None,
1495            NormMethod::TmmwSp,
1496            None,
1497            Some(NormParams {
1498                do_weighting: false,
1499                ..Default::default()
1500            }),
1501            &[1.06398557538257, 0.879775142410609, 1.06829838721288],
1502        );
1503        check(
1504            &x,
1505            20,
1506            3,
1507            None,
1508            NormMethod::Rle,
1509            None,
1510            None,
1511            &[2.60622175336887, 0.562126260530541, 0.682581887787085],
1512        );
1513        check(
1514            &x,
1515            20,
1516            3,
1517            None,
1518            NormMethod::UpperQuartile,
1519            None,
1520            None,
1521            &[1.25294073464069, 0.810726357708683, 0.984453434360544],
1522        );
1523    }
1524
1525    // ----------------- //
1526    // degenerate inputs //
1527    // ----------------- //
1528
1529    #[test]
1530    fn test_single_sample_degrades_to_no_normalisation() {
1531        let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1532        let f = calc_norm_factors(&x, 5, 1, None, NormMethod::Tmm, None, None).unwrap();
1533        assert_eq!(f, vec![1.0]);
1534    }
1535
1536    #[test]
1537    fn test_all_zero_matrix_degrades_to_no_normalisation() {
1538        let x = vec![0.0_f64; 12];
1539        for method in [
1540            NormMethod::Tmm,
1541            NormMethod::TmmwSp,
1542            NormMethod::Rle,
1543            NormMethod::UpperQuartile,
1544        ] {
1545            let f = calc_norm_factors(&x, 4, 3, None, method, None, None);
1546            // Every library size is zero: rejected before the method runs. edgeR
1547            // returns ones for the same input with lib.size supplied, which the
1548            // next assertion pins.
1549            assert!(f.is_err());
1550        }
1551        let f = calc_norm_factors(
1552            &x,
1553            4,
1554            3,
1555            Some(&[10.0, 10.0, 10.0]),
1556            NormMethod::Tmm,
1557            None,
1558            None,
1559        )
1560        .unwrap();
1561        assert_eq!(f, vec![1.0; 3]);
1562    }
1563
1564    #[test]
1565    fn test_proportional_columns_give_exactly_one() {
1566        // c(10..100) and twice that: M is constant, so TMM short-circuits.
1567        let mut x = Vec::new();
1568        for i in 1..=10 {
1569            x.push(10.0 * i as f64);
1570            x.push(20.0 * i as f64);
1571        }
1572        for method in [NormMethod::Tmm, NormMethod::TmmwSp, NormMethod::Rle] {
1573            let f = calc_norm_factors(&x, 10, 2, None, method, None, None).unwrap();
1574            assert_relative_eq!(f[0], 1.0, max_relative = 1e-14);
1575            assert_relative_eq!(f[1], 1.0, max_relative = 1e-14);
1576        }
1577    }
1578
1579    #[test]
1580    fn test_identical_columns_give_exactly_one() {
1581        let mut x = Vec::new();
1582        for i in 1..=10 {
1583            x.push(i as f64);
1584            x.push(i as f64);
1585        }
1586        for method in [NormMethod::Tmm, NormMethod::TmmwSp] {
1587            let f = calc_norm_factors(&x, 10, 2, None, method, None, None).unwrap();
1588            assert_eq!(f, vec![1.0, 1.0]);
1589        }
1590    }
1591
1592    #[test]
1593    fn test_factors_have_geometric_mean_one() {
1594        let (x, g, s) = read_csv("test_data_part1.csv");
1595        for method in [
1596            NormMethod::Tmm,
1597            NormMethod::TmmwSp,
1598            NormMethod::Rle,
1599            NormMethod::UpperQuartile,
1600            NormMethod::None,
1601        ] {
1602            let f = calc_norm_factors(&x, g, s, None, method, None, None).unwrap();
1603            let product: f64 = f.iter().product();
1604            assert_relative_eq!(product, 1.0, max_relative = 1e-12);
1605        }
1606    }
1607
1608    // -------------------------- //
1609    // reference column selection //
1610    // -------------------------- //
1611
1612    #[test]
1613    fn test_reference_column_is_the_one_nearest_the_mean_f75() {
1614        let (x, g, s) = read_csv("test_data_part1.csv");
1615        let lib = resolve_lib_size(&x, s, None).unwrap();
1616        let (cm, n_kept) = to_column_major_nonzero(&x, g, s).unwrap();
1617        // R: f75 = 0.0561, 0.0734, 0.0558, 0.0607; which.min(abs(f75 - mean)) = 4.
1618        let chosen = resolve_ref_column(&cm, n_kept, s, &lib, NormMethod::Tmm).unwrap();
1619        assert_eq!(chosen, 3);
1620        // TMMwsp uses the root-count rule unconditionally.
1621        let wsp = resolve_ref_column(&cm, n_kept, s, &lib, NormMethod::TmmwSp).unwrap();
1622        assert_eq!(wsp, arg_max_sqrt_colsum(&cm, n_kept, s));
1623    }
1624
1625    #[test]
1626    fn test_explicit_reference_column_overrides_the_heuristic() {
1627        let (x, g, s) = read_csv("test_data_part1.csv");
1628        let auto = calc_norm_factors(&x, g, s, None, NormMethod::Tmm, None, None).unwrap();
1629        let pinned = calc_norm_factors(&x, g, s, None, NormMethod::Tmm, Some(3), None).unwrap();
1630        assert_eq!(auto, pinned);
1631    }
1632
1633    // ------------------------ //
1634    // generic float and layout //
1635    // ------------------------ //
1636
1637    #[test]
1638    fn test_f32_counts_agree_with_f64() {
1639        let (x, g, s) = read_csv("test_data_part1.csv");
1640        let x32: Vec<f32> = x.iter().map(|v| *v as f32).collect();
1641        for method in [
1642            NormMethod::Tmm,
1643            NormMethod::TmmwSp,
1644            NormMethod::Rle,
1645            NormMethod::UpperQuartile,
1646        ] {
1647            let a = calc_norm_factors(&x, g, s, None, method, None, None).unwrap();
1648            let b = calc_norm_factors(&x32, g, s, None, method, None, None).unwrap();
1649            for (p, q) in a.iter().zip(b.iter()) {
1650                assert_relative_eq!(p, q, max_relative = 1e-12);
1651            }
1652        }
1653    }
1654
1655    #[test]
1656    fn test_transpose_drops_only_all_zero_genes() {
1657        // Three genes, two samples; the middle gene is all zero.
1658        let x = vec![1.0, 2.0, 0.0, 0.0, 3.0, 4.0];
1659        let (cm, n_kept) = to_column_major_nonzero(&x, 3, 2).unwrap();
1660        assert_eq!(n_kept, 2);
1661        assert_eq!(cm, vec![1.0, 3.0, 2.0, 4.0]);
1662    }
1663
1664    // ----------- //
1665    // error paths //
1666    // ----------- //
1667
1668    #[test]
1669    fn test_rejects_an_out_of_range_reference_column() {
1670        let x = vec![1.0_f64; 6];
1671        let err = calc_norm_factors(&x, 3, 2, None, NormMethod::Tmm, Some(2), None).unwrap_err();
1672        assert!(matches!(err, EdgeErrors::InvalidReferenceColumn(_)));
1673    }
1674
1675    #[test]
1676    fn test_rejects_a_library_size_of_the_wrong_length() {
1677        let x = vec![1.0_f64; 6];
1678        let err =
1679            calc_norm_factors(&x, 3, 2, Some(&[10.0]), NormMethod::Tmm, None, None).unwrap_err();
1680        assert!(matches!(
1681            err,
1682            EdgeErrors::LengthMismatch {
1683                name: "lib_size",
1684                ..
1685            }
1686        ));
1687    }
1688
1689    #[test]
1690    fn test_rejects_a_dead_sample() {
1691        // Sample two has no counts at all, so its library size is zero.
1692        let x = vec![5.0, 0.0, 4.0, 7.0, 0.0, 6.0, 3.0, 0.0, 2.0, 9.0, 0.0, 8.0];
1693        let err = calc_norm_factors(&x, 4, 3, None, NormMethod::Tmm, None, None).unwrap_err();
1694        assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
1695    }
1696
1697    #[test]
1698    fn test_rejects_a_shape_mismatch_and_empty_counts() {
1699        let err =
1700            calc_norm_factors(&[1.0_f64; 5], 3, 2, None, NormMethod::Tmm, None, None).unwrap_err();
1701        assert!(matches!(
1702            err,
1703            EdgeErrors::LengthMismatch { name: "counts", .. }
1704        ));
1705        let err =
1706            calc_norm_factors::<f64>(&[], 0, 2, None, NormMethod::Tmm, None, None).unwrap_err();
1707        assert!(matches!(err, EdgeErrors::EmptyCounts { .. }));
1708    }
1709
1710    #[test]
1711    fn test_rejects_negative_counts() {
1712        let x = vec![1.0, 2.0, -3.0, 4.0];
1713        let err = calc_norm_factors(&x, 2, 2, None, NormMethod::Tmm, None, None).unwrap_err();
1714        assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
1715    }
1716
1717    #[test]
1718    fn test_rejects_parameters_outside_their_domain() {
1719        let x = vec![1.0_f64; 6];
1720        for params in [
1721            NormParams {
1722                logratio_trim: 1.0,
1723                ..Default::default()
1724            },
1725            NormParams {
1726                sum_trim: -0.1,
1727                ..Default::default()
1728            },
1729            NormParams {
1730                quantile: 1.5,
1731                ..Default::default()
1732            },
1733        ] {
1734            let err =
1735                calc_norm_factors(&x, 3, 2, None, NormMethod::Tmm, None, Some(params)).unwrap_err();
1736            assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
1737        }
1738    }
1739
1740    #[test]
1741    fn test_upper_quartile_rejects_a_zero_quantile() {
1742        // Every gene survives the all-zero filter via sample two, but sample one
1743        // is zero in seven of eight, so its upper quartile is zero (NaN in edgeR).
1744        let mut x = vec![0.0_f64; 16];
1745        for g in 0..8 {
1746            x[g * 2 + 1] = 1.0;
1747        }
1748        x[7 * 2] = 10.0;
1749        let err =
1750            calc_norm_factors(&x, 8, 2, None, NormMethod::UpperQuartile, None, None).unwrap_err();
1751        assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
1752    }
1753
1754    #[test]
1755    fn test_rle_rejects_a_matrix_with_no_gene_positive_everywhere() {
1756        // Every geometric mean is zero, so the median ratio is undefined.
1757        let x = vec![1.0, 0.0, 0.0, 2.0, 3.0, 0.0, 0.0, 4.0];
1758        let err = calc_norm_factors(&x, 4, 2, None, NormMethod::Rle, None, None).unwrap_err();
1759        assert!(matches!(err, EdgeErrors::NoGenesAfterFiltering { .. }));
1760    }
1761}