1use crate::error::FdarError;
19use crate::linalg::{cholesky_factor, cholesky_forward_back, compute_xtx};
20use crate::matrix::FdMatrix;
21use crate::regression::{fdata_to_pc_1d, FpcaResult};
22
23#[derive(Debug, Clone, PartialEq)]
29#[non_exhaustive]
30pub struct FofResult {
31 pub intercept: Vec<f64>,
33 pub beta_surface: FdMatrix,
35 pub fitted: FdMatrix,
37 pub residuals: FdMatrix,
39 pub r_squared_t: Vec<f64>,
41 pub r_squared: f64,
43 pub ncomp_x: usize,
45 pub ncomp_y: usize,
47 pub fpca_x: FpcaResult,
49 pub fpca_y: FpcaResult,
51 pub coef_matrix: FdMatrix,
53}
54
55#[must_use = "expensive computation whose result should not be discarded"]
113pub fn fof_regression(
114 x_data: &FdMatrix,
115 y_data: &FdMatrix,
116 x_argvals: &[f64],
117 y_argvals: &[f64],
118 ncomp_x: usize,
119 ncomp_y: usize,
120) -> Result<FofResult, FdarError> {
121 let (n_x, m_x) = x_data.shape();
122 let (n_y, m_y) = y_data.shape();
123
124 if n_x != n_y {
125 return Err(FdarError::InvalidDimension {
126 parameter: "y_data",
127 expected: format!("{n_x} rows (matching x_data)"),
128 actual: format!("{n_y} rows"),
129 });
130 }
131 let n = n_x;
132
133 if n < 3 {
134 return Err(FdarError::InvalidDimension {
135 parameter: "x_data",
136 expected: "at least 3 observations".to_string(),
137 actual: format!("{n}"),
138 });
139 }
140 if x_argvals.len() != m_x {
141 return Err(FdarError::InvalidDimension {
142 parameter: "x_argvals",
143 expected: format!("{m_x} elements"),
144 actual: format!("{} elements", x_argvals.len()),
145 });
146 }
147 if y_argvals.len() != m_y {
148 return Err(FdarError::InvalidDimension {
149 parameter: "y_argvals",
150 expected: format!("{m_y} elements"),
151 actual: format!("{} elements", y_argvals.len()),
152 });
153 }
154 if ncomp_x == 0 {
155 return Err(FdarError::InvalidParameter {
156 parameter: "ncomp_x",
157 message: "must be >= 1".to_string(),
158 });
159 }
160 if ncomp_y == 0 {
161 return Err(FdarError::InvalidParameter {
162 parameter: "ncomp_y",
163 message: "must be >= 1".to_string(),
164 });
165 }
166
167 let ncomp_x = ncomp_x.min(n - 1).min(m_x);
168 let ncomp_y = ncomp_y.min(n - 1).min(m_y);
169
170 let fpca_x = fdata_to_pc_1d(x_data, ncomp_x, x_argvals)?;
172 let fpca_y = fdata_to_pc_1d(y_data, ncomp_y, y_argvals)?;
173
174 let x_scores = fpca_x.project(x_data)?;
179 let y_scores = fpca_y.project(y_data)?;
180
181 let mut xtx = compute_xtx(&x_scores);
182 let ridge = 1e-8 * (0..ncomp_x).map(|k| xtx[k * ncomp_x + k]).sum::<f64>() / ncomp_x as f64;
185 for k in 0..ncomp_x {
186 xtx[k * ncomp_x + k] += ridge.max(1e-12);
187 }
188 let l = cholesky_factor(&xtx, ncomp_x)?;
189
190 let mut coef_matrix = FdMatrix::zeros(ncomp_x, ncomp_y);
192 for l_col in 0..ncomp_y {
193 let mut xty = vec![0.0; ncomp_x];
195 for k in 0..ncomp_x {
196 let mut s = 0.0;
197 for i in 0..n {
198 s += x_scores[(i, k)] * y_scores[(i, l_col)];
199 }
200 xty[k] = s;
201 }
202 let b_col = cholesky_forward_back(&l, &xty, ncomp_x);
203 for k in 0..ncomp_x {
204 coef_matrix[(k, l_col)] = b_col[k];
205 }
206 }
207
208 let mut beta_surface = FdMatrix::zeros(m_y, m_x);
211 for si in 0..m_y {
212 for tj in 0..m_x {
213 let mut val = 0.0;
214 for k in 0..ncomp_x {
215 for l_col in 0..ncomp_y {
216 val += coef_matrix[(k, l_col)]
217 * fpca_x.rotation[(tj, k)]
218 * fpca_y.rotation[(si, l_col)];
219 }
220 }
221 beta_surface[(si, tj)] = val;
222 }
223 }
224
225 let mut fitted_scores = FdMatrix::zeros(n, ncomp_y);
228 for i in 0..n {
229 for l_col in 0..ncomp_y {
230 let mut s = 0.0;
231 for k in 0..ncomp_x {
232 s += x_scores[(i, k)] * coef_matrix[(k, l_col)];
233 }
234 fitted_scores[(i, l_col)] = s;
235 }
236 }
237
238 let mut fitted = FdMatrix::zeros(n, m_y);
240 for i in 0..n {
241 for j in 0..m_y {
242 let mut val = fpca_y.mean[j];
243 for l_col in 0..ncomp_y {
244 val += fitted_scores[(i, l_col)] * fpca_y.rotation[(j, l_col)];
245 }
246 fitted[(i, j)] = val;
247 }
248 }
249
250 let mut residuals = FdMatrix::zeros(n, m_y);
252 for i in 0..n {
253 for j in 0..m_y {
254 residuals[(i, j)] = y_data[(i, j)] - fitted[(i, j)];
255 }
256 }
257
258 let intercept = fpca_y.mean.clone();
260
261 let mut r_squared_t = vec![0.0; m_y];
263 for j in 0..m_y {
264 let y_mean_j = fpca_y.mean[j];
265 let mut ss_tot = 0.0;
266 let mut ss_res = 0.0;
267 for i in 0..n {
268 ss_tot += (y_data[(i, j)] - y_mean_j).powi(2);
269 ss_res += residuals[(i, j)].powi(2);
270 }
271 r_squared_t[j] = if ss_tot > 0.0 {
272 1.0 - ss_res / ss_tot
273 } else {
274 0.0
275 };
276 }
277
278 let r_squared = r_squared_t.iter().sum::<f64>() / m_y as f64;
279
280 Ok(FofResult {
281 intercept,
282 beta_surface,
283 fitted,
284 residuals,
285 r_squared_t,
286 r_squared,
287 ncomp_x,
288 ncomp_y,
289 fpca_x,
290 fpca_y,
291 coef_matrix,
292 })
293}
294
295pub fn predict_fof(fit: &FofResult, new_x: &FdMatrix) -> Result<FdMatrix, FdarError> {
342 let (n_new, _m_x) = new_x.shape();
343
344 let x_scores = fit.fpca_x.project(new_x)?;
346
347 let ncomp_x = fit.ncomp_x;
348 let ncomp_y = fit.ncomp_y;
349 let m_y = fit.fpca_y.mean.len();
350
351 let mut pred_scores = FdMatrix::zeros(n_new, ncomp_y);
353 for i in 0..n_new {
354 for l_col in 0..ncomp_y {
355 let mut s = 0.0;
356 for k in 0..ncomp_x {
357 s += x_scores[(i, k)] * fit.coef_matrix[(k, l_col)];
358 }
359 pred_scores[(i, l_col)] = s;
360 }
361 }
362
363 let mut predicted = FdMatrix::zeros(n_new, m_y);
365 for i in 0..n_new {
366 for j in 0..m_y {
367 let mut val = fit.fpca_y.mean[j];
368 for l_col in 0..ncomp_y {
369 val += pred_scores[(i, l_col)] * fit.fpca_y.rotation[(j, l_col)];
370 }
371 predicted[(i, j)] = val;
372 }
373 }
374
375 Ok(predicted)
376}
377
378#[derive(Debug, Clone, PartialEq)]
386#[non_exhaustive]
387pub struct FofCvResult {
388 pub candidates: Vec<(usize, usize)>,
390 pub cv_errors: Vec<f64>,
392 pub optimal: (usize, usize),
394 pub min_cv_mse: f64,
396}
397
398#[must_use = "expensive computation whose result should not be discarded"]
419pub fn fof_cv(
420 x_data: &FdMatrix,
421 y_data: &FdMatrix,
422 x_argvals: &[f64],
423 y_argvals: &[f64],
424 ncomp_x_max: usize,
425 ncomp_y_max: usize,
426 n_folds: usize,
427 seed: u64,
428) -> Result<FofCvResult, FdarError> {
429 let n = x_data.nrows();
430 if n < n_folds {
431 return Err(FdarError::InvalidDimension {
432 parameter: "x_data",
433 expected: format!("at least {n_folds} rows"),
434 actual: format!("{n}"),
435 });
436 }
437
438 let folds = crate::cv::create_folds(n, n_folds, seed);
439 let ncomp_x_max = ncomp_x_max.min(n - 2);
440 let ncomp_y_max = ncomp_y_max.min(n - 2);
441 let m_y = y_data.ncols();
442
443 let y_weights = crate::helpers::simpsons_weights(y_argvals);
445
446 let mut candidates = Vec::new();
447 let mut cv_errors = Vec::new();
448 let mut best = (1, 1);
449 let mut best_mse = f64::INFINITY;
450
451 for ncx in 1..=ncomp_x_max {
452 for ncy in 1..=ncomp_y_max {
453 let mut total_imse = 0.0;
454 let mut count = 0;
455
456 for fold in 0..n_folds {
457 let train_idx: Vec<usize> = (0..n).filter(|&i| folds[i] != fold).collect();
458 let test_idx: Vec<usize> = (0..n).filter(|&i| folds[i] == fold).collect();
459 let n_test = test_idx.len();
460 if n_test == 0 || train_idx.len() < ncx.max(ncy) + 2 {
461 continue;
462 }
463
464 let train_x = x_data.select_rows(&train_idx);
465 let train_y = y_data.select_rows(&train_idx);
466 let test_x = x_data.select_rows(&test_idx);
467 let test_y = y_data.select_rows(&test_idx);
468
469 let Ok(fit) = fof_regression(&train_x, &train_y, x_argvals, y_argvals, ncx, ncy)
470 else {
471 continue;
472 };
473
474 let Ok(predicted) = predict_fof(&fit, &test_x) else {
475 continue;
476 };
477
478 for ti in 0..n_test {
480 let imse: f64 = (0..m_y)
481 .map(|j| (test_y[(ti, j)] - predicted[(ti, j)]).powi(2) * y_weights[j])
482 .sum();
483 total_imse += imse;
484 count += 1;
485 }
486 }
487
488 let mse = if count > 0 {
489 total_imse / count as f64
490 } else {
491 f64::INFINITY
492 };
493
494 candidates.push((ncx, ncy));
495 cv_errors.push(mse);
496
497 if mse < best_mse {
498 best_mse = mse;
499 best = (ncx, ncy);
500 }
501 }
502 }
503
504 if candidates.is_empty() {
505 return Err(FdarError::ComputationFailed {
506 operation: "fof_cv",
507 detail: "no valid (ncomp_x, ncomp_y) produced CV errors".into(),
508 });
509 }
510
511 Ok(FofCvResult {
512 candidates,
513 cv_errors,
514 optimal: best,
515 min_cv_mse: best_mse,
516 })
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522 use std::f64::consts::PI;
523
524 fn make_fof_data(
528 n: usize,
529 mx: usize,
530 my: usize,
531 seed: u64,
532 ) -> (FdMatrix, FdMatrix, Vec<f64>, Vec<f64>) {
533 let tx: Vec<f64> = (0..mx).map(|j| j as f64 / (mx - 1).max(1) as f64).collect();
534 let ty: Vec<f64> = (0..my).map(|j| j as f64 / (my - 1).max(1) as f64).collect();
535
536 let mut x = FdMatrix::zeros(n, mx);
537 let mut y = FdMatrix::zeros(n, my);
538
539 for i in 0..n {
540 let a =
542 ((seed.wrapping_mul(17).wrapping_add(i as u64 * 31) % 1000) as f64 / 500.0) - 1.0;
543 let b =
544 ((seed.wrapping_mul(7).wrapping_add(i as u64 * 53) % 1000) as f64 / 500.0) - 1.0;
545 let c =
546 ((seed.wrapping_mul(3).wrapping_add(i as u64 * 79) % 1000) as f64 / 500.0) - 1.0;
547 for j in 0..mx {
548 x[(i, j)] = a * (2.0 * PI * tx[j]).sin() + b * (4.0 * PI * tx[j]).cos() + c * tx[j];
549 }
550
551 for j in 0..my {
553 y[(i, j)] = 1.5 * a * (2.0 * PI * ty[j]).cos() - 0.8 * b * (3.0 * PI * ty[j]).sin()
554 + 0.5 * c * ty[j].powi(2)
555 + 0.01 * (seed.wrapping_add(i as u64 + j as u64) % 10) as f64;
556 }
557 }
558 (x, y, tx, ty)
559 }
560
561 #[test]
562 fn test_fof_regression_dimensions() {
563 let (x, y, tx, ty) = make_fof_data(30, 40, 25, 42);
564 let fit = fof_regression(&x, &y, &tx, &ty, 3, 3).unwrap();
565
566 assert_eq!(fit.fitted.shape(), (30, 25));
567 assert_eq!(fit.residuals.shape(), (30, 25));
568 assert_eq!(fit.beta_surface.shape(), (25, 40));
569 assert_eq!(fit.intercept.len(), 25);
570 assert_eq!(fit.r_squared_t.len(), 25);
571 assert_eq!(fit.coef_matrix.shape(), (3, 3));
572 assert_eq!(fit.ncomp_x, 3);
573 assert_eq!(fit.ncomp_y, 3);
574 }
575
576 #[test]
577 fn test_fof_regression_r_squared_positive() {
578 let (x, y, tx, ty) = make_fof_data(30, 40, 25, 42);
579 let fit = fof_regression(&x, &y, &tx, &ty, 3, 3).unwrap();
580
581 assert!(
583 fit.r_squared > 0.0,
584 "R² should be positive for correlated data, got {}",
585 fit.r_squared
586 );
587 }
588
589 #[test]
590 fn test_predict_fof_training_matches_fitted() {
591 let (x, y, tx, ty) = make_fof_data(30, 40, 25, 42);
592 let fit = fof_regression(&x, &y, &tx, &ty, 3, 3).unwrap();
593 let predicted = predict_fof(&fit, &x).unwrap();
594
595 assert_eq!(predicted.shape(), fit.fitted.shape());
596 let (n, my) = predicted.shape();
597 for i in 0..n {
598 for j in 0..my {
599 assert!(
600 (predicted[(i, j)] - fit.fitted[(i, j)]).abs() < 1e-6,
601 "predicted should match fitted at ({i}, {j}): {} vs {}",
602 predicted[(i, j)],
603 fit.fitted[(i, j)]
604 );
605 }
606 }
607 }
608
609 #[test]
610 fn test_predict_fof_new_data_finite() {
611 let (x, y, tx, ty) = make_fof_data(30, 40, 25, 42);
612 let fit = fof_regression(&x, &y, &tx, &ty, 3, 3).unwrap();
613
614 let n_new = 10;
616 let mx = 40;
617 let mut new_x = FdMatrix::zeros(n_new, mx);
618 for i in 0..n_new {
619 let p = (i as f64 + 0.5) * PI / n_new as f64;
620 for j in 0..mx {
621 new_x[(i, j)] = (2.0 * PI * tx[j] + p).cos();
622 }
623 }
624
625 let predicted = predict_fof(&fit, &new_x).unwrap();
626 assert_eq!(predicted.shape(), (n_new, 25));
627 for i in 0..n_new {
628 for j in 0..25 {
629 assert!(
630 predicted[(i, j)].is_finite(),
631 "prediction should be finite at ({i}, {j})"
632 );
633 }
634 }
635 }
636
637 #[test]
638 fn test_fof_regression_mismatched_n() {
639 let (x, _y, tx, ty) = make_fof_data(30, 40, 25, 42);
640 let y_bad = FdMatrix::zeros(20, 25);
641 let result = fof_regression(&x, &y_bad, &tx, &ty, 3, 3);
642 assert!(result.is_err());
643 }
644
645 #[test]
646 fn test_fof_regression_bad_argvals() {
647 let (x, y, _tx, ty) = make_fof_data(30, 40, 25, 42);
648 let bad_tx: Vec<f64> = (0..10).map(|j| j as f64).collect(); let result = fof_regression(&x, &y, &bad_tx, &ty, 3, 3);
650 assert!(result.is_err());
651 }
652
653 #[test]
654 fn test_fof_regression_zero_ncomp() {
655 let (x, y, tx, ty) = make_fof_data(30, 40, 25, 42);
656 assert!(fof_regression(&x, &y, &tx, &ty, 0, 3).is_err());
657 assert!(fof_regression(&x, &y, &tx, &ty, 3, 0).is_err());
658 }
659
660 #[test]
661 fn test_fof_cv() {
662 let (x, y, tx, ty) = make_fof_data(30, 40, 25, 42);
663 let cv = fof_cv(&x, &y, &tx, &ty, 4, 4, 5, 42).unwrap();
664 assert!(!cv.candidates.is_empty());
665 assert!(cv.optimal.0 >= 1);
666 assert!(cv.optimal.1 >= 1);
667 assert!(cv.min_cv_mse.is_finite());
668 }
669
670 #[test]
671 fn test_fof_regression_residuals_consistent() {
672 let (x, y, tx, ty) = make_fof_data(30, 40, 25, 42);
673 let fit = fof_regression(&x, &y, &tx, &ty, 3, 3).unwrap();
674
675 let (n, my) = y.shape();
676 for i in 0..n {
677 for j in 0..my {
678 let expected_resid = y[(i, j)] - fit.fitted[(i, j)];
679 assert!(
680 (fit.residuals[(i, j)] - expected_resid).abs() < 1e-10,
681 "residual mismatch at ({i}, {j})"
682 );
683 }
684 }
685 }
686}