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

469 lines
16 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"
"strings"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// overdeterminedLSFixture builds a deterministic sparse overdetermined
// system: thirty rows, twelve columns, three stored entries per row,
// a right-hand side taken from a known x through the matrix plus a
// small inconsistent part, and its dense twin for the reference
// solvers.
func overdeterminedLSFixture(t *testing.T) (coo *core.SparseCOO, b *core.Array, dense *core.Array) {
t.Helper()
const m, n = 30, 12
g := core.NewGenerator(7)
idx := make([]int64, 0, 3*m+2)
vals := make([]float64, 0, 3*m+2)
add := func(r, c int, v float64) {
idx = append(idx, int64(r), int64(c))
vals = append(vals, v)
}
for i := range m {
add(i, i%n, 1+float64(g.Next()%100)/200)
add(i, (i*7+3)%n, -1+float64(g.Next()%100)/100)
add(i, (i*13+5)%n, float64(g.Next()%100)/100)
}
add(0, 0, 3)
add(1, 2, 2)
indices, err := core.FromInts(idx, len(vals), 2)
if err != nil {
t.Fatalf("FromInts: %v", err)
}
coo, err = core.NewSparseCOO(indices, floatsToArray(vals, []int{len(vals)}), []int{m, n})
if err != nil {
t.Fatalf("NewSparseCOO: %v", err)
}
csr, err := CSRFromCOO(coo)
if err != nil {
t.Fatalf("CSRFromCOO: %v", err)
}
xTrue := make([]float64, n)
for i := range n {
xTrue[i] = math.Sin(0.7*float64(i)) + float64(i%5)*0.3 - 0.6
}
b, err = csr.MatVec(floatsToArray(xTrue, []int{n}))
if err != nil {
t.Fatalf("MatVec: %v", err)
}
for i := range m {
b.RawFloats()[i] += 0.01 * math.Cos(float64(i))
}
denseVals := make([]float64, m*n)
for i := range len(vals) {
denseVals[int(idx[2*i])*n+int(idx[2*i+1])] += vals[i]
}
return coo, b, floatsToArray(denseVals, []int{m, n})
}
// lsAchievedNorms recomputes ‖b − A·x‖ and ‖Aᵀ(b − A·x)‖ through the
// public sparse surface, independently of the solver's own operator.
func lsAchievedNorms(t *testing.T, coo *core.SparseCOO, b, x *core.Array) (rNorm, arNorm float64) {
t.Helper()
csr, err := CSRFromCOO(coo)
if err != nil {
t.Fatalf("CSRFromCOO: %v", err)
}
ax, err := csr.MatVec(x)
if err != nil {
t.Fatalf("MatVec: %v", err)
}
r := core.New(core.Float, b.Len())
for i := range b.Len() {
r.RawFloats()[i] = b.FloatAt(i) - ax.FloatAt(i)
}
rNorm = norm2F64(vectorF64(r, b.Len()))
at, err := csr.Transpose().MatVec(r)
if err != nil {
t.Fatalf("MatVec: %v", err)
}
arNorm = norm2F64(vectorF64(at, at.Len()))
return rNorm, arNorm
}
func TestSpLSQRMatchesDenseLeastSquares(t *testing.T) {
coo, b, dense := overdeterminedLSFixture(t)
ref, err := LeastSquares(dense, b)
if err != nil {
t.Fatalf("LeastSquares: %v", err)
}
x, info, err := SpLSQR(coo, b, 1e-12, 0, 0)
if err != nil {
t.Fatalf("SpLSQR: %v", err)
}
worst, refMax := 0.0, 0.0
for i := range ref.Len() {
if d := math.Abs(x.FloatAt(i) - ref.FloatAt(i)); d > worst {
worst = d
}
if v := math.Abs(ref.FloatAt(i)); v > refMax {
refMax = v
}
}
if worst > 1e-9*refMax {
t.Fatalf("LSQR disagrees with the dense solve by %.3g (scale %.3g)", worst, refMax)
}
if !info.Converged {
t.Fatalf("LSQR stopped without convergence: %+v", info)
}
if info.Criterion != LeastSquaresResidual && info.Criterion != LeastSquaresNormal {
t.Fatalf("criterion %q is not a residual test", info.Criterion)
}
if info.Iterations > 2*12 {
t.Fatalf("LSQR took %d steps for a 12-column Krylov space", info.Iterations)
}
// The achieved quantities must be the explicit truth, not the
// in-loop estimates.
rNorm, arNorm := lsAchievedNorms(t, coo, b, x)
if math.Abs(rNorm-info.ResidualNorm) > 1e-9*info.ResidualNorm {
t.Fatalf("reported residual %.3g does not match the explicit %.3g", info.ResidualNorm, rNorm)
}
if math.Abs(arNorm-info.NormalResidual) > 1e-9*info.NormalResidual {
t.Fatalf("reported normal residual %.3g does not match the explicit %.3g", info.NormalResidual, arNorm)
}
}
func TestSpLSMRMatchesDenseLeastSquares(t *testing.T) {
coo, b, dense := overdeterminedLSFixture(t)
ref, err := LeastSquares(dense, b)
if err != nil {
t.Fatalf("LeastSquares: %v", err)
}
x, info, err := SpLSMR(coo, b, 1e-12, 0, 0)
if err != nil {
t.Fatalf("SpLSMR: %v", err)
}
worst, refMax := 0.0, 0.0
for i := range ref.Len() {
if d := math.Abs(x.FloatAt(i) - ref.FloatAt(i)); d > worst {
worst = d
}
if v := math.Abs(ref.FloatAt(i)); v > refMax {
refMax = v
}
}
if worst > 1e-9*refMax {
t.Fatalf("LSMR disagrees with the dense solve by %.3g (scale %.3g)", worst, refMax)
}
if !info.Converged || info.Criterion == "" {
t.Fatalf("LSMR stopped without convergence: %+v", info)
}
// LSMR's own minimisation target moves monotonically.
for i := 1; i < len(info.normalEstimates); i++ {
if info.normalEstimates[i] > info.normalEstimates[i-1] {
t.Fatalf("LSMR normal-equations estimate rose at step %d: %.6g after %.6g",
i, info.normalEstimates[i], info.normalEstimates[i-1])
}
}
}
func TestSpLSQREstimateTrajectoriesMonotone(t *testing.T) {
coo, b, _ := overdeterminedLSFixture(t)
// LSQR's residual estimate is |φ̄|, shrank every step by |sn| ≤ 1;
// LSMR's normal estimate is |ζ̄|, shrank by |s̄| ≤ 1. Both are
// monotone by construction and must stay so in float.
_, lsInfo, err := SpLSQR(coo, b, 1e-12, 0, 0)
if err != nil {
t.Fatalf("SpLSQR: %v", err)
}
for i := 1; i < len(lsInfo.residualEstimates); i++ {
if lsInfo.residualEstimates[i] > lsInfo.residualEstimates[i-1] {
t.Fatalf("LSQR residual estimate rose at step %d: %.6g after %.6g",
i, lsInfo.residualEstimates[i], lsInfo.residualEstimates[i-1])
}
}
_, lsmrInfo, err := SpLSMR(coo, b, 1e-12, 0, 0)
if err != nil {
t.Fatalf("SpLSMR: %v", err)
}
for i := 1; i < len(lsmrInfo.normalEstimates); i++ {
if lsmrInfo.normalEstimates[i] > lsmrInfo.normalEstimates[i-1] {
t.Fatalf("LSMR normal estimate rose at step %d: %.6g after %.6g",
i, lsmrInfo.normalEstimates[i], lsmrInfo.normalEstimates[i-1])
}
}
}
// TestSpLeastSquaresMinimumNorm pins the hand-checkable rank-deficient
// consistent system A = [[1,0,1],[0,1,1],[1,1,2]], b = (1,2,3): the
// solution family is (1−t, 2−t, t) and the minimum-norm member is
// (0, 1, 1), which both solvers must answer from a zero start.
func TestSpLeastSquaresMinimumNorm(t *testing.T) {
indices, err := core.FromInts([]int64{0, 0, 0, 2, 1, 1, 1, 2, 2, 0, 2, 1, 2, 2}, 7, 2)
if err != nil {
t.Fatalf("FromInts: %v", err)
}
coo, err := core.NewSparseCOO(indices, floatsToArray([]float64{1, 1, 1, 1, 1, 1, 2}, []int{7}), []int{3, 3})
if err != nil {
t.Fatalf("NewSparseCOO: %v", err)
}
csr, err := CSRFromCOO(coo)
if err != nil {
t.Fatalf("CSRFromCOO: %v", err)
}
b, err := csr.MatVec(floatsToArray([]float64{1, 2, 0}, []int{3}))
if err != nil {
t.Fatalf("MatVec: %v", err)
}
want := []float64{0, 1, 1}
for name, run := range map[string]func(*core.SparseCOO, *core.Array) (*core.Array, *LeastSquaresInfo, error){
"LSQR": func(a *core.SparseCOO, bb *core.Array) (*core.Array, *LeastSquaresInfo, error) {
return SpLSQR(a, bb, 1e-13, 0, 0)
},
"LSMR": func(a *core.SparseCOO, bb *core.Array) (*core.Array, *LeastSquaresInfo, error) {
return SpLSMR(a, bb, 1e-13, 0, 0)
},
} {
x, info, err := run(coo, b)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
for i := range want {
if math.Abs(x.FloatAt(i)-want[i]) > 1e-10 {
t.Fatalf("%s: x[%d] = %.12g, want the minimum-norm %.12g", name, i, x.FloatAt(i), want[i])
}
}
if !info.Converged {
t.Fatalf("%s: not converged: %+v", name, info)
}
}
// The same answer must agree with the SVD's minimum-norm solution.
dense := floatsToArray([]float64{1, 0, 1, 0, 1, 1, 1, 1, 2}, []int{3, 3})
pinv, err := Pinverse(dense, 0)
if err != nil {
t.Fatalf("Pinverse: %v", err)
}
ref := core.New(core.Float, 3)
for i := range 3 {
s := 0.0
for j := range 3 {
s += pinv.FloatAt(i*3+j) * b.FloatAt(j)
}
ref.RawFloats()[i] = s
}
for i := range 3 {
if math.Abs(ref.FloatAt(i)-want[i]) > 1e-12 {
t.Fatalf("Pinverse reference %.12g disagrees with the hand solution %.12g", ref.FloatAt(i), want[i])
}
}
}
// TestSpLeastSquaresIllConditioned pins convergence with the criterion
// recorded on a system whose diagonal decays by four orders: the
// conditional estimate stays under the limit and a residual test
// ends the iteration.
func TestSpLeastSquaresIllConditioned(t *testing.T) {
const n = 10
idx := make([]int64, 0, 3*n)
vals := make([]float64, 0, 3*n)
for i := range n {
idx = append(idx, int64(i), int64(i))
vals = append(vals, math.Pow(10, -0.45*float64(i)))
if i+1 < n {
idx = append(idx, int64(i), int64(i+1), int64(i+1), int64(i))
vals = append(vals, 1e-7, 1e-7)
}
}
indices, err := core.FromInts(idx, len(vals), 2)
if err != nil {
t.Fatalf("FromInts: %v", err)
}
coo, err := core.NewSparseCOO(indices, floatsToArray(vals, []int{len(vals)}), []int{n, n})
if err != nil {
t.Fatalf("NewSparseCOO: %v", err)
}
csr, err := CSRFromCOO(coo)
if err != nil {
t.Fatalf("CSRFromCOO: %v", err)
}
b, err := csr.MatVec(floatsToArray([]float64{1, 1, 1, 1, 1, 1, 1, 1, 1, 1}, []int{n}))
if err != nil {
t.Fatalf("MatVec: %v", err)
}
for name, run := range map[string]func(*core.SparseCOO, *core.Array) (*core.Array, *LeastSquaresInfo, error){
"LSQR": func(a *core.SparseCOO, bb *core.Array) (*core.Array, *LeastSquaresInfo, error) {
return SpLSQR(a, bb, 1e-9, 0, 0)
},
"LSMR": func(a *core.SparseCOO, bb *core.Array) (*core.Array, *LeastSquaresInfo, error) {
return SpLSMR(a, bb, 1e-9, 0, 0)
},
} {
x, info, err := run(coo, b)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if !info.Converged || info.Criterion == "" {
t.Fatalf("%s: ill-conditioned system ended without a recorded criterion: %+v", name, info)
}
if info.Condition <= 0 || info.MatrixNorm <= 0 {
t.Fatalf("%s: estimates not reported: %+v", name, info)
}
rNorm, _ := lsAchievedNorms(t, coo, b, x)
if rNorm > 1e-8 {
t.Fatalf("%s: achieved residual %.3g is too coarse", name, rNorm)
}
}
}
func TestSpLeastSquaresErrors(t *testing.T) {
coo, b, _ := overdeterminedLSFixture(t)
// Underdetermined systems are refused, in the dense surface's own
// words.
small, err := core.NewSparseCOO(
mustInts(t, []int64{0, 0, 0, 1}, 2, 2),
floatsToArray([]float64{1, 1}, []int{2}),
[]int{2, 3})
if err != nil {
t.Fatalf("NewSparseCOO: %v", err)
}
for name, run := range map[string]func(*core.SparseCOO, *core.Array) (*core.Array, *LeastSquaresInfo, error){
"LSQR": func(a *core.SparseCOO, bb *core.Array) (*core.Array, *LeastSquaresInfo, error) {
return SpLSQR(a, bb, 0, 0, 0)
},
"LSMR": func(a *core.SparseCOO, bb *core.Array) (*core.Array, *LeastSquaresInfo, error) {
return SpLSMR(a, bb, 0, 0, 0)
},
} {
if _, _, err := run(small, mustFloats(t, []float64{1, 2}, 2)); err == nil || !strings.Contains(err.Error(), "overdetermined") {
t.Fatalf("%s: underdetermined system accepted: %v", name, err)
}
}
// Complex inputs.
complexValues, err := core.FromComplexes([]complex128{1}, 1)
if err != nil {
t.Fatalf("FromComplexes: %v", err)
}
complexCOO, err := core.NewSparseCOO(mustInts(t, []int64{0, 0}, 1, 2), complexValues, []int{1, 1})
if err != nil {
t.Fatalf("NewSparseCOO: %v", err)
}
if _, _, err := SpLSQR(complexCOO, mustFloats(t, []float64{1}, 1), 0, 0, 0); err == nil {
t.Fatal("a complex matrix was accepted")
}
if _, _, err := SpLSQR(coo, mustFloats(t, []float64{1, 2}, 2), 0, 0, 0); err == nil {
t.Fatal("a short right-hand side was accepted")
}
if _, _, err := SpLSQR(coo, core.New(core.Float, 3, 3), 0, 0, 0); err == nil {
t.Fatal("a rank-2 right-hand side was accepted")
}
// A budget that runs out with every tolerance unmet is the budget's
// own fault: the error names the steps, not a drift no estimate
// committed. A tolerance of 1e-14 with two steps fires no criterion
// (the residual is still 1.59), and neither does 1e-300 in one.
if _, _, err := SpLSQR(coo, b, 1e-14, 2, 0); err == nil ||
!strings.Contains(err.Error(), "no convergence in 2 steps") ||
strings.Contains(err.Error(), "drifted") {
t.Fatalf("exhausted budget misreported: %v", err)
}
if _, _, err := SpLSQR(coo, b, 1e-300, 1, 0); err == nil ||
!strings.Contains(err.Error(), "no convergence in 1 steps") ||
strings.Contains(err.Error(), "drifted") {
t.Fatalf("exhausted budget misreported: %v", err)
}
if _, _, err := SpLSMR(coo, b, 1e-300, 1, 0); err == nil ||
!strings.Contains(err.Error(), "no convergence in 1 steps") ||
strings.Contains(err.Error(), "drifted") {
t.Fatalf("exhausted budget misreported: %v", err)
}
// A zero right-hand side answers the exact zero without a step.
zero := floatsToArray(make([]float64, b.Len()), []int{b.Len()})
x, info, err := SpLSQR(coo, zero, 0, 0, 0)
if err != nil {
t.Fatalf("zero right-hand side: %v", err)
}
for i := range x.Len() {
if x.FloatAt(i) != 0 {
t.Fatalf("zero right-hand side answered %.3g", x.FloatAt(i))
}
}
if info.Criterion != "" || !info.Converged || info.Iterations != 0 {
t.Fatalf("zero right-hand side info: %+v", info)
}
// A non-empty matrix with no column-space component of b answers
// the exact zero as well: both columns lie along (1,0) and
// b = (0,1) is orthogonal to the column space.
null, err := core.NewSparseCOO(mustInts(t, []int64{0, 0, 0, 1}, 2, 2), floatsToArray([]float64{1, 2}, []int{2}), []int{2, 2})
if err != nil {
t.Fatalf("NewSparseCOO: %v", err)
}
ortho := mustFloats(t, []float64{0, 1}, 2)
x, info, err = SpLSQR(null, ortho, 0, 0, 0)
if err != nil {
t.Fatalf("orthogonal right-hand side: %v", err)
}
for i := range x.Len() {
if x.FloatAt(i) != 0 {
t.Fatalf("orthogonal right-hand side answered %.3g", x.FloatAt(i))
}
}
if info.ResidualNorm <= 0 {
t.Fatalf("the achieved residual of an orthogonal system vanished: %+v", info)
}
}
// TestFinishLeastSquaresDriftRefusal pins the drift guard at its own
// gate: a fired criterion whose recomputed norms do not carry it is
// refused with the empty string, while the same answer at a tolerance
// it genuinely meets is carried. The end-to-end budget pins live in
// TestSpLeastSquaresErrors; the drift itself needs the estimate and
// the truth to disagree, which is settled here without a recursion.
func TestFinishLeastSquaresDriftRefusal(t *testing.T) {
coo, err := core.NewSparseCOO(mustInts(t, []int64{0, 0}, 1, 2), mustFloats(t, []float64{1}, 1), []int{1, 1})
if err != nil {
t.Fatalf("NewSparseCOO: %v", err)
}
op, err := newLSQROperator(coo)
if err != nil {
t.Fatalf("newLSQROperator: %v", err)
}
// x = 0.5 against A = [1], b = [1] leaves both the residual and
// the normal residual at 0.5 with ‖b‖ = 1.
if got := finishLeastSquares("SpLSQR", LeastSquaresResidual, false, 3, op, []float64{1}, []float64{0.5}, 1, 1, 1e-14); got != "" {
t.Fatalf("a drifted criterion carried as %q", got)
}
if got := finishLeastSquares("SpLSQR", LeastSquaresResidual, false, 3, op, []float64{1}, []float64{0.5}, 1, 1, 0.4); got != LeastSquaresResidual {
t.Fatalf("an honestly met criterion refused: %q", got)
}
}
// TestSpLSQREstimateSeriesIdentities pins what each estimate series
// carries, not merely that it shrinks. LSQR's residual estimate tracks
// ‖b − A·x‖ and its normal-equations estimate tracks ‖Aᵀ(b − A·x)‖, so
// on a converged fit the first ends beside the explicitly recomputed
// info.ResidualNorm while the second sits orders of magnitude below it.
// Filling the residual series with the normal estimate, or the reverse,
// keeps both series non-increasing and fails here.
func TestSpLSQREstimateSeriesIdentities(t *testing.T) {
coo, b, _ := overdeterminedLSFixture(t)
_, info, err := SpLSQR(coo, b, 1e-12, 0, 0)
if err != nil {
t.Fatalf("SpLSQR: %v", err)
}
re, ne := info.residualEstimates, info.normalEstimates
if len(re) != info.Iterations || len(ne) != info.Iterations {
t.Fatalf("estimate series of %d and %d against %d recorded steps", len(re), len(ne), info.Iterations)
}
if len(re) == 0 {
t.Fatal("no estimates recorded")
}
lastRe, lastNe := re[len(re)-1], ne[len(ne)-1]
if dev := math.Abs(lastRe - info.ResidualNorm); dev > 0.5*info.ResidualNorm {
t.Fatalf("the residual estimate ends at %.6g against the explicit residual %.6g, want the two on the same scale",
lastRe, info.ResidualNorm)
}
if lastNe > 1e-3*info.ResidualNorm {
t.Fatalf("the normal-equations estimate ends at %.6g against a residual of %.6g, want the normal residual's own, much smaller scale",
lastNe, info.ResidualNorm)
}
if lastRe == lastNe {
t.Fatalf("both series end at %.6g, want the residual and the normal-equations estimates", lastRe)
}
}