use super::boost_fosr::{build_bspline_design_at, BaseLearner};
use super::{BoostingConfig, GamlssResult};
use crate::error::FdarError;
use crate::helpers::NUMERICAL_EPS;
use crate::linalg::cholesky_factor;
use crate::matrix::FdMatrix;
use crate::smooth_basis::bspline_penalty_matrix;
fn mu_neg_gradient(y: &FdMatrix, mu: &FdMatrix, sigma: &FdMatrix, sigma_floor: f64) -> FdMatrix {
let (n, m) = y.shape();
let floor2 = (sigma_floor * sigma_floor).max(NUMERICAL_EPS);
let mut u = FdMatrix::zeros(n, m);
for t in 0..m {
let y_col = y.column(t);
let mu_col = mu.column(t);
let sig_col = sigma.column(t);
let u_col = u.column_mut(t);
for i in 0..n {
let s2 = (sig_col[i] * sig_col[i]).max(floor2);
u_col[i] = (y_col[i] - mu_col[i]) / s2;
}
}
u
}
fn sigma_neg_gradient(y: &FdMatrix, mu: &FdMatrix, sigma: &FdMatrix) -> FdMatrix {
let (n, m) = y.shape();
let mut u = FdMatrix::zeros(n, m);
for t in 0..m {
let y_col = y.column(t);
let mu_col = mu.column(t);
let sig_col = sigma.column(t);
let u_col = u.column_mut(t);
for i in 0..n {
let s = sig_col[i].max(NUMERICAL_EPS);
let r2 = (y_col[i] - mu_col[i]).powi(2);
u_col[i] = -1.0 + r2 / (s * s);
}
}
u
}
fn gaussian_loglik(y: &FdMatrix, mu: &FdMatrix, sigma: &FdMatrix) -> f64 {
let (n, m) = y.shape();
let mut ll = 0.0f64;
for t in 0..m {
let y_col = y.column(t);
let mu_col = mu.column(t);
let sig_col = sigma.column(t);
for i in 0..n {
let s = sig_col[i].max(NUMERICAL_EPS);
let r = y_col[i] - mu_col[i];
ll += -s.ln() - r * r / (2.0 * s * s);
}
}
ll
}
fn build_learners(
predictors: &FdMatrix,
n: usize,
config: &BoostingConfig,
) -> Result<Vec<BaseLearner>, FdarError> {
let p = predictors.ncols();
let nbasis = config.nbasis;
let order = config.order;
let lambda = config.lambda;
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: "gamlss_fosr base-learner Cholesky",
detail: format!("predictor j={j}: {e:?} — try increasing lambda or decreasing nbasis"),
})?;
learners.push(BaseLearner { phi, l, k });
}
Ok(learners)
}
#[must_use = "expensive computation whose result should not be discarded"]
pub fn gamlss_fosr(
data: &FdMatrix,
predictors: &FdMatrix,
argvals: &[f64],
config: &BoostingConfig,
) -> Result<GamlssResult, 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 nu = config.nu;
let learners = build_learners(predictors, n, config)?;
let mu_intercept: Vec<f64> = (0..m_t)
.map(|t| (0..n).map(|i| data[(i, t)]).sum::<f64>() / n as f64)
.collect();
let mut mu_fitted = FdMatrix::zeros(n, m_t);
for t in 0..m_t {
let col = mu_fitted.column_mut(t);
for i in 0..n {
col[i] = mu_intercept[t];
}
}
let sigma_marg = {
let total = (n * m_t) as f64;
let mut ss = 0.0f64;
for t in 0..m_t {
for i in 0..n {
let d = data[(i, t)] - mu_intercept[t];
ss += d * d;
}
}
(ss / total).sqrt().max(1e-6)
};
let eta0 = sigma_marg.ln();
let sigma_intercept: Vec<f64> = vec![sigma_marg; m_t];
let mut eta_sigma = FdMatrix::zeros(n, m_t); let mut sigma_fitted = FdMatrix::zeros(n, m_t);
for t in 0..m_t {
let eta_col = eta_sigma.column_mut(t);
for i in 0..n {
eta_col[i] = eta0;
}
}
for t in 0..m_t {
let col = sigma_fitted.column_mut(t);
for i in 0..n {
col[i] = sigma_marg;
}
}
let mut mu_beta = FdMatrix::zeros(p, m_t);
let mut sigma_beta = FdMatrix::zeros(p, m_t);
let scale_ref = {
let total = (n * m_t) as f64;
let mut mean = 0.0f64;
for t in 0..m_t {
for i in 0..n {
mean += data[(i, t)];
}
}
mean /= total;
let mut var = 0.0f64;
for t in 0..m_t {
for i in 0..n {
let d = data[(i, t)] - mean;
var += d * d;
}
}
(var / total).sqrt().max(1e-6)
};
let log_sigma_lo = (scale_ref * 1e-2).ln();
let log_sigma_hi = (scale_ref * 1e2).ln();
let mut ll_path: Vec<f64> = Vec::with_capacity(config.mstop);
for _iter in 0..config.mstop {
let u_mu = mu_neg_gradient(data, &mu_fitted, &sigma_fitted, sigma_marg);
let (j_mu, fitted_mu, coefs_mu) = super::boost_fosr::boost_fosr_one_step(&u_mu, &learners)?;
for t in 0..m_t {
let mu_col = mu_fitted.column_mut(t);
let h_col = fitted_mu.column(t);
for i in 0..n {
mu_col[i] += nu * h_col[i];
}
}
let n_f64 = n as f64;
for t in 0..m_t {
let h_col = fitted_mu.column(t);
let mean_effect: f64 = h_col.iter().sum::<f64>() / n_f64;
mu_beta[(j_mu, t)] += nu * mean_effect;
}
let _ = coefs_mu;
let u_sigma = sigma_neg_gradient(data, &mu_fitted, &sigma_fitted);
let mut u_sigma_mean = vec![0.0f64; m_t];
for t in 0..m_t {
let col = u_sigma.column(t);
u_sigma_mean[t] = col.iter().sum::<f64>() / n_f64;
}
let mut u_sigma_centered = u_sigma.clone();
for t in 0..m_t {
let col = u_sigma_centered.column_mut(t);
let mt = u_sigma_mean[t];
for v in col.iter_mut() {
*v -= mt;
}
}
let (j_sigma, fitted_sigma, coefs_sigma) =
super::boost_fosr::boost_fosr_one_step(&u_sigma_centered, &learners)?;
for t in 0..m_t {
let eta_col = eta_sigma.column_mut(t);
let h_col = fitted_sigma.column(t);
let sig_col = sigma_fitted.column_mut(t);
let intercept_t = u_sigma_mean[t];
for i in 0..n {
eta_col[i] =
(eta_col[i] + nu * (intercept_t + h_col[i])).clamp(log_sigma_lo, log_sigma_hi);
sig_col[i] = eta_col[i].exp();
}
}
for t in 0..m_t {
let h_col = fitted_sigma.column(t);
let mean_effect: f64 = h_col.iter().sum::<f64>() / n_f64;
sigma_beta[(j_sigma, t)] += nu * mean_effect;
}
let _ = coefs_sigma;
let ll = gaussian_loglik(data, &mu_fitted, &sigma_fitted);
ll_path.push(ll);
}
let log_likelihood = ll_path.last().copied().unwrap_or(f64::NEG_INFINITY);
Ok(GamlssResult {
mu_fitted,
sigma_fitted,
mu_intercept,
sigma_intercept,
mu_beta,
sigma_beta,
log_likelihood,
ll_path,
mstop: config.mstop,
nu,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::matrix::FdMatrix;
use crate::test_helpers::uniform_grid;
fn default_config() -> BoostingConfig {
BoostingConfig {
mstop: 30,
nu: 0.1,
nbasis: 8,
order: 4,
lfd_order: 2,
lambda: 0.5,
ncomp_x: 3,
seed: 42,
}
}
fn make_heterosc_dataset(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).max(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_idx, &tv) in argvals.iter().enumerate() {
let sigma_t = if tv > 0.5 { 0.5 } else { 0.1 };
let mu_scale = (std::f64::consts::PI * tv).sin();
for i in 0..n {
let noise = sigma_t * ((i as f64 * 1.7321 + t_idx as f64 * 0.9137).sin());
y_data[i + t_idx * n] = x1[i] * mu_scale + noise;
}
}
let data = FdMatrix::from_column_major(y_data, n, m).unwrap();
(data, predictors, argvals)
}
#[test]
fn gamlss_sigma_positive_everywhere() {
let (data, predictors, argvals) = make_heterosc_dataset(40, 20);
let config = default_config();
let result = gamlss_fosr(&data, &predictors, &argvals, &config).unwrap();
let (n, m) = result.sigma_fitted.shape();
for i in 0..n {
for t in 0..m {
let s = result.sigma_fitted[(i, t)];
assert!(
s > 0.0,
"sigma_fitted[({i},{t})] = {s} is not positive — positivity guard failed"
);
}
}
}
#[test]
fn gamlss_loglik_non_decreasing() {
let (data, predictors, argvals) = make_heterosc_dataset(40, 20);
let config = BoostingConfig {
mstop: 50,
nu: 0.05,
..default_config()
};
let result = gamlss_fosr(&data, &predictors, &argvals, &config).unwrap();
assert_eq!(
result.ll_path.len(),
config.mstop,
"ll_path length must equal mstop"
);
assert!(
result.ll_path.iter().all(|v| v.is_finite()),
"ll_path must be finite"
);
let last_ll = *result.ll_path.last().unwrap();
assert!(
(last_ll - result.log_likelihood).abs() < 1e-10,
"log_likelihood must equal last ll_path entry"
);
let first_ll = result.ll_path[0];
assert!(
last_ll >= first_ll - 1.0, "Final LL {last_ll} should be >= initial LL {first_ll} (minus tolerance) on signal data"
);
}
#[test]
fn gamlss_recovers_mean_and_scale() {
let (data, predictors, argvals) = make_heterosc_dataset(40, 20);
let config = BoostingConfig {
mstop: 50,
nu: 0.1,
lambda: 0.3,
..default_config()
};
let result = gamlss_fosr(&data, &predictors, &argvals, &config).unwrap();
let n = data.nrows();
let m = data.ncols();
let m_half = m / 2;
let mut sigma_left = 0.0f64;
let mut sigma_right = 0.0f64;
for i in 0..n {
for t in 0..m_half {
sigma_left += result.sigma_fitted[(i, t)];
}
for t in m_half..m {
sigma_right += result.sigma_fitted[(i, t)];
}
}
sigma_left /= (n * m_half) as f64;
sigma_right /= (n * (m - m_half)) as f64;
assert!(
sigma_right > sigma_left,
"Expected sigma_right ({sigma_right:.4}) > sigma_left ({sigma_left:.4}) — \
heteroscedasticity not captured"
);
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.mu_fitted[(i, t)]).powi(2);
ss_tot += (data[(i, t)] - mean_t).powi(2);
}
}
let r2 = if ss_tot > 1e-15 {
1.0 - ss_res / ss_tot
} else {
0.0
};
assert!(
r2 > 0.0,
"μ̂ should improve over the intercept (R² = {r2:.4})"
);
}
#[test]
fn gamlss_result_shapes() {
let n = 30;
let m = 15;
let (data, predictors, argvals) = make_heterosc_dataset(n, m);
let config = default_config();
let result = gamlss_fosr(&data, &predictors, &argvals, &config).unwrap();
assert_eq!(result.mu_fitted.shape(), (n, m), "mu_fitted shape");
assert_eq!(result.sigma_fitted.shape(), (n, m), "sigma_fitted shape");
assert_eq!(
result.mu_beta.shape(),
(predictors.ncols(), m),
"mu_beta shape"
);
assert_eq!(
result.sigma_beta.shape(),
(predictors.ncols(), m),
"sigma_beta shape"
);
assert_eq!(result.mu_intercept.len(), m, "mu_intercept length");
assert_eq!(result.sigma_intercept.len(), m, "sigma_intercept length");
assert_eq!(result.ll_path.len(), config.mstop, "ll_path length");
assert_eq!(result.mstop, config.mstop, "mstop field");
assert!((result.nu - config.nu).abs() < 1e-15, "nu field");
}
#[test]
fn gamlss_errors_on_dimension_mismatch() {
let argvals: Vec<f64> = uniform_grid(15);
let data = FdMatrix::zeros(20, 15);
let predictors = FdMatrix::zeros(10, 2);
let config = default_config();
let err = gamlss_fosr(&data, &predictors, &argvals, &config).unwrap_err();
assert!(
matches!(err, FdarError::InvalidDimension { .. }),
"Expected InvalidDimension on nrows mismatch, got {err:?}"
);
}
#[test]
fn gamlss_errors_on_invalid_params() {
let (data, predictors, argvals) = make_heterosc_dataset(30, 15);
let mut config = default_config();
config.mstop = 0;
let err = gamlss_fosr(&data, &predictors, &argvals, &config).unwrap_err();
assert!(
matches!(err, FdarError::InvalidParameter { .. }),
"mstop=0 → InvalidParameter, got {err:?}"
);
let mut config = default_config();
config.nu = 0.0;
let err = gamlss_fosr(&data, &predictors, &argvals, &config).unwrap_err();
assert!(
matches!(err, FdarError::InvalidParameter { .. }),
"nu=0 → InvalidParameter, got {err:?}"
);
let mut config = default_config();
config.lambda = 0.0;
let err = gamlss_fosr(&data, &predictors, &argvals, &config).unwrap_err();
assert!(
matches!(err, FdarError::InvalidParameter { .. }),
"lambda=0 → InvalidParameter, got {err:?}"
);
}
}