Skip to main content

fdars_core/
fof_regression.rs

1//! Function-on-function regression.
2//!
3//! Model: `Y(s) = α(s) + ∫ β(s,t) X(t) dt + ε(s)`
4//!
5//! Uses double FPCA: decompose both response Y and predictor X into
6//! FPC scores, regress Y-scores on X-scores, then reconstruct β(s,t)
7//! and fitted curves.
8//!
9//! # References
10//!
11//! - Ramsay, J. O. & Silverman, B. W. (2005). *Functional Data Analysis*, Ch. 16-17.
12//! - Yao, F., Müller, H.-G. & Wang, J.-L. (2005). Functional linear regression
13//!   analysis for longitudinal data. *Annals of Statistics*, 33(6), 2873--2903.
14//! - Ivanescu, A. E., Staicu, A.-M., Scheipl, F. & Greven, S. (2015).
15//!   Penalized function-on-function regression. *Computational Statistics*,
16//!   30(2), 539--568.
17
18use 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// ---------------------------------------------------------------------------
24// Result type
25// ---------------------------------------------------------------------------
26
27/// Result of function-on-function regression.
28#[derive(Debug, Clone, PartialEq)]
29#[non_exhaustive]
30pub struct FofResult {
31    /// Intercept function α(s) (length m_y)
32    pub intercept: Vec<f64>,
33    /// Coefficient surface β(s,t) stored as (m_y x m_x) matrix
34    pub beta_surface: FdMatrix,
35    /// Fitted response curves (n x m_y)
36    pub fitted: FdMatrix,
37    /// Residual curves (n x m_y)
38    pub residuals: FdMatrix,
39    /// R-squared per response grid point (length m_y)
40    pub r_squared_t: Vec<f64>,
41    /// Overall R-squared (mean of pointwise R-squared)
42    pub r_squared: f64,
43    /// Number of predictor FPC components used
44    pub ncomp_x: usize,
45    /// Number of response FPC components used
46    pub ncomp_y: usize,
47    /// FPCA of predictor (for projection)
48    pub fpca_x: FpcaResult,
49    /// FPCA of response (for reconstruction)
50    pub fpca_y: FpcaResult,
51    /// Coefficient matrix B: Y-scores = X-scores * B (ncomp_x x ncomp_y)
52    pub coef_matrix: FdMatrix,
53}
54
55// ---------------------------------------------------------------------------
56// Main function
57// ---------------------------------------------------------------------------
58
59/// Function-on-function regression via double FPCA.
60///
61/// Decomposes both predictor and response via FPCA, regresses Y-scores
62/// on X-scores using OLS, then reconstructs the coefficient surface
63/// β(s,t) and fitted curves.
64///
65/// # Arguments
66/// * `x_data` - Functional predictor (n x m_x)
67/// * `y_data` - Functional response (n x m_y)
68/// * `x_argvals` - Predictor grid (length m_x)
69/// * `y_argvals` - Response grid (length m_y)
70/// * `ncomp_x` - Number of predictor FPC components
71/// * `ncomp_y` - Number of response FPC components
72///
73/// # Errors
74///
75/// Returns [`FdarError::InvalidDimension`] if `x_data` and `y_data` have
76/// different row counts, or argvals lengths do not match column counts.
77/// Returns [`FdarError::InvalidParameter`] if `ncomp_x` or `ncomp_y` is zero.
78/// Returns [`FdarError::ComputationFailed`] if FPCA or OLS fails.
79///
80/// # References
81///
82/// Ramsay, J. O. & Silverman, B. W. (2005). *Functional Data Analysis*, Ch. 16-17.
83///
84/// # Examples
85///
86/// ```
87/// use fdars_core::matrix::FdMatrix;
88/// use fdars_core::fof_regression::fof_regression;
89///
90/// let (n, mx, my) = (25, 30, 20);
91/// let x = FdMatrix::from_column_major(
92///     (0..n * mx).map(|k| {
93///         let i = (k % n) as f64;
94///         let j = (k / n) as f64;
95///         ((i + 1.0) * j * 0.2).sin()
96///     }).collect(), n, mx,
97/// ).unwrap();
98/// let y = FdMatrix::from_column_major(
99///     (0..n * my).map(|k| {
100///         let i = (k % n) as f64;
101///         let j = (k / n) as f64;
102///         0.5 * ((i + 1.0) * j * 0.15).cos()
103///     }).collect(), n, my,
104/// ).unwrap();
105/// let tx: Vec<f64> = (0..mx).map(|j| j as f64 / (mx - 1) as f64).collect();
106/// let ty: Vec<f64> = (0..my).map(|j| j as f64 / (my - 1) as f64).collect();
107///
108/// let fit = fof_regression(&x, &y, &tx, &ty, 3, 3).unwrap();
109/// assert_eq!(fit.fitted.shape(), (n, my));
110/// assert_eq!(fit.beta_surface.shape(), (my, mx));
111/// ```
112#[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    // --- FPCA on X and Y ---
171    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    // --- Multivariate OLS: Y_scores = X_scores * B ---
175    // Use projected scores (weighted inner product with eigenfunctions) rather
176    // than SVD-derived scores so that training and prediction follow the same
177    // computational path, guaranteeing exact agreement on training data.
178    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    // Small ridge regularization for numerical stability (standard in
183    // double-FPCA regression; see Ivanescu et al. 2015).
184    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    // Solve for each column of Y_scores separately
191    let mut coef_matrix = FdMatrix::zeros(ncomp_x, ncomp_y);
192    for l_col in 0..ncomp_y {
193        // X' * y_scores[:,l_col]
194        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    // --- Reconstruct coefficient surface β(s,t) ---
209    // β(s_i, t_j) = Σ_k Σ_l B_{kl} * φ_x^k(t_j) * φ_y^l(s_i)
210    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    // --- Fitted Y-scores and reconstruction ---
226    // Ŷ_scores = X_scores * B (n x ncomp_y)
227    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    // Reconstruct fitted curves: Ŷ(s) = mean_y(s) + Σ_l fitted_score_l * φ_y^l(s)
239    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    // --- Residuals ---
251    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    // --- Intercept: α(s) = mean_y(s) (since we centered Y via FPCA) ---
259    let intercept = fpca_y.mean.clone();
260
261    // --- Pointwise R² ---
262    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
295// ---------------------------------------------------------------------------
296// Prediction
297// ---------------------------------------------------------------------------
298
299/// Predict functional responses from new functional predictors.
300///
301/// Projects `new_x` onto the predictor FPCA, computes predicted Y-scores
302/// via the fitted coefficient matrix, and reconstructs response curves.
303///
304/// # Arguments
305/// * `fit` - A fitted [`FofResult`]
306/// * `new_x` - New functional predictor data (n_new x m_x)
307///
308/// # Errors
309///
310/// Returns [`FdarError::InvalidDimension`] if the column count of `new_x`
311/// does not match the predictor grid used during fitting.
312///
313/// # Examples
314///
315/// ```
316/// use fdars_core::matrix::FdMatrix;
317/// use fdars_core::fof_regression::{fof_regression, predict_fof};
318///
319/// let (n, mx, my) = (25, 30, 20);
320/// let x = FdMatrix::from_column_major(
321///     (0..n * mx).map(|k| {
322///         let i = (k % n) as f64;
323///         let j = (k / n) as f64;
324///         ((i + 1.0) * j * 0.2).sin()
325///     }).collect(), n, mx,
326/// ).unwrap();
327/// let y = FdMatrix::from_column_major(
328///     (0..n * my).map(|k| {
329///         let i = (k % n) as f64;
330///         let j = (k / n) as f64;
331///         0.5 * ((i + 1.0) * j * 0.15).cos()
332///     }).collect(), n, my,
333/// ).unwrap();
334/// let tx: Vec<f64> = (0..mx).map(|j| j as f64 / (mx - 1) as f64).collect();
335/// let ty: Vec<f64> = (0..my).map(|j| j as f64 / (my - 1) as f64).collect();
336///
337/// let fit = fof_regression(&x, &y, &tx, &ty, 3, 3).unwrap();
338/// let predicted = predict_fof(&fit, &x).unwrap();
339/// assert_eq!(predicted.shape(), (n, my));
340/// ```
341pub fn predict_fof(fit: &FofResult, new_x: &FdMatrix) -> Result<FdMatrix, FdarError> {
342    let (n_new, _m_x) = new_x.shape();
343
344    // Project onto predictor FPCA
345    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    // Compute predicted Y-scores: Ŷ_scores = X_scores * B
352    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    // Reconstruct: Ŷ(s) = mean_y(s) + Σ_l score_l * φ_y^l(s)
364    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// ---------------------------------------------------------------------------
379// Tests
380// ---------------------------------------------------------------------------
381// Cross-validation
382// ---------------------------------------------------------------------------
383
384/// Result of function-on-function cross-validation.
385#[derive(Debug, Clone, PartialEq)]
386#[non_exhaustive]
387pub struct FofCvResult {
388    /// (ncomp_x, ncomp_y) candidates tested.
389    pub candidates: Vec<(usize, usize)>,
390    /// Integrated CV-MSE for each candidate.
391    pub cv_errors: Vec<f64>,
392    /// Optimal (ncomp_x, ncomp_y).
393    pub optimal: (usize, usize),
394    /// Minimum integrated CV-MSE.
395    pub min_cv_mse: f64,
396}
397
398/// K-fold cross-validation for function-on-function regression.
399///
400/// Searches over a grid of (ncomp_x, ncomp_y) values and selects the
401/// combination minimizing integrated mean squared error (IMSE).
402///
403/// # Arguments
404/// * `x_data` - Functional predictor (n × m_x)
405/// * `y_data` - Functional response (n × m_y)
406/// * `x_argvals` - Predictor grid (length m_x)
407/// * `y_argvals` - Response grid (length m_y)
408/// * `ncomp_x_max` - Maximum predictor components to try
409/// * `ncomp_y_max` - Maximum response components to try
410/// * `n_folds` - Number of CV folds
411/// * `seed` - Random seed for fold assignment
412///
413/// # References
414///
415/// Ivanescu, A. E., Staicu, A.-M., Scheipl, F. & Greven, S. (2015).
416/// Penalized function-on-function regression. *Computational Statistics*,
417/// 30(2), 539--568.
418#[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    // Integration weights for IMSE
444    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                // Integrated MSE per test curve
479                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    /// Generate test data with multiple independent modes of variation so
525    /// that requesting several FPC components produces a well-conditioned
526    /// score matrix.
527    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            // Multiple independent per-observation loadings for X
541            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            // Y depends on X via integral-like coupling with distinct modes
552            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        // For correlated data, overall R² should be positive
582        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        // Create slightly different new data
615        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(); // wrong length
649        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}