use crate::linalg::generic::mat_symmetric_eigen;
#[must_use]
#[derive(Clone, Copy, Debug)]
pub struct RegularizationReport<const N: usize> {
pub cov: [[f64; N]; N],
pub n_clipped: usize,
pub max_clip_magnitude: f64,
pub clipped_indices: [bool; N],
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RegularizationError {
EigendecompositionFailed,
NonFiniteInput,
}
impl std::fmt::Display for RegularizationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EigendecompositionFailed => write!(f, "eigendecomposition failed"),
Self::NonFiniteInput => write!(f, "input matrix contains NaN/Inf"),
}
}
}
impl std::error::Error for RegularizationError {}
#[allow(clippy::needless_range_loop)]
pub fn nearest_psd<const N: usize>(
cov: &[[f64; N]; N],
min_eig: f64,
) -> Result<RegularizationReport<N>, RegularizationError> {
if cov.iter().flatten().any(|x| !x.is_finite()) {
return Err(RegularizationError::NonFiniteInput);
}
let (eigs, v) =
mat_symmetric_eigen(cov).ok_or(RegularizationError::EigendecompositionFailed)?;
let mut clipped_indices = [false; N];
let mut n_clipped = 0;
let mut max_clip_magnitude = 0.0_f64;
let mut new_eigs = [0.0_f64; N];
for i in 0..N {
if eigs[i] < min_eig {
new_eigs[i] = min_eig;
clipped_indices[i] = true;
n_clipped += 1;
let clip_mag = min_eig - eigs[i];
if clip_mag > max_clip_magnitude {
max_clip_magnitude = clip_mag;
}
} else {
new_eigs[i] = eigs[i];
}
}
let mut new_cov = [[0.0_f64; N]; N];
for i in 0..N {
for j in 0..N {
let mut s = 0.0;
for k in 0..N {
s += v[i][k] * new_eigs[k] * v[j][k];
}
new_cov[i][j] = s;
}
}
Ok(RegularizationReport {
cov: new_cov,
n_clipped,
max_clip_magnitude,
clipped_indices,
})
}
pub fn eigenvalue_floor<const N: usize>(
cov: &[[f64; N]; N],
min_eig: f64,
) -> Result<RegularizationReport<N>, RegularizationError> {
nearest_psd(cov, min_eig)
}
#[inline]
#[allow(clippy::needless_range_loop)]
pub fn tikhonov<const N: usize>(cov: &[[f64; N]; N], alpha: f64) -> [[f64; N]; N] {
let mut out = *cov;
for i in 0..N {
out[i][i] += alpha;
}
out
}
#[must_use]
#[derive(Clone, Copy, Debug)]
pub struct TikhonovReport<const N: usize> {
pub cov: [[f64; N]; N],
pub alpha: f64,
pub condition_number_before: f64,
pub condition_number_after: f64,
}
#[allow(clippy::needless_range_loop)]
pub fn tikhonov_with_report<const N: usize>(cov: &[[f64; N]; N], alpha: f64) -> TikhonovReport<N> {
let condition_number_before = crate::linalg::generic::condition_number(cov);
let damped = tikhonov(cov, alpha);
let condition_number_after = crate::linalg::generic::condition_number(&damped);
TikhonovReport {
cov: damped,
alpha,
condition_number_before,
condition_number_after,
}
}
#[cfg(test)]
#[allow(clippy::needless_range_loop)]
mod tests {
use super::*;
fn assert_approximately_psd<const N: usize>(a: &[[f64; N]; N], tol: f64) {
let (eigs, _) = mat_symmetric_eigen(a).expect("eigendecomp");
for &lam in &eigs {
assert!(lam >= -tol, "eigenvalue {lam} is below -{tol}");
}
}
#[test]
fn nearest_psd_indefinite_input_yields_psd() {
let a = [[1.5, 1.5], [1.5, 0.5]];
let report = nearest_psd::<2>(&a, 0.0).unwrap();
assert!(report.n_clipped >= 1);
assert!(report.max_clip_magnitude > 0.0);
assert_approximately_psd(&report.cov, 1e-12);
}
#[test]
fn nearest_psd_clipped_indices_match() {
let a = [[3.0, 0.0, 0.0], [0.0, -2.0, 0.0], [0.0, 0.0, 1.0]];
let report = nearest_psd::<3>(&a, 0.0).unwrap();
assert_eq!(report.n_clipped, 1);
let mut found_clip = false;
for i in 0..3 {
if report.clipped_indices[i] {
found_clip = true;
}
}
assert!(found_clip);
assert_approximately_psd(&report.cov, 1e-12);
}
#[test]
fn nearest_psd_already_psd_min_eig_zero_passthrough() {
let a = [[2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]];
let report = nearest_psd::<3>(&a, 0.0).unwrap();
assert_eq!(report.n_clipped, 0);
assert_eq!(report.max_clip_magnitude, 0.0);
for i in 0..3 {
assert!(!report.clipped_indices[i]);
for j in 0..3 {
assert!(
(report.cov[i][j] - a[i][j]).abs() < 1e-12,
"({i},{j}): {} vs {}",
report.cov[i][j],
a[i][j]
);
}
}
}
#[test]
fn nearest_psd_psd_near_singular_eigenvectors_preserved() {
let a = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1e-20]];
let report = nearest_psd::<3>(&a, 1e-15).unwrap();
assert!(report.n_clipped == 1);
assert!(report.max_clip_magnitude > 0.0);
let (_eigs_out, v_out) = mat_symmetric_eigen(&report.cov).unwrap();
for k in 0..3 {
let mut max = 0.0_f64;
let mut max_count = 0;
for i in 0..3 {
let abs_v = v_out[i][k].abs();
if abs_v > 0.99 {
max_count += 1;
if abs_v > max {
max = abs_v;
}
}
}
assert_eq!(max_count, 1, "eigenvector {k} not axis-aligned");
assert!(max > 0.99);
}
}
#[test]
fn nearest_psd_non_finite_input() {
let a = [[1.0, f64::NAN], [f64::NAN, 1.0]];
assert_eq!(
nearest_psd::<2>(&a, 0.0).err(),
Some(RegularizationError::NonFiniteInput)
);
}
#[test]
fn nearest_psd_floor_positive_clips_all_smaller() {
let a = [[0.5, 0.0], [0.0, 0.1]];
let report = nearest_psd::<2>(&a, 1.0).unwrap();
assert_eq!(report.n_clipped, 2);
let (eigs, _) = mat_symmetric_eigen(&report.cov).unwrap();
for &lam in &eigs {
assert!(lam >= 1.0 - 1e-12, "lambda = {lam}");
}
}
#[test]
fn nearest_psd_preserves_symmetry() {
let a = [[4.0, 1.0, 0.5], [1.0, 3.0, 0.2], [0.5, 0.2, -0.5]];
let report = nearest_psd::<3>(&a, 0.0).unwrap();
for i in 0..3 {
for j in 0..3 {
assert!(
(report.cov[i][j] - report.cov[j][i]).abs() < 1e-12,
"({i},{j}) vs ({j},{i}): {} vs {}",
report.cov[i][j],
report.cov[j][i]
);
}
}
}
#[test]
fn eigenvalue_floor_alias_matches_nearest_psd() {
let a = [[2.0, 0.5], [0.5, 0.1]];
let r1 = nearest_psd::<2>(&a, 0.05).unwrap();
let r2 = eigenvalue_floor::<2>(&a, 0.05).unwrap();
for i in 0..2 {
for j in 0..2 {
assert_eq!(r1.cov[i][j], r2.cov[i][j]);
}
}
assert_eq!(r1.n_clipped, r2.n_clipped);
}
#[test]
fn tikhonov_adds_alpha_to_diagonal() {
let a = [[1.0, 0.5], [0.5, 2.0]];
let out = tikhonov::<2>(&a, 0.1);
assert!((out[0][0] - 1.1).abs() < 1e-15);
assert!((out[1][1] - 2.1).abs() < 1e-15);
assert_eq!(out[0][1], a[0][1]);
assert_eq!(out[1][0], a[1][0]);
}
#[test]
fn tikhonov_alpha_zero_is_identity() {
let a = [[1.0, 0.5, 0.0], [0.5, 2.0, 0.3], [0.0, 0.3, 1.5]];
let out = tikhonov::<3>(&a, 0.0);
for i in 0..3 {
for j in 0..3 {
assert_eq!(out[i][j], a[i][j]);
}
}
}
#[test]
fn tikhonov_makes_indefinite_matrix_pd_when_alpha_large_enough() {
let a = [[1.0, 2.0], [2.0, 1.0]];
let out = tikhonov::<2>(&a, 2.0);
let (eigs, _) = mat_symmetric_eigen(&out).unwrap();
for &lam in &eigs {
assert!(lam > 0.0, "eigenvalue {lam} not positive");
}
}
#[test]
fn tikhonov_with_report_well_conditioned_input_alpha_zero_passthrough() {
let a = [[2.0, 0.5], [0.5, 3.0]];
let report = tikhonov_with_report::<2>(&a, 0.0);
assert_eq!(report.alpha, 0.0);
assert!(report.condition_number_before.is_finite());
assert!(report.condition_number_after.is_finite());
assert!(
(report.condition_number_after - report.condition_number_before).abs() < 1e-9,
"κ_before={}, κ_after={}",
report.condition_number_before,
report.condition_number_after
);
}
#[test]
fn tikhonov_with_report_singular_input_becomes_finite_after_damping() {
let a = [[1.0, 1.0], [1.0, 1.0]];
let report = tikhonov_with_report::<2>(&a, 1.0);
assert!(report.condition_number_before.is_infinite());
assert!(report.condition_number_after.is_finite());
assert!(
(report.condition_number_after - 3.0).abs() < 1e-6,
"κ_after = {}",
report.condition_number_after
);
}
#[test]
fn tikhonov_with_report_alpha_too_small_doesnt_help() {
let a = [[1.0_f64, 1.0], [1.0, 1.0]];
let report = tikhonov_with_report::<2>(&a, 1e-20);
assert!(report.condition_number_after > 1e15);
}
#[test]
fn nearest_psd_minimizes_frobenius_distance() {
let a = [[1.0, 2.0, 0.5], [2.0, -1.0, 0.3], [0.5, 0.3, 0.5]];
let (eigs, _) = mat_symmetric_eigen(&a).unwrap();
let report = nearest_psd::<3>(&a, 0.0).unwrap();
let predicted_dist_sq: f64 = eigs.iter().filter(|&&l| l < 0.0).map(|&l| l * l).sum();
let mut actual_dist_sq = 0.0;
for i in 0..3 {
for j in 0..3 {
let d = a[i][j] - report.cov[i][j];
actual_dist_sq += d * d;
}
}
assert!(
(actual_dist_sq - predicted_dist_sq).abs() / predicted_dist_sq < 1e-10,
"Frobenius distance²: actual {actual_dist_sq}, predicted {predicted_dist_sq}"
);
}
#[test]
fn nearest_psd_min_eig_positive_minimizes_clipped_squared_sum() {
let a = [[2.0, 0.0, 0.0], [0.0, 0.5, 0.0], [0.0, 0.0, 0.1]];
let min_eig = 1.0_f64;
let (eigs, _) = mat_symmetric_eigen(&a).unwrap();
let report = nearest_psd::<3>(&a, min_eig).unwrap();
let predicted_dist_sq: f64 = eigs
.iter()
.filter(|&&l| l < min_eig)
.map(|&l| (min_eig - l).powi(2))
.sum();
let mut actual_dist_sq = 0.0;
for i in 0..3 {
for j in 0..3 {
let d = a[i][j] - report.cov[i][j];
actual_dist_sq += d * d;
}
}
assert!(
(actual_dist_sq - predicted_dist_sq).abs() / predicted_dist_sq < 1e-10,
"actual {actual_dist_sq} vs predicted {predicted_dist_sq}"
);
}
}