Skip to main content

forge_core/algo/
cmaes.rs

1//! CMA-ES — Covariance Matrix Adaptation Evolution Strategy
2//! (Hansen & Ostermeier 2001; parameters per Hansen's tutorial, 2016).
3//!
4//! The reference black-box optimizer for hard continuous problems: it samples a
5//! multivariate normal, then adapts its mean, step size, and full covariance
6//! matrix to the local landscape — learning variable scales and correlations,
7//! which makes it excel on ill-conditioned, non-separable surfaces (e.g.
8//! Rosenbrock) where coordinate-wise methods crawl.
9//!
10//! The search runs in a **normalized `[0, 1]^n` space** (each variable mapped
11//! from its bounds), so a single scalar `sigma0` is meaningful regardless of how
12//! the box is scaled per dimension; candidates are clamped into the box for
13//! evaluation. The covariance is diagonalized each generation with a Jacobi
14//! eigensolver (no external linear-algebra dependency), keeping the run
15//! deterministic for a given seed.
16
17// Index-based loops are clearer than iterator chains for the matrix algebra and
18// Jacobi rotations in this module.
19#![allow(clippy::needless_range_loop)]
20
21use super::Optimizer;
22use crate::problem::Problem;
23use crate::rng::Rng;
24use crate::solution::{Report, Solution, StopReason};
25use crate::termination::Termination;
26
27/// CMA-ES configuration.
28#[derive(Debug, Clone, Copy)]
29pub struct CmaEs {
30    /// Population size `λ`; `None` uses the default `4 + ⌊3 ln n⌋`.
31    pub population: Option<usize>,
32    /// Initial step size in the normalized `[0, 1]` space (the box is rescaled
33    /// per dimension, so `0.3` ≈ covering a third of each variable's range).
34    pub sigma0: f64,
35    /// RNG seed; same seed + same problem + same budget ⇒ same result.
36    pub seed: u64,
37}
38
39impl Default for CmaEs {
40    fn default() -> Self {
41        CmaEs {
42            population: None,
43            sigma0: 0.3,
44            seed: 42,
45        }
46    }
47}
48
49impl Optimizer for CmaEs {
50    fn with_seed(&self, seed: u64) -> Self {
51        CmaEs { seed, ..*self }
52    }
53
54    fn optimize(&self, problem: &dyn Problem, term: &Termination) -> Report {
55        crate::problem::validate(problem).unwrap_or_else(|e| panic!("CmaEs: invalid problem: {e}"));
56        let run = cma_run(problem, term, self.seed, self.population, self.sigma0, 0);
57        let stop = term.reason(run.evaluations, run.best.value).unwrap_or(
58            // The loop only exits early when an internal criterion fired.
59            StopReason::Converged,
60        );
61        Report {
62            solution: run.best,
63            stop,
64            evaluations: run.evaluations,
65        }
66    }
67}
68
69/// Restart regime for [`RestartCmaEs`].
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71#[non_exhaustive]
72pub enum Restart {
73    /// IPOP (Auger & Hansen 2005): each restart doubles the population.
74    Ipop,
75    /// BIPOP (Hansen 2009): interlaces the IPOP regime with a small-population
76    /// regime (random λ between the default and half the large λ, randomly
77    /// reduced initial step size), giving both regimes similar evaluation
78    /// budgets. The BBOB-2009 reference multimodal strategy.
79    Bipop,
80}
81
82/// CMA-ES with restarts — the accepted, cheap way to make CMA-ES competitive
83/// on multimodal functions (IPOP: Auger & Hansen, CEC 2005; BIPOP: Hansen,
84/// GECCO/BBOB 2009). Each inner run stops on the standard internal criteria
85/// (TolFunHist / TolX / ConditionCov / σ collapse) and the next run restarts
86/// with a fresh sub-stream seed (`mix_seed(seed, run_index)`), so the whole
87/// procedure stays deterministic for a given seed.
88///
89/// The global [`Termination`] budget/target bounds the *total* evaluations
90/// across all runs; small-regime BIPOP runs are additionally capped at half
91/// the evaluations of the latest large run, per the reference description.
92#[derive(Debug, Clone, Copy)]
93pub struct RestartCmaEs {
94    /// Restart regime.
95    pub strategy: Restart,
96    /// Initial step size for large-regime runs (normalized [0, 1] space).
97    pub sigma0: f64,
98    /// Safety cap on the number of restarts (the budget usually binds first).
99    pub max_restarts: usize,
100    /// RNG seed; same seed + same problem + same budget ⇒ same result.
101    pub seed: u64,
102}
103
104impl Default for RestartCmaEs {
105    fn default() -> Self {
106        RestartCmaEs {
107            strategy: Restart::Bipop,
108            sigma0: 0.3,
109            max_restarts: 100,
110            seed: 42,
111        }
112    }
113}
114
115impl RestartCmaEs {
116    /// IPOP configuration with defaults.
117    pub fn ipop() -> Self {
118        RestartCmaEs {
119            strategy: Restart::Ipop,
120            ..RestartCmaEs::default()
121        }
122    }
123
124    /// BIPOP configuration with defaults.
125    pub fn bipop() -> Self {
126        RestartCmaEs::default()
127    }
128}
129
130impl Optimizer for RestartCmaEs {
131    fn with_seed(&self, seed: u64) -> Self {
132        RestartCmaEs { seed, ..*self }
133    }
134
135    fn optimize(&self, problem: &dyn Problem, term: &Termination) -> Report {
136        crate::problem::validate(problem)
137            .unwrap_or_else(|e| panic!("RestartCmaEs: invalid problem: {e}"));
138        let n = problem.bounds().len();
139        let lambda_def = (4 + (3.0 * (n as f64).ln()) as usize).max(4);
140
141        // Control stream for BIPOP's random small-regime draws, disjoint from
142        // every inner run's stream (which use mix_seed(seed, run_index)).
143        let mut ctl = Rng::split(self.seed, u64::MAX);
144
145        let mut best = Solution {
146            x: vec![0.0; n],
147            value: f64::INFINITY,
148        };
149        let mut evaluations = 0usize;
150        let mut lambda_large = lambda_def;
151        let mut budget_large = 0usize;
152        let mut budget_small = 0usize;
153        let mut last_large_run = 0usize;
154        let mut any_converged = false;
155
156        for run_idx in 0..self.max_restarts.max(1) as u64 {
157            if term.reason(evaluations, best.value).is_some() {
158                break;
159            }
160            // Pick the regime. The first run is a large-regime run at the
161            // default λ; afterwards IPOP always doubles, while BIPOP gives
162            // the turn to whichever regime has spent less budget.
163            let (lambda, sigma0, cap, is_large) = if run_idx == 0 {
164                (lambda_def, self.sigma0, None, true)
165            } else {
166                match self.strategy {
167                    Restart::Ipop => {
168                        lambda_large *= 2;
169                        (lambda_large, self.sigma0, None, true)
170                    }
171                    Restart::Bipop => {
172                        if budget_large <= budget_small {
173                            lambda_large *= 2;
174                            (lambda_large, self.sigma0, None, true)
175                        } else {
176                            // λ_s = ⌊λ_def · (λ_large / (2 λ_def))^(u₁²)⌋,
177                            // σ_s = σ0 · 10^(−2 u₂)  (Hansen 2009).
178                            let u1 = ctl.uniform();
179                            let u2 = ctl.uniform();
180                            let ratio = lambda_large as f64 / (2.0 * lambda_def as f64);
181                            let lam = ((lambda_def as f64) * ratio.max(1.0).powf(u1 * u1)) as usize;
182                            let sig = self.sigma0 * 10f64.powf(-2.0 * u2);
183                            // Small runs get at most half the latest large
184                            // run's evaluations.
185                            (
186                                lam.max(4),
187                                sig,
188                                Some((last_large_run / 2).max(lam.max(4))),
189                                false,
190                            )
191                        }
192                    }
193                }
194            };
195
196            let inner_term = Termination {
197                max_evaluations: match cap {
198                    Some(c) => (evaluations + c).min(term.max_evaluations),
199                    None => term.max_evaluations,
200                },
201                target: term.target,
202            };
203            let out = cma_run(
204                problem,
205                &inner_term,
206                crate::rng::mix_seed(self.seed, run_idx),
207                Some(lambda),
208                sigma0,
209                evaluations,
210            );
211            let used = out.evaluations - evaluations;
212            evaluations = out.evaluations;
213            if is_large {
214                budget_large += used;
215                last_large_run = used;
216            } else {
217                budget_small += used;
218            }
219            if out.best.value < best.value {
220                best = out.best;
221            }
222            any_converged |= out.converged;
223            // A run stopped by the small-regime cap counts as restartable;
224            // only a global budget/target stop ends the procedure.
225            if !out.converged && term.reason(evaluations, best.value).is_some() {
226                break;
227            }
228        }
229
230        let stop = term
231            .reason(evaluations, best.value)
232            .unwrap_or(if any_converged {
233                StopReason::Converged
234            } else {
235                StopReason::BudgetExhausted
236            });
237        Report {
238            solution: best,
239            stop,
240            evaluations,
241        }
242    }
243}
244
245/// Outcome of one CMA-ES run (restart wrappers merge several of these).
246pub(crate) struct CmaRunOutcome {
247    pub best: Solution,
248    /// Global evaluation count after the run (continues from `start_evals`).
249    pub evaluations: usize,
250    /// True when an internal convergence criterion stopped the run (budget
251    /// remained) — the restart trigger.
252    pub converged: bool,
253}
254
255/// One CMA-ES run counting evaluations from `start_evals` against `term`.
256///
257/// Stops on the global budget/target, or on the standard internal criteria
258/// (Hansen's defaults, simplified): **TolFunHist** (range of the
259/// best-of-generation history over `10 + ⌈30n/λ⌉` generations below `1e-12`),
260/// **TolX** (`σ·p_c` and `σ·√C_kk` all below `1e-12·σ0`), **ConditionCov**
261/// (condition number of `C` above `1e14`), and σ collapse/divergence.
262pub(crate) fn cma_run(
263    problem: &dyn Problem,
264    term: &Termination,
265    seed: u64,
266    population: Option<usize>,
267    sigma0: f64,
268    start_evals: usize,
269) -> CmaRunOutcome {
270    let bounds = problem.bounds();
271    let n = bounds.len();
272    let mut rng = Rng::new(seed);
273
274    // Strategy parameters (Hansen tutorial).
275    let lambda = population
276        .unwrap_or(4 + (3.0 * (n as f64).ln()) as usize)
277        .max(4);
278    let mu = lambda / 2;
279    // Recombination weights (positive, log-decreasing), normalized to 1.
280    // Canonical form: wᵢ' = ln((λ+1)/2) − ln(i+1)  (Hansen tutorial eq. 49;
281    // identical to ln(μ+½) only for even λ).
282    let raw: Vec<f64> = (0..mu)
283        .map(|i| ((lambda as f64 + 1.0) / 2.0).ln() - ((i + 1) as f64).ln())
284        .collect();
285    let wsum: f64 = raw.iter().sum();
286    let w: Vec<f64> = raw.iter().map(|&v| v / wsum).collect();
287    let mu_eff = 1.0 / w.iter().map(|&v| v * v).sum::<f64>();
288
289    let nf = n as f64;
290    let c_sigma = (mu_eff + 2.0) / (nf + mu_eff + 5.0);
291    let d_sigma = 1.0 + 2.0 * (((mu_eff - 1.0) / (nf + 1.0)).sqrt() - 1.0).max(0.0) + c_sigma;
292    let c_c = (4.0 + mu_eff / nf) / (nf + 4.0 + 2.0 * mu_eff / nf);
293    let c_1 = 2.0 / ((nf + 1.3).powi(2) + mu_eff);
294    let c_mu = (1.0 - c_1).min(2.0 * (mu_eff - 2.0 + 1.0 / mu_eff) / ((nf + 2.0).powi(2) + mu_eff));
295    // Expected length of an N(0, I) vector.
296    let e_n = nf.sqrt() * (1.0 - 1.0 / (4.0 * nf) + 1.0 / (21.0 * nf * nf));
297
298    // State, in normalized [0, 1] space.
299    let mut mean = vec![0.5; n];
300    let mut sigma = sigma0;
301    let mut cov = identity(n);
302    let mut p_sigma = vec![0.0; n];
303    let mut p_c = vec![0.0; n];
304    let mut generation = 0i32;
305
306    // Internal convergence bookkeeping.
307    let hist_len = 10 + (30.0 * nf / lambda as f64).ceil() as usize;
308    let mut hist: Vec<f64> = Vec::with_capacity(hist_len + 1);
309    let tolx = 1e-12 * sigma0;
310    let mut converged = false;
311
312    let mut best = Solution {
313        x: denormalize(&mean, bounds),
314        value: f64::INFINITY,
315    };
316    let mut evaluations = start_evals;
317
318    'outer: while term.reason(evaluations, best.value).is_none() {
319        // Diagonalize C = B diag(d²) Bᵀ; D = sqrt of eigenvalues. The
320        // eigenvalue floor is relative to the largest eigenvalue: an
321        // absolute floor could inject huge 1/d factors into C^{-1/2}.
322        let (eigvals, b) = jacobi_eigen(&cov);
323        let max_eig = eigvals.iter().cloned().fold(f64::MIN_POSITIVE, f64::max);
324        let d: Vec<f64> = eigvals
325            .iter()
326            .map(|&v| v.max(max_eig * 1e-14).sqrt())
327            .collect();
328
329        // Sample λ offspring; store their normalized step y. Candidates are
330        // clamped into the box, and the *clamped* step (u_c − m)/σ is what
331        // feeds every adaptation update, so the fitness of the repaired
332        // point is never attributed to a step the search did not take.
333        let mut pop: Vec<(f64, Vec<f64>)> = Vec::with_capacity(lambda);
334        for _ in 0..lambda {
335            if term.reason(evaluations, best.value).is_some() {
336                break 'outer; // budget/target hit mid-generation
337            }
338            let z: Vec<f64> = (0..n).map(|_| rng.normal()).collect();
339            let dz: Vec<f64> = (0..n).map(|j| d[j] * z[j]).collect();
340            let y = matvec(&b, &dz); // B (D z)
341            let u_c: Vec<f64> = (0..n)
342                .map(|i| (mean[i] + sigma * y[i]).clamp(0.0, 1.0))
343                .collect();
344            let x = denormalize(&u_c, bounds);
345            let f = problem.objective(&x);
346            evaluations += 1;
347            let f = if f.is_finite() { f } else { f64::INFINITY };
348            if f < best.value {
349                best = Solution { x, value: f };
350            }
351            let y_eff: Vec<f64> = (0..n).map(|i| (u_c[i] - mean[i]) / sigma).collect();
352            pop.push((f, y_eff));
353        }
354
355        // Rank by fitness (ascending) and recombine the μ best steps.
356        pop.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
357        let mut y_w = vec![0.0; n];
358        for (i, wi) in w.iter().enumerate() {
359            for k in 0..n {
360                y_w[k] += wi * pop[i].1[k];
361            }
362        }
363
364        // Move the mean: m ← m + σ y_w. The recombined steps are built from
365        // in-box points, so the mean stays in [0, 1] without clamping.
366        for k in 0..n {
367            mean[k] += sigma * y_w[k];
368        }
369
370        // Step-size evolution path: p_σ ← (1−c_σ) p_σ + √(c_σ(2−c_σ)μ_eff) C^{-1/2} y_w.
371        let c_inv_sqrt_yw = c_inv_sqrt_mul(&b, &d, &y_w);
372        let cs_factor = (c_sigma * (2.0 - c_sigma) * mu_eff).sqrt();
373        for k in 0..n {
374            p_sigma[k] = (1.0 - c_sigma) * p_sigma[k] + cs_factor * c_inv_sqrt_yw[k];
375        }
376        let ps_norm = norm(&p_sigma);
377
378        generation += 1;
379        // Heaviside step stalls the rank-one update when σ would jump.
380        let hsig = if ps_norm / (1.0 - (1.0 - c_sigma).powi(2 * generation)).sqrt()
381            < (1.4 + 2.0 / (nf + 1.0)) * e_n
382        {
383            1.0
384        } else {
385            0.0
386        };
387
388        // Covariance evolution path.
389        let cc_factor = (c_c * (2.0 - c_c) * mu_eff).sqrt();
390        for k in 0..n {
391            p_c[k] = (1.0 - c_c) * p_c[k] + hsig * cc_factor * y_w[k];
392        }
393
394        // Rank-one + rank-μ covariance update.
395        let delta_hsig = (1.0 - hsig) * c_c * (2.0 - c_c);
396        for a in 0..n {
397            for bcol in a..n {
398                let mut rank_mu = 0.0;
399                for (i, wi) in w.iter().enumerate() {
400                    rank_mu += wi * pop[i].1[a] * pop[i].1[bcol];
401                }
402                let rank_one = p_c[a] * p_c[bcol];
403                let val = (1.0 - c_1 - c_mu) * cov[a][bcol]
404                    + c_1 * (rank_one + delta_hsig * cov[a][bcol])
405                    + c_mu * rank_mu;
406                cov[a][bcol] = val;
407                cov[bcol][a] = val; // keep symmetric
408            }
409        }
410
411        // Step-size update, with collapse *and* divergence guards (the box
412        // is [0, 1]^n, so a step size of 10^6 ranges is degenerate).
413        sigma *= ((c_sigma / d_sigma) * (ps_norm / e_n - 1.0)).exp();
414        if !(1e-300..=1e6).contains(&sigma) {
415            converged = true; // collapsed / degenerate (NaN also fails this)
416            break;
417        }
418
419        // Internal convergence criteria (restart triggers).
420        hist.push(pop[0].0);
421        if hist.len() > hist_len {
422            hist.remove(0);
423            let (lo, hi) = hist
424                .iter()
425                .fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), &v| {
426                    (l.min(v), h.max(v))
427                });
428            if hi - lo < 1e-12 {
429                converged = true; // TolFunHist
430                break;
431            }
432        }
433        let tolx_met = (0..n)
434            .all(|k| (sigma * p_c[k]).abs() < tolx && sigma * cov[k][k].max(0.0).sqrt() < tolx);
435        if tolx_met {
436            converged = true; // TolX
437            break;
438        }
439        let min_eig = eigvals.iter().cloned().fold(f64::INFINITY, f64::min);
440        if max_eig / min_eig.max(f64::MIN_POSITIVE) > 1e14 {
441            converged = true; // ConditionCov
442            break;
443        }
444    }
445
446    CmaRunOutcome {
447        best,
448        evaluations,
449        converged,
450    }
451}
452
453/// Maps a normalized point in `[0, 1]^n` to the box (no clamping).
454fn denormalize(u: &[f64], bounds: &[(f64, f64)]) -> Vec<f64> {
455    u.iter()
456        .zip(bounds)
457        .map(|(&ui, &(lo, hi))| lo + ui * (hi - lo))
458        .collect()
459}
460
461fn identity(n: usize) -> Vec<Vec<f64>> {
462    let mut m = vec![vec![0.0; n]; n];
463    for (i, row) in m.iter_mut().enumerate() {
464        row[i] = 1.0;
465    }
466    m
467}
468
469/// Matrix-vector product `M v`.
470fn matvec(m: &[Vec<f64>], v: &[f64]) -> Vec<f64> {
471    m.iter()
472        .map(|row| row.iter().zip(v).map(|(a, b)| a * b).sum())
473        .collect()
474}
475
476fn norm(v: &[f64]) -> f64 {
477    v.iter().map(|x| x * x).sum::<f64>().sqrt()
478}
479
480/// Computes `C^{-1/2} v = B D^{-1} Bᵀ v`, where columns of `B` are eigenvectors
481/// and `D` holds the square roots of the eigenvalues.
482fn c_inv_sqrt_mul(b: &[Vec<f64>], d: &[f64], v: &[f64]) -> Vec<f64> {
483    let n = v.len();
484    // a = Bᵀ v
485    let mut a = vec![0.0; n];
486    for (j, aj) in a.iter_mut().enumerate() {
487        for i in 0..n {
488            *aj += b[i][j] * v[i];
489        }
490        *aj /= d[j];
491    }
492    // result = B a
493    matvec(b, &a)
494}
495
496/// Symmetric eigendecomposition by the cyclic Jacobi method. Returns the
497/// eigenvalues and an orthonormal matrix whose **columns** are the matching
498/// eigenvectors. Deterministic; intended for the small-to-moderate `n` typical
499/// of optimization problems.
500fn jacobi_eigen(input: &[Vec<f64>]) -> (Vec<f64>, Vec<Vec<f64>>) {
501    let n = input.len();
502    let mut a: Vec<Vec<f64>> = input.to_vec();
503    let mut v = identity(n);
504    if n == 1 {
505        return (vec![a[0][0]], v);
506    }
507
508    for _ in 0..100 {
509        // Off-diagonal magnitude; stop once negligible *relative to the
510        // diagonal scale* (an absolute tolerance would be too strict for large
511        // entries and vacuous for tiny ones).
512        let mut off = 0.0;
513        for p in 0..n {
514            for q in p + 1..n {
515                off += a[p][q] * a[p][q];
516            }
517        }
518        let scale = (0..n)
519            .map(|i| a[i][i].abs())
520            .fold(f64::MIN_POSITIVE, f64::max);
521        if off.sqrt() < 1e-14 * scale {
522            break;
523        }
524
525        for p in 0..n {
526            for q in p + 1..n {
527                if a[p][q].abs() < 1e-300 {
528                    continue;
529                }
530                // Jacobi rotation angle that zeros a[p][q].
531                let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
532                let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
533                let c = 1.0 / (t * t + 1.0).sqrt();
534                let s = t * c;
535
536                // Rotate rows/columns p and q.
537                for k in 0..n {
538                    let akp = a[k][p];
539                    let akq = a[k][q];
540                    a[k][p] = c * akp - s * akq;
541                    a[k][q] = s * akp + c * akq;
542                }
543                for k in 0..n {
544                    let apk = a[p][k];
545                    let aqk = a[q][k];
546                    a[p][k] = c * apk - s * aqk;
547                    a[q][k] = s * apk + c * aqk;
548                }
549                // Accumulate the rotation into the eigenvector matrix.
550                for k in 0..n {
551                    let vkp = v[k][p];
552                    let vkq = v[k][q];
553                    v[k][p] = c * vkp - s * vkq;
554                    v[k][q] = s * vkp + c * vkq;
555                }
556            }
557        }
558    }
559
560    let eigvals = (0..n).map(|i| a[i][i]).collect();
561    (eigvals, v)
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn jacobi_recovers_known_eigenpairs() {
570        // [[2,1],[1,2]] has eigenvalues 1 and 3.
571        let (vals, vecs) = jacobi_eigen(&[vec![2.0, 1.0], vec![1.0, 2.0]]);
572        let mut sorted = vals.clone();
573        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
574        assert!((sorted[0] - 1.0).abs() < 1e-9 && (sorted[1] - 3.0).abs() < 1e-9);
575        // Eigenvectors are orthonormal: columns have unit norm.
576        for j in 0..2 {
577            let col_norm = (0..2).map(|i| vecs[i][j] * vecs[i][j]).sum::<f64>().sqrt();
578            assert!((col_norm - 1.0).abs() < 1e-9);
579        }
580    }
581}