use faer::{ColRef, Mat, MatRef, RowRef};
use once_cell::sync::Lazy;
use rayon::prelude::*;
use rustc_hash::FxHashMap;
use statrs::distribution::{DiscreteCDF, Poisson};
use std::cell::RefCell;
use std::time::Instant;
use crate::prelude::BixverseFloat;
use bixverse_core::core::math::{
matrix_helpers::rank_matrix_col, vector_helpers::standard_deviation,
};
#[derive(Debug, Clone, Copy, Default)]
pub enum GsvaKernel {
#[default]
Gaussian,
Poisson,
None,
}
pub fn parse_gsva_kernel(s: &str) -> Option<GsvaKernel> {
match s.to_lowercase().as_str() {
"gaussian" => Some(GsvaKernel::Gaussian),
"poisson" => Some(GsvaKernel::Poisson),
"none" => Some(GsvaKernel::None),
_ => None,
}
}
const SIGMA_FACTOR: f64 = 4.0;
const POISSON_BANDWIDTH: f64 = 0.5;
thread_local! {
static POISSON_CACHE: RefCell<FxHashMap<u32, Poisson>> = RefCell::new(FxHashMap::default());
}
static POISSON_LOOKUP: Lazy<Vec<Vec<f64>>> = Lazy::new(|| {
let mut table = Vec::new();
for lambda_int in 1..=100 {
let lambda = lambda_int as f64 / 10.0;
let poisson = Poisson::new(lambda).unwrap();
let mut row = Vec::new();
for k in 0..=50 {
row.push(poisson.cdf(k));
}
table.push(row);
}
table
});
static NORMAL_CDF_TABLE: Lazy<Vec<f64>> = Lazy::new(|| {
(-8000..=8000)
.map(|i| {
let z = i as f64 / 1000.0; let t = 1.0 / (1.0 + 0.2316419 * z.abs());
let poly = t
* (0.319381530
+ t * (-0.356563782
+ t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))));
let cdf = 1.0 - 0.39894228 * (-0.5 * z * z).exp() * poly;
if z >= 0.0 { cdf } else { 1.0 - cdf }
})
.collect()
});
#[inline(always)]
fn fast_normal_cdf(table: &[f64], x: f64, mean: f64, std_dev: f64) -> f64 {
let z = (x - mean) / std_dev;
if z <= -8.0 {
return 0.0;
}
if z >= 8.0 {
return 1.0;
}
let index = ((z + 8.0) * 1000.0) as usize;
if index < table.len() - 1 {
let frac = ((z + 8.0) * 1000.0) - index as f64;
table[index] * (1.0 - frac) + table[index + 1] * frac
} else {
table[table.len() - 1]
}
}
#[inline(always)]
fn fast_poisson_cdf(lambda: f64, k: u64) -> f64 {
if (0.1..=10.0).contains(&lambda) && k <= 50 {
let lambda_idx = ((lambda * 10.0) as usize).saturating_sub(1);
if lambda_idx < POISSON_LOOKUP.len() {
return POISSON_LOOKUP[lambda_idx][k as usize];
}
}
POISSON_CACHE.with(|cache| {
let lambda_key = (lambda * 10000.0) as u32; let mut cache_map = cache.borrow_mut();
let poisson = cache_map
.entry(lambda_key)
.or_insert_with(|| Poisson::new(lambda).unwrap());
poisson.cdf(k)
})
}
fn row_kernel_density<T>(
density_row: RowRef<T>,
test_row: RowRef<T>,
use_gaussian: bool,
bandwidth: f64,
) -> Vec<T>
where
T: BixverseFloat,
{
let n_test = test_row.ncols();
let n_density = density_row.ncols();
let mut results = Vec::with_capacity(n_test);
let density_len_inv = 1.0_f64 / n_density as f64;
let density_f64: Vec<f64> = density_row.iter().map(|&x| x.to_f64().unwrap()).collect();
if use_gaussian {
let table: &[f64] = &NORMAL_CDF_TABLE;
for test_val in test_row.iter() {
let test_val_f64 = test_val.to_f64().unwrap();
let mut acc = [0.0_f64; 4];
let chunks = density_f64.chunks_exact(4);
let remainder = chunks.remainder();
for chunk in chunks {
for (a, &d) in acc.iter_mut().zip(chunk) {
*a += fast_normal_cdf(table, test_val_f64, d, bandwidth);
}
}
for &d in remainder {
acc[0] += fast_normal_cdf(table, test_val_f64, d, bandwidth);
}
let left_tail = ((acc[0] + acc[1]) + (acc[2] + acc[3])) * density_len_inv;
let ratio = (1.0 - left_tail) / left_tail.max(1e-15);
results.push(T::from_f64(-ratio.ln()).unwrap());
}
} else {
let mut sorted = density_f64.clone();
sorted.sort_unstable_by(|a, b| a.total_cmp(b));
let mut distinct: Vec<(f64, f64)> = Vec::new();
for d in sorted {
match distinct.last_mut() {
Some((v, count)) if *v == d => *count += 1.0,
_ => distinct.push((d, 1.0)),
}
}
for test_val in test_row.iter() {
let test_val_u64 = test_val.to_f64().unwrap().max(0.0) as u64;
let mut left_tail = 0.0_f64;
for &(d, count) in &distinct {
let lambda = d + POISSON_BANDWIDTH;
if lambda > 0.0 {
left_tail += count * fast_poisson_cdf(lambda, test_val_u64);
}
}
left_tail *= density_len_inv;
let ratio = (1.0 - left_tail) / left_tail.max(1e-15);
results.push(T::from_f64(-ratio.ln()).unwrap());
}
}
results
}
pub fn matrix_kernel_density<T>(
density_matrix: &MatRef<T>,
test_matrix: &MatRef<T>,
use_gaussian: bool,
) -> Mat<T>
where
T: BixverseFloat,
{
let (n_genes, _) = density_matrix.shape();
let (_, n_test_samples) = test_matrix.shape();
let bandwidths: Vec<f64> = if use_gaussian {
(0..n_genes)
.map(|gene_idx| {
let row_data: Vec<f64> = density_matrix
.row(gene_idx)
.iter()
.map(|&x| x.to_f64().unwrap())
.collect();
(standard_deviation(&row_data) / SIGMA_FACTOR).max(0.001)
})
.collect()
} else {
vec![0.0; n_genes]
};
let results: Vec<Vec<T>> = density_matrix
.par_row_iter()
.zip(test_matrix.par_row_iter())
.zip(bandwidths.par_iter())
.map(|((dens_row, test_row), bw)| row_kernel_density(dens_row, test_row, use_gaussian, *bw))
.collect();
let mut result = Mat::zeros(n_genes, n_test_samples);
for (gene_idx, row_data) in results.into_iter().enumerate() {
for (sample_idx, val) in row_data.into_iter().enumerate() {
result[(gene_idx, sample_idx)] = val;
}
}
result
}
pub fn order_rankstat<T>(values: &[T]) -> (Vec<usize>, Vec<T>)
where
T: BixverseFloat,
{
let n = values.len();
let mut indices: Vec<usize> = (0..n).collect();
indices.sort_by(|&a, &b| values[b].partial_cmp(&values[a]).unwrap());
let mut rank_stats = vec![T::zero(); n];
let n_t = T::from_usize(n).unwrap();
let half_n = n_t / T::from_f64(2.0).unwrap();
for (rank, &original_idx) in indices.iter().enumerate() {
let rank_t = T::from_usize(rank).unwrap();
rank_stats[original_idx] = (n_t - rank_t - half_n).abs();
}
(indices, rank_stats)
}
#[inline]
fn gsva_random_walk<T: BixverseFloat>(
gene_set_ranks: &[usize],
decreasing_order_indices: &[usize],
symmetric_rank_stats: &[T],
tau: f64,
n: usize,
) -> (T, T) {
let mut weights = Vec::with_capacity(gene_set_ranks.len());
let mut total_in = T::zero();
for &rank in gene_set_ranks {
let orig_gene_idx = decreasing_order_indices[rank];
let stat_val = symmetric_rank_stats[orig_gene_idx];
let weight = if tau == 1.0 {
stat_val
} else {
T::from_f64(stat_val.to_f64().unwrap().powf(tau)).unwrap()
};
weights.push((rank, weight));
total_in += weight;
}
if total_in <= T::zero() {
return (T::nan(), T::nan());
}
weights.sort_unstable_by_key(|&(rank, _)| rank);
let total_out = T::from_usize(n.saturating_sub(gene_set_ranks.len())).unwrap();
if total_out <= T::zero() {
return (T::nan(), T::nan());
}
let total_in_inv = T::one() / total_in;
let total_out_inv = T::one() / total_out;
let mut max_pos = T::zero();
let mut max_neg = T::zero();
let mut cumulative_in = T::zero();
for (j, &(rank, weight)) in weights.iter().enumerate() {
let cumulative_out = T::from_usize(rank - j).unwrap();
if rank > j {
let walk_stat = cumulative_in * total_in_inv - cumulative_out * total_out_inv;
if walk_stat < max_neg {
max_neg = walk_stat;
}
}
cumulative_in += weight;
let walk_stat = cumulative_in * total_in_inv - cumulative_out * total_out_inv;
if walk_stat > max_pos {
max_pos = walk_stat;
}
if walk_stat < max_neg {
max_neg = walk_stat;
}
}
let tail_out = n - weights.len();
let walk_stat = cumulative_in * total_in_inv - T::from_usize(tail_out).unwrap() * total_out_inv;
if walk_stat < max_neg {
max_neg = walk_stat;
}
(max_pos, max_neg)
}
const RANK_ABSENT: u32 = u32::MAX;
fn inverse_permutation(order: &[usize]) -> Vec<u32> {
let size = order.iter().max().map_or(0, |&m| m + 1);
let mut out = vec![RANK_ABSENT; size];
for (rank, &gene_idx) in order.iter().enumerate() {
out[gene_idx] = rank as u32;
}
out
}
pub fn gsva_score_genesets<T: BixverseFloat>(
gene_sets: &[Vec<usize>],
decreasing_order_indices: &[usize],
symmetric_rank_stats: &[T],
tau: f64,
max_diff: bool,
abs_rank: bool,
) -> Vec<T> {
let n = decreasing_order_indices.len();
let rank_lookup = inverse_permutation(decreasing_order_indices);
gene_sets
.iter()
.map(|gene_set| {
if gene_set.is_empty() {
return T::nan();
}
let mut gene_set_ranks = Vec::with_capacity(gene_set.len());
for &gene_idx in gene_set {
match rank_lookup.get(gene_idx) {
Some(&rank) if rank != RANK_ABSENT => gene_set_ranks.push(rank as usize),
_ => {}
}
}
gene_set_ranks.sort_unstable();
gene_set_ranks.dedup();
if gene_set_ranks.is_empty() {
return T::nan();
}
let (walk_stat_pos, walk_stat_neg) = gsva_random_walk(
&gene_set_ranks,
decreasing_order_indices,
symmetric_rank_stats,
tau,
n,
);
if walk_stat_pos.is_nan() || walk_stat_neg.is_nan() {
T::nan()
} else if max_diff {
if abs_rank {
walk_stat_pos - walk_stat_neg
} else {
walk_stat_pos + walk_stat_neg
}
} else if walk_stat_pos > walk_stat_neg.abs() {
walk_stat_pos
} else {
walk_stat_neg
}
})
.collect()
}
fn calculate_rank_weights<T: BixverseFloat>(ranks: &MatRef<T>, alpha: f64) -> Mat<T> {
let (n_genes, n_samples) = ranks.shape();
let mut weights = Mat::zeros(n_genes, n_samples);
weights
.par_col_iter_mut()
.enumerate()
.for_each(|(col_idx, mut col)| {
let ranks_col = ranks.col(col_idx);
for (row_idx, &rank) in ranks_col.iter().enumerate() {
col[row_idx] = T::from_f64(rank.abs().to_f64().unwrap().powf(alpha)).unwrap();
}
});
weights
}
#[inline]
fn ssgsea_fast_random_walk<T: BixverseFloat>(
gene_set_indices: &[usize],
rank_lookup: &[u32],
gene_ranking: &[usize],
rank_weights: &ColRef<T>,
) -> T {
let n = gene_ranking.len();
let k = gene_set_indices.len();
if k == 0 {
return T::nan();
}
let mut positions: Vec<u32> = gene_set_indices
.iter()
.filter_map(|&g| rank_lookup.get(g).copied())
.filter(|&p| p != RANK_ABSENT)
.collect();
if k < 32 {
positions.sort_unstable();
positions.dedup();
}
let found_genes = positions.len();
if found_genes == 0 {
return T::nan();
}
let mut sum_weighted_ranks = T::zero();
let mut sum_weights = T::zero();
let mut sum_gene_set_ranks = T::zero();
for &rank_pos in &positions {
let rank_pos = rank_pos as usize;
let weight = rank_weights[gene_ranking[rank_pos]];
let rank_contribution = T::from_usize(n - rank_pos).unwrap();
sum_weighted_ranks += weight * rank_contribution;
sum_weights += weight;
sum_gene_set_ranks += rank_contribution;
}
if sum_weights <= T::zero() {
return T::nan();
}
let step_cdf_in_gene_set = sum_weighted_ranks / sum_weights;
let n_t = T::from_usize(n).unwrap();
let sum_all_ranks = n_t * (n_t + T::one()) / T::from_f64(2.0).unwrap();
let step_cdf_out_gene_set =
(sum_all_ranks - sum_gene_set_ranks) / T::from_usize(n - found_genes).unwrap();
step_cdf_in_gene_set - step_cdf_out_gene_set
}
pub fn gsva<T: BixverseFloat>(
expression_matrix: &MatRef<T>,
gene_sets: &[Vec<usize>],
kernel: GsvaKernel,
tau: f64,
max_diff: bool,
abs_rank: bool,
print_timings: bool,
) -> Mat<T> {
let start_total = Instant::now();
let (_, n_samples) = expression_matrix.shape();
let mut result = Mat::zeros(gene_sets.len(), n_samples);
if print_timings {
println!(
"Starting GSVA with {} samples and {} gene sets",
n_samples,
gene_sets.len()
);
}
let start_kcdf = Instant::now();
let kcdf_matrix = match kernel {
GsvaKernel::Gaussian => matrix_kernel_density(expression_matrix, expression_matrix, true),
GsvaKernel::Poisson => matrix_kernel_density(expression_matrix, expression_matrix, false),
GsvaKernel::None => expression_matrix.to_owned(),
};
let kcdf_time = start_kcdf.elapsed();
if print_timings {
match kernel {
GsvaKernel::None => println!("Step 1 - KCDF skipped: {:.2?}", kcdf_time),
_ => println!("Step 1 - Kernel density estimation: {:.2?}", kcdf_time),
}
}
let start_parallel = Instant::now();
result
.par_col_iter_mut()
.enumerate()
.for_each(|(sample_idx, mut col)| {
let sample_start = Instant::now();
let extract_start = Instant::now();
let sample_kcdf: Vec<T> = kcdf_matrix.col(sample_idx).iter().copied().collect();
let extract_time = extract_start.elapsed();
let rank_start = Instant::now();
let (decreasing_order, rank_stats) = order_rankstat(&sample_kcdf);
let rank_time = rank_start.elapsed();
let score_start = Instant::now();
let scores = gsva_score_genesets(
gene_sets,
&decreasing_order,
&rank_stats,
tau,
max_diff,
abs_rank,
);
let score_time = score_start.elapsed();
for (gene_set_idx, &score) in scores.iter().enumerate() {
col[gene_set_idx] = score;
}
let sample_total = sample_start.elapsed();
if print_timings
&& (sample_idx < 5 || sample_idx % 100 == 0 || sample_idx >= n_samples - 5)
{
println!(
"Sample {}: total={:.2?}, extract={:.2?}, rank={:.2?}, score={:.2?}",
sample_idx, sample_total, extract_time, rank_time, score_time
);
}
});
let parallel_time = start_parallel.elapsed();
let total_time = start_total.elapsed();
if print_timings {
println!("Step 2-4 - Parallel processing: {:.2?}", parallel_time);
println!("Total GSVA time: {:.2?}", total_time);
println!(
"Time breakdown: KCDF={:.1?} ({:.1}%), Parallel={:.1?} ({:.1}%)",
kcdf_time,
kcdf_time.as_secs_f64() / total_time.as_secs_f64() * 100.0,
parallel_time,
parallel_time.as_secs_f64() / total_time.as_secs_f64() * 100.0
);
}
result
}
pub fn ssgsea<T: BixverseFloat>(
expression_matrix: &MatRef<T>,
gene_sets: &[Vec<usize>],
alpha: f64,
normalisation: bool,
print_timings: bool,
) -> Mat<T> {
let start_total = Instant::now();
let (n_genes, n_samples) = expression_matrix.shape();
if print_timings {
println!(
"Starting ssGSEA with {} samples and {} gene sets",
n_samples,
gene_sets.len()
);
}
let start_ranks = Instant::now();
let ranks = rank_matrix_col(expression_matrix);
let ranks_time = start_ranks.elapsed();
if print_timings {
println!("Step 1 - Calculating ranks: {:.2?}", ranks_time);
}
let start_weights = Instant::now();
let rank_weights = calculate_rank_weights(&ranks.as_ref(), alpha);
let weights_time = start_weights.elapsed();
if print_timings {
println!("Step 2 - Calculating rank weights: {:.2?}", weights_time);
}
let start_scoring = Instant::now();
let mut result: Mat<T> = Mat::zeros(gene_sets.len(), n_samples);
result
.par_col_iter_mut()
.enumerate()
.for_each(|(sample_idx, mut col)| {
let sample_start = Instant::now();
let ranking_start = Instant::now();
let sample_ranks: Vec<T> = ranks.col(sample_idx).iter().copied().collect();
let mut gene_ranking: Vec<usize> = (0..n_genes).collect();
gene_ranking
.sort_unstable_by(|&a, &b| sample_ranks[b].partial_cmp(&sample_ranks[a]).unwrap());
let ranking_time = ranking_start.elapsed();
let score_start = Instant::now();
let rank_lookup = inverse_permutation(&gene_ranking);
let rank_weights_col = rank_weights.col(sample_idx);
let scores: Vec<T> = gene_sets
.iter()
.map(|gene_set| {
ssgsea_fast_random_walk(
gene_set,
&rank_lookup,
&gene_ranking,
&rank_weights_col,
)
})
.collect();
let score_end = score_start.elapsed();
for (gene_set_idx, &score) in scores.iter().enumerate() {
col[gene_set_idx] = score;
}
let sample_total = sample_start.elapsed();
if print_timings
&& (sample_idx < 5 || sample_idx % 100 == 0 || sample_idx >= n_samples - 5)
{
println!(
"Sample {}: total={:.2?}, ranking={:.2?}, scoring={:.2?}",
sample_idx, sample_total, ranking_time, score_end
);
}
});
let scoring_time = start_scoring.elapsed();
if print_timings {
println!(
"Step 3 - Calculating enrichment scores: {:.2?}",
scoring_time
);
}
if normalisation {
let start_norm = Instant::now();
let mut min_score = T::infinity();
let mut max_score = T::neg_infinity();
let mut has_finite = false;
for col_idx in 0..result.ncols() {
for &score in result.col(col_idx).iter() {
if score.is_finite() {
min_score = min_score.min(score);
max_score = max_score.max(score);
has_finite = true;
}
}
}
if !has_finite {
return result;
}
let range = max_score - min_score;
if range > T::zero() && range.is_finite() {
result.par_col_iter_mut().for_each(|col| {
for score in col.iter_mut() {
if score.is_finite() {
*score /= range;
}
}
});
}
let norm_time = start_norm.elapsed();
if print_timings {
println!("Step 4 - Normalisation: {:.2?}", norm_time);
}
}
let total_time = start_total.elapsed();
if print_timings {
println!("Total ssGSEA time: {:.2?}", total_time);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn order_rankstat_ranks_descending_and_centres_the_statistics() {
let values: Vec<f64> = vec![0.1, 0.5, 0.3, 0.9];
let (order, stats) = order_rankstat(&values);
assert_eq!(order, vec![3, 1, 2, 0]);
assert_eq!(stats, vec![1.0, 1.0, 0.0, 2.0]);
}
#[test]
fn order_rankstat_half_n_stays_fractional_for_odd_backgrounds() {
let values: Vec<f64> = vec![0.5, 0.1, 0.4, 0.2, 0.3];
let (order, stats) = order_rankstat(&values);
assert_eq!(order, vec![0, 2, 4, 3, 1]);
assert_eq!(stats, vec![2.5, 1.5, 1.5, 0.5, 0.5]);
assert!(stats.iter().all(|s| (s - s.floor() - 0.5).abs() < 1e-15));
}
#[test]
fn gsva_score_genesets_covers_all_three_scoring_modes() {
let order = vec![3_usize, 1, 2, 0];
let stats: Vec<f64> = vec![1.0, 1.0, 0.0, 2.0];
let gene_sets = vec![vec![1_usize, 0]];
let max_diff_abs = gsva_score_genesets(&gene_sets, &order, &stats, 1.0, true, true);
let max_diff_sum = gsva_score_genesets(&gene_sets, &order, &stats, 1.0, true, false);
let largest_abs = gsva_score_genesets(&gene_sets, &order, &stats, 1.0, false, false);
assert_relative_eq!(max_diff_abs[0], 0.5, epsilon = 1e-12);
assert_relative_eq!(max_diff_sum[0], -0.5, epsilon = 1e-12);
assert_relative_eq!(largest_abs[0], -0.5, epsilon = 1e-12);
}
#[test]
fn gsva_score_genesets_returns_the_positive_walk_when_it_dominates() {
let order = vec![3_usize, 1, 2, 0];
let stats: Vec<f64> = vec![1.0, 1.0, 0.0, 2.0];
let gene_sets = vec![vec![3_usize]];
let largest_abs = gsva_score_genesets(&gene_sets, &order, &stats, 1.0, false, false);
let max_diff_abs = gsva_score_genesets(&gene_sets, &order, &stats, 1.0, true, true);
assert_relative_eq!(largest_abs[0], 1.0, epsilon = 1e-12);
assert_relative_eq!(max_diff_abs[0], 1.0, epsilon = 1e-12);
}
#[test]
fn gsva_score_genesets_returns_nan_on_every_degenerate_set() {
let order = vec![3_usize, 1, 2, 0];
let stats: Vec<f64> = vec![1.0, 1.0, 0.0, 2.0];
let gene_sets = vec![
vec![], vec![99_usize], vec![2_usize], vec![0, 1, 2, 3], ];
let scores = gsva_score_genesets(&gene_sets, &order, &stats, 1.0, true, true);
assert!(scores.iter().all(|s| s.is_nan()), "got {scores:?}");
}
#[test]
fn gsva_score_genesets_deduplicates_repeated_gene_indices() {
let order = vec![3_usize, 1, 2, 0];
let stats: Vec<f64> = vec![1.0, 1.0, 0.0, 2.0];
let gene_sets = vec![
vec![1_usize, 0], vec![1_usize, 0, 1, 0], vec![1_usize, 0, 1, 0, 1], ];
let scores = gsva_score_genesets(&gene_sets, &order, &stats, 1.0, true, true);
assert_relative_eq!(scores[0], 0.5, epsilon = 1e-12);
assert_relative_eq!(scores[1], 0.5, epsilon = 1e-12);
assert_relative_eq!(scores[2], 0.5, epsilon = 1e-12);
}
#[test]
fn gsva_score_genesets_returns_nan_for_a_duplicated_full_background_set() {
let order = vec![3_usize, 1, 2, 0];
let stats: Vec<f64> = vec![1.0, 1.0, 0.0, 2.0];
let gene_sets = vec![vec![0_usize, 1, 2, 3, 3, 0]];
let scores = gsva_score_genesets(&gene_sets, &order, &stats, 1.0, true, true);
assert!(scores[0].is_nan(), "got {scores:?}");
}
#[test]
fn parse_gsva_kernel_round_trips_the_three_kernel_names() {
assert!(matches!(
parse_gsva_kernel("gaussian"),
Some(GsvaKernel::Gaussian)
));
assert!(matches!(
parse_gsva_kernel("Poisson"),
Some(GsvaKernel::Poisson)
));
assert!(matches!(parse_gsva_kernel("NONE"), Some(GsvaKernel::None)));
assert!(parse_gsva_kernel("normal").is_none());
assert!(parse_gsva_kernel("").is_none());
}
}