159 lines
4.6 KiB
Go
159 lines
4.6 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package grad
|
||
|
||
import (
|
||
"math"
|
||
"testing"
|
||
|
||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||
)
|
||
|
||
// Regression pins for the MatMul adjoints: the 1-D × 2-D branch against
|
||
// central differences, real and complex, and the Newton-CG loop's last
|
||
// iteration.
|
||
|
||
// TestMatMulVectorByMatrixGradient pins the 1-D × 2-D branch of
|
||
// the MatMul adjoint (da = g·Bᵀ, db = outer(a, g)), real and complex,
|
||
// against central differences. The branch had no gradient test at all.
|
||
func TestMatMulVectorByMatrixGradient(t *testing.T) {
|
||
avec := []float64{1.5, -0.5, 2, 0.25}
|
||
bvec := []float64{
|
||
0.5, -1, 2,
|
||
1.5, 0.25, -0.75,
|
||
-2, 1, 0.5,
|
||
1, -0.5, 1.25,
|
||
}
|
||
// A weighted linear loss, so every output slot carries its own
|
||
// coefficient and a wrong routing cannot cancel against another.
|
||
w := []float64{0.7, -1.3, 2.1}
|
||
lossOf := func(a, b *core.Array) float64 {
|
||
out, err := core.MatMul2D(a, b)
|
||
if err != nil {
|
||
t.Fatalf("MatMul2D: %v", err)
|
||
}
|
||
s := 0.0
|
||
for i := range out.Len() {
|
||
s += w[i] * out.FloatAt(i)
|
||
}
|
||
return s
|
||
}
|
||
cloneWith := func(a *core.Array, i int, v float64) *core.Array {
|
||
vals := make([]float64, a.Len())
|
||
for k := range a.Len() {
|
||
vals[k] = a.FloatAt(k)
|
||
}
|
||
vals[i] = v
|
||
out, err := core.FromFloats(vals, a.Shape()...)
|
||
if err != nil {
|
||
t.Fatalf("FromFloats: %v", err)
|
||
}
|
||
return out
|
||
}
|
||
a, _ := FromFloat64s(avec, true, 4)
|
||
b, _ := FromFloat64s(bvec, true, 4, 3)
|
||
if err := backwardWeightedMatMul(t, a, b, w); err != nil {
|
||
t.Fatalf("Backward: %v", err)
|
||
}
|
||
baseA, _ := core.FromFloats(avec, 4)
|
||
baseB, _ := core.FromFloats(bvec, 4, 3)
|
||
for i := range 4 {
|
||
eps := 1e-6
|
||
want := (lossOf(cloneWith(baseA, i, avec[i]+eps), baseB) -
|
||
lossOf(cloneWith(baseA, i, avec[i]-eps), baseB)) / (2 * eps)
|
||
if math.Abs(a.Grad().FloatAt(i)-want) > 1e-5*(1+math.Abs(want)) {
|
||
t.Fatalf("da[%d] = %g, want %g", i, a.Grad().FloatAt(i), want)
|
||
}
|
||
}
|
||
for i := range 12 {
|
||
eps := 1e-6
|
||
want := (lossOf(baseA, cloneWith(baseB, i, bvec[i]+eps)) -
|
||
lossOf(baseA, cloneWith(baseB, i, bvec[i]-eps))) / (2 * eps)
|
||
if math.Abs(b.Grad().FloatAt(i)-want) > 1e-5*(1+math.Abs(want)) {
|
||
t.Fatalf("db[%d] = %g, want %g", i, b.Grad().FloatAt(i), want)
|
||
}
|
||
}
|
||
|
||
// The complex 1-D × 2-D branch against numericComplexGrad, under
|
||
// the same weighted fold backwardComplex builds: L = Σ Re(w̄·y) +
|
||
// Σ|y|²/n with the helper's own deterministic weights.
|
||
cb := FromArray(mustComplexes([]complex128{
|
||
0.5 + 0.5i, -1,
|
||
1.5, 0.25 - 0.75i,
|
||
-2 + 1i, 1,
|
||
}, 3, 2), true)
|
||
op := func(x *Tensor) (*Tensor, error) { return x.MatMul(cb) }
|
||
vals := []complex128{1 + 0.5i, -0.25 - 1i, 0.75 + 0.25i}
|
||
xt := backwardComplex(t, op, vals, 3)
|
||
closs := complexLossOf(t, op, spectralWeights(2, 11))
|
||
checkAgainstNumeric(t, xt.Grad(), numericComplexGrad(closs, xt.Data()), 1e-6)
|
||
}
|
||
|
||
// backwardWeightedMatMul builds L = w·(a·B) over fresh leaves and runs
|
||
// one backward pass.
|
||
func backwardWeightedMatMul(t *testing.T, a, b *Tensor, w []float64) error {
|
||
t.Helper()
|
||
out, err := a.MatMul(b)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
wv, err := FromFloat64s(w, false, len(w))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
loss, err := out.Mul(wv)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
sum, err := loss.Sum()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
sum.Backward()
|
||
return nil
|
||
}
|
||
|
||
// TestNewtonCGConvergesOnTheLastIteration pins that a tolerance
|
||
// met exactly on the final permitted iteration is a success, not a
|
||
// budget error whose message prints a gradient already under the
|
||
// tolerance.
|
||
func TestNewtonCGConvergesOnTheLastIteration(t *testing.T) {
|
||
f := func(x *Tensor) (*Tensor, error) {
|
||
d, err := x.Sub(mustTensorF64(1.5))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return d.Mul(d)
|
||
}
|
||
x0, _ := core.FromFloats([]float64{0}, 1)
|
||
// One truncated-CG step lands within the Hessian-product rounding
|
||
// of the minimiser (about 1e-11 here); a tolerance of 1e-9 is met
|
||
// by exactly that step, on the final permitted iteration.
|
||
out, _, err := MinimiseNewtonCG(f, x0, NewtonCGOptions{MaxIterations: 1, Tolerance: 1e-9})
|
||
if err != nil {
|
||
t.Fatalf("MinimiseNewtonCG on a quadratic with one exact step: %v", err)
|
||
}
|
||
if math.Abs(out.FloatAt(0)-1.5) > 1e-9 {
|
||
t.Fatalf("minimum = %g, want 1.5", out.FloatAt(0))
|
||
}
|
||
}
|
||
|
||
// mustTensorF64 wraps one float as a no-grad tensor.
|
||
func mustTensorF64(v float64) *Tensor {
|
||
t, err := FromFloat64s([]float64{v}, false, 1)
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
return t
|
||
}
|
||
|
||
// mustComplexes builds a complex array or fails the test.
|
||
func mustComplexes(vals []complex128, shape ...int) *core.Array {
|
||
a, err := core.FromComplexes(vals, shape...)
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
return a
|
||
}
|