use crate::dispersion::cox_reid::{CoxReidParams, common_dispersion_cox_reid};
use crate::dispersion::estimate::residual_df;
use crate::glm::fit::{DEFAULT_PRIOR_COUNT, FitMethod, GlmFit, glm_fit};
use crate::glm::test::QlSummary;
use crate::limma::squeeze_var::{SqueezeVarParams, squeeze_var};
use crate::prelude::*;
use crate::ql::weights::{compute_adjust_vec, update_prior};
use crate::utils::design::choose_lowess_span;
const MAX_DISPERSION: f64 = 4.0;
const MIN_DF: f64 = 1e-8;
const TOP_PROPORTION_SMALL_N: usize = 20;
const TOP_PROPORTION_MIN_SPAN: f64 = 0.02;
const TOP_PROPORTION_POWER: f64 = 1.0 / 3.0;
#[derive(Clone, Copy, Debug)]
pub struct QlFitParams {
pub abundance_trend: bool,
pub robust: bool,
pub winsor_tail_p: (f64, f64),
pub legacy: bool,
pub top_proportion: Option<f64>,
pub prior_count: f64,
}
impl Default for QlFitParams {
fn default() -> Self {
Self {
abundance_trend: true,
robust: false,
winsor_tail_p: (0.05, 0.1),
legacy: false,
top_proportion: None,
prior_count: DEFAULT_PRIOR_COUNT,
}
}
}
enum Extras {
Legacy(Vec<f64>),
Current {
deviance_adj: Vec<f64>,
df_residual_adj: Vec<f64>,
average: f64,
},
}
#[derive(Clone, Debug)]
pub struct QlFit {
pub coefficients: Vec<f64>,
pub unshrunk_coefficients: Option<Vec<f64>>,
pub fitted: Vec<f64>,
pub deviance: Vec<f64>,
pub df_residual: usize,
pub method: FitMethod,
pub dispersion: Recycled<f64>,
pub s2_post: Vec<f64>,
pub s2_prior: Vec<f64>,
pub df_prior: Vec<f64>,
pub df_residual_adj: Option<Vec<f64>>,
pub deviance_adj: Option<Vec<f64>>,
pub df_residual_zeros: Option<Vec<f64>>,
pub average_ql_dispersion: Option<f64>,
pub top_proportion: Option<f64>,
}
impl QlFit {
pub fn as_glm_fit(&self) -> GlmFit {
GlmFit {
coefficients: self.coefficients.clone(),
unshrunk_coefficients: self.unshrunk_coefficients.clone(),
fitted: self.fitted.clone(),
deviance: self.deviance.clone(),
df_residual: self.df_residual,
method: self.method,
}
}
pub fn ql_summary(&self) -> QlSummary<'_> {
let (df_residual_adj, df_residual_zeros, average_ql_dispersion) = match (
self.df_residual_adj.as_deref(),
self.df_residual_zeros.as_deref(),
) {
(Some(adj), _) => (adj, None, self.average_ql_dispersion),
(None, Some(zeros)) => (zeros, Some(zeros), None),
(None, None) => unreachable!("glm_ql_fit always fills one of the two"),
};
QlSummary {
s2_post: &self.s2_post,
df_prior: &self.df_prior,
df_residual_adj,
df_residual_zeros,
fitted: &self.fitted,
average_ql_dispersion,
}
}
}
#[allow(clippy::too_many_arguments)]
fn dispersion_from_top_genes<T: EdgeFloat>(
counts: &[T],
n_genes: usize,
n_samples: usize,
design: &[f64],
n_coef: usize,
offset: &Recycled<f64>,
weights: Option<&Recycled<f64>>,
ave_log_cpm: &[f64],
top_proportion: f64,
) -> Result<f64, EdgeErrors> {
let n_top = ((top_proportion * n_genes as f64).ceil() as usize).clamp(1, n_genes);
let mut order: Vec<usize> = (0..n_genes).collect();
order.sort_by(|&a, &b| ave_log_cpm[b].total_cmp(&ave_log_cpm[a]));
let kept = &order[..n_top];
let mut selected = Vec::with_capacity(n_top * n_samples);
for &gene in kept {
selected.extend_from_slice(&counts[gene * n_samples..(gene + 1) * n_samples]);
}
let selected_offset = offset.subset(kept, n_samples);
let selected_weights = weights.map(|w| w.subset(kept, n_samples));
let params = CoxReidParams {
subset: None,
..Default::default()
};
common_dispersion_cox_reid(
&selected,
n_top,
n_samples,
design,
n_coef,
&selected_offset,
selected_weights.as_ref(),
None,
Some(params),
)
}
#[allow(clippy::too_many_arguments)]
fn resolve_dispersion<T: EdgeFloat>(
counts: &[T],
n_genes: usize,
n_samples: usize,
design: &[f64],
n_coef: usize,
dispersion: Option<&Recycled<f64>>,
offset: &Recycled<f64>,
weights: Option<&Recycled<f64>>,
ave_log_cpm: &[f64],
params: &QlFitParams,
) -> Result<(Recycled<f64>, Option<f64>), EdgeErrors> {
if let Some(d) = dispersion {
d.validate(n_genes, n_samples)?;
let resolved = if params.legacy {
d.clone()
} else {
d.map(|v| v.min(MAX_DISPERSION))
};
return Ok((resolved, None));
}
if params.legacy {
return Err(EdgeErrors::InvalidArgument(
"the legacy quasi-likelihood pipeline needs an explicit dispersion".to_string(),
));
}
let proportion = match params.top_proportion {
Some(p) => {
if !(0.0..=1.0).contains(&p) {
return Err(EdgeErrors::InvalidArgument(format!(
"top_proportion must lie in [0, 1], got {p}"
)));
}
p
}
None => {
let df_residual = (n_samples - n_coef) as f64;
let scale = (n_genes as f64 * df_residual.sqrt()).round() as usize;
choose_lowess_span(
scale,
TOP_PROPORTION_SMALL_N,
TOP_PROPORTION_MIN_SPAN,
TOP_PROPORTION_POWER,
)
}
};
let estimated = dispersion_from_top_genes(
counts,
n_genes,
n_samples,
design,
n_coef,
offset,
weights,
ave_log_cpm,
proportion,
)?;
Ok((Recycled::scalar(estimated), Some(proportion)))
}
#[allow(clippy::too_many_arguments)]
pub fn glm_ql_fit<T: EdgeFloat>(
counts: &[T],
n_genes: usize,
n_samples: usize,
design: &[f64],
n_coef: usize,
dispersion: Option<&Recycled<f64>>,
offset: &Recycled<f64>,
weights: Option<&Recycled<f64>>,
ave_log_cpm: &[f64],
params: Option<QlFitParams>,
) -> Result<QlFit, EdgeErrors> {
let params = params.unwrap_or_default();
if ave_log_cpm.len() != n_genes {
return Err(EdgeErrors::LengthMismatch {
name: "ave_log_cpm",
expected: n_genes,
got: ave_log_cpm.len(),
});
}
if n_coef >= n_samples {
return Err(EdgeErrors::InvalidArgument(format!(
"no residual degrees of freedom: {n_coef} coefficients for {n_samples} samples"
)));
}
let (dispersion, top_proportion) = resolve_dispersion(
counts,
n_genes,
n_samples,
design,
n_coef,
dispersion,
offset,
weights,
ave_log_cpm,
¶ms,
)?;
let fit = glm_fit(
counts,
n_genes,
n_samples,
design,
n_coef,
&dispersion,
offset,
weights,
params.prior_count,
)?;
let (fit, s2, df, extras) = if params.legacy {
let zeros = residual_df(counts, &fit.fitted, n_genes, n_samples, design, n_coef)?;
let s2: Vec<f64> = fit
.deviance
.iter()
.zip(zeros.iter())
.map(|(dev, d)| if *d > 0.0 { dev / d.max(MIN_DF) } else { 0.0 })
.collect();
let df = zeros.clone();
(fit, s2, df, Extras::Legacy(zeros))
} else {
let average = update_prior(
counts,
n_genes,
n_samples,
&fit.fitted,
design,
n_coef,
&dispersion,
weights,
ave_log_cpm,
)?;
let scaled = dispersion.map(|v| v / average[0]);
let refit = glm_fit(
counts,
n_genes,
n_samples,
design,
n_coef,
&scaled,
offset,
weights,
params.prior_count,
)?;
let adjusted = compute_adjust_vec(
counts,
n_genes,
n_samples,
&refit.fitted,
design,
n_coef,
&dispersion,
&average,
weights,
)?;
let s2 = adjusted.s2;
let df = adjusted.df.clone();
(
refit,
s2,
df,
Extras::Current {
deviance_adj: adjusted.deviance,
df_residual_adj: adjusted.df,
average: average[0],
},
)
};
let s2: Vec<f64> = s2.iter().map(|v| v.max(0.0)).collect();
let covariate = params.abundance_trend.then_some(ave_log_cpm);
let squeezed = squeeze_var(
&s2,
&df,
covariate,
Some(SqueezeVarParams {
robust: params.robust,
winsor_tail_p: params.winsor_tail_p,
span: None,
legacy: None,
}),
)?;
let (df_residual_adj, deviance_adj, df_residual_zeros, average_ql_dispersion) = match extras {
Extras::Legacy(zeros) => (None, None, Some(zeros), None),
Extras::Current {
deviance_adj,
df_residual_adj,
average,
} => (
Some(df_residual_adj),
Some(deviance_adj),
None,
Some(average),
),
};
Ok(QlFit {
coefficients: fit.coefficients,
unshrunk_coefficients: fit.unshrunk_coefficients,
fitted: fit.fitted,
deviance: fit.deviance,
df_residual: fit.df_residual,
method: fit.method,
dispersion,
s2_post: squeezed.var_post,
s2_prior: squeezed.var_prior,
df_prior: squeezed.df_prior,
df_residual_adj,
deviance_adj,
df_residual_zeros,
average_ql_dispersion,
top_proportion,
})
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[allow(clippy::type_complexity)]
fn fixture() -> (Vec<f64>, Vec<f64>, Vec<f64>, Recycled<f64>) {
#[rustfmt::skip]
let counts: Vec<f64> = vec![
8.0, 16.0, 32.0, 64.0, 128.0, 256.0,
4.0, 4.0, 8.0, 8.0, 16.0, 16.0,
64.0, 64.0, 64.0, 128.0, 128.0, 128.0,
12.0, 20.0, 28.0, 40.0, 56.0, 72.0,
100.0, 110.0, 90.0, 220.0, 200.0, 240.0,
6.0, 6.0, 6.0, 12.0, 12.0, 12.0,
33.0, 29.0, 31.0, 60.0, 66.0, 58.0,
5.0, 7.0, 9.0, 11.0, 13.0, 15.0,
];
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];
#[rustfmt::skip]
let ave_log_cpm: Vec<f64> = vec![
16.352208774832395, 13.428_444_360_659_48, 16.540653864245503,
15.247872115017659, 17.265_794_023_014_92, 13.385375638767593,
15.515907201909817, 13.510906520851451,
];
let offset = Recycled::scalar(1024.0_f64.ln());
(counts, design, ave_log_cpm, offset)
}
#[test]
fn test_matches_edger_glm_ql_fit() {
let (counts, design, ave_log_cpm, offset) = fixture();
let fit = glm_ql_fit(
&counts,
8,
6,
&design,
2,
Some(&Recycled::scalar(0.1)),
&offset,
None,
&ave_log_cpm,
None,
)
.unwrap();
assert_relative_eq!(fit.average_ql_dispersion.unwrap(), 1.0, max_relative = 1e-9);
#[rustfmt::skip]
let expected_deviance_adj = [
14.056_702_642_505_744, 2.637_071_899_051_510_3, 0.0,
3.622_743_572_123_198, 0.335_888_879_589_633_1, 0.0,
0.13845513353745997, 0.901_705_566_985_588,
];
for (got, want) in fit
.deviance_adj
.as_ref()
.unwrap()
.iter()
.zip(expected_deviance_adj.iter())
{
assert_relative_eq!(got, want, epsilon = 1e-12, max_relative = 1e-9);
}
#[rustfmt::skip]
let expected_df_adj = [
3.9930661446445948, 3.9165793318728928, 4.000_432_236_818_225,
3.993_449_436_198_199, 4.000_786_650_272_691, 3.9135554751192987,
3.997_871_470_245_632, 3.9254667939092465,
];
for (got, want) in fit
.df_residual_adj
.as_ref()
.unwrap()
.iter()
.zip(expected_df_adj.iter())
{
assert_relative_eq!(got, want, max_relative = 1e-9);
}
#[rustfmt::skip]
let expected_s2_post = [
2.345_460_198_842_870_3, 0.445_691_045_010_048_86, 0.00006973623748113801,
0.648_121_251_900_738_4, 0.055_977_102_693_401_455, 0.00001201585381793704,
0.038_430_468_762_857_07, 0.152_181_733_621_699_12,
];
for (got, want) in fit.s2_post.iter().zip(expected_s2_post.iter()) {
assert_relative_eq!(got, want, epsilon = 1e-12, max_relative = 1e-9);
}
#[rustfmt::skip]
let expected_s2_prior = [
3.900_756_118_528_421e-4, 4.082_620_246_643_251e-5, 2.0919450483691712e-4,
1.3097776273064543e-1, 2.063_339_839_045_184e-5, 3.552_327_374_654_404e-5,
4.6021345637373576e-2, 5.306_012_383_376_751e-5,
];
for (got, want) in fit.s2_prior.iter().zip(expected_s2_prior.iter()) {
assert_relative_eq!(got, want, epsilon = 1e-12, max_relative = 1e-9);
}
assert_relative_eq!(fit.df_prior[0], 2.000_419_894_664_604, max_relative = 1e-9);
}
#[test]
fn test_coefficients_match_edger() {
let (counts, design, ave_log_cpm, offset) = fixture();
let fit = glm_ql_fit(
&counts,
8,
6,
&design,
2,
Some(&Recycled::scalar(0.1)),
&offset,
None,
&ave_log_cpm,
None,
)
.unwrap();
#[rustfmt::skip]
let expected = [
-3.998_302_407_272_247_3, 2.073_604_138_029_604,
-5.234_572_423_573_774, 0.902_455_000_021_505_2,
-2.770_881_612_936_046, 0.692_172_046_401_738_7,
-3.929_753_093_122_353, 1.025_618_522_757_791_4,
-2.325_296_511_038_456_7, 0.787_776_161_427_847_1,
-5.119_337_159_996_189, 0.682_890_680_392_755_9,
-3.493_704_561_642_108, 0.680_348_083_641_102_9,
-4.968_106_190_272_266, 0.610_909_082_322_973_7,
];
for (got, want) in fit.coefficients.iter().zip(expected.iter()) {
assert_relative_eq!(got, want, epsilon = 1e-7, max_relative = 1e-6);
}
}
#[test]
fn test_legacy_pipeline_takes_the_other_route() {
let (counts, design, ave_log_cpm, offset) = fixture();
let params = QlFitParams {
legacy: true,
..Default::default()
};
let fit = glm_ql_fit(
&counts,
8,
6,
&design,
2,
Some(&Recycled::scalar(0.1)),
&offset,
None,
&ave_log_cpm,
Some(params),
)
.unwrap();
assert!(fit.df_residual_zeros.is_some());
assert!(fit.deviance_adj.is_none());
assert!(fit.df_residual_adj.is_none());
assert!(fit.average_ql_dispersion.is_none());
for d in fit.df_residual_zeros.as_ref().unwrap() {
assert_relative_eq!(d, &4.0, max_relative = 1e-12);
}
}
#[test]
fn test_dispersion_is_clamped_on_the_current_pipeline() {
let (counts, design, ave_log_cpm, offset) = fixture();
let fit = glm_ql_fit(
&counts,
8,
6,
&design,
2,
Some(&Recycled::scalar(9.0)),
&offset,
None,
&ave_log_cpm,
None,
)
.unwrap();
assert_eq!(fit.dispersion, Recycled::Scalar(MAX_DISPERSION));
}
#[test]
fn test_legacy_pipeline_does_not_clamp() {
let (counts, design, ave_log_cpm, offset) = fixture();
let params = QlFitParams {
legacy: true,
..Default::default()
};
let fit = glm_ql_fit(
&counts,
8,
6,
&design,
2,
Some(&Recycled::scalar(9.0)),
&offset,
None,
&ave_log_cpm,
Some(params),
)
.unwrap();
assert_eq!(fit.dispersion, Recycled::Scalar(9.0));
}
#[test]
fn test_estimates_a_dispersion_when_none_is_given() {
let (counts, design, ave_log_cpm, offset) = fixture();
let fit = glm_ql_fit(
&counts,
8,
6,
&design,
2,
None,
&offset,
None,
&ave_log_cpm,
None,
)
.unwrap();
let proportion = fit.top_proportion.unwrap();
assert!(
(0.0..=1.0).contains(&proportion),
"proportion {proportion} outside [0, 1]"
);
match fit.dispersion {
Recycled::Scalar(d) => {
assert!(d.is_finite() && d >= 0.0, "estimated dispersion {d}");
}
_ => panic!("an estimated dispersion should be a scalar"),
}
}
#[test]
fn test_legacy_pipeline_refuses_to_estimate_a_dispersion() {
let (counts, design, ave_log_cpm, offset) = fixture();
let params = QlFitParams {
legacy: true,
..Default::default()
};
let err = glm_ql_fit(
&counts,
8,
6,
&design,
2,
None,
&offset,
None,
&ave_log_cpm,
Some(params),
)
.unwrap_err();
assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
}
#[test]
fn test_rejects_a_bad_top_proportion() {
let (counts, design, ave_log_cpm, offset) = fixture();
let params = QlFitParams {
top_proportion: Some(1.5),
..Default::default()
};
let err = glm_ql_fit(
&counts,
8,
6,
&design,
2,
None,
&offset,
None,
&ave_log_cpm,
Some(params),
)
.unwrap_err();
assert!(matches!(err, EdgeErrors::InvalidArgument(_)));
}
#[test]
fn test_rejects_a_covariate_of_the_wrong_length() {
let (counts, design, _, offset) = fixture();
let err = glm_ql_fit(
&counts,
8,
6,
&design,
2,
Some(&Recycled::scalar(0.1)),
&offset,
None,
&[0.0; 3],
None,
)
.unwrap_err();
assert!(matches!(err, EdgeErrors::LengthMismatch { .. }));
}
#[test]
fn test_untrended_prior_is_shared() {
let (counts, design, ave_log_cpm, offset) = fixture();
let params = QlFitParams {
abundance_trend: false,
..Default::default()
};
let fit = glm_ql_fit(
&counts,
8,
6,
&design,
2,
Some(&Recycled::scalar(0.1)),
&offset,
None,
&ave_log_cpm,
Some(params),
)
.unwrap();
assert_eq!(fit.s2_prior.len(), 1);
}
}