// Copyright (c) 2026 Petr Balvín (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 } } }