Skip to main content

rlevo_evolution/algorithms/
ga.rs

1//! Real-valued Genetic Algorithm.
2//!
3//! A canonical textbook GA over `Tensor<B, 2>` populations:
4//!
5//! 1. Evaluate the current population (done externally by the harness).
6//! 2. Select parents via [`crate::ops::selection::tournament_select`].
7//! 3. Recombine via [`crate::ops::crossover::blx_alpha`] or
8//!    [`crate::ops::crossover::uniform_crossover`].
9//! 4. Mutate via [`crate::ops::mutation::gaussian_mutation`].
10//! 5. Replace via [`crate::ops::replacement::elitist`] or
11//!    [`crate::ops::replacement::generational`].
12//!
13//! Operator variants are enum-selected via the [`GaConfig`] to avoid a
14//! generic explosion; custom operator mixtures can still be built
15//! bottom-up against the [`Strategy`] trait directly.
16//!
17//! # References
18//!
19//! - Goldberg (1989), *Genetic Algorithms in Search, Optimization, and
20//!   Machine Learning*.
21//! - Deb & Agrawal (1995), *Simulated binary crossover for continuous
22//!   search space*.
23
24use std::marker::PhantomData;
25
26use burn::tensor::{Tensor, TensorData, backend::Backend};
27use rand::Rng;
28use rand::RngExt;
29
30use crate::ops::{
31    crossover::{blx_alpha, uniform_crossover},
32    mutation::gaussian_mutation,
33    replacement::{elitist, generational},
34    selection::tournament_select,
35};
36use crate::rng::{SeedPurpose, seed_stream};
37use crate::strategy::{Strategy, StrategyMetrics};
38
39/// Selection algorithm choice.
40#[derive(Debug, Clone, Copy)]
41pub enum GaSelection {
42    /// k-tournament selection.
43    Tournament { size: usize },
44}
45
46/// Crossover algorithm choice.
47#[derive(Debug, Clone, Copy)]
48pub enum GaCrossover {
49    /// BLX-α real-valued crossover.
50    BlxAlpha { alpha: f32 },
51    /// Uniform swap crossover with per-gene probability `p`.
52    Uniform { p: f32 },
53}
54
55/// Replacement algorithm choice.
56#[derive(Debug, Clone, Copy)]
57pub enum GaReplacement {
58    /// Offspring replace the entire parent population.
59    Generational,
60    /// `elitism_k` best parents persist; the rest come from offspring.
61    Elitist { elitism_k: usize },
62}
63
64/// Static configuration for a [`GeneticAlgorithm`] run.
65#[derive(Debug, Clone)]
66pub struct GaConfig {
67    /// Number of individuals per generation.
68    pub pop_size: usize,
69    /// Genome dimensionality.
70    pub genome_dim: usize,
71    /// Lower / upper bound on initial samples and clamping.
72    pub bounds: (f32, f32),
73    /// σ for isotropic Gaussian mutation.
74    pub mutation_sigma: f32,
75    /// Selection operator.
76    pub selection: GaSelection,
77    /// Crossover operator.
78    pub crossover: GaCrossover,
79    /// Replacement policy.
80    pub replacement: GaReplacement,
81}
82
83impl GaConfig {
84    /// Sensible defaults for small-scale continuous optimization.
85    #[must_use]
86    pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
87        Self {
88            pop_size,
89            genome_dim,
90            bounds: (-5.12, 5.12),
91            mutation_sigma: 0.3,
92            selection: GaSelection::Tournament { size: 2 },
93            crossover: GaCrossover::BlxAlpha { alpha: 0.5 },
94            replacement: GaReplacement::Elitist { elitism_k: 1 },
95        }
96    }
97}
98
99/// Generation-to-generation state carried by [`GeneticAlgorithm`].
100#[derive(Debug, Clone)]
101pub struct GaState<B: Backend> {
102    /// Current population, shape `(pop_size, genome_dim)`.
103    pub population: Tensor<B, 2>,
104    /// Cached fitness for the current population. Empty on init until
105    /// the first `tell` call overwrites it.
106    pub fitness: Vec<f32>,
107    /// Best-so-far genome, shape `(1, genome_dim)`.
108    pub best_genome: Option<Tensor<B, 2>>,
109    /// Best-so-far fitness.
110    pub best_fitness: f32,
111    /// Completed-generation counter.
112    pub generation: usize,
113}
114
115/// Real-valued canonical Genetic Algorithm.
116///
117/// # Example
118///
119/// ```no_run
120/// use burn::backend::Flex;
121/// use rlevo_evolution::algorithms::ga::{
122///     GaConfig, GaCrossover, GaReplacement, GaSelection, GeneticAlgorithm,
123/// };
124///
125/// let strategy = GeneticAlgorithm::<Flex>::new();
126/// let params = GaConfig {
127///     pop_size: 64,
128///     genome_dim: 10,
129///     bounds: (-5.12, 5.12),
130///     mutation_sigma: 0.3,
131///     selection: GaSelection::Tournament { size: 2 },
132///     crossover: GaCrossover::BlxAlpha { alpha: 0.5 },
133///     replacement: GaReplacement::Elitist { elitism_k: 2 },
134/// };
135/// let _ = (strategy, params);
136/// ```
137#[derive(Debug, Clone, Copy, Default)]
138pub struct GeneticAlgorithm<B: Backend> {
139    _backend: PhantomData<fn() -> B>,
140}
141
142impl<B: Backend> GeneticAlgorithm<B> {
143    /// Build a new (stateless) strategy object.
144    #[must_use]
145    pub fn new() -> Self {
146        Self {
147            _backend: PhantomData,
148        }
149    }
150
151    fn sample_initial_population(
152        params: &GaConfig,
153        rng: &mut dyn Rng,
154        device: &<B as burn::tensor::backend::BackendTypes>::Device,
155    ) -> Tensor<B, 2> {
156        let (lo, hi) = params.bounds;
157        // Host-sample the initial population from a deterministic
158        // `seed_stream` rather than the process-wide Flex RNG (`B::seed` +
159        // `Tensor::random`), whose draws interleave with sibling tests under
160        // the parallel runner and are not reproducible across schedules.
161        let pop = params.pop_size;
162        let genome_dim = params.genome_dim;
163        let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
164        let mut rows = Vec::with_capacity(pop * genome_dim);
165        for _ in 0..pop * genome_dim {
166            rows.push(lo + (hi - lo) * stream.random::<f32>());
167        }
168        Tensor::<B, 2>::from_data(TensorData::new(rows, [pop, genome_dim]), device)
169    }
170}
171
172impl<B: Backend> Strategy<B> for GeneticAlgorithm<B>
173where
174    B::Device: Clone,
175{
176    type Params = GaConfig;
177    type State = GaState<B>;
178    type Genome = Tensor<B, 2>;
179
180    /// Build the initial state.
181    ///
182    /// Samples an `(pop_size, genome_dim)` real-valued population uniformly
183    /// within `params.bounds` using a host RNG derived from `rng`. Sets
184    /// `fitness` to empty and `best_fitness` to `f32::INFINITY`; the first
185    /// [`tell`](Self::tell) call populates both.
186    fn init(&self, params: &GaConfig, rng: &mut dyn Rng, device: &<B as burn::tensor::backend::BackendTypes>::Device) -> GaState<B> {
187        let population = Self::sample_initial_population(params, rng, device);
188        GaState {
189            population,
190            fitness: Vec::new(),
191            best_genome: None,
192            best_fitness: f32::INFINITY,
193            generation: 0,
194        }
195    }
196
197    /// Propose the next offspring population.
198    ///
199    /// On the very first call (before any [`tell`](Self::tell)), `state.fitness`
200    /// is empty — the harness has not evaluated the seed population yet. In
201    /// that case the unchanged seed population is returned so the harness can
202    /// evaluate and pass it back to `tell`.
203    ///
204    /// On subsequent calls the method runs selection → crossover → mutation,
205    /// deriving three independent host sub-streams from `rng` via
206    /// [`crate::rng::seed_stream`]:
207    ///
208    /// - `SeedPurpose::Selection` — two independent tournament draws
209    ///   (parents A and parents B);
210    /// - `SeedPurpose::Crossover` — BLX-α or uniform crossover;
211    /// - `SeedPurpose::Mutation` — isotropic Gaussian perturbation.
212    ///
213    /// After mutation, offspring are clamped to `params.bounds`.
214    fn ask(
215        &self,
216        params: &GaConfig,
217        state: &GaState<B>,
218        rng: &mut dyn Rng,
219        device: &<B as burn::tensor::backend::BackendTypes>::Device,
220    ) -> (Tensor<B, 2>, GaState<B>) {
221        // On the first call, state.fitness is empty; the harness has not
222        // evaluated anyone yet. Return the initial population unchanged.
223        if state.fitness.is_empty() {
224            return (state.population.clone(), state.clone());
225        }
226
227        let GaConfig {
228            pop_size,
229            mutation_sigma,
230            selection,
231            crossover,
232            ..
233        } = params;
234
235        let mut crossover_rng = seed_stream(
236            rng.next_u64(),
237            state.generation as u64,
238            SeedPurpose::Crossover,
239        );
240        let mut mutation_rng = seed_stream(
241            rng.next_u64(),
242            state.generation as u64,
243            SeedPurpose::Mutation,
244        );
245        let mut selection_rng = seed_stream(
246            rng.next_u64(),
247            state.generation as u64,
248            SeedPurpose::Selection,
249        );
250
251        // 1. Select two tournaments' worth of parents.
252        let parents_a = match selection {
253            GaSelection::Tournament { size } => tournament_select(
254                &state.population,
255                &state.fitness,
256                *size,
257                *pop_size,
258                &mut selection_rng,
259                device,
260            ),
261        };
262        let parents_b = match selection {
263            GaSelection::Tournament { size } => tournament_select(
264                &state.population,
265                &state.fitness,
266                *size,
267                *pop_size,
268                &mut selection_rng,
269                device,
270            ),
271        };
272
273        // 2. Recombine. Crossover draws from the host `crossover_rng`.
274        let offspring = match crossover {
275            GaCrossover::BlxAlpha { alpha } => {
276                blx_alpha(parents_a, parents_b, *alpha, &mut crossover_rng, device)
277            }
278            GaCrossover::Uniform { p } => {
279                uniform_crossover(parents_a, parents_b, *p, &mut crossover_rng, device)
280            }
281        };
282
283        // 3. Mutate from the host `mutation_rng`.
284        let offspring = gaussian_mutation(offspring, *mutation_sigma, &mut mutation_rng, device);
285
286        // 4. Clamp to bounds.
287        let (lo, hi) = params.bounds;
288        let offspring = offspring.clamp(lo, hi);
289
290        (offspring, state.clone())
291    }
292
293    /// Consume offspring fitness and produce the next generation's state.
294    ///
295    /// The first call (when `state.fitness` is empty) caches the seed
296    /// population's fitness and increments the generation counter; no
297    /// replacement is performed.
298    ///
299    /// On subsequent calls the replacement policy is applied:
300    ///
301    /// - [`GaReplacement::Generational`] — offspring completely replace
302    ///   parents.
303    /// - [`GaReplacement::Elitist`] — the `elitism_k` lowest-cost parents
304    ///   survive; the remainder come from offspring.
305    ///
306    /// `fitness` must have shape `(pop_size,)` with values in the
307    /// minimization (cost) convention — lower is better.
308    fn tell(
309        &self,
310        params: &GaConfig,
311        population: Tensor<B, 2>,
312        fitness: Tensor<B, 1>,
313        mut state: GaState<B>,
314        _rng: &mut dyn Rng,
315    ) -> (GaState<B>, StrategyMetrics) {
316        let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
317
318        // First `tell` after `init`: cache fitness for the seed population.
319        if state.fitness.is_empty() {
320            state.fitness.clone_from(&fitness_host);
321            state.generation += 1;
322            update_best(&mut state, &population, &fitness_host);
323            let m = StrategyMetrics::from_host_fitness(
324                state.generation,
325                &fitness_host,
326                state.best_fitness,
327            );
328            state.best_fitness = m.best_fitness_ever;
329            return (state, m);
330        }
331
332        let device = state.population.device();
333        let (next_pop, next_fitness) = match params.replacement {
334            GaReplacement::Generational => generational::<B>(
335                state.population.clone(),
336                &state.fitness,
337                population.clone(),
338                fitness_host.clone(),
339            ),
340            GaReplacement::Elitist { elitism_k } => elitist::<B>(
341                state.population.clone(),
342                &state.fitness,
343                population.clone(),
344                &fitness_host,
345                elitism_k,
346                &device,
347            ),
348        };
349
350        update_best(&mut state, &next_pop, &next_fitness);
351        state.population = next_pop;
352        state.fitness.clone_from(&next_fitness);
353        state.generation += 1;
354        let m =
355            StrategyMetrics::from_host_fitness(state.generation, &next_fitness, state.best_fitness);
356        state.best_fitness = m.best_fitness_ever;
357        (state, m)
358    }
359
360    /// Return the best-so-far genome and its fitness.
361    ///
362    /// Returns `None` before the first [`tell`](Self::tell) call.
363    /// The fitness value uses the minimization convention (lower is better).
364    fn best(&self, state: &GaState<B>) -> Option<(Tensor<B, 2>, f32)> {
365        state
366            .best_genome
367            .as_ref()
368            .map(|g| (g.clone(), state.best_fitness))
369    }
370}
371
372fn update_best<B: Backend>(state: &mut GaState<B>, pop: &Tensor<B, 2>, fitness: &[f32]) {
373    if fitness.is_empty() {
374        return;
375    }
376    let mut best_idx = 0_usize;
377    let mut best_f = fitness[0];
378    for (i, &f) in fitness.iter().enumerate().skip(1) {
379        if f < best_f {
380            best_f = f;
381            best_idx = i;
382        }
383    }
384    if best_f < state.best_fitness {
385        let device = pop.device();
386        #[allow(clippy::cast_possible_wrap)]
387        let idx = Tensor::<B, 1, burn::tensor::Int>::from_data(
388            TensorData::new(vec![best_idx as i64], [1]),
389            &device,
390        );
391        state.best_genome = Some(pop.clone().select(0, idx));
392        state.best_fitness = best_f;
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use crate::fitness::FromFitnessEvaluable;
400    use crate::strategy::EvolutionaryHarness;
401    use burn::backend::Flex;
402    use rlevo_core::fitness::FitnessEvaluable;
403
404    type TestBackend = Flex;
405
406    struct Sphere;
407    struct SphereFit;
408    impl FitnessEvaluable for SphereFit {
409        type Individual = Vec<f64>;
410        type Landscape = Sphere;
411        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
412            x.iter().map(|v| v * v).sum()
413        }
414    }
415
416    #[test]
417    fn ga_converges_on_sphere_d2() {
418        let device = Default::default();
419        let strategy = GeneticAlgorithm::<TestBackend>::new();
420        let params = GaConfig {
421            pop_size: 64,
422            genome_dim: 2,
423            bounds: (-5.0, 5.0),
424            mutation_sigma: 0.2,
425            selection: GaSelection::Tournament { size: 2 },
426            crossover: GaCrossover::BlxAlpha { alpha: 0.5 },
427            replacement: GaReplacement::Elitist { elitism_k: 1 },
428        };
429        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
430
431        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
432            strategy, params, fitness_fn, 42, device, 200,
433        );
434        harness.reset();
435        loop {
436            let step = harness.step(());
437            if step.done {
438                break;
439            }
440        }
441        let m = harness.latest_metrics().unwrap();
442        assert!(
443            m.best_fitness_ever < 1e-2,
444            "expected Sphere-D2 convergence, got best_fitness_ever={}",
445            m.best_fitness_ever
446        );
447    }
448}