Skip to main content

rlevo_evolution/algorithms/metaheuristic/
abc.rs

1//! Artificial Bee Colony.
2//!
3//! Canonical ABC fused into a single `Strategy::ask` / `tell` round per
4//! generation. Each generation produces `2 · pop_size` candidate
5//! solutions:
6//!
7//! 1. **Employed phase** (`pop_size` candidates). For every bee `i`, pick
8//!    a neighbour `k ≠ i`, pick a random dimension `j`, and perturb:
9//!    `v_ij = x_ij + φ·(x_ij − x_kj)` with `φ ∈ U[−1, 1]`.
10//! 2. **Onlooker phase** (`pop_size` candidates). Draw a target `t` via
11//!    tournament selection (fitness-biased), then perturb exactly as in
12//!    the employed phase.
13//!
14//! `tell` scores the `2N` candidates, greedy-accepts the best
15//! improvement per target bee, and increments the target's `trial`
16//! counter when no candidate improved it. Scout bees — those with
17//! `trial > limit` — are replaced by fresh uniform samples on device.
18//!
19//! # First-generation protocol
20//!
21//! `ask` detects the first call by checking whether `fitness` is empty
22//! and, if so, returns the current colony unchanged (no perturbation).
23//! `tell` detects the same condition and uses the received fitness to
24//! seed `AbcState::fitness` and `best_genome` before returning.
25//! Any caller that bypasses [`EvolutionaryHarness`] must therefore call
26//! `ask` → evaluate → `tell` **twice** before the employed/onlooker
27//! phases are active.
28//!
29//! [`EvolutionaryHarness`]: crate::strategy::EvolutionaryHarness
30//!
31//! # References
32//!
33//! - Karaboga (2005), *An idea based on honey bee swarm for numerical
34//!   optimization* (Erciyes Univ. Tech. Report TR06).
35
36use std::marker::PhantomData;
37
38use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
39use rand::Rng;
40use rand::RngExt;
41
42use crate::rng::{SeedPurpose, seed_stream};
43use crate::strategy::{Strategy, StrategyMetrics};
44
45/// Static configuration for [`ArtificialBeeColony`].
46#[derive(Debug, Clone)]
47pub struct AbcConfig {
48    /// Colony size. The algorithm draws `2 · pop_size` candidates per
49    /// generation (employed + onlooker).
50    pub pop_size: usize,
51    /// Genome dimensionality.
52    pub genome_dim: usize,
53    /// Search-space bounds.
54    pub bounds: (f32, f32),
55    /// Scout trigger. A bee with `trial > limit` is reinitialized.
56    /// Karaboga's canonical default is `pop_size · genome_dim / 2`.
57    pub limit: usize,
58    /// Tournament size for onlooker selection. Canonical ABC uses
59    /// roulette (fitness-proportionate); tournament is a GPU-friendly
60    /// equivalent that reuses [`crate::ops::selection::tournament_select`].
61    pub tournament_size: usize,
62}
63
64impl AbcConfig {
65    /// Default configuration for a given population size and genome dimensionality.
66    ///
67    /// The scout `limit` is set to `(pop_size * genome_dim) / 2` following
68    /// Karaboga's canonical recommendation. The `tournament_size` defaults
69    /// to `3` (three-way tournament for onlooker selection).
70    #[must_use]
71    pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
72        Self {
73            pop_size,
74            genome_dim,
75            bounds: (-5.12, 5.12),
76            limit: (pop_size * genome_dim) / 2,
77            tournament_size: 3,
78        }
79    }
80}
81
82/// Generation state for [`ArtificialBeeColony`].
83#[derive(Debug, Clone)]
84pub struct AbcState<B: Backend> {
85    /// Current colony, shape `(pop_size, D)`.
86    pub colony: Tensor<B, 2>,
87    /// Host-side fitness cache.
88    pub fitness: Vec<f32>,
89    /// Per-bee trial counter.
90    pub trial: Vec<usize>,
91    /// Target-bee mapping recorded by `ask` so `tell` knows which bee
92    /// each candidate belongs to. Empty after `init` and after the
93    /// bootstrap `ask` call (when `fitness` is still empty); populated
94    /// with `2 · pop_size` indices from the second `ask` onward.
95    pub target_of_candidate: Vec<usize>,
96    /// Best-so-far genome.
97    pub best_genome: Option<Tensor<B, 2>>,
98    /// Best-so-far fitness.
99    pub best_fitness: f32,
100    /// Generation counter.
101    pub generation: usize,
102}
103
104/// Artificial Bee Colony strategy.
105///
106/// # Panics
107///
108/// [`Strategy::init`] panics if `params.pop_size < 2`, since the
109/// employed-phase neighbour `k ≠ i` cannot be drawn from a colony of
110/// one.
111///
112/// # Example
113///
114/// ```no_run
115/// use burn::backend::Flex;
116/// use rlevo_evolution::algorithms::metaheuristic::abc::{AbcConfig, ArtificialBeeColony};
117///
118/// let strategy = ArtificialBeeColony::<Flex>::new();
119/// let params = AbcConfig::default_for(30, 10);
120/// let _ = (strategy, params);
121/// ```
122#[derive(Debug, Clone, Copy, Default)]
123pub struct ArtificialBeeColony<B: Backend> {
124    _backend: PhantomData<fn() -> B>,
125}
126
127impl<B: Backend> ArtificialBeeColony<B> {
128    /// Builds a new (stateless) strategy object.
129    #[must_use]
130    pub fn new() -> Self {
131        Self {
132            _backend: PhantomData,
133        }
134    }
135
136    #[allow(clippy::too_many_arguments)]
137    fn build_candidates(
138        targets: &[usize],
139        neighbors: &[usize],
140        dims: &[usize],
141        phi: &[f32],
142        colony: &Tensor<B, 2>,
143        pop_size: usize,
144        genome_dim: usize,
145        device: &<B as burn::tensor::backend::BackendTypes>::Device,
146    ) -> Tensor<B, 2> {
147        // Base = copy of targets' rows (we only modify one dim each).
148        #[allow(clippy::cast_possible_wrap)]
149        let target_idx: Vec<i64> = targets.iter().map(|&i| i as i64).collect();
150        let _ = pop_size; // number of candidates is inferred below
151        let n_cand = targets.len();
152        let target_tensor =
153            Tensor::<B, 1, Int>::from_data(TensorData::new(target_idx, [n_cand]), device);
154        let base = colony.clone().select(0, target_tensor);
155
156        // Compute the perturbation for the single selected dim per row.
157        #[allow(clippy::cast_possible_wrap)]
158        let neighbor_idx: Vec<i64> = neighbors.iter().map(|&i| i as i64).collect();
159        let neighbor_tensor =
160            Tensor::<B, 1, Int>::from_data(TensorData::new(neighbor_idx, [n_cand]), device);
161        let neighbor_rows = colony.clone().select(0, neighbor_tensor);
162
163        // Build a (n_cand, D) mask with `1` at (row, dims[row]).
164        let mut mask = vec![0i64; n_cand * genome_dim];
165        for (row, &j) in dims.iter().enumerate() {
166            mask[row * genome_dim + j] = 1;
167        }
168        let mask_bool =
169            Tensor::<B, 2, Int>::from_data(TensorData::new(mask, [n_cand, genome_dim]), device)
170                .equal_elem(1);
171
172        // φ is per-row; broadcast to (n_cand, D).
173        let phi_row = Tensor::<B, 1>::from_data(TensorData::new(phi.to_vec(), [n_cand]), device)
174            .unsqueeze_dim::<2>(1)
175            .expand([n_cand, genome_dim]);
176        let delta = phi_row.mul(base.clone() - neighbor_rows);
177        let perturbed = base.clone() + delta;
178        base.mask_where(mask_bool, perturbed)
179    }
180}
181
182impl<B: Backend> Strategy<B> for ArtificialBeeColony<B>
183where
184    B::Device: Clone,
185{
186    type Params = AbcConfig;
187    type State = AbcState<B>;
188    type Genome = Tensor<B, 2>;
189
190    fn init(&self, params: &AbcConfig, rng: &mut dyn Rng, device: &<B as burn::tensor::backend::BackendTypes>::Device) -> AbcState<B> {
191        assert!(params.pop_size >= 2, "ABC requires pop_size >= 2");
192        let (lo, hi) = params.bounds;
193        // Host-sample the initial colony from a deterministic `seed_stream`
194        // rather than the process-wide Flex RNG (`B::seed` + `Tensor::random`),
195        // whose draws interleave with sibling tests under the parallel runner
196        // and are not reproducible across thread schedules.
197        let pop = params.pop_size;
198        let genome_dim = params.genome_dim;
199        let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
200        let mut colony_rows = Vec::with_capacity(pop * genome_dim);
201        for _ in 0..pop * genome_dim {
202            colony_rows.push(lo + (hi - lo) * stream.random::<f32>());
203        }
204        let colony =
205            Tensor::<B, 2>::from_data(TensorData::new(colony_rows, [pop, genome_dim]), device);
206        AbcState {
207            colony,
208            fitness: Vec::new(),
209            trial: vec![0; params.pop_size],
210            target_of_candidate: Vec::new(),
211            best_genome: None,
212            best_fitness: f32::INFINITY,
213            generation: 0,
214        }
215    }
216
217    fn ask(
218        &self,
219        params: &AbcConfig,
220        state: &AbcState<B>,
221        rng: &mut dyn Rng,
222        device: &<B as burn::tensor::backend::BackendTypes>::Device,
223    ) -> (Tensor<B, 2>, AbcState<B>) {
224        if state.fitness.is_empty() {
225            return (state.colony.clone(), state.clone());
226        }
227
228        let pop = params.pop_size;
229        let genome_dim = params.genome_dim;
230        let n_cand = 2 * pop;
231
232        let mut stream = seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Other);
233
234        let mut targets = Vec::with_capacity(n_cand);
235        let mut neighbors = Vec::with_capacity(n_cand);
236        let mut dims = Vec::with_capacity(n_cand);
237        let mut phis = Vec::with_capacity(n_cand);
238
239        // Employed phase — every bee is a target exactly once.
240        for i in 0..pop {
241            targets.push(i);
242        }
243        // Onlooker phase — tournament selection, fitness-biased.
244        for _ in 0..pop {
245            let mut best = stream.random_range(0..pop);
246            for _ in 1..params.tournament_size {
247                let c = stream.random_range(0..pop);
248                if state.fitness[c] < state.fitness[best] {
249                    best = c;
250                }
251            }
252            targets.push(best);
253        }
254        // Neighbour + dim + φ for every candidate.
255        for &t in &targets {
256            let mut k = stream.random_range(0..pop);
257            if k == t {
258                k = (k + 1) % pop;
259            }
260            neighbors.push(k);
261            dims.push(stream.random_range(0..genome_dim));
262            let phi = 2.0 * stream.random::<f32>() - 1.0;
263            phis.push(phi);
264        }
265
266        let candidates = Self::build_candidates(
267            &targets,
268            &neighbors,
269            &dims,
270            &phis,
271            &state.colony,
272            pop,
273            genome_dim,
274            device,
275        );
276        let (lo, hi) = params.bounds;
277        let candidates = candidates.clamp(lo, hi);
278
279        let mut next = state.clone();
280        next.target_of_candidate = targets;
281        (candidates, next)
282    }
283
284    #[allow(clippy::too_many_lines)]
285    fn tell(
286        &self,
287        params: &AbcConfig,
288        candidates: Tensor<B, 2>,
289        fitness: Tensor<B, 1>,
290        mut state: AbcState<B>,
291        rng: &mut dyn Rng,
292    ) -> (AbcState<B>, StrategyMetrics) {
293        let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
294        let device = candidates.device();
295        let pop = params.pop_size;
296        let genome_dim = params.genome_dim;
297
298        // First tell: population is the initial colony being scored.
299        if state.fitness.is_empty() {
300            state.fitness.clone_from(&fitness_host);
301            let best_idx = argmin(&fitness_host);
302            state.best_fitness = fitness_host[best_idx];
303            #[allow(clippy::cast_possible_wrap)]
304            let idx = Tensor::<B, 1, Int>::from_data(
305                TensorData::new(vec![best_idx as i64], [1]),
306                &device,
307            );
308            state.best_genome = Some(candidates.clone().select(0, idx));
309            state.colony = candidates;
310            state.generation += 1;
311            let m = StrategyMetrics::from_host_fitness(
312                state.generation,
313                &fitness_host,
314                state.best_fitness,
315            );
316            state.best_fitness = m.best_fitness_ever;
317            return (state, m);
318        }
319
320        // For every target, find the best improving candidate (if any).
321        // `best_per_target[t] = (cand_idx, cand_fit)` when improvement.
322        let mut best_per_target: Vec<Option<(usize, f32)>> = vec![None; pop];
323        for (cand_idx, &t) in state.target_of_candidate.iter().enumerate() {
324            let cand_fit = fitness_host[cand_idx];
325            if cand_fit <= state.fitness[t] {
326                match best_per_target[t] {
327                    None => best_per_target[t] = Some((cand_idx, cand_fit)),
328                    Some((_, prev)) if cand_fit < prev => {
329                        best_per_target[t] = Some((cand_idx, cand_fit));
330                    }
331                    _ => {}
332                }
333            }
334        }
335
336        // Apply replacements via gather: we build an index tensor
337        // `row_source[i]` that is either `i` (keep current) pointing
338        // into `state.colony`, or `pop + cand_idx` pointing into a
339        // stacked tensor `[state.colony; candidates]`.
340        let stacked = Tensor::cat(vec![state.colony.clone(), candidates.clone()], 0);
341        #[allow(clippy::cast_possible_wrap)]
342        let mut rs: Vec<i64> = (0..pop).map(|i| i as i64).collect();
343        let mut new_fitness = state.fitness.clone();
344        for t in 0..pop {
345            match best_per_target[t] {
346                Some((cand_idx, cand_fit)) => {
347                    #[allow(clippy::cast_possible_wrap)]
348                    {
349                        rs[t] = (pop + cand_idx) as i64;
350                    }
351                    new_fitness[t] = cand_fit;
352                    state.trial[t] = 0;
353                }
354                None => {
355                    state.trial[t] += 1;
356                }
357            }
358        }
359        let idx = Tensor::<B, 1, Int>::from_data(TensorData::new(rs, [pop]), &device);
360        state.colony = stacked.select(0, idx);
361        state.fitness = new_fitness;
362
363        // Scout phase: reinit any bee whose trial exceeded the limit.
364        let mut scouts: Vec<usize> = Vec::new();
365        for (i, trial) in state.trial.iter_mut().enumerate() {
366            if *trial > params.limit {
367                scouts.push(i);
368                *trial = 0;
369            }
370        }
371        if !scouts.is_empty() {
372            let (lo, hi) = params.bounds;
373            // Host-sample scout replacements from a deterministic
374            // `seed_stream` so the refill is reproducible across thread
375            // schedules rather than racing the global Flex RNG.
376            let mut scout_stream =
377                seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Replacement);
378            let mut fresh_rows = Vec::with_capacity(scouts.len() * genome_dim);
379            for _ in 0..scouts.len() * genome_dim {
380                fresh_rows.push(lo + (hi - lo) * scout_stream.random::<f32>());
381            }
382            let fresh = Tensor::<B, 2>::from_data(
383                TensorData::new(fresh_rows, [scouts.len(), genome_dim]),
384                &device,
385            );
386            // Overwrite those rows via gather-trick.
387            #[allow(clippy::cast_possible_wrap)]
388            let mut rs2: Vec<i64> = (0..pop).map(|i| i as i64).collect();
389            for (k, &scout) in scouts.iter().enumerate() {
390                #[allow(clippy::cast_possible_wrap)]
391                {
392                    rs2[scout] = (pop + k) as i64;
393                }
394                // Scout fitness is unknown until next generation —
395                // carry INF so any candidate improves it.
396                state.fitness[scout] = f32::INFINITY;
397            }
398            let stacked2 = Tensor::cat(vec![state.colony.clone(), fresh], 0);
399            let idx2 = Tensor::<B, 1, Int>::from_data(TensorData::new(rs2, [pop]), &device);
400            state.colony = stacked2.select(0, idx2);
401        }
402
403        // Update best-so-far from the refreshed colony's fitness cache
404        // (excluding INF-tagged scouts, which next `ask` evaluates).
405        let best_idx = argmin(&state.fitness);
406        if state.fitness[best_idx].is_finite() && state.fitness[best_idx] < state.best_fitness {
407            state.best_fitness = state.fitness[best_idx];
408            #[allow(clippy::cast_possible_wrap)]
409            let idx = Tensor::<B, 1, Int>::from_data(
410                TensorData::new(vec![best_idx as i64], [1]),
411                &device,
412            );
413            state.best_genome = Some(state.colony.clone().select(0, idx));
414        }
415
416        state.generation += 1;
417        let m =
418            StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
419        state.best_fitness = m.best_fitness_ever;
420        (state, m)
421    }
422
423    fn best(&self, state: &AbcState<B>) -> Option<(Tensor<B, 2>, f32)> {
424        state
425            .best_genome
426            .as_ref()
427            .map(|g| (g.clone(), state.best_fitness))
428    }
429}
430
431fn argmin(xs: &[f32]) -> usize {
432    let mut best_idx = 0usize;
433    let mut best = f32::INFINITY;
434    for (i, &v) in xs.iter().enumerate() {
435        if v < best {
436            best = v;
437            best_idx = i;
438        }
439    }
440    best_idx
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use crate::fitness::FromFitnessEvaluable;
447    use crate::strategy::EvolutionaryHarness;
448    use burn::backend::Flex;
449    use rlevo_core::fitness::FitnessEvaluable;
450
451    type TestBackend = Flex;
452
453    struct Sphere;
454    struct SphereFit;
455    impl FitnessEvaluable for SphereFit {
456        type Individual = Vec<f64>;
457        type Landscape = Sphere;
458        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
459            x.iter().map(|v| v * v).sum()
460        }
461    }
462
463    #[test]
464    fn abc_converges_on_sphere_d10() {
465        let device = Default::default();
466        let strategy = ArtificialBeeColony::<TestBackend>::new();
467        let params = AbcConfig::default_for(30, 10);
468        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
469        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
470            strategy, params, fitness_fn, 13, device, 400,
471        );
472        harness.reset();
473        while !harness.step(()).done {}
474        let best = harness.latest_metrics().unwrap().best_fitness_ever;
475        assert!(best < 1e-4, "ABC D10 best={best}");
476    }
477}