use nalgebra::{DMatrix, DVector};
use super::eigen::EigenDecomposition;
use super::marchenko_pastur::MpFit;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DenoiseMethod {
Constant,
Target,
}
#[derive(Debug, Clone)]
pub struct DenoiseResult {
pub matrix: DMatrix<f64>,
pub eigenvalues: DVector<f64>,
pub trace: f64,
pub method: DenoiseMethod,
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn denoise(eigen: &EigenDecomposition, mp_fit: &MpFit, method: DenoiseMethod) -> DenoiseResult {
let original_trace = eigen.trace();
let mut cleaned = eigen.eigenvalues.clone();
match method {
DenoiseMethod::Constant => {
let mut noise_sum = 0.0;
let mut noise_count = 0_usize;
for idx in 0..cleaned.len() {
if cleaned[idx] <= mp_fit.lambda_plus {
noise_sum += cleaned[idx];
noise_count += 1;
}
}
if noise_count > 0 {
let noise_mean = noise_sum / noise_count as f64;
for idx in 0..cleaned.len() {
if cleaned[idx] <= mp_fit.lambda_plus {
cleaned[idx] = noise_mean;
}
}
}
}
DenoiseMethod::Target => {
for idx in 0..cleaned.len() {
if cleaned[idx] <= mp_fit.lambda_plus {
cleaned[idx] = 1.0;
}
}
let current_trace: f64 = cleaned.iter().sum();
if current_trace > f64::EPSILON {
let scale = original_trace / current_trace;
cleaned *= scale;
}
}
}
let lambda_diag = DMatrix::from_diagonal(&cleaned);
let matrix = &eigen.eigenvectors * lambda_diag * eigen.eigenvectors.transpose();
let trace = cleaned.iter().sum();
DenoiseResult {
matrix,
eigenvalues: cleaned,
trace,
method,
}
}
#[must_use]
pub fn renormalize_to_correlation(matrix: &DMatrix<f64>) -> DMatrix<f64> {
let n = matrix.nrows();
let inv_std: DVector<f64> = DVector::from_fn(n, |i, _| {
let d = matrix[(i, i)];
if d > f64::EPSILON {
1.0 / d.sqrt()
} else {
1.0
}
});
let mut corr = matrix.clone();
for i in 0..n {
for j in 0..n {
corr[(i, j)] *= inv_std[i] * inv_std[j];
}
}
corr
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::eigen::eigendecompose;
use crate::math::marchenko_pastur::fit_sigma_sq;
use crate::math::sample_covariance::correlation_matrix;
use approx::assert_relative_eq;
use nalgebra::DMatrix;
fn denoise_from_returns(returns: &DMatrix<f64>, method: DenoiseMethod) -> DenoiseResult {
let cov = correlation_matrix(returns).unwrap();
let eigen = eigendecompose(&cov.correlation).unwrap();
let mp_fit = fit_sigma_sq(&eigen.eigenvalues, cov.q).unwrap();
denoise(&eigen, &mp_fit, method)
}
#[test]
fn test_trace_preservation_constant() {
#[rustfmt::skip]
let returns = DMatrix::from_row_slice(10, 3, &[
0.01, -0.02, 0.03,
-0.01, 0.01, -0.02,
0.02, 0.00, 0.01,
-0.03, 0.02, 0.00,
0.01, -0.01, 0.02,
0.00, 0.03, -0.01,
-0.02, 0.01, 0.03,
0.03, -0.02, -0.01,
-0.01, 0.00, 0.02,
0.02, 0.01, -0.03,
]);
let result = denoise_from_returns(&returns, DenoiseMethod::Constant);
assert_relative_eq!(result.trace, 3.0, epsilon = 1e-10);
}
#[test]
fn test_trace_preservation_target() {
#[rustfmt::skip]
let returns = DMatrix::from_row_slice(10, 3, &[
0.01, -0.02, 0.03,
-0.01, 0.01, -0.02,
0.02, 0.00, 0.01,
-0.03, 0.02, 0.00,
0.01, -0.01, 0.02,
0.00, 0.03, -0.01,
-0.02, 0.01, 0.03,
0.03, -0.02, -0.01,
-0.01, 0.00, 0.02,
0.02, 0.01, -0.03,
]);
let result = denoise_from_returns(&returns, DenoiseMethod::Target);
assert_relative_eq!(result.trace, 3.0, epsilon = 1e-10);
}
#[test]
fn test_denoised_symmetry() {
#[rustfmt::skip]
let returns = DMatrix::from_row_slice(10, 3, &[
0.01, -0.02, 0.03,
-0.01, 0.01, -0.02,
0.02, 0.00, 0.01,
-0.03, 0.02, 0.00,
0.01, -0.01, 0.02,
0.00, 0.03, -0.01,
-0.02, 0.01, 0.03,
0.03, -0.02, -0.01,
-0.01, 0.00, 0.02,
0.02, 0.01, -0.03,
]);
let result = denoise_from_returns(&returns, DenoiseMethod::Constant);
let num_assets = result.matrix.nrows();
for row in 0..num_assets {
for col in 0..num_assets {
assert_relative_eq!(
result.matrix[(row, col)],
result.matrix[(col, row)],
epsilon = 1e-12
);
}
}
}
#[test]
fn test_denoised_psd() {
#[rustfmt::skip]
let returns = DMatrix::from_row_slice(10, 3, &[
0.01, -0.02, 0.03,
-0.01, 0.01, -0.02,
0.02, 0.00, 0.01,
-0.03, 0.02, 0.00,
0.01, -0.01, 0.02,
0.00, 0.03, -0.01,
-0.02, 0.01, 0.03,
0.03, -0.02, -0.01,
-0.01, 0.00, 0.02,
0.02, 0.01, -0.03,
]);
let result = denoise_from_returns(&returns, DenoiseMethod::Constant);
for idx in 0..result.eigenvalues.len() {
assert!(
result.eigenvalues[idx] >= -1e-10,
"eigenvalue {} is negative: {}",
idx,
result.eigenvalues[idx]
);
}
}
#[test]
fn test_pure_noise_approaches_identity() {
let num_obs = 500;
let num_assets = 5;
let mut data = vec![0.0_f64; num_obs * num_assets];
let mut seed: u64 = 42;
let denom = f64::from(1u32 << 31);
for val in &mut data {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
let bits = u32::try_from(seed >> 33).expect("31-bit value after right shift");
*val = f64::from(bits) / denom - 0.5;
}
let returns = DMatrix::from_row_slice(num_obs, num_assets, &data);
let result = denoise_from_returns(&returns, DenoiseMethod::Constant);
for idx in 0..num_assets {
assert_relative_eq!(result.matrix[(idx, idx)], 1.0, epsilon = 0.15);
}
for row in 0..num_assets {
for col in 0..num_assets {
if row != col {
assert!(
result.matrix[(row, col)].abs() < 0.3,
"off-diagonal ({},{}) too large: {}",
row,
col,
result.matrix[(row, col)]
);
}
}
}
}
#[test]
fn test_cleaned_eigenvalues_sorted() {
#[rustfmt::skip]
let returns = DMatrix::from_row_slice(10, 3, &[
0.01, -0.02, 0.03,
-0.01, 0.01, -0.02,
0.02, 0.00, 0.01,
-0.03, 0.02, 0.00,
0.01, -0.01, 0.02,
0.00, 0.03, -0.01,
-0.02, 0.01, 0.03,
0.03, -0.02, -0.01,
-0.01, 0.00, 0.02,
0.02, 0.01, -0.03,
]);
let result = denoise_from_returns(&returns, DenoiseMethod::Constant);
for idx in 1..result.eigenvalues.len() {
assert!(
result.eigenvalues[idx - 1] >= result.eigenvalues[idx] - 1e-10,
"eigenvalues not sorted at index {idx}"
);
}
}
}