Files
tensor/optim/rootsystem.go
T

438 lines
16 KiB
Go
Raw Normal View History

2026-09-03 10:00:00 +02:00
// 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
}