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