// 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" "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() }