Files
tensor/linalg/decomp_test.go
T

480 lines
14 KiB
Go
Raw Normal View History

2026-09-03 10:00:00 +02:00
// 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/core"
"testing"
)
// TestSVDReconstruction checks that A = U · Σ · Vᵀ reconstructs A
// for several rank profiles (full-rank square, tall, wide, and a
// rank-1 matrix where the one-sided Jacobi prototype failed).
func TestSVDReconstruction(t *testing.T) {
cases := []struct {
name string
vals []float64
m, n int
hasSwap bool
}{
{
name: "square_full_rank",
vals: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9},
m: 3, n: 3,
},
{
name: "tall_rank2",
vals: []float64{1, 1, 1, 2, 2, 2, 3, 3, 3, 1, 2, 3},
m: 4, n: 3,
},
{
name: "wide_rank2",
vals: []float64{1, 2, 3, 4, 5, 1, 2, 3, 4, 5, 1, 2},
m: 3, n: 4,
hasSwap: true,
},
{
name: "rank1",
vals: []float64{2, 4, 6, 8, 10, 12},
m: 3, n: 2,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
a := mustFromFloats(t, tc.vals, tc.m, tc.n)
uOut, sigma, vt, err := SVD(a)
if err != nil {
t.Fatalf("SVD: %v", err)
}
// Thin SVD: U is (m, k), Vᵀ is (k, n) where k = min(m, n).
k := min(tc.m, tc.n)
wantU := [2]int{tc.m, k}
wantVt := [2]int{k, tc.n}
if got := uOut.Shape(); got[0] != wantU[0] || got[1] != wantU[1] {
t.Fatalf("U shape: got %v want %v", got, wantU)
}
if got := vt.Shape(); got[0] != wantVt[0] || got[1] != wantVt[1] {
t.Fatalf("Vᵀ shape: got %v want %v", got, wantVt)
}
wantSigma := k
if got := sigma.Shape()[0]; got != wantSigma {
t.Fatalf("sigma shape: got %d want %d", got, wantSigma)
}
aRecon := matmulSVDReconstruct(uOut, sigma, vt, tc.m, tc.n)
if !matricesClose(aRecon, denseFloats(a, tc.m, tc.n), tc.m, tc.n, 1e-9) {
t.Fatalf("SVD reconstruction [%s]: max diff > 1e-9\norig=%v\nrecon=%v",
tc.name, a, aRecon)
}
if !matricesThinOrthogonal(uOut.RawFloats(), uOut.Shape()[0], U_SVD_COLS(uOut), 1e-9) {
t.Errorf("U not column-orthonormal in %s (shape=%v)", tc.name, uOut.Shape())
}
for i := 1; i < wantSigma; i++ {
if sigma.RawFloats()[i-1] < sigma.RawFloats()[i] {
t.Errorf("Σ not sorted descending: %v", sigma.RawFloats())
break
}
}
})
}
}
// TestSVDRankDeficient checks the SVD on a deliberately rank-deficient
// matrix, the case the one-sided Jacobi prototype failed on.
func TestSVDRankDeficient(t *testing.T) {
u := []float64{1, 2, 3, 4}
v := []float64{1, -1, 1}
a := outerProduct(u, v, 4, 3)
uOut, sigma, vt, err := SVD(a)
if err != nil {
t.Fatalf("SVD: %v", err)
}
if len(sigma.RawFloats()) != 3 {
t.Fatalf("σ shape: %v", sigma.Shape())
}
if !(sigma.RawFloats()[0] > 1e-9) {
t.Errorf("σ₀: got %g, want > 1e-9", sigma.RawFloats()[0])
}
if math.Abs(sigma.RawFloats()[1]) > 1e-9 || math.Abs(sigma.RawFloats()[2]) > 1e-9 {
t.Errorf("σ₁ and σ₂ should be ~0, got %g %g",
sigma.RawFloats()[1], sigma.RawFloats()[2])
}
aRecon := matmulSVDReconstruct(uOut, sigma, vt, 4, 3)
if !matricesClose(aRecon, denseFloats(a, 4, 3), 4, 3, 1e-9) {
t.Fatalf("rank-deficient SVD reconstruction: max diff > 1e-9\norig=%v\nrecon=%v",
a, aRecon)
}
}
// TestEigenDiagonal checks that a diagonal matrix returns the
// diagonal entries as eigenvalues.
func TestEigenDiagonal(t *testing.T) {
a := mustFromFloats(t, []float64{
3, 0, 0,
0, 1, 0,
0, 0, 5,
}, 3, 3)
vals, vecs, err := Eigen(a)
if err != nil {
t.Fatalf("Eigen: %v", err)
}
if got := vals.Shape()[0]; got != 3 {
t.Fatalf("vals shape: %v", vals.Shape())
}
want := []float64{1, 3, 5}
for i, w := range want {
if math.Abs(vals.RawFloats()[i]-w) > 1e-9 {
t.Errorf("eigenvalue[%d]: got %g want %g", i, vals.RawFloats()[i], w)
}
}
recon := eigenReconstruct(vecs, vals, 3)
wantMat := denseFloats(a, 3, 3)
if !matricesClose(recon, wantMat, 3, 3, 1e-9) {
t.Fatalf("Eigen reconstruction: max diff > 1e-9\nwant=%v\ngot=%v",
wantMat, recon)
}
}
// TestEigenAsymmetric verifies the asymmetric input path errors.
func TestEigenAsymmetric(t *testing.T) {
a := mustFromFloats(t, []float64{
1, 2,
3, 4,
}, 2, 2)
if _, _, err := Eigen(a); err == nil {
t.Fatal("Eigen: expected error on asymmetric matrix")
}
}
// TestPinverse checks Moore-Penrose pseudoinverse on the canonical
// example and verifies A · A⁺ · A = A on a rank-deficient matrix.
func TestPinverse(t *testing.T) {
a := mustFromFloats(t, []float64{1, 2, 3, 4}, 2, 2)
inv, err := Pinverse(a, 0)
if err != nil {
t.Fatalf("Pinverse: %v", err)
}
if !matricesClose(denseFloats(inv, 2, 2), []float64{-2, 1, 1.5, -0.5}, 2, 2, 1e-9) {
t.Fatalf("Pinverse of invertible: got %v", inv)
}
u := []float64{1, 2, 3}
v := []float64{2, -1}
aRank1 := outerProduct(u, v, 3, 2)
pinv, err := Pinverse(aRank1, 0)
if err != nil {
t.Fatalf("Pinverse(rank1): %v", err)
}
_ = pinv
recon := matmulMul(matmulMul(denseFloats(aRank1, 3, 2), denseFloats(pinv, 2, 3), 3, 2, 3), denseFloats(aRank1, 3, 2), 3, 3, 2)
want := denseFloats(aRank1, 3, 2)
if !matricesClose(recon, want, 3, 2, 1e-9) {
t.Fatalf("A · A⁺ · A != A for rank-1 input")
}
}
// TestPinverseWideThinShapes pins the aspect-ratio plumbing of the
// pseudoinverse: a wide (m < n) input used to read the thin Vᵀ as an
// (n, n) matrix and panic past its payload. Values ride on the SVD
// track; the shapes must hold on their own.
func TestPinverseWideThinShapes(t *testing.T) {
diag := mustFromFloats(t, []float64{
3, 0, 0,
0, 2, 0,
}, 2, 3)
inv, err := Pinverse(diag, 0)
if err != nil {
t.Fatalf("Pinverse wide diagonal: %v", err)
}
if inv.Shape()[0] != 3 || inv.Shape()[1] != 2 {
t.Fatalf("Pinverse wide diagonal shape: %v, want (3, 2)", inv.Shape())
}
general := mustFromFloats(t, []float64{1, 2, 3, 4, 5, 6}, 2, 3)
inv2, err := Pinverse(general, 0)
if err != nil {
t.Fatalf("Pinverse wide general: %v", err)
}
if inv2.Shape()[0] != 3 || inv2.Shape()[1] != 2 {
t.Fatalf("Pinverse wide general shape: %v, want (3, 2)", inv2.Shape())
}
}
// TestPinverseWideDiagonalValues checks the wide-matrix values against
// the exact pseudoinverse once the SVD track lands.
func TestPinverseWideDiagonalValues(t *testing.T) {
a := mustFromFloats(t, []float64{
3, 0, 0,
0, 2, 0,
}, 2, 3)
inv, err := Pinverse(a, 0)
if err != nil {
t.Fatalf("Pinverse wide: %v", err)
}
want := []float64{1.0 / 3, 0, 0, 0.5, 0, 0}
got := denseFloats(inv, 3, 2)
for i, w := range want {
if diff := got[i] - w; diff > 1e-12 || diff < -1e-12 {
t.Fatalf("Pinverse wide [%d]: got %v, want %v", i, got[i], w)
}
}
}
// TestMatrixRankProperties checks rank on a couple of cases.
func TestMatrixRankProperties(t *testing.T) {
// Identity padded with a zero row: rows [1,0],[0,1],[0,0].
a := mustFromFloats(t, []float64{1, 0, 0, 1, 0, 0}, 3, 2)
if got, err := MatrixRank(a, 0); err != nil || got != 2 {
t.Errorf("MatrixRank(full-rank): got %d err %v, want 2", got, err)
}
// Every row a multiple of [1,2]: rank 1.
rank1 := mustFromFloats(t, []float64{1, 2, 2, 4, 3, 6}, 3, 2)
if got, err := MatrixRank(rank1, 0); err != nil || got != 1 {
t.Errorf("MatrixRank(rank-1): got %d err %v, want 1", got, err)
}
}
// TestCondProperties checks condition number on a diagonal matrix
// (singular values are the diagonal entries).
func TestCondProperties(t *testing.T) {
a := mustFromFloats(t, []float64{
3, 0,
0, 0.5,
}, 2, 2)
c, err := Cond(a, 0)
if err != nil {
t.Fatalf("Cond: %v", err)
}
if math.Abs(c-6) > 1e-9 {
t.Errorf("Cond([[3, 0], [0, 0.5]]): got %g want 6", c)
}
sing := mustFromFloats(t, []float64{
2, 0,
0, 0,
}, 2, 2)
c, err = Cond(sing, 0)
if err != nil {
t.Fatalf("Cond(singular): %v", err)
}
if !math.IsInf(c, 1) {
t.Errorf("Cond(singular): got %g want +Inf", c)
}
}
// TestSVDWideTransposed checks that a wide matrix (m < n) decomposes
// correctly with the transpose swap.
func TestSVDWideTransposed(t *testing.T) {
a := mustFromFloats(t, []float64{
1, 2, 3, 4,
5, 6, 7, 8,
9, 10, 11, 12,
}, 3, 4)
uOut, sigma, vt, err := SVD(a)
if err != nil {
t.Fatalf("SVD: %v", err)
}
if uOut.Shape()[0] != 3 || uOut.Shape()[1] != 3 {
t.Fatalf("U shape: got %v want (3, 3)", uOut.Shape())
}
if vt.Shape()[0] != 3 || vt.Shape()[1] != 4 {
t.Fatalf("Vᵀ shape: got %v want (3, 4)", vt.Shape())
}
if sigma.Shape()[0] != 3 {
t.Fatalf("Σ shape: got %v want (3,)", sigma.Shape())
}
recon := matmulSVDReconstruct(uOut, sigma, vt, 3, 4)
if !matricesClose(recon, denseFloats(a, 3, 4), 3, 4, 1e-9) {
t.Fatalf("wide SVD reconstruction: max diff > 1e-9")
}
}
// --- helpers (test-only) ---
// U_SVD_COLS returns the number of columns of a thin SVD U
// (min(m, n)), the rank-n bound the orthogonal columns reach.
func U_SVD_COLS(u *core.Array) int {
return u.Shape()[1]
}
func matmulSVDReconstruct(u, sigma, vt *core.Array, m, n int) []float64 {
// Thin SVD: U is (m, k); Σ is (k,); Vᵀ is (k, n) where k = min(m, n).
k := min(m, n)
uMat := denseFloats(u, m, k)
vtMat := denseFloats(vt, k, n)
sVals := sigma.RawFloats()
out := make([]float64, m*n)
for i := range m {
for j := range n {
s := 0.0
for kk := range k {
s += uMat[i*k+kk] * sVals[kk] * vtMat[kk*n+j]
}
out[i*n+j] = s
}
}
return out
}
func eigenReconstruct(q, vals *core.Array, n int) []float64 {
qMat := denseFloats(q, n, n)
out := make([]float64, n*n)
for i := range n {
for j := range n {
s := 0.0
for k := range n {
s += qMat[i*n+k] * vals.RawFloats()[k] * qMat[j*n+k]
}
out[i*n+j] = s
}
}
return out
}
func outerProduct(u, v []float64, m, n int) *core.Array {
out := make([]float64, m*n)
for i := range m {
for j := range n {
out[i*n+j] = u[i] * v[j]
}
}
return floatsToArray(out, []int{m, n})
}
func matmulMul(a, b []float64, rows, mid, cols int) []float64 {
out := make([]float64, rows*cols)
for i := range rows {
for j := range cols {
s := 0.0
for k := range mid {
s += a[i*mid+k] * b[k*cols+j]
}
out[i*cols+j] = s
}
}
return out
}
func matricesClose(a, b []float64, m, n int, tol float64) bool {
if len(a) != m*n || len(b) != m*n {
return false
}
for i := 0; i < m*n; i++ {
if math.Abs(a[i]-b[i]) > tol {
return false
}
}
return true
}
// matricesThinOrthogonal checks that a (m, n) matrix a satisfies
// aᵀ a = I_n. Used for the thin SVD factor U.
func matricesThinOrthogonal(a []float64, m, n int, tol float64) bool {
for i := range n {
for j := range n {
s := 0.0
want := 0.0
if i == j {
want = 1
}
for k := range m {
s += a[k*n+i] * a[k*n+j]
}
if math.Abs(s-want) > tol {
return false
}
}
}
return true
}
// TestLeastSquaresCollinear pins the rank guard: exactly collinear
// columns are refused, and near-collinear ones whose R pivot sits at
// the rounding floor of max|R| are refused too, where the old
// exact-zero check returned a silently huge x.
func TestLeastSquaresCollinear(t *testing.T) {
// Exactly collinear, integer dtype: the historical case.
ints, err := core.FromInts([]int64{1, 2, 2, 4}, 2, 2)
if err != nil {
t.Fatalf("FromInts: %v", err)
}
if _, err := LeastSquares(ints, mustFloats(t, []float64{3, 7})); err == nil {
t.Fatal("exactly collinear integer columns: want an error")
}
// Near-collinear float columns: the second column differs from the
// first by 2^-45, so its R pivot (2^-45) sits below the relative
// rank floor n·eps·max|R| = 2·eps·2^10, where back-substitution
// would amplify it into a ~1e13 answer. Powers of two keep every
// elimination step exact, so the trip is deterministic.
a := mustFloats(t, []float64{
1024, 1024,
0, math.Ldexp(1, -45),
0, 0,
}, 3, 2)
if _, err := LeastSquares(a, mustFloats(t, []float64{1, 1, 1})); err == nil {
t.Fatal("near-collinear columns: want an error")
}
// A well-conditioned system of the same shape still solves.
good := mustFloats(t, []float64{
1, 1,
1, 2,
1, 3,
}, 3, 2)
x, err := LeastSquares(good, mustFloats(t, []float64{2, 3, 4}))
if err != nil {
t.Fatalf("LeastSquares full-rank: %v", err)
}
if math.Abs(x.FloatAt(0)-1) > 1e-9 || math.Abs(x.FloatAt(1)-1) > 1e-9 {
t.Fatalf("x = (%.12g, %.12g), want (1, 1)", x.FloatAt(0), x.FloatAt(1))
}
}
// TestCondZeroMatrix pins the zero-matrix contract: no usable inverse
// direction, so the condition number is +Inf, not 0.
func TestCondZeroMatrix(t *testing.T) {
c, err := Cond(mustFloats(t, []float64{0, 0, 0, 0}, 2, 2), 0)
if err != nil {
t.Fatalf("Cond: %v", err)
}
if !math.IsInf(c, 1) {
t.Fatalf("Cond(zero matrix) = %g, want +Inf", c)
}
}
// TestCholeskyUpdateRejectsUpperTriangle pins the factor validation:
// a nonzero strict upper triangle is refused, matching what the error
// message has always claimed.
func TestCholeskyUpdateRejectsUpperTriangle(t *testing.T) {
l := mustFloats(t, []float64{
2, 1,
0, 1,
}, 2, 2)
if _, err := CholeskyUpdate(l, mustFloats(t, []float64{1, 1})); err == nil {
t.Fatal("CholeskyUpdate with a nonzero upper triangle: want an error")
}
if _, err := CholeskyDowndate(l, mustFloats(t, []float64{1, 1})); err == nil {
t.Fatal("CholeskyDowndate with a nonzero upper triangle: want an error")
}
}
// TestHouseholderVectorIntoLargeScale pins the overflow-safe reflector:
// entries near 1e154 keep a finite norm and beta instead of squaring
// their way to +Inf.
func TestHouseholderVectorIntoLargeScale(t *testing.T) {
x := []float64{1e154, 1e154, 1e154}
dst := make([]float64, 3)
hh := householderVectorInto(dst, x)
if math.IsInf(hh.beta, 0) || math.IsNaN(hh.beta) {
t.Fatalf("beta = %v, want finite", hh.beta)
}
for i, v := range hh.v {
if math.IsInf(v, 0) || math.IsNaN(v) {
t.Fatalf("v[%d] = %v, want finite", i, v)
}
}
// The zero vector still answers a zero-beta identity reflector.
empty := householderVectorInto(make([]float64, 2), []float64{0, 0})
if empty.beta != 0 {
t.Fatalf("beta = %v for a zero vector, want 0", empty.beta)
}
}