fdars_core/scalar_on_function/glm.rs
1//! Functional Generalized Linear Model (GLM) over FPC scores.
2//!
3//! Implements `functional_glm` — a scalar-on-function GLM that covers the four
4//! mainstream exponential-family distributions through a [`GlmFamily`] enum
5//! (canonical link + variance function per family). The IRLS loop runs over
6//! Functional Principal Component (FPC) scores produced by [`fdata_to_pc_1d`],
7//! reusing the same weighted-normal-equations solver as [`functional_logistic`].
8//!
9//! # Supported families and canonical links
10//!
11//! | Family | Link g(μ) | Variance V(μ) |
12//! |--------|-----------|--------------|
13//! | [`GlmFamily::Binomial`] | logit | μ(1−μ) |
14//! | [`GlmFamily::Poisson`] | log | μ |
15//! | [`GlmFamily::Gamma`] | inverse | μ² |
16//! | [`GlmFamily::Gaussian`] | identity | 1 |
17//!
18//! # IRLS convergence
19//!
20//! The loop converges when the absolute change in deviance between consecutive
21//! iterations is below `tol`, or when `max_iter` is reached. Gaussian with
22//! identity link converges in a single IRLS step (weights ≡ 1 → OLS).
23//!
24//! # Convention divergences from R `glm()`
25//!
26//! - **Convergence criterion:** deviance-change `< tol`, not coefficient-change
27//! (more scale-invariant; recommended for multi-family code).
28//! - **Canonical links only:** Gamma uses inverse link (g(μ)=1/μ), NOT log-link.
29//! - **AIC/BIC:** computed as `−2·log_likelihood + 2p` and `−2·log_likelihood + p·ln(n)`
30//! using the log-likelihood kernel per family. The dispersion φ is **not** folded into
31//! the Gamma/Gaussian AIC/BIC log-likelihood kernel, so Gamma and Gaussian AIC magnitudes
32//! are **not** directly comparable to R's `glm()` / `lm()` output.
33//! - **Standard errors:** the dispersion φ (φ = 1 for Binomial/Poisson; Pearson χ²/dof for
34//! Gaussian/Gamma) IS applied to the reported coefficient standard errors:
35//! `Var(β̂) = φ·(XᵀWX)⁻¹`.
36//! - **μ/η clamping:** Poisson clamps η ≤ 500 before `exp`; Gamma clamps η ≥ 1e-10 so
37//! μ = 1/η remains finite; all families clamp μ ≥ 1e-10 in weight/deviance computations.
38//! - **Gamma intercept initialisation:** β₀ = 1/mean(y) so η₀ > 0 (μ₀ = mean(y)), preventing
39//! a divide-by-zero on the very first IRLS step.
40//!
41//! [`functional_logistic`]: crate::scalar_on_function::functional_logistic
42
43use super::{
44 build_design_matrix, cholesky_factor, cholesky_solve, compute_beta_se, compute_fitted,
45 compute_ols_std_errors, recover_beta_t, sigmoid, FunctionalGlmResult, GlmFamily,
46};
47use crate::error::FdarError;
48use crate::matrix::FdMatrix;
49use crate::regression::{fdata_to_pc_1d, FpcaResult};
50
51// ---------------------------------------------------------------------------
52// GlmFamily methods — per-family link / variance / deviance / log-likelihood
53// ---------------------------------------------------------------------------
54
55impl GlmFamily {
56 /// Inverse link function: η → μ = g⁻¹(η), clamped to a valid range.
57 pub(crate) fn inv_link(self, eta: f64) -> f64 {
58 match self {
59 GlmFamily::Binomial => sigmoid(eta),
60 GlmFamily::Poisson => eta.min(500.0_f64).exp().max(1e-10),
61 GlmFamily::Gamma => (1.0 / eta.max(1e-10)).max(1e-10),
62 GlmFamily::Gaussian => eta,
63 }
64 }
65
66 /// Link derivative: dη/dμ = g′(μ).
67 ///
68 /// **Stored separately from [`irls_weight`].** The working response
69 /// `z = η + (y − μ) · g′(μ)` must use this value directly — never derive
70 /// the working response from `1/weight`. For Gamma, g′(μ) = −1/μ² is
71 /// **negative**, which is required for IRLS to converge.
72 pub(crate) fn link_deriv(self, mu: f64) -> f64 {
73 match self {
74 GlmFamily::Binomial => 1.0 / (mu * (1.0 - mu)).max(1e-10),
75 GlmFamily::Poisson => 1.0 / mu.max(1e-10),
76 GlmFamily::Gamma => -1.0 / mu.max(1e-10).powi(2), // NEGATIVE — do not confuse with irls_weight
77 GlmFamily::Gaussian => 1.0,
78 }
79 }
80
81 /// IRLS weight: w_i = (dμ/dη)² / V(μ) = 1 / (V(μ) · g′(μ)²).
82 ///
83 /// For canonical links this simplifies to:
84 /// Binomial = μ(1−μ), Poisson = μ, Gamma = μ², Gaussian = 1.
85 ///
86 /// **Gamma derivation (inverse link):**
87 /// - dμ/dη = −μ² (from μ = 1/η → dμ/dη = −1/η² = −μ²)
88 /// - V(μ) = μ²
89 /// - w = (dμ/dη)² / V(μ) = μ⁴ / μ² = μ²
90 pub(crate) fn irls_weight(self, mu: f64) -> f64 {
91 match self {
92 GlmFamily::Binomial => (mu * (1.0 - mu)).max(1e-10),
93 GlmFamily::Poisson => mu.max(1e-10),
94 // w = μ² (NOT 1/μ²) — see derivation in doc comment above
95 GlmFamily::Gamma => mu.max(1e-10).powi(2),
96 GlmFamily::Gaussian => 1.0,
97 }
98 }
99
100 /// Total deviance D = 2 Σ d(y_i, μ_i).
101 ///
102 /// Uses the `0·log(0) = 0` convention (Pitfall 4 in RESEARCH.md) via the
103 /// private `xlogy` helper.
104 pub(crate) fn deviance(self, y: &[f64], mu: &[f64]) -> f64 {
105 fn xlogy(x: f64, y: f64) -> f64 {
106 if x == 0.0 {
107 0.0
108 } else {
109 x * y.ln()
110 }
111 }
112 y.iter()
113 .zip(mu)
114 .map(|(&yi, &mi)| match self {
115 GlmFamily::Binomial => {
116 2.0 * (xlogy(yi, yi / mi.max(1e-15))
117 + xlogy(1.0 - yi, (1.0 - yi) / (1.0 - mi).max(1e-15)))
118 }
119 GlmFamily::Poisson => 2.0 * (xlogy(yi, yi / mi.max(1e-15)) - (yi - mi)),
120 GlmFamily::Gamma => 2.0 * ((yi - mi) / mi.max(1e-15) - (yi / mi.max(1e-15)).ln()),
121 GlmFamily::Gaussian => (yi - mi).powi(2),
122 })
123 .sum()
124 }
125
126 /// Log-likelihood kernel sufficient for AIC/BIC (excludes normalising constants).
127 ///
128 /// Note: for Gamma and Gaussian the dispersion parameter φ is not estimated
129 /// separately. See module-level documentation for the resulting AIC
130 /// comparability caveat.
131 ///
132 /// For the Poisson family, `log(y!) = ln Γ(y+1)` is computed via the private
133 /// [`ln_gamma`] Lanczos helper — O(1) and overflow-free (the earlier
134 /// `Σ_{k=1}^{y} ln(k)` form was O(y) and, on a saturating `y as u64`, unbounded).
135 pub(crate) fn log_likelihood(self, y: &[f64], mu: &[f64]) -> f64 {
136 y.iter()
137 .zip(mu)
138 .map(|(&yi, &mi)| match self {
139 GlmFamily::Binomial => {
140 let mi = mi.clamp(1e-15, 1.0 - 1e-15);
141 yi * mi.ln() + (1.0 - yi) * (1.0 - mi).ln()
142 }
143 GlmFamily::Poisson => {
144 let mi = mi.max(1e-300);
145 // log(y!) = ln Γ(y+1) — O(1), overflow-free (yi is a validated
146 // finite non-negative integer, so yi + 1.0 >= 1.0).
147 let ln_y_fact = ln_gamma(yi + 1.0);
148 yi * mi.ln() - mi - ln_y_fact
149 }
150 GlmFamily::Gamma => {
151 let mi = mi.max(1e-300);
152 -yi / mi - mi.ln()
153 }
154 GlmFamily::Gaussian => {
155 // Kernel only: −(y−μ)² (scale by −1/(2σ²) for absolute LL)
156 -(yi - mi).powi(2)
157 }
158 })
159 .sum()
160 }
161}
162
163// ---------------------------------------------------------------------------
164// Response-domain validation
165// ---------------------------------------------------------------------------
166
167fn validate_response(y: &[f64], family: GlmFamily) -> Result<(), FdarError> {
168 // Reject non-finite responses for ALL families FIRST. IEEE 754 makes
169 // `NaN <= 0.0` false and `f64::INFINITY.floor() == f64::INFINITY`, so a
170 // non-finite value would otherwise slip past the per-family guards below —
171 // producing an all-NaN result (Gamma NaN) or, for Poisson, a `yi as u64`
172 // saturation to u64::MAX driving an unbounded log-factorial loop.
173 if let Some(&bad) = y.iter().find(|v| !v.is_finite()) {
174 return Err(FdarError::InvalidParameter {
175 parameter: "y",
176 message: format!("response contains a non-finite value ({bad})"),
177 });
178 }
179 match family {
180 GlmFamily::Binomial => {
181 if y.iter().any(|&yi| yi != 0.0 && yi != 1.0) {
182 return Err(FdarError::InvalidParameter {
183 parameter: "y",
184 message: "all values must be 0.0 or 1.0 for Binomial family".to_string(),
185 });
186 }
187 }
188 GlmFamily::Poisson => {
189 if y.iter().any(|&yi| yi < 0.0 || yi != yi.floor()) {
190 return Err(FdarError::InvalidParameter {
191 parameter: "y",
192 message: "all values must be non-negative integers for Poisson family"
193 .to_string(),
194 });
195 }
196 }
197 GlmFamily::Gamma => {
198 if y.iter().any(|&yi| yi <= 0.0) {
199 return Err(FdarError::InvalidParameter {
200 parameter: "y",
201 message: "all values must be strictly positive for Gamma family".to_string(),
202 });
203 }
204 }
205 GlmFamily::Gaussian => {} // unrestricted
206 }
207 Ok(())
208}
209
210/// Natural log of the Gamma function via the Lanczos approximation (g = 7, n = 9).
211///
212/// Used for the Poisson `log(y!) = ln Γ(y+1)` term. O(1) and overflow-free — it
213/// replaces an O(y) running `Σ ln(k)` sum, and adds no crate dependency (statrs
214/// is not vendored). Accurate to ~15 significant digits for the arguments used
215/// here (`x = y + 1 >= 1`); the reflection branch covers `x < 0.5` for completeness.
216fn ln_gamma(x: f64) -> f64 {
217 const G: f64 = 7.0;
218 const C: [f64; 9] = [
219 0.999_999_999_999_809_9,
220 676.520_368_121_885_1,
221 -1_259.139_216_722_402_8,
222 771.323_428_777_653_1,
223 -176.615_029_162_140_6,
224 12.507_343_278_686_905,
225 -0.138_571_095_265_720_12,
226 9.984_369_578_019_572e-6,
227 1.505_632_735_149_311_6e-7,
228 ];
229 if x < 0.5 {
230 // Reflection: ln Γ(x) = ln(π / sin(πx)) − ln Γ(1 − x)
231 std::f64::consts::PI.ln() - (std::f64::consts::PI * x).sin().abs().ln() - ln_gamma(1.0 - x)
232 } else {
233 let x = x - 1.0;
234 let mut a = C[0];
235 let t = x + G + 0.5;
236 for (i, &c) in C.iter().enumerate().skip(1) {
237 a += c / (x + i as f64);
238 }
239 0.5 * (2.0 * std::f64::consts::PI).ln() + (x + 0.5) * t.ln() - t + a.ln()
240 }
241}
242
243// ---------------------------------------------------------------------------
244// Generic IRLS step
245// ---------------------------------------------------------------------------
246
247/// One IRLS step: compute working response and solve weighted normal equations.
248/// Returns updated beta or `None` if the system is singular.
249fn irls_step_glm(
250 design: &FdMatrix,
251 y: &[f64],
252 beta: &[f64],
253 family: GlmFamily,
254) -> Option<Vec<f64>> {
255 let (n, p) = design.shape();
256
257 // Linear predictor η = Xβ
258 let eta: Vec<f64> = (0..n)
259 .map(|i| (0..p).map(|j| design[(i, j)] * beta[j]).sum())
260 .collect();
261
262 // μ = g⁻¹(η), w = IRLS weight, z = working response
263 let mu: Vec<f64> = eta.iter().map(|&e| family.inv_link(e)).collect();
264 let w: Vec<f64> = mu.iter().map(|&m| family.irls_weight(m)).collect();
265 // z_i = η_i + (y_i − μ_i) · g′(μ_i) [MUST use link_deriv, NOT 1/weight]
266 let z: Vec<f64> = (0..n)
267 .map(|i| eta[i] + (y[i] - mu[i]) * family.link_deriv(mu[i]))
268 .collect();
269
270 // Weighted normal equations: (X′WX)β = X′Wz
271 let mut xtwx = vec![0.0; p * p];
272 for k in 0..p {
273 for j in k..p {
274 let s: f64 = (0..n).map(|i| design[(i, k)] * w[i] * design[(i, j)]).sum();
275 xtwx[k * p + j] = s;
276 xtwx[j * p + k] = s;
277 }
278 }
279 let xtwz: Vec<f64> = (0..p)
280 .map(|k| (0..n).map(|i| design[(i, k)] * w[i] * z[i]).sum())
281 .collect();
282
283 cholesky_solve(&xtwx, &xtwz, p).ok()
284}
285
286// ---------------------------------------------------------------------------
287// IRLS loop
288// ---------------------------------------------------------------------------
289
290/// Run IRLS until deviance-change < tol or max_iter is reached.
291/// Returns (beta, iterations).
292fn irls_loop_glm(
293 design: &FdMatrix,
294 y: &[f64],
295 family: GlmFamily,
296 max_iter: usize,
297 tol: f64,
298) -> (Vec<f64>, usize) {
299 let p_total = design.ncols();
300 let mut beta = init_beta(p_total, y, family);
301 let mut iterations = 0;
302
303 // Initial deviance
304 let mu_init: Vec<f64> = {
305 let (n, p) = design.shape();
306 (0..n)
307 .map(|i| {
308 let eta: f64 = (0..p).map(|j| design[(i, j)] * beta[j]).sum();
309 family.inv_link(eta)
310 })
311 .collect()
312 };
313 let mut dev_old = family.deviance(y, &mu_init);
314
315 for iter in 0..max_iter {
316 iterations = iter + 1;
317 let Some(beta_new) = irls_step_glm(design, y, &beta, family) else {
318 break;
319 };
320 // Compute new deviance
321 let (n, p) = design.shape();
322 let mu_new: Vec<f64> = (0..n)
323 .map(|i| {
324 let eta: f64 = (0..p).map(|j| design[(i, j)] * beta_new[j]).sum();
325 family.inv_link(eta)
326 })
327 .collect();
328 let dev_new = family.deviance(y, &mu_new);
329 beta = beta_new;
330 if (dev_new - dev_old).abs() < tol {
331 break;
332 }
333 dev_old = dev_new;
334 }
335 (beta, iterations)
336}
337
338/// Initialise β for the IRLS loop.
339///
340/// Zero-initialisation works for Binomial (η=0 → μ=0.5), Poisson (η=0 → μ=1),
341/// and Gaussian. For Gamma, zero β → η=0 → μ=1/0 = ∞ on the first step, so
342/// the intercept is initialised to 1/mean(y) so that η₀ > 0 and μ₀ = mean(y).
343fn init_beta(p: usize, y: &[f64], family: GlmFamily) -> Vec<f64> {
344 let mut beta = vec![0.0_f64; p];
345 if let GlmFamily::Gamma = family {
346 let mean_y = y.iter().sum::<f64>() / y.len() as f64;
347 beta[0] = 1.0 / mean_y.max(1e-10);
348 }
349 beta
350}
351
352// ---------------------------------------------------------------------------
353// Result assembly
354// ---------------------------------------------------------------------------
355
356fn build_glm_result(
357 design: &FdMatrix,
358 beta: Vec<f64>,
359 y: &[f64],
360 fpca: FpcaResult,
361 ncomp: usize,
362 m: usize,
363 iterations: usize,
364 family: GlmFamily,
365) -> FunctionalGlmResult {
366 let (n, p) = design.shape();
367 let linear_predictors = compute_fitted(design, &beta);
368 let fitted_values: Vec<f64> = linear_predictors
369 .iter()
370 .map(|&e| family.inv_link(e))
371 .collect();
372
373 let beta_t = recover_beta_t(&beta[1..=ncomp], &fpca.rotation, m);
374 let gamma: Vec<f64> = beta[1 + ncomp..].to_vec();
375
376 // SE from Fisher information matrix (X′WX)⁻¹ evaluated at converged β
377 let w_final: Vec<f64> = fitted_values
378 .iter()
379 .map(|&mu| family.irls_weight(mu))
380 .collect();
381 let mut xtwx = vec![0.0; p * p];
382 for k in 0..p {
383 for j in k..p {
384 let s: f64 = (0..n)
385 .map(|i| design[(i, k)] * w_final[i] * design[(i, j)])
386 .sum();
387 xtwx[k * p + j] = s;
388 xtwx[j * p + k] = s;
389 }
390 }
391 // Dispersion φ scales the coefficient covariance: Var(β̂) = φ·(XᵀWX)⁻¹.
392 // φ = 1 for Binomial/Poisson (fixed by the family); for Gaussian/Gamma it is
393 // estimated by the Pearson χ² statistic over residual dof, so the reported
394 // standard errors are not systematically too small.
395 let dispersion = match family {
396 GlmFamily::Binomial | GlmFamily::Poisson => 1.0,
397 GlmFamily::Gaussian => {
398 let dof = n.saturating_sub(p).max(1) as f64;
399 let rss: f64 = y
400 .iter()
401 .zip(&fitted_values)
402 .map(|(&yi, &mi)| (yi - mi).powi(2))
403 .sum();
404 rss / dof
405 }
406 GlmFamily::Gamma => {
407 let dof = n.saturating_sub(p).max(1) as f64;
408 // Pearson χ² with V(μ) = μ²: Σ ((yᵢ − μᵢ)/μᵢ)²
409 let chi2: f64 = y
410 .iter()
411 .zip(&fitted_values)
412 .map(|(&yi, &mi)| ((yi - mi) / mi.max(1e-10)).powi(2))
413 .sum();
414 chi2 / dof
415 }
416 };
417 let std_errors = cholesky_factor(&xtwx, p).map_or_else(
418 |_| vec![f64::NAN; p],
419 |l| compute_ols_std_errors(&l, p, dispersion),
420 );
421 let beta_se = compute_beta_se(&std_errors[1..=ncomp], &fpca.rotation, m);
422
423 let ll = family.log_likelihood(y, &fitted_values);
424 let deviance = family.deviance(y, &fitted_values);
425 let nf = n as f64;
426 let pf = p as f64;
427 let aic = -2.0 * ll + 2.0 * pf;
428 let bic = -2.0 * ll + nf.ln() * pf;
429
430 FunctionalGlmResult {
431 intercept: beta[0],
432 beta_t,
433 beta_se,
434 gamma,
435 fitted_values,
436 linear_predictors,
437 ncomp,
438 coefficients: beta,
439 std_errors,
440 log_likelihood: ll,
441 deviance,
442 iterations,
443 fpca,
444 aic,
445 bic,
446 family,
447 }
448}
449
450// ---------------------------------------------------------------------------
451// Public API
452// ---------------------------------------------------------------------------
453
454/// Fit a functional GLM for a scalar response over a functional predictor.
455///
456/// Models: g(E[Y | X]) = α + ∫β(t)X(t)dt + γᵀz
457///
458/// via IRLS (iteratively reweighted least squares) on FPC scores, where g is
459/// the canonical link function for the chosen [`GlmFamily`].
460///
461/// # Arguments
462///
463/// * `data` — Functional predictor matrix (n × m, column-major `FdMatrix`)
464/// * `y` — Scalar response vector (length n); must satisfy the family's domain
465/// constraint (Binomial: {0,1}; Poisson: non-negative integers; Gamma: > 0)
466/// * `family` — Exponential-family distribution; determines link and variance
467/// * `scalar_covariates` — Optional scalar covariate matrix (n × p)
468/// * `ncomp` — Number of FPC components (clamped to min(n−1, m))
469/// * `max_iter` — Maximum IRLS iterations (pass 0 for default of 25)
470/// * `tol` — Deviance-change convergence tolerance (pass ≤ 0.0 for default 1e-6)
471///
472/// # Returns
473///
474/// A [`FunctionalGlmResult`] containing: intercept, functional coefficient
475/// β(t), FPC score coefficients γ, fitted values μ = g⁻¹(η), linear
476/// predictors η, deviance, log-likelihood, AIC, BIC, standard errors, and
477/// the embedded [`FpcaResult`] for projecting new data.
478///
479/// # Errors
480///
481/// Returns [`FdarError::InvalidDimension`] if:
482/// - `data` has fewer than 3 rows or zero columns
483/// - `y.len() != n`
484/// - `scalar_covariates` is provided but its row count differs from `n`
485///
486/// Returns [`FdarError::InvalidParameter`] if any response value violates the
487/// family's domain constraint (Binomial y ∉ {0,1}; Poisson y < 0 or non-integer;
488/// Gamma y ≤ 0).
489///
490/// Returns [`FdarError::ComputationFailed`] if the SVD inside FPCA fails.
491///
492/// # Examples
493///
494/// ```
495/// use fdars_core::matrix::FdMatrix;
496/// use fdars_core::scalar_on_function::{functional_glm, GlmFamily};
497///
498/// let data = FdMatrix::from_column_major(
499/// (0..600).map(|i| (i as f64 * 0.05).sin()).collect(),
500/// 20, 30,
501/// ).unwrap();
502/// let y: Vec<f64> = (0..20).map(|i| (i as f64) * 0.4).collect();
503/// let fit = functional_glm(&data, &y, GlmFamily::Gaussian, None, 3, 25, 1e-6).unwrap();
504/// assert_eq!(fit.fitted_values.len(), 20);
505/// assert_eq!(fit.beta_t.len(), 30);
506/// assert!(fit.iterations >= 1);
507/// ```
508#[must_use = "expensive computation whose result should not be discarded"]
509pub fn functional_glm(
510 data: &FdMatrix,
511 y: &[f64],
512 family: GlmFamily,
513 scalar_covariates: Option<&FdMatrix>,
514 ncomp: usize,
515 max_iter: usize,
516 tol: f64,
517) -> Result<FunctionalGlmResult, FdarError> {
518 let (n, m) = data.shape();
519
520 // --- Dimension checks (fire before FPCA) ---
521 if n < 3 {
522 return Err(FdarError::InvalidDimension {
523 parameter: "data",
524 expected: "at least 3 rows".to_string(),
525 actual: format!("{n}"),
526 });
527 }
528 if m == 0 {
529 return Err(FdarError::InvalidDimension {
530 parameter: "data",
531 expected: "at least 1 column".to_string(),
532 actual: "0".to_string(),
533 });
534 }
535 if y.len() != n {
536 return Err(FdarError::InvalidDimension {
537 parameter: "y",
538 expected: format!("{n}"),
539 actual: format!("{}", y.len()),
540 });
541 }
542 if let Some(sc) = scalar_covariates {
543 let sc_rows = sc.shape().0;
544 if sc_rows != n {
545 return Err(FdarError::InvalidDimension {
546 parameter: "scalar_covariates",
547 expected: format!("{n} rows (matching data)"),
548 actual: format!("{sc_rows}"),
549 });
550 }
551 }
552
553 // --- Response-domain guard (fires before FPCA) ---
554 validate_response(y, family)?;
555
556 let ncomp = ncomp.min(n - 1).min(m);
557 let argvals: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1).max(1) as f64).collect();
558 let fpca = fdata_to_pc_1d(data, ncomp, &argvals)?;
559 let design = build_design_matrix(&fpca.scores, ncomp, scalar_covariates, n);
560
561 let max_iter = if max_iter == 0 { 25 } else { max_iter };
562 let tol = if tol <= 0.0 { 1e-6 } else { tol };
563
564 let (beta, iterations) = irls_loop_glm(&design, y, family, max_iter, tol);
565 Ok(build_glm_result(
566 &design, beta, y, fpca, ncomp, m, iterations, family,
567 ))
568}
569
570/// Predict response for new functional data using a fitted GLM.
571///
572/// Projects new curves through the stored FPCA, computes the linear predictor,
573/// and applies the inverse link: μ = g⁻¹(η).
574///
575/// # Arguments
576///
577/// * `fit` — A fitted [`FunctionalGlmResult`]
578/// * `new_data` — New functional predictor matrix (n_new × m), where `m` MUST
579/// equal the training grid length
580/// * `new_scalar` — Optional new scalar covariates (n_new × p)
581///
582/// # Errors
583///
584/// Returns [`FdarError::InvalidDimension`] if `new_data`'s column count differs
585/// from the training grid length, or if `new_scalar`'s shape does not match the
586/// fitted model's scalar-covariate count (or is missing when the model has
587/// scalar covariates). This prevents out-of-bounds indexing / silent truncation.
588pub fn predict_functional_glm(
589 fit: &FunctionalGlmResult,
590 new_data: &FdMatrix,
591 new_scalar: Option<&FdMatrix>,
592) -> Result<Vec<f64>, FdarError> {
593 let (n_new, m) = new_data.shape();
594 let ncomp = fit.ncomp;
595 let p_scalar = fit.gamma.len();
596 let m_train = fit.fpca.mean.len();
597
598 if m != m_train {
599 return Err(FdarError::InvalidDimension {
600 parameter: "new_data",
601 expected: format!("{m_train} columns (training grid length)"),
602 actual: format!("{m}"),
603 });
604 }
605 match new_scalar {
606 Some(sc) => {
607 let (sc_rows, sc_cols) = sc.shape();
608 if sc_rows != n_new {
609 return Err(FdarError::InvalidDimension {
610 parameter: "new_scalar",
611 expected: format!("{n_new} rows (matching new_data)"),
612 actual: format!("{sc_rows}"),
613 });
614 }
615 if sc_cols != p_scalar {
616 return Err(FdarError::InvalidDimension {
617 parameter: "new_scalar",
618 expected: format!("{p_scalar} columns (model scalar covariates)"),
619 actual: format!("{sc_cols}"),
620 });
621 }
622 }
623 None if p_scalar > 0 => {
624 return Err(FdarError::InvalidDimension {
625 parameter: "new_scalar",
626 expected: format!("{p_scalar} columns (model was fit with scalar covariates)"),
627 actual: "None".to_string(),
628 });
629 }
630 None => {}
631 }
632
633 Ok((0..n_new)
634 .map(|i| {
635 let mut eta = fit.coefficients[0]; // intercept
636 for k in 0..ncomp {
637 let mut s = 0.0;
638 for j in 0..m {
639 s += (new_data[(i, j)] - fit.fpca.mean[j])
640 * fit.fpca.rotation[(j, k)]
641 * fit.fpca.weights[j];
642 }
643 eta += fit.coefficients[1 + k] * s;
644 }
645 if let Some(sc) = new_scalar {
646 for j in 0..p_scalar {
647 eta += fit.gamma[j] * sc[(i, j)];
648 }
649 }
650 fit.family.inv_link(eta)
651 })
652 .collect())
653}
654
655// ---------------------------------------------------------------------------
656// Inline tests
657// ---------------------------------------------------------------------------
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662 use crate::scalar_on_function::functional_logistic;
663
664 fn make_data(n: usize, m: usize) -> FdMatrix {
665 FdMatrix::from_column_major(
666 (0..n * m)
667 .map(|i| ((i as f64) * 0.07).sin() + 0.01 * (i as f64))
668 .collect(),
669 n,
670 m,
671 )
672 .unwrap()
673 }
674
675 // ------------------------------------------------------------------
676 // Task 1: Gaussian tracer smoke test
677 // ------------------------------------------------------------------
678
679 #[test]
680 fn test_gaussian_smoke() {
681 let n = 30;
682 let m = 40;
683 let data = make_data(n, m);
684 let y: Vec<f64> = (0..n).map(|i| (i as f64) * 0.5 + 1.0).collect();
685
686 let fit = functional_glm(&data, &y, GlmFamily::Gaussian, None, 3, 25, 1e-6).unwrap();
687
688 assert_eq!(fit.fitted_values.len(), n, "fitted_values len");
689 assert_eq!(fit.beta_t.len(), m, "beta_t len");
690 assert!(fit.iterations >= 1, "at least one iteration");
691 assert!(
692 fit.fitted_values.iter().all(|v| v.is_finite()),
693 "all fitted_values finite"
694 );
695 }
696
697 // ------------------------------------------------------------------
698 // Task 2: Binomial parity with functional_logistic
699 // ------------------------------------------------------------------
700
701 #[test]
702 fn test_binomial_parity_with_logistic() {
703 let n = 30;
704 let m = 50;
705 let data = make_data(n, m);
706 // Binary labels: first half 0, second half 1
707 let y_bin: Vec<f64> = (0..n).map(|i| if i < n / 2 { 0.0 } else { 1.0 }).collect();
708
709 // functional_logistic stops on max-coefficient-change while functional_glm
710 // stops on deviance-change. The per-step IRLS update is identical, so with a
711 // tight tol and ample iterations BOTH fully converge to the same fixed point
712 // — making the parity comparison deterministic (not criterion-timing dependent).
713 let fit_logistic = functional_logistic(&data, &y_bin, None, 3, 100, 1e-12).unwrap();
714 let fit_glm =
715 functional_glm(&data, &y_bin, GlmFamily::Binomial, None, 3, 100, 1e-12).unwrap();
716
717 // Coefficient parity
718 for (i, (a, b)) in fit_logistic
719 .coefficients
720 .iter()
721 .zip(&fit_glm.coefficients)
722 .enumerate()
723 {
724 assert!(
725 (a - b).abs() < 1e-6,
726 "coefficient[{i}] mismatch: logistic={a}, glm={b}"
727 );
728 }
729 // Fitted value (probability) parity
730 for (i, (a, b)) in fit_logistic
731 .probabilities
732 .iter()
733 .zip(&fit_glm.fitted_values)
734 .enumerate()
735 {
736 assert!(
737 (a - b).abs() < 1e-6,
738 "fitted_value[{i}] mismatch: logistic={a}, glm={b}"
739 );
740 }
741 }
742
743 #[test]
744 fn test_binomial_out_of_range_guard() {
745 let n = 10;
746 let m = 20;
747 let data = make_data(n, m);
748 let mut y = vec![0.0f64; n];
749 y[3] = 0.5; // invalid
750
751 let result = functional_glm(&data, &y, GlmFamily::Binomial, None, 3, 25, 1e-6);
752 assert!(
753 matches!(result, Err(FdarError::InvalidParameter { .. })),
754 "expected InvalidParameter for out-of-range Binomial y"
755 );
756 }
757
758 // ------------------------------------------------------------------
759 // Task 3: Poisson recovery
760 // ------------------------------------------------------------------
761
762 /// Build a rich functional dataset with K orthogonal components, each with varying amplitude.
763 ///
764 /// Curve i = Σ_k scores_k[i] * basis_k(t) where basis_k = sin(k*π*t).
765 /// This ensures FPCA picks up K independent components and the design matrix is full-rank.
766 fn make_rich_data(n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
767 // Generate 3 components with decorrelated, linearly-spaced scores.
768 // Curve i = s0*sin(πt) + 0.5*s1*sin(2πt) + 0.25*s2*sin(3πt), where
769 // s0, s1, s2 are index-based quasi-random permutations so the 3 FPCA
770 // components are well-separated and X'WX is non-singular.
771 let mut vals = vec![0.0f64; n * m];
772 let mut first_scores = vec![0.0f64; n];
773 for i in 0..n {
774 let s0 = (i as f64 / (n - 1) as f64) * 2.0 - 1.0; // in [-1, 1]
775 let s1 = ((i * 3 % n) as f64 / (n - 1) as f64) * 1.6 - 0.8;
776 let s2 = ((i * 7 % n) as f64 / (n - 1) as f64) * 1.4 - 0.7;
777 first_scores[i] = s0;
778 for j in 0..m {
779 let t = j as f64 / (m - 1) as f64;
780 let b0 = (std::f64::consts::PI * t).sin();
781 let b1 = (2.0 * std::f64::consts::PI * t).sin();
782 let b2 = (3.0 * std::f64::consts::PI * t).sin();
783 vals[i + j * n] = s0 * b0 + 0.5 * s1 * b1 + 0.25 * s2 * b2;
784 }
785 }
786 let data = FdMatrix::from_column_major(vals, n, m).unwrap();
787 (data, first_scores)
788 }
789
790 #[test]
791 fn test_poisson_recovery() {
792 // Deterministic: true Poisson log(mu_i) = 1.0 + 1.5 * first_score_i.
793 // y_i = round(mu_i) (integer counts).
794 let n = 100;
795 let m = 30;
796
797 let (data, first_scores) = make_rich_data(n, m);
798 let true_mu: Vec<f64> = first_scores
799 .iter()
800 .map(|&s| (1.0 + 1.5 * s).exp())
801 .collect();
802 let y: Vec<f64> = true_mu.iter().map(|&mu| mu.round().max(0.0)).collect();
803
804 let fit = functional_glm(&data, &y, GlmFamily::Poisson, None, 3, 100, 1e-6).unwrap();
805
806 assert!(
807 fit.fitted_values.iter().all(|&v| v.is_finite() && v > 0.0),
808 "all fitted_values finite and positive"
809 );
810
811 // Pearson correlation between fit.fitted_values and true_mu
812 let corr = pearson_corr(&fit.fitted_values, &true_mu);
813 assert!(corr > 0.9, "Pearson corr={corr} should be > 0.9");
814 }
815
816 #[test]
817 fn test_gamma_recovery() {
818 // True model: 1/μ_i = 2.0 + 1.0 * first_score_i (Gamma inverse link).
819 // first_scores from make_rich_data are in [-1, 1], so η_i ∈ [1.0, 3.0] > 0,
820 // and μ_i = 1/η_i ∈ [0.33, 1.0] — strictly positive throughout.
821 //
822 // This test verifies that the Gamma GLM with CORRECT IRLS weight (w = μ²)
823 // recovers the true generating mean with Pearson correlation > 0.9.
824 // The 3-component functional data from make_rich_data ensures the FPCA
825 // scores are not trivially aligned with the true score, providing a
826 // meaningful regression test.
827 //
828 // Note: for this noiseless multi-component fixture the IRLS weight choice
829 // (μ² vs 1/μ²) both converge to the same point estimates; the primary
830 // value of the corr > 0.9 assertion is to confirm the overall GLM algorithm
831 // is producing a sensible Gamma fit, not just finite values.
832 let n = 100;
833 let m = 30;
834
835 let (data, first_scores) = make_rich_data(n, m);
836 // η_i = 2.0 + 1.0 * s_i, s_i ∈ [-1, 1] → η_i ∈ [1.0, 3.0] > 0
837 let true_mu: Vec<f64> = first_scores
838 .iter()
839 .map(|&s| 1.0 / (2.0 + 1.0 * s))
840 .collect();
841 let y = true_mu.clone();
842
843 let fit = functional_glm(&data, &y, GlmFamily::Gamma, None, 3, 100, 1e-6).unwrap();
844
845 assert!(
846 fit.fitted_values.iter().all(|&v| v.is_finite() && v > 0.0),
847 "all Gamma fitted_values finite and positive"
848 );
849
850 // Sanity: fitted means correlate with true means
851 let corr = pearson_corr(&fit.fitted_values, &true_mu);
852 assert!(
853 corr > 0.9,
854 "Gamma recovery: Pearson corr={corr:.4} should be > 0.9"
855 );
856 }
857
858 // ------------------------------------------------------------------
859 // Task 3: domain guard tests
860 // ------------------------------------------------------------------
861
862 #[test]
863 fn test_poisson_negative_guard() {
864 let n = 10;
865 let m = 20;
866 let data = make_data(n, m);
867 let mut y = vec![1.0f64; n];
868 y[2] = -1.0;
869
870 let result = functional_glm(&data, &y, GlmFamily::Poisson, None, 3, 25, 1e-6);
871 assert!(
872 matches!(result, Err(FdarError::InvalidParameter { .. })),
873 "expected InvalidParameter for negative Poisson y"
874 );
875 }
876
877 #[test]
878 fn test_poisson_noninteger_guard() {
879 let n = 10;
880 let m = 20;
881 let data = make_data(n, m);
882 let mut y = vec![1.0f64; n];
883 y[5] = 1.5;
884
885 let result = functional_glm(&data, &y, GlmFamily::Poisson, None, 3, 25, 1e-6);
886 assert!(
887 matches!(result, Err(FdarError::InvalidParameter { .. })),
888 "expected InvalidParameter for non-integer Poisson y"
889 );
890 }
891
892 #[test]
893 fn test_gamma_nonpositive_guard() {
894 let n = 10;
895 let m = 20;
896 let data = make_data(n, m);
897 let mut y = vec![1.0f64; n];
898 y[4] = 0.0;
899
900 let result = functional_glm(&data, &y, GlmFamily::Gamma, None, 3, 25, 1e-6);
901 assert!(
902 matches!(result, Err(FdarError::InvalidParameter { .. })),
903 "expected InvalidParameter for non-positive Gamma y"
904 );
905 }
906
907 #[test]
908 fn test_dimension_mismatch_guard() {
909 let n = 10;
910 let m = 20;
911 let data = make_data(n, m);
912 let y = vec![1.0f64; n + 1]; // wrong length
913
914 let result = functional_glm(&data, &y, GlmFamily::Gaussian, None, 3, 25, 1e-6);
915 assert!(
916 matches!(result, Err(FdarError::InvalidDimension { .. })),
917 "expected InvalidDimension for y.len() mismatch"
918 );
919 }
920
921 #[test]
922 fn test_nonfinite_response_guard() {
923 // IN-01 / CR-02a: NaN (Gamma) and +Inf (Poisson) must be rejected rather
924 // than slipping past the per-family guards into an all-NaN result / an
925 // unbounded log-factorial loop.
926 let n = 10;
927 let m = 20;
928 let data = make_data(n, m);
929
930 let mut y_nan = vec![1.0f64; n];
931 y_nan[3] = f64::NAN;
932 assert!(
933 matches!(
934 functional_glm(&data, &y_nan, GlmFamily::Gamma, None, 3, 25, 1e-6),
935 Err(FdarError::InvalidParameter { .. })
936 ),
937 "expected InvalidParameter for NaN Gamma response"
938 );
939
940 let mut y_inf = vec![1.0f64; n];
941 y_inf[5] = f64::INFINITY;
942 assert!(
943 matches!(
944 functional_glm(&data, &y_inf, GlmFamily::Poisson, None, 3, 25, 1e-6),
945 Err(FdarError::InvalidParameter { .. })
946 ),
947 "expected InvalidParameter for +Inf Poisson response"
948 );
949 }
950
951 #[test]
952 fn test_predict_dimension_guard() {
953 // CR-03: predict must reject a new_data grid length that differs from the
954 // training grid instead of panicking / silently truncating.
955 let n = 30;
956 let m = 40;
957 let data = make_data(n, m);
958 let y: Vec<f64> = (0..n).map(|i| (i as f64) * 0.5 + 1.0).collect();
959 let fit = functional_glm(&data, &y, GlmFamily::Gaussian, None, 3, 25, 1e-6).unwrap();
960
961 // Correct grid length succeeds.
962 assert!(predict_functional_glm(&fit, &data, None).is_ok());
963
964 // Wrong grid length → InvalidDimension (no panic).
965 let wrong = make_data(5, m + 3);
966 assert!(
967 matches!(
968 predict_functional_glm(&fit, &wrong, None),
969 Err(FdarError::InvalidDimension { .. })
970 ),
971 "expected InvalidDimension for mismatched predict grid length"
972 );
973 }
974
975 // ------------------------------------------------------------------
976 // Helper: Pearson correlation
977 // ------------------------------------------------------------------
978
979 fn pearson_corr(x: &[f64], y: &[f64]) -> f64 {
980 let n = x.len() as f64;
981 let mx = x.iter().sum::<f64>() / n;
982 let my = y.iter().sum::<f64>() / n;
983 let num: f64 = x
984 .iter()
985 .zip(y)
986 .map(|(&xi, &yi)| (xi - mx) * (yi - my))
987 .sum();
988 let dx: f64 = x.iter().map(|&xi| (xi - mx).powi(2)).sum::<f64>().sqrt();
989 let dy: f64 = y.iter().map(|&yi| (yi - my).powi(2)).sum::<f64>().sqrt();
990 if dx == 0.0 || dy == 0.0 {
991 0.0
992 } else {
993 num / (dx * dy)
994 }
995 }
996}