695 lines
23 KiB
Go
695 lines
23 KiB
Go
// 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"
|
||
"sourcedock.dev/petrbalvin/tensor/internal/engine"
|
||
"sourcedock.dev/petrbalvin/tensor/linalg"
|
||
)
|
||
|
||
// FitStatus states how an iterative fit ended.
|
||
type FitStatus int
|
||
|
||
const (
|
||
// FitConverged marks a run that met one of the tolerances: the
|
||
// relative chi2 improvement, the gradient norm GradTol or the
|
||
// step size StepTol.
|
||
FitConverged FitStatus = iota
|
||
// FitStalled marks a run whose step died: the normal equations
|
||
// turned singular or the damping collapsed without the residual
|
||
// meeting the tolerance. The returned point is the best one the
|
||
// run reached, never a partial step past it.
|
||
FitStalled
|
||
// FitBudget marks a run that spent its iteration budget before
|
||
// any tolerance or stall fired. The returned point is the last
|
||
// iterate.
|
||
FitBudget
|
||
)
|
||
|
||
// FitResult carries everything a fit reports: the point it ended on,
|
||
// the (weighted) residual sum of squares there, how the run ended
|
||
// and, when requested, the parameter covariance at that point.
|
||
type FitResult struct {
|
||
Parameters *core.Array
|
||
Chi2 float64
|
||
Status FitStatus
|
||
Covariance *core.Array
|
||
}
|
||
|
||
// LMOptions tunes LevenbergMarquardt and LevenbergMarquardtFit.
|
||
// Lambda ≤ 0 means 1e-3 (the initial damping factor), Tolerance ≤ 0
|
||
// means 1e-10 (the relative χ² improvement threshold) and
|
||
// MaxIterations ≤ 0 means 200.
|
||
type LMOptions struct {
|
||
MaxIterations int
|
||
Tolerance float64
|
||
Lambda float64
|
||
Jacobian func(p *core.Array) (*core.Array, error)
|
||
// AllowBudgetExit makes a run that exhausts MaxIterations report
|
||
// its last point instead of an error. The default is false, so a
|
||
// budget stop is never mistaken for a converged answer; the flag
|
||
// mirrors LBFGSOptions.AllowBudgetExit. LevenbergMarquardtFit
|
||
// needs no flag: it reports the budget stop as FitBudget.
|
||
AllowBudgetExit bool
|
||
// ParallelJacobian lets the central-difference Jacobian sweep its
|
||
// columns on several goroutines. Setting it is the caller's
|
||
// consent that the residual callback may run concurrently from
|
||
// more than one goroutine: the default false keeps every
|
||
// evaluation on the caller's goroutine, and the fit is bit for bit
|
||
// the same either way, because the columns are independent and
|
||
// each one is differenced by the same stencil. The field does
|
||
// nothing while Jacobian supplies the analytic matrix.
|
||
ParallelJacobian bool
|
||
// GradTol converges the fit once the infinity norm of the
|
||
// gradient Jᵀr falls to it, the test that catches the flat
|
||
// optimum where chi2 still falls in slivers while the step
|
||
// directions carry no information. A value ≤ 0 disables the test,
|
||
// which is the default: a caller who sets it picks the scale.
|
||
GradTol float64
|
||
// StepTol converges the fit once an accepted step's infinity norm
|
||
// falls to StepTol·(‖p‖∞ + StepTol), the relative step test that
|
||
// stops a fit whose parameters have stopped moving meaningfully.
|
||
// A value ≤ 0 disables the test, which is the default.
|
||
StepTol float64
|
||
// Sigma weights the residuals by the measurement covariance: a
|
||
// vector holds one positive variance per residual, a square
|
||
// matrix holds the full nR×nR covariance and must be exactly
|
||
// symmetric and positive definite. The fit whitens the residuals
|
||
// and the Jacobian through the factor once, chi2 becomes rᵀC⁻¹r
|
||
// and a requested covariance becomes (JᵀC⁻¹J)⁻¹. Nil, the
|
||
// default, leaves every residual unweighted.
|
||
Sigma *core.Array
|
||
// RequestCovariance fills FitResult.Covariance with (JᵀJ)⁻¹ at
|
||
// the returned point, or (JᵀC⁻¹J)⁻¹ under Sigma. The answer costs
|
||
// one more Jacobian at the final point, which on the
|
||
// difference route is two residual evaluations per parameter. A
|
||
// Jacobian that is rank-deficient at the returned point has no
|
||
// covariance to report and the run fails naming that, so a
|
||
// caller asking for a covariance accepts the trade on a fit it
|
||
// expects to stall.
|
||
RequestCovariance bool
|
||
}
|
||
|
||
// LevenbergMarquardt minimises ‖r(p)‖² by LM damping of the
|
||
// Gauss-Newton step, with a central-difference or user-supplied
|
||
// analytic Jacobian and the library's LU solver for the normal
|
||
// equations.
|
||
//
|
||
// The historical error contract stays: a run that stalls (a singular
|
||
// solve, a collapsed damping) or spends its budget without
|
||
// AllowBudgetExit is an error, not a point. LevenbergMarquardtFit
|
||
// reports those conditions as a FitResult instead and carries the
|
||
// gradient, step, weight and covariance options.
|
||
func LevenbergMarquardt(residual func(*core.Array) (*core.Array, error), p0 *core.Array, opts LMOptions) (*core.Array, float64, error) {
|
||
res, err := runLevenbergMarquardt(residual, p0, opts, true)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return res.Parameters, res.Chi2, nil
|
||
}
|
||
|
||
// LevenbergMarquardtFit is LevenbergMarquardt with the full report:
|
||
// the status that says how the run ended and, on request, the
|
||
// parameter covariance. Where LevenbergMarquardt keeps its historical
|
||
// error contract, this one reports every ended run as a result: a
|
||
// stalled step and a spent budget come back as FitStalled and
|
||
// FitBudget on the best point reached, never as an error. The errors
|
||
// here are the model's own fault (a residual that fails or turns
|
||
// non-finite at a state the fit adopts, a malformed Sigma) and, with
|
||
// RequestCovariance, a rank-deficient Jacobian at the answer.
|
||
func LevenbergMarquardtFit(residual func(*core.Array) (*core.Array, error), p0 *core.Array, opts LMOptions) (*FitResult, error) {
|
||
return runLevenbergMarquardt(residual, p0, opts, false)
|
||
}
|
||
|
||
// denseFloats returns the array's float64 payload when a is a dense
|
||
// float64 array and nil otherwise: hot loops branch once on the result
|
||
// and sweep the payload directly, falling back to the widening
|
||
// accessor for views and other dtypes. The elements are identical
|
||
// either way, so a dense sweep computes the same bits as the accessor
|
||
// walk it replaces.
|
||
func denseFloats(a *core.Array) []float64 {
|
||
if !a.Strided() && a.Dtype() == core.Float {
|
||
return a.RawFloats()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// whitener carries the Sigma factor: the per-residual divisors of a
|
||
// variance vector, or the lower Cholesky factor of a full covariance.
|
||
// A nil whitener is the unweighted fit.
|
||
type whitener struct {
|
||
diag []float64
|
||
factor [][]float64
|
||
}
|
||
|
||
// sigmaWhitener validates Sigma against the residual count nR and
|
||
// factors it. The matrix form is checked for exact symmetry before
|
||
// the factorisation reads one triangle: an asymmetric partner would
|
||
// silently weight by a matrix the caller did not pass.
|
||
func sigmaWhitener(sigma *core.Array, nR int) (*whitener, error) {
|
||
if sigma == nil {
|
||
return nil, nil
|
||
}
|
||
if err := requireReal("LevenbergMarquardt", "Sigma", sigma); err != nil {
|
||
return nil, err
|
||
}
|
||
if sigma.NDim() == 1 {
|
||
if sigma.Len() != nR {
|
||
return nil, base.Errf("LevenbergMarquardt: Sigma must hold one variance per residual (%d), got %d", nR, sigma.Len())
|
||
}
|
||
w := &whitener{diag: make([]float64, nR)}
|
||
for i := range nR {
|
||
v := sigma.FloatAt(i)
|
||
if math.IsNaN(v) || v <= 0 {
|
||
return nil, base.Errf("LevenbergMarquardt: Sigma must hold positive variances, got %g at %d", v, i)
|
||
}
|
||
w.diag[i] = math.Sqrt(v)
|
||
}
|
||
return w, nil
|
||
}
|
||
if sigma.NDim() != 2 || sigma.Shape()[0] != nR || sigma.Shape()[1] != nR {
|
||
return nil, base.Errf("LevenbergMarquardt: Sigma must be a %d×%d covariance or a vector of %d variances, got shape %s",
|
||
nR, nR, nR, base.ShapeText(sigma.Shape()))
|
||
}
|
||
for i := range nR {
|
||
for j := i + 1; j < nR; j++ {
|
||
up, lo := sigma.FloatAt(i*nR+j), sigma.FloatAt(j*nR+i)
|
||
if up != lo {
|
||
return nil, base.Errf("LevenbergMarquardt: Sigma must be symmetric, got %g and %g at (%d, %d)", up, lo, i, j)
|
||
}
|
||
}
|
||
}
|
||
l, err := linalg.Cholesky(sigma)
|
||
if err != nil {
|
||
return nil, base.Errf("LevenbergMarquardt: Sigma is not positive definite: %w", err)
|
||
}
|
||
w := &whitener{factor: make([][]float64, nR)}
|
||
for i := range nR {
|
||
row := make([]float64, i+1)
|
||
for j := range i + 1 {
|
||
row[j] = l.FloatAt(i*nR + j)
|
||
}
|
||
w.factor[i] = row
|
||
}
|
||
return w, nil
|
||
}
|
||
|
||
// vector whitens a residual in place: y solves L y = r.
|
||
func (w *whitener) vector(r []float64) {
|
||
if w == nil {
|
||
return
|
||
}
|
||
if w.diag != nil {
|
||
for i := range r {
|
||
r[i] /= w.diag[i]
|
||
}
|
||
return
|
||
}
|
||
for i := range r {
|
||
s := r[i]
|
||
li := w.factor[i]
|
||
for j := range i {
|
||
s -= li[j] * r[j]
|
||
}
|
||
r[i] = s / li[i]
|
||
}
|
||
}
|
||
|
||
// matrix whitens a Jacobian in place: the rows solve L J' = J, so the
|
||
// downstream normal equations accumulate JᵀC⁻¹J without knowing a
|
||
// weight exists.
|
||
func (w *whitener) matrix(jac [][]float64) {
|
||
if w == nil {
|
||
return
|
||
}
|
||
for i := range jac {
|
||
if w.diag != nil {
|
||
for j := range jac[i] {
|
||
jac[i][j] /= w.diag[i]
|
||
}
|
||
continue
|
||
}
|
||
li := w.factor[i]
|
||
for j := range jac[i] {
|
||
s := jac[i][j]
|
||
for k := range i {
|
||
s -= li[k] * jac[k][j]
|
||
}
|
||
jac[i][j] = s / li[i]
|
||
}
|
||
}
|
||
}
|
||
|
||
// covarianceFromJac inverts the unweighted normal equations of the
|
||
// (whitened) Jacobian, which is the parameter covariance. The solve
|
||
// runs column by column against the identity and the answer is
|
||
// symmetrised explicitly: a pivoted LU on a symmetric matrix may
|
||
// leave last-bit asymmetry the covariance must not carry.
|
||
func covarianceFromJac(name string, jac [][]float64, nP int) (*core.Array, error) {
|
||
a := make([][]float64, nP)
|
||
for i := range a {
|
||
a[i] = make([]float64, nP)
|
||
}
|
||
for k := range len(jac) {
|
||
row := jac[k]
|
||
for i := range nP {
|
||
xi := row[i]
|
||
ai := a[i]
|
||
for j := range nP {
|
||
ai[j] += xi * row[j]
|
||
}
|
||
}
|
||
}
|
||
rhs := make([][]float64, nP)
|
||
for i := range nP {
|
||
rhs[i] = make([]float64, nP)
|
||
rhs[i][i] = 1
|
||
}
|
||
x, err := base.SolveSystem(name, a, rhs)
|
||
if err != nil {
|
||
return nil, base.Errf("%s: the Jacobian is rank-deficient at the answer, so no covariance exists: %w", name, err)
|
||
}
|
||
out := core.New(core.Float, nP, nP)
|
||
v := out.RawFloats()
|
||
for i := range nP {
|
||
for j := range nP {
|
||
v[i*nP+j] = (x[i][j] + x[j][i]) / 2
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// runLevenbergMarquardt carries the fit. The legacy flag restores the
|
||
// historical error contract of LevenbergMarquardt: the same stalls
|
||
// the FitResult reports come back as errors with the messages the
|
||
// package has always published, so existing callers see nothing move.
|
||
func runLevenbergMarquardt(residual func(*core.Array) (*core.Array, error), p0 *core.Array, opts LMOptions, legacy bool) (*FitResult, error) {
|
||
if p0.Dtype() == core.Complex {
|
||
return nil, base.Errf("LevenbergMarquardt: complex parameters are not supported")
|
||
}
|
||
nP := p0.Len()
|
||
if nP == 0 {
|
||
return nil, base.Errf("LevenbergMarquardt: the parameter vector must not be empty")
|
||
}
|
||
if opts.MaxIterations <= 0 {
|
||
opts.MaxIterations = 200
|
||
}
|
||
if opts.Tolerance <= 0 {
|
||
opts.Tolerance = 1e-10
|
||
}
|
||
if opts.Lambda <= 0 {
|
||
opts.Lambda = 1e-3
|
||
}
|
||
|
||
// cloneDense promotes through FloatAt, so Int and Float32 starting
|
||
// vectors behave exactly like Float64 ones (a RawFloats copy would
|
||
// silently start the fit from zeros for those dtypes).
|
||
p := cloneDense(p0)
|
||
nR := 0
|
||
// whiten carries the Sigma factor; it is still nil for the very
|
||
// first evaluation, and the residual it returns is whitened by
|
||
// hand right after the factor is built.
|
||
var whiten *whitener
|
||
// evalR reads the residual at pp. The finiteness gate is strict
|
||
// for the states the fit adopts (the start point and every
|
||
// accepted iterate): a non-finite residual there poisons chi2 and
|
||
// every comparison against it, and the fit would die later as a
|
||
// bogus "the damping collapsed" diagnosis instead of the model's
|
||
// own fault. Backtracking trials take the lenient variant: a step
|
||
// into a saturating model is a candidate to damp past, not a dead
|
||
// run, the same recovery FindRootSystem's trials make. The Sigma
|
||
// whitening lands here, so every downstream consumer (chi2, the
|
||
// difference stencil, the trial comparison) works on the whitened
|
||
// residual and the weighted fit is the unweighted one on whitened
|
||
// data.
|
||
evalR := func(pp []float64, strict bool, dst []float64) ([]float64, error) {
|
||
a := linalg.ArrayFromFloatsSafe(pp, nP)
|
||
r, err := residual(a)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if r.NDim() != 1 {
|
||
return nil, base.Errf("LevenbergMarquardt: the residual must be a vector")
|
||
}
|
||
if err := requireReal("LevenbergMarquardt", "residuals", r); err != nil {
|
||
return nil, err
|
||
}
|
||
if nR != 0 && r.Len() != nR {
|
||
return nil, base.Errf("LevenbergMarquardt: the residual length changed from %d to %d mid-fit", nR, r.Len())
|
||
}
|
||
// dst carries a buffer the stencil reuses across columns; the
|
||
// states the fit keeps come back freshly allocated. Every entry
|
||
// of the buffer is written before it is read.
|
||
res := dst
|
||
if cap(res) < r.Len() {
|
||
res = make([]float64, r.Len())
|
||
}
|
||
res = res[:r.Len()]
|
||
// Both branches fill res with the identical elements: the dense
|
||
// sweep reads the payload the accessor walk would widen.
|
||
if fs := denseFloats(r); fs != nil {
|
||
if strict {
|
||
for i, v := range fs {
|
||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||
return nil, base.Errf("LevenbergMarquardt: the residual returned the non-finite value %g at %d", v, i)
|
||
}
|
||
}
|
||
}
|
||
copy(res, fs)
|
||
} else {
|
||
for i := range r.Len() {
|
||
v := r.FloatAt(i)
|
||
if strict && (math.IsNaN(v) || math.IsInf(v, 0)) {
|
||
return nil, base.Errf("LevenbergMarquardt: the residual returned the non-finite value %g at %d", v, i)
|
||
}
|
||
res[i] = v
|
||
}
|
||
}
|
||
whiten.vector(res)
|
||
return res, nil
|
||
}
|
||
r, rerr := evalR(p, true, nil)
|
||
if rerr != nil {
|
||
return nil, base.Errf("LevenbergMarquardt: %w", rerr)
|
||
}
|
||
nR = len(r)
|
||
if nR < nP {
|
||
return nil, base.Errf("LevenbergMarquardt: underdetermined (%d obs, %d params)", nR, nP)
|
||
}
|
||
whiten, werr := sigmaWhitener(opts.Sigma, nR)
|
||
if werr != nil {
|
||
return nil, werr
|
||
}
|
||
whiten.vector(r)
|
||
chi2 := 0.0
|
||
for i := range nR {
|
||
chi2 += r[i] * r[i]
|
||
}
|
||
|
||
// buildJacobian assembles the row-major Jacobian at p, either from
|
||
// the caller's analytic callback or by central differences on the
|
||
// residual, one column per parameter. Its storage is allocated
|
||
// once and refilled per iteration: the sweep writes every entry.
|
||
jac := make([][]float64, nR)
|
||
for i := range nR {
|
||
jac[i] = make([]float64, nP)
|
||
}
|
||
// The difference stencils and the two residual vectors the columns
|
||
// are differenced from, allocated on first use and carried across
|
||
// the whole fit: a fit with an analytic Jacobian pays for none of
|
||
// them. The stencil carries the offset on one parameter at a time,
|
||
// restored as soon as the column is done, so neither a copy of the
|
||
// whole parameter vector nor a residual slice per column is needed.
|
||
var pp, pm []float64
|
||
var resPlus, resMinus []float64
|
||
buildJacobian := func(p []float64) error {
|
||
if opts.Jacobian != nil {
|
||
jm, err := opts.Jacobian(linalg.ArrayFromFloatsSafe(p, nP))
|
||
if err != nil {
|
||
return base.Errf("LevenbergMarquardt: %w", err)
|
||
}
|
||
if jm.NDim() != 2 || jm.Shape()[0] != nR || jm.Shape()[1] != nP {
|
||
return base.Errf("LevenbergMarquardt: the Jacobian must be a %d×%d matrix, got shape %s",
|
||
nR, nP, base.ShapeText(jm.Shape()))
|
||
}
|
||
if err := requireReal("LevenbergMarquardt", "Jacobians", jm); err != nil {
|
||
return err
|
||
}
|
||
if fs := denseFloats(jm); fs != nil {
|
||
for i := range nR {
|
||
copy(jac[i], fs[i*nP:(i+1)*nP])
|
||
}
|
||
} else {
|
||
for i := range nR {
|
||
ji := jac[i]
|
||
for j := range nP {
|
||
ji[j] = jm.FloatAt(i*nP + j)
|
||
}
|
||
}
|
||
}
|
||
whiten.matrix(jac)
|
||
return nil
|
||
}
|
||
// Central differences: evalR copies into the array handed to the
|
||
// callback, so nothing observes later mutation. column walks one
|
||
// parameter's stencil and writes that column of jac, and nothing
|
||
// else, so the bits it produces do not depend on which driver
|
||
// walks the columns.
|
||
column := func(j int, sp, sm, rp, rm []float64) ([]float64, []float64, error) {
|
||
eps := math.Sqrt(base.EpsF) * math.Max(1, math.Abs(p[j]))
|
||
sp[j] += eps
|
||
sm[j] -= eps
|
||
rp, re1 := evalR(sp, true, rp)
|
||
rm, re2 := evalR(sm, true, rm)
|
||
sp[j], sm[j] = p[j], p[j]
|
||
if re1 != nil || re2 != nil {
|
||
return rp, rm, firstError(re1, re2)
|
||
}
|
||
for i := range nR {
|
||
jac[i][j] = (rp[i] - rm[i]) / (2 * eps)
|
||
}
|
||
return rp, rm, nil
|
||
}
|
||
if opts.ParallelJacobian {
|
||
// The consent the option records lets the columns go to the
|
||
// engine's workers: each goroutine owns a disjoint run of
|
||
// columns, writes only into those columns of jac and reads
|
||
// only p, so no two workers write the same address and the
|
||
// sweep needs no locks. A failing column records its error
|
||
// and abandons the rest of its run; the reported one is the
|
||
// lowest failing column, the one the serial walk would hit
|
||
// first. The residual buffers live per worker instead of
|
||
// being carried across columns: the option exists for
|
||
// expensive callbacks, where the carry buys nothing.
|
||
colErrs := make([]error, nP)
|
||
engine.ParallelMin(nP, 1, func(start, end int) {
|
||
sp, sm := make([]float64, nP), make([]float64, nP)
|
||
copy(sp, p)
|
||
copy(sm, p)
|
||
var rp, rm []float64
|
||
for j := start; j < end; j++ {
|
||
var err error
|
||
rp, rm, err = column(j, sp, sm, rp, rm)
|
||
if err != nil {
|
||
colErrs[j] = err
|
||
return
|
||
}
|
||
}
|
||
})
|
||
for _, err := range colErrs {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
if cap(pp) < nP {
|
||
pp, pm = make([]float64, nP), make([]float64, nP)
|
||
}
|
||
pp, pm = pp[:nP], pm[:nP]
|
||
copy(pp, p)
|
||
copy(pm, p)
|
||
for j := range nP {
|
||
var err error
|
||
resPlus, resMinus, err = column(j, pp, pm, resPlus, resMinus)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// The normal equations' storage, reused across iterations: the
|
||
// upper triangle of a is refilled by accumulation from an explicit
|
||
// zero and its lower one is mirrored back, bv is cleared likewise,
|
||
// and every other buffer is fully overwritten before it is read.
|
||
a := make([][]float64, nP)
|
||
for i := range nP {
|
||
a[i] = make([]float64, nP)
|
||
}
|
||
bv := make([]float64, nP)
|
||
pNew := make([]float64, nP)
|
||
// One right-hand-side header for the whole fit: the solve writes
|
||
// the step through bv in place, so the wrapper never changes.
|
||
solveRHS := [][]float64{bv}
|
||
lambda := opts.Lambda
|
||
status := FitBudget
|
||
var solveErr error
|
||
var collapseAt float64
|
||
|
||
// buildResult packs the fit's answer at the current p. The
|
||
// covariance rebuilds the Jacobian there: the loop's last one
|
||
// belongs to the point the last accepted step left behind, and the
|
||
// covariance must describe the point it is published beside.
|
||
buildResult := func() (*FitResult, error) {
|
||
out := core.New(core.Float, nP)
|
||
copy(out.RawFloats(), p)
|
||
res := &FitResult{Parameters: out, Chi2: chi2, Status: status}
|
||
if opts.RequestCovariance {
|
||
if jerr := buildJacobian(p); jerr != nil {
|
||
return nil, jerr
|
||
}
|
||
cov, cerr := covarianceFromJac("LevenbergMarquardt", jac, nP)
|
||
if cerr != nil {
|
||
return nil, cerr
|
||
}
|
||
res.Covariance = cov
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
if chi2 == 0 {
|
||
// A start whose residual cancels exactly is already the perfect
|
||
// fit: the improvement test below is strict and cannot accept
|
||
// the zero step it produces, so the run would die in the
|
||
// damping collapse for being perfect.
|
||
status = FitConverged
|
||
return buildResult()
|
||
}
|
||
|
||
for iter := 0; iter < opts.MaxIterations; iter++ {
|
||
if jerr := buildJacobian(p); jerr != nil {
|
||
return nil, jerr
|
||
}
|
||
|
||
// a = JᵀJ + λ·diag(JᵀJ), bv = −Jᵀr. The accumulation walks the
|
||
// rows in ascending order, so each entry sums the same
|
||
// products in the same order the column-wise walk visited.
|
||
// The off-diagonal pair (i, j) and (j, i) sums the same
|
||
// products in the same order, the product commuting bitwise,
|
||
// so the pass runs the upper triangle alone and the mirror
|
||
// below reproduces the lower one exactly.
|
||
for i := range nP {
|
||
ai := a[i]
|
||
for j := i; j < nP; j++ {
|
||
ai[j] = 0
|
||
}
|
||
}
|
||
clear(bv)
|
||
for k := range nR {
|
||
row := jac[k]
|
||
rk := r[k]
|
||
for i := range nP {
|
||
bv[i] -= row[i] * rk
|
||
}
|
||
for i := range nP {
|
||
xi := row[i]
|
||
ai := a[i]
|
||
for j := i; j < nP; j++ {
|
||
ai[j] += xi * row[j]
|
||
}
|
||
}
|
||
}
|
||
for i := range nP {
|
||
ai := a[i]
|
||
for j := i + 1; j < nP; j++ {
|
||
a[j][i] = ai[j]
|
||
}
|
||
}
|
||
for i := range nP {
|
||
a[i][i] *= (1 + lambda)
|
||
}
|
||
if opts.GradTol > 0 {
|
||
// The gradient test runs on bv before the solve: ‖bv‖∞ is
|
||
// ‖Jᵀr‖∞, and a gradient this small says the parameter
|
||
// directions carry nothing the step could spend, which is
|
||
// exactly the flat optimum the chi2 test alone never
|
||
// reaches.
|
||
gInf := 0.0
|
||
for i := range nP {
|
||
gInf = max(gInf, math.Abs(bv[i]))
|
||
}
|
||
if gInf <= opts.GradTol {
|
||
status = FitConverged
|
||
return buildResult()
|
||
}
|
||
}
|
||
|
||
delta, derr := base.SolveSystem("LevenbergMarquardt", a, solveRHS)
|
||
if derr != nil {
|
||
// A singular solve leaves the current point standing: it
|
||
// was good enough to build normal equations from, and no
|
||
// step replaced it. The fit reports it and stops.
|
||
status = FitStalled
|
||
solveErr = derr
|
||
break
|
||
}
|
||
|
||
for j := range nP {
|
||
pNew[j] = p[j] + delta[0][j]
|
||
}
|
||
// A trial point: a saturating model here is damped past, and a
|
||
// finite chi2New admits only finite components, so an accepted
|
||
// trial never carries poison into the fit state.
|
||
rNew, rerr := evalR(pNew, false, nil)
|
||
if rerr != nil {
|
||
return nil, base.Errf("LevenbergMarquardt: %w", rerr)
|
||
}
|
||
chi2New := 0.0
|
||
for i := range nR {
|
||
chi2New += rNew[i] * rNew[i]
|
||
}
|
||
|
||
if chi2New < chi2 {
|
||
// The relative step test runs on the step just accepted,
|
||
// against the scale of the point it left: a step this small
|
||
// says the parameters have stopped moving meaningfully,
|
||
// whatever the residual still promises.
|
||
var stepSmall bool
|
||
if opts.StepTol > 0 {
|
||
stepInf, pInf := 0.0, 0.0
|
||
for j := range nP {
|
||
stepInf = max(stepInf, math.Abs(delta[0][j]))
|
||
pInf = max(pInf, math.Abs(p[j]))
|
||
}
|
||
stepSmall = stepInf <= opts.StepTol*(pInf+opts.StepTol)
|
||
}
|
||
// The tolerance break must accept the better point too:
|
||
// reporting chi2New beside the old p publishes a fit quality
|
||
// the returned parameters do not achieve.
|
||
chi2Old := chi2
|
||
copy(p, pNew)
|
||
r = rNew
|
||
chi2 = chi2New
|
||
if chi2Old-chi2 < opts.Tolerance*(1+chi2Old) {
|
||
status = FitConverged
|
||
return buildResult()
|
||
}
|
||
lambda *= 0.3
|
||
if stepSmall {
|
||
status = FitConverged
|
||
return buildResult()
|
||
}
|
||
} else {
|
||
lambda *= 10
|
||
if lambda > 1e20 {
|
||
// A damping that collapsed has no step left to take:
|
||
// the last point is a stall, never a converged answer.
|
||
status = FitStalled
|
||
collapseAt = lambda
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
if legacy {
|
||
switch status {
|
||
case FitStalled:
|
||
if solveErr != nil {
|
||
return nil, base.Errf("LevenbergMarquardt: %w", solveErr)
|
||
}
|
||
return nil, base.Errf("LevenbergMarquardt: the damping collapsed to %g without the residual meeting the tolerance", collapseAt)
|
||
case FitBudget:
|
||
if !opts.AllowBudgetExit {
|
||
return nil, base.Errf("LevenbergMarquardt: the iteration budget of %d ran out without the residual meeting the tolerance", opts.MaxIterations)
|
||
}
|
||
case FitConverged:
|
||
}
|
||
}
|
||
return buildResult()
|
||
}
|