use rayon::prelude::*;
use crate::core::base::loess::{LoessRegression, LoessSurface};
use crate::core::math::distributions::{gamma_cdf, gamma_sf, norm_ppf};
use crate::core::math::stats::{fit_gamma_mle, ks_test_1samp, p_adjust_fdr};
use crate::core::math::vector_helpers::interp_log_linear_at;
use crate::enrichment::gsea::{calc_gsea_stats, create_random_gs_indices};
use crate::prelude::*;
const BLITZ_DEFAULT_PERMUTATIONS: usize = 2000;
const BLITZ_DEFAULT_ANCHORS: usize = 40;
const BLITZ_DEFAULT_SEED: u64 = 42;
const BLITZ_ANCHOR_TOP_FRACTION: f64 = 0.5;
const BLITZ_MIN_ANCHORS: usize = 2;
const BLITZ_MIN_PERMUTATIONS: usize = 2;
const BLITZ_MIN_TAIL_COUNT: usize = 250;
const BLITZ_MIN_PERMUTATIONS_SPLIT: usize = 1000;
const BLITZ_SPAN_SHAPE: f64 = 0.25;
const BLITZ_SPAN_SCALE: f64 = 0.15;
const BLITZ_SPAN_RATIO: f64 = 0.5;
const BLITZ_LOESS_ROBUSTNESS_ITERS: usize = 3;
const BLITZ_LOESS_DEGREE: usize = 1;
const BLITZ_MIN_TAIL_PROB: f64 = f64::MIN_POSITIVE;
const BLITZ_MAX_TAIL_PROB: f64 = 0.5;
#[derive(Clone, Copy, Debug)]
pub struct BlitzGseaParams {
pub permutations: usize,
pub anchors: usize,
pub symmetric: bool,
pub centre: bool,
pub ks_test: bool,
pub seed: u64,
}
impl Default for BlitzGseaParams {
fn default() -> Self {
Self::new(None, None, None, None, None, None)
}
}
impl BlitzGseaParams {
pub fn new(
permutations: Option<usize>,
anchors: Option<usize>,
symmetric: Option<bool>,
centre: Option<bool>,
ks_test: Option<bool>,
seed: Option<u64>,
) -> Self {
Self {
permutations: permutations.unwrap_or(BLITZ_DEFAULT_PERMUTATIONS),
anchors: anchors.unwrap_or(BLITZ_DEFAULT_ANCHORS),
symmetric: symmetric.unwrap_or(false),
centre: centre.unwrap_or(true),
ks_test: ks_test.unwrap_or(true),
seed: seed.unwrap_or(BLITZ_DEFAULT_SEED),
}
}
}
#[derive(Clone, Copy, Debug)]
struct AnchorFit {
size: f64,
shape_pos: f64,
scale_pos: f64,
shape_neg: f64,
scale_neg: f64,
pos_ratio: f64,
ks_pos: f64,
ks_neg: f64,
}
#[derive(Clone, Copy, Debug)]
pub struct BlitzGseaTail {
pub shape_pos: f64,
pub scale_pos: f64,
pub shape_neg: f64,
pub scale_neg: f64,
pub pos_ratio: f64,
}
#[derive(Clone, Debug)]
pub struct BlitzGseaNull {
pub anchor_sizes: Vec<f64>,
pub shape_pos: Vec<f64>,
pub scale_pos: Vec<f64>,
pub shape_neg: Vec<f64>,
pub scale_neg: Vec<f64>,
pub pos_ratio: Vec<f64>,
pub ks_pos: f64,
pub ks_neg: f64,
pub centred: bool,
}
impl BlitzGseaNull {
pub fn tail_at(&self, size: f64) -> BlitzGseaTail {
let size = size.max(1.0);
let at = |y: &[f64]| interp_log_linear_at(&self.anchor_sizes, y, size);
BlitzGseaTail {
shape_pos: at(&self.shape_pos),
scale_pos: at(&self.scale_pos),
shape_neg: at(&self.shape_neg),
scale_neg: at(&self.scale_neg),
pos_ratio: at(&self.pos_ratio).clamp(0.0, 1.0),
}
}
pub fn validate(&self) -> Result<(), BixverseErrors> {
let n = self.anchor_sizes.len();
if n == 0 {
return Err(BixverseErrors::InvalidArgument(
"The blitzGSEA null model has an empty anchor grid.".to_string(),
));
}
for (name, values) in [
("shape_pos", &self.shape_pos),
("scale_pos", &self.scale_pos),
("shape_neg", &self.shape_neg),
("scale_neg", &self.scale_neg),
("pos_ratio", &self.pos_ratio),
] {
if values.len() != n {
return Err(BixverseErrors::InvalidArgument(format!(
"The blitzGSEA null model has {} values for '{name}' but {n} anchor sizes.",
values.len()
)));
}
}
Ok(())
}
}
#[derive(Clone, Debug)]
struct ScoredPathway {
es: f64,
nes: f64,
pval: f64,
size: usize,
leading_edge: Vec<i32>,
}
#[derive(Clone, Debug)]
pub struct BlitzGseaResults {
pub es: Vec<f64>,
pub nes: Vec<f64>,
pub pvals: Vec<f64>,
pub sidak: Vec<f64>,
pub fdr: Vec<f64>,
pub size: Vec<usize>,
pub leading_edge: Vec<Vec<i32>>,
}
fn centre_stats<T: BixverseFloat>(stats: &[T], centre: bool) -> Vec<T> {
if !centre || stats.is_empty() {
return stats.to_vec();
}
let n = T::from_usize(stats.len()).unwrap();
let mean = stats.iter().fold(T::zero(), |acc, &x| acc + x) / n;
stats.iter().map(|&x| x - mean).collect()
}
fn anchor_grid(n_genes: usize, anchors: usize) -> Vec<usize> {
if n_genes < 2 {
return Vec::new();
}
let top = ((n_genes as f64 * BLITZ_ANCHOR_TOP_FRACTION) as usize).clamp(1, n_genes - 1);
if anchors <= 1 || top == 1 {
return vec![1];
}
let step = (top as f64).ln() / (anchors - 1) as f64;
let mut sizes: Vec<usize> = (0..anchors)
.map(|i| ((step * i as f64).exp().round() as usize).clamp(1, top))
.collect();
sizes.sort_unstable();
sizes.dedup();
sizes
}
fn null_scores<T: BixverseFloat>(
stats: &[T],
size: usize,
permutations: usize,
seed: u64,
) -> Vec<f64> {
let samples = create_random_gs_indices(permutations, size, stats.len(), seed, false);
samples
.into_par_iter()
.map(|mut indices| {
indices.sort_unstable();
let indices: Vec<i32> = indices.iter().map(|&i| i as i32).collect();
let stats_res = calc_gsea_stats(stats, &indices, T::one(), false, false, false);
stats_res.es.to_f64().unwrap_or(0.0)
})
.collect()
}
fn fit_anchor(
scores: &[f64],
size: usize,
symmetric: bool,
ks_test: bool,
) -> Result<AnchorFit, BixverseErrors> {
let positive: Vec<f64> = scores.iter().copied().filter(|&s| s > 0.0).collect();
let negative: Vec<f64> = scores.iter().filter(|&&s| s < 0.0).map(|&s| -s).collect();
let n_pos = positive.len();
let n_neg = negative.len();
if n_pos + n_neg == 0 {
return Err(BixverseErrors::InvalidArgument(format!(
"Every null enrichment score at anchor size {size} was exactly zero."
)));
}
let pooled = symmetric || n_pos < BLITZ_MIN_TAIL_COUNT || n_neg < BLITZ_MIN_TAIL_COUNT;
let pos_ratio = n_pos as f64 / (n_pos + n_neg) as f64;
let ks_of = |sample: &[f64], shape: f64, scale: f64| -> Result<f64, BixverseErrors> {
if !ks_test {
return Ok(1.0);
}
Ok(ks_test_1samp(sample, |x| gamma_cdf(x, shape, scale).unwrap_or(0.0))?.pval)
};
if pooled {
let mut both = positive;
both.extend_from_slice(&negative);
let (shape, scale) = fit_gamma_mle(&both)?;
let ks = ks_of(&both, shape, scale)?;
return Ok(AnchorFit {
size: size as f64,
shape_pos: shape,
scale_pos: scale,
shape_neg: shape,
scale_neg: scale,
pos_ratio,
ks_pos: ks,
ks_neg: ks,
});
}
let (shape_pos, scale_pos) = fit_gamma_mle(&positive)?;
let (shape_neg, scale_neg) = fit_gamma_mle(&negative)?;
Ok(AnchorFit {
size: size as f64,
shape_pos,
scale_pos,
shape_neg,
scale_neg,
pos_ratio,
ks_pos: ks_of(&positive, shape_pos, scale_pos)?,
ks_neg: ks_of(&negative, shape_neg, scale_neg)?,
})
}
fn smooth_anchors(log_sizes: &[f64], values: &[f64], span: f64) -> Vec<f64> {
LoessRegression::with_options(
span,
BLITZ_LOESS_DEGREE,
LoessSurface::Direct,
BLITZ_LOESS_ROBUSTNESS_ITERS,
)
.fit(log_sizes, values)
.fitted_vals
}
fn sidak(pvals: &[f64]) -> Vec<f64> {
let m = pvals.len() as f64;
pvals
.iter()
.map(|&p| (-((m * (-p).ln_1p()).exp_m1())).clamp(0.0, 1.0))
.collect()
}
pub fn pval_and_nes(es: f64, tail: &BlitzGseaTail) -> Result<(f64, f64), BixverseErrors> {
let one_sided = if es > 0.0 {
tail.pos_ratio * gamma_sf(es, tail.shape_pos, tail.scale_pos)?
} else {
(1.0 - tail.pos_ratio) * gamma_sf(-es, tail.shape_neg, tail.scale_neg)?
};
let one_sided = one_sided.min(BLITZ_MAX_TAIL_PROB);
let quantile = norm_ppf(one_sided.max(BLITZ_MIN_TAIL_PROB))?;
let nes = if es > 0.0 { -quantile } else { quantile };
Ok((2.0 * one_sided, nes))
}
pub fn calibrate_null<T: BixverseFloat>(
stats: &[T],
params: &BlitzGseaParams,
) -> Result<BlitzGseaNull, BixverseErrors> {
if params.anchors < BLITZ_MIN_ANCHORS {
return Err(BixverseErrors::InvalidArgument(format!(
"blitzGSEA needs at least {BLITZ_MIN_ANCHORS} anchors to build a size-dependent \
null; got {}.",
params.anchors
)));
}
if params.permutations < BLITZ_MIN_PERMUTATIONS {
return Err(BixverseErrors::InvalidArgument(format!(
"blitzGSEA needs at least {BLITZ_MIN_PERMUTATIONS} permutations per anchor to fit \
a gamma; got {}.",
params.permutations
)));
}
let sizes = anchor_grid(stats.len(), params.anchors);
if sizes.is_empty() {
return Err(BixverseErrors::InvalidArgument(format!(
"A signature of {} genes is too short to calibrate against; a gene set needs at \
least one hit and one miss.",
stats.len()
)));
}
let centred = centre_stats(stats, params.centre);
let symmetric = params.symmetric || params.permutations < BLITZ_MIN_PERMUTATIONS_SPLIT;
let fits: Vec<AnchorFit> = sizes
.par_iter()
.map(|&size| {
let seed = params.seed.wrapping_add(size as u64);
let scores = null_scores(¢red, size, params.permutations, seed);
fit_anchor(&scores, size, symmetric, params.ks_test)
})
.collect::<Result<Vec<_>, _>>()?;
let anchor_sizes: Vec<f64> = fits.iter().map(|f| f.size).collect();
let log_sizes: Vec<f64> = anchor_sizes.iter().map(|size| size.ln()).collect();
let column =
|extract: fn(&AnchorFit) -> f64| -> Vec<f64> { fits.iter().map(extract).collect() };
let n = fits.len() as f64;
let mean_of =
|extract: fn(&AnchorFit) -> f64| -> f64 { fits.iter().map(extract).sum::<f64>() / n };
Ok(BlitzGseaNull {
shape_pos: smooth_anchors(&log_sizes, &column(|f| f.shape_pos), BLITZ_SPAN_SHAPE),
scale_pos: smooth_anchors(&log_sizes, &column(|f| f.scale_pos), BLITZ_SPAN_SCALE),
shape_neg: smooth_anchors(&log_sizes, &column(|f| f.shape_neg), BLITZ_SPAN_SHAPE),
scale_neg: smooth_anchors(&log_sizes, &column(|f| f.scale_neg), BLITZ_SPAN_SCALE),
pos_ratio: smooth_anchors(&log_sizes, &column(|f| f.pos_ratio), BLITZ_SPAN_RATIO),
ks_pos: mean_of(|f| f.ks_pos),
ks_neg: mean_of(|f| f.ks_neg),
centred: params.centre,
anchor_sizes,
})
}
pub fn blitzgsea_score<T: BixverseFloat>(
stats: &[T],
pathways: &[Vec<i32>],
null: &BlitzGseaNull,
params: &BlitzGseaParams,
one_indexed: bool,
) -> Result<BlitzGseaResults, BixverseErrors> {
null.validate()?;
if null.centred != params.centre {
return Err(BixverseErrors::InvalidArgument(format!(
"The blitzGSEA null was calibrated with centre = {} but scoring was asked for \
centre = {}. The enrichment scores would sit on a scale the null was never \
fitted to.",
null.centred, params.centre
)));
}
let centred = centre_stats(stats, params.centre);
let scored: Vec<ScoredPathway> = pathways
.par_iter()
.map(|indices| {
let mut indices = indices.clone();
indices.sort_unstable();
indices.dedup();
if indices.is_empty() {
return Err(BixverseErrors::InvalidArgument(
"A gene set has no genes left after intersecting with the signature; \
filter it out before scoring."
.to_string(),
));
}
let gsea_stats =
calc_gsea_stats(¢red, &indices, T::one(), true, false, one_indexed);
let es = gsea_stats.es.to_f64().unwrap_or(0.0);
let tail = null.tail_at(indices.len() as f64);
let (pval, nes) = pval_and_nes(es, &tail)?;
Ok(ScoredPathway {
es,
nes,
pval,
size: indices.len(),
leading_edge: gsea_stats.leading_edge,
})
})
.collect::<Result<Vec<_>, BixverseErrors>>()?;
let mut es = Vec::with_capacity(scored.len());
let mut nes = Vec::with_capacity(scored.len());
let mut pvals = Vec::with_capacity(scored.len());
let mut size = Vec::with_capacity(scored.len());
let mut leading_edge = Vec::with_capacity(scored.len());
for pathway in scored {
es.push(pathway.es);
nes.push(pathway.nes);
pvals.push(pathway.pval);
size.push(pathway.size);
leading_edge.push(pathway.leading_edge);
}
Ok(BlitzGseaResults {
sidak: sidak(&pvals),
fdr: p_adjust_fdr(&pvals),
es,
nes,
pvals,
size,
leading_edge,
})
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn signature(n: usize) -> Vec<f64> {
(0..n)
.map(|i| 3.0 - 6.0 * (i as f64) / (n as f64 - 1.0))
.collect()
}
fn small_params() -> BlitzGseaParams {
BlitzGseaParams {
permutations: 200,
anchors: 8,
..Default::default()
}
}
#[test]
fn test_anchor_grid_is_sorted_and_unique() {
let grid = anchor_grid(10_000, 40);
assert!(grid.windows(2).all(|w| w[0] < w[1]));
assert!(grid.iter().all(|&size| (1..10_000).contains(&size)));
assert_eq!(grid[0], 1);
}
#[test]
fn test_anchor_grid_concentrates_on_the_small_end() {
let grid = anchor_grid(20_000, 40);
let below_100 = grid.iter().filter(|&&size| size < 100).count();
assert!(below_100 >= 15, "only {below_100} anchors below 100");
assert!(
below_100 * 5 >= grid.len() * 2,
"{below_100} of {} anchors below 100 is underweighted",
grid.len()
);
}
#[test]
fn test_anchor_grid_stops_short_of_the_signature() {
let grid = anchor_grid(10_000, 40);
let top = *grid.iter().max().unwrap();
assert!(top <= 5_000, "top anchor {top} runs too far");
assert!(
top >= 4_000,
"top anchor {top} does not cover real libraries"
);
}
#[test]
fn test_anchor_grid_does_not_depend_on_the_library() {
let grid = anchor_grid(20_000, 40);
assert!(grid.iter().any(|&size| size > 500));
assert!(grid.iter().any(|&size| size > 5_000));
}
#[test]
fn test_anchor_grid_degenerate_signatures() {
assert!(anchor_grid(1, 40).is_empty());
assert!(anchor_grid(0, 40).is_empty());
assert_eq!(anchor_grid(3, 40), vec![1]);
assert_eq!(anchor_grid(10_000, 1), vec![1]);
}
#[test]
fn test_centre_stats() {
let stats = [3.0, 1.0, -1.0, 1.0];
let centred = centre_stats(&stats, true);
let sum: f64 = centred.iter().sum();
assert_relative_eq!(sum, 0.0, epsilon = 1e-12);
assert_eq!(centre_stats(&stats, false), stats.to_vec());
}
#[test]
fn test_sidak_matches_the_closed_form() {
let pvals = [0.01, 0.2, 0.5];
let m = pvals.len() as f64;
let adjusted = sidak(&pvals);
for (&p, &adj) in pvals.iter().zip(adjusted.iter()) {
assert_relative_eq!(adj, 1.0 - (1.0 - p).powf(m), max_relative = 1e-12);
}
}
#[test]
fn test_sidak_keeps_tiny_pvalues() {
let adjusted = sidak(&[1e-300, 0.5]);
assert_relative_eq!(adjusted[0], 2e-300, max_relative = 1e-10);
assert!(adjusted[0] > 0.0);
}
#[test]
fn test_sidak_stays_in_range() {
let adjusted = sidak(&[0.0, 1.0, 0.9, 0.9, 0.9]);
assert!(adjusted.iter().all(|&p| (0.0..=1.0).contains(&p)));
assert_eq!(adjusted[0], 0.0);
assert_eq!(adjusted[1], 1.0);
}
fn balanced_tail() -> BlitzGseaTail {
BlitzGseaTail {
shape_pos: 4.0,
scale_pos: 0.05,
shape_neg: 4.0,
scale_neg: 0.05,
pos_ratio: 0.5,
}
}
#[test]
fn test_nes_follows_the_sign_of_the_score() {
let tail = balanced_tail();
let (p_pos, nes_pos) = pval_and_nes(0.6, &tail).unwrap();
let (p_neg, nes_neg) = pval_and_nes(-0.6, &tail).unwrap();
assert!(nes_pos > 0.0, "got {nes_pos}");
assert!(nes_neg < 0.0, "got {nes_neg}");
assert_relative_eq!(p_pos, p_neg, max_relative = 1e-12);
assert_relative_eq!(nes_pos, -nes_neg, max_relative = 1e-12);
}
#[test]
fn test_stronger_scores_give_smaller_pvalues() {
let tail = balanced_tail();
let (weak, nes_weak) = pval_and_nes(0.25, &tail).unwrap();
let (strong, nes_strong) = pval_and_nes(0.8, &tail).unwrap();
assert!(strong < weak);
assert!(nes_strong > nes_weak);
}
#[test]
fn test_extreme_score_stays_finite() {
let (pval, nes) = pval_and_nes(50.0, &balanced_tail()).unwrap();
assert!(nes.is_finite() && nes > 30.0, "got {nes}");
assert!(pval >= 0.0);
}
#[test]
fn test_uninformative_score_clamps_to_one() {
let tail = BlitzGseaTail {
pos_ratio: 1.0,
..balanced_tail()
};
let (pval, nes) = pval_and_nes(1e-9, &tail).unwrap();
assert_relative_eq!(pval, 1.0, max_relative = 1e-12);
assert_relative_eq!(nes, 0.0, epsilon = 1e-12);
}
#[test]
fn test_pos_ratio_scales_the_tail() {
let balanced = pval_and_nes(0.5, &balanced_tail()).unwrap().0;
let all_positive = pval_and_nes(
0.5,
&BlitzGseaTail {
pos_ratio: 1.0,
..balanced_tail()
},
)
.unwrap()
.0;
assert_relative_eq!(all_positive, 2.0 * balanced, max_relative = 1e-12);
}
#[test]
fn test_tail_at_interpolates_and_clamps_the_ratio() {
let null = BlitzGseaNull {
anchor_sizes: vec![10.0, 20.0],
shape_pos: vec![1.0, 3.0],
scale_pos: vec![0.1, 0.3],
shape_neg: vec![2.0, 4.0],
scale_neg: vec![0.2, 0.4],
pos_ratio: vec![-0.2, 1.4],
ks_pos: 1.0,
ks_neg: 1.0,
centred: true,
};
let t = (15.0f64.ln() - 10.0f64.ln()) / (20.0f64.ln() - 10.0f64.ln());
let tail = null.tail_at(15.0);
assert_relative_eq!(tail.shape_pos, 1.0 + 2.0 * t, max_relative = 1e-12);
assert_relative_eq!(tail.scale_neg, 0.2 + 0.2 * t, max_relative = 1e-12);
assert!((0.0..=1.0).contains(&tail.pos_ratio));
assert!((0.0..=1.0).contains(&null.tail_at(0.0).pos_ratio));
assert!((0.0..=1.0).contains(&null.tail_at(100.0).pos_ratio));
}
#[test]
fn test_params_new_matches_default() {
let defaults = BlitzGseaParams::default();
let explicit = BlitzGseaParams::new(None, None, None, None, None, None);
assert_eq!(defaults.permutations, explicit.permutations);
assert_eq!(defaults.anchors, explicit.anchors);
assert_eq!(defaults.symmetric, explicit.symmetric);
assert_eq!(defaults.centre, explicit.centre);
assert_eq!(defaults.ks_test, explicit.ks_test);
assert_eq!(defaults.seed, explicit.seed);
}
#[test]
fn test_params_new_overrides_only_what_is_given() {
let params = BlitzGseaParams::new(Some(500), None, Some(true), None, None, Some(7));
assert_eq!(params.permutations, 500);
assert_eq!(params.anchors, BLITZ_DEFAULT_ANCHORS);
assert!(params.symmetric);
assert!(params.centre);
assert!(params.ks_test);
assert_eq!(params.seed, 7);
}
#[test]
fn test_calibration_shapes_and_ranges() {
let stats = signature(2_000);
let null = calibrate_null(&stats, &small_params()).unwrap();
let n = null.anchor_sizes.len();
assert!(n > 5);
assert_eq!(null.shape_pos.len(), n);
assert_eq!(null.scale_pos.len(), n);
assert_eq!(null.shape_neg.len(), n);
assert_eq!(null.scale_neg.len(), n);
assert_eq!(null.pos_ratio.len(), n);
assert!(null.shape_pos.iter().all(|v| v.is_finite() && *v > 0.0));
assert!(null.scale_pos.iter().all(|v| v.is_finite() && *v > 0.0));
assert!(null.anchor_sizes.windows(2).all(|w| w[0] < w[1]));
}
#[test]
fn test_calibration_is_reproducible() {
let stats = signature(1_500);
let params = small_params();
let first = calibrate_null(&stats, ¶ms).unwrap();
let second = calibrate_null(&stats, ¶ms).unwrap();
for (a, b) in first.shape_pos.iter().zip(second.shape_pos.iter()) {
assert_relative_eq!(*a, *b, max_relative = 1e-15);
}
assert_relative_eq!(first.ks_pos, second.ks_pos, max_relative = 1e-15);
}
#[test]
fn test_low_permutation_count_forces_pooled_tails() {
let stats = signature(1_000);
let params = BlitzGseaParams {
permutations: 100,
anchors: 6,
symmetric: false,
..Default::default()
};
let null = calibrate_null(&stats, ¶ms).unwrap();
for (pos, neg) in null.shape_pos.iter().zip(null.shape_neg.iter()) {
assert_relative_eq!(*pos, *neg, max_relative = 1e-12);
}
}
#[test]
fn test_calibration_rejects_an_impossible_grid() {
let stats = vec![1.0_f64];
assert!(calibrate_null(&stats, &small_params()).is_err());
}
#[test]
fn test_enriched_set_beats_a_spread_out_one() {
let stats = signature(2_000);
let params = small_params();
let null = calibrate_null(&stats, ¶ms).unwrap();
let top: Vec<i32> = (0..50).collect();
let spread: Vec<i32> = (0..50).map(|i| i * 40).collect();
let res = blitzgsea_score(&stats, &[top, spread], &null, ¶ms, false).unwrap();
assert!(res.es[0] > 0.5, "top set scored {}", res.es[0]);
assert!(res.es[0] > res.es[1]);
assert!(res.pvals[0] < res.pvals[1]);
assert!(res.nes[0] > res.nes[1]);
}
#[test]
fn test_depleted_set_scores_negative() {
let stats = signature(2_000);
let params = small_params();
let null = calibrate_null(&stats, ¶ms).unwrap();
let bottom: Vec<i32> = (1_950..2_000).collect();
let res = blitzgsea_score(&stats, &[bottom], &null, ¶ms, false).unwrap();
assert!(res.es[0] < -0.5, "got {}", res.es[0]);
assert!(res.nes[0] < 0.0);
assert!(!res.leading_edge[0].is_empty());
}
#[test]
fn test_scoring_result_columns_line_up() {
let stats = signature(1_200);
let params = small_params();
let null = calibrate_null(&stats, ¶ms).unwrap();
let pathways: Vec<Vec<i32>> = vec![(0..20).collect(), (100..140).collect()];
let res = blitzgsea_score(&stats, &pathways, &null, ¶ms, false).unwrap();
assert_eq!(res.es.len(), 2);
assert_eq!(res.nes.len(), 2);
assert_eq!(res.pvals.len(), 2);
assert_eq!(res.sidak.len(), 2);
assert_eq!(res.fdr.len(), 2);
assert_eq!(res.size, vec![20, 40]);
assert_eq!(res.leading_edge.len(), 2);
assert!(res.pvals.iter().all(|p| (0.0..=1.0).contains(p)));
assert!(res.fdr.iter().zip(res.pvals.iter()).all(|(f, p)| f >= p));
assert!(res.nes.iter().all(|n| n.is_finite()));
}
#[test]
fn test_one_indexed_matches_zero_indexed() {
let stats = signature(1_000);
let params = small_params();
let null = calibrate_null(&stats, ¶ms).unwrap();
let zero: Vec<i32> = (0..30).collect();
let one: Vec<i32> = (1..=30).collect();
let from_zero = blitzgsea_score(&stats, &[zero], &null, ¶ms, false).unwrap();
let from_one = blitzgsea_score(&stats, &[one], &null, ¶ms, true).unwrap();
assert_relative_eq!(from_zero.es[0], from_one.es[0], max_relative = 1e-12);
}
#[test]
fn test_empty_pathway_list() {
let stats = signature(500);
let params = small_params();
let null = calibrate_null(&stats, ¶ms).unwrap();
let res = blitzgsea_score(&stats, &[], &null, ¶ms, false).unwrap();
assert!(res.es.is_empty());
assert!(res.fdr.is_empty());
}
#[test]
fn test_empty_pathway_errors_rather_than_panicking() {
let stats = signature(500);
let params = small_params();
let null = calibrate_null(&stats, ¶ms).unwrap();
let res = blitzgsea_score(&stats, &[vec![]], &null, ¶ms, false);
assert!(res.is_err());
}
#[test]
fn test_unsorted_indices_score_the_same() {
let stats = signature(1_000);
let params = small_params();
let null = calibrate_null(&stats, ¶ms).unwrap();
let sorted = vec![1, 5, 9, 40, 100];
let shuffled = vec![100, 5, 40, 1, 9];
let a = blitzgsea_score(&stats, &[sorted], &null, ¶ms, false).unwrap();
let b = blitzgsea_score(&stats, &[shuffled], &null, ¶ms, false).unwrap();
assert_relative_eq!(a.es[0], b.es[0], max_relative = 1e-12);
assert_eq!(a.size[0], b.size[0]);
}
#[test]
fn test_duplicate_indices_are_collapsed() {
let stats = signature(1_000);
let params = small_params();
let null = calibrate_null(&stats, ¶ms).unwrap();
let once = blitzgsea_score(&stats, &[vec![1, 5, 9]], &null, ¶ms, false).unwrap();
let twice =
blitzgsea_score(&stats, &[vec![1, 1, 5, 5, 9, 9]], &null, ¶ms, false).unwrap();
assert_eq!(twice.size[0], 3);
assert_relative_eq!(once.es[0], twice.es[0], max_relative = 1e-12);
}
#[test]
fn test_centring_mismatch_is_rejected() {
let stats = signature(500);
let calibrated = BlitzGseaParams {
centre: true,
..small_params()
};
let null = calibrate_null(&stats, &calibrated).unwrap();
let scoring = BlitzGseaParams {
centre: false,
..small_params()
};
let res = blitzgsea_score(&stats, &[vec![1, 2, 3]], &null, &scoring, false);
assert!(res.is_err());
}
#[test]
fn test_malformed_null_is_rejected() {
let stats = signature(500);
let params = small_params();
let mut null = calibrate_null(&stats, ¶ms).unwrap();
assert!(null.validate().is_ok());
null.shape_pos.pop();
assert!(null.validate().is_err());
assert!(blitzgsea_score(&stats, &[vec![1, 2, 3]], &null, ¶ms, false).is_err());
}
#[test]
fn test_too_few_anchors_is_rejected() {
let stats = signature(1_000);
for anchors in [0, 1] {
let params = BlitzGseaParams {
anchors,
..small_params()
};
assert!(
calibrate_null(&stats, ¶ms).is_err(),
"anchors = {anchors} should be rejected"
);
}
}
#[test]
fn test_too_few_permutations_is_rejected() {
let stats = signature(1_000);
for permutations in [0, 1] {
let params = BlitzGseaParams {
permutations,
..small_params()
};
assert!(
calibrate_null(&stats, ¶ms).is_err(),
"permutations = {permutations} should be rejected"
);
}
}
#[test]
fn test_ratio_endpoints_stay_finite() {
let all_negative = BlitzGseaTail {
pos_ratio: 0.0,
..balanced_tail()
};
let all_positive = BlitzGseaTail {
pos_ratio: 1.0,
..balanced_tail()
};
let (p_pos, nes_pos) = pval_and_nes(0.5, &all_negative).unwrap();
let (p_neg, nes_neg) = pval_and_nes(-0.5, &all_positive).unwrap();
assert_eq!(p_pos, 0.0);
assert_eq!(p_neg, 0.0);
assert!(nes_pos.is_finite() && nes_pos > 30.0);
assert!(nes_neg.is_finite() && nes_neg < -30.0);
}
#[test]
fn test_extrapolated_tail_stays_usable() {
let stats = signature(2_000);
let params = small_params();
let null = calibrate_null(&stats, ¶ms).unwrap();
let top = *null
.anchor_sizes
.last()
.expect("calibration returns anchors");
for size in [top + 1.0, top * 1.5, stats.len() as f64 - 1.0] {
let tail = null.tail_at(size);
assert!(
tail.shape_pos > 0.0,
"shape at {size} is {}",
tail.shape_pos
);
assert!(
tail.scale_pos > 0.0,
"scale at {size} is {}",
tail.scale_pos
);
assert!(tail.shape_neg > 0.0 && tail.scale_neg > 0.0);
assert!(pval_and_nes(0.3, &tail).is_ok());
}
}
#[test]
fn test_tail_at_clamps_sizes_below_one() {
let stats = signature(500);
let params = small_params();
let null = calibrate_null(&stats, ¶ms).unwrap();
let one = null.tail_at(1.0);
for size in [0.0, -5.0, 0.5] {
let tail = null.tail_at(size);
assert_relative_eq!(tail.shape_pos, one.shape_pos, max_relative = 1e-12);
}
}
}