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

695 lines
23 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"
"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()
}