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")
|
|
|
|
|
|
}
|
2026-09-27 11:12:43 +02:00
|
|
|
|
// A non-finite entry slips through the Hermitian test above: the
|
|
|
|
|
|
// difference of a NaN and its conjugate never exceeds the tolerance.
|
|
|
|
|
|
// The sparse twin refuses it in its own guard, and so does this one,
|
|
|
|
|
|
// before the Jacobi sweep burns its passes on a poisoned matrix.
|
|
|
|
|
|
for i := range n {
|
|
|
|
|
|
for j := range n {
|
|
|
|
|
|
z := h[i*n+j]
|
|
|
|
|
|
if math.IsNaN(real(z)) || math.IsNaN(imag(z)) || math.IsInf(real(z), 0) || math.IsInf(imag(z), 0) {
|
|
|
|
|
|
return nil, nil, base.Errf("EigenComplex: entry [%d,%d] is not finite", i, j)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-03 10:00:00 +02:00
|
|
|
|
// 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
|
2026-09-27 11:58:07 +02:00
|
|
|
|
// The per-entry skip must sit where a matrix whose every
|
|
|
|
|
|
// off-diagonal entry is skippable also passes the aggregate
|
|
|
|
|
|
// convergence test: with P pairs the off-norm of all-skipped entries
|
|
|
|
|
|
// reaches skip·√P, so a skip at the plain threshold leaves a matrix
|
|
|
|
|
|
// whose entries are uniformly just under it frozen above the test,
|
|
|
|
|
|
// and the sweep exhausts its passes without turning a single
|
|
|
|
|
|
// rotation.
|
|
|
|
|
|
skip := threshold
|
|
|
|
|
|
if pairs := float64(n * (n - 1) / 2); pairs > 1 {
|
|
|
|
|
|
skip = threshold / math.Sqrt(pairs)
|
|
|
|
|
|
}
|
2026-09-03 10:00:00 +02:00
|
|
|
|
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]
|
2026-09-27 11:58:07 +02:00
|
|
|
|
if base.AbsComplex(z) <= skip {
|
2026-09-03 10:00:00 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|