use std::marker::PhantomData;
use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
use rand::Rng;
use rand::RngExt;
use rlevo_core::bounds::Bounds;
use rlevo_core::config::{self, ConfigError, ConstraintKind, Validate};
use super::len_matches_pop;
use crate::ops::selection::{argmax_host, tournament_indices_host};
use crate::rng::{SeedPurpose, seed_stream};
use crate::strategy::{Strategy, StrategyMetrics};
#[derive(Debug, Clone)]
pub struct AbcConfig {
pub pop_size: usize,
pub genome_dim: usize,
pub bounds: Bounds,
pub limit: usize,
pub tournament_size: usize,
}
impl AbcConfig {
#[must_use]
pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
Self {
pop_size,
genome_dim,
bounds: Bounds::new(-5.12, 5.12),
limit: (pop_size * genome_dim) / 2,
tournament_size: 3,
}
}
}
impl Validate for AbcConfig {
fn validate(&self) -> Result<(), ConfigError> {
const C: &str = "AbcConfig";
config::at_least(C, "pop_size", self.pop_size, 2)?;
config::nonzero(C, "genome_dim", self.genome_dim)?;
config::at_least(C, "limit", self.limit, 1)?;
config::at_least(C, "tournament_size", self.tournament_size, 1)?;
if self.tournament_size > 2 * self.pop_size {
return Err(ConfigError {
config: C,
field: "tournament_size",
kind: ConstraintKind::Custom("tournament_size must not exceed 2 * pop_size"),
});
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct AbcState<B: Backend> {
colony: Tensor<B, 2>,
fitness: Vec<f32>,
trial: Vec<usize>,
target_of_candidate: Vec<usize>,
best_genome: Option<Tensor<B, 2>>,
best_fitness: f32,
generation: usize,
}
impl<B: Backend> AbcState<B> {
#[allow(clippy::too_many_arguments)]
pub fn try_new(
colony: Tensor<B, 2>,
fitness: Vec<f32>,
trial: Vec<usize>,
target_of_candidate: Vec<usize>,
best_genome: Option<Tensor<B, 2>>,
best_fitness: f32,
generation: usize,
) -> Result<Self, ConfigError> {
let pop = colony.dims()[0];
config::nonzero("AbcState", "pop_size", pop)?;
len_matches_pop("AbcState", "fitness", pop, fitness.len())?;
len_matches_pop("AbcState", "trial", pop, trial.len())?;
if !target_of_candidate.is_empty() && target_of_candidate.len() != 2 * pop {
return Err(ConfigError {
config: "AbcState",
field: "target_of_candidate",
kind: ConstraintKind::Custom("length must equal 2 * pop_size"),
});
}
Ok(Self {
colony,
fitness,
trial,
target_of_candidate,
best_genome,
best_fitness,
generation,
})
}
#[must_use]
pub fn colony(&self) -> &Tensor<B, 2> {
&self.colony
}
#[must_use]
pub fn fitness(&self) -> &[f32] {
&self.fitness
}
#[must_use]
pub fn trial(&self) -> &[usize] {
&self.trial
}
#[must_use]
pub fn target_of_candidate(&self) -> &[usize] {
&self.target_of_candidate
}
#[must_use]
pub fn best_genome(&self) -> Option<&Tensor<B, 2>> {
self.best_genome.as_ref()
}
#[must_use]
pub fn best_fitness(&self) -> f32 {
self.best_fitness
}
#[must_use]
pub fn generation(&self) -> usize {
self.generation
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ArtificialBeeColony<B: Backend> {
_backend: PhantomData<fn() -> B>,
}
impl<B: Backend> ArtificialBeeColony<B> {
#[must_use]
pub fn new() -> Self {
Self {
_backend: PhantomData,
}
}
#[allow(clippy::too_many_arguments)]
fn build_candidates(
targets: &[usize],
neighbors: &[usize],
dims: &[usize],
phi: &[f32],
colony: &Tensor<B, 2>,
pop_size: usize,
genome_dim: usize,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> Tensor<B, 2> {
#[allow(clippy::cast_possible_wrap)]
let target_idx: Vec<i64> = targets.iter().map(|&i| i as i64).collect();
let _ = pop_size; let n_cand = targets.len();
let target_tensor =
Tensor::<B, 1, Int>::from_data(TensorData::new(target_idx, [n_cand]), device);
let base = colony.clone().select(0, target_tensor);
#[allow(clippy::cast_possible_wrap)]
let neighbor_idx: Vec<i64> = neighbors.iter().map(|&i| i as i64).collect();
let neighbor_tensor =
Tensor::<B, 1, Int>::from_data(TensorData::new(neighbor_idx, [n_cand]), device);
let neighbor_rows = colony.clone().select(0, neighbor_tensor);
let mut mask = vec![0i64; n_cand * genome_dim];
for (row, &j) in dims.iter().enumerate() {
mask[row * genome_dim + j] = 1;
}
let mask_bool =
Tensor::<B, 2, Int>::from_data(TensorData::new(mask, [n_cand, genome_dim]), device)
.equal_elem(1);
let phi_row = Tensor::<B, 1>::from_data(TensorData::new(phi.to_vec(), [n_cand]), device)
.unsqueeze_dim::<2>(1)
.expand([n_cand, genome_dim]);
let delta = phi_row.mul(base.clone() - neighbor_rows);
let perturbed = base.clone() + delta;
base.mask_where(mask_bool, perturbed)
}
}
impl<B: Backend> Strategy<B> for ArtificialBeeColony<B>
where
B::Device: Clone,
{
type Params = AbcConfig;
type State = AbcState<B>;
type Genome = Tensor<B, 2>;
fn init(
&self,
params: &AbcConfig,
rng: &mut dyn Rng,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> AbcState<B> {
debug_assert!(
params.validate().is_ok(),
"invalid AbcConfig reached init: {params:?}"
);
let (lo, hi): (f32, f32) = params.bounds.into();
let pop = params.pop_size;
let genome_dim = params.genome_dim;
let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
let mut colony_rows = Vec::with_capacity(pop * genome_dim);
for _ in 0..pop * genome_dim {
colony_rows.push(lo + (hi - lo) * stream.random::<f32>());
}
let colony =
Tensor::<B, 2>::from_data(TensorData::new(colony_rows, [pop, genome_dim]), device);
AbcState {
colony,
fitness: Vec::new(),
trial: vec![0; params.pop_size],
target_of_candidate: Vec::new(),
best_genome: None,
best_fitness: f32::NEG_INFINITY,
generation: 0,
}
}
fn ask(
&self,
params: &AbcConfig,
state: &AbcState<B>,
rng: &mut dyn Rng,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> (Tensor<B, 2>, AbcState<B>) {
if state.fitness.is_empty() {
return (state.colony.clone(), state.clone());
}
let pop = params.pop_size;
let genome_dim = params.genome_dim;
let n_cand = 2 * pop;
let mut stream = seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Other);
let mut targets = Vec::with_capacity(n_cand);
let mut neighbors = Vec::with_capacity(n_cand);
let mut dims = Vec::with_capacity(n_cand);
let mut phis = Vec::with_capacity(n_cand);
for i in 0..pop {
targets.push(i);
}
targets.extend(
tournament_indices_host(&state.fitness, params.tournament_size, pop, &mut stream)
.into_iter()
.map(|w| usize::try_from(w).expect("winner index is non-negative")),
);
for &t in &targets {
let mut k = stream.random_range(0..pop);
if k == t {
k = (k + 1) % pop;
}
neighbors.push(k);
dims.push(stream.random_range(0..genome_dim));
let phi = 2.0 * stream.random::<f32>() - 1.0;
phis.push(phi);
}
let candidates = Self::build_candidates(
&targets,
&neighbors,
&dims,
&phis,
&state.colony,
pop,
genome_dim,
device,
);
let (lo, hi): (f32, f32) = params.bounds.into();
let candidates = candidates.clamp(lo, hi);
let mut next = state.clone();
next.target_of_candidate = targets;
(candidates, next)
}
#[allow(clippy::too_many_lines)]
fn tell(
&self,
params: &AbcConfig,
candidates: Tensor<B, 2>,
fitness: Tensor<B, 1>,
mut state: AbcState<B>,
rng: &mut dyn Rng,
) -> (AbcState<B>, StrategyMetrics) {
let fitness_host = fitness
.into_data()
.into_vec::<f32>()
.expect("fitness tensor must be readable as f32");
let device = candidates.device();
let pop = params.pop_size;
let genome_dim = params.genome_dim;
if state.fitness.is_empty() {
state.fitness.clone_from(&fitness_host);
let best_idx = argmax_host(&fitness_host);
state.best_fitness = fitness_host[best_idx];
#[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(candidates.clone().select(0, idx));
state.colony = candidates;
state.generation += 1;
let m = StrategyMetrics::from_host_fitness(
state.generation,
&fitness_host,
state.best_fitness,
);
state.best_fitness = m.best_fitness_ever();
return (state, m);
}
let mut best_per_target: Vec<Option<(usize, f32)>> = vec![None; pop];
for (cand_idx, &t) in state.target_of_candidate.iter().enumerate() {
let cand_fit = fitness_host[cand_idx];
if cand_fit >= state.fitness[t] {
match best_per_target[t] {
None => best_per_target[t] = Some((cand_idx, cand_fit)),
Some((_, prev)) if cand_fit > prev => {
best_per_target[t] = Some((cand_idx, cand_fit));
}
_ => {}
}
}
}
let stacked = Tensor::cat(vec![state.colony.clone(), candidates.clone()], 0);
#[allow(clippy::cast_possible_wrap)]
let mut rs: Vec<i64> = (0..pop).map(|i| i as i64).collect();
let mut new_fitness = state.fitness.clone();
for t in 0..pop {
match best_per_target[t] {
Some((cand_idx, cand_fit)) => {
#[allow(clippy::cast_possible_wrap)]
{
rs[t] = (pop + cand_idx) as i64;
}
new_fitness[t] = cand_fit;
state.trial[t] = 0;
}
None => {
state.trial[t] += 1;
}
}
}
let idx = Tensor::<B, 1, Int>::from_data(TensorData::new(rs, [pop]), &device);
state.colony = stacked.select(0, idx);
state.fitness = new_fitness;
let mut scouts: Vec<usize> = Vec::new();
for (i, trial) in state.trial.iter_mut().enumerate() {
if *trial > params.limit {
scouts.push(i);
*trial = 0;
}
}
if !scouts.is_empty() {
let (lo, hi): (f32, f32) = params.bounds.into();
let mut scout_stream = seed_stream(
rng.next_u64(),
state.generation as u64,
SeedPurpose::Replacement,
);
let mut fresh_rows = Vec::with_capacity(scouts.len() * genome_dim);
for _ in 0..scouts.len() * genome_dim {
fresh_rows.push(lo + (hi - lo) * scout_stream.random::<f32>());
}
let fresh = Tensor::<B, 2>::from_data(
TensorData::new(fresh_rows, [scouts.len(), genome_dim]),
&device,
);
#[allow(clippy::cast_possible_wrap)]
let mut rs2: Vec<i64> = (0..pop).map(|i| i as i64).collect();
for (k, &scout) in scouts.iter().enumerate() {
#[allow(clippy::cast_possible_wrap)]
{
rs2[scout] = (pop + k) as i64;
}
state.fitness[scout] = f32::NEG_INFINITY;
}
let stacked2 = Tensor::cat(vec![state.colony.clone(), fresh], 0);
let idx2 = Tensor::<B, 1, Int>::from_data(TensorData::new(rs2, [pop]), &device);
state.colony = stacked2.select(0, idx2);
}
let best_idx = argmax_host(&state.fitness);
if state.fitness[best_idx].is_finite() && state.fitness[best_idx] > state.best_fitness {
state.best_fitness = state.fitness[best_idx];
#[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(state.colony.clone().select(0, idx));
}
state.generation += 1;
let m =
StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
state.best_fitness = m.best_fitness_ever();
(state, m)
}
fn best(&self, state: &AbcState<B>) -> Option<(Tensor<B, 2>, f32)> {
state
.best_genome
.as_ref()
.map(|g| (g.clone(), state.best_fitness))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fitness::FromFitnessEvaluable;
use crate::strategy::EvolutionaryHarness;
use burn::backend::Flex;
use rand::SeedableRng;
use rand::rngs::StdRng;
use rlevo_core::fitness::FitnessEvaluable;
type TestBackend = Flex;
#[test]
fn try_new_checks_cache_lengths() {
let device = Default::default();
let colony = Tensor::<TestBackend, 2>::zeros([3, 2], &device);
assert!(
AbcState::try_new(
colony.clone(),
vec![],
vec![0; 3],
vec![],
None,
f32::MIN,
0
)
.is_ok()
);
assert!(
AbcState::try_new(
colony.clone(),
vec![1.0; 3],
vec![0; 3],
vec![7; 6],
None,
1.0,
1
)
.is_ok()
);
assert!(
AbcState::try_new(
colony.clone(),
vec![1.0; 2],
vec![0; 3],
vec![],
None,
1.0,
1
)
.is_err()
);
assert!(AbcState::try_new(colony, vec![], vec![], vec![0; 5], None, 1.0, 1).is_err());
let empty = Tensor::<TestBackend, 2>::zeros([0, 2], &device);
assert!(AbcState::try_new(empty, vec![], vec![], vec![], None, 1.0, 0).is_err());
}
#[test]
fn default_config_validates() {
assert!(AbcConfig::default_for(30, 10).validate().is_ok());
}
#[test]
fn rejects_pop_size_below_two() {
let mut cfg = AbcConfig::default_for(30, 10);
cfg.pop_size = 1;
assert_eq!(cfg.validate().unwrap_err().field, "pop_size");
}
struct Sphere;
struct SphereFit;
impl FitnessEvaluable for SphereFit {
type Individual = Vec<f64>;
type Landscape = Sphere;
fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
x.iter().map(|v| v * v).sum()
}
}
#[test]
fn onlooker_targets_prefer_best_source() {
let device = Default::default();
let strategy = ArtificialBeeColony::<TestBackend>::new();
let mut params = AbcConfig::default_for(16, 4);
params.tournament_size = 8;
let mut rng = StdRng::seed_from_u64(11);
let mut state = strategy.init(¶ms, &mut rng, &device);
state.fitness = vec![0.0; 16];
state.fitness[3] = 100.0;
let (_candidates, next) = strategy.ask(¶ms, &state, &mut rng, &device);
let onlooker_hits = next.target_of_candidate[16..]
.iter()
.filter(|&&t| t == 3)
.count();
assert!(
onlooker_hits >= 3,
"onlooker hits on the best bee = {onlooker_hits} (expected ~6)",
);
}
#[test]
fn abc_converges_on_sphere_d10() {
let device = Default::default();
let strategy = ArtificialBeeColony::<TestBackend>::new();
let params = AbcConfig::default_for(30, 10);
let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
strategy, params, fitness_fn, 13, device, 400,
)
.expect("valid params");
harness.reset();
while !harness.step(()).done {}
let best = harness.latest_metrics().unwrap().best_fitness_ever();
assert!(best < 1e-4, "ABC D10 best={best}");
}
struct PartialNanFitness;
impl<B: Backend> crate::fitness::BatchFitnessFn<B, Tensor<B, 2>> for PartialNanFitness {
fn evaluate_batch(
&mut self,
population: &Tensor<B, 2>,
device: &<B as burn::tensor::backend::BackendTypes>::Device,
) -> Tensor<B, 1> {
let n = population.dims()[0];
#[allow(clippy::cast_precision_loss)]
let mut vals: Vec<f32> = (0..n).map(|i| -(i as f32)).collect();
vals[0] = f32::NAN;
Tensor::<B, 1>::from_data(TensorData::new(vals, [n]), device)
}
fn sense(&self) -> rlevo_core::objective::ObjectiveSense {
rlevo_core::objective::ObjectiveSense::Maximize
}
}
#[test]
#[allow(clippy::float_cmp)] fn ask_on_empty_fitness_returns_colony_unchanged() {
let device = Default::default();
let strategy = ArtificialBeeColony::<TestBackend>::new();
let params = AbcConfig::default_for(6, 4);
let mut rng = StdRng::seed_from_u64(3);
let state = strategy.init(¶ms, &mut rng, &device);
let (genome, next) = strategy.ask(¶ms, &state, &mut rng, &device);
let before = state
.colony()
.clone()
.into_data()
.into_vec::<f32>()
.expect("colony readable as f32");
let after = genome
.into_data()
.into_vec::<f32>()
.expect("genome readable as f32");
assert_eq!(before, after);
assert!(next.target_of_candidate().is_empty());
}
#[test]
fn pop_size_two_minimal_colony_runs() {
let device = Default::default();
let strategy = ArtificialBeeColony::<TestBackend>::new();
let params = AbcConfig::default_for(2, 3);
let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
strategy, params, fitness_fn, 5, device, 8,
)
.expect("valid params");
harness.reset();
while !harness.step(()).done {}
assert!(
harness
.latest_metrics()
.unwrap()
.best_fitness_ever()
.is_finite()
);
}
#[test]
fn genome_dim_one_degenerate_mask_runs() {
let device = Default::default();
let strategy = ArtificialBeeColony::<TestBackend>::new();
let params = AbcConfig::default_for(6, 1);
let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
strategy, params, fitness_fn, 8, device, 8,
)
.expect("valid params");
harness.reset();
while !harness.step(()).done {}
assert!(
harness
.latest_metrics()
.unwrap()
.best_fitness_ever()
.is_finite()
);
}
#[test]
#[allow(clippy::float_cmp)] fn scout_reinit_triggers_on_stagnation() {
let device = Default::default();
let strategy = ArtificialBeeColony::<TestBackend>::new();
let mut params = AbcConfig::default_for(4, 2);
params.limit = 1;
let colony = Tensor::<TestBackend, 2>::zeros([4, 2], &device);
let state = AbcState::try_new(
colony,
vec![0.0; 4], vec![1; 4], vec![0, 1, 2, 3, 0, 1, 2, 3], None,
f32::NEG_INFINITY,
5,
)
.expect("valid state");
let candidates = Tensor::<TestBackend, 2>::full([8, 2], 3.0, &device);
let fit =
Tensor::<TestBackend, 1>::from_data(TensorData::new(vec![-18.0_f32; 8], [8]), &device);
let mut rng = StdRng::seed_from_u64(9);
let (next, _m) = strategy.tell(¶ms, candidates, fit, state, &mut rng);
assert!(
next.trial().iter().all(|&t| t == 0),
"trials not reset: {:?}",
next.trial()
);
let colony_vals = next
.colony()
.clone()
.into_data()
.into_vec::<f32>()
.expect("colony readable as f32");
assert!(
colony_vals.iter().any(|&v| v != 0.0),
"no scout reinit happened; colony still all-zero"
);
}
#[test]
fn nan_fitness_survives_harness() {
let device = Default::default();
let strategy = ArtificialBeeColony::<TestBackend>::new();
let params = AbcConfig::default_for(6, 3);
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
strategy,
params,
PartialNanFitness,
4,
device,
4,
)
.expect("valid params");
harness.reset();
while !harness.step(()).done {}
let m = harness.latest_metrics().unwrap();
assert!(
m.best_fitness_ever().is_finite(),
"best_fitness_ever not finite: {}",
m.best_fitness_ever()
);
assert!(m.broken_count() > 0, "expected a broken (NaN) member");
}
}