Files
tensor/optim/linear.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

309 lines
10 KiB
Go
Raw 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"
"sourcedock.dev/petrbalvin/tensor/internal/core"
"sourcedock.dev/petrbalvin/tensor/linalg"
)
// Linear constraints handled by the augmented Lagrangian. Each row of
// A carries a two-sided bound l ≤ A·x ≤ u: a row with l = u is an
// equality constraint, a one-sided row opens the other side with an
// infinity. The outer loop escalates the quadratic penalty and ascends
// the row multipliers until the true violations vanish; the inner
// problem is the box-capable L-BFGS, so the options bounds compose
// with the linear rows.
// LinearConstraints holds the rows of l ≤ A·x ≤ u. A is an r×n matrix
// over the n variables, Lower and Upper hold one entry per row, and an
// infinite entry opens that side. A row with Lower = Upper is an
// equality constraint.
type LinearConstraints struct {
A *core.Array
Lower []float64
Upper []float64
}
// rowPenalty is one row's augmented-Lagrangian state: the coefficient
// vector, the multipliers (High carries the equality row's signed
// multiplier; the two one-sided multipliers of an inequality row are
// both non-negative) and the violation measured at the point of the
// last objective or gradient call.
type rowPenalty struct {
coeffs []float64
lower float64
upper float64
equal bool
high float64
low float64
violate float64
side float64 // +1 above the upper wall, -1 below the lower one
}
// The augmented-Lagrangian schedule both constraint entries walk: the
// linear rows of MinimiseConstrained and the nonlinear rows of
// MinimiseNonlinearConstrained share one penalty path, one ceiling and
// one feasibility gate, so the two cannot silently diverge.
const (
almMuStart = 10.0
almMuGrowth = 10.0
almMuCeiling = 1e10
almOuterRound = 40
// almFeasibleAt is the absolute row violation the outer loop
// accepts as feasible. Like the other tolerances in the package it
// is absolute, deliberately independent of opts.Tolerance (which
// stays the inner solver's projected-gradient tolerance): tying
// the two made a caller who asked for a tighter inner solve get a
// hard failure instead of a tighter answer.
almFeasibleAt = 1e-10
)
// measure evaluates every row at p, records each violation and its
// side for the penalty terms, and returns the worst true violation.
func measure(p *core.Array, rows []rowPenalty) float64 {
worst := 0.0
n := p.Len()
for i := range rows {
r := &rows[i]
ax := 0.0
for j := range n {
ax += r.coeffs[j] * p.FloatAt(j)
}
v, side := 0.0, 0.0
switch {
case ax > r.upper:
v, side = ax-r.upper, 1
case ax < r.lower:
v, side = r.lower-ax, -1
}
r.violate, r.side = v, side
if v > worst {
worst = v
}
}
return worst
}
// slope is d(penalty row)/d(ax) at the measured point: the multiplier
// of the active side plus the penalty's derivative. Zero inside the
// row's bounds.
func (r *rowPenalty) slope(mu float64) float64 {
switch r.side {
case 0:
return 0
case 1:
// An equality row violates to one side only, so its slope is
// the same whichever sign the violation carries.
return r.high + mu*r.violate
default:
if r.equal {
return r.high - mu*r.violate
}
return -(r.low + mu*r.violate)
}
}
// term is the row's contribution to the augmented Lagrangian at the
// measured point: the multiplier times the signed violation plus the
// quadratic penalty. An equality row's multiplier is signed and rides
// the side; an inequality row's one-sided multipliers are
// non-negative.
func (r *rowPenalty) term(mu float64) float64 {
if r.side == 0 {
return 0
}
mult := r.high
if !r.equal && r.side < 0 {
mult = r.low
}
if r.equal {
mult *= r.side
}
return mult*r.violate + 0.5*mu*r.violate*r.violate
}
// MinimiseConstrained returns the point and value of a local minimum
// of f subject to the box walls carried by opts and the linear rows
// l ≤ A·x ≤ u. It is the only entry point whose matrix is read
// directly, so only here is a complex A refused beside the usual dtype
// check on the starting point. Each row is used in the caller's own
// units: the violation, the multipliers and the penalty inherit the
// row's scale, so a row whose coefficients sit many orders above
// another's is enforced to a correspondingly tighter absolute
// precision. Each outer round minimises f plus the rows' augmented
// Lagrangian terms, then ascends the row multipliers and scales the
// quadratic penalty. The inner solves are MinimiseLBFGS warm-started
// from the previous round's point; the supplied gradient, when given,
// is the gradient of f and the rows' contributions are chained onto it
// analytically, and the callback must return n real values. A start
// outside the feasible set is fine: the outer rounds pull it back, and
// failure to reach feasibility is an error naming the worst remaining
// violation. Feasibility is judged on the caller's rows against a
// fixed absolute threshold of 1e-10, independent of opts.Tolerance:
// the option stays the inner solver's projected-gradient tolerance,
// and asking for a tighter inner solve never turns into a hard failure
// of the outer loop. A badly scaled row (a·x = 1 with a of 1e9 or
// more) can put the step that reduces the augmented Lagrangian beyond
// the inner line search's reach: such a run is refused with the stall
// diagnostic, never returned as a converged answer.
func MinimiseConstrained(f func(*core.Array) (float64, error), grad func(*core.Array) (*core.Array, error),
x0 *core.Array, cons LinearConstraints, opts LBFGSOptions) (*core.Array, float64, error) {
const name = "MinimiseConstrained"
n := x0.Len()
if n == 0 {
return nil, 0, base.Errf("%s: the starting point must have at least one element", name)
}
if x0.Dtype() == core.Complex {
return nil, 0, base.Errf("%s: complex starting points are not supported", name)
}
if cons.A == nil {
return nil, 0, base.Errf("%s: the constraint matrix is nil", name)
}
if err := requireReal(name, "constraint matrices", cons.A); err != nil {
return nil, 0, err
}
if cons.A.NDim() != 2 || cons.A.Shape()[1] != n {
return nil, 0, base.Errf("%s: the constraint matrix is %s, want r×%d", name, base.ShapeText(cons.A.Shape()), n)
}
rowCount := cons.A.Shape()[0]
if rowCount == 0 {
return nil, 0, base.Errf("%s: the constraint matrix has no rows", name)
}
if len(cons.Lower) != rowCount || len(cons.Upper) != rowCount {
return nil, 0, base.Errf("%s: the bounds hold %d and %d entries for %d rows",
name, len(cons.Lower), len(cons.Upper), rowCount)
}
rows := make([]rowPenalty, rowCount)
for i := range rows {
r := &rows[i]
r.coeffs = make([]float64, n)
for j := range n {
a := cons.A.FloatAt(i*n + j)
if math.IsNaN(a) || math.IsInf(a, 0) {
return nil, 0, base.Errf("%s: row %d carries a non-finite coefficient", name, i+1)
}
r.coeffs[j] = a
}
if math.IsNaN(cons.Lower[i]) || math.IsNaN(cons.Upper[i]) || cons.Lower[i] > cons.Upper[i] {
return nil, 0, base.Errf("%s: row %d has bounds [%g, %g]", name, i+1, cons.Lower[i], cons.Upper[i])
}
r.lower, r.upper = cons.Lower[i], cons.Upper[i]
r.equal = r.lower == r.upper
}
x := make([]float64, n)
for i := range n {
x[i] = x0.FloatAt(i)
}
if opts.Tolerance <= 0 {
opts.Tolerance = 1e-8
}
mu := almMuStart
// The inner solve's start point and the augmented gradient buffer:
// one array each for the whole run. MinimiseLBFGS reads the start
// once at entry and copies every gradient out at once, so both are
// fully rewritten before the next reader sees them.
start := core.New(core.Float, n)
gradBuf := core.New(core.Float, n)
for range almOuterRound {
objective := func(p *core.Array) (float64, error) {
fv, err := f(p)
if err != nil {
return 0, err
}
measure(p, rows)
total := fv
for i := range rows {
total += rows[i].term(mu)
}
return total, nil
}
objectiveGrad := func(p *core.Array) (*core.Array, error) {
g, err := grad(p)
if err != nil {
return nil, err
}
// The payload is validated here, before anything reads it:
// RawFloats() is nil for a complex or non-float payload and
// as short as the callback's array, so slicing it first
// panics instead of reporting the contract breach.
if err := requireReal(name, "gradients", g); err != nil {
return nil, err
}
if g.Len() != n {
return nil, base.Errf("%s: the gradient callback returned %d elements for %d variables",
name, g.Len(), n)
}
measure(p, rows)
out := gradBuf
for j := range n {
out.RawFloats()[j] = g.FloatAt(j)
}
for i := range rows {
w := rows[i].slope(mu)
if w == 0 {
continue
}
for j := range n {
out.RawFloats()[j] += w * rows[i].coeffs[j]
}
}
return out, nil
}
innerGrad := objectiveGrad
if grad == nil {
innerGrad = nil // the inner solver differences the objective
}
copy(start.RawFloats(), x)
// The inner solve is inexact by design: the outer loop judges
// the point by the rows' feasibility, so a run that spends its
// iteration budget without reaching the projected-gradient
// tolerance still carries the outer loop forward.
inner := opts
inner.AllowBudgetExit = true
pt, _, err := MinimiseLBFGS(objective, innerGrad, start, inner)
if err != nil {
return nil, 0, base.Errf("%s: %w", name, err)
}
for i := range n {
x[i] = pt.FloatAt(i)
}
if measure(linalg.ArrayFromFloatsSafe(x, n), rows) <= almFeasibleAt {
// The returned value is f(x) itself, not the augmented
// Lagrangian the inner solver minimised: the penalty terms
// may be small but their multipliers are not, and the
// caller asked for the objective.
fv, ferr := f(linalg.ArrayFromFloatsSafe(x, n))
if ferr != nil {
return nil, 0, base.Errf("%s: %w", name, ferr)
}
out, _ := packResult(x, fv)
return out, fv, nil
}
for i := range rows {
r := &rows[i]
switch {
case r.equal:
r.high += mu * r.side * r.violate
case r.side > 0:
r.high = max(0, r.high+mu*r.violate)
case r.side < 0:
r.low = max(0, r.low+mu*r.violate)
}
}
if mu < almMuCeiling {
mu *= almMuGrowth
}
}
worst := measure(linalg.ArrayFromFloatsSafe(x, n), rows)
return nil, 0, base.Errf("%s: %d rounds left the worst row violation at %g", name, almOuterRound, worst)
}