use faer::MatRef;
use rustc_hash::FxHashMap;
use crate::prelude::*;
const FACTOR_HASH_BASE: f64 = (std::f64::consts::E + std::f64::consts::PI) / 5.0;
pub const LIMMA_LOWESS_DEFAULTS: (usize, f64, f64) = (50, 0.3, 1.0 / 3.0);
fn as_matrix(design: &[f64], n_rows: usize, n_cols: usize) -> Result<MatRef<'_, f64>, EdgeErrors> {
validate_shape(design.len(), n_rows, n_cols)?;
Ok(MatRef::from_row_major_slice(design, n_rows, n_cols))
}
fn validate_shape(len: usize, n_rows: usize, n_cols: usize) -> Result<(), EdgeErrors> {
if len != n_rows * n_cols {
return Err(EdgeErrors::LengthMismatch {
name: "design",
expected: n_rows * n_cols,
got: len,
});
}
if n_rows == 0 || n_cols == 0 {
return Err(EdgeErrors::MustBePositive("design dimensions".to_string()));
}
Ok(())
}
pub fn matrix_rank(design: &[f64], n_rows: usize, n_cols: usize) -> Result<usize, EdgeErrors> {
let mat = as_matrix(design, n_rows, n_cols)?;
let svd = mat
.thin_svd()
.map_err(|e| EdgeErrors::SolveFailed(format!("SVD of the design matrix failed: {e:?}")))?;
let s = svd.S();
let values = s.column_vector();
let largest = (0..values.nrows()).fold(0.0_f64, |acc, i| acc.max(values[i]));
let tol = largest * n_rows.max(n_cols) as f64 * f64::EPSILON;
Ok((0..values.nrows()).filter(|&i| values[i] > tol).count())
}
pub fn is_full_rank(design: &[f64], n_rows: usize, n_cols: usize) -> Result<bool, EdgeErrors> {
Ok(matrix_rank(design, n_rows, n_cols)? == n_cols)
}
pub fn non_estimable(
design: &[f64],
n_rows: usize,
n_cols: usize,
) -> Result<Option<Vec<usize>>, EdgeErrors> {
let mat = as_matrix(design, n_rows, n_cols)?;
let qr = mat.qr();
let r = qr.thin_R();
let diag_len = r.nrows().min(r.ncols());
let diag: Vec<f64> = (0..diag_len).map(|i| r[(i, i)].abs()).collect();
if diag.is_empty() {
return Ok(Some((0..n_cols).collect()));
}
let largest = diag.iter().fold(0.0_f64, |acc, &v| acc.max(v));
let tol = largest * n_rows.max(n_cols) as f64 * f64::EPSILON;
let flagged: Vec<usize> = diag
.iter()
.enumerate()
.filter(|&(_, &v)| v < tol)
.map(|(i, _)| i)
.collect();
if flagged.is_empty() {
Ok(None)
} else {
Ok(Some(flagged))
}
}
pub fn hat_diagonal(design: &[f64], n_rows: usize, n_cols: usize) -> Result<Vec<f64>, EdgeErrors> {
let mat = as_matrix(design, n_rows, n_cols)?;
let q = mat.qr().compute_thin_Q();
Ok((0..n_rows)
.map(|i| (0..q.ncols()).map(|j| q[(i, j)] * q[(i, j)]).sum())
.collect())
}
pub fn design_as_factor(
design: &[f64],
n_rows: usize,
n_cols: usize,
) -> Result<(Vec<usize>, usize), EdgeErrors> {
validate_shape(design.len(), n_rows, n_cols)?;
let mut powers = Vec::with_capacity(n_cols);
let mut power = 1.0_f64;
for _ in 0..n_cols {
powers.push(power);
power *= FACTOR_HASH_BASE;
}
let hashes: Vec<f64> = design
.chunks_exact(n_cols)
.map(|row| row.iter().zip(powers.iter()).map(|(a, b)| a * b).sum())
.collect();
let mut sorted: Vec<f64> = hashes.clone();
sorted.sort_by(|a, b| a.total_cmp(b));
sorted.dedup_by(|a, b| a.to_bits() == b.to_bits());
let level: FxHashMap<u64, usize> = sorted
.iter()
.enumerate()
.map(|(i, v)| (v.to_bits(), i))
.collect();
let groups: Vec<usize> = hashes.iter().map(|v| level[&v.to_bits()]).collect();
Ok((groups, sorted.len()))
}
#[derive(Clone, Debug)]
pub struct ContrastDesign {
pub design: Vec<f64>,
pub coef: Vec<usize>,
}
pub fn contrast_as_coef(
design: &[f64],
n_rows: usize,
n_cols: usize,
contrast: &[f64],
n_contrasts: usize,
first: bool,
) -> Result<ContrastDesign, EdgeErrors> {
validate_shape(design.len(), n_rows, n_cols)?;
if contrast.len() != n_cols * n_contrasts {
return Err(EdgeErrors::LengthMismatch {
name: "contrast",
expected: n_cols * n_contrasts,
got: contrast.len(),
});
}
if n_contrasts == 0 || n_contrasts > n_cols {
return Err(EdgeErrors::InvalidArgument(format!(
"expected between 1 and {n_cols} contrasts, got {n_contrasts}"
)));
}
if contrast.iter().all(|v| *v == 0.0) {
return Err(EdgeErrors::InvalidArgument(
"contrast is entirely zero".to_string(),
));
}
let contrast_mat = MatRef::from_column_major_slice(contrast, n_cols, n_contrasts);
let qr = contrast_mat.qr();
let q = qr.compute_Q();
let r = qr.thin_R();
let mut designt = vec![0.0; n_cols * n_rows];
for a in 0..n_cols {
for sample in 0..n_rows {
let mut acc = 0.0;
for k in 0..n_cols {
acc += q[(k, a)] * design[sample * n_cols + k];
}
designt[a * n_rows + sample] = acc;
}
}
for sample in 0..n_rows {
for row in (0..n_contrasts).rev() {
let mut acc = designt[row * n_rows + sample];
for k in (row + 1)..n_contrasts {
acc -= r[(row, k)] * designt[k * n_rows + sample];
}
let pivot = r[(row, row)];
if pivot == 0.0 {
return Err(EdgeErrors::InvalidArgument(
"contrast is rank deficient".to_string(),
));
}
designt[row * n_rows + sample] = acc / pivot;
}
}
let order: Vec<usize> = if first {
(0..n_cols).collect()
} else {
(n_contrasts..n_cols).chain(0..n_contrasts).collect()
};
let coef: Vec<usize> = if first {
(0..n_contrasts).collect()
} else {
(n_cols - n_contrasts..n_cols).collect()
};
let mut out = vec![0.0; n_rows * n_cols];
for sample in 0..n_rows {
for (slot, &source) in order.iter().enumerate() {
out[sample * n_cols + slot] = designt[source * n_rows + sample];
}
}
Ok(ContrastDesign { design: out, coef })
}
pub fn choose_lowess_span(n: usize, small_n: usize, min_span: f64, power: f64) -> f64 {
if n <= small_n {
return 1.0;
}
(min_span + (1.0 - min_span) * (small_n as f64 / n as f64).powf(power)).min(1.0)
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn two_group() -> (Vec<f64>, usize, usize) {
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, ];
(design, 6, 2)
}
fn rank_deficient() -> (Vec<f64>, usize, usize) {
let design = vec![
1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 2.0, 1.0, 1.0, 2.0, ];
(design, 4, 3)
}
#[test]
fn test_matrix_rank_of_a_full_rank_design() {
let (d, r, c) = two_group();
assert_eq!(matrix_rank(&d, r, c).unwrap(), 2);
assert!(is_full_rank(&d, r, c).unwrap());
}
#[test]
fn test_matrix_rank_detects_a_collinear_column() {
let (d, r, c) = rank_deficient();
assert_eq!(matrix_rank(&d, r, c).unwrap(), 2);
assert!(!is_full_rank(&d, r, c).unwrap());
}
#[test]
fn test_non_estimable_is_none_for_a_full_rank_design() {
let (d, r, c) = two_group();
assert!(non_estimable(&d, r, c).unwrap().is_none());
}
#[test]
fn test_non_estimable_flags_the_dependent_column() {
let (d, r, c) = rank_deficient();
let flagged = non_estimable(&d, r, c).unwrap().unwrap();
assert_eq!(flagged, vec![2]);
}
#[test]
fn test_hat_diagonal_matches_the_analytic_leverage() {
let (d, r, c) = two_group();
let hat = hat_diagonal(&d, r, c).unwrap();
for h in &hat {
assert_relative_eq!(h, &(1.0 / 3.0), max_relative = 1e-12);
}
let total: f64 = hat.iter().sum();
assert_relative_eq!(total, 2.0, max_relative = 1e-12);
}
#[test]
fn test_design_as_factor_recovers_two_groups() {
let (d, r, c) = two_group();
let (groups, n) = design_as_factor(&d, r, c).unwrap();
assert_eq!(n, 2);
assert_eq!(groups, vec![0, 0, 0, 1, 1, 1]);
}
#[test]
fn test_design_as_factor_with_a_continuous_covariate() {
let design = vec![1.0, 0.1, 1.0, 0.2, 1.0, 0.3, 1.0, 0.4];
let (groups, n) = design_as_factor(&design, 4, 2).unwrap();
assert_eq!(n, 4);
assert_eq!(groups, vec![0, 1, 2, 3]);
}
#[test]
fn test_design_as_factor_labels_in_ascending_hash_order() {
let design = vec![
1.0, 1.0, 1.0, 0.0, 1.0, 1.0, ];
let (groups, n) = design_as_factor(&design, 3, 2).unwrap();
assert_eq!(n, 2);
assert_eq!(groups, vec![1, 0, 1]);
}
#[test]
fn test_rejects_a_shape_mismatch() {
let err = matrix_rank(&[1.0, 2.0, 3.0], 2, 2).unwrap_err();
assert!(matches!(err, EdgeErrors::LengthMismatch { .. }));
let err = design_as_factor(&[1.0, 2.0, 3.0], 2, 2).unwrap_err();
assert!(matches!(err, EdgeErrors::LengthMismatch { .. }));
}
#[test]
fn test_contrast_as_coef_matches_limma() {
let design = vec![
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, ];
let contrast = vec![0.0, 1.0, -1.0];
let out = contrast_as_coef(&design, 6, 3, &contrast, 1, false).unwrap();
assert_eq!(out.coef, vec![2]);
let expected_contrast = [-0.05, -0.2, -0.1, 0.05, 0.15, 0.1];
for (sample, want) in expected_contrast.iter().enumerate() {
assert_relative_eq!(
out.design[sample * 3 + 2],
want,
epsilon = 1e-12,
max_relative = 1e-9
);
}
assert_eq!(matrix_rank(&out.design, 6, 3).unwrap(), 3);
}
#[test]
fn test_contrast_column_carries_the_contrast() {
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];
let contrast = vec![0.0, 1.0];
let out = contrast_as_coef(&design, 6, 2, &contrast, 1, false).unwrap();
assert_eq!(out.coef, vec![1]);
let column: Vec<f64> = (0..6).map(|i| out.design[i * 2 + 1].abs()).collect();
assert_eq!(column, vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0]);
}
#[test]
fn test_contrast_as_coef_can_put_the_contrast_first() {
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];
let out = contrast_as_coef(&design, 6, 2, &[0.0, 1.0], 1, true).unwrap();
assert_eq!(out.coef, vec![0]);
}
#[test]
fn test_contrast_as_coef_rejects_a_zero_contrast() {
let design = vec![1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0];
let err = contrast_as_coef(&design, 4, 2, &[0.0, 0.0], 1, false).unwrap_err();
assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
}
#[test]
fn test_contrast_as_coef_rejects_a_shape_mismatch() {
let design = vec![1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0];
let err = contrast_as_coef(&design, 4, 2, &[0.0, 1.0, 2.0], 1, false).unwrap_err();
assert!(matches!(err, EdgeErrors::LengthMismatch { .. }));
}
#[test]
fn test_choose_lowess_span_matches_limma() {
let (small_n, min_span, power) = LIMMA_LOWESS_DEFAULTS;
assert_relative_eq!(
choose_lowess_span(10, small_n, min_span, power),
1.0,
max_relative = 1e-12
);
assert_relative_eq!(
choose_lowess_span(100, small_n, min_span, power),
0.855_590_4,
max_relative = 1e-7
);
assert_relative_eq!(
choose_lowess_span(1000, small_n, min_span, power),
0.557_882_2,
max_relative = 1e-7
);
}
#[test]
fn test_rejects_empty_dimensions() {
let err = matrix_rank(&[], 0, 0).unwrap_err();
assert!(matches!(err, EdgeErrors::MustBePositive(_)));
}
}