feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,694 @@
|
||||
// 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()
|
||||
}
|
||||
Reference in New Issue
Block a user