Skip to main content

chronix_analytics/multivariate/
correlation.rs

1//! Correlation analysis: Pearson, Spearman, Kendall, rolling, lag, cross-correlation.
2
3use crate::compute::{simd_dot_product, simd_mean};
4
5use crate::multivariate::error::MultivariateError;
6
7/// Pairwise NaN deletion — keeps only indices where both x and y are finite.
8fn 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
19/// Common trait for pairwise correlation methods.
20pub trait CorrelationMethod: Send + Sync {
21    /// Compute the correlation coefficient between two equal-length slices.
22    fn compute(&self, x: &[f64], y: &[f64]) -> Result<f64, MultivariateError>;
23
24    /// Whether this method supports incremental (online) updates.
25    fn supports_incremental(&self) -> bool;
26}
27
28/// Pearson correlation coefficient.
29pub 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    /// Compute Pearson correlation between two equal-length slices.
42    ///
43    /// `NaN` pairs are deleted pairwise. When either series has **zero
44    /// variance** the correlation is undefined and the result is `NaN`, not
45    /// `0.0` — the convention `RollingCorrelation` and
46    /// `rolling_corr`, applied here for the same reason: `0.0` asserts "these
47    /// series are uncorrelated" where the truth is "there is nothing to
48    /// correlate", and a caller acts on the first.
49    ///
50    /// # Errors
51    ///
52    /// [`MultivariateError::DimensionMismatch`] on unequal lengths, and
53    /// [`MultivariateError::InsufficientData`] with fewer than two usable
54    /// pairs.
55    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        // Pairwise NaN deletion
63        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
97/// Spearman rank correlation.
98pub 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    /// Compute Spearman correlation (rank-based).
111    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        // Pairwise NaN deletion
119        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
152/// Kendall Tau-a rank correlation.
153pub 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    /// Compute Kendall Tau-b using O(n log n) merge-sort algorithm (Knight 2006).
166    ///
167    /// Counts discordant pairs via merge-sort-based inversion counting on the
168    /// y-ranks after sorting by x-ranks.  Tau-b accounts for ties on both
169    /// x and y through the denominator √((n₀−n₁)(n₀−n₂)).
170    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        // Pairwise NaN deletion
178        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        // Sort pairs by x, breaking ties by y
185        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        // Extract y-values in x-sorted order; count inversions via merge sort
189        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; // total pairs
194
195        // Count tied pairs on x
196        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        // Count tied pairs on y (from the sorted y values — re-sort by y)
209        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        // Count joint ties (tied on BOTH x and y).
224        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        // concordant = n0 - discordant - (ties_x - ties_xy) - (ties_y - ties_xy) - ties_xy
240        //            = n0 - discordant - ties_x - ties_y + ties_xy
241        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    /// Merge-sort-based inversion count — O(n log n).
251    ///
252    /// Uses `scratch` as a pre-allocated temporary buffer to avoid
253    /// per-recursion allocations.  `scratch` must be at least as long
254    /// as `arr`.
255    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        // Merge into scratch, then copy back
266        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; // all remaining left elements are inversions
274                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
293/// Rolling Pearson correlation with O(1) update per point.
294pub struct RollingCorrelation {
295    window: usize,
296}
297
298impl RollingCorrelation {
299    /// Creates a new rolling correlator with the given window size.
300    pub fn new(window: usize) -> Self {
301        Self {
302            window: window.max(1),
303        }
304    }
305
306    /// Compute rolling Pearson correlation between two series.
307    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            // Periodic FP drift recomputation every 1024 steps.
346            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            // Emit NaN until the window is full, and for an
360            // undefined correlation.
361            //
362            // This function previously reported a value computed from a
363            // *partial* window for the first `window - 1` rows, and `0.0`
364            // when either series had zero variance. Both are wrong and they
365            // disagreed with `preprocess::rolling_corr`, which returns NaN in
366            // exactly those cases — two functions with the same name and
367            // purpose gave different answers for the same input.
368            //
369            // NaN is also the semantically correct value: `0.0` claims "these
370            // series are uncorrelated" when the truth is "correlation is
371            // undefined here". This matches pandas
372            // `rolling(window).corr()`, whose `min_periods` defaults to the
373            // window size.
374            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
396/// Lag correlation: Pearson at various time offsets.
397pub struct LagCorrelation;
398
399impl LagCorrelation {
400    /// Compute Pearson correlation at each lag in `lags`.
401    ///
402    /// A lag whose correlation is **undefined** — the shift consumes the whole
403    /// series, or either window has zero variance — yields `NaN`, not `0.0`.
404    /// `0.0` is the worse answer of the two because it conflates "uncorrelated"
405    /// with "there is not enough information to say", and it is *actionable*
406    /// where a `NaN` is visibly absent (the same convention
407    /// [`RollingCorrelation`] follows).
408    ///
409    /// Cost is O(n) per lag, so O(n·|lags|) overall — ask for the lags wanted
410    /// rather than a range and then one of them.
411    ///
412    /// # Errors
413    ///
414    /// [`MultivariateError::DimensionMismatch`] if `x` and `y` differ in
415    /// length.
416    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
451/// Full N×N pairwise Pearson correlation matrix.
452pub struct CrossCorrelationMatrix;
453
454impl CrossCorrelationMatrix {
455    /// Compute pairwise Pearson correlation for all series.
456    ///
457    /// Returns a symmetric N×N matrix.
458    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        // Monotonic relationship → Spearman = 1.0
502        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    /// `RollingCorrelation` and `preprocess::rolling_corr` compute
514    /// the same statistic and must agree on conventions — partial windows and
515    /// undefined correlations are NaN in both.
516    #[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        // And the convention is specifically: NaN until the window is full.
546        assert!(ours[..window - 1].iter().all(|v| v.is_nan()));
547        assert!(!ours[window - 1].is_nan());
548    }
549
550    /// A constant series has zero variance, so the correlation is undefined —
551    /// not zero. Reporting `0.0` claimed "uncorrelated" for "unknown".
552    #[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        // After the first window, should be close to 1.0
572        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); // a and b perfectly correlated
584        assert!((mat[0][2] - (-1.0)).abs() < 1e-6); // a and c anti-correlated
585        assert!((mat[0][0] - 1.0).abs() < 1e-15); // diagonal = 1
586    }
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        // At lag 0 should be ~1.0
595        assert!((results[1].1 - 1.0).abs() < 1e-3);
596    }
597
598    /// An undefined lag correlation is `NaN`, never `0.0`. `0.0` asserts
599    /// "these series are uncorrelated" where the truth is "there is not enough
600    /// information to say", and a caller acts on the first and ignores the
601    /// second.
602    #[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        // A shift longer than the series leaves nothing to correlate.
608        let out = LagCorrelation::compute(&x, &y, &[10, -10]).unwrap();
609        assert!(out.iter().all(|(_, r)| r.is_nan()), "{out:?}");
610
611        // Zero variance in one window is undefined too.
612        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        // A defined lag is still a number.
617        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        // Use trait objects for dynamic dispatch
627        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        // Verify supports_incremental flag
648        assert!(PearsonCorrelation.supports_incremental());
649        assert!(!SpearmanCorrelation.supports_incremental());
650        assert!(!KendallTau.supports_incremental());
651    }
652}