Files

610 lines
20 KiB
Go
Raw Permalink 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 optim
import (
"math"
"math/big"
"strings"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// linearPair returns the determined linear pair r(p) = (p₀ + 2p₁ − 3,
// 2p₀ + p₁ − 4) with its constant Jacobian, the model whose
// Gauss-Newton step is exact.
func linearPair() (func(*core.Array) (*core.Array, error), func(*core.Array) (*core.Array, error)) {
residual := func(p *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{
p.FloatAt(0) + 2*p.FloatAt(1) - 3,
2*p.FloatAt(0) + p.FloatAt(1) - 4,
}, 2)
}
jacobian := func(*core.Array) (*core.Array, error) {
return core.FromFloats([]float64{1, 2, 2, 1}, 2, 2)
}
return residual, jacobian
}
// TestLevenbergMarquardtFitConverged pins the Fit surface on the model
// the plain entry point already covers: same point, same chi2, and a
// status that says converged.
func TestLevenbergMarquardtFitConverged(t *testing.T) {
residual, jacobian := linearPair()
p0 := mustFloats(t, []float64{0, 0}, 2)
res, err := LevenbergMarquardtFit(residual, p0, LMOptions{Jacobian: jacobian, Tolerance: 1e-14})
if err != nil {
t.Fatalf("LevenbergMarquardtFit: %v", err)
}
if res.Status != FitConverged {
t.Fatalf("status = %d, want FitConverged", res.Status)
}
if math.Abs(res.Parameters.FloatAt(0)-5.0/3) > 1e-8 || math.Abs(res.Parameters.FloatAt(1)-2.0/3) > 1e-8 {
t.Fatalf("p = (%.12g, %.12g), want (5/3, 2/3)", res.Parameters.FloatAt(0), res.Parameters.FloatAt(1))
}
if res.Chi2 > 1e-16 {
t.Fatalf("chi2 = %v, want 0", res.Chi2)
}
if res.Covariance != nil {
t.Fatal("a fit that did not ask for a covariance returned one")
}
legacyP, legacyChi2, err := LevenbergMarquardt(residual, p0, LMOptions{Jacobian: jacobian, Tolerance: 1e-14})
if err != nil {
t.Fatalf("LevenbergMarquardt: %v", err)
}
if legacyP.FloatAt(0) != res.Parameters.FloatAt(0) || legacyP.FloatAt(1) != res.Parameters.FloatAt(1) {
t.Fatal("the two entry points returned different parameters")
}
if legacyChi2 != res.Chi2 {
t.Fatalf("the two entry points reported chi2 %v and %v", legacyChi2, res.Chi2)
}
}
// TestLevenbergMarquardtGradientExit pins the gradient test: with the
// chi2 tolerance set far below anything the fit can reach, the only
// exit that can fire after the first accepted step is ‖Jᵀr‖∞. The
// residual call count proves it: the start and the one trial make
// two, and a third call would mean the run walked into a second
// solve, which the gradient test forbids.
func TestLevenbergMarquardtGradientExit(t *testing.T) {
residual, jacobian := linearPair()
calls := 0
counted := func(p *core.Array) (*core.Array, error) {
calls++
return residual(p)
}
p0 := mustFloats(t, []float64{0, 0}, 2)
// Lambda 1e-300 leaves the damping factor 1+λ bit-identically 1,
// so the first step is the exact Gauss-Newton one and the run
// reaches the solution in one acceptance.
res, err := LevenbergMarquardtFit(counted, p0, LMOptions{
Jacobian: jacobian,
Tolerance: 1e-30,
GradTol: 1e-7,
Lambda: 1e-300,
})
if err != nil {
t.Fatalf("LevenbergMarquardtFit: %v", err)
}
if res.Status != FitConverged {
t.Fatalf("status = %d, want FitConverged", res.Status)
}
if calls != 2 {
t.Fatalf("the residual was evaluated %d times, want 2 (the gradient test must stop the run before its second solve)", calls)
}
if math.Abs(res.Parameters.FloatAt(0)-5.0/3) > 1e-8 || math.Abs(res.Parameters.FloatAt(1)-2.0/3) > 1e-8 {
t.Fatalf("p = (%.12g, %.12g), want (5/3, 2/3)", res.Parameters.FloatAt(0), res.Parameters.FloatAt(1))
}
}
// TestLevenbergMarquardtStepExit pins the step test: a linear
// residual whose first accepted step is negligible against the
// point's own scale converges on that step, with the chi2 tolerance
// far too tight to claim the exit.
func TestLevenbergMarquardtStepExit(t *testing.T) {
residual := func(p *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{10 * (p.FloatAt(0) - 10)}, 1)
}
jacobian := func(*core.Array) (*core.Array, error) {
return core.FromFloats([]float64{10}, 1, 1)
}
counted := func(count *int) func(*core.Array) (*core.Array, error) {
return func(p *core.Array) (*core.Array, error) {
*count++
return residual(p)
}
}
stepCalls := 0
res, err := LevenbergMarquardtFit(counted(&stepCalls), mustFloats(t, []float64{10.001}, 1), LMOptions{
Jacobian: jacobian,
Tolerance: 1e-15,
StepTol: 0.5,
})
if err != nil {
t.Fatalf("LevenbergMarquardtFit: %v", err)
}
if res.Status != FitConverged {
t.Fatalf("status = %d, want FitConverged", res.Status)
}
if stepCalls != 2 {
t.Fatalf("the step-exit run evaluated the residual %d times, want 2", stepCalls)
}
if math.Abs(res.Parameters.FloatAt(0)-10) > 1e-4 {
t.Fatalf("p = %.12g, want 10", res.Parameters.FloatAt(0))
}
// Without the step test the same run needs the chi2 tolerance, and
// at 1e-6 that fires one acceptance later: three evaluations.
wideCalls := 0
wide, err := LevenbergMarquardtFit(counted(&wideCalls), mustFloats(t, []float64{10.001}, 1), LMOptions{
Jacobian: jacobian,
Tolerance: 1e-6,
})
if err != nil {
t.Fatalf("LevenbergMarquardtFit without StepTol: %v", err)
}
if wide.Status != FitConverged {
t.Fatalf("status = %d, want FitConverged", wide.Status)
}
if wideCalls != 3 {
t.Fatalf("the control run evaluated the residual %d times, want 3", wideCalls)
}
}
// TestLevenbergMarquardtFitStalled pins the stalled contract: a
// parameter the residual never sees makes the normal equations
// singular on the first iteration, and the fit reports the start
// point back with FitStalled instead of an error. The legacy entry
// point keeps its historical error for the same run.
func TestLevenbergMarquardtFitStalled(t *testing.T) {
residual := func(p *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{p.FloatAt(1) - 1, p.FloatAt(1) - 1}, 2)
}
jacobian := func(*core.Array) (*core.Array, error) {
return core.FromFloats([]float64{0, 1, 0, 1}, 2, 2)
}
p0 := mustFloats(t, []float64{5, 5}, 2)
res, err := LevenbergMarquardtFit(residual, p0, LMOptions{Jacobian: jacobian})
if err != nil {
t.Fatalf("LevenbergMarquardtFit: %v", err)
}
if res.Status != FitStalled {
t.Fatalf("status = %d, want FitStalled", res.Status)
}
if res.Parameters.FloatAt(0) != 5 || res.Parameters.FloatAt(1) != 5 {
t.Fatalf("p = (%.12g, %.12g), want the untouched start", res.Parameters.FloatAt(0), res.Parameters.FloatAt(1))
}
if res.Chi2 != 32 {
t.Fatalf("chi2 = %v, want 32", res.Chi2)
}
if _, _, err := LevenbergMarquardt(residual, p0, LMOptions{Jacobian: jacobian}); err == nil {
t.Fatal("the legacy entry point must keep reporting the singular solve as an error")
}
}
// TestLevenbergMarquardtFitBudget pins the budget report: a fit given
// two iterations of a problem that needs more returns its last point
// with FitBudget and no error, and the legacy entry point refuses the
// same run unless AllowBudgetExit is set.
func TestLevenbergMarquardtFitBudget(t *testing.T) {
xData := []float64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
residual := func(p *core.Array) (*core.Array, error) {
a, b, c := p.FloatAt(0), p.FloatAt(1), p.FloatAt(2)
out := core.New(core.Float, len(xData))
for i := range len(xData) {
out.RawFloats()[i] = 3*math.Exp(-0.5*float64(xData[i])) + 0.5 -
(a*math.Exp(-b*float64(xData[i])) + c)
}
return out, nil
}
p0 := mustFloats(t, []float64{20, 5, 20}, 3)
res, err := LevenbergMarquardtFit(residual, p0, LMOptions{MaxIterations: 2})
if err != nil {
t.Fatalf("LevenbergMarquardtFit: %v", err)
}
if res.Status != FitBudget {
t.Fatalf("status = %d, want FitBudget", res.Status)
}
if res.Parameters == nil {
t.Fatal("a budget stop must still report its last point")
}
if _, _, err := LevenbergMarquardt(residual, p0, LMOptions{MaxIterations: 2}); err == nil {
t.Fatal("the legacy entry point must refuse a budget stop without AllowBudgetExit")
}
if _, _, err := LevenbergMarquardt(residual, p0, LMOptions{MaxIterations: 2, AllowBudgetExit: true}); err != nil {
t.Fatalf("LevenbergMarquardt with AllowBudgetExit: %v", err)
}
}
// weightedModel builds the three-observation linear model r(p) = y −
// Xp with its Jacobian, small enough for the exact rational referent.
func weightedModel() (func(*core.Array) (*core.Array, error), func(*core.Array) (*core.Array, error)) {
x := [][]float64{{1, 0}, {0, 1}, {1, 1}}
y := []float64{1.2, 0.7, 2.4}
residual := func(p *core.Array) (*core.Array, error) {
out := core.New(core.Float, 3)
for i := range 3 {
out.RawFloats()[i] = y[i] - (x[i][0]*p.FloatAt(0) + x[i][1]*p.FloatAt(1))
}
return out, nil
}
jacobian := func(*core.Array) (*core.Array, error) {
return core.FromFloats([]float64{-1, 0, 0, -1, -1, -1}, 3, 2)
}
return residual, jacobian
}
// ratSolve solves a x = b exactly over the rationals by elimination
// with nonzero pivots; a singular system returns nil. The inputs are
// copied: big.Rat values are pointers, and the elimination mutates
// every entry it touches.
func ratSolve(a, b [][]*big.Rat) [][]*big.Rat {
n := len(a)
m := make([][]*big.Rat, n)
for i := range n {
m[i] = make([]*big.Rat, 0, len(a[i])+len(b[i]))
for _, v := range a[i] {
m[i] = append(m[i], new(big.Rat).Set(v))
}
for _, v := range b[i] {
m[i] = append(m[i], new(big.Rat).Set(v))
}
}
zero := new(big.Rat)
for col := range n {
piv := -1
for row := col; row < n; row++ {
if m[row][col].Cmp(zero) != 0 {
piv = row
break
}
}
if piv < 0 {
return nil
}
m[col], m[piv] = m[piv], m[col]
inv := new(big.Rat).Inv(m[col][col])
for j := col; j < len(m[col]); j++ {
m[col][j].Mul(m[col][j], inv)
}
for row := range n {
if row == col || m[row][col].Cmp(zero) == 0 {
continue
}
f := new(big.Rat).Set(m[row][col])
for j := col; j < len(m[row]); j++ {
m[row][j].Sub(m[row][j], new(big.Rat).Mul(f, m[col][j]))
}
}
}
out := make([][]*big.Rat, n)
for i := range n {
out[i] = m[i][n:]
}
return out
}
// ratFroms converts float columns into exact rationals.
func ratFroms(vs []float64) []*big.Rat {
out := make([]*big.Rat, len(vs))
for i, v := range vs {
out[i] = new(big.Rat).SetFloat64(v)
}
return out
}
// ratColumn turns a vector into the one-column right-hand side
// ratSolve takes.
func ratColumn(vs []*big.Rat) [][]*big.Rat {
out := make([][]*big.Rat, len(vs))
for i, v := range vs {
out[i] = []*big.Rat{v}
}
return out
}
// ratMatrixInvert inverts a small square rational matrix.
func ratMatrixInvert(a [][]*big.Rat) [][]*big.Rat {
n := len(a)
id := make([][]*big.Rat, n)
for i := range n {
row := make([]*big.Rat, n)
for j := range n {
row[j] = new(big.Rat)
if i == j {
row[j].SetInt64(1)
}
}
id[i] = row
}
return ratSolve(a, id)
}
// TestLevenbergMarquardtWeights pins the weighted fit against an
// exact rational referent: chi2 becomes rᵀC⁻¹r, the parameters
// minimise it, and the diagonal and matrix forms of Sigma agree on
// the same weighting.
func TestLevenbergMarquardtWeights(t *testing.T) {
residual, jacobian := weightedModel()
cov := []float64{
2, 1, 0,
1, 3, 1,
0, 1, 2,
}
sigma, err := core.FromFloats(cov, 3, 3)
if err != nil {
t.Fatal(err)
}
p0 := mustFloats(t, []float64{0, 0}, 2)
res, err := LevenbergMarquardtFit(residual, p0, LMOptions{Jacobian: jacobian, Sigma: sigma})
if err != nil {
t.Fatalf("LevenbergMarquardtFit: %v", err)
}
if res.Status != FitConverged {
t.Fatalf("status = %d, want FitConverged", res.Status)
}
// The exact referent: r = y − Xp, so the weighted optimum solves
// (XᵀC⁻¹X) p = XᵀC⁻¹y.
xf := [][]float64{{1, 0}, {0, 1}, {1, 1}}
yf := []float64{1.2, 0.7, 2.4}
cr := make([][]*big.Rat, 3)
for i := range 3 {
cr[i] = ratFroms(cov[i*3 : i*3+3])
}
cinv := ratMatrixInvert(cr)
if cinv == nil {
t.Fatal("the referent covariance is singular")
}
xr := make([][]*big.Rat, 3)
for i := range 3 {
xr[i] = ratFroms(xf[i])
}
yr := ratFroms(yf)
cinvX := ratSolve(cr, xr)
normal := make([][]*big.Rat, 2)
for i := range 2 {
normal[i] = make([]*big.Rat, 2)
for j := range 2 {
s := new(big.Rat)
for k := range 3 {
s.Add(s, new(big.Rat).Mul(xr[k][i], cinvX[k][j]))
}
normal[i][j] = s
}
}
rhs := make([]*big.Rat, 2)
cinvY := ratSolve(cr, ratColumn(yr))
if cinvY == nil {
t.Fatal("the referent solve failed")
}
for i := range 2 {
s := new(big.Rat)
for k := range 3 {
s.Add(s, new(big.Rat).Mul(xr[k][i], cinvY[k][0]))
}
rhs[i] = s
}
wantP := ratSolve(normal, ratColumn(rhs))
if wantP == nil {
t.Fatal("the referent normal equations are singular")
}
for j := range 2 {
got := new(big.Rat).SetFloat64(res.Parameters.FloatAt(j))
d := new(big.Rat).Sub(got, wantP[j][0])
d.Abs(d)
if d.Cmp(new(big.Rat).SetFloat64(1e-10)) > 0 {
t.Fatalf("parameter %d = %.12g, want %s", j, res.Parameters.FloatAt(j), wantP[j][0].FloatString(12))
}
}
// The weighted chi2: rᵀC⁻¹r at the returned point.
rAt := make([]*big.Rat, 3)
for k := range 3 {
rAt[k] = new(big.Rat).SetFloat64(yf[k] - (xf[k][0]*res.Parameters.FloatAt(0) + xf[k][1]*res.Parameters.FloatAt(1)))
}
cinvR := make([]*big.Rat, 3)
for k := range 3 {
s := new(big.Rat)
for j := range 3 {
s.Add(s, new(big.Rat).Mul(cinv[k][j], rAt[j]))
}
cinvR[k] = s
}
chiRef := new(big.Rat)
for k := range 3 {
chiRef.Add(chiRef, new(big.Rat).Mul(rAt[k], cinvR[k]))
}
chiFloat, _ := chiRef.Float64()
if math.Abs(res.Chi2-chiFloat) > 1e-12*math.Max(1, math.Abs(chiFloat)) {
t.Fatalf("chi2 = %.17g, want %.17g", res.Chi2, chiFloat)
}
// The weighted answer must differ from the unweighted one, or the
// test proves nothing about the weighting.
plain, _, err := LevenbergMarquardt(residual, p0, LMOptions{Jacobian: jacobian})
if err != nil {
t.Fatalf("the unweighted fit: %v", err)
}
if plain.FloatAt(0) == res.Parameters.FloatAt(0) && plain.FloatAt(1) == res.Parameters.FloatAt(1) {
t.Fatal("the weighted and unweighted fits returned identical parameters")
}
// The diagonal form weights by the same matrix's diagonal, and the
// legacy entry point carries Sigma too.
variance, err := core.FromFloats([]float64{2, 3, 2}, 3)
if err != nil {
t.Fatal(err)
}
diagRes, err := LevenbergMarquardtFit(residual, p0, LMOptions{Jacobian: jacobian, Sigma: variance})
if err != nil {
t.Fatalf("the diagonal Sigma fit: %v", err)
}
if diagRes.Status != FitConverged {
t.Fatalf("the diagonal Sigma status = %d, want FitConverged", diagRes.Status)
}
wp, _, err := LevenbergMarquardt(residual, p0, LMOptions{Jacobian: jacobian, Sigma: sigma})
if err != nil {
t.Fatalf("the legacy weighted fit: %v", err)
}
if wp.FloatAt(0) != res.Parameters.FloatAt(0) || wp.FloatAt(1) != res.Parameters.FloatAt(1) {
t.Fatal("the legacy weighted fit returned different parameters")
}
}
// TestLevenbergMarquardtWeightedCovariance pins the covariance under
// Sigma: (JᵀC⁻¹J)⁻¹ against the exact rational inverse, entry by
// entry.
func TestLevenbergMarquardtWeightedCovariance(t *testing.T) {
residual, jacobian := weightedModel()
cov := []float64{
2, 1, 0,
1, 3, 1,
0, 1, 2,
}
sigma, err := core.FromFloats(cov, 3, 3)
if err != nil {
t.Fatal(err)
}
xf := [][]float64{{1, 0}, {0, 1}, {1, 1}}
cr := make([][]*big.Rat, 3)
for i := range 3 {
cr[i] = ratFroms(cov[i*3 : i*3+3])
}
xr := make([][]*big.Rat, 3)
for i := range 3 {
xr[i] = ratFroms(xf[i])
}
cinvX := ratSolve(cr, xr)
normal := make([][]*big.Rat, 2)
for i := range 2 {
normal[i] = make([]*big.Rat, 2)
for j := range 2 {
s := new(big.Rat)
for k := range 3 {
s.Add(s, new(big.Rat).Mul(xr[k][i], cinvX[k][j]))
}
normal[i][j] = s
}
}
want := ratMatrixInvert(normal)
if want == nil {
t.Fatal("the referent normal matrix is singular")
}
res, err := LevenbergMarquardtFit(residual, mustFloats(t, []float64{0, 0}, 2), LMOptions{
Jacobian: jacobian,
Sigma: sigma,
RequestCovariance: true,
})
if err != nil {
t.Fatalf("LevenbergMarquardtFit: %v", err)
}
if res.Covariance == nil {
t.Fatal("the requested covariance is missing")
}
if res.Covariance.NDim() != 2 || res.Covariance.Shape()[0] != 2 || res.Covariance.Shape()[1] != 2 {
t.Fatalf("covariance shape %s, want 2×2", base.ShapeText(res.Covariance.Shape()))
}
for i := range 2 {
for j := range 2 {
got := res.Covariance.FloatAt(i*2 + j)
wantFloat, _ := want[i][j].Float64()
if math.Abs(got-wantFloat) > 1e-9*math.Max(1, math.Abs(wantFloat)) {
t.Fatalf("covariance (%d, %d) = %.17g, want %.17g", i, j, got, wantFloat)
}
mirror := res.Covariance.FloatAt(j*2 + i)
if got != mirror {
t.Fatalf("covariance (%d, %d) = %.17g against (%d, %d) = %.17g", i, j, got, j, i, mirror)
}
}
}
}
// TestLevenbergMarquardtUnweightedCovariance pins the unweighted
// covariance (JᵀJ)⁻¹ against the exact rational inverse, including on
// the perfect start whose fit never enters the iteration loop.
func TestLevenbergMarquardtUnweightedCovariance(t *testing.T) {
residual, jacobian := weightedModel()
xf := [][]float64{{1, 0}, {0, 1}, {1, 1}}
xr := make([][]*big.Rat, 3)
for i := range 3 {
xr[i] = ratFroms(xf[i])
}
xtX := make([][]*big.Rat, 2)
for i := range 2 {
xtX[i] = make([]*big.Rat, 2)
for j := range 2 {
s := new(big.Rat)
for k := range 3 {
s.Add(s, new(big.Rat).Mul(xr[k][i], xr[k][j]))
}
xtX[i][j] = s
}
}
want := ratMatrixInvert(xtX)
if want == nil {
t.Fatal("the referent normal matrix is singular")
}
starts := [][]float64{{0, 0}, {1.2, 0.7}}
for _, s := range starts {
res, err := LevenbergMarquardtFit(residual, mustFloats(t, s, 2), LMOptions{
Jacobian: jacobian,
RequestCovariance: true,
})
if err != nil {
t.Fatalf("LevenbergMarquardtFit(%v): %v", s, err)
}
if res.Covariance == nil {
t.Fatalf("LevenbergMarquardtFit(%v): the requested covariance is missing", s)
}
for i := range 2 {
for j := range 2 {
got := res.Covariance.FloatAt(i*2 + j)
wantFloat, _ := want[i][j].Float64()
if math.Abs(got-wantFloat) > 1e-9*math.Max(1, math.Abs(wantFloat)) {
t.Fatalf("start %v: covariance (%d, %d) = %.17g, want %.17g", s, i, j, got, wantFloat)
}
}
}
}
}
// TestLevenbergMarquardtSigmaErrors pins the Sigma contract: shapes,
// positive variances, exact symmetry and positive definiteness are
// all checked before the fit moves.
func TestLevenbergMarquardtSigmaErrors(t *testing.T) {
residual, jacobian := weightedModel()
p0 := mustFloats(t, []float64{0, 0}, 2)
cases := []struct {
name string
sigma *core.Array
want string
}{
{"wrong length", mustFloats(t, []float64{1, 2}, 2), "one variance per residual"},
{"wrong shape", mustFloats(t, []float64{1, 0, 0, 1, 0, 0}, 2, 3), "shape (2, 3)"},
{"zero variance", mustFloats(t, []float64{1, 0, 1}, 3), "positive variances"},
{"negative variance", mustFloats(t, []float64{1, -2, 1}, 3), "positive variances"},
}
for _, tc := range cases {
if _, err := LevenbergMarquardtFit(residual, p0, LMOptions{Jacobian: jacobian, Sigma: tc.sigma}); err == nil {
t.Fatalf("%s: want an error", tc.name)
} else if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s: error %q, want it to mention %q", tc.name, err, tc.want)
}
}
asymmetric := mustFloats(t, []float64{2, 1, 0, 1, 3, 1, 2, 1, 2}, 3, 3)
if _, err := LevenbergMarquardtFit(residual, p0, LMOptions{Jacobian: jacobian, Sigma: asymmetric}); err == nil {
t.Fatal("an asymmetric Sigma: want an error")
} else if !strings.Contains(err.Error(), "symmetric") {
t.Fatalf("asymmetric Sigma: error %q, want it to mention symmetry", err)
}
indefinite := mustFloats(t, []float64{1, 2, 2, 1}, 2, 2)
wrongRows := mustFloats(t, []float64{1, 0, 0, 1}, 2, 2)
if _, err := LevenbergMarquardtFit(residual, p0, LMOptions{Jacobian: jacobian, Sigma: indefinite}); err == nil {
t.Fatal("an indefinite Sigma: want an error")
}
if _, err := LevenbergMarquardtFit(residual, p0, LMOptions{Jacobian: jacobian, Sigma: wrongRows}); err == nil {
t.Fatal("a Sigma of the wrong row count: want an error")
}
}