Files
tensor/linalg/decomp3.go
T

344 lines
11 KiB
Go
Raw Permalink Normal View History

2026-09-03 10:00:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package linalg
import (
"math"
"math/cmplx"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// core.Complex decompositions. The real `Eigen` tridiagonalises a symmetric
// matrix with Householder reflections, a construction that has no
// direct complex analogue; `EigenComplex` instead runs the Jacobi
// iteration, which generalises cleanly to Hermitian matrices through a
// two-step rotation: a complex phase turn that makes the offending
// off-diagonal entry real, then a real plane rotation that zeroes it.
// Every step preserves the spectrum exactly and the iteration inherits
// the real Jacobi convergence argument, at the usual price of
// O(n³) per sweep.
//
// `SVDComplex` builds on `EigenComplex`: the singular vectors of A are
// the eigenvectors of the Hermitian A^H·A, the singular values are
// their square roots, and the left vectors follow as A·v/σ, completed
// through the null space by Gram-Schmidt. Squaring the spectrum costs
// accuracy on very small singular values, which the doc contract
// states plainly.
// EigenComplex returns the eigenvalues and eigenvectors of a complex
// Hermitian matrix. Values are real, sorted ascending; vectors has
// shape (n, n) with column j the unit eigenvector for values[j].
// The matrix must be square, complex and Hermitian within a scale-
// relative 1e-12 tolerance. Real symmetric matrices should use the
// faster `Eigen`.
func EigenComplex(a *core.Array) (values, vectors *core.Array, err error) {
if a.Dtype() != core.Complex {
return nil, nil, base.Errf("EigenComplex: needs a complex matrix, got dtype %s", a.Dtype())
}
if a.NDim() != 2 || a.Shape()[0] != a.Shape()[1] {
return nil, nil, base.Errf("EigenComplex: needs a square 2-D matrix, got shape %s", base.ShapeText(a.Shape()))
}
n := a.Shape()[0]
if n == 0 {
return nil, nil, base.Errf("EigenComplex: zero-sized matrix, got shape %s", base.ShapeText(a.Shape()))
}
h := make([]complex128, n*n)
if a.Dtype() == core.Complex && !a.Strided() && len(a.RawComplexes()) == n*n {
copy(h, a.RawComplexes())
} else {
for i := range n * n {
h[i] = a.ComplexAt(i)
}
}
if !hermitianOK(h, n) {
return nil, nil, base.Errf("EigenComplex: matrix is not Hermitian within 1e-12 tolerance")
}
// The Jacobi sweep's convergence test is a sum of squared
// magnitudes: outside the safe window it overflows to +Inf, which
// makes every sweep look already converged, or underflows to 0,
// which makes the threshold 0 and stops on the first check, and
// either way the raw diagonal is returned as the spectrum. The
// matrix is moved into the window and the values, which carry the
// scale, are moved back before they are returned; the vectors are
// scale-free.
ws := windowScale(maxMagComplex(h))
if ws != 1 {
scaleComplexes(h, ws)
}
// The accumulated similarity: columns of v are eigenvectors.
v := eyeComplex(n)
if err := hermitianJacobi(h, v, n); err != nil {
return nil, nil, base.Errf("EigenComplex: %w", err)
}
vals := make([]float64, n)
for i := range n {
vals[i] = real(h[i*n+i])
}
idx := sortAscIndices(vals)
outVals := make([]float64, n)
outVecs := make([]complex128, n*n)
for j := range n {
outVals[j] = vals[idx[j]]
for i := range n {
outVecs[i*n+j] = v[i*n+idx[j]]
}
}
if ws != 1 {
unscaleFloats(outVals, ws)
}
valuesArr := floatsToArray(outVals, []int{n})
vecArr := core.New(core.Complex, []int{n, n}...)
copy(vecArr.RawComplexes(), outVecs)
return valuesArr, vecArr, nil
}
// hermitianJacobi diagonalises a Hermitian matrix in place, sweeping
// all pairs until the off-diagonal mass is at rounding level. The
// eigenvectors accumulate into v. An exhausted sweep without reaching
// the threshold is an error, never a silently unconverged spectrum.
func hermitianJacobi(h []complex128, v []complex128, n int) error {
norm := 0.0
for i := range n * n {
norm += real(h[i])*real(h[i]) + imag(h[i])*imag(h[i])
}
norm = math.Sqrt(norm)
// A purely relative convergence threshold: an absolute floor such
// as max(1, norm) would declare a Hermitian matrix of norm below
// the floor already diagonal and return its raw diagonal, identity
// eigenvectors included.
threshold := 1e-13 * norm
offMass := func() float64 {
off := 0.0
for p := range n {
for q := p + 1; q < n; q++ {
z := h[p*n+q]
off += real(z)*real(z) + imag(z)*imag(z)
}
}
return math.Sqrt(off)
}
for range 60 {
if offMass() <= threshold {
return nil
}
for p := range n {
for q := p + 1; q < n; q++ {
z := h[p*n+q]
if base.AbsComplex(z) <= threshold {
continue
}
// Step 1: a diagonal phase turn makes the pair entry
// real and positive.
if imag(z) != 0 || real(z) < 0 {
alpha := cmplx.Phase(z)
rot := cmplx.Exp(complex(0, -alpha))
// The similarity h = Dᴴ·h·D turns the pair entry
// real; V = V·D scales the p-th component of every
// vector.
for j := range n {
h[p*n+j] *= rot
}
for i := range n {
h[i*n+p] *= cmplx.Conj(rot)
}
for i := range n {
v[i*n+p] *= cmplx.Conj(rot)
}
z = h[p*n+q]
}
// Step 2: the real Jacobi rotation that zeroes the now
// real entry, applied to rows and columns p, q.
tau := (real(h[q*n+q]) - real(h[p*n+p])) / (2 * real(z))
var t float64
if tau >= 0 {
t = 1 / (tau + math.Sqrt(1+tau*tau))
} else {
t = -1 / (-tau + math.Sqrt(1+tau*tau))
}
c := 1 / math.Sqrt(1+t*t)
s := t * c
cr, sr := complex(c, 0), complex(s, 0)
// h = Gᴴ·h·G mixes rows and columns p, q; V = V·G
// mixes the p, q components of every eigenvector.
for j := range n {
hp, hq := h[p*n+j], h[q*n+j]
h[p*n+j] = cr*hp - sr*hq
h[q*n+j] = sr*hp + cr*hq
}
for i := range n {
hp, hq := h[i*n+p], h[i*n+q]
h[i*n+p] = cr*hp - sr*hq
h[i*n+q] = sr*hp + cr*hq
}
for i := range n {
vp, vq := v[i*n+p], v[i*n+q]
v[i*n+p] = cr*vp - sr*vq
v[i*n+q] = sr*vp + cr*vq
}
h[p*n+q] = 0
h[q*n+p] = 0
}
}
}
if offMass() <= threshold {
return nil
}
return base.Errf("the Jacobi sweep failed to converge on a %d×%d Hermitian matrix in 60 passes", n, n)
}
// hermitianOK reports whether every entry satisfies a[i][j] equals
// conj(a[j][i]) within a purely relative tolerance: an absolute floor
// would admit an asymmetry that is a large fraction of a small-scale
// matrix, so the guard would approve a matrix that is not Hermitian
// whatever the size of its entries.
func hermitianOK(h []complex128, n int) bool {
scale := 0.0
for _, z := range h {
if a := base.AbsComplex(z); a > scale {
scale = a
}
}
tol := 1e-12 * scale
for i := range n {
for j := i; j < n; j++ {
z, m := h[i*n+j], h[j*n+i]
if d := base.AbsComplex(z - cmplx.Conj(m)); d > tol {
return false
}
}
}
return true
}
func eyeComplex(n int) []complex128 {
out := make([]complex128, n*n)
for i := range n {
out[i*n+i] = 1
}
return out
}
// SVDComplex returns the thin singular value decomposition of a
// complex m×n matrix: A = U·diag(Σ)·Vᴴ with Σ sorted descending. U
// has shape (m, n), sigma (n,) and vᴴ (n, n), matching the real `SVD`'s
// shapes. Wide inputs (m < n) decompose the conjugate transpose and
// swap the factors. Because the singular values come from the
// eigenvalues of Aᴴ·A, their accuracy on very small singular values is
// that of a squared condition, fine for rank decisions, weaker for
// resolving near-null directions.
func SVDComplex(a *core.Array) (u, sigma, vH *core.Array, err error) {
if a.Dtype() != core.Complex {
return nil, nil, nil, base.Errf("SVDComplex: needs a complex matrix, got dtype %s", a.Dtype())
}
if a.NDim() != 2 {
return nil, nil, nil, base.Errf("SVDComplex: needs a 2-D matrix, got shape %s", base.ShapeText(a.Shape()))
}
m, n := a.Shape()[0], a.Shape()[1]
if m == 0 || n == 0 {
return nil, nil, nil, base.Errf("SVDComplex: zero-sized matrix, got shape %s", base.ShapeText(a.Shape()))
}
flat := make([]complex128, m*n)
if a.Dtype() == core.Complex && !a.Strided() && len(a.RawComplexes()) == m*n {
copy(flat, a.RawComplexes())
} else {
for i := range m * n {
flat[i] = a.ComplexAt(i)
}
}
// The bidiagonalisation's reflector norms are sums of raw squares:
// outside the safe window a tiny matrix has norm 0, which skips the
// reflector and silently zeroes the below-diagonal mass, and a huge
// one overflows to +Inf, which puts NaN in beta and the update. The
// matrix is moved into the window and the singular values, which
// carry the scale, are moved back on return; the unitary factors are
// scale-free.
ws := windowScale(maxMagComplex(flat))
if ws != 1 {
scaleComplexes(flat, ws)
}
if m < n {
// A = V₂·Σ·U₂ᴴ obtained from the tall decomposition of Aᴴ.
// Shapes mirror the real `SVD`: U (m, m), Σ (m,), Vᴴ (m, n).
ah := transposeConj(flat, m, n)
u2, sigma2, v2, err := svdComplexTall(ah, n, m)
if err != nil {
return nil, nil, nil, err
}
unscaleFloats(sigma2, ws)
uOut := core.New(core.Complex, []int{m, m}...)
copy(uOut.RawComplexes(), v2)
sigOut := floatsToArray(sigma2, []int{m})
vhOut := core.New(core.Complex, []int{m, n}...)
copy(vhOut.RawComplexes(), transposeConj(u2, n, m))
return uOut, sigOut, vhOut, nil
}
uMat, sigVals, vMat, err := svdComplexTall(flat, m, n)
if err != nil {
return nil, nil, nil, err
}
unscaleFloats(sigVals, ws)
uOut := core.New(core.Complex, []int{m, n}...)
copy(uOut.RawComplexes(), uMat)
sigOut := floatsToArray(sigVals, []int{n})
vhOut := core.New(core.Complex, []int{n, n}...)
copy(vhOut.RawComplexes(), transposeConj(vMat, n, n))
return uOut, sigOut, vhOut, nil
}
// svdComplexTall decomposes the complex m×n matrix with m ≥ n by
// Golub-Kahan bidiagonalisation and the Golub-Reinsch shifted QR
// iteration on the real bidiagonal: A = U·diag(σ)·Vᴴ with σ sorted
// descending, U thin (m×n) and V square (n×n).
func svdComplexTall(flat []complex128, m, n int) ([]complex128, []float64, []complex128, error) {
work := append([]complex128(nil), flat...)
d, e, u, v := svdBidiagonalise(work, m, n)
if err := svdGolubReinsch(d, e, u, v, m, n); err != nil {
return nil, nil, nil, err
}
// Non-negative singular values, flipping the matching U column.
for i := range n {
if d[i] < 0 {
d[i] = -d[i]
for r := range m {
u[r*m+i] = -u[r*m+i]
}
}
}
// Descending order, permuting the factor columns along.
for i := range n {
big := i
for j := i + 1; j < n; j++ {
if d[j] > d[big] {
big = j
}
}
if big != i {
d[i], d[big] = d[big], d[i]
for r := range m {
u[r*m+i], u[r*m+big] = u[r*m+big], u[r*m+i]
}
for r := range n {
v[r*n+i], v[r*n+big] = v[r*n+big], v[r*n+i]
}
}
}
thin := make([]complex128, m*n)
for i := range m {
copy(thin[i*n:(i+1)*n], u[i*m:i*m+n])
}
return thin, d, v, nil
}
func transposeConj(a []complex128, m, n int) []complex128 {
out := make([]complex128, n*m)
for i := range m {
for j := range n {
out[j*m+i] = cmplx.Conj(a[i*n+j])
}
}
return out
}