use crate::error::FdarError;
use crate::fpca_variants::gaussian_smooth_cov;
use crate::helpers::simpsons_weights;
use crate::irreg_fdata::kernels::kernel_gaussian;
use crate::irreg_fdata::{cov_irreg, mean_irreg, IrregFdata, KernelType};
use crate::matrix::FdMatrix;
use crate::pace_fpca::{pace_fpca, PaceFpcaConfig, PaceFpcaResult};
use nalgebra::DMatrix;
fn validate_grid(grid: &[f64]) -> Result<(), FdarError> {
if grid.len() < 2 {
return Err(FdarError::InvalidDimension {
parameter: "grid",
expected: ">= 2 points".to_string(),
actual: grid.len().to_string(),
});
}
if grid.windows(2).any(|w| w[0] >= w[1]) {
return Err(FdarError::InvalidParameter {
parameter: "grid",
message: "grid must be strictly increasing".to_string(),
});
}
Ok(())
}
fn psd_project(cov: &FdMatrix, grid: &[f64]) -> Result<FdMatrix, FdarError> {
let m = grid.len();
let w = simpsons_weights(grid);
let sqrt_w: Vec<f64> = w.iter().map(|v| v.sqrt()).collect();
let mut c_scaled = vec![0.0_f64; m * m];
for col in 0..m {
for row in 0..m {
c_scaled[row + col * m] = sqrt_w[row] * cov[(row, col)] * sqrt_w[col];
}
}
let eigen = DMatrix::from_column_slice(m, m, &c_scaled).symmetric_eigen();
let mut cov_data = vec![0.0_f64; m * m];
for k in 0..eigen.eigenvalues.len() {
let lam = eigen.eigenvalues[k];
if lam <= 0.0 {
continue; }
let mut phi = vec![0.0_f64; m];
for j in 0..m {
let raw = eigen.eigenvectors[(j, k)];
phi[j] = if sqrt_w[j] > 1e-15 {
raw / sqrt_w[j]
} else {
raw
};
}
for j in 0..m {
for i in 0..m {
cov_data[i + j * m] += lam * phi[i] * phi[j];
}
}
}
FdMatrix::from_column_major(cov_data, m, m).map_err(|e| FdarError::ComputationFailed {
operation: "face_covariance PSD projection",
detail: e.to_string(),
})
}
#[must_use = "face_covariance returns the covariance surface; ignoring it wastes the computation"]
pub fn face_covariance(
ifd: &IrregFdata,
grid: &[f64],
bandwidth: f64,
) -> Result<FdMatrix, FdarError> {
if ifd.n_obs() == 0 {
return Err(FdarError::InvalidDimension {
parameter: "ifd",
expected: ">= 1 observation".to_string(),
actual: "0".to_string(),
});
}
validate_grid(grid)?;
if !bandwidth.is_finite() || bandwidth <= 0.0 {
return Err(FdarError::InvalidParameter {
parameter: "bandwidth",
message: format!("bandwidth must be finite and > 0, got {bandwidth}"),
});
}
let raw_cov = cov_irreg(ifd, grid, grid, bandwidth);
let smooth_cov = gaussian_smooth_cov(&raw_cov, grid, bandwidth);
psd_project(&smooth_cov, grid)
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct MfaceCovResult {
pub block_cov: FdMatrix,
pub grids: Vec<Vec<f64>>,
pub offsets: Vec<usize>,
}
impl MfaceCovResult {
#[must_use]
pub fn block(&self, p: usize, q: usize) -> FdMatrix {
let gp = self.grids[p].len();
let gq = self.grids[q].len();
let op = self.offsets[p];
let oq = self.offsets[q];
let mut out = FdMatrix::zeros(gp, gq);
for col in 0..gq {
for row in 0..gp {
out[(row, col)] = self.block_cov[(op + row, oq + col)];
}
}
out
}
}
fn cross_cov_surface(
off_p: &[usize],
t_p: &[f64],
c_p: &[f64],
off_q: &[usize],
t_q: &[f64],
c_q: &[f64],
n: usize,
s_grid: &[f64],
t_grid: &[f64],
bandwidth: f64,
) -> FdMatrix {
let ns = s_grid.len();
let nt = t_grid.len();
let mut data = vec![0.0_f64; ns * nt];
for (si, &s) in s_grid.iter().enumerate() {
for (ti, &t) in t_grid.iter().enumerate() {
let mut sum_w = 0.0;
let mut sum_p = 0.0;
for i in 0..n {
let (ps, pe) = (off_p[i], off_p[i + 1]);
let (qs, qe) = (off_q[i], off_q[i + 1]);
for j1 in ps..pe {
let w1 = kernel_gaussian((t_p[j1] - s) / bandwidth);
for j2 in qs..qe {
let w2 = kernel_gaussian((t_q[j2] - t) / bandwidth);
let w = w1 * w2;
sum_w += w;
sum_p += w * c_p[j1] * c_q[j2];
}
}
}
data[si + ti * ns] = if sum_w > 0.0 { sum_p / sum_w } else { 0.0 };
}
}
FdMatrix::from_column_major(data, ns, nt).expect("dimension invariant: ns*nt")
}
#[must_use = "mface_covariance returns the block covariance; ignoring it wastes the computation"]
pub fn mface_covariance(
variables: &[IrregFdata],
grids: &[Vec<f64>],
bandwidth: f64,
) -> Result<MfaceCovResult, FdarError> {
let p_count = variables.len();
if p_count < 2 {
return Err(FdarError::InvalidDimension {
parameter: "variables",
expected: ">= 2 variables".to_string(),
actual: p_count.to_string(),
});
}
if grids.len() != p_count {
return Err(FdarError::InvalidDimension {
parameter: "grids",
expected: format!("{p_count} grids (one per variable)"),
actual: grids.len().to_string(),
});
}
if !bandwidth.is_finite() || bandwidth <= 0.0 {
return Err(FdarError::InvalidParameter {
parameter: "bandwidth",
message: format!("bandwidth must be finite and > 0, got {bandwidth}"),
});
}
let n = variables[0].n_obs();
for (p, var) in variables.iter().enumerate() {
if var.n_obs() != n {
return Err(FdarError::InvalidDimension {
parameter: "variables",
expected: format!("all variables observed on {n} subjects"),
actual: format!("variable {p} has {} observations", var.n_obs()),
});
}
if grids[p].len() < 2 {
return Err(FdarError::InvalidDimension {
parameter: "grids",
expected: ">= 2 points per grid".to_string(),
actual: format!("grid {p} has {} points", grids[p].len()),
});
}
}
let mut offsets = Vec::with_capacity(p_count);
let mut g_total = 0usize;
for g in grids {
offsets.push(g_total);
g_total += g.len();
}
g_total
.checked_mul(g_total)
.ok_or_else(|| FdarError::ComputationFailed {
operation: "mface_covariance block allocation",
detail: format!("G_total={g_total}: G_total² overflows usize"),
})?;
let centered: Vec<Vec<f64>> = variables
.iter()
.map(|var| {
let mean = mean_irreg(var, &var.argvals, bandwidth, KernelType::Gaussian);
var.values
.iter()
.zip(mean.iter())
.map(|(&v, &m)| v - m)
.collect()
})
.collect();
let mut block_data = vec![0.0_f64; g_total * g_total];
for p in 0..p_count {
let diag = face_covariance(&variables[p], &grids[p], bandwidth)?;
let op = offsets[p];
let gp = grids[p].len();
for col in 0..gp {
for row in 0..gp {
block_data[(op + row) + (op + col) * g_total] = diag[(row, col)];
}
}
}
for p in 0..p_count {
for q in (p + 1)..p_count {
let cross = cross_cov_surface(
&variables[p].offsets,
&variables[p].argvals,
¢ered[p],
&variables[q].offsets,
&variables[q].argvals,
¢ered[q],
n,
&grids[p],
&grids[q],
bandwidth,
);
let op = offsets[p];
let oq = offsets[q];
let gp = grids[p].len();
let gq = grids[q].len();
for col in 0..gq {
for row in 0..gp {
let val = cross[(row, col)];
block_data[(op + row) + (oq + col) * g_total] = val;
block_data[(oq + col) + (op + row) * g_total] = val; }
}
}
}
let block_cov = FdMatrix::from_column_major(block_data, g_total, g_total).map_err(|e| {
FdarError::ComputationFailed {
operation: "mface_covariance block assembly",
detail: e.to_string(),
}
})?;
Ok(MfaceCovResult {
block_cov,
grids: grids.to_vec(),
offsets,
})
}
#[must_use = "face_trajectory returns fitted trajectories + bands; ignoring it wastes the computation"]
pub fn face_trajectory(
data: &IrregFdata,
config: &PaceFpcaConfig,
) -> Result<PaceFpcaResult, FdarError> {
pace_fpca(data, config)
}
#[cfg(test)]
mod tests {
use super::*;
use nalgebra::Cholesky;
use rand::rngs::StdRng;
use rand::SeedableRng;
use rand_distr::{Distribution, StandardNormal};
fn sparse_sample() -> (IrregFdata, Vec<f64>) {
let argvals = vec![
vec![0.0, 0.3, 0.6, 1.0],
vec![0.1, 0.5, 0.9],
vec![0.0, 0.4, 0.7, 0.95],
vec![0.2, 0.6],
vec![0.05, 0.45, 0.85],
vec![0.15, 0.55, 0.9],
vec![0.0, 0.5, 1.0],
vec![0.3, 0.7],
vec![0.1, 0.4, 0.8],
vec![0.25, 0.65, 0.95],
];
let values: Vec<Vec<f64>> = argvals
.iter()
.enumerate()
.map(|(i, ts)| {
let a = 0.5 + i as f64 * 0.1;
ts.iter()
.map(|&t| a * (std::f64::consts::PI * t).sin())
.collect()
})
.collect();
let grid: Vec<f64> = (0..11).map(|i| i as f64 / 10.0).collect();
(IrregFdata::from_lists(&argvals, &values), grid)
}
fn min_eigenvalue(cov: &FdMatrix) -> f64 {
let (m, _) = cov.shape();
let dm = DMatrix::from_fn(m, m, |i, j| cov[(i, j)]);
dm.symmetric_eigen()
.eigenvalues
.iter()
.cloned()
.fold(f64::INFINITY, f64::min)
}
#[test]
fn test_face_covariance_shape() {
let (ifd, grid) = sparse_sample();
let cov = face_covariance(&ifd, &grid, 0.3).unwrap();
let m = grid.len();
assert_eq!(cov.shape(), (m, m));
for i in 0..m {
for j in 0..m {
assert!(
(cov[(i, j)] - cov[(j, i)]).abs() < 1e-9,
"not symmetric at ({i},{j})"
);
}
}
assert!(min_eigenvalue(&cov) >= -1e-9, "not PSD");
}
#[test]
fn test_face_covariance_dense_limit() {
let m = 31usize;
let grid: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
let kernel = DMatrix::from_fn(m, m, |i, j| (-(grid[i] - grid[j]).abs()).exp());
let chol = Cholesky::new(kernel).expect("OU kernel is PD");
let l = chol.l();
let n = 200usize;
let mut rng = StdRng::seed_from_u64(42);
let mut argvals_list = Vec::with_capacity(n);
let mut values_list = Vec::with_capacity(n);
for _ in 0..n {
let z: Vec<f64> = (0..m).map(|_| StandardNormal.sample(&mut rng)).collect();
let zvec = nalgebra::DVector::from_vec(z);
let x = &l * zvec; argvals_list.push(grid.clone());
values_list.push(x.iter().copied().collect());
}
let ifd = IrregFdata::from_lists(&argvals_list, &values_list);
let cov = face_covariance(&ifd, &grid, 0.05).unwrap();
let mut max_err = 0.0_f64;
for si in 0..m {
for ti in 0..m {
let truth = (-(grid[si] - grid[ti]).abs()).exp();
max_err = max_err.max((cov[(si, ti)] - truth).abs());
}
}
assert!(
max_err < 0.30,
"dense-limit max error {max_err} exceeds tolerance"
);
}
#[test]
fn test_face_covariance_errors() {
let (ifd, grid) = sparse_sample();
let empty = IrregFdata::from_lists(&[], &[]);
assert!(face_covariance(&empty, &grid, 0.3).is_err());
assert!(face_covariance(&ifd, &[], 0.3).is_err());
assert!(face_covariance(&ifd, &[0.5], 0.3).is_err());
assert!(face_covariance(&ifd, &[0.0, 0.5, 0.4], 0.3).is_err());
assert!(face_covariance(&ifd, &grid, 0.0).is_err());
assert!(face_covariance(&ifd, &grid, -1.0).is_err());
assert!(face_covariance(&ifd, &grid, f64::NAN).is_err());
assert!(face_covariance(&ifd, &grid, f64::INFINITY).is_err());
}
fn two_var_sample(n: usize, m: usize, seed: u64) -> (Vec<IrregFdata>, Vec<Vec<f64>>, f64) {
let grid: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
let mut rng = StdRng::seed_from_u64(seed);
let amps: Vec<f64> = (0..n)
.map(|_| {
let z: f64 = StandardNormal.sample(&mut rng);
1.0 + z
})
.collect();
let abar = amps.iter().sum::<f64>() / n as f64;
let lambda_pop = amps.iter().map(|&a| (a - abar).powi(2)).sum::<f64>() / n as f64;
let mut ax = Vec::with_capacity(n);
let mut vx = Vec::with_capacity(n);
let mut ay = Vec::with_capacity(n);
let mut vy = Vec::with_capacity(n);
for &a in &s {
ax.push(grid.clone());
vx.push(
grid.iter()
.map(|&t| a * (std::f64::consts::PI * t).sin())
.collect(),
);
ay.push(grid.clone());
vy.push(
grid.iter()
.map(|&t| a * (std::f64::consts::PI * t).cos())
.collect(),
);
}
let vars = vec![
IrregFdata::from_lists(&ax, &vx),
IrregFdata::from_lists(&ay, &vy),
];
(vars, vec![grid.clone(), grid], lambda_pop)
}
#[test]
fn test_mface_shape() {
let (vars, grids, _) = two_var_sample(20, 9, 7);
let res = mface_covariance(&vars, &grids, 0.15).unwrap();
let g0 = grids[0].len();
let g1 = grids[1].len();
let gt = g0 + g1;
assert_eq!(res.block_cov.shape(), (gt, gt));
assert_eq!(res.offsets, vec![0, g0]);
for i in 0..gt {
for j in 0..gt {
assert!(
(res.block_cov[(i, j)] - res.block_cov[(j, i)]).abs() < 1e-9,
"block matrix not symmetric at ({i},{j})"
);
}
}
let f0 = face_covariance(&vars[0], &grids[0], 0.15).unwrap();
let b00 = res.block(0, 0);
for i in 0..g0 {
for j in 0..g0 {
assert!((b00[(i, j)] - f0[(i, j)]).abs() < 1e-9);
}
}
let b01 = res.block(0, 1);
let b10 = res.block(1, 0);
assert_eq!(b01.shape(), (g0, g1));
assert_eq!(b10.shape(), (g1, g0));
for i in 0..g0 {
for j in 0..g1 {
assert!((b01[(i, j)] - b10[(j, i)]).abs() < 1e-9);
}
}
}
#[test]
fn test_mface_known_structure() {
let m = 21usize;
let (vars, grids, lambda) = two_var_sample(200, m, 11);
let res = mface_covariance(&vars, &grids, 0.08).unwrap();
let b01 = res.block(0, 1);
let grid = &grids[0];
let mut max_err = 0.0_f64;
for si in 0..m {
for ti in 0..m {
let truth = lambda
* (std::f64::consts::PI * grid[si]).sin()
* (std::f64::consts::PI * grid[ti]).cos();
max_err = max_err.max((b01[(si, ti)] - truth).abs());
}
}
assert!(
max_err < 0.4,
"mface cross-block max error {max_err} exceeds tolerance"
);
}
#[test]
fn test_mface_errors() {
let (vars, grids, _) = two_var_sample(10, 6, 3);
assert!(mface_covariance(&[], &[], 0.1).is_err());
assert!(mface_covariance(&vars[..1], &grids[..1], 0.1).is_err());
assert!(mface_covariance(&vars, &grids[..1], 0.1).is_err());
let (vars_a, grids_a, _) = two_var_sample(10, 6, 5);
let (vars_b, _, _) = two_var_sample(8, 6, 6);
let mixed = vec![vars_a[0].clone(), vars_b[0].clone()];
assert!(mface_covariance(&mixed, &grids_a, 0.1).is_err());
let short_grids = vec![vec![0.5], grids[1].clone()];
assert!(mface_covariance(&vars, &short_grids, 0.1).is_err());
assert!(mface_covariance(&vars, &grids, 0.0).is_err());
assert!(mface_covariance(&vars, &grids, f64::NAN).is_err());
}
#[test]
fn test_mface_three_vars() {
let n = 15usize;
let (v01, g01, _) = two_var_sample(n, 6, 21);
let (v2only, _, _) = two_var_sample(n, 8, 22);
let grid3: Vec<f64> = (0..8).map(|i| i as f64 / 7.0).collect();
let vars = vec![v01[0].clone(), v01[1].clone(), v2only[0].clone()];
let grids = vec![g01[0].clone(), g01[1].clone(), grid3];
let res = mface_covariance(&vars, &grids, 0.15).unwrap();
let gt: usize = grids.iter().map(std::vec::Vec::len).sum();
assert_eq!(res.block_cov.shape(), (gt, gt));
assert_eq!(res.offsets, vec![0, 6, 12]);
for i in 0..gt {
for j in 0..gt {
assert!((res.block_cov[(i, j)] - res.block_cov[(j, i)]).abs() < 1e-9);
}
}
for p in 0..3 {
let diag = face_covariance(&vars[p], &grids[p], 0.15).unwrap();
let bpp = res.block(p, p);
for i in 0..grids[p].len() {
for j in 0..grids[p].len() {
assert!((bpp[(i, j)] - diag[(i, j)]).abs() < 1e-9);
}
}
for q in (p + 1)..3 {
let bpq = res.block(p, q);
let bqp = res.block(q, p);
assert_eq!(bpq.shape(), (grids[p].len(), grids[q].len()));
for i in 0..grids[p].len() {
for j in 0..grids[q].len() {
assert!((bpq[(i, j)] - bqp[(j, i)]).abs() < 1e-9);
}
}
}
}
}
fn dense_sample(n: usize, m: usize, seed: u64) -> (IrregFdata, Vec<f64>, Vec<Vec<f64>>) {
let grid: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
let mut rng = StdRng::seed_from_u64(seed);
let mut argvals = Vec::with_capacity(n);
let mut values = Vec::with_capacity(n);
let mut truth = Vec::with_capacity(n);
for _ in 0..n {
let z: f64 = StandardNormal.sample(&mut rng);
let a = 1.0 + 0.5 * z;
let curve: Vec<f64> = grid
.iter()
.map(|&t| a * (std::f64::consts::PI * t).sin())
.collect();
argvals.push(grid.clone());
values.push(curve.clone());
truth.push(curve);
}
(IrregFdata::from_lists(&argvals, &values), grid, truth)
}
#[test]
fn test_face_trajectory_delegation() {
let (data, grid, _) = dense_sample(15, 21, 1);
let config = PaceFpcaConfig {
ncomp: 2,
bandwidth: 0.1,
sigma2: 0.01,
work_grid: grid,
alpha: 0.05,
};
let a = face_trajectory(&data, &config).unwrap();
let b = pace_fpca(&data, &config).unwrap();
assert!(a == b, "face_trajectory must delegate to pace_fpca exactly");
}
#[test]
fn test_face_trajectory_bands() {
let m = 41usize;
let (data, grid, truth) = dense_sample(25, m, 2);
let config = PaceFpcaConfig {
ncomp: 2,
bandwidth: 0.1,
sigma2: 0.01,
work_grid: grid,
alpha: 0.05,
};
let res = face_trajectory(&data, &config).unwrap();
let n = truth.len();
let mut inside = 0usize;
let mut total = 0usize;
for i in 0..n {
for j in 0..m {
let lo = res.fitted_lower[(i, j)];
let hi = res.fitted_upper[(i, j)];
if truth[i][j] >= lo && truth[i][j] <= hi {
inside += 1;
}
total += 1;
}
}
let frac = inside as f64 / total as f64;
assert!(frac >= 0.85, "only {frac} of true points inside bands");
}
#[test]
fn test_reexports() {
use crate::{face_covariance, face_trajectory, mface_covariance, MfaceCovResult};
let (vars, grids, _) = two_var_sample(12, 7, 99);
let _diag = face_covariance(&vars[0], &grids[0], 0.15).unwrap();
let res: MfaceCovResult = mface_covariance(&vars, &grids, 0.15).unwrap();
let _ = res.block(0, 1);
let config = PaceFpcaConfig {
ncomp: 1,
bandwidth: 0.15,
sigma2: 0.01,
work_grid: grids[0].clone(),
alpha: 0.05,
};
let _traj = face_trajectory(&vars[0], &config).unwrap();
}
}