1use 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#[derive(Debug, Clone, Copy)]
37pub enum EsKind {
38 OnePlusOne,
40 OnePlusLambda { lambda: usize },
42 MuCommaLambda { mu: usize, lambda: usize },
44 MuPlusLambda { mu: usize, lambda: usize },
46}
47
48impl EsKind {
49 #[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#[derive(Debug, Clone)]
63pub struct EsConfig {
64 pub kind: EsKind,
66 pub genome_dim: usize,
68 pub bounds: (f32, f32),
70 pub initial_sigma: f32,
72 pub tau: f32,
75}
76
77impl EsConfig {
78 #[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#[derive(Debug, Clone)]
100pub struct EsState<B: Backend> {
101 pub parents: Tensor<B, 2>,
104 pub sigmas: Tensor<B, 1>,
111 pub parent_fitness: Vec<f32>,
113 pub best_genome: Option<Tensor<B, 2>>,
115 pub best_fitness: f32,
117 pub generation: usize,
119 pub successes_in_window: u32,
121 pub window_len: u32,
123}
124
125#[derive(Debug, Clone, Copy, Default)]
138pub struct EvolutionStrategy<B: Backend> {
139 _backend: PhantomData<fn() -> B>,
140}
141
142impl<B: Backend> EvolutionStrategy<B> {
143 #[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 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 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 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 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 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 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 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 let mutated = gaussian_mutation_per_row(
283 duplicated_parents,
284 offspring_sigmas.clone(),
285 &mut mutation_rng,
286 device,
287 );
288
289 let (lo, hi) = params.bounds;
291 let mutated = mutated.clamp(lo, hi);
292
293 let mut state = state.clone();
294 let combined_sigmas = Tensor::cat(vec![state.sigmas.clone(), offspring_sigmas], 0);
302 state.sigmas = combined_sigmas;
303 (mutated, state)
304 }
305
306 #[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 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 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 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 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 #[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 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 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 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 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 let best = run_es(EsKind::MuPlusLambda { mu: 5, lambda: 20 }, 10, 1500, 42);
582 assert!(best < 1e-6, "MuPlusLambda D10 best={best}");
583 }
584}