use crate::errors::EdgeErrors;
use crate::numeric::stats::p_adjust_bh;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SortBy {
PValue,
LogFc,
None,
}
#[derive(Clone, Debug)]
pub struct TopTags {
pub index: Vec<usize>,
pub log_fc: Vec<f64>,
pub log_cpm: Vec<f64>,
pub statistic: Vec<f64>,
pub p_value: Vec<f64>,
pub fdr: Vec<f64>,
}
fn check_length(name: &'static str, values: &[f64], n_genes: usize) -> Result<(), EdgeErrors> {
if values.len() != n_genes {
return Err(EdgeErrors::LengthMismatch {
name,
expected: n_genes,
got: values.len(),
});
}
Ok(())
}
fn check_cutoff(name: &str, value: f64) -> Result<(), EdgeErrors> {
if !(value.is_finite() && (0.0..=1.0).contains(&value)) {
return Err(EdgeErrors::InvalidArgument(format!(
"'{name}' must be a probability in [0, 1]; got {value}."
)));
}
Ok(())
}
pub fn top_tags(
log_fc: &[f64],
log_cpm: &[f64],
statistic: Option<&[f64]>,
p_value: &[f64],
n: usize,
sort_by: SortBy,
p_cutoff: f64,
) -> Result<TopTags, EdgeErrors> {
let n_genes = log_fc.len();
if n_genes == 0 {
return Err(EdgeErrors::EmptyCounts {
n_genes: 0,
n_samples: 0,
});
}
if n == 0 {
return Err(EdgeErrors::MustBePositive("n".to_string()));
}
check_length("log_cpm", log_cpm, n_genes)?;
check_length("p_value", p_value, n_genes)?;
if let Some(s) = statistic {
check_length("statistic", s, n_genes)?;
}
check_cutoff("p_cutoff", p_cutoff)?;
let fdr = p_adjust_bh(p_value);
let mut order: Vec<usize> = (0..n_genes).collect();
match sort_by {
SortBy::PValue => order.sort_by(|&a, &b| {
p_value[a]
.total_cmp(&p_value[b])
.then_with(|| log_fc[b].abs().total_cmp(&log_fc[a].abs()))
}),
SortBy::LogFc => order.sort_by(|&a, &b| log_fc[b].abs().total_cmp(&log_fc[a].abs())),
SortBy::None => {}
}
if p_cutoff < 1.0 {
order.retain(|&g| fdr[g] <= p_cutoff);
}
order.truncate(n);
Ok(TopTags {
log_fc: order.iter().map(|&g| log_fc[g]).collect(),
log_cpm: order.iter().map(|&g| log_cpm[g]).collect(),
statistic: match statistic {
Some(s) => order.iter().map(|&g| s[g]).collect(),
None => Vec::new(),
},
p_value: order.iter().map(|&g| p_value[g]).collect(),
fdr: order.iter().map(|&g| fdr[g]).collect(),
index: order,
})
}
pub fn decide_tests(
p_value: &[f64],
log_fc: &[f64],
p_cutoff: f64,
lfc: f64,
) -> Result<Vec<i8>, EdgeErrors> {
check_length("log_fc", log_fc, p_value.len())?;
check_cutoff("p_cutoff", p_cutoff)?;
if !lfc.is_finite() || lfc < 0.0 {
return Err(EdgeErrors::InvalidArgument(format!(
"'lfc' must be non-negative and finite; got {lfc}."
)));
}
let fdr = p_adjust_bh(p_value);
Ok(fdr
.iter()
.zip(log_fc.iter())
.map(|(q, fc)| {
if *q >= p_cutoff || fc.abs() < lfc {
0
} else if *fc < 0.0 {
-1
} else {
1
}
})
.collect())
}
#[cfg(test)]
mod tests {
#![allow(clippy::excessive_precision)]
use super::*;
use approx::assert_relative_eq;
const P_VALUE: [f64; 6] = [
3.4349237412116053e-06,
7.6299000470704081e-01,
4.6743523769487572e-01,
1.4972774802797630e-05,
1.0337909917591625e-02,
8.9951524706410912e-01,
];
const LOG_FC: [f64; 6] = [
1.76837964949810367,
-0.13190914581416660,
-0.89031602850510549,
5.98706174338887909,
0.86708062515865070,
-0.12249104113230144,
];
const LOG_CPM: [f64; 6] = [
4.6828570480490503,
5.6094058355452656,
1.8369026364109835,
2.4631896748603697,
10.7199768972231873,
2.8848509178259678,
];
const FDR: [f64; 6] = [
2.0609542447269630e-05,
8.9951524706410912e-01,
7.0115285654231352e-01,
4.4918324408392889e-05,
2.0675819835183250e-02,
8.9951524706410912e-01,
];
#[test]
fn test_top_tags_sorts_by_p_value() {
let got = top_tags(
&LOG_FC,
&LOG_CPM,
None,
&P_VALUE,
usize::MAX,
SortBy::PValue,
1.0,
)
.unwrap();
assert_eq!(got.index, vec![0, 3, 4, 2, 1, 5]);
let want_fdr = [
2.0609542447269630e-05,
4.4918324408392889e-05,
2.0675819835183250e-02,
7.0115285654231352e-01,
8.9951524706410912e-01,
8.9951524706410912e-01,
];
for (g, w) in got.fdr.iter().zip(want_fdr.iter()) {
assert_relative_eq!(g, w, max_relative = 1e-12);
}
let want_fc = [
1.76837964949810367,
5.98706174338887909,
0.86708062515865070,
-0.89031602850510549,
-0.13190914581416660,
-0.12249104113230144,
];
for (g, w) in got.log_fc.iter().zip(want_fc.iter()) {
assert_relative_eq!(g, w, max_relative = 1e-12);
}
assert!(got.statistic.is_empty());
}
#[test]
fn test_top_tags_sorts_by_absolute_fold_change() {
let got = top_tags(
&LOG_FC,
&LOG_CPM,
None,
&P_VALUE,
usize::MAX,
SortBy::LogFc,
1.0,
)
.unwrap();
assert_eq!(got.index, vec![3, 0, 2, 4, 1, 5]);
let want_fdr = [
4.4918324408392889e-05,
2.0609542447269630e-05,
7.0115285654231352e-01,
2.0675819835183250e-02,
8.9951524706410912e-01,
8.9951524706410912e-01,
];
for (g, w) in got.fdr.iter().zip(want_fdr.iter()) {
assert_relative_eq!(g, w, max_relative = 1e-12);
}
}
#[test]
fn test_top_tags_leaves_the_order_alone_when_asked() {
let got = top_tags(
&LOG_FC,
&LOG_CPM,
None,
&P_VALUE,
usize::MAX,
SortBy::None,
1.0,
)
.unwrap();
assert_eq!(got.index, vec![0, 1, 2, 3, 4, 5]);
for (g, w) in got.fdr.iter().zip(FDR.iter()) {
assert_relative_eq!(g, w, max_relative = 1e-12);
}
}
#[test]
fn test_top_tags_filters_on_the_adjusted_p_value() {
let got = top_tags(
&LOG_FC,
&LOG_CPM,
None,
&P_VALUE,
usize::MAX,
SortBy::PValue,
0.05,
)
.unwrap();
assert_eq!(got.index, vec![0, 3, 4]);
assert_eq!(got.p_value.len(), 3);
assert!(got.fdr.iter().all(|q| *q <= 0.05));
}
#[test]
fn test_top_tags_truncates_to_n() {
let got = top_tags(&LOG_FC, &LOG_CPM, None, &P_VALUE, 3, SortBy::PValue, 1.0).unwrap();
assert_eq!(got.index, vec![0, 3, 4]);
}
#[test]
fn test_top_tags_can_return_nothing() {
let got = top_tags(
&LOG_FC,
&LOG_CPM,
None,
&P_VALUE,
usize::MAX,
SortBy::PValue,
1e-9,
)
.unwrap();
assert!(got.index.is_empty());
assert!(got.log_fc.is_empty());
assert!(got.fdr.is_empty());
}
#[test]
fn test_top_tags_carries_a_statistic_column() {
let stat = [10.0, 1.0, 2.0, 9.0, 5.0, 0.5];
let got = top_tags(
&LOG_FC,
&LOG_CPM,
Some(&stat),
&P_VALUE,
usize::MAX,
SortBy::PValue,
1.0,
)
.unwrap();
assert_eq!(got.statistic, vec![10.0, 9.0, 5.0, 2.0, 1.0, 0.5]);
}
#[test]
fn test_top_tags_breaks_ties_the_way_r_does() {
let p = [0.1, 0.1, 0.1, 0.05];
let fc = [1.0, -3.0, 1.0, 0.5];
let got = top_tags(&fc, &fc, None, &p, usize::MAX, SortBy::PValue, 1.0).unwrap();
assert_eq!(got.index, vec![3, 1, 0, 2]);
let got = top_tags(&fc, &fc, None, &p, usize::MAX, SortBy::LogFc, 1.0).unwrap();
assert_eq!(got.index, vec![1, 0, 2, 3]);
}
#[test]
fn test_top_tags_rejects_bad_input() {
assert!(matches!(
top_tags(&[], &[], None, &[], 5, SortBy::PValue, 1.0),
Err(EdgeErrors::EmptyCounts { .. })
));
assert!(matches!(
top_tags(&LOG_FC, &LOG_CPM, None, &P_VALUE, 0, SortBy::PValue, 1.0),
Err(EdgeErrors::MustBePositive(_))
));
assert!(matches!(
top_tags(
&LOG_FC,
&LOG_CPM[..3],
None,
&P_VALUE,
5,
SortBy::PValue,
1.0
),
Err(EdgeErrors::LengthMismatch {
name: "log_cpm",
..
})
));
assert!(matches!(
top_tags(
&LOG_FC,
&LOG_CPM,
None,
&P_VALUE[..3],
5,
SortBy::PValue,
1.0
),
Err(EdgeErrors::LengthMismatch {
name: "p_value",
..
})
));
assert!(matches!(
top_tags(
&LOG_FC,
&LOG_CPM,
Some(&[1.0, 2.0]),
&P_VALUE,
5,
SortBy::PValue,
1.0
),
Err(EdgeErrors::LengthMismatch {
name: "statistic",
..
})
));
assert!(matches!(
top_tags(&LOG_FC, &LOG_CPM, None, &P_VALUE, 5, SortBy::PValue, 1.5),
Err(EdgeErrors::InvalidArgument(_))
));
assert!(matches!(
top_tags(
&LOG_FC,
&LOG_CPM,
None,
&P_VALUE,
5,
SortBy::PValue,
f64::NAN
),
Err(EdgeErrors::InvalidArgument(_))
));
}
#[test]
fn test_decide_tests_matches_edger_without_a_fold_change_threshold() {
let got = decide_tests(&P_VALUE, &LOG_FC, 0.05, 0.0).unwrap();
assert_eq!(got, vec![1, 0, 0, 1, 1, 0]);
}
#[test]
fn test_decide_tests_applies_the_fold_change_threshold() {
let got = decide_tests(&P_VALUE, &LOG_FC, 0.05, 1.0).unwrap();
assert_eq!(got, vec![1, 0, 0, 1, 0, 0]);
}
#[test]
fn test_decide_tests_at_a_higher_fold_change_threshold() {
let got = decide_tests(&P_VALUE, &LOG_FC, 0.05, 2.0).unwrap();
assert_eq!(got, vec![0, 0, 0, 1, 0, 0]);
}
#[test]
fn test_decide_tests_calls_the_down_direction() {
let got = decide_tests(&P_VALUE, &LOG_FC, 1.0, 0.0).unwrap();
assert_eq!(got, vec![1, -1, -1, 1, 1, -1]);
}
#[test]
fn test_decide_tests_is_strict_at_the_cutoff() {
assert_eq!(decide_tests(&[0.05], &[3.0], 0.05, 0.0).unwrap(), vec![0]);
assert_eq!(decide_tests(&[0.05], &[3.0], 0.06, 0.0).unwrap(), vec![1]);
}
#[test]
fn test_decide_tests_at_the_extremes_of_the_cutoff() {
assert_eq!(
decide_tests(&P_VALUE, &LOG_FC, 0.0, 0.0).unwrap(),
vec![0, 0, 0, 0, 0, 0]
);
}
#[test]
fn test_decide_tests_rejects_bad_input() {
assert!(matches!(
decide_tests(&P_VALUE, &LOG_FC[..3], 0.05, 0.0),
Err(EdgeErrors::LengthMismatch { name: "log_fc", .. })
));
assert!(matches!(
decide_tests(&P_VALUE, &LOG_FC, -0.1, 0.0),
Err(EdgeErrors::InvalidArgument(_))
));
assert!(matches!(
decide_tests(&P_VALUE, &LOG_FC, 1.1, 0.0),
Err(EdgeErrors::InvalidArgument(_))
));
assert!(matches!(
decide_tests(&P_VALUE, &LOG_FC, f64::NAN, 0.0),
Err(EdgeErrors::InvalidArgument(_))
));
assert!(matches!(
decide_tests(&P_VALUE, &LOG_FC, 0.05, -1.0),
Err(EdgeErrors::InvalidArgument(_))
));
assert!(matches!(
decide_tests(&P_VALUE, &LOG_FC, 0.05, f64::INFINITY),
Err(EdgeErrors::InvalidArgument(_))
));
}
#[test]
fn test_decide_tests_on_an_empty_table() {
assert!(decide_tests(&[], &[], 0.05, 0.0).unwrap().is_empty());
}
}