//! 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 { 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 { 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 { 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 { 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 = 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); } }