Files
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

127 lines
4.3 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"
)
// Broyden's quasi-Newton maintenance for FindRootSystem, enabled by
// RootSystemOptions.UseBroyden. The central-difference Jacobian is
// built once at the start and the iteration walks on its maintained
// inverse: after every accepted step the rank-one update corrects the
// inverse so that it satisfies the secant equation H·y = s for the
// step just taken.
//
// The update maintained here is the bad Broyden form in its inverse
// shape, H + (s − H·y)·yᵀ/(yᵀy) with H the maintained inverse: a
// rank-one correction along the residual-difference direction y that
// satisfies the secant equation exactly for the step just taken, and
// turns every later iteration into a single matrix-vector product,
// δ = −H·r. The good form, whose correction runs along sᵀH instead,
// satisfies the same equation with a different matrix; maintaining it
// needs the product sᵀH·y on top of the step, which spends back the
// per-iteration saving the option exists for.
// broydenInvert fills h with the inverse of the central-difference
// Jacobian jac by solving jac·h = I column-wise through the library's
// LU solver: one factorisation, n right-hand columns, the same cost
// class as the Newton path's single solve. jacWork is consumed in
// place, exactly as the Newton path consumes it, so jac stays
// pristine for the steepest-descent fallback. A singular Jacobian is
// returned as the solver's own error for the caller to fall back on.
func broydenInvert(jac, jacWork, h [][]float64) error {
n := len(jac)
rhs := make([][]float64, n)
for i := range n {
rhs[i] = make([]float64, n)
rhs[i][i] = 1
}
for i := range n {
copy(jacWork[i], jac[i])
}
sol, err := base.SolveSystem("FindRootSystem", jacWork, rhs)
if err != nil {
return err
}
// sol[k] is jac⁻¹·eₖ, the kth column of the inverse.
for i := range n {
for k := range n {
h[i][k] = sol[k][i]
}
}
return nil
}
// broydenMaintain applies the rank-one update
//
// H ← H + (s − H·y)·yᵀ/(yᵀy)
//
// to the maintained inverse, where s is the accepted step and y the
// residual change it produced, and reports whether the inverse is fit
// to carry forward, together with the running count of consecutive
// steps that failed to lower the residual infinity norm. A false
// report leaves h untouched and orders FindRootSystem to rebuild the
// Jacobian numerically before it steps again, the restart the option
// documents. Two observations order the restart, both a degradation of
// the rank-one model:
//
// - the update denominator yᵀy is zero, non-finite, or at rounding
// level against the residual's own scale (‖y‖∞ ≤ ε·max(1, ‖r‖∞)):
// the division would amplify cancellation noise into H, and a y
// that small carries no curvature information at all;
// - two consecutive accepted steps each failed to lower the
// residual infinity norm. The damping guarantees the residual sum
// of squares falls on every accepted step, so a flat infinity
// norm twice in a row means the maintained inverse has stopped
// predicting the landscape and a fresh Jacobian is cheaper than
// more crawling.
func broydenMaintain(h [][]float64, s, y, r []float64, res, resPrev float64, stalled int) (bool, int) {
den := 0.0
ynorm := 0.0
for i := range y {
den += y[i] * y[i]
if v := math.Abs(y[i]); v > ynorm {
ynorm = v
}
}
if den == 0 || math.IsNaN(den) || math.IsInf(den, 0) || ynorm <= base.EpsF*math.Max(1, normInfOfStep(r)) {
return false, 0
}
if res < resPrev {
stalled = 0
} else {
stalled++
if stalled >= 2 {
return false, 0
}
}
for i := range h {
hy := 0.0
for k := range y {
hy += h[i][k] * y[k]
}
w := (s[i] - hy) / den
for k := range y {
h[i][k] += w * y[k]
}
}
return true, stalled
}
// broydenStep writes the quasi-Newton step δ = −H·r into step: the
// whole per-iteration linear algebra the maintained inverse leaves,
// a single matrix-vector product.
func broydenStep(h [][]float64, r, step []float64) {
for i := range step {
s := 0.0
for k := range r {
s += h[i][k] * r[k]
}
step[i] = -s
}
}