1use faer::MatRef;
13use rustc_hash::FxHashMap;
14
15use crate::prelude::*;
16
17const FACTOR_HASH_BASE: f64 = (std::f64::consts::E + std::f64::consts::PI) / 5.0;
27
28pub const LIMMA_LOWESS_DEFAULTS: (usize, f64, f64) = (50, 0.3, 1.0 / 3.0);
30
31fn 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
52fn 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
79pub 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
111pub 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
126pub 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
173pub 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
196pub 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 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#[derive(Clone, Debug)]
255pub struct ContrastDesign {
256 pub design: Vec<f64>,
259 pub coef: Vec<usize>,
261}
262
263pub 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 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 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 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
373pub 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#[cfg(test)]
401mod tests {
402 use super::*;
403 use approx::assert_relative_eq;
404
405 fn two_group() -> (Vec<f64>, usize, usize) {
407 let design = vec![
408 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, ];
415 (design, 6, 2)
416 }
417
418 fn rank_deficient() -> (Vec<f64>, usize, usize) {
420 let design = vec![
421 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 2.0, 1.0, 1.0, 2.0, ];
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 #[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 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 #[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 #[test]
491 fn test_design_as_factor_labels_in_ascending_hash_order() {
492 let design = vec![
493 1.0, 1.0, 1.0, 0.0, 1.0, 1.0, ];
497 let (groups, n) = design_as_factor(&design, 3, 2).unwrap();
498 assert_eq!(n, 2);
499 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 #[test]
521 fn test_contrast_as_coef_matches_limma() {
522 let design = vec![
523 1.0, 0.0, 0.1, 1.0, 0.0, 0.4, 1.0, 0.0, 0.2, 1.0, 1.0, 0.9, 1.0, 1.0, 0.7, 1.0, 1.0, 0.8, ];
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 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 assert_eq!(matrix_rank(&out.design, 6, 3).unwrap(), 3);
548 }
549
550 #[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 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 #[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}