409 lines
11 KiB
Rust
409 lines
11 KiB
Rust
//! 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);
|
|
}
|
|
}
|