Files
tensor/linalg/eigenreal.go
T
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

447 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package linalg
import (
"math"
"math/cmplx"
"slices"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// The general eigenproblem. Eigen answers real symmetric matrices and
// EigenComplex Hermitian ones, where structure pays for itself. A
// general square matrix has no structure to exploit, so EigenGeneral
// goes through the complex Schur form: the input is promoted to
// complex128, reduced to upper Hessenberg form by complex Householder
// reflectors, and driven to upper triangular form by the shifted QR
// iteration, one Wilkinson-shifted sweep of Givens rotations per step,
// deflating whenever a subdiagonal entry falls under a scale-relative
// tolerance. Working over the complex plane is what keeps the
// iteration single-shift: conjugate eigenvalue pairs are ordinary
// points there, where a real iteration would need the double-shift
// bulge chase to reach them.
//
// Eigenvectors come from the triangular Schur factor by back
// substitution and one multiplication with the accumulated unitary
// similarity, which keeps them orthonormal to rounding level even
// when the eigenvalues themselves are ill conditioned.
// EigenGeneral returns the eigenvalues and eigenvectors of a square
// matrix of any dtype; real and integer inputs are promoted to
// complex128. Values is a complex128 vector sorted descending by
// magnitude, bitwise ties broken by descending real part and then
// descending imaginary part; conjugate pairs share a magnitude only
// to rounding, so their relative order follows the rounding noise
// rather than the tiebreak. Vectors is a complex128 (n, n) array
// whose column j is the unit eigenvector for values[j]. Symmetric
// real matrices get a faster answer from Eigen and Hermitian matrices
// from EigenComplex.
func EigenGeneral(a *core.Array) (values, vectors *core.Array, err error) {
if a.NDim() != 2 || a.Shape()[0] != a.Shape()[1] {
return nil, nil, base.Errf("EigenGeneral: 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("EigenGeneral: zero-sized matrix, got shape %s", base.ShapeText(a.Shape()))
}
h := make([]complex128, n*n)
switch {
case a.Dtype() == core.Complex && !a.Strided() && len(a.RawComplexes()) == n*n:
copy(h, a.RawComplexes())
case a.Dtype() == core.Float && !a.Strided() && len(a.RawFloats()) == n*n:
for i := range n * n {
h[i] = complex(a.RawFloats()[i], 0)
}
default:
for i := range n * n {
h[i] = a.ComplexAt(i)
}
}
// Outside the safe window the reflector norms, the norm the shift
// floors are taken against and the squared magnitudes the sweeps form
// all leave the normal range: a tiny matrix collapses to zeros and a
// huge one has vMax*vMax overflow, which zeroes beta so the
// Hessenberg reduction is skipped and the QR iteration then fails on
// a perfectly valid matrix. The matrix is moved into the window and
// the eigenvalues, which carry the scale, are moved back on return;
// the Schur vectors are scale-free.
ws := windowScale(maxMagComplex(h))
if ws != 1 {
scaleComplexes(h, ws)
}
scale := 0.0
for _, z := range h {
if m := base.AbsComplex(z); m > scale {
scale = m
}
}
// q accumulates the unitary similarity: the input satisfies
// A = q·H·qᴴ at every stage.
q := eyeComplex(n)
hessenbergComplex(h, q, n)
if err := hessenbergQr(h, q, n, scale); err != nil {
return nil, nil, err
}
vals := make([]complex128, n)
for i := range n {
vals[i] = h[i*n+i]
}
idx := sortMagDescIndices(vals)
outVals := make([]complex128, n)
for j := range n {
outVals[j] = vals[idx[j]]
}
// Eigenvector j depends on the position of λ_j inside the Schur
// factor, so they are built in place order and permuted afterwards.
inPlace := make([]complex128, n*n)
for j := range n {
v := schurEigenVector(h, q, n, j, scale)
for i := range n {
inPlace[i*n+j] = v[i]
}
}
outVecs := make([]complex128, n*n)
for j := range n {
for i := range n {
outVecs[i*n+j] = inPlace[i*n+idx[j]]
}
}
// Only the eigenvalues carry the matrix's scale.
if ws != 1 {
unscaleComplexes(outVals, ws)
}
valuesArr := core.New(core.Complex, []int{n}...)
copy(valuesArr.RawComplexes(), outVals)
vecArr := core.New(core.Complex, []int{n, n}...)
copy(vecArr.RawComplexes(), outVecs)
return valuesArr, vecArr, nil
}
// hessenbergComplex reduces h to upper Hessenberg form in place by
// complex Householder reflectors, accumulating the unitary similarity
// into q so that the original matrix satisfies A = q·H·qᴴ.
func hessenbergComplex(h, q []complex128, n int) {
for k := range n - 2 {
// The reflector zeroes column k below its subdiagonal; columns
// already in form are skipped. Squared magnitudes are summed
// relative to the column's largest entry so a column with
// entries near 1e154 cannot overflow on the way to its norm.
scale := 0.0
for i := k + 1; i < n; i++ {
if m := base.AbsComplex(h[i*n+k]); m > scale {
scale = m
}
}
below := 0.0
if scale > 0 {
for i := k + 2; i < n; i++ {
m := base.AbsComplex(h[i*n+k]) / scale
below += m * m
}
}
if below == 0 {
continue
}
x0 := h[(k+1)*n+k]
norm := scale * math.Hypot(base.AbsComplex(x0)/scale, math.Sqrt(below))
// alpha = −sign(x0)·‖x‖ lands the reflection on the far side
// of x0, away from the cancellation zone.
phase := complex(1, 0)
if m := base.AbsComplex(x0); m > 0 {
phase = x0 / complex(m, 0)
}
alpha := -phase * complex(norm, 0)
v := make([]complex128, n-k-1)
v[0] = x0 - alpha
for i := 1; i < n-k-1; i++ {
v[i] = h[(k+1+i)*n+k]
}
vMax := 0.0
for _, z := range v {
if m := base.AbsComplex(z); m > vMax {
vMax = m
}
}
vNorm2 := 0.0
if vMax > 0 {
for _, z := range v {
re := real(z) / vMax
im := imag(z) / vMax
vNorm2 += re*re + im*im
}
}
// beta = 2/(vᴴv) needs vMax*vMax to stay in the normal range. A
// column whose entries are extreme in the matrix's own units
// (far below it, say) overflows that square to +Inf, which makes
// beta +0, or underflows it to 0, which makes beta +Inf and the
// update NaN. Both are silent corruption of an otherwise valid
// reduction, so the reflector is re-expressed in window units
// with the scaling compensated in beta, exactly as
// householderVectorInto does for the real reflectors.
betaR := 2 / (vMax * vMax * vNorm2)
if betaR <= 0 || math.IsInf(betaR, 0) {
wsV := windowScale(vMax)
for i := range v {
v[i] = complex(real(v[i])*wsV, imag(v[i])*wsV)
}
w := vMax * wsV
betaR = 2 / (w * w * vNorm2)
}
beta := complex(betaR, 0)
// H = P·H with P = I − β·v·vᴴ, rows k+1..n−1, columns k..n−1.
for j := k; j < n; j++ {
s := complex(0, 0)
for i := range v {
s += cmplx.Conj(v[i]) * h[(k+1+i)*n+j]
}
s *= beta
for i := range v {
h[(k+1+i)*n+j] -= s * v[i]
}
}
// H = H·P, every row, columns k+1..n−1.
for i := range n {
s := complex(0, 0)
for j := range v {
s += h[i*n+k+1+j] * v[j]
}
s *= beta
for j := range v {
h[i*n+k+1+j] -= s * cmplx.Conj(v[j])
}
}
// q = q·P keeps the similarity: A = q·H·qᴴ.
for i := range n {
s := complex(0, 0)
for j := range v {
s += q[i*n+k+1+j] * v[j]
}
s *= beta
for j := range v {
q[i*n+k+1+j] -= s * cmplx.Conj(v[j])
}
}
// Column k below the subdiagonal is zero by construction; make
// it exactly so instead of carrying rounding residue.
for i := k + 2; i < n; i++ {
h[i*n+k] = 0
}
}
}
// hessenbergQr drives the shifted QR iteration on an upper Hessenberg
// matrix until every subdiagonal entry deflates, accumulating the
// Schur similarity into q. On return h is upper triangular: the Schur
// form of the original matrix, with its eigenvalues on the diagonal.
func hessenbergQr(h, q []complex128, n int, scale float64) error {
// Purely relative to the matrix magnitude: an absolute floor would
// treat a legitimate tiny-scale matrix (a 1e-20 rotation, say) as
// one big deflated block and return zeros.
floor := base.EpsF * scale
negligible := func(i int) bool {
local := base.EpsF * (base.AbsComplex(h[i*n+i]) + base.AbsComplex(h[(i-1)*n+i-1]))
return base.AbsComplex(h[i*n+i-1]) <= math.Max(local, floor)
}
// Rotation scratch reused across sweeps; each sweep uses the first
// hi−lo entries of each.
cs := make([]complex128, n)
sn := make([]complex128, n)
hi := n - 1
iter := 0
for hi > 0 {
for hi > 0 && negligible(hi) {
h[hi*n+hi-1] = 0
hi--
iter = 0
}
if hi == 0 {
return nil
}
lo := hi
for lo > 0 && !negligible(lo) {
lo--
}
if lo > 0 {
// A negligible entry splits the matrix; the block above it
// is settled in later rounds.
h[lo*n+lo-1] = 0
}
iter++
if iter > 100 {
return base.Errf("EigenGeneral: QR iteration failed to converge at row %d", hi)
}
var mu complex128
if iter%10 == 0 {
// An exceptional shift breaks the rare cyclic pattern the
// Wilkinson shift can settle into.
mu = h[hi*n+hi] + complex(0.75*base.AbsComplex(h[hi*n+hi-1]), 0)
} else {
mu = wilkinsonShift(h, n, hi)
}
qrSweepComplex(h, q, n, lo, hi, mu, cs, sn)
}
return nil
}
// wilkinsonShift returns the eigenvalue of the trailing 2×2 block
// closest to its bottom-right entry: the shift that deflates the
// subdiagonal under it fastest.
func wilkinsonShift(h []complex128, n, i int) complex128 {
a := h[(i-1)*n+i-1]
b := h[(i-1)*n+i]
c := h[i*n+i-1]
d := h[i*n+i]
delta := (a - d) / 2
disc := cmplx.Sqrt(delta*delta + b*c)
low := d + delta - disc
high := d + delta + disc
if base.AbsComplex(low-d) <= base.AbsComplex(high-d) {
return low
}
return high
}
// qrSweepComplex performs one explicit single-shift QR step over the
// active block [lo, hi]: H = G·(H − μI)·Gᴴ + μI for the sequence of
// Givens rotations G that triangularises H − μI, with every rotation
// folded into q so the similarity stays exact. Each rotation satisfies
// G·[a; b] = [r; 0] with r real, which is what zeroes the subdiagonal
// one entry per step. cs and sn are the caller's scratch for the
// rotations; each sweep fully overwrites its first hi−lo entries.
func qrSweepComplex(h, q []complex128, n, lo, hi int, mu complex128, cs, sn []complex128) {
for i := lo; i <= hi; i++ {
h[i*n+i] -= mu
}
for j := lo; j < hi; j++ {
a, b := h[j*n+j], h[(j+1)*n+j]
r := math.Hypot(base.AbsComplex(a), base.AbsComplex(b))
var c, s complex128
if r == 0 {
c, s = 1, 0
} else {
c, s = a/complex(r, 0), b/complex(r, 0)
}
cs[j-lo], sn[j-lo] = c, s
// Left: rows j and j+1 over columns j..n−1.
for k := j; k < n; k++ {
x, y := h[j*n+k], h[(j+1)*n+k]
h[j*n+k] = cmplx.Conj(c)*x + cmplx.Conj(s)*y
h[(j+1)*n+k] = -s*x + c*y
}
h[(j+1)*n+j] = 0
}
for j := lo; j < hi; j++ {
c, s := cs[j-lo], sn[j-lo]
// Right: columns j and j+1 down to row j+1, the deepest row
// the triangular factor can reach in either column.
for i := 0; i <= j+1; i++ {
x, y := h[i*n+j], h[i*n+j+1]
h[i*n+j] = x*c + y*s
h[i*n+j+1] = -x*cmplx.Conj(s) + y*cmplx.Conj(c)
}
for i := range n {
x, y := q[i*n+j], q[i*n+j+1]
q[i*n+j] = x*c + y*s
q[i*n+j+1] = -x*cmplx.Conj(s) + y*cmplx.Conj(c)
}
}
for i := lo; i <= hi; i++ {
h[i*n+i] += mu
}
}
// schurEigenVector builds the unit eigenvector for the diagonal entry
// j of a triangular Schur factor t with its similarity q (the input
// satisfies A = q·t·qᴴ): back substitution on the leading block gives
// the coordinates in Schur space, one multiplication with q lifts
// them back. A nearly multiple eigenvalue perturbs the denominator
// off exact zero, the standard defence against dividing by the
// spectrum's own degeneracy.
func schurEigenVector(t, q []complex128, n, j int, scale float64) []complex128 {
x := make([]complex128, n)
x[j] = 1
lam := t[j*n+j]
// Relative to the matrix magnitude for the same reason as the QR
// floor above: tiny-scale spectra deserve their eigenvectors too.
denFloor := base.EpsF * scale
for i := j - 1; i >= 0; i-- {
s := -t[i*n+j]
for k := i + 1; k < j; k++ {
s -= t[i*n+k] * x[k]
}
d := t[i*n+i] - lam
if base.AbsComplex(d) < denFloor {
d = complex(denFloor, 0)
}
if base.AbsComplex(d) == 0 {
// The zero matrix: every denominator vanishes, and the
// coordinate is unconstrained. Zero keeps the vector finite
// where 0/0 would poison it with NaN.
x[i] = 0
continue
}
x[i] = s / d
}
v := make([]complex128, n)
for i := range n {
acc := complex(0, 0)
for k := range j + 1 {
acc += q[i*n+k] * x[k]
}
v[i] = acc
}
norm := 0.0
for _, z := range v {
norm += real(z)*real(z) + imag(z)*imag(z)
}
norm = math.Sqrt(norm)
if norm > 0 {
for i := range v {
v[i] /= complex(norm, 0)
}
}
return v
}
// sortMagDescIndices sorts indices so the values come descending by
// magnitude, bitwise ties broken by descending real part and then
// descending imaginary part. The sort is stable, so ties beyond the
// comparator keep their original order exactly as before.
func sortMagDescIndices(vals []complex128) []int {
idx := make([]int, len(vals))
for i := range idx {
idx[i] = i
}
slices.SortStableFunc(idx, func(a, b int) int {
ma, mb := base.AbsComplex(vals[a]), base.AbsComplex(vals[b])
if ma != mb {
if ma > mb {
return -1
}
return 1
}
ra, rb := real(vals[a]), real(vals[b])
if ra != rb {
if ra > rb {
return -1
}
return 1
}
ia, ib := imag(vals[a]), imag(vals[b])
switch {
case ia > ib:
return -1
case ia < ib:
return 1
default:
return 0
}
})
return idx
}