use super::*;
use crate::matrix::traits::{SampleOps, SymmetricTopEigen, TopEigenArgs};
fn with_spectrum(lambda: &[f64], seed: u64) -> DMatrix<f64> {
let n = lambda.len();
let q = DMatrix::<f64>::rnorm_seeded(n, n, seed).qr().q();
let a = &q * DMatrix::from_diagonal(&DVector::from_column_slice(lambda)) * q.transpose();
(&a + a.transpose()) * 0.5
}
fn wigner(n: usize, seed: u64) -> DMatrix<f64> {
let x = DMatrix::<f64>::rnorm_seeded(n, n, seed);
(&x + x.transpose()) / (2.0 * n as f64).sqrt()
}
fn planted(n: usize, spikes: &[f64], sinks: &[f64], seed: u64) -> DMatrix<f64> {
let m = spikes.len() + sinks.len();
let u = DMatrix::<f64>::rnorm_seeded(n, m, seed + 1).qr().q();
let d: Vec<f64> = spikes.iter().chain(sinks).copied().collect();
wigner(n, seed) + &u * DMatrix::from_diagonal(&DVector::from_vec(d)) * u.transpose()
}
fn exact(a: &DMatrix<f64>) -> (DVector<f64>, DMatrix<f64>) {
sorted_eigen(a.clone())
}
fn assert_agrees(a: &DMatrix<f64>, rank: usize, got: &TopEigen<DMatrix<f64>, DVector<f64>>) {
let (values, vectors) = exact(a);
let scale = values[0].abs().max(values[values.len() - 1].abs());
assert_eq!(got.values.len(), rank);
assert_eq!(got.vectors.shape(), (a.nrows(), rank));
assert!(got.converged, "not converged in {} iterations", got.iters);
for i in 0..rank {
let err = (got.values[i] - values[i]).abs();
assert!(
err <= 1e-8 * scale,
"value {i}: {} vs exact {} (err {err})",
got.values[i],
values[i]
);
}
for i in 0..rank {
let gap = if i + 1 < values.len() {
values[i] - values[i + 1]
} else {
f64::INFINITY
};
let gap = if i > 0 {
gap.min(values[i - 1] - values[i])
} else {
gap
};
if gap > 1e-3 * scale {
let cos = got.vectors.column(i).dot(&vectors.column(i)).abs();
assert!(cos >= 1.0 - 1e-8, "vector {i}: cosine {cos}");
}
}
let gram = got.vectors.tr_mul(&got.vectors);
let off = (&gram - DMatrix::identity(rank, rank)).amax();
assert!(off <= 1e-10, "not orthonormal: {off}");
}
#[test]
fn matches_symmetric_eigen_with_planted_spikes() {
let spikes: Vec<f64> = (0..8).map(|i| 20.0 - 2.0 * i as f64).collect();
let a = planted(400, &spikes, &[], 1);
let got = a.top_eigen(8).unwrap();
assert_agrees(&a, 8, &got);
}
#[test]
fn matches_symmetric_eigen_into_the_bulk() {
let a = planted(300, &[9.0, 7.0, 5.0], &[], 2);
let got = a.top_eigen(12).unwrap();
assert_agrees(&a, 12, &got);
}
#[test]
fn large_negative_eigenvalues_are_not_leading() {
let a = planted(400, &[10.0, 8.0, 6.0, 4.0], &[-60.0, -50.0, -40.0], 3);
let got = a.top_eigen(4).unwrap();
assert_agrees(&a, 4, &got);
assert!(got.values.iter().all(|&v| v > 3.0), "{}", got.values);
let mut lambda: Vec<f64> = (0..250).map(|i| -100.0 + 0.3 * i as f64).collect();
lambda.extend([0.5, 1.0, 1.5, 2.0, 2.5]);
let a = with_spectrum(&lambda, 4);
let got = a.top_eigen(5).unwrap();
assert_agrees(&a, 5, &got);
let want = [2.5, 2.0, 1.5, 1.0, 0.5];
for (g, w) in got.values.iter().zip(want) {
assert!((g - w).abs() < 1e-6, "{} vs {w}", got.values);
}
}
#[test]
fn all_negative_spectrum() {
let lambda: Vec<f64> = (0..200).map(|i| -1.0 - i as f64).collect();
let a = with_spectrum(&lambda, 5);
let got = a.top_eigen(6).unwrap();
assert_agrees(&a, 6, &got);
assert!((got.values[0] + 1.0).abs() < 1e-8);
}
#[test]
fn identity_like_spectrum_converges() {
let a = DMatrix::<f64>::identity(100, 100) * 3.0;
let got = a.top_eigen(5).unwrap();
assert!(got.converged);
assert!(got.values.iter().all(|&v| (v - 3.0).abs() < 1e-12));
}
#[test]
fn warm_start_converges_in_fewer_iterations() {
let lambda: Vec<f64> = (0..500_i32)
.map(|i| {
if i < 5 {
-20.0 + i as f64
} else {
0.98f64.powi(i)
}
})
.collect();
let a = with_spectrum(&lambda, 6);
let first = a.top_eigen(20).unwrap();
assert!(first.converged);
let shift = DVector::<f64>::from_fn(500, |i, _| 0.02 * ((i % 7) as f64 - 3.0));
let b = &a + DMatrix::from_diagonal(&shift);
let args = TopEigenArgs::default();
let cold = b.top_eigen_with(20, &args, None).unwrap();
let warm = b.top_eigen_with(20, &args, Some(&first.vectors)).unwrap();
assert_agrees(&b, 20, &warm);
assert!(
warm.iters < cold.iters,
"warm {} vs cold {} iterations",
warm.iters,
cold.iters
);
let again = a.top_eigen_with(20, &args, Some(&first.vectors)).unwrap();
assert_eq!(again.iters, 0);
}
#[test]
fn warm_start_of_the_wrong_height_is_an_error() {
let a = wigner(50, 7);
let w = DMatrix::<f64>::zeros(49, 3);
assert!(a
.top_eigen_with(3, &TopEigenArgs::default(), Some(&w))
.is_err());
}
#[test]
fn rank_zero_and_rank_at_least_n() {
let a = planted(60, &[5.0], &[-9.0], 8);
let none = a.top_eigen(0).unwrap();
assert_eq!(none.values.len(), 0);
assert_eq!(none.vectors.shape(), (60, 0));
let (values, _) = exact(&a);
for rank in [60, 61, 1000] {
let all = a.top_eigen(rank).unwrap();
assert_eq!(all.values.len(), 60);
assert_eq!(all.vectors.shape(), (60, 60));
assert!((&all.values - &values).amax() < 1e-10);
assert!(all.values[59] < -8.0, "negative value kept last");
}
}
#[test]
fn non_square_is_an_error() {
assert!(DMatrix::<f64>::zeros(4, 5).top_eigen(2).is_err());
}
#[test]
fn non_finite_entries_are_an_error() {
let mut a = wigner(80, 12);
a[(3, 5)] = f64::NAN;
a[(5, 3)] = f64::NAN;
assert!(a.top_eigen(4).is_err());
}
#[test]
fn hollow_matrix_with_zero_rayleigh_quotients() {
let (p, q) = (60, 90);
let b = DMatrix::<f64>::rnorm_seeded(p, q, 13);
let mut a = DMatrix::<f64>::zeros(p + q, p + q);
a.view_mut((0, p), (p, q)).copy_from(&b);
a.view_mut((p, 0), (q, p)).copy_from(&b.transpose());
let got = a.top_eigen(5).unwrap();
assert_agrees(&a, 5, &got);
}
#[test]
fn f32_matches_f64() {
let a = planted(300, &[12.0, 9.0, 7.0, 5.0], &[-30.0], 9);
let want = a.top_eigen(4).unwrap();
let a32 = a.map(|x| x as f32);
let args = TopEigenArgs {
tol: 1e-4,
..TopEigenArgs::default()
};
let got = a32.top_eigen_with(4, &args, None).unwrap();
assert!(got.converged);
for i in 0..4 {
assert!((got.values[i] as f64 - want.values[i]).abs() < 1e-3);
let cos: f64 = (0..300)
.map(|j| got.vectors[(j, i)] as f64 * want.vectors[(j, i)])
.sum();
assert!(cos > 1.0 - 1e-4, "vector {i}: cosine {cos}");
}
}
#[test]
fn parallel_product_matches_serial() {
for n in [5, 70, 333] {
let a = wigner(n, n as u64);
let x = DMatrix::<f64>::rnorm_seeded(n, 7, 3);
let err = (sym_times(&a, &x) - &a * &x).amax();
assert!(err < 1e-12, "n = {n}: {err}");
}
}
#[cfg(feature = "tensor")]
#[test]
fn tensor_matches_dmatrix() {
use candle_core::{Device, Tensor};
let a = planted(300, &[10.0, 8.0, 6.0, 5.0, 4.0], &[-25.0], 10);
let want = a.top_eigen(5).unwrap();
let t = a.to_tensor(&Device::Cpu).unwrap();
let got = t.top_eigen(5).unwrap();
assert!(got.converged);
let values: Vec<f64> = got.values.to_vec1().unwrap();
let vectors = DMatrix::<f64>::from_tensor(&got.vectors).unwrap();
for i in 0..5 {
assert!((values[i] - want.values[i]).abs() < 1e-9, "{values:?}");
let cos = vectors.column(i).dot(&want.vectors.column(i));
assert!(cos > 1.0 - 1e-9, "vector {i}: cosine {cos}");
}
let t32 = t.to_dtype(candle_core::DType::F32).unwrap();
let args = TopEigenArgs {
tol: 1e-4,
..TopEigenArgs::default()
};
let warm = t32.top_eigen_with(5, &args, Some(&got.vectors)).unwrap();
assert!(warm.converged);
assert_eq!(warm.values.dtype(), candle_core::DType::F32);
assert_eq!(t.top_eigen(0).unwrap().vectors.dims(), &[300, 0]);
assert_eq!(t.top_eigen(400).unwrap().values.dims(), &[300]);
assert!(Tensor::zeros((3, 4), candle_core::DType::F64, &Device::Cpu)
.unwrap()
.top_eigen(1)
.is_err());
}
#[cfg(feature = "ndarray")]
#[test]
fn ndarray_matches_dmatrix() {
use ndarray::Array2;
let a = planted(300, &[10.0, 8.0, 6.0, 5.0, 4.0], &[-25.0], 10);
let want = a.top_eigen(5).unwrap();
let arr = Array2::from_shape_fn((300, 300), |(i, j)| a[(i, j)]);
let got = arr.top_eigen(5).unwrap();
assert!(got.converged);
assert_eq!(got.vectors.dim(), (300, 5));
for i in 0..5 {
assert!((got.values[i] - want.values[i]).abs() < 1e-9);
let cos: f64 = (0..300)
.map(|j| got.vectors[(j, i)] * want.vectors[(j, i)])
.sum();
assert!(cos > 1.0 - 1e-9, "vector {i}: cosine {cos}");
}
let arr32 = arr.mapv(|x| x as f32);
let args = TopEigenArgs {
tol: 1e-4,
..TopEigenArgs::default()
};
let warm32 = got.vectors.mapv(|x| x as f32);
let warm = arr32.top_eigen_with(5, &args, Some(&warm32)).unwrap();
assert!(warm.converged);
assert!((warm.values[0] as f64 - want.values[0]).abs() < 1e-3);
assert_eq!(arr.top_eigen(0).unwrap().vectors.dim(), (300, 0));
assert_eq!(arr.top_eigen(400).unwrap().values.len(), 300);
assert!(Array2::<f64>::zeros((3, 4)).top_eigen(1).is_err());
}
fn reduced_correlation(n: usize, factors: usize, samples: usize, seed: u64) -> DMatrix<f64> {
let w = DMatrix::<f64>::rnorm_seeded(n, factors, seed) * (0.6 / (factors as f64).sqrt());
let z = DMatrix::<f64>::rnorm_seeded(factors, samples, seed + 1);
let e = DMatrix::<f64>::rnorm_seeded(n, samples, seed + 2) * 0.8;
let mut y = &w * z + e;
for mut row in y.row_iter_mut() {
let mean = row.mean();
row.add_scalar_mut(-mean);
let sd = row.norm();
row /= sd;
}
let mut r = &y * y.transpose();
for i in 0..n {
let comm = (0..n)
.filter(|&j| j != i)
.map(|j| r[(i, j)].abs())
.fold(0.0, f64::max);
r[(i, i)] = comm;
}
r
}
#[test]
#[ignore]
fn top_eigen_benchmark() {
let a = reduced_correlation(2000, 100, 1000, 11);
let t0 = std::time::Instant::now();
let got = a.top_eigen(100).unwrap();
let fast = t0.elapsed();
let warm = a
.top_eigen_with(100, &TopEigenArgs::default(), Some(&got.vectors))
.unwrap();
let t1 = std::time::Instant::now();
let (values, _) = exact(&a);
let full = t1.elapsed();
let err = (0..100)
.map(|i| (got.values[i] - values[i]).abs())
.fold(0.0, f64::max);
println!(
"top_eigen: {fast:?} ({} iters, converged {}), warm restart {} iters; \
symmetric_eigen: {full:?}; max value error {err:e}; λ_min {}",
got.iters, got.converged, warm.iters, values[1999]
);
assert!(got.converged);
assert!(err < 1e-6 * values[0]);
}