1use crate::error::FdarError;
22use crate::iter_maybe_parallel;
23use crate::matrix::FdMatrix;
24use crate::smoothing;
25#[cfg(feature = "parallel")]
26use rayon::iter::ParallelIterator;
27
28#[derive(Debug, Clone, PartialEq)]
34#[non_exhaustive]
35#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
36pub struct ConcurrentRegrResult {
37 pub beta_curve: FdMatrix,
39 pub intercept: Vec<f64>,
41 pub fitted: FdMatrix,
43 pub residuals: FdMatrix,
45 pub argvals: Vec<f64>,
47}
48
49#[must_use = "expensive computation whose result should not be discarded"]
92pub fn concurrent_regression(
93 response: &FdMatrix,
94 predictors: &[FdMatrix],
95 argvals: Option<&[f64]>,
96 bandwidth: f64,
97 kernel: &str,
98) -> Result<ConcurrentRegrResult, FdarError> {
99 if predictors.is_empty() {
103 return Err(FdarError::InvalidDimension {
104 parameter: "predictors",
105 expected: "at least 1".to_string(),
106 actual: "0".to_string(),
107 });
108 }
109
110 let (n, m) = response.shape();
111
112 if n < 2 {
113 return Err(FdarError::InvalidDimension {
114 parameter: "response",
115 expected: "at least 2 rows (observations)".to_string(),
116 actual: format!("{n}"),
117 });
118 }
119 if m == 0 {
120 return Err(FdarError::InvalidDimension {
121 parameter: "response",
122 expected: "non-zero columns (grid points)".to_string(),
123 actual: "0".to_string(),
124 });
125 }
126
127 for (k, pred) in predictors.iter().enumerate() {
128 if pred.nrows() != n {
129 return Err(FdarError::InvalidDimension {
130 parameter: "predictors[k]",
131 expected: format!("{n} rows (matching response)"),
132 actual: format!("{} (predictor index {k})", pred.nrows()),
133 });
134 }
135 if pred.ncols() != m {
136 return Err(FdarError::InvalidDimension {
137 parameter: "predictors[k]",
138 expected: format!("{m} columns (matching response)"),
139 actual: format!("{} (predictor index {k})", pred.ncols()),
140 });
141 }
142 }
143
144 if !bandwidth.is_finite() || bandwidth <= 0.0 {
150 return Err(FdarError::InvalidParameter {
151 parameter: "bandwidth",
152 message: format!("must be finite and positive, got {bandwidth}"),
153 });
154 }
155
156 if let Some(av) = argvals {
157 if av.len() != m {
158 return Err(FdarError::InvalidDimension {
159 parameter: "argvals",
160 expected: format!("{m} elements (matching response columns)"),
161 actual: format!("{}", av.len()),
162 });
163 }
164 }
165
166 let argvals_owned: Vec<f64> = argvals
170 .map(|v| v.to_vec())
171 .unwrap_or_else(|| (0..m).map(|j| j as f64 / (m - 1).max(1) as f64).collect());
172
173 let p = predictors.len();
174 let q = p + 1; if n <= p {
182 return Err(FdarError::InvalidDimension {
183 parameter: "response",
184 expected: format!(
185 "at least {} rows (more observations than predictors p={})",
186 p + 1,
187 p
188 ),
189 actual: format!("{n}"),
190 });
191 }
192
193 let raw_cols: Vec<(f64, Vec<f64>)> = iter_maybe_parallel!(0..m)
201 .map(|j| {
202 let mut xtx = vec![0.0_f64; q * q];
203 let mut xty = vec![0.0_f64; q];
204
205 for i in 0..n {
206 let mut row = vec![1.0_f64; q];
208 for (k, pred) in predictors.iter().enumerate() {
209 row[k + 1] = pred[(i, j)];
210 }
211 for a in 0..q {
212 for b in 0..q {
213 xtx[a * q + b] += row[a] * row[b];
214 }
215 xty[a] += row[a] * response[(i, j)];
216 }
217 }
218
219 let eps = 1e-10 * (xtx[0] + 1.0);
222 for d in 0..q {
223 xtx[d * q + d] += eps;
224 }
225
226 let coef = smoothing::solve_gaussian_pub(&mut xtx, &mut xty, q);
227
228 let raw_intercept_j = coef[0];
229 let raw_beta_j: Vec<f64> = coef[1..q].to_vec();
230 (raw_intercept_j, raw_beta_j)
231 })
232 .collect();
233
234 let mut raw_intercept = vec![0.0_f64; m];
236 let mut raw_beta = vec![0.0_f64; p * m];
239 for (j, (ic, bk)) in raw_cols.into_iter().enumerate() {
240 raw_intercept[j] = ic;
241 for k in 0..p {
242 raw_beta[k * m + j] = bk[k];
243 }
244 }
245
246 let intercept = smoothing::local_linear(
250 &argvals_owned,
251 &raw_intercept,
252 &argvals_owned,
253 bandwidth,
254 kernel,
255 )?;
256
257 let mut beta_curve = FdMatrix::zeros(p, m);
258 for k in 0..p {
259 let raw_k: Vec<f64> = (0..m).map(|j| raw_beta[k * m + j]).collect();
260 let smooth_k =
261 smoothing::local_linear(&argvals_owned, &raw_k, &argvals_owned, bandwidth, kernel)?;
262 for j in 0..m {
263 beta_curve[(k, j)] = smooth_k[j];
264 }
265 }
266
267 let mut fitted = FdMatrix::zeros(n, m);
271 let mut residuals = FdMatrix::zeros(n, m);
272 for j in 0..m {
273 for i in 0..n {
274 let mut val = intercept[j];
275 for (k, pred) in predictors.iter().enumerate() {
276 val += beta_curve[(k, j)] * pred[(i, j)];
277 }
278 fitted[(i, j)] = val;
279 residuals[(i, j)] = response[(i, j)] - val;
280 }
281 }
282
283 Ok(ConcurrentRegrResult {
284 beta_curve,
285 intercept,
286 fitted,
287 residuals,
288 argvals: argvals_owned,
289 })
290}
291
292#[cfg(test)]
297mod tests {
298 use super::*;
299 use crate::test_helpers::uniform_grid;
300
301 #[test]
306 fn test_shape_smoke() {
307 let n = 6;
308 let m = 8;
309 let argvals = uniform_grid(m);
310
311 let mut response = FdMatrix::zeros(n, m);
313 for i in 0..n {
314 for j in 0..m {
315 response[(i, j)] = (i as f64 + 1.0) * argvals[j];
316 }
317 }
318
319 let mut predictor = FdMatrix::zeros(n, m);
321 for i in 0..n {
322 for j in 0..m {
323 predictor[(i, j)] = (i as f64 + 1.0) + argvals[j];
324 }
325 }
326
327 let result = concurrent_regression(&response, &[predictor], None, 0.2, "gaussian");
328 assert!(result.is_ok(), "expected Ok, got {result:?}");
329 let r = result.unwrap();
330
331 assert_eq!(r.beta_curve.shape(), (1, m), "beta_curve shape");
332 assert_eq!(r.intercept.len(), m, "intercept length");
333 assert_eq!(r.fitted.shape(), (n, m), "fitted shape");
334 assert_eq!(r.residuals.shape(), (n, m), "residuals shape");
335 assert_eq!(r.argvals.len(), m, "argvals length");
336 }
337
338 #[test]
343 fn test_multi_predictor_shape() {
344 let n = 6;
345 let m = 8;
346 let p = 3;
347 let argvals = uniform_grid(m);
348
349 let mut response = FdMatrix::zeros(n, m);
350 for i in 0..n {
351 for j in 0..m {
352 response[(i, j)] = (i as f64 + 1.0) * argvals[j];
353 }
354 }
355
356 let predictors: Vec<FdMatrix> = (0..p)
357 .map(|k| {
358 let mut pred = FdMatrix::zeros(n, m);
359 for i in 0..n {
360 for j in 0..m {
361 pred[(i, j)] = (i as f64 + k as f64 + 1.0) * argvals[j] + 0.1;
362 }
363 }
364 pred
365 })
366 .collect();
367
368 let result = concurrent_regression(&response, &predictors, None, 0.2, "gaussian");
369 assert!(result.is_ok(), "expected Ok for p=3, got {result:?}");
370 let r = result.unwrap();
371 assert_eq!(r.beta_curve.shape(), (p, m), "beta_curve shape for p=3");
372 }
373
374 #[test]
379 fn test_parallel_sequential_equivalence() {
380 let n = 6;
381 let m = 8;
382 let argvals = uniform_grid(m);
383
384 let mut response = FdMatrix::zeros(n, m);
385 let mut predictor = FdMatrix::zeros(n, m);
386 for i in 0..n {
387 for j in 0..m {
388 response[(i, j)] = (i as f64 + 1.0) * argvals[j] + 0.3;
389 predictor[(i, j)] = argvals[j] * (i as f64 + 0.5);
390 }
391 }
392
393 let r1 = concurrent_regression(&response, &[predictor.clone()], None, 0.2, "gaussian")
394 .expect("first call should succeed");
395 let r2 = concurrent_regression(&response, &[predictor], None, 0.2, "gaussian")
396 .expect("second call should succeed");
397
398 assert_eq!(
399 r1, r2,
400 "two calls on identical inputs must produce equal results"
401 );
402 }
403
404 fn lcg_noise(seed: u64, n: usize) -> Vec<f64> {
410 let mut state = seed;
411 (0..n)
412 .map(|_| {
413 state = state
414 .wrapping_mul(6_364_136_223_846_793_005)
415 .wrapping_add(1_442_695_040_888_963_407);
416 (state >> 33) as f64 / (u32::MAX as f64) - 0.5
418 })
419 .collect()
420 }
421
422 #[test]
423 fn test_recovery_known_beta() {
424 let n = 50usize;
425 let m = 50usize;
426 let argvals = uniform_grid(m);
427
428 let true_beta0 = 0.5_f64;
431 let true_beta: Vec<f64> = argvals
432 .iter()
433 .map(|&t| (std::f64::consts::PI * t).sin())
434 .collect();
435
436 let mut predictor = FdMatrix::zeros(n, m);
438 for i in 0..n {
439 for j in 0..m {
440 predictor[(i, j)] =
441 (2.0 * std::f64::consts::PI * (i as f64 / n as f64) + argvals[j]).sin();
442 }
443 }
444
445 let mut x_max = 0.0_f64;
447 for i in 0..n {
448 for j in 0..m {
449 let v = predictor[(i, j)].abs();
450 if v > x_max {
451 x_max = v;
452 }
453 }
454 }
455 let noise_scale = 0.05 * x_max;
456
457 let noise = lcg_noise(42, n * m);
459 let mut response = FdMatrix::zeros(n, m);
460 for i in 0..n {
461 for j in 0..m {
462 response[(i, j)] =
463 true_beta0 + true_beta[j] * predictor[(i, j)] + noise_scale * noise[i * m + j];
464 }
465 }
466
467 let result =
468 concurrent_regression(&response, &[predictor], Some(&argvals), 0.15, "gaussian")
469 .expect("recovery test should succeed");
470
471 for j in 5..45 {
475 let diff = (result.beta_curve[(0, j)] - true_beta[j]).abs();
476 assert!(
477 diff < 0.15,
478 "recovery failed at j={j}: got {}, expected {}, diff={diff}",
479 result.beta_curve[(0, j)],
480 true_beta[j]
481 );
482 }
483 }
484
485 fn roughness(row: &[f64]) -> f64 {
490 let m = row.len();
491 if m < 3 {
492 return 0.0;
493 }
494 (1..m - 1)
495 .map(|j| {
496 let d = row[j + 1] - 2.0 * row[j] + row[j - 1];
497 d * d
498 })
499 .sum()
500 }
501
502 #[test]
503 fn test_monotone_roughness() {
504 let n = 50usize;
505 let m = 50usize;
506 let argvals = uniform_grid(m);
507
508 let true_beta0 = 0.5_f64;
509 let true_beta: Vec<f64> = argvals
510 .iter()
511 .map(|&t| (2.0 * std::f64::consts::PI * t).sin())
512 .collect();
513
514 let mut predictor = FdMatrix::zeros(n, m);
515 for i in 0..n {
516 for j in 0..m {
517 predictor[(i, j)] =
518 (2.0 * std::f64::consts::PI * (i as f64 / n as f64) + argvals[j]).sin();
519 }
520 }
521
522 let mut x_max = 0.0_f64;
523 for i in 0..n {
524 for j in 0..m {
525 let v = predictor[(i, j)].abs();
526 if v > x_max {
527 x_max = v;
528 }
529 }
530 }
531 let noise_scale = 0.05 * x_max;
532 let noise = lcg_noise(42, n * m);
533 let mut response = FdMatrix::zeros(n, m);
534 for i in 0..n {
535 for j in 0..m {
536 response[(i, j)] =
537 true_beta0 + true_beta[j] * predictor[(i, j)] + noise_scale * noise[i * m + j];
538 }
539 }
540
541 let bandwidths = [0.05, 0.15, 0.35];
542 let roughnesses: Vec<f64> = bandwidths
543 .iter()
544 .map(|&bw| {
545 let r = concurrent_regression(
546 &response,
547 &[predictor.clone()],
548 Some(&argvals),
549 bw,
550 "gaussian",
551 )
552 .expect("monotone roughness call should succeed");
553 let beta_row: Vec<f64> = (0..m).map(|j| r.beta_curve[(0, j)]).collect();
554 roughness(&beta_row)
555 })
556 .collect();
557
558 assert!(
559 roughnesses[0] > roughnesses[1],
560 "roughness(bw=0.05)={} should be > roughness(bw=0.15)={}",
561 roughnesses[0],
562 roughnesses[1]
563 );
564 assert!(
565 roughnesses[1] > roughnesses[2],
566 "roughness(bw=0.15)={} should be > roughness(bw=0.35)={}",
567 roughnesses[1],
568 roughnesses[2]
569 );
570 }
571
572 #[test]
577 fn test_residuals_consistency() {
578 let n = 6;
579 let m = 8;
580 let argvals = uniform_grid(m);
581
582 let mut response = FdMatrix::zeros(n, m);
583 let mut predictor = FdMatrix::zeros(n, m);
584 for i in 0..n {
585 for j in 0..m {
586 response[(i, j)] = (i as f64 + 1.0) * argvals[j] + 0.1;
587 predictor[(i, j)] = argvals[j] + i as f64 * 0.1;
588 }
589 }
590
591 let result = concurrent_regression(&response, &[predictor], None, 0.2, "gaussian")
592 .expect("residuals consistency call should succeed");
593
594 for i in 0..n {
595 for j in 0..m {
596 let expected_resid = response[(i, j)] - result.fitted[(i, j)];
597 let diff = (result.residuals[(i, j)] - expected_resid).abs();
598 assert!(
599 diff < 1e-10,
600 "residual inconsistency at ({i},{j}): stored={}, computed={expected_resid}, diff={diff}",
601 result.residuals[(i, j)]
602 );
603 }
604 }
605 }
606
607 #[test]
612 fn test_invalid_inputs() {
613 let n = 6;
614 let m = 8;
615 let argvals = uniform_grid(m);
616
617 let mut response = FdMatrix::zeros(n, m);
618 let mut predictor = FdMatrix::zeros(n, m);
619 for i in 0..n {
620 for j in 0..m {
621 response[(i, j)] = (i as f64 + 1.0) * argvals[j];
622 predictor[(i, j)] = argvals[j];
623 }
624 }
625
626 let err = concurrent_regression(&response, &[], None, 0.2, "gaussian")
628 .expect_err("empty predictors should return Err");
629 assert!(
630 matches!(
631 err,
632 FdarError::InvalidDimension {
633 parameter: "predictors",
634 ..
635 }
636 ),
637 "expected InvalidDimension for empty predictors, got {err:?}"
638 );
639
640 let tiny_response = FdMatrix::zeros(1, m);
642 let tiny_pred = FdMatrix::zeros(1, m);
643 let err = concurrent_regression(&tiny_response, &[tiny_pred], None, 0.2, "gaussian")
644 .expect_err("n<2 response should return Err");
645 assert!(
646 matches!(
647 err,
648 FdarError::InvalidDimension {
649 parameter: "response",
650 ..
651 }
652 ),
653 "expected InvalidDimension for response n<2, got {err:?}"
654 );
655
656 let wrong_n_pred = FdMatrix::zeros(n + 1, m);
658 let err = concurrent_regression(&response, &[wrong_n_pred], None, 0.2, "gaussian")
659 .expect_err("wrong nrows predictor should return Err");
660 assert!(
661 matches!(
662 err,
663 FdarError::InvalidDimension {
664 parameter: "predictors[k]",
665 ..
666 }
667 ),
668 "expected InvalidDimension for predictor nrows mismatch, got {err:?}"
669 );
670
671 let wrong_m_pred = FdMatrix::zeros(n, m + 1);
673 let err = concurrent_regression(&response, &[wrong_m_pred], None, 0.2, "gaussian")
674 .expect_err("wrong ncols predictor should return Err");
675 assert!(
676 matches!(
677 err,
678 FdarError::InvalidDimension {
679 parameter: "predictors[k]",
680 ..
681 }
682 ),
683 "expected InvalidDimension for predictor ncols mismatch, got {err:?}"
684 );
685
686 let err = concurrent_regression(&response, &[predictor.clone()], None, 0.0, "gaussian")
688 .expect_err("bandwidth=0.0 should return Err");
689 assert!(
690 matches!(
691 err,
692 FdarError::InvalidParameter {
693 parameter: "bandwidth",
694 ..
695 }
696 ),
697 "expected InvalidParameter for bandwidth=0.0, got {err:?}"
698 );
699
700 let err = concurrent_regression(&response, &[predictor.clone()], None, -0.1, "gaussian")
702 .expect_err("negative bandwidth should return Err");
703 assert!(
704 matches!(
705 err,
706 FdarError::InvalidParameter {
707 parameter: "bandwidth",
708 ..
709 }
710 ),
711 "expected InvalidParameter for negative bandwidth, got {err:?}"
712 );
713
714 let wrong_argvals: Vec<f64> = uniform_grid(m + 3);
716 let err = concurrent_regression(
717 &response,
718 &[predictor],
719 Some(&wrong_argvals),
720 0.2,
721 "gaussian",
722 )
723 .expect_err("wrong argvals length should return Err");
724 assert!(
725 matches!(
726 err,
727 FdarError::InvalidDimension {
728 parameter: "argvals",
729 ..
730 }
731 ),
732 "expected InvalidDimension for argvals length mismatch, got {err:?}"
733 );
734 }
735
736 #[test]
745 fn test_nan_inf_bandwidth_returns_error() {
746 let n = 4;
747 let m = 6;
748
749 let mut response = FdMatrix::zeros(n, m);
750 let mut predictor = FdMatrix::zeros(n, m);
751 for i in 0..n {
752 for j in 0..m {
753 response[(i, j)] = (i as f64 + 1.0) * (j as f64 + 1.0);
754 predictor[(i, j)] = (i as f64 + 1.0) + (j as f64 * 0.1);
755 }
756 }
757
758 let err =
760 concurrent_regression(&response, &[predictor.clone()], None, f64::NAN, "gaussian")
761 .expect_err("NaN bandwidth must return Err");
762 assert!(
763 matches!(
764 err,
765 FdarError::InvalidParameter {
766 parameter: "bandwidth",
767 ..
768 }
769 ),
770 "expected InvalidParameter for NaN bandwidth, got {err:?}"
771 );
772
773 let err = concurrent_regression(
775 &response,
776 &[predictor.clone()],
777 None,
778 f64::INFINITY,
779 "gaussian",
780 )
781 .expect_err("+Inf bandwidth must return Err");
782 assert!(
783 matches!(
784 err,
785 FdarError::InvalidParameter {
786 parameter: "bandwidth",
787 ..
788 }
789 ),
790 "expected InvalidParameter for +Inf bandwidth, got {err:?}"
791 );
792
793 concurrent_regression(&response, &[predictor], None, 0.2, "gaussian")
795 .expect("positive finite bandwidth must succeed");
796 }
797
798 #[test]
804 fn test_underdetermined_system_returns_error() {
805 let m = 8;
806
807 let n = 3usize;
810 let p = 3usize;
811 let response = FdMatrix::zeros(n, m);
812 let predictors: Vec<FdMatrix> = (0..p).map(|_| FdMatrix::zeros(n, m)).collect();
813
814 let err = concurrent_regression(&response, &predictors, None, 0.2, "gaussian")
815 .expect_err("n == p should return Err (underdetermined)");
816 assert!(
817 matches!(
818 err,
819 FdarError::InvalidDimension {
820 parameter: "response",
821 ..
822 }
823 ),
824 "expected InvalidDimension for n==p, got {err:?}"
825 );
826
827 let n2 = 2usize;
829 let p2 = 4usize;
830 let response2 = FdMatrix::zeros(n2, m);
831 let predictors2: Vec<FdMatrix> = (0..p2).map(|_| FdMatrix::zeros(n2, m)).collect();
832
833 let err2 = concurrent_regression(&response2, &predictors2, None, 0.2, "gaussian")
834 .expect_err("n < p should return Err (underdetermined)");
835 assert!(
836 matches!(
837 err2,
838 FdarError::InvalidDimension {
839 parameter: "response",
840 ..
841 }
842 ),
843 "expected InvalidDimension for n<p, got {err2:?}"
844 );
845
846 let n3 = 6usize;
848 let p3 = 2usize;
849 let mut resp3 = FdMatrix::zeros(n3, m);
850 let argvals = uniform_grid(m);
851 for i in 0..n3 {
852 for j in 0..m {
853 resp3[(i, j)] = (i as f64 + 1.0) * argvals[j];
854 }
855 }
856 let preds3: Vec<FdMatrix> = (0..p3)
857 .map(|k| {
858 let mut pred = FdMatrix::zeros(n3, m);
859 for i in 0..n3 {
860 for j in 0..m {
861 pred[(i, j)] = (i as f64 + k as f64 + 1.0) * argvals[j] + 0.1;
862 }
863 }
864 pred
865 })
866 .collect();
867 concurrent_regression(&resp3, &preds3, None, 0.2, "gaussian")
868 .expect("n > p should succeed");
869 }
870}