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