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;
25
26use crate::ops::mutation::gaussian_mutation_per_row;
27use crate::rng::{SeedPurpose, seed_stream};
28use crate::strategy::{Strategy, StrategyMetrics};
29
30/// Static configuration for an [`EvolutionaryProgramming`] run.
31#[derive(Debug, Clone)]
32pub struct EpConfig {
33    /// Parent population size (offspring population is also μ — EP is
34    /// strictly `μ + μ`).
35    pub mu: usize,
36    /// Genome dimensionality.
37    pub genome_dim: usize,
38    /// Search-space bounds (initialization and clamping).
39    pub bounds: (f32, f32),
40    /// Initial σ for every individual.
41    pub initial_sigma: f32,
42    /// Learning rate for the log-normal σ update. Default is
43    /// `1 / sqrt(2 · sqrt(D))`.
44    pub tau: f32,
45    /// Number of opponents per tournament round (q-tournament).
46    pub tournament_q: usize,
47}
48
49impl EpConfig {
50    /// Default configuration for a given dimensionality.
51    #[must_use]
52    pub fn default_for(mu: usize, genome_dim: usize) -> Self {
53        #[allow(clippy::cast_precision_loss)]
54        let d = genome_dim as f32;
55        let tau = 1.0 / (2.0 * d.sqrt()).sqrt();
56        Self {
57            mu,
58            genome_dim,
59            bounds: (-5.12, 5.12),
60            initial_sigma: 1.0,
61            tau,
62            tournament_q: 10,
63        }
64    }
65}
66
67/// Generation-to-generation state for [`EvolutionaryProgramming`].
68#[derive(Debug, Clone)]
69pub struct EpState<B: Backend> {
70    /// Parents, shape `(μ, D)`.
71    pub parents: Tensor<B, 2>,
72    /// Per-parent σ, shape `(μ,)`.
73    pub sigmas: Tensor<B, 1>,
74    /// Parent fitnesses, host-side cache.
75    pub parent_fitness: Vec<f32>,
76    /// Best-so-far genome, shape `(1, D)`.
77    pub best_genome: Option<Tensor<B, 2>>,
78    /// Best-so-far fitness.
79    pub best_fitness: f32,
80    /// Generation counter.
81    pub generation: usize,
82}
83
84/// Classical Fogel EP.
85///
86/// # Example
87///
88/// ```no_run
89/// use burn::backend::NdArray;
90/// use rlevo_evolution::algorithms::ep::{EpConfig, EvolutionaryProgramming};
91///
92/// let strategy = EvolutionaryProgramming::<NdArray>::new();
93/// let params = EpConfig::default_for(30, 10);
94/// let _ = (strategy, params);
95/// ```
96#[derive(Debug, Clone, Copy, Default)]
97pub struct EvolutionaryProgramming<B: Backend> {
98    _backend: PhantomData<fn() -> B>,
99}
100
101impl<B: Backend> EvolutionaryProgramming<B> {
102    /// Builds a new (stateless) strategy object.
103    #[must_use]
104    pub fn new() -> Self {
105        Self {
106            _backend: PhantomData,
107        }
108    }
109}
110
111impl<B: Backend> Strategy<B> for EvolutionaryProgramming<B>
112where
113    B::Device: Clone,
114{
115    type Params = EpConfig;
116    type State = EpState<B>;
117    type Genome = Tensor<B, 2>;
118
119    fn init(&self, params: &EpConfig, rng: &mut dyn Rng, device: &B::Device) -> EpState<B> {
120        let (lo, hi) = params.bounds;
121        B::seed(device, rng.next_u64());
122        let parents = Tensor::<B, 2>::random(
123            [params.mu, params.genome_dim],
124            burn::tensor::Distribution::Uniform(f64::from(lo), f64::from(hi)),
125            device,
126        );
127        let sigmas = Tensor::<B, 1>::from_data(
128            TensorData::new(vec![params.initial_sigma; params.mu], [params.mu]),
129            device,
130        );
131        EpState {
132            parents,
133            sigmas,
134            parent_fitness: Vec::new(),
135            best_genome: None,
136            best_fitness: f32::INFINITY,
137            generation: 0,
138        }
139    }
140
141    fn ask(
142        &self,
143        params: &EpConfig,
144        state: &EpState<B>,
145        rng: &mut dyn Rng,
146        device: &B::Device,
147    ) -> (Tensor<B, 2>, EpState<B>) {
148        // First call: evaluate the initial parents.
149        if state.parent_fitness.is_empty() {
150            return (state.parents.clone(), state.clone());
151        }
152
153        let mu = params.mu;
154        let mut sigma_rng =
155            seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Other);
156        let mut mutation_rng = seed_stream(
157            rng.next_u64(),
158            state.generation as u64,
159            SeedPurpose::Mutation,
160        );
161
162        // Log-normal σ update for every parent.
163        B::seed(device, sigma_rng.next_u64());
164        let noise =
165            Tensor::<B, 1>::random([mu], burn::tensor::Distribution::Normal(0.0, 1.0), device);
166        let offspring_sigmas = state.sigmas.clone() * noise.mul_scalar(params.tau).exp();
167
168        // Mutate each parent exactly once using its own σ.
169        B::seed(device, mutation_rng.next_u64());
170        let offspring =
171            gaussian_mutation_per_row(state.parents.clone(), offspring_sigmas.clone(), device);
172        let (lo, hi) = params.bounds;
173        let offspring = offspring.clamp(lo, hi);
174
175        // Stash offspring σ onto state via concatenation (parent_σ || offspring_σ).
176        let mut state = state.clone();
177        state.sigmas = Tensor::cat(vec![state.sigmas.clone(), offspring_sigmas], 0);
178        (offspring, state)
179    }
180
181    fn tell(
182        &self,
183        params: &EpConfig,
184        offspring: Tensor<B, 2>,
185        fitness: Tensor<B, 1>,
186        mut state: EpState<B>,
187        rng: &mut dyn Rng,
188    ) -> (EpState<B>, StrategyMetrics) {
189        let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
190        let device = offspring.device();
191
192        // First `tell`: evaluated the initial parents.
193        if state.parent_fitness.is_empty() {
194            state.parent_fitness.clone_from(&fitness_host);
195            state.generation += 1;
196            update_best(&mut state, &offspring, &fitness_host);
197            let m = StrategyMetrics::from_host_fitness(
198                state.generation,
199                &fitness_host,
200                state.best_fitness,
201            );
202            state.best_fitness = m.best_fitness_ever;
203            state.parents = offspring;
204            state.sigmas = Tensor::<B, 1>::from_data(
205                TensorData::new(vec![params.initial_sigma; params.mu], [params.mu]),
206                &device,
207            );
208            return (state, m);
209        }
210
211        let mu = params.mu;
212        // Build the (μ + μ) pool.
213        let combined_pop = Tensor::cat(vec![state.parents.clone(), offspring.clone()], 0);
214        let combined_fit: Vec<f32> = state
215            .parent_fitness
216            .iter()
217            .chain(fitness_host.iter())
218            .copied()
219            .collect();
220        let combined_sigmas = state.sigmas.clone(); // already (μ + μ) thanks to `ask`.
221
222        // q-tournament: for each of the 2μ members, sample q opponents
223        // and count wins (lower fitness beats higher). The μ highest-
224        // win members survive.
225        let mut selection_rng = seed_stream(
226            rng.next_u64(),
227            state.generation as u64,
228            SeedPurpose::Selection,
229        );
230        let n = combined_fit.len();
231        let mut win_counts: Vec<u32> = vec![0; n];
232        for (i, &my_fit) in combined_fit.iter().enumerate() {
233            for _ in 0..params.tournament_q {
234                use rand::RngExt;
235                let opp = selection_rng.random_range(0..n);
236                if my_fit < combined_fit[opp] {
237                    win_counts[i] += 1;
238                }
239            }
240        }
241
242        // Sort by (win_count desc, fitness asc) and pick top μ.
243        let mut indexed: Vec<usize> = (0..n).collect();
244        indexed.sort_by(|&a, &b| {
245            win_counts[b].cmp(&win_counts[a]).then_with(|| {
246                combined_fit[a]
247                    .partial_cmp(&combined_fit[b])
248                    .unwrap_or(std::cmp::Ordering::Equal)
249            })
250        });
251        indexed.truncate(mu);
252        #[allow(clippy::cast_possible_wrap)]
253        let survivor_idx: Vec<i64> = indexed.iter().map(|&i| i as i64).collect();
254
255        let idx_tensor =
256            Tensor::<B, 1, Int>::from_data(TensorData::new(survivor_idx.clone(), [mu]), &device);
257        let next_parents = combined_pop.select(0, idx_tensor.clone());
258        let next_sigmas = combined_sigmas.select(0, idx_tensor);
259        let next_fitness: Vec<f32> = survivor_idx
260            .iter()
261            .map(|&i| {
262                #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
263                combined_fit[i as usize]
264            })
265            .collect();
266
267        state.parents = next_parents;
268        state.sigmas = next_sigmas;
269        state.parent_fitness = next_fitness;
270        state.generation += 1;
271        update_best(&mut state, &offspring, &fitness_host);
272        let m =
273            StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
274        state.best_fitness = m.best_fitness_ever;
275        (state, m)
276    }
277
278    fn best(&self, state: &EpState<B>) -> Option<(Tensor<B, 2>, f32)> {
279        state
280            .best_genome
281            .as_ref()
282            .map(|g| (g.clone(), state.best_fitness))
283    }
284}
285
286fn update_best<B: Backend>(state: &mut EpState<B>, pop: &Tensor<B, 2>, fitness: &[f32]) {
287    if fitness.is_empty() {
288        return;
289    }
290    let mut best_idx = 0usize;
291    let mut best_f = fitness[0];
292    for (i, &f) in fitness.iter().enumerate().skip(1) {
293        if f < best_f {
294            best_f = f;
295            best_idx = i;
296        }
297    }
298    if best_f < state.best_fitness {
299        let device = pop.device();
300        #[allow(clippy::cast_possible_wrap)]
301        let idx =
302            Tensor::<B, 1, Int>::from_data(TensorData::new(vec![best_idx as i64], [1]), &device);
303        state.best_genome = Some(pop.clone().select(0, idx));
304        state.best_fitness = best_f;
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::fitness::FromFitnessEvaluable;
312    use crate::strategy::EvolutionaryHarness;
313    use burn::backend::NdArray;
314    use rlevo_core::fitness::FitnessEvaluable;
315    type TestBackend = NdArray;
316
317    struct Sphere;
318    struct SphereFit;
319    impl FitnessEvaluable for SphereFit {
320        type Individual = Vec<f64>;
321        type Landscape = Sphere;
322        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
323            x.iter().map(|v| v * v).sum()
324        }
325    }
326
327    #[test]
328    fn ep_converges_on_sphere_d2() {
329        let device = Default::default();
330        let params = EpConfig::default_for(10, 2);
331        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
332        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
333            EvolutionaryProgramming::<TestBackend>::new(),
334            params,
335            fitness_fn,
336            3,
337            device,
338            300,
339        );
340        harness.reset();
341        loop {
342            if harness.step(()).done {
343                break;
344            }
345        }
346        let best = harness.latest_metrics().unwrap().best_fitness_ever;
347        assert!(best < 1e-2, "EP Sphere-D2 best={best}");
348    }
349
350    #[test]
351    fn ep_converges_on_sphere_d10() {
352        let device = Default::default();
353        let params = EpConfig::default_for(20, 10);
354        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
355        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
356            EvolutionaryProgramming::<TestBackend>::new(),
357            params,
358            fitness_fn,
359            5,
360            device,
361            2000,
362        );
363        harness.reset();
364        loop {
365            if harness.step(()).done {
366                break;
367            }
368        }
369        let best = harness.latest_metrics().unwrap().best_fitness_ever;
370        assert!(best < 1e-4, "EP Sphere-D10 best={best}");
371    }
372}