601 lines
19 KiB
Go
601 lines
19 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
|
|
// SPDX-License-Identifier: MIT
|
|||
|
|
|
|||
|
|
package optim
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"cmp"
|
|||
|
|
"math"
|
|||
|
|
"slices"
|
|||
|
|
|
|||
|
|
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
|||
|
|
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
|||
|
|
"sourcedock.dev/petrbalvin/tensor/linalg"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Two derivative-free global searchers beside the differential
|
|||
|
|
// evolution in devolution.go, both drawing from the house xoshiro
|
|||
|
|
// generator so a fixed seed fixes the whole trajectory.
|
|||
|
|
//
|
|||
|
|
// CMA-ES (the covariance matrix adaptation evolution strategy) is the
|
|||
|
|
// choice for smooth continuous landscapes where a local method cannot
|
|||
|
|
// be trusted to find the right basin: it adapts a full covariance
|
|||
|
|
// matrix from the successful offspring, which turns it along the
|
|||
|
|
// valley whatever orientation the valley has. The implementation is a
|
|||
|
|
// bounded single run of the standard algorithm with the default
|
|||
|
|
// parameters of N. Hansen, The CMA Evolution Strategy: A Tutorial,
|
|||
|
|
// arXiv:1604.00772 (2016): rank-one and rank-mu updates, the evolution
|
|||
|
|
// paths p_sigma and p_c with their tag for the stalled-path case, and
|
|||
|
|
// the tutorial's equations (48) to (53) for the weights and rates.
|
|||
|
|
// No restarts: the restart schemes (IPOP and friends) are the
|
|||
|
|
// caller's loop, and this entry reports one run honestly.
|
|||
|
|
//
|
|||
|
|
// Simulated annealing is the choice for landscapes too rough, too
|
|||
|
|
// discrete-like or too deceptive for covariance adaptation: a random
|
|||
|
|
// walk that accepts uphill steps with the Metropolis probability and
|
|||
|
|
// cools the acceptance threshold geometrically. It is a basin finder,
|
|||
|
|
// not a precision optimiser.
|
|||
|
|
|
|||
|
|
// CMAESOptions tunes MinimiseCMAES. Sigma0 ≤ 0 means 0.3 (the
|
|||
|
|
// tutorial's typical starting step for problems scaled to O(1)),
|
|||
|
|
// Generations ≤ 0 means 500, Tolerance ≤ 0 means 1e-12, Seed 0 is
|
|||
|
|
// replaced by 42 as in MinimiseDifferentialEvolution (any other
|
|||
|
|
// value, negatives included, seeds the xoshiro stream directly).
|
|||
|
|
//
|
|||
|
|
// The tolerance ends the run when either the distribution has
|
|||
|
|
// collapsed or the landscape has gone flat: the largest principal axis
|
|||
|
|
// of the search distribution, sigma·sqrt(max C_ii), has fallen to
|
|||
|
|
// Tolerance·max(1, ‖mean‖∞), or the objective spread over one
|
|||
|
|
// generation has fallen to Tolerance·max(1, |best|). The axis is the
|
|||
|
|
// diagonal of C, a proxy for the largest eigenvalue that avoids a
|
|||
|
|
// second decomposition; a run that stops on the spread criterion on a
|
|||
|
|
// genuinely flat landscape reports what it saw.
|
|||
|
|
type CMAESOptions struct {
|
|||
|
|
Sigma0 float64
|
|||
|
|
Generations int
|
|||
|
|
Tolerance float64
|
|||
|
|
Seed int64
|
|||
|
|
// AllowBudgetExit makes a run that exhausts Generations report its
|
|||
|
|
// best point instead of an error. The default is false, so a
|
|||
|
|
// budget stop is never mistaken for a converged answer; the flag
|
|||
|
|
// mirrors LBFGSOptions.AllowBudgetExit.
|
|||
|
|
AllowBudgetExit bool
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MinimiseCMAES returns the point and value of the best solution f the
|
|||
|
|
// strategy found. f receives candidate points as rank-1 arrays; a
|
|||
|
|
// non-finite value or an error is fatal for the run. There are no
|
|||
|
|
// bounds: CMA-ES is an unconstrained method, and the caller who needs
|
|||
|
|
// a box should reparametrise (or use the differential-evolution entry,
|
|||
|
|
// which clamps).
|
|||
|
|
//
|
|||
|
|
// Termination, budget and honesty: a run ends when the tolerance
|
|||
|
|
// criteria above are met (converged) or when the generation budget is
|
|||
|
|
// spent. Following the house budget policy, an exhausted budget is an
|
|||
|
|
// error naming the best value reached and the tolerance it fell short
|
|||
|
|
// of, never a silent answer; AllowBudgetExit is the documented escape
|
|||
|
|
// hatch.
|
|||
|
|
func MinimiseCMAES(f func(*core.Array) (float64, error), x0 *core.Array, opts CMAESOptions) (*core.Array, float64, error) {
|
|||
|
|
const name = "MinimiseCMAES"
|
|||
|
|
if x0.Dtype() == core.Complex {
|
|||
|
|
return nil, 0, base.Errf("%s: complex starting points are not supported", name)
|
|||
|
|
}
|
|||
|
|
n := x0.Len()
|
|||
|
|
if n == 0 {
|
|||
|
|
return nil, 0, base.Errf("%s: the starting point must have at least one element", name)
|
|||
|
|
}
|
|||
|
|
sigma := opts.Sigma0
|
|||
|
|
if sigma <= 0 {
|
|||
|
|
sigma = 0.3
|
|||
|
|
}
|
|||
|
|
generations := opts.Generations
|
|||
|
|
if generations <= 0 {
|
|||
|
|
generations = 500
|
|||
|
|
}
|
|||
|
|
tol := opts.Tolerance
|
|||
|
|
if tol <= 0 {
|
|||
|
|
tol = 1e-12
|
|||
|
|
}
|
|||
|
|
seed := opts.Seed
|
|||
|
|
if seed == 0 {
|
|||
|
|
seed = 42
|
|||
|
|
}
|
|||
|
|
g := core.NewGenerator(seed)
|
|||
|
|
|
|||
|
|
// The tutorial's default parameters, equations (48) to (53).
|
|||
|
|
lambda := 4 + int(3*math.Log(float64(n)))
|
|||
|
|
mu := lambda / 2
|
|||
|
|
weights := make([]float64, mu)
|
|||
|
|
wSum := 0.0
|
|||
|
|
for i := range mu {
|
|||
|
|
weights[i] = math.Log(float64(lambda)/2+0.5) - math.Log(float64(i+1))
|
|||
|
|
wSum += weights[i]
|
|||
|
|
}
|
|||
|
|
for i := range mu {
|
|||
|
|
weights[i] /= wSum
|
|||
|
|
}
|
|||
|
|
muEff := 0.0
|
|||
|
|
for _, w := range weights {
|
|||
|
|
muEff += w * w
|
|||
|
|
}
|
|||
|
|
muEff = 1 / muEff
|
|||
|
|
cSigma := (muEff + 2) / (float64(n) + muEff + 5)
|
|||
|
|
dSigma := 1 + 2*math.Max(0, math.Sqrt((muEff-1)/(float64(n)+1))-1) + cSigma
|
|||
|
|
cC := (4 + muEff/float64(n)) / (4 + float64(n) + 2*muEff/float64(n))
|
|||
|
|
c1 := 2 / ((float64(n)+1.3)*(float64(n)+1.3) + muEff)
|
|||
|
|
cMu := math.Min(1-c1, 2*(muEff-2+1/muEff)/((float64(n)+2)*(float64(n)+2)+muEff))
|
|||
|
|
chiN := math.Sqrt(float64(n)) * (1 - 1/(4*float64(n)) + 1/(21*float64(n)*float64(n)))
|
|||
|
|
|
|||
|
|
mean := cloneDense(x0)
|
|||
|
|
cov := make([]float64, n*n)
|
|||
|
|
for i := range n {
|
|||
|
|
cov[i*n+i] = 1
|
|||
|
|
}
|
|||
|
|
pathC := make([]float64, n)
|
|||
|
|
pathS := make([]float64, n)
|
|||
|
|
|
|||
|
|
eval := func(p []float64) (float64, error) {
|
|||
|
|
v, err := f(linalg.ArrayFromFloatsSafe(p, len(p)))
|
|||
|
|
if err != nil {
|
|||
|
|
return 0, base.Errf("%s: %w", name, err)
|
|||
|
|
}
|
|||
|
|
if math.IsNaN(v) || math.IsInf(v, 0) {
|
|||
|
|
return 0, base.Errf("%s: the objective is non-finite (%g)", name, v)
|
|||
|
|
}
|
|||
|
|
return v, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bestF, bestX := math.Inf(1), make([]float64, n)
|
|||
|
|
offX := make([]float64, lambda*n)
|
|||
|
|
offY := make([]float64, lambda*n)
|
|||
|
|
offF := make([]float64, lambda)
|
|||
|
|
order := make([]int, lambda)
|
|||
|
|
yW := make([]float64, n)
|
|||
|
|
yC := make([]float64, n)
|
|||
|
|
newCov := make([]float64, n*n)
|
|||
|
|
// Per-generation scratch, owned by the run: the sampling scale per
|
|||
|
|
// principal axis, the unit normal drawn per offspring, the
|
|||
|
|
// eigenvector projection rootInverse accumulates in, the rank-mu
|
|||
|
|
// outer-product accumulator with its weighted axis vector, and the
|
|||
|
|
// diagonalisation's own working set. Each is fully overwritten
|
|||
|
|
// before it is read.
|
|||
|
|
sd := make([]float64, n)
|
|||
|
|
z := make([]float64, n)
|
|||
|
|
yInv := make([]float64, n)
|
|||
|
|
rankMu := make([]float64, n*n)
|
|||
|
|
wy := make([]float64, n)
|
|||
|
|
var eig jacobiScratch
|
|||
|
|
|
|||
|
|
for gen := range generations {
|
|||
|
|
vals, vecs := eig.eigen(cov, n)
|
|||
|
|
// Sampling scale per principal axis, floored at zero: a
|
|||
|
|
// numerically degenerate axis contributes nothing rather than
|
|||
|
|
// a complex square root.
|
|||
|
|
for i := range n {
|
|||
|
|
sd[i] = math.Sqrt(math.Max(vals[i], 0))
|
|||
|
|
}
|
|||
|
|
worstF := math.Inf(-1)
|
|||
|
|
bestGen := math.Inf(1)
|
|||
|
|
for k := range lambda {
|
|||
|
|
// One unit normal per principal direction: the draw is
|
|||
|
|
// B·diag(sd)·z, whose covariance is exactly C. Sharing a
|
|||
|
|
// single scalar across the directions would sample a
|
|||
|
|
// diagonal distribution scaled by one fixed vector and
|
|||
|
|
// leave the adapted covariance unused.
|
|||
|
|
for i := range n {
|
|||
|
|
z[i] = g.NormalUnit()
|
|||
|
|
}
|
|||
|
|
cmaDraw(vecs, sd, z, offY[k*n:k*n+n])
|
|||
|
|
for i := range n {
|
|||
|
|
offX[k*n+i] = mean[i] + sigma*offY[k*n+i]
|
|||
|
|
}
|
|||
|
|
v, err := eval(offX[k*n : k*n+n])
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, 0, err
|
|||
|
|
}
|
|||
|
|
offF[k] = v
|
|||
|
|
worstF = math.Max(worstF, v)
|
|||
|
|
bestGen = math.Min(bestGen, v)
|
|||
|
|
}
|
|||
|
|
for k := range lambda {
|
|||
|
|
order[k] = k
|
|||
|
|
}
|
|||
|
|
slices.SortStableFunc(order, func(a, b int) int { return cmp.Compare(offF[a], offF[b]) })
|
|||
|
|
if offF[order[0]] < bestF {
|
|||
|
|
bestF = offF[order[0]]
|
|||
|
|
copy(bestX, offX[order[0]*n:(order[0]+1)*n])
|
|||
|
|
}
|
|||
|
|
clear(yW)
|
|||
|
|
for k := range mu {
|
|||
|
|
w := weights[k]
|
|||
|
|
for i := range n {
|
|||
|
|
yW[i] += w * offY[order[k]*n+i]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
for i := range n {
|
|||
|
|
mean[i] += sigma * yW[i]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Conjugate evolution path: C^{-1/2} y_w through the
|
|||
|
|
// eigendecomposition, then the sigma update from its length.
|
|||
|
|
rootInverse(yC, yW, vals, vecs, yInv, n)
|
|||
|
|
psNorm := 0.0
|
|||
|
|
for i := range n {
|
|||
|
|
pathS[i] = (1-cSigma)*pathS[i] + math.Sqrt(cSigma*(2-cSigma)*muEff)*yC[i]
|
|||
|
|
psNorm += pathS[i] * pathS[i]
|
|||
|
|
}
|
|||
|
|
psNorm = math.Sqrt(psNorm)
|
|||
|
|
sigma *= math.Exp((cSigma / dSigma) * (psNorm/chiN - 1))
|
|||
|
|
hs := 0.0
|
|||
|
|
if psNorm/math.Sqrt(1-math.Pow(1-cSigma, 2*float64(gen+1))) < (1.4+2/(float64(n)+1))*chiN {
|
|||
|
|
hs = 1
|
|||
|
|
}
|
|||
|
|
pcNormScale := math.Sqrt(cC * (2 - cC) * muEff)
|
|||
|
|
for i := range n {
|
|||
|
|
pathC[i] = (1-cC)*pathC[i] + hs*pcNormScale*yW[i]
|
|||
|
|
}
|
|||
|
|
// The rank-one and rank-mu updates; delta(hs) keeps the
|
|||
|
|
// covariance from growing along p_c across a stall of the
|
|||
|
|
// conjugate path. The rank-mu sum accumulates as contiguous
|
|||
|
|
// outer products, one per selected offspring walked in order:
|
|||
|
|
// every cell still sums (w·yᵢ)·yⱼ over ascending k, so the
|
|||
|
|
// bits are the per-cell walk's and the inner loop stays on
|
|||
|
|
// unit stride.
|
|||
|
|
clear(rankMu)
|
|||
|
|
for k := range mu {
|
|||
|
|
base := order[k] * n
|
|||
|
|
w := weights[k]
|
|||
|
|
for i := range n {
|
|||
|
|
wy[i] = w * offY[base+i]
|
|||
|
|
}
|
|||
|
|
for i := range n {
|
|||
|
|
wi := wy[i]
|
|||
|
|
row := rankMu[i*n : i*n+n]
|
|||
|
|
yk := offY[base : base+n]
|
|||
|
|
for j := range n {
|
|||
|
|
row[j] += wi * yk[j]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
factor := 1 + c1*(1-hs) - c1 - cMu
|
|||
|
|
for i := range n {
|
|||
|
|
ci := c1 * pathC[i]
|
|||
|
|
off := i * n
|
|||
|
|
for j := range n {
|
|||
|
|
newCov[off+j] = factor*cov[off+j] + ci*pathC[j] + cMu*rankMu[off+j]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
for i := range n {
|
|||
|
|
for j := i + 1; j < n; j++ {
|
|||
|
|
avg := (newCov[i*n+j] + newCov[j*n+i]) / 2
|
|||
|
|
newCov[i*n+j], newCov[j*n+i] = avg, avg
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
copy(cov, newCov)
|
|||
|
|
|
|||
|
|
axis := 0.0
|
|||
|
|
for i := range n {
|
|||
|
|
axis = math.Max(axis, sigma*math.Sqrt(math.Max(cov[i*n+i], 0)))
|
|||
|
|
}
|
|||
|
|
if math.IsNaN(axis) || sigma > 1e12 {
|
|||
|
|
return nil, 0, base.Errf("%s: the search diverged (sigma %g, largest axis %g) at f = %g", name, sigma, axis, bestF)
|
|||
|
|
}
|
|||
|
|
if axis <= tol*math.Max(1, maxAbs(mean)) || worstF-bestGen <= tol*math.Max(1, math.Abs(bestF)) {
|
|||
|
|
out, fv := packResult(bestX, bestF)
|
|||
|
|
return out, fv, nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !opts.AllowBudgetExit {
|
|||
|
|
return nil, 0, base.Errf("%s: the generation budget of %d ran out at f = %g, above the tolerance %g", name, generations, bestF, tol)
|
|||
|
|
}
|
|||
|
|
out, fv := packResult(bestX, bestF)
|
|||
|
|
return out, fv, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// rootInverse writes C^{-1/2} y into dst through the eigendecomposition
|
|||
|
|
// the caller already paid for: B·diag(1/sqrt(d))·Bᵀ·y with the
|
|||
|
|
// eigenvalues floored away from zero so a numerically flat direction
|
|||
|
|
// cannot divide by nothing. tmp is scratch of length at least n, fully
|
|||
|
|
// overwritten before it is read.
|
|||
|
|
func rootInverse(dst, y, vals, vecs, tmp []float64, n int) {
|
|||
|
|
scale := 0.0
|
|||
|
|
for i := range n {
|
|||
|
|
scale = math.Max(scale, vals[i])
|
|||
|
|
}
|
|||
|
|
floor := 1e-20 * math.Max(1, scale)
|
|||
|
|
for j := range n { // tmp = Bᵀ y
|
|||
|
|
s := 0.0
|
|||
|
|
for i := range n {
|
|||
|
|
s += vecs[j*n+i] * y[i]
|
|||
|
|
}
|
|||
|
|
tmp[j] = s / math.Sqrt(math.Max(vals[j], floor))
|
|||
|
|
}
|
|||
|
|
clear(dst)
|
|||
|
|
for j := range n { // dst = B tmp
|
|||
|
|
c := tmp[j]
|
|||
|
|
if c == 0 {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
for i := range n {
|
|||
|
|
dst[i] += vecs[j*n+i] * c
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// eigenPair is one eigenvalue with the column its eigenvector occupies
|
|||
|
|
// in the accumulator the diagonalisation carries.
|
|||
|
|
type eigenPair struct {
|
|||
|
|
val float64
|
|||
|
|
index int
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// jacobiScratch is one diagonalisation's working set: the cyclically
|
|||
|
|
// rotated copy of the matrix, the eigenvector accumulator, the
|
|||
|
|
// value/index pairs the descending sort walks and the two blocks handed
|
|||
|
|
// back. The driver keeps one instance for the whole run, so the
|
|||
|
|
// per-generation decomposition allocates nothing; every buffer is fully
|
|||
|
|
// overwritten before it is read.
|
|||
|
|
type jacobiScratch struct {
|
|||
|
|
work []float64
|
|||
|
|
acc []float64
|
|||
|
|
pairs []eigenPair
|
|||
|
|
vals []float64
|
|||
|
|
sorted []float64
|
|||
|
|
evecs []float64
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// jacobiEigen diagonalises the symmetric row-major n×n matrix into
|
|||
|
|
// freshly allocated blocks, the allocating entry over
|
|||
|
|
// (*jacobiScratch).eigen.
|
|||
|
|
func jacobiEigen(a []float64, n int) (vals, vecs []float64) {
|
|||
|
|
var s jacobiScratch
|
|||
|
|
return s.eigen(a, n)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// eigen diagonalises the symmetric row-major n×n matrix by cyclic
|
|||
|
|
// Jacobi rotations: the eigenvalues come back in descending order and
|
|||
|
|
// vecs[j*n+i] is component i of the eigenvector that belongs to
|
|||
|
|
// vals[j]. The sweeps stop once the off-diagonal mass has fallen to
|
|||
|
|
// the working precision of the matrix's own Frobenius norm. Both
|
|||
|
|
// returned blocks belong to the scratch and stay valid until its next
|
|||
|
|
// call.
|
|||
|
|
func (s *jacobiScratch) eigen(a []float64, n int) (vals, vecs []float64) {
|
|||
|
|
if cap(s.work) < n*n {
|
|||
|
|
s.work = make([]float64, n*n)
|
|||
|
|
}
|
|||
|
|
work := s.work[:n*n]
|
|||
|
|
copy(work, a)
|
|||
|
|
if cap(s.acc) < n*n {
|
|||
|
|
s.acc = make([]float64, n*n)
|
|||
|
|
}
|
|||
|
|
acc := s.acc[:n*n]
|
|||
|
|
clear(acc)
|
|||
|
|
for i := range n {
|
|||
|
|
acc[i*n+i] = 1
|
|||
|
|
}
|
|||
|
|
frob := 0.0
|
|||
|
|
for _, v := range work {
|
|||
|
|
frob += v * v
|
|||
|
|
}
|
|||
|
|
const maxSweeps = 60
|
|||
|
|
for range maxSweeps {
|
|||
|
|
off := 0.0
|
|||
|
|
for i := range n {
|
|||
|
|
for j := i + 1; j < n; j++ {
|
|||
|
|
off += work[i*n+j] * work[i*n+j]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if off <= 1e-30*math.Max(frob, 1) {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
for p := range n {
|
|||
|
|
for q := p + 1; q < n; q++ {
|
|||
|
|
apq := work[p*n+q]
|
|||
|
|
if apq == 0 {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
theta := (work[q*n+q] - work[p*n+p]) / (2 * apq)
|
|||
|
|
t := 1 / (math.Abs(theta) + math.Sqrt(theta*theta+1))
|
|||
|
|
if theta < 0 {
|
|||
|
|
t = -t
|
|||
|
|
}
|
|||
|
|
c := 1 / math.Sqrt(t*t+1)
|
|||
|
|
s := t * c
|
|||
|
|
for k := range n {
|
|||
|
|
akp, akq := work[k*n+p], work[k*n+q]
|
|||
|
|
work[k*n+p] = c*akp - s*akq
|
|||
|
|
work[k*n+q] = s*akp + c*akq
|
|||
|
|
}
|
|||
|
|
for k := range n {
|
|||
|
|
apk, aqk := work[p*n+k], work[q*n+k]
|
|||
|
|
work[p*n+k] = c*apk - s*aqk
|
|||
|
|
work[q*n+k] = s*apk + c*aqk
|
|||
|
|
}
|
|||
|
|
work[p*n+q], work[q*n+p] = 0, 0
|
|||
|
|
for k := range n {
|
|||
|
|
vkp, vkq := acc[k*n+p], acc[k*n+q]
|
|||
|
|
acc[k*n+p] = c*vkp - s*vkq
|
|||
|
|
acc[k*n+q] = s*vkp + c*vkq
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if cap(s.vals) < n {
|
|||
|
|
s.vals = make([]float64, n)
|
|||
|
|
}
|
|||
|
|
vals = s.vals[:n]
|
|||
|
|
for i := range n {
|
|||
|
|
vals[i] = work[i*n+i]
|
|||
|
|
}
|
|||
|
|
// Sort the pairs descending by value. The accumulator carries
|
|||
|
|
// eigenvector k in its column k, and the documented layout here is
|
|||
|
|
// row j for vector j, so the extraction transposes.
|
|||
|
|
if cap(s.pairs) < n {
|
|||
|
|
s.pairs = make([]eigenPair, n)
|
|||
|
|
}
|
|||
|
|
pairs := s.pairs[:n]
|
|||
|
|
for i := range n {
|
|||
|
|
pairs[i] = eigenPair{vals[i], i}
|
|||
|
|
}
|
|||
|
|
slices.SortFunc(pairs, func(a, b eigenPair) int { return cmp.Compare(b.val, a.val) })
|
|||
|
|
if cap(s.sorted) < n {
|
|||
|
|
s.sorted = make([]float64, n)
|
|||
|
|
}
|
|||
|
|
if cap(s.evecs) < n*n {
|
|||
|
|
s.evecs = make([]float64, n*n)
|
|||
|
|
}
|
|||
|
|
sortedVals := s.sorted[:n]
|
|||
|
|
sortedVecs := s.evecs[:n*n]
|
|||
|
|
for j, pr := range pairs {
|
|||
|
|
sortedVals[j] = pr.val
|
|||
|
|
for i := range n {
|
|||
|
|
sortedVecs[j*n+i] = acc[i*n+pr.index]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return sortedVals, sortedVecs
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SimulatedAnnealingOptions tunes MinimiseSimulatedAnnealing.
|
|||
|
|
// Steps ≤ 0 means 20000 proposals, Temperature0 ≤ 0 means 1,
|
|||
|
|
// CoolingRate ≤ 0 means 0.9995 (the geometric factor per proposal),
|
|||
|
|
// StepScale ≤ 0 means 0.1 (the relative Gaussian proposal scale),
|
|||
|
|
// Tolerance ≤ 0 means 1e-6, Seed 0 is replaced by 42 as in
|
|||
|
|
// MinimiseDifferentialEvolution.
|
|||
|
|
//
|
|||
|
|
// The schedule is the algorithm: the temperature runs from
|
|||
|
|
// Temperature0 down by the factor CoolingRate at every proposal, so
|
|||
|
|
// the chain freezes exponentially and the tail of the schedule is a
|
|||
|
|
// local polish. Tolerance is the convergence test on that tail: the
|
|||
|
|
// best value may not improve by more than Tolerance·max(1, |best|)
|
|||
|
|
// over the final quarter of the schedule. A run that is still
|
|||
|
|
// improving at the end of the schedule has not converged, and the
|
|||
|
|
// budget refusal says so with both figures, exactly as the local
|
|||
|
|
// solvers refuse an unfinished run; AllowBudgetExit is the escape
|
|||
|
|
// hatch that reports the best point anyway.
|
|||
|
|
type SimulatedAnnealingOptions struct {
|
|||
|
|
Steps int
|
|||
|
|
Temperature0 float64
|
|||
|
|
CoolingRate float64
|
|||
|
|
StepScale float64
|
|||
|
|
Seed int64
|
|||
|
|
Tolerance float64
|
|||
|
|
// AllowBudgetExit makes a run whose schedule ended while the best
|
|||
|
|
// value was still improving report its best point instead of an
|
|||
|
|
// error. The flag mirrors LBFGSOptions.AllowBudgetExit.
|
|||
|
|
AllowBudgetExit bool
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MinimiseSimulatedAnnealing returns the best point and value the
|
|||
|
|
// chain found: a Metropolis walk over the landscape with a Gaussian
|
|||
|
|
// proposal of the given relative scale (coordinate i moves by
|
|||
|
|
// StepScale·max(1, |x_i|) standard normals) and the geometric cooling
|
|||
|
|
// schedule above. f receives candidate points as rank-1 arrays; a
|
|||
|
|
// non-finite value or an error is fatal for the run. Simulated
|
|||
|
|
// annealing finds basins, not minima to full precision: run
|
|||
|
|
// MinimiseLBFGS from the returned point when a polished answer is
|
|||
|
|
// wanted, which is the standard composition.
|
|||
|
|
func MinimiseSimulatedAnnealing(f func(*core.Array) (float64, error), x0 *core.Array, opts SimulatedAnnealingOptions) (*core.Array, float64, error) {
|
|||
|
|
const name = "MinimiseSimulatedAnnealing"
|
|||
|
|
if x0.Dtype() == core.Complex {
|
|||
|
|
return nil, 0, base.Errf("%s: complex starting points are not supported", name)
|
|||
|
|
}
|
|||
|
|
n := x0.Len()
|
|||
|
|
if n == 0 {
|
|||
|
|
return nil, 0, base.Errf("%s: the starting point must have at least one element", name)
|
|||
|
|
}
|
|||
|
|
steps := opts.Steps
|
|||
|
|
if steps <= 0 {
|
|||
|
|
steps = 20000
|
|||
|
|
}
|
|||
|
|
t0 := opts.Temperature0
|
|||
|
|
if t0 <= 0 {
|
|||
|
|
t0 = 1
|
|||
|
|
}
|
|||
|
|
cooling := opts.CoolingRate
|
|||
|
|
if cooling > 1 {
|
|||
|
|
return nil, 0, base.Errf("%s: the cooling rate %g would heat the chain; want a factor in (0, 1]", name, cooling)
|
|||
|
|
}
|
|||
|
|
if cooling <= 0 {
|
|||
|
|
cooling = 0.9995
|
|||
|
|
}
|
|||
|
|
scale := opts.StepScale
|
|||
|
|
if scale <= 0 {
|
|||
|
|
scale = 0.1
|
|||
|
|
}
|
|||
|
|
tol := opts.Tolerance
|
|||
|
|
if tol <= 0 {
|
|||
|
|
tol = 1e-6
|
|||
|
|
}
|
|||
|
|
seed := opts.Seed
|
|||
|
|
if seed == 0 {
|
|||
|
|
seed = 42
|
|||
|
|
}
|
|||
|
|
g := core.NewGenerator(seed)
|
|||
|
|
|
|||
|
|
x := cloneDense(x0)
|
|||
|
|
cur, err := f(linalg.ArrayFromFloatsSafe(x, n))
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, 0, base.Errf("%s: %w", name, err)
|
|||
|
|
}
|
|||
|
|
if math.IsNaN(cur) || math.IsInf(cur, 0) {
|
|||
|
|
return nil, 0, base.Errf("%s: the objective is non-finite (%g) at the start", name, cur)
|
|||
|
|
}
|
|||
|
|
bestF, bestX := cur, slices.Clone(x)
|
|||
|
|
proposal := make([]float64, n)
|
|||
|
|
quarterF := math.Inf(1)
|
|||
|
|
quarterAt := 3 * steps / 4
|
|||
|
|
|
|||
|
|
for k := range steps {
|
|||
|
|
temperature := t0 * math.Pow(cooling, float64(k))
|
|||
|
|
for i := range n {
|
|||
|
|
proposal[i] = x[i] + scale*math.Max(1, math.Abs(x[i]))*g.NormalUnit()
|
|||
|
|
}
|
|||
|
|
fv, ferr := f(linalg.ArrayFromFloatsSafe(proposal, n))
|
|||
|
|
if ferr != nil {
|
|||
|
|
return nil, 0, base.Errf("%s: %w", name, ferr)
|
|||
|
|
}
|
|||
|
|
if math.IsNaN(fv) || math.IsInf(fv, 0) {
|
|||
|
|
return nil, 0, base.Errf("%s: the objective is non-finite (%g) at proposal %d", name, fv, k+1)
|
|||
|
|
}
|
|||
|
|
delta := fv - cur
|
|||
|
|
if delta <= 0 || g.Unit() < math.Exp(-delta/temperature) {
|
|||
|
|
copy(x, proposal)
|
|||
|
|
cur = fv
|
|||
|
|
}
|
|||
|
|
if cur < bestF {
|
|||
|
|
bestF = cur
|
|||
|
|
copy(bestX, x)
|
|||
|
|
}
|
|||
|
|
if k == quarterAt {
|
|||
|
|
quarterF = bestF
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// The schedule ended: convergence is the frozen tail, judged by
|
|||
|
|
// the improvement the final quarter still bought.
|
|||
|
|
if quarterF-bestF > tol*math.Max(1, math.Abs(bestF)) && !opts.AllowBudgetExit {
|
|||
|
|
return nil, 0, base.Errf("%s: the schedule of %d steps ended with the best value still improving (%g to %g); raise Steps or set AllowBudgetExit",
|
|||
|
|
name, steps, quarterF, bestF)
|
|||
|
|
}
|
|||
|
|
out, fv := packResult(bestX, bestF)
|
|||
|
|
return out, fv, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// cmaDraw fills y with B·diag(sd)·z: one unit normal per principal
|
|||
|
|
// direction transformed into the covariance's own axes, the draw the
|
|||
|
|
// tutorial's sampling equation defines, whose covariance is the
|
|||
|
|
// adapted C itself. The walk goes one principal direction at a time so
|
|||
|
|
// the eigenvector rows are read on unit stride; the per-element
|
|||
|
|
// grouping (v·sdⱼ)·zⱼ and the ascending j order are the i-outer walk's,
|
|||
|
|
// so the draw is bit for bit the same.
|
|||
|
|
func cmaDraw(vecs, sd, z, y []float64) {
|
|||
|
|
clear(y)
|
|||
|
|
n := len(y)
|
|||
|
|
for j := range z {
|
|||
|
|
sdJ, zJ := sd[j], z[j]
|
|||
|
|
row := vecs[j*n : j*n+n]
|
|||
|
|
for i := range n {
|
|||
|
|
y[i] += row[i] * sdJ * zJ
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|