use crate::equivalence::{EquivalenceBounds, OneSidedTestResult, TostResult};
use crate::error::{Result, StatError};
use statrs::distribution::{ContinuousCDF, Normal};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum CorrelationTostMethod {
Pearson,
Spearman,
}
pub fn tost_correlation(
x: &[f64],
y: &[f64],
rho_null: f64,
bounds: &EquivalenceBounds,
alpha: f64,
method: CorrelationTostMethod,
) -> Result<TostResult> {
validate_inputs(x, y, alpha)?;
let n = x.len();
if n < 4 {
return Err(StatError::InsufficientData { needed: 4, got: n });
}
let r = match method {
CorrelationTostMethod::Pearson => pearson_cor(x, y)?,
CorrelationTostMethod::Spearman => spearman_cor(x, y)?,
};
let (lower_bound, upper_bound) = bounds_for_correlation(bounds)?;
if lower_bound < -1.0 || upper_bound > 1.0 {
return Err(StatError::InvalidParameter(
"Correlation bounds must be between -1 and 1".to_string(),
));
}
let z_r = fisher_z(r);
let z_lower = fisher_z(lower_bound + rho_null);
let z_upper = fisher_z(upper_bound + rho_null);
let se_z = 1.0 / ((n - 3) as f64).sqrt();
let normal = Normal::new(0.0, 1.0)
.map_err(|e| StatError::InvalidParameter(format!("Failed to create normal: {}", e)))?;
let stat_lower = (z_r - z_lower) / se_z;
let p_lower = normal.sf(stat_lower);
let stat_upper = (z_r - z_upper) / se_z;
let p_upper = normal.cdf(stat_upper);
let tost_p = p_lower.max(p_upper);
let z_crit = normal.inverse_cdf(1.0 - alpha);
let ci_z_lower = z_r - z_crit * se_z;
let ci_z_upper = z_r + z_crit * se_z;
let ci_r_lower = fisher_z_inv(ci_z_lower);
let ci_r_upper = fisher_z_inv(ci_z_upper);
let estimate = r - rho_null;
let ci = (ci_r_lower - rho_null, ci_r_upper - rho_null);
let equivalent = ci.0 >= lower_bound && ci.1 <= upper_bound;
let method_name = match method {
CorrelationTostMethod::Pearson => "Correlation TOST (Pearson)",
CorrelationTostMethod::Spearman => "Correlation TOST (Spearman)",
};
Ok(TostResult {
estimate,
ci,
bounds: (lower_bound, upper_bound),
lower_test: OneSidedTestResult {
hypothesis: format!("H0: r <= {:.4}", lower_bound + rho_null),
statistic: stat_lower,
p_value: p_lower,
rejected: p_lower < alpha,
},
upper_test: OneSidedTestResult {
hypothesis: format!("H0: r >= {:.4}", upper_bound + rho_null),
statistic: stat_upper,
p_value: p_upper,
rejected: p_upper < alpha,
},
tost_p_value: tost_p,
equivalent,
alpha,
n,
df: Some((n - 3) as f64), method: method_name.to_string(),
})
}
fn fisher_z(r: f64) -> f64 {
let r = r.clamp(-0.9999999, 0.9999999);
0.5 * ((1.0 + r) / (1.0 - r)).ln()
}
fn fisher_z_inv(z: f64) -> f64 {
z.tanh()
}
fn pearson_cor(x: &[f64], y: &[f64]) -> Result<f64> {
let n = x.len() as f64;
let mean_x: f64 = x.iter().sum::<f64>() / n;
let mean_y: f64 = y.iter().sum::<f64>() / n;
let mut sum_xy = 0.0;
let mut sum_x2 = 0.0;
let mut sum_y2 = 0.0;
for (xi, yi) in x.iter().zip(y.iter()) {
let dx = xi - mean_x;
let dy = yi - mean_y;
sum_xy += dx * dy;
sum_x2 += dx * dx;
sum_y2 += dy * dy;
}
let denom = (sum_x2 * sum_y2).sqrt();
if denom == 0.0 {
return Err(StatError::InvalidParameter(
"Cannot compute correlation: zero variance".to_string(),
));
}
Ok(sum_xy / denom)
}
fn spearman_cor(x: &[f64], y: &[f64]) -> Result<f64> {
let ranks_x = compute_ranks(x);
let ranks_y = compute_ranks(y);
pearson_cor(&ranks_x, &ranks_y)
}
fn compute_ranks(data: &[f64]) -> Vec<f64> {
let n = data.len();
let mut indexed: Vec<(usize, f64)> = data.iter().enumerate().map(|(i, &v)| (i, v)).collect();
indexed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
let mut ranks = vec![0.0; n];
let mut i = 0;
while i < n {
let mut j = i;
while j < n && indexed[j].1 == indexed[i].1 {
j += 1;
}
let avg_rank = (i + 1 + j) as f64 / 2.0;
for item in indexed.iter().take(j).skip(i) {
ranks[item.0] = avg_rank;
}
i = j;
}
ranks
}
fn bounds_for_correlation(bounds: &EquivalenceBounds) -> Result<(f64, f64)> {
match bounds {
EquivalenceBounds::Raw { lower, upper } => Ok((*lower, *upper)),
EquivalenceBounds::Symmetric { delta } => Ok((-*delta, *delta)),
EquivalenceBounds::CohenD { .. } => Err(StatError::InvalidParameter(
"Cohen's d bounds not applicable for correlation TOST; use Raw or Symmetric bounds"
.to_string(),
)),
}
}
fn validate_inputs(x: &[f64], y: &[f64], alpha: f64) -> Result<()> {
if x.is_empty() || y.is_empty() {
return Err(StatError::EmptyData);
}
if x.len() != y.len() {
return Err(StatError::InvalidParameter(format!(
"x and y must have same length: {} vs {}",
x.len(),
y.len()
)));
}
if !(0.0 < alpha && alpha < 1.0) {
return Err(StatError::InvalidParameter(format!(
"alpha must be between 0 and 1, got {}",
alpha
)));
}
for (i, (&xi, &yi)) in x.iter().zip(y.iter()).enumerate() {
if !xi.is_finite() || !yi.is_finite() {
return Err(StatError::InvalidParameter(format!(
"Non-finite value at index {}: x={}, y={}",
i, xi, yi
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_correlation_tost_weak_correlation() {
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
let y = vec![1.5, 1.8, 3.2, 4.1, 4.8, 6.3, 7.0, 7.9, 9.2, 10.1];
let bounds = EquivalenceBounds::Symmetric { delta: 0.3 };
let result =
tost_correlation(&x, &y, 0.0, &bounds, 0.05, CorrelationTostMethod::Pearson).unwrap();
assert!(!result.equivalent);
}
#[test]
fn test_correlation_tost_near_zero() {
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
let y = vec![5.1, 4.9, 5.0, 5.2, 4.8, 5.1, 4.9, 5.0, 5.1, 4.9];
let bounds = EquivalenceBounds::Symmetric { delta: 0.5 };
let result =
tost_correlation(&x, &y, 0.0, &bounds, 0.05, CorrelationTostMethod::Pearson).unwrap();
assert!(result.estimate.abs() < 0.3);
}
#[test]
fn test_spearman_correlation_tost() {
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
let y = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
let bounds = EquivalenceBounds::Symmetric { delta: 0.3 };
let result =
tost_correlation(&x, &y, 0.0, &bounds, 0.05, CorrelationTostMethod::Spearman).unwrap();
assert!((result.estimate - 1.0).abs() < 0.01);
}
#[test]
fn test_fisher_z_transformation() {
assert!((fisher_z(0.0)).abs() < 1e-10);
assert!(fisher_z(0.5) > 0.0);
assert!(fisher_z(-0.5) < 0.0);
for r in [-0.9, -0.5, 0.0, 0.5, 0.9] {
let z = fisher_z(r);
let r_back = fisher_z_inv(z);
assert!((r - r_back).abs() < 1e-10);
}
}
#[test]
fn test_invalid_bounds_for_correlation() {
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let y = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let bounds = EquivalenceBounds::CohenD { d: 0.5 };
let result = tost_correlation(&x, &y, 0.0, &bounds, 0.05, CorrelationTostMethod::Pearson);
assert!(result.is_err());
}
#[test]
fn test_correlation_bounds_outside_range() {
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let y = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let bounds = EquivalenceBounds::Raw {
lower: -1.5,
upper: 1.5,
};
let result = tost_correlation(&x, &y, 0.0, &bounds, 0.05, CorrelationTostMethod::Pearson);
assert!(result.is_err());
}
}