Skip to main content

rlevo_evolution/algorithms/
ga_binary.rs

1//! Binary-coded Genetic Algorithm.
2//!
3//! A canonical GA over `Tensor<B, 2, Int>` populations where every gene is
4//! restricted to `{0, 1}`:
5//!
6//! 1. Evaluate the current population (done externally by the harness).
7//! 2. Select two independent sets of parents via
8//!    [`crate::ops::selection::tournament_indices_host`] (k-tournament).
9//! 3. Recombine via [`crate::ops::crossover::binary_uniform_crossover`]
10//!    (per-gene coin flip with probability `crossover_p`).
11//! 4. Mutate via [`crate::ops::mutation::bit_flip_mutation`]
12//!    (per-gene flip with probability `mutation_rate`).
13//! 5. Replace via fixed elitist policy: the `elitism_k` best parents
14//!    survive; the remaining `pop_size − elitism_k` slots are filled by
15//!    the best offspring.
16//!
17//! Unlike [`crate::algorithms::ga::GeneticAlgorithm`], there is no
18//! enum-selectable replacement policy — only elitist replacement is
19//! supported. Extend [`BinaryGaConfig`] and the `tell` impl if a
20//! generational variant is needed.
21//!
22//! All random draws go through [`crate::rng::seed_stream`] — never
23//! `B::seed` + `Tensor::random` — so per-run results are reproducible
24//! across thread schedules.
25//!
26//! # Fitness convention
27//!
28//! Fitness is treated as cost (lower is better), matching all other
29//! strategies in this crate. Maximization benchmarks like `OneMax` must be
30//! phrased as minimization before being plugged in, e.g.:
31//! `cost = D − count_ones(genome)`.
32//!
33//! # References
34//!
35//! - Holland (1975), *Adaptation in Natural and Artificial Systems*.
36//! - Goldberg (1989), *Genetic Algorithms in Search, Optimization, and
37//!   Machine Learning*.
38
39use std::marker::PhantomData;
40
41use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
42use rand::Rng;
43use rand::RngExt;
44
45use crate::ops::crossover::binary_uniform_crossover;
46use crate::ops::mutation::bit_flip_mutation;
47use crate::ops::selection::{tournament_indices_host, truncation_indices_host};
48use crate::rng::{SeedPurpose, seed_stream};
49use crate::strategy::{Strategy, StrategyMetrics};
50
51/// Static configuration for a [`BinaryGeneticAlgorithm`] run.
52#[derive(Debug, Clone)]
53pub struct BinaryGaConfig {
54    /// Population size.
55    pub pop_size: usize,
56    /// Genome dimensionality.
57    pub genome_dim: usize,
58    /// Probability of bit flip per gene.
59    pub mutation_rate: f32,
60    /// Probability of taking parent A's bit in uniform crossover.
61    pub crossover_p: f32,
62    /// Tournament size for parent selection.
63    pub tournament_size: usize,
64    /// Number of elites carried over to the next generation.
65    pub elitism_k: usize,
66}
67
68impl BinaryGaConfig {
69    /// Sensible defaults for small-scale binary optimization.
70    ///
71    /// Mutation rate defaults to `1 / D` (the standard "one expected
72    /// flip per genome" rule from the binary-GA literature).
73    #[must_use]
74    pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
75        Self {
76            pop_size,
77            genome_dim,
78            #[allow(clippy::cast_precision_loss)]
79            mutation_rate: 1.0 / genome_dim as f32,
80            crossover_p: 0.5,
81            tournament_size: 2,
82            elitism_k: 1,
83        }
84    }
85}
86
87/// State for [`BinaryGeneticAlgorithm`].
88#[derive(Debug, Clone)]
89pub struct BinaryGaState<B: Backend> {
90    /// Current population, shape `(pop_size, D)`.
91    pub population: Tensor<B, 2, Int>,
92    /// Host-side fitness cache for the current population. Empty on
93    /// init until the first `tell` call populates it.
94    pub fitness: Vec<f32>,
95    /// Best-so-far genome.
96    pub best_genome: Option<Tensor<B, 2, Int>>,
97    /// Best-so-far fitness.
98    pub best_fitness: f32,
99    /// Generation counter.
100    pub generation: usize,
101}
102
103/// Binary-coded canonical Genetic Algorithm.
104///
105/// # Example
106///
107/// ```no_run
108/// use burn::backend::Flex;
109/// use rlevo_evolution::algorithms::ga_binary::{BinaryGaConfig, BinaryGeneticAlgorithm};
110///
111/// let strategy = BinaryGeneticAlgorithm::<Flex>::new();
112/// let params = BinaryGaConfig::default_for(32, 16);
113/// let _ = (strategy, params);
114/// ```
115#[derive(Debug, Clone, Copy, Default)]
116pub struct BinaryGeneticAlgorithm<B: Backend> {
117    _backend: PhantomData<fn() -> B>,
118}
119
120impl<B: Backend> BinaryGeneticAlgorithm<B> {
121    /// Builds a new (stateless) strategy object.
122    #[must_use]
123    pub fn new() -> Self {
124        Self {
125            _backend: PhantomData,
126        }
127    }
128
129    fn sample_initial_population(
130        params: &BinaryGaConfig,
131        rng: &mut dyn Rng,
132        device: &<B as burn::tensor::backend::BackendTypes>::Device,
133    ) -> Tensor<B, 2, Int> {
134        // Host-sample U[0,1) from a deterministic `seed_stream` rather than
135        // the process-wide Flex RNG (`B::seed` + `Tensor::random`), whose
136        // draws interleave with sibling tests under the parallel runner and
137        // are not reproducible across thread schedules.
138        let pop = params.pop_size;
139        let genome_dim = params.genome_dim;
140        let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
141        let mut rows = Vec::with_capacity(pop * genome_dim);
142        for _ in 0..pop * genome_dim {
143            rows.push(stream.random::<f32>());
144        }
145        let u = Tensor::<B, 2>::from_data(TensorData::new(rows, [pop, genome_dim]), device);
146        u.lower_elem(0.5).int()
147    }
148}
149
150impl<B: Backend> Strategy<B> for BinaryGeneticAlgorithm<B>
151where
152    B::Device: Clone,
153{
154    type Params = BinaryGaConfig;
155    type State = BinaryGaState<B>;
156    type Genome = Tensor<B, 2, Int>;
157
158    /// Build the initial state.
159    ///
160    /// Samples an `(pop_size, D)` binary population uniformly at random
161    /// (each gene independently `Bernoulli(0.5)`) using a host RNG derived
162    /// from `rng`. Sets `fitness` to empty and `best_fitness` to
163    /// `f32::INFINITY`; the first [`tell`](Self::tell) call populates both.
164    fn init(
165        &self,
166        params: &BinaryGaConfig,
167        rng: &mut dyn Rng,
168        device: &<B as burn::tensor::backend::BackendTypes>::Device,
169    ) -> BinaryGaState<B> {
170        BinaryGaState {
171            population: Self::sample_initial_population(params, rng, device),
172            fitness: Vec::new(),
173            best_genome: None,
174            best_fitness: f32::INFINITY,
175            generation: 0,
176        }
177    }
178
179    /// Propose the next offspring population.
180    ///
181    /// On the very first call (before any [`tell`](Self::tell)), `state.fitness`
182    /// is empty — the harness has not evaluated the seed population yet. In
183    /// that case the unchanged seed population is returned so the harness can
184    /// evaluate and pass it back to `tell`.
185    ///
186    /// On subsequent calls the method runs one full selection → crossover →
187    /// mutation pipeline, deriving three independent host sub-streams from
188    /// `rng` via [`crate::rng::seed_stream`]:
189    ///
190    /// - `SeedPurpose::Selection` — two independent tournament draws
191    ///   (parents A and parents B);
192    /// - `SeedPurpose::Crossover` — per-gene coin flip
193    ///   (probability `crossover_p`);
194    /// - `SeedPurpose::Mutation` — per-gene bit-flip
195    ///   (probability `mutation_rate`).
196    fn ask(
197        &self,
198        params: &BinaryGaConfig,
199        state: &BinaryGaState<B>,
200        rng: &mut dyn Rng,
201        device: &<B as burn::tensor::backend::BackendTypes>::Device,
202    ) -> (Tensor<B, 2, Int>, BinaryGaState<B>) {
203        if state.fitness.is_empty() {
204            return (state.population.clone(), state.clone());
205        }
206
207        let mut selection_rng = seed_stream(
208            rng.next_u64(),
209            state.generation as u64,
210            SeedPurpose::Selection,
211        );
212        let mut crossover_rng = seed_stream(
213            rng.next_u64(),
214            state.generation as u64,
215            SeedPurpose::Crossover,
216        );
217        let mut mutation_rng = seed_stream(
218            rng.next_u64(),
219            state.generation as u64,
220            SeedPurpose::Mutation,
221        );
222
223        let idx_a = tournament_indices_host(
224            &state.fitness,
225            params.tournament_size,
226            params.pop_size,
227            &mut selection_rng,
228        );
229        let idx_b = tournament_indices_host(
230            &state.fitness,
231            params.tournament_size,
232            params.pop_size,
233            &mut selection_rng,
234        );
235        let parents_a = state.population.clone().select(
236            0,
237            Tensor::<B, 1, Int>::from_data(TensorData::new(idx_a, [params.pop_size]), device),
238        );
239        let parents_b = state.population.clone().select(
240            0,
241            Tensor::<B, 1, Int>::from_data(TensorData::new(idx_b, [params.pop_size]), device),
242        );
243
244        let offspring = binary_uniform_crossover(
245            parents_a,
246            parents_b,
247            params.crossover_p,
248            &mut crossover_rng,
249            device,
250        );
251
252        let offspring = bit_flip_mutation(offspring, params.mutation_rate, &mut mutation_rng, device);
253
254        (offspring, state.clone())
255    }
256
257    /// Consume offspring fitness and produce the next generation's state.
258    ///
259    /// The first call (when `state.fitness` is empty) caches the seed
260    /// population's fitness and increments the generation counter; no
261    /// replacement is performed.
262    ///
263    /// On subsequent calls the method performs elitist replacement: the
264    /// `elitism_k` lowest-cost parents survive directly, and the remaining
265    /// `pop_size − elitism_k` slots are filled with the best offspring.
266    /// Both selections use [`crate::ops::selection::truncation_indices_host`].
267    ///
268    /// `fitness` must have shape `(pop_size,)` with values in the
269    /// minimization (cost) convention — lower is better.
270    fn tell(
271        &self,
272        params: &BinaryGaConfig,
273        offspring: Tensor<B, 2, Int>,
274        fitness: Tensor<B, 1>,
275        mut state: BinaryGaState<B>,
276        _rng: &mut dyn Rng,
277    ) -> (BinaryGaState<B>, StrategyMetrics) {
278        let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
279        let device = offspring.device();
280
281        // First `tell`: initial population just evaluated.
282        if state.fitness.is_empty() {
283            state.fitness.clone_from(&fitness_host);
284            state.generation += 1;
285            update_best(&mut state, &offspring, &fitness_host);
286            let m = StrategyMetrics::from_host_fitness(
287                state.generation,
288                &fitness_host,
289                state.best_fitness,
290            );
291            state.best_fitness = m.best_fitness_ever;
292            state.population = offspring;
293            return (state, m);
294        }
295
296        // Elitist replacement on (pop, fitness) × (offspring, fitness).
297        let pop_size = params.pop_size;
298        let k = params.elitism_k.min(pop_size);
299
300        let elite_idx = truncation_indices_host(&state.fitness, k);
301        let elites = state.population.clone().select(
302            0,
303            Tensor::<B, 1, Int>::from_data(TensorData::new(elite_idx.clone(), [k]), &device),
304        );
305        let n_off_keep = pop_size - k;
306        let off_keep_idx = truncation_indices_host(&fitness_host, n_off_keep);
307        let kept_off = offspring.clone().select(
308            0,
309            Tensor::<B, 1, Int>::from_data(
310                TensorData::new(off_keep_idx.clone(), [n_off_keep]),
311                &device,
312            ),
313        );
314        let next_pop = Tensor::cat(vec![elites, kept_off], 0);
315        let mut next_fit = Vec::with_capacity(pop_size);
316        for i in elite_idx {
317            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
318            next_fit.push(state.fitness[i as usize]);
319        }
320        for i in off_keep_idx {
321            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
322            next_fit.push(fitness_host[i as usize]);
323        }
324
325        update_best(&mut state, &next_pop, &next_fit);
326        state.population = next_pop;
327        state.fitness.clone_from(&next_fit);
328        state.generation += 1;
329        let m = StrategyMetrics::from_host_fitness(state.generation, &next_fit, state.best_fitness);
330        state.best_fitness = m.best_fitness_ever;
331        (state, m)
332    }
333
334    /// Return the best-so-far genome and its fitness.
335    ///
336    /// Returns `None` before the first [`tell`](Self::tell) call.
337    /// The fitness value uses the minimization convention (lower is better).
338    fn best(&self, state: &BinaryGaState<B>) -> Option<(Tensor<B, 2, Int>, f32)> {
339        state
340            .best_genome
341            .as_ref()
342            .map(|g| (g.clone(), state.best_fitness))
343    }
344}
345
346fn update_best<B: Backend>(state: &mut BinaryGaState<B>, pop: &Tensor<B, 2, Int>, fitness: &[f32]) {
347    if fitness.is_empty() {
348        return;
349    }
350    let mut best_idx = 0usize;
351    let mut best_f = fitness[0];
352    for (i, &f) in fitness.iter().enumerate().skip(1) {
353        if f < best_f {
354            best_f = f;
355            best_idx = i;
356        }
357    }
358    if best_f < state.best_fitness {
359        let device = pop.device();
360        #[allow(clippy::cast_possible_wrap)]
361        let idx =
362            Tensor::<B, 1, Int>::from_data(TensorData::new(vec![best_idx as i64], [1]), &device);
363        state.best_genome = Some(pop.clone().select(0, idx));
364        state.best_fitness = best_f;
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use crate::fitness::BatchFitnessFn;
372    use crate::strategy::EvolutionaryHarness;
373    use burn::backend::Flex;
374    type TestBackend = Flex;
375
376    /// `OneMax` phrased as minimization: `cost = D − count_ones`.
377    struct OneMaxCost {
378        dim: usize,
379    }
380
381    impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2, Int>> for OneMaxCost {
382        fn evaluate_batch(
383            &mut self,
384            population: &Tensor<B, 2, Int>,
385            device: &<B as burn::tensor::backend::BackendTypes>::Device,
386        ) -> Tensor<B, 1> {
387            let dims = population.dims();
388            let pop_size = dims[0];
389            let data = population
390                .clone()
391                .into_data()
392                .into_vec::<i32>()
393                .unwrap_or_default();
394            let mut fitness = Vec::with_capacity(pop_size);
395            for row in 0..pop_size {
396                let mut ones = 0_u32;
397                for col in 0..self.dim {
398                    if data[row * self.dim + col] != 0 {
399                        ones += 1;
400                    }
401                }
402                #[allow(clippy::cast_precision_loss)]
403                let cost = (self.dim as f32) - (ones as f32);
404                fitness.push(cost);
405            }
406            Tensor::<B, 1>::from_data(TensorData::new(fitness, [pop_size]), device)
407        }
408    }
409
410    #[test]
411    fn binary_ga_solves_onemax() {
412        let device = Default::default();
413        let dim = 16;
414        let params = BinaryGaConfig::default_for(32, dim);
415        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
416            BinaryGeneticAlgorithm::<TestBackend>::new(),
417            params,
418            OneMaxCost { dim },
419            7,
420            device,
421            200,
422        );
423        harness.reset();
424        loop {
425            if harness.step(()).done {
426                break;
427            }
428        }
429        let best = harness.latest_metrics().unwrap().best_fitness_ever;
430        // OneMax optimum: cost == 0 (all ones).
431        approx::assert_relative_eq!(best, 0.0, epsilon = 1e-6);
432    }
433}