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,408 @@
//! 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::{Result, StatsError, 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]) -> Result<EffectSize> {
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);
// 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]) -> Result<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]) -> Result<EffectSize> {
if treatment.len() < 2 {
return Err(StatsError::InsufficientData {
needed: 2,
got: treatment.len(),
});
}
if control.len() < 2 {
return Err(StatsError::InsufficientData {
needed: 2,
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]) -> Result<EffectSize> {
if a.len() != b.len() {
return Err(StatsError::DimensionMismatch(format!(
"Arrays must have same length: {} vs {}",
a.len(),
b.len()
)));
}
if a.len() < 2 {
return Err(StatsError::InsufficientData {
needed: 2,
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);
}
}