use rayon::prelude::*;
use crate::errors::EdgeErrors;
use crate::utils::design::non_estimable;
use crate::utils::linalg::{
cholesky_lower, cross_inverse, forward_substitute, invert_upper_triangular,
};
use crate::utils::recycled::{Recycled, RecycledRow};
const RANK_TOL: f64 = 1e-7;
const MIN_WEIGHT: f64 = 1e-15;
#[derive(Clone, Debug)]
pub struct LmFitResult {
pub coefficients: Vec<f64>,
pub stdev_unscaled: Vec<f64>,
pub sigma: Vec<f64>,
pub df_residual: Vec<f64>,
pub fitted: Vec<f64>,
pub cov_coefficients: Vec<f64>,
pub pivot: Vec<usize>,
pub rank: usize,
}
struct BlockCorrelation<'a> {
block: &'a [usize],
correlation: f64,
chol: Vec<f64>,
}
fn fill_correlation(
out: &mut [f64],
stride: usize,
obs: &[usize],
block: &[usize],
correlation: f64,
) {
for (i, &si) in obs.iter().enumerate() {
for (j, &sj) in obs.iter().enumerate() {
out[i * stride + j] = if i == j {
1.0
} else if block[si] == block[sj] {
correlation
} else {
0.0
};
}
}
}
struct Scratch {
obs: Vec<usize>,
a: Vec<f64>,
z: Vec<f64>,
hh: Vec<f64>,
tau: Vec<f64>,
col_norm: Vec<f64>,
r: Vec<f64>,
r_inv: Vec<f64>,
est: Vec<usize>,
beta: Vec<f64>,
chol: Vec<f64>,
}
impl Scratch {
fn new(n_samples: usize, n_coef: usize, blocked: bool) -> Self {
Self {
obs: Vec::with_capacity(n_samples),
a: vec![0.0; n_samples * n_coef],
z: vec![0.0; n_samples],
hh: vec![0.0; n_samples * n_coef],
tau: vec![0.0; n_coef],
col_norm: vec![0.0; n_coef],
r: vec![0.0; n_coef * n_coef],
r_inv: vec![0.0; n_coef * n_coef],
est: Vec::with_capacity(n_coef),
beta: vec![0.0; n_coef],
chol: if blocked {
vec![0.0; n_samples * n_samples]
} else {
Vec::new()
},
}
}
}
#[allow(clippy::too_many_arguments)]
fn rank_revealing_qr(
a: &mut [f64],
mut z: Option<&mut [f64]>,
hh: &mut [f64],
tau: &mut [f64],
col_norm: &[f64],
r: &mut [f64],
est: &mut Vec<usize>,
m: usize,
stride: usize,
n_coef: usize,
pivot: bool,
) -> usize {
est.clear();
let mut rank = 0usize;
for j in 0..n_coef {
if rank == m {
break;
}
let col = &a[j * stride..j * stride + m];
let norm = col[rank..].iter().map(|v| v * v).sum::<f64>().sqrt();
if norm == 0.0 || (pivot && norm <= RANK_TOL * col_norm[j]) {
continue;
}
for i in 0..rank {
r[i * n_coef + rank] = a[j * stride + i];
}
let head = a[j * stride + rank];
let alpha = if head >= 0.0 { -norm } else { norm };
r[rank * n_coef + rank] = alpha;
let base = rank * stride;
for i in rank..m {
hh[base + i] = a[j * stride + i];
}
hh[base + rank] = head - alpha;
tau[rank] = 1.0 / (norm * (norm + head.abs()));
for jj in (j + 1)..n_coef {
let cbase = jj * stride;
let mut dot = 0.0;
for i in rank..m {
dot += hh[base + i] * a[cbase + i];
}
let factor = dot * tau[rank];
for i in rank..m {
a[cbase + i] -= factor * hh[base + i];
}
}
if let Some(z) = z.as_deref_mut() {
let mut dot = 0.0;
for i in rank..m {
dot += hh[base + i] * z[i];
}
let factor = dot * tau[rank];
for i in rank..m {
z[i] -= factor * hh[base + i];
}
}
est.push(j);
rank += 1;
}
rank
}
struct GeneOut<'a> {
coef: &'a mut [f64],
stdev: &'a mut [f64],
fitted: &'a mut [f64],
sigma: &'a mut f64,
df: &'a mut f64,
}
#[allow(clippy::too_many_arguments)]
fn fit_gene(
scratch: &mut Scratch,
y_row: &[f64],
design: &[f64],
n_samples: usize,
n_coef: usize,
weights: Option<RecycledRow<'_, f64>>,
gls: Option<&BlockCorrelation<'_>>,
design_full_rank: bool,
out: GeneOut<'_>,
) {
let Scratch {
obs,
a,
z,
hh,
tau,
col_norm,
r,
r_inv,
est,
beta,
chol,
} = scratch;
obs.clear();
for (sample, &value) in y_row.iter().enumerate() {
let usable = value.is_finite()
&& match weights {
Some(w) => {
let wj = w.get(sample);
wj.is_finite() && wj > MIN_WEIGHT
}
None => true,
};
if usable {
obs.push(sample);
}
}
let m = obs.len();
if m == 0 {
out.coef.fill(f64::NAN);
out.stdev.fill(f64::NAN);
out.fitted.fill(f64::NAN);
*out.sigma = 0.0;
*out.df = 0.0;
return;
}
for (i, &sample) in obs.iter().enumerate() {
let scale = match weights {
Some(w) => w.get(sample).sqrt(),
None => 1.0,
};
let row = &design[sample * n_coef..(sample + 1) * n_coef];
for (j, &x) in row.iter().enumerate() {
a[j * n_samples + i] = scale * x;
}
z[i] = scale * y_row[sample];
}
if let Some(g) = gls {
let factor: &[f64] = if m < n_samples {
fill_correlation(chol, n_samples, obs, g.block, g.correlation);
let factored = cholesky_lower(chol, m, n_samples);
debug_assert!(factored, "principal submatrix must be positive definite");
&chol[..]
} else {
&g.chol
};
for j in 0..n_coef {
forward_substitute(
factor,
n_samples,
m,
&mut a[j * n_samples..j * n_samples + m],
);
}
forward_substitute(factor, n_samples, m, &mut z[..m]);
}
let pivot = !design_full_rank || m < n_samples;
if pivot {
for j in 0..n_coef {
let col = &a[j * n_samples..j * n_samples + m];
col_norm[j] = col.iter().map(|v| v * v).sum::<f64>().sqrt();
}
}
let rank = rank_revealing_qr(
a,
Some(z.as_mut_slice()),
hh,
tau,
col_norm,
r,
est,
m,
n_samples,
n_coef,
pivot,
);
let df = m - rank;
let rss: f64 = z[rank..m].iter().map(|v| v * v).sum();
*out.df = df as f64;
*out.sigma = if df > 0 {
(rss / df as f64).sqrt()
} else {
0.0
};
for i in (0..rank).rev() {
let mut sum = z[i];
for t in (i + 1)..rank {
sum -= r[i * n_coef + t] * beta[t];
}
beta[i] = sum / r[i * n_coef + i];
}
out.coef.fill(f64::NAN);
for (t, &j) in est.iter().enumerate() {
out.coef[j] = beta[t];
}
invert_upper_triangular(r, r_inv, rank, n_coef);
out.stdev.fill(f64::NAN);
for (t, &j) in est.iter().enumerate() {
let row: f64 = (t..rank).map(|s| r_inv[t * n_coef + s].powi(2)).sum();
out.stdev[j] = row.sqrt();
}
for (sample, slot) in out.fitted.iter_mut().enumerate() {
let row = &design[sample * n_coef..(sample + 1) * n_coef];
let mut acc = 0.0;
for (t, &j) in est.iter().enumerate() {
acc += row[j] * beta[t];
}
*slot = acc;
}
}
fn design_factorisation(
design: &[f64],
n_samples: usize,
n_coef: usize,
shared_weights: Option<&[f64]>,
gls: Option<&BlockCorrelation<'_>>,
) -> (Vec<f64>, Vec<usize>, usize) {
let mut a = vec![0.0; n_samples * n_coef];
for sample in 0..n_samples {
let scale = match shared_weights {
Some(w) => w[sample].sqrt(),
None => 1.0,
};
let row = &design[sample * n_coef..(sample + 1) * n_coef];
for (j, &x) in row.iter().enumerate() {
a[j * n_samples + sample] = scale * x;
}
}
if let Some(g) = gls {
for j in 0..n_coef {
forward_substitute(
&g.chol,
n_samples,
n_samples,
&mut a[j * n_samples..(j + 1) * n_samples],
);
}
}
let col_norm: Vec<f64> = (0..n_coef)
.map(|j| {
a[j * n_samples..(j + 1) * n_samples]
.iter()
.map(|v| v * v)
.sum::<f64>()
.sqrt()
})
.collect();
let mut hh = vec![0.0; n_samples * n_coef];
let mut tau = vec![0.0; n_coef];
let mut r = vec![0.0; n_coef * n_coef];
let mut est = Vec::with_capacity(n_coef);
let rank = rank_revealing_qr(
&mut a, None, &mut hh, &mut tau, &col_norm, &mut r, &mut est, n_samples, n_samples, n_coef,
true,
);
let mut r_inv = vec![0.0; n_coef * n_coef];
invert_upper_triangular(&r, &mut r_inv, rank, n_coef);
let cov = cross_inverse(&r_inv, rank, n_coef);
let mut pivot = est.clone();
pivot.extend((0..n_coef).filter(|j| !est.contains(j)));
(cov, pivot, rank)
}
#[allow(clippy::too_many_arguments)]
pub fn lm_fit(
y: &[f64],
n_genes: usize,
n_samples: usize,
design: &[f64],
n_coef: usize,
weights: Option<&Recycled<f64>>,
block: Option<&[usize]>,
correlation: Option<f64>,
) -> Result<LmFitResult, EdgeErrors> {
if n_genes == 0 || n_samples == 0 {
return Err(EdgeErrors::EmptyCounts { n_genes, n_samples });
}
if n_coef == 0 {
return Err(EdgeErrors::MustBePositive("n_coef".to_string()));
}
if y.len() != n_genes * n_samples {
return Err(EdgeErrors::LengthMismatch {
name: "y",
expected: n_genes * n_samples,
got: y.len(),
});
}
if design.len() != n_samples * n_coef {
return Err(EdgeErrors::LengthMismatch {
name: "design",
expected: n_samples * n_coef,
got: design.len(),
});
}
if let Some(w) = weights {
w.validate(n_genes, n_samples)?;
}
let gls = match block {
None => None,
Some(b) => {
if b.len() != n_samples {
return Err(EdgeErrors::LengthMismatch {
name: "block",
expected: n_samples,
got: b.len(),
});
}
let rho = correlation.ok_or_else(|| {
EdgeErrors::InvalidArgument(
"block requires a correlation; limma would estimate one with duplicateCorrelation"
.to_string(),
)
})?;
if !rho.is_finite() || rho.abs() >= 1.0 {
return Err(EdgeErrors::InvalidArgument(format!(
"correlation must be finite and strictly inside (-1, 1); got {rho}"
)));
}
if rho == 0.0 {
None
} else {
let obs: Vec<usize> = (0..n_samples).collect();
let mut chol = vec![0.0; n_samples * n_samples];
fill_correlation(&mut chol, n_samples, &obs, b, rho);
if !cholesky_lower(&mut chol, n_samples, n_samples) {
return Err(EdgeErrors::CholeskyFailed(format!(
"correlation {rho} makes the block covariance indefinite; \
a block of size k needs correlation above -1/(k - 1)"
)));
}
Some(BlockCorrelation {
block: b,
correlation: rho,
chol,
})
}
}
};
let design_full_rank = non_estimable(design, n_samples, n_coef)?.is_none();
let shared_weights: Option<Vec<f64>> = if y.par_iter().all(|v| v.is_finite()) {
match weights {
Some(Recycled::Scalar(w)) => Some(vec![*w; n_samples]),
Some(Recycled::BySample(v)) => Some(v.clone()),
_ => None,
}
} else {
None
};
let (cov_coefficients, pivot, rank) = design_factorisation(
design,
n_samples,
n_coef,
shared_weights.as_deref(),
gls.as_ref(),
);
let mut coefficients = vec![0.0; n_genes * n_coef];
let mut stdev_unscaled = vec![0.0; n_genes * n_coef];
let mut sigma = vec![0.0; n_genes];
let mut df_residual = vec![0.0; n_genes];
let mut fitted = vec![0.0; n_genes * n_samples];
let blocked = gls.is_some();
coefficients
.par_chunks_mut(n_coef)
.zip(stdev_unscaled.par_chunks_mut(n_coef))
.zip(fitted.par_chunks_mut(n_samples))
.zip(sigma.par_iter_mut())
.zip(df_residual.par_iter_mut())
.enumerate()
.for_each_init(
|| Scratch::new(n_samples, n_coef, blocked),
|scratch, (gene, ((((coef, stdev), fit), sig), df))| {
let start = gene * n_samples;
fit_gene(
scratch,
&y[start..start + n_samples],
design,
n_samples,
n_coef,
weights.map(|w| w.row(gene, n_samples)),
gls.as_ref(),
design_full_rank,
GeneOut {
coef,
stdev,
fitted: fit,
sigma: sig,
df,
},
);
},
);
Ok(LmFitResult {
coefficients,
stdev_unscaled,
sigma,
df_residual,
fitted,
cov_coefficients,
pivot,
rank,
})
}
#[cfg(test)]
mod tests {
#![allow(clippy::excessive_precision, clippy::approx_constant)]
use super::*;
use approx::assert_relative_eq;
const N_GENES: usize = 8;
const N_SAMPLES: usize = 5;
const NA: f64 = f64::NAN;
fn fixture_y() -> Vec<f64> {
(0..N_GENES * N_SAMPLES)
.map(|i| ((i * 7) % 23) as f64 / 64.0)
.collect()
}
fn fixture_weights() -> Vec<f64> {
(0..N_GENES * N_SAMPLES)
.map(|i| (((i * 5) % 7) + 1) as f64 / 8.0)
.collect()
}
fn design_two() -> Vec<f64> {
vec![1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
}
fn design_aliased() -> Vec<f64> {
vec![
1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0, 0.0,
]
}
fn blocks() -> Vec<usize> {
vec![1, 1, 2, 2, 3]
}
fn assert_close(got: &[f64], want: &[f64]) {
assert_eq!(got.len(), want.len(), "length");
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
if w.is_nan() {
assert!(g.is_nan(), "index {i}: expected NA, got {g}");
} else {
assert!(g.is_finite(), "index {i}: expected {w}, got {g}");
assert_relative_eq!(*g, *w, max_relative = 1e-13, epsilon = 0.0);
}
}
}
const A_COEF: [f64; 16] = [
0.054687499999999972,
0.15364583333333334,
0.24218749999999997,
-0.085937499999999986,
0.070312499999999972,
0.15364583333333334,
0.25781250000000011,
-0.085937500000000042,
0.085937499999999972,
0.033854166666666678,
0.27343749999999994,
-0.085937499999999972,
0.10156249999999997,
0.033854166666666678,
0.2890625,
-0.085937499999999972,
];
const A_SDU: [f64; 16] = [
0.70710678118654746,
0.9128709291752769,
0.70710678118654746,
0.9128709291752769,
0.70710678118654746,
0.9128709291752769,
0.70710678118654746,
0.9128709291752769,
0.70710678118654746,
0.9128709291752769,
0.70710678118654746,
0.9128709291752769,
0.70710678118654746,
0.9128709291752769,
0.70710678118654746,
0.9128709291752769,
];
const A_SIGMA: [f64; 8] = [
0.11164557869909383,
0.099845257878545909,
0.11164557869909383,
0.099845257878545896,
0.11164557869909383,
0.099845257878545896,
0.11164557869909383,
0.099845257878545896,
];
const A_FITTED: [f64; 40] = [
0.054687499999999972,
0.054687499999999972,
0.20833333333333331,
0.20833333333333331,
0.20833333333333331,
0.24218749999999997,
0.24218749999999997,
0.15625,
0.15625,
0.15625,
0.070312499999999972,
0.070312499999999972,
0.22395833333333331,
0.22395833333333331,
0.22395833333333331,
0.25781250000000011,
0.25781250000000011,
0.17187500000000006,
0.17187500000000006,
0.17187500000000006,
0.085937499999999972,
0.085937499999999972,
0.11979166666666666,
0.11979166666666666,
0.11979166666666666,
0.27343749999999994,
0.27343749999999994,
0.18749999999999997,
0.18749999999999997,
0.18749999999999997,
0.10156249999999997,
0.10156249999999997,
0.13541666666666666,
0.13541666666666666,
0.13541666666666666,
0.2890625,
0.2890625,
0.20312500000000003,
0.20312500000000003,
0.20312500000000003,
];
const B_COEF: [f64; 16] = [
0.072916666666666671,
0.23053728070175439,
0.26041666666666663,
-0.10992324561403506,
0.088541666666666741,
0.23053728070175433,
0.27604166666666669,
-0.10992324561403508,
0.10416666666666669,
-0.07209429824561403,
0.29166666666666669,
-0.10992324561403508,
0.11979166666666664,
-0.072094298245614016,
0.30729166666666669,
-0.10992324561403506,
];
const B_SDU: [f64; 2] = [0.57735026918962584, 0.73746840550820003];
const B_SIGMA: [f64; 8] = [
0.094323144471374784,
0.074810921506209174,
0.094323144471374742,
0.074810921506209146,
0.11154962751509892,
0.074810921506209146,
0.11154962751509892,
0.07481092150620916,
];
const C_COEF: [f64; 16] = [
0.093749999999999972,
0.066105769230769246,
0.22851562500000003,
-0.042436079545454544,
0.10069444444444443,
0.15451388888888887,
0.24687500000000015,
-0.05156250000000008,
0.058593750000000035,
0.084635416666666616,
0.26432291666666669,
-0.044010416666666691,
0.083333333333333343,
0.070833333333333331,
0.32812500000000006,
-0.099759615384615405,
];
const C_SDU: [f64; 16] = [
1.0690449676496978,
1.325987088263592,
1.0,
1.3142574813455419,
0.94280904158206336,
1.3333333333333333,
0.89442719099991608,
1.1710800875382399,
1.4142135623730956,
1.6329931618554525,
0.81649658092772603,
1.2110601416389968,
1.1547005383792515,
1.3662601021279464,
1.0690449676496978,
1.325987088263592,
];
const C_SIGMA: [f64; 8] = [
0.07289162230162545,
0.054945826898183045,
0.054147465881078799,
0.066068380670931875,
0.082088067294221856,
0.07410330019231505,
0.091613384135604214,
0.074599961439633961,
];
const D_COEF: [f64; 16] = [
0.054687499999999972,
0.15364583333333334,
0.24218749999999997,
-0.085937499999999986,
0.070312499999999972,
0.15364583333333334,
0.2578125,
-0.031249999999999965,
0.085937499999999972,
0.033854166666666678,
NA,
NA,
0.10156249999999997,
0.033854166666666678,
0.2890625,
-0.085937499999999972,
];
const D_SDU: [f64; 16] = [
0.70710678118654746,
0.9128709291752769,
0.70710678118654746,
0.9128709291752769,
0.70710678118654746,
0.9128709291752769,
0.70710678118654757,
1.0,
0.70710678118654746,
0.9128709291752769,
NA,
NA,
0.70710678118654746,
0.9128709291752769,
0.70710678118654746,
0.9128709291752769,
];
const D_SIGMA: [f64; 8] = [
0.11164557869909383,
0.099845257878545909,
0.11164557869909383,
0.077339804192278636,
0.11164557869909383,
0.0,
0.11164557869909383,
0.099845257878545896,
];
const D_DF: [f64; 8] = [3.0, 3.0, 3.0, 2.0, 3.0, 0.0, 3.0, 3.0];
const D_FITTED: [f64; 40] = [
0.054687499999999972,
0.054687499999999972,
0.20833333333333331,
0.20833333333333331,
0.20833333333333331,
0.24218749999999997,
0.24218749999999997,
0.15625,
0.15625,
0.15625,
0.070312499999999972,
0.070312499999999972,
0.22395833333333331,
0.22395833333333331,
0.22395833333333331,
0.2578125,
0.2578125,
0.22656250000000003,
0.22656250000000003,
0.22656250000000003,
0.085937499999999972,
0.085937499999999972,
0.11979166666666666,
0.11979166666666666,
0.11979166666666666,
NA,
NA,
NA,
NA,
NA,
0.10156249999999997,
0.10156249999999997,
0.13541666666666666,
0.13541666666666666,
0.13541666666666666,
0.2890625,
0.2890625,
0.20312500000000003,
0.20312500000000003,
0.20312500000000003,
];
const E_COEF: [f64; 16] = [
0.093749999999999972,
0.066105769230769246,
0.22851562500000003,
-0.042436079545454544,
0.10069444444444443,
0.15451388888888887,
0.24687499999999996,
-0.029427083333333336,
0.058593750000000035,
0.084635416666666616,
NA,
NA,
0.083333333333333343,
0.070833333333333331,
0.32812500000000006,
-0.099759615384615405,
];
const E_SDU: [f64; 16] = [
1.0690449676496978,
1.325987088263592,
1.0,
1.3142574813455419,
0.94280904158206336,
1.3333333333333333,
0.89442719099991586,
1.2110601416389966,
1.4142135623730956,
1.6329931618554525,
NA,
NA,
1.1547005383792515,
1.3662601021279464,
1.0690449676496978,
1.325987088263592,
];
const E_SIGMA: [f64; 8] = [
0.07289162230162545,
0.054945826898183045,
0.054147465881078799,
0.063048940228462927,
0.082088067294221856,
0.0,
0.091613384135604214,
0.074599961439633961,
];
const F_COEF: [f64; 16] = [
0.0546875,
0.14362980769230768,
0.24218749999999997,
-0.077524038461538436,
0.070312500000000042,
0.14362980769230765,
0.25781249999999989,
-0.077524038461538367,
0.085937499999999958,
0.033052884615384658,
0.27343749999999994,
-0.077524038461538394,
0.10156249999999996,
0.033052884615384651,
0.28906249999999983,
-0.077524038461538325,
];
const F_SDU: [f64; 2] = [0.79056941504209466, 1.0047961905856253];
const F_SIGMA: [f64; 8] = [
0.1146379531823081,
0.10410655316738308,
0.11463795318230807,
0.10410655316738308,
0.12883085482904671,
0.10410655316738308,
0.12883085482904669,
0.10410655316738311,
];
const G_COEF: [f64; 16] = [
0.1020338931741292,
0.046620906898569577,
0.22414926304156485,
-0.029290377485346103,
0.10866723833352257,
0.1386183553847985,
0.24332672403599037,
-0.037918062449936007,
0.051037727636791902,
0.097377781866368834,
0.2613411183871478,
-0.035655125769476104,
0.077711629165160057,
0.080295409798648854,
0.3364088931741292,
-0.10120602688561266,
];
const G_SDU: [f64; 16] = [
1.139580592613717,
1.4040630530325249,
1.112163511876666,
1.4227748106584122,
1.0256781561509392,
1.4571513092032287,
0.99664956319551301,
1.2759337202972107,
1.5469735769439759,
1.7857323811461194,
0.91075033227467828,
1.2983569545962401,
1.2788621058650516,
1.5060416834761272,
1.139580592613717,
1.4040630530325249,
];
const G_SIGMA: [f64; 8] = [
0.070104095566521502,
0.055924276262054526,
0.057844680310825951,
0.067449568692666786,
0.094410200626087654,
0.077120443875363126,
0.10549134470829188,
0.07499280377972585,
];
const G_FITTED_GENE0: [f64; 5] = [
0.1020338931741292,
0.1020338931741292,
0.14865480007269877,
0.14865480007269877,
0.14865480007269877,
];
const H_COEF: [f64; 16] = [
0.1020338931741292,
0.046620906898569577,
0.22414926304156485,
-0.029290377485346103,
0.10866723833352257,
0.1386183553847985,
0.24332672403599037,
-0.025878807369323673,
0.051037727636791902,
0.097377781866368834,
NA,
NA,
0.077711629165160057,
0.080295409798648854,
0.3364088931741292,
-0.10120602688561266,
];
const H_SDU: [f64; 16] = [
1.139580592613717,
1.4040630530325249,
1.112163511876666,
1.4227748106584122,
1.0256781561509392,
1.4571513092032287,
0.99664956319551323,
1.288400954083966,
1.5469735769439759,
1.7857323811461194,
NA,
NA,
1.2788621058650516,
1.5060416834761272,
1.139580592613717,
1.4040630530325249,
];
const H_SIGMA: [f64; 8] = [
0.070104095566521502,
0.055924276262054526,
0.057844680310825951,
0.067507835762062693,
0.094410200626087654,
0.0,
0.10549134470829188,
0.07499280377972585,
];
const K_COEF: [f64; 16] = [
0.07853837083483986,
0.23106008862949068,
0.26603837083483994,
-0.107400358117879,
0.094163370834839874,
0.23106008862949068,
0.28166337083484,
-0.10740035811787897,
0.10978837083483996,
-0.094332132363834628,
0.29728837083484005,
-0.107400358117879,
0.12541337083483997,
-0.094332132363834642,
0.31291337083483994,
-0.10740035811787904,
];
const K_SDU: [f64; 2] = [0.63943105293252589, 0.80103691702704827];
const K_SIGMA: [f64; 8] = [
0.10159645906688854,
0.081201785694141715,
0.10159645906688854,
0.081201785694141715,
0.12346737328937207,
0.081201785694141715,
0.12346737328937207,
0.081201785694141729,
];
#[test]
fn test_unweighted_matches_limma() {
let fit = lm_fit(
&fixture_y(),
N_GENES,
N_SAMPLES,
&design_two(),
2,
None,
None,
None,
)
.unwrap();
assert_close(&fit.coefficients, &A_COEF);
assert_close(&fit.stdev_unscaled, &A_SDU);
assert_close(&fit.sigma, &A_SIGMA);
assert_close(&fit.df_residual, &[3.0; 8]);
assert_close(&fit.fitted, &A_FITTED);
}
const A_COV: [f64; 4] = [0.49999999999999994, -0.5, -0.5, 0.83333333333333337];
const A_COV_WEIGHTED: [f64; 4] = [
2.6666666666666656,
-2.6666666666666661,
-2.6666666666666661,
3.333333333333333,
];
#[test]
fn test_cov_coefficients_match_the_shared_design_qr() {
let fit = lm_fit(
&fixture_y(),
N_GENES,
N_SAMPLES,
&design_two(),
2,
None,
None,
None,
)
.unwrap();
assert_eq!(fit.rank, 2);
assert_eq!(fit.pivot, vec![0, 1]);
assert_close(&fit.cov_coefficients, &A_COV);
for gene in 0..N_GENES {
assert_relative_eq!(
fit.stdev_unscaled[gene * 2],
A_COV[0].sqrt(),
epsilon = 1e-12
);
}
}
#[test]
fn test_cov_coefficients_fold_in_a_shared_weight_row() {
let w = Recycled::by_sample(vec![0.125, 0.25, 0.375, 0.5, 0.625]);
let fit = lm_fit(
&fixture_y(),
N_GENES,
N_SAMPLES,
&design_two(),
2,
Some(&w),
None,
None,
)
.unwrap();
assert_eq!(fit.rank, 2);
assert_close(&fit.cov_coefficients, &A_COV_WEIGHTED);
}
#[test]
fn test_cov_coefficients_ignore_per_gene_weights() {
let w = Recycled::full(fixture_weights(), N_GENES, N_SAMPLES).unwrap();
let fit = lm_fit(
&fixture_y(),
N_GENES,
N_SAMPLES,
&design_two(),
2,
Some(&w),
None,
None,
)
.unwrap();
assert_close(&fit.cov_coefficients, &A_COV);
}
#[test]
fn test_aliased_design_reports_rank_and_pivot() {
let fit = lm_fit(
&fixture_y(),
N_GENES,
N_SAMPLES,
&design_aliased(),
3,
None,
None,
None,
)
.unwrap();
assert_eq!(fit.rank, 2);
assert_eq!(fit.pivot, vec![0, 1, 2]);
assert_eq!(fit.cov_coefficients.len(), 4);
assert_close(&fit.cov_coefficients, &A_COV);
}
#[test]
fn test_sample_weights_match_limma() {
let w = Recycled::by_sample(vec![1.0, 2.0, 0.5, 4.0, 0.25]);
let fit = lm_fit(
&fixture_y(),
N_GENES,
N_SAMPLES,
&design_two(),
2,
Some(&w),
None,
None,
)
.unwrap();
assert_close(&fit.coefficients, &B_COEF);
assert_close(&fit.sigma, &B_SIGMA);
for row in fit.stdev_unscaled.chunks_exact(2) {
assert_close(row, &B_SDU);
}
}
#[test]
fn test_full_weights_match_limma() {
let w = Recycled::full(fixture_weights(), N_GENES, N_SAMPLES).unwrap();
let fit = lm_fit(
&fixture_y(),
N_GENES,
N_SAMPLES,
&design_two(),
2,
Some(&w),
None,
None,
)
.unwrap();
assert_close(&fit.coefficients, &C_COEF);
assert_close(&fit.stdev_unscaled, &C_SDU);
assert_close(&fit.sigma, &C_SIGMA);
assert_close(&fit.df_residual, &[3.0; 8]);
}
#[test]
fn test_missing_value_refits_row() {
let mut y = fixture_y();
y[3 * N_SAMPLES + 2] = f64::NAN;
for s in 0..N_SAMPLES {
y[5 * N_SAMPLES + s] = f64::NAN;
}
let fit = lm_fit(&y, N_GENES, N_SAMPLES, &design_two(), 2, None, None, None).unwrap();
assert_close(&fit.coefficients, &D_COEF);
assert_close(&fit.stdev_unscaled, &D_SDU);
assert_close(&fit.sigma, &D_SIGMA);
assert_close(&fit.df_residual, &D_DF);
assert_close(&fit.fitted, &D_FITTED);
}
#[test]
fn test_missing_value_with_full_weights() {
let mut y = fixture_y();
y[3 * N_SAMPLES + 2] = f64::NEG_INFINITY;
for s in 0..N_SAMPLES {
y[5 * N_SAMPLES + s] = f64::NAN;
}
let w = Recycled::full(fixture_weights(), N_GENES, N_SAMPLES).unwrap();
let fit = lm_fit(
&y,
N_GENES,
N_SAMPLES,
&design_two(),
2,
Some(&w),
None,
None,
)
.unwrap();
assert_close(&fit.coefficients, &E_COEF);
assert_close(&fit.stdev_unscaled, &E_SDU);
assert_close(&fit.sigma, &E_SIGMA);
assert_close(&fit.df_residual, &D_DF);
}
#[test]
fn test_zero_weight_drops_the_observation() {
let mut weights = fixture_weights();
weights[3 * N_SAMPLES + 2] = 0.0;
for s in 0..N_SAMPLES {
weights[5 * N_SAMPLES + s] = 0.0;
}
let w = Recycled::full(weights, N_GENES, N_SAMPLES).unwrap();
let fit = lm_fit(
&fixture_y(),
N_GENES,
N_SAMPLES,
&design_two(),
2,
Some(&w),
None,
None,
)
.unwrap();
assert_close(&fit.coefficients, &E_COEF);
assert_close(&fit.stdev_unscaled, &E_SDU);
assert_close(&fit.sigma, &E_SIGMA);
assert_close(&fit.df_residual, &D_DF);
}
#[test]
fn test_block_correlation_matches_limma() {
let fit = lm_fit(
&fixture_y(),
N_GENES,
N_SAMPLES,
&design_two(),
2,
None,
Some(&blocks()),
Some(0.25),
)
.unwrap();
assert_close(&fit.coefficients, &F_COEF);
assert_close(&fit.sigma, &F_SIGMA);
assert_close(&fit.df_residual, &[3.0; 8]);
for row in fit.stdev_unscaled.chunks_exact(2) {
assert_close(row, &F_SDU);
}
}
#[test]
fn test_block_correlation_with_full_weights() {
let w = Recycled::full(fixture_weights(), N_GENES, N_SAMPLES).unwrap();
let fit = lm_fit(
&fixture_y(),
N_GENES,
N_SAMPLES,
&design_two(),
2,
Some(&w),
Some(&blocks()),
Some(0.25),
)
.unwrap();
assert_close(&fit.coefficients, &G_COEF);
assert_close(&fit.stdev_unscaled, &G_SDU);
assert_close(&fit.sigma, &G_SIGMA);
assert_close(&fit.fitted[..N_SAMPLES], &G_FITTED_GENE0);
}
#[test]
fn test_block_correlation_with_sample_weights() {
let w = Recycled::by_sample(vec![1.0, 2.0, 0.5, 4.0, 0.25]);
let fit = lm_fit(
&fixture_y(),
N_GENES,
N_SAMPLES,
&design_two(),
2,
Some(&w),
Some(&blocks()),
Some(0.25),
)
.unwrap();
assert_close(&fit.coefficients, &K_COEF);
assert_close(&fit.sigma, &K_SIGMA);
for row in fit.stdev_unscaled.chunks_exact(2) {
assert_close(row, &K_SDU);
}
}
#[test]
fn test_block_correlation_with_missing_refits_submatrix() {
let mut y = fixture_y();
y[3 * N_SAMPLES + 2] = f64::NAN;
for s in 0..N_SAMPLES {
y[5 * N_SAMPLES + s] = f64::NAN;
}
let w = Recycled::full(fixture_weights(), N_GENES, N_SAMPLES).unwrap();
let fit = lm_fit(
&y,
N_GENES,
N_SAMPLES,
&design_two(),
2,
Some(&w),
Some(&blocks()),
Some(0.25),
)
.unwrap();
assert_close(&fit.coefficients, &H_COEF);
assert_close(&fit.stdev_unscaled, &H_SDU);
assert_close(&fit.sigma, &H_SIGMA);
assert_close(&fit.df_residual, &D_DF);
}
#[test]
fn test_zero_correlation_reduces_to_least_squares() {
let fit = lm_fit(
&fixture_y(),
N_GENES,
N_SAMPLES,
&design_two(),
2,
None,
Some(&blocks()),
Some(0.0),
)
.unwrap();
assert_close(&fit.coefficients, &A_COEF);
assert_close(&fit.sigma, &A_SIGMA);
}
#[test]
fn test_correlation_without_block_is_ignored() {
let fit = lm_fit(
&fixture_y(),
N_GENES,
N_SAMPLES,
&design_two(),
2,
None,
None,
Some(0.25),
)
.unwrap();
assert_close(&fit.coefficients, &A_COEF);
assert_close(&fit.sigma, &A_SIGMA);
}
#[test]
fn test_rank_deficient_design_aliases_the_last_column() {
let fit = lm_fit(
&fixture_y(),
N_GENES,
N_SAMPLES,
&design_aliased(),
3,
None,
None,
None,
)
.unwrap();
for gene in 0..N_GENES {
let coef = &fit.coefficients[gene * 3..(gene + 1) * 3];
let sdu = &fit.stdev_unscaled[gene * 3..(gene + 1) * 3];
assert_close(&coef[..2], &A_COEF[gene * 2..(gene + 1) * 2]);
assert_close(&sdu[..2], &A_SDU[gene * 2..(gene + 1) * 2]);
assert!(coef[2].is_nan());
assert!(sdu[2].is_nan());
}
assert_close(&fit.sigma, &A_SIGMA);
assert_close(&fit.df_residual, &[3.0; 8]);
assert_close(&fit.fitted, &A_FITTED);
}
#[test]
fn test_single_gene() {
let y: Vec<f64> = fixture_y()[..N_SAMPLES].to_vec();
let fit = lm_fit(&y, 1, N_SAMPLES, &design_two(), 2, None, None, None).unwrap();
assert_close(&fit.coefficients, &A_COEF[..2]);
assert_close(&fit.stdev_unscaled, &A_SDU[..2]);
assert_close(&fit.sigma, &A_SIGMA[..1]);
assert_close(&fit.df_residual, &[3.0]);
assert_close(&fit.fitted, &A_FITTED[..N_SAMPLES]);
}
#[test]
fn test_saturated_design_gives_zero_sigma() {
let mut design = vec![0.0; N_SAMPLES * N_SAMPLES];
for s in 0..N_SAMPLES {
design[s * N_SAMPLES + s] = 1.0;
}
let y = fixture_y();
let fit = lm_fit(&y, N_GENES, N_SAMPLES, &design, N_SAMPLES, None, None, None).unwrap();
assert_close(&fit.df_residual, &[0.0; 8]);
assert_close(&fit.sigma, &[0.0; 8]);
assert_close(&fit.coefficients, &y);
assert_close(&fit.fitted, &y);
}
#[test]
fn test_rejects_empty_input() {
assert!(matches!(
lm_fit(&[], 0, N_SAMPLES, &design_two(), 2, None, None, None),
Err(EdgeErrors::EmptyCounts { n_genes: 0, .. })
));
assert!(matches!(
lm_fit(&[], N_GENES, 0, &design_two(), 2, None, None, None),
Err(EdgeErrors::EmptyCounts { n_samples: 0, .. })
));
}
#[test]
fn test_rejects_zero_coefficients() {
assert!(matches!(
lm_fit(&fixture_y(), N_GENES, N_SAMPLES, &[], 0, None, None, None),
Err(EdgeErrors::MustBePositive(_))
));
}
#[test]
fn test_rejects_wrong_response_length() {
assert!(matches!(
lm_fit(
&[0.0; 7],
N_GENES,
N_SAMPLES,
&design_two(),
2,
None,
None,
None
),
Err(EdgeErrors::LengthMismatch {
name: "y",
expected: 40,
got: 7
})
));
}
#[test]
fn test_rejects_wrong_design_length() {
assert!(matches!(
lm_fit(
&fixture_y(),
N_GENES,
N_SAMPLES,
&[1.0; 8],
2,
None,
None,
None
),
Err(EdgeErrors::LengthMismatch {
name: "design",
expected: 10,
got: 8
})
));
}
#[test]
fn test_rejects_mismatched_weights() {
let w = Recycled::by_sample(vec![1.0, 2.0]);
assert!(matches!(
lm_fit(
&fixture_y(),
N_GENES,
N_SAMPLES,
&design_two(),
2,
Some(&w),
None,
None
),
Err(EdgeErrors::LengthMismatch {
name: "by_sample",
..
})
));
}
#[test]
fn test_rejects_wrong_block_length() {
assert!(matches!(
lm_fit(
&fixture_y(),
N_GENES,
N_SAMPLES,
&design_two(),
2,
None,
Some(&[1, 1, 2]),
Some(0.25)
),
Err(EdgeErrors::LengthMismatch {
name: "block",
expected: 5,
got: 3
})
));
}
#[test]
fn test_rejects_block_without_correlation() {
assert!(matches!(
lm_fit(
&fixture_y(),
N_GENES,
N_SAMPLES,
&design_two(),
2,
None,
Some(&blocks()),
None
),
Err(EdgeErrors::InvalidArgument(_))
));
}
#[test]
fn test_rejects_out_of_range_correlation() {
for rho in [1.0, -1.0, 2.5, f64::NAN] {
assert!(matches!(
lm_fit(
&fixture_y(),
N_GENES,
N_SAMPLES,
&design_two(),
2,
None,
Some(&blocks()),
Some(rho)
),
Err(EdgeErrors::InvalidArgument(_))
));
}
}
#[test]
fn test_rejects_indefinite_block_covariance() {
assert!(matches!(
lm_fit(
&fixture_y(),
N_GENES,
N_SAMPLES,
&design_two(),
2,
None,
Some(&[1, 1, 1, 1, 1]),
Some(-0.5)
),
Err(EdgeErrors::CholeskyFailed(_))
));
}
}