Files
rustytorch/crates/specialized/rtx-neuro-stats/src/correction.rs
T
2026-03-04 00:08:42 +00:00

264 lines
8.2 KiB
Rust

//! Multiple comparison correction methods
//!
//! Provides methods to control for multiple comparisons:
//! - FDR (False Discovery Rate) using Benjamini-Hochberg
//! - Bonferroni correction
//! - Holm-Bonferroni step-down procedure
/// Correction method for multiple comparisons
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CorrectionMethod {
/// Benjamini-Hochberg FDR correction
Fdr,
/// Bonferroni correction (most conservative)
Bonferroni,
/// Holm-Bonferroni step-down procedure
Holm,
/// No correction
None,
}
/// False Discovery Rate (FDR) correction using Benjamini-Hochberg procedure
///
/// Controls the expected proportion of false positives among rejected hypotheses.
///
/// # Arguments
/// * `pvalues` - Vector of uncorrected p-values
/// * `alpha` - Significance level (e.g., 0.05)
///
/// # Returns
/// Tuple of (reject, pvalues_corrected):
/// - reject: Vector of booleans indicating which hypotheses to reject
/// - pvalues_corrected: Adjusted p-values
///
/// # Example
/// ```
/// use rtx_neuro_stats::fdr_correction;
///
/// let pvalues = vec![0.01, 0.04, 0.03, 0.20, 0.001];
/// let (reject, pvals_corrected) = fdr_correction(&pvalues, 0.05);
///
/// // pvals_corrected[4] (0.001) should be smallest and still < 0.05
/// assert!(reject[4]); // 0.001 should be rejected
/// ```
pub fn fdr_correction(pvalues: &[f64], alpha: f64) -> (Vec<bool>, Vec<f64>) {
let n = pvalues.len();
if n == 0 {
return (Vec::new(), Vec::new());
}
// Sort p-values with original indices
let mut indexed: Vec<(usize, f64)> = pvalues.iter().copied().enumerate().collect();
indexed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
// Compute adjusted p-values using Benjamini-Hochberg
let mut pvals_corrected = vec![0.0; n];
let mut reject = vec![false; n];
// Work backwards to ensure monotonicity
let mut min_so_far: f64 = 1.0;
for (rank, &(orig_idx, pval)) in indexed.iter().enumerate().rev() {
// BH formula: p_adj = p * n / (rank + 1)
let adjusted = (pval * n as f64 / (rank + 1) as f64).min(1.0);
min_so_far = f64::min(min_so_far, adjusted);
pvals_corrected[orig_idx] = min_so_far;
reject[orig_idx] = min_so_far <= alpha;
}
(reject, pvals_corrected)
}
/// Bonferroni correction
///
/// Most conservative multiple comparison correction.
/// Simply multiplies p-values by the number of tests.
///
/// # Arguments
/// * `pvalues` - Vector of uncorrected p-values
/// * `alpha` - Significance level (e.g., 0.05)
///
/// # Returns
/// Tuple of (reject, pvalues_corrected)
pub fn bonferroni_correction(pvalues: &[f64], alpha: f64) -> (Vec<bool>, Vec<f64>) {
let n = pvalues.len();
if n == 0 {
return (Vec::new(), Vec::new());
}
let pvals_corrected: Vec<f64> = pvalues.iter().map(|&p| (p * n as f64).min(1.0)).collect();
let reject: Vec<bool> = pvals_corrected.iter().map(|&p| p <= alpha).collect();
(reject, pvals_corrected)
}
/// Holm-Bonferroni step-down correction
///
/// Less conservative than Bonferroni while still controlling FWER.
///
/// # Arguments
/// * `pvalues` - Vector of uncorrected p-values
/// * `alpha` - Significance level (e.g., 0.05)
///
/// # Returns
/// Tuple of (reject, pvalues_corrected)
pub fn holm_correction(pvalues: &[f64], alpha: f64) -> (Vec<bool>, Vec<f64>) {
let n = pvalues.len();
if n == 0 {
return (Vec::new(), Vec::new());
}
// Sort p-values with original indices
let mut indexed: Vec<(usize, f64)> = pvalues.iter().copied().enumerate().collect();
indexed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let mut pvals_corrected = vec![0.0; n];
let mut reject = vec![false; n];
// Holm step-down: p_adj[i] = max(p[j] * (n - j)) for j <= i
let mut max_so_far: f64 = 0.0;
for (rank, &(orig_idx, pval)) in indexed.iter().enumerate() {
let adjusted = (pval * (n - rank) as f64).min(1.0);
max_so_far = f64::max(max_so_far, adjusted);
pvals_corrected[orig_idx] = max_so_far;
reject[orig_idx] = max_so_far <= alpha;
}
(reject, pvals_corrected)
}
/// Apply any correction method
///
/// # Arguments
/// * `pvalues` - Vector of uncorrected p-values
/// * `alpha` - Significance level
/// * `method` - Correction method to use
///
/// # Returns
/// Tuple of (reject, pvalues_corrected)
pub fn correct_pvalues(
pvalues: &[f64],
alpha: f64,
method: CorrectionMethod,
) -> (Vec<bool>, Vec<f64>) {
match method {
CorrectionMethod::Fdr => fdr_correction(pvalues, alpha),
CorrectionMethod::Bonferroni => bonferroni_correction(pvalues, alpha),
CorrectionMethod::Holm => holm_correction(pvalues, alpha),
CorrectionMethod::None => {
let reject: Vec<bool> = pvalues.iter().map(|&p| p <= alpha).collect();
(reject, pvalues.to_vec())
}
}
}
/// Compute the number of significant tests after correction
pub fn n_significant(reject: &[bool]) -> usize {
reject.iter().filter(|&&r| r).count()
}
/// Compute the proportion of significant tests
pub fn proportion_significant(reject: &[bool]) -> f64 {
if reject.is_empty() {
return 0.0;
}
n_significant(reject) as f64 / reject.len() as f64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fdr_basic() {
let pvalues = vec![0.01, 0.04, 0.03, 0.20, 0.001];
let (reject, pvals_corrected) = fdr_correction(&pvalues, 0.05);
// Check that 0.001 is still significant
assert!(reject[4]);
// 0.20 should not be significant
assert!(!reject[3]);
// Corrected p-values should be >= original
for i in 0..pvalues.len() {
assert!(pvals_corrected[i] >= pvalues[i]);
}
// Corrected p-values should be <= 1.0
for &p in &pvals_corrected {
assert!(p <= 1.0);
}
}
#[test]
fn test_fdr_all_significant() {
let pvalues = vec![0.001, 0.002, 0.003, 0.004, 0.005];
let (reject, _pvals_corrected) = fdr_correction(&pvalues, 0.05);
// All should be significant
assert!(reject.iter().all(|&r| r));
}
#[test]
fn test_fdr_none_significant() {
let pvalues = vec![0.5, 0.6, 0.7, 0.8, 0.9];
let (reject, _pvals_corrected) = fdr_correction(&pvalues, 0.05);
// None should be significant
assert!(reject.iter().all(|&r| !r));
}
#[test]
fn test_bonferroni() {
let pvalues = vec![0.01, 0.02, 0.03];
let (reject, pvals_corrected) = bonferroni_correction(&pvalues, 0.05);
// 0.01 * 3 = 0.03, should be significant
assert!(reject[0]);
// 0.02 * 3 = 0.06, should not be significant
assert!(!reject[1]);
// 0.03 * 3 = 0.09, should not be significant
assert!(!reject[2]);
assert!((pvals_corrected[0] - 0.03).abs() < 1e-10);
assert!((pvals_corrected[1] - 0.06).abs() < 1e-10);
assert!((pvals_corrected[2] - 0.09).abs() < 1e-10);
}
#[test]
fn test_holm() {
let pvalues = vec![0.01, 0.04, 0.03, 0.20];
let (reject, pvals_corrected) = holm_correction(&pvalues, 0.05);
// Holm is less conservative than Bonferroni
// 0.01 * 4 = 0.04, significant
assert!(reject[0]);
// Corrected values should be monotonic
let mut sorted_corrected: Vec<(usize, f64)> = pvalues.iter().copied().enumerate().collect();
sorted_corrected.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
}
#[test]
fn test_correct_pvalues() {
let pvalues = vec![0.01, 0.04, 0.10];
let (rej_fdr, _) = correct_pvalues(&pvalues, 0.05, CorrectionMethod::Fdr);
let (rej_bonf, _) = correct_pvalues(&pvalues, 0.05, CorrectionMethod::Bonferroni);
let (rej_none, _) = correct_pvalues(&pvalues, 0.05, CorrectionMethod::None);
// No correction should have more rejections
assert!(n_significant(&rej_none) >= n_significant(&rej_fdr));
assert!(n_significant(&rej_fdr) >= n_significant(&rej_bonf));
}
#[test]
fn test_empty_input() {
let pvalues: Vec<f64> = Vec::new();
let (reject, pvals_corrected) = fdr_correction(&pvalues, 0.05);
assert!(reject.is_empty());
assert!(pvals_corrected.is_empty());
}
}