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}