use rustc_hash::FxHashSet;
use crate::{
core::math::stats::{hypergeom_pval, p_adjust_fdr},
prelude::BixverseFloat,
};
#[derive(Clone, Debug)]
pub struct HypergeomResult<T> {
pub pval: Vec<T>,
pub fdr: Vec<T>,
pub odds_ratio: Vec<T>,
pub hits: Vec<usize>,
pub gs_length: Vec<usize>,
}
#[inline]
pub fn hypergeom_odds_ratio<T>(a1_b1: usize, a0_b1: usize, a1_b0: usize, a0_b0: usize) -> T
where
T: BixverseFloat,
{
T::from_f64((a1_b1 as f64 / a0_b1 as f64) / (a1_b0 as f64 / a0_b0 as f64)).unwrap()
}
#[inline]
pub fn count_hits(
gene_set_list: &[FxHashSet<String>],
target_genes: &FxHashSet<String>,
) -> Vec<usize> {
let hits: Vec<usize> = gene_set_list
.iter()
.map(|targets| targets.intersection(target_genes).count())
.collect();
hits
}
pub fn hypergeom_helper<T>(
target_genes: &FxHashSet<String>,
gene_sets: &[FxHashSet<String>],
gene_universe: &[String],
) -> HypergeomResult<T>
where
T: BixverseFloat,
{
let gene_universe_length = gene_universe.len();
let trials = target_genes.len();
let gene_set_lengths = gene_sets.iter().map(|s| s.len()).collect::<Vec<usize>>();
let hits = count_hits(gene_sets, target_genes);
let pvals: Vec<T> = hits
.iter()
.zip(gene_set_lengths.iter())
.map(|(hit, gene_set_length)| {
if *hit > 0 {
hypergeom_pval(
*hit - 1,
*gene_set_length,
gene_universe_length - *gene_set_length,
trials,
)
} else {
T::one()
}
})
.collect();
let odds_ratios: Vec<T> = hits
.iter()
.zip(gene_set_lengths.iter())
.map(|(hit, gene_set_length)| {
hypergeom_odds_ratio(
*hit,
*gene_set_length - *hit,
trials - *hit,
(gene_universe_length + *hit) - *gene_set_length - trials,
)
})
.collect();
let fdr = p_adjust_fdr(&pvals);
HypergeomResult {
pval: pvals,
fdr,
odds_ratio: odds_ratios,
hits,
gs_length: gene_set_lengths,
}
}
pub fn filter_gse_results<T>(
res: HypergeomResult<T>,
min_overlap: Option<usize>,
fdr_threshold: Option<T>,
) -> (HypergeomResult<T>, Vec<usize>)
where
T: BixverseFloat,
{
let to_keep: Vec<usize> = (0..res.pval.len())
.filter(|i| {
if let Some(min_overlap) = min_overlap
&& res.hits[*i] < min_overlap
{
return false;
}
if let Some(fdr_threshold) = fdr_threshold
&& res.fdr[*i] > fdr_threshold
{
return false;
}
true
})
.collect();
(
HypergeomResult {
pval: to_keep.iter().map(|i| res.pval[*i]).collect(),
fdr: to_keep.iter().map(|i| res.fdr[*i]).collect(),
odds_ratio: to_keep.iter().map(|i| res.odds_ratio[*i]).collect(),
hits: to_keep.iter().map(|i| res.hits[*i]).collect(),
gs_length: to_keep.iter().map(|i| res.gs_length[*i]).collect(),
},
to_keep.iter().map(|x| *x + 1).collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn universe(n: usize) -> Vec<String> {
(0..n).map(|i| format!("g{i}")).collect()
}
fn set_of(indices: &[usize]) -> FxHashSet<String> {
indices.iter().map(|i| format!("g{i}")).collect()
}
#[test]
fn odds_ratio_is_the_2x2_cross_product() {
assert_relative_eq!(
hypergeom_odds_ratio::<f64>(5, 5, 10, 80),
8.0,
epsilon = 1e-12
);
}
#[test]
fn odds_ratio_degenerate_cells_are_inf_zero_and_nan() {
assert!(hypergeom_odds_ratio::<f64>(5, 0, 0, 95).is_infinite());
assert_relative_eq!(hypergeom_odds_ratio::<f64>(0, 5, 5, 90), 0.0);
assert!(hypergeom_odds_ratio::<f64>(0, 0, 5, 90).is_nan());
}
#[test]
fn count_hits_counts_the_intersection() {
let sets = [set_of(&[0, 1, 2]), set_of(&[5, 6]), FxHashSet::default()];
assert_eq!(count_hits(&sets, &set_of(&[0, 2, 5])), vec![2, 1, 0]);
}
#[test]
fn single_hit_gene_sets_get_a_real_pvalue() {
let res: HypergeomResult<f64> = hypergeom_helper(
&set_of(&(0..15).collect::<Vec<_>>()),
&[set_of(&[0])],
&universe(100),
);
assert_eq!(res.hits, vec![1]);
assert_relative_eq!(res.pval[0], 0.15, epsilon = 1e-9);
}
#[test]
fn hypergeom_helper_ranks_enrichment_and_pins_the_odds_ratio() {
let target: FxHashSet<String> = set_of(&(0..5).chain(50..60).collect::<Vec<_>>());
let enriched = set_of(&(0..10).collect::<Vec<_>>());
let background = set_of(&(80..90).collect::<Vec<_>>());
let res: HypergeomResult<f64> =
hypergeom_helper(&target, &[enriched, background], &universe(100));
assert_eq!(res.hits, vec![5, 0]);
assert_relative_eq!(res.odds_ratio[0], 8.0, epsilon = 1e-12);
assert!(
res.pval[0] < res.pval[1],
"enriched {} should beat background {}",
res.pval[0],
res.pval[1]
);
assert!((0.0..=1.0).contains(&res.pval[0]));
}
#[test]
fn hypergeom_helper_survives_a_target_larger_than_the_complement() {
let res: HypergeomResult<f64> = hypergeom_helper(
&set_of(&(0..8).chain(15..17).collect::<Vec<_>>()),
&[set_of(&(0..15).collect::<Vec<_>>())],
&universe(20),
);
assert_eq!(res.hits, vec![8]);
assert_relative_eq!(res.odds_ratio[0], 12.0 / 7.0, epsilon = 1e-12);
}
#[test]
fn hypergeom_helper_handles_no_gene_sets() {
let res: HypergeomResult<f64> = hypergeom_helper(&set_of(&[0, 1]), &[], &universe(10));
assert!(res.pval.is_empty() && res.fdr.is_empty() && res.hits.is_empty());
}
#[test]
fn filter_gse_results_returns_one_based_indices() {
let res = HypergeomResult::<f64> {
pval: vec![0.001, 0.2, 0.03],
fdr: vec![0.003, 0.2, 0.045],
odds_ratio: vec![8.0, 1.1, 3.0],
hits: vec![5, 1, 4],
gs_length: vec![10, 3, 8],
};
let (kept, idx) = filter_gse_results(res.clone(), Some(4), Some(0.05));
assert_eq!(idx, vec![1, 3]);
assert_eq!(kept.hits, vec![5, 4]);
let (all, idx_all) = filter_gse_results(res, None, None);
assert_eq!(idx_all, vec![1, 2, 3]);
assert_eq!(all.hits, vec![5, 1, 4]);
}
}