use crate::infer;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct CorrelationMatrix {
pub labels: Vec<String>,
pub values: Vec<Vec<f64>>,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct PointBiserialEntry {
pub categorical: String,
pub numeric: String,
pub correlation: f64,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Relationships {
pub pearson: Option<CorrelationMatrix>,
pub cramers_v: Option<CorrelationMatrix>,
pub point_biserial: Vec<PointBiserialEntry>,
}
impl Relationships {
pub fn compute(
numeric_cols: &[(&str, &[f64])],
categorical_cols: &[(&str, &[String])],
) -> Option<Self> {
let pearson = compute_pearson(numeric_cols);
let cramers_v = compute_cramers_v(categorical_cols);
let point_biserial = compute_point_biserial(numeric_cols, categorical_cols);
if pearson.is_none() && cramers_v.is_none() && point_biserial.is_empty() {
None
} else {
Some(Relationships {
pearson,
cramers_v,
point_biserial,
})
}
}
}
fn compute_pearson(cols: &[(&str, &[f64])]) -> Option<CorrelationMatrix> {
if cols.len() < 2 {
return None;
}
let n_rows = cols[0].1.len();
if n_rows == 0 {
return None;
}
let mut rows_data: Vec<Vec<f64>> = vec![vec![0.0; cols.len()]; n_rows];
for (j, (_, values)) in cols.iter().enumerate() {
if values.len() != n_rows {
return None;
}
for i in 0..n_rows {
rows_data[i][j] = values[i];
}
}
let labels: Vec<String> = cols.iter().map(|(name, _)| name.to_string()).collect();
let values = datarust::stats::correlation_matrix(&rows_data);
Some(CorrelationMatrix { labels, values })
}
fn compute_cramers_v(cols: &[(&str, &[String])]) -> Option<CorrelationMatrix> {
if cols.len() < 2 {
return None;
}
let n_rows = cols[0].1.len();
if n_rows == 0 {
return None;
}
let p = cols.len();
let labels: Vec<String> = cols.iter().map(|(name, _)| name.to_string()).collect();
let mut values = vec![vec![0.0; p]; p];
for i in 0..p {
values[i][i] = 1.0;
for j in (i + 1)..p {
let v = calculate_cramers_v_pair(cols[i].1, cols[j].1);
values[i][j] = v;
values[j][i] = v;
}
}
Some(CorrelationMatrix { labels, values })
}
fn calculate_cramers_v_pair(col_a: &[String], col_b: &[String]) -> f64 {
let n_rows = col_a.len().min(col_b.len());
if n_rows == 0 {
return 0.0;
}
let mut counts: HashMap<(&str, &str), usize> = HashMap::new();
let mut row_levels: HashSet<&str> = HashSet::new();
let mut col_levels: HashSet<&str> = HashSet::new();
let mut total_valid = 0usize;
for i in 0..n_rows {
let cell_a = &col_a[i];
let cell_b = &col_b[i];
if infer::is_missing(cell_a) || infer::is_missing(cell_b) {
continue;
}
let a_str = cell_a.trim();
let b_str = cell_b.trim();
row_levels.insert(a_str);
col_levels.insert(b_str);
*counts.entry((a_str, b_str)).or_insert(0) += 1;
total_valid += 1;
}
let r = row_levels.len();
let c = col_levels.len();
if total_valid == 0 || r <= 1 || c <= 1 {
return 0.0;
}
let mut row_totals: HashMap<&str, usize> = HashMap::new();
let mut col_totals: HashMap<&str, usize> = HashMap::new();
for (&(r_val, c_val), &cnt) in &counts {
*row_totals.entry(r_val).or_insert(0) += cnt;
*col_totals.entry(c_val).or_insert(0) += cnt;
}
let mut chi2 = 0.0;
let n_f64 = total_valid as f64;
for &r_val in &row_levels {
let r_tot = *row_totals.get(r_val).unwrap_or(&0) as f64;
for &c_val in &col_levels {
let c_tot = *col_totals.get(c_val).unwrap_or(&0) as f64;
let expected = (r_tot * c_tot) / n_f64;
if expected > 0.0 {
let observed = *counts.get(&(r_val, c_val)).unwrap_or(&0) as f64;
let diff = observed - expected;
chi2 += (diff * diff) / expected;
}
}
}
let min_dim = (r - 1).min(c - 1) as f64;
if min_dim == 0.0 {
0.0
} else {
let v = (chi2 / (n_f64 * min_dim)).sqrt();
if v.is_nan() {
0.0
} else {
v.min(1.0)
}
}
}
fn compute_point_biserial(
numeric_cols: &[(&str, &[f64])],
categorical_cols: &[(&str, &[String])],
) -> Vec<PointBiserialEntry> {
let mut entries = Vec::new();
for (cat_name, cat_values) in categorical_cols {
let mut unique_vals: Vec<&str> = Vec::new();
for val in *cat_values {
if infer::is_missing(val) {
continue;
}
let trimmed = val.trim();
if !unique_vals.contains(&trimmed) {
unique_vals.push(trimmed);
}
if unique_vals.len() > 2 {
break;
}
}
if unique_vals.len() != 2 {
continue;
}
let val_0 = unique_vals[0];
let binary_encoded: Vec<f64> = cat_values
.iter()
.map(|cell| {
if infer::is_missing(cell) {
f64::NAN
} else if cell.trim() == val_0 {
0.0
} else {
1.0
}
})
.collect();
for (num_name, num_values) in numeric_cols {
if let Some(r) = calculate_point_biserial(&binary_encoded, num_values) {
entries.push(PointBiserialEntry {
categorical: cat_name.to_string(),
numeric: num_name.to_string(),
correlation: r,
});
}
}
}
entries
}
fn calculate_point_biserial(binary: &[f64], numeric: &[f64]) -> Option<f64> {
let n = binary.len().min(numeric.len());
if n == 0 {
return None;
}
let mut group_0 = Vec::new();
let mut group_1 = Vec::new();
for i in 0..n {
let b = binary[i];
let y = numeric[i];
if b.is_finite() && y.is_finite() {
if b == 0.0 {
group_0.push(y);
} else if b == 1.0 {
group_1.push(y);
}
}
}
let n0 = group_0.len();
let n1 = group_1.len();
let total_n = n0 + n1;
if n0 == 0 || n1 == 0 || total_n < 3 {
return None;
}
let m0 = datarust::stats::mean(&group_0);
let m1 = datarust::stats::mean(&group_1);
let all_y: Vec<f64> = group_0.iter().chain(group_1.iter()).copied().collect();
let s_y = datarust::stats::std(&all_y, 1);
if s_y == 0.0 {
return Some(0.0);
}
let r = ((m1 - m0) / s_y) * ((n0 * n1) as f64 / (total_n * (total_n - 1)) as f64).sqrt();
if r.is_nan() {
None
} else {
Some(r.clamp(-1.0, 1.0))
}
}