feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,745 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package linalg
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/engine"
|
||||
)
|
||||
|
||||
// Complex sparse linear algebra. The real sparse surface
|
||||
// stores float64 payloads; the complex side mirrors it with a complex
|
||||
// CSR whose values are complex128, built from a core.SparseCOO holding
|
||||
// complex values (NewSparseCOO accepts them; SparseFrom does not, so
|
||||
// callers assemble the COO directly). The solvers are complex
|
||||
// conjugate gradient for Hermitian positive-definite systems, complex
|
||||
// BiCGSTAB for general ones, and a Hermitian Lanczos eigensolver; the
|
||||
// electromagnetics Helmholtz problems that motivated this surface hit
|
||||
// all three.
|
||||
|
||||
// complexCSR is the complex128 compressed sparse row form.
|
||||
type complexCSR struct {
|
||||
rowStart []int
|
||||
colIdx []int
|
||||
vals []complex128
|
||||
n int
|
||||
}
|
||||
|
||||
// cEntry is one coordinate entry on the way into CSR form.
|
||||
type cEntry struct {
|
||||
row, col int
|
||||
val complex128
|
||||
}
|
||||
|
||||
// stableOrderComplexEntries orders entries by (row, col) and returns
|
||||
// the slice holding the result. Two stable counting passes reach the
|
||||
// order a stable comparison sort by (row, col) reaches, in linear time:
|
||||
// the first places by column, the second by row, so entries that share
|
||||
// both coordinates keep their input order and their values accumulate
|
||||
// in it.
|
||||
func stableOrderComplexEntries(entries []cEntry, rows, cols int) []cEntry {
|
||||
buf := make([]cEntry, len(entries))
|
||||
count := make([]int, max(rows, cols)+1)
|
||||
countingPlace(buf, entries, count, func(e cEntry) int { return e.col })
|
||||
countingPlace(entries, buf, count, func(e cEntry) int { return e.row })
|
||||
return entries
|
||||
}
|
||||
|
||||
// cooToComplexCSR converts a complex-valued SparseCOO to CSR form,
|
||||
// merging duplicate coordinates and dropping explicit zeros. The
|
||||
// values must be complex128; anything else is an error naming the
|
||||
// dtype.
|
||||
func cooToComplexCSR(s *core.SparseCOO, name string) (*complexCSR, error) {
|
||||
if s.Values.Dtype() != core.Complex {
|
||||
return nil, base.Errf("%s: needs complex128 values, got %s", name, s.Values.Dtype())
|
||||
}
|
||||
if len(s.Shape) != 2 {
|
||||
return nil, base.Errf("%s: needs a 2-D matrix, got rank %d", name, len(s.Shape))
|
||||
}
|
||||
rows, cols := s.Shape[0], s.Shape[1]
|
||||
if rows != cols {
|
||||
return nil, base.Errf("%s: needs a square matrix, got %d×%d", name, rows, cols)
|
||||
}
|
||||
nnz := s.Indices.Shape()[0]
|
||||
idx := s.Indices.RawInts()
|
||||
entries := make([]cEntry, nnz)
|
||||
for i := range nnz {
|
||||
r := int(idx[i*2])
|
||||
c := int(idx[i*2+1])
|
||||
if r < 0 || r >= rows || c < 0 || c >= cols {
|
||||
return nil, base.Errf("%s: index [%d,%d] out of range for %d×%d", name, r, c, rows, cols)
|
||||
}
|
||||
entries[i] = cEntry{r, c, s.Values.ComplexAt(i)}
|
||||
}
|
||||
// Sort by (row, col) so duplicates merge and rows are contiguous,
|
||||
// keeping equal coordinates in COO order.
|
||||
sorted := stableOrderComplexEntries(entries, rows, cols)
|
||||
cm := &complexCSR{
|
||||
rowStart: make([]int, rows+1),
|
||||
colIdx: make([]int, 0, nnz),
|
||||
vals: make([]complex128, 0, nnz),
|
||||
n: rows,
|
||||
}
|
||||
// The duplicates are adjacent after the sort, so each run merges into
|
||||
// one accumulated value on the way into the compressed form.
|
||||
for p := 0; p < len(sorted); {
|
||||
e := sorted[p]
|
||||
v := e.val
|
||||
p++
|
||||
for p < len(sorted) && sorted[p].row == e.row && sorted[p].col == e.col {
|
||||
v += sorted[p].val
|
||||
p++
|
||||
}
|
||||
if v == 0 {
|
||||
continue
|
||||
}
|
||||
cm.colIdx = append(cm.colIdx, e.col)
|
||||
cm.vals = append(cm.vals, v)
|
||||
cm.rowStart[e.row+1]++
|
||||
}
|
||||
for i := range cm.n {
|
||||
cm.rowStart[i+1] += cm.rowStart[i]
|
||||
}
|
||||
return cm, nil
|
||||
}
|
||||
|
||||
// checkHermitian verifies every stored entry has a conjugate mirror,
|
||||
// the property the complex CG solver and the Hermitian Lanczos rely
|
||||
// on. The tolerance is purely relative to the largest magnitude present,
|
||||
// deliberately without an absolute floor: a floor would approve a matrix
|
||||
// of small scale whose asymmetry is a large fraction of it, and the
|
||||
// Jacobi and Lanczos routes would then answer for a matrix that is
|
||||
// neither A nor Aᴴ. Relative-only keeps the approval and the arithmetic
|
||||
// in agreement. A non-finite entry is refused in its own right: NaN
|
||||
// compares unequal to everything, so the mirror test alone would wave
|
||||
// it through as Hermitian. The wording of the refusal is unchanged.
|
||||
func (c *complexCSR) checkHermitian(name string) error {
|
||||
scale := 0.0
|
||||
for _, v := range c.vals {
|
||||
if a := cmplxAbs(v); a > scale {
|
||||
scale = a
|
||||
}
|
||||
}
|
||||
tol := 1e-12 * scale
|
||||
for i := range c.n {
|
||||
for p := c.rowStart[i]; p < c.rowStart[i+1]; p++ {
|
||||
j := c.colIdx[p]
|
||||
v := c.vals[p]
|
||||
if math.IsNaN(real(v)) || math.IsNaN(imag(v)) ||
|
||||
math.IsInf(real(v), 0) || math.IsInf(imag(v), 0) {
|
||||
return base.Errf("%s: entry [%d,%d] is not finite", name, i, j)
|
||||
}
|
||||
mirror, ok := c.at(j, i)
|
||||
if !ok || cmplxAbs(mirror-complexConj(v)) > tol {
|
||||
return base.Errf("%s: matrix is not Hermitian within 1e-12 tolerance", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func complexConj(z complex128) complex128 { return complex(real(z), -imag(z)) }
|
||||
|
||||
func cmplxAbs(z complex128) float64 { return math.Hypot(real(z), imag(z)) }
|
||||
|
||||
// at reads the entry (row, col), reporting whether it is stored. The
|
||||
// row's column indices are sorted, so the lookup is a binary search:
|
||||
// the Hermitian check calls it once per stored entry, and a linear
|
||||
// scan made that check quadratic in the row length.
|
||||
func (c *complexCSR) at(row, col int) (complex128, bool) {
|
||||
lo, hi := c.rowStart[row], c.rowStart[row+1]
|
||||
for lo < hi {
|
||||
mid := int(uint(lo+hi) >> 1)
|
||||
if c.colIdx[mid] < col {
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
}
|
||||
if lo < c.rowStart[row+1] && c.colIdx[lo] == col {
|
||||
return c.vals[lo], true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// complexMatVecRange computes y[i] = A[i,:]·x for the rows in [s, e) of
|
||||
// a matrix in compressed row form. It is the whole body of the product,
|
||||
// so the serial path calls it directly and the parallel path calls it
|
||||
// per chunk: taking the structure slices as arguments rather than as
|
||||
// captured variables keeps the serial path free of the closure a row
|
||||
// split needs, which the complex solvers call once per iteration.
|
||||
func complexMatVecRange(s, e int, rowStart, colIdx []int, values, x, y []complex128) {
|
||||
for i := s; i < e; i++ {
|
||||
sum := 0i
|
||||
for p := rowStart[i]; p < rowStart[i+1]; p++ {
|
||||
sum += values[p] * x[colIdx[p]]
|
||||
}
|
||||
y[i] = sum
|
||||
}
|
||||
}
|
||||
|
||||
// matVec computes y = A·x over complex vectors. Output rows are
|
||||
// independent, so the row range splits across workers once a worker's
|
||||
// share of the stored entries pays for the split, and runs on the
|
||||
// calling goroutine below that.
|
||||
func (c *complexCSR) matVec(x, y []complex128) {
|
||||
// The three structure slices are taken once: every worker's rows walk
|
||||
// them per stored entry, and the row loop holds no other state.
|
||||
rowStart := c.rowStart
|
||||
colIdx := c.colIdx
|
||||
vals := c.vals
|
||||
if sparseMatVecSplit(c.n, len(vals)) {
|
||||
engine.ParallelMin(c.n, 1, func(s, e int) {
|
||||
complexMatVecRange(s, e, rowStart, colIdx, vals, x, y)
|
||||
})
|
||||
return
|
||||
}
|
||||
complexMatVecRange(0, c.n, rowStart, colIdx, vals, x, y)
|
||||
}
|
||||
|
||||
// complexVecF64 flattens a rank-1 complex array, rejecting anything
|
||||
// else loudly.
|
||||
func complexVecF64(b *core.Array, n int, name string) ([]complex128, error) {
|
||||
if b.NDim() != 1 || b.Len() != n {
|
||||
return nil, base.Errf("%s: b must be a rank-1 vector of length %d", name, n)
|
||||
}
|
||||
if b.Dtype() != core.Complex {
|
||||
return nil, base.Errf("%s: b must be complex128, got %s", name, b.Dtype())
|
||||
}
|
||||
out := make([]complex128, n)
|
||||
if !b.Strided() {
|
||||
copy(out, b.RawComplexes()[:n])
|
||||
return out, nil
|
||||
}
|
||||
for i := range n {
|
||||
out[i] = b.ComplexAt(i)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// complexFromArray builds the result array from a complex vector.
|
||||
func complexFromArray(v []complex128) (*core.Array, error) {
|
||||
out, err := zeros(core.Complex, []int{len(v)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copy(out.RawComplexes(), v)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SpSolveComplexCG returns x solving A·x = b for a Hermitian
|
||||
// positive-definite sparse A with complex entries, by conjugate
|
||||
// gradient with a complex Jacobi (diagonal) preconditioner. The
|
||||
// stopping rule mirrors SpSolve: ‖b − A·x‖₂ ≤ tol·‖b‖₂, tol ≤ 0 uses
|
||||
// 1e-10, maxIter ≤ 0 uses n, and an unconverged solve is an error
|
||||
// naming the achieved residual rather than a silent approximation. A
|
||||
// zero or missing diagonal entry is refused, exactly as the real
|
||||
// solver refuses it.
|
||||
func SpSolveComplexCG(a *core.SparseCOO, b *core.Array, tol float64, maxIter int) (*core.Array, error) {
|
||||
const name = "SpSolveComplexCG"
|
||||
c, err := cooToComplexCSR(a, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.checkHermitian(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := c.n
|
||||
bv, err := complexVecF64(b, n, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tol <= 0 {
|
||||
tol = spSolveTol
|
||||
}
|
||||
if maxIter <= 0 {
|
||||
maxIter = n
|
||||
}
|
||||
diag := make([]complex128, n)
|
||||
for i := range n {
|
||||
d, ok := c.at(i, i)
|
||||
if !ok || d == 0 {
|
||||
return nil, base.Errf("%s: zero diagonal entry at row %d", name, i)
|
||||
}
|
||||
diag[i] = d
|
||||
}
|
||||
// The system is solved in scaled units, which leaves the solution x
|
||||
// untouched: A·x = b and (f·A)·x = (f·b) have the same x. The factor
|
||||
// is 1 for any ordinary magnitude, so the recurrence stays
|
||||
// bit-identical there, and outside that range it is what keeps the
|
||||
// dot products and the squared magnitudes in the recurrence from
|
||||
// overflowing or underflowing to nothing.
|
||||
ws := scaleSystem(c, bv, diag)
|
||||
bNorm := normC(bv)
|
||||
if bNorm == 0 {
|
||||
return complexFromArray(make([]complex128, n))
|
||||
}
|
||||
x := make([]complex128, n)
|
||||
r := append([]complex128(nil), bv...)
|
||||
p := make([]complex128, n)
|
||||
z := make([]complex128, n)
|
||||
Ap := make([]complex128, n)
|
||||
for i := range n {
|
||||
z[i] = r[i] / diag[i]
|
||||
}
|
||||
copy(p, z)
|
||||
rz := dotC(r, z)
|
||||
for iter := range maxIter {
|
||||
c.matVec(p, Ap)
|
||||
pAp := dotC(p, Ap)
|
||||
if !cFinite(pAp) || pAp == 0 {
|
||||
return nil, base.Errf("%s: breakdown at step %d (p·A·p vanished)", name, iter+1)
|
||||
}
|
||||
alpha := rz / pAp
|
||||
for i := range n {
|
||||
x[i] += alpha * p[i]
|
||||
r[i] -= alpha * Ap[i]
|
||||
}
|
||||
if normC(r) <= tol*bNorm {
|
||||
return complexFromArray(x)
|
||||
}
|
||||
for i := range n {
|
||||
z[i] = r[i] / diag[i]
|
||||
}
|
||||
rzNext := dotC(r, z)
|
||||
if rzNext == 0 {
|
||||
return nil, base.Errf("%s: breakdown at step %d (r·z vanished)", name, iter+1)
|
||||
}
|
||||
beta := rzNext / rz
|
||||
for i := range n {
|
||||
p[i] = z[i] + beta*p[i]
|
||||
}
|
||||
rz = rzNext
|
||||
}
|
||||
// The residual and tolerance are reported in the caller's units, not
|
||||
// in the scaled units the recurrence ran in (ws is 1 unless the
|
||||
// system was extreme).
|
||||
return nil, base.Errf("%s: no convergence in %d steps, residual %.3g (tolerance %.3g)",
|
||||
name, maxIter, normC(r)/ws, tol*bNorm/ws)
|
||||
}
|
||||
|
||||
// dotC is the complex inner product aᴴ·b (conjugating the left side,
|
||||
// the convention every complex Krylov method uses).
|
||||
func dotC(a, b []complex128) complex128 {
|
||||
s := 0i
|
||||
for i := range a {
|
||||
s += complexConj(a[i]) * b[i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// normC is the Euclidean norm of a complex vector. The squares are
|
||||
// summed directly while the sum stays finite and non-zero, which is what
|
||||
// the stopping rules of the solvers were tuned on and is bit-for-bit the
|
||||
// magnitude of every ordinary vector. A sum that overflows to +Inf or
|
||||
// underflows to 0 (the whole vector below about 1e-162) carries no
|
||||
// magnitude at all, so it falls back to a max-scaled accumulation that
|
||||
// does: the magnitude has to exist for a relative convergence test to
|
||||
// mean anything.
|
||||
func normC(a []complex128) float64 {
|
||||
s := 0.0
|
||||
for _, v := range a {
|
||||
s += real(v)*real(v) + imag(v)*imag(v)
|
||||
}
|
||||
if s == 0 || math.IsInf(s, 1) {
|
||||
return normCScaled(a)
|
||||
}
|
||||
return math.Sqrt(s)
|
||||
}
|
||||
|
||||
// normCScaled sums the squares relative to the largest magnitude, so no
|
||||
// intermediate leaves the normal float64 range.
|
||||
func normCScaled(a []complex128) float64 {
|
||||
maxAbs := 0.0
|
||||
for _, v := range a {
|
||||
if m := cmplxAbs(v); m > maxAbs {
|
||||
maxAbs = m
|
||||
}
|
||||
}
|
||||
if maxAbs == 0 {
|
||||
return 0
|
||||
}
|
||||
s := 0.0
|
||||
for _, v := range a {
|
||||
re := real(v) / maxAbs
|
||||
im := imag(v) / maxAbs
|
||||
s += re*re + im*im
|
||||
}
|
||||
return maxAbs * math.Sqrt(s)
|
||||
}
|
||||
|
||||
// scaleSystem moves a system's matrix, right-hand side and preconditioner
|
||||
// into the magnitude window the Krylov recurrences are safe in, by one
|
||||
// shared exact power of two, and reports that factor. A·x = b and
|
||||
// (f·A)·x = (f·b) have the same solution x, so the factor cancels: it
|
||||
// only has to come off the residual and tolerance the error paths print
|
||||
// back. Inside the window the dot products, the squared magnitudes and
|
||||
// the stopping rule's tol*bNorm all mean what they claim; outside it a
|
||||
// huge b makes bNorm +Inf (every affine check passes) and a tiny one
|
||||
// makes it 0 (the zero solution is returned as exact).
|
||||
func scaleSystem(c *complexCSR, bv, diag []complex128) float64 {
|
||||
ws := windowScale(math.Max(maxMagComplex(c.vals), maxMagComplex(bv)))
|
||||
if ws != 1 {
|
||||
scaleComplexes(c.vals, ws)
|
||||
scaleComplexes(bv, ws)
|
||||
scaleComplexes(diag, ws)
|
||||
}
|
||||
return ws
|
||||
}
|
||||
|
||||
// SpSolveComplexBiCGSTAB returns x solving A·x = b for a general
|
||||
// (nonsymmetric, non-Hermitian) sparse A with complex entries, by
|
||||
// BiCGSTAB with a complex Jacobi preconditioner. The contract mirrors
|
||||
// SpSolveBiCGSTAB, including the affirmative convergence checks a NaN
|
||||
// residual can never pass and the breakdown errors that name the step.
|
||||
func SpSolveComplexBiCGSTAB(a *core.SparseCOO, b *core.Array, tol float64, maxIter int) (*core.Array, error) {
|
||||
const name = "SpSolveComplexBiCGSTAB"
|
||||
c, err := cooToComplexCSR(a, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := c.n
|
||||
bv, err := complexVecF64(b, n, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tol <= 0 {
|
||||
tol = spSolveTol
|
||||
}
|
||||
if maxIter <= 0 {
|
||||
maxIter = n
|
||||
}
|
||||
diag := make([]complex128, n)
|
||||
for i := range n {
|
||||
d, ok := c.at(i, i)
|
||||
if !ok || d == 0 {
|
||||
return nil, base.Errf("%s: zero diagonal entry at row %d", name, i)
|
||||
}
|
||||
diag[i] = d
|
||||
}
|
||||
// The system is solved in scaled units, which leaves the solution x
|
||||
// untouched, exactly as in SpSolveComplexCG above.
|
||||
ws := scaleSystem(c, bv, diag)
|
||||
bNorm := normC(bv)
|
||||
if bNorm == 0 {
|
||||
return complexFromArray(make([]complex128, n))
|
||||
}
|
||||
r := append([]complex128(nil), bv...)
|
||||
rHat := append([]complex128(nil), r...)
|
||||
x := make([]complex128, n)
|
||||
v := make([]complex128, n)
|
||||
p := make([]complex128, n)
|
||||
s := make([]complex128, n)
|
||||
t := make([]complex128, n)
|
||||
precond := func(dst, src []complex128) {
|
||||
for i := range n {
|
||||
dst[i] = src[i] / diag[i]
|
||||
}
|
||||
}
|
||||
rho := 1 + 0i
|
||||
alpha := 1 + 0i
|
||||
omega := 1 + 0i
|
||||
z := make([]complex128, n)
|
||||
y := make([]complex128, n)
|
||||
mt := make([]complex128, n)
|
||||
for iter := range maxIter {
|
||||
rhoNext := dotC(rHat, r)
|
||||
if !cFinite(rhoNext) || rhoNext == 0 {
|
||||
return nil, base.Errf("%s: breakdown at step %d (rho vanished)", name, iter+1)
|
||||
}
|
||||
if iter > 0 {
|
||||
beta := rhoNext / rho * (alpha / omega)
|
||||
for i := range n {
|
||||
p[i] = r[i] + beta*(p[i]-omega*v[i])
|
||||
}
|
||||
} else {
|
||||
copy(p, r)
|
||||
}
|
||||
precond(z, p)
|
||||
c.matVec(z, v)
|
||||
rhatV := dotC(rHat, v)
|
||||
if rhatV == 0 {
|
||||
return nil, base.Errf("%s: breakdown at step %d (direction orthogonal to shadow)", name, iter+1)
|
||||
}
|
||||
alpha = rhoNext / rhatV
|
||||
for i := range n {
|
||||
s[i] = r[i] - alpha*v[i]
|
||||
}
|
||||
if !vecFiniteC(s) {
|
||||
return nil, base.Errf("%s: breakdown at step %d (non-finite residual)", name, iter+1)
|
||||
}
|
||||
// The half step may already be exact. The check is affirmative
|
||||
// so a NaN norm (which fails every comparison) can never read
|
||||
// as converged.
|
||||
if normC(s) > tol*bNorm {
|
||||
precond(y, s)
|
||||
c.matVec(y, t)
|
||||
precond(mt, t)
|
||||
mtS := dotC(mt, s)
|
||||
mtT := dotC(mt, t)
|
||||
if mtT == 0 {
|
||||
return nil, base.Errf("%s: breakdown at step %d (stabiliser vanished)", name, iter+1)
|
||||
}
|
||||
omega = mtS / mtT
|
||||
if !cFinite(omega) {
|
||||
return nil, base.Errf("%s: breakdown at step %d (stabiliser vanished)", name, iter+1)
|
||||
}
|
||||
for i := range n {
|
||||
x[i] += alpha*z[i] + omega*y[i]
|
||||
r[i] = s[i] - omega*t[i]
|
||||
}
|
||||
if !vecFiniteC(r) {
|
||||
return nil, base.Errf("%s: breakdown at step %d (non-finite residual)", name, iter+1)
|
||||
}
|
||||
if normC(r) <= tol*bNorm {
|
||||
return complexFromArray(x)
|
||||
}
|
||||
if omega == 0 {
|
||||
return nil, base.Errf("%s: stagnation at step %d (omega zero)", name, iter+1)
|
||||
}
|
||||
} else {
|
||||
for i := range n {
|
||||
x[i] += alpha * z[i]
|
||||
}
|
||||
return complexFromArray(x)
|
||||
}
|
||||
rho = rhoNext
|
||||
}
|
||||
// The residual and tolerance are reported in the caller's units, not
|
||||
// in the scaled units the recurrence ran in (ws is 1 unless the
|
||||
// system was extreme).
|
||||
return nil, base.Errf("%s: no convergence in %d steps, residual %.3g (tolerance %.3g)",
|
||||
name, maxIter, normC(r)/ws, tol*bNorm/ws)
|
||||
}
|
||||
|
||||
func cFinite(z complex128) bool {
|
||||
return !math.IsNaN(real(z)) && !math.IsInf(real(z), 0) &&
|
||||
!math.IsNaN(imag(z)) && !math.IsInf(imag(z), 0)
|
||||
}
|
||||
|
||||
func vecFiniteC(v []complex128) bool {
|
||||
for _, z := range v {
|
||||
if !cFinite(z) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SpEigenComplex returns the k eigenvalues of largest magnitude of a
|
||||
// Hermitian sparse matrix with complex entries, each with its unit
|
||||
// eigenvector, by the Lanczos recurrence over complex vectors. The
|
||||
// contract mirrors SpEigen: values ordered by descending magnitude,
|
||||
// eigenvectors as columns of an (n, k) complex array, approximate
|
||||
// Ritz pairs whose accuracy improves with the iteration budget. A
|
||||
// non-Hermitian matrix is refused; the general complex eigenproblem
|
||||
// has no short recurrence and belongs to dense methods.
|
||||
func SpEigenComplex(s *core.SparseCOO, k int, gen *core.Generator) (values, vectors *core.Array, err error) {
|
||||
const name = "SpEigenComplex"
|
||||
if len(s.Shape) != 2 || s.Shape[0] != s.Shape[1] {
|
||||
return nil, nil, base.Errf("%s: needs a square 2-D sparse matrix, got shape %v", name, s.Shape)
|
||||
}
|
||||
n := s.Shape[0]
|
||||
if n == 0 {
|
||||
return nil, nil, base.Errf("%s: zero-sized matrix, got shape %v", name, s.Shape)
|
||||
}
|
||||
if k < 1 || k > n {
|
||||
return nil, nil, base.Errf("%s: k must be in [1, %d], got %d", name, n, k)
|
||||
}
|
||||
c, err := cooToComplexCSR(s, name)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := c.checkHermitian(name); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// The recurrence is projected in scaled units: the alphas and betas
|
||||
// carry the matrix's magnitude, the basis vectors do not, and a
|
||||
// power-of-two factor multiplies the first two exactly while leaving
|
||||
// the third bit-identical, so only the Ritz values have to be moved
|
||||
// back. Without it the projected tridiagonal's squared magnitudes
|
||||
// overflow at the top of the range and the recurrence collapses to
|
||||
// NaN, and at the bottom the deflation floor sees zeros.
|
||||
ws := windowScale(maxMagComplex(c.vals))
|
||||
if ws != 1 {
|
||||
scaleComplexes(c.vals, ws)
|
||||
}
|
||||
if gen == nil {
|
||||
gen = core.NewGenerator(spEigenSeed)
|
||||
}
|
||||
alphas, betas, basis := c.lanczosComplex(k, gen)
|
||||
r := len(alphas)
|
||||
|
||||
tMat := make([]float64, r*r)
|
||||
for i := range r {
|
||||
tMat[i*r+i] = alphas[i]
|
||||
if i+1 < r {
|
||||
tMat[i*r+i+1] = betas[i]
|
||||
tMat[(i+1)*r+i] = betas[i]
|
||||
}
|
||||
}
|
||||
tVec := eye(r)
|
||||
if err := symmetricQr(tMat, tVec, r); err != nil {
|
||||
return nil, nil, base.Errf("SpEigenComplex: %w", err)
|
||||
}
|
||||
|
||||
idx := make([]int, r)
|
||||
for i := range r {
|
||||
idx[i] = i
|
||||
}
|
||||
// Rank by descending magnitude, the same comparator SpEigen uses.
|
||||
for i := 1; i < r; i++ {
|
||||
for j := i; j > 0; j-- {
|
||||
a, b := idx[j-1], idx[j]
|
||||
da, db := math.Abs(tMat[a*r+a]), math.Abs(tMat[b*r+b])
|
||||
if da > db || (da == db && a <= b) {
|
||||
break
|
||||
}
|
||||
idx[j-1], idx[j] = idx[j], idx[j-1]
|
||||
}
|
||||
}
|
||||
sel := idx[:k]
|
||||
|
||||
outVals := make([]float64, k)
|
||||
outVecs := make([]complex128, n*k)
|
||||
tmp := make([]complex128, n)
|
||||
for j, si := range sel {
|
||||
outVals[j] = tMat[si*r+si]
|
||||
// Ritz vector: lift the real tridiagonal eigenvector through
|
||||
// the complex Lanczos basis, v = Q·y.
|
||||
clear(tmp)
|
||||
for p := range r {
|
||||
y := tVec[p*r+si]
|
||||
if y == 0 {
|
||||
continue
|
||||
}
|
||||
row := basis[p*n : (p+1)*n]
|
||||
for i := range n {
|
||||
tmp[i] += complex(y, 0) * row[i]
|
||||
}
|
||||
}
|
||||
norm := normC(tmp)
|
||||
if norm > 0 {
|
||||
for i := range n {
|
||||
tmp[i] /= complex(norm, 0)
|
||||
}
|
||||
}
|
||||
for i := range n {
|
||||
outVecs[i*k+j] = tmp[i]
|
||||
}
|
||||
}
|
||||
if ws != 1 {
|
||||
unscaleFloats(outVals, ws)
|
||||
}
|
||||
vecs, err := complexFromArray2D(outVecs, n, k)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return floatsToArray(outVals, []int{k}), vecs, nil
|
||||
}
|
||||
|
||||
// complexFromArray2D builds an (n, k) complex array from row-major
|
||||
// values.
|
||||
func complexFromArray2D(v []complex128, n, k int) (*core.Array, error) {
|
||||
out, err := zeros(core.Complex, []int{n, k})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copy(out.RawComplexes(), v)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// lanczosComplex runs the Hermitian Lanczos recurrence over complex
|
||||
// vectors: alphas stay real (qᴴAq of a Hermitian A), betas stay real
|
||||
// (vector norms), so the projected problem reuses the real symmetric
|
||||
// tridiagonal eigensolver unchanged. Full reorthogonalisation and the
|
||||
// purely scale-relative deflation floor mirror the real lanczos.
|
||||
func (c *complexCSR) lanczosComplex(k int, gen *core.Generator) (alphas, betas []float64, basis []complex128) {
|
||||
n := c.n
|
||||
steps := min(n, max(2*k, k+spEigenBlock))
|
||||
w := make([]complex128, n)
|
||||
q := make([]complex128, n)
|
||||
prev := make([]complex128, n)
|
||||
// The budget bounds the recurrence: one basis row and at most one
|
||||
// coefficient per step, so all three are sized once rather than grown.
|
||||
alphas = make([]float64, 0, steps)
|
||||
betas = make([]float64, 0, steps)
|
||||
basis = make([]complex128, 0, steps*n)
|
||||
scale := 0.0
|
||||
addScale := func(v float64) {
|
||||
if v > scale {
|
||||
scale = v
|
||||
}
|
||||
}
|
||||
start := func() {
|
||||
for i := range n {
|
||||
q[i] = complex(gen.NormalUnit(), gen.NormalUnit())
|
||||
}
|
||||
for p := range len(alphas) {
|
||||
row := basis[p*n : (p+1)*n]
|
||||
d := dotC(row, q)
|
||||
for i := range n {
|
||||
q[i] -= d * row[i]
|
||||
}
|
||||
}
|
||||
if norm := normC(q); norm > 0 {
|
||||
for i := range n {
|
||||
q[i] /= complex(norm, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
start()
|
||||
for range steps {
|
||||
basis = append(basis, q...)
|
||||
c.matVec(q, w)
|
||||
alpha := real(dotC(q, w))
|
||||
alphas = append(alphas, alpha)
|
||||
addScale(math.Abs(alpha))
|
||||
a := complex(alpha, 0)
|
||||
for i := range n {
|
||||
w[i] -= a * q[i]
|
||||
}
|
||||
if len(alphas) > 1 {
|
||||
b := complex(betas[len(betas)-1], 0)
|
||||
for i := range n {
|
||||
w[i] -= b * prev[i]
|
||||
}
|
||||
}
|
||||
// Full reorthogonalisation, twice: one pass removes the
|
||||
// accumulated loss, the second what the first reintroduces.
|
||||
for range 2 {
|
||||
for p := range len(alphas) {
|
||||
row := basis[p*n : (p+1)*n]
|
||||
d := dotC(row, w)
|
||||
for i := range n {
|
||||
w[i] -= d * row[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
beta := normC(w)
|
||||
// A purely scale-relative exhaustion threshold; an absolute
|
||||
// floor would collapse the recurrence for tiny-norm matrices.
|
||||
if beta <= float64(n)*base.EpsF*scale {
|
||||
if len(alphas) >= n {
|
||||
break
|
||||
}
|
||||
betas = append(betas, 0)
|
||||
start()
|
||||
continue
|
||||
}
|
||||
betas = append(betas, beta)
|
||||
addScale(beta)
|
||||
copy(prev, q)
|
||||
for i := range n {
|
||||
q[i] = w[i] / complex(beta, 0)
|
||||
}
|
||||
}
|
||||
if len(betas) >= len(alphas) {
|
||||
betas = betas[:len(alphas)-1]
|
||||
}
|
||||
return alphas, betas, basis
|
||||
}
|
||||
Reference in New Issue
Block a user