feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user