Initial commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user