use crate::basis::bspline::bspline_basis;
use crate::error::FdarError;
use crate::linalg::{cholesky_factor, cholesky_forward_back};
use crate::matrix::FdMatrix;
use crate::smooth_basis::bspline_penalty_matrix;
use super::{BoostFosrResult, BoostingConfig};
pub(crate) fn build_bspline_design_at(x_vals: &[f64], nbasis: usize, order: usize) -> Vec<f64> {
let nknots = nbasis.saturating_sub(order).max(2);
bspline_basis(x_vals, nknots, order)
}
fn phi_t_times_vec(phi: &[f64], u_col: &[f64], n: usize, k: usize) -> Vec<f64> {
(0..k)
.map(|kk| {
let phi_col = &phi[kk * n..(kk + 1) * n]; phi_col.iter().zip(u_col).map(|(&p, &u)| p * u).sum::<f64>()
})
.collect()
}
pub(crate) fn pointwise_r_squared(data: &FdMatrix, fitted: &FdMatrix) -> Vec<f64> {
let (n, m) = data.shape();
(0..m)
.map(|t| {
let mean_t: f64 = (0..n).map(|i| data[(i, t)]).sum::<f64>() / n as f64;
let ss_tot: f64 = (0..n).map(|i| (data[(i, t)] - mean_t).powi(2)).sum();
let ss_res: f64 = (0..n)
.map(|i| (data[(i, t)] - fitted[(i, t)]).powi(2))
.sum();
if ss_tot > 1e-15 {
1.0 - ss_res / ss_tot
} else {
0.0
}
})
.collect()
}
pub(crate) struct BaseLearner {
pub phi: Vec<f64>,
pub l: Vec<f64>,
pub k: usize,
}
pub(crate) fn boost_fosr_one_step(
residuals: &FdMatrix,
learners: &[BaseLearner],
) -> Result<(usize, FdMatrix, FdMatrix), FdarError> {
let (n, m_t) = residuals.shape();
let mut best_rss = f64::INFINITY;
let mut best_j = 0usize;
let mut best_fitted: Option<FdMatrix> = None;
let mut best_coefs: Option<FdMatrix> = None;
for (j, learner) in learners.iter().enumerate() {
let k = learner.k;
let phi = &learner.phi;
let l = &learner.l;
let mut fitted_j = FdMatrix::zeros(n, m_t);
let mut coefs_j = FdMatrix::zeros(k, m_t);
for t in 0..m_t {
let u_col = residuals.column(t); let rhs = phi_t_times_vec(phi, u_col, n, k); let c_t = cholesky_forward_back(l, &rhs, k);
for i in 0..n {
let mut val = 0.0;
for kk in 0..k {
val += phi[i + kk * n] * c_t[kk]; }
fitted_j[(i, t)] = val;
}
for kk in 0..k {
coefs_j[(kk, t)] = c_t[kk];
}
}
let mut rss = 0.0f64;
for t in 0..m_t {
let u_col = residuals.column(t);
for i in 0..n {
let diff = u_col[i] - fitted_j[(i, t)];
rss += diff * diff;
}
}
if rss < best_rss {
best_rss = rss;
best_j = j;
best_fitted = Some(fitted_j);
best_coefs = Some(coefs_j);
}
}
match (best_fitted, best_coefs) {
(Some(f), Some(c)) => Ok((best_j, f, c)),
_ => Err(FdarError::ComputationFailed {
operation: "boost_fosr_one_step",
detail:
"no valid base-learner found; all Cholesky solves failed or no learners provided"
.to_string(),
}),
}
}
#[must_use = "expensive computation whose result should not be discarded"]
pub fn boost_fosr(
data: &FdMatrix,
predictors: &FdMatrix,
argvals: &[f64],
config: &BoostingConfig,
) -> Result<BoostFosrResult, FdarError> {
let (n, m_t) = data.shape();
let p = predictors.ncols();
if n < 3 || m_t == 0 || predictors.nrows() != n {
return Err(FdarError::InvalidDimension {
parameter: "data/predictors",
expected: format!("n >= 3, m > 0, predictors.nrows() == n (n={n})"),
actual: format!("n={n}, m={m_t}, predictors.nrows()={}", predictors.nrows()),
});
}
if argvals.len() != m_t {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: format!("length == data.ncols() = {m_t}"),
actual: format!("length = {}", argvals.len()),
});
}
if p == 0 {
return Err(FdarError::InvalidDimension {
parameter: "predictors",
expected: "at least 1 predictor column".to_string(),
actual: "0 columns".to_string(),
});
}
if config.mstop == 0 {
return Err(FdarError::InvalidParameter {
parameter: "mstop",
message: "must be >= 1".to_string(),
});
}
if config.nu <= 0.0 || config.nu > 1.0 {
return Err(FdarError::InvalidParameter {
parameter: "nu",
message: format!("must be in (0, 1], got {}", config.nu),
});
}
if config.nbasis < 4 {
return Err(FdarError::InvalidParameter {
parameter: "nbasis",
message: format!("must be >= 4, got {}", config.nbasis),
});
}
if config.lambda <= 0.0 {
return Err(FdarError::InvalidParameter {
parameter: "lambda",
message: format!("must be > 0, got {}", config.lambda),
});
}
if config.order < 1 {
return Err(FdarError::InvalidParameter {
parameter: "order",
message: format!("must be >= 1, got {}", config.order),
});
}
if config.nbasis > n {
return Err(FdarError::InvalidParameter {
parameter: "nbasis",
message: format!(
"must be <= n (got nbasis={}, n={n}) — prevents degenerate Cholesky",
config.nbasis
),
});
}
let nbasis = config.nbasis;
let order = config.order;
let lambda = config.lambda;
let nu = config.nu;
let intercept: Vec<f64> = (0..m_t)
.map(|t| (0..n).map(|i| data[(i, t)]).sum::<f64>() / n as f64)
.collect();
let mut f_current = FdMatrix::zeros(n, m_t);
for t in 0..m_t {
for i in 0..n {
f_current[(i, t)] = intercept[t];
}
}
let mut beta = FdMatrix::zeros(p, m_t);
let mut learners: Vec<BaseLearner> = Vec::with_capacity(p);
for j in 0..p {
let x_col = predictors.column(j);
let phi = build_bspline_design_at(x_col, nbasis, order);
let actual_k = phi.len() / n;
let x_argvals_for_penalty: Vec<f64> = {
let x_min = x_col.iter().copied().fold(f64::INFINITY, f64::min);
let x_max = x_col.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let n_pts = (actual_k + 1).max(20);
(0..n_pts)
.map(|i| x_min + (x_max - x_min) * i as f64 / (n_pts - 1).max(1) as f64)
.collect()
};
let r_col_major =
bspline_penalty_matrix(&x_argvals_for_penalty, actual_k, order, config.lfd_order);
let k = actual_k;
let mut a = vec![0.0f64; k * k];
for row in 0..k {
for col in row..k {
let mut s = 0.0f64;
for i in 0..n {
s += phi[i + row * n] * phi[i + col * n];
}
a[row * k + col] = s;
a[col * k + row] = s;
}
}
for col in 0..k {
for row in 0..k {
a[row * k + col] += lambda * r_col_major[row + col * k];
}
}
for i in 0..k {
a[i * k + i] += 1e-10;
}
let l = cholesky_factor(&a, k).map_err(|e| FdarError::ComputationFailed {
operation: "boost_fosr base-learner Cholesky",
detail: format!("predictor j={j}: {e:?} — try increasing lambda or decreasing nbasis"),
})?;
learners.push(BaseLearner { phi, l, k });
}
let mut selected_learners = Vec::with_capacity(config.mstop);
let mut gcv_path = Vec::with_capacity(config.mstop);
for _iter in 0..config.mstop {
let mut residuals = FdMatrix::zeros(n, m_t);
for t in 0..m_t {
let y_col = data.column(t);
let f_col = f_current.column(t);
let r_col = residuals.column_mut(t);
for i in 0..n {
r_col[i] = y_col[i] - f_col[i];
}
}
let rss_before: f64 = (0..m_t)
.flat_map(|t| residuals.column(t).iter().map(|&r| r * r))
.sum();
gcv_path.push(rss_before);
let (j_star, fitted_star, _coefs_star) = boost_fosr_one_step(&residuals, &learners)?;
selected_learners.push(j_star);
for t in 0..m_t {
let f_col = f_current.column_mut(t);
let h_col = fitted_star.column(t);
for i in 0..n {
f_col[i] += nu * h_col[i];
}
}
for t in 0..m_t {
let h_col = fitted_star.column(t);
let mean_effect: f64 = h_col.iter().sum::<f64>() / n as f64;
beta[(j_star, t)] += nu * mean_effect;
}
}
let mut final_residuals = FdMatrix::zeros(n, m_t);
for t in 0..m_t {
let y_col = data.column(t);
let f_col = f_current.column(t);
let r_col = final_residuals.column_mut(t);
for i in 0..n {
r_col[i] = y_col[i] - f_col[i];
}
}
let r_squared_t = pointwise_r_squared(data, &f_current);
let r_squared = r_squared_t.iter().sum::<f64>() / m_t as f64;
Ok(BoostFosrResult {
intercept,
beta,
fitted: f_current,
residuals: final_residuals,
r_squared_t,
r_squared,
mstop: config.mstop,
nu,
selected_learners,
gcv_path,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::matrix::FdMatrix;
use crate::test_helpers::uniform_grid;
fn make_synthetic(n: usize, m: usize) -> (FdMatrix, FdMatrix, Vec<f64>) {
let argvals = uniform_grid(m);
let x1: Vec<f64> = (0..n).map(|i| i as f64 / (n - 1) as f64).collect();
let x2: Vec<f64> = vec![0.5f64; n];
let mut pred_data = vec![0.0f64; n * 2];
pred_data[..n].copy_from_slice(&x1);
pred_data[n..].copy_from_slice(&x2);
let predictors = FdMatrix::from_column_major(pred_data, n, 2).unwrap();
let mut y_data = vec![0.0f64; n * m];
for (t, &tv) in argvals.iter().enumerate() {
let beta_t = (std::f64::consts::PI * tv).sin(); for i in 0..n {
let noise = 0.03 * ((i + t) as f64 * 1.7321).sin(); y_data[i + t * n] = x1[i] * beta_t + noise;
}
}
let data = FdMatrix::from_column_major(y_data, n, m).unwrap();
(data, predictors, argvals)
}
fn default_config() -> BoostingConfig {
BoostingConfig {
mstop: 30,
nu: 0.1,
nbasis: 8,
order: 4,
lfd_order: 2,
lambda: 0.1,
ncomp_x: 3,
seed: 42,
}
}
#[test]
fn boost_fosr_reduces_rss_monotonically() {
let (data, predictors, argvals) = make_synthetic(25, 20);
let config = default_config();
let result = boost_fosr(&data, &predictors, &argvals, &config).unwrap();
let gcv = &result.gcv_path;
assert_eq!(
gcv.len(),
config.mstop,
"gcv_path should have mstop entries"
);
for i in 1..gcv.len() {
assert!(
gcv[i] <= gcv[i - 1] + 1e-8,
"RSS should be non-increasing: gcv[{i}]={} > gcv[{}]={}",
gcv[i],
i - 1,
gcv[i - 1]
);
}
}
#[test]
fn boost_fosr_recovers_known_beta() {
let (data, predictors, argvals) = make_synthetic(30, 20);
let config = BoostingConfig {
mstop: 50,
nu: 0.1,
nbasis: 8,
order: 4,
lfd_order: 2,
lambda: 0.05,
ncomp_x: 3,
seed: 42,
};
let result = boost_fosr(&data, &predictors, &argvals, &config).unwrap();
assert!(
result.r_squared > 0.8,
"Expected R² > 0.8 on synthetic data, got {}",
result.r_squared
);
let (n, m) = data.shape();
let mut ss_res = 0.0f64;
let mut ss_tot = 0.0f64;
for t in 0..m {
let mean_t: f64 = (0..n).map(|i| data[(i, t)]).sum::<f64>() / n as f64;
for i in 0..n {
ss_res += (data[(i, t)] - result.fitted[(i, t)]).powi(2);
ss_tot += (data[(i, t)] - mean_t).powi(2);
}
}
let global_r2 = if ss_tot > 1e-15 {
1.0 - ss_res / ss_tot
} else {
0.0
};
assert!(
global_r2 > 0.8,
"Global R² should exceed 0.8, got {global_r2}"
);
}
#[test]
fn boost_fosr_r_squared_in_range() {
let (data, predictors, argvals) = make_synthetic(20, 15);
let config = default_config();
let result = boost_fosr(&data, &predictors, &argvals, &config).unwrap();
assert!(
result.r_squared >= -0.05,
"r_squared below -0.05: {}",
result.r_squared
);
assert!(
result.r_squared <= 1.0 + 1e-8,
"r_squared above 1: {}",
result.r_squared
);
for (t, &r2t) in result.r_squared_t.iter().enumerate() {
assert!(r2t >= -0.05, "r_squared_t[{t}] = {r2t} < -0.05");
assert!(r2t <= 1.0 + 1e-8, "r_squared_t[{t}] = {r2t} > 1");
}
}
#[test]
fn boost_fosr_selected_learners_valid() {
let (data, predictors, argvals) = make_synthetic(20, 15);
let config = default_config();
let result = boost_fosr(&data, &predictors, &argvals, &config).unwrap();
let p = predictors.ncols();
assert_eq!(
result.selected_learners.len(),
config.mstop,
"selected_learners must have mstop entries"
);
for (iter, &j) in result.selected_learners.iter().enumerate() {
assert!(j < p, "selected_learners[{iter}] = {j} >= p={p}");
}
}
#[test]
fn boost_fosr_errors_on_dimension_mismatch() {
let argvals: Vec<f64> = (0..15).map(|i| i as f64 / 14.0).collect();
let data = FdMatrix::zeros(20, 15);
let predictors = FdMatrix::zeros(10, 2);
let config = default_config();
let err = boost_fosr(&data, &predictors, &argvals, &config).unwrap_err();
assert!(
matches!(err, FdarError::InvalidDimension { .. }),
"Expected InvalidDimension, got {err:?}"
);
}
#[test]
fn boost_fosr_errors_on_invalid_params() {
let (data, predictors, argvals) = make_synthetic(20, 15);
let mut config = default_config();
config.mstop = 0;
let err = boost_fosr(&data, &predictors, &argvals, &config).unwrap_err();
assert!(
matches!(err, FdarError::InvalidParameter { .. }),
"mstop=0 should return InvalidParameter"
);
let mut config = default_config();
config.nu = -0.1;
let err = boost_fosr(&data, &predictors, &argvals, &config).unwrap_err();
assert!(
matches!(err, FdarError::InvalidParameter { .. }),
"nu<0 should return InvalidParameter"
);
let mut config = default_config();
config.lambda = 0.0;
let err = boost_fosr(&data, &predictors, &argvals, &config).unwrap_err();
assert!(
matches!(err, FdarError::InvalidParameter { .. }),
"lambda=0 should return InvalidParameter"
);
let mut config = default_config();
config.nbasis = 2;
let err = boost_fosr(&data, &predictors, &argvals, &config).unwrap_err();
assert!(
matches!(err, FdarError::InvalidParameter { .. }),
"nbasis<4 should return InvalidParameter"
);
}
}