#![allow(clippy::cast_precision_loss)]
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash)]
pub enum MultipleTestingMethod {
#[default]
BenjaminiHochberg,
BenjaminiYekutieli,
Bonferroni,
Holm,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct FdrAdjustment {
pub method: MultipleTestingMethod,
pub exclude_contemporaneous: bool,
}
impl Default for FdrAdjustment {
fn default() -> Self {
Self {
method: MultipleTestingMethod::BenjaminiHochberg,
exclude_contemporaneous: true,
}
}
}
impl FdrAdjustment {
#[must_use]
pub const fn bh() -> Self {
Self { method: MultipleTestingMethod::BenjaminiHochberg, exclude_contemporaneous: true }
}
#[must_use]
pub const fn by() -> Self {
Self { method: MultipleTestingMethod::BenjaminiYekutieli, exclude_contemporaneous: true }
}
#[must_use]
pub const fn with_exclude_contemporaneous(mut self, exclude: bool) -> Self {
self.exclude_contemporaneous = exclude;
self
}
}
#[must_use]
pub fn adjust_pvalues(p_values: &[f64], method: MultipleTestingMethod) -> Vec<f64> {
match method {
MultipleTestingMethod::BenjaminiHochberg => benjamini_hochberg(p_values),
MultipleTestingMethod::BenjaminiYekutieli => benjamini_yekutieli(p_values),
MultipleTestingMethod::Bonferroni => bonferroni(p_values),
MultipleTestingMethod::Holm => holm(p_values),
}
}
#[must_use]
pub fn benjamini_hochberg(p_values: &[f64]) -> Vec<f64> {
bh_family(p_values, 1.0)
}
#[must_use]
pub fn benjamini_yekutieli(p_values: &[f64]) -> Vec<f64> {
let m = p_values.len();
if m == 0 {
return Vec::new();
}
let mut h = 0.0;
for i in 1..=m {
h += 1.0 / i as f64;
}
bh_family(p_values, h)
}
#[must_use]
pub fn bonferroni(p_values: &[f64]) -> Vec<f64> {
let m = p_values.len() as f64;
p_values.iter().map(|&p| (p * m).clamp(0.0, 1.0)).collect()
}
#[must_use]
pub fn holm(p_values: &[f64]) -> Vec<f64> {
let m = p_values.len();
if m == 0 {
return Vec::new();
}
let mut idx: Vec<usize> = (0..m).collect();
idx.sort_by(|&a, &b| {
p_values[a].partial_cmp(&p_values[b]).unwrap_or(std::cmp::Ordering::Equal)
});
let mut adj = vec![0.0; m];
let mut running = 0.0_f64;
for (rank0, &i) in idx.iter().enumerate() {
let remaining = m - rank0; let candidate = (p_values[i] * remaining as f64).min(1.0);
running = running.max(candidate);
adj[i] = running;
}
adj
}
fn bh_family(p_values: &[f64], scale: f64) -> Vec<f64> {
let m = p_values.len();
if m == 0 {
return Vec::new();
}
let mut idx: Vec<usize> = (0..m).collect();
idx.sort_by(|&a, &b| {
p_values[a].partial_cmp(&p_values[b]).unwrap_or(std::cmp::Ordering::Equal)
});
let mut adj = vec![0.0; m];
let mut running: f64 = 1.0;
for (rank_rev, &i) in idx.iter().rev().enumerate() {
let rank = m - rank_rev; let candidate = (p_values[i] * scale * m as f64 / rank as f64).min(1.0);
running = running.min(candidate);
adj[i] = running;
}
adj
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bh_preserves_length_and_bounds() {
let p = [0.001, 0.04, 0.5, 0.02];
let a = benjamini_hochberg(&p);
assert_eq!(a.len(), 4);
assert!(a.iter().all(|&x| (0.0..=1.0).contains(&x)));
assert!(a[0] <= a[2]);
}
#[test]
fn by_is_at_least_as_conservative_as_bh() {
let p = [0.001, 0.01, 0.02, 0.04, 0.2];
let bh = benjamini_hochberg(&p);
let by = benjamini_yekutieli(&p);
for (a, b) in bh.iter().zip(by.iter()) {
assert!(b + 1e-12 >= *a, "bh={a} by={b}");
}
}
#[test]
fn bonferroni_scales_by_m() {
let p = [0.01, 0.02];
let a = bonferroni(&p);
assert!((a[0] - 0.02).abs() < 1e-12);
assert!((a[1] - 0.04).abs() < 1e-12);
}
#[test]
fn holm_matches_known_two_test_case() {
let p = [0.01, 0.04];
let a = holm(&p);
assert!((a[0] - 0.02).abs() < 1e-12);
assert!((a[1] - 0.04).abs() < 1e-12);
}
#[test]
fn adjust_pvalues_dispatches() {
let p = [0.01, 0.02, 0.03];
assert_eq!(
adjust_pvalues(&p, MultipleTestingMethod::BenjaminiHochberg),
benjamini_hochberg(&p)
);
assert_eq!(adjust_pvalues(&p, MultipleTestingMethod::Holm), holm(&p));
}
}