use crate::categorical::{expected_frequencies, validate_2x2_table, validate_contingency_table};
use crate::error::{Result, StatError};
use statrs::distribution::{ContinuousCDF, Normal};
#[derive(Debug, Clone)]
pub struct AssociationResult {
pub estimate: f64,
pub se: Option<f64>,
pub conf_int_lower: Option<f64>,
pub conf_int_upper: Option<f64>,
pub method: String,
}
#[derive(Debug, Clone)]
pub struct KappaResult {
pub kappa: f64,
pub se: f64,
pub z: f64,
pub p_value: f64,
pub conf_int_lower: f64,
pub conf_int_upper: f64,
pub weighted: bool,
pub method: String,
}
pub fn cramers_v(observed: &[Vec<usize>]) -> Result<AssociationResult> {
let (n_rows, n_cols) = validate_contingency_table(observed)?;
if n_rows < 2 || n_cols < 2 {
return Err(StatError::InvalidParameter(
"Table must have at least 2 rows and 2 columns".to_string(),
));
}
let expected = expected_frequencies(observed);
let total: usize = observed.iter().map(|row| row.iter().sum::<usize>()).sum();
let mut chi_sq = 0.0;
for i in 0..n_rows {
for j in 0..n_cols {
let o = observed[i][j] as f64;
let e = expected[i][j];
if e > 0.0 {
chi_sq += (o - e).powi(2) / e;
}
}
}
let k = (n_rows - 1).min(n_cols - 1) as f64;
let v = if total > 0 && k > 0.0 {
(chi_sq / (total as f64 * k)).sqrt()
} else {
0.0
};
Ok(AssociationResult {
estimate: v,
se: None,
conf_int_lower: None,
conf_int_upper: None,
method: "Cramer's V".to_string(),
})
}
pub fn phi_coefficient(table: &[[usize; 2]; 2]) -> Result<AssociationResult> {
validate_2x2_table(table)?;
let a = table[0][0] as f64;
let b = table[0][1] as f64;
let c = table[1][0] as f64;
let d = table[1][1] as f64;
let numerator = a * d - b * c;
let denominator = ((a + b) * (c + d) * (a + c) * (b + d)).sqrt();
let phi = if denominator > 0.0 {
numerator / denominator
} else {
0.0
};
Ok(AssociationResult {
estimate: phi,
se: None,
conf_int_lower: None,
conf_int_upper: None,
method: "Phi coefficient".to_string(),
})
}
pub fn contingency_coef(observed: &[Vec<usize>]) -> Result<AssociationResult> {
let (n_rows, n_cols) = validate_contingency_table(observed)?;
if n_rows < 2 || n_cols < 2 {
return Err(StatError::InvalidParameter(
"Table must have at least 2 rows and 2 columns".to_string(),
));
}
let expected = expected_frequencies(observed);
let total: usize = observed.iter().map(|row| row.iter().sum::<usize>()).sum();
let mut chi_sq = 0.0;
for i in 0..n_rows {
for j in 0..n_cols {
let o = observed[i][j] as f64;
let e = expected[i][j];
if e > 0.0 {
chi_sq += (o - e).powi(2) / e;
}
}
}
let c = if chi_sq + total as f64 > 0.0 {
(chi_sq / (chi_sq + total as f64)).sqrt()
} else {
0.0
};
Ok(AssociationResult {
estimate: c,
se: None,
conf_int_lower: None,
conf_int_upper: None,
method: "Contingency coefficient".to_string(),
})
}
pub fn cohen_kappa(table: &[Vec<usize>], weighted: bool) -> Result<KappaResult> {
let (n_rows, n_cols) = validate_contingency_table(table)?;
if n_rows != n_cols {
return Err(StatError::InvalidParameter(
"Kappa requires a square table (same number of rows and columns)".to_string(),
));
}
let k = n_rows; let n: usize = table.iter().map(|row| row.iter().sum::<usize>()).sum();
if n == 0 {
return Err(StatError::EmptyData);
}
let n_f = n as f64;
let row_totals: Vec<f64> = table
.iter()
.map(|row| row.iter().sum::<usize>() as f64)
.collect();
let col_totals: Vec<f64> = (0..k)
.map(|j| table.iter().map(|row| row[j]).sum::<usize>() as f64)
.collect();
let (kappa, se) = if weighted && k > 2 {
let w: Vec<Vec<f64>> = (0..k)
.map(|i| {
(0..k)
.map(|j| 1.0 - (i as f64 - j as f64).abs() / (k - 1) as f64)
.collect()
})
.collect();
let mut po_w = 0.0;
for i in 0..k {
for j in 0..k {
po_w += w[i][j] * table[i][j] as f64 / n_f;
}
}
let mut pe_w = 0.0;
for i in 0..k {
for j in 0..k {
pe_w += w[i][j] * row_totals[i] * col_totals[j] / (n_f * n_f);
}
}
let kappa_w = if (1.0 - pe_w).abs() > 1e-10 {
(po_w - pe_w) / (1.0 - pe_w)
} else {
1.0
};
let se_w = weighted_kappa_se(&w, table, n, &row_totals, &col_totals, kappa_w);
(kappa_w, se_w)
} else {
let po: f64 = (0..k).map(|i| table[i][i] as f64).sum::<f64>() / n_f;
let pe: f64 = (0..k).map(|i| row_totals[i] * col_totals[i]).sum::<f64>() / (n_f * n_f);
let kappa = if (1.0 - pe).abs() > 1e-10 {
(po - pe) / (1.0 - pe)
} else {
1.0
};
let se = kappa_se(table, n, &row_totals, &col_totals, po, pe);
(kappa, se)
};
let z = if se > 0.0 { kappa / se } else { 0.0 };
let p_value = if z.is_finite() {
let normal = Normal::new(0.0, 1.0).unwrap();
2.0 * normal.sf(z.abs())
} else {
0.0
};
let z_crit = 1.96;
let conf_int_lower = kappa - z_crit * se;
let conf_int_upper = kappa + z_crit * se;
let method = if weighted {
"Weighted Cohen's Kappa"
} else {
"Cohen's Kappa"
};
Ok(KappaResult {
kappa,
se,
z,
p_value,
conf_int_lower,
conf_int_upper,
weighted,
method: method.to_string(),
})
}
fn kappa_se(
table: &[Vec<usize>],
n: usize,
row_totals: &[f64],
col_totals: &[f64],
po: f64,
pe: f64,
) -> f64 {
let n_f = n as f64;
let k = table.len();
let mut sum1 = 0.0;
for i in 0..k {
let p_ii = table[i][i] as f64 / n_f;
let p_i_plus = row_totals[i] / n_f;
let p_plus_i = col_totals[i] / n_f;
sum1 += p_ii * (1.0 - (p_i_plus + p_plus_i) * (1.0 - po)).powi(2);
}
let term1 = sum1 - (po * pe - 2.0 * pe + po).powi(2);
let term2 = (1.0 - pe).powi(4);
if term2 > 0.0 {
(term1 / (n_f * term2)).sqrt()
} else {
0.0
}
}
fn weighted_kappa_se(
w: &[Vec<f64>],
table: &[Vec<usize>],
n: usize,
row_totals: &[f64],
col_totals: &[f64],
_kappa: f64,
) -> f64 {
let n_f = n as f64;
let k = table.len();
let mut po = 0.0;
let mut pe = 0.0;
for i in 0..k {
for j in 0..k {
po += w[i][j] * table[i][j] as f64 / n_f;
pe += w[i][j] * row_totals[i] * col_totals[j] / (n_f * n_f);
}
}
let var_kappa = (po * (1.0 - po)) / (n_f * (1.0 - pe).powi(2));
var_kappa.sqrt().max(0.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cramers_v_2x2() {
let observed = vec![vec![10, 20], vec![30, 40]];
let result = cramers_v(&observed).unwrap();
assert!(result.estimate >= 0.0 && result.estimate <= 1.0);
}
#[test]
fn test_cramers_v_perfect_association() {
let observed = vec![vec![50, 0], vec![0, 50]];
let result = cramers_v(&observed).unwrap();
assert!((result.estimate - 1.0).abs() < 1e-10);
}
#[test]
fn test_cramers_v_no_association() {
let observed = vec![vec![25, 25], vec![25, 25]];
let result = cramers_v(&observed).unwrap();
assert!((result.estimate - 0.0).abs() < 1e-10);
}
#[test]
fn test_phi_coefficient() {
let table = [[10, 20], [30, 40]];
let result = phi_coefficient(&table).unwrap();
assert!(result.estimate >= -1.0 && result.estimate <= 1.0);
}
#[test]
fn test_phi_perfect_positive() {
let table = [[50, 0], [0, 50]];
let result = phi_coefficient(&table).unwrap();
assert!((result.estimate - 1.0).abs() < 1e-10);
}
#[test]
fn test_contingency_coef() {
let observed = vec![vec![10, 20], vec![30, 40]];
let result = contingency_coef(&observed).unwrap();
assert!(result.estimate >= 0.0 && result.estimate <= 1.0);
}
#[test]
fn test_cohen_kappa_perfect() {
let table = vec![vec![30, 0, 0], vec![0, 30, 0], vec![0, 0, 40]];
let result = cohen_kappa(&table, false).unwrap();
assert!((result.kappa - 1.0).abs() < 1e-10);
}
#[test]
fn test_cohen_kappa_no_agreement() {
let table = vec![vec![0, 50, 0], vec![50, 0, 0], vec![0, 0, 0]];
let result = cohen_kappa(&table, false).unwrap();
assert!(result.kappa < 0.0);
}
#[test]
fn test_cohen_kappa_moderate() {
let table = vec![vec![20, 5, 0], vec![10, 30, 5], vec![0, 5, 25]];
let result = cohen_kappa(&table, false).unwrap();
assert!(result.kappa > 0.0);
assert!(result.p_value < 0.05);
}
#[test]
fn test_weighted_kappa() {
let table = vec![vec![20, 5, 0], vec![10, 30, 5], vec![0, 5, 25]];
let unweighted = cohen_kappa(&table, false).unwrap();
let weighted = cohen_kappa(&table, true).unwrap();
assert!((unweighted.kappa - weighted.kappa).abs() > 0.001);
}
}