Files
tensor/optim/global2_test.go
T

379 lines
13 KiB
Go
Raw Normal View History

2026-09-03 10:00:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package optim
import (
"math"
"strings"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// rotEllipsoid builds the test landscape: f(x) = Σ i·(Rx)ᵢ² for a
// fixed orthonormal R (the Householder reflection through the
// normalised all-ones vector), a rotated ellipsoid with condition 6ⁿ
// along axes the coordinate system does not see. It returns the
// objective and the apply function so tests can check coordinates.
func rotEllipsoid(t *testing.T, n int) (func(*core.Array) (float64, error), func(x []float64) []float64) {
t.Helper()
v := make([]float64, n)
for i := range n {
v[i] = float64(i + 1)
}
norm := 0.0
for _, a := range v {
norm += a * a
}
norm = math.Sqrt(norm)
apply := func(x []float64) []float64 {
dot := 0.0
for i := range n {
dot += v[i] * x[i]
}
dot *= 2 / (norm * norm)
rx := make([]float64, n)
for i := range n {
rx[i] = x[i] - dot*v[i]
}
return rx
}
f := func(a *core.Array) (float64, error) {
total := 0.0
for i, rx := range apply(floatsOf(a)) {
total += float64(i+1) * rx * rx
}
return total, nil
}
return f, apply
}
// TestJacobiEigen pins the eigendecomposition the CMA-ES loop leans
// on: a known symmetric matrix with irrational eigenvalues, verified
// through the reconstruction C = B·D·Bᵀ and the orthogonality of B.
func TestJacobiEigen(t *testing.T) {
// [[2, 1], [1, 3]]: eigenvalues (5 ± √5)/2.
vals, vecs := jacobiEigen([]float64{2, 1, 1, 3}, 2)
want1 := (5 + math.Sqrt(5)) / 2
want2 := (5 - math.Sqrt(5)) / 2
if math.Abs(vals[0]-want1) > 1e-12 || math.Abs(vals[1]-want2) > 1e-12 {
t.Fatalf("eigenvalues = (%g, %g), want (%g, %g)", vals[0], vals[1], want1, want2)
}
// Orthonormality: B·Bᵀ = I.
for i := range 2 {
for j := range 2 {
s := 0.0
for k := range 2 {
s += vecs[k*2+i] * vecs[k*2+j]
}
want := 0.0
if i == j {
want = 1
}
if math.Abs(s-want) > 1e-12 {
t.Fatalf("B·Bᵀ[%d][%d] = %g, want %g", i, j, s, want)
}
}
}
// Reconstruction: Σ_j vals[j]·vec_j·vec_jᵀ = C.
for i := range 2 {
for j := range 2 {
s := 0.0
for k := range 2 {
s += vals[k] * vecs[k*2+i] * vecs[k*2+j]
}
want := 1.0
if i == j {
want = float64(2 + i)
}
if math.Abs(s-want) > 1e-12 {
t.Fatalf("reconstruction[%d][%d] = %g, want %g", i, j, s, want)
}
}
}
}
// TestMinimiseCMAESRotatedEllipsoid pins the strategy on a rotated
// ellipsoid, the landscape its covariance adaptation exists for: seed
// 7, sigma0 0.5 and a budget of 300 generations carry the run to the
// origin within 1e−6 per coordinate and 1e−12 in value.
func TestMinimiseCMAESRotatedEllipsoid(t *testing.T) {
f, _ := rotEllipsoid(t, 6)
start := mustFloats(t, []float64{1, -1, 0.5, 2, 0, -0.5})
x, fv, err := MinimiseCMAES(f, start, CMAESOptions{Seed: 7, Sigma0: 0.5, Generations: 300})
if err != nil {
t.Fatalf("MinimiseCMAES: %v", err)
}
if fv > 1e-12 {
t.Fatalf("value = %.3e, want <= 1e-12", fv)
}
for i := range 6 {
if math.Abs(x.FloatAt(i)) > 1e-5 {
t.Fatalf("x[%d] = %.3e, want within 1e-5 of the origin", i, x.FloatAt(i))
}
}
}
// TestMinimiseCMAESDeterministic pins reproducibility: two runs on one
// seed walk the same landscape with the same draws and must return
// bit-identical trajectories.
func TestMinimiseCMAESDeterministic(t *testing.T) {
f, _ := rotEllipsoid(t, 4)
start := mustFloats(t, []float64{0.5, -0.5, 1, -1})
// Bit-identical trajectories under one seed: 80 generations do not
// exhaust to convergence, so the runs use the escape hatch and the
// pin is on the trajectories themselves.
xa, fa, errA := MinimiseCMAES(f, start, CMAESOptions{Seed: 7, Sigma0: 0.5, Generations: 80, AllowBudgetExit: true})
xb, fb, errB := MinimiseCMAES(f, start, CMAESOptions{Seed: 7, Sigma0: 0.5, Generations: 80, AllowBudgetExit: true})
if errA != nil || errB != nil {
t.Fatalf("MinimiseCMAES: %v, %v", errA, errB)
}
if math.Float64bits(fa) != math.Float64bits(fb) {
t.Fatalf("values differ across identical runs: %.20g vs %.20g", fa, fb)
}
for i := range 4 {
if math.Float64bits(xa.FloatAt(i)) != math.Float64bits(xb.FloatAt(i)) {
t.Fatalf("x[%d] differs across identical runs", i)
}
}
}
// TestMinimiseCMAESBudgetAndGates pins the honest budget refusal, the
// escape hatch, the divergence refusal on an unbounded-below
// objective, and the input and objective gates.
func TestMinimiseCMAESBudgetAndGates(t *testing.T) {
f, _ := rotEllipsoid(t, 4)
start := mustFloats(t, []float64{0.5, -0.5, 1, -1})
// Two generations cannot collapse the distribution: the budget
// stop is refused with the evidence, not reported as an answer.
_, _, err := MinimiseCMAES(f, start, CMAESOptions{Seed: 7, Sigma0: 0.5, Generations: 2})
if err == nil || !strings.Contains(err.Error(), "budget") {
t.Fatalf("error = %v, want the budget refusal", err)
}
// The escape hatch returns the best point with no error.
xb, _, err := MinimiseCMAES(f, start, CMAESOptions{Seed: 7, Sigma0: 0.5, Generations: 2, AllowBudgetExit: true})
if err != nil || xb == nil {
t.Fatalf("AllowBudgetExit: err = %v, x = %v", err, xb)
}
// An objective unbounded below drives sigma past the guard.
_, _, err = MinimiseCMAES(func(a *core.Array) (float64, error) {
s := 0.0
for i := range a.Len() {
s += a.FloatAt(i) * a.FloatAt(i)
}
return -math.Sqrt(s), nil
}, start, CMAESOptions{Seed: 7, Sigma0: 0.5, Generations: 200})
if err == nil || !strings.Contains(err.Error(), "diverged") {
t.Fatalf("error = %v, want the divergence refusal", err)
}
// Empty and complex starts are refused.
if _, _, err := MinimiseCMAES(f, core.New(core.Float, 0), CMAESOptions{}); err == nil {
t.Fatal("an empty starting point was accepted")
}
if _, _, err := MinimiseCMAES(f, mustComplexPoint(t), CMAESOptions{}); err == nil {
t.Fatal("a complex starting point was accepted")
}
// A non-finite objective is fatal.
if _, _, err := MinimiseCMAES(func(*core.Array) (float64, error) { return math.NaN(), nil },
start, CMAESOptions{Seed: 7, Generations: 5}); err == nil {
t.Fatal("a NaN objective was accepted")
}
// An objective's own error propagates.
if _, _, err := MinimiseCMAES(func(*core.Array) (float64, error) {
return 0, base.Errf("the model exploded")
}, start, CMAESOptions{Seed: 7, Generations: 5}); err == nil {
t.Fatal("the objective's error did not propagate")
}
// The defaults carry the run when the caller passes nothing but
// the escape hatch: sigma0 0.3, 500 generations, tolerance
// 1e-12 and seed 42.
_, _, err = MinimiseCMAES(f, start, CMAESOptions{AllowBudgetExit: true})
if err != nil {
t.Fatalf("MinimiseCMAES with defaults: %v", err)
}
}
// TestMinimiseAnnealRotatedEllipsoid pins simulated annealing on the
// same rotated ellipsoid at basin precision: 30000 proposals reach the
// origin's basin, which for this landscape means every coordinate
// within 0.5 and a value below 6.
func TestMinimiseAnnealRotatedEllipsoid(t *testing.T) {
f, _ := rotEllipsoid(t, 6)
start := mustFloats(t, []float64{1, -1, 0.5, 2, 0, -0.5})
x, fv, err := MinimiseSimulatedAnnealing(f, start, SimulatedAnnealingOptions{Seed: 7, Steps: 30000})
if err != nil {
t.Fatalf("MinimiseSimulatedAnnealing: %v", err)
}
if fv > 6 {
t.Fatalf("value = %.3e, want <= 6 (the basin of the origin)", fv)
}
for i := range 6 {
if math.Abs(x.FloatAt(i)) > 0.5 {
t.Fatalf("x[%d] = %.3e, want within the unit basin", i, x.FloatAt(i))
}
}
}
// TestMinimiseAnnealDeterministicAndBudget pins reproducibility, the
// budget refusal of a chain that is still improving, the escape hatch
// and the input gates.
func TestMinimiseAnnealDeterministicAndBudget(t *testing.T) {
f, _ := rotEllipsoid(t, 4)
start := mustFloats(t, []float64{0.5, -0.5, 1, -1})
// Bit-identical trajectories under one seed: 500 proposals end
// mid-polish, so the runs use the escape hatch and the pin is on
// the trajectories themselves.
xa, fa, errA := MinimiseSimulatedAnnealing(f, start, SimulatedAnnealingOptions{Seed: 7, Steps: 500, AllowBudgetExit: true})
xb, fb, errB := MinimiseSimulatedAnnealing(f, start, SimulatedAnnealingOptions{Seed: 7, Steps: 500, AllowBudgetExit: true})
if errA != nil || errB != nil {
t.Fatalf("MinimiseSimulatedAnnealing: %v, %v", errA, errB)
}
if math.Float64bits(fa) != math.Float64bits(fb) {
t.Fatalf("values differ across identical runs: %.20g vs %.20g", fa, fb)
}
for i := range 4 {
if math.Float64bits(xa.FloatAt(i)) != math.Float64bits(xb.FloatAt(i)) {
t.Fatalf("x[%d] differs across identical runs", i)
}
}
// A linear objective keeps producing new bests to the last
// proposal: the schedule ends unfinished and is refused.
line := func(a *core.Array) (float64, error) {
s := 0.0
for i := range a.Len() {
s -= a.FloatAt(i)
}
return s, nil
}
_, _, err := MinimiseSimulatedAnnealing(line, start, SimulatedAnnealingOptions{Seed: 7, Steps: 2000, Tolerance: 1e-300})
if err == nil || !strings.Contains(err.Error(), "still improving") {
t.Fatalf("error = %v, want the unfinished-schedule refusal", err)
}
// The escape hatch reports the best point anyway.
xbest, fv, err := MinimiseSimulatedAnnealing(line, start, SimulatedAnnealingOptions{Seed: 7, Steps: 2000, Tolerance: 1e-300, AllowBudgetExit: true})
if err != nil || xbest == nil {
t.Fatalf("AllowBudgetExit: err = %v, x = %v", err, xbest)
}
if fv >= 0 {
t.Fatalf("value = %.3e, want the linear objective's negative value", fv)
}
// Empty and complex starts are refused.
if _, _, err := MinimiseSimulatedAnnealing(f, core.New(core.Float, 0), SimulatedAnnealingOptions{}); err == nil {
t.Fatal("an empty starting point was accepted")
}
if _, _, err := MinimiseSimulatedAnnealing(f, mustComplexPoint(t), SimulatedAnnealingOptions{}); err == nil {
t.Fatal("a complex starting point was accepted")
}
// A non-finite objective at the start is refused.
if _, _, err := MinimiseSimulatedAnnealing(func(*core.Array) (float64, error) { return math.Inf(-1), nil },
start, SimulatedAnnealingOptions{}); err == nil {
t.Fatal("a non-finite start value was accepted")
}
// An objective's own error propagates.
if _, _, err := MinimiseSimulatedAnnealing(func(*core.Array) (float64, error) {
return 0, base.Errf("the model exploded")
}, start, SimulatedAnnealingOptions{}); err == nil {
t.Fatal("the objective's error did not propagate")
}
// A proposal that wanders where the objective is undefined is
// fatal, and so is one that errors: the chain climbs a linear
// slope until it crosses the model's domain.
climb := func(limit float64, verdict func() (float64, error)) func(*core.Array) (float64, error) {
return func(a *core.Array) (float64, error) {
s := 0.0
for i := range a.Len() {
s -= a.FloatAt(i)
}
if s < limit {
return verdict()
}
return s, nil
}
}
if _, _, err := MinimiseSimulatedAnnealing(climb(-30, func() (float64, error) { return math.NaN(), nil }),
start, SimulatedAnnealingOptions{Seed: 7, Steps: 4000}); err == nil {
t.Fatal("a NaN proposal value was accepted")
}
if _, _, err := MinimiseSimulatedAnnealing(climb(-30, func() (float64, error) {
return 0, base.Errf("the proposal exploded")
}), start, SimulatedAnnealingOptions{Seed: 7, Steps: 4000}); err == nil {
t.Fatal("the proposal's error did not propagate")
}
}
// TestCMADrawSamplesTheAdaptedCovariance pins the sampling equation:
// the candidates are B·diag(sd)·z with one unit normal per principal
// direction, so the empirical covariance of many draws reproduces the
// adapted C even after a rotation that leaves no axis aligned. A
// shared scalar across the directions sampled a diagonal distribution
// instead and failed this probe loudly.
func TestCMADrawSamplesTheAdaptedCovariance(t *testing.T) {
n := 4
// C = Q·D·Qᵀ for a Householder reflection through (1, 2, 3, 4) and
// a spread of eigenvalues, so no axis survives the rotation.
q := make([]float64, n*n)
{
v := []float64{1, 2, 3, 4}
norm := 0.0
for _, x := range v {
norm += x * x
}
norm = math.Sqrt(norm)
for i := range n {
for j := range n {
h := 0.0
if i == j {
h = 1
}
q[i*n+j] = h - 2*v[i]*v[j]/(norm*norm)
}
}
}
eigen := []float64{4, 1, 0.5, 0.25}
sd := make([]float64, n)
for i := range n {
sd[i] = math.Sqrt(eigen[i])
}
c := make([]float64, n*n)
for i := range n {
for j := range n {
for k := range n {
c[i*n+j] += q[k*n+i] * eigen[k] * q[k*n+j]
}
}
}
g := core.NewGenerator(9)
draws := 300000
z := make([]float64, n)
var y []float64
sum := make([]float64, n)
cov := make([]float64, n*n)
for range draws {
for i := range n {
z[i] = g.NormalUnit()
}
y = make([]float64, n)
cmaDraw(q, sd, z, y)
for i := range n {
sum[i] += y[i]
for j := range n {
cov[i*n+j] += y[i] * y[j]
}
}
}
froC, froErr := 0.0, 0.0
for i := range n {
for j := range n {
cov[i*n+j] = cov[i*n+j]/float64(draws) - sum[i]*sum[j]/(float64(draws)*float64(draws))
d := cov[i*n+j] - c[i*n+j]
froErr += d * d
froC += c[i*n+j] * c[i*n+j]
}
}
if math.Sqrt(froErr/froC) > 0.02 {
t.Fatalf("empirical covariance off the adapted C by %.3g relative Frobenius", math.Sqrt(froErr/froC))
}
}