Files
tensor/linalg/sparsesolve.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

208 lines
7.3 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 (
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// Sparse symmetric positive-definite solve. The dense `Solve` factors
// A once and back-substitutes, which costs O(n³) time and O(n²) memory
// and is the right answer for a modest, dense system. When A is large
// and sparse that factorisation is both unaffordable and wasteful: the
// fill-in alone can exceed the memory the non-zeros needed. SpSolve
// runs the preconditioned conjugate gradient instead, whose every step
// is one sparse-matrix-vector product plus a handful of vector
// operations, so the cost tracks the non-zero count rather than the
// dimension.
//
// Conjugate gradient applies only to a symmetric positive-definite
// matrix, and it is not a drop-in for `Solve`: the answer is an
// approximation within a stated residual tolerance, not the exact
// solution a factorisation returns. Symmetry is verified the same way
// `SpEigen` verifies it. Positive-definiteness cannot be checked
// cheaply up front, so it is caught in flight: a non-positive search
// curvature is the matrix declaring itself indefinite.
// spSolveTol is the default relative residual when the caller passes
// zero. The iteration default is the dimension rather than a constant:
// an exact arithmetic conjugate gradient terminates in at most n
// steps, so a larger budget cannot buy convergence, only round-off
// work.
const spSolveTol = 1e-10
// checkSparseSquare validates the inputs every real sparse solver
// shares: a is a square 2-D matrix of non-complex dtype and, when b is
// not nil, b is a real right-hand side vector of the matrix dimension.
// It returns the dimension n.
func checkSparseSquare(name string, a *core.SparseCOO, b *core.Array) (int, error) {
if a.Values.Dtype() == core.Complex {
return 0, base.Errf("%s: complex sparse matrices are not supported", name)
}
if len(a.Shape) != 2 || a.Shape[0] != a.Shape[1] {
return 0, base.Errf("%s: needs a square 2-D sparse matrix, got shape %v", name, a.Shape)
}
n := a.Shape[0]
if n == 0 {
return 0, base.Errf("%s: zero-sized matrix, got shape %v", name, a.Shape)
}
if b != nil {
if b.NDim() != 1 || b.Shape()[0] != n {
return 0, base.Errf("%s: right-hand side must be a vector of length %d, got shape %s",
name, n, base.ShapeText(b.Shape()))
}
if b.Dtype() == core.Complex {
return 0, base.Errf("%s: complex right-hand side is not supported", name)
}
}
return n, nil
}
// pickPreconditioner resolves the optional ILU argument of the Krylov
// solvers, falling back to the Jacobi diagonal of c when none was
// given. An ILU built for another dimension is refused: applying it
// would index past its vectors, or precondition with the wrong
// factorisation without a word.
func pickPreconditioner(name string, c *sparseCSR, precond []*SparseILU) (*SparseILU, []float64, error) {
if len(precond) > 0 {
if precond[0] == nil {
return nil, nil, base.Errf("%s: the preconditioner is nil", name)
}
if precond[0].n != c.n {
return nil, nil, base.Errf("%s: the preconditioner was built for dimension %d, the system is %d",
name, precond[0].n, c.n)
}
return precond[0], nil, nil
}
diag, err := c.diagonal(name)
if err != nil {
return nil, nil, err
}
return nil, diag, nil
}
// vectorF64 flattens a rank-1 array into a plain float64 working
// vector of the expected length n.
func vectorF64(b *core.Array, n int) []float64 {
r := make([]float64, n)
for i := range n {
r[i] = b.FloatAt(i)
}
return r
}
// SpSolve returns the vector x solving A·x = b for a real symmetric
// positive-definite sparse A, by preconditioned conjugate gradient
// with a Jacobi (diagonal) preconditioner. The right-hand side b is a
// rank-1 vector of length n.
//
// The stopping rule is the relative residual ‖b − A·x‖₂ ≤ tol·‖b‖₂.
// A tol ≤ 0 uses 1e-10, and maxIter ≤ 0 uses n steps. x starts at
// zero, so the initial residual is b itself and the loop needs no
// separate first matvec. The default preconditioner is Jacobi
// scaling; an ILU(0) factorisation from NewSparseILU may be passed to
// replace it.
//
// An unconverged solve is an error naming the residual achieved, with
// no estimate returned, rather than a silent approximation.
// The matrix must be symmetric, and a zero diagonal entry is refused:
// the Jacobi preconditioner divides by it, and for a symmetric
// positive-definite matrix the diagonal is necessarily positive.
func SpSolve(a *core.SparseCOO, b *core.Array, tol float64, maxIter int, precond ...*SparseILU) (*core.Array, error) {
const name = "SpSolve"
n, err := checkSparseSquare(name, a, b)
if err != nil {
return nil, err
}
c, err := symmetricCSR(a, name)
if err != nil {
return nil, err
}
ilu, diag, err := pickPreconditioner(name, c, precond)
if err != nil {
return nil, err
}
if tol <= 0 {
tol = spSolveTol
}
if maxIter <= 0 {
maxIter = n
}
// x starts at zero, so r = b − A·x is just b and the loop needs no
// opening matvec.
r := vectorF64(b, n)
bNorm := norm2F64(r)
if bNorm == 0 {
// The exact solution of A·0 = 0 is the zero vector; a relative
// stopping rule would otherwise divide by zero.
return core.Zeros(core.Float, n)
}
z := make([]float64, n)
iluPrecondition(z, r, ilu, diag)
p := append([]float64(nil), z...)
rz := dotF64(r, z)
x := make([]float64, n)
ap := make([]float64, n)
for iter := range maxIter {
c.matVec(p, ap)
pAp := dotF64(p, ap)
if !finiteF64(pAp) || pAp <= 0 {
// A non-positive or non-finite curvature means the matrix
// is not positive-definite, or has overflowed the
// representable range; either way conjugate gradient
// cannot proceed. A non-finite value must be an error,
// not a NaN that slips through the <= 0 test.
return nil, base.Errf("%s: matrix is not positive-definite (curvature %.3g at step %d)",
name, pAp, iter+1)
}
alpha := rz / pAp
for i := range n {
x[i] += alpha * p[i]
r[i] -= alpha * ap[i]
}
// norm2F64 skips NaN entries, so an all-NaN residual reads as a
// zero norm; a non-finite state is a breakdown before the
// convergence test can mistake it for an exact solve.
if !vecFinite(r) {
return nil, base.Errf("%s: non-finite residual at step %d", name, iter+1)
}
if norm2F64(r) <= tol*bNorm {
return floatsToArray(x, []int{n}), nil
}
iluPrecondition(z, r, ilu, diag)
rzNext := dotF64(r, z)
if rz == 0 {
return nil, base.Errf("%s: breakdown at step %d (preconditioned residual vanished)", name, iter+1)
}
beta := rzNext / rz
for i := range n {
p[i] = z[i] + beta*p[i]
}
rz = rzNext
}
return nil, base.Errf("%s: no convergence in %d steps, residual %.3g (tolerance %.3g)",
name, maxIter, norm2F64(r), tol*bNorm)
}
// diagonal returns the main diagonal, which the Jacobi preconditioner
// divides by. A missing or zero entry makes the preconditioner
// undefined and, for a symmetric positive-definite matrix, signals
// that the input is not one.
func (c *sparseCSR) diagonal(name string) ([]float64, error) {
d := make([]float64, c.n)
for i := range c.n {
v, ok := c.at(i, i)
if !ok || v == 0 {
return nil, base.Errf("%s: zero or missing diagonal entry at %d", name, i)
}
d[i] = v
}
return d, nil
}