Files
tensor/optim/leastsq_test.go
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

296 lines
11 KiB
Go
Raw Permalink 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 optim
import (
"math"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
"testing"
)
// TestLevenbergMarquardt fits y = a·e^{−bx} + c to exact data and
// checks that the fitted parameters match the generating values
// within the convergence tolerance of the LM fitter.
func TestLevenbergMarquardt(t *testing.T) {
xData := []float64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
yData := make([]float64, len(xData))
for i, x := range xData {
yData[i] = 3*math.Exp(-0.5*float64(x)) + 0.5
}
yArr := mustFloats(t, yData, len(yData))
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] = yArr.FloatAt(i) - (a*math.Exp(-b*float64(xData[i])) + c)
}
return out, nil
}
p0 := mustFloats(t, []float64{2, 0.3, 0.1}, 3)
pOpt, chi2, err := LevenbergMarquardt(residual, p0, LMOptions{})
if err != nil {
t.Fatalf("LevenbergMarquardt: %v", err)
}
if chi2 > 1e-4 {
t.Fatalf("χ² = %v, want < 1e-4 for exact data", chi2)
}
a := pOpt.FloatAt(0)
b := pOpt.FloatAt(1)
if math.Abs(a-3) > 0.01 || math.Abs(b-0.5) > 0.01 {
t.Fatalf("a = %.8f, b = %.8f, want ≈ 3, 0.5", a, b)
}
}
// TestLevenbergMarquardtErrors pins the error contract.
func TestLevenbergMarquardtErrors(t *testing.T) {
f := func(p *core.Array) (*core.Array, error) { return nil, nil }
cx, _ := core.FromComplexes([]complex128{1}, 1)
if _, _, err := LevenbergMarquardt(f, cx, LMOptions{}); err == nil {
t.Fatal("expected an error for complex parameters")
}
empty, _ := core.FromFloats(nil, 0)
if _, _, err := LevenbergMarquardt(f, empty, LMOptions{}); err == nil {
t.Fatal("expected an error for an empty parameter vector")
}
}
// TestLevenbergMarquardtAnalyticJacobian fits the same exponential
// model twice, once with the analytic Jacobian and once with the
// central-difference fallback: both must land on the same optimum,
// the analytic one reaching it without the extra evaluations.
func TestLevenbergMarquardtAnalyticJacobian(t *testing.T) {
xData := []float64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
yData := make([]float64, len(xData))
for i, x := range xData {
yData[i] = 3*math.Exp(-0.5*float64(x)) + 0.5
}
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] = yData[i] - (a*math.Exp(-b*float64(xData[i])) + c)
}
return out, nil
}
jacobian := func(p *core.Array) (*core.Array, error) {
a, b := p.FloatAt(0), p.FloatAt(1)
out := core.New(core.Float, len(xData), 3)
for i := range len(xData) {
e := math.Exp(-b * float64(xData[i]))
out.RawFloats()[i*3+0] = -e
out.RawFloats()[i*3+1] = a * float64(xData[i]) * e
out.RawFloats()[i*3+2] = -1
}
return out, nil
}
p0 := mustFloats(t, []float64{2, 0.3, 0.1}, 3)
pA, chi2A, err := LevenbergMarquardt(residual, p0, LMOptions{Jacobian: jacobian})
if err != nil {
t.Fatalf("LevenbergMarquardt analytic: %v", err)
}
pD, chi2D, err := LevenbergMarquardt(residual, p0, LMOptions{})
if err != nil {
t.Fatalf("LevenbergMarquardt differences: %v", err)
}
for j := range 3 {
if math.Abs(pA.FloatAt(j)-pD.FloatAt(j)) > 1e-6 {
t.Fatalf("parameter %d: analytic %.12g, differences %.12g", j, pA.FloatAt(j), pD.FloatAt(j))
}
}
if chi2A > 1e-4 {
t.Fatalf("analytic χ² = %v, want < 1e-4", chi2A)
}
if math.Abs(chi2A-chi2D) > 1e-8 {
t.Fatalf("χ² disagree: analytic %v, differences %v", chi2A, chi2D)
}
}
// TestLevenbergMarquardtAnalyticExact solves a determined linear
// system, where the Gauss-Newton step is exact and the analytic
// Jacobian reaches the solution the equations dictate.
func TestLevenbergMarquardtAnalyticExact(t *testing.T) {
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(p *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{1, 2, 2, 1}, 2, 2)
}
p0 := mustFloats(t, []float64{0, 0}, 2)
pOpt, chi2, err := LevenbergMarquardt(residual, p0, LMOptions{Jacobian: jacobian, Tolerance: 1e-14})
if err != nil {
t.Fatalf("LevenbergMarquardt: %v", err)
}
if math.Abs(pOpt.FloatAt(0)-5.0/3) > 1e-8 || math.Abs(pOpt.FloatAt(1)-2.0/3) > 1e-8 {
t.Fatalf("p = (%.12g, %.12g), want (5/3, 2/3)", pOpt.FloatAt(0), pOpt.FloatAt(1))
}
if chi2 > 1e-16 {
t.Fatalf("χ² = %v, want 0", chi2)
}
}
// TestLevenbergMarquardtJacobianErrors pins the analytic Jacobian's
// own contract: shape mismatches and callback errors surface.
func TestLevenbergMarquardtJacobianErrors(t *testing.T) {
residual := func(p *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{p.FloatAt(0) - 1, p.FloatAt(1) - 2}, 2)
}
rank1 := func(p *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{1, 1}, 2)
}
if _, _, err := LevenbergMarquardt(residual, mustFloats(t, []float64{0, 0}, 2),
LMOptions{Jacobian: rank1}); err == nil {
t.Fatal("expected an error for a rank-1 Jacobian")
}
wrongDims := func(p *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{1, 1, 1}, 3, 1)
}
if _, _, err := LevenbergMarquardt(residual, mustFloats(t, []float64{0, 0}, 2),
LMOptions{Jacobian: wrongDims}); err == nil {
t.Fatal("expected an error for a Jacobian of the wrong dimensions")
}
boom := func(p *core.Array) (*core.Array, error) {
return nil, base.Errf("jacobian failed")
}
if _, _, err := LevenbergMarquardt(residual, mustFloats(t, []float64{0, 0}, 2),
LMOptions{Jacobian: boom}); err == nil {
t.Fatal("expected the Jacobian error to propagate")
}
}
// TestLevenbergMarquardtDtypes pins the parameter-vector promotion:
// Int and Float32 starts must behave exactly like Float64 ones. The
// old RawFloats() copy silently started those fits from zeros.
func TestLevenbergMarquardtDtypes(t *testing.T) {
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)
}
ints, err := core.FromInts([]int64{0, 0}, 2)
if err != nil {
t.Fatalf("FromInts: %v", err)
}
thirtyTwo, err := core.FromFloat32s([]float32{0, 0}, 2)
if err != nil {
t.Fatalf("FromFloat32s: %v", err)
}
for name, p0 := range map[string]*core.Array{"int": ints, "float32": thirtyTwo} {
pOpt, chi2, err := LevenbergMarquardt(residual, p0, LMOptions{Jacobian: jacobian, Tolerance: 1e-14})
if err != nil {
t.Fatalf("LevenbergMarquardt(%s start): %v", name, err)
}
if math.Abs(pOpt.FloatAt(0)-5.0/3) > 1e-8 || math.Abs(pOpt.FloatAt(1)-2.0/3) > 1e-8 {
t.Fatalf("LevenbergMarquardt(%s start): p = (%.12g, %.12g), want (5/3, 2/3)",
name, pOpt.FloatAt(0), pOpt.FloatAt(1))
}
if chi2 > 1e-16 {
t.Fatalf("LevenbergMarquardt(%s start): χ² = %v, want 0", name, chi2)
}
}
}
// TestLevenbergMarquardtResidualLengthChange pins the residual
// contract: a callback whose output length changes mid-fit is an
// error naming the mismatch, not a shape panic.
func TestLevenbergMarquardtResidualLengthChange(t *testing.T) {
calls := 0
residual := func(*core.Array) (*core.Array, error) {
calls++
if calls == 1 {
return core.FromFloats([]float64{1, 2, 3}, 3)
}
return core.FromFloats([]float64{1, 2}, 2)
}
_, _, err := LevenbergMarquardt(residual, mustFloats(t, []float64{0, 0}, 2), LMOptions{})
if err == nil {
t.Fatal("residual length change mid-fit: want an error")
}
}
// TestLevenbergMarquardtStencilMatchesAnalyticJacobian pins the reused
// difference stencil against the analytic route. The stencil carries
// the offset on one parameter at a time and puts it back as soon as the
// column is differenced, so each column's Jacobian is the central
// difference of that parameter alone and the two routes land on the
// same point. A stencil that leaves its offset behind differences every
// later column at a point that is also displaced earlier: a different
// Jacobian, and a fit that parts company with the analytic route.
func TestLevenbergMarquardtStencilMatchesAnalyticJacobian(t *testing.T) {
// A decay with an offset and a smooth nuisance term, so every
// parameter carries curvature and the columns are coupled.
const n = 40
xs := make([]float64, n)
ys := make([]float64, n)
for i := range n {
xs[i] = float64(i) * 0.25
ys[i] = 1.5*math.Exp(-0.8*xs[i]) + 0.7 + 0.05*math.Sin(2*xs[i])
}
residual := func(p *core.Array) (*core.Array, error) {
out := core.New(core.Float, n)
v := out.RawFloats()
for i := range n {
v[i] = ys[i] - (p.FloatAt(0)*math.Exp(-p.FloatAt(1)*xs[i]) +
p.FloatAt(2) + p.FloatAt(3)*math.Sin(2*xs[i]))
}
return out, nil
}
jacobian := func(p *core.Array) (*core.Array, error) {
v := make([]float64, n*4)
for i := range n {
e := math.Exp(-p.FloatAt(1) * xs[i])
v[i*4+0] = -e
v[i*4+1] = p.FloatAt(0) * xs[i] * e
v[i*4+2] = -1
v[i*4+3] = -math.Sin(2 * xs[i])
}
return core.FromFloats(v, n, 4)
}
start, err := core.FromFloats([]float64{1.2, 0.9, 0.4, 0.04}, 4)
if err != nil {
t.Fatal(err)
}
fd, fdChi, err := LevenbergMarquardt(residual, start, LMOptions{})
if err != nil {
t.Fatalf("LevenbergMarquardt with a difference stencil: %v", err)
}
analytic, analyticChi, err := LevenbergMarquardt(residual, start, LMOptions{Jacobian: jacobian})
if err != nil {
t.Fatalf("LevenbergMarquardt with an analytic Jacobian: %v", err)
}
if fd.Len() != analytic.Len() {
t.Fatalf("the two routes fit %d and %d parameters", fd.Len(), analytic.Len())
}
for i := range fd.Len() {
if d := math.Abs(fd.FloatAt(i) - analytic.FloatAt(i)); d > 1e-12 {
t.Fatalf("parameter %d: the stencil route gives %.17g, the analytic one %.17g (differ by %g)",
i, fd.FloatAt(i), analytic.FloatAt(i), d)
}
}
if math.Abs(fdChi-analyticChi) > 1e-12 {
t.Fatalf("the two routes report chi2 %.17g and %.17g", fdChi, analyticChi)
}
// The stencil is carried across the fit's iterations, so the same
// start must reproduce the same bits.
again, againChi, err := LevenbergMarquardt(residual, start, LMOptions{})
if err != nil {
t.Fatalf("the repeated stencil fit: %v", err)
}
for i := range again.Len() {
if math.Float64bits(again.FloatAt(i)) != math.Float64bits(fd.FloatAt(i)) {
t.Fatalf("parameter %d moved between stencil fits: %.17g against %.17g", i, again.FloatAt(i), fd.FloatAt(i))
}
}
if math.Float64bits(againChi) != math.Float64bits(fdChi) {
t.Fatalf("chi2 moved between stencil fits: %.17g against %.17g", againChi, fdChi)
}
}