Initial commit
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
//! Cluster-based permutation tests for spatio-temporal data
|
||||
//!
|
||||
//! Implements cluster-based permutation tests as described in:
|
||||
//! Maris & Oostenveld (2007) "Nonparametric statistical testing of EEG- and MEG-data"
|
||||
//!
|
||||
//! These tests control for multiple comparisons by forming clusters of
|
||||
//! adjacent significant points and testing cluster-level statistics.
|
||||
|
||||
use crate::error::{NeuroError, NeuroResult};
|
||||
use crate::stats::permutation::Tail;
|
||||
use crate::stats::utils;
|
||||
use rand::prelude::*;
|
||||
use rayon::prelude::*;
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
|
||||
/// Sparse adjacency matrix for spatial connectivity
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Adjacency {
|
||||
/// Number of nodes (e.g., channels or sources)
|
||||
pub n_nodes: usize,
|
||||
/// Adjacency list: for each node, list of neighbor indices
|
||||
pub neighbors: Vec<Vec<usize>>,
|
||||
}
|
||||
|
||||
impl Adjacency {
|
||||
/// Create adjacency from neighbor lists
|
||||
pub fn new(neighbors: Vec<Vec<usize>>) -> Self {
|
||||
let n_nodes = neighbors.len();
|
||||
Self { n_nodes, neighbors }
|
||||
}
|
||||
|
||||
/// Create fully connected adjacency (all nodes are neighbors)
|
||||
pub fn fully_connected(n_nodes: usize) -> Self {
|
||||
let neighbors: Vec<Vec<usize>> = (0..n_nodes)
|
||||
.map(|i| (0..n_nodes).filter(|&j| j != i).collect())
|
||||
.collect();
|
||||
Self { n_nodes, neighbors }
|
||||
}
|
||||
|
||||
/// Create grid adjacency (4-connectivity for 2D grid)
|
||||
pub fn grid_2d(n_rows: usize, n_cols: usize) -> Self {
|
||||
let n_nodes = n_rows * n_cols;
|
||||
let mut neighbors = vec![Vec::new(); n_nodes];
|
||||
|
||||
for row in 0..n_rows {
|
||||
for col in 0..n_cols {
|
||||
let idx = row * n_cols + col;
|
||||
// Up
|
||||
if row > 0 {
|
||||
neighbors[idx].push((row - 1) * n_cols + col);
|
||||
}
|
||||
// Down
|
||||
if row < n_rows - 1 {
|
||||
neighbors[idx].push((row + 1) * n_cols + col);
|
||||
}
|
||||
// Left
|
||||
if col > 0 {
|
||||
neighbors[idx].push(row * n_cols + col - 1);
|
||||
}
|
||||
// Right
|
||||
if col < n_cols - 1 {
|
||||
neighbors[idx].push(row * n_cols + col + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self { n_nodes, neighbors }
|
||||
}
|
||||
|
||||
/// Create adjacency from distance matrix (connect nodes within threshold)
|
||||
pub fn from_distance_matrix(distances: &[Vec<f64>], threshold: f64) -> Self {
|
||||
let n_nodes = distances.len();
|
||||
let neighbors: Vec<Vec<usize>> = (0..n_nodes)
|
||||
.map(|i| {
|
||||
(0..n_nodes)
|
||||
.filter(|&j| i != j && distances[i][j] <= threshold)
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
Self { n_nodes, neighbors }
|
||||
}
|
||||
}
|
||||
|
||||
/// A cluster of connected significant points
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Cluster {
|
||||
/// Indices of points in the cluster (flattened: node * n_times + time)
|
||||
pub indices: Vec<usize>,
|
||||
/// Cluster statistic (sum of t-values)
|
||||
pub statistic: f64,
|
||||
/// Node indices in the cluster
|
||||
pub nodes: Vec<usize>,
|
||||
/// Time indices in the cluster
|
||||
pub times: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Result of cluster-based permutation test
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClusterTestResult {
|
||||
/// Observed t-statistics [n_nodes x n_times]
|
||||
pub t_obs: Vec<Vec<f64>>,
|
||||
/// Clusters found in observed data
|
||||
pub clusters: Vec<Cluster>,
|
||||
/// P-values for each cluster
|
||||
pub cluster_pvalues: Vec<f64>,
|
||||
/// Null distribution of maximum cluster statistics
|
||||
pub h0: Vec<f64>,
|
||||
/// Number of permutations
|
||||
pub n_permutations: usize,
|
||||
/// Threshold used for clustering
|
||||
pub threshold: f64,
|
||||
}
|
||||
|
||||
/// Find clusters in a 2D array of statistics
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `stats` - 2D array of test statistics [n_nodes x n_times]
|
||||
/// * `threshold` - Cluster-forming threshold (absolute value)
|
||||
/// * `adjacency` - Spatial adjacency matrix (None = no spatial clustering)
|
||||
/// * `tail` - Which tail to consider for clustering
|
||||
///
|
||||
/// # Returns
|
||||
/// Vector of clusters found
|
||||
pub fn find_clusters(
|
||||
stats: &[Vec<f64>],
|
||||
threshold: f64,
|
||||
adjacency: Option<&Adjacency>,
|
||||
tail: Tail,
|
||||
) -> Vec<Cluster> {
|
||||
if stats.is_empty() || stats[0].is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let n_nodes = stats.len();
|
||||
let n_times = stats[0].len();
|
||||
|
||||
// Create mask of significant points
|
||||
let mask: Vec<Vec<bool>> = stats
|
||||
.iter()
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(|&v| match tail {
|
||||
Tail::TwoSided => v.abs() >= threshold,
|
||||
Tail::Greater => v >= threshold,
|
||||
Tail::Less => v <= -threshold,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Find connected components
|
||||
let mut visited = vec![vec![false; n_times]; n_nodes];
|
||||
let mut clusters = Vec::new();
|
||||
|
||||
for node in 0..n_nodes {
|
||||
for time in 0..n_times {
|
||||
if mask[node][time] && !visited[node][time] {
|
||||
// BFS to find cluster
|
||||
let cluster = bfs_cluster(
|
||||
&mask,
|
||||
&mut visited,
|
||||
node,
|
||||
time,
|
||||
n_nodes,
|
||||
n_times,
|
||||
adjacency,
|
||||
stats,
|
||||
);
|
||||
if !cluster.indices.is_empty() {
|
||||
clusters.push(cluster);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort clusters by absolute statistic (largest first)
|
||||
clusters.sort_by(|a, b| {
|
||||
b.statistic
|
||||
.abs()
|
||||
.partial_cmp(&a.statistic.abs())
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
clusters
|
||||
}
|
||||
|
||||
/// BFS to find connected cluster
|
||||
fn bfs_cluster(
|
||||
mask: &[Vec<bool>],
|
||||
visited: &mut [Vec<bool>],
|
||||
start_node: usize,
|
||||
start_time: usize,
|
||||
n_nodes: usize,
|
||||
n_times: usize,
|
||||
adjacency: Option<&Adjacency>,
|
||||
stats: &[Vec<f64>],
|
||||
) -> Cluster {
|
||||
let mut indices = Vec::new();
|
||||
let mut nodes_set = HashSet::new();
|
||||
let mut times_set = HashSet::new();
|
||||
let mut statistic = 0.0;
|
||||
|
||||
let mut queue = VecDeque::new();
|
||||
queue.push_back((start_node, start_time));
|
||||
visited[start_node][start_time] = true;
|
||||
|
||||
while let Some((node, time)) = queue.pop_front() {
|
||||
indices.push(node * n_times + time);
|
||||
nodes_set.insert(node);
|
||||
times_set.insert(time);
|
||||
statistic += stats[node][time];
|
||||
|
||||
// Check temporal neighbors
|
||||
if time > 0 && mask[node][time - 1] && !visited[node][time - 1] {
|
||||
visited[node][time - 1] = true;
|
||||
queue.push_back((node, time - 1));
|
||||
}
|
||||
if time < n_times - 1 && mask[node][time + 1] && !visited[node][time + 1] {
|
||||
visited[node][time + 1] = true;
|
||||
queue.push_back((node, time + 1));
|
||||
}
|
||||
|
||||
// Check spatial neighbors
|
||||
if let Some(adj) = adjacency {
|
||||
for &neighbor in &adj.neighbors[node] {
|
||||
if neighbor < n_nodes && mask[neighbor][time] && !visited[neighbor][time] {
|
||||
visited[neighbor][time] = true;
|
||||
queue.push_back((neighbor, time));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut nodes: Vec<usize> = nodes_set.into_iter().collect();
|
||||
let mut times: Vec<usize> = times_set.into_iter().collect();
|
||||
nodes.sort_unstable();
|
||||
times.sort_unstable();
|
||||
|
||||
Cluster {
|
||||
indices,
|
||||
statistic,
|
||||
nodes,
|
||||
times,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spatio-temporal cluster-based permutation test
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `x` - Condition A data [n_observations x n_nodes x n_times]
|
||||
/// * `y` - Condition B data (optional, for two-sample test)
|
||||
/// * `threshold` - Cluster-forming threshold (t-value)
|
||||
/// * `n_permutations` - Number of permutations
|
||||
/// * `adjacency` - Spatial adjacency (None = temporal clustering only)
|
||||
/// * `tail` - Test tail
|
||||
/// * `seed` - Optional random seed
|
||||
///
|
||||
/// # Returns
|
||||
/// ClusterTestResult with clusters and p-values
|
||||
pub fn spatio_temporal_cluster_test(
|
||||
x: &[Vec<Vec<f64>>],
|
||||
y: Option<&[Vec<Vec<f64>>]>,
|
||||
threshold: f64,
|
||||
n_permutations: usize,
|
||||
adjacency: Option<&Adjacency>,
|
||||
tail: Tail,
|
||||
seed: Option<u64>,
|
||||
) -> NeuroResult<ClusterTestResult> {
|
||||
if x.is_empty() {
|
||||
return Err(NeuroError::Stats("Empty data".to_string()));
|
||||
}
|
||||
|
||||
let n_obs_x = x.len();
|
||||
let n_nodes = x[0].len();
|
||||
let n_times = x[0][0].len();
|
||||
|
||||
// Validate dimensions
|
||||
for obs in x {
|
||||
if obs.len() != n_nodes || obs.iter().any(|row| row.len() != n_times) {
|
||||
return Err(NeuroError::Stats(
|
||||
"All observations must have same dimensions".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Determine test type
|
||||
let (t_obs, _compute_stat): (
|
||||
Vec<Vec<f64>>,
|
||||
Box<dyn Fn(&[Vec<Vec<f64>>], Option<&[Vec<Vec<f64>>]>) -> Vec<Vec<f64>> + Send + Sync>,
|
||||
) = match y {
|
||||
Some(y_data) => {
|
||||
// Two-sample test
|
||||
let _n_obs_y = y_data.len();
|
||||
for obs in y_data {
|
||||
if obs.len() != n_nodes || obs.iter().any(|row| row.len() != n_times) {
|
||||
return Err(NeuroError::Stats("Y dimensions must match X".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let t = compute_t_stats_ind(x, y_data, n_nodes, n_times);
|
||||
let func = Box::new(move |a: &[Vec<Vec<f64>>], b: Option<&[Vec<Vec<f64>>]>| {
|
||||
compute_t_stats_ind(a, b.unwrap(), n_nodes, n_times)
|
||||
});
|
||||
(t, func)
|
||||
}
|
||||
None => {
|
||||
// One-sample test
|
||||
let t = compute_t_stats_1samp(x, n_nodes, n_times);
|
||||
let func = Box::new(move |a: &[Vec<Vec<f64>>], _: Option<&[Vec<Vec<f64>>]>| {
|
||||
compute_t_stats_1samp(a, n_nodes, n_times)
|
||||
});
|
||||
(t, func)
|
||||
}
|
||||
};
|
||||
|
||||
// Find clusters in observed data
|
||||
let clusters = find_clusters(&t_obs, threshold, adjacency, tail);
|
||||
|
||||
// Generate null distribution
|
||||
let h0: Vec<f64> = if y.is_some() {
|
||||
// Two-sample: permute group labels
|
||||
let _n_obs_y = y.unwrap().len();
|
||||
let mut combined: Vec<Vec<Vec<f64>>> = x.to_vec();
|
||||
combined.extend(y.unwrap().iter().cloned());
|
||||
|
||||
(0..n_permutations)
|
||||
.into_par_iter()
|
||||
.map(|i| {
|
||||
let mut rng = match seed {
|
||||
Some(s) => StdRng::seed_from_u64(s.wrapping_add(i as u64)),
|
||||
None => StdRng::from_entropy(),
|
||||
};
|
||||
|
||||
let mut shuffled = combined.clone();
|
||||
shuffled.shuffle(&mut rng);
|
||||
|
||||
let perm_x = &shuffled[..n_obs_x];
|
||||
let perm_y = &shuffled[n_obs_x..];
|
||||
|
||||
let t_perm = compute_t_stats_ind(perm_x, perm_y, n_nodes, n_times);
|
||||
let perm_clusters = find_clusters(&t_perm, threshold, adjacency, tail);
|
||||
|
||||
perm_clusters
|
||||
.first()
|
||||
.map(|c| c.statistic.abs())
|
||||
.unwrap_or(0.0)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
// One-sample: sign flipping
|
||||
(0..n_permutations)
|
||||
.into_par_iter()
|
||||
.map(|i| {
|
||||
let mut rng = match seed {
|
||||
Some(s) => StdRng::seed_from_u64(s.wrapping_add(i as u64)),
|
||||
None => StdRng::from_entropy(),
|
||||
};
|
||||
|
||||
// Generate sign flips
|
||||
let signs: Vec<f64> = (0..n_obs_x)
|
||||
.map(|_| if rng.r#gen::<bool>() { 1.0 } else { -1.0 })
|
||||
.collect();
|
||||
|
||||
// Apply signs
|
||||
let flipped: Vec<Vec<Vec<f64>>> = x
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, obs)| {
|
||||
obs.iter()
|
||||
.map(|row| row.iter().map(|&v| v * signs[k]).collect())
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let t_perm = compute_t_stats_1samp(&flipped, n_nodes, n_times);
|
||||
let perm_clusters = find_clusters(&t_perm, threshold, adjacency, tail);
|
||||
|
||||
perm_clusters
|
||||
.first()
|
||||
.map(|c| c.statistic.abs())
|
||||
.unwrap_or(0.0)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Compute cluster p-values
|
||||
let cluster_pvalues: Vec<f64> = clusters
|
||||
.iter()
|
||||
.map(|cluster| {
|
||||
let count = h0.iter().filter(|&&v| v >= cluster.statistic.abs()).count();
|
||||
(count as f64 + 1.0) / (n_permutations as f64 + 1.0)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(ClusterTestResult {
|
||||
t_obs,
|
||||
clusters,
|
||||
cluster_pvalues,
|
||||
h0,
|
||||
n_permutations,
|
||||
threshold,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute one-sample t-statistics for each node and time
|
||||
fn compute_t_stats_1samp(data: &[Vec<Vec<f64>>], n_nodes: usize, n_times: usize) -> Vec<Vec<f64>> {
|
||||
let _n_obs = data.len() as f64;
|
||||
|
||||
(0..n_nodes)
|
||||
.map(|node| {
|
||||
(0..n_times)
|
||||
.map(|time| {
|
||||
let values: Vec<f64> = data.iter().map(|obs| obs[node][time]).collect();
|
||||
let mean = utils::mean(&values);
|
||||
let se = utils::sem(&values);
|
||||
if se < 1e-10 { 0.0 } else { mean / se }
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compute independent two-sample t-statistics for each node and time
|
||||
fn compute_t_stats_ind(
|
||||
x: &[Vec<Vec<f64>>],
|
||||
y: &[Vec<Vec<f64>>],
|
||||
n_nodes: usize,
|
||||
n_times: usize,
|
||||
) -> Vec<Vec<f64>> {
|
||||
let n_x = x.len() as f64;
|
||||
let n_y = y.len() as f64;
|
||||
|
||||
(0..n_nodes)
|
||||
.map(|node| {
|
||||
(0..n_times)
|
||||
.map(|time| {
|
||||
let vals_x: Vec<f64> = x.iter().map(|obs| obs[node][time]).collect();
|
||||
let vals_y: Vec<f64> = y.iter().map(|obs| obs[node][time]).collect();
|
||||
|
||||
let mean_x = utils::mean(&vals_x);
|
||||
let mean_y = utils::mean(&vals_y);
|
||||
let var_x = utils::variance(&vals_x, 1);
|
||||
let var_y = utils::variance(&vals_y, 1);
|
||||
|
||||
let se = (var_x / n_x + var_y / n_y).sqrt();
|
||||
if se < 1e-10 {
|
||||
0.0
|
||||
} else {
|
||||
(mean_x - mean_y) / se
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_adjacency_grid() {
|
||||
let adj = Adjacency::grid_2d(3, 3);
|
||||
assert_eq!(adj.n_nodes, 9);
|
||||
// Corner has 2 neighbors
|
||||
assert_eq!(adj.neighbors[0].len(), 2);
|
||||
// Center has 4 neighbors
|
||||
assert_eq!(adj.neighbors[4].len(), 4);
|
||||
// Edge has 3 neighbors
|
||||
assert_eq!(adj.neighbors[1].len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_clusters() {
|
||||
// Simple 2x5 array with clusters
|
||||
// Without spatial adjacency, each row forms separate temporal clusters
|
||||
let stats = vec![vec![0.5, 2.5, 2.8, 0.3, 0.1], vec![0.2, 2.6, 2.4, 0.4, 3.0]];
|
||||
|
||||
let clusters = find_clusters(&stats, 2.0, None, Tail::Greater);
|
||||
|
||||
// Should find clusters (row0 cols 1-2, row1 cols 1-2, row1 col 4)
|
||||
assert!(!clusters.is_empty());
|
||||
// With spatial adjacency, the two middle clusters merge
|
||||
let adj = Adjacency::fully_connected(2);
|
||||
let clusters_spatial = find_clusters(&stats, 2.0, Some(&adj), Tail::Greater);
|
||||
assert!(clusters_spatial.len() <= clusters.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_test_1samp() {
|
||||
// Create data with a significant cluster
|
||||
let mut rng = StdRng::seed_from_u64(42);
|
||||
|
||||
let n_obs = 20;
|
||||
let n_nodes = 4;
|
||||
let n_times = 10;
|
||||
|
||||
// Generate data with effect in nodes 1-2, times 3-6
|
||||
let data: Vec<Vec<Vec<f64>>> = (0..n_obs)
|
||||
.map(|_| {
|
||||
(0..n_nodes)
|
||||
.map(|node| {
|
||||
(0..n_times)
|
||||
.map(|time| {
|
||||
let noise: f64 = rng.r#gen::<f64>() * 0.5 - 0.25;
|
||||
if (1..=2).contains(&node) && (3..=6).contains(&time) {
|
||||
1.5 + noise // Effect
|
||||
} else {
|
||||
noise // No effect
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let adj = Adjacency::fully_connected(n_nodes);
|
||||
let result = spatio_temporal_cluster_test(
|
||||
&data,
|
||||
None,
|
||||
2.0,
|
||||
500,
|
||||
Some(&adj),
|
||||
Tail::Greater,
|
||||
Some(42),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Should find at least one significant cluster
|
||||
assert!(!result.clusters.is_empty());
|
||||
// First cluster should be significant
|
||||
assert!(result.cluster_pvalues[0] < 0.05);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! 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_core::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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
//! Effect size measures
|
||||
//!
|
||||
//! Provides standardized effect size calculations:
|
||||
//! - Cohen's d (for two-group comparisons)
|
||||
//! - Hedges' g (bias-corrected Cohen's d)
|
||||
//! - Glass's delta (when group variances differ)
|
||||
//! - Eta-squared and partial eta-squared (for ANOVA)
|
||||
|
||||
use crate::error::{NeuroError, NeuroResult};
|
||||
use crate::stats::utils;
|
||||
|
||||
/// Effect size magnitude interpretation
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EffectMagnitude {
|
||||
/// |d| < 0.2
|
||||
Negligible,
|
||||
/// 0.2 <= |d| < 0.5
|
||||
Small,
|
||||
/// 0.5 <= |d| < 0.8
|
||||
Medium,
|
||||
/// |d| >= 0.8
|
||||
Large,
|
||||
}
|
||||
|
||||
impl EffectMagnitude {
|
||||
/// Interpret effect size using Cohen's guidelines
|
||||
pub fn from_d(d: f64) -> Self {
|
||||
let abs_d = d.abs();
|
||||
if abs_d < 0.2 {
|
||||
Self::Negligible
|
||||
} else if abs_d < 0.5 {
|
||||
Self::Small
|
||||
} else if abs_d < 0.8 {
|
||||
Self::Medium
|
||||
} else {
|
||||
Self::Large
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpret eta-squared effect size
|
||||
pub fn from_eta_squared(eta2: f64) -> Self {
|
||||
if eta2 < 0.01 {
|
||||
Self::Negligible
|
||||
} else if eta2 < 0.06 {
|
||||
Self::Small
|
||||
} else if eta2 < 0.14 {
|
||||
Self::Medium
|
||||
} else {
|
||||
Self::Large
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Effect size result
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EffectSize {
|
||||
/// The effect size value
|
||||
pub value: f64,
|
||||
/// 95% confidence interval (if computed)
|
||||
pub ci_95: Option<(f64, f64)>,
|
||||
/// Interpretation of magnitude
|
||||
pub magnitude: EffectMagnitude,
|
||||
/// Name of the effect size measure
|
||||
pub measure: String,
|
||||
}
|
||||
|
||||
/// Cohen's d for independent samples
|
||||
///
|
||||
/// Standardized mean difference using pooled standard deviation.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `a` - First sample
|
||||
/// * `b` - Second sample
|
||||
///
|
||||
/// # Returns
|
||||
/// Cohen's d effect size
|
||||
pub fn cohens_d(a: &[f64], b: &[f64]) -> NeuroResult<EffectSize> {
|
||||
if a.len() < 2 {
|
||||
return Err(NeuroError::Stats(format!(
|
||||
"Insufficient data in sample A: need at least 2 samples, got {}",
|
||||
a.len()
|
||||
)));
|
||||
}
|
||||
if b.len() < 2 {
|
||||
return Err(NeuroError::Stats(format!(
|
||||
"Insufficient data in sample B: need at least 2 samples, got {}",
|
||||
b.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let n_a = a.len() as f64;
|
||||
let n_b = b.len() as f64;
|
||||
let mean_a = utils::mean(a);
|
||||
let mean_b = utils::mean(b);
|
||||
let var_a = utils::variance(a, 1);
|
||||
let var_b = utils::variance(b, 1);
|
||||
|
||||
// Pooled standard deviation
|
||||
let pooled_var = ((n_a - 1.0) * var_a + (n_b - 1.0) * var_b) / (n_a + n_b - 2.0);
|
||||
let pooled_sd = pooled_var.sqrt();
|
||||
|
||||
if pooled_sd < 1e-10 {
|
||||
return Ok(EffectSize {
|
||||
value: 0.0,
|
||||
ci_95: Some((0.0, 0.0)),
|
||||
magnitude: EffectMagnitude::Negligible,
|
||||
measure: "Cohen's d".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let d = (mean_a - mean_b) / pooled_sd;
|
||||
|
||||
// Approximate 95% CI using non-central t-distribution approximation
|
||||
let se_d = ((n_a + n_b) / (n_a * n_b) + d.powi(2) / (2.0 * (n_a + n_b))).sqrt();
|
||||
let ci_95 = Some((d - 1.96 * se_d, d + 1.96 * se_d));
|
||||
|
||||
Ok(EffectSize {
|
||||
value: d,
|
||||
ci_95,
|
||||
magnitude: EffectMagnitude::from_d(d),
|
||||
measure: "Cohen's d".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Hedges' g (bias-corrected Cohen's d)
|
||||
///
|
||||
/// Applies a correction factor for small sample sizes.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `a` - First sample
|
||||
/// * `b` - Second sample
|
||||
///
|
||||
/// # Returns
|
||||
/// Hedges' g effect size
|
||||
pub fn hedges_g(a: &[f64], b: &[f64]) -> NeuroResult<EffectSize> {
|
||||
let d_result = cohens_d(a, b)?;
|
||||
let d = d_result.value;
|
||||
|
||||
let n = a.len() + b.len();
|
||||
// Correction factor (Hedges, 1981)
|
||||
let correction = 1.0 - 3.0 / (4.0 * (n as f64) - 9.0);
|
||||
let g = d * correction;
|
||||
|
||||
// Adjust CI
|
||||
let ci_95 = d_result
|
||||
.ci_95
|
||||
.map(|(lo, hi)| (lo * correction, hi * correction));
|
||||
|
||||
Ok(EffectSize {
|
||||
value: g,
|
||||
ci_95,
|
||||
magnitude: EffectMagnitude::from_d(g),
|
||||
measure: "Hedges' g".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Glass's delta
|
||||
///
|
||||
/// Uses only the control group's standard deviation as denominator.
|
||||
/// Useful when group variances differ substantially.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `treatment` - Treatment group
|
||||
/// * `control` - Control group (used for SD)
|
||||
///
|
||||
/// # Returns
|
||||
/// Glass's delta effect size
|
||||
pub fn glass_delta(treatment: &[f64], control: &[f64]) -> NeuroResult<EffectSize> {
|
||||
if treatment.len() < 2 {
|
||||
return Err(NeuroError::Stats(format!(
|
||||
"Insufficient data in treatment group: need at least 2 samples, got {}",
|
||||
treatment.len()
|
||||
)));
|
||||
}
|
||||
if control.len() < 2 {
|
||||
return Err(NeuroError::Stats(format!(
|
||||
"Insufficient data in control group: need at least 2 samples, got {}",
|
||||
control.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let mean_t = utils::mean(treatment);
|
||||
let mean_c = utils::mean(control);
|
||||
let sd_c = utils::std_dev(control, 1);
|
||||
|
||||
if sd_c < 1e-10 {
|
||||
return Ok(EffectSize {
|
||||
value: 0.0,
|
||||
ci_95: Some((0.0, 0.0)),
|
||||
magnitude: EffectMagnitude::Negligible,
|
||||
measure: "Glass's delta".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let delta = (mean_t - mean_c) / sd_c;
|
||||
|
||||
// Approximate SE
|
||||
let n_t = treatment.len() as f64;
|
||||
let n_c = control.len() as f64;
|
||||
let se = (1.0 / n_t + 1.0 / n_c + delta.powi(2) / (2.0 * n_c)).sqrt();
|
||||
let ci_95 = Some((delta - 1.96 * se, delta + 1.96 * se));
|
||||
|
||||
Ok(EffectSize {
|
||||
value: delta,
|
||||
ci_95,
|
||||
magnitude: EffectMagnitude::from_d(delta),
|
||||
measure: "Glass's delta".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Cohen's d for paired samples
|
||||
///
|
||||
/// Uses the standard deviation of differences as denominator.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `a` - First measurements
|
||||
/// * `b` - Second measurements (paired with a)
|
||||
///
|
||||
/// # Returns
|
||||
/// Cohen's d for paired samples
|
||||
pub fn cohens_d_paired(a: &[f64], b: &[f64]) -> NeuroResult<EffectSize> {
|
||||
if a.len() != b.len() {
|
||||
return Err(NeuroError::Stats(format!(
|
||||
"Arrays must have same length: {} vs {}",
|
||||
a.len(),
|
||||
b.len()
|
||||
)));
|
||||
}
|
||||
if a.len() < 2 {
|
||||
return Err(NeuroError::Stats(format!(
|
||||
"Insufficient data: need at least 2 samples, got {}",
|
||||
a.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// Compute differences
|
||||
let diff: Vec<f64> = a.iter().zip(b.iter()).map(|(x, y)| x - y).collect();
|
||||
let mean_diff = utils::mean(&diff);
|
||||
let sd_diff = utils::std_dev(&diff, 1);
|
||||
|
||||
if sd_diff < 1e-10 {
|
||||
return Ok(EffectSize {
|
||||
value: 0.0,
|
||||
ci_95: Some((0.0, 0.0)),
|
||||
magnitude: EffectMagnitude::Negligible,
|
||||
measure: "Cohen's d (paired)".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let d = mean_diff / sd_diff;
|
||||
let n = a.len() as f64;
|
||||
let se = (1.0 / n + d.powi(2) / (2.0 * n)).sqrt();
|
||||
let ci_95 = Some((d - 1.96 * se, d + 1.96 * se));
|
||||
|
||||
Ok(EffectSize {
|
||||
value: d,
|
||||
ci_95,
|
||||
magnitude: EffectMagnitude::from_d(d),
|
||||
measure: "Cohen's d (paired)".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Eta-squared from F-test result
|
||||
///
|
||||
/// Proportion of variance explained by group membership.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `ss_between` - Sum of squares between groups
|
||||
/// * `ss_total` - Total sum of squares (ss_between + ss_within)
|
||||
///
|
||||
/// # Returns
|
||||
/// Eta-squared effect size
|
||||
pub fn eta_squared(ss_between: f64, ss_total: f64) -> EffectSize {
|
||||
let eta2 = if ss_total < 1e-10 {
|
||||
0.0
|
||||
} else {
|
||||
ss_between / ss_total
|
||||
};
|
||||
|
||||
EffectSize {
|
||||
value: eta2,
|
||||
ci_95: None,
|
||||
magnitude: EffectMagnitude::from_eta_squared(eta2),
|
||||
measure: "Eta-squared".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Omega-squared (less biased than eta-squared)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `ss_between` - Sum of squares between groups
|
||||
/// * `ss_total` - Total sum of squares
|
||||
/// * `ms_within` - Mean square within groups
|
||||
/// * `n` - Total sample size
|
||||
/// * `k` - Number of groups
|
||||
pub fn omega_squared(
|
||||
ss_between: f64,
|
||||
ss_total: f64,
|
||||
ms_within: f64,
|
||||
_n: f64,
|
||||
k: f64,
|
||||
) -> EffectSize {
|
||||
let omega2 = if ss_total < 1e-10 {
|
||||
0.0
|
||||
} else {
|
||||
(ss_between - (k - 1.0) * ms_within) / (ss_total + ms_within)
|
||||
};
|
||||
|
||||
EffectSize {
|
||||
value: omega2.max(0.0),
|
||||
ci_95: None,
|
||||
magnitude: EffectMagnitude::from_eta_squared(omega2),
|
||||
measure: "Omega-squared".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Point-biserial correlation (effect size for t-test)
|
||||
///
|
||||
/// Converts t-statistic to correlation coefficient.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `t` - t-statistic
|
||||
/// * `df` - Degrees of freedom
|
||||
pub fn point_biserial_r(t: f64, df: f64) -> EffectSize {
|
||||
let r = (t.powi(2) / (t.powi(2) + df)).sqrt() * t.signum();
|
||||
|
||||
EffectSize {
|
||||
value: r,
|
||||
ci_95: None,
|
||||
magnitude: if r.abs() < 0.1 {
|
||||
EffectMagnitude::Negligible
|
||||
} else if r.abs() < 0.3 {
|
||||
EffectMagnitude::Small
|
||||
} else if r.abs() < 0.5 {
|
||||
EffectMagnitude::Medium
|
||||
} else {
|
||||
EffectMagnitude::Large
|
||||
},
|
||||
measure: "Point-biserial r".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_cohens_d_large() {
|
||||
// Two clearly different groups
|
||||
let a = vec![10.0, 11.0, 12.0, 10.5, 11.5];
|
||||
let b = vec![5.0, 6.0, 5.5, 6.5, 5.8];
|
||||
|
||||
let result = cohens_d(&a, &b).unwrap();
|
||||
|
||||
assert!(result.value > 2.0); // Large effect
|
||||
assert_eq!(result.magnitude, EffectMagnitude::Large);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cohens_d_small() {
|
||||
// Two very similar groups with small difference
|
||||
let a = vec![10.0, 10.1, 9.9, 10.05, 9.95, 10.02];
|
||||
let b = vec![9.95, 10.05, 9.85, 10.0, 9.9, 9.98];
|
||||
|
||||
let result = cohens_d(&a, &b).unwrap();
|
||||
|
||||
// Effect size should be small or negligible
|
||||
assert!(result.value.abs() < 0.8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hedges_g() {
|
||||
let a = vec![10.0, 11.0, 12.0, 10.5, 11.5];
|
||||
let b = vec![5.0, 6.0, 5.5, 6.5, 5.8];
|
||||
|
||||
let d = cohens_d(&a, &b).unwrap();
|
||||
let g = hedges_g(&a, &b).unwrap();
|
||||
|
||||
// Hedges' g should be slightly smaller (bias correction)
|
||||
assert!(g.value.abs() < d.value.abs());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cohens_d_paired() {
|
||||
// Clear improvement with some variation in improvement
|
||||
let before = vec![5.0, 6.0, 5.5, 6.5, 5.8, 5.2];
|
||||
let after = vec![7.5, 8.8, 8.2, 9.0, 8.5, 7.8]; // Varying improvement amounts
|
||||
|
||||
let result = cohens_d_paired(&before, &after).unwrap();
|
||||
|
||||
// before - after is negative (improvement), so effect size is negative
|
||||
// Large absolute value indicates large effect
|
||||
assert!(result.value < -1.0);
|
||||
assert_eq!(result.magnitude, EffectMagnitude::Large);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_eta_squared() {
|
||||
let result = eta_squared(100.0, 200.0);
|
||||
assert!((result.value - 0.5).abs() < 1e-10);
|
||||
assert_eq!(result.magnitude, EffectMagnitude::Large);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_point_biserial_r() {
|
||||
let result = point_biserial_r(4.0, 20.0);
|
||||
assert!(result.value > 0.5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! # Statistical Analysis Module
|
||||
//!
|
||||
//! Statistical analysis tools for neuroimaging data.
|
||||
//!
|
||||
//! This module provides:
|
||||
//! - Permutation tests (1-sample, paired, independent)
|
||||
//! - Cluster-based permutation tests for spatio-temporal data
|
||||
//! - Multiple comparison corrections (FDR, Bonferroni, Holm)
|
||||
//! - Parametric tests (t-tests, F-tests/ANOVA)
|
||||
//! - Effect size measures (Cohen's d, Hedges' g)
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use rtx_neuro_core::stats::{permutation_test_1samp, fdr_correction, Tail};
|
||||
//!
|
||||
//! // One-sample permutation test
|
||||
//! let data = vec![1.2, 2.3, 1.8, 2.1, 1.9];
|
||||
//! let result = permutation_test_1samp(&data, 0.0, 10000, Tail::TwoSided, None)?;
|
||||
//! println!("t-statistic: {}, p-value: {}", result.statistic, result.pvalue);
|
||||
//!
|
||||
//! // FDR correction for multiple comparisons
|
||||
//! let pvalues = vec![0.01, 0.04, 0.03, 0.20, 0.001];
|
||||
//! let (reject, pvals_corrected) = fdr_correction(&pvalues, 0.05);
|
||||
//! ```
|
||||
|
||||
pub mod cluster;
|
||||
pub mod correction;
|
||||
pub mod effect_size;
|
||||
pub mod parametric;
|
||||
pub mod permutation;
|
||||
|
||||
// Re-export commonly used types and functions
|
||||
pub use cluster::{
|
||||
Adjacency, Cluster, ClusterTestResult, find_clusters, spatio_temporal_cluster_test,
|
||||
};
|
||||
pub use correction::{CorrectionMethod, bonferroni_correction, fdr_correction, holm_correction};
|
||||
pub use effect_size::{EffectSize, cohens_d, hedges_g};
|
||||
pub use parametric::{TTestResult, f_oneway, ttest_1samp, ttest_ind, ttest_rel};
|
||||
pub use permutation::{
|
||||
PermutationResult, Tail, permutation_test_1samp, permutation_test_ind, permutation_test_rel,
|
||||
};
|
||||
|
||||
/// Common statistical utilities
|
||||
pub mod utils {
|
||||
/// Compute mean of a slice
|
||||
pub fn mean(data: &[f64]) -> f64 {
|
||||
if data.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
data.iter().sum::<f64>() / data.len() as f64
|
||||
}
|
||||
|
||||
/// Compute variance of a slice (sample variance, ddof=1)
|
||||
pub fn variance(data: &[f64], ddof: usize) -> f64 {
|
||||
if data.len() <= ddof {
|
||||
return 0.0;
|
||||
}
|
||||
let m = mean(data);
|
||||
let sum_sq: f64 = data.iter().map(|x| (x - m).powi(2)).sum();
|
||||
sum_sq / (data.len() - ddof) as f64
|
||||
}
|
||||
|
||||
/// Compute standard deviation of a slice
|
||||
pub fn std_dev(data: &[f64], ddof: usize) -> f64 {
|
||||
variance(data, ddof).sqrt()
|
||||
}
|
||||
|
||||
/// Compute standard error of the mean
|
||||
pub fn sem(data: &[f64]) -> f64 {
|
||||
if data.len() <= 1 {
|
||||
return 0.0;
|
||||
}
|
||||
std_dev(data, 1) / (data.len() as f64).sqrt()
|
||||
}
|
||||
|
||||
/// Rank data (for non-parametric tests)
|
||||
pub fn rank(data: &[f64]) -> Vec<f64> {
|
||||
let n = data.len();
|
||||
let mut indexed: Vec<(usize, f64)> = data.iter().copied().enumerate().collect();
|
||||
indexed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let mut ranks = vec![0.0; n];
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
let mut j = i;
|
||||
// Find ties
|
||||
while j < n - 1 && (indexed[j].1 - indexed[j + 1].1).abs() < 1e-10 {
|
||||
j += 1;
|
||||
}
|
||||
// Average rank for ties
|
||||
let avg_rank = (i + j) as f64 / 2.0 + 1.0;
|
||||
for k in i..=j {
|
||||
ranks[indexed[k].0] = avg_rank;
|
||||
}
|
||||
i = j + 1;
|
||||
}
|
||||
ranks
|
||||
}
|
||||
|
||||
/// Compute percentile of data
|
||||
pub fn percentile(data: &[f64], p: f64) -> f64 {
|
||||
if data.is_empty() {
|
||||
return f64::NAN;
|
||||
}
|
||||
let mut sorted = data.to_vec();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let idx = (p / 100.0) * (sorted.len() - 1) as f64;
|
||||
let lower = idx.floor() as usize;
|
||||
let upper = idx.ceil() as usize;
|
||||
|
||||
if lower == upper {
|
||||
sorted[lower]
|
||||
} else {
|
||||
let frac = idx - lower as f64;
|
||||
sorted[lower] * (1.0 - frac) + sorted[upper] * frac
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mean() {
|
||||
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
assert!((utils::mean(&data) - 3.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_variance() {
|
||||
let data = vec![2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
|
||||
let var = utils::variance(&data, 1);
|
||||
assert!((var - 4.571428571428571).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rank() {
|
||||
let data = vec![3.0, 1.0, 4.0, 1.0, 5.0];
|
||||
let ranks = utils::rank(&data);
|
||||
// 1.0 appears twice -> avg rank 1.5
|
||||
// 3.0 -> rank 3
|
||||
// 4.0 -> rank 4
|
||||
// 5.0 -> rank 5
|
||||
assert!((ranks[0] - 3.0).abs() < 1e-10); // 3.0
|
||||
assert!((ranks[1] - 1.5).abs() < 1e-10); // 1.0 (tie)
|
||||
assert!((ranks[2] - 4.0).abs() < 1e-10); // 4.0
|
||||
assert!((ranks[3] - 1.5).abs() < 1e-10); // 1.0 (tie)
|
||||
assert!((ranks[4] - 5.0).abs() < 1e-10); // 5.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
//! Parametric statistical tests
|
||||
//!
|
||||
//! Provides classical parametric tests:
|
||||
//! - One-sample t-test
|
||||
//! - Paired t-test
|
||||
//! - Independent two-sample t-test (Welch's)
|
||||
//! - One-way ANOVA (F-test)
|
||||
|
||||
use crate::error::{NeuroError, NeuroResult};
|
||||
use crate::stats::utils;
|
||||
use statrs::distribution::{ContinuousCDF, FisherSnedecor, StudentsT};
|
||||
|
||||
/// Result of a t-test
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TTestResult {
|
||||
/// The t-statistic
|
||||
pub statistic: f64,
|
||||
/// The p-value (two-tailed)
|
||||
pub pvalue: f64,
|
||||
/// Degrees of freedom
|
||||
pub df: f64,
|
||||
/// Mean of first sample (or difference for paired)
|
||||
pub mean: f64,
|
||||
/// Standard error
|
||||
pub se: f64,
|
||||
/// 95% confidence interval for the mean
|
||||
pub ci_95: (f64, f64),
|
||||
}
|
||||
|
||||
/// Result of an F-test (ANOVA)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FTestResult {
|
||||
/// The F-statistic
|
||||
pub statistic: f64,
|
||||
/// The p-value
|
||||
pub pvalue: f64,
|
||||
/// Degrees of freedom (between groups, within groups)
|
||||
pub df: (f64, f64),
|
||||
/// Sum of squares between groups
|
||||
pub ss_between: f64,
|
||||
/// Sum of squares within groups
|
||||
pub ss_within: f64,
|
||||
/// Mean square between groups
|
||||
pub ms_between: f64,
|
||||
/// Mean square within groups
|
||||
pub ms_within: f64,
|
||||
/// Eta-squared effect size
|
||||
pub eta_squared: f64,
|
||||
}
|
||||
|
||||
/// One-sample t-test
|
||||
///
|
||||
/// Tests whether the mean of a sample differs from a hypothesized value.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - Sample data
|
||||
/// * `popmean` - Hypothesized population mean
|
||||
///
|
||||
/// # Returns
|
||||
/// TTestResult with t-statistic, p-value, and confidence interval
|
||||
pub fn ttest_1samp(data: &[f64], popmean: f64) -> NeuroResult<TTestResult> {
|
||||
if data.len() < 2 {
|
||||
return Err(NeuroError::Stats(format!(
|
||||
"Insufficient data: need at least 2 samples, got {}",
|
||||
data.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let n = data.len() as f64;
|
||||
let mean = utils::mean(data);
|
||||
let se = utils::sem(data);
|
||||
let df = n - 1.0;
|
||||
|
||||
if se < 1e-10 {
|
||||
// All values are identical
|
||||
let statistic = if (mean - popmean).abs() < 1e-10 {
|
||||
0.0
|
||||
} else {
|
||||
(mean - popmean).signum() * f64::INFINITY
|
||||
};
|
||||
return Ok(TTestResult {
|
||||
statistic,
|
||||
pvalue: if statistic.is_infinite() { 0.0 } else { 1.0 },
|
||||
df,
|
||||
mean,
|
||||
se,
|
||||
ci_95: (mean, mean),
|
||||
});
|
||||
}
|
||||
|
||||
let t_stat = (mean - popmean) / se;
|
||||
|
||||
// Compute p-value using Student's t distribution
|
||||
let t_dist = StudentsT::new(0.0, 1.0, df)
|
||||
.map_err(|e| NeuroError::Stats(format!("Failed to create t-distribution: {}", e)))?;
|
||||
|
||||
let pvalue = 2.0 * (1.0 - t_dist.cdf(t_stat.abs()));
|
||||
|
||||
// 95% CI
|
||||
let t_crit = t_dist.inverse_cdf(0.975).max(0.0).min(100.0); // Fallback to reasonable bounds
|
||||
let ci_95 = (mean - t_crit * se, mean + t_crit * se);
|
||||
|
||||
Ok(TTestResult {
|
||||
statistic: t_stat,
|
||||
pvalue,
|
||||
df,
|
||||
mean,
|
||||
se,
|
||||
ci_95,
|
||||
})
|
||||
}
|
||||
|
||||
/// Paired-sample t-test
|
||||
///
|
||||
/// Tests whether the mean difference between paired samples differs from zero.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `a` - First sample
|
||||
/// * `b` - Second sample (paired with a)
|
||||
///
|
||||
/// # Returns
|
||||
/// TTestResult for the paired differences
|
||||
pub fn ttest_rel(a: &[f64], b: &[f64]) -> NeuroResult<TTestResult> {
|
||||
if a.len() != b.len() {
|
||||
return Err(NeuroError::Stats(format!(
|
||||
"Arrays must have same length: {} vs {}",
|
||||
a.len(),
|
||||
b.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// Compute differences and use one-sample t-test
|
||||
let diff: Vec<f64> = a.iter().zip(b.iter()).map(|(x, y)| x - y).collect();
|
||||
ttest_1samp(&diff, 0.0)
|
||||
}
|
||||
|
||||
/// Independent two-sample t-test (Welch's t-test)
|
||||
///
|
||||
/// Tests whether two independent samples have different means.
|
||||
/// Uses Welch's approximation which doesn't assume equal variances.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `a` - First sample
|
||||
/// * `b` - Second sample
|
||||
///
|
||||
/// # Returns
|
||||
/// TTestResult for the difference in means
|
||||
pub fn ttest_ind(a: &[f64], b: &[f64]) -> NeuroResult<TTestResult> {
|
||||
if a.len() < 2 {
|
||||
return Err(NeuroError::Stats(format!(
|
||||
"Insufficient data in sample A: need at least 2 samples, got {}",
|
||||
a.len()
|
||||
)));
|
||||
}
|
||||
if b.len() < 2 {
|
||||
return Err(NeuroError::Stats(format!(
|
||||
"Insufficient data in sample B: need at least 2 samples, got {}",
|
||||
b.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let n_a = a.len() as f64;
|
||||
let n_b = b.len() as f64;
|
||||
let mean_a = utils::mean(a);
|
||||
let mean_b = utils::mean(b);
|
||||
let var_a = utils::variance(a, 1);
|
||||
let var_b = utils::variance(b, 1);
|
||||
|
||||
let se = (var_a / n_a + var_b / n_b).sqrt();
|
||||
let mean_diff = mean_a - mean_b;
|
||||
|
||||
if se < 1e-10 {
|
||||
let statistic = if mean_diff.abs() < 1e-10 {
|
||||
0.0
|
||||
} else {
|
||||
mean_diff.signum() * f64::INFINITY
|
||||
};
|
||||
return Ok(TTestResult {
|
||||
statistic,
|
||||
pvalue: if statistic.is_infinite() { 0.0 } else { 1.0 },
|
||||
df: n_a + n_b - 2.0,
|
||||
mean: mean_diff,
|
||||
se,
|
||||
ci_95: (mean_diff, mean_diff),
|
||||
});
|
||||
}
|
||||
|
||||
let t_stat = mean_diff / se;
|
||||
|
||||
// Welch-Satterthwaite degrees of freedom
|
||||
let v_a = var_a / n_a;
|
||||
let v_b = var_b / n_b;
|
||||
let df = (v_a + v_b).powi(2) / (v_a.powi(2) / (n_a - 1.0) + v_b.powi(2) / (n_b - 1.0));
|
||||
|
||||
// Compute p-value
|
||||
let t_dist = StudentsT::new(0.0, 1.0, df)
|
||||
.map_err(|e| NeuroError::Stats(format!("Failed to create t-distribution: {}", e)))?;
|
||||
|
||||
let pvalue = 2.0 * (1.0 - t_dist.cdf(t_stat.abs()));
|
||||
|
||||
// 95% CI
|
||||
let t_crit = t_dist.inverse_cdf(0.975).max(0.0).min(100.0);
|
||||
let ci_95 = (mean_diff - t_crit * se, mean_diff + t_crit * se);
|
||||
|
||||
Ok(TTestResult {
|
||||
statistic: t_stat,
|
||||
pvalue,
|
||||
df,
|
||||
mean: mean_diff,
|
||||
se,
|
||||
ci_95,
|
||||
})
|
||||
}
|
||||
|
||||
/// One-way ANOVA (F-test)
|
||||
///
|
||||
/// Tests whether the means of multiple groups differ.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `groups` - Vector of groups, each group is a vector of observations
|
||||
///
|
||||
/// # Returns
|
||||
/// FTestResult with F-statistic, p-value, and effect size
|
||||
pub fn f_oneway(groups: &[&[f64]]) -> NeuroResult<FTestResult> {
|
||||
if groups.len() < 2 {
|
||||
return Err(NeuroError::Stats(
|
||||
"Need at least 2 groups for ANOVA".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
for (_i, group) in groups.iter().enumerate() {
|
||||
if group.len() < 2 {
|
||||
return Err(NeuroError::Stats(format!(
|
||||
"Insufficient data in group: need at least 2 samples, got {}",
|
||||
group.len()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let k = groups.len() as f64; // Number of groups
|
||||
let n: f64 = groups.iter().map(|g| g.len() as f64).sum(); // Total observations
|
||||
|
||||
// Grand mean
|
||||
let all_values: Vec<f64> = groups.iter().flat_map(|g| g.iter().copied()).collect();
|
||||
let grand_mean = utils::mean(&all_values);
|
||||
|
||||
// Group means
|
||||
let group_means: Vec<f64> = groups.iter().map(|g| utils::mean(g)).collect();
|
||||
|
||||
// Sum of squares between groups
|
||||
let ss_between: f64 = groups
|
||||
.iter()
|
||||
.zip(group_means.iter())
|
||||
.map(|(g, &gm)| g.len() as f64 * (gm - grand_mean).powi(2))
|
||||
.sum();
|
||||
|
||||
// Sum of squares within groups
|
||||
let ss_within: f64 = groups
|
||||
.iter()
|
||||
.zip(group_means.iter())
|
||||
.map(|(g, &gm)| g.iter().map(|&x| (x - gm).powi(2)).sum::<f64>())
|
||||
.sum();
|
||||
|
||||
// Degrees of freedom
|
||||
let df_between = k - 1.0;
|
||||
let df_within = n - k;
|
||||
|
||||
// Mean squares
|
||||
let ms_between = ss_between / df_between;
|
||||
let ms_within = ss_within / df_within;
|
||||
|
||||
// F-statistic
|
||||
let f_stat = if ms_within < 1e-10 {
|
||||
if ms_between < 1e-10 {
|
||||
0.0
|
||||
} else {
|
||||
f64::INFINITY
|
||||
}
|
||||
} else {
|
||||
ms_between / ms_within
|
||||
};
|
||||
|
||||
// P-value from F-distribution
|
||||
let f_dist = FisherSnedecor::new(df_between, df_within)
|
||||
.map_err(|e| NeuroError::Stats(format!("Failed to create F-distribution: {}", e)))?;
|
||||
|
||||
let pvalue = if f_stat.is_infinite() {
|
||||
0.0
|
||||
} else {
|
||||
1.0 - f_dist.cdf(f_stat)
|
||||
};
|
||||
|
||||
// Effect size (eta-squared)
|
||||
let ss_total = ss_between + ss_within;
|
||||
let eta_squared = if ss_total < 1e-10 {
|
||||
0.0
|
||||
} else {
|
||||
ss_between / ss_total
|
||||
};
|
||||
|
||||
Ok(FTestResult {
|
||||
statistic: f_stat,
|
||||
pvalue,
|
||||
df: (df_between, df_within),
|
||||
ss_between,
|
||||
ss_within,
|
||||
ms_between,
|
||||
ms_within,
|
||||
eta_squared,
|
||||
})
|
||||
}
|
||||
|
||||
/// Vectorized t-test for multiple features
|
||||
///
|
||||
/// Performs element-wise t-tests on multi-dimensional data.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - 2D array [n_observations x n_features]
|
||||
/// * `popmean` - Hypothesized population mean
|
||||
///
|
||||
/// # Returns
|
||||
/// Tuple of (t_statistics, p_values) for each feature
|
||||
pub fn ttest_1samp_vectorized(
|
||||
data: &[Vec<f64>],
|
||||
popmean: f64,
|
||||
) -> NeuroResult<(Vec<f64>, Vec<f64>)> {
|
||||
if data.is_empty() {
|
||||
return Err(NeuroError::Stats("Empty data".to_string()));
|
||||
}
|
||||
|
||||
let n_obs = data.len();
|
||||
let n_features = data[0].len();
|
||||
|
||||
if n_obs < 2 {
|
||||
return Err(NeuroError::Stats(format!(
|
||||
"Insufficient data: need at least 2 samples, got {}",
|
||||
n_obs
|
||||
)));
|
||||
}
|
||||
|
||||
let df = (n_obs - 1) as f64;
|
||||
let t_dist = StudentsT::new(0.0, 1.0, df)
|
||||
.map_err(|e| NeuroError::Stats(format!("Failed to create t-distribution: {}", e)))?;
|
||||
|
||||
let mut t_stats = Vec::with_capacity(n_features);
|
||||
let mut pvalues = Vec::with_capacity(n_features);
|
||||
|
||||
for j in 0..n_features {
|
||||
let col: Vec<f64> = data.iter().map(|row| row[j] - popmean).collect();
|
||||
let mean = utils::mean(&col);
|
||||
let se = utils::sem(&col);
|
||||
|
||||
let t_stat = if se < 1e-10 { 0.0 } else { mean / se };
|
||||
let pvalue = 2.0 * (1.0 - t_dist.cdf(t_stat.abs()));
|
||||
|
||||
t_stats.push(t_stat);
|
||||
pvalues.push(pvalue);
|
||||
}
|
||||
|
||||
Ok((t_stats, pvalues))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ttest_1samp_significant() {
|
||||
let data = vec![2.1, 2.3, 1.9, 2.5, 2.2, 2.0, 2.4, 2.1];
|
||||
let result = ttest_1samp(&data, 0.0).unwrap();
|
||||
|
||||
assert!(result.statistic > 0.0);
|
||||
assert!(result.pvalue < 0.001);
|
||||
assert!(result.ci_95.0 > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ttest_1samp_not_significant() {
|
||||
let data = vec![0.1, -0.2, 0.15, -0.1, 0.05, -0.05, 0.1, -0.15];
|
||||
let result = ttest_1samp(&data, 0.0).unwrap();
|
||||
|
||||
assert!(result.pvalue > 0.05);
|
||||
assert!(result.ci_95.0 < 0.0 && result.ci_95.1 > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ttest_rel() {
|
||||
let a = vec![10.0, 11.0, 12.0, 13.0, 14.0, 15.0];
|
||||
let b = vec![8.0, 9.0, 10.0, 11.0, 12.0, 13.0];
|
||||
let result = ttest_rel(&a, &b).unwrap();
|
||||
|
||||
assert!(result.statistic > 0.0);
|
||||
assert!(result.pvalue < 0.001);
|
||||
assert!((result.mean - 2.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ttest_ind() {
|
||||
let a = vec![10.0, 11.0, 12.0, 10.5, 11.5, 10.8];
|
||||
let b = vec![5.0, 6.0, 5.5, 6.5, 5.8, 6.2];
|
||||
let result = ttest_ind(&a, &b).unwrap();
|
||||
|
||||
assert!(result.statistic > 0.0);
|
||||
assert!(result.pvalue < 0.001);
|
||||
assert!(result.ci_95.0 > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_f_oneway_significant() {
|
||||
let group1 = vec![10.0, 11.0, 12.0, 10.5, 11.5];
|
||||
let group2 = vec![5.0, 6.0, 5.5, 6.5, 5.8];
|
||||
let group3 = vec![15.0, 16.0, 15.5, 16.5, 15.8];
|
||||
|
||||
let result = f_oneway(&[&group1, &group2, &group3]).unwrap();
|
||||
|
||||
assert!(result.statistic > 0.0);
|
||||
assert!(result.pvalue < 0.001);
|
||||
assert!(result.eta_squared > 0.8); // Large effect
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_f_oneway_not_significant() {
|
||||
let group1 = vec![10.0, 11.0, 9.0, 10.5, 9.5];
|
||||
let group2 = vec![10.2, 10.8, 9.2, 10.3, 9.7];
|
||||
let group3 = vec![9.8, 11.2, 9.1, 10.7, 9.6];
|
||||
|
||||
let result = f_oneway(&[&group1, &group2, &group3]).unwrap();
|
||||
|
||||
assert!(result.pvalue > 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ttest_vectorized() {
|
||||
let data = vec![
|
||||
vec![2.0, 0.1, 3.0],
|
||||
vec![2.2, -0.1, 2.8],
|
||||
vec![1.9, 0.05, 3.1],
|
||||
vec![2.1, -0.05, 2.9],
|
||||
];
|
||||
|
||||
let (t_stats, pvalues) = ttest_1samp_vectorized(&data, 0.0).unwrap();
|
||||
|
||||
assert_eq!(t_stats.len(), 3);
|
||||
assert_eq!(pvalues.len(), 3);
|
||||
// First and third features should be significant
|
||||
assert!(pvalues[0] < 0.05);
|
||||
assert!(pvalues[2] < 0.05);
|
||||
// Second feature should not be significant
|
||||
assert!(pvalues[1] > 0.05);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
//! Permutation tests for hypothesis testing
|
||||
//!
|
||||
//! Provides non-parametric permutation tests that don't assume normal distribution.
|
||||
|
||||
use crate::error::{NeuroError, NeuroResult};
|
||||
use crate::stats::utils;
|
||||
use rand::prelude::*;
|
||||
use rayon::prelude::*;
|
||||
|
||||
/// Tail of the test (one-sided or two-sided)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tail {
|
||||
/// Two-sided test (H1: mean != popmean)
|
||||
TwoSided,
|
||||
/// Left-tailed test (H1: mean < popmean)
|
||||
Less,
|
||||
/// Right-tailed test (H1: mean > popmean)
|
||||
Greater,
|
||||
}
|
||||
|
||||
/// Result of a permutation test
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PermutationResult {
|
||||
/// The observed test statistic
|
||||
pub statistic: f64,
|
||||
/// The p-value
|
||||
pub pvalue: f64,
|
||||
/// The null distribution (permuted statistics)
|
||||
pub null_distribution: Vec<f64>,
|
||||
/// Number of permutations performed
|
||||
pub n_permutations: usize,
|
||||
}
|
||||
|
||||
/// One-sample permutation test
|
||||
///
|
||||
/// Tests whether the mean of a sample differs from a population mean.
|
||||
/// Uses sign-flipping to generate the null distribution.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - Sample data
|
||||
/// * `popmean` - Population mean to test against
|
||||
/// * `n_permutations` - Number of permutations (default: 10000)
|
||||
/// * `tail` - Type of test (two-sided, less, greater)
|
||||
/// * `seed` - Optional random seed for reproducibility
|
||||
///
|
||||
/// # Returns
|
||||
/// A `PermutationResult` with the test statistic and p-value
|
||||
pub fn permutation_test_1samp(
|
||||
data: &[f64],
|
||||
popmean: f64,
|
||||
n_permutations: usize,
|
||||
tail: Tail,
|
||||
seed: Option<u64>,
|
||||
) -> NeuroResult<PermutationResult> {
|
||||
if data.len() < 2 {
|
||||
return Err(NeuroError::Stats(format!(
|
||||
"Insufficient data: need at least 2 samples, got {}",
|
||||
data.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// Center the data
|
||||
let centered: Vec<f64> = data.iter().map(|x| x - popmean).collect();
|
||||
|
||||
// Compute observed t-statistic
|
||||
let obs_stat = compute_t_statistic_1samp(¢ered);
|
||||
|
||||
// Generate null distribution using sign-flipping
|
||||
let null_dist: Vec<f64> = (0..n_permutations)
|
||||
.into_par_iter()
|
||||
.map(|i| {
|
||||
let mut rng = match seed {
|
||||
Some(s) => StdRng::seed_from_u64(s.wrapping_add(i as u64)),
|
||||
None => StdRng::from_entropy(),
|
||||
};
|
||||
|
||||
// Randomly flip signs
|
||||
let flipped: Vec<f64> = centered
|
||||
.iter()
|
||||
.map(|&x| if rng.r#gen::<bool>() { x } else { -x })
|
||||
.collect();
|
||||
|
||||
compute_t_statistic_1samp(&flipped)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Compute p-value
|
||||
let pvalue = compute_pvalue(obs_stat, &null_dist, tail);
|
||||
|
||||
Ok(PermutationResult {
|
||||
statistic: obs_stat,
|
||||
pvalue,
|
||||
null_distribution: null_dist,
|
||||
n_permutations,
|
||||
})
|
||||
}
|
||||
|
||||
/// Paired-sample permutation test
|
||||
///
|
||||
/// Tests whether the mean difference between paired samples differs from zero.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `a` - First sample
|
||||
/// * `b` - Second sample (paired with a)
|
||||
/// * `n_permutations` - Number of permutations
|
||||
/// * `tail` - Type of test
|
||||
/// * `seed` - Optional random seed
|
||||
pub fn permutation_test_rel(
|
||||
a: &[f64],
|
||||
b: &[f64],
|
||||
n_permutations: usize,
|
||||
tail: Tail,
|
||||
seed: Option<u64>,
|
||||
) -> NeuroResult<PermutationResult> {
|
||||
if a.len() != b.len() {
|
||||
return Err(NeuroError::Stats(format!(
|
||||
"Arrays must have same length: {} vs {}",
|
||||
a.len(),
|
||||
b.len()
|
||||
)));
|
||||
}
|
||||
|
||||
if a.len() < 2 {
|
||||
return Err(NeuroError::Stats(format!(
|
||||
"Insufficient data: need at least 2 samples, got {}",
|
||||
a.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// Compute differences
|
||||
let diff: Vec<f64> = a.iter().zip(b.iter()).map(|(x, y)| x - y).collect();
|
||||
|
||||
// Use one-sample test on differences
|
||||
permutation_test_1samp(&diff, 0.0, n_permutations, tail, seed)
|
||||
}
|
||||
|
||||
/// Independent two-sample permutation test
|
||||
///
|
||||
/// Tests whether two independent samples have different means.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `a` - First sample
|
||||
/// * `b` - Second sample
|
||||
/// * `n_permutations` - Number of permutations
|
||||
/// * `tail` - Type of test
|
||||
/// * `seed` - Optional random seed
|
||||
pub fn permutation_test_ind(
|
||||
a: &[f64],
|
||||
b: &[f64],
|
||||
n_permutations: usize,
|
||||
tail: Tail,
|
||||
seed: Option<u64>,
|
||||
) -> NeuroResult<PermutationResult> {
|
||||
if a.len() < 2 {
|
||||
return Err(NeuroError::Stats(format!(
|
||||
"Insufficient data in sample A: need at least 2 samples, got {}",
|
||||
a.len()
|
||||
)));
|
||||
}
|
||||
if b.len() < 2 {
|
||||
return Err(NeuroError::Stats(format!(
|
||||
"Insufficient data in sample B: need at least 2 samples, got {}",
|
||||
b.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// Combine samples
|
||||
let mut combined: Vec<f64> = a.to_vec();
|
||||
combined.extend_from_slice(b);
|
||||
let n_a = a.len();
|
||||
|
||||
// Compute observed t-statistic
|
||||
let obs_stat = compute_t_statistic_ind(a, b);
|
||||
|
||||
// Generate null distribution by permuting group labels
|
||||
let null_dist: Vec<f64> = (0..n_permutations)
|
||||
.into_par_iter()
|
||||
.map(|i| {
|
||||
let mut rng = match seed {
|
||||
Some(s) => StdRng::seed_from_u64(s.wrapping_add(i as u64)),
|
||||
None => StdRng::from_entropy(),
|
||||
};
|
||||
|
||||
// Shuffle combined data
|
||||
let mut shuffled = combined.clone();
|
||||
shuffled.shuffle(&mut rng);
|
||||
|
||||
// Split into two groups
|
||||
let perm_a = &shuffled[..n_a];
|
||||
let perm_b = &shuffled[n_a..];
|
||||
|
||||
compute_t_statistic_ind(perm_a, perm_b)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Compute p-value
|
||||
let pvalue = compute_pvalue(obs_stat, &null_dist, tail);
|
||||
|
||||
Ok(PermutationResult {
|
||||
statistic: obs_stat,
|
||||
pvalue,
|
||||
null_distribution: null_dist,
|
||||
n_permutations,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute one-sample t-statistic
|
||||
fn compute_t_statistic_1samp(data: &[f64]) -> f64 {
|
||||
let _n = data.len() as f64;
|
||||
let mean = utils::mean(data);
|
||||
let se = utils::sem(data);
|
||||
|
||||
if se < 1e-10 {
|
||||
if mean.abs() < 1e-10 {
|
||||
0.0
|
||||
} else {
|
||||
mean.signum() * f64::INFINITY
|
||||
}
|
||||
} else {
|
||||
mean / se
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute independent two-sample t-statistic (Welch's t-test)
|
||||
fn compute_t_statistic_ind(a: &[f64], b: &[f64]) -> f64 {
|
||||
let mean_a = utils::mean(a);
|
||||
let mean_b = utils::mean(b);
|
||||
let var_a = utils::variance(a, 1);
|
||||
let var_b = utils::variance(b, 1);
|
||||
let n_a = a.len() as f64;
|
||||
let n_b = b.len() as f64;
|
||||
|
||||
let se = (var_a / n_a + var_b / n_b).sqrt();
|
||||
|
||||
if se < 1e-10 {
|
||||
let diff = mean_a - mean_b;
|
||||
if diff.abs() < 1e-10 {
|
||||
0.0
|
||||
} else {
|
||||
diff.signum() * f64::INFINITY
|
||||
}
|
||||
} else {
|
||||
(mean_a - mean_b) / se
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute p-value from null distribution
|
||||
fn compute_pvalue(observed: f64, null_dist: &[f64], tail: Tail) -> f64 {
|
||||
let n = null_dist.len() as f64;
|
||||
|
||||
match tail {
|
||||
Tail::TwoSided => {
|
||||
let abs_obs = observed.abs();
|
||||
let count = null_dist.iter().filter(|&&x| x.abs() >= abs_obs).count();
|
||||
(count as f64 + 1.0) / (n + 1.0)
|
||||
}
|
||||
Tail::Greater => {
|
||||
let count = null_dist.iter().filter(|&&x| x >= observed).count();
|
||||
(count as f64 + 1.0) / (n + 1.0)
|
||||
}
|
||||
Tail::Less => {
|
||||
let count = null_dist.iter().filter(|&&x| x <= observed).count();
|
||||
(count as f64 + 1.0) / (n + 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-dimensional permutation test for arrays
|
||||
///
|
||||
/// Performs element-wise permutation tests on multi-dimensional data.
|
||||
/// Useful for testing significance at each time point or sensor.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - 2D array [n_observations x n_features]
|
||||
/// * `n_permutations` - Number of permutations
|
||||
/// * `tail` - Type of test
|
||||
/// * `seed` - Optional random seed
|
||||
///
|
||||
/// # Returns
|
||||
/// Tuple of (t_statistics, p_values) for each feature
|
||||
pub fn permutation_test_1samp_nd(
|
||||
data: &[Vec<f64>],
|
||||
popmean: f64,
|
||||
n_permutations: usize,
|
||||
tail: Tail,
|
||||
seed: Option<u64>,
|
||||
) -> NeuroResult<(Vec<f64>, Vec<f64>)> {
|
||||
if data.is_empty() {
|
||||
return Err(NeuroError::Stats("Empty data array".to_string()));
|
||||
}
|
||||
|
||||
let n_obs = data.len();
|
||||
let n_features = data[0].len();
|
||||
|
||||
if n_obs < 2 {
|
||||
return Err(NeuroError::Stats(format!(
|
||||
"Insufficient data: need at least 2 samples, got {}",
|
||||
n_obs
|
||||
)));
|
||||
}
|
||||
|
||||
// Check all rows have same length
|
||||
for row in data {
|
||||
if row.len() != n_features {
|
||||
return Err(NeuroError::Stats(
|
||||
"All rows must have same length".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Center data
|
||||
let centered: Vec<Vec<f64>> = data
|
||||
.iter()
|
||||
.map(|row| row.iter().map(|x| x - popmean).collect())
|
||||
.collect();
|
||||
|
||||
// Compute observed t-statistics for each feature
|
||||
let obs_stats: Vec<f64> = (0..n_features)
|
||||
.map(|j| {
|
||||
let col: Vec<f64> = centered.iter().map(|row| row[j]).collect();
|
||||
compute_t_statistic_1samp(&col)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Generate null distributions
|
||||
let null_dists: Vec<Vec<f64>> = (0..n_permutations)
|
||||
.into_par_iter()
|
||||
.map(|i| {
|
||||
let mut rng = match seed {
|
||||
Some(s) => StdRng::seed_from_u64(s.wrapping_add(i as u64)),
|
||||
None => StdRng::from_entropy(),
|
||||
};
|
||||
|
||||
// Generate sign flips
|
||||
let signs: Vec<f64> = (0..n_obs)
|
||||
.map(|_| if rng.r#gen::<bool>() { 1.0 } else { -1.0 })
|
||||
.collect();
|
||||
|
||||
// Compute t-statistics for each feature
|
||||
(0..n_features)
|
||||
.map(|j| {
|
||||
let col: Vec<f64> = centered
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, row)| row[j] * signs[k])
|
||||
.collect();
|
||||
compute_t_statistic_1samp(&col)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Compute p-values for each feature
|
||||
let pvalues: Vec<f64> = (0..n_features)
|
||||
.map(|j| {
|
||||
let null_dist: Vec<f64> = null_dists.iter().map(|perm| perm[j]).collect();
|
||||
compute_pvalue(obs_stats[j], &null_dist, tail)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok((obs_stats, pvalues))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_permutation_1samp_significant() {
|
||||
// Data clearly above 0
|
||||
let data = vec![2.1, 2.3, 1.9, 2.5, 2.2, 2.0, 2.4, 2.1];
|
||||
let result = permutation_test_1samp(&data, 0.0, 1000, Tail::TwoSided, Some(42)).unwrap();
|
||||
|
||||
assert!(result.statistic > 0.0);
|
||||
assert!(result.pvalue < 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_permutation_1samp_not_significant() {
|
||||
// Data centered around 0
|
||||
let data = vec![0.1, -0.2, 0.15, -0.1, 0.05, -0.05, 0.1, -0.15];
|
||||
let result = permutation_test_1samp(&data, 0.0, 1000, Tail::TwoSided, Some(42)).unwrap();
|
||||
|
||||
assert!(result.pvalue > 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_permutation_paired() {
|
||||
// Paired samples with clear difference
|
||||
let a = vec![10.0, 11.0, 12.0, 13.0, 14.0];
|
||||
let b = vec![8.0, 9.0, 10.0, 11.0, 12.0];
|
||||
let result = permutation_test_rel(&a, &b, 1000, Tail::Greater, Some(42)).unwrap();
|
||||
|
||||
assert!(result.statistic > 0.0);
|
||||
assert!(result.pvalue < 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_permutation_ind() {
|
||||
// Two independent groups with different means
|
||||
let a = vec![10.0, 11.0, 12.0, 10.5, 11.5];
|
||||
let b = vec![5.0, 6.0, 5.5, 6.5, 5.8];
|
||||
let result = permutation_test_ind(&a, &b, 1000, Tail::TwoSided, Some(42)).unwrap();
|
||||
|
||||
assert!(result.statistic > 0.0);
|
||||
assert!(result.pvalue < 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_permutation_nd() {
|
||||
// Multi-dimensional data with more samples for reliable p-values
|
||||
let data = vec![
|
||||
vec![2.0, 0.1, 3.0],
|
||||
vec![2.2, -0.1, 2.8],
|
||||
vec![1.9, 0.05, 3.1],
|
||||
vec![2.1, -0.05, 2.9],
|
||||
vec![2.05, 0.02, 3.05],
|
||||
vec![1.95, -0.02, 2.95],
|
||||
vec![2.15, 0.08, 3.08],
|
||||
vec![2.08, -0.08, 2.92],
|
||||
];
|
||||
let (stats, pvals) =
|
||||
permutation_test_1samp_nd(&data, 0.0, 1000, Tail::TwoSided, Some(42)).unwrap();
|
||||
|
||||
assert_eq!(stats.len(), 3);
|
||||
assert_eq!(pvals.len(), 3);
|
||||
// First and third features should have large t-statistics
|
||||
assert!(stats[0] > 5.0);
|
||||
assert!(stats[2] > 5.0);
|
||||
// Second feature should have small t-statistic
|
||||
assert!(stats[1].abs() < 2.0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user