// Copyright (c) 2026 Petr Balvín (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) } }