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

379 lines
13 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"
"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))
}
}