use crate::error::FdarError;
use crate::fdata;
use crate::helpers::{gaussian_kernel, simpsons_weights};
use crate::matrix::FdMatrix;
use crate::regression::{fdata_to_pc_1d, FpcaResult};
use nalgebra::DMatrix;
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct FsvdResult {
pub singular_values: Vec<f64>,
pub left_functions: FdMatrix,
pub right_functions: FdMatrix,
pub left_scores: FdMatrix,
pub right_scores: FdMatrix,
}
#[must_use = "cross_covariance returns the surface; ignoring it wastes the computation"]
pub fn cross_covariance(x: &FdMatrix, y: &FdMatrix) -> Result<FdMatrix, FdarError> {
let (nx, p) = x.shape();
let (ny, q) = y.shape();
if nx != ny {
return Err(FdarError::InvalidDimension {
parameter: "y",
expected: format!("{nx} rows (matching x)"),
actual: format!("{ny} rows"),
});
}
if nx < 2 {
return Err(FdarError::InvalidDimension {
parameter: "x",
expected: ">= 2 rows".to_string(),
actual: nx.to_string(),
});
}
if p == 0 || q == 0 {
return Err(FdarError::InvalidDimension {
parameter: if p == 0 { "x" } else { "y" },
expected: ">= 1 column".to_string(),
actual: format!("p = {p}, q = {q}"),
});
}
p.checked_mul(q)
.ok_or_else(|| FdarError::InvalidParameter {
parameter: "x",
message: format!(
"p={p}, q={q} too large: p*q would overflow usize (max {})",
usize::MAX
),
})?;
let xc = fdata::center_1d(x);
let yc = fdata::center_1d(y);
let denom = (nx - 1) as f64;
let mut cov = FdMatrix::zeros(p, q);
for s in 0..p {
let cxs = xc.column(s);
for t in 0..q {
let cyt = yc.column(t);
let val: f64 = cxs
.iter()
.zip(cyt.iter())
.map(|(&a, &b)| a * b)
.sum::<f64>()
/ denom;
cov[(s, t)] = val;
}
}
Ok(cov)
}
#[must_use = "fpca_der returns the derivative FPCA result; ignoring it wastes the computation"]
pub fn fpca_der(
data: &FdMatrix,
ncomp: usize,
argvals: &[f64],
nderiv: usize,
) -> Result<FpcaResult, FdarError> {
let (n, m) = data.shape();
if n == 0 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "n > 0 rows".to_string(),
actual: format!("n = {n}"),
});
}
if m == 0 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "m > 0 columns".to_string(),
actual: format!("m = {m}"),
});
}
if argvals.len() != m {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: format!("{m} elements"),
actual: format!("{} elements", argvals.len()),
});
}
if ncomp < 1 {
return Err(FdarError::InvalidParameter {
parameter: "ncomp",
message: format!("ncomp must be >= 1, got {ncomp}"),
});
}
if nderiv > 0 && m < 2 {
return Err(FdarError::InvalidParameter {
parameter: "data",
message: format!("need >= 2 columns for a numerical derivative, got m = {m}"),
});
}
let deriv = fdata::deriv_1d(data, argvals, nderiv);
fdata_to_pc_1d(&deriv, ncomp, argvals)
}
#[must_use = "dynamical_correlation returns the scalar association; ignoring it wastes the computation"]
pub fn dynamical_correlation(
x: &FdMatrix,
y: &FdMatrix,
argvals: &[f64],
) -> Result<f64, FdarError> {
let (nx, mx) = x.shape();
let (ny, my) = y.shape();
if nx != ny {
return Err(FdarError::InvalidDimension {
parameter: "y",
expected: format!("{nx} rows (matching x)"),
actual: format!("{ny} rows"),
});
}
if mx != my {
return Err(FdarError::InvalidDimension {
parameter: "y",
expected: format!("{mx} columns (matching x)"),
actual: format!("{my} columns"),
});
}
if argvals.len() != mx {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: format!("{mx} elements"),
actual: format!("{} elements", argvals.len()),
});
}
if nx < 2 {
return Err(FdarError::InvalidDimension {
parameter: "x",
expected: ">= 2 rows".to_string(),
actual: nx.to_string(),
});
}
let m = mx;
let n = nx;
let domain_length = argvals[m - 1] - argvals[0];
if domain_length <= 0.0 || domain_length.is_nan() {
return Err(FdarError::InvalidParameter {
parameter: "argvals",
message: "argvals must be increasing with positive domain length".to_string(),
});
}
let w = simpsons_weights(argvals);
let mut xc1 = x.clone();
let mut yc1 = y.clone();
for (src, dst) in [(x, &mut xc1), (y, &mut yc1)] {
for i in 0..n {
let aver: f64 = (0..m).map(|j| src[(i, j)] * w[j]).sum::<f64>() / domain_length;
for j in 0..m {
dst[(i, j)] -= aver;
}
}
}
let center_pop = |mat: &mut FdMatrix| {
for j in 0..m {
let mean_j: f64 = (0..n).map(|i| mat[(i, j)]).sum::<f64>() / n as f64;
for i in 0..n {
mat[(i, j)] -= mean_j;
}
}
};
center_pop(&mut xc1);
center_pop(&mut yc1);
let standardize = |mat: &mut FdMatrix| {
for i in 0..n {
let norm_sq: f64 =
(0..m).map(|j| mat[(i, j)].powi(2) * w[j]).sum::<f64>() / domain_length;
let norm = norm_sq.sqrt();
if norm < 1e-15 {
for j in 0..m {
mat[(i, j)] = 0.0;
}
} else {
for j in 0..m {
mat[(i, j)] /= norm;
}
}
}
};
standardize(&mut xc1);
standardize(&mut yc1);
let total: f64 = (0..n)
.map(|i| {
let z: f64 = (0..m)
.map(|j| xc1[(i, j)] * yc1[(i, j)] * w[j])
.sum::<f64>()
/ domain_length;
z
})
.sum();
Ok(total / n as f64)
}
#[must_use = "fsvd returns the cross-FPCA result; ignoring it wastes the computation"]
pub fn fsvd(
x: &FdMatrix,
argvals_x: &[f64],
y: &FdMatrix,
argvals_y: &[f64],
ncomp: usize,
) -> Result<FsvdResult, FdarError> {
let (nx, p) = x.shape();
let (ny, q) = y.shape();
if nx != ny {
return Err(FdarError::InvalidDimension {
parameter: "y",
expected: format!("{nx} rows (matching x)"),
actual: format!("{ny} rows"),
});
}
if nx < 2 {
return Err(FdarError::InvalidDimension {
parameter: "x",
expected: ">= 2 rows".to_string(),
actual: nx.to_string(),
});
}
if argvals_x.len() != p {
return Err(FdarError::InvalidDimension {
parameter: "argvals_x",
expected: format!("{p} elements"),
actual: format!("{} elements", argvals_x.len()),
});
}
if argvals_y.len() != q {
return Err(FdarError::InvalidDimension {
parameter: "argvals_y",
expected: format!("{q} elements"),
actual: format!("{} elements", argvals_y.len()),
});
}
if ncomp < 1 {
return Err(FdarError::InvalidParameter {
parameter: "ncomp",
message: format!("ncomp must be >= 1, got {ncomp}"),
});
}
let k = ncomp.min(p).min(q);
let c = cross_covariance(x, y)?;
let wx = simpsons_weights(argvals_x);
let wy = simpsons_weights(argvals_y);
let sqrt_wx: Vec<f64> = wx.iter().map(|v| v.sqrt()).collect();
let sqrt_wy: Vec<f64> = wy.iter().map(|v| v.sqrt()).collect();
let mut cw = FdMatrix::zeros(p, q);
for s in 0..p {
for t in 0..q {
cw[(s, t)] = sqrt_wx[s] * c[(s, t)] * sqrt_wy[t];
}
}
let gram_on_right = q <= p; let g_dim = q.min(p);
let mut gram = vec![0.0_f64; g_dim * g_dim];
if gram_on_right {
for a in 0..q {
for b in 0..q {
gram[a + b * q] = (0..p).map(|s| cw[(s, a)] * cw[(s, b)]).sum();
}
}
} else {
for a in 0..p {
for b in 0..p {
gram[a + b * p] = (0..q).map(|t| cw[(a, t)] * cw[(b, t)]).sum();
}
}
}
let eigen = DMatrix::from_column_slice(g_dim, g_dim, &gram).symmetric_eigen();
let mut pairs: Vec<(f64, usize)> = (0..eigen.eigenvalues.len())
.map(|idx| (eigen.eigenvalues[idx], idx))
.collect();
pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let pairs: Vec<(f64, usize)> = pairs.into_iter().take(k).collect();
let mut singular_values = Vec::with_capacity(k);
let mut left = FdMatrix::zeros(p, k);
let mut right = FdMatrix::zeros(q, k);
for (comp, &(lam, col_idx)) in pairs.iter().enumerate() {
let sigma = lam.max(0.0).sqrt();
singular_values.push(sigma);
let mut uk = vec![0.0_f64; p];
let mut vk = vec![0.0_f64; q];
if gram_on_right {
for t in 0..q {
vk[t] = eigen.eigenvectors[(t, col_idx)];
}
if sigma > 1e-12 {
for s in 0..p {
uk[s] = (0..q).map(|t| cw[(s, t)] * vk[t]).sum::<f64>() / sigma;
}
}
} else {
for s in 0..p {
uk[s] = eigen.eigenvectors[(s, col_idx)];
}
if sigma > 1e-12 {
for t in 0..q {
vk[t] = (0..p).map(|s| cw[(s, t)] * uk[s]).sum::<f64>() / sigma;
}
}
}
for s in 0..p {
left[(s, comp)] = if sqrt_wx[s] > 1e-15 {
uk[s] / sqrt_wx[s]
} else {
uk[s]
};
}
for t in 0..q {
right[(t, comp)] = if sqrt_wy[t] > 1e-15 {
vk[t] / sqrt_wy[t]
} else {
vk[t]
};
}
}
for comp in 0..k {
let s_max = (0..p)
.max_by(|&a, &b| {
left[(a, comp)]
.abs()
.partial_cmp(&left[(b, comp)].abs())
.unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or(0);
if left[(s_max, comp)] < 0.0 {
for s in 0..p {
left[(s, comp)] = -left[(s, comp)];
}
for t in 0..q {
right[(t, comp)] = -right[(t, comp)];
}
}
}
let xc = fdata::center_1d(x);
let yc = fdata::center_1d(y);
let mut left_scores = FdMatrix::zeros(nx, k);
let mut right_scores = FdMatrix::zeros(nx, k);
for comp in 0..k {
for i in 0..nx {
left_scores[(i, comp)] = (0..p)
.map(|s| xc[(i, s)] * left[(s, comp)] * wx[s])
.sum::<f64>();
right_scores[(i, comp)] = (0..q)
.map(|t| yc[(i, t)] * right[(t, comp)] * wy[t])
.sum::<f64>();
}
}
Ok(FsvdResult {
singular_values,
left_functions: left,
right_functions: right,
left_scores,
right_scores,
})
}
pub(crate) fn gaussian_smooth_cov(cov: &FdMatrix, argvals: &[f64], bandwidth: f64) -> FdMatrix {
let m = argvals.len();
let mut kernel = vec![0.0_f64; m * m];
for a in 0..m {
let mut row_sum = 0.0;
for b in 0..m {
let kv = gaussian_kernel((argvals[a] - argvals[b]).abs(), bandwidth);
kernel[a + b * m] = kv;
row_sum += kv;
}
if row_sum > 1e-15 {
for b in 0..m {
kernel[a + b * m] /= row_sum;
}
}
}
let mut tmp = FdMatrix::zeros(m, m);
for i in 0..m {
for j in 0..m {
let mut acc = 0.0;
for a in 0..m {
acc += kernel[i + a * m] * cov[(a, j)];
}
tmp[(i, j)] = acc;
}
}
let mut out = FdMatrix::zeros(m, m);
for i in 0..m {
for j in 0..m {
let mut acc = 0.0;
for b in 0..m {
acc += tmp[(i, b)] * kernel[j + b * m];
}
out[(i, j)] = acc;
}
}
out
}
#[must_use = "ssvd returns the smoothed FPCA result; ignoring it wastes the computation"]
pub fn ssvd(
data: &FdMatrix,
ncomp: usize,
argvals: &[f64],
bandwidth: f64,
) -> Result<FpcaResult, FdarError> {
let (n, m) = data.shape();
if n == 0 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "n > 0 rows".to_string(),
actual: format!("n = {n}"),
});
}
if m == 0 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "m > 0 columns".to_string(),
actual: format!("m = {m}"),
});
}
if argvals.len() != m {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: format!("{m} elements"),
actual: format!("{} elements", argvals.len()),
});
}
if ncomp < 1 {
return Err(FdarError::InvalidParameter {
parameter: "ncomp",
message: format!("ncomp must be >= 1, got {ncomp}"),
});
}
if bandwidth < 0.0 || bandwidth.is_nan() {
return Err(FdarError::InvalidParameter {
parameter: "bandwidth",
message: format!("bandwidth must be >= 0, got {bandwidth}"),
});
}
let (centered, means) = {
let c = fdata::center_1d(data);
let mut means = vec![0.0; m];
for j in 0..m {
means[j] = (0..n).map(|i| data[(i, j)]).sum::<f64>() / n as f64;
}
(c, means)
};
let emp = fdata::functional_covariance(data)?;
let smooth_cov = if bandwidth <= 1e-10 {
emp
} else {
gaussian_smooth_cov(&emp, argvals, bandwidth)
};
let w = simpsons_weights(argvals);
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] * smooth_cov[(row, col)] * sqrt_w[col];
}
}
let eigen = DMatrix::from_column_slice(m, m, &c_scaled).symmetric_eigen();
let mut pairs: Vec<(f64, usize)> = (0..eigen.eigenvalues.len())
.map(|k| (eigen.eigenvalues[k], k))
.collect();
pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let pairs: Vec<(f64, usize)> = pairs
.into_iter()
.filter(|&(lam, _)| lam > 0.0)
.take(ncomp)
.collect();
let actual = pairs.len();
let denom = (n - 1) as f64;
let mut singular_values = Vec::with_capacity(actual);
let mut rotation = FdMatrix::zeros(m, actual);
for (comp, &(lam, col_idx)) in pairs.iter().enumerate() {
singular_values.push((lam * denom).sqrt());
for j in 0..m {
let raw = eigen.eigenvectors[(j, col_idx)];
rotation[(j, comp)] = if sqrt_w[j] > 1e-15 {
raw / sqrt_w[j]
} else {
raw
};
}
}
for comp in 0..actual {
let j_max = (0..m)
.max_by(|&a, &b| {
rotation[(a, comp)]
.abs()
.partial_cmp(&rotation[(b, comp)].abs())
.unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or(0);
if rotation[(j_max, comp)] < 0.0 {
for j in 0..m {
rotation[(j, comp)] = -rotation[(j, comp)];
}
}
}
let mut scores = FdMatrix::zeros(n, actual);
for comp in 0..actual {
for i in 0..n {
scores[(i, comp)] = (0..m)
.map(|j| centered[(i, j)] * rotation[(j, comp)] * w[j])
.sum::<f64>();
}
}
Ok(FpcaResult {
singular_values,
rotation,
scores,
mean: means,
centered,
weights: w,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn weighted_l2_sq(c: &[f64], w: &[f64]) -> f64 {
c.iter().zip(w.iter()).map(|(&v, &wj)| v * v * wj).sum()
}
fn approx(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() < tol
}
#[test]
fn test_cross_cov_shape() {
let x = FdMatrix::from_column_major((0..8).map(|i| i as f64).collect(), 4, 2).unwrap();
let y =
FdMatrix::from_column_major((0..12).map(|i| (i as f64).sin()).collect(), 4, 3).unwrap();
let c = cross_covariance(&x, &y).unwrap();
assert_eq!(c.shape(), (2, 3));
}
#[test]
fn test_cross_cov_self() {
let x = FdMatrix::from_column_major(vec![1.0, 2.0, 5.0, 3.0, 0.0, 4.0, 2.0, 7.0], 4, 2)
.unwrap();
let c = cross_covariance(&x, &x).unwrap();
let fc = fdata::functional_covariance(&x).unwrap();
assert_eq!(c.shape(), fc.shape());
for s in 0..2 {
for t in 0..2 {
assert!(
approx(c[(s, t)], fc[(s, t)], 1e-12),
"c[{s},{t}]={} fc={}",
c[(s, t)],
fc[(s, t)]
);
}
}
}
#[test]
fn test_cross_cov_hand_computed() {
let x = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0, 6.0, 8.0], 3, 2).unwrap();
let y = FdMatrix::from_column_major(vec![2.0, 4.0, 6.0, 10.0, 10.0, 13.0], 3, 2).unwrap();
let c = cross_covariance(&x, &y).unwrap();
assert!(approx(c[(0, 0)], 2.0, 1e-12));
assert!(approx(c[(0, 1)], 1.5, 1e-12));
assert!(approx(c[(1, 0)], 4.0, 1e-12));
assert!(approx(c[(1, 1)], 3.0, 1e-12));
}
#[test]
fn test_cross_cov_errors() {
let x = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0], 2, 2).unwrap();
let y3 = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], 3, 2).unwrap();
assert!(cross_covariance(&x, &y3).is_err());
let x1 = FdMatrix::from_column_major(vec![1.0, 2.0], 1, 2).unwrap();
let y1 = FdMatrix::from_column_major(vec![3.0, 4.0], 1, 2).unwrap();
assert!(cross_covariance(&x1, &y1).is_err());
let x0 = FdMatrix::zeros(3, 0);
let y0 = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 3, 1).unwrap();
assert!(cross_covariance(&x0, &y0).is_err());
}
#[test]
fn test_fpca_der_nderiv0() {
let data = FdMatrix::from_column_major(
(0..40)
.map(|i| (i as f64 * 0.13).sin() + (i as f64 * 0.02))
.collect(),
5,
8,
)
.unwrap();
let argvals: Vec<f64> = (0..8).map(|i| i as f64 / 7.0).collect();
let a = fpca_der(&data, 3, &argvals, 0).unwrap();
let b = fdata_to_pc_1d(&data, 3, &argvals).unwrap();
assert_eq!(a.singular_values.len(), b.singular_values.len());
for k in 0..a.singular_values.len() {
assert!(
approx(a.singular_values[k], b.singular_values[k], 1e-12),
"sv[{k}]: {} vs {}",
a.singular_values[k],
b.singular_values[k]
);
}
let (m, nc) = a.rotation.shape();
for j in 0..m {
for k in 0..nc {
assert!(approx(a.rotation[(j, k)], b.rotation[(j, k)], 1e-12));
}
}
}
#[test]
fn test_fpca_der() {
let m = 40usize;
let n = 6usize;
let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
let amps = [0.5, 1.0, 1.5, 2.0, 2.5, 3.0];
let mut vals = vec![0.0; n * m];
for j in 0..m {
for i in 0..n {
let t = argvals[j];
vals[i + j * n] = amps[i] * (2.0 * std::f64::consts::PI * t).sin();
}
}
let data = FdMatrix::from_column_major(vals, n, m).unwrap();
let res = fpca_der(&data, 1, &argvals, 1).unwrap();
let deriv = fdata::deriv_1d(&data, &argvals, 1);
let dc = fdata::center_1d(&deriv);
let mut sse = 0.0;
let mut sst = 0.0;
for i in 0..n {
for j in 0..m {
let recon = res.scores[(i, 0)] * res.rotation[(j, 0)];
let actual = dc[(i, j)];
sse += (actual - recon).powi(2);
sst += actual.powi(2);
}
}
assert!(
sse / sst < 1e-6,
"relative reconstruction error {}",
sse / sst
);
}
#[test]
fn test_fpca_der_errors() {
let argvals: Vec<f64> = (0..8).map(|i| i as f64 / 7.0).collect();
let data = FdMatrix::from_column_major((0..40).map(|i| i as f64).collect(), 5, 8).unwrap();
assert!(fpca_der(&FdMatrix::zeros(0, 0), 1, &[], 1).is_err());
assert!(fpca_der(&data, 1, &argvals[..7], 1).is_err());
assert!(fpca_der(&data, 0, &argvals, 1).is_err());
let thin = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 3, 1).unwrap();
assert!(fpca_der(&thin, 1, &[0.0], 1).is_err());
}
fn sine_sample(n: usize, m: usize, seedish: f64) -> (FdMatrix, Vec<f64>) {
let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
let mut vals = vec![0.0; n * m];
for i in 0..n {
for j in 0..m {
let t = argvals[j];
vals[i + j * n] =
((i as f64 + 1.0) * seedish * t).sin() + 0.3 * (i as f64 + 1.0) * t;
}
}
(FdMatrix::from_column_major(vals, n, m).unwrap(), argvals)
}
#[test]
fn test_dyncorr_identical() {
let (data, argvals) = sine_sample(6, 20, 6.0);
let r = dynamical_correlation(&data, &data, &argvals).unwrap();
assert!(approx(r, 1.0, 1e-10), "dyncorr(x,x) = {r}");
}
#[test]
fn test_dyncorr_negated() {
let (data, argvals) = sine_sample(6, 20, 5.0);
let (n, m) = data.shape();
let mut neg = data.clone();
for i in 0..n {
for j in 0..m {
neg[(i, j)] = -data[(i, j)];
}
}
let r = dynamical_correlation(&data, &neg, &argvals).unwrap();
assert!(approx(r, -1.0, 1e-10), "dyncorr(x,-x) = {r}");
}
#[test]
fn test_dyncorr_range() {
let (x, argvals) = sine_sample(7, 25, 4.0);
let (y, _) = sine_sample(7, 25, 9.0);
let r = dynamical_correlation(&x, &y, &argvals).unwrap();
assert!(
(-1.0 - 1e-9..=1.0 + 1e-9).contains(&r),
"dyncorr out of range: {r}"
);
}
#[test]
fn test_dyncorr_errors() {
let (x, argvals) = sine_sample(5, 10, 3.0);
let (y6, _) = sine_sample(6, 10, 3.0);
assert!(dynamical_correlation(&x, &y6, &argvals).is_err());
let (yc, _) = sine_sample(5, 12, 3.0);
assert!(dynamical_correlation(&x, &yc, &argvals).is_err());
assert!(dynamical_correlation(&x, &x, &argvals[..9]).is_err());
let (x1, a1) = sine_sample(1, 10, 3.0);
assert!(dynamical_correlation(&x1, &x1, &a1).is_err());
}
#[test]
fn test_fsvd_unit_norm() {
let (x, ax) = sine_sample(6, 15, 4.0);
let (y, ay) = sine_sample(6, 12, 7.0);
let res = fsvd(&x, &ax, &y, &ay, 2).unwrap();
let wx = simpsons_weights(&ax);
let wy = simpsons_weights(&ay);
for comp in 0..res.singular_values.len() {
let ln: f64 = (0..15)
.map(|s| res.left_functions[(s, comp)].powi(2) * wx[s])
.sum();
let rn: f64 = (0..12)
.map(|t| res.right_functions[(t, comp)].powi(2) * wy[t])
.sum();
assert!(approx(ln, 1.0, 1e-8), "left norm[{comp}]={ln}");
assert!(approx(rn, 1.0, 1e-8), "right norm[{comp}]={rn}");
}
}
#[test]
fn test_fsvd_rank1() {
let n = 8usize;
let px = 20usize;
let qy = 18usize;
let ax: Vec<f64> = (0..px).map(|i| i as f64 / (px as f64 - 1.0)).collect();
let ay: Vec<f64> = (0..qy).map(|i| i as f64 / (qy as f64 - 1.0)).collect();
let amps: Vec<f64> = (0..n).map(|i| 0.5 + i as f64 * 0.4).collect();
let mut xv = vec![0.0; n * px];
let mut yv = vec![0.0; n * qy];
for i in 0..n {
for j in 0..px {
xv[i + j * n] = amps[i] * (std::f64::consts::PI * ax[j]).sin();
}
for j in 0..qy {
yv[i + j * n] = amps[i] * (std::f64::consts::PI * ay[j]).cos();
}
}
let x = FdMatrix::from_column_major(xv, n, px).unwrap();
let y = FdMatrix::from_column_major(yv, n, qy).unwrap();
let res = fsvd(&x, &ax, &y, &ay, 3).unwrap();
assert!(
res.singular_values[0] > 1e6 * res.singular_values[1].max(1e-14),
"not rank-1 dominant: {:?}",
res.singular_values
);
let c = cross_covariance(&x, &y).unwrap();
let mut sse = 0.0;
let mut sst = 0.0;
for s in 0..px {
for t in 0..qy {
let mut recon = 0.0;
for k in 0..res.singular_values.len() {
recon += res.singular_values[k]
* res.left_functions[(s, k)]
* res.right_functions[(t, k)];
}
sse += (c[(s, t)] - recon).powi(2);
sst += c[(s, t)].powi(2);
}
}
assert!(
sse / sst < 1e-6,
"cross-cov reconstruction rel err {}",
sse / sst
);
}
#[test]
fn test_fsvd_wide_left_gram_branch() {
let n = 8usize;
let px = 14usize;
let qy = 22usize;
let ax: Vec<f64> = (0..px).map(|i| i as f64 / (px as f64 - 1.0)).collect();
let ay: Vec<f64> = (0..qy).map(|i| i as f64 / (qy as f64 - 1.0)).collect();
let amps: Vec<f64> = (0..n).map(|i| 0.5 + i as f64 * 0.4).collect();
let mut xv = vec![0.0; n * px];
let mut yv = vec![0.0; n * qy];
for i in 0..n {
for j in 0..px {
xv[i + j * n] = amps[i] * (std::f64::consts::PI * ax[j]).sin();
}
for j in 0..qy {
yv[i + j * n] = amps[i] * (std::f64::consts::PI * ay[j]).cos();
}
}
let x = FdMatrix::from_column_major(xv, n, px).unwrap();
let y = FdMatrix::from_column_major(yv, n, qy).unwrap();
let res = fsvd(&x, &ax, &y, &ay, 2).unwrap();
assert_eq!(res.left_functions.shape(), (px, 2));
assert_eq!(res.right_functions.shape(), (qy, 2));
let wx = simpsons_weights(&ax);
let wy = simpsons_weights(&ay);
assert!(approx(
weighted_l2_sq(res.left_functions.column(0), &wx),
1.0,
1e-8
));
assert!(approx(
weighted_l2_sq(res.right_functions.column(0), &wy),
1.0,
1e-8
));
let c = cross_covariance(&x, &y).unwrap();
let mut sse = 0.0;
let mut sst = 0.0;
for s in 0..px {
for t in 0..qy {
let mut recon = 0.0;
for k in 0..res.singular_values.len() {
recon += res.singular_values[k]
* res.left_functions[(s, k)]
* res.right_functions[(t, k)];
}
sse += (c[(s, t)] - recon).powi(2);
sst += c[(s, t)].powi(2);
}
}
assert!(
sse / sst < 1e-6,
"wide reconstruction rel err {}",
sse / sst
);
}
#[test]
fn test_fsvd_errors() {
let (x, ax) = sine_sample(5, 10, 3.0);
let (y, ay) = sine_sample(5, 8, 4.0);
let (y6, ay6) = sine_sample(6, 8, 4.0);
assert!(fsvd(&x, &ax, &y6, &ay6, 1).is_err());
assert!(fsvd(&x, &ax, &y, &ay, 0).is_err());
assert!(fsvd(&x, &ax[..9], &y, &ay, 1).is_err());
}
#[test]
fn test_ssvd_dense_limit() {
let (data, argvals) = sine_sample(8, 20, 5.0);
let a = ssvd(&data, 3, &argvals, 1e-12).unwrap();
let b = fdata_to_pc_1d(&data, 3, &argvals).unwrap();
assert_eq!(a.singular_values.len(), b.singular_values.len());
for k in 0..a.singular_values.len() {
let rel = (a.singular_values[k] - b.singular_values[k]).abs()
/ b.singular_values[k].abs().max(1e-12);
assert!(
rel < 1e-4,
"sv[{k}] dense-limit mismatch: {} vs {} (rel {})",
a.singular_values[k],
b.singular_values[k],
rel
);
}
}
#[test]
fn test_ssvd_orthonormality() {
let (data, argvals) = sine_sample(8, 20, 5.0);
let res = ssvd(&data, 3, &argvals, 0.05).unwrap();
let w = simpsons_weights(&argvals);
let nc = res.rotation.shape().1;
for a in 0..nc {
for b in 0..nc {
let ip: f64 = (0..20)
.map(|j| res.rotation[(j, a)] * res.rotation[(j, b)] * w[j])
.sum();
let expected = if a == b { 1.0 } else { 0.0 };
assert!(approx(ip, expected, 1e-6), "⟨φ{a},φ{b}⟩={ip}");
}
}
}
#[test]
fn test_ssvd_errors() {
let (data, argvals) = sine_sample(5, 10, 3.0);
assert!(ssvd(&data, 0, &argvals, 0.1).is_err());
assert!(ssvd(&FdMatrix::zeros(0, 0), 1, &[], 0.1).is_err());
assert!(ssvd(&data, 1, &argvals[..9], 0.1).is_err());
assert!(ssvd(&data, 1, &argvals, -0.1).is_err());
}
#[test]
fn smoke_reexports() {
let (x, argvals) = sine_sample(4, 6, 4.0);
let (y, ay) = sine_sample(4, 6, 6.0);
let _c = crate::cross_covariance(&x, &y).unwrap();
let _f = crate::fpca_der(&x, 1, &argvals, 1).unwrap();
let _d = crate::dynamical_correlation(&x, &y, &argvals).unwrap();
let _s = crate::fsvd(&x, &argvals, &y, &ay, 1).unwrap();
let _v = crate::ssvd(&x, 1, &argvals, 0.1).unwrap();
let _first: &crate::FsvdResult = &_s;
let w = simpsons_weights(&argvals);
let _ = weighted_l2_sq(x.column(0), &w);
}
}