Skip to main content

rlevo_evolution/algorithms/
ga_binary.rs

1//! Binary-coded Genetic Algorithm.
2//!
3//! Operates on a `Tensor<B, 2, Int>` population where each gene is
4//! restricted to `{0, 1}`. Selection and elitism reuse the host-side
5//! fitness indexing helpers shared with the real-coded GA; crossover
6//! and mutation use the binary-specific operators in
7//! [`crate::ops::crossover::binary_uniform_crossover`] and
8//! [`crate::ops::mutation::bit_flip_mutation`].
9//!
10//! Fitness is treated as cost (lower is better) to match the rest of
11//! the strategies in this crate; benchmarks like `OneMax` must be phrased
12//! as minimization (`D − count_ones`) before being plugged in.
13
14use std::marker::PhantomData;
15
16use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
17use rand::Rng;
18
19use crate::ops::crossover::binary_uniform_crossover;
20use crate::ops::mutation::bit_flip_mutation;
21use crate::ops::selection::{tournament_indices_host, truncation_indices_host};
22use crate::rng::{SeedPurpose, seed_stream};
23use crate::strategy::{Strategy, StrategyMetrics};
24
25/// Static configuration for a [`BinaryGeneticAlgorithm`] run.
26#[derive(Debug, Clone)]
27pub struct BinaryGaConfig {
28    /// Population size.
29    pub pop_size: usize,
30    /// Genome dimensionality.
31    pub genome_dim: usize,
32    /// Probability of bit flip per gene.
33    pub mutation_rate: f32,
34    /// Probability of taking parent A's bit in uniform crossover.
35    pub crossover_p: f32,
36    /// Tournament size for parent selection.
37    pub tournament_size: usize,
38    /// Number of elites carried over to the next generation.
39    pub elitism_k: usize,
40}
41
42impl BinaryGaConfig {
43    /// Sensible defaults for small-scale binary optimization.
44    ///
45    /// Mutation rate defaults to `1 / D` (the standard "one expected
46    /// flip per genome" rule from the binary-GA literature).
47    #[must_use]
48    pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
49        Self {
50            pop_size,
51            genome_dim,
52            #[allow(clippy::cast_precision_loss)]
53            mutation_rate: 1.0 / genome_dim as f32,
54            crossover_p: 0.5,
55            tournament_size: 2,
56            elitism_k: 1,
57        }
58    }
59}
60
61/// State for [`BinaryGeneticAlgorithm`].
62#[derive(Debug, Clone)]
63pub struct BinaryGaState<B: Backend> {
64    /// Current population, shape `(pop_size, D)`.
65    pub population: Tensor<B, 2, Int>,
66    /// Host-side fitness cache for the current population. Empty on
67    /// init until the first `tell` call populates it.
68    pub fitness: Vec<f32>,
69    /// Best-so-far genome.
70    pub best_genome: Option<Tensor<B, 2, Int>>,
71    /// Best-so-far fitness.
72    pub best_fitness: f32,
73    /// Generation counter.
74    pub generation: usize,
75}
76
77/// Binary-coded canonical Genetic Algorithm.
78///
79/// # Example
80///
81/// ```no_run
82/// use burn::backend::NdArray;
83/// use rlevo_evolution::algorithms::ga_binary::{BinaryGaConfig, BinaryGeneticAlgorithm};
84///
85/// let strategy = BinaryGeneticAlgorithm::<NdArray>::new();
86/// let params = BinaryGaConfig::default_for(32, 16);
87/// let _ = (strategy, params);
88/// ```
89#[derive(Debug, Clone, Copy, Default)]
90pub struct BinaryGeneticAlgorithm<B: Backend> {
91    _backend: PhantomData<fn() -> B>,
92}
93
94impl<B: Backend> BinaryGeneticAlgorithm<B> {
95    /// Builds a new (stateless) strategy object.
96    #[must_use]
97    pub fn new() -> Self {
98        Self {
99            _backend: PhantomData,
100        }
101    }
102
103    fn sample_initial_population(
104        params: &BinaryGaConfig,
105        rng: &mut dyn Rng,
106        device: &B::Device,
107    ) -> Tensor<B, 2, Int> {
108        B::seed(device, rng.next_u64());
109        let u = Tensor::<B, 2>::random(
110            [params.pop_size, params.genome_dim],
111            burn::tensor::Distribution::Uniform(0.0, 1.0),
112            device,
113        );
114        u.lower_elem(0.5).int()
115    }
116}
117
118impl<B: Backend> Strategy<B> for BinaryGeneticAlgorithm<B>
119where
120    B::Device: Clone,
121{
122    type Params = BinaryGaConfig;
123    type State = BinaryGaState<B>;
124    type Genome = Tensor<B, 2, Int>;
125
126    fn init(
127        &self,
128        params: &BinaryGaConfig,
129        rng: &mut dyn Rng,
130        device: &B::Device,
131    ) -> BinaryGaState<B> {
132        BinaryGaState {
133            population: Self::sample_initial_population(params, rng, device),
134            fitness: Vec::new(),
135            best_genome: None,
136            best_fitness: f32::INFINITY,
137            generation: 0,
138        }
139    }
140
141    fn ask(
142        &self,
143        params: &BinaryGaConfig,
144        state: &BinaryGaState<B>,
145        rng: &mut dyn Rng,
146        device: &B::Device,
147    ) -> (Tensor<B, 2, Int>, BinaryGaState<B>) {
148        if state.fitness.is_empty() {
149            return (state.population.clone(), state.clone());
150        }
151
152        let mut selection_rng = seed_stream(
153            rng.next_u64(),
154            state.generation as u64,
155            SeedPurpose::Selection,
156        );
157        let mut crossover_rng = seed_stream(
158            rng.next_u64(),
159            state.generation as u64,
160            SeedPurpose::Crossover,
161        );
162        let mut mutation_rng = seed_stream(
163            rng.next_u64(),
164            state.generation as u64,
165            SeedPurpose::Mutation,
166        );
167
168        let idx_a = tournament_indices_host(
169            &state.fitness,
170            params.tournament_size,
171            params.pop_size,
172            &mut selection_rng,
173        );
174        let idx_b = tournament_indices_host(
175            &state.fitness,
176            params.tournament_size,
177            params.pop_size,
178            &mut selection_rng,
179        );
180        let parents_a = state.population.clone().select(
181            0,
182            Tensor::<B, 1, Int>::from_data(TensorData::new(idx_a, [params.pop_size]), device),
183        );
184        let parents_b = state.population.clone().select(
185            0,
186            Tensor::<B, 1, Int>::from_data(TensorData::new(idx_b, [params.pop_size]), device),
187        );
188
189        B::seed(device, crossover_rng.next_u64());
190        let offspring = binary_uniform_crossover(parents_a, parents_b, params.crossover_p, device);
191
192        B::seed(device, mutation_rng.next_u64());
193        let offspring = bit_flip_mutation(offspring, params.mutation_rate, device);
194
195        (offspring, state.clone())
196    }
197
198    fn tell(
199        &self,
200        params: &BinaryGaConfig,
201        offspring: Tensor<B, 2, Int>,
202        fitness: Tensor<B, 1>,
203        mut state: BinaryGaState<B>,
204        _rng: &mut dyn Rng,
205    ) -> (BinaryGaState<B>, StrategyMetrics) {
206        let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
207        let device = offspring.device();
208
209        // First `tell`: initial population just evaluated.
210        if state.fitness.is_empty() {
211            state.fitness.clone_from(&fitness_host);
212            state.generation += 1;
213            update_best(&mut state, &offspring, &fitness_host);
214            let m = StrategyMetrics::from_host_fitness(
215                state.generation,
216                &fitness_host,
217                state.best_fitness,
218            );
219            state.best_fitness = m.best_fitness_ever;
220            state.population = offspring;
221            return (state, m);
222        }
223
224        // Elitist replacement on (pop, fitness) × (offspring, fitness).
225        let pop_size = params.pop_size;
226        let k = params.elitism_k.min(pop_size);
227
228        let elite_idx = truncation_indices_host(&state.fitness, k);
229        let elites = state.population.clone().select(
230            0,
231            Tensor::<B, 1, Int>::from_data(TensorData::new(elite_idx.clone(), [k]), &device),
232        );
233        let n_off_keep = pop_size - k;
234        let off_keep_idx = truncation_indices_host(&fitness_host, n_off_keep);
235        let kept_off = offspring.clone().select(
236            0,
237            Tensor::<B, 1, Int>::from_data(
238                TensorData::new(off_keep_idx.clone(), [n_off_keep]),
239                &device,
240            ),
241        );
242        let next_pop = Tensor::cat(vec![elites, kept_off], 0);
243        let mut next_fit = Vec::with_capacity(pop_size);
244        for i in elite_idx {
245            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
246            next_fit.push(state.fitness[i as usize]);
247        }
248        for i in off_keep_idx {
249            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
250            next_fit.push(fitness_host[i as usize]);
251        }
252
253        update_best(&mut state, &next_pop, &next_fit);
254        state.population = next_pop;
255        state.fitness.clone_from(&next_fit);
256        state.generation += 1;
257        let m = StrategyMetrics::from_host_fitness(state.generation, &next_fit, state.best_fitness);
258        state.best_fitness = m.best_fitness_ever;
259        (state, m)
260    }
261
262    fn best(&self, state: &BinaryGaState<B>) -> Option<(Tensor<B, 2, Int>, f32)> {
263        state
264            .best_genome
265            .as_ref()
266            .map(|g| (g.clone(), state.best_fitness))
267    }
268}
269
270fn update_best<B: Backend>(state: &mut BinaryGaState<B>, pop: &Tensor<B, 2, Int>, fitness: &[f32]) {
271    if fitness.is_empty() {
272        return;
273    }
274    let mut best_idx = 0usize;
275    let mut best_f = fitness[0];
276    for (i, &f) in fitness.iter().enumerate().skip(1) {
277        if f < best_f {
278            best_f = f;
279            best_idx = i;
280        }
281    }
282    if best_f < state.best_fitness {
283        let device = pop.device();
284        #[allow(clippy::cast_possible_wrap)]
285        let idx =
286            Tensor::<B, 1, Int>::from_data(TensorData::new(vec![best_idx as i64], [1]), &device);
287        state.best_genome = Some(pop.clone().select(0, idx));
288        state.best_fitness = best_f;
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::fitness::BatchFitnessFn;
296    use crate::strategy::EvolutionaryHarness;
297    use burn::backend::NdArray;
298    type TestBackend = NdArray;
299
300    /// `OneMax` phrased as minimization: `cost = D − count_ones`.
301    struct OneMaxCost {
302        dim: usize,
303    }
304
305    impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2, Int>> for OneMaxCost {
306        fn evaluate_batch(
307            &mut self,
308            population: &Tensor<B, 2, Int>,
309            device: &B::Device,
310        ) -> Tensor<B, 1> {
311            let dims = population.shape().dims;
312            let pop_size = dims[0];
313            let data = population
314                .clone()
315                .into_data()
316                .into_vec::<i64>()
317                .unwrap_or_default();
318            let mut fitness = Vec::with_capacity(pop_size);
319            for row in 0..pop_size {
320                let mut ones = 0_u32;
321                for col in 0..self.dim {
322                    if data[row * self.dim + col] != 0 {
323                        ones += 1;
324                    }
325                }
326                #[allow(clippy::cast_precision_loss)]
327                let cost = (self.dim as f32) - (ones as f32);
328                fitness.push(cost);
329            }
330            Tensor::<B, 1>::from_data(TensorData::new(fitness, [pop_size]), device)
331        }
332    }
333
334    #[test]
335    fn binary_ga_solves_onemax() {
336        let device = Default::default();
337        let dim = 16;
338        let params = BinaryGaConfig::default_for(32, dim);
339        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
340            BinaryGeneticAlgorithm::<TestBackend>::new(),
341            params,
342            OneMaxCost { dim },
343            7,
344            device,
345            200,
346        );
347        harness.reset();
348        loop {
349            if harness.step(()).done {
350                break;
351            }
352        }
353        let best = harness.latest_metrics().unwrap().best_fitness_ever;
354        // OneMax optimum: cost == 0 (all ones).
355        approx::assert_relative_eq!(best, 0.0, epsilon = 1e-6);
356    }
357}