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;
28
29use crate::ops::{
30    crossover::{blx_alpha, uniform_crossover},
31    mutation::gaussian_mutation,
32    replacement::{elitist, generational},
33    selection::tournament_select,
34};
35use crate::rng::{SeedPurpose, seed_stream};
36use crate::strategy::{Strategy, StrategyMetrics};
37
38/// Selection algorithm choice.
39#[derive(Debug, Clone, Copy)]
40pub enum GaSelection {
41    /// k-tournament selection.
42    Tournament { size: usize },
43}
44
45/// Crossover algorithm choice.
46#[derive(Debug, Clone, Copy)]
47pub enum GaCrossover {
48    /// BLX-α real-valued crossover.
49    BlxAlpha { alpha: f32 },
50    /// Uniform swap crossover with per-gene probability `p`.
51    Uniform { p: f32 },
52}
53
54/// Replacement algorithm choice.
55#[derive(Debug, Clone, Copy)]
56pub enum GaReplacement {
57    /// Offspring replace the entire parent population.
58    Generational,
59    /// `elitism_k` best parents persist; the rest come from offspring.
60    Elitist { elitism_k: usize },
61}
62
63/// Static configuration for a [`GeneticAlgorithm`] run.
64#[derive(Debug, Clone)]
65pub struct GaConfig {
66    /// Number of individuals per generation.
67    pub pop_size: usize,
68    /// Genome dimensionality.
69    pub genome_dim: usize,
70    /// Lower / upper bound on initial samples and clamping.
71    pub bounds: (f32, f32),
72    /// σ for isotropic Gaussian mutation.
73    pub mutation_sigma: f32,
74    /// Selection operator.
75    pub selection: GaSelection,
76    /// Crossover operator.
77    pub crossover: GaCrossover,
78    /// Replacement policy.
79    pub replacement: GaReplacement,
80}
81
82impl GaConfig {
83    /// Sensible defaults for small-scale continuous optimization.
84    #[must_use]
85    pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
86        Self {
87            pop_size,
88            genome_dim,
89            bounds: (-5.12, 5.12),
90            mutation_sigma: 0.3,
91            selection: GaSelection::Tournament { size: 2 },
92            crossover: GaCrossover::BlxAlpha { alpha: 0.5 },
93            replacement: GaReplacement::Elitist { elitism_k: 1 },
94        }
95    }
96}
97
98/// Generation-to-generation state carried by [`GeneticAlgorithm`].
99#[derive(Debug, Clone)]
100pub struct GaState<B: Backend> {
101    /// Current population, shape `(pop_size, genome_dim)`.
102    pub population: Tensor<B, 2>,
103    /// Cached fitness for the current population. Empty on init until
104    /// the first `tell` call overwrites it.
105    pub fitness: Vec<f32>,
106    /// Best-so-far genome, shape `(1, genome_dim)`.
107    pub best_genome: Option<Tensor<B, 2>>,
108    /// Best-so-far fitness.
109    pub best_fitness: f32,
110    /// Completed-generation counter.
111    pub generation: usize,
112}
113
114/// Real-valued canonical Genetic Algorithm.
115///
116/// # Example
117///
118/// ```no_run
119/// use burn::backend::NdArray;
120/// use rlevo_evolution::algorithms::ga::{
121///     GaConfig, GaCrossover, GaReplacement, GaSelection, GeneticAlgorithm,
122/// };
123///
124/// let strategy = GeneticAlgorithm::<NdArray>::new();
125/// let params = GaConfig {
126///     pop_size: 64,
127///     genome_dim: 10,
128///     bounds: (-5.12, 5.12),
129///     mutation_sigma: 0.3,
130///     selection: GaSelection::Tournament { size: 2 },
131///     crossover: GaCrossover::BlxAlpha { alpha: 0.5 },
132///     replacement: GaReplacement::Elitist { elitism_k: 2 },
133/// };
134/// let _ = (strategy, params);
135/// ```
136#[derive(Debug, Clone, Copy, Default)]
137pub struct GeneticAlgorithm<B: Backend> {
138    _backend: PhantomData<fn() -> B>,
139}
140
141impl<B: Backend> GeneticAlgorithm<B> {
142    /// Build a new (stateless) strategy object.
143    #[must_use]
144    pub fn new() -> Self {
145        Self {
146            _backend: PhantomData,
147        }
148    }
149
150    fn sample_initial_population(
151        params: &GaConfig,
152        rng: &mut dyn Rng,
153        device: &B::Device,
154    ) -> Tensor<B, 2> {
155        let (lo, hi) = params.bounds;
156        let range = f64::from(hi - lo);
157        let lo_f = f64::from(lo);
158        B::seed(device, rng.next_u64());
159        
160        Tensor::<B, 2>::random(
161            [params.pop_size, params.genome_dim],
162            burn::tensor::Distribution::Uniform(lo_f, lo_f + range),
163            device,
164        )
165    }
166}
167
168impl<B: Backend> Strategy<B> for GeneticAlgorithm<B>
169where
170    B::Device: Clone,
171{
172    type Params = GaConfig;
173    type State = GaState<B>;
174    type Genome = Tensor<B, 2>;
175
176    fn init(&self, params: &GaConfig, rng: &mut dyn Rng, device: &B::Device) -> GaState<B> {
177        let population = Self::sample_initial_population(params, rng, device);
178        GaState {
179            population,
180            fitness: Vec::new(),
181            best_genome: None,
182            best_fitness: f32::INFINITY,
183            generation: 0,
184        }
185    }
186
187    fn ask(
188        &self,
189        params: &GaConfig,
190        state: &GaState<B>,
191        rng: &mut dyn Rng,
192        device: &B::Device,
193    ) -> (Tensor<B, 2>, GaState<B>) {
194        // On the first call, state.fitness is empty; the harness has not
195        // evaluated anyone yet. Return the initial population unchanged.
196        if state.fitness.is_empty() {
197            return (state.population.clone(), state.clone());
198        }
199
200        let GaConfig {
201            pop_size,
202            mutation_sigma,
203            selection,
204            crossover,
205            ..
206        } = params;
207
208        let mut crossover_rng = seed_stream(
209            rng.next_u64(),
210            state.generation as u64,
211            SeedPurpose::Crossover,
212        );
213        let mut mutation_rng = seed_stream(
214            rng.next_u64(),
215            state.generation as u64,
216            SeedPurpose::Mutation,
217        );
218        let mut selection_rng = seed_stream(
219            rng.next_u64(),
220            state.generation as u64,
221            SeedPurpose::Selection,
222        );
223
224        // 1. Select two tournaments' worth of parents.
225        let parents_a = match selection {
226            GaSelection::Tournament { size } => tournament_select(
227                &state.population,
228                &state.fitness,
229                *size,
230                *pop_size,
231                &mut selection_rng,
232                device,
233            ),
234        };
235        let parents_b = match selection {
236            GaSelection::Tournament { size } => tournament_select(
237                &state.population,
238                &state.fitness,
239                *size,
240                *pop_size,
241                &mut selection_rng,
242                device,
243            ),
244        };
245
246        // 2. Recombine.
247        B::seed(device, crossover_rng.next_u64());
248        let offspring = match crossover {
249            GaCrossover::BlxAlpha { alpha } => blx_alpha(parents_a, parents_b, *alpha, device),
250            GaCrossover::Uniform { p } => uniform_crossover(parents_a, parents_b, *p, device),
251        };
252
253        // 3. Mutate.
254        B::seed(device, mutation_rng.next_u64());
255        let offspring = gaussian_mutation(offspring, *mutation_sigma, device);
256
257        // 4. Clamp to bounds.
258        let (lo, hi) = params.bounds;
259        let offspring = offspring.clamp(lo, hi);
260
261        (offspring, state.clone())
262    }
263
264    fn tell(
265        &self,
266        params: &GaConfig,
267        population: Tensor<B, 2>,
268        fitness: Tensor<B, 1>,
269        mut state: GaState<B>,
270        _rng: &mut dyn Rng,
271    ) -> (GaState<B>, StrategyMetrics) {
272        let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
273
274        // First `tell` after `init`: cache fitness for the seed population.
275        if state.fitness.is_empty() {
276            state.fitness.clone_from(&fitness_host);
277            state.generation += 1;
278            update_best(&mut state, &population, &fitness_host);
279            let m = StrategyMetrics::from_host_fitness(
280                state.generation,
281                &fitness_host,
282                state.best_fitness,
283            );
284            state.best_fitness = m.best_fitness_ever;
285            return (state, m);
286        }
287
288        let device = state.population.device();
289        let (next_pop, next_fitness) = match params.replacement {
290            GaReplacement::Generational => generational::<B>(
291                state.population.clone(),
292                &state.fitness,
293                population.clone(),
294                fitness_host.clone(),
295            ),
296            GaReplacement::Elitist { elitism_k } => elitist::<B>(
297                state.population.clone(),
298                &state.fitness,
299                population.clone(),
300                &fitness_host,
301                elitism_k,
302                &device,
303            ),
304        };
305
306        update_best(&mut state, &next_pop, &next_fitness);
307        state.population = next_pop;
308        state.fitness.clone_from(&next_fitness);
309        state.generation += 1;
310        let m =
311            StrategyMetrics::from_host_fitness(state.generation, &next_fitness, state.best_fitness);
312        state.best_fitness = m.best_fitness_ever;
313        (state, m)
314    }
315
316    fn best(&self, state: &GaState<B>) -> Option<(Tensor<B, 2>, f32)> {
317        state
318            .best_genome
319            .as_ref()
320            .map(|g| (g.clone(), state.best_fitness))
321    }
322}
323
324fn update_best<B: Backend>(state: &mut GaState<B>, pop: &Tensor<B, 2>, fitness: &[f32]) {
325    if fitness.is_empty() {
326        return;
327    }
328    let mut best_idx = 0_usize;
329    let mut best_f = fitness[0];
330    for (i, &f) in fitness.iter().enumerate().skip(1) {
331        if f < best_f {
332            best_f = f;
333            best_idx = i;
334        }
335    }
336    if best_f < state.best_fitness {
337        let device = pop.device();
338        #[allow(clippy::cast_possible_wrap)]
339        let idx = Tensor::<B, 1, burn::tensor::Int>::from_data(
340            TensorData::new(vec![best_idx as i64], [1]),
341            &device,
342        );
343        state.best_genome = Some(pop.clone().select(0, idx));
344        state.best_fitness = best_f;
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::fitness::FromFitnessEvaluable;
352    use crate::strategy::EvolutionaryHarness;
353    use burn::backend::NdArray;
354    use rlevo_core::fitness::FitnessEvaluable;
355
356    type TestBackend = NdArray;
357
358    struct Sphere;
359    struct SphereFit;
360    impl FitnessEvaluable for SphereFit {
361        type Individual = Vec<f64>;
362        type Landscape = Sphere;
363        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
364            x.iter().map(|v| v * v).sum()
365        }
366    }
367
368    #[test]
369    fn ga_converges_on_sphere_d2() {
370        let device = Default::default();
371        let strategy = GeneticAlgorithm::<TestBackend>::new();
372        let params = GaConfig {
373            pop_size: 64,
374            genome_dim: 2,
375            bounds: (-5.0, 5.0),
376            mutation_sigma: 0.2,
377            selection: GaSelection::Tournament { size: 2 },
378            crossover: GaCrossover::BlxAlpha { alpha: 0.5 },
379            replacement: GaReplacement::Elitist { elitism_k: 1 },
380        };
381        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
382
383        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
384            strategy, params, fitness_fn, 42, device, 200,
385        );
386        harness.reset();
387        loop {
388            let step = harness.step(());
389            if step.done {
390                break;
391            }
392        }
393        let m = harness.latest_metrics().unwrap();
394        assert!(
395            m.best_fitness_ever < 1e-2,
396            "expected Sphere-D2 convergence, got best_fitness_ever={}",
397            m.best_fitness_ever
398        );
399    }
400}