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