use std::marker::PhantomData;
use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
use rand::Rng;
use rand::RngExt;
use crate::ops::crossover::binary_uniform_crossover;
use crate::ops::mutation::bit_flip_mutation;
use crate::ops::selection::{tournament_indices_host, truncation_indices_host};
use crate::rng::{SeedPurpose, seed_stream};
use crate::strategy::{Strategy, StrategyMetrics};
use rlevo_core::config::{self, ConfigError, ConstraintKind, Validate};
use rlevo_core::probability::Probability;
#[derive(Debug, Clone)]
pub struct BinaryGaConfig {
pub pop_size: usize,
pub genome_dim: usize,
pub mutation_rate: Probability,
pub crossover_p: Probability,
pub tournament_size: usize,
pub elitism_k: usize,
}
impl BinaryGaConfig {
#[must_use]
pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
Self {
pop_size,
genome_dim,
#[allow(clippy::cast_precision_loss)]
mutation_rate: Probability::new(1.0 / genome_dim as f32),
crossover_p: Probability::new(0.5),
tournament_size: 2,
elitism_k: 1,
}
}
}
impl Validate for BinaryGaConfig {
fn validate(&self) -> Result<(), ConfigError> {
const C: &str = "BinaryGaConfig";
config::at_least(C, "pop_size", self.pop_size, 1)?;
config::nonzero(C, "genome_dim", self.genome_dim)?;
config::at_least(C, "tournament_size", self.tournament_size, 1)?;
if self.tournament_size > self.pop_size {
return Err(ConfigError {
config: C,
field: "tournament_size",
kind: ConstraintKind::Custom("tournament_size must not exceed pop_size"),
});
}
if self.elitism_k > self.pop_size {
return Err(ConfigError {
config: C,
field: "elitism_k",
kind: ConstraintKind::Custom("elitism_k must not exceed pop_size"),
});
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct BinaryGaState<B: Backend> {
pub population: Tensor<B, 2, Int>,
pub fitness: Vec<f32>,
pub best_genome: Option<Tensor<B, 2, Int>>,
pub best_fitness: f32,
pub generation: usize,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct BinaryGeneticAlgorithm<B: Backend> {
_backend: PhantomData<fn() -> B>,
}
impl<B: Backend> BinaryGeneticAlgorithm<B> {
#[must_use]
pub fn new() -> Self {
Self {
_backend: PhantomData,
}
}
fn sample_initial_population(
params: &BinaryGaConfig,
rng: &mut dyn Rng,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> Tensor<B, 2, Int> {
let pop = params.pop_size;
let genome_dim = params.genome_dim;
let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
let mut rows = Vec::with_capacity(pop * genome_dim);
for _ in 0..pop * genome_dim {
rows.push(stream.random::<f32>());
}
let u = Tensor::<B, 2>::from_data(TensorData::new(rows, [pop, genome_dim]), device);
u.lower_elem(0.5).int()
}
}
impl<B: Backend> Strategy<B> for BinaryGeneticAlgorithm<B>
where
B::Device: Clone,
{
type Params = BinaryGaConfig;
type State = BinaryGaState<B>;
type Genome = Tensor<B, 2, Int>;
fn init(
&self,
params: &BinaryGaConfig,
rng: &mut dyn Rng,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> BinaryGaState<B> {
debug_assert!(
params.validate().is_ok(),
"invalid BinaryGaConfig reached init: {params:?}"
);
BinaryGaState {
population: Self::sample_initial_population(params, rng, device),
fitness: Vec::new(),
best_genome: None,
best_fitness: f32::NEG_INFINITY,
generation: 0,
}
}
fn ask(
&self,
params: &BinaryGaConfig,
state: &BinaryGaState<B>,
rng: &mut dyn Rng,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> (Tensor<B, 2, Int>, BinaryGaState<B>) {
if state.fitness.is_empty() {
return (state.population.clone(), state.clone());
}
let mut selection_rng = seed_stream(
rng.next_u64(),
state.generation as u64,
SeedPurpose::Selection,
);
let mut crossover_rng = seed_stream(
rng.next_u64(),
state.generation as u64,
SeedPurpose::Crossover,
);
let mut mutation_rng = seed_stream(
rng.next_u64(),
state.generation as u64,
SeedPurpose::Mutation,
);
let idx_a = tournament_indices_host(
&state.fitness,
params.tournament_size,
params.pop_size,
&mut selection_rng,
);
let idx_b = tournament_indices_host(
&state.fitness,
params.tournament_size,
params.pop_size,
&mut selection_rng,
);
let parents_a = state.population.clone().select(
0,
Tensor::<B, 1, Int>::from_data(TensorData::new(idx_a, [params.pop_size]), device),
);
let parents_b = state.population.clone().select(
0,
Tensor::<B, 1, Int>::from_data(TensorData::new(idx_b, [params.pop_size]), device),
);
let offspring = binary_uniform_crossover(
parents_a,
parents_b,
params.crossover_p,
&mut crossover_rng,
device,
);
let offspring =
bit_flip_mutation(offspring, params.mutation_rate, &mut mutation_rng, device);
(offspring, state.clone())
}
fn tell(
&self,
params: &BinaryGaConfig,
offspring: Tensor<B, 2, Int>,
fitness: Tensor<B, 1>,
mut state: BinaryGaState<B>,
_rng: &mut dyn Rng,
) -> (BinaryGaState<B>, StrategyMetrics) {
let fitness_host = fitness
.into_data()
.into_vec::<f32>()
.expect("fitness tensor must be readable as f32");
let device = offspring.device();
if state.fitness.is_empty() {
state.fitness.clone_from(&fitness_host);
state.generation += 1;
update_best(&mut state, &offspring, &fitness_host);
let m = StrategyMetrics::from_host_fitness(
state.generation,
&fitness_host,
state.best_fitness,
);
state.best_fitness = m.best_fitness_ever();
state.population = offspring;
return (state, m);
}
let pop_size = params.pop_size;
let k = params.elitism_k.min(pop_size);
let elite_idx = truncation_indices_host(&state.fitness, k);
let elites = state.population.clone().select(
0,
Tensor::<B, 1, Int>::from_data(TensorData::new(elite_idx.clone(), [k]), &device),
);
let n_off_keep = pop_size - k;
let off_keep_idx = truncation_indices_host(&fitness_host, n_off_keep);
let kept_off = offspring.clone().select(
0,
Tensor::<B, 1, Int>::from_data(
TensorData::new(off_keep_idx.clone(), [n_off_keep]),
&device,
),
);
let next_pop = Tensor::cat(vec![elites, kept_off], 0);
let mut next_fit = Vec::with_capacity(pop_size);
for i in elite_idx {
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
next_fit.push(state.fitness[i as usize]);
}
for i in off_keep_idx {
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
next_fit.push(fitness_host[i as usize]);
}
update_best(&mut state, &next_pop, &next_fit);
state.population = next_pop;
state.fitness.clone_from(&next_fit);
state.generation += 1;
let m = StrategyMetrics::from_host_fitness(state.generation, &next_fit, state.best_fitness);
state.best_fitness = m.best_fitness_ever();
(state, m)
}
fn best(&self, state: &BinaryGaState<B>) -> Option<(Tensor<B, 2, Int>, f32)> {
state
.best_genome
.as_ref()
.map(|g| (g.clone(), state.best_fitness))
}
}
fn update_best<B: Backend>(state: &mut BinaryGaState<B>, pop: &Tensor<B, 2, Int>, fitness: &[f32]) {
if fitness.is_empty() {
return;
}
let mut best_idx = 0usize;
let mut best_f = fitness[0];
for (i, &f) in fitness.iter().enumerate().skip(1) {
if f > best_f {
best_f = f;
best_idx = i;
}
}
if best_f > state.best_fitness {
let device = pop.device();
#[allow(clippy::cast_possible_wrap)]
let idx =
Tensor::<B, 1, Int>::from_data(TensorData::new(vec![best_idx as i64], [1]), &device);
state.best_genome = Some(pop.clone().select(0, idx));
state.best_fitness = best_f;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fitness::BatchFitnessFn;
use crate::strategy::EvolutionaryHarness;
use burn::backend::Flex;
type TestBackend = Flex;
#[test]
fn default_config_validates() {
assert!(BinaryGaConfig::default_for(32, 16).validate().is_ok());
}
#[test]
fn rejects_elitism_larger_than_pop() {
let mut cfg = BinaryGaConfig::default_for(8, 16);
cfg.elitism_k = 16;
assert_eq!(cfg.validate().unwrap_err().field, "elitism_k");
}
struct OneMax {
dim: usize,
}
impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2, Int>> for OneMax {
fn evaluate_batch(
&mut self,
population: &Tensor<B, 2, Int>,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> Tensor<B, 1> {
let dims = population.dims();
let pop_size = dims[0];
let data = population
.clone()
.into_data()
.into_vec::<i32>()
.expect("genome host-read of a tensor this test just built");
let mut fitness = Vec::with_capacity(pop_size);
for row in 0..pop_size {
let mut ones = 0_u32;
for col in 0..self.dim {
if data[row * self.dim + col] != 0 {
ones += 1;
}
}
#[allow(clippy::cast_precision_loss)]
fitness.push(ones as f32);
}
Tensor::<B, 1>::from_data(TensorData::new(fitness, [pop_size]), device)
}
fn sense(&self) -> rlevo_core::objective::ObjectiveSense {
rlevo_core::objective::ObjectiveSense::Maximize
}
}
#[test]
fn binary_ga_solves_onemax() {
let device = Default::default();
let dim = 16;
let params = BinaryGaConfig::default_for(32, dim);
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
BinaryGeneticAlgorithm::<TestBackend>::new(),
params,
OneMax { dim },
7,
device,
200,
)
.expect("valid params");
harness.reset();
loop {
if harness.step(()).done {
break;
}
}
let best = harness.latest_metrics().unwrap().best_fitness_ever();
#[allow(clippy::cast_precision_loss)]
let optimum = dim as f32;
approx::assert_relative_eq!(best, optimum, epsilon = 1e-6);
}
fn ask_over_uniform_population(rate: f32, bit: i32) -> Vec<i32> {
use rand::SeedableRng;
use rand::rngs::StdRng;
let device = Default::default();
let (pop, dim) = (4usize, 8usize);
let mut params = BinaryGaConfig::default_for(pop, dim);
params.mutation_rate = Probability::new(rate);
let strategy = BinaryGeneticAlgorithm::<TestBackend>::new();
let population = Tensor::<TestBackend, 2, Int>::from_data(
TensorData::new(vec![bit; pop * dim], [pop, dim]),
&device,
);
let state = BinaryGaState {
population,
fitness: vec![1.0; pop],
best_genome: None,
best_fitness: f32::NEG_INFINITY,
generation: 1,
};
let mut rng = StdRng::seed_from_u64(0);
let (offspring, _) = strategy.ask(¶ms, &state, &mut rng, &device);
offspring
.into_data()
.into_vec::<i32>()
.expect("offspring host-read of a tensor this test just built")
}
#[test]
fn mutation_rate_zero_flips_no_bits() {
let bits = ask_over_uniform_population(0.0, 0);
assert!(
bits.iter().all(|&b| b == 0),
"rate 0.0 must leave every offspring bit at the parent value, got {bits:?}"
);
}
#[test]
fn mutation_rate_one_flips_every_bit() {
let bits = ask_over_uniform_population(1.0, 0);
assert!(
bits.iter().all(|&b| b == 1),
"rate 1.0 must flip every 0→1, got {bits:?}"
);
}
#[test]
fn elitism_k_equals_pop_minus_one_keeps_top_parents_and_one_offspring() {
use rand::SeedableRng;
use rand::rngs::StdRng;
let device = Default::default();
let (pop, dim) = (4usize, 4usize);
let mut params = BinaryGaConfig::default_for(pop, dim);
params.elitism_k = pop - 1; let strategy = BinaryGeneticAlgorithm::<TestBackend>::new();
let parent_pop = Tensor::<TestBackend, 2, Int>::from_data(
TensorData::new(vec![0i32; pop * dim], [pop, dim]),
&device,
);
let state = BinaryGaState {
population: parent_pop,
fitness: vec![1.0, 2.0, 3.0, 4.0],
best_genome: None,
best_fitness: 4.0,
generation: 1,
};
let offspring = Tensor::<TestBackend, 2, Int>::from_data(
TensorData::new(vec![1i32; pop * dim], [pop, dim]),
&device,
);
let off_fitness = Tensor::<TestBackend, 1>::from_data(
TensorData::new(vec![10.0f32, 0.0, 0.0, 0.0], [pop]),
&device,
);
let mut rng = StdRng::seed_from_u64(0);
let (next, m) = strategy.tell(¶ms, offspring, off_fitness, state, &mut rng);
assert_eq!(next.population.dims(), [pop, dim]);
assert_eq!(next.fitness, vec![4.0, 3.0, 2.0, 10.0]);
approx::assert_relative_eq!(m.best_fitness_ever(), 10.0, epsilon = 1e-6);
}
}