Skip to main content

edge_rs/utils/
design.rs

1//! Design matrix inspection.
2//!
3//! Rank, estimability, leverage, and the factor construction that decides
4//! whether a GLM fit can take the one-way path.
5//!
6//! Designs are `f64`, not generic over [`crate::prelude::EdgeFloat`]: they are
7//! tiny, so `f32` would save no memory and put rank decisions at the mercy of
8//! rounding.
9//!
10//! Matrices are row-major: row `i` is `design[i * n_coef..(i + 1) * n_coef]`.
11
12use faer::MatRef;
13use rustc_hash::FxHashMap;
14
15use crate::prelude::*;
16
17////////////
18// Consts //
19////////////
20
21/// Multiplier edgeR uses to hash a design row down to one number.
22///
23/// `designAsFactor` forms `sum_j design[i, j] * z^j` with `z = (e + pi) / 5`
24/// and groups rows sharing a value. Must match edgeR exactly: it decides
25/// between the one-way and Levenberg paths, which differ in the last digits.
26const FACTOR_HASH_BASE: f64 = (std::f64::consts::E + std::f64::consts::PI) / 5.0;
27
28/// limma's defaults for [`choose_lowess_span`]: `small_n`, `min_span`, `power`.
29pub const LIMMA_LOWESS_DEFAULTS: (usize, f64, f64) = (50, 0.3, 1.0 / 3.0);
30
31/////////////
32// Helpers //
33/////////////
34
35/// Wraps a row-major slice as a faer matrix, checking the shape first.
36///
37/// ### Params
38///
39/// * `design` - Row-major values
40/// * `n_rows` - Number of rows
41/// * `n_cols` - Number of columns
42///
43/// ### Returns
44///
45/// The borrowed matrix, or [`EdgeErrors::LengthMismatch`] if the slice does not
46/// match the stated shape.
47fn as_matrix(design: &[f64], n_rows: usize, n_cols: usize) -> Result<MatRef<'_, f64>, EdgeErrors> {
48    validate_shape(design.len(), n_rows, n_cols)?;
49    Ok(MatRef::from_row_major_slice(design, n_rows, n_cols))
50}
51
52/// Checks that a row-major design's length matches its shape and both
53/// dimensions are positive.
54///
55/// ### Params
56///
57/// * `len` - Length of the row-major slice
58/// * `n_rows` - Number of rows
59/// * `n_cols` - Number of columns
60///
61/// ### Returns
62///
63/// `Ok(())` when `len == n_rows * n_cols` and both dimensions are positive,
64/// otherwise [`EdgeErrors::LengthMismatch`] or [`EdgeErrors::MustBePositive`].
65fn validate_shape(len: usize, n_rows: usize, n_cols: usize) -> Result<(), EdgeErrors> {
66    if len != n_rows * n_cols {
67        return Err(EdgeErrors::LengthMismatch {
68            name: "design",
69            expected: n_rows * n_cols,
70            got: len,
71        });
72    }
73    if n_rows == 0 || n_cols == 0 {
74        return Err(EdgeErrors::MustBePositive("design dimensions".to_string()));
75    }
76    Ok(())
77}
78
79///////////////
80// Front end //
81///////////////
82
83/// Numerical rank of a design matrix.
84///
85/// Counts singular values above `max(S) * max(n_rows, n_cols) * eps`, numpy's
86/// `matrix_rank` rule.
87///
88/// ### Params
89///
90/// * `design` - Row-major design matrix
91/// * `n_rows` - Number of samples
92/// * `n_cols` - Number of coefficients
93///
94/// ### Returns
95///
96/// The rank, or [`EdgeErrors`] if the shape is wrong or the SVD fails.
97pub fn matrix_rank(design: &[f64], n_rows: usize, n_cols: usize) -> Result<usize, EdgeErrors> {
98    let mat = as_matrix(design, n_rows, n_cols)?;
99    let svd = mat
100        .thin_svd()
101        .map_err(|e| EdgeErrors::SolveFailed(format!("SVD of the design matrix failed: {e:?}")))?;
102
103    let s = svd.S();
104    let values = s.column_vector();
105    let largest = (0..values.nrows()).fold(0.0_f64, |acc, i| acc.max(values[i]));
106    let tol = largest * n_rows.max(n_cols) as f64 * f64::EPSILON;
107
108    Ok((0..values.nrows()).filter(|&i| values[i] > tol).count())
109}
110
111/// Whether every coefficient in the design is identifiable.
112///
113/// ### Params
114///
115/// * `design` - Row-major design matrix
116/// * `n_rows` - Number of samples
117/// * `n_cols` - Number of coefficients
118///
119/// ### Returns
120///
121/// `true` when the rank equals the column count. Port of limma's `is.fullrank`.
122pub fn is_full_rank(design: &[f64], n_rows: usize, n_cols: usize) -> Result<bool, EdgeErrors> {
123    Ok(matrix_rank(design, n_rows, n_cols)? == n_cols)
124}
125
126/// Coefficients that cannot be estimated from this design.
127///
128/// Flags columns whose diagonal entry of `R` is negligible. Port of limma's
129/// `nonEstimable`.
130///
131/// ### Params
132///
133/// * `design` - Row-major design matrix
134/// * `n_rows` - Number of samples
135/// * `n_cols` - Number of coefficients
136///
137/// ### Returns
138///
139/// `None` when every coefficient is estimable, otherwise the offending column
140/// indices in increasing order.
141pub fn non_estimable(
142    design: &[f64],
143    n_rows: usize,
144    n_cols: usize,
145) -> Result<Option<Vec<usize>>, EdgeErrors> {
146    let mat = as_matrix(design, n_rows, n_cols)?;
147    let qr = mat.qr();
148    let r = qr.thin_R();
149
150    let diag_len = r.nrows().min(r.ncols());
151    let diag: Vec<f64> = (0..diag_len).map(|i| r[(i, i)].abs()).collect();
152    if diag.is_empty() {
153        return Ok(Some((0..n_cols).collect()));
154    }
155
156    let largest = diag.iter().fold(0.0_f64, |acc, &v| acc.max(v));
157    let tol = largest * n_rows.max(n_cols) as f64 * f64::EPSILON;
158
159    let flagged: Vec<usize> = diag
160        .iter()
161        .enumerate()
162        .filter(|&(_, &v)| v < tol)
163        .map(|(i, _)| i)
164        .collect();
165
166    if flagged.is_empty() {
167        Ok(None)
168    } else {
169        Ok(Some(flagged))
170    }
171}
172
173/// Leverage of each sample (the hat matrix diagonal).
174///
175/// Row sums of squares of the thin `Q` factor, so the `n_rows` by `n_rows` hat
176/// matrix is never formed.
177///
178/// ### Params
179///
180/// * `design` - Row-major design matrix
181/// * `n_rows` - Number of samples
182/// * `n_cols` - Number of coefficients
183///
184/// ### Returns
185///
186/// One leverage per sample, each in `[0, 1]` for a full rank design.
187pub fn hat_diagonal(design: &[f64], n_rows: usize, n_cols: usize) -> Result<Vec<f64>, EdgeErrors> {
188    let mat = as_matrix(design, n_rows, n_cols)?;
189    let q = mat.qr().compute_thin_Q();
190
191    Ok((0..n_rows)
192        .map(|i| (0..q.ncols()).map(|j| q[(i, j)] * q[(i, j)]).sum())
193        .collect())
194}
195
196/// Groups samples by their design row.
197///
198/// Port of edgeR's `designAsFactor`: hash each row with `FACTOR_HASH_BASE`,
199/// then label the distinct values in ascending order. When the group count
200/// equals the coefficient count, `glm_fit` takes the closed-form one-way fit.
201///
202/// Rows match on the exact `f64` hash, as in edgeR, so rows differing by
203/// rounding land in different groups and fall back to the slower correct path.
204///
205/// ### Params
206///
207/// * `design` - Row-major design matrix
208/// * `n_rows` - Number of samples
209/// * `n_cols` - Number of coefficients
210///
211/// ### Returns
212///
213/// A group index per sample and the number of distinct groups.
214pub fn design_as_factor(
215    design: &[f64],
216    n_rows: usize,
217    n_cols: usize,
218) -> Result<(Vec<usize>, usize), EdgeErrors> {
219    validate_shape(design.len(), n_rows, n_cols)?;
220
221    let mut powers = Vec::with_capacity(n_cols);
222    let mut power = 1.0_f64;
223    for _ in 0..n_cols {
224        powers.push(power);
225        power *= FACTOR_HASH_BASE;
226    }
227
228    let hashes: Vec<f64> = design
229        .chunks_exact(n_cols)
230        .map(|row| row.iter().zip(powers.iter()).map(|(a, b)| a * b).sum())
231        .collect();
232
233    // Distinct hashes in ascending order, matching numpy's `unique`.
234    let mut sorted: Vec<f64> = hashes.clone();
235    sorted.sort_by(|a, b| a.total_cmp(b));
236    sorted.dedup_by(|a, b| a.to_bits() == b.to_bits());
237
238    let level: FxHashMap<u64, usize> = sorted
239        .iter()
240        .enumerate()
241        .map(|(i, v)| (v.to_bits(), i))
242        .collect();
243
244    let groups: Vec<usize> = hashes.iter().map(|v| level[&v.to_bits()]).collect();
245
246    Ok((groups, sorted.len()))
247}
248
249////////////////////////////
250// Contrast reformulation //
251////////////////////////////
252
253/// A design rewritten so that a contrast becomes a coefficient.
254#[derive(Clone, Debug)]
255pub struct ContrastDesign {
256    /// The reformed design, row-major `n_rows * n_cols`. Same shape and column
257    /// span as the input.
258    pub design: Vec<f64>,
259    /// Column indices holding the contrasts, in ascending order.
260    pub coef: Vec<usize>,
261}
262
263/// Rewrites a design matrix so that a contrast becomes one of its coefficients.
264///
265/// Port of limma's `contrastAsCoef`, used by edgeR's `glmLRT` and `glmQLFTest`
266/// when a contrast rather than a coefficient index is supplied.
267///
268/// The rotation is `Q` from the QR of the contrast, then a triangular solve
269/// against `R` so the new coefficient is the contrast itself, not a multiple.
270/// The sign of `Q` is not pinned down, but the contrast columns are
271/// sign-invariant because the `R` solve carries the same sign. Only nuisance
272/// columns can flip, which leaves their span and the fit unchanged.
273///
274/// ### Params
275///
276/// * `design` - Row-major design, `n_rows * n_cols`
277/// * `n_rows` - Number of samples
278/// * `n_cols` - Number of coefficients
279/// * `contrast` - Column-major contrast, `n_cols * n_contrasts`
280/// * `n_contrasts` - Number of contrasts, usually one
281/// * `first` - Put the contrasts in the leading columns, not the trailing
282///   ones. limma defaults to `true`; edgeR calls it with `false`.
283///
284/// ### Returns
285///
286/// The reformed design and the indices of its contrast columns, or
287/// [`EdgeErrors`] if the shapes disagree, either design dimension is zero, or
288/// the contrast is entirely zero.
289pub fn contrast_as_coef(
290    design: &[f64],
291    n_rows: usize,
292    n_cols: usize,
293    contrast: &[f64],
294    n_contrasts: usize,
295    first: bool,
296) -> Result<ContrastDesign, EdgeErrors> {
297    validate_shape(design.len(), n_rows, n_cols)?;
298    if contrast.len() != n_cols * n_contrasts {
299        return Err(EdgeErrors::LengthMismatch {
300            name: "contrast",
301            expected: n_cols * n_contrasts,
302            got: contrast.len(),
303        });
304    }
305    if n_contrasts == 0 || n_contrasts > n_cols {
306        return Err(EdgeErrors::InvalidArgument(format!(
307            "expected between 1 and {n_cols} contrasts, got {n_contrasts}"
308        )));
309    }
310    if contrast.iter().all(|v| *v == 0.0) {
311        return Err(EdgeErrors::InvalidArgument(
312            "contrast is entirely zero".to_string(),
313        ));
314    }
315
316    let contrast_mat = MatRef::from_column_major_slice(contrast, n_cols, n_contrasts);
317    let qr = contrast_mat.qr();
318    let q = qr.compute_Q();
319    let r = qr.thin_R();
320
321    // designT = Q' X', of shape n_cols by n_rows.
322    let mut designt = vec![0.0; n_cols * n_rows];
323    for a in 0..n_cols {
324        for sample in 0..n_rows {
325            let mut acc = 0.0;
326            for k in 0..n_cols {
327                acc += q[(k, a)] * design[sample * n_cols + k];
328            }
329            designt[a * n_rows + sample] = acc;
330        }
331    }
332
333    // Solve R z = designT[..n_contrasts, ..] so the contrast rows read as the
334    // contrast, not a multiple of it.
335    for sample in 0..n_rows {
336        for row in (0..n_contrasts).rev() {
337            let mut acc = designt[row * n_rows + sample];
338            for k in (row + 1)..n_contrasts {
339                acc -= r[(row, k)] * designt[k * n_rows + sample];
340            }
341            let pivot = r[(row, row)];
342            if pivot == 0.0 {
343                return Err(EdgeErrors::InvalidArgument(
344                    "contrast is rank deficient".to_string(),
345                ));
346            }
347            designt[row * n_rows + sample] = acc / pivot;
348        }
349    }
350
351    // Back to row-major; contrast columns go last unless `first`.
352    let order: Vec<usize> = if first {
353        (0..n_cols).collect()
354    } else {
355        (n_contrasts..n_cols).chain(0..n_contrasts).collect()
356    };
357    let coef: Vec<usize> = if first {
358        (0..n_contrasts).collect()
359    } else {
360        (n_cols - n_contrasts..n_cols).collect()
361    };
362
363    let mut out = vec![0.0; n_rows * n_cols];
364    for sample in 0..n_rows {
365        for (slot, &source) in order.iter().enumerate() {
366            out[sample * n_cols + slot] = designt[source * n_rows + sample];
367        }
368    }
369
370    Ok(ContrastDesign { design: out, coef })
371}
372
373/// Lowess span for a given number of observations.
374///
375/// Port of limma's `chooseLowessSpan`: wider windows for small experiments,
376/// tapering towards `min_span`. [`LIMMA_LOWESS_DEFAULTS`] carries limma's
377/// defaults; edgePython passes different ones. See `UPSTREAM_DEVIATIONS.md` A8.
378///
379/// ### Params
380///
381/// * `n` - Number of observations
382/// * `small_n` - Count below which the span is 1
383/// * `min_span` - Asymptotic span for large `n`
384/// * `power` - Exponent controlling how fast the span tapers
385///
386/// ### Returns
387///
388/// A span in `(0, 1]`.
389pub fn choose_lowess_span(n: usize, small_n: usize, min_span: f64, power: f64) -> f64 {
390    if n <= small_n {
391        return 1.0;
392    }
393    (min_span + (1.0 - min_span) * (small_n as f64 / n as f64).powf(power)).min(1.0)
394}
395
396///////////
397// Tests //
398///////////
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use approx::assert_relative_eq;
404
405    /// Two-group design with an intercept: 6 samples, 2 coefficients.
406    fn two_group() -> (Vec<f64>, usize, usize) {
407        let design = vec![
408            1.0, 0.0, //
409            1.0, 0.0, //
410            1.0, 0.0, //
411            1.0, 1.0, //
412            1.0, 1.0, //
413            1.0, 1.0, //
414        ];
415        (design, 6, 2)
416    }
417
418    /// Rank deficient: the third column is the sum of the first two.
419    fn rank_deficient() -> (Vec<f64>, usize, usize) {
420        let design = vec![
421            1.0, 0.0, 1.0, //
422            1.0, 0.0, 1.0, //
423            1.0, 1.0, 2.0, //
424            1.0, 1.0, 2.0, //
425        ];
426        (design, 4, 3)
427    }
428
429    #[test]
430    fn test_matrix_rank_of_a_full_rank_design() {
431        let (d, r, c) = two_group();
432        assert_eq!(matrix_rank(&d, r, c).unwrap(), 2);
433        assert!(is_full_rank(&d, r, c).unwrap());
434    }
435
436    #[test]
437    fn test_matrix_rank_detects_a_collinear_column() {
438        let (d, r, c) = rank_deficient();
439        assert_eq!(matrix_rank(&d, r, c).unwrap(), 2);
440        assert!(!is_full_rank(&d, r, c).unwrap());
441    }
442
443    #[test]
444    fn test_non_estimable_is_none_for_a_full_rank_design() {
445        let (d, r, c) = two_group();
446        assert!(non_estimable(&d, r, c).unwrap().is_none());
447    }
448
449    #[test]
450    fn test_non_estimable_flags_the_dependent_column() {
451        let (d, r, c) = rank_deficient();
452        let flagged = non_estimable(&d, r, c).unwrap().unwrap();
453        assert_eq!(flagged, vec![2]);
454    }
455
456    /// R: `diag(X %*% solve(t(X) %*% X) %*% t(X))` on the two-group design is
457    /// 1/3 for every sample, since each group has three replicates.
458    #[test]
459    fn test_hat_diagonal_matches_the_analytic_leverage() {
460        let (d, r, c) = two_group();
461        let hat = hat_diagonal(&d, r, c).unwrap();
462        for h in &hat {
463            assert_relative_eq!(h, &(1.0 / 3.0), max_relative = 1e-12);
464        }
465        // The leverages of a full rank design sum to its rank.
466        let total: f64 = hat.iter().sum();
467        assert_relative_eq!(total, 2.0, max_relative = 1e-12);
468    }
469
470    #[test]
471    fn test_design_as_factor_recovers_two_groups() {
472        let (d, r, c) = two_group();
473        let (groups, n) = design_as_factor(&d, r, c).unwrap();
474        assert_eq!(n, 2);
475        assert_eq!(groups, vec![0, 0, 0, 1, 1, 1]);
476    }
477
478    /// Every row distinct gives one group per sample, pushing `glm_fit` off the
479    /// one-way path.
480    #[test]
481    fn test_design_as_factor_with_a_continuous_covariate() {
482        let design = vec![1.0, 0.1, 1.0, 0.2, 1.0, 0.3, 1.0, 0.4];
483        let (groups, n) = design_as_factor(&design, 4, 2).unwrap();
484        assert_eq!(n, 4);
485        assert_eq!(groups, vec![0, 1, 2, 3]);
486    }
487
488    /// Labels follow ascending row hash, not first appearance (numpy's `unique`,
489    /// hence edgeR's).
490    #[test]
491    fn test_design_as_factor_labels_in_ascending_hash_order() {
492        let design = vec![
493            1.0, 1.0, //
494            1.0, 0.0, //
495            1.0, 1.0, //
496        ];
497        let (groups, n) = design_as_factor(&design, 3, 2).unwrap();
498        assert_eq!(n, 2);
499        // The (1, 0) row hashes lower, so it takes label 0 despite appearing second.
500        assert_eq!(groups, vec![1, 0, 1]);
501    }
502
503    #[test]
504    fn test_rejects_a_shape_mismatch() {
505        let err = matrix_rank(&[1.0, 2.0, 3.0], 2, 2).unwrap_err();
506        assert!(matches!(err, EdgeErrors::LengthMismatch { .. }));
507        let err = design_as_factor(&[1.0, 2.0, 3.0], 2, 2).unwrap_err();
508        assert!(matches!(err, EdgeErrors::LengthMismatch { .. }));
509    }
510
511    /// Parity with limma 3.66:
512    /// ```r
513    /// X <- cbind(1, c(0,0,0,1,1,1), c(0.1,0.4,0.2,0.9,0.7,0.8))
514    /// contrastAsCoef(X, c(0, 1, -1), first = FALSE)
515    /// ```
516    ///
517    /// The contrast lands in the last column, as edgeR's `glmLRT` expects. The
518    /// nuisance columns may differ in sign from limma's, so they are checked by
519    /// span only.
520    #[test]
521    fn test_contrast_as_coef_matches_limma() {
522        let design = vec![
523            1.0, 0.0, 0.1, //
524            1.0, 0.0, 0.4, //
525            1.0, 0.0, 0.2, //
526            1.0, 1.0, 0.9, //
527            1.0, 1.0, 0.7, //
528            1.0, 1.0, 0.8, //
529        ];
530        let contrast = vec![0.0, 1.0, -1.0];
531        let out = contrast_as_coef(&design, 6, 3, &contrast, 1, false).unwrap();
532
533        assert_eq!(out.coef, vec![2]);
534
535        // limma's third column, the contrast itself.
536        let expected_contrast = [-0.05, -0.2, -0.1, 0.05, 0.15, 0.1];
537        for (sample, want) in expected_contrast.iter().enumerate() {
538            assert_relative_eq!(
539                out.design[sample * 3 + 2],
540                want,
541                epsilon = 1e-12,
542                max_relative = 1e-9
543            );
544        }
545
546        // Same span as the original, so the refit is a genuine nested model.
547        assert_eq!(matrix_rank(&out.design, 6, 3).unwrap(), 3);
548    }
549
550    /// The contrast column must reproduce the contrast applied to the original
551    /// coefficients, whatever sign the QR chose.
552    #[test]
553    fn test_contrast_column_carries_the_contrast() {
554        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];
555        let contrast = vec![0.0, 1.0];
556        let out = contrast_as_coef(&design, 6, 2, &contrast, 1, false).unwrap();
557        assert_eq!(out.coef, vec![1]);
558        // With this contrast the reformed design is the original one, so the
559        // group indicator column survives unchanged up to sign.
560        let column: Vec<f64> = (0..6).map(|i| out.design[i * 2 + 1].abs()).collect();
561        assert_eq!(column, vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0]);
562    }
563
564    #[test]
565    fn test_contrast_as_coef_can_put_the_contrast_first() {
566        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];
567        let out = contrast_as_coef(&design, 6, 2, &[0.0, 1.0], 1, true).unwrap();
568        assert_eq!(out.coef, vec![0]);
569    }
570
571    #[test]
572    fn test_contrast_as_coef_rejects_a_zero_contrast() {
573        let design = vec![1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0];
574        let err = contrast_as_coef(&design, 4, 2, &[0.0, 0.0], 1, false).unwrap_err();
575        assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
576    }
577
578    #[test]
579    fn test_contrast_as_coef_rejects_a_shape_mismatch() {
580        let design = vec![1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0];
581        let err = contrast_as_coef(&design, 4, 2, &[0.0, 1.0, 2.0], 1, false).unwrap_err();
582        assert!(matches!(err, EdgeErrors::LengthMismatch { .. }));
583    }
584
585    /// limma: `chooseLowessSpan(10)`, `(100)`, `(1000)` give 1, 0.8555904,
586    /// 0.5578822 at limma's own defaults of `small.n = 50, min.span = 0.3`.
587    /// edgePython passes 25 and 0.2 instead, which is `UPSTREAM_DEVIATIONS.md` A8.
588    #[test]
589    fn test_choose_lowess_span_matches_limma() {
590        let (small_n, min_span, power) = LIMMA_LOWESS_DEFAULTS;
591        assert_relative_eq!(
592            choose_lowess_span(10, small_n, min_span, power),
593            1.0,
594            max_relative = 1e-12
595        );
596        assert_relative_eq!(
597            choose_lowess_span(100, small_n, min_span, power),
598            0.855_590_4,
599            max_relative = 1e-7
600        );
601        assert_relative_eq!(
602            choose_lowess_span(1000, small_n, min_span, power),
603            0.557_882_2,
604            max_relative = 1e-7
605        );
606    }
607
608    #[test]
609    fn test_rejects_empty_dimensions() {
610        let err = matrix_rank(&[], 0, 0).unwrap_err();
611        assert!(matches!(err, EdgeErrors::MustBePositive(_)));
612    }
613}