Files
tensor/optim/rootsystem.go
T
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

438 lines
16 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 (
"fmt"
"math"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
"sourcedock.dev/petrbalvin/tensor/internal/engine"
"sourcedock.dev/petrbalvin/tensor/linalg"
)
// Root finding for systems of nonlinear equations r(x) = 0 with as
// many equations as unknowns. FindRoot and FindRootNewton cover the
// one-dimensional case; implicit solvers, equilibrium conditions and
// closure relations live in many dimensions, where a residual vector
// replaces the single equation.
//
// The method is the damped Newton iteration: each step solves the
// linearised system J·δ = −r with a central-difference Jacobian and
// the library's LU solver, then backtracks along δ until the residual
// norm actually falls, which keeps the iteration from leaping out of
// the basin of attraction when the linear model overshoots.
//
// RootSystemOptions.UseBroyden switches how the Jacobian is
// maintained: it is built by central differences once at the start
// and carried between steps by the rank-one Broyden update on
// its inverse, with a numerical rebuild whenever the update degrades
// (broydenMaintain documents the triggers). The default false keeps
// the per-step Jacobian and the iteration exactly as described here.
// RootSystemOptions tunes FindRootSystem. Tolerance ≤ 0 means 1e-10
// (an infinity-norm threshold on both the residual and the scaled
// step), MaxIterations ≤ 0 means 100.
type RootSystemOptions struct {
Tolerance float64
MaxIterations int
// UseBroyden maintains the Jacobian across steps instead of
// rebuilding it every round: one central-difference Jacobian at
// the start, then the rank-one Broyden update on its inverse
// after every accepted step, with a numerical rebuild whenever the
// update degrades (the triggers are documented on
// broydenMaintain). The default false leaves the per-step
// Jacobian and the iteration's results exactly as they are
// without the option.
UseBroyden bool
// ParallelJacobian lets the central-difference Jacobian sweep its
// columns on several goroutines, the build at the start and every
// Broyden restart included. 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 run is bit for bit the same either
// way, because the columns are independent and each one is
// differenced by the same stencil.
ParallelJacobian bool
}
// FindRootSystem solves r(x) = 0 for a vector function r of an n-vector,
// returning the solution and the residual infinity norm at it. The
// function must return a vector of the same length as x0. A singular
// Jacobian, a step that collapses before the tolerance is met or an
// exhausted iteration budget are errors, never silent answers.
//
// The iteration is a local solver: it follows the residual norm
// downhill, so a start whose basin contains no root, or one sitting
// in a parasitic minimum of the residual norm, reports the unfinished
// residual instead of pretending to converge. When the Newton system
// is singular the step falls back to the steepest-descent direction
// of the residual norm, which lets the iteration slide off the
// singular locus rather than dying there.
func FindRootSystem(f func(x *core.Array) (*core.Array, error), x0 *core.Array,
opts RootSystemOptions) (*core.Array, float64, error) {
if opts.Tolerance <= 0 {
opts.Tolerance = 1e-10
}
if opts.MaxIterations <= 0 {
opts.MaxIterations = 100
}
if x0.Dtype() == core.Complex {
return nil, 0, base.Errf("FindRootSystem: complex unknowns are not supported")
}
n := x0.Len()
if n == 0 {
return nil, 0, base.Errf("FindRootSystem: the initial guess must not be empty")
}
// eval reads the residual at x. The finiteness gate is strict for
// the states the iteration adopts (the start point and accepted
// iterates): a non-finite residual there is invisible to the
// norm's strict comparisons and would read as a converged root,
// the same refusal LevenbergMarquardt makes. Backtracking trials
// take the lenient variant: a trial that stepped into overflow is
// a rejected candidate to halve past, not a dead run, exactly the
// recovery the damping exists for.
eval := func(x []float64, strict bool, dst []float64) ([]float64, error) {
// The callback receives a copy, not an aliasing view of the
// reused iterate or stencil buffers: arrays are contractually
// immutable, and a callback that retains its argument must not
// observe the later writes. Minimise, L-BFGS and Levenberg-
// Marquardt copy for the same reason.
xa, aerr := core.FromFloats(x, len(x))
if aerr != nil {
return nil, aerr
}
out, err := f(xa)
if err != nil {
return nil, err
}
if out.NDim() != 1 || out.Len() != n {
return nil, base.Errf("residual has shape %s, want a vector of length %d",
base.ShapeText(out.Shape()), n)
}
if err := requireReal("FindRootSystem", "residuals", out); err != nil {
return nil, err
}
// dst carries a buffer the Jacobian's stencil reuses across
// columns; the states the iteration keeps come back freshly
// allocated. Every entry of the buffer is written before it is
// read.
res := dst
if cap(res) < n {
res = make([]float64, n)
}
r := res[:n]
for i := range n {
r[i] = out.FloatAt(i)
if strict && (math.IsNaN(r[i]) || math.IsInf(r[i], 0)) {
// Bare, as Minimise's eval explains: every call site
// wraps once with the entry-point name, so the surfaced
// message carries exactly one prefix.
return nil, fmt.Errorf("the residual returned the non-finite value %g at coordinate %d", r[i], i)
}
}
return r, nil
}
// cloneDense promotes through FloatAt, so Int and Float32 starting
// vectors behave exactly like Float64 ones (a RawFloats fast path
// would hand the iteration a nil slice for those dtypes).
x := cloneDense(x0)
r, err := eval(x, true, nil)
if err != nil {
return nil, 0, base.Errf("FindRootSystem: %w", err)
}
res := normInfOfStep(r)
jac := make([][]float64, n)
for i := range n {
jac[i] = make([]float64, n)
}
// The Newton solve runs on a working copy of the Jacobian, refilled
// from jac every round: base.Factor consumes its argument in place
// before it can fail, and the fallback below needs the pristine
// central-difference matrix.
jacWork := make([][]float64, n)
for i := range n {
jacWork[i] = make([]float64, n)
}
// Buffers reused across iterations: the two perturbed stencils, the
// backtracking candidate, the Newton column and the step. Each is
// fully rewritten before it is read, and only transiently wrapped
// views of them reach the callback. The two residual vectors of the
// Jacobian's stencil are carried the same way: a column is
// differenced from them and they are dead once it is written, so
// one pair per round replaces one pair per column.
xp := make([]float64, n)
xm := make([]float64, n)
candidate := make([]float64, n)
col := make([]float64, n)
colWrap := [][]float64{nil}
step := make([]float64, n)
var resPlus, resMinus []float64
// Broyden state, allocated only under UseBroyden: the maintained
// inverse of the Jacobian and the flag saying it is fit to use,
// false until the first Jacobian is inverted and again after any
// restart, which sends the next round down the same
// build-and-invert path as the first.
useBroyden := opts.UseBroyden
var invJac [][]float64
var sVec, yVec []float64
haveInv := false
stalled := 0
if useBroyden {
invJac = make([][]float64, n)
for i := range n {
invJac[i] = make([]float64, n)
}
sVec = make([]float64, n)
yVec = make([]float64, n)
}
for range opts.MaxIterations {
if res <= opts.Tolerance {
return linalg.ArrayFromFloatsSafe(x, len(x)), res, nil
}
var derr error
if useBroyden && haveInv {
// The maintained inverse turns the linearised solve into a
// matrix-vector product, δ = −H·r: no Jacobian build and
// no factorisation this round.
broydenStep(invJac, r, step)
} else {
// Central-difference Jacobian, one column per unknown. The
// stencil carries the offset on one unknown at a time,
// restored as soon as the column is differenced, so the
// whole point is copied once per round rather than once per
// column. column walks one unknown'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(x[j]))
sp[j] += eps
sm[j] -= eps
rp, e1 := eval(sp, true, rp)
rm, e2 := eval(sm, true, rm)
sp[j], sm[j] = x[j], x[j]
if e1 != nil || e2 != nil {
return rp, rm, firstError(e1, e2)
}
for i := range n {
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 x, so no two workers write the same
// address and the sweep needs no locks. The build runs
// once at the start and again at every Broyden restart,
// both through this sweep. 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.
colErrs := make([]error, n)
engine.ParallelMin(n, 1, func(start, end int) {
sp, sm := make([]float64, n), make([]float64, n)
copy(sp, x)
copy(sm, x)
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 nil, 0, base.Errf("FindRootSystem: %w", err)
}
}
} else {
copy(xp, x)
copy(xm, x)
for j := range n {
var err error
resPlus, resMinus, err = column(j, xp, xm, resPlus, resMinus)
if err != nil {
return nil, 0, base.Errf("FindRootSystem: %w", err)
}
}
}
for i := range n {
col[i] = -r[i]
}
// Factor consumes the matrix in place: the pivot search swaps
// the rows and the elimination overwrites the subdiagonal with
// the multipliers, and the mutation lands in base.go before
// CheckSingular can reject the matrix. The solve therefore runs
// on the copy, so the steepest-descent fallback reads the
// pristine Jacobian: a direction formed from the factored
// matrix applies the permuted LU factors to r, which is not
// −Jᵀr and does not descend.
for i := range n {
copy(jacWork[i], jac[i])
}
colWrap[0] = col
if useBroyden {
// First round or restart after a degraded update:
// rebuild the inverse and step with it at once, so a
// fresh Jacobian is never paid for without moving. A
// singular Jacobian takes the same steepest-descent
// escape the Newton path uses, with the inverse left
// unfit so the next round rebuilds.
if ierr := broydenInvert(jac, jacWork, invJac); ierr != nil {
derr = ierr
steepestDescentStep(jac, r, step)
} else {
haveInv = true
broydenStep(invJac, r, step)
}
} else {
sol, serr := base.SolveSystem("FindRootSystem", jacWork, colWrap)
derr = serr
if serr != nil {
// A singular Jacobian has no Newton direction, but the
// steepest-descent direction of the merit function ‖r‖²
// always exists when r ≠ 0 and always decreases it, so
// the iteration escapes the singular locus instead of
// dying there.
steepestDescentStep(jac, r, step)
} else {
for i := range n {
step[i] = sol[0][i]
}
}
}
}
// Backtrack along the step until the residual norm falls, at
// the Armijo fraction of the predicted linear decrease.
resSq := 0.0
for i := range n {
resSq += r[i] * r[i]
}
// Scale the descent fallback down to Newton's magnitude so the
// first trial is comparable.
if derr != nil {
stepNorm := normInfOfStep(step)
if stepNorm > 0 {
scale := normInfOfStep(col) / stepNorm
if scale > 0 && !math.IsInf(scale, 0) {
for i := range step {
step[i] *= scale
}
}
}
}
alpha := 1.0
improved := false
for range 60 {
for j := range n {
candidate[j] = x[j] + alpha*step[j]
}
rc, cerr := eval(candidate, false, nil)
if cerr != nil {
return nil, 0, base.Errf("FindRootSystem: %w", cerr)
}
candSq := 0.0
for i := range n {
candSq += rc[i] * rc[i]
}
// A trial whose residual overflowed is a rejected candidate:
// its norm is Inf or NaN, compares false below, and the
// halving continues, which is what the damping exists for.
// A finite sum of squares admits only finite components, so
// whatever passes both comparisons is adoptable.
if candSq <= (1-1e-4*alpha)*resSq || candSq < opts.Tolerance*opts.Tolerance {
// The candidate buffer is reused by the next round's
// backtracking, so the accepted point is copied into
// the iteration's own state.
if useBroyden {
// The update's step and residual differences come
// from the state the accepted step leaves, so they
// are read before the copy overwrites it.
for j := range n {
sVec[j] = candidate[j] - x[j]
yVec[j] = rc[j] - r[j]
}
}
copy(x, candidate)
r = rc
resNew := normInfOfStep(r)
// The update corrects a maintained inverse, so it only
// runs when one is fit to be corrected: after a
// singular round the rebuild next loop takes over.
if useBroyden && haveInv {
// A false report leaves the inverse untouched and
// sends the next round back to a fresh Jacobian.
haveInv, stalled = broydenMaintain(invJac, sVec, yVec, r, resNew, res, stalled)
}
res = resNew
improved = true
break
}
alpha /= 2
}
if !improved {
return nil, 0, base.Errf("FindRootSystem: the residual cannot be reduced below %g by damping", res)
}
// The documented success criterion is both a small step and a
// residual under the tolerance: a vanished step alone can come
// from a singular Jacobian whose steepest-descent fallback is
// numerically zero, and that point is a stall to keep working
// on, never a root to publish.
if res <= opts.Tolerance && normInfOfStep(step) <= opts.Tolerance*(1+normInfOfStep(x)) {
return linalg.ArrayFromFloatsSafe(x, len(x)), res, nil
}
}
// The last accepted step updated r and res after the loop-top test,
// so a run that met the tolerance exactly on the final iteration
// must re-test before the budget refusal reports it.
if res <= opts.Tolerance {
return linalg.ArrayFromFloatsSafe(x, len(x)), res, nil
}
return nil, 0, base.Errf("FindRootSystem: reached MaxIterations=%d with residual %g",
opts.MaxIterations, res)
}
// normInfOfStep returns the infinity norm of a step vector.
func normInfOfStep(step []float64) float64 {
worst := 0.0
for _, v := range step {
if a := math.Abs(v); a > worst {
worst = a
}
}
return worst
}
// steepestDescentStep writes the negative gradient of the merit
// function ‖r‖², −Jᵀr, into step: entry i sums column i of the
// Jacobian against the residual, so entry j of the Jacobian's row k
// carries ∂r_k/∂x_j. jac must be the pristine central-difference
// matrix, never a factored one: Factor overwrites the subdiagonal
// with the multipliers and swaps the rows in place, so a factored
// matrix yields a direction that is no descent direction at all.
func steepestDescentStep(jac [][]float64, r, step []float64) {
for i := range step {
s := 0.0
for k := range r {
s += jac[k][i] * r[k]
}
step[i] = -s
}
}
// firstError returns the first non-nil of two errors.
func firstError(e1, e2 error) error {
if e1 != nil {
return e1
}
return e2
}