Skip to main content

rlevo_evolution/algorithms/metaheuristic/
aco_r.rs

1//! Ant Colony Optimization for continuous domains (`ACO_R`).
2//!
3//! Socha & Dorigo's 2008 extension of ACO to real-valued search spaces.
4//! The colony maintains an **archive** of the `k` best solutions seen
5//! so far; per generation it samples `m` offspring by drawing each
6//! dimension from a Gaussian kernel centred on an archive solution
7//! selected by rank-weighted roulette:
8//!
9//! 1. Compute per-archive, per-dim `σ_{l,d} = ξ · mean_e |x_{e,d} − x_{l,d}|`.
10//! 2. For every offspring `i` and every dimension `d`:
11//!    - Sample an archive index `l ∼ Categorical(w)` where
12//!      `w_l ∝ exp(−(rank_l − 1)² / (2·q²·k²))`.
13//!    - Sample `x_{i,d} ∼ N(x_{l,d}, σ_{l,d})`.
14//! 3. Evaluate offspring, merge with archive, keep top `k`.
15//!
16//! # References
17//!
18//! - Socha & Dorigo (2008), *Ant colony optimization for continuous domains*.
19
20use std::f32::consts::PI;
21use std::marker::PhantomData;
22
23use burn::tensor::{Distribution, Int, Tensor, TensorData, backend::Backend};
24use rand::Rng;
25use rand_distr::{Distribution as RandDistDist, Normal};
26
27use crate::rng::{SeedPurpose, seed_stream};
28use crate::strategy::{Strategy, StrategyMetrics};
29
30/// Static configuration for [`AntColonyReal`].
31#[derive(Debug, Clone)]
32pub struct AcoRConfig {
33    /// Archive size (number of "best" solutions kept). Socha & Dorigo's
34    /// recommended default is `k = 50`.
35    pub archive_size: usize,
36    /// Offspring per generation (`m` in the paper).
37    pub m: usize,
38    /// Genome dimensionality.
39    pub genome_dim: usize,
40    /// Search-space bounds.
41    pub bounds: (f32, f32),
42    /// Exploration scale (`ξ`). Higher → wider sampling. Canonical 0.85.
43    pub xi: f32,
44    /// Rank-weight decay (`q`). Smaller → stronger bias toward top of
45    /// the archive. Canonical `q = 0.01` (sharp) up to `q ≈ 0.5` (flat).
46    pub q: f32,
47}
48
49impl AcoRConfig {
50    /// Default configuration for a given archive size, offspring count, and dimensionality.
51    #[must_use]
52    pub fn default_for(archive_size: usize, m: usize, genome_dim: usize) -> Self {
53        Self {
54            archive_size,
55            m,
56            genome_dim,
57            bounds: (-5.12, 5.12),
58            xi: 0.85,
59            q: 0.1,
60        }
61    }
62
63    /// Steady-state offspring count per generation (`m`). Note that
64    /// the very first generation evaluates the full initial archive
65    /// (`archive_size` rows) instead — only generations ≥ 1 score `m`.
66    #[must_use]
67    pub fn steady_state_pop_size(&self) -> usize {
68        self.m
69    }
70}
71
72/// Generation state for [`AntColonyReal`].
73#[derive(Debug, Clone)]
74pub struct AcoRState<B: Backend> {
75    /// Archive of `k` best solutions, shape `(k, D)`.
76    pub archive: Tensor<B, 2>,
77    /// Host-side archive fitness, sorted ascending (best first) after
78    /// the first `tell`.
79    pub archive_fitness: Vec<f32>,
80    /// Cached archive weights (recomputed only when `q` or `k` change).
81    pub weights: Vec<f32>,
82    /// Best-so-far genome.
83    pub best_genome: Option<Tensor<B, 2>>,
84    /// Best-so-far fitness.
85    pub best_fitness: f32,
86    /// Generation counter.
87    pub generation: usize,
88}
89
90/// Ant Colony Optimization (continuous domains).
91///
92/// # Panics
93///
94/// [`Strategy::init`] panics if `params.archive_size < 2` (the σ
95/// computation needs at least two archive solutions to take a pairwise
96/// distance) or if `params.m < 1` (no offspring to draw).
97///
98/// # Example
99///
100/// ```no_run
101/// use burn::backend::NdArray;
102/// use rlevo_evolution::algorithms::metaheuristic::aco_r::{AcoRConfig, AntColonyReal};
103///
104/// let strategy = AntColonyReal::<NdArray>::new();
105/// let params = AcoRConfig::default_for(50, 10, 10);
106/// let _ = (strategy, params);
107/// ```
108#[derive(Debug, Clone, Copy, Default)]
109pub struct AntColonyReal<B: Backend> {
110    _backend: PhantomData<fn() -> B>,
111}
112
113impl<B: Backend> AntColonyReal<B> {
114    /// Builds a new (stateless) strategy object.
115    #[must_use]
116    pub fn new() -> Self {
117        Self {
118            _backend: PhantomData,
119        }
120    }
121
122    /// Compute rank-based archive weights `w_l ∝ exp(−(l−1)² / (2·q²·k²))`.
123    fn compute_weights(archive_size: usize, q: f32) -> Vec<f32> {
124        #[allow(clippy::cast_precision_loss)]
125        let k = archive_size as f32;
126        let denom = 2.0 * q * q * k * k;
127        let scale = 1.0 / (q * k * (2.0 * PI).sqrt());
128        let mut w: Vec<f32> = (0..archive_size)
129            .map(|l| {
130                #[allow(clippy::cast_precision_loss)]
131                let rank = l as f32;
132                scale * (-(rank * rank) / denom).exp()
133            })
134            .collect();
135        let total: f32 = w.iter().sum();
136        for v in &mut w {
137            *v /= total;
138        }
139        w
140    }
141}
142
143impl<B: Backend> Strategy<B> for AntColonyReal<B>
144where
145    B::Device: Clone,
146{
147    type Params = AcoRConfig;
148    type State = AcoRState<B>;
149    type Genome = Tensor<B, 2>;
150
151    fn init(&self, params: &AcoRConfig, rng: &mut dyn Rng, device: &B::Device) -> AcoRState<B> {
152        assert!(params.archive_size >= 2, "ACO_R requires archive_size >= 2");
153        assert!(params.m >= 1, "ACO_R requires m >= 1");
154        let (lo, hi) = params.bounds;
155        B::seed(device, rng.next_u64());
156        let archive = Tensor::<B, 2>::random(
157            [params.archive_size, params.genome_dim],
158            Distribution::Uniform(f64::from(lo), f64::from(hi)),
159            device,
160        );
161        AcoRState {
162            archive,
163            archive_fitness: Vec::new(),
164            weights: Self::compute_weights(params.archive_size, params.q),
165            best_genome: None,
166            best_fitness: f32::INFINITY,
167            generation: 0,
168        }
169    }
170
171    #[allow(clippy::many_single_char_names)]
172    fn ask(
173        &self,
174        params: &AcoRConfig,
175        state: &AcoRState<B>,
176        rng: &mut dyn Rng,
177        device: &B::Device,
178    ) -> (Tensor<B, 2>, AcoRState<B>) {
179        // First call: evaluate the initial archive.
180        if state.archive_fitness.is_empty() {
181            return (state.archive.clone(), state.clone());
182        }
183
184        let k = params.archive_size;
185        let m = params.m;
186        let d = params.genome_dim;
187
188        // σ[l, j] = ξ · (1/(k-1)) · Σ_e |archive[e, j] - archive[l, j]|
189        // Computed on-device by expanding archive along axis 0 to (k, k, d),
190        // taking |a - b|, reducing along axis 0 (the "e" axis).
191        let archive_l = state.archive.clone().unsqueeze_dim::<3>(0); // (1, k, d)
192        let archive_e = state.archive.clone().unsqueeze_dim::<3>(1); // (k, 1, d)
193        let diffs = (archive_l.expand([k, k, d]) - archive_e.expand([k, k, d])).abs();
194        #[allow(clippy::cast_precision_loss)]
195        let inv = params.xi / ((k - 1).max(1) as f32);
196        let sigma = diffs.sum_dim(0).squeeze::<2>().mul_scalar(inv); // (k, d)
197
198        // Weighted index sampling (host-side) — `m · d` independent draws.
199        let mut stream = seed_stream(
200            rng.next_u64(),
201            state.generation as u64,
202            SeedPurpose::Selection,
203        );
204        let mut mean_rows = vec![0f32; m * d];
205        let mut sigma_rows = vec![0f32; m * d];
206
207        // Gather host-side slices for indexing.
208        let archive_host = state.archive.clone().into_data().into_vec::<f32>().unwrap();
209        let sigma_host = sigma.into_data().into_vec::<f32>().unwrap();
210        let cdf: Vec<f32> = {
211            let mut acc = 0.0;
212            let mut v = Vec::with_capacity(k);
213            for &w in &state.weights {
214                acc += w;
215                v.push(acc);
216            }
217            v
218        };
219        let pick = |u: f32| -> usize { cdf.iter().position(|&c| u <= c).unwrap_or(k - 1) };
220
221        for i in 0..m {
222            for j in 0..d {
223                use rand::RngExt;
224                let u: f32 = stream.random::<f32>();
225                let l = pick(u);
226                mean_rows[i * d + j] = archive_host[l * d + j];
227                sigma_rows[i * d + j] = sigma_host[l * d + j].max(1e-12);
228            }
229        }
230
231        // Sample N(mean, sigma) host-side. Using rand_distr keeps the
232        // draw on the same splitmix stream already threaded through
233        // `stream` above.
234        let mut offspring = vec![0f32; m * d];
235        let mut sample_rng = seed_stream(
236            rng.next_u64(),
237            state.generation as u64,
238            SeedPurpose::Mutation,
239        );
240        for (idx, out) in offspring.iter_mut().enumerate() {
241            let normal = Normal::new(mean_rows[idx], sigma_rows[idx]).expect("sigma > 0");
242            *out = normal.sample(&mut sample_rng);
243        }
244        let (lo, hi) = params.bounds;
245        for v in &mut offspring {
246            *v = v.clamp(lo, hi);
247        }
248        let new_pop = Tensor::<B, 2>::from_data(TensorData::new(offspring, [m, d]), device);
249
250        (new_pop, state.clone())
251    }
252
253    fn tell(
254        &self,
255        params: &AcoRConfig,
256        population: Tensor<B, 2>,
257        fitness: Tensor<B, 1>,
258        mut state: AcoRState<B>,
259        _rng: &mut dyn Rng,
260    ) -> (AcoRState<B>, StrategyMetrics) {
261        let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
262        let device = population.device();
263        let k = params.archive_size;
264
265        // First tell: the population being scored IS the initial archive.
266        if state.archive_fitness.is_empty() {
267            // Sort archive by fitness.
268            let mut idx: Vec<usize> = (0..fitness_host.len()).collect();
269            idx.sort_by(|&a, &b| fitness_host[a].partial_cmp(&fitness_host[b]).unwrap());
270            #[allow(clippy::cast_possible_wrap)]
271            let sorted_idx = Tensor::<B, 1, Int>::from_data(
272                TensorData::new(idx.iter().map(|&i| i as i64).collect::<Vec<_>>(), [k]),
273                &device,
274            );
275            state.archive = population.clone().select(0, sorted_idx);
276            state.archive_fitness = idx.iter().map(|&i| fitness_host[i]).collect();
277            state.best_fitness = state.archive_fitness[0];
278            let first_idx =
279                Tensor::<B, 1, Int>::from_data(TensorData::new(vec![0_i64], [1]), &device);
280            state.best_genome = Some(state.archive.clone().select(0, first_idx));
281            state.generation += 1;
282            let m = StrategyMetrics::from_host_fitness(
283                state.generation,
284                &fitness_host,
285                state.best_fitness,
286            );
287            state.best_fitness = m.best_fitness_ever;
288            return (state, m);
289        }
290
291        // Steady state: merge archive + offspring, keep top-k.
292        let combined = Tensor::cat(vec![state.archive.clone(), population.clone()], 0);
293        let mut combined_f: Vec<f32> = state.archive_fitness.clone();
294        combined_f.extend_from_slice(&fitness_host);
295        let mut idx: Vec<usize> = (0..combined_f.len()).collect();
296        idx.sort_by(|&a, &b| combined_f[a].partial_cmp(&combined_f[b]).unwrap());
297        idx.truncate(k);
298        #[allow(clippy::cast_possible_wrap)]
299        let top_idx = Tensor::<B, 1, Int>::from_data(
300            TensorData::new(idx.iter().map(|&i| i as i64).collect::<Vec<_>>(), [k]),
301            &device,
302        );
303        state.archive = combined.select(0, top_idx);
304        state.archive_fitness = idx.iter().map(|&i| combined_f[i]).collect();
305
306        if state.archive_fitness[0] < state.best_fitness {
307            state.best_fitness = state.archive_fitness[0];
308            let first_idx =
309                Tensor::<B, 1, Int>::from_data(TensorData::new(vec![0_i64], [1]), &device);
310            state.best_genome = Some(state.archive.clone().select(0, first_idx));
311        }
312
313        state.generation += 1;
314        let m =
315            StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
316        state.best_fitness = m.best_fitness_ever;
317        (state, m)
318    }
319
320    fn best(&self, state: &AcoRState<B>) -> Option<(Tensor<B, 2>, f32)> {
321        state
322            .best_genome
323            .as_ref()
324            .map(|g| (g.clone(), state.best_fitness))
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::fitness::FromFitnessEvaluable;
332    use crate::strategy::EvolutionaryHarness;
333    use burn::backend::NdArray;
334    use rlevo_core::fitness::FitnessEvaluable;
335
336    type TestBackend = NdArray;
337
338    struct Sphere;
339    struct SphereFit;
340    impl FitnessEvaluable for SphereFit {
341        type Individual = Vec<f64>;
342        type Landscape = Sphere;
343        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
344            x.iter().map(|v| v * v).sum()
345        }
346    }
347
348    #[test]
349    fn weights_sum_to_one() {
350        let w = AntColonyReal::<TestBackend>::compute_weights(10, 0.1);
351        let total: f32 = w.iter().sum();
352        approx::assert_relative_eq!(total, 1.0, epsilon = 1e-5);
353    }
354
355    #[test]
356    fn aco_r_converges_on_sphere_d10() {
357        let device = Default::default();
358        let strategy = AntColonyReal::<TestBackend>::new();
359        let params = AcoRConfig::default_for(30, 15, 10);
360        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
361        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
362            strategy, params, fitness_fn, 17, device, 400,
363        );
364        harness.reset();
365        while !harness.step(()).done {}
366        let best = harness.latest_metrics().unwrap().best_fitness_ever;
367        assert!(best < 1e-3, "ACO_R D10 best={best}");
368    }
369}