Skip to main content

fdars_core/scalar_on_function/
multi.rs

1//! Multi-predictor scalar-on-function regression.
2//!
3//! Extends [`fregre_lm`](super::fregre_lm::fregre_lm) to accept multiple
4//! functional predictors, each with its own FPCA decomposition and number
5//! of components.
6//!
7//! Model: `y = α + Σ_k ∫ β_k(t) X_k(t) dt + γᵀz + ε`
8//!
9//! # References
10//!
11//! - Ramsay, J. O. & Silverman, B. W. (2005). *Functional Data Analysis*, Ch. 15.
12//! - Febrero-Bande, M. & Oviedo de la Fuente, M. (2012). Statistical Computing
13//!   in Functional Data Analysis: The R Package fda.usc. *Journal of Statistical
14//!   Software*, 51(4), 1--28.
15
16use crate::error::FdarError;
17use crate::matrix::FdMatrix;
18use crate::regression::{fdata_to_pc_1d, FpcaResult};
19
20use super::{compute_fitted, compute_r_squared, ols_solve, MultiFregreLmResult};
21
22/// Scalar-on-function regression with multiple functional predictors.
23///
24/// Runs FPCA on each functional predictor separately, concatenates score
25/// matrices, and fits OLS. Each predictor can have a different grid and
26/// number of components.
27///
28/// # Arguments
29/// * `predictors` - Slice of `(data, argvals, ncomp)` tuples for each functional predictor
30/// * `y` - Scalar response (length n)
31/// * `scalar_covariates` - Optional scalar covariates (n x p)
32///
33/// # Errors
34///
35/// Returns [`FdarError::InvalidDimension`] if predictors have differing row counts,
36/// `y.len() != n`, or `scalar_covariates` row count differs from `n`.
37/// Returns [`FdarError::InvalidParameter`] if `predictors` is empty.
38/// Returns [`FdarError::ComputationFailed`] if any FPCA or OLS step fails.
39///
40/// # References
41///
42/// Ramsay, J. O. & Silverman, B. W. (2005). *Functional Data Analysis*, Ch. 15.
43///
44/// # Examples
45///
46/// ```
47/// use fdars_core::matrix::FdMatrix;
48/// use fdars_core::scalar_on_function::fregre_lm_multi;
49///
50/// let (n, m1, m2) = (25, 30, 20);
51/// let x1 = FdMatrix::from_column_major(
52///     (0..n * m1).map(|k| {
53///         let i = (k % n) as f64;
54///         let j = (k / n) as f64;
55///         ((i + 1.0) * j * 0.2).sin()
56///     }).collect(), n, m1,
57/// ).unwrap();
58/// let x2 = FdMatrix::from_column_major(
59///     (0..n * m2).map(|k| {
60///         let i = (k % n) as f64;
61///         let j = (k / n) as f64;
62///         ((i + 1.0) * j * 0.3).cos()
63///     }).collect(), n, m2,
64/// ).unwrap();
65/// let t1: Vec<f64> = (0..m1).map(|j| j as f64 / (m1 - 1) as f64).collect();
66/// let t2: Vec<f64> = (0..m2).map(|j| j as f64 / (m2 - 1) as f64).collect();
67/// let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.3).sin()).collect();
68///
69/// let fit = fregre_lm_multi(
70///     &[(&x1, t1.as_slice(), 3), (&x2, t2.as_slice(), 2)],
71///     &y, None,
72/// ).unwrap();
73/// assert_eq!(fit.fitted_values.len(), n);
74/// assert_eq!(fit.beta_t.len(), 2);
75/// assert_eq!(fit.ncomp, vec![3, 2]);
76/// ```
77#[must_use = "expensive computation whose result should not be discarded"]
78pub fn fregre_lm_multi(
79    predictors: &[(&FdMatrix, &[f64], usize)],
80    y: &[f64],
81    scalar_covariates: Option<&FdMatrix>,
82) -> Result<MultiFregreLmResult, FdarError> {
83    // --- Validate inputs ---
84    if predictors.is_empty() {
85        return Err(FdarError::InvalidParameter {
86            parameter: "predictors",
87            message: "at least one functional predictor is required".to_string(),
88        });
89    }
90
91    let n = predictors[0].0.nrows();
92    if n < 3 {
93        return Err(FdarError::InvalidDimension {
94            parameter: "predictors",
95            expected: "at least 3 observations".to_string(),
96            actual: format!("{n}"),
97        });
98    }
99    if y.len() != n {
100        return Err(FdarError::InvalidDimension {
101            parameter: "y",
102            expected: format!("{n}"),
103            actual: format!("{}", y.len()),
104        });
105    }
106
107    for (k, &(data_k, argvals_k, _)) in predictors.iter().enumerate() {
108        if data_k.nrows() != n {
109            return Err(FdarError::InvalidDimension {
110                parameter: "predictors",
111                expected: format!("{n} rows for predictor {k}"),
112                actual: format!("{} rows", data_k.nrows()),
113            });
114        }
115        if argvals_k.len() != data_k.ncols() {
116            return Err(FdarError::InvalidDimension {
117                parameter: "argvals",
118                expected: format!("{} elements for predictor {k}", data_k.ncols()),
119                actual: format!("{} elements", argvals_k.len()),
120            });
121        }
122    }
123
124    if let Some(sc) = scalar_covariates {
125        if sc.nrows() != n {
126            return Err(FdarError::InvalidDimension {
127                parameter: "scalar_covariates",
128                expected: format!("{n} rows"),
129                actual: format!("{} rows", sc.nrows()),
130            });
131        }
132    }
133
134    // --- Run FPCA on each predictor ---
135    let k_preds = predictors.len();
136    let mut fpcas: Vec<FpcaResult> = Vec::with_capacity(k_preds);
137    let mut ncomp_vec: Vec<usize> = Vec::with_capacity(k_preds);
138
139    for &(data_k, argvals_k, ncomp_k) in predictors {
140        let nc = ncomp_k.max(1).min(n - 1).min(data_k.ncols());
141        let fpca = fdata_to_pc_1d(data_k, nc, argvals_k)?;
142        ncomp_vec.push(nc);
143        fpcas.push(fpca);
144    }
145
146    let total_scores: usize = ncomp_vec.iter().sum();
147    let p_scalar = scalar_covariates.map_or(0, FdMatrix::ncols);
148    let p_total = 1 + total_scores + p_scalar;
149
150    // --- Build concatenated design matrix: [1, scores_1, ..., scores_K, scalars] ---
151    // Use projected scores (weighted inner product with eigenfunctions) rather
152    // than SVD-derived scores so that training and prediction follow the same
153    // computational path, guaranteeing exact agreement on training data.
154    let mut design = FdMatrix::zeros(n, p_total);
155    for i in 0..n {
156        design[(i, 0)] = 1.0;
157    }
158
159    let mut col_offset = 1;
160    for (idx, fpca) in fpcas.iter().enumerate() {
161        let nc = ncomp_vec[idx];
162        let m_k = fpca.mean.len();
163        let data_k = predictors[idx].0;
164        for i in 0..n {
165            for k in 0..nc {
166                let mut s = 0.0;
167                for j in 0..m_k {
168                    s += (data_k[(i, j)] - fpca.mean[j]) * fpca.rotation[(j, k)] * fpca.weights[j];
169                }
170                design[(i, col_offset + k)] = s;
171            }
172        }
173        col_offset += nc;
174    }
175
176    if let Some(sc) = scalar_covariates {
177        for i in 0..n {
178            for j in 0..p_scalar {
179                design[(i, col_offset + j)] = sc[(i, j)];
180            }
181        }
182    }
183
184    // --- OLS ---
185    let (coeffs, _hat_diag) = ols_solve(&design, y)?;
186
187    let fitted_values = compute_fitted(&design, &coeffs);
188    let residuals: Vec<f64> = y
189        .iter()
190        .zip(&fitted_values)
191        .map(|(&yi, &yh)| yi - yh)
192        .collect();
193    let (r_squared, r_squared_adj) = compute_r_squared(y, &residuals, p_total);
194
195    let ss_res: f64 = residuals.iter().map(|r| r * r).sum();
196    let df_resid = (n as f64 - p_total as f64).max(1.0);
197    let residual_se = (ss_res / df_resid).sqrt();
198
199    let nf = n as f64;
200    let aic = nf * (ss_res / nf).ln() + 2.0 * p_total as f64;
201    let bic = nf * (ss_res / nf).ln() + nf.ln() * p_total as f64;
202
203    // --- Recover β_k(t) for each predictor ---
204    let mut beta_t: Vec<Vec<f64>> = Vec::with_capacity(k_preds);
205    let mut coeff_offset = 1;
206    for (idx, fpca) in fpcas.iter().enumerate() {
207        let nc = ncomp_vec[idx];
208        let m_k = fpca.rotation.nrows();
209        let fpc_coeffs = &coeffs[coeff_offset..coeff_offset + nc];
210        let mut beta_k = vec![0.0; m_k];
211        for k in 0..nc {
212            for j in 0..m_k {
213                beta_k[j] += fpc_coeffs[k] * fpca.rotation[(j, k)];
214            }
215        }
216        beta_t.push(beta_k);
217        coeff_offset += nc;
218    }
219
220    // Scalar coefficients
221    let gamma: Vec<f64> = coeffs[1 + total_scores..].to_vec();
222
223    Ok(MultiFregreLmResult {
224        intercept: coeffs[0],
225        beta_t,
226        gamma,
227        fitted_values,
228        residuals,
229        r_squared,
230        r_squared_adj,
231        ncomp: ncomp_vec,
232        fpcas,
233        coefficients: coeffs,
234        residual_se,
235        aic,
236        bic,
237    })
238}
239
240/// Predict from a multi-predictor functional linear model.
241///
242/// Projects each new predictor onto its stored FPCA, concatenates scores,
243/// and multiplies by the fitted coefficients.
244///
245/// # Arguments
246/// * `fit` - A fitted [`MultiFregreLmResult`]
247/// * `new_predictors` - Slice of new functional predictor matrices (one per predictor, each n_new x m_k)
248/// * `new_scalar` - Optional new scalar covariates (n_new x p)
249///
250/// # Errors
251///
252/// Returns [`FdarError::InvalidDimension`] if the number of new predictors
253/// does not match the number used in fitting, or if row counts differ.
254///
255/// # Examples
256///
257/// ```
258/// use fdars_core::matrix::FdMatrix;
259/// use fdars_core::scalar_on_function::{fregre_lm_multi, predict_fregre_lm_multi};
260///
261/// let (n, m1, m2) = (25, 30, 20);
262/// let x1 = FdMatrix::from_column_major(
263///     (0..n * m1).map(|k| {
264///         let i = (k % n) as f64;
265///         let j = (k / n) as f64;
266///         ((i + 1.0) * j * 0.2).sin()
267///     }).collect(), n, m1,
268/// ).unwrap();
269/// let x2 = FdMatrix::from_column_major(
270///     (0..n * m2).map(|k| {
271///         let i = (k % n) as f64;
272///         let j = (k / n) as f64;
273///         ((i + 1.0) * j * 0.3).cos()
274///     }).collect(), n, m2,
275/// ).unwrap();
276/// let t1: Vec<f64> = (0..m1).map(|j| j as f64 / (m1 - 1) as f64).collect();
277/// let t2: Vec<f64> = (0..m2).map(|j| j as f64 / (m2 - 1) as f64).collect();
278/// let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.3).sin()).collect();
279///
280/// let fit = fregre_lm_multi(
281///     &[(&x1, t1.as_slice(), 3), (&x2, t2.as_slice(), 2)],
282///     &y, None,
283/// ).unwrap();
284/// let preds = predict_fregre_lm_multi(&fit, &[&x1, &x2], None).unwrap();
285/// assert_eq!(preds.len(), n);
286/// ```
287pub fn predict_fregre_lm_multi(
288    fit: &MultiFregreLmResult,
289    new_predictors: &[&FdMatrix],
290    new_scalar: Option<&FdMatrix>,
291) -> Result<Vec<f64>, FdarError> {
292    let k_preds = fit.fpcas.len();
293    if new_predictors.len() != k_preds {
294        return Err(FdarError::InvalidDimension {
295            parameter: "new_predictors",
296            expected: format!("{k_preds} predictors"),
297            actual: format!("{} predictors", new_predictors.len()),
298        });
299    }
300
301    let n_new = new_predictors[0].nrows();
302    for (idx, &pred) in new_predictors.iter().enumerate() {
303        if pred.nrows() != n_new {
304            return Err(FdarError::InvalidDimension {
305                parameter: "new_predictors",
306                expected: format!("{n_new} rows for predictor {idx}"),
307                actual: format!("{} rows", pred.nrows()),
308            });
309        }
310    }
311
312    if let Some(sc) = new_scalar {
313        if sc.nrows() != n_new {
314            return Err(FdarError::InvalidDimension {
315                parameter: "new_scalar",
316                expected: format!("{n_new} rows"),
317                actual: format!("{} rows", sc.nrows()),
318            });
319        }
320    }
321
322    // Compute predictions directly via manual projection (matching the
323    // approach used by predict_fregre_lm to ensure numerical consistency
324    // between fitted values and training-data predictions).
325    let p_scalar = fit.gamma.len();
326
327    let mut predictions = vec![0.0; n_new];
328    for i in 0..n_new {
329        let mut yhat = fit.intercept;
330
331        // Score-coefficient contributions from each functional predictor
332        let mut coeff_offset = 1;
333        for (idx, &pred) in new_predictors.iter().enumerate() {
334            let fpca = &fit.fpcas[idx];
335            let nc = fit.ncomp[idx];
336            let m_k = fpca.mean.len();
337            for k in 0..nc {
338                let mut s = 0.0;
339                for j in 0..m_k {
340                    s += (pred[(i, j)] - fpca.mean[j]) * fpca.rotation[(j, k)] * fpca.weights[j];
341                }
342                yhat += fit.coefficients[coeff_offset + k] * s;
343            }
344            coeff_offset += nc;
345        }
346
347        // Scalar covariate contributions
348        if let Some(sc) = new_scalar {
349            for j in 0..p_scalar {
350                yhat += fit.gamma[j] * sc[(i, j)];
351            }
352        }
353
354        predictions[i] = yhat;
355    }
356
357    Ok(predictions)
358}
359
360/// K-fold cross-validation for multi-predictor functional regression.
361///
362/// Searches over a grid of per-predictor ncomp values (all predictors share
363/// the same ncomp) and selects the one minimizing CV-MSE.
364///
365/// # Arguments
366/// * `predictors` - Slice of `(data, argvals)` for each functional predictor
367/// * `y` - Scalar response (length n)
368/// * `scalar_covariates` - Optional scalar covariates (n × p)
369/// * `ncomp_max` - Maximum ncomp to try (shared across all predictors)
370/// * `n_folds` - Number of CV folds
371/// * `seed` - Random seed for fold assignment
372///
373/// # References
374///
375/// Febrero-Bande, M. & Oviedo de la Fuente, M. (2012). Statistical Computing
376/// in Functional Data Analysis: The R Package fda.usc. *Journal of Statistical
377/// Software*, 51(4), 1--28.
378#[must_use = "expensive computation whose result should not be discarded"]
379pub fn fregre_lm_multi_cv(
380    predictors: &[(&FdMatrix, &[f64])],
381    y: &[f64],
382    scalar_covariates: Option<&FdMatrix>,
383    ncomp_max: usize,
384    n_folds: usize,
385    seed: u64,
386) -> Result<MultiCvResult, FdarError> {
387    if predictors.is_empty() {
388        return Err(FdarError::InvalidParameter {
389            parameter: "predictors",
390            message: "need at least one functional predictor".into(),
391        });
392    }
393    let n = predictors[0].0.nrows();
394    if n < n_folds {
395        return Err(FdarError::InvalidDimension {
396            parameter: "data",
397            expected: format!("at least {n_folds} rows"),
398            actual: format!("{n}"),
399        });
400    }
401
402    let folds = crate::cv::create_folds(n, n_folds, seed);
403    let ncomp_max = ncomp_max.min(n - 2);
404    let k_preds = predictors.len();
405
406    let mut candidates = Vec::new();
407    let mut cv_errors = Vec::new();
408    let mut best_ncomp = 1;
409    let mut best_mse = f64::INFINITY;
410    let mut best_oof = vec![f64::NAN; n];
411
412    for nc in 1..=ncomp_max {
413        let mut oof_preds = vec![f64::NAN; n];
414        let mut total_se = 0.0;
415        let mut count = 0;
416
417        for fold in 0..n_folds {
418            let train_idx: Vec<usize> = (0..n).filter(|&i| folds[i] != fold).collect();
419            let test_idx: Vec<usize> = (0..n).filter(|&i| folds[i] == fold).collect();
420            let n_train = train_idx.len();
421            let n_test = test_idx.len();
422            if n_test == 0 || n_train < nc + 2 {
423                continue;
424            }
425
426            // Build train/test splits for each predictor
427            let mut train_preds: Vec<(FdMatrix, Vec<f64>, usize)> = Vec::with_capacity(k_preds);
428            let mut test_preds: Vec<FdMatrix> = Vec::with_capacity(k_preds);
429
430            for &(data_k, argvals_k) in predictors {
431                let train_k = data_k.select_rows(&train_idx);
432                let test_k = data_k.select_rows(&test_idx);
433                train_preds.push((train_k, argvals_k.to_vec(), nc));
434                test_preds.push(test_k);
435            }
436
437            let train_y: Vec<f64> = train_idx.iter().map(|&i| y[i]).collect();
438            let train_sc = scalar_covariates.map(|sc| sc.select_rows(&train_idx));
439            let test_sc = scalar_covariates.map(|sc| sc.select_rows(&test_idx));
440
441            let pred_refs: Vec<(&FdMatrix, &[f64], usize)> = train_preds
442                .iter()
443                .map(|(d, a, c)| (d, a.as_slice(), *c))
444                .collect();
445
446            let Ok(fit) = fregre_lm_multi(&pred_refs, &train_y, train_sc.as_ref()) else {
447                continue;
448            };
449
450            let test_pred_refs: Vec<&FdMatrix> = test_preds.iter().collect();
451            let Ok(preds) = predict_fregre_lm_multi(&fit, &test_pred_refs, test_sc.as_ref()) else {
452                continue;
453            };
454
455            for (ti, &i) in test_idx.iter().enumerate() {
456                oof_preds[i] = preds[ti];
457                total_se += (y[i] - preds[ti]).powi(2);
458                count += 1;
459            }
460        }
461
462        let mse = if count > 0 {
463            total_se / count as f64
464        } else {
465            f64::INFINITY
466        };
467
468        candidates.push(nc);
469        cv_errors.push(mse);
470
471        if mse < best_mse {
472            best_mse = mse;
473            best_ncomp = nc;
474            best_oof = oof_preds;
475        }
476    }
477
478    if candidates.is_empty() {
479        return Err(FdarError::ComputationFailed {
480            operation: "fregre_lm_multi_cv",
481            detail: "no valid ncomp produced CV errors".into(),
482        });
483    }
484
485    Ok(MultiCvResult {
486        candidates,
487        cv_errors,
488        optimal_ncomp: best_ncomp,
489        min_cv_mse: best_mse,
490        oof_predictions: best_oof,
491    })
492}
493
494/// Result of multi-predictor cross-validation.
495#[derive(Debug, Clone, PartialEq)]
496#[non_exhaustive]
497pub struct MultiCvResult {
498    /// Candidate ncomp values tested.
499    pub candidates: Vec<usize>,
500    /// CV-MSE for each candidate.
501    pub cv_errors: Vec<f64>,
502    /// Optimal ncomp (shared across all predictors).
503    pub optimal_ncomp: usize,
504    /// Minimum CV-MSE.
505    pub min_cv_mse: f64,
506    /// Out-of-fold predictions at optimal ncomp (length n).
507    pub oof_predictions: Vec<f64>,
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use std::f64::consts::PI;
514
515    /// Generate test data with multiple independent modes of variation per
516    /// predictor so that requesting >1 FPC component produces a well-
517    /// conditioned design matrix.
518    fn make_multi_data(
519        n: usize,
520        m1: usize,
521        m2: usize,
522        seed: u64,
523    ) -> (FdMatrix, FdMatrix, Vec<f64>, Vec<f64>, Vec<f64>) {
524        let t1: Vec<f64> = (0..m1).map(|j| j as f64 / (m1 - 1).max(1) as f64).collect();
525        let t2: Vec<f64> = (0..m2).map(|j| j as f64 / (m2 - 1).max(1) as f64).collect();
526        let mut x1 = FdMatrix::zeros(n, m1);
527        let mut x2 = FdMatrix::zeros(n, m2);
528        let mut y = vec![0.0; n];
529
530        for i in 0..n {
531            // Multiple independent per-observation "loadings"
532            let a1 =
533                ((seed.wrapping_mul(17).wrapping_add(i as u64 * 31) % 1000) as f64 / 500.0) - 1.0;
534            let b1 =
535                ((seed.wrapping_mul(7).wrapping_add(i as u64 * 53) % 1000) as f64 / 500.0) - 1.0;
536            let c1 =
537                ((seed.wrapping_mul(3).wrapping_add(i as u64 * 79) % 1000) as f64 / 500.0) - 1.0;
538
539            let a2 =
540                ((seed.wrapping_mul(11).wrapping_add(i as u64 * 43) % 1000) as f64 / 500.0) - 1.0;
541            let b2 =
542                ((seed.wrapping_mul(23).wrapping_add(i as u64 * 67) % 1000) as f64 / 500.0) - 1.0;
543
544            // X1: three modes of variation
545            for j in 0..m1 {
546                x1[(i, j)] =
547                    a1 * (2.0 * PI * t1[j]).sin() + b1 * (4.0 * PI * t1[j]).cos() + c1 * t1[j];
548            }
549            // X2: two modes of variation (different frequencies)
550            for j in 0..m2 {
551                x2[(i, j)] = a2 * (2.0 * PI * t2[j]).cos() + b2 * (6.0 * PI * t2[j]).sin();
552            }
553            y[i] = 2.0 * a1 - 1.5 * b1
554                + 0.8 * a2
555                + 0.3 * b2
556                + 0.05 * (seed.wrapping_add(i as u64) % 10) as f64;
557        }
558        (x1, x2, y, t1, t2)
559    }
560
561    #[test]
562    fn test_fregre_lm_multi_two_predictors() {
563        let (x1, x2, y, t1, t2) = make_multi_data(30, 40, 25, 42);
564        let fit = fregre_lm_multi(&[(&x1, &t1, 3), (&x2, &t2, 2)], &y, None).unwrap();
565
566        assert_eq!(fit.fitted_values.len(), 30);
567        assert_eq!(fit.residuals.len(), 30);
568        assert_eq!(fit.beta_t.len(), 2);
569        assert_eq!(fit.beta_t[0].len(), 40);
570        assert_eq!(fit.beta_t[1].len(), 25);
571        assert_eq!(fit.ncomp, vec![3, 2]);
572        assert!(fit.r_squared >= 0.0);
573        assert!(fit.r_squared <= 1.0 + 1e-10);
574    }
575
576    #[test]
577    fn test_fregre_lm_multi_with_scalar() {
578        let (x1, x2, y, t1, t2) = make_multi_data(30, 40, 25, 42);
579        let mut sc = FdMatrix::zeros(30, 2);
580        for i in 0..30 {
581            sc[(i, 0)] = i as f64 / 30.0;
582            sc[(i, 1)] = (i as f64 * 0.7).sin();
583        }
584        let fit = fregre_lm_multi(&[(&x1, &t1, 3), (&x2, &t2, 2)], &y, Some(&sc)).unwrap();
585
586        assert_eq!(fit.gamma.len(), 2);
587        assert!(fit.r_squared >= 0.0);
588    }
589
590    #[test]
591    fn test_predict_multi_on_training() {
592        let (x1, x2, y, t1, t2) = make_multi_data(30, 40, 25, 42);
593        let fit = fregre_lm_multi(&[(&x1, &t1, 3), (&x2, &t2, 2)], &y, None).unwrap();
594
595        let preds = predict_fregre_lm_multi(&fit, &[&x1, &x2], None).unwrap();
596        assert_eq!(preds.len(), 30);
597        for i in 0..30 {
598            assert!(
599                (preds[i] - fit.fitted_values[i]).abs() < 1e-6,
600                "prediction on training data should match fitted values at index {i}: got {}, expected {}",
601                preds[i], fit.fitted_values[i]
602            );
603        }
604    }
605
606    #[test]
607    fn test_predict_multi_new_data_finite() {
608        let (x1, x2, y, t1, t2) = make_multi_data(30, 40, 25, 42);
609        let fit = fregre_lm_multi(&[(&x1, &t1, 3), (&x2, &t2, 2)], &y, None).unwrap();
610
611        // Create slightly different "new" data
612        let n_new = 10;
613        let mut new_x1 = FdMatrix::zeros(n_new, 40);
614        let mut new_x2 = FdMatrix::zeros(n_new, 25);
615        for i in 0..n_new {
616            let p = (i as f64 + 0.5) * PI / n_new as f64;
617            for j in 0..40 {
618                new_x1[(i, j)] = (2.0 * PI * t1[j] + p).sin() + 0.1;
619            }
620            for j in 0..25 {
621                new_x2[(i, j)] = (2.0 * PI * t2[j] + p).cos() - 0.1;
622            }
623        }
624
625        let preds = predict_fregre_lm_multi(&fit, &[&new_x1, &new_x2], None).unwrap();
626        assert_eq!(preds.len(), n_new);
627        for &p in &preds {
628            assert!(p.is_finite(), "predictions should be finite, got {p}");
629        }
630    }
631
632    #[test]
633    fn test_fregre_lm_multi_mismatched_n() {
634        let (x1, _x2, y, t1, t2) = make_multi_data(30, 40, 25, 42);
635        let x2_bad = FdMatrix::zeros(20, 25);
636        let result = fregre_lm_multi(&[(&x1, &t1, 3), (&x2_bad, &t2, 2)], &y, None);
637        assert!(result.is_err());
638    }
639
640    #[test]
641    fn test_fregre_lm_multi_single_predictor_matches_fregre_lm() {
642        let n = 30;
643        let m = 50;
644        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
645        let mut data = FdMatrix::zeros(n, m);
646        let mut y = vec![0.0; n];
647        for i in 0..n {
648            // Generate data with multiple modes of variation (matching the
649            // pattern used in the existing fregre_lm tests).
650            let a =
651                ((42_u64.wrapping_mul(17).wrapping_add(i as u64 * 31) % 1000) as f64 / 500.0) - 1.0;
652            let b =
653                ((42_u64.wrapping_mul(7).wrapping_add(i as u64 * 53) % 1000) as f64 / 500.0) - 1.0;
654            let c =
655                ((42_u64.wrapping_mul(3).wrapping_add(i as u64 * 79) % 1000) as f64 / 500.0) - 1.0;
656            for j in 0..m {
657                data[(i, j)] = a * (2.0 * PI * t[j]).sin() + b * (4.0 * PI * t[j]).cos() + c * t[j];
658            }
659            y[i] = 2.0 * a + 3.0 * b + 0.05 * (42_u64.wrapping_add(i as u64) % 10) as f64;
660        }
661
662        let single = crate::scalar_on_function::fregre_lm(&data, &y, None, 3).unwrap();
663        let multi = fregre_lm_multi(&[(&data, &t, 3)], &y, None).unwrap();
664
665        // R-squared should be very close
666        assert!(
667            (single.r_squared - multi.r_squared).abs() < 1e-8,
668            "single R²={} vs multi R²={}",
669            single.r_squared,
670            multi.r_squared
671        );
672
673        // Fitted values should match
674        for i in 0..n {
675            assert!(
676                (single.fitted_values[i] - multi.fitted_values[i]).abs() < 1e-8,
677                "fitted values differ at {i}"
678            );
679        }
680    }
681
682    #[test]
683    fn test_fregre_lm_multi_empty_predictors() {
684        let y = vec![1.0, 2.0, 3.0];
685        let result = fregre_lm_multi(&[], &y, None);
686        assert!(result.is_err());
687    }
688
689    #[test]
690    fn test_fregre_lm_multi_cv() {
691        let (x1, x2, y, t1, t2) = make_multi_data(30, 40, 25, 42);
692        let cv = fregre_lm_multi_cv(&[(&x1, &t1), (&x2, &t2)], &y, None, 5, 5, 42).unwrap();
693        assert!(!cv.candidates.is_empty());
694        assert!(cv.optimal_ncomp >= 1);
695        assert!(cv.min_cv_mse.is_finite());
696        assert_eq!(cv.oof_predictions.len(), 30);
697        // At least some OOF predictions should be finite
698        let n_finite = cv.oof_predictions.iter().filter(|x| x.is_finite()).count();
699        assert!(n_finite > 20, "most OOF predictions should be finite");
700    }
701
702    #[test]
703    fn test_fregre_lm_multi_residuals_sum_near_zero() {
704        let (x1, x2, y, t1, t2) = make_multi_data(30, 40, 25, 42);
705        let fit = fregre_lm_multi(&[(&x1, &t1, 3), (&x2, &t2, 2)], &y, None).unwrap();
706
707        let resid_sum: f64 = fit.residuals.iter().sum();
708        assert!(
709            resid_sum.abs() < 1e-8,
710            "residuals should sum to ~0 with intercept, got {resid_sum}"
711        );
712    }
713}