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

452 lines
12 KiB
Rust

//! 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::{Result, StatsError, 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) -> Result<TTestResult> {
if data.len() < 2 {
return Err(StatsError::InsufficientData {
needed: 2,
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| {
StatsError::ComputationError(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]) -> Result<TTestResult> {
if a.len() != b.len() {
return Err(StatsError::DimensionMismatch(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]) -> Result<TTestResult> {
if a.len() < 2 {
return Err(StatsError::InsufficientData {
needed: 2,
got: a.len(),
});
}
if b.len() < 2 {
return Err(StatsError::InsufficientData {
needed: 2,
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| {
StatsError::ComputationError(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]]) -> Result<FTestResult> {
if groups.len() < 2 {
return Err(StatsError::InvalidInput(
"Need at least 2 groups for ANOVA".to_string(),
));
}
for group in groups {
if group.len() < 2 {
return Err(StatsError::InsufficientData {
needed: 2,
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| {
StatsError::ComputationError(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) -> Result<(Vec<f64>, Vec<f64>)> {
if data.is_empty() {
return Err(StatsError::InvalidInput("Empty data".to_string()));
}
let n_obs = data.len();
let n_features = data[0].len();
if n_obs < 2 {
return Err(StatsError::InsufficientData {
needed: 2,
got: n_obs,
});
}
let df = (n_obs - 1) as f64;
let t_dist = StudentsT::new(0.0, 1.0, df).map_err(|e| {
StatsError::ComputationError(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);
}
}