use ndarray::{Array1, Array2};
const MAX_SWEEPS: usize = 60;
pub struct JacobiSvd {
pub u: Array2<f64>,
pub s: Array1<f64>,
pub v: Array2<f64>,
}
pub fn jacobi_svd(a: &Array2<f64>) -> Option<JacobiSvd> {
let (m, n) = a.dim();
debug_assert!(
m >= n,
"one-sided Jacobi needs at least as many rows as columns"
);
let mut w = a.clone(); let mut v = Array2::<f64>::eye(n);
let tol = f64::EPSILON * (m as f64).sqrt();
{
let norms: Vec<f64> = (0..n)
.map(|j| (0..m).map(|i| w[[i, j]] * w[[i, j]]).sum::<f64>().sqrt())
.collect();
let biggest = norms.iter().copied().fold(0.0f64, f64::max);
if biggest > 0.0 {
let cutoff = biggest * f64::EPSILON * f64::EPSILON;
for j in 0..n {
if norms[j] <= cutoff {
for i in 0..m {
w[[i, j]] = 0.0;
}
}
}
}
}
for _ in 0..MAX_SWEEPS {
let mut rotations = 0usize;
for p in 0..n.saturating_sub(1) {
for q in (p + 1)..n {
let mut app = 0.0;
let mut aqq = 0.0;
let mut apq = 0.0;
for i in 0..m {
let (x, y) = (w[[i, p]], w[[i, q]]);
app += x * x;
aqq += y * y;
apq += x * y;
}
if !apq.is_finite() || !app.is_finite() || !aqq.is_finite() {
return None;
}
if app <= 0.0 || aqq <= 0.0 {
continue;
}
if apq == 0.0 || apq.abs() <= tol * app.sqrt() * aqq.sqrt() {
continue;
}
rotations += 1;
let tau = (aqq - app) / (2.0 * apq);
let t = if tau >= 0.0 {
1.0 / (tau + (1.0 + tau * tau).sqrt())
} else {
-1.0 / (-tau + (1.0 + tau * tau).sqrt())
};
let c = 1.0 / (1.0 + t * t).sqrt();
let s = c * t;
for i in 0..m {
let (x, y) = (w[[i, p]], w[[i, q]]);
w[[i, p]] = c * x - s * y;
w[[i, q]] = s * x + c * y;
}
for i in 0..n {
let (x, y) = (v[[i, p]], v[[i, q]]);
v[[i, p]] = c * x - s * y;
v[[i, q]] = s * x + c * y;
}
}
}
if rotations == 0 {
break;
}
}
let mut sigma = Array1::<f64>::zeros(n);
for j in 0..n {
let norm = (0..m).map(|i| w[[i, j]] * w[[i, j]]).sum::<f64>().sqrt();
if !norm.is_finite() {
return None;
}
sigma[j] = norm;
}
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&i, &j| {
sigma[j]
.partial_cmp(&sigma[i])
.unwrap_or(std::cmp::Ordering::Equal)
});
let smax = sigma.iter().copied().fold(0.0f64, f64::max);
let floor = f64::EPSILON * smax * (m as f64).sqrt();
let mut u = Array2::<f64>::zeros((m, n));
let mut s_out = Array1::<f64>::zeros(n);
let mut v_out = Array2::<f64>::zeros((n, n));
let mut deficient = Vec::new();
for (new, &old) in order.iter().enumerate() {
s_out[new] = sigma[old];
for i in 0..n {
v_out[[i, new]] = v[[i, old]];
}
if sigma[old] > floor {
let inv = 1.0 / sigma[old];
for i in 0..m {
u[[i, new]] = w[[i, old]] * inv;
}
} else {
deficient.push(new);
}
}
complete_orthonormal_basis(&mut u, &deficient);
Some(JacobiSvd {
u,
s: s_out,
v: v_out,
})
}
fn complete_orthonormal_basis(u: &mut Array2<f64>, deficient: &[usize]) {
let (m, n) = u.dim();
if deficient.is_empty() {
return;
}
let mut used = vec![false; m];
for &j in deficient {
let mut best: Option<(f64, Array1<f64>, usize)> = None;
for cand in 0..m {
if used[cand] {
continue;
}
let mut trial = Array1::<f64>::zeros(m);
trial[cand] = 1.0;
for _ in 0..2 {
for k in 0..n {
if k == j {
continue;
}
let dot: f64 = (0..m).map(|i| u[[i, k]] * trial[i]).sum();
if dot != 0.0 {
for i in 0..m {
trial[i] -= dot * u[[i, k]];
}
}
}
}
let norm = trial.iter().map(|x| x * x).sum::<f64>().sqrt();
if best.as_ref().is_none_or(|(b, _, _)| norm > *b) {
best = Some((norm, trial, cand));
}
if norm > 0.9 {
break;
}
}
if let Some((norm, trial, cand)) = best {
if norm > 0.0 {
used[cand] = true;
for i in 0..m {
u[[i, j]] = trial[i] / norm;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::Lcg;
fn frob(a: &Array2<f64>) -> f64 {
a.iter().map(|v| v * v).sum::<f64>().sqrt()
}
fn recompose(svd: &JacobiSvd) -> Array2<f64> {
let scaled = &svd.u * &svd.s.view().insert_axis(ndarray::Axis(0));
scaled.dot(&svd.v.t())
}
#[test]
fn beats_bidiagonal_qr_on_the_ill_conditioned_counterexample() {
let a = ndarray::arr2(&[
[0.0, 0.0],
[0.0, 0.0],
[0.0, 0.0],
[0.8977478193857099, -1.0],
[0.0, 7255691.862956913],
]);
let svd = jacobi_svd(&a).expect("should converge");
let err = frob(&(&recompose(&svd) - &a)) / frob(&a);
assert!(
err < 1e-15,
"Jacobi reconstruction {err:.3e} is no better than bidiagonal QR's 1.3e-9"
);
let orth = crate::dense::orthogonality_error(&svd.u.view());
assert!(orth < 1e-13, "||U^T U - I|| = {orth:.3e}");
}
#[test]
fn matches_a_reference_on_well_conditioned_input() {
let mut rng = Lcg::new(3);
let a = Array2::from_shape_fn((12, 8), |_| rng.signed());
let svd = jacobi_svd(&a).unwrap();
let m = nalgebra::DMatrix::from_fn(12, 8, |i, j| a[[i, j]]);
let mut want: Vec<f64> = m.singular_values().iter().copied().collect();
want.sort_by(|x, y| y.partial_cmp(x).unwrap());
for (i, &g) in svd.s.iter().enumerate() {
approx::assert_relative_eq!(g, want[i], max_relative = 1e-12);
}
let err = frob(&(&recompose(&svd) - &a)) / frob(&a);
assert!(err < 1e-14, "reconstruction {err:.3e}");
}
#[test]
fn rank_deficient_still_yields_an_orthonormal_basis() {
let mut rng = Lcg::new(5);
let mut a = Array2::from_shape_fn((10, 5), |_| rng.signed());
let c0 = a.column(0).to_owned();
a.column_mut(2).assign(&c0);
a.column_mut(4).fill(0.0);
let svd = jacobi_svd(&a).unwrap();
assert!(
svd.s[4] < 1e-14,
"expected a zero singular value, got {}",
svd.s[4]
);
let orth = crate::dense::orthogonality_error(&svd.u.view());
assert!(
orth < 1e-12,
"||U^T U - I|| = {orth:.3e} on a deficient operand"
);
let err = frob(&(&recompose(&svd) - &a)) / frob(&a);
assert!(err < 1e-14, "reconstruction {err:.3e}");
}
#[test]
fn all_zero_operand() {
let a = Array2::<f64>::zeros((6, 3));
let svd = jacobi_svd(&a).unwrap();
assert!(svd.s.iter().all(|&v| v == 0.0));
let orth = crate::dense::orthogonality_error(&svd.u.view());
assert!(
orth < 1e-12,
"||U^T U - I|| = {orth:.3e} on the zero matrix"
);
}
#[test]
fn single_column() {
let a = ndarray::arr2(&[[3.0], [4.0]]);
let svd = jacobi_svd(&a).unwrap();
approx::assert_relative_eq!(svd.s[0], 5.0, max_relative = 1e-14);
}
#[test]
fn rejects_non_finite() {
let a = ndarray::arr2(&[[1.0, 0.0], [0.0, f64::NAN]]);
assert!(jacobi_svd(&a).is_none());
}
}