Initial commit

This commit is contained in:
redclawsystems
2026-03-04 00:08:42 +00:00
commit 4d88dc0584
4449 changed files with 1556714 additions and 0 deletions
@@ -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(&centered);
// 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);
}
}