Skip to main content

rlevo_evolution/algorithms/
ep.rs

1//! Evolutionary Programming (Fogel-style).
2//!
3//! Classical EP differs from ES in the details:
4//!
5//! - **No crossover**. Each parent produces exactly one offspring by
6//!   Gaussian mutation.
7//! - **Self-adaptive σ**. Each individual carries its own σ, updated
8//!   by the log-normal rule `σ' = σ · exp(τ · N(0, 1))`. Shared with ES
9//!   but applied before every mutation call, not only at survivor time.
10//! - **q-tournament survivor selection** on the `(μ + μ)` pool. Each
11//!   individual plays `q` random opponents; the μ individuals with the
12//!   highest win-counts survive. This diverges from truncation
13//!   selection — EP gives weaker individuals a stochastic chance to
14//!   survive.
15//!
16//! # Reference
17//!
18//! - Fogel (1994), *An introduction to simulated evolutionary
19//!   optimization*.
20
21use std::marker::PhantomData;
22
23use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
24use rand::Rng;
25use rand::RngExt;
26use rand_distr::{Distribution as _, Normal};
27
28use crate::ops::mutation::gaussian_mutation_per_row;
29use crate::rng::{SeedPurpose, seed_stream};
30use crate::strategy::{Strategy, StrategyMetrics};
31
32/// Static configuration for an [`EvolutionaryProgramming`] run.
33#[derive(Debug, Clone)]
34pub struct EpConfig {
35    /// Parent population size (offspring population is also μ — EP is
36    /// strictly `μ + μ`).
37    pub mu: usize,
38    /// Genome dimensionality.
39    pub genome_dim: usize,
40    /// Search-space bounds (initialization and clamping).
41    pub bounds: (f32, f32),
42    /// Initial σ for every individual.
43    pub initial_sigma: f32,
44    /// Learning rate for the log-normal σ update. Default is
45    /// `1 / sqrt(2 · sqrt(D))`.
46    pub tau: f32,
47    /// Number of opponents per tournament round (q-tournament).
48    pub tournament_q: usize,
49}
50
51impl EpConfig {
52    /// Default configuration for a given dimensionality.
53    ///
54    /// Sets `initial_sigma = 1.0`, `tournament_q = 10`, and derives
55    /// `tau = 1.0 / sqrt(2.0 · sqrt(D))` — the standard EP learning-rate
56    /// recommendation from Fogel (1994). Bounds are `(-5.12, 5.12)`.
57    #[must_use]
58    pub fn default_for(mu: usize, genome_dim: usize) -> Self {
59        #[allow(clippy::cast_precision_loss)]
60        let d = genome_dim as f32;
61        let tau = 1.0 / (2.0 * d.sqrt()).sqrt();
62        Self {
63            mu,
64            genome_dim,
65            bounds: (-5.12, 5.12),
66            initial_sigma: 1.0,
67            tau,
68            tournament_q: 10,
69        }
70    }
71}
72
73/// Generation-to-generation state for [`EvolutionaryProgramming`].
74///
75/// The two-phase ask/tell handshake uses `parent_fitness.is_empty()` as
76/// a sentinel: on the very first [`Strategy::ask`] call the initial
77/// parents are returned unchanged; on the very first [`Strategy::tell`]
78/// call `parent_fitness` is populated and
79/// `best_genome`/`best_fitness` are initialized. Subsequent
80/// ask/tell cycles produce, evaluate, and select from the `(μ + μ)` pool.
81///
82/// During `ask`, `sigmas` is temporarily expanded to length `2μ` (parent
83/// σ concatenated with offspring σ) so `tell` can apply q-tournament
84/// selection over the combined pool without re-deriving σ values. After
85/// `tell` completes, `sigmas` is back to length `μ`.
86#[derive(Debug, Clone)]
87pub struct EpState<B: Backend> {
88    /// Current parents, shape `(μ, D)`.
89    pub parents: Tensor<B, 2>,
90    /// Per-individual step-size σ, shape `(μ,)` between generations and
91    /// `(2μ,)` transiently inside an ask/tell cycle (parent σ ‖ offspring σ).
92    pub sigmas: Tensor<B, 1>,
93    /// Host-side fitness cache for the current parents.
94    ///
95    /// Empty before the first [`Strategy::tell`] call; length `μ`
96    /// thereafter. The `is_empty()` check distinguishes the initial
97    /// evaluation phase from subsequent tournament-selection generations.
98    pub parent_fitness: Vec<f32>,
99    /// Best-so-far genome, shape `(1, D)`.
100    ///
101    /// `None` before the first [`Strategy::tell`] call.
102    pub best_genome: Option<Tensor<B, 2>>,
103    /// Best-so-far fitness across all completed generations.
104    ///
105    /// `f32::INFINITY` before the first [`Strategy::tell`] call.
106    pub best_fitness: f32,
107    /// Number of completed `tell` calls (zero-based generation index + 1).
108    pub generation: usize,
109}
110
111/// Classical Fogel EP.
112///
113/// # Example
114///
115/// ```no_run
116/// use burn::backend::Flex;
117/// use rlevo_evolution::algorithms::ep::{EpConfig, EvolutionaryProgramming};
118///
119/// let strategy = EvolutionaryProgramming::<Flex>::new();
120/// let params = EpConfig::default_for(30, 10);
121/// let _ = (strategy, params);
122/// ```
123#[derive(Debug, Clone, Copy, Default)]
124pub struct EvolutionaryProgramming<B: Backend> {
125    _backend: PhantomData<fn() -> B>,
126}
127
128impl<B: Backend> EvolutionaryProgramming<B> {
129    /// Builds a new (stateless) strategy object.
130    #[must_use]
131    pub fn new() -> Self {
132        Self {
133            _backend: PhantomData,
134        }
135    }
136}
137
138impl<B: Backend> Strategy<B> for EvolutionaryProgramming<B>
139where
140    B::Device: Clone,
141{
142    type Params = EpConfig;
143    type State = EpState<B>;
144    type Genome = Tensor<B, 2>;
145
146    /// Samples the initial parent population uniformly within
147    /// `params.bounds`, initializes per-parent σ to
148    /// `params.initial_sigma`, and returns an [`EpState`] with an empty
149    /// fitness cache.
150    ///
151    /// Initial sampling goes through [`seed_stream`] rather than
152    /// `B::seed + Tensor::random` to keep results reproducible across
153    /// parallel test threads.
154    fn init(&self, params: &EpConfig, rng: &mut dyn Rng, device: &<B as burn::tensor::backend::BackendTypes>::Device) -> EpState<B> {
155        let (lo, hi) = params.bounds;
156        // Host-sample the initial parents from a deterministic `seed_stream`
157        // rather than the process-wide Flex RNG (`B::seed` + `Tensor::random`),
158        // whose draws interleave with sibling tests under the parallel runner
159        // and are not reproducible across thread schedules.
160        let mu = params.mu;
161        let genome_dim = params.genome_dim;
162        let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
163        let mut parent_rows = Vec::with_capacity(mu * genome_dim);
164        for _ in 0..mu * genome_dim {
165            parent_rows.push(lo + (hi - lo) * stream.random::<f32>());
166        }
167        let parents =
168            Tensor::<B, 2>::from_data(TensorData::new(parent_rows, [mu, genome_dim]), device);
169        let sigmas = Tensor::<B, 1>::from_data(
170            TensorData::new(vec![params.initial_sigma; params.mu], [params.mu]),
171            device,
172        );
173        EpState {
174            parents,
175            sigmas,
176            parent_fitness: Vec::new(),
177            best_genome: None,
178            best_fitness: f32::INFINITY,
179            generation: 0,
180        }
181    }
182
183    /// Proposes the offspring population for this generation.
184    ///
185    /// **First call (fitness cache empty):** returns the initial parents
186    /// unchanged so the caller can evaluate them before any mutation step.
187    ///
188    /// **Subsequent calls:**
189    ///
190    /// 1. Applies the log-normal σ update to each parent:
191    ///    `σ'_i = σ_i · exp(τ · N(0, 1))`, host-sampled via
192    ///    [`seed_stream`] with [`SeedPurpose::Other`].
193    /// 2. Mutates each parent by its updated σ using
194    ///    [`gaussian_mutation_per_row`], host-sampled via [`seed_stream`]
195    ///    with [`SeedPurpose::Mutation`].
196    /// 3. Clamps offspring to `params.bounds`.
197    /// 4. Appends the offspring σ values to `state.sigmas`, making it
198    ///    length `2μ` so [`Strategy::tell`] can select over the combined
199    ///    pool without re-deriving them.
200    ///
201    /// Returns the offspring tensor and the updated state.
202    fn ask(
203        &self,
204        params: &EpConfig,
205        state: &EpState<B>,
206        rng: &mut dyn Rng,
207        device: &<B as burn::tensor::backend::BackendTypes>::Device,
208    ) -> (Tensor<B, 2>, EpState<B>) {
209        // First call: evaluate the initial parents.
210        if state.parent_fitness.is_empty() {
211            return (state.parents.clone(), state.clone());
212        }
213
214        let mu = params.mu;
215        let mut sigma_rng =
216            seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Other);
217        let mut mutation_rng = seed_stream(
218            rng.next_u64(),
219            state.generation as u64,
220            SeedPurpose::Mutation,
221        );
222
223        // Log-normal σ update for every parent. Host-sample the N(0,1)
224        // noise from the deterministic `sigma_rng` so it is reproducible
225        // across thread schedules.
226        let normal = Normal::new(0.0f32, 1.0).expect("unit normal is well-defined");
227        let mut noise_rows = Vec::with_capacity(mu);
228        for _ in 0..mu {
229            noise_rows.push(normal.sample(&mut sigma_rng));
230        }
231        let noise = Tensor::<B, 1>::from_data(TensorData::new(noise_rows, [mu]), device);
232        let offspring_sigmas = state.sigmas.clone() * noise.mul_scalar(params.tau).exp();
233
234        // Mutate each parent exactly once using its own σ, drawing from the
235        // host `mutation_rng`.
236        let offspring = gaussian_mutation_per_row(
237            state.parents.clone(),
238            offspring_sigmas.clone(),
239            &mut mutation_rng,
240            device,
241        );
242        let (lo, hi) = params.bounds;
243        let offspring = offspring.clamp(lo, hi);
244
245        // Stash offspring σ onto state via concatenation (parent_σ || offspring_σ).
246        let mut state = state.clone();
247        state.sigmas = Tensor::cat(vec![state.sigmas.clone(), offspring_sigmas], 0);
248        (offspring, state)
249    }
250
251    /// Consumes the evaluated offspring and advances the state.
252    ///
253    /// **First call (fitness cache empty):** stores the initial parent
254    /// fitness, initializes `best_genome`/`best_fitness`, resets σ to
255    /// `params.initial_sigma`, and increments the generation counter.
256    ///
257    /// **Subsequent calls:**
258    ///
259    /// 1. Builds the `(μ + μ)` combined pool of parents and offspring
260    ///    (and their `2μ` σ values from [`Strategy::ask`]).
261    /// 2. Runs q-tournament selection: each of the `2μ` members plays
262    ///    `params.tournament_q` random opponents; the member wins a bout
263    ///    if its fitness is strictly lower. The μ members with the most
264    ///    wins survive; ties are broken by fitness (lower wins).
265    ///    Tournament indices are host-sampled via [`seed_stream`] with
266    ///    [`SeedPurpose::Selection`].
267    /// 3. Updates `best_genome`/`best_fitness` from the offspring
268    ///    fitness if improved.
269    ///
270    /// Returns the updated [`EpState`] and a [`StrategyMetrics`] snapshot
271    /// covering the current offspring generation's fitness distribution.
272    fn tell(
273        &self,
274        params: &EpConfig,
275        offspring: Tensor<B, 2>,
276        fitness: Tensor<B, 1>,
277        mut state: EpState<B>,
278        rng: &mut dyn Rng,
279    ) -> (EpState<B>, StrategyMetrics) {
280        let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
281        let device = offspring.device();
282
283        // First `tell`: evaluated the initial parents.
284        if state.parent_fitness.is_empty() {
285            state.parent_fitness.clone_from(&fitness_host);
286            state.generation += 1;
287            update_best(&mut state, &offspring, &fitness_host);
288            let m = StrategyMetrics::from_host_fitness(
289                state.generation,
290                &fitness_host,
291                state.best_fitness,
292            );
293            state.best_fitness = m.best_fitness_ever;
294            state.parents = offspring;
295            state.sigmas = Tensor::<B, 1>::from_data(
296                TensorData::new(vec![params.initial_sigma; params.mu], [params.mu]),
297                &device,
298            );
299            return (state, m);
300        }
301
302        let mu = params.mu;
303        // Build the (μ + μ) pool.
304        let combined_pop = Tensor::cat(vec![state.parents.clone(), offspring.clone()], 0);
305        let combined_fit: Vec<f32> = state
306            .parent_fitness
307            .iter()
308            .chain(fitness_host.iter())
309            .copied()
310            .collect();
311        let combined_sigmas = state.sigmas.clone(); // already (μ + μ) thanks to `ask`.
312
313        // q-tournament: for each of the 2μ members, sample q opponents
314        // and count wins (lower fitness beats higher). The μ highest-
315        // win members survive.
316        let mut selection_rng = seed_stream(
317            rng.next_u64(),
318            state.generation as u64,
319            SeedPurpose::Selection,
320        );
321        let n = combined_fit.len();
322        let mut win_counts: Vec<u32> = vec![0; n];
323        for (i, &my_fit) in combined_fit.iter().enumerate() {
324            for _ in 0..params.tournament_q {
325                let opp = selection_rng.random_range(0..n);
326                if my_fit < combined_fit[opp] {
327                    win_counts[i] += 1;
328                }
329            }
330        }
331
332        // Sort by (win_count desc, fitness asc) and pick top μ.
333        let mut indexed: Vec<usize> = (0..n).collect();
334        indexed.sort_by(|&a, &b| {
335            win_counts[b].cmp(&win_counts[a]).then_with(|| {
336                combined_fit[a]
337                    .partial_cmp(&combined_fit[b])
338                    .unwrap_or(std::cmp::Ordering::Equal)
339            })
340        });
341        indexed.truncate(mu);
342        #[allow(clippy::cast_possible_wrap)]
343        let survivor_idx: Vec<i64> = indexed.iter().map(|&i| i as i64).collect();
344
345        let idx_tensor =
346            Tensor::<B, 1, Int>::from_data(TensorData::new(survivor_idx.clone(), [mu]), &device);
347        let next_parents = combined_pop.select(0, idx_tensor.clone());
348        let next_sigmas = combined_sigmas.select(0, idx_tensor);
349        let next_fitness: Vec<f32> = survivor_idx
350            .iter()
351            .map(|&i| {
352                #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
353                combined_fit[i as usize]
354            })
355            .collect();
356
357        state.parents = next_parents;
358        state.sigmas = next_sigmas;
359        state.parent_fitness = next_fitness;
360        state.generation += 1;
361        update_best(&mut state, &offspring, &fitness_host);
362        let m =
363            StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
364        state.best_fitness = m.best_fitness_ever;
365        (state, m)
366    }
367
368    /// Returns the best-so-far genome and its raw (minimization) fitness.
369    ///
370    /// Returns `None` before the first [`Strategy::tell`] call, when
371    /// `EpState::best_genome` is still `None`.
372    fn best(&self, state: &EpState<B>) -> Option<(Tensor<B, 2>, f32)> {
373        state
374            .best_genome
375            .as_ref()
376            .map(|g| (g.clone(), state.best_fitness))
377    }
378}
379
380fn update_best<B: Backend>(state: &mut EpState<B>, pop: &Tensor<B, 2>, fitness: &[f32]) {
381    if fitness.is_empty() {
382        return;
383    }
384    let mut best_idx = 0usize;
385    let mut best_f = fitness[0];
386    for (i, &f) in fitness.iter().enumerate().skip(1) {
387        if f < best_f {
388            best_f = f;
389            best_idx = i;
390        }
391    }
392    if best_f < state.best_fitness {
393        let device = pop.device();
394        #[allow(clippy::cast_possible_wrap)]
395        let idx =
396            Tensor::<B, 1, Int>::from_data(TensorData::new(vec![best_idx as i64], [1]), &device);
397        state.best_genome = Some(pop.clone().select(0, idx));
398        state.best_fitness = best_f;
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use crate::fitness::FromFitnessEvaluable;
406    use crate::strategy::EvolutionaryHarness;
407    use burn::backend::Flex;
408    use rlevo_core::fitness::FitnessEvaluable;
409    type TestBackend = Flex;
410
411    struct Sphere;
412    struct SphereFit;
413    impl FitnessEvaluable for SphereFit {
414        type Individual = Vec<f64>;
415        type Landscape = Sphere;
416        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
417            x.iter().map(|v| v * v).sum()
418        }
419    }
420
421    #[test]
422    fn ep_converges_on_sphere_d2() {
423        let device = Default::default();
424        let params = EpConfig::default_for(10, 2);
425        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
426        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
427            EvolutionaryProgramming::<TestBackend>::new(),
428            params,
429            fitness_fn,
430            3,
431            device,
432            300,
433        );
434        harness.reset();
435        loop {
436            if harness.step(()).done {
437                break;
438            }
439        }
440        let best = harness.latest_metrics().unwrap().best_fitness_ever;
441        assert!(best < 1e-2, "EP Sphere-D2 best={best}");
442    }
443
444    #[test]
445    fn ep_converges_on_sphere_d10() {
446        let device = Default::default();
447        let params = EpConfig::default_for(20, 10);
448        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
449        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
450            EvolutionaryProgramming::<TestBackend>::new(),
451            params,
452            fitness_fn,
453            5,
454            device,
455            2000,
456        );
457        harness.reset();
458        loop {
459            if harness.step(()).done {
460                break;
461            }
462        }
463        let best = harness.latest_metrics().unwrap().best_fitness_ever;
464        assert!(best < 1e-4, "EP Sphere-D10 best={best}");
465    }
466}