Files
tensor/linalg/decomp2.go
T

1347 lines
41 KiB
Go
Raw Permalink 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
// SVD, Eigen, Pinverse, MatrixRank and Cond. Operates on
// float64 in and out; complex matrices are rejected.
//
// SVD: two-sided Jacobi rotation (Kogbetliantz / Press et al. NR3 §11.4).
// Both U and V are computed simultaneously by applying Jacobi
// rotations column-by-column on A and row-by-row on AᵀA; the
// algorithm is quadratic in the number of sweeps but converges
// robustly on ill-conditioned and rank-deficient matrices and is
// straightforward to keep correct. The matrix sizes tensor calls
// (n ≪ 256) keep this competitive with the QR approach.
//
// Eigen: implicit-shift QR on a symmetric tridiagonal matrix
// (Householder reduction first). Quadratic in sweeps but standard
// and stable. Both eigenvalues and eigenvectors are returned.
import (
"math"
"slices"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
"sourcedock.dev/petrbalvin/tensor/internal/engine"
)
// svdMinWork is the measured parallel gate for one reflector
// application or matrix product slice, counted in element touches
// (items dispatched times the per-item inner length). Below it the
// work finishes before the worker dispatch pays off and runs on the
// calling goroutine. The bulge chase itself (symmetricQr,
// tridiagQrStep) stays serial: each rotation reads what the previous
// one wrote, and even the rounding-level entries far from the band
// feed a bulge a few rotations later, so no part of the tMat sweep may
// be reordered or narrowed. Only the eigenvector accumulation is
// order-safe: its mixes touch qMat alone, so they are recorded during
// the chase and replayed in batches over disjoint row ranges.
const svdMinWork = 16384
// svdMinItemsPerWorker is the floor on items per worker in the SVD
// dispatches.
const svdMinItemsPerWorker = 8
// qrFlushCap bounds the recorded rotation buffer between batched
// applications, keeping the replay scratch at a fixed size for any n.
const qrFlushCap = 65536
// qrRotation records one Givens rotation accumulated on the right of
// the eigenvector matrix: coordinates k and k+1 rotate by
// G = [[c, −s], [s, c]].
type qrRotation struct {
k int
c, s float64
}
// qrSweep collects the rotations of one symmetricQr run and applies
// them to the eigenvector accumulator in batches.
//
// Deferring the mixes is bit-identical to applying them inside the
// chase. A qMat cell (i, k) is touched only by rotations whose band
// position is k or k-1, each computing c·old + s·old' and -s·old +
// c·old' from the cell's current value and the rotation's (c, s); the
// pair (c, s) is read off tMat alone. Recording the rotations and
// replaying them in the same chronological order therefore feeds every
// cell exactly the arithmetic sequence the inline loop produced, and a
// flush only cuts that sequence into consecutive chunks. The replay
// walks disjoint row ranges per worker, so the parallel batch and the
// serial fallback write the same bits.
type qrSweep struct {
qMat []float64
n int
rots []qrRotation
// inline mirrors the pre-deferral behaviour: below qrDeferMin the
// chase applies each rotation to the accumulator as it runs,
// because the buffer, the flush and the worker dispatch cost more
// than the n-row mix they would save.
inline bool
}
// qrDeferMin is the accumulator size from which the eigenvector mixes
// defer to batched replay. Measured on the square sweep: at 128 the
// deferral loses a third of the run to the buffer and the dispatch,
// at 192 it measures even, and at 256 the batched replay wins 1.6
// times, with the gap widening as the mix grows cubically.
const qrDeferMin = 192
func newQrSweep(qMat []float64, n int) *qrSweep {
return &qrSweep{qMat: qMat, n: n, rots: make([]qrRotation, 0, 256),
inline: n < qrDeferMin}
}
// record applies one rotation inline below qrDeferMin and otherwise
// appends it, flushing the buffer at the cap so the scratch stays
// bounded for large n. Both modes feed the cell the same arithmetic in
// the same chronological position, so the result does not depend on
// the mode.
func (qa *qrSweep) record(k int, c, s float64) {
if qa.inline {
qMat, n := qa.qMat, qa.n
for i := range n {
qi := qMat[i*n+k]
qi1 := qMat[i*n+k+1]
qMat[i*n+k] = c*qi + s*qi1
qMat[i*n+k+1] = -s*qi + c*qi1
}
return
}
qa.rots = append(qa.rots, qrRotation{k: k, c: c, s: s})
if len(qa.rots) >= qrFlushCap {
qa.flush()
}
}
// flush applies the recorded rotations to qMat, one worker per row
// range, and empties the buffer. Below the dispatch gate the batch
// runs on the calling goroutine.
func (qa *qrSweep) flush() {
rots := qa.rots
if len(rots) == 0 {
return
}
n := qa.n
qMat := qa.qMat
apply := func(start, end int) {
for i := start; i < end; i++ {
row := i * n
for _, r := range rots {
qi := qMat[row+r.k]
qi1 := qMat[row+r.k+1]
qMat[row+r.k] = r.c*qi + r.s*qi1
qMat[row+r.k+1] = -r.s*qi + r.c*qi1
}
}
}
if len(rots)*n >= svdMinWork {
engine.ParallelMin(n, 1, func(start, end int) {
apply(start, end)
})
} else {
apply(0, n)
}
qa.rots = rots[:0]
}
// scaleWinLo and scaleWinHi bound the magnitude window the squared
// arithmetic of the decompositions is exact and finite in. Every square,
// sum of squares, product and fourth power of an entry inside it stays a
// normal float64: 2^200 squared is 2^400 and its fourth power 2^800, far
// inside the 1.8e308 ceiling, and 2^-300 squared is 2^-600, far above
// the 2^-1022 normal floor. Outside the window the raw accumulations
// underflow to zero, which silently skips reflectors, or overflow to
// +Inf, which zeroes a reflector's beta and poisons the update with NaN.
const (
scaleWinLo = -300 // 2^-300 ≈ 4.9e-91
scaleWinHi = 200 // 2^200 ≈ 1.6e60
)
// windowScale returns the exact power of two that moves maxAbs into
// [2^scaleWinLo, 2^scaleWinHi], and 1 when it already lies inside. A
// decomposition multiplies its matrix by the factor and its spectrum by
// 1/f: that is the same problem in scaled units, because eigenvalues and
// singular values scale with the matrix while eigenvectors and the
// orthogonal factors do not. Powers of two carry no rounding, so an
// in-window input keeps every bit of the original arithmetic and a
// rescaled one keeps the accuracy its exponent leaves it.
func windowScale(maxAbs float64) float64 {
if maxAbs <= 0 || math.IsNaN(maxAbs) || math.IsInf(maxAbs, 0) {
return 1
}
// Frexp splits maxAbs into m·2^e with m in [0.5, 1).
_, e := math.Frexp(maxAbs)
switch {
case e-1 > scaleWinHi:
return math.Ldexp(1, scaleWinHi-e)
case e-1 < scaleWinLo:
return math.Ldexp(1, scaleWinLo+1-e)
}
return 1
}
// maxMagF64 returns the largest absolute value in a real slice.
func maxMagF64(s []float64) float64 {
m := 0.0
for _, v := range s {
if a := math.Abs(v); a > m {
m = a
}
}
return m
}
// maxMagComplex returns the largest magnitude in a complex slice. The
// magnitudes come from math.Hypot, so an entry beyond ~1e154 keeps a
// finite magnitude where a sum of raw squares overflows.
func maxMagComplex(s []complex128) float64 {
m := 0.0
for _, z := range s {
if a := cmplxAbs(z); a > m {
m = a
}
}
return m
}
// scaleFloats multiplies every entry of s by f; f is an exact power of
// two, so the entries come back bit for bit from unscaleFloats.
func scaleFloats(s []float64, f float64) {
for i := range s {
s[i] *= f
}
}
// unscaleFloats divides every entry of s by f, an exact power of two.
func unscaleFloats(s []float64, f float64) {
for i := range s {
s[i] /= f
}
}
// scaleComplexes multiplies every entry of s by the real power of two f.
func scaleComplexes(s []complex128, f float64) {
for i := range s {
s[i] = complex(real(s[i])*f, imag(s[i])*f)
}
}
// unscaleComplexes divides every entry of s by the real power of two f.
func unscaleComplexes(s []complex128, f float64) {
for i := range s {
s[i] = complex(real(s[i])/f, imag(s[i])/f)
}
}
// SVD returns the thin singular value decomposition a = U · Σ · Vᵀ
// of an m×n matrix a with m ≥ n. For m < n the matrix is
// transposed first and the orthogonal factors are swapped on
// return. U is m×n with orthonormal columns (Uᵀ U = I_n); Σ is a
// 1-D length-n array of non-negative singular values sorted
// descending; Vᵀ is n×n orthogonal.
//
// Algorithm:
// 1. Bidiagonalise A, giving A = U₁ · B · V₁ᵀ via Householder.
// 2. Form C = Bᵀ B (n × n symmetric tridiagonal).
// 3. Symmetric implicit-shift QR with Wilkinson shift on C,
// accumulating rotations into V = V₁ · Q.
// 4. Σ = √(eigenvalues of C). Reconstruct U = A · V / Σ and
// orthogonalise via Gram-Schmidt to absorb numerical drift.
//
// This is the standard double-step SVD via Bᵀ B + symmetric QR:
// robust, well-understood, and reuses the symmetric tridiagonal
// eigensolver already shipped for `Eigen`.
//
// The double step prices the small end of the spectrum: the singular
// values come from the eigenvalues of BᵀB, so their accuracy on very
// small singular values is that of a squared condition number, fine
// for rank decisions, weaker for resolving near-null directions.
// `SVDComplex` states the same contract for its Aᴴ·A route.
func SVD(a *core.Array) (u, sigma, vt *core.Array, err error) {
if a.Dtype() == core.Complex {
return nil, nil, nil, base.Errf("SVD: complex matrices are not supported")
}
if a.NDim() != 2 {
return nil, nil, nil, base.Errf("SVD: 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("SVD: zero-sized matrix, got shape %s", base.ShapeText(a.Shape()))
}
transposed := false
Amat := denseFloats(a, a.Shape()[0], a.Shape()[1])
// Outside the safe window every squared intermediate of the
// bidiagonalisation, of C = BᵀB and of the σ recovery would leave
// the normal range. The matrix is moved into the window for the
// computation and the singular values, which carry the scale, are
// moved back once the factorisation is done; the orthogonal factors
// are scale-free.
ws := windowScale(maxMagF64(Amat))
if ws != 1 {
scaleFloats(Amat, ws)
}
origM, origN := m, n
if m < n {
transposed = true
Amat = base.TransposeFlat(Amat, m, n)
m, n = n, m
}
bDiag, bSuper, v1 := bidiagonalise(Amat, m, n)
// Form C = Bᵀ B (n × n symmetric tridiagonal): AᵀA = V₁ C V₁ᵀ.
// The diagonal carries the superdiagonal's contribution:
// C[i,i] = B[i,i]² + B[i−1,i]²; the off-diagonal is
// C[i,i+1] = B[i,i]·B[i,i+1].
cMat := make([]float64, n*n)
for i := 0; i < n; i++ {
cMat[i*n+i] = bDiag[i] * bDiag[i]
if i > 0 {
cMat[i*n+i] += bSuper[i-1] * bSuper[i-1]
}
if i+1 < n {
cMat[i*n+i+1] = bDiag[i] * bSuper[i]
cMat[(i+1)*n+i] = cMat[i*n+i+1]
}
}
// Eigendecompose C with the shipped symmetric solver: C = W Λ Wᵀ,
// so the right singular vectors are V = V₁·W with Σ = √Λ. C is
// (n × n) symmetric and tiny (n ≪ 1024 in the typical workspace),
// so the eigensolver is much cheaper than a QR sweep per call.
// C is exactly symmetric by construction, so the internals of
// `Eigen` run directly.
tMat, qMat := householderTridiag(cMat, n)
qT := base.TransposeFlat(qMat, n, n)
if err := symmetricQr(tMat, qT, n); err != nil {
return nil, nil, nil, base.Errf("SVD: %w", err)
}
eVals := make([]float64, n)
for i := range n {
eVals[i] = tMat[i*n+i]
}
eIdx := sortAscIndices(eVals)
cVals := make([]float64, n) // ascending eigenvalues, as Eigen returns them
eVecs := make([]float64, n*n)
for j := range n {
cVals[j] = eVals[eIdx[j]]
for i := range n {
eVecs[i*n+j] = qT[i*n+eIdx[j]]
}
}
vMat := make([]float64, n*n) // V = V₁·W
// Rows are independent; each element keeps its serial dot over k.
if n*n*n >= svdMinWork {
engine.ParallelMin(n, 1, func(start, end int) {
for i := start; i < end; i++ {
for j := range n {
s := 0.0
for k := range n {
s += v1[i*n+k] * eVecs[k*n+j]
}
vMat[i*n+j] = s
}
}
})
} else {
for i := range n {
for j := range n {
s := 0.0
for k := range n {
s += v1[i*n+k] * eVecs[k*n+j]
}
vMat[i*n+j] = s
}
}
}
// Eigen returns ascending order; the SVD contract wants Σ
// descending, so permute the columns of V to match.
idx := sortDescIndices(cVals)
permuteCols(vMat, n, n, idx)
// Recover Σ directly from A as σₖ = ‖A·vₖ‖: the BᵀB round trip
// squares the condition number, so reading √λ back would lose half
// the digits on ill-conditioned inputs. Columns are independent and
// each norm keeps its serial accumulation order over i, then j.
sorted := make([]float64, n)
if m*n*n >= svdMinWork {
engine.ParallelMin(n, 1, func(start, end int) {
for k := start; k < end; k++ {
norm := 0.0
for i := range m {
acc := 0.0
for j := range n {
acc += Amat[i*n+j] * vMat[j*n+k]
}
norm += acc * acc
}
sorted[k] = math.Sqrt(norm)
}
})
} else {
for k := range n {
norm := 0.0
for i := range m {
acc := 0.0
for j := range n {
acc += Amat[i*n+j] * vMat[j*n+k]
}
norm += acc * acc
}
sorted[k] = math.Sqrt(norm)
}
}
// The recovered Σ can disagree with the eigenvalue order by
// rounding on near-degenerate values; settle on one descending
// order before building U, permuting V along with it.
if ord := sortDescIndices(sorted); !isIdentityPerm(ord) {
permuteCols(vMat, n, n, ord)
perm := make([]float64, n)
copy(perm, sorted)
for i := range n {
sorted[i] = perm[ord[i]]
}
}
// Thin U = A·V·Σ⁻¹ from the working (m ≥ n) matrix, with A and Σ in
// the same scaled units.
uThin := reconstructUThin(Amat, vMat, sorted, m, n)
// The singular values carry the matrix's scale and the orthogonal
// factors do not, so only Σ is moved back out of the scaled units
// the factorisation ran in.
if ws != 1 {
unscaleFloats(sorted, ws)
}
U := floatsToArray(uThin, []int{m, n})
S := floatsToArray(sorted, []int{n})
Vt := floatsToArray(base.TransposeFlat(vMat, n, n), []int{n, n})
if transposed {
// The input had m < n; the factorisation ran on the transpose
// Aᵀ = U' Σ V'ᵀ, so the original reads A = V' Σ U'ᵀ:
// U_orig = V' itself (origM × origM) and Vᵀ_orig = (thin U')ᵀ.
return floatsToArray(vMat, []int{origM, origM}),
S,
floatsToArray(base.TransposeFlat(uThin, m, n), []int{origM, origN}),
nil
}
return U, S, Vt, nil
}
// bidiagonalise reduces A (m×n with m ≥ n, row-major) to upper
// bidiagonal form via Golub-Kahan Householder reflectors: B =
// H_p…H₁·A·K₁…K_q, so A = U₁·B·V₁ᵀ with V₁ = K₁K₂…K_q accumulated by
// right-multiplying the reflectors into its columns. The left factor U₁
// is not accumulated: the only caller recovers U from A·V after the
// sweep, and carrying the m×m accumulator cost an eye(m) matrix and an
// m-row update per left reflector without feeding any returned value.
// The bidiagonal and the right accumulator are exactly the values the
// accumulation carried: neither reads uMat.
//
// Reflectors apply strictly in sweep order; within one reflector every
// touched column or row is updated exactly once from the frozen
// reflector vector and beta, so the per-line work (dot along the line,
// then the same-operand rank-1 subtraction) dispatches over the lines
// of bFull and the accumulator in one parallel range. The per-element
// sequence is the serial one, so the factorisation is bit-identical
// across crew sizes.
func bidiagonalise(aMat []float64, m, n int) (bDiag, bSuper []float64, vMat []float64) {
bDiag = make([]float64, n)
if n > 1 {
bSuper = make([]float64, n-1)
}
bFull := make([]float64, m*n)
copy(bFull, aMat)
vMat = eye(n)
// Reflector scratch reused across the sweep; each step uses the
// prefix it needs.
vec := make([]float64, m)
vecR := make([]float64, n)
for k := range n {
// Left reflector: rows k..m-1 of column k, zeroing below (k, k).
lv := vec[:m-k]
for i := range lv {
lv[i] = bFull[(k+i)*n+k]
}
hh := householderVectorInto(lv, lv)
if hh.beta != 0 {
ln := m - k
bColumn := func(j int) {
dot := 0.0
for i := range ln {
dot += hh.v[i] * bFull[(k+i)*n+j]
}
w := hh.beta * dot
for i := range ln {
bFull[(k+i)*n+j] -= hh.v[i] * w
}
}
if (n-k)*ln >= svdMinWork {
engine.ParallelMin(n-k, svdMinItemsPerWorker, func(start, end int) {
for j := start; j < end; j++ {
bColumn(k + j)
}
})
} else {
for j := k; j < n; j++ {
bColumn(j)
}
}
}
if k+1 >= n {
break
}
// Right reflector: columns k+1..n-1 of row k, zeroing right of
// (k, k+1).
rv := vecR[:n-k-1]
for j := range rv {
rv[j] = bFull[k*n+(k+1+j)]
}
hhR := householderVectorInto(rv, rv)
if hhR.beta != 0 {
ln := len(rv)
bRow := func(i int) {
dot := 0.0
for j := range ln {
dot += hhR.v[j] * bFull[i*n+(k+1+j)]
}
w := hhR.beta * dot
for j := range ln {
bFull[i*n+(k+1+j)] -= hhR.v[j] * w
}
}
vRow := func(j int) {
dot := 0.0
for i := range ln {
dot += hhR.v[i] * vMat[j*n+(k+1+i)]
}
w := hhR.beta * dot
for i := range ln {
vMat[j*n+(k+1+i)] -= hhR.v[i] * w
}
}
// V₁ = K₁K₂…: likewise from the right, columns k+1..n-1.
// Rows k..m-1 of bFull and rows of vMat: disjoint lines.
items := (m - k) + n
if items*ln >= svdMinWork {
engine.ParallelMin(items, svdMinItemsPerWorker, func(start, end int) {
for it := start; it < end; it++ {
if it < m-k {
bRow(k + it)
} else {
vRow(it - (m - k))
}
}
})
} else {
for i := k; i < m; i++ {
bRow(i)
}
for j := range n {
vRow(j)
}
}
}
}
for i := range n {
bDiag[i] = bFull[i*n+i]
if i+1 < n {
bSuper[i] = bFull[i*n+i+1]
}
}
return bDiag, bSuper, vMat
}
// smallOff is the deflation threshold for the tridiagonal QR sweep.
func smallOff(d1, d2 float64) float64 {
return base.EpsF * (math.Abs(d1) + math.Abs(d2))
}
// reconstructUThin builds thin U = A_orig · V · diag(1/Σ) when Σ is
// non-zero, and falls back to the kernel projection for small
// σᵢ. aOrig is (post-transpose) input shape (m, n). vMat is (n, n).
// sVals are the singular values in matched column order. Result
// has shape (m, n).
//
// The candidate projection A_orig·V is computed once into a scratch,
// dispatched over the rows of aOrig: every element is the same serial
// dot over j (ascending) the per-column loop used to run, so the
// values are bit-identical and only their computing order moved. The
// modified Gram-Schmidt pass stays serial: column k reads every
// earlier column, and each projection's dot is an order-bound
// reduction over the column being updated.
func reconstructUThin(aOrig, vMat, sVals []float64, m, n int) []float64 {
biggest := 0.0
for _, v := range sVals {
if v > biggest {
biggest = v
}
}
thresh := base.EpsF * float64(m) * biggest
// The Gram-Schmidt pass tests the norm of a column that the division
// by σ makes unit-length, so its floor is an absolute one relative to
// that unit and not to the largest singular value: with the singular
// values themselves as the reference, a matrix whose spectrum is
// above ~1e16 would route every healthy column to the kernel rebuild
// below and lose the factorisation.
unitTol := base.EpsF * float64(m)
// The un-orthonormalised candidate is A_orig·V/Σ on the columns
// where σ is non-tiny (and A_orig·V otherwise), re-orthonormalised
// via modified Gram-Schmidt below so that UᵀU = I_n holds even when
// the reduction leaves small numerical drift. A column whose
// residual is essentially zero (σ at rounding level) is rebuilt from
// the best orthogonalised coordinate candidate e_j: the largest
// residual is at least 1/√m, because a unit vector of the
// orthogonal complement has a coordinate at least that large.
uMat := make([]float64, m*n)
// Scratch reused across columns: the working column and, for kernel
// columns, the candidate buffers (fully overwritten before use).
best := make([]float64, m)
cand := make([]float64, m)
// proj holds the un-orthonormalised projection A_orig·V, one value
// per (row, singular vector) pair, each from the same serial dot.
proj := engine.GetFloat64Buf(m * n)
defer engine.PutFloat64Buf(proj)
if m*n*n >= svdMinWork {
engine.ParallelMin(m, 1, func(start, end int) {
for i := start; i < end; i++ {
row := aOrig[i*n : i*n+n]
out := i * n
for k := range n {
s := 0.0
for j := range n {
s += row[j] * vMat[j*n+k]
}
proj[out+k] = s
}
}
})
} else {
for i := range m {
for k := range n {
s := 0.0
for j := range n {
s += aOrig[i*n+j] * vMat[j*n+k]
}
proj[i*n+k] = s
}
}
}
for k := range n {
if sVals[k] > thresh {
inv := 1 / sVals[k]
for i := range m {
uMat[i*n+k] = proj[i*n+k] * inv
}
} else {
for i := range m {
uMat[i*n+k] = proj[i*n+k]
}
}
// Subtract projections on the earlier columns.
for j := range k {
dot := 0.0
for i := range m {
dot += uMat[i*n+j] * uMat[i*n+k]
}
for i := range m {
uMat[i*n+k] -= dot * uMat[i*n+j]
}
}
nrm := 0.0
for i := range m {
nrm += uMat[i*n+k] * uMat[i*n+k]
}
nrm = math.Sqrt(nrm)
if nrm > unitTol {
inv := 1 / nrm
for i := range m {
uMat[i*n+k] *= inv
}
continue
}
// Kernel column: orthogonalise every coordinate candidate and
// keep the healthiest residual.
bestRes := -1.0
for j := range m {
for i := range m {
cand[i] = 0
}
cand[j] = 1
for range 2 {
for j2 := range k {
d := 0.0
for i := range m {
d += uMat[i*n+j2] * cand[i]
}
for i := range m {
cand[i] -= d * uMat[i*n+j2]
}
}
}
rn := 0.0
for i := range m {
rn += cand[i] * cand[i]
}
if rn > bestRes {
bestRes = rn
copy(best, cand)
}
}
if bestRes > 0 {
inv := 1 / math.Sqrt(bestRes)
for i := range m {
uMat[i*n+k] = best[i] * inv
}
}
}
return uMat
}
// Eigen returns the eigenvalues and orthonormal eigenvectors of a
// real symmetric n×n matrix a. Eigenvalues are returned in a 1-D
// float array, sorted in ascending order; the eigenvectors are
// the columns of an n×n orthogonal array. Asymmetric matrices are
// not supported.
func Eigen(a *core.Array) (values, vectors *core.Array, err error) {
if a.Dtype() == core.Complex {
return nil, nil, base.Errf("Eigen: complex matrices are not supported")
}
if a.NDim() != 2 || a.Shape()[0] != a.Shape()[1] {
return nil, nil, base.Errf("Eigen: needs a square 2-D matrix, got shape %s", base.ShapeText(a.Shape()))
}
n := a.Shape()[0]
mat := denseFloats(a, n, n)
// A non-finite entry slips through the symmetry test below: the
// difference of two NaNs never exceeds the tolerance, so a poisoned
// matrix reads as symmetric and the sweep hands back a NaN spectrum
// with a nil error. The sparse twin refuses the same input, and so
// does this one.
for i := range n {
for j := range n {
if math.IsNaN(mat[i*n+j]) || math.IsInf(mat[i*n+j], 0) {
return nil, nil, base.Errf("Eigen: entry [%d,%d] is not finite", i, j)
}
}
}
// A matrix outside the safe window would drive the reflector norms
// and the sweep's squared accumulation past the representable range;
// the tridiagonalisation and the QR sweep run on it scaled into the
// window and the eigenvalues are scaled back. The eigenvectors are
// the same for every positive multiple of the matrix.
ws := windowScale(maxMagF64(mat))
if ws != 1 {
scaleFloats(mat, ws)
}
if !isSymmetric(mat, n) {
return nil, nil, base.Errf("Eigen: matrix is not symmetric within 1e-12 tolerance")
}
tMat, qMat := householderTridiag(mat, n)
// The reduction maintains T = qMat·A·qMatᵀ (reflectors applied on
// the left of qMat), so the eigenvector matrix of A after the QR
// sweep is qMatᵀ·G_total. The sweep accumulates rotations on the
// right of its accumulator, hence the transpose before the call.
qT := base.TransposeFlat(qMat, n, n)
if err := symmetricQr(tMat, qT, n); err != nil {
return nil, nil, base.Errf("Eigen: %w", err)
}
vals := make([]float64, n)
for i := range n {
vals[i] = tMat[i*n+i]
}
idx := sortAscIndices(vals)
out := make([]float64, n)
for i := range n {
out[i] = vals[idx[i]]
}
sortedCols := make([]float64, n*n)
for j := range n {
for i := range n {
sortedCols[i*n+j] = qT[i*n+idx[j]]
}
}
if ws != 1 {
unscaleFloats(out, ws)
}
return floatsToArray(out, []int{n}), floatsToArray(sortedCols, []int{n, n}), nil
}
// Pinverse returns the Moore-Penrose pseudoinverse of a 2-D matrix,
// built from the SVD by inverting singular values strictly greater
// than ε. When eps ≤ 0 the default is max(m, n) · max(Σ) · base.EpsF.
func Pinverse(a *core.Array, eps float64) (*core.Array, error) {
if a.Dtype() == core.Complex {
return nil, base.Errf("Pinverse: complex matrices are not supported")
}
if a.NDim() != 2 {
return nil, base.Errf("Pinverse: 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, base.Errf("Pinverse: zero-sized matrix, got shape %s", base.ShapeText(a.Shape()))
}
u, sigma, vt, err := SVD(a)
if err != nil {
return nil, err
}
sVals := sigma.RawFloats()
// The thin factorisation carries min(m, n) singular values: U is
// (m, r) and Vᵀ is (r, n), whatever the input aspect ratio.
r := min(m, n)
maxS := 0.0
for _, s := range sVals {
if s > maxS {
maxS = s
}
}
if eps <= 0 {
dim := max(n, m)
eps = float64(dim) * maxS * base.EpsF
}
dInv := make([]float64, r)
for i, s := range sVals {
if s > eps {
dInv[i] = 1 / s
}
}
uMat := denseFloats(u, m, r)
vtMat := denseFloats(vt, r, n)
out := make([]float64, n*m)
// A⁺ = V · Σ⁻¹ · Uᵀ = (V · Σ⁻¹) · Uᵀ, shape (n, m):
// out[i,j] = Σ_k V[i,k] · Σ⁻¹[k] · U[j,k], with V[i,k] = Vᵀ[k,i].
for i := range n {
for j := range m {
s := 0.0
for k := range r {
s += vtMat[k*n+i] * dInv[k] * uMat[j*r+k]
}
out[i*m+j] = s
}
}
return floatsToArray(out, []int{n, m}), nil
}
// MatrixRank returns the number of singular values of a 2-D matrix
// strictly greater than eps. With eps ≤ 0 the default is
// max(m, n) · max(Σ) · base.EpsF.
func MatrixRank(a *core.Array, eps float64) (int, error) {
if a.Dtype() == core.Complex {
return 0, base.Errf("MatrixRank: complex matrices are not supported")
}
if a.NDim() != 2 {
return 0, base.Errf("MatrixRank: needs a 2-D matrix, got shape %s", base.ShapeText(a.Shape()))
}
_, sigma, _, err := SVD(a)
if err != nil {
return 0, err
}
return countAboveThreshold(sigma.RawFloats(), eps, a.Shape()[0], a.Shape()[1]), nil
}
// Cond returns the 2-norm condition number σ_max / σ_min of a 2-D
// matrix. If any singular value is zero (or at-or-below a positive
// eps) the result is +Inf, mirroring the standard convention.
func Cond(a *core.Array, eps float64) (float64, error) {
if a.Dtype() == core.Complex {
return 0, base.Errf("Cond: complex matrices are not supported")
}
if a.NDim() != 2 {
return 0, base.Errf("Cond: needs a 2-D matrix, got shape %s", base.ShapeText(a.Shape()))
}
_, sigma, _, err := SVD(a)
if err != nil {
return 0, err
}
return condValue(sigma.RawFloats(), eps, a.Shape()[0], a.Shape()[1]), nil
}
// householderTridiag reduces a symmetric matrix to tridiagonal form via
// Householder reflectors, accumulating the orthogonal factor into qMat.
//
// The sweep order is untouched. Within one reflector the LEFT
// application to T (columns) and the accumulation into qMat (columns)
// run over disjoint buffers, so they share one dispatch; the RIGHT
// application to T (rows) must follow, because it reads the entries the
// left application has just written; the drift symmetrisation follows
// it serially. Each line is updated exactly once from the frozen
// reflector with the serial dot order, so the result is bit-identical.
func householderTridiag(aMat []float64, n int) (tMat, qMat []float64) {
tMat = make([]float64, n*n)
copy(tMat, aMat)
qMat = eye(n)
// Reflector scratch reused across the sweep.
v := make([]float64, n)
for k := 0; k < n-2; k++ {
vv := v[:n-k-1]
for i := 0; i < n-k-1; i++ {
vv[i] = tMat[(k+1+i)*n+k]
}
u := householderVectorInto(vv, vv)
if u.beta == 0 {
continue
}
ln := n - k - 1
tColumn := func(j int) {
dot := 0.0
for i := range ln {
dot += u.v[i] * tMat[(k+1+i)*n+j]
}
w := u.beta * dot
for i := range ln {
tMat[(k+1+i)*n+j] -= u.v[i] * w
}
}
qColumn := func(j int) {
dot := 0.0
for i := range ln {
dot += u.v[i] * qMat[(k+1+i)*n+j]
}
w := u.beta * dot
for i := range ln {
qMat[(k+1+i)*n+j] -= u.v[i] * w
}
}
// Apply H from both sides to T: left pass over the columns of T
// together with the qMat accumulation (disjoint buffers).
items := (n - k) + n
if items*ln >= svdMinWork {
engine.ParallelMin(items, svdMinItemsPerWorker, func(start, end int) {
for it := start; it < end; it++ {
if it < n-k {
tColumn(k + it)
} else {
qColumn(it - (n - k))
}
}
})
} else {
for j := k; j < n; j++ {
tColumn(j)
}
for j := range n {
qColumn(j)
}
}
// Right pass over the rows of T: reads the left pass's writes,
// so it stays strictly after it.
tRow := func(i int) {
dot := 0.0
for j := range ln {
dot += u.v[j] * tMat[i*n+(k+1)+j]
}
w := u.beta * dot
for j := range ln {
tMat[i*n+(k+1)+j] -= u.v[j] * w
}
}
if (n-k)*ln >= svdMinWork {
engine.ParallelMin(n-k, svdMinItemsPerWorker, func(start, end int) {
for i := start; i < end; i++ {
tRow(k + i)
}
})
} else {
for i := k; i < n; i++ {
tRow(i)
}
}
// Symmetrise against numerical drift.
for i := k + 1; i < n; i++ {
for j := i + 1; j < n; j++ {
avg := (tMat[i*n+j] + tMat[j*n+i]) / 2
tMat[i*n+j] = avg
tMat[j*n+i] = avg
}
}
}
return tMat, qMat
}
// symmetricQr runs the implicit-shift symmetric QR algorithm with
// the Wilkinson shift on a symmetric tridiagonal tMat. Returns the
// updated tridiagonal (whose diagonal is the eigenvalues) and the
// eigenvector matrix Q, or an error when the sweep exhausts its
// passes without deflating every subdiagonal entry: an unconverged
// spectrum is never returned as an answer.
func symmetricQr(tMat, qMat []float64, n int) error {
maxIters := max(30*n, 30)
// Deflation floor relative to the matrix norm: a purely
// neighbour-relative threshold never triggers when both diagonal
// entries are small, even though the off-diagonal is already at
// rounding level of the whole transform (backward stability
// guarantees nothing better than eps·‖T‖ anyway).
scale := 0.0
for i := range n {
scale += tMat[i*n+i] * tMat[i*n+i]
if i+1 < n {
e := tMat[i*n+i+1]
scale += 2 * e * e
}
}
tolAbs := base.EpsF * math.Sqrt(scale)
negligible := func(i int) bool { // tests the subdiagonal entry (i, i-1)
e := math.Abs(tMat[i*n+(i-1)])
// An exactly zero entry is deflated by definition: a zero
// matrix has tolAbs 0 and relative floors of 0, and a strict
// inequality would never deflate it.
return e == 0 || e < smallOff(tMat[i*n+i], tMat[(i-1)*n+(i-1)]) || e < tolAbs
}
acc := newQrSweep(qMat, n)
for range maxIters {
h := n - 1
for h > 0 && negligible(h) {
tMat[h*n+(h-1)] = 0
tMat[(h-1)*n+h] = 0
h--
}
if h <= 0 {
break
}
// The active block containing h reaches up to the first
// negligible subdiagonal entry (its decoupling boundary):
// expanding l past that boundary would leave a zero entry
// inside the window, where the bulge chase would abort and
// strand the block below it.
l := h - 1
for l > 0 && !negligible(l) {
l--
}
if l > 0 {
tMat[l*n+(l-1)] = 0
tMat[(l-1)*n+l] = 0
}
if h <= l {
continue
}
if h-l == 1 {
// A 2×2 block is diagonalised exactly by one Jacobi
// rotation; the shift chase leaves a rounding-level
// off-diagonal that can sit forever above the deflation
// threshold, so finish it off directly.
jacobi2x2(tMat, acc, l, n)
tMat[l*n+h] = 0
tMat[h*n+l] = 0
continue
}
d := tMat[(h-1)*n+(h-1)]
e := tMat[(h-1)*n+h]
f := tMat[h*n+h]
shift := wilkinsonShift2x2(d, e, f)
tridiagQrStep(tMat, acc, l, h, n, shift)
}
// An exhausted sweep must not pass its tridiagonal off as a
// spectrum: every sibling iterator here (hessenbergQr, the
// Golub-Reinsch SVD, schurQR) errors on exhaustion, and so does
// this one.
for i := 1; i < n; i++ {
if !negligible(i) {
return base.Errf("the QR sweep failed to deflate a %d×%d matrix in %d passes", n, n, maxIters)
}
}
acc.flush()
return nil
}
// applyGivens applies the rotation G = [[c, −s], [s, c]] on coordinates
// (k, k+1) as a similarity transform of tMat and records it for the
// batched accumulation on the right of the eigenvector matrix.
func applyGivens(tMat []float64, acc *qrSweep, k, n int, c, s float64) {
for j := range n {
tk := tMat[k*n+j]
tk1 := tMat[(k+1)*n+j]
tMat[k*n+j] = c*tk + s*tk1
tMat[(k+1)*n+j] = -s*tk + c*tk1
}
for i := range n {
ti := tMat[i*n+k]
ti1 := tMat[i*n+k+1]
tMat[i*n+k] = c*ti + s*ti1
tMat[i*n+k+1] = -s*ti + c*ti1
}
acc.record(k, c, s)
}
// jacobi2x2 diagonalises the symmetric 2×2 block at (k, k+1) exactly
// with one Jacobi rotation, recording the rotation into acc.
func jacobi2x2(tMat []float64, acc *qrSweep, k, n int) {
a := tMat[k*n+k]
b := tMat[k*n+k+1]
d := tMat[(k+1)*n+(k+1)]
if b == 0 {
return
}
tau := (d - a) / (2 * b)
// Smaller root of t² + ((a−d)/b)·t − 1 = 0: t = −sign(τ)/(|τ|+√(1+τ²)).
t := -1.0 / (math.Abs(tau) + math.Sqrt(1+tau*tau))
if tau < 0 {
t = -t
}
c := 1 / math.Sqrt(1+t*t)
s := t * c
applyGivens(tMat, acc, k, n, c, s)
}
func wilkinsonShift2x2(d, e, f float64) float64 {
delta := (d - f) / 2
if delta == 0 {
return f - math.Abs(e)
}
sq := math.Sqrt(delta*delta + e*e)
if delta > 0 {
return f - e*e/(delta+sq)
}
return f + e*e/(sq-delta)
}
// tridiagQrStep performs one implicit-shift QR step on the symmetric
// tridiagonal block [l, h] of tMat, recording the rotations into acc
// for the batched eigenvector accumulation. Every rotation is a
// similarity transform applied to full rows and columns, so tMat stays
// exactly similar to its input; the pair (c, s) comes from the
// subdiagonal and the bulge the previous rotation left behind, the
// bulge chase of Golub & Van Loan §8.5.
//
// The tMat passes are strictly sequential and must stay so: rotation
// k+1 annihilates the bulge rotation k wrote, and the rounding-level
// entries far from the band drift inward one diagonal per pass, so
// narrowing or reordering any pass would move bits. Only the qMat mix
// is free of that chain; it is replayed from the recording, in order,
// by acc.flush.
func tridiagQrStep(tMat []float64, acc *qrSweep, l, h, n int, shift float64) {
// The first rotation is read from the leading entry of (T − μI);
// later ones annihilate the chased bulge.
x := tMat[l*n+l] - shift
y := tMat[l*n+(l+1)]
for k := l; k < h; k++ {
if k > l {
x = tMat[(k-1)*n+k] // subdiagonal entry
y = tMat[(k-1)*n+(k+1)] // bulge to annihilate
}
r := math.Hypot(x, y)
if r == 0 {
return // nothing to rotate; the band is already clean
}
c, s := x/r, y/r
// Rows k, k+1: left multiplication by Gᵀ, G = [[c, −s], [s, c]].
for j := range n {
tk := tMat[k*n+j]
tk1 := tMat[(k+1)*n+j]
tMat[k*n+j] = c*tk + s*tk1
tMat[(k+1)*n+j] = -s*tk + c*tk1
}
// Columns k, k+1: right multiplication by G.
for i := range n {
ti := tMat[i*n+k]
ti1 := tMat[i*n+k+1]
tMat[i*n+k] = c*ti + s*ti1
tMat[i*n+k+1] = -s*ti + c*ti1
}
// Eigenvector accumulation on the right: Q = Q·G, replayed in
// order by acc.flush.
acc.record(k, c, s)
}
}
// householder represents a Householder reflection v and its beta.
type householder struct {
v []float64
beta float64
}
// householderVectorInto builds the standard reflect-to-first-coordinate
// Householder vector for x, where H = I - beta v vᵀ with H x = sign(x₀)‖x‖ e₁,
// writing the reflector into dst (which must have room for len(x)
// entries and may alias x) so sweep callers can reuse one buffer
// instead of allocating per reflection. Squared magnitudes are summed
// relative to the largest entry so a vector with entries near 1e154
// does not overflow on the way to its norm.
//
// beta = 2/(vᵀv) needs the square of v's largest entry to stay in the
// normal range. Past about 1.3e154 that square is +Inf and beta comes
// out as +0, which callers read as "no reflection at all", and below
// about 1e-154 it is subnormal or 0 and beta comes out as +Inf, whose
// product with a zero dot is NaN. Either way the reflector is silently
// lost. When the largest entry falls outside that range the reflector is
// re-expressed in units of the exact power of two that brings it into
// the safe window: v is scaled and beta carries the compensatory square,
// so H is the same reflection with every intermediate O(1).
func householderVectorInto(dst, x []float64) householder {
maxAbs := 0.0
for _, xi := range x {
if v := math.Abs(xi); v > maxAbs {
maxAbs = v
}
}
if maxAbs == 0 {
return householder{v: x, beta: 0}
}
scaled2 := 0.0
for _, xi := range x {
xi /= maxAbs
scaled2 += xi * xi
}
norm := maxAbs * math.Sqrt(scaled2)
dst[0] = x[0] + signOrNonZero(x[0])*norm
copy(dst[1:], x[1:])
vMax := 0.0
for _, vi := range dst {
if v := math.Abs(vi); v > vMax {
vMax = v
}
}
if vMax == 0 {
return householder{v: dst, beta: 0}
}
v2 := 0.0
for _, vi := range dst {
vi /= vMax
v2 += vi * vi
}
if beta := 2 / (vMax * vMax * v2); beta > 0 && beta < math.MaxFloat64 {
return householder{v: dst, beta: beta}
}
ws := windowScale(vMax)
scaleFloats(dst, ws)
// w keeps max|v| inside the safe window, so w*w is a normal finite
// number and 2/(w*w*v2) is finite and non-zero: the same reflection,
// still built so that H x = sign(x₀)‖x‖ e₁.
w := vMax * ws
return householder{v: dst, beta: 2 / (w * w * v2)}
}
// signOrNonZero returns the sign of x, with 0 mapping to +1 (Householder
// convention).
func signOrNonZero(x float64) float64 {
if x >= 0 {
return 1
}
return -1
}
// isSymmetric reports whether the matrix is symmetric within a purely
// relative tolerance: a floor would admit an asymmetry that is a large
// fraction of a small-scale matrix, so the guard would call a matrix
// symmetric that is not, whatever the absolute size of its entries.
func isSymmetric(mat []float64, n int) bool {
scale := 0.0
for i := range n {
for j := range n {
a := math.Abs(mat[i*n+j])
if a > scale {
scale = a
}
}
}
tol := 1e-12 * scale
for i := range n {
for j := i + 1; j < n; j++ {
if math.Abs(mat[i*n+j]-mat[j*n+i]) > tol {
return false
}
}
}
return true
}
func countAboveThreshold(s []float64, eps float64, m, n int) int {
if eps <= 0 {
dim := max(n, m)
biggest := 0.0
for _, v := range s {
if v > biggest {
biggest = v
}
}
eps = float64(dim) * biggest * base.EpsF
}
count := 0
for _, v := range s {
if v > eps {
count++
}
}
return count
}
func condValue(s []float64, eps float64, m, n int) float64 {
if len(s) == 0 {
return 0
}
biggest := 0.0
smallest := math.Inf(1)
for _, v := range s {
if v > biggest {
biggest = v
}
if v < smallest {
smallest = v
}
}
if biggest == 0 {
// The zero matrix has no usable inverse direction; the condition
// number is infinite, matching the doc contract.
return math.Inf(1)
}
if smallest <= 0 {
return math.Inf(1)
}
if eps > 0 && smallest <= eps {
return math.Inf(1)
}
return biggest / smallest
}
func permuteCols(a []float64, rows, cols int, idx []int) {
out := make([]float64, rows*cols)
for j := range cols {
for i := range rows {
out[i*cols+j] = a[i*cols+idx[j]]
}
}
copy(a, out)
}
// sortDescIndices orders the indices so the values descend. The sort is
// stable, matching the insertion sort it replaced bit for bit: ties keep
// their original order, so the returned permutation is identical.
func sortDescIndices(s []float64) []int {
idx := make([]int, len(s))
for i := range idx {
idx[i] = i
}
slices.SortStableFunc(idx, func(a, b int) int {
switch {
case s[a] > s[b]:
return -1
case s[a] < s[b]:
return 1
default:
return 0
}
})
return idx
}
// sortAscIndices orders the indices so the values ascend, stable like
// sortDescIndices.
func sortAscIndices(s []float64) []int {
idx := make([]int, len(s))
for i := range idx {
idx[i] = i
}
slices.SortStableFunc(idx, func(a, b int) int {
switch {
case s[a] < s[b]:
return -1
case s[a] > s[b]:
return 1
default:
return 0
}
})
return idx
}
// isIdentityPerm reports whether the permutation is the identity.
func isIdentityPerm(p []int) bool {
for i, v := range p {
if v != i {
return false
}
}
return true
}