Skip to main content

fdars_core/scalar_on_function/
mod.rs

1//! Scalar-on-function regression with mixed scalar/functional covariates.
2//!
3//! Implements models of the form:
4//! ```text
5//! y = α + ∫β(t)X(t)dt + γᵀz + ε
6//! ```
7//! where X(t) is a functional predictor, z is a vector of scalar covariates,
8//! β(t) is the functional coefficient, and γ is the vector of scalar coefficients.
9//!
10//! # Methods
11//!
12//! - [`fregre_lm`]: FPC-based functional linear model with optional scalar covariates
13//! - [`fregre_l1`]: L1 (median) robust functional regression via IRLS
14//! - [`fregre_huber`]: Huber M-estimation robust functional regression via IRLS
15//! - [`fregre_np_mixed`]: Nonparametric kernel regression with product kernels
16//! - [`functional_logistic`]: Logistic regression for binary outcomes
17//! - [`fregre_cv`]: Cross-validation for number of FPC components
18
19use crate::error::FdarError;
20use crate::linalg::cholesky_solve as linalg_cholesky_solve;
21use crate::matrix::FdMatrix;
22use crate::regression::{FpcaResult, PlsResult};
23
24mod bootstrap;
25mod cv;
26mod fregre_lm;
27mod glm;
28mod logistic;
29mod multi;
30mod nonparametric;
31mod pls;
32mod robust;
33#[cfg(test)]
34mod tests;
35
36// Re-export all public items from submodules
37pub use bootstrap::{bootstrap_ci_fregre_lm, bootstrap_ci_functional_logistic};
38pub use cv::{fregre_basis_cv, fregre_np_cv};
39pub use fregre_lm::{fregre_cv, fregre_lm, model_selection_ncomp, predict_fregre_lm};
40pub use glm::{functional_glm, predict_functional_glm};
41// GlmFamily and FunctionalGlmResult are defined in this module (mod.rs) and
42// exported from here directly — no glm:: re-export needed for those types.
43pub use logistic::{functional_logistic, predict_functional_logistic};
44pub use multi::{fregre_lm_multi, fregre_lm_multi_cv, predict_fregre_lm_multi, MultiCvResult};
45pub use nonparametric::{
46    fregre_np_from_distances, fregre_np_mixed, predict_fregre_np, predict_fregre_np_from_distances,
47};
48pub use pls::{fregre_pls, predict_fregre_pls};
49pub use robust::{fregre_huber, fregre_l1, predict_fregre_robust};
50
51// ---------------------------------------------------------------------------
52// Result types
53// ---------------------------------------------------------------------------
54
55/// Result of functional linear regression.
56#[derive(Debug, Clone, PartialEq)]
57#[non_exhaustive]
58pub struct FregreLmResult {
59    /// Intercept α
60    pub intercept: f64,
61    /// Functional coefficient β(t), evaluated on the original grid (length m)
62    pub beta_t: Vec<f64>,
63    /// Pointwise standard errors of β(t) (length m)
64    pub beta_se: Vec<f64>,
65    /// Scalar coefficients γ (one per scalar covariate)
66    pub gamma: Vec<f64>,
67    /// Fitted values ŷ (length n)
68    pub fitted_values: Vec<f64>,
69    /// Residuals y - ŷ (length n)
70    pub residuals: Vec<f64>,
71    /// R² statistic
72    pub r_squared: f64,
73    /// Adjusted R²
74    pub r_squared_adj: f64,
75    /// Standard errors of all coefficients (intercept, FPC scores, scalar covariates)
76    pub std_errors: Vec<f64>,
77    /// Number of FPC components used
78    pub ncomp: usize,
79    /// FPCA result (for projecting new data)
80    pub fpca: FpcaResult,
81    /// Regression coefficients on (FPC scores, scalar covariates) — internal
82    pub coefficients: Vec<f64>,
83    /// Residual standard error
84    pub residual_se: f64,
85    /// GCV criterion value (if computed)
86    pub gcv: f64,
87    /// Akaike Information Criterion
88    pub aic: f64,
89    /// Bayesian Information Criterion
90    pub bic: f64,
91}
92
93/// Result of nonparametric functional regression with mixed predictors.
94#[derive(Debug, Clone, PartialEq)]
95#[non_exhaustive]
96pub struct FregreNpResult {
97    /// Fitted values ŷ (length n)
98    pub fitted_values: Vec<f64>,
99    /// Residuals y - ŷ (length n)
100    pub residuals: Vec<f64>,
101    /// R² statistic
102    pub r_squared: f64,
103    /// Bandwidth for functional distance kernel
104    pub h_func: f64,
105    /// Bandwidth for scalar covariates kernel
106    pub h_scalar: f64,
107    /// Leave-one-out CV error
108    pub cv_error: f64,
109}
110
111/// Result of robust (L1 or Huber) functional regression.
112#[derive(Debug, Clone, PartialEq)]
113#[non_exhaustive]
114pub struct FregreRobustResult {
115    /// Intercept
116    pub intercept: f64,
117    /// Functional coefficient β(t), evaluated on the original grid (length m)
118    pub beta_t: Vec<f64>,
119    /// Fitted values ŷ (length n)
120    pub fitted_values: Vec<f64>,
121    /// Residuals y - ŷ (length n)
122    pub residuals: Vec<f64>,
123    /// Regression coefficients (intercept, FPC scores, scalar covariates)
124    pub coefficients: Vec<f64>,
125    /// Number of FPC components used
126    pub ncomp: usize,
127    /// FPCA result (for projecting new data)
128    pub fpca: FpcaResult,
129    /// Number of IRLS iterations performed
130    pub iterations: usize,
131    /// Whether the IRLS algorithm converged
132    pub converged: bool,
133    /// Final IRLS weights (length n)
134    pub weights: Vec<f64>,
135    /// R² statistic
136    pub r_squared: f64,
137}
138
139/// Result of functional logistic regression.
140#[derive(Debug, Clone, PartialEq)]
141#[non_exhaustive]
142pub struct FunctionalLogisticResult {
143    /// Intercept α
144    pub intercept: f64,
145    /// Functional coefficient β(t), evaluated on the original grid (length m)
146    pub beta_t: Vec<f64>,
147    /// Pointwise standard errors of β(t) (length m)
148    pub beta_se: Vec<f64>,
149    /// Scalar coefficients γ (one per scalar covariate)
150    pub gamma: Vec<f64>,
151    /// Predicted probabilities P(Y=1) (length n)
152    pub probabilities: Vec<f64>,
153    /// Predicted class labels (0 or 1)
154    pub predicted_classes: Vec<usize>,
155    /// Number of FPC components used
156    pub ncomp: usize,
157    /// Classification accuracy on training data
158    pub accuracy: f64,
159    /// Standard errors of all coefficients (intercept, FPC scores, scalar covariates)
160    pub std_errors: Vec<f64>,
161    /// Regression coefficients on (FPC scores, scalar covariates) — internal
162    pub coefficients: Vec<f64>,
163    /// Log-likelihood at convergence
164    pub log_likelihood: f64,
165    /// Number of IRLS iterations
166    pub iterations: usize,
167    /// FPCA result (for projecting new data)
168    pub fpca: FpcaResult,
169    /// Akaike Information Criterion
170    pub aic: f64,
171    /// Bayesian Information Criterion
172    pub bic: f64,
173}
174
175/// Result of cross-validation for K selection.
176#[derive(Debug, Clone, PartialEq)]
177#[non_exhaustive]
178pub struct FregreCvResult {
179    /// Candidate K values tested
180    pub k_values: Vec<usize>,
181    /// CV error for each K
182    pub cv_errors: Vec<f64>,
183    /// Optimal K (minimizing CV error)
184    pub optimal_k: usize,
185    /// Minimum CV error
186    pub min_cv_error: f64,
187    /// Out-of-fold predictions at optimal K (length n, each predicted when held out)
188    pub oof_predictions: Vec<f64>,
189    /// Fold assignment for each observation (0..n_folds)
190    pub fold_assignments: Vec<usize>,
191    /// Per-fold MSE at optimal K
192    pub fold_errors: Vec<f64>,
193}
194
195/// Result of PLS-based scalar-on-function regression.
196#[derive(Debug, Clone, PartialEq)]
197#[non_exhaustive]
198pub struct PlsRegressionResult {
199    /// Intercept α
200    pub intercept: f64,
201    /// Functional coefficient β(t), evaluated on the original grid (length m)
202    pub beta_t: Vec<f64>,
203    /// Scalar coefficients γ (one per scalar covariate)
204    pub gamma: Vec<f64>,
205    /// Fitted values ŷ (length n)
206    pub fitted_values: Vec<f64>,
207    /// Residuals y - ŷ (length n)
208    pub residuals: Vec<f64>,
209    /// R² statistic
210    pub r_squared: f64,
211    /// Adjusted R²
212    pub r_squared_adj: f64,
213    /// Number of PLS components used
214    pub ncomp: usize,
215    /// PLS result (for projecting new data)
216    pub pls: PlsResult,
217    /// Regression coefficients on (intercept, PLS scores, scalar covariates)
218    pub coefficients: Vec<f64>,
219    /// Residual standard error
220    pub residual_se: f64,
221    /// Akaike Information Criterion
222    pub aic: f64,
223    /// Bayesian Information Criterion
224    pub bic: f64,
225}
226
227/// Result of multi-predictor functional linear regression.
228#[derive(Debug, Clone, PartialEq)]
229#[non_exhaustive]
230pub struct MultiFregreLmResult {
231    /// Intercept α
232    pub intercept: f64,
233    /// Functional coefficients beta_k(t) for each predictor, each length m_k.
234    pub beta_t: Vec<Vec<f64>>,
235    /// Scalar coefficients γ (one per scalar covariate)
236    pub gamma: Vec<f64>,
237    /// Fitted values ŷ (length n)
238    pub fitted_values: Vec<f64>,
239    /// Residuals y - ŷ (length n)
240    pub residuals: Vec<f64>,
241    /// R²
242    pub r_squared: f64,
243    /// Adjusted R²
244    pub r_squared_adj: f64,
245    /// Number of FPC components used per functional predictor
246    pub ncomp: Vec<usize>,
247    /// FPCA results for each functional predictor (for projection)
248    pub fpcas: Vec<FpcaResult>,
249    /// Regression coefficients [intercept, scores_1..., scores_2..., ..., scalars...]
250    pub coefficients: Vec<f64>,
251    /// Residual standard error
252    pub residual_se: f64,
253    /// AIC
254    pub aic: f64,
255    /// BIC
256    pub bic: f64,
257}
258
259/// Criterion used for model selection.
260#[derive(Debug, Clone, Copy, PartialEq)]
261pub enum SelectionCriterion {
262    /// Akaike Information Criterion
263    Aic,
264    /// Bayesian Information Criterion
265    Bic,
266    /// Generalized Cross-Validation
267    Gcv,
268}
269
270/// Result of ncomp model selection.
271#[derive(Debug, Clone, PartialEq)]
272#[non_exhaustive]
273pub struct ModelSelectionResult {
274    /// Best number of FPC components by the chosen criterion
275    pub best_ncomp: usize,
276    /// (ncomp, AIC, BIC, GCV) for each candidate
277    pub criteria: Vec<(usize, f64, f64, f64)>,
278}
279
280/// Result of bootstrap confidence intervals for β(t).
281#[derive(Debug, Clone, PartialEq)]
282#[non_exhaustive]
283pub struct BootstrapCiResult {
284    /// Pointwise lower bound (length m).
285    pub lower: Vec<f64>,
286    /// Pointwise upper bound (length m).
287    pub upper: Vec<f64>,
288    /// Original β(t) estimate (length m).
289    pub center: Vec<f64>,
290    /// Simultaneous lower bound (sup-norm adjusted, length m).
291    pub sim_lower: Vec<f64>,
292    /// Simultaneous upper bound (sup-norm adjusted, length m).
293    pub sim_upper: Vec<f64>,
294    /// Number of bootstrap replicates that converged.
295    pub n_boot_success: usize,
296}
297
298/// Result of lambda selection for basis regression via cross-validation.
299#[derive(Debug, Clone, PartialEq)]
300#[non_exhaustive]
301pub struct FregreBasisCvResult {
302    /// Optimal smoothing parameter lambda.
303    pub optimal_lambda: f64,
304    /// Mean CV error for each lambda.
305    pub cv_errors: Vec<f64>,
306    /// SE of CV error across folds for each lambda.
307    pub cv_se: Vec<f64>,
308    /// Lambda values tested.
309    pub lambda_values: Vec<f64>,
310    /// Minimum mean CV error.
311    pub min_cv_error: f64,
312}
313
314/// Result of bandwidth selection for nonparametric regression via CV.
315#[derive(Debug, Clone, PartialEq)]
316#[non_exhaustive]
317pub struct FregreNpCvResult {
318    /// Optimal bandwidth.
319    pub optimal_h: f64,
320    /// Mean CV error for each bandwidth.
321    pub cv_errors: Vec<f64>,
322    /// SE of CV error across folds for each bandwidth.
323    pub cv_se: Vec<f64>,
324    /// Bandwidth values tested.
325    pub h_values: Vec<f64>,
326    /// Minimum mean CV error.
327    pub min_cv_error: f64,
328}
329
330/// Exponential-family distribution for [`functional_glm`].
331///
332/// Each variant specifies the canonical link function and variance function
333/// for one member of the exponential family.
334///
335/// | Variant   | Link g(μ)  | Inverse link g⁻¹(η) | Variance V(μ) |
336/// |-----------|------------|---------------------|--------------|
337/// | Binomial  | logit      | sigmoid             | μ(1−μ)       |
338/// | Poisson   | log        | exp                 | μ            |
339/// | Gamma     | inverse    | 1/η                 | μ²           |
340/// | Gaussian  | identity   | η                   | 1            |
341#[derive(Debug, Clone, Copy, PartialEq)]
342#[non_exhaustive]
343#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
344pub enum GlmFamily {
345    /// Logit link; binary outcomes (y ∈ {0, 1}).
346    Binomial,
347    /// Log link; non-negative integer counts (y ∈ {0, 1, 2, …}).
348    Poisson,
349    /// Inverse link (canonical); strictly positive continuous responses (y > 0).
350    Gamma,
351    /// Identity link; continuous unbounded responses.
352    Gaussian,
353}
354
355/// Result of [`functional_glm`] for a scalar response over functional predictors.
356///
357/// Contains the fitted model parameters, diagnostic statistics, and the embedded
358/// [`crate::regression::FpcaResult`] for projecting new data.
359#[derive(Debug, Clone, PartialEq)]
360#[non_exhaustive]
361#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
362pub struct FunctionalGlmResult {
363    /// Intercept α
364    pub intercept: f64,
365    /// Functional coefficient β(t), evaluated on the original grid (length m)
366    pub beta_t: Vec<f64>,
367    /// Pointwise standard errors of β(t) (length m)
368    pub beta_se: Vec<f64>,
369    /// Scalar coefficients γ (one per scalar covariate)
370    pub gamma: Vec<f64>,
371    /// Fitted mean values μ = g⁻¹(η) (length n)
372    pub fitted_values: Vec<f64>,
373    /// Linear predictors η = Xβ (length n)
374    pub linear_predictors: Vec<f64>,
375    /// Number of FPC components used
376    pub ncomp: usize,
377    /// All regression coefficients [intercept, γ₁…γ_K, z₁…z_P]
378    pub coefficients: Vec<f64>,
379    /// Standard errors of all coefficients (intercept, FPC scores, scalar covariates)
380    pub std_errors: Vec<f64>,
381    /// Log-likelihood at convergence (kernel; see module doc for AIC comparability note)
382    pub log_likelihood: f64,
383    /// GLM deviance D = 2(LL_saturated − LL_fitted)
384    pub deviance: f64,
385    /// Number of IRLS iterations performed
386    pub iterations: usize,
387    /// FPCA result (embedded for projecting new data)
388    pub fpca: crate::regression::FpcaResult,
389    /// Akaike Information Criterion: −2·log_likelihood + 2·p
390    pub aic: f64,
391    /// Bayesian Information Criterion: −2·log_likelihood + p·ln(n)
392    pub bic: f64,
393    /// Exponential-family distribution used for this fit
394    pub family: GlmFamily,
395}
396
397impl FunctionalGlmResult {
398    /// Predict response for new functional data. Delegates to [`predict_functional_glm`].
399    ///
400    /// # Errors
401    ///
402    /// Propagates [`FdarError::InvalidDimension`] from [`predict_functional_glm`]
403    /// when `new_data` / `new_scalar` shapes do not match the fitted model.
404    pub fn predict(
405        &self,
406        new_data: &FdMatrix,
407        new_scalar: Option<&FdMatrix>,
408    ) -> Result<Vec<f64>, FdarError> {
409        predict_functional_glm(self, new_data, new_scalar)
410    }
411}
412
413// ---------------------------------------------------------------------------
414// Shared linear algebra helpers (delegated to crate::linalg)
415// ---------------------------------------------------------------------------
416
417// Re-export for use by submodules and explain/ modules that import from
418// `crate::scalar_on_function::{cholesky_factor, cholesky_forward_back, compute_xtx}`.
419pub(crate) use crate::linalg::cholesky_factor;
420pub(crate) use crate::linalg::cholesky_forward_back;
421pub(crate) use crate::linalg::compute_xtx;
422
423/// Compute X'y (length p).
424fn compute_xty(x: &FdMatrix, y: &[f64]) -> Vec<f64> {
425    let (n, p) = x.shape();
426    (0..p)
427        .map(|k| {
428            let mut s = 0.0;
429            for i in 0..n {
430                s += x[(i, k)] * y[i];
431            }
432            s
433        })
434        .collect()
435}
436
437/// Solve Ax = b via Cholesky decomposition (A must be symmetric positive definite).
438pub(super) fn cholesky_solve(a: &[f64], b: &[f64], p: usize) -> Result<Vec<f64>, FdarError> {
439    linalg_cholesky_solve(a, b, p)
440}
441
442/// Compute hat matrix diagonal: H_ii = x_i' (X'X)^{-1} x_i, given Cholesky factor L of X'X.
443pub(crate) fn compute_hat_diagonal(x: &FdMatrix, l: &[f64]) -> Vec<f64> {
444    let (n, p) = x.shape();
445    let mut hat_diag = vec![0.0; n];
446    for i in 0..n {
447        let mut v = vec![0.0; p];
448        for j in 0..p {
449            v[j] = x[(i, j)];
450            for k in 0..j {
451                v[j] -= l[j * p + k] * v[k];
452            }
453            v[j] /= l[j * p + j];
454        }
455        hat_diag[i] = v.iter().map(|vi| vi * vi).sum();
456    }
457    hat_diag
458}
459
460/// Compute diagonal of (X'X)^{-1} given Cholesky factor L, then SE = sqrt(sigma² * diag).
461fn compute_ols_std_errors(l: &[f64], p: usize, sigma2: f64) -> Vec<f64> {
462    let mut se = vec![0.0; p];
463    for j in 0..p {
464        let mut v = vec![0.0; p];
465        v[j] = 1.0;
466        for k in 0..p {
467            for kk in 0..k {
468                v[k] -= l[k * p + kk] * v[kk];
469            }
470            v[k] /= l[k * p + k];
471        }
472        se[j] = (sigma2 * v.iter().map(|vi| vi * vi).sum::<f64>()).sqrt();
473    }
474    se
475}
476
477// ---------------------------------------------------------------------------
478// Design matrix and coefficient recovery
479// ---------------------------------------------------------------------------
480
481/// Build design matrix: \[1, ξ_1, ..., ξ_K, z_1, ..., z_p\].
482/// Validate inputs for fregre_lm / functional_logistic.
483fn validate_fregre_inputs(
484    n: usize,
485    m: usize,
486    y: &[f64],
487    scalar_covariates: Option<&FdMatrix>,
488) -> Result<(), FdarError> {
489    if n < 3 {
490        return Err(FdarError::InvalidDimension {
491            parameter: "data",
492            expected: "at least 3 rows".to_string(),
493            actual: format!("{n}"),
494        });
495    }
496    if m == 0 {
497        return Err(FdarError::InvalidDimension {
498            parameter: "data",
499            expected: "at least 1 column".to_string(),
500            actual: "0".to_string(),
501        });
502    }
503    if y.len() != n {
504        return Err(FdarError::InvalidDimension {
505            parameter: "y",
506            expected: format!("{n}"),
507            actual: format!("{}", y.len()),
508        });
509    }
510    if let Some(sc) = scalar_covariates {
511        if sc.nrows() != n {
512            return Err(FdarError::InvalidDimension {
513                parameter: "scalar_covariates",
514                expected: format!("{n} rows"),
515                actual: format!("{} rows", sc.nrows()),
516            });
517        }
518    }
519    Ok(())
520}
521
522/// Resolve ncomp: auto-select via CV if 0, otherwise clamp.
523fn resolve_ncomp(
524    ncomp: usize,
525    data: &FdMatrix,
526    y: &[f64],
527    scalar_covariates: Option<&FdMatrix>,
528    n: usize,
529    m: usize,
530) -> Result<usize, FdarError> {
531    if ncomp == 0 {
532        let cv = fregre_cv(data, y, scalar_covariates, 1, m.min(n - 1).min(20), 5)?;
533        Ok(cv.optimal_k)
534    } else {
535        Ok(ncomp.min(n - 1).min(m))
536    }
537}
538
539pub(crate) fn build_design_matrix(
540    fpca_scores: &FdMatrix,
541    ncomp: usize,
542    scalar_covariates: Option<&FdMatrix>,
543    n: usize,
544) -> FdMatrix {
545    let p_scalar = scalar_covariates.map_or(0, super::matrix::FdMatrix::ncols);
546    let p_total = 1 + ncomp + p_scalar;
547    let mut design = FdMatrix::zeros(n, p_total);
548    for i in 0..n {
549        design[(i, 0)] = 1.0;
550        for k in 0..ncomp {
551            design[(i, 1 + k)] = fpca_scores[(i, k)];
552        }
553        if let Some(sc) = scalar_covariates {
554            for j in 0..p_scalar {
555                design[(i, 1 + ncomp + j)] = sc[(i, j)];
556            }
557        }
558    }
559    design
560}
561
562/// Recover functional coefficient β(t) = Σ_k γ_k φ_k(t).
563fn recover_beta_t(fpc_coeffs: &[f64], rotation: &FdMatrix, m: usize) -> Vec<f64> {
564    let ncomp = fpc_coeffs.len();
565    let mut beta_t = vec![0.0; m];
566    for k in 0..ncomp {
567        for j in 0..m {
568            beta_t[j] += fpc_coeffs[k] * rotation[(j, k)];
569        }
570    }
571    beta_t
572}
573
574/// Pointwise standard error of β(t) via error propagation through FPCA rotation.
575///
576/// SE[β(t_j)]² = Σ_k φ_k(t_j)² · SE[γ_k]²
577fn compute_beta_se(gamma_se: &[f64], rotation: &FdMatrix, m: usize) -> Vec<f64> {
578    let ncomp = gamma_se.len();
579    let mut beta_se = vec![0.0; m];
580    for j in 0..m {
581        let mut var_j = 0.0;
582        for k in 0..ncomp {
583            var_j += rotation[(j, k)].powi(2) * gamma_se[k].powi(2);
584        }
585        beta_se[j] = var_j.sqrt();
586    }
587    beta_se
588}
589
590/// Compute fitted values ŷ = X β.
591fn compute_fitted(design: &FdMatrix, coeffs: &[f64]) -> Vec<f64> {
592    let (n, p) = design.shape();
593    (0..n)
594        .map(|i| {
595            let mut yhat = 0.0;
596            for j in 0..p {
597                yhat += design[(i, j)] * coeffs[j];
598            }
599            yhat
600        })
601        .collect()
602}
603
604/// Compute R² and adjusted R².
605fn compute_r_squared(y: &[f64], residuals: &[f64], p_total: usize) -> (f64, f64) {
606    let n = y.len();
607    let y_mean = y.iter().sum::<f64>() / n as f64;
608    let ss_tot: f64 = y.iter().map(|&yi| (yi - y_mean).powi(2)).sum();
609    let ss_res: f64 = residuals.iter().map(|r| r * r).sum();
610    let r_squared = if ss_tot > 0.0 {
611        1.0 - ss_res / ss_tot
612    } else {
613        0.0
614    };
615    let df_model = (p_total - 1) as f64;
616    let r_squared_adj = if n as f64 - df_model - 1.0 > 0.0 {
617        1.0 - (1.0 - r_squared) * (n as f64 - 1.0) / (n as f64 - df_model - 1.0)
618    } else {
619        r_squared
620    };
621    (r_squared, r_squared_adj)
622}
623
624// ---------------------------------------------------------------------------
625// OLS solver
626// ---------------------------------------------------------------------------
627
628/// Solve ordinary least squares: min ||Xb - y||² via normal equations with Cholesky.
629/// Returns (coefficients, hat_matrix_diagonal) or error if singular.
630fn ols_solve(x: &FdMatrix, y: &[f64]) -> Result<(Vec<f64>, Vec<f64>), FdarError> {
631    let (n, p) = x.shape();
632    if n < p || p == 0 {
633        return Err(FdarError::InvalidDimension {
634            parameter: "design matrix",
635            expected: format!("n >= p and p > 0 (p={p})"),
636            actual: format!("n={n}, p={p}"),
637        });
638    }
639    let xtx = compute_xtx(x);
640    let xty = compute_xty(x, y);
641    let l = cholesky_factor(&xtx, p)?;
642    let b = cholesky_forward_back(&l, &xty, p);
643    let hat_diag = compute_hat_diagonal(x, &l);
644    Ok((b, hat_diag))
645}
646
647/// Sigmoid function: 1 / (1 + exp(-x))
648pub(crate) fn sigmoid(x: f64) -> f64 {
649    if x >= 0.0 {
650        1.0 / (1.0 + (-x).exp())
651    } else {
652        let ex = x.exp();
653        ex / (1.0 + ex)
654    }
655}
656
657// ---------------------------------------------------------------------------
658// Predict methods on result structs
659// ---------------------------------------------------------------------------
660
661impl FregreLmResult {
662    /// Predict new responses. Delegates to [`predict_fregre_lm`].
663    pub fn predict(&self, new_data: &FdMatrix, new_scalar: Option<&FdMatrix>) -> Vec<f64> {
664        predict_fregre_lm(self, new_data, new_scalar)
665    }
666}
667
668impl FregreRobustResult {
669    /// Predict new responses. Delegates to [`predict_fregre_robust`].
670    pub fn predict(&self, new_data: &FdMatrix, new_scalar: Option<&FdMatrix>) -> Vec<f64> {
671        predict_fregre_robust(self, new_data, new_scalar)
672    }
673}
674
675impl FunctionalLogisticResult {
676    /// Predict P(Y=1) for new data. Delegates to [`predict_functional_logistic`].
677    pub fn predict(&self, new_data: &FdMatrix, new_scalar: Option<&FdMatrix>) -> Vec<f64> {
678        predict_functional_logistic(self, new_data, new_scalar)
679    }
680}
681
682impl MultiFregreLmResult {
683    /// Predict new responses. Delegates to [`predict_fregre_lm_multi`].
684    ///
685    /// # Errors
686    ///
687    /// Returns [`FdarError`] if prediction fails due to dimension mismatches.
688    pub fn predict(
689        &self,
690        new_predictors: &[&FdMatrix],
691        new_scalar: Option<&FdMatrix>,
692    ) -> Result<Vec<f64>, FdarError> {
693        predict_fregre_lm_multi(self, new_predictors, new_scalar)
694    }
695}