Skip to main content

rlevo_evolution/algorithms/
es_classical.rs

1//! Classical Evolution Strategies.
2//!
3//! Four canonical variants parameterized on a single [`EsConfig`]:
4//!
5//! - `(1+1)` — a single parent, a single offspring, 1/5th success-rule
6//!   σ adaptation.
7//! - `(1+λ)` — a single parent, λ offspring per generation; the best
8//!   offspring replaces the parent iff its fitness improves. The
9//!   underlying mutation/selection loop is also reused by Cartesian GP.
10//! - `(μ,λ)` — μ parents, λ offspring; parents are discarded each
11//!   generation.
12//! - `(μ+λ)` — μ parents, λ offspring; survivors are the μ best of the
13//!   combined pool.
14//!
15//! σ adaptation is by log-normal self-adaptation in the multi-parent
16//! variants; `(1+1)` uses Rechenberg's 1/5th success rule.
17//!
18//! # References
19//!
20//! - Beyer & Schwefel (2002), *Evolution strategies: A comprehensive
21//!   introduction*.
22
23use std::marker::PhantomData;
24
25use burn::tensor::{Tensor, TensorData, backend::Backend};
26use rand::Rng;
27
28use crate::ops::mutation::gaussian_mutation_per_row;
29use crate::ops::replacement::{mu_comma_lambda, mu_plus_lambda};
30use crate::rng::{SeedPurpose, seed_stream};
31use crate::strategy::{Strategy, StrategyMetrics};
32
33/// Which selection scheme the ES uses.
34#[derive(Debug, Clone, Copy)]
35pub enum EsKind {
36    /// `(1+1)` with 1/5-rule σ adaptation.
37    OnePlusOne,
38    /// `(1+λ)` with shared σ across offspring.
39    OnePlusLambda { lambda: usize },
40    /// `(μ,λ)` with log-normal per-individual σ adaptation.
41    MuCommaLambda { mu: usize, lambda: usize },
42    /// `(μ+λ)` with log-normal per-individual σ adaptation.
43    MuPlusLambda { mu: usize, lambda: usize },
44}
45
46impl EsKind {
47    /// Returns the effective offspring-population size for this variant.
48    #[must_use]
49    pub fn population_size(&self) -> usize {
50        match self {
51            EsKind::OnePlusOne => 1,
52            EsKind::OnePlusLambda { lambda }
53            | EsKind::MuCommaLambda { lambda, .. }
54            | EsKind::MuPlusLambda { lambda, .. } => *lambda,
55        }
56    }
57}
58
59/// Static configuration for an [`EvolutionStrategy`] run.
60#[derive(Debug, Clone)]
61pub struct EsConfig {
62    /// Variant to run.
63    pub kind: EsKind,
64    /// Genome dimensionality.
65    pub genome_dim: usize,
66    /// Search-space bounds; used for initialization and clamping.
67    pub bounds: (f32, f32),
68    /// Initial σ (log-normal self-adaptation modifies it in state).
69    pub initial_sigma: f32,
70    /// Learning-rate scale for log-normal σ update. Standard default is
71    /// `1.0 / sqrt(2 * sqrt(D))`.
72    pub tau: f32,
73}
74
75impl EsConfig {
76    /// Default configuration for a given ES variant and dimensionality.
77    #[must_use]
78    pub fn default_for(kind: EsKind, genome_dim: usize) -> Self {
79        #[allow(clippy::cast_precision_loss)]
80        let d = genome_dim as f32;
81        let tau = 1.0 / (2.0 * d.sqrt()).sqrt();
82        Self {
83            kind,
84            genome_dim,
85            bounds: (-5.12, 5.12),
86            initial_sigma: 1.0,
87            tau,
88        }
89    }
90}
91
92/// Generation state for [`EvolutionStrategy`].
93#[derive(Debug, Clone)]
94pub struct EsState<B: Backend> {
95    /// Parent population. `(μ, D)` for μ-parent variants; `(1, D)` for
96    /// (1+1) and (1+λ).
97    pub parents: Tensor<B, 2>,
98    /// Per-parent σ values.
99    ///
100    /// Shape between generations is `(μ,)` for log-normal adaptation and
101    /// `(1,)` for `(1+1)`/`(1+λ)` with shared σ. Between an `ask` and the
102    /// matching `tell` the tensor is temporarily `(μ + λ,)`: parent σ
103    /// followed by per-offspring σ. See `ask` for the rationale.
104    pub sigmas: Tensor<B, 1>,
105    /// Parent fitnesses.
106    pub parent_fitness: Vec<f32>,
107    /// Best-so-far genome, shape `(1, D)`.
108    pub best_genome: Option<Tensor<B, 2>>,
109    /// Best-so-far fitness.
110    pub best_fitness: f32,
111    /// Completed-generation counter.
112    pub generation: usize,
113    /// (1+1) only: running success-rate counter for the 1/5th rule.
114    pub successes_in_window: u32,
115    /// (1+1) only: window length observed so far.
116    pub window_len: u32,
117}
118
119/// Classical Evolution Strategy.
120///
121/// # Example
122///
123/// ```no_run
124/// use burn::backend::NdArray;
125/// use rlevo_evolution::algorithms::es_classical::{EsConfig, EsKind, EvolutionStrategy};
126///
127/// let strategy = EvolutionStrategy::<NdArray>::new();
128/// let params = EsConfig::default_for(EsKind::MuPlusLambda { mu: 5, lambda: 20 }, 10);
129/// let _ = (strategy, params);
130/// ```
131#[derive(Debug, Clone, Copy, Default)]
132pub struct EvolutionStrategy<B: Backend> {
133    _backend: PhantomData<fn() -> B>,
134}
135
136impl<B: Backend> EvolutionStrategy<B> {
137    /// Builds a new (stateless) strategy object.
138    #[must_use]
139    pub fn new() -> Self {
140        Self {
141            _backend: PhantomData,
142        }
143    }
144
145    fn mu(kind: EsKind) -> usize {
146        match kind {
147            EsKind::OnePlusOne | EsKind::OnePlusLambda { .. } => 1,
148            EsKind::MuCommaLambda { mu, .. } | EsKind::MuPlusLambda { mu, .. } => mu,
149        }
150    }
151
152    fn sample_initial_parents(
153        params: &EsConfig,
154        rng: &mut dyn Rng,
155        device: &B::Device,
156    ) -> (Tensor<B, 2>, Tensor<B, 1>) {
157        let mu = Self::mu(params.kind);
158        let (lo, hi) = params.bounds;
159        B::seed(device, rng.next_u64());
160        let parents = Tensor::<B, 2>::random(
161            [mu, params.genome_dim],
162            burn::tensor::Distribution::Uniform(f64::from(lo), f64::from(hi)),
163            device,
164        );
165        let sigmas = Tensor::<B, 1>::from_data(
166            TensorData::new(vec![params.initial_sigma; mu], [mu]),
167            device,
168        );
169        (parents, sigmas)
170    }
171}
172
173impl<B: Backend> Strategy<B> for EvolutionStrategy<B>
174where
175    B::Device: Clone,
176{
177    type Params = EsConfig;
178    type State = EsState<B>;
179    type Genome = Tensor<B, 2>;
180
181    fn init(&self, params: &EsConfig, rng: &mut dyn Rng, device: &B::Device) -> EsState<B> {
182        let (parents, sigmas) = Self::sample_initial_parents(params, rng, device);
183        EsState {
184            parents,
185            sigmas,
186            parent_fitness: Vec::new(),
187            best_genome: None,
188            best_fitness: f32::INFINITY,
189            generation: 0,
190            successes_in_window: 0,
191            window_len: 0,
192        }
193    }
194
195    fn ask(
196        &self,
197        params: &EsConfig,
198        state: &EsState<B>,
199        rng: &mut dyn Rng,
200        device: &B::Device,
201    ) -> (Tensor<B, 2>, EsState<B>) {
202        // First call: evaluate the initial parents as the "offspring"
203        // so fitness is populated in the subsequent `tell`.
204        if state.parent_fitness.is_empty() {
205            return (state.parents.clone(), state.clone());
206        }
207
208        let lambda = params.kind.population_size();
209        let mu = Self::mu(params.kind);
210
211        let mut mutation_rng = seed_stream(
212            rng.next_u64(),
213            state.generation as u64,
214            SeedPurpose::Mutation,
215        );
216        let mut sigma_rng =
217            seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Other);
218
219        // Build an offspring population of size λ by sampling a parent
220        // index per offspring and mutating. Uniform random parent
221        // selection — no fitness pressure applied at this stage in
222        // classical ES; survivor selection provides the pressure.
223        let mut parent_indices: Vec<i64> = Vec::with_capacity(lambda);
224        {
225            use rand::RngExt;
226            for _ in 0..lambda {
227                #[allow(clippy::cast_possible_wrap)]
228                parent_indices.push(sigma_rng.random_range(0..mu) as i64);
229            }
230        }
231        let idx_tensor = Tensor::<B, 1, burn::tensor::Int>::from_data(
232            TensorData::new(parent_indices.clone(), [lambda]),
233            device,
234        );
235        let duplicated_parents = state.parents.clone().select(0, idx_tensor.clone());
236        let duplicated_sigmas = state.sigmas.clone().select(0, idx_tensor);
237
238        // Apply log-normal σ adaptation (multi-parent case) or keep σ
239        // shared (1+1 / 1+λ). Log-normal: σ' = σ * exp(τ · N(0,1)).
240        let is_one_plus = matches!(
241            params.kind,
242            EsKind::OnePlusOne | EsKind::OnePlusLambda { .. }
243        );
244        let offspring_sigmas = if is_one_plus {
245            duplicated_sigmas
246        } else {
247            B::seed(device, sigma_rng.next_u64());
248            let noise = Tensor::<B, 1>::random(
249                [lambda],
250                burn::tensor::Distribution::Normal(0.0, 1.0),
251                device,
252            );
253            duplicated_sigmas * noise.mul_scalar(params.tau).exp()
254        };
255
256        // Mutate parents by the per-offspring σ.
257        B::seed(device, mutation_rng.next_u64());
258        let mutated =
259            gaussian_mutation_per_row(duplicated_parents, offspring_sigmas.clone(), device);
260
261        // Clamp to bounds.
262        let (lo, hi) = params.bounds;
263        let mutated = mutated.clamp(lo, hi);
264
265        let mut state = state.clone();
266        // Carry offspring σ to `tell` by appending them to `state.sigmas`.
267        // After this point sigmas has shape `(μ + λ,)`: the first μ entries
268        // are the unchanged parent σ, the last λ are the per-offspring σ.
269        // `tell` slices both halves to align survivor σ with survivor genomes
270        // (`(μ+λ)` selection draws from the union, `(μ,λ)` only from the λ
271        // offspring slice). Folding the offspring σ into the existing field
272        // avoids adding a transient pending-σ field to `EsState`.
273        let combined_sigmas = Tensor::cat(vec![state.sigmas.clone(), offspring_sigmas], 0);
274        state.sigmas = combined_sigmas;
275        (mutated, state)
276    }
277
278    #[allow(clippy::too_many_lines)]
279    fn tell(
280        &self,
281        params: &EsConfig,
282        offspring: Tensor<B, 2>,
283        fitness: Tensor<B, 1>,
284        mut state: EsState<B>,
285        _rng: &mut dyn Rng,
286    ) -> (EsState<B>, StrategyMetrics) {
287        let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
288
289        // First `tell` after `init`: offspring here is actually the
290        // initial parent population evaluated.
291        if state.parent_fitness.is_empty() {
292            state.parent_fitness.clone_from(&fitness_host);
293            state.generation += 1;
294            update_best(&mut state, &offspring, &fitness_host);
295            let m = StrategyMetrics::from_host_fitness(
296                state.generation,
297                &fitness_host,
298                state.best_fitness,
299            );
300            state.best_fitness = m.best_fitness_ever;
301            state.parents = offspring;
302            // Restore parent-count σ vector.
303            let mu = Self::mu(params.kind);
304            let device = state.parents.device();
305            state.sigmas = Tensor::<B, 1>::from_data(
306                TensorData::new(vec![params.initial_sigma; mu], [mu]),
307                &device,
308            );
309            return (state, m);
310        }
311
312        let device = offspring.device();
313        let mu = Self::mu(params.kind);
314        // state.sigmas currently holds parent σ concatenated with
315        // offspring σ, per `ask`'s scratchpad trick.
316        let lambda = params.kind.population_size();
317        #[allow(clippy::single_range_in_vec_init)]
318        let parent_sigmas = state.sigmas.clone().slice([0..mu]);
319        #[allow(clippy::single_range_in_vec_init)]
320        let offspring_sigmas = state.sigmas.clone().slice([mu..(mu + lambda)]);
321
322        match params.kind {
323            EsKind::OnePlusOne => {
324                // One parent, one offspring. Fitness[0] is the offspring.
325                let parent_fit = state.parent_fitness[0];
326                let offspring_fit = fitness_host[0];
327                let success = offspring_fit < parent_fit;
328                state.window_len += 1;
329                if success {
330                    state.successes_in_window += 1;
331                    state.parents.clone_from(&offspring);
332                    state.parent_fitness = vec![offspring_fit];
333                }
334                // Rechenberg 1/5-rule every 10 · D generations.
335                #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
336                let window = 10_u32.saturating_mul(params.genome_dim as u32).max(1);
337                if state.window_len >= window {
338                    #[allow(clippy::cast_precision_loss)]
339                    let rate = state.successes_in_window as f32 / state.window_len as f32;
340                    let current_sigma =
341                        state.sigmas.clone().into_data().into_vec::<f32>().unwrap()[0];
342                    let new_sigma = if rate > 0.2 {
343                        current_sigma * 1.22
344                    } else if rate < 0.2 {
345                        current_sigma / 1.22
346                    } else {
347                        current_sigma
348                    };
349                    state.sigmas =
350                        Tensor::<B, 1>::from_data(TensorData::new(vec![new_sigma], [1]), &device);
351                    state.successes_in_window = 0;
352                    state.window_len = 0;
353                } else {
354                    state.sigmas = parent_sigmas;
355                }
356            }
357            EsKind::OnePlusLambda { .. } => {
358                // Best of (parent, offspring pool).
359                let best_off_idx = argmin(&fitness_host);
360                let best_off_fit = fitness_host[best_off_idx];
361                if best_off_fit < state.parent_fitness[0] {
362                    #[allow(clippy::single_range_in_vec_init)]
363                    let best_row = offspring.clone().slice([best_off_idx..best_off_idx + 1]);
364                    state.parents = best_row;
365                    state.parent_fitness = vec![best_off_fit];
366                }
367                state.sigmas = parent_sigmas;
368            }
369            EsKind::MuCommaLambda { mu, .. } => {
370                let (survivors, survivor_f) =
371                    mu_comma_lambda::<B>(offspring.clone(), &fitness_host, mu, &device);
372                // Gather survivor σs matching the same indices.
373                let survivor_idx =
374                    crate::ops::selection::truncation_indices_host(&fitness_host, mu);
375                let survivor_sigmas = offspring_sigmas.select(
376                    0,
377                    Tensor::<B, 1, burn::tensor::Int>::from_data(
378                        TensorData::new(survivor_idx, [mu]),
379                        &device,
380                    ),
381                );
382                state.parents = survivors;
383                state.parent_fitness = survivor_f;
384                state.sigmas = survivor_sigmas;
385            }
386            EsKind::MuPlusLambda { mu, .. } => {
387                let (survivors, survivor_f) = mu_plus_lambda::<B>(
388                    state.parents.clone(),
389                    &state.parent_fitness,
390                    offspring.clone(),
391                    &fitness_host,
392                    mu,
393                    &device,
394                );
395                // Survivor σ via truncation_indices_host on the combined fitness.
396                let combined_f: Vec<f32> = state
397                    .parent_fitness
398                    .iter()
399                    .chain(fitness_host.iter())
400                    .copied()
401                    .collect();
402                let survivor_idx = crate::ops::selection::truncation_indices_host(&combined_f, mu);
403                let combined_sigmas = Tensor::cat(vec![parent_sigmas, offspring_sigmas], 0);
404                let survivor_sigmas = combined_sigmas.select(
405                    0,
406                    Tensor::<B, 1, burn::tensor::Int>::from_data(
407                        TensorData::new(survivor_idx, [mu]),
408                        &device,
409                    ),
410                );
411                state.parents = survivors;
412                state.parent_fitness = survivor_f;
413                state.sigmas = survivor_sigmas;
414            }
415        }
416
417        state.generation += 1;
418        update_best(&mut state, &offspring, &fitness_host);
419        let m =
420            StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
421        state.best_fitness = m.best_fitness_ever;
422        (state, m)
423    }
424
425    fn best(&self, state: &EsState<B>) -> Option<(Tensor<B, 2>, f32)> {
426        state
427            .best_genome
428            .as_ref()
429            .map(|g| (g.clone(), state.best_fitness))
430    }
431}
432
433fn argmin(xs: &[f32]) -> usize {
434    let mut best_idx = 0usize;
435    let mut best = f32::INFINITY;
436    for (i, &v) in xs.iter().enumerate() {
437        if v < best {
438            best = v;
439            best_idx = i;
440        }
441    }
442    best_idx
443}
444
445fn update_best<B: Backend>(state: &mut EsState<B>, pop: &Tensor<B, 2>, fitness: &[f32]) {
446    if fitness.is_empty() {
447        return;
448    }
449    let best_idx = argmin(fitness);
450    let best_f = fitness[best_idx];
451    if best_f < state.best_fitness {
452        let device = pop.device();
453        #[allow(clippy::cast_possible_wrap)]
454        let idx = Tensor::<B, 1, burn::tensor::Int>::from_data(
455            TensorData::new(vec![best_idx as i64], [1]),
456            &device,
457        );
458        state.best_genome = Some(pop.clone().select(0, idx));
459        state.best_fitness = best_f;
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use crate::fitness::FromFitnessEvaluable;
467    use crate::strategy::EvolutionaryHarness;
468    use burn::backend::NdArray;
469    use rlevo_core::fitness::FitnessEvaluable;
470    type TestBackend = NdArray;
471
472    struct Sphere;
473    struct SphereFit;
474    impl FitnessEvaluable for SphereFit {
475        type Individual = Vec<f64>;
476        type Landscape = Sphere;
477        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
478            x.iter().map(|v| v * v).sum()
479        }
480    }
481
482    fn run_es(kind: EsKind, dim: usize, generations: usize, seed: u64) -> f32 {
483        let device = Default::default();
484        let strategy = EvolutionStrategy::<TestBackend>::new();
485        let params = EsConfig::default_for(kind, dim);
486        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
487        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
488            strategy,
489            params,
490            fitness_fn,
491            seed,
492            device,
493            generations,
494        );
495        harness.reset();
496        loop {
497            let step = harness.step(());
498            if step.done {
499                break;
500            }
501        }
502        harness.latest_metrics().unwrap().best_fitness_ever
503    }
504
505    #[test]
506    fn one_plus_lambda_converges_on_sphere_d2() {
507        let best = run_es(EsKind::OnePlusLambda { lambda: 8 }, 2, 200, 7);
508        assert!(best < 1e-2, "OnePlusLambda best={best}");
509    }
510
511    #[test]
512    fn one_plus_one_converges_on_sphere_d2() {
513        let best = run_es(EsKind::OnePlusOne, 2, 500, 11);
514        assert!(best < 1e-2, "OnePlusOne best={best}");
515    }
516
517    #[test]
518    fn mu_plus_lambda_converges_on_sphere_d2() {
519        let best = run_es(EsKind::MuPlusLambda { mu: 3, lambda: 8 }, 2, 200, 7);
520        assert!(best < 1e-2, "MuPlusLambda best={best}");
521    }
522
523    #[test]
524    fn mu_comma_lambda_converges_on_sphere_d2() {
525        let best = run_es(EsKind::MuCommaLambda { mu: 3, lambda: 8 }, 2, 200, 7);
526        assert!(best < 1e-1, "MuCommaLambda best={best}");
527    }
528
529    #[test]
530    fn mu_plus_lambda_converges_on_sphere_d10() {
531        // Convergence on Sphere (D=10) to best_fitness < 1e-6 within
532        // budget on ndarray. We allow a generous budget because the
533        // classical ES is slower than CMA-ES; the goal is to verify
534        // convergence direction, not to optimize hyperparameters.
535        let best = run_es(EsKind::MuPlusLambda { mu: 5, lambda: 20 }, 10, 1500, 42);
536        assert!(best < 1e-6, "MuPlusLambda D10 best={best}");
537    }
538}