1#![allow(clippy::cast_precision_loss, clippy::needless_range_loop, clippy::similar_names)]
6
7#[derive(Clone, Debug, Default)]
9pub struct ParCorrWorkspace {
10 pub design: Vec<f64>,
12 pub gram: Vec<f64>,
14 pub beta: Vec<f64>,
16 pub rx: Vec<f64>,
18 pub ry: Vec<f64>,
20 capacity_n: usize,
21 capacity_p: usize,
22}
23
24impl ParCorrWorkspace {
25 pub fn prepare(&mut self, n: usize, p: usize) {
27 let ncols = p.max(1);
28 let need_design = n.saturating_mul(ncols);
29 if self.design.len() < need_design {
30 self.design.resize(need_design, 0.0);
31 }
32 let need_gram = ncols.saturating_mul(ncols);
33 if self.gram.len() < need_gram {
34 self.gram.resize(need_gram, 0.0);
35 }
36 if self.beta.len() < ncols {
37 self.beta.resize(ncols, 0.0);
38 }
39 if self.rx.len() < n {
40 self.rx.resize(n, 0.0);
41 }
42 if self.ry.len() < n {
43 self.ry.resize(n, 0.0);
44 }
45 self.capacity_n = self.capacity_n.max(n);
46 self.capacity_p = self.capacity_p.max(p);
47 }
48
49 #[must_use]
51 pub const fn capacity_n(&self) -> usize {
52 self.capacity_n
53 }
54
55 #[must_use]
57 pub const fn capacity_p(&self) -> usize {
58 self.capacity_p
59 }
60}
61
62#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
64pub struct ParCorrQuery {
65 pub x: usize,
67 pub y: usize,
69 pub z_start: usize,
71 pub z_len: usize,
73}
74
75#[must_use]
77pub fn pearson(x: &[f64], y: &[f64]) -> Option<f64> {
78 debug_assert_eq!(x.len(), y.len());
79 let n = x.len();
80 if n < 2 {
81 return None;
82 }
83 let nf = n as f64;
84 let mut mx = 0.0;
85 let mut my = 0.0;
86 for i in 0..n {
87 mx += x[i];
88 my += y[i];
89 }
90 mx /= nf;
91 my /= nf;
92 let mut cxx = 0.0;
93 let mut cyy = 0.0;
94 let mut cxy = 0.0;
95 for i in 0..n {
96 let dx = x[i] - mx;
97 let dy = y[i] - my;
98 cxx += dx * dx;
99 cyy += dy * dy;
100 cxy += dx * dy;
101 }
102 if constant_column(cxx, mx, nf) || constant_column(cyy, my, nf) {
103 return None;
104 }
105 Some(cxy / (cxx * cyy).sqrt())
106}
107
108fn constant_column(css: f64, mean: f64, nf: f64) -> bool {
111 let tol = nf * (f64::EPSILON * (1.0 + mean.abs())).powi(2);
112 !(css.is_finite() && css > tol)
113}
114
115fn build_design(z_cols: &[&[f64]], n: usize, design: &mut [f64]) {
116 for (j, z) in z_cols.iter().enumerate() {
117 let base = j * n;
118 let mean = z.iter().sum::<f64>() / n as f64;
119 for r in 0..n {
120 design[base + r] = z[r] - mean;
121 }
122 }
123}
124
125fn form_gram(design: &[f64], n: usize, ncols: usize, gram: &mut [f64]) {
126 gram.fill(0.0);
127 for c1 in 0..ncols {
128 for c2 in c1..ncols {
129 let mut acc = 0.0;
130 let a = &design[c1 * n..(c1 + 1) * n];
131 let b = &design[c2 * n..(c2 + 1) * n];
132 for r in 0..n {
133 acc += a[r] * b[r];
134 }
135 gram[c1 * ncols + c2] = acc;
136 gram[c2 * ncols + c1] = acc;
137 }
138 }
139}
140
141fn form_xty(design: &[f64], y: &[f64], n: usize, ncols: usize, out: &mut [f64]) {
142 let y_mean = y.iter().sum::<f64>() / n as f64;
143 for c in 0..ncols {
144 let mut acc = 0.0;
145 let col = &design[c * n..(c + 1) * n];
146 for r in 0..n {
147 acc += col[r] * (y[r] - y_mean);
148 }
149 out[c] = acc;
150 }
151}
152
153fn solve_inplace(gram: &mut [f64], rhs: &mut [f64], ncols: usize) -> bool {
156 let mut scale = 0.0_f64;
157 for d in 0..ncols {
158 scale = scale.max(gram[d * ncols + d].abs());
159 }
160 if !(scale.is_finite() && scale > 0.0) {
161 return false;
162 }
163 let tol = 1e-12 * scale;
164 for col in 0..ncols {
165 let mut best_row = col;
166 let mut best = gram[col * ncols + col].abs();
167 for row in (col + 1)..ncols {
168 let v = gram[row * ncols + col].abs();
169 if v > best {
170 best = v;
171 best_row = row;
172 }
173 }
174 if best <= tol {
175 return false;
176 }
177 if best_row != col {
178 for j in 0..ncols {
179 gram.swap(col * ncols + j, best_row * ncols + j);
180 }
181 rhs.swap(col, best_row);
182 }
183 let pivot = gram[col * ncols + col];
184 for j in 0..ncols {
185 gram[col * ncols + j] /= pivot;
186 }
187 rhs[col] /= pivot;
188 for row in 0..ncols {
189 if row == col {
190 continue;
191 }
192 let factor = gram[row * ncols + col];
193 for j in 0..ncols {
194 gram[row * ncols + j] -= factor * gram[col * ncols + j];
195 }
196 rhs[row] -= factor * rhs[col];
197 }
198 }
199 true
200}
201
202const SINGULAR_RIDGE: f64 = 1e-8;
207
208fn solve_normal_equations(
211 design: &[f64],
212 y: &[f64],
213 n: usize,
214 ncols: usize,
215 gram: &mut [f64],
216 beta: &mut [f64],
217) -> bool {
218 form_gram(design, n, ncols, gram);
219 form_xty(design, y, n, ncols, beta);
220 if solve_inplace(gram, beta, ncols) {
221 return true;
222 }
223 form_gram(design, n, ncols, gram);
224 form_xty(design, y, n, ncols, beta);
225 let mut scale = 0.0_f64;
226 for d in 0..ncols {
227 scale = scale.max(gram[d * ncols + d].abs());
228 }
229 if !(scale.is_finite() && scale > 0.0) {
230 return false;
231 }
232 for d in 0..ncols {
233 gram[d * ncols + d] += SINGULAR_RIDGE * scale;
234 }
235 solve_inplace(gram, beta, ncols)
236}
237
238fn residualize_into_scalar(
239 y: &[f64],
240 z_cols: &[&[f64]],
241 design: &mut [f64],
242 gram: &mut [f64],
243 beta: &mut [f64],
244 out: &mut [f64],
245) -> bool {
246 let n = y.len();
247 let p = z_cols.len();
248 for col in z_cols {
249 if col.len() != n {
250 return false;
251 }
252 }
253 let ncols = p;
254 build_design(z_cols, n, design);
255 if !solve_normal_equations(design, y, n, ncols, gram, beta) {
256 return false;
257 }
258 let y_mean = y.iter().sum::<f64>() / n as f64;
259 for r in 0..n {
260 let mut pred = 0.0;
261 for c in 0..ncols {
262 pred += design[c * n + r] * beta[c];
263 }
264 out[r] = (y[r] - y_mean) - pred;
265 }
266 true
267}
268
269fn partial_correlation_scalar_impl(
271 x: &[f64],
272 y: &[f64],
273 z_cols: &[&[f64]],
274 workspace: &mut ParCorrWorkspace,
275) -> Option<f64> {
276 if x.len() != y.len() || x.len() < 3 {
277 return None;
278 }
279 let n = x.len();
280 if z_cols.is_empty() {
281 return pearson(x, y);
282 }
283 workspace.prepare(n, z_cols.len());
284 let ncols = z_cols.len();
285 let design = &mut workspace.design[..n * ncols];
286 let gram = &mut workspace.gram[..ncols * ncols];
287 let beta = &mut workspace.beta[..ncols];
288 let rx = &mut workspace.rx[..n];
289 if !residualize_into_scalar(x, z_cols, design, gram, beta, rx) {
290 return None;
291 }
292 let design = &mut workspace.design[..n * ncols];
293 let gram = &mut workspace.gram[..ncols * ncols];
294 let beta = &mut workspace.beta[..ncols];
295 let ry = &mut workspace.ry[..n];
296 if !residualize_into_scalar(y, z_cols, design, gram, beta, ry) {
297 return None;
298 }
299 pearson(&workspace.rx[..n], &workspace.ry[..n]).or(Some(0.0))
303}
304
305fn partial_correlation_portable_impl(
308 x: &[f64],
309 y: &[f64],
310 z_cols: &[&[f64]],
311 workspace: &mut ParCorrWorkspace,
312) -> Option<f64> {
313 if x.len() != y.len() || x.len() < 3 {
314 return None;
315 }
316 let n = x.len();
317 if z_cols.is_empty() {
318 return pearson_fused(x, y);
319 }
320 for col in z_cols {
321 if col.len() != n {
322 return None;
323 }
324 }
325 workspace.prepare(n, z_cols.len());
326 let ncols = z_cols.len();
327 {
328 let design = &mut workspace.design[..n * ncols];
329 build_design(z_cols, n, design);
330 let gram = &mut workspace.gram[..ncols * ncols];
331 let beta = &mut workspace.beta[..ncols];
332 if !solve_normal_equations(design, x, n, ncols, gram, beta) {
333 return None;
334 }
335 let rx = &mut workspace.rx[..n];
336 residual_from_beta(x, design, beta, n, ncols, rx);
337 }
338 {
339 let design = &mut workspace.design[..n * ncols];
340 let gram = &mut workspace.gram[..ncols * ncols];
341 let beta = &mut workspace.beta[..ncols];
342 if !solve_normal_equations(design, y, n, ncols, gram, beta) {
343 return None;
344 }
345 let ry = &mut workspace.ry[..n];
346 residual_from_beta(y, design, beta, n, ncols, ry);
347 }
348 pearson_fused(&workspace.rx[..n], &workspace.ry[..n]).or(Some(0.0))
350}
351
352fn residual_from_beta(
353 y: &[f64],
354 design: &[f64],
355 beta: &[f64],
356 n: usize,
357 ncols: usize,
358 out: &mut [f64],
359) {
360 let y_mean = y.iter().sum::<f64>() / n as f64;
361 for r in 0..n {
362 let mut pred = 0.0;
363 for c in 0..ncols {
364 pred += design[c * n + r] * beta[c];
365 }
366 out[r] = (y[r] - y_mean) - pred;
367 }
368}
369
370fn pearson_fused(x: &[f64], y: &[f64]) -> Option<f64> {
372 const CHUNK: usize = 8;
373 debug_assert_eq!(x.len(), y.len());
374 let n = x.len();
375 if n < 2 {
376 return None;
377 }
378 let nf = n as f64;
379 let (mut mx, mut my) = (0.0, 0.0);
380 let mut i = 0;
381 while i + CHUNK <= n {
382 let mut sx = 0.0;
383 let mut sy = 0.0;
384 for k in 0..CHUNK {
385 sx += x[i + k];
386 sy += y[i + k];
387 }
388 mx += sx;
389 my += sy;
390 i += CHUNK;
391 }
392 while i < n {
393 mx += x[i];
394 my += y[i];
395 i += 1;
396 }
397 mx /= nf;
398 my /= nf;
399 let (mut cxx, mut cyy, mut cxy) = (0.0, 0.0, 0.0);
400 i = 0;
401 while i + CHUNK <= n {
402 let mut sxx = 0.0;
403 let mut syy = 0.0;
404 let mut sxy = 0.0;
405 for k in 0..CHUNK {
406 let dx = x[i + k] - mx;
407 let dy = y[i + k] - my;
408 sxx += dx * dx;
409 syy += dy * dy;
410 sxy += dx * dy;
411 }
412 cxx += sxx;
413 cyy += syy;
414 cxy += sxy;
415 i += CHUNK;
416 }
417 while i < n {
418 let dx = x[i] - mx;
419 let dy = y[i] - my;
420 cxx += dx * dx;
421 cyy += dy * dy;
422 cxy += dx * dy;
423 i += 1;
424 }
425 if constant_column(cxx, mx, nf) || constant_column(cyy, my, nf) {
426 return None;
427 }
428 Some(cxy / (cxx * cyy).sqrt())
429}
430
431#[must_use]
433pub fn partial_correlation_scalar(
434 x: &[f64],
435 y: &[f64],
436 z_cols: &[&[f64]],
437 workspace: &mut ParCorrWorkspace,
438) -> Option<f64> {
439 partial_correlation_scalar_impl(x, y, z_cols, workspace)
440}
441
442#[must_use]
444pub fn partial_correlation_portable(
445 x: &[f64],
446 y: &[f64],
447 z_cols: &[&[f64]],
448 workspace: &mut ParCorrWorkspace,
449) -> Option<f64> {
450 partial_correlation_portable_impl(x, y, z_cols, workspace)
451}
452
453#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
457pub enum ParCorrMode {
458 Native,
460 Portable,
462}
463
464impl ParCorrMode {
465 #[must_use]
467 pub const fn is_portable(self) -> bool {
468 matches!(self, Self::Portable)
469 }
470}
471
472impl From<bool> for ParCorrMode {
473 fn from(portable: bool) -> Self {
474 if portable { Self::Portable } else { Self::Native }
475 }
476}
477
478pub fn partial_correlation_batch(
484 columns: &[&[f64]],
485 queries: &[ParCorrQuery],
486 z_flat: &[usize],
487 out: &mut [Option<f64>],
488 workspace: &mut ParCorrWorkspace,
489 mode: ParCorrMode,
490) {
491 assert_eq!(out.len(), queries.len());
492 let mut z_bufs: Vec<&[f64]> = Vec::new();
493 for (qi, q) in queries.iter().enumerate() {
494 z_bufs.clear();
495 let end = q.z_start + q.z_len;
496 for &zi in &z_flat[q.z_start..end] {
497 z_bufs.push(columns[zi]);
498 }
499 let r = if mode.is_portable() {
500 partial_correlation_portable(columns[q.x], columns[q.y], &z_bufs, workspace)
501 } else {
502 partial_correlation_scalar(columns[q.x], columns[q.y], &z_bufs, workspace)
503 };
504 out[qi] = r;
505 }
506}
507
508#[cfg(test)]
509#[allow(clippy::cast_precision_loss, clippy::many_single_char_names)]
510mod tests {
511 use antecedent_core::{KernelPolicy, ToleranceClass};
512
513 use super::*;
514 use crate::dispatch::{KernelImpl, partial_correlation, select_impl};
515
516 #[test]
517 fn pearson_perfect() {
518 let x = [1.0, 2.0, 3.0, 4.0];
519 let y = [2.0, 4.0, 6.0, 8.0];
520 assert!(ToleranceClass::StableFloat.close(pearson(&x, &y).unwrap(), 1.0));
521 }
522
523 #[test]
524 fn parcorr_removes_confounder() {
525 let n = 200usize;
526 let z: Vec<f64> = (0..n).map(|i| ((i as f64) - 99.5) / 50.0).collect();
527 let x: Vec<f64> = (0..n).map(|i| z[i] + ((i % 3) as f64 - 1.0) * 0.1).collect();
528 let y: Vec<f64> = (0..n).map(|i| z[i] + ((i % 5) as f64 - 2.0) * 0.1).collect();
529 let mut ws = ParCorrWorkspace::default();
530 let raw = pearson(&x, &y).unwrap();
531 let partial = partial_correlation_scalar(&x, &y, &[&z], &mut ws).unwrap();
532 assert!(raw > 0.9);
533 assert!(partial.abs() < 0.2, "partial={partial}");
534 }
535
536 fn one_z_intercept_oracle(x: &[f64], y: &[f64], z: &[f64]) -> f64 {
537 let mean = |v: &[f64]| v.iter().sum::<f64>() / v.len() as f64;
538 let mx = mean(x);
539 let my = mean(y);
540 let mz = mean(z);
541 let z_ss = z.iter().map(|v| (v - mz).powi(2)).sum::<f64>();
542 let bx = x.iter().zip(z).map(|(a, b)| (a - mx) * (b - mz)).sum::<f64>() / z_ss;
543 let by = y.iter().zip(z).map(|(a, b)| (a - my) * (b - mz)).sum::<f64>() / z_ss;
544 let rx: Vec<_> = x.iter().zip(z).map(|(a, b)| (a - mx) - bx * (b - mz)).collect();
545 let ry: Vec<_> = y.iter().zip(z).map(|(a, b)| (a - my) - by * (b - mz)).collect();
546 pearson(&rx, &ry).unwrap()
547 }
548
549 #[test]
550 fn parcorr_is_translation_invariant_and_matches_intercept_oracle() {
551 let x = [
552 -0.427_007, -1.147_988, 1.561_491, -1.892_812, 1.154_907, 0.455_615, -1.952_965,
553 0.823_206,
554 ];
555 let y = [
556 -2.391_322, -2.619_455, -1.515_701, -3.279_834, -1.518_526, -2.738_805, -2.950_828,
557 -2.147_493,
558 ];
559 let z = [
560 -0.704_669, -1.396_603, 0.603_738, -1.710_255, 0.143_528, -0.537_244, -1.768_004,
561 0.029_743,
562 ];
563 let z_shifted: Vec<_> = z.iter().map(|v| v + 100.0).collect();
564 let oracle = one_z_intercept_oracle(&x, &y, &z);
565 let mut ws_scalar = ParCorrWorkspace::default();
566 let mut ws_portable = ParCorrWorkspace::default();
567 let scalar = partial_correlation_scalar(&x, &y, &[&z], &mut ws_scalar).unwrap();
568 let translated = partial_correlation_scalar(&x, &y, &[&z_shifted], &mut ws_scalar).unwrap();
569 let portable =
570 partial_correlation_portable(&x, &y, &[&z_shifted], &mut ws_portable).unwrap();
571 assert!((scalar - translated).abs() <= 1e-12, "{scalar} vs {translated}");
572 assert!((scalar - oracle).abs() <= 1e-12, "{scalar} vs {oracle}");
573 assert!((portable - oracle).abs() <= 1e-12, "{portable} vs {oracle}");
574 }
575
576 #[test]
577 fn parcorr_batch_is_translation_invariant() {
578 let n = 100usize;
579 let z: Vec<_> = (0..n).map(|i| (i as f64 * 0.17).sin() + 3.0).collect();
580 let z_shifted: Vec<_> = z.iter().map(|v| v - 10_000.0).collect();
581 let x: Vec<_> = (0..n).map(|i| 1.5 * z[i] + (i as f64 * 0.31).cos()).collect();
582 let y: Vec<_> = (0..n).map(|i| -0.7 * z[i] + (i as f64 * 0.23).sin()).collect();
583 let queries = [ParCorrQuery { x: 0, y: 1, z_start: 0, z_len: 1 }];
584 let z_flat = [2usize];
585 let mut base = [None];
586 let mut shifted = [None];
587 let mut ws = ParCorrWorkspace::default();
588 partial_correlation_batch(
589 &[&x, &y, &z],
590 &queries,
591 &z_flat,
592 &mut base,
593 &mut ws,
594 ParCorrMode::Native,
595 );
596 partial_correlation_batch(
597 &[&x, &y, &z_shifted],
598 &queries,
599 &z_flat,
600 &mut shifted,
601 &mut ws,
602 ParCorrMode::Portable,
603 );
604 assert!((base[0].unwrap() - shifted[0].unwrap()).abs() <= 1e-10);
605 }
606
607 #[test]
608 fn parcorr_is_invariant_across_seeded_random_offsets() {
609 let n = 96usize;
610 let z: Vec<_> = (0..n).map(|i| (i as f64 * 0.19).sin() + 0.01 * i as f64).collect();
611 let x: Vec<_> = (0..n).map(|i| 0.8 * z[i] + (i as f64 * 0.37).cos() - 2.0).collect();
612 let y: Vec<_> = (0..n).map(|i| -1.2 * z[i] + (i as f64 * 0.29).sin() + 4.0).collect();
613 let mut ws = ParCorrWorkspace::default();
614 let reference = partial_correlation_scalar(&x, &y, &[&z], &mut ws).unwrap();
615 let mut state = 0x5eed_cafe_f00d_beefu64;
616 for _ in 0..100 {
617 state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
618 let unit = (state >> 11) as f64 / (1u64 << 53) as f64;
619 let offset = -10_000.0 + 20_000.0 * unit;
620 let shifted: Vec<_> = z.iter().map(|value| value + offset).collect();
621 let scalar = partial_correlation_scalar(&x, &y, &[&shifted], &mut ws).unwrap();
622 let portable = partial_correlation_portable(&x, &y, &[&shifted], &mut ws).unwrap();
623 assert!((scalar - reference).abs() <= 1e-10, "offset={offset}");
624 assert!((portable - reference).abs() <= 1e-10, "offset={offset}");
625 }
626 }
627
628 #[test]
629 fn scalar_portable_differential() {
630 let n = 128usize;
631 let z: Vec<f64> = (0..n).map(|i| (i as f64).sin()).collect();
632 let x: Vec<f64> = (0..n).map(|i| z[i] + 0.1 * (i as f64)).collect();
633 let y: Vec<f64> = (0..n).map(|i| 2.0 * z[i] + 0.05 * (i as f64)).collect();
634 let mut ws_s = ParCorrWorkspace::default();
635 let mut ws_p = ParCorrWorkspace::default();
636 let s = partial_correlation_scalar(&x, &y, &[&z], &mut ws_s).unwrap();
637 let p = partial_correlation_portable(&x, &y, &[&z], &mut ws_p).unwrap();
638 assert!(ToleranceClass::StableFloat.close(s, p));
639 }
640
641 #[test]
642 fn batch_reuses_workspace() {
643 let n = 64usize;
644 let c0: Vec<f64> = (0..n).map(|i| i as f64).collect();
645 let c1: Vec<f64> = (0..n).map(|i| (i as f64) * 0.5).collect();
646 let c2: Vec<f64> = (0..n).map(|i| (i as f64).sin()).collect();
647 let columns: [&[f64]; 3] = [&c0, &c1, &c2];
648 let queries = [
649 ParCorrQuery { x: 0, y: 1, z_start: 0, z_len: 1 },
650 ParCorrQuery { x: 0, y: 2, z_start: 1, z_len: 0 },
651 ];
652 let z_flat = [2usize];
653 let mut out = [None; 2];
654 let mut ws = ParCorrWorkspace::default();
655 partial_correlation_batch(
656 &columns,
657 &queries,
658 &z_flat,
659 &mut out,
660 &mut ws,
661 ParCorrMode::Native,
662 );
663 let cap_n = ws.capacity_n();
664 let cap_p = ws.capacity_p();
665 for _ in 0..20 {
666 partial_correlation_batch(
667 &columns,
668 &queries,
669 &z_flat,
670 &mut out,
671 &mut ws,
672 ParCorrMode::Portable,
673 );
674 assert_eq!(ws.capacity_n(), cap_n);
675 assert_eq!(ws.capacity_p(), cap_p);
676 }
677 assert!(out[0].is_some());
678 }
679
680 #[test]
681 fn dispatch_force_scalar() {
682 let x = [1.0, 2.0, 3.0, 4.0, 5.0];
683 let y = [2.0, 3.0, 4.0, 5.0, 6.0];
684 let mut ws = ParCorrWorkspace::default();
685 let policy = KernelPolicy::scalar_only();
686 assert_eq!(select_impl(&policy), KernelImpl::Scalar);
687 let r = partial_correlation(&policy, &x, &y, &[], &mut ws).unwrap();
688 assert!(ToleranceClass::StableFloat.close(r, 1.0));
689 }
690}