1use crate::infer;
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, PartialEq)]
8#[cfg_attr(feature = "serde", derive(serde::Serialize))]
9pub struct CorrelationMatrix {
10 pub labels: Vec<String>,
12 pub values: Vec<Vec<f64>>,
14}
15
16#[derive(Debug, Clone, PartialEq)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize))]
19pub struct PointBiserialEntry {
20 pub categorical: String,
22 pub numeric: String,
24 pub correlation: f64,
26}
27
28#[derive(Debug, Clone, PartialEq)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize))]
31pub struct Relationships {
32 pub pearson: Option<CorrelationMatrix>,
34 pub cramers_v: Option<CorrelationMatrix>,
36 pub point_biserial: Vec<PointBiserialEntry>,
38}
39
40impl Relationships {
41 pub fn compute<T: AsRef<str>>(
43 numeric_cols: &[(&str, &[f64])],
44 categorical_cols: &[(&str, &[T])],
45 ) -> Option<Self> {
46 let pearson = compute_pearson(numeric_cols);
47 let cramers_v = compute_cramers_v(categorical_cols);
48 let point_biserial = compute_point_biserial(numeric_cols, categorical_cols);
49
50 if pearson.is_none() && cramers_v.is_none() && point_biserial.is_empty() {
51 None
52 } else {
53 Some(Relationships {
54 pearson,
55 cramers_v,
56 point_biserial,
57 })
58 }
59 }
60}
61
62fn compute_pearson(cols: &[(&str, &[f64])]) -> Option<CorrelationMatrix> {
64 if cols.len() < 2 {
65 return None;
66 }
67 let n_rows = cols[0].1.len();
68 if n_rows == 0 {
69 return None;
70 }
71
72 let mut rows_data: Vec<Vec<f64>> = vec![vec![0.0; cols.len()]; n_rows];
77 for (j, (_, values)) in cols.iter().enumerate() {
78 if values.len() != n_rows {
79 return None;
80 }
81 for i in 0..n_rows {
82 rows_data[i][j] = values[i];
83 }
84 }
85
86 let labels: Vec<String> = cols.iter().map(|(name, _)| name.to_string()).collect();
87 let values = datarust::stats::correlation_matrix(&rows_data);
88
89 Some(CorrelationMatrix { labels, values })
90}
91
92struct CodedColumn<'a> {
96 levels: Vec<&'a str>,
98 codes: Vec<usize>,
100}
101
102fn encode_column<'a, T: AsRef<str> + 'a>(cells: &'a [T]) -> CodedColumn<'a> {
104 let mut map: HashMap<&'a str, usize> = HashMap::new();
105 let mut levels: Vec<&'a str> = Vec::new();
106 let mut codes = Vec::with_capacity(cells.len());
107 for cell in cells {
108 let cell = cell.as_ref();
109 if infer::is_missing(cell) {
110 codes.push(usize::MAX);
111 continue;
112 }
113 let trimmed = cell.trim();
114 let next = levels.len();
115 let code = *map.entry(trimmed).or_insert_with(|| {
116 levels.push(trimmed);
117 next
118 });
119 codes.push(code);
120 }
121 CodedColumn { levels, codes }
122}
123
124fn compute_cramers_v<T: AsRef<str>>(cols: &[(&str, &[T])]) -> Option<CorrelationMatrix> {
126 if cols.len() < 2 {
127 return None;
128 }
129 let n_rows = cols[0].1.len();
130 if n_rows == 0 {
131 return None;
132 }
133
134 let encoded: Vec<CodedColumn> = cols
135 .iter()
136 .map(|(_, values)| encode_column(values))
137 .collect();
138
139 let p = cols.len();
140 let labels: Vec<String> = cols.iter().map(|(name, _)| name.to_string()).collect();
141 let mut values = vec![vec![0.0; p]; p];
142
143 for i in 0..p {
144 values[i][i] = 1.0;
145 for j in (i + 1)..p {
146 let v = cramers_v_from_codes(&encoded[i], &encoded[j]);
147 values[i][j] = v;
148 values[j][i] = v;
149 }
150 }
151
152 Some(CorrelationMatrix { labels, values })
153}
154
155fn cramers_v_from_codes(a: &CodedColumn, b: &CodedColumn) -> f64 {
163 let n = a.codes.len().min(b.codes.len());
164 let a_r = a.levels.len();
165 let a_c = b.levels.len();
166 let mut total = 0usize;
167 let mut row_totals = vec![0usize; a_r];
168 let mut col_totals = vec![0usize; a_c];
169
170 let flat_limit = 1 << 16;
171 let mut flat: Option<Vec<usize>> = if a_r * a_c <= flat_limit {
172 Some(vec![0; a_r * a_c])
173 } else {
174 None
175 };
176 let mut counts: HashMap<usize, usize> = HashMap::new();
177
178 for i in 0..n {
179 let ri = a.codes[i];
180 let ci = b.codes[i];
181 if ri == usize::MAX || ci == usize::MAX {
182 continue;
183 }
184 row_totals[ri] += 1;
185 col_totals[ci] += 1;
186 let key = ri * a_c + ci;
187 match flat.as_mut() {
188 Some(t) => t[key] += 1,
189 None => *counts.entry(key).or_insert(0) += 1,
190 }
191 total += 1;
192 }
193
194 if total == 0 {
195 return 0.0;
196 }
197 let r = row_totals.iter().filter(|&&t| t > 0).count();
199 let c = col_totals.iter().filter(|&&t| t > 0).count();
200 if r <= 1 || c <= 1 {
201 return 0.0;
202 }
203
204 let n_f = total as f64;
205 let mut chi2 = 0.0;
206 match flat {
207 Some(t) => {
208 for (key, &obs) in t.iter().enumerate() {
209 if obs == 0 {
210 continue;
211 }
212 let ri = key / a_c;
213 let ci = key % a_c;
214 let expected = row_totals[ri] as f64 * col_totals[ci] as f64 / n_f;
215 if expected > 0.0 {
216 let obs_f = obs as f64;
217 chi2 += obs_f * obs_f / expected;
218 }
219 }
220 }
221 None => {
222 for (&key, &obs) in &counts {
223 let ri = key / a_c;
224 let ci = key % a_c;
225 let expected = row_totals[ri] as f64 * col_totals[ci] as f64 / n_f;
226 if expected > 0.0 {
227 let obs_f = obs as f64;
228 chi2 += obs_f * obs_f / expected;
229 }
230 }
231 }
232 }
233 chi2 -= n_f;
234
235 let min_dim = (r - 1).min(c - 1) as f64;
236 if min_dim == 0.0 {
237 0.0
238 } else {
239 let v = (chi2 / (n_f * min_dim)).sqrt();
240 if v.is_nan() {
241 0.0
242 } else {
243 v.min(1.0)
244 }
245 }
246}
247
248fn compute_point_biserial<T: AsRef<str>>(
250 numeric_cols: &[(&str, &[f64])],
251 categorical_cols: &[(&str, &[T])],
252) -> Vec<PointBiserialEntry> {
253 let mut entries = Vec::new();
254
255 for (cat_name, cat_values) in categorical_cols {
256 let mut unique_vals: Vec<&str> = Vec::new();
258 for val in *cat_values {
259 let val = val.as_ref();
260 if infer::is_missing(val) {
261 continue;
262 }
263 let trimmed = val.trim();
264 if !unique_vals.contains(&trimmed) {
265 unique_vals.push(trimmed);
266 }
267 if unique_vals.len() > 2 {
268 break;
269 }
270 }
271
272 if unique_vals.len() != 2 {
274 continue;
275 }
276
277 let val_0 = unique_vals[0];
278
279 let binary_encoded: Vec<f64> = cat_values
281 .iter()
282 .map(|cell| {
283 let cell = cell.as_ref();
284 if infer::is_missing(cell) {
285 f64::NAN
286 } else if cell.trim() == val_0 {
287 0.0
288 } else {
289 1.0
290 }
291 })
292 .collect();
293
294 for (num_name, num_values) in numeric_cols {
295 if let Some(r) = calculate_point_biserial(&binary_encoded, num_values) {
296 entries.push(PointBiserialEntry {
297 categorical: cat_name.to_string(),
298 numeric: num_name.to_string(),
299 correlation: r,
300 });
301 }
302 }
303 }
304
305 entries
306}
307
308fn calculate_point_biserial(binary: &[f64], numeric: &[f64]) -> Option<f64> {
316 let n = binary.len().min(numeric.len());
317 if n == 0 {
318 return None;
319 }
320
321 let (mut n0, mut n1, mut sum0, mut sum1) = (0usize, 0usize, 0.0, 0.0);
324 let mut sum_sq = 0.0;
325 for i in 0..n {
326 let b = binary[i];
327 let y = numeric[i];
328 if b.is_finite() && y.is_finite() {
329 if b == 0.0 {
330 n0 += 1;
331 sum0 += y;
332 sum_sq += y * y;
333 } else if b == 1.0 {
334 n1 += 1;
335 sum1 += y;
336 sum_sq += y * y;
337 }
338 }
339 }
340
341 let total_n = n0 + n1;
342 if n0 == 0 || n1 == 0 || total_n < 3 {
343 return None;
344 }
345
346 let m0 = sum0 / n0 as f64;
347 let m1 = sum1 / n1 as f64;
348 let total_sum = sum0 + sum1;
349
350 let ss = sum_sq - (total_sum * total_sum) / total_n as f64;
352 let s_y = (ss / (total_n - 1) as f64).sqrt();
353
354 if s_y == 0.0 {
355 return Some(0.0);
356 }
357
358 let r = ((m1 - m0) / s_y) * ((n0 * n1) as f64 / (total_n * (total_n - 1)) as f64).sqrt();
359
360 if r.is_nan() {
361 None
362 } else {
363 Some(r.clamp(-1.0, 1.0))
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370
371 #[test]
372 fn compute_pearson_returns_none_for_less_than_two_cols() {
373 let cols = vec![("a", &[1.0, 2.0][..])];
374 assert!(compute_pearson(&cols).is_none());
375 }
376
377 #[test]
378 fn compute_pearson_returns_none_for_empty() {
379 let cols = vec![("a", &[][..]), ("b", &[][..])];
380 assert!(compute_pearson(&cols).is_none());
381 }
382
383 #[test]
384 fn compute_pearson_perfect_correlation() {
385 let cols = vec![("x", &[1.0, 2.0, 3.0][..]), ("y", &[2.0, 4.0, 6.0][..])];
386 let mat = compute_pearson(&cols).unwrap();
387 assert_eq!(mat.labels, vec!["x", "y"]);
388 assert!((mat.values[0][1] - 1.0).abs() < 1e-6);
389 assert!((mat.values[1][0] - 1.0).abs() < 1e-6);
390 assert!((mat.values[0][0] - 1.0).abs() < 1e-6);
391 assert!((mat.values[1][1] - 1.0).abs() < 1e-6);
392 }
393
394 #[test]
395 fn compute_pearson_negative_correlation() {
396 let cols = vec![("x", &[1.0, 2.0, 3.0][..]), ("y", &[3.0, 2.0, 1.0][..])];
397 let mat = compute_pearson(&cols).unwrap();
398 assert!((mat.values[0][1] + 1.0).abs() < 1e-6);
399 }
400
401 #[test]
402 fn compute_pearson_uncorrelated() {
403 let cols = vec![("x", &[1.0, 2.0, 3.0][..]), ("y", &[2.0, 1.0, 3.0][..])];
404 let mat = compute_pearson(&cols).unwrap();
405 assert!(mat.values[0][1].abs() < 1.0);
406 }
407
408 #[test]
409 fn compute_cramers_v_returns_none_for_less_than_two_cols() {
410 let a = ["x".to_string()];
411 let cols = vec![("a", &a[..])];
412 assert!(compute_cramers_v(&cols).is_none());
413 }
414
415 #[test]
416 fn compute_cramers_v_perfect_association() {
417 let a = [
418 "x".to_string(),
419 "x".to_string(),
420 "y".to_string(),
421 "y".to_string(),
422 ];
423 let b = [
424 "p".to_string(),
425 "p".to_string(),
426 "q".to_string(),
427 "q".to_string(),
428 ];
429 let cols = vec![("a", &a[..]), ("b", &b[..])];
430 let mat = compute_cramers_v(&cols).unwrap();
431 assert_eq!(mat.labels, vec!["a", "b"]);
432 assert!((mat.values[0][1] - 1.0).abs() < 1e-6);
433 }
434
435 #[test]
436 fn compute_cramers_v_with_missing() {
437 let a = ["x".to_string(), "NA".to_string(), "y".to_string()];
438 let b = ["p".to_string(), "q".to_string(), "NA".to_string()];
439 let cols = vec![("a", &a[..]), ("b", &b[..])];
440 let mat = compute_cramers_v(&cols).unwrap();
441 assert!(mat.values[0][1] >= 0.0);
443 }
444
445 #[test]
446 fn compute_cramers_v_single_level() {
447 let a = ["x".to_string(), "x".to_string(), "x".to_string()];
448 let b = ["p".to_string(), "q".to_string(), "r".to_string()];
449 let cols = vec![("a", &a[..]), ("b", &b[..])];
450 let mat = compute_cramers_v(&cols).unwrap();
451 assert_eq!(mat.values[0][1], 0.0);
453 }
454
455 #[test]
456 fn compute_point_biserial_binary_categorical() {
457 let numeric = vec![("num", &[1.0, 1.0, 5.0, 5.0][..])];
458 let cat = [
459 "low".to_string(),
460 "low".to_string(),
461 "high".to_string(),
462 "high".to_string(),
463 ];
464 let categorical = vec![("cat", &cat[..])];
465 let entries = compute_point_biserial(&numeric, &categorical);
466 assert_eq!(entries.len(), 1);
467 assert_eq!(entries[0].categorical, "cat");
468 assert_eq!(entries[0].numeric, "num");
469 assert!((entries[0].correlation.abs() - 1.0).abs() < 1e-6);
470 }
471
472 #[test]
473 fn compute_point_biserial_non_binary_skipped() {
474 let numeric = vec![("num", &[1.0, 2.0, 3.0][..])];
475 let cat = ["a".to_string(), "b".to_string(), "c".to_string()];
476 let categorical = vec![("cat", &cat[..])];
477 let entries = compute_point_biserial(&numeric, &categorical);
478 assert_eq!(entries.len(), 0);
479 }
480
481 #[test]
482 fn compute_point_biserial_with_missing() {
483 let numeric = vec![("num", &[1.0, f64::NAN, 5.0, 5.0][..])];
484 let cat = [
485 "low".to_string(),
486 "low".to_string(),
487 "high".to_string(),
488 "high".to_string(),
489 ];
490 let categorical = vec![("cat", &cat[..])];
491 let entries = compute_point_biserial(&numeric, &categorical);
492 assert_eq!(entries.len(), 1);
494 }
495
496 #[test]
497 fn relationships_compute_all() {
498 let numeric = vec![
499 ("x", &[1.0, 2.0, 3.0, 4.0][..]),
500 ("y", &[2.0, 4.0, 6.0, 8.0][..]),
501 ];
502 let cat = [
503 "p".to_string(),
504 "p".to_string(),
505 "q".to_string(),
506 "q".to_string(),
507 ];
508 let categorical = vec![("a", &cat[..])];
509 let rels = Relationships::compute(&numeric, &categorical).unwrap();
510 assert!(rels.pearson.is_some());
511 assert!(rels.cramers_v.is_none()); assert!(!rels.point_biserial.is_empty());
513 }
514
515 #[test]
516 fn relationships_compute_only_categorical() {
517 let numeric = vec![];
518 let a = [
519 "p".to_string(),
520 "p".to_string(),
521 "q".to_string(),
522 "q".to_string(),
523 ];
524 let b = [
525 "x".to_string(),
526 "x".to_string(),
527 "y".to_string(),
528 "y".to_string(),
529 ];
530 let categorical = vec![("a", &a[..]), ("b", &b[..])];
531 let rels = Relationships::compute(&numeric, &categorical).unwrap();
532 assert!(rels.pearson.is_none());
533 assert!(rels.cramers_v.is_some());
534 }
535
536 fn calculate_point_biserial_two_pass(binary: &[f64], numeric: &[f64]) -> Option<f64> {
540 let n = binary.len().min(numeric.len());
541 if n == 0 {
542 return None;
543 }
544
545 let (mut n0, mut n1, mut sum0, mut sum1) = (0usize, 0usize, 0.0, 0.0);
547 for i in 0..n {
548 let b = binary[i];
549 let y = numeric[i];
550 if b.is_finite() && y.is_finite() {
551 if b == 0.0 {
552 n0 += 1;
553 sum0 += y;
554 } else if b == 1.0 {
555 n1 += 1;
556 sum1 += y;
557 }
558 }
559 }
560
561 let total_n = n0 + n1;
562 if n0 == 0 || n1 == 0 || total_n < 3 {
563 return None;
564 }
565
566 let m0 = sum0 / n0 as f64;
567 let m1 = sum1 / n1 as f64;
568 let m_all = (sum0 + sum1) / total_n as f64;
569
570 let mut ss = 0.0;
572 for i in 0..n {
573 let b = binary[i];
574 let y = numeric[i];
575 if b.is_finite() && y.is_finite() && (b == 0.0 || b == 1.0) {
576 let d = y - m_all;
577 ss += d * d;
578 }
579 }
580 let s_y = (ss / (total_n - 1) as f64).sqrt();
581
582 if s_y == 0.0 {
583 return Some(0.0);
584 }
585
586 let r = ((m1 - m0) / s_y) * ((n0 * n1) as f64 / (total_n * (total_n - 1)) as f64).sqrt();
587
588 if r.is_nan() {
589 None
590 } else {
591 Some(r.clamp(-1.0, 1.0))
592 }
593 }
594
595 #[test]
596 fn point_biserial_single_pass_matches_two_pass() {
597 let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
600 let mut next = || -> f64 {
601 state ^= state >> 12;
602 state ^= state << 25;
603 state ^= state >> 27;
604 (state.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64 * 2.0
605 - 1.0
606 };
607
608 let cases: Vec<(Vec<f64>, Vec<f64>)> = (0..50)
609 .map(|_| {
610 let len = 20 + (next().abs() * 980.0) as usize; let binary: Vec<f64> = (0..len)
612 .map(|i| {
613 let r = next();
614 if i % 7 == 0 {
615 f64::NAN
616 } else if i % 13 == 0 {
617 f64::INFINITY
618 } else if r > 0.3 {
619 1.0
620 } else if r < -0.3 {
621 0.0
622 } else {
623 2.0 }
625 })
626 .collect();
627 let numeric: Vec<f64> = (0..len).map(|_| next() * 100.0).collect();
628 (binary, numeric)
629 })
630 .collect();
631
632 for (i, (binary, numeric)) in cases.iter().enumerate() {
633 let got = calculate_point_biserial(binary, numeric);
634 let want = calculate_point_biserial_two_pass(binary, numeric);
635 match (got, want) {
636 (Some(g), Some(w)) => {
637 assert!(
641 (g - w).abs() < 1e-14
642 || (g.to_bits() as i64 - w.to_bits() as i64).unsigned_abs() <= 1,
643 "case {i}: single-pass {g} != two-pass {w}"
644 );
645 }
646 (None, None) => {}
647 (g, w) => {
648 panic!("case {i}: mismatch: single-pass={g:?}, two-pass={w:?}");
649 }
650 }
651 }
652 }
653
654 #[test]
655 fn point_biserial_single_pass_matches_two_pass_known_values() {
656 let cases: &[(&[f64], &[f64], Option<f64>)] = &[
658 (
660 &[0.0, 0.0, 1.0, 1.0, 1.0],
661 &[5.0, 5.0, 5.0, 5.0, 5.0],
662 Some(0.0),
663 ),
664 (&[0.0, 1.0], &[1.0, 2.0], None),
666 (&[0.0, 0.0, 0.0], &[1.0, 2.0, 3.0], None),
668 (&[0.0, f64::NAN, 0.0], &[1.0, 2.0, 3.0], None),
670 (&[0.0, 1.0, 1.0], &[f64::INFINITY, 10.0, 11.0], None),
672 (
678 &[0.0, 0.0, 1.0, 1.0],
679 &[0.0, 10.0, 20.0, 30.0],
680 Some(0.8944271909999159),
681 ),
682 ];
683
684 for (i, (binary, numeric, expected)) in cases.iter().enumerate() {
685 let got_single = calculate_point_biserial(binary, numeric);
686 let got_two = calculate_point_biserial_two_pass(binary, numeric);
687 assert_eq!(got_single, got_two, "case {i}: methods disagree");
688 if let Some(exp) = expected {
689 let r = got_single.unwrap();
690 assert_eq!(
691 r.to_bits(),
692 exp.to_bits(),
693 "case {i}: got {r}, expected {exp}"
694 );
695 } else {
696 assert!(
697 got_single.is_none(),
698 "case {i}: expected None, got {got_single:?}"
699 );
700 }
701 }
702 }
703}