#![allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::needless_range_loop,
clippy::similar_names,
clippy::many_single_char_names,
clippy::too_many_arguments,
clippy::too_many_lines
)]
use std::sync::Arc;
use antecedent_core::VariableId;
use crate::design::{BasisKind, DesignColumn, DesignColumnMap, DesignColumnRole, RecordedSmooth};
use crate::error::StatsError;
use crate::gram::{form_xtx, invert_square};
use crate::linalg::{DenseLinearAlgebra, FitDiagnostics, LeastSquaresWorkspace};
const CUBIC_DEGREE: usize = 3;
const CUBIC_ORDER: usize = CUBIC_DEGREE + 1;
#[derive(Clone, Debug, PartialEq)]
pub struct SmoothSpec {
pub raw_col: usize,
pub n_basis: usize,
pub lambda: f64,
pub auto_lambda: bool,
pub knots: Option<Arc<[f64]>>,
pub variable: Option<VariableId>,
}
impl SmoothSpec {
#[must_use]
pub fn new(raw_col: usize, n_basis: usize, lambda: f64) -> Self {
Self { raw_col, n_basis, lambda, auto_lambda: false, knots: None, variable: None }
}
#[must_use]
pub fn auto(raw_col: usize, n_basis: usize) -> Self {
Self { raw_col, n_basis, lambda: 0.0, auto_lambda: true, knots: None, variable: None }
}
#[must_use]
pub fn with_variable(mut self, id: VariableId) -> Self {
self.variable = Some(id);
self
}
#[must_use]
pub fn with_knots(mut self, knots: impl Into<Arc<[f64]>>) -> Self {
self.knots = Some(knots.into());
self
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GamOptions {
pub max_iter: u32,
pub tol: f64,
}
impl Default for GamOptions {
fn default() -> Self {
Self { max_iter: 100, tol: 1e-6 }
}
}
#[derive(Clone, Debug, Default)]
pub struct GamWorkspace {
pub partial: Vec<f64>,
pub fitted: Vec<f64>,
pub smooth_fit: Vec<f64>,
pub gram: Vec<f64>,
pub rhs: Vec<f64>,
pub ls: LeastSquaresWorkspace,
grow_count: u32,
}
impl GamWorkspace {
pub fn prepare(&mut self, nrows: usize, max_basis: usize) {
let grow = |v: &mut Vec<f64>, n: usize, count: &mut u32| {
if v.capacity() < n {
*count = count.saturating_add(1);
}
if v.len() < n {
v.resize(n, 0.0);
} else {
v.truncate(n);
}
};
grow(&mut self.partial, nrows, &mut self.grow_count);
grow(&mut self.fitted, nrows, &mut self.grow_count);
grow(&mut self.smooth_fit, nrows, &mut self.grow_count);
grow(&mut self.gram, max_basis * max_basis, &mut self.grow_count);
grow(&mut self.rhs, max_basis, &mut self.grow_count);
}
}
#[derive(Clone, Debug)]
pub struct GamFit {
pub intercept: f64,
pub coefficients: Vec<f64>,
pub smooths: Vec<RecordedSmooth>,
pub fitted: Vec<f64>,
pub residuals: Vec<f64>,
pub edf_approx: f64,
pub iterations: u32,
pub converged: bool,
pub diagnostics: FitDiagnostics,
raw_cols: Vec<usize>,
centers: Vec<f64>,
}
pub fn expand_bspline(
x: &[f64],
n_basis: usize,
knots: Option<&[f64]>,
) -> Result<(Vec<f64>, Arc<[f64]>), StatsError> {
if x.is_empty() {
return Err(StatsError::Shape { message: "empty x for B-spline expansion" });
}
if n_basis < CUBIC_ORDER {
return Err(StatsError::Shape { message: "n_basis must be ≥ 4 for cubic B-splines" });
}
for &v in x {
if !v.is_finite() {
return Err(StatsError::Shape {
message: "non-finite predictor in B-spline expansion",
});
}
}
let knot_vec: Arc<[f64]> = if let Some(k) = knots {
validate_knots(k, n_basis)?;
Arc::from(k.to_vec())
} else {
Arc::from(quantile_knots(x, n_basis)?)
};
let nrows = x.len();
let mut basis = vec![0.0; nrows * n_basis];
for r in 0..nrows {
eval_cubic_bspline(x[r], &knot_vec, n_basis, &mut basis, r, nrows);
}
Ok((basis, knot_vec))
}
pub fn compile_additive_design(
x_colmajor: &[f64],
nrows: usize,
n_raw_cols: usize,
specs: &[SmoothSpec],
) -> Result<(Vec<f64>, DesignColumnMap, Vec<RecordedSmooth>), StatsError> {
validate_raw_layout(x_colmajor, nrows, n_raw_cols, specs)?;
let mut ncols = 1usize;
for s in specs {
ncols = ncols.saturating_add(s.n_basis);
}
let mut matrix = vec![0.0; nrows * ncols];
for r in 0..nrows {
matrix[r] = 1.0;
}
let mut columns = vec![DesignColumn::from_role(DesignColumnRole::Intercept)];
let mut smooths = Vec::with_capacity(specs.len());
let mut col = 1usize;
for (si, spec) in specs.iter().enumerate() {
let xcol = raw_column(x_colmajor, nrows, spec.raw_col);
let (basis, knots) = expand_bspline(xcol, spec.n_basis, spec.knots.as_deref())?;
let start = col;
let end = col + spec.n_basis;
for b in 0..spec.n_basis {
let src = b * nrows;
let dst = (start + b) * nrows;
matrix[dst..dst + nrows].copy_from_slice(&basis[src..src + nrows]);
let role = match spec.variable {
Some(id) => DesignColumnRole::Covariate(id),
None => DesignColumnRole::Covariate(VariableId::from_raw(spec.raw_col as u32)),
};
columns.push(DesignColumn {
role,
contrast_idx: None,
standardization_idx: None,
smooth_idx: Some(si),
});
}
smooths.push(RecordedSmooth {
variable: spec.variable.or(Some(VariableId::from_raw(spec.raw_col as u32))),
basis: BasisKind::CubicBSpline,
knots,
lambda: spec.lambda,
column_range: (start, end),
n_basis: spec.n_basis,
});
col = end;
}
let map = DesignColumnMap::from_columns(columns).with_smooth_links(&smooths);
Ok((matrix, map, smooths))
}
pub fn fit_gam(
x_colmajor: &[f64],
nrows: usize,
n_raw_cols: usize,
y: &[f64],
specs: &[SmoothSpec],
options: &GamOptions,
_backend: &impl DenseLinearAlgebra,
workspace: &mut GamWorkspace,
) -> Result<GamFit, StatsError> {
if specs.is_empty() {
return Err(StatsError::Shape { message: "GAM requires at least one smooth term" });
}
if y.len() != nrows {
return Err(StatsError::Shape { message: "y length != nrows" });
}
validate_raw_layout(x_colmajor, nrows, n_raw_cols, specs)?;
for s in specs {
if !(s.auto_lambda || (s.lambda.is_finite() && s.lambda >= 0.0)) {
return Err(StatsError::Shape { message: "smooth lambda must be finite and ≥ 0" });
}
if s.n_basis < CUBIC_ORDER {
return Err(StatsError::Shape { message: "n_basis must be ≥ 4 for cubic B-splines" });
}
}
let max_basis = specs.iter().map(|s| s.n_basis).max().unwrap_or(0);
workspace.prepare(nrows, max_basis);
let mut bases: Vec<Arc<[f64]>> = Vec::with_capacity(specs.len());
let mut smooth_meta: Vec<RecordedSmooth> = Vec::with_capacity(specs.len());
let mut coef_offsets = Vec::with_capacity(specs.len());
let mut chosen_lambda = Vec::with_capacity(specs.len());
let mut total_coefs = 0usize;
let mut col_cursor = 1usize; for spec in specs {
let xcol = raw_column(x_colmajor, nrows, spec.raw_col);
let (basis, knots) = expand_bspline(xcol, spec.n_basis, spec.knots.as_deref())?;
coef_offsets.push(total_coefs);
total_coefs += spec.n_basis;
let start = col_cursor;
let end = col_cursor + spec.n_basis;
chosen_lambda.push(spec.lambda);
smooth_meta.push(RecordedSmooth {
variable: spec.variable.or(Some(VariableId::from_raw(spec.raw_col as u32))),
basis: BasisKind::CubicBSpline,
knots,
lambda: spec.lambda,
column_range: (start, end),
n_basis: spec.n_basis,
});
bases.push(Arc::from(basis));
col_cursor = end;
}
let mut coefficients = vec![0.0; total_coefs];
let mut smooth_fits: Vec<Vec<f64>> = (0..specs.len()).map(|_| vec![0.0; nrows]).collect();
let y_mean = mean(y);
let mut intercept = y_mean;
workspace.fitted.fill(intercept);
let mut converged = false;
let mut iterations = 0u32;
let mut edf_approx = 1.0; let mut prev_rss = f64::INFINITY;
let mut selected_lambda = false;
for iter in 1..=options.max_iter {
iterations = iter;
let mut max_delta = 0.0_f64;
for (j, spec) in specs.iter().enumerate() {
for r in 0..nrows {
let mut other = intercept;
for (k, sf) in smooth_fits.iter().enumerate() {
if k != j {
other += sf[r];
}
}
workspace.partial[r] = y[r] - other;
}
let basis = bases[j].as_ref();
if !selected_lambda && spec.auto_lambda {
chosen_lambda[j] = select_lambda_gcv(
basis,
nrows,
spec.n_basis,
&workspace.partial[..nrows],
&mut workspace.gram,
&mut workspace.rhs,
)?;
smooth_meta[j].lambda = chosen_lambda[j];
}
let lambda = chosen_lambda[j];
let beta = roughness_basis_solve(
basis,
nrows,
spec.n_basis,
&workspace.partial[..nrows],
lambda,
&mut workspace.gram,
&mut workspace.rhs,
)?;
let off = coef_offsets[j];
coefficients[off..off + spec.n_basis].copy_from_slice(&beta);
for r in 0..nrows {
let mut pred = 0.0;
for b in 0..spec.n_basis {
pred += basis[b * nrows + r] * beta[b];
}
workspace.smooth_fit[r] = pred;
}
let f_mean = mean(&workspace.smooth_fit[..nrows]);
for r in 0..nrows {
workspace.smooth_fit[r] -= f_mean;
max_delta = max_delta.max((workspace.smooth_fit[r] - smooth_fits[j][r]).abs());
smooth_fits[j][r] = workspace.smooth_fit[r];
}
if iter == 1 {
edf_approx +=
roughness_edf(basis, nrows, spec.n_basis, lambda, &mut workspace.gram)? - 1.0;
}
}
selected_lambda = true;
let mut sum = 0.0;
for r in 0..nrows {
let mut s = 0.0;
for sf in &smooth_fits {
s += sf[r];
}
sum += y[r] - s;
}
intercept = sum / nrows as f64;
let mut rss = 0.0;
for r in 0..nrows {
let mut pred = intercept;
for sf in &smooth_fits {
pred += sf[r];
}
workspace.fitted[r] = pred;
let e = y[r] - pred;
rss += e * e;
}
let fit_scale =
workspace.fitted[..nrows].iter().fold(0.0_f64, |acc, &v| acc.max(v.abs())).max(1.0);
let rss_delta = (prev_rss - rss).abs();
prev_rss = rss;
if max_delta < options.tol * fit_scale || rss_delta < options.tol * (1.0 + rss) {
converged = true;
break;
}
}
let mut residuals = vec![0.0; nrows];
for r in 0..nrows {
residuals[r] = y[r] - workspace.fitted[r];
}
let mut centers = vec![0.0; specs.len()];
for (j, spec) in specs.iter().enumerate() {
let basis = bases[j].as_ref();
let off = coef_offsets[j];
let mut sum = 0.0;
for r in 0..nrows {
let mut pred = 0.0;
for b in 0..spec.n_basis {
pred += basis[b * nrows + r] * coefficients[off + b];
}
sum += pred;
}
centers[j] = sum / nrows as f64;
}
let rank = 1 + specs.iter().map(|s| s.n_basis).sum::<usize>();
let raw_cols: Vec<usize> = specs.iter().map(|s| s.raw_col).collect();
Ok(GamFit {
intercept,
coefficients,
smooths: smooth_meta,
fitted: workspace.fitted[..nrows].to_vec(),
residuals,
edf_approx,
iterations,
converged,
diagnostics: FitDiagnostics::new(rank, None, "gam", workspace.grow_count),
raw_cols,
centers,
})
}
pub fn predict_gam(
fit: &GamFit,
x_colmajor: &[f64],
nrows: usize,
n_raw_cols: usize,
) -> Result<Vec<f64>, StatsError> {
if x_colmajor.len() < nrows.saturating_mul(n_raw_cols) {
return Err(StatsError::Shape { message: "X buffer too short" });
}
if fit.smooths.len() != fit.raw_cols.len() || fit.smooths.len() != fit.centers.len() {
return Err(StatsError::Backend("GAM fit smooth/raw_col/center length mismatch".into()));
}
let mut pred = vec![fit.intercept; nrows];
let mut coef_off = 0usize;
for (j, smooth) in fit.smooths.iter().enumerate() {
let raw_col = fit.raw_cols[j];
if raw_col >= n_raw_cols {
return Err(StatsError::Shape { message: "predict raw column out of range" });
}
let xcol = raw_column(x_colmajor, nrows, raw_col);
let (basis, _) = expand_bspline(xcol, smooth.n_basis, Some(smooth.knots.as_ref()))?;
let center = fit.centers[j];
for r in 0..nrows {
let mut s = 0.0;
for b in 0..smooth.n_basis {
s += basis[b * nrows + r] * fit.coefficients[coef_off + b];
}
pred[r] += s - center;
}
coef_off += smooth.n_basis;
}
Ok(pred)
}
#[must_use]
pub fn fitted_from_gam(fit: &GamFit) -> &[f64] {
&fit.fitted
}
impl GamFit {
pub fn predict_row(&self, raw_row: &[f64]) -> Result<f64, StatsError> {
if self.smooths.len() != self.raw_cols.len() || self.smooths.len() != self.centers.len() {
return Err(StatsError::Backend(
"GAM fit smooth/raw_col/center length mismatch".into(),
));
}
let mut pred = self.intercept;
let mut coef_off = 0usize;
for (j, smooth) in self.smooths.iter().enumerate() {
let raw_col = self.raw_cols[j];
let Some(&x) = raw_row.get(raw_col) else {
return Err(StatsError::Shape { message: "predict raw column out of range" });
};
pred += self.smooth_dot(smooth, coef_off, x) - self.centers[j];
coef_off += smooth.n_basis;
}
Ok(pred)
}
pub fn smooth_partial(&self, smooth_index: usize, x: f64) -> Result<f64, StatsError> {
if smooth_index >= self.smooths.len() || smooth_index >= self.centers.len() {
return Err(StatsError::Shape { message: "smooth index out of range" });
}
let coef_off: usize = self.smooths[..smooth_index].iter().map(|s| s.n_basis).sum();
let smooth = &self.smooths[smooth_index];
Ok(self.smooth_dot(smooth, coef_off, x) - self.centers[smooth_index])
}
#[must_use]
pub fn smooth_for_raw_col(&self, raw_col: usize) -> Option<usize> {
self.raw_cols.iter().position(|&c| c == raw_col)
}
fn smooth_dot(&self, smooth: &RecordedSmooth, coef_off: usize, x: f64) -> f64 {
let (span, values) = cubic_bspline_nonzeros(x, smooth.knots.as_ref());
let first = span.saturating_sub(CUBIC_DEGREE);
let mut s = 0.0;
for (i, &v) in values.iter().enumerate() {
let b = first + i;
if b < smooth.n_basis {
s += v * self.coefficients[coef_off + b];
}
}
s
}
}
fn validate_raw_layout(
x_colmajor: &[f64],
nrows: usize,
n_raw_cols: usize,
specs: &[SmoothSpec],
) -> Result<(), StatsError> {
if nrows == 0 {
return Err(StatsError::Shape { message: "empty design" });
}
if x_colmajor.len() < nrows.saturating_mul(n_raw_cols) {
return Err(StatsError::Shape { message: "X buffer too short" });
}
for s in specs {
if s.raw_col >= n_raw_cols {
return Err(StatsError::Shape { message: "smooth raw_col out of range" });
}
}
Ok(())
}
fn raw_column(x_colmajor: &[f64], nrows: usize, col: usize) -> &[f64] {
&x_colmajor[col * nrows..(col + 1) * nrows]
}
fn mean(v: &[f64]) -> f64 {
if v.is_empty() {
return 0.0;
}
v.iter().sum::<f64>() / v.len() as f64
}
fn validate_knots(knots: &[f64], n_basis: usize) -> Result<(), StatsError> {
let need = n_basis + CUBIC_ORDER;
if knots.len() != need {
return Err(StatsError::Shape {
message: "knot vector length must equal n_basis + 4 for cubic B-splines",
});
}
for w in knots.windows(2) {
if !(w[0].is_finite() && w[1].is_finite()) || w[1] < w[0] {
return Err(StatsError::Shape { message: "knots must be finite and non-decreasing" });
}
}
Ok(())
}
fn quantile_knots(x: &[f64], n_basis: usize) -> Result<Vec<f64>, StatsError> {
let mut sorted = x.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let xmin = sorted[0];
let xmax = sorted[sorted.len() - 1];
if !(xmax - xmin).is_finite() {
return Err(StatsError::Shape { message: "non-finite predictor range" });
}
let (xmin, xmax) =
if (xmax - xmin).abs() < 1e-15 { (xmin - 1.0, xmax + 1.0) } else { (xmin, xmax) };
let n_interior = n_basis.saturating_sub(CUBIC_ORDER);
let mut knots = Vec::with_capacity(n_basis + CUBIC_ORDER);
for _ in 0..CUBIC_ORDER {
knots.push(xmin);
}
if n_interior > 0 {
let n = sorted.len();
for i in 1..=n_interior {
let q = i as f64 / (n_interior + 1) as f64;
let pos = q * (n - 1) as f64;
let lo = pos.floor() as usize;
let hi = pos.ceil() as usize;
let t = pos - lo as f64;
let v = sorted[lo] * (1.0 - t) + sorted[hi.min(n - 1)] * t;
knots.push(v);
}
}
for _ in 0..CUBIC_ORDER {
knots.push(xmax);
}
Ok(knots)
}
fn cubic_bspline_nonzeros(x: f64, knots: &[f64]) -> (usize, [f64; CUBIC_ORDER]) {
let eps = 1e-14;
let left = knots[CUBIC_DEGREE];
let right = knots[knots.len() - CUBIC_ORDER];
let xx = if x >= right {
right - eps
} else if x < left {
left
} else {
x
};
let mut span = CUBIC_DEGREE;
for i in CUBIC_DEGREE..(knots.len() - CUBIC_ORDER) {
if xx >= knots[i] && xx < knots[i + 1] {
span = i;
break;
}
if i == knots.len() - CUBIC_ORDER - 1 {
span = i;
}
}
let mut ndu = [[0.0_f64; CUBIC_ORDER]; CUBIC_ORDER];
ndu[0][0] = 1.0;
let mut left = [0.0_f64; CUBIC_ORDER];
let mut right = [0.0_f64; CUBIC_ORDER];
for j in 1..CUBIC_ORDER {
left[j] = xx - knots[span + 1 - j];
right[j] = knots[span + j] - xx;
let mut saved = 0.0;
for r in 0..j {
let temp = ndu[r][j - 1] / (right[r + 1] + left[j - r]);
ndu[r][j] = saved + right[r + 1] * temp;
saved = left[j - r] * temp;
}
ndu[j][j] = saved;
}
let mut values = [0.0_f64; CUBIC_ORDER];
for (i, v) in values.iter_mut().enumerate() {
*v = ndu[i][CUBIC_DEGREE];
}
(span, values)
}
fn eval_cubic_bspline(
x: f64,
knots: &[f64],
n_basis: usize,
out: &mut [f64],
row: usize,
nrows: usize,
) {
let (span, values) = cubic_bspline_nonzeros(x, knots);
for b in 0..n_basis {
out[b * nrows + row] = 0.0;
}
let first = span.saturating_sub(CUBIC_DEGREE);
for i in 0..CUBIC_ORDER {
let b = first + i;
if b < n_basis {
out[b * nrows + row] = values[i];
}
}
}
fn second_difference_matrix(n_basis: usize) -> Result<Vec<f64>, StatsError> {
if n_basis < 3 {
return Err(StatsError::Shape { message: "second-difference penalty needs n_basis ≥ 3" });
}
let rows = n_basis - 2;
let mut d2 = vec![0.0; rows * n_basis];
for i in 0..rows {
d2[i * n_basis + i] = 1.0;
d2[i * n_basis + i + 1] = -2.0;
d2[i * n_basis + i + 2] = 1.0;
}
Ok(d2)
}
fn second_difference_penalty(n_basis: usize) -> Result<Vec<f64>, StatsError> {
let d2 = second_difference_matrix(n_basis)?;
let rows = n_basis - 2;
let mut p = vec![0.0; n_basis * n_basis];
for i in 0..n_basis {
for j in i..n_basis {
let mut acc = 0.0;
for r in 0..rows {
acc += d2[r * n_basis + i] * d2[r * n_basis + j];
}
p[i * n_basis + j] = acc;
if i != j {
p[j * n_basis + i] = acc;
}
}
}
Ok(p)
}
fn add_scaled_penalty(gram: &mut [f64], penalty: &[f64], n_basis: usize, lambda: f64) {
if lambda == 0.0 {
return;
}
for i in 0..n_basis * n_basis {
gram[i] += lambda * penalty[i];
}
}
fn roughness_basis_solve(
basis: &[f64],
nrows: usize,
n_basis: usize,
y: &[f64],
lambda: f64,
gram: &mut [f64],
rhs: &mut [f64],
) -> Result<Vec<f64>, StatsError> {
if gram.len() < n_basis * n_basis || rhs.len() < n_basis {
return Err(StatsError::Backend("GAM workspace too small".into()));
}
let penalty = second_difference_penalty(n_basis)?;
form_xtx(basis, nrows, n_basis, gram);
add_scaled_penalty(gram, &penalty, n_basis, lambda);
for c in 0..n_basis {
let mut s = 0.0;
let col = &basis[c * nrows..(c + 1) * nrows];
for r in 0..nrows {
s += col[r] * y[r];
}
rhs[c] = s;
}
let Some(inv) = invert_square(&gram[..n_basis * n_basis], n_basis) else {
return Err(StatsError::Backend("GAM: singular B'B+λP".into()));
};
let mut beta = vec![0.0; n_basis];
for i in 0..n_basis {
let mut s = 0.0;
for j in 0..n_basis {
s += inv[i * n_basis + j] * rhs[j];
}
beta[i] = s;
}
Ok(beta)
}
fn roughness_edf(
basis: &[f64],
nrows: usize,
n_basis: usize,
lambda: f64,
gram: &mut [f64],
) -> Result<f64, StatsError> {
let penalty = second_difference_penalty(n_basis)?;
form_xtx(basis, nrows, n_basis, gram);
let xtx = gram[..n_basis * n_basis].to_vec();
let mut penalized = xtx.clone();
add_scaled_penalty(&mut penalized, &penalty, n_basis, lambda);
let Some(inv) = invert_square(&penalized, n_basis) else {
return Err(StatsError::Backend("GAM: singular B'B+λP for EDF".into()));
};
let mut edf = 0.0;
for i in 0..n_basis {
let mut s = 0.0;
for j in 0..n_basis {
s += inv[i * n_basis + j] * xtx[j * n_basis + i];
}
edf += s;
}
Ok(edf)
}
const GCV_LAMBDA_GRID: [f64; 25] = [
1e-6, 3e-6, 1e-5, 3e-5, 1e-4, 3e-4, 1e-3, 3e-3, 1e-2, 3e-2, 0.1, 0.3, 1.0, 3.0, 10.0, 30.0,
100.0, 300.0, 1e3, 3e3, 1e4, 3e4, 1e5, 3e5, 1e6,
];
fn select_lambda_gcv(
basis: &[f64],
nrows: usize,
n_basis: usize,
y: &[f64],
gram: &mut [f64],
rhs: &mut [f64],
) -> Result<f64, StatsError> {
let mut best_lambda = GCV_LAMBDA_GRID[0];
let mut best_gcv = f64::INFINITY;
for &lambda in &GCV_LAMBDA_GRID {
let beta = roughness_basis_solve(basis, nrows, n_basis, y, lambda, gram, rhs)?;
let mut rss = 0.0;
for r in 0..nrows {
let mut pred = 0.0;
for b in 0..n_basis {
pred += basis[b * nrows + r] * beta[b];
}
let e = y[r] - pred;
rss += e * e;
}
let edf = roughness_edf(basis, nrows, n_basis, lambda, gram)?;
let denom = (nrows as f64 - edf).max(1e-8);
let gcv = (nrows as f64) * rss / (denom * denom);
if gcv < best_gcv {
best_gcv = gcv;
best_lambda = lambda;
}
}
Ok(best_lambda)
}
#[cfg(test)]
#[allow(clippy::float_cmp)]
mod tests {
use super::*;
use crate::faer_backend::FaerBackend;
#[test]
fn edf_approx_matches_finite_difference_operator_trace() {
fn fit_for(y: &[f64], x: &[f64], nrows: usize, specs: &[SmoothSpec]) -> GamFit {
let mut ws = GamWorkspace::default();
fit_gam(
x,
nrows,
1,
y,
specs,
&GamOptions { max_iter: 5000, tol: 1e-12 },
&FaerBackend,
&mut ws,
)
.unwrap()
}
let nrows = 60usize;
let x: Vec<f64> = linspace(nrows, 0.0, 1.0);
let y: Vec<f64> =
x.iter().enumerate().map(|(i, &v)| (3.0 * v).sin() + 0.05 * (i % 7) as f64).collect();
for n_basis in [6usize, 10] {
for lambda in [0.01f64, 1.0, 25.0] {
let specs = [SmoothSpec::new(0, n_basis, lambda)];
let base = fit_for(&y, &x, nrows, &specs);
let h = 1e-6;
let mut trace = 0.0;
for i in 0..nrows {
let mut up = y.clone();
up[i] += h;
let mut down = y.clone();
down[i] -= h;
let fu = fit_for(&up, &x, nrows, &specs);
let fd = fit_for(&down, &x, nrows, &specs);
trace += (fu.fitted[i] - fd.fitted[i]) / (2.0 * h);
}
assert!(
(base.edf_approx - trace).abs() < 1e-4,
"n_basis={n_basis} lambda={lambda}: edf_approx={} but measured tr(H)={trace}",
base.edf_approx
);
}
}
}
fn linspace(n: usize, a: f64, b: f64) -> Vec<f64> {
(0..n).map(|i| a + (b - a) * (i as f64) / (n - 1) as f64).collect()
}
fn colmajor_from_cols(cols: &[Vec<f64>]) -> (Vec<f64>, usize, usize) {
let nrows = cols[0].len();
let ncols = cols.len();
let mut x = vec![0.0; nrows * ncols];
for (c, col) in cols.iter().enumerate() {
x[c * nrows..(c + 1) * nrows].copy_from_slice(col);
}
(x, nrows, ncols)
}
#[test]
fn expand_bspline_partition_of_unity() {
let x = linspace(50, -1.0, 1.0);
let (basis, knots) = expand_bspline(&x, 8, None).unwrap();
assert_eq!(knots.len(), 8 + CUBIC_ORDER);
for r in 0..x.len() {
let mut s = 0.0;
for b in 0..8 {
s += basis[b * x.len() + r];
}
assert!((s - 1.0).abs() < 1e-10, "row {r} sum={s}");
}
}
#[test]
fn fit_gam_recovers_additive_signal() {
let n = 300usize;
let x1 = linspace(n, 0.0, 1.0);
let x2: Vec<f64> = (0..n).map(|i| (i as f64 / n as f64) * 2.0 - 1.0).collect();
let y: Vec<f64> = (0..n)
.map(|i| 2.0 + (2.0 * std::f64::consts::PI * x1[i]).sin() + 0.5 * x2[i] * x2[i])
.collect();
let (x, nrows, ncols) = colmajor_from_cols(&[x1, x2]);
let specs = [
SmoothSpec::new(0, 10, 0.1).with_variable(VariableId::from_raw(0)),
SmoothSpec::new(1, 10, 0.1).with_variable(VariableId::from_raw(1)),
];
let backend = FaerBackend;
let mut ws = GamWorkspace::default();
let fit = fit_gam(&x, nrows, ncols, &y, &specs, &GamOptions::default(), &backend, &mut ws)
.unwrap();
assert!(fit.converged, "iterations={}", fit.iterations);
let ss_res: f64 = fit.residuals.iter().map(|e| e * e).sum();
let y_bar = mean(&y);
let ss_tot: f64 = y
.iter()
.map(|yi| {
let d = yi - y_bar;
d * d
})
.sum();
let r2 = 1.0 - ss_res / ss_tot;
assert!(r2 > 0.95, "R²={r2}");
assert!(fit.edf_approx > 1.0);
assert_eq!(fit.diagnostics.backend, "gam");
assert_eq!(fit.smooths.len(), 2);
}
#[test]
fn predict_row_matches_predict_gam_bit_for_bit() {
let n = 200usize;
let x1 = linspace(n, 0.0, 1.0);
let x2: Vec<f64> = (0..n).map(|i| ((i * 7 + 3) % n) as f64 / n as f64 - 0.5).collect();
let y: Vec<f64> = (0..n)
.map(|i| 1.0 + (3.0 * x1[i]).sin() + x2[i] * x2[i] + 0.01 * (i % 5) as f64)
.collect();
let (x, nrows, ncols) = colmajor_from_cols(&[x1.clone(), x2.clone()]);
let specs = [SmoothSpec::new(0, 8, 0.5), SmoothSpec::new(1, 6, 1.0)];
let mut ws = GamWorkspace::default();
let fit =
fit_gam(&x, nrows, ncols, &y, &specs, &GamOptions::default(), &FaerBackend, &mut ws)
.unwrap();
for &(a, b) in
&[(0.0, -0.5), (0.31, 0.12), (1.0, 0.49), (-0.2, 0.0), (1.3, -0.7), (0.777, 0.123)]
{
let row = [a, b];
let batch = predict_gam(&fit, &row, 1, 2).unwrap()[0];
let single = fit.predict_row(&row).unwrap();
assert!(
batch.to_bits() == single.to_bits(),
"predict_row diverged at ({a},{b}): batch={batch:?} single={single:?}"
);
let additive = fit.intercept
+ fit.smooth_partial(0, a).unwrap()
+ fit.smooth_partial(1, b).unwrap();
assert!(
(additive - single).abs() <= 1e-12 * single.abs().max(1.0),
"smooth_partial decomposition diverged at ({a},{b})"
);
}
assert_eq!(fit.smooth_for_raw_col(0), Some(0));
assert_eq!(fit.smooth_for_raw_col(1), Some(1));
assert_eq!(fit.smooth_for_raw_col(2), None);
}
#[test]
fn predict_row_matches_training_fitted_values() {
let n = 150usize;
let x1 = linspace(n, -2.0, 2.0);
let x2: Vec<f64> = (0..n).map(|i| ((i * 13 + 1) % n) as f64 / n as f64).collect();
let y: Vec<f64> = (0..n).map(|i| 0.3 * x1[i] - 0.8 * x2[i] + (x1[i] * 1.7).cos()).collect();
let (x, nrows, ncols) = colmajor_from_cols(&[x1.clone(), x2.clone()]);
let specs = [SmoothSpec::new(0, 7, 0.2), SmoothSpec::new(1, 5, 0.7)];
let mut ws = GamWorkspace::default();
let fit =
fit_gam(&x, nrows, ncols, &y, &specs, &GamOptions::default(), &FaerBackend, &mut ws)
.unwrap();
for r in 0..n {
let row = [x1[r], x2[r]];
let pred = fit.predict_row(&row).unwrap();
assert!(
pred.to_bits() == fit.fitted[r].to_bits(),
"row {r}: predict_row={pred:?} fitted={:?}",
fit.fitted[r]
);
}
}
#[test]
fn high_lambda_smooth_approaches_linear_null_space() {
let n = 80usize;
let x1 = linspace(n, -1.0, 1.0);
let y: Vec<f64> = x1.iter().map(|&v| 3.0 + 0.01 * v).collect();
let (x, nrows, ncols) = colmajor_from_cols(&[x1]);
let specs = [SmoothSpec::new(0, 6, 1e6)];
let backend = FaerBackend;
let mut ws = GamWorkspace::default();
let fit = fit_gam(&x, nrows, ncols, &y, &specs, &GamOptions::default(), &backend, &mut ws)
.unwrap();
assert!((fit.intercept - 3.0).abs() < 0.05);
let max_abs_smooth: f64 =
fit.fitted.iter().map(|&f| (f - fit.intercept).abs()).fold(0.0, f64::max);
assert!(max_abs_smooth < 0.05, "max_abs_smooth={max_abs_smooth}");
}
#[test]
fn linear_signal_has_near_zero_second_difference_penalty() {
for k in [4usize, 6, 10] {
let p = second_difference_penalty(k).unwrap();
let beta: Vec<f64> = (0..k).map(|i| 2.0 + 0.75 * i as f64).collect();
let mut quad = 0.0;
for i in 0..k {
for j in 0..k {
quad += beta[i] * p[i * k + j] * beta[j];
}
}
assert!(quad.abs() < 1e-12, "K={k} β'Pβ={quad}");
}
let n = 120usize;
let x1 = linspace(n, -1.0, 1.0);
let y: Vec<f64> = x1.iter().map(|&v| 1.5 + 2.0 * v).collect();
let (x, nrows, ncols) = colmajor_from_cols(&[x1.clone()]);
let specs = [SmoothSpec::new(0, 8, 1e-4)];
let mut ws = GamWorkspace::default();
let fit =
fit_gam(&x, nrows, ncols, &y, &specs, &GamOptions::default(), &FaerBackend, &mut ws)
.unwrap();
let mut max_d2 = 0.0_f64;
for r in 1..n - 1 {
let d2 = fit.fitted[r - 1] - 2.0 * fit.fitted[r] + fit.fitted[r + 1];
max_d2 = max_d2.max(d2.abs());
}
assert!(max_d2 < 1e-4, "max discrete curvature={max_d2}");
}
#[test]
fn increasing_lambda_monotonically_reduces_edf() {
let n = 100usize;
let x1 = linspace(n, 0.0, 1.0);
let y: Vec<f64> =
x1.iter().map(|&v| (2.0 * std::f64::consts::PI * v).sin() + 0.05 * v).collect();
let (x, nrows, ncols) = colmajor_from_cols(&[x1]);
let mut ws = GamWorkspace::default();
let mut prev = f64::INFINITY;
for &lambda in &[0.01, 0.1, 1.0, 10.0, 100.0, 1e4] {
let specs = [SmoothSpec::new(0, 10, lambda)];
let fit = fit_gam(
&x,
nrows,
ncols,
&y,
&specs,
&GamOptions::default(),
&FaerBackend,
&mut ws,
)
.unwrap();
assert!(
fit.edf_approx <= prev + 1e-9,
"edf rose with λ={lambda}: {} > {prev}",
fit.edf_approx
);
prev = fit.edf_approx;
}
}
#[test]
fn roughness_penalty_beats_identity_ridge_on_curved_signal() {
let n = 200usize;
let x1 = linspace(n, 0.0, 1.0);
let mut y = Vec::with_capacity(n);
for (i, &v) in x1.iter().enumerate() {
let noise = 0.15 * (((i * 17) % 10) as f64 / 10.0 - 0.5);
y.push((2.0 * std::f64::consts::PI * v).sin() + noise);
}
let (basis, _) = expand_bspline(&x1, 12, None).unwrap();
let mut gram = vec![0.0; 12 * 12];
let mut rhs = vec![0.0; 12];
let beta_r = roughness_basis_solve(&basis, n, 12, &y, 1.0, &mut gram, &mut rhs).unwrap();
form_xtx(&basis, n, 12, &mut gram);
for c in 0..12 {
gram[c * 12 + c] += 1.0;
}
for c in 0..12 {
let mut s = 0.0;
for r in 0..n {
s += basis[c * n + r] * y[r];
}
rhs[c] = s;
}
let inv = invert_square(&gram[..144], 12).unwrap();
let mut beta_i = [0.0; 12];
for i in 0..12 {
let mut s = 0.0;
for j in 0..12 {
s += inv[i * 12 + j] * rhs[j];
}
beta_i[i] = s;
}
let mut rss_r = 0.0;
let mut rss_i = 0.0;
let mut curv_err_r = 0.0;
let mut curv_err_i = 0.0;
for r in 0..n {
let truth = (2.0 * std::f64::consts::PI * x1[r]).sin();
let mut pr = 0.0;
let mut pi = 0.0;
for b in 0..12 {
pr += basis[b * n + r] * beta_r[b];
pi += basis[b * n + r] * beta_i[b];
}
rss_r += (pr - truth) * (pr - truth);
rss_i += (pi - truth) * (pi - truth);
curv_err_r += (pr - truth).abs();
curv_err_i += (pi - truth).abs();
}
assert!(rss_r < rss_i, "roughness RSS={rss_r} should beat identity ridge RSS={rss_i}");
assert!(curv_err_r < curv_err_i);
}
#[test]
fn second_difference_penalty_matches_direct_d2t_d2() {
for k in [4usize, 6, 8, 12] {
let p = second_difference_penalty(k).unwrap();
let d2 = second_difference_matrix(k).unwrap();
let rows = k - 2;
for i in 0..k {
for j in 0..k {
let mut acc = 0.0;
for r in 0..rows {
acc += d2[r * k + i] * d2[r * k + j];
}
assert!(
(p[i * k + j] - acc).abs() < 1e-14,
"P mismatch at ({i},{j}) for K={k}"
);
}
}
}
}
#[test]
fn intercept_remains_unpenalized_under_large_lambda() {
let n = 60usize;
let x1 = linspace(n, 0.0, 1.0);
let y: Vec<f64> =
x1.iter().map(|&v| 5.0 + 0.2 * (2.0 * std::f64::consts::PI * v).sin()).collect();
let (x, nrows, ncols) = colmajor_from_cols(&[x1]);
let specs = [SmoothSpec::new(0, 8, 1e8)];
let mut ws = GamWorkspace::default();
let fit =
fit_gam(&x, nrows, ncols, &y, &specs, &GamOptions::default(), &FaerBackend, &mut ws)
.unwrap();
assert!((fit.intercept - 5.0).abs() < 0.15, "intercept={}", fit.intercept);
}
#[test]
fn auto_lambda_gcv_selects_finite_penalty() {
let n = 100usize;
let x1 = linspace(n, 0.0, 1.0);
let y: Vec<f64> = x1
.iter()
.enumerate()
.map(|(i, &v)| (2.0 * std::f64::consts::PI * v).sin() + 0.05 * (i as f64).sin())
.collect();
let (x, nrows, ncols) = colmajor_from_cols(&[x1]);
let specs = [SmoothSpec::auto(0, 10)];
let mut ws = GamWorkspace::default();
let fit =
fit_gam(&x, nrows, ncols, &y, &specs, &GamOptions::default(), &FaerBackend, &mut ws)
.unwrap();
assert!(fit.smooths[0].lambda.is_finite() && fit.smooths[0].lambda > 0.0);
assert!(fit.converged);
}
#[test]
fn predict_matches_fitted_on_training() {
let n = 100usize;
let x1 = linspace(n, 0.0, 1.0);
let y: Vec<f64> = x1.iter().map(|&v| (std::f64::consts::PI * v).sin()).collect();
let (x, nrows, ncols) = colmajor_from_cols(&[x1]);
let specs = [SmoothSpec::new(0, 8, 0.01).with_variable(VariableId::from_raw(0))];
let backend = FaerBackend;
let mut ws = GamWorkspace::default();
let fit = fit_gam(&x, nrows, ncols, &y, &specs, &GamOptions::default(), &backend, &mut ws)
.unwrap();
let pred = predict_gam(&fit, &x, nrows, ncols).unwrap();
for r in 0..nrows {
assert!(
(pred[r] - fit.fitted[r]).abs() < 1e-6,
"row {r}: pred={} fit={}",
pred[r],
fit.fitted[r]
);
}
assert_eq!(fitted_from_gam(&fit).len(), nrows);
}
#[test]
fn predict_single_row_is_not_just_intercept() {
let n = 80usize;
let x1 = linspace(n, 0.0, 1.0);
let y: Vec<f64> = x1.iter().map(|&v| (2.0 * std::f64::consts::PI * v).sin()).collect();
let (x, nrows, ncols) = colmajor_from_cols(&[x1.clone()]);
let specs = [SmoothSpec::new(0, 8, 0.01)];
let backend = FaerBackend;
let mut ws = GamWorkspace::default();
let fit = fit_gam(&x, nrows, ncols, &y, &specs, &GamOptions::default(), &backend, &mut ws)
.unwrap();
let idx = n / 4;
let x_one = vec![x1[idx]];
let pred = predict_gam(&fit, &x_one, 1, 1).unwrap();
assert!(
(pred[0] - fit.fitted[idx]).abs() < 1e-5,
"single-row pred={} train_fit={} intercept={}",
pred[0],
fit.fitted[idx],
fit.intercept
);
assert!((pred[0] - fit.intercept).abs() > 0.5);
}
#[test]
fn compile_additive_design_sets_smooth_links() {
let n = 20usize;
let x1 = linspace(n, 0.0, 1.0);
let (x, nrows, ncols) = colmajor_from_cols(&[x1]);
let specs = [SmoothSpec::new(0, 6, 0.5).with_variable(VariableId::from_raw(7))];
let (matrix, map, smooths) = compile_additive_design(&x, nrows, ncols, &specs).unwrap();
assert_eq!(matrix.len(), nrows * (1 + 6));
assert_eq!(smooths.len(), 1);
assert_eq!(smooths[0].column_range, (1, 7));
assert_eq!(smooths[0].n_basis, 6);
assert_eq!(map.get(0).unwrap().smooth_idx, None);
assert_eq!(map.get(1).unwrap().smooth_idx, Some(0));
assert_eq!(map.get(6).unwrap().smooth_idx, Some(0));
assert_eq!(map.get(1).unwrap().role, DesignColumnRole::Covariate(VariableId::from_raw(7)));
}
#[test]
fn shape_errors() {
let x = vec![1.0, 2.0, 3.0];
assert!(expand_bspline(&x, 3, None).is_err());
assert!(expand_bspline(&[], 6, None).is_err());
let specs = [SmoothSpec::new(0, 6, -1.0)];
let backend = FaerBackend;
let mut ws = GamWorkspace::default();
let err =
fit_gam(&x, 3, 1, &[1.0, 2.0, 3.0], &specs, &GamOptions::default(), &backend, &mut ws);
assert!(err.is_err());
let specs = [SmoothSpec::new(1, 6, 0.1)];
let err =
fit_gam(&x, 3, 1, &[1.0, 2.0, 3.0], &specs, &GamOptions::default(), &backend, &mut ws);
assert!(err.is_err());
}
#[test]
fn with_smooth_provenance_on_compiled_design() {
use crate::design::CompiledDesign;
let t = vec![0.0_f64, 1.0];
let y = vec![1.0_f64, 2.0];
let design = CompiledDesign::linear_adjustment(&t, &[], &y, &[]).unwrap();
assert!(design.smooths.is_empty());
let smooth = RecordedSmooth {
variable: Some(VariableId::from_raw(0)),
basis: BasisKind::CubicBSpline,
knots: Arc::from(vec![0.0; 10]),
lambda: 0.1,
column_range: (1, 2),
n_basis: 1,
};
let design = design.with_smooth_provenance(vec![smooth]);
assert_eq!(design.smooths.len(), 1);
assert_eq!(design.columns.get(1).and_then(|c| c.smooth_idx), Some(0));
}
}