chronix_analytics/multivariate/
correlation.rs1use crate::compute::{simd_dot_product, simd_mean};
4
5use crate::multivariate::error::MultivariateError;
6
7fn filter_nan_pairs(x: &[f64], y: &[f64]) -> (Vec<f64>, Vec<f64>) {
9 let (mut xf, mut yf) = (Vec::with_capacity(x.len()), Vec::with_capacity(y.len()));
10 for (&xv, &yv) in x.iter().zip(y.iter()) {
11 if xv.is_finite() && yv.is_finite() {
12 xf.push(xv);
13 yf.push(yv);
14 }
15 }
16 (xf, yf)
17}
18
19pub trait CorrelationMethod: Send + Sync {
21 fn compute(&self, x: &[f64], y: &[f64]) -> Result<f64, MultivariateError>;
23
24 fn supports_incremental(&self) -> bool;
26}
27
28pub struct PearsonCorrelation;
30
31impl CorrelationMethod for PearsonCorrelation {
32 fn compute(&self, x: &[f64], y: &[f64]) -> Result<f64, MultivariateError> {
33 Self::compute(x, y)
34 }
35 fn supports_incremental(&self) -> bool {
36 true
37 }
38}
39
40impl PearsonCorrelation {
41 pub fn compute(x: &[f64], y: &[f64]) -> Result<f64, MultivariateError> {
61 if x.len() != y.len() {
62 return Err(MultivariateError::DimensionMismatch {
63 expected: x.len(),
64 got: y.len(),
65 });
66 }
67 let (x, y) = filter_nan_pairs(x, y);
69 if x.len() < 2 {
70 return Err(MultivariateError::InsufficientData {
71 min: 2,
72 got: x.len(),
73 });
74 }
75 let mx = simd_mean(&x);
76 let my = simd_mean(&y);
77 let dx: Vec<f64> = x.iter().map(|v| v - mx).collect();
78 let dy: Vec<f64> = y.iter().map(|v| v - my).collect();
79 let cov = simd_dot_product(&dx, &dy).map_err(|_| MultivariateError::InsufficientData {
80 min: 2,
81 got: dx.len(),
82 })?;
83 let sx = simd_dot_product(&dx, &dx)
84 .map_err(|_| MultivariateError::InsufficientData {
85 min: 2,
86 got: dx.len(),
87 })?
88 .sqrt();
89 let sy = simd_dot_product(&dy, &dy)
90 .map_err(|_| MultivariateError::InsufficientData {
91 min: 2,
92 got: dy.len(),
93 })?
94 .sqrt();
95 if sx < 1e-15 || sy < 1e-15 {
96 return Ok(f64::NAN);
97 }
98 Ok(cov / (sx * sy))
99 }
100}
101
102pub struct SpearmanCorrelation;
104
105impl CorrelationMethod for SpearmanCorrelation {
106 fn compute(&self, x: &[f64], y: &[f64]) -> Result<f64, MultivariateError> {
107 Self::compute(x, y)
108 }
109 fn supports_incremental(&self) -> bool {
110 false
111 }
112}
113
114impl SpearmanCorrelation {
115 pub fn compute(x: &[f64], y: &[f64]) -> Result<f64, MultivariateError> {
117 if x.len() != y.len() {
118 return Err(MultivariateError::DimensionMismatch {
119 expected: x.len(),
120 got: y.len(),
121 });
122 }
123 let (x, y) = filter_nan_pairs(x, y);
125 if x.len() < 2 {
126 return Err(MultivariateError::InsufficientData {
127 min: 2,
128 got: x.len(),
129 });
130 }
131 let rx = rank(&x);
132 let ry = rank(&y);
133 PearsonCorrelation::compute(&rx, &ry)
134 }
135}
136
137fn rank(data: &[f64]) -> Vec<f64> {
138 let n = data.len();
139 let mut indexed: Vec<(usize, f64)> = data.iter().copied().enumerate().collect();
140 indexed.sort_by(|a, b| a.1.total_cmp(&b.1));
141 let mut ranks = vec![0.0; n];
142 let mut i = 0;
143 while i < n {
144 let mut j = i;
145 while j < n && (indexed[j].1 - indexed[i].1).abs() < 1e-15 {
146 j += 1;
147 }
148 let avg_rank = (i + j + 1) as f64 / 2.0;
149 for item in &indexed[i..j] {
150 ranks[item.0] = avg_rank;
151 }
152 i = j;
153 }
154 ranks
155}
156
157pub struct KendallTau;
159
160impl CorrelationMethod for KendallTau {
161 fn compute(&self, x: &[f64], y: &[f64]) -> Result<f64, MultivariateError> {
162 Self::compute(x, y)
163 }
164 fn supports_incremental(&self) -> bool {
165 false
166 }
167}
168
169impl KendallTau {
170 pub fn compute(x: &[f64], y: &[f64]) -> Result<f64, MultivariateError> {
176 if x.len() != y.len() {
177 return Err(MultivariateError::DimensionMismatch {
178 expected: x.len(),
179 got: y.len(),
180 });
181 }
182 let (x, y) = filter_nan_pairs(x, y);
184 let n = x.len();
185 if n < 2 {
186 return Err(MultivariateError::InsufficientData { min: 2, got: n });
187 }
188
189 let mut pairs: Vec<(f64, f64)> = x.iter().copied().zip(y.iter().copied()).collect();
191 pairs.sort_by(|a, b| a.0.total_cmp(&b.0).then_with(|| a.1.total_cmp(&b.1)));
192
193 let mut y_sorted: Vec<f64> = pairs.iter().map(|p| p.1).collect();
195 let mut scratch = vec![0.0f64; n];
196 let discordant = Self::merge_sort_count(&mut y_sorted, &mut scratch);
197
198 let n0 = (n * (n - 1) / 2) as f64; let mut ties_x: i64 = 0;
202 let mut i = 0;
203 while i < n {
204 let mut j = i + 1;
205 while j < n && (pairs[j].0 - pairs[i].0).abs() < 1e-15 {
206 j += 1;
207 }
208 let run = (j - i) as i64;
209 ties_x += run * (run - 1) / 2;
210 i = j;
211 }
212
213 let mut y_vals: Vec<f64> = pairs.iter().map(|p| p.1).collect();
215 y_vals.sort_by(f64::total_cmp);
216 let mut ties_y: i64 = 0;
217 let mut i = 0;
218 while i < n {
219 let mut j = i + 1;
220 while j < n && (y_vals[j] - y_vals[i]).abs() < 1e-15 {
221 j += 1;
222 }
223 let run = (j - i) as i64;
224 ties_y += run * (run - 1) / 2;
225 i = j;
226 }
227
228 let mut ties_xy: i64 = 0;
230 let mut i = 0;
231 while i < n {
232 let mut j = i + 1;
233 while j < n
234 && (pairs[j].0 - pairs[i].0).abs() < 1e-15
235 && (pairs[j].1 - pairs[i].1).abs() < 1e-15
236 {
237 j += 1;
238 }
239 let run = (j - i) as i64;
240 ties_xy += run * (run - 1) / 2;
241 i = j;
242 }
243
244 let concordant = n0 as i64 - discordant as i64 - ties_x - ties_y + ties_xy;
247 let denom = ((n0 - ties_x as f64) * (n0 - ties_y as f64)).sqrt();
248 if denom < 1e-15 {
249 return Ok(0.0);
250 }
251 let tau = (concordant - discordant as i64) as f64 / denom;
252 Ok(tau.clamp(-1.0, 1.0))
253 }
254
255 fn merge_sort_count(arr: &mut [f64], scratch: &mut [f64]) -> usize {
261 let n = arr.len();
262 if n <= 1 {
263 return 0;
264 }
265 let mid = n / 2;
266
267 let mut count = Self::merge_sort_count(&mut arr[..mid], &mut scratch[..mid])
268 + Self::merge_sort_count(&mut arr[mid..], &mut scratch[mid..n]);
269
270 let (mut i, mut j, mut k) = (0, 0, 0);
272 while i < mid && j < n - mid {
273 if arr[i] <= arr[mid + j] {
274 scratch[k] = arr[i];
275 i += 1;
276 } else {
277 scratch[k] = arr[mid + j];
278 count += mid - i; j += 1;
280 }
281 k += 1;
282 }
283 while i < mid {
284 scratch[k] = arr[i];
285 i += 1;
286 k += 1;
287 }
288 while j < n - mid {
289 scratch[k] = arr[mid + j];
290 j += 1;
291 k += 1;
292 }
293 arr[..n].copy_from_slice(&scratch[..n]);
294 count
295 }
296}
297
298pub struct RollingCorrelation {
300 window: usize,
301}
302
303impl RollingCorrelation {
304 pub fn new(window: usize) -> Self {
306 Self {
307 window: window.max(1),
308 }
309 }
310
311 pub fn compute(&self, x: &[f64], y: &[f64]) -> Result<Vec<f64>, MultivariateError> {
313 if x.len() != y.len() {
314 return Err(MultivariateError::DimensionMismatch {
315 expected: x.len(),
316 got: y.len(),
317 });
318 }
319 let n = x.len();
320 if n < self.window {
321 return Err(MultivariateError::InsufficientData {
322 min: self.window,
323 got: n,
324 });
325 }
326
327 let mut result = Vec::with_capacity(n);
328 let mut sx = 0.0;
329 let mut sy = 0.0;
330 let mut sxx = 0.0;
331 let mut syy = 0.0;
332 let mut sxy = 0.0;
333
334 for i in 0..n {
335 sx += x[i];
336 sy += y[i];
337 sxx += x[i] * x[i];
338 syy += y[i] * y[i];
339 sxy += x[i] * y[i];
340
341 if i >= self.window {
342 let old = i - self.window;
343 sx -= x[old];
344 sy -= y[old];
345 sxx -= x[old] * x[old];
346 syy -= y[old] * y[old];
347 sxy -= x[old] * y[old];
348 }
349
350 if i > 0 && i % 1024 == 0 {
352 let start = i.saturating_sub(self.window.saturating_sub(1));
353 sx = x[start..=i].iter().sum();
354 sy = y[start..=i].iter().sum();
355 sxx = x[start..=i].iter().map(|&v| v * v).sum();
356 syy = y[start..=i].iter().map(|&v| v * v).sum();
357 sxy = x[start..=i]
358 .iter()
359 .zip(&y[start..=i])
360 .map(|(&a, &b)| a * b)
361 .sum();
362 }
363
364 if i + 1 < self.window {
380 result.push(f64::NAN);
381 continue;
382 }
383
384 let count = (i + 1).min(self.window) as f64;
385 let mx = sx / count;
386 let my = sy / count;
387 let cov = sxy / count - mx * my;
388 let vx = (sxx / count - mx * mx).max(0.0);
389 let vy = (syy / count - my * my).max(0.0);
390 let denom = (vx * vy).sqrt();
391 if denom < 1e-15 {
392 result.push(f64::NAN);
393 } else {
394 result.push(cov / denom);
395 }
396 }
397 Ok(result)
398 }
399}
400
401pub struct LagCorrelation;
403
404impl LagCorrelation {
405 pub fn compute(
422 x: &[f64],
423 y: &[f64],
424 lags: &[i32],
425 ) -> Result<Vec<(i32, f64)>, MultivariateError> {
426 if x.len() != y.len() {
427 return Err(MultivariateError::DimensionMismatch {
428 expected: x.len(),
429 got: y.len(),
430 });
431 }
432 let mut results = Vec::with_capacity(lags.len());
433 for &lag in lags {
434 let (xs, ys) = if lag >= 0 {
435 let l = lag as usize;
436 if l >= x.len() {
437 results.push((lag, f64::NAN));
438 continue;
439 }
440 (&x[l..], &y[..x.len() - l])
441 } else {
442 let l = (-lag) as usize;
443 if l >= y.len() {
444 results.push((lag, f64::NAN));
445 continue;
446 }
447 (&x[..y.len() - l], &y[l..])
448 };
449 let r = PearsonCorrelation::compute(xs, ys).unwrap_or(f64::NAN);
450 results.push((lag, r));
451 }
452 Ok(results)
453 }
454}
455
456pub struct CrossCorrelationMatrix;
458
459impl CrossCorrelationMatrix {
460 pub fn compute(data: &[&[f64]]) -> Result<Vec<Vec<f64>>, MultivariateError> {
464 let _start = std::time::Instant::now();
465 let n = data.len();
466 let mut matrix = vec![vec![0.0; n]; n];
467 for i in 0..n {
468 matrix[i][i] = 1.0;
469 for j in (i + 1)..n {
470 let r = PearsonCorrelation::compute(data[i], data[j]).unwrap_or(f64::NAN);
471 matrix[i][j] = r;
472 matrix[j][i] = r;
473 }
474 }
475 metrics::histogram!("chronix_correlation_compute_duration_seconds")
476 .record(_start.elapsed().as_secs_f64());
477 Ok(matrix)
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 #[test]
486 fn pearson_perfectly_correlated() {
487 let x: Vec<f64> = (0..100).map(|i| i as f64).collect();
488 let y: Vec<f64> = (0..100).map(|i| i as f64 * 2.0 + 5.0).collect();
489 let r = PearsonCorrelation::compute(&x, &y).unwrap();
490 assert!((r - 1.0).abs() < 1e-6);
491 }
492
493 #[test]
494 fn pearson_anti_correlated() {
495 let x: Vec<f64> = (0..100).map(|i| i as f64).collect();
496 let y: Vec<f64> = (0..100).map(|i| -(i as f64)).collect();
497 let r = PearsonCorrelation::compute(&x, &y).unwrap();
498 assert!((r - (-1.0)).abs() < 1e-6);
499 }
500
501 #[test]
502 fn spearman_rank_correlation() {
503 let x: Vec<f64> = (0..50).map(|i| i as f64).collect();
504 let y: Vec<f64> = (0..50).map(|i| (i as f64).powi(2)).collect();
505 let r = SpearmanCorrelation::compute(&x, &y).unwrap();
506 assert!((r - 1.0).abs() < 1e-6);
508 }
509
510 #[test]
511 fn kendall_tau() {
512 let x: Vec<f64> = (0..20).map(|i| i as f64).collect();
513 let y: Vec<f64> = (0..20).map(|i| i as f64 * 3.0).collect();
514 let r = KendallTau::compute(&x, &y).unwrap();
515 assert!((r - 1.0).abs() < 1e-6);
516 }
517
518 #[test]
522 fn rolling_correlation_conventions_match_preprocess() {
523 let n = 60;
524 let window = 10;
525 let x: Vec<f64> = (0..n).map(|i| (i as f64 * 0.7).sin()).collect();
526 let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.7).cos()).collect();
527
528 let ours = RollingCorrelation::new(window).compute(&x, &y).unwrap();
529 let theirs = crate::preprocess::rolling_corr(&x, &y, window);
530
531 assert_eq!(ours.len(), theirs.len());
532 for i in 0..n {
533 assert_eq!(
534 ours[i].is_nan(),
535 theirs[i].is_nan(),
536 "NaN convention differs at index {i}: {} vs {}",
537 ours[i],
538 theirs[i]
539 );
540 if !ours[i].is_nan() {
541 assert!(
542 (ours[i] - theirs[i]).abs() < 1e-9,
543 "values differ at index {i}: {} vs {}",
544 ours[i],
545 theirs[i]
546 );
547 }
548 }
549
550 assert!(ours[..window - 1].iter().all(|v| v.is_nan()));
552 assert!(!ours[window - 1].is_nan());
553 }
554
555 #[test]
558 fn zero_variance_is_nan_not_zero() {
559 let x = vec![5.0; 40];
560 let y: Vec<f64> = (0..40).map(|i| i as f64).collect();
561 let r = RollingCorrelation::new(10).compute(&x, &y).unwrap();
562 assert!(
563 r[39].is_nan(),
564 "zero-variance correlation should be NaN, got {}",
565 r[39]
566 );
567 }
568
569 #[test]
570 fn rolling_matches_offline() {
571 let n = 200;
572 let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
573 let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.5) + 3.0).collect();
574 let rc = RollingCorrelation::new(50);
575 let result = rc.compute(&x, &y).unwrap();
576 assert!((result[n - 1] - 1.0).abs() < 1e-3);
578 }
579
580 #[test]
581 fn cross_correlation_matrix() {
582 let a: Vec<f64> = (0..100).map(|i| i as f64).collect();
583 let b: Vec<f64> = (0..100).map(|i| i as f64 * 2.0).collect();
584 let c: Vec<f64> = (0..100).map(|i| -(i as f64)).collect();
585 let data: Vec<&[f64]> = vec![&a, &b, &c];
586 let mat = CrossCorrelationMatrix::compute(&data).unwrap();
587 assert_eq!(mat.len(), 3);
588 assert!((mat[0][1] - 1.0).abs() < 1e-6); assert!((mat[0][2] - (-1.0)).abs() < 1e-6); assert!((mat[0][0] - 1.0).abs() < 1e-15); }
592
593 #[test]
594 fn lag_correlation() {
595 let x: Vec<f64> = (0..100).map(|i| i as f64).collect();
596 let y: Vec<f64> = (0..100).map(|i| i as f64).collect();
597 let results = LagCorrelation::compute(&x, &y, &[-1, 0, 1]).unwrap();
598 assert_eq!(results.len(), 3);
599 assert!((results[1].1 - 1.0).abs() < 1e-3);
601 }
602
603 #[test]
608 fn an_undefined_lag_correlation_is_nan() {
609 let x = vec![1.0, 2.0, 3.0, 4.0];
610 let y = vec![2.0, 4.0, 6.0, 8.0];
611
612 let out = LagCorrelation::compute(&x, &y, &[10, -10]).unwrap();
614 assert!(out.iter().all(|(_, r)| r.is_nan()), "{out:?}");
615
616 let flat = vec![7.0; 4];
618 let out = LagCorrelation::compute(&flat, &y, &[0]).unwrap();
619 assert!(out[0].1.is_nan(), "{out:?}");
620
621 let out = LagCorrelation::compute(&x, &y, &[0]).unwrap();
623 assert!((out[0].1 - 1.0).abs() < 1e-9, "{out:?}");
624 }
625
626 #[test]
627 fn correlation_method_trait_dispatch() {
628 let x: Vec<f64> = (0..50).map(|i| i as f64).collect();
629 let y: Vec<f64> = (0..50).map(|i| i as f64 * 2.0 + 1.0).collect();
630
631 let methods: Vec<Box<dyn CorrelationMethod>> = vec![
633 Box::new(PearsonCorrelation),
634 Box::new(SpearmanCorrelation),
635 Box::new(KendallTau),
636 ];
637
638 for method in &methods {
639 let r = method.compute(&x, &y).unwrap();
640 assert!(
641 (r - 1.0).abs() < 1e-6,
642 "{}: expected ~1.0, got {}",
643 if method.supports_incremental() {
644 "Pearson"
645 } else {
646 "other"
647 },
648 r
649 );
650 }
651
652 assert!(PearsonCorrelation.supports_incremental());
654 assert!(!SpearmanCorrelation.supports_incremental());
655 assert!(!KendallTau.supports_incremental());
656 }
657}