552 lines
18 KiB
Go
552 lines
18 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
|
|
// SPDX-License-Identifier: MIT
|
|||
|
|
|
|||
|
|
package optim
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
|||
|
|
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
|||
|
|
"sourcedock.dev/petrbalvin/tensor/linalg"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
import "math"
|
|||
|
|
|
|||
|
|
// Limited-memory BFGS optimisation. The plain `Minimise` (Nelder-Mead)
|
|||
|
|
// is derivative-free and robust but needs O(n²) memory for the simplex
|
|||
|
|
// and many objective evaluations per step. L-BFGS is the standard
|
|||
|
|
// quasi-Newton method for smooth objectives on moderate-to-large
|
|||
|
|
// problems: it approximates the inverse Hessian from the last `memory`
|
|||
|
|
// position/gradient pairs by the two-loop recursion, which costs
|
|||
|
|
// O(memory·n) per step, far cheaper than the O(n²) simplex or the
|
|||
|
|
// O(n³) dense Hessian, and far faster to converge than gradient
|
|||
|
|
// descent on ill-conditioned objectives.
|
|||
|
|
//
|
|||
|
|
// The gradient may be supplied by the caller or computed by central
|
|||
|
|
// finite differences, which costs one extra objective evaluation per
|
|||
|
|
// coordinate per step.
|
|||
|
|
|
|||
|
|
// LBFGSOptions tunes the optimiser. MaxIterations ≤ 0 means 10000,
|
|||
|
|
// Tolerance ≤ 0 means 1e-8 (L∞ norm of the projected gradient, see
|
|||
|
|
// below), Memory ≤ 0 means 10.
|
|||
|
|
//
|
|||
|
|
// The tolerance is absolute in the gradient's own scale: an objective
|
|||
|
|
// whose gradient sits many orders of magnitude below one is reported
|
|||
|
|
// converged at its start point. Rescale the objective to O(1) before
|
|||
|
|
// calling MinimiseLBFGS when the natural units are not of that size.
|
|||
|
|
//
|
|||
|
|
// Lower and Upper, when non-nil, bound the search coordinate-wise:
|
|||
|
|
// an infinite entry leaves that side open and a crossed or NaN pair of
|
|||
|
|
// walls is an error. The iterate is then kept feasible by projecting
|
|||
|
|
// the line search onto the box, and a coordinate sitting on a wall the
|
|||
|
|
// gradient pushes against is pinned: the two-loop recursion and the
|
|||
|
|
// curvature pairs run in the free subspace, and the convergence
|
|||
|
|
// measure is the projected gradient, which is the KKT residual of the
|
|||
|
|
// box problem. A gradient by finite differences turns one-sided at a
|
|||
|
|
// wall, so the objective is never evaluated outside the box. x0 is
|
|||
|
|
// projected onto the box rather than refused.
|
|||
|
|
type LBFGSOptions struct {
|
|||
|
|
MaxIterations int
|
|||
|
|
Tolerance float64
|
|||
|
|
Memory int
|
|||
|
|
Lower []float64
|
|||
|
|
Upper []float64
|
|||
|
|
// AllowBudgetExit makes a run that exhausts MaxIterations report
|
|||
|
|
// the best point it reached with a nil error instead of refusing
|
|||
|
|
// it. MinimiseConstrained sets it for its inner solves, whose
|
|||
|
|
// accuracy the outer loop's feasibility check judges; a direct
|
|||
|
|
// caller leaves it false, so a budget stop is never mistaken for a
|
|||
|
|
// converged answer. A run that converges normally is unaffected.
|
|||
|
|
AllowBudgetExit bool
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MinimiseLBFGS returns the point and value of a local minimum of f
|
|||
|
|
// near x0 by the limited-memory BFGS method with backtracking Armijo
|
|||
|
|
// line search. The gradient grad may be nil, in which case central
|
|||
|
|
// finite differences are used (costing two extra objective evaluations
|
|||
|
|
// per coordinate per step).
|
|||
|
|
func MinimiseLBFGS(f func(*core.Array) (float64, error), grad func(*core.Array) (*core.Array, error),
|
|||
|
|
x0 *core.Array, opts LBFGSOptions) (*core.Array, float64, error) {
|
|||
|
|
n := x0.Len()
|
|||
|
|
if n == 0 {
|
|||
|
|
return nil, 0, base.Errf("MinimiseLBFGS: the starting point must have at least one element")
|
|||
|
|
}
|
|||
|
|
if x0.Dtype() == core.Complex {
|
|||
|
|
return nil, 0, base.Errf("MinimiseLBFGS: complex starting points are not supported")
|
|||
|
|
}
|
|||
|
|
if opts.MaxIterations <= 0 {
|
|||
|
|
opts.MaxIterations = 10000
|
|||
|
|
}
|
|||
|
|
if opts.Tolerance <= 0 {
|
|||
|
|
opts.Tolerance = 1e-8
|
|||
|
|
}
|
|||
|
|
if opts.Memory <= 0 {
|
|||
|
|
opts.Memory = 10
|
|||
|
|
}
|
|||
|
|
mem := min(opts.Memory, n)
|
|||
|
|
|
|||
|
|
lower, upper, err := boundsOf(opts, n)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, 0, base.Errf("MinimiseLBFGS: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
x := make([]float64, n)
|
|||
|
|
for i := range n {
|
|||
|
|
x[i] = min(max(x0.FloatAt(i), lower[i]), upper[i])
|
|||
|
|
}
|
|||
|
|
fx, err := f(linalg.ArrayFromFloatsSafe(x, n))
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, 0, base.Errf("MinimiseLBFGS: %w", err)
|
|||
|
|
}
|
|||
|
|
if math.IsNaN(fx) || math.IsInf(fx, 0) {
|
|||
|
|
return nil, 0, base.Errf("MinimiseLBFGS: the objective returned the non-finite value %g at the starting point", fx)
|
|||
|
|
}
|
|||
|
|
g := make([]float64, n)
|
|||
|
|
xp := make([]float64, n)
|
|||
|
|
xm := make([]float64, n)
|
|||
|
|
if err := evalGrad(f, grad, x, g, lower, upper, xp, xm); err != nil {
|
|||
|
|
return nil, 0, base.Errf("MinimiseLBFGS: %w", err)
|
|||
|
|
}
|
|||
|
|
pinnedMask := make([]bool, n)
|
|||
|
|
projected := make([]float64, n)
|
|||
|
|
project := func() float64 {
|
|||
|
|
// The projected gradient zeroes a coordinate that sits on a
|
|||
|
|
// wall the gradient pushes against: its norm is the KKT
|
|||
|
|
// residual of the box problem, its support the free subspace
|
|||
|
|
// the two-loop recursion runs in. The true gradient stays
|
|||
|
|
// untouched in g.
|
|||
|
|
norm := 0.0
|
|||
|
|
for i := range n {
|
|||
|
|
pinnedMask[i] = pinnedAt(lower, upper, x, g, i)
|
|||
|
|
if pinnedMask[i] {
|
|||
|
|
projected[i] = 0
|
|||
|
|
} else {
|
|||
|
|
projected[i] = g[i]
|
|||
|
|
}
|
|||
|
|
if v := math.Abs(projected[i]); v > norm {
|
|||
|
|
norm = v
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return norm
|
|||
|
|
}
|
|||
|
|
gNorm := project()
|
|||
|
|
if gNorm <= opts.Tolerance {
|
|||
|
|
out, fv := packResult(x, fx)
|
|||
|
|
return out, fv, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Limited-memory correction pairs: (s_i, y_i) with ρ_i = 1/(y_iᵀs_i).
|
|||
|
|
// The window is a ring of pairs, each pair's vectors allocated with
|
|||
|
|
// the first pair that needs them: count is how many of
|
|||
|
|
// its slots hold a pair, head is the oldest of them, and the pair at
|
|||
|
|
// position d of the window sits at (head+d) % mem, position 0 being
|
|||
|
|
// the oldest. A step that arrives once the window is full overwrites
|
|||
|
|
// the slot it drops, so an accepted step allocates nothing.
|
|||
|
|
history := make([]lmPair, mem)
|
|||
|
|
head, count := 0, 0
|
|||
|
|
|
|||
|
|
// Scratch reused across iterations: the two-loop direction, its
|
|||
|
|
// per-pair store, the accepted-step buffers and the finite-difference
|
|||
|
|
// step and its two stencils. Each is fully overwritten before it is
|
|||
|
|
// read, and nothing escapes the iteration by reference.
|
|||
|
|
q := make([]float64, n)
|
|||
|
|
store := make([]float64, mem)
|
|||
|
|
dir := q
|
|||
|
|
trial := make([]float64, n)
|
|||
|
|
xNew := make([]float64, n)
|
|||
|
|
gNew := make([]float64, n)
|
|||
|
|
sVec := make([]float64, n)
|
|||
|
|
yVec := make([]float64, n)
|
|||
|
|
|
|||
|
|
converged := false
|
|||
|
|
for iter := 0; iter < opts.MaxIterations; iter++ {
|
|||
|
|
gNorm = project()
|
|||
|
|
if gNorm <= opts.Tolerance {
|
|||
|
|
converged = true
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
// Two-loop recursion for the search direction, driven by the
|
|||
|
|
// projected gradient and confined to the free subspace: the
|
|||
|
|
// dots skip pinned coordinates and q is re-zeroed on them
|
|||
|
|
// after every update, because history pairs predate the
|
|||
|
|
// current active set and their stale components would drag a
|
|||
|
|
// pinned coordinate off its wall or corrupt the slope test.
|
|||
|
|
twoLoopRecursion(projected, history, head, count, mem, pinnedMask, q, store)
|
|||
|
|
// The search direction is −q (the negative approximate
|
|||
|
|
// inverse Hessian times the gradient); q is dead past this
|
|||
|
|
// point, so the negation happens in place.
|
|||
|
|
for j := range n {
|
|||
|
|
dir[j] = -q[j]
|
|||
|
|
}
|
|||
|
|
dirNorm := maxAbs(dir)
|
|||
|
|
if dirNorm == 0 {
|
|||
|
|
return nil, 0, base.Errf("MinimiseLBFGS: the search direction vanished with a projected gradient of %g", gNorm)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Backtracking Armijo line search along the free subspace,
|
|||
|
|
// projected onto the box so every trial point stays feasible.
|
|||
|
|
// The trial step starts at one unit, the standard choice, and
|
|||
|
|
// halves until the objective's sufficient decrease is met: the
|
|||
|
|
// step that actually reduces a quadratic is ≈ 1/L for a
|
|||
|
|
// curvature L, so a stiff objective (penalty weights, mixed
|
|||
|
|
// units) needs a long halving chain before any trial is
|
|||
|
|
// acceptable. A search that cannot find an acceptable point
|
|||
|
|
// within the budget is a failure, never a silent success: the
|
|||
|
|
// caller would otherwise read the start point as the answer.
|
|||
|
|
step := 1.0
|
|||
|
|
slope := 0.0
|
|||
|
|
for j := range n {
|
|||
|
|
slope += g[j] * dir[j]
|
|||
|
|
}
|
|||
|
|
if slope >= 0 {
|
|||
|
|
return nil, 0, base.Errf("MinimiseLBFGS: the search direction is not a descent direction (slope %g) at a projected gradient of %g",
|
|||
|
|
slope, gNorm)
|
|||
|
|
}
|
|||
|
|
const (
|
|||
|
|
armijo = 1e-4
|
|||
|
|
backtrack = 60
|
|||
|
|
)
|
|||
|
|
accepted := false
|
|||
|
|
var ft float64
|
|||
|
|
var ferr error
|
|||
|
|
for range backtrack {
|
|||
|
|
for j := range n {
|
|||
|
|
trial[j] = min(max(x[j]+step*dir[j], lower[j]), upper[j])
|
|||
|
|
}
|
|||
|
|
ft, ferr = f(linalg.ArrayFromFloatsSafe(trial, n))
|
|||
|
|
if ferr != nil {
|
|||
|
|
step *= 0.5
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if ft <= fx+armijo*step*slope {
|
|||
|
|
accepted = true
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
step *= 0.5
|
|||
|
|
}
|
|||
|
|
if !accepted {
|
|||
|
|
if ferr != nil {
|
|||
|
|
// The last trial failed by error, not by value: the
|
|||
|
|
// caller sees the cause instead of a bare stall.
|
|||
|
|
return nil, 0, base.Errf("MinimiseLBFGS: the line search stalled at a step of %g with a projected gradient of %g: %w",
|
|||
|
|
step, gNorm, ferr)
|
|||
|
|
}
|
|||
|
|
return nil, 0, base.Errf("MinimiseLBFGS: the line search stalled at a step of %g with a projected gradient of %g",
|
|||
|
|
step, gNorm)
|
|||
|
|
}
|
|||
|
|
if math.IsNaN(ft) || math.IsInf(ft, 0) {
|
|||
|
|
return nil, 0, base.Errf("MinimiseLBFGS: the objective returned the non-finite value %g during the line search", ft)
|
|||
|
|
}
|
|||
|
|
// The accepted point is the projected trial, not the unprojected
|
|||
|
|
// step: the displacement s must be what actually moved. Its
|
|||
|
|
// value is the line search's own: the trial already sat at
|
|||
|
|
// xNew, so a second evaluation would ask the objective for the
|
|||
|
|
// number it just returned.
|
|||
|
|
copy(xNew, trial)
|
|||
|
|
fxNew := ft
|
|||
|
|
if err := evalGrad(f, grad, xNew, gNew, lower, upper, xp, xm); err != nil {
|
|||
|
|
return nil, 0, base.Errf("MinimiseLBFGS: %w", err)
|
|||
|
|
}
|
|||
|
|
ys := 0.0
|
|||
|
|
for j := range n {
|
|||
|
|
sVec[j] = xNew[j] - x[j]
|
|||
|
|
yVec[j] = gNew[j] - g[j]
|
|||
|
|
// A coordinate the projection held still carries no
|
|||
|
|
// curvature information; its gradient change is noise the
|
|||
|
|
// second loop would otherwise mix into the direction.
|
|||
|
|
if sVec[j] == 0 {
|
|||
|
|
yVec[j] = 0
|
|||
|
|
}
|
|||
|
|
ys += yVec[j] * sVec[j]
|
|||
|
|
}
|
|||
|
|
if ys > 1e-16 {
|
|||
|
|
if count < mem {
|
|||
|
|
p := &history[(head+count)%mem]
|
|||
|
|
if p.s == nil {
|
|||
|
|
// The vectors arrive with the first pair that needs
|
|||
|
|
// them, so a solve that takes few steps does not pay
|
|||
|
|
// for the whole window up front.
|
|||
|
|
p.s = make([]float64, n)
|
|||
|
|
p.y = make([]float64, n)
|
|||
|
|
}
|
|||
|
|
copy(p.s, sVec)
|
|||
|
|
copy(p.y, yVec)
|
|||
|
|
p.rho = 1 / ys
|
|||
|
|
count++
|
|||
|
|
} else {
|
|||
|
|
p := &history[head]
|
|||
|
|
copy(p.s, sVec)
|
|||
|
|
copy(p.y, yVec)
|
|||
|
|
p.rho = 1 / ys
|
|||
|
|
head = (head + 1) % mem
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
copy(x, xNew)
|
|||
|
|
copy(g, gNew)
|
|||
|
|
fx = fxNew
|
|||
|
|
}
|
|||
|
|
// The last accepted step updated x, g and fx after the loop-top
|
|||
|
|
// test, so a run whose tolerance was met exactly on the final
|
|||
|
|
// iteration must re-test before the budget refusal reports it.
|
|||
|
|
gNorm = project()
|
|||
|
|
if gNorm <= opts.Tolerance {
|
|||
|
|
converged = true
|
|||
|
|
}
|
|||
|
|
// Falling out of the loop means the budget ran out, not that a
|
|||
|
|
// minimum was found: the projected gradient is still above the
|
|||
|
|
// tolerance, and reporting the point as a converged answer is the
|
|||
|
|
// silent-wrongness the other exits refuse. The best point is not
|
|||
|
|
// returned, exactly as the stall and direction exits do not return
|
|||
|
|
// theirs.
|
|||
|
|
if !converged {
|
|||
|
|
if !opts.AllowBudgetExit {
|
|||
|
|
return nil, 0, base.Errf("MinimiseLBFGS: the iteration budget of %d ran out with a projected gradient of %g, above the tolerance %g",
|
|||
|
|
opts.MaxIterations, gNorm, opts.Tolerance)
|
|||
|
|
}
|
|||
|
|
out, fv := packResult(x, fx)
|
|||
|
|
return out, fv, nil
|
|||
|
|
}
|
|||
|
|
out, fv := packResult(x, fx)
|
|||
|
|
return out, fv, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// lmPair is one limited-memory correction pair: the position
|
|||
|
|
// difference s, the gradient difference y and ρ = 1/(yᵀs).
|
|||
|
|
type lmPair struct {
|
|||
|
|
s, y []float64
|
|||
|
|
rho float64
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// twoLoopRecursion drives the search direction through the stored
|
|||
|
|
// pairs, driven by the projected gradient q starts from. When no
|
|||
|
|
// coordinate is pinned the four sweeps run mask-free, which is the
|
|||
|
|
// common case for an interior iterate; with pins the masked walk
|
|||
|
|
// skips them in the dots and re-zeroes q on them after every update.
|
|||
|
|
// Both walks produce the same bits for the same state: the mask-free
|
|||
|
|
// path is the masked one with an always-false branch removed.
|
|||
|
|
func twoLoopRecursion(projected []float64, history []lmPair, head, count, mem int, pinned []bool, q, store []float64) {
|
|||
|
|
n := len(projected)
|
|||
|
|
copy(q, projected)
|
|||
|
|
store = store[:count]
|
|||
|
|
free := 0
|
|||
|
|
for j := range n {
|
|||
|
|
if !pinned[j] {
|
|||
|
|
free++
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if free == n {
|
|||
|
|
for i := count - 1; i >= 0; i-- {
|
|||
|
|
p := &history[(head+i)%mem]
|
|||
|
|
dot := 0.0
|
|||
|
|
for j := range n {
|
|||
|
|
dot += p.s[j] * q[j]
|
|||
|
|
}
|
|||
|
|
store[i] = dot * p.rho
|
|||
|
|
for j := range n {
|
|||
|
|
q[j] -= store[i] * p.y[j]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
for i := count - 1; i >= 0; i-- {
|
|||
|
|
p := &history[(head+i)%mem]
|
|||
|
|
dot := 0.0
|
|||
|
|
for j := range n {
|
|||
|
|
if !pinned[j] {
|
|||
|
|
dot += p.s[j] * q[j]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
store[i] = dot * p.rho
|
|||
|
|
for j := range n {
|
|||
|
|
q[j] -= store[i] * p.y[j]
|
|||
|
|
if pinned[j] {
|
|||
|
|
q[j] = 0
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
gamma := 1.0
|
|||
|
|
if count > 0 {
|
|||
|
|
last := &history[(head+count-1)%mem]
|
|||
|
|
num, den := 0.0, 0.0
|
|||
|
|
for j := range n {
|
|||
|
|
num += last.s[j] * last.y[j]
|
|||
|
|
den += last.y[j] * last.y[j]
|
|||
|
|
}
|
|||
|
|
if den != 0 {
|
|||
|
|
gamma = num / den
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
for j := range n {
|
|||
|
|
q[j] *= gamma
|
|||
|
|
}
|
|||
|
|
if free == n {
|
|||
|
|
for i := range count {
|
|||
|
|
p := &history[(head+i)%mem]
|
|||
|
|
dot := 0.0
|
|||
|
|
for j := range n {
|
|||
|
|
dot += p.y[j] * q[j]
|
|||
|
|
}
|
|||
|
|
coeff := store[i] - p.rho*dot
|
|||
|
|
for j := range n {
|
|||
|
|
q[j] += coeff * p.s[j]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
for i := range count {
|
|||
|
|
p := &history[(head+i)%mem]
|
|||
|
|
dot := 0.0
|
|||
|
|
for j := range n {
|
|||
|
|
if !pinned[j] {
|
|||
|
|
dot += p.y[j] * q[j]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
coeff := store[i] - p.rho*dot
|
|||
|
|
for j := range n {
|
|||
|
|
q[j] += coeff * p.s[j]
|
|||
|
|
if pinned[j] {
|
|||
|
|
q[j] = 0
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// boundsOf resolves the optional box walls: a nil slice opens every
|
|||
|
|
// side, an infinite entry opens one side, and NaN walls or a crossed
|
|||
|
|
// pair are errors.
|
|||
|
|
func boundsOf(opts LBFGSOptions, n int) (lower, upper []float64, err error) {
|
|||
|
|
lower = make([]float64, n)
|
|||
|
|
upper = make([]float64, n)
|
|||
|
|
for i := range n {
|
|||
|
|
lower[i], upper[i] = math.Inf(-1), math.Inf(1)
|
|||
|
|
}
|
|||
|
|
if opts.Lower != nil {
|
|||
|
|
if len(opts.Lower) != n {
|
|||
|
|
return nil, nil, base.Errf("the lower bounds hold %d entries for %d variables", len(opts.Lower), n)
|
|||
|
|
}
|
|||
|
|
copy(lower, opts.Lower)
|
|||
|
|
}
|
|||
|
|
if opts.Upper != nil {
|
|||
|
|
if len(opts.Upper) != n {
|
|||
|
|
return nil, nil, base.Errf("the upper bounds hold %d entries for %d variables", len(opts.Upper), n)
|
|||
|
|
}
|
|||
|
|
copy(upper, opts.Upper)
|
|||
|
|
}
|
|||
|
|
for i := range n {
|
|||
|
|
if math.IsNaN(lower[i]) || math.IsNaN(upper[i]) || lower[i] > upper[i] {
|
|||
|
|
return nil, nil, base.Errf("variable %d has bounds [%g, %g]", i, lower[i], upper[i])
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return lower, upper, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// pinnedAt reports whether coordinate i sits on a wall the gradient
|
|||
|
|
// pushes against: at a lower wall with a positive gradient the descent
|
|||
|
|
// direction -g points out of the box, so the KKT condition holds and
|
|||
|
|
// the coordinate is pinned. The wall test carries a relative tolerance
|
|||
|
|
// wide enough to cover the drift of accepted steps that landed beside
|
|||
|
|
// the wall without crossing it; release needs only the gradient to
|
|||
|
|
// turn favourable, so a generous tolerance cannot trap a coordinate.
|
|||
|
|
func pinnedAt(lower, upper, x, g []float64, i int) bool {
|
|||
|
|
const wall = 1e-7
|
|||
|
|
// An equality-bounded coordinate can never move, whatever the
|
|||
|
|
// gradient says: pinning it keeps the two-loop direction and the
|
|||
|
|
// projected-gradient norm consistent with the frozen wall.
|
|||
|
|
if !math.IsInf(lower[i], 0) && lower[i] == upper[i] {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
if g[i] > 0 && !math.IsInf(lower[i], 0) && x[i]-lower[i] <= wall*math.Max(1, math.Abs(lower[i])) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
if g[i] < 0 && !math.IsInf(upper[i], 0) && upper[i]-x[i] <= wall*math.Max(1, math.Abs(upper[i])) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// evalGrad fills g with the gradient of f at x: the caller-supplied
|
|||
|
|
// gradient if available, else finite differences with step
|
|||
|
|
// √ε·max(1,|xᵢ|). A coordinate whose central stencil would leave the
|
|||
|
|
// box falls back to the one-sided stencil that stays inside it, so a
|
|||
|
|
// bounded objective is never evaluated outside its domain. xp and xm
|
|||
|
|
// are scratch of length len(x): the perturbed copy carries the offset
|
|||
|
|
// on one coordinate only, which is restored once the coordinate is
|
|||
|
|
// done, so neither a copy of the whole point nor a fresh slice per
|
|||
|
|
// coordinate is needed. linalg.ArrayFromFloatsSafe copies into the
|
|||
|
|
// array handed to f, so the objective never observes later mutation.
|
|||
|
|
func evalGrad(f func(*core.Array) (float64, error), grad func(*core.Array) (*core.Array, error),
|
|||
|
|
x, g, lower, upper, xp, xm []float64) error {
|
|||
|
|
if grad != nil {
|
|||
|
|
ga, err := grad(linalg.ArrayFromFloatsSafe(x, len(x)))
|
|||
|
|
if err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
if err := requireReal("MinimiseLBFGS", "gradients", ga); err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
if ga.Len() != len(x) {
|
|||
|
|
return base.Errf("MinimiseLBFGS: the gradient callback returned %d elements for %d variables",
|
|||
|
|
ga.Len(), len(x))
|
|||
|
|
}
|
|||
|
|
for i := range x {
|
|||
|
|
g[i] = ga.FloatAt(i)
|
|||
|
|
// A non-finite gradient coordinate must be an error, not a
|
|||
|
|
// NaN that skips the projected-gradient max and reads as
|
|||
|
|
// converged.
|
|||
|
|
if math.IsNaN(g[i]) || math.IsInf(g[i], 0) {
|
|||
|
|
return base.Errf("MinimiseLBFGS: the gradient callback returned the non-finite value %g at coordinate %d", g[i], i)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
copy(xp, x)
|
|||
|
|
copy(xm, x)
|
|||
|
|
for i := range x {
|
|||
|
|
eps := math.Sqrt(base.EpsF) * math.Max(1, math.Abs(x[i]))
|
|||
|
|
xp[i] = min(x[i]+eps, upper[i])
|
|||
|
|
xm[i] = max(x[i]-eps, lower[i])
|
|||
|
|
if xp[i] == xm[i] {
|
|||
|
|
// An equality-bounded coordinate clamps both stencil
|
|||
|
|
// points to the same wall; the central difference would
|
|||
|
|
// divide 0/0 into a NaN gradient.
|
|||
|
|
g[i] = 0
|
|||
|
|
xp[i], xm[i] = x[i], x[i]
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
fp, err := f(linalg.ArrayFromFloatsSafe(xp, len(x)))
|
|||
|
|
if err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
fm, err := f(linalg.ArrayFromFloatsSafe(xm, len(x)))
|
|||
|
|
if err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
g[i] = (fp - fm) / (xp[i] - xm[i])
|
|||
|
|
xp[i], xm[i] = x[i], x[i]
|
|||
|
|
// The finite-difference path validates what the analytic one
|
|||
|
|
// does: a NaN the stencil produces is invisible to the
|
|||
|
|
// projected-gradient max and would read as converged.
|
|||
|
|
if math.IsNaN(g[i]) || math.IsInf(g[i], 0) {
|
|||
|
|
return base.Errf("MinimiseLBFGS: the objective returned the non-finite values %g and %g around coordinate %d", fp, fm, i)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// maxAbs returns the maximum absolute value of a slice.
|
|||
|
|
func maxAbs(v []float64) float64 {
|
|||
|
|
m := 0.0
|
|||
|
|
for _, a := range v {
|
|||
|
|
if x := math.Abs(a); x > m {
|
|||
|
|
m = x
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return m
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// packResult wraps a flat vector as a rank-1 core.Array.
|
|||
|
|
func packResult(x []float64, fx float64) (*core.Array, float64) {
|
|||
|
|
out := core.New(core.Float, len(x))
|
|||
|
|
copy(out.RawFloats(), x)
|
|||
|
|
return out, fx
|
|||
|
|
}
|