use crate::error::FdarError;
use crate::function_on_scalar::penalty_matrix;
use crate::helpers::simpsons_weights;
use crate::linalg::{cholesky_factor, cholesky_forward_back, cholesky_solve};
use crate::matrix::FdMatrix;
use nalgebra::DMatrix;
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum PeerPenalty {
Ridge,
Difference { order: usize },
Decree(Vec<f64>, usize),
}
impl Default for PeerPenalty {
fn default() -> Self {
PeerPenalty::Difference { order: 2 }
}
}
#[derive(Debug, Clone, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum LambdaChoice {
Fixed(f64),
#[default]
Gcv,
Reml,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum LambdaMethod {
Fixed,
Gcv,
Reml,
}
#[derive(Debug, Clone, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PeerConfig {
pub penalty: PeerPenalty,
pub lambda: LambdaChoice,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[must_use = "expensive computation whose result should not be discarded"]
pub struct PeerResult {
pub beta: Vec<f64>,
pub intercept: f64,
pub w_bar: Vec<f64>,
pub fitted_values: Vec<f64>,
pub effective_df: f64,
pub lambda: f64,
pub penalty_type: PeerPenalty,
pub gcv: Option<f64>,
pub lambda_method: LambdaMethod,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[must_use = "expensive computation whose result should not be discarded"]
pub struct LpeerResult {
pub beta: Vec<f64>,
pub intercept: f64,
pub w_bar: Vec<f64>,
pub fitted_values: Vec<f64>,
pub sigma2_subject: f64,
pub sigma2_resid: f64,
pub n_subjects: usize,
pub lambda: f64,
pub penalty_type: PeerPenalty,
pub gcv: Option<f64>,
pub lambda_method: LambdaMethod,
}
pub fn peer(
data: &FdMatrix,
y: &[f64],
argvals: &[f64],
config: &PeerConfig,
) -> Result<PeerResult, FdarError> {
let (n, m) = data.shape();
if n < 2 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "at least 2 observations".to_string(),
actual: format!("{n} rows"),
});
}
if m < 3 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "at least 3 evaluation points (m >= 3)".to_string(),
actual: format!("{m} columns"),
});
}
if argvals.len() != m {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: format!("{m}"),
actual: format!("{}", argvals.len()),
});
}
if y.len() != n {
return Err(FdarError::InvalidDimension {
parameter: "y",
expected: format!("{n}"),
actual: format!("{}", y.len()),
});
}
if y.iter().any(|v| !v.is_finite()) {
return Err(FdarError::InvalidParameter {
parameter: "y",
message: "response contains non-finite values (NaN/Inf)".to_string(),
});
}
if argvals.iter().any(|v| !v.is_finite()) {
return Err(FdarError::InvalidParameter {
parameter: "argvals",
message: "argvals contains non-finite values (NaN/Inf)".to_string(),
});
}
if argvals.windows(2).any(|w| w[1] <= w[0]) {
return Err(FdarError::InvalidParameter {
parameter: "argvals",
message: "argvals must be strictly increasing".to_string(),
});
}
let w = simpsons_weights(argvals);
let mut wmat = FdMatrix::zeros(n, m);
for i in 0..n {
for j in 0..m {
wmat[(i, j)] = data[(i, j)] * w[j];
}
}
let y_bar: f64 = y.iter().sum::<f64>() / n as f64;
let yc: Vec<f64> = y.iter().map(|&yi| yi - y_bar).collect();
let w_bar: Vec<f64> = (0..m)
.map(|j| (0..n).map(|i| wmat[(i, j)]).sum::<f64>() / n as f64)
.collect();
let mut wc = FdMatrix::zeros(n, m);
for i in 0..n {
for j in 0..m {
wc[(i, j)] = wmat[(i, j)] - w_bar[j];
}
}
let q = build_q(m, &config.penalty)?;
let mut wtw = vec![0.0_f64; m * m];
for j in 0..m {
for k in j..m {
let s: f64 = (0..n).map(|i| wc[(i, j)] * wc[(i, k)]).sum();
wtw[j * m + k] = s;
wtw[k * m + j] = s;
}
}
let wty: Vec<f64> = (0..m)
.map(|j| (0..n).map(|i| wc[(i, j)] * yc[i]).sum())
.collect();
let (lambda, gcv_score, lambda_method) = match &config.lambda {
LambdaChoice::Fixed(lam) => (*lam, None, LambdaMethod::Fixed),
LambdaChoice::Gcv => {
let (lam, g) = select_lambda_gcv_peer(&wc, &yc, &wtw, &wty, &q, m, n);
(lam, Some(g), LambdaMethod::Gcv)
}
LambdaChoice::Reml => {
let lam = select_lambda_reml_peer(&wc, &yc, &q, m, n);
(lam, None, LambdaMethod::Reml)
}
};
let mut a = vec![0.0_f64; m * m];
for i in 0..m * m {
a[i] = wtw[i] + lambda * q[i];
}
let beta = cholesky_solve(&a, &wty, m)?;
if beta.iter().any(|v| !v.is_finite()) {
return Err(FdarError::ComputationFailed {
operation: "peer",
detail: "non-finite coefficient (singular penalized system)".into(),
});
}
let effective_df = compute_peer_trace_hat(&wtw, &q, lambda, m, n);
let fitted_values: Vec<f64> = (0..n)
.map(|i| y_bar + (0..m).map(|j| wc[(i, j)] * beta[j]).sum::<f64>())
.collect();
Ok(PeerResult {
beta,
intercept: y_bar,
w_bar,
fitted_values,
effective_df,
lambda,
penalty_type: config.penalty.clone(),
gcv: gcv_score,
lambda_method,
})
}
pub fn lpeer(
data: &FdMatrix,
y: &[f64],
argvals: &[f64],
subject_map: &[usize],
config: &PeerConfig,
) -> Result<LpeerResult, FdarError> {
let (n, m) = data.shape();
if n < 2 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "at least 2 observations".to_string(),
actual: format!("{n} rows"),
});
}
if m < 3 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "at least 3 evaluation points (m >= 3)".to_string(),
actual: format!("{m} columns"),
});
}
if argvals.len() != m {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: format!("{m}"),
actual: format!("{}", argvals.len()),
});
}
if y.len() != n {
return Err(FdarError::InvalidDimension {
parameter: "y",
expected: format!("{n}"),
actual: format!("{}", y.len()),
});
}
if subject_map.len() != n {
return Err(FdarError::InvalidDimension {
parameter: "subject_map",
expected: format!("{n}"),
actual: format!("{}", subject_map.len()),
});
}
if y.iter().any(|v| !v.is_finite()) {
return Err(FdarError::InvalidParameter {
parameter: "y",
message: "response contains non-finite values (NaN/Inf)".to_string(),
});
}
if argvals.iter().any(|v| !v.is_finite()) {
return Err(FdarError::InvalidParameter {
parameter: "argvals",
message: "argvals contains non-finite values (NaN/Inf)".to_string(),
});
}
if argvals.windows(2).any(|w| w[1] <= w[0]) {
return Err(FdarError::InvalidParameter {
parameter: "argvals",
message: "argvals must be strictly increasing".to_string(),
});
}
let (sm_dense, n_subjects) = crate::famm::build_subject_map(subject_map);
if n_subjects < 2 {
return Err(FdarError::InvalidParameter {
parameter: "subject_map",
message: "at least 2 distinct subjects required for lpeer".to_string(),
});
}
let w = simpsons_weights(argvals);
let mut wmat = FdMatrix::zeros(n, m);
for i in 0..n {
for j in 0..m {
wmat[(i, j)] = data[(i, j)] * w[j];
}
}
let y_bar: f64 = y.iter().sum::<f64>() / n as f64;
let yc: Vec<f64> = y.iter().map(|&yi| yi - y_bar).collect();
let w_bar: Vec<f64> = (0..m)
.map(|j| (0..n).map(|i| wmat[(i, j)]).sum::<f64>() / n as f64)
.collect();
let mut wc = FdMatrix::zeros(n, m);
for i in 0..n {
for j in 0..m {
wc[(i, j)] = wmat[(i, j)] - w_bar[j];
}
}
let q = build_q(m, &config.penalty)?;
let mut wtw = vec![0.0_f64; m * m];
for j in 0..m {
for k in j..m {
let s: f64 = (0..n).map(|i| wc[(i, j)] * wc[(i, k)]).sum();
wtw[j * m + k] = s;
wtw[k * m + j] = s;
}
}
let wty: Vec<f64> = (0..m)
.map(|j| (0..n).map(|i| wc[(i, j)] * yc[i]).sum())
.collect();
let (lambda, gcv_score, lambda_method) = match &config.lambda {
LambdaChoice::Fixed(lam) => (*lam, None, LambdaMethod::Fixed),
LambdaChoice::Gcv => {
let (lam, g) = select_lambda_gcv_peer(&wc, &yc, &wtw, &wty, &q, m, n);
(lam, Some(g), LambdaMethod::Gcv)
}
LambdaChoice::Reml => {
let lam = select_lambda_reml_peer(&wc, &yc, &q, m, n);
(lam, None, LambdaMethod::Reml)
}
};
let ncomp = (n - 1).min(m).min(10);
let fpca = crate::regression::fdata_to_pc_1d(data, ncomp, argvals)?;
let scores = &fpca.scores;
let result =
crate::famm::fit_scalar_mixed_model(&yc, &sm_dense, n_subjects, Some(scores), ncomp);
if result.gamma.iter().any(|v| !v.is_finite()) {
return Err(FdarError::ComputationFailed {
operation: "lpeer",
detail: "non-finite gamma from mixed model".into(),
});
}
let su = result.sigma2_u;
let se = result.sigma2_eps;
let mut gcnt = vec![0usize; n_subjects];
for &g in &sm_dense {
gcnt[g] += 1;
}
let sigma_inv = |v: &[f64]| -> Vec<f64> {
let mut gsum = vec![0.0_f64; n_subjects];
for i in 0..n {
gsum[sm_dense[i]] += v[i];
}
(0..n)
.map(|i| {
let g = sm_dense[i];
let shrink = su / (se + gcnt[g] as f64 * su);
(v[i] - shrink * gsum[g]) / se
})
.collect::<Vec<f64>>()
};
let mut sinv_s = vec![0.0_f64; n * ncomp];
for k in 0..ncomp {
let col: Vec<f64> = (0..n).map(|i| scores[(i, k)]).collect();
let sc = sigma_inv(&col);
for i in 0..n {
sinv_s[i * ncomp + k] = sc[i];
}
}
let sinv_yc = sigma_inv(&yc);
let mut a_mat = vec![0.0_f64; ncomp * ncomp];
let mut b_vec = vec![0.0_f64; ncomp];
for k in 0..ncomp {
for l in 0..ncomp {
a_mat[k * ncomp + l] = (0..n).map(|i| scores[(i, k)] * sinv_s[i * ncomp + l]).sum();
}
b_vec[k] = (0..n).map(|i| scores[(i, k)] * sinv_yc[i]).sum();
}
let mut q_phi = vec![0.0_f64; m * ncomp];
for i in 0..m {
for l in 0..ncomp {
q_phi[i * ncomp + l] = (0..m)
.map(|jj| q[i * m + jj] * fpca.rotation[(jj, l)])
.sum();
}
}
for k in 0..ncomp {
for l in 0..ncomp {
let p_kl: f64 = (0..m)
.map(|i| fpca.rotation[(i, k)] * q_phi[i * ncomp + l])
.sum();
a_mat[k * ncomp + l] += lambda * p_kl;
}
}
let gamma = cholesky_solve(&a_mat, &b_vec, ncomp).unwrap_or_else(|_| result.gamma.clone());
let beta: Vec<f64> = (0..m)
.map(|j| {
(0..ncomp.min(gamma.len()))
.map(|k| gamma[k] * fpca.rotation[(j, k)])
.sum()
})
.collect();
if beta.iter().any(|v| !v.is_finite()) {
return Err(FdarError::ComputationFailed {
operation: "lpeer",
detail: "non-finite beta after back-projection".into(),
});
}
let base = y_bar - w_bar.iter().zip(&beta).map(|(wb, b)| wb * b).sum::<f64>();
let fitted_values: Vec<f64> = (0..n)
.map(|i| base + (0..m).map(|j| data[(i, j)] * w[j] * beta[j]).sum::<f64>())
.collect();
Ok(LpeerResult {
beta,
intercept: y_bar,
w_bar,
fitted_values,
sigma2_subject: result.sigma2_u,
sigma2_resid: result.sigma2_eps,
n_subjects,
lambda,
penalty_type: config.penalty.clone(),
gcv: gcv_score,
lambda_method,
})
}
impl LpeerResult {
pub fn predict(&self, new_data: &FdMatrix, argvals: &[f64]) -> Result<Vec<f64>, FdarError> {
peer_predict_core(&self.beta, self.intercept, &self.w_bar, new_data, argvals)
}
}
fn build_q(m: usize, penalty: &PeerPenalty) -> Result<Vec<f64>, FdarError> {
match penalty {
PeerPenalty::Ridge => {
let mut q = vec![0.0_f64; m * m];
for i in 0..m {
q[i * m + i] = 1.0;
}
Ok(q)
}
PeerPenalty::Difference { order: 2 } => Ok(penalty_matrix(m)),
PeerPenalty::Difference { order } => Err(FdarError::InvalidParameter {
parameter: "penalty",
message: format!(
"Difference order {order} unsupported; only order 2 is available in this release"
),
}),
PeerPenalty::Decree(q_raw, p_q) => {
if *p_q != m || q_raw.len() != m * m {
return Err(FdarError::InvalidDimension {
parameter: "penalty",
expected: format!("{m}x{m} ({} elems)", m * m),
actual: format!("{p_q}x{p_q} ({} elems)", q_raw.len()),
});
}
Ok(q_raw.clone())
}
}
}
fn compute_peer_trace_hat(wtw: &[f64], q: &[f64], lambda: f64, m: usize, n: usize) -> f64 {
let mut a = vec![0.0_f64; m * m];
for i in 0..m * m {
a[i] = wtw[i] + lambda * q[i];
}
let Ok(l) = cholesky_factor(&a, m) else {
return m as f64; };
let mut trace = 0.0_f64;
for j in 0..m {
let col: Vec<f64> = (0..m).map(|i| wtw[i * m + j]).collect();
let z = cholesky_forward_back(&l, &col, m);
trace += z[j];
}
trace.min(n as f64)
}
fn gcv_lambda_grid() -> Vec<f64> {
(0..40)
.map(|i| 10.0_f64.powf(-6.0 + 10.0 * i as f64 / 39.0))
.collect()
}
fn select_lambda_gcv_peer(
wc: &FdMatrix,
yc: &[f64],
wtw: &[f64],
wty: &[f64],
q: &[f64],
m: usize,
n: usize,
) -> (f64, f64) {
let grid = gcv_lambda_grid();
let mut best_lam = grid[0];
let mut best_gcv = f64::INFINITY;
for &lam in &grid {
let mut a = vec![0.0_f64; m * m];
for i in 0..m * m {
a[i] = wtw[i] + lam * q[i];
}
let Ok(beta) = cholesky_solve(&a, wty, m) else {
continue;
};
let rss: f64 = (0..n)
.map(|i| {
let yhat: f64 = (0..m).map(|j| wc[(i, j)] * beta[j]).sum();
(yc[i] - yhat).powi(2)
})
.sum();
let trh = compute_peer_trace_hat(wtw, q, lam, m, n);
let denom = n as f64 - trh;
if denom <= 0.0 {
continue;
}
let gcv = n as f64 * rss / (denom * denom);
if gcv < best_gcv {
best_gcv = gcv;
best_lam = lam;
}
}
(best_lam, best_gcv)
}
fn select_lambda_reml_peer(wc: &FdMatrix, yc: &[f64], q: &[f64], m: usize, n: usize) -> f64 {
let q_mat = DMatrix::from_row_slice(m, m, q);
let eigen = q_mat.symmetric_eigen();
let mut idx_sorted: Vec<usize> = (0..m).collect();
idx_sorted.sort_by(|&a, &b| {
eigen.eigenvalues[a]
.partial_cmp(&eigen.eigenvalues[b])
.unwrap_or(std::cmp::Ordering::Equal)
});
let max_ev = idx_sorted
.iter()
.map(|&i| eigen.eigenvalues[i].abs())
.fold(0.0_f64, f64::max);
let tol = 1e-8 * max_ev.max(1.0);
let null_idx: Vec<usize> = idx_sorted
.iter()
.copied()
.filter(|&i| eigen.eigenvalues[i].abs() < tol)
.collect();
let range_idx: Vec<usize> = idx_sorted
.iter()
.copied()
.filter(|&i| eigen.eigenvalues[i].abs() >= tol)
.collect();
let s = null_idx.len();
let r = range_idx.len();
if r == 0 {
return 1e-4; }
let mut z_null = vec![0.0_f64; n * s];
let mut z_range = vec![0.0_f64; n * r];
for i in 0..n {
for (col, &ev_idx) in null_idx.iter().enumerate() {
let mut val = 0.0;
for j in 0..m {
val += wc[(i, j)] * eigen.eigenvectors[(j, ev_idx)];
}
z_null[i * s + col] = val;
}
for (col, &ev_idx) in range_idx.iter().enumerate() {
let mut val = 0.0;
for j in 0..m {
val += wc[(i, j)] * eigen.eigenvectors[(j, ev_idx)];
}
z_range[i * r + col] = val;
}
}
let mut ztz_range = vec![0.0_f64; r * r];
for a in 0..r {
for b in a..r {
let s_val: f64 = (0..n)
.map(|i| z_range[i * r + a] * z_range[i * r + b])
.sum();
ztz_range[a * r + b] = s_val;
ztz_range[b * r + a] = s_val;
}
}
let y_mean = yc.iter().sum::<f64>() / n as f64;
let y_var = yc.iter().map(|&v| (v - y_mean).powi(2)).sum::<f64>() / (n - 1).max(1) as f64;
let mut sigma2_e = y_var.max(1e-12);
let mut sigma2_u = (sigma2_e * 0.1).max(1e-12);
let mut alpha = vec![0.0_f64; s];
if s > 0 {
let mut ztz_null = vec![0.0_f64; s * s];
for a in 0..s {
for b in a..s {
let sv: f64 = (0..n).map(|i| z_null[i * s + a] * z_null[i * s + b]).sum();
ztz_null[a * s + b] = sv;
ztz_null[b * s + a] = sv;
}
}
for diag in 0..s {
ztz_null[diag * s + diag] += 1e-10;
}
let zty_null: Vec<f64> = (0..s)
.map(|col| (0..n).map(|i| z_null[i * s + col] * yc[i]).sum())
.collect();
if let Ok(a_init) = cholesky_solve(&ztz_null, &zty_null, s) {
alpha = a_init;
}
}
for _iter in 0..100 {
let su_old = sigma2_u;
let se_old = sigma2_e;
let r_alpha: Vec<f64> = if s > 0 {
(0..n)
.map(|i| {
let za: f64 = (0..s).map(|col| z_null[i * s + col] * alpha[col]).sum();
yc[i] - za
})
.collect()
} else {
yc.to_vec()
};
let mut big_m = vec![0.0_f64; r * r];
for idx in 0..r * r {
big_m[idx] = ztz_range[idx] / sigma2_e;
}
for diag in 0..r {
big_m[diag * r + diag] += 1.0 / sigma2_u;
}
let l_m = match cholesky_factor(&big_m, r) {
Ok(l) => l,
Err(_) => {
sigma2_u = su_old;
sigma2_e = se_old;
break;
}
};
let mut sigma_b = vec![0.0_f64; r * r];
let mut trace_sigma_b = 0.0_f64;
for col in 0..r {
let mut e_col = vec![0.0_f64; r];
e_col[col] = 1.0;
let sol = cholesky_forward_back(&l_m, &e_col, r);
for row in 0..r {
sigma_b[row * r + col] = sol[row];
}
trace_sigma_b += sol[col];
}
let ztr: Vec<f64> = (0..r)
.map(|col| (0..n).map(|i| z_range[i * r + col] * r_alpha[i]).sum())
.collect();
let b_hat: Vec<f64> = (0..r)
.map(|row| {
(0..r)
.map(|col| sigma_b[row * r + col] * ztr[col])
.sum::<f64>()
/ sigma2_e
})
.collect();
let b_sq: f64 = b_hat.iter().map(|&v| v * v).sum();
let sigma2_u_new = (b_sq + trace_sigma_b) / r as f64;
let resid: Vec<f64> = (0..n)
.map(|i| {
let zb: f64 = (0..r).map(|col| z_range[i * r + col] * b_hat[col]).sum();
r_alpha[i] - zb
})
.collect();
let tr_zsz: f64 = (0..r)
.map(|a| {
(0..r)
.map(|j| sigma_b[a * r + j] * ztz_range[j * r + a])
.sum::<f64>()
})
.sum();
let resid_sq: f64 = resid.iter().map(|&v| v * v).sum();
let sigma2_e_new = (resid_sq + tr_zsz) / n as f64;
let sigma2_u_c = sigma2_u_new.max(1e-12);
let sigma2_e_c = sigma2_e_new.max(1e-12);
if s > 0 {
let ratio = sigma2_e_c / sigma2_u_c;
let mut k_mat = ztz_range.clone();
for diag in 0..r {
k_mat[diag * r + diag] += ratio;
}
if let Ok(l_k) = cholesky_factor(&k_mat, r) {
let mut gls_lhs = vec![0.0_f64; s * s]; let mut gls_rhs = vec![0.0_f64; s];
let zr_yc: Vec<f64> = (0..r)
.map(|col| (0..n).map(|i| z_range[i * r + col] * yc[i]).sum())
.collect();
let kinv_zr_yc = cholesky_forward_back(&l_k, &zr_yc, r);
let sinv_yc: Vec<f64> = (0..n)
.map(|i| {
let zk: f64 = (0..r)
.map(|col| z_range[i * r + col] * kinv_zr_yc[col])
.sum();
(yc[i] - zk) / sigma2_e_c
})
.collect();
for col_null in 0..s {
let zr_zn: Vec<f64> = (0..r)
.map(|col| {
(0..n)
.map(|i| z_range[i * r + col] * z_null[i * s + col_null])
.sum()
})
.collect();
let kinv_zr_zn = cholesky_forward_back(&l_k, &zr_zn, r);
let sinv_zn: Vec<f64> = (0..n)
.map(|i| {
let zk: f64 = (0..r)
.map(|col| z_range[i * r + col] * kinv_zr_zn[col])
.sum();
(z_null[i * s + col_null] - zk) / sigma2_e_c
})
.collect();
for row_null in 0..s {
let v: f64 = (0..n).map(|i| z_null[i * s + row_null] * sinv_zn[i]).sum();
gls_lhs[row_null * s + col_null] = v;
}
let rhs_val: f64 = (0..n).map(|i| z_null[i * s + col_null] * sinv_yc[i]).sum();
gls_rhs[col_null] = rhs_val;
}
for diag in 0..s {
gls_lhs[diag * s + diag] += 1e-10;
}
if let Ok(alpha_new) = cholesky_solve(&gls_lhs, &gls_rhs, s) {
alpha = alpha_new;
}
}
}
sigma2_u = sigma2_u_c;
sigma2_e = sigma2_e_c;
let delta = (sigma2_u_new - su_old).abs() + (sigma2_e_new - se_old).abs();
if delta < 1e-8 * (su_old + se_old) {
break;
}
}
(sigma2_e / sigma2_u).max(1e-15)
}
fn peer_predict_core(
beta: &[f64],
intercept: f64,
w_bar: &[f64],
new_data: &FdMatrix,
argvals: &[f64],
) -> Result<Vec<f64>, FdarError> {
let (n_new, m_new) = new_data.shape();
let m = beta.len();
if m_new != m {
return Err(FdarError::InvalidDimension {
parameter: "new_data",
expected: format!("{m} columns (training grid length)"),
actual: format!("{m_new}"),
});
}
if argvals.len() != m {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: format!("{m}"),
actual: format!("{}", argvals.len()),
});
}
if argvals.windows(2).any(|w| w[1] <= w[0]) {
return Err(FdarError::InvalidParameter {
parameter: "argvals",
message: "argvals must be strictly increasing".to_string(),
});
}
debug_assert_eq!(w_bar.len(), m, "w_bar length must equal beta length");
let w = simpsons_weights(argvals);
let base: f64 = intercept - w_bar.iter().zip(beta).map(|(wb, b)| wb * b).sum::<f64>();
let preds: Vec<f64> = (0..n_new)
.map(|i| {
base + (0..m)
.map(|j| new_data[(i, j)] * w[j] * beta[j])
.sum::<f64>()
})
.collect();
Ok(preds)
}
impl PeerResult {
pub fn predict(&self, new_data: &FdMatrix, argvals: &[f64]) -> Result<Vec<f64>, FdarError> {
peer_predict_core(&self.beta, self.intercept, &self.w_bar, new_data, argvals)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::uniform_grid;
fn hash_unit(k: u64) -> f64 {
let mut z = k
.wrapping_add(0x9E37_79B9_7F4A_7C15)
.wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 30)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
((z >> 11) as f64 / (1_u64 << 53) as f64) * 2.0 - 1.0
}
fn make_fixture() -> (FdMatrix, Vec<f64>, Vec<f64>, Vec<f64>) {
let (n, m) = (200_usize, 40_usize);
let t = uniform_grid(m);
let true_beta: Vec<f64> = t
.iter()
.map(|&ti| (std::f64::consts::PI * ti).sin())
.collect();
let w = simpsons_weights(&t);
let mut data = FdMatrix::zeros(n, m);
let mut y = vec![0.0_f64; n];
for i in 0..n {
for j in 0..m {
let xi = hash_unit((i * m + j) as u64);
data[(i, j)] = xi;
y[i] += xi * true_beta[j] * w[j];
}
y[i] += 0.005 * hash_unit(1_000_000 + i as u64);
}
(data, y, t, true_beta)
}
#[test]
fn test_peer_difference_beta_recovery() {
let (data, y, t, true_beta) = make_fixture();
let config = PeerConfig {
penalty: PeerPenalty::Difference { order: 2 },
lambda: LambdaChoice::Fixed(1e-4),
};
let result = peer(&data, &y, &t, &config).expect("peer() should succeed");
let max_err = result
.beta
.iter()
.zip(true_beta.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0_f64, f64::max);
assert!(
max_err < 0.1,
"Difference beta recovery error: {max_err} >= 0.1"
);
assert!(
result.fitted_values.iter().all(|v| v.is_finite()),
"fitted_values contain non-finite values"
);
}
#[test]
fn test_peer_result_shape() {
let (data, y, t, _) = make_fixture();
let m = t.len();
let n = y.len();
let config = PeerConfig {
penalty: PeerPenalty::Difference { order: 2 },
lambda: LambdaChoice::Fixed(1e-4),
};
let result = peer(&data, &y, &t, &config).expect("peer() should succeed");
assert_eq!(result.beta.len(), m, "beta length should be m={m}");
assert_eq!(
result.fitted_values.len(),
n,
"fitted_values length should be n={n}"
);
assert!(
result.effective_df.is_finite() && result.effective_df > 0.0,
"effective_df={} must be finite and positive",
result.effective_df
);
assert!(
(result.lambda - 1e-4).abs() < 1e-15,
"lambda should be 1e-4, got {}",
result.lambda
);
assert_eq!(
result.penalty_type,
PeerPenalty::Difference { order: 2 },
"penalty_type should be Difference{{order:2}}"
);
let y_bar = y.iter().sum::<f64>() / n as f64;
assert!(
(result.intercept - y_bar).abs() < 1e-9,
"intercept={} should equal mean(y)={y_bar} within 1e-9",
result.intercept
);
}
#[test]
fn test_peer_ridge_fits() {
let (data, y, t, _) = make_fixture();
let m = t.len();
let config = PeerConfig {
penalty: PeerPenalty::Ridge,
lambda: LambdaChoice::Fixed(1e-4),
};
let result = peer(&data, &y, &t, &config).expect("peer() with Ridge should succeed");
assert_eq!(result.beta.len(), m);
assert!(
result.beta.iter().all(|v| v.is_finite()),
"Ridge beta contains non-finite values"
);
assert!(
result.fitted_values.iter().all(|v| v.is_finite()),
"Ridge fitted_values contain non-finite values"
);
}
#[test]
fn test_peer_decree_fits() {
let (data, y, t, _) = make_fixture();
let m = t.len();
let q_flat = penalty_matrix(m);
let config = PeerConfig {
penalty: PeerPenalty::Decree(q_flat, m),
lambda: LambdaChoice::Fixed(1e-4),
};
let result = peer(&data, &y, &t, &config).expect("peer() with Decree should succeed");
assert_eq!(result.beta.len(), m);
assert!(
result.beta.iter().all(|v| v.is_finite()),
"Decree beta contains non-finite values"
);
assert!(
result.fitted_values.iter().all(|v| v.is_finite()),
"Decree fitted_values contain non-finite values"
);
}
#[test]
fn test_peer_difference_order_rejected() {
let (data, y, t, _) = make_fixture();
let config = PeerConfig {
penalty: PeerPenalty::Difference { order: 3 },
lambda: LambdaChoice::Fixed(1e-4),
};
let err = peer(&data, &y, &t, &config).expect_err("order 3 should be rejected");
assert!(
matches!(err, FdarError::InvalidParameter { .. }),
"expected InvalidParameter, got {err:?}"
);
}
#[test]
fn test_peer_decree_distinct_from_roughness() {
let (data, y, t, _) = make_fixture();
let m = t.len();
let lambda = LambdaChoice::Fixed(1.0);
let config_diff = PeerConfig {
penalty: PeerPenalty::Difference { order: 2 },
lambda: lambda.clone(),
};
let res_diff = peer(&data, &y, &t, &config_diff).expect("Difference{2} fit should succeed");
let b = m / 2;
let mut q_partition = vec![0.0_f64; m * m];
for i in 0..m.saturating_sub(2) {
if i + 1 == b || i + 2 == b || i == b {
continue;
}
let coeffs = [(i, 1.0_f64), (i + 1, -2.0), (i + 2, 1.0)];
for &(r, cr) in &coeffs {
for &(c, cc) in &coeffs {
q_partition[r * m + c] += cr * cc;
}
}
}
for i in 0..m {
q_partition[i * m + i] += 1e-6;
}
let config_dec = PeerConfig {
penalty: PeerPenalty::Decree(q_partition, m),
lambda,
};
let res_dec =
peer(&data, &y, &t, &config_dec).expect("Decree partition fit should succeed");
assert!(
res_diff.beta.iter().all(|v| v.is_finite()),
"Difference beta contains non-finite"
);
assert!(
res_dec.beta.iter().all(|v| v.is_finite()),
"Decree beta contains non-finite"
);
let max_diff = res_diff
.beta
.iter()
.zip(res_dec.beta.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0_f64, f64::max);
assert!(
max_diff > 1e-3,
"Decree partition Q should yield a β(t) distinct from Difference{{2}} \
by > 1e-3; got max_diff={max_diff}"
);
}
#[test]
fn test_peer_decree_wrong_dim() {
let (data, y, t, _) = make_fixture();
let m = t.len();
let config = PeerConfig {
penalty: PeerPenalty::Decree(vec![0.0; (m - 1) * (m - 1)], m - 1),
lambda: LambdaChoice::Fixed(1.0),
};
let err = peer(&data, &y, &t, &config).expect_err("wrong-dim Decree should return Err");
assert!(
matches!(err, FdarError::InvalidDimension { .. }),
"expected InvalidDimension, got {err:?}"
);
}
#[test]
fn test_peer_argvals_mismatch() {
let (data, y, t, _) = make_fixture();
let m = t.len();
let argvals_bad: Vec<f64> = (0..=m).map(|i| i as f64 / m as f64).collect();
let config = PeerConfig::default();
let err = peer(&data, &y, &argvals_bad, &config)
.expect_err("mismatched argvals should return Err");
assert!(
matches!(err, FdarError::InvalidDimension { .. }),
"expected InvalidDimension, got {err:?}"
);
}
#[test]
fn test_peer_no_nan_all_families() {
let (data, y, t, _) = make_fixture();
let m = t.len();
let lambda = LambdaChoice::Fixed(1.0);
let config_ridge = PeerConfig {
penalty: PeerPenalty::Ridge,
lambda: lambda.clone(),
};
let res_ridge = peer(&data, &y, &t, &config_ridge).expect("Ridge should succeed");
assert!(
res_ridge.beta.iter().all(|v| v.is_finite()),
"Ridge beta contains NaN/Inf"
);
assert!(
res_ridge.fitted_values.iter().all(|v| v.is_finite()),
"Ridge fitted_values contains NaN/Inf"
);
assert!(
res_ridge.effective_df.is_finite(),
"Ridge effective_df is not finite"
);
let config_diff = PeerConfig {
penalty: PeerPenalty::Difference { order: 2 },
lambda: lambda.clone(),
};
let res_diff = peer(&data, &y, &t, &config_diff).expect("Difference{2} should succeed");
assert!(
res_diff.beta.iter().all(|v| v.is_finite()),
"Difference beta contains NaN/Inf"
);
assert!(
res_diff.fitted_values.iter().all(|v| v.is_finite()),
"Difference fitted_values contains NaN/Inf"
);
assert!(
res_diff.effective_df.is_finite(),
"Difference effective_df is not finite"
);
let q_flat = penalty_matrix(m);
let config_dec = PeerConfig {
penalty: PeerPenalty::Decree(q_flat, m),
lambda,
};
let res_dec = peer(&data, &y, &t, &config_dec).expect("Decree should succeed");
assert!(
res_dec.beta.iter().all(|v| v.is_finite()),
"Decree beta contains NaN/Inf"
);
assert!(
res_dec.fitted_values.iter().all(|v| v.is_finite()),
"Decree fitted_values contains NaN/Inf"
);
assert!(
res_dec.effective_df.is_finite(),
"Decree effective_df is not finite"
);
}
#[test]
fn test_peer_stores_w_bar_for_prediction() {
let (data, y, t, _) = make_fixture();
let (n, m) = data.shape();
let w = simpsons_weights(&t);
let config = PeerConfig {
penalty: PeerPenalty::Difference { order: 2 },
lambda: LambdaChoice::Fixed(1e-4),
};
let result = peer(&data, &y, &t, &config).expect("peer() should succeed");
assert_eq!(result.w_bar.len(), m, "w_bar length must equal m");
let base = result.intercept
- result
.w_bar
.iter()
.zip(&result.beta)
.map(|(wb, b)| wb * b)
.sum::<f64>();
for i in 0..n {
let pred = base
+ (0..m)
.map(|j| data[(i, j)] * w[j] * result.beta[j])
.sum::<f64>();
assert!(
(pred - result.fitted_values[i]).abs() < 1e-9,
"prediction reconstruction mismatch at row {i}: {pred} vs {}",
result.fitted_values[i]
);
}
}
#[test]
fn test_peer_rejects_non_monotonic_argvals() {
let (data, y, t, _) = make_fixture();
let mut bad = t.clone();
bad.swap(0, 1); let config = PeerConfig::default();
let res = peer(&data, &y, &bad, &config);
assert!(
matches!(res, Err(FdarError::InvalidParameter { .. })),
"non-monotonic argvals must be rejected, got {res:?}"
);
}
#[test]
fn test_peer_rejects_single_observation() {
let m = 5;
let t = uniform_grid(m);
let mut data = FdMatrix::zeros(1, m);
for j in 0..m {
data[(0, j)] = 1.0 + j as f64;
}
let y = vec![1.0];
let res = peer(&data, &y, &t, &PeerConfig::default());
assert!(
matches!(res, Err(FdarError::InvalidDimension { .. })),
"single observation must be rejected, got {res:?}"
);
}
#[test]
fn test_peer_rejects_non_finite_y() {
let (data, mut y, t, _) = make_fixture();
y[0] = f64::NAN;
let res = peer(&data, &y, &t, &PeerConfig::default());
assert!(
matches!(res, Err(FdarError::InvalidParameter { .. })),
"non-finite y must be rejected, got {res:?}"
);
}
#[test]
fn test_peer_gcv_deterministic() {
let (data, y, t, _) = make_fixture();
let config = PeerConfig {
penalty: PeerPenalty::Difference { order: 2 },
lambda: LambdaChoice::Gcv,
};
let r1 = peer(&data, &y, &t, &config).expect("first GCV call should succeed");
let r2 = peer(&data, &y, &t, &config).expect("second GCV call should succeed");
assert_eq!(
r1.lambda, r2.lambda,
"GCV lambda must be bit-exact across two runs: {} vs {}",
r1.lambda, r2.lambda
);
assert_eq!(r1.lambda_method, LambdaMethod::Gcv);
assert!(r1.gcv.is_some(), "GCV result must have gcv score");
}
#[test]
fn test_peer_gcv_recovers_beta() {
let (data, y, t, true_beta) = make_fixture();
let config = PeerConfig {
penalty: PeerPenalty::Difference { order: 2 },
lambda: LambdaChoice::Gcv,
};
let result = peer(&data, &y, &t, &config).expect("GCV peer() should succeed");
assert!(
result.lambda > 1e-10 && result.lambda < 1e6,
"GCV lambda should be non-degenerate, got {}",
result.lambda
);
assert!(result.gcv.is_some(), "gcv field must be Some for Gcv");
assert_eq!(result.lambda_method, LambdaMethod::Gcv);
let max_err = result
.beta
.iter()
.zip(true_beta.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0_f64, f64::max);
assert!(max_err < 0.15, "GCV beta recovery error: {max_err} >= 0.15");
}
#[test]
fn test_peer_reml_deterministic() {
let (data, y, t, _) = make_fixture();
let config = PeerConfig {
penalty: PeerPenalty::Difference { order: 2 },
lambda: LambdaChoice::Reml,
};
let r1 = peer(&data, &y, &t, &config).expect("first REML call should succeed");
let r2 = peer(&data, &y, &t, &config).expect("second REML call should succeed");
assert_eq!(
r1.lambda, r2.lambda,
"REML lambda must be bit-exact across two runs: {} vs {}",
r1.lambda, r2.lambda
);
assert_eq!(r1.lambda_method, LambdaMethod::Reml);
assert!(r1.gcv.is_none(), "REML result must have gcv == None");
}
#[test]
fn test_peer_reml_lambda_positive() {
let (data, y, t, _) = make_fixture();
let config = PeerConfig {
penalty: PeerPenalty::Difference { order: 2 },
lambda: LambdaChoice::Reml,
};
let result = peer(&data, &y, &t, &config).expect("REML peer() should succeed");
assert!(
result.lambda > 0.0 && result.lambda.is_finite(),
"REML lambda must be positive and finite, got {}",
result.lambda
);
assert!(result.gcv.is_none(), "gcv must be None for Reml");
assert_eq!(result.lambda_method, LambdaMethod::Reml);
}
#[test]
fn test_peer_reml_gcv_beta_agreement() {
let (data, y, t, true_beta) = make_fixture();
let config_gcv = PeerConfig {
penalty: PeerPenalty::Difference { order: 2 },
lambda: LambdaChoice::Gcv,
};
let config_reml = PeerConfig {
penalty: PeerPenalty::Difference { order: 2 },
lambda: LambdaChoice::Reml,
};
let res_gcv = peer(&data, &y, &t, &config_gcv).expect("GCV fit should succeed");
let res_reml = peer(&data, &y, &t, &config_reml).expect("REML fit should succeed");
let reml_err = res_reml
.beta
.iter()
.zip(true_beta.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0_f64, f64::max);
assert!(
reml_err < 0.15,
"REML beta recovery error: {reml_err} >= 0.15"
);
let beta_diff = res_gcv
.beta
.iter()
.zip(res_reml.beta.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0_f64, f64::max);
assert!(
beta_diff < 0.2,
"REML vs GCV beta disagreement: {beta_diff} >= 0.2 (lambdas: gcv={}, reml={})",
res_gcv.lambda,
res_reml.lambda
);
}
#[test]
fn test_peer_predict_self_consistent() {
let (data, y, t, _) = make_fixture();
let config = PeerConfig {
penalty: PeerPenalty::Difference { order: 2 },
lambda: LambdaChoice::Fixed(1e-4),
};
let result = peer(&data, &y, &t, &config).expect("peer() should succeed");
let preds = result
.predict(&data, &t)
.expect("predict on training data should succeed");
assert_eq!(preds.len(), result.fitted_values.len());
for (i, (p, f)) in preds.iter().zip(&result.fitted_values).enumerate() {
assert!(
(p - f).abs() < 1e-9,
"predict vs fitted_values mismatch at row {i}: {p} vs {f}"
);
}
}
#[test]
fn test_predict_wrong_ncols() {
let (data, y, t, _) = make_fixture();
let (_, m) = data.shape();
let config = PeerConfig {
penalty: PeerPenalty::Ridge,
lambda: LambdaChoice::Fixed(1e-4),
};
let result = peer(&data, &y, &t, &config).expect("peer() should succeed");
let mut bad_data = FdMatrix::zeros(5, m - 1);
for i in 0..5 {
for j in 0..(m - 1) {
bad_data[(i, j)] = hash_unit((i * m + j) as u64);
}
}
let err = result
.predict(&bad_data, &t[..m - 1])
.expect_err("wrong ncols should return Err");
assert!(
matches!(err, FdarError::InvalidDimension { .. }),
"expected InvalidDimension, got {err:?}"
);
}
#[test]
fn test_predict_new_curves_finite() {
let (data, y, t, _) = make_fixture();
let (n, m) = data.shape();
let config = PeerConfig {
penalty: PeerPenalty::Ridge,
lambda: LambdaChoice::Fixed(1e-4),
};
let result = peer(&data, &y, &t, &config).expect("peer() should succeed");
let mut new_data = FdMatrix::zeros(n, m);
for i in 0..n {
for j in 0..m {
new_data[(i, j)] = hash_unit((100_000 + i * m + j) as u64) + 10.0;
}
}
let preds = result
.predict(&new_data, &t)
.expect("predict on new curves should succeed");
assert!(
preds.iter().all(|v| v.is_finite()),
"predictions on fresh curves contain non-finite values"
);
}
#[test]
fn test_peer_reml_ridge_and_zeroq_edges() {
let (data, y, t, _) = make_fixture();
let m = t.len();
let config_ridge = PeerConfig {
penalty: PeerPenalty::Ridge,
lambda: LambdaChoice::Reml,
};
let res_ridge =
peer(&data, &y, &t, &config_ridge).expect("REML with Ridge (s=0) should not panic");
assert!(
res_ridge.lambda > 0.0 && res_ridge.lambda.is_finite(),
"Ridge REML lambda must be positive finite, got {}",
res_ridge.lambda
);
let zero_q = vec![0.0_f64; m * m];
let config_zero = PeerConfig {
penalty: PeerPenalty::Decree(zero_q, m),
lambda: LambdaChoice::Reml,
};
let res_zero =
peer(&data, &y, &t, &config_zero).expect("REML with zero-Q should not panic");
assert!(
(res_zero.lambda - 1e-4).abs() < 1e-15,
"zero-Q REML fallback lambda should be 1e-4, got {}",
res_zero.lambda
);
}
fn make_lpeer_fixture() -> (FdMatrix, Vec<f64>, Vec<f64>, Vec<usize>, f64) {
let (n_subjects, obs_per, m) = (20_usize, 10_usize, 10_usize);
let n = n_subjects * obs_per;
let t = uniform_grid(m);
let true_beta: Vec<f64> = t
.iter()
.map(|&ti| (std::f64::consts::PI * ti).sin())
.collect();
let w = simpsons_weights(&t);
let sigma2_u_true = 1.0_f64;
let mut data = FdMatrix::zeros(n, m);
let mut y = vec![0.0_f64; n];
let mut subject_map = vec![0_usize; n];
for s in 0..n_subjects {
let u_s = hash_unit(s as u64) * sigma2_u_true.sqrt();
for obs in 0..obs_per {
let i = s * obs_per + obs;
subject_map[i] = s;
for j in 0..m {
let xi = hash_unit((i * m + j) as u64);
data[(i, j)] = xi;
y[i] += xi * true_beta[j] * w[j];
}
y[i] += u_s; y[i] += 0.02 * hash_unit(1_000_000 + i as u64);
}
}
(data, y, t, subject_map, sigma2_u_true)
}
#[test]
fn test_lpeer_variance_non_negative() {
let (data, y, t, subject_map, _) = make_lpeer_fixture();
let config = PeerConfig {
penalty: PeerPenalty::Ridge,
lambda: LambdaChoice::Fixed(1e-3),
};
let fit = lpeer(&data, &y, &t, &subject_map, &config)
.expect("lpeer() should succeed on longitudinal fixture");
assert!(
fit.sigma2_subject >= 0.0,
"sigma2_subject must be non-negative, got {}",
fit.sigma2_subject
);
assert!(
fit.sigma2_resid >= 0.0,
"sigma2_resid must be non-negative, got {}",
fit.sigma2_resid
);
}
#[test]
fn test_lpeer_beta_recovery() {
let (data, y, t, subject_map, _) = make_lpeer_fixture();
let m = t.len();
let true_beta: Vec<f64> = t
.iter()
.map(|&ti| (std::f64::consts::PI * ti).sin())
.collect();
let config = PeerConfig {
penalty: PeerPenalty::Ridge,
lambda: LambdaChoice::Fixed(1e-3),
};
let fit = lpeer(&data, &y, &t, &subject_map, &config)
.expect("lpeer() should succeed on longitudinal fixture");
assert_eq!(fit.beta.len(), m);
let max_err = fit
.beta
.iter()
.zip(true_beta.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0_f64, f64::max);
assert!(
max_err < 0.5,
"lpeer β(t) recovery error vs sin(πt): {max_err} >= 0.5"
);
}
#[test]
fn test_lpeer_lambda_regularizes() {
let (data, y, t, subject_map, _) = make_lpeer_fixture();
let cfg_light = PeerConfig {
penalty: PeerPenalty::Difference { order: 2 },
lambda: LambdaChoice::Fixed(0.0),
};
let cfg_heavy = PeerConfig {
penalty: PeerPenalty::Difference { order: 2 },
lambda: LambdaChoice::Fixed(1e6),
};
let light = lpeer(&data, &y, &t, &subject_map, &cfg_light).expect("light λ fit");
let heavy = lpeer(&data, &y, &t, &subject_map, &cfg_heavy).expect("heavy λ fit");
let max_diff = light
.beta
.iter()
.zip(heavy.beta.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0_f64, f64::max);
assert!(
max_diff > 1e-3,
"λ does not regularize β(t): max|β(λ=0) − β(λ=1e6)| = {max_diff} <= 1e-3"
);
assert!(heavy.beta.iter().all(|v| v.is_finite()));
}
#[test]
fn test_lpeer_sigma2_tracks_injection() {
let (data, y, t, subject_map, sigma2_u_true) = make_lpeer_fixture();
let config = PeerConfig {
penalty: PeerPenalty::Ridge,
lambda: LambdaChoice::Fixed(1e-3),
};
let fit = lpeer(&data, &y, &t, &subject_map, &config)
.expect("lpeer() should succeed on longitudinal fixture");
assert!(
fit.sigma2_subject > 0.1 && fit.sigma2_subject < 5.0,
"lpeer sigma2_subject={} should track injected {sigma2_u_true} within (0.1, 5.0)",
fit.sigma2_subject
);
}
#[test]
fn test_lpeer_invalid_subject_map() {
let (data, y, t, _, _) = make_lpeer_fixture();
let n = y.len();
let bad_map: Vec<usize> = (0..n - 1).map(|i| i / 5).collect();
let config = PeerConfig {
penalty: PeerPenalty::Ridge,
lambda: LambdaChoice::Fixed(1e-3),
};
let err = lpeer(&data, &y, &t, &bad_map, &config)
.expect_err("wrong-length subject_map should return Err");
assert!(
matches!(err, FdarError::InvalidDimension { .. }),
"expected InvalidDimension, got {err:?}"
);
}
#[test]
fn test_lpeer_single_subject_rejected() {
let (data, y, t, _, _) = make_lpeer_fixture();
let n = y.len();
let single_map = vec![0_usize; n];
let config = PeerConfig {
penalty: PeerPenalty::Ridge,
lambda: LambdaChoice::Fixed(1e-3),
};
let err = lpeer(&data, &y, &t, &single_map, &config)
.expect_err("single-subject map should be rejected");
assert!(
matches!(err, FdarError::InvalidParameter { .. }),
"expected InvalidParameter, got {err:?}"
);
}
#[test]
fn test_lpeer_predict_self_consistent() {
let (data, y, t, subject_map, _) = make_lpeer_fixture();
let config = PeerConfig {
penalty: PeerPenalty::Ridge,
lambda: LambdaChoice::Fixed(1e-3),
};
let fit = lpeer(&data, &y, &t, &subject_map, &config)
.expect("lpeer() should succeed on longitudinal fixture");
let preds = fit
.predict(&data, &t)
.expect("LpeerResult::predict on training data should succeed");
assert_eq!(preds.len(), fit.fitted_values.len());
for (i, (p, f)) in preds.iter().zip(&fit.fitted_values).enumerate() {
assert!(
(p - f).abs() < 1e-9,
"LpeerResult::predict vs fitted_values mismatch at row {i}: {p} vs {f}"
);
}
}
#[test]
fn test_crate_root_exports_compile() {
use crate::error::FdarError;
let _peer_fn: fn(&FdMatrix, &[f64], &[f64], &PeerConfig) -> Result<PeerResult, FdarError> =
crate::peer::peer;
let _lpeer_fn: fn(
&FdMatrix,
&[f64],
&[f64],
&[usize],
&PeerConfig,
) -> Result<LpeerResult, FdarError> = crate::peer::lpeer;
let _lc = LambdaChoice::Fixed(1.0);
let _lm = LambdaMethod::Fixed;
let _pp = PeerPenalty::Ridge;
let _ = PeerConfig {
penalty: _pp,
lambda: _lc,
};
let _ = std::mem::size_of::<PeerResult>();
let _ = std::mem::size_of::<LpeerResult>();
}
#[test]
fn test_prelude_exports_compile() {
use crate::error::FdarError;
use crate::prelude::*;
let _peer_fn: fn(&FdMatrix, &[f64], &[f64], &PeerConfig) -> Result<PeerResult, FdarError> =
peer;
let _lpeer_fn: fn(
&FdMatrix,
&[f64],
&[f64],
&[usize],
&PeerConfig,
) -> Result<LpeerResult, FdarError> = lpeer;
let _lc = LambdaChoice::Gcv;
let _lm = LambdaMethod::Gcv;
let _pp = PeerPenalty::Ridge;
let _ = PeerConfig {
penalty: _pp,
lambda: _lc,
};
let _ = std::mem::size_of::<PeerResult>();
let _ = std::mem::size_of::<LpeerResult>();
}
}