from schur import schur
from linalg import test_matrix
SVD_SWEEPS = 60
SVD_TOL = 1.0e-15
#| Decompose `A`, which is `m` by `n` with `m >= n`. `A` is overwritten with
#| `U S`, so its columns come back scaled by the singular values; `s` gets the
#| singular values and `V` the right vectors, in whatever order the sweeps left
#| them. Returns how many sweeps it took.
def svd_jacobi(A, s, V, m, n) {
for i to n {
for j to n {
V[i, j] = 0.0
}
V[i, i] = 1.0
}
sweeps = 0
for sweep to SVD_SWEEPS {
sweeps = sweeps + 1
off = 0.0
for p to n - 1 {
for qq to n - 1 - p {
q = p + 1 + qq
alpha = 0.0
beta = 0.0
gamma = 0.0
for i to m {
alpha = alpha + A[i, p] * A[i, p]
beta = beta + A[i, q] * A[i, q]
gamma = gamma + A[i, p] * A[i, q]
}
scale = sqrt(alpha * beta)
if scale > 0.0 {
off = max(off, abs(gamma) / scale)
if abs(gamma) > SVD_TOL * scale {
# the rotation that makes these two columns orthogonal
zeta = (beta - alpha) / (2.0 * gamma)
t = 1.0 / (abs(zeta) + sqrt(1.0 + zeta * zeta))
if zeta < 0.0 {
t = -t
}
c = 1.0 / sqrt(1.0 + t * t)
sn = c * t
for i to m {
ap = A[i, p]
aq = A[i, q]
A[i, p] = c * ap - sn * aq
A[i, q] = sn * ap + c * aq
}
for i to n {
vp = V[i, p]
vq = V[i, q]
V[i, p] = c * vp - sn * vq
V[i, q] = sn * vp + c * vq
}
}
}
}
}
if off < SVD_TOL {
break
}
}
for j to n {
total = 0.0
for i to m {
total = total + A[i, j] * A[i, j]
}
s[j] = sqrt(total)
}
return 1.0 * sweeps
}
#| The full decomposition, sorted: `U` is `m` by `n`, `s` descends, and
#| `A = U diag(s) V^T`. `A` is left alone.
def svd(A, U, s, V, m, n) {
work = zeros(m, n)
for i to m {
for j to n {
work[i, j] = A[i, j]
}
}
junk = svd_jacobi(work, s, V, m, n)
# sort descending, carrying the columns of both factors along
for a to n {
best = a
for b to n - a {
j = a + b
if s[j] > s[best] {
best = j
}
}
if best != a {
tmp = s[a]
s[a] = s[best]
s[best] = tmp
for i to m {
tw = work[i, a]
work[i, a] = work[i, best]
work[i, best] = tw
}
for i to n {
tv = V[i, a]
V[i, a] = V[i, best]
V[i, best] = tv
}
}
}
# each column over its singular value; a zero one gets a unit vector
U[:, :] = work / s
for j to n {
if not (s[j] > 0.0) {
U[:, j] = 0.0
U[j, j] = 1.0
}
}
return 0.0
}
#| The largest entry of `A - U diag(s) V^T`.
def svd_residual(A: Arr, U: Arr, s: Arr, V: Arr, m, n) {
return max(abs((U[0:m, 0:n] * s[0:n]) @ transpose(V[0:n, 0:n]) - A[0:m, 0:n]))
}
#| How far the `n` columns of an `m` by `n` matrix are from orthonormal.
def svd_orthonormal(M: Arr, m, n) {
C = M[0:m, 0:n]
return max(abs(transpose(C) @ C - eye(n)))
}
#| A matrix with the singular values you ask for: `Q1 diag(want) Q2^T`, with the
#| two orthogonal factors taken from the Schur vectors of two unrelated
#| matrices, so nothing about the construction lines up with the axes.
def svd_with_values(want: Arr, n, seed) {
T = zeros(n, n)
Q1 = zeros(n, n)
Q2 = zeros(n, n)
junk = schur(test_matrix(n, seed), T, Q1)
junk = schur(test_matrix(n, seed + 1000.0), T, Q2)
return (Q1 * want) @ transpose(Q2)
}
test "a diagonal matrix has its own entries as singular values" {
n = 4
A = diag([3.0, -5.0, 0.5, 2.0])
U = zeros(n, n)
s = zeros(n)
V = zeros(n, n)
junk = svd(A, U, s, V, n, n)
assert_near(s[0], 5.0, 1.0e-13)
assert_near(s[1], 3.0, 1.0e-13)
assert_near(s[2], 2.0, 1.0e-13)
assert_near(s[3], 0.5, 1.0e-13)
assert_lt(svd_residual(A, U, s, V, n, n), 1.0e-13)
}
test "U S V^T puts a square matrix back, with both factors orthonormal" {
worst_res = 0.0
worst_u = 0.0
worst_v = 0.0
ncases = 10
for si to ncases {
n = 2 + mod(si, 7.0)
A = test_matrix(n, 17.0 + 31.0 * si)
U = zeros(n, n)
s = zeros(n)
V = zeros(n, n)
junk = svd(A, U, s, V, n, n)
worst_res = max(worst_res, svd_residual(A, U, s, V, n, n))
worst_u = max(worst_u, svd_orthonormal(U, n, n))
worst_v = max(worst_v, svd_orthonormal(V, n, n))
for j to n - 1 {
assert_lt(-1.0e-15, s[j] - s[j + 1])
}
}
assert_lt(worst_res, 1.0e-13)
assert_lt(worst_u, 1.0e-13)
assert_lt(worst_v, 1.0e-13)
}
test "a tall matrix works too, and its singular values are the square roots of eig(A^T A)" {
m = 9
n = 4
A = zeros(m, n)
B = test_matrix(m, 71.0)
for i to m {
for j to n {
A[i, j] = B[i, j]
}
}
U = zeros(m, n)
s = zeros(n)
V = zeros(n, n)
junk = svd(A, U, s, V, m, n)
assert_lt(svd_residual(A, U, s, V, m, n), 1.0e-13)
assert_lt(svd_orthonormal(U, m, n), 1.0e-13)
G = transpose(A) @ A
vals = zeros(n)
vecs = zeros(n, n)
eig_sym(G, vals, vecs)
# eig_sym returns them ascending, svd descending
for j to n {
assert_near(s[j], sqrt(vals[n - 1 - j]), 1.0e-11)
}
}
test "the small singular values survive, where going through A^T A loses them" {
n = 5
want = zeros(n)
want[0] = 1.0
want[1] = 1.0e-3
want[2] = 1.0e-6
want[3] = 1.0e-9
want[4] = 1.0e-12
A = svd_with_values(want, n, 23.0)
U = zeros(n, n)
s = zeros(n)
V = zeros(n, n)
junk = svd(A, U, s, V, n, n)
# the squared route: eigenvalues of A^T A, whose condition number is squared
G = transpose(A) @ A
vals = zeros(n)
vecs = zeros(n, n)
eig_sym(G, vals, vecs)
jac = zeros(n)
sq = zeros(n)
for j to n {
jac[j] = abs(s[j] - want[j]) / want[j]
sq[j] = abs(sqrt(max(vals[n - 1 - j], 0.0)) - want[j]) / want[j]
}
assert_lt(jac[0], 1.0e-14)
assert_lt(jac[1], 1.0e-13)
assert_lt(jac[2], 1.0e-10)
assert_lt(jac[3], 1.0e-6)
assert_lt(jac[4], 1.0e-3)
# the squared route has thrown away the fourth and the fifth entirely
assert_lt(0.1, sq[3])
assert_lt(0.5, sq[4])
# and is worse at every level below the largest
for j to n - 1 {
assert_lt(jac[j + 1], sq[j + 1])
}
}
test "a rank-deficient matrix reports the rank it has" {
n = 6
want = zeros(n)
want[0] = 4.0
want[1] = 2.0
want[2] = 0.5
A = svd_with_values(want, n, 137.0)
U = zeros(n, n)
s = zeros(n)
V = zeros(n, n)
junk = svd(A, U, s, V, n, n)
assert_near(s[0], 4.0, 1.0e-12)
assert_near(s[1], 2.0, 1.0e-12)
assert_near(s[2], 0.5, 1.0e-12)
rank = 3
for j to n - rank {
assert_lt(s[rank + j], 1.0e-14)
}
assert_lt(svd_residual(A, U, s, V, n, n), 1.0e-13)
}
# SPDX-License-Identifier: MIT OR Apache-2.0
# SPDX-FileCopyrightText: 2026 The Viv authors