Skip to main content

rlevo_evolution/algorithms/metaheuristic/
cuckoo.rs

1//! Cuckoo Search via Lévy flights.
2//!
3//! Each generation every nest proposes a new egg by taking a
4//! Lévy-stable step from its current position:
5//!
6//! - `u ∼ N(0, σ_u²)`, `v ∼ N(0, 1)`,
7//! - `step = u / |v|^(1/β)`,
8//! - `x'_i = x_i + α · step`,
9//!
10//! where `σ_u = (Γ(1+β)·sin(π·β/2) / (Γ((1+β)/2)·β·2^((β−1)/2)))^(1/β)`
11//! (Mantegna's algorithm, β ≈ 1.5).
12//!
13//! `tell` greedy-accepts each new egg against its own slot, then
14//! abandons the `p_a · N` worst nests and reinitializes them from the
15//! search bounds. Abandoned slots carry sentinel `+∞` fitness so the
16//! next generation's Lévy proposal always lands.
17//!
18//! # Numerical parity caveat
19//!
20//! The fractional power `|v|^(1/β)` is FMA-reorder-sensitive — wgpu
21//! reductions can drift ~`1e-3` relative from ndarray on the same seed.
22//! The backend-parity test relaxes tolerance for CS accordingly.
23//!
24//! # References
25//!
26//! - Yang & Deb (2009), *Cuckoo Search via Lévy Flights*.
27//! - Mantegna (1994), *Fast, accurate algorithm for numerical simulation
28//!   of Lévy stable stochastic processes*.
29
30use std::f32::consts::PI;
31use std::marker::PhantomData;
32
33use burn::tensor::{Distribution, Int, Tensor, TensorData, backend::Backend};
34use rand::Rng;
35use rand_distr::{Distribution as RandDistDist, Normal};
36
37use crate::rng::{SeedPurpose, seed_stream};
38use crate::strategy::{Strategy, StrategyMetrics};
39
40/// Static configuration for [`CuckooSearch`].
41#[derive(Debug, Clone)]
42pub struct CuckooConfig {
43    /// Nest count.
44    pub pop_size: usize,
45    /// Genome dimensionality.
46    pub genome_dim: usize,
47    /// Search-space bounds.
48    pub bounds: (f32, f32),
49    /// Step size scale (`α` in the paper). Canonical `α = 0.01`
50    /// multiplied by the search-space width; strategy users should
51    /// tune relative to their domain.
52    pub alpha: f32,
53    /// Lévy index (`β`). Must be in `(0, 2)`; canonical 1.5.
54    pub beta: f32,
55    /// Nest abandonment probability (`p_a`). Canonical 0.25.
56    pub p_a: f32,
57}
58
59impl CuckooConfig {
60    /// Default configuration for a given population size and genome dimensionality.
61    #[must_use]
62    pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
63        Self {
64            pop_size,
65            genome_dim,
66            bounds: (-5.12, 5.12),
67            alpha: 0.05,
68            beta: 1.5,
69            p_a: 0.25,
70        }
71    }
72}
73
74/// Generation state for [`CuckooSearch`].
75#[derive(Debug, Clone)]
76pub struct CuckooState<B: Backend> {
77    /// Current nests, shape `(pop_size, D)`.
78    pub nests: Tensor<B, 2>,
79    /// Host-side fitness cache; `+∞` for abandoned slots.
80    pub fitness: Vec<f32>,
81    /// Best-so-far genome.
82    pub best_genome: Option<Tensor<B, 2>>,
83    /// Best-so-far fitness.
84    pub best_fitness: f32,
85    /// Generation counter.
86    pub generation: usize,
87}
88
89/// Cuckoo Search strategy.
90///
91/// # Example
92///
93/// ```no_run
94/// use burn::backend::NdArray;
95/// use rlevo_evolution::algorithms::metaheuristic::cuckoo::{CuckooConfig, CuckooSearch};
96///
97/// let strategy = CuckooSearch::<NdArray>::new();
98/// let params = CuckooConfig::default_for(30, 10);
99/// let _ = (strategy, params);
100/// ```
101#[derive(Debug, Clone, Copy, Default)]
102pub struct CuckooSearch<B: Backend> {
103    _backend: PhantomData<fn() -> B>,
104}
105
106impl<B: Backend> CuckooSearch<B> {
107    /// Builds a new (stateless) strategy object.
108    #[must_use]
109    pub fn new() -> Self {
110        Self {
111            _backend: PhantomData,
112        }
113    }
114
115    /// Mantegna's `σ_u` for the `u ∼ N(0, σ_u²)` draw.
116    fn mantegna_sigma_u(beta: f32) -> f32 {
117        // Γ(1 + β) · sin(π·β/2)  /  ( Γ((1+β)/2) · β · 2^((β-1)/2) ) ) ^ (1/β)
118        let num = gamma(1.0 + beta) * ((PI * beta) / 2.0).sin();
119        let den = gamma(f32::midpoint(1.0, beta)) * beta * 2f32.powf((beta - 1.0) / 2.0);
120        (num / den).powf(1.0 / beta)
121    }
122}
123
124/// Lanczos approximation for `Γ(z)` on positive reals. Only used
125/// host-side in [`CuckooSearch`] and [`super::bat`] for small constants.
126#[allow(clippy::many_single_char_names)]
127fn gamma(z: f32) -> f32 {
128    // 5-term Lanczos coefficients (g = 7). Enough for `z ∈ [0.5, 5]`
129    // which covers the Lévy-flight parameter range.
130    let g = 7.0_f32;
131    let p: [f32; 9] = [
132        0.999_999_999_999_809_93,
133        676.520_4,
134        -1_259.139_2,
135        771.323_4,
136        -176.615_04,
137        12.507_343,
138        -0.138_571_1,
139        9.984_369e-6,
140        1.505_632_7e-7,
141    ];
142    if z < 0.5 {
143        return PI / ((PI * z).sin() * gamma(1.0 - z));
144    }
145    let z = z - 1.0;
146    let mut x = p[0];
147    for (i, &coef) in p.iter().enumerate().skip(1) {
148        #[allow(clippy::cast_precision_loss)]
149        let i_f32 = i as f32;
150        x += coef / (z + i_f32);
151    }
152    let t = z + g + 0.5;
153    (2.0 * PI).sqrt() * t.powf(z + 0.5) * (-t).exp() * x
154}
155
156impl<B: Backend> Strategy<B> for CuckooSearch<B>
157where
158    B::Device: Clone,
159{
160    type Params = CuckooConfig;
161    type State = CuckooState<B>;
162    type Genome = Tensor<B, 2>;
163
164    fn init(&self, params: &CuckooConfig, rng: &mut dyn Rng, device: &B::Device) -> CuckooState<B> {
165        let (lo, hi) = params.bounds;
166        B::seed(device, rng.next_u64());
167        let nests = Tensor::<B, 2>::random(
168            [params.pop_size, params.genome_dim],
169            Distribution::Uniform(f64::from(lo), f64::from(hi)),
170            device,
171        );
172        CuckooState {
173            nests,
174            fitness: Vec::new(),
175            best_genome: None,
176            best_fitness: f32::INFINITY,
177            generation: 0,
178        }
179    }
180
181    fn ask(
182        &self,
183        params: &CuckooConfig,
184        state: &CuckooState<B>,
185        rng: &mut dyn Rng,
186        device: &B::Device,
187    ) -> (Tensor<B, 2>, CuckooState<B>) {
188        if state.fitness.is_empty() {
189            return (state.nests.clone(), state.clone());
190        }
191
192        let pop = params.pop_size;
193        let d = params.genome_dim;
194        let sigma_u = Self::mantegna_sigma_u(params.beta);
195
196        let mut stream = seed_stream(
197            rng.next_u64(),
198            state.generation as u64,
199            SeedPurpose::Mutation,
200        );
201        let normal_u = Normal::new(0.0_f32, sigma_u).expect("σ_u > 0");
202        let normal_v = Normal::new(0.0_f32, 1.0_f32).unwrap();
203        let mut step = vec![0f32; pop * d];
204        for v in &mut step {
205            let u: f32 = normal_u.sample(&mut stream);
206            let w: f32 = normal_v.sample(&mut stream);
207            *v = u / w.abs().powf(1.0 / params.beta);
208        }
209        let step_tensor = Tensor::<B, 2>::from_data(TensorData::new(step, [pop, d]), device);
210
211        let (lo, hi) = params.bounds;
212        let new_nests = (state.nests.clone() + step_tensor.mul_scalar(params.alpha)).clamp(lo, hi);
213
214        let mut next = state.clone();
215        next.nests.clone_from(&new_nests);
216        (new_nests, next)
217    }
218
219    fn tell(
220        &self,
221        params: &CuckooConfig,
222        population: Tensor<B, 2>,
223        fitness: Tensor<B, 1>,
224        mut state: CuckooState<B>,
225        rng: &mut dyn Rng,
226    ) -> (CuckooState<B>, StrategyMetrics) {
227        let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
228        let device = population.device();
229        let pop = params.pop_size;
230        let d = params.genome_dim;
231
232        if state.fitness.is_empty() {
233            state.fitness.clone_from(&fitness_host);
234            let best_idx = argmin(&fitness_host);
235            state.best_fitness = fitness_host[best_idx];
236            #[allow(clippy::cast_possible_wrap)]
237            let idx = Tensor::<B, 1, Int>::from_data(
238                TensorData::new(vec![best_idx as i64], [1]),
239                &device,
240            );
241            state.best_genome = Some(population.clone().select(0, idx));
242            state.nests = population;
243            state.generation += 1;
244            let m = StrategyMetrics::from_host_fitness(
245                state.generation,
246                &fitness_host,
247                state.best_fitness,
248            );
249            state.best_fitness = m.best_fitness_ever;
250            return (state, m);
251        }
252
253        // Greedy accept per slot.
254        #[allow(clippy::cast_possible_wrap)]
255        let mut rs: Vec<i64> = (0..pop).map(|i| i as i64).collect();
256        let mut new_fitness = state.fitness.clone();
257        for i in 0..pop {
258            if fitness_host[i] <= state.fitness[i] {
259                #[allow(clippy::cast_possible_wrap)]
260                {
261                    rs[i] = (pop + i) as i64;
262                }
263                new_fitness[i] = fitness_host[i];
264            }
265        }
266        let stacked = Tensor::cat(vec![state.nests.clone(), population.clone()], 0);
267        let idx = Tensor::<B, 1, Int>::from_data(TensorData::new(rs, [pop]), &device);
268        state.nests = stacked.select(0, idx);
269        state.fitness = new_fitness;
270
271        // Abandon worst `p_a · pop` nests — reinit with uniform sample;
272        // mark fitness +∞ so next ask's Lévy proposal always lands.
273        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
274        let n_abandon = (params.p_a * pop as f32) as usize;
275        if n_abandon > 0 {
276            let mut rank: Vec<usize> = (0..pop).collect();
277            rank.sort_by(|&a, &b| state.fitness[b].partial_cmp(&state.fitness[a]).unwrap());
278            let worst: Vec<usize> = rank.into_iter().take(n_abandon).collect();
279            let (lo, hi) = params.bounds;
280            B::seed(&device, rng.next_u64());
281            let fresh = Tensor::<B, 2>::random(
282                [n_abandon, d],
283                Distribution::Uniform(f64::from(lo), f64::from(hi)),
284                &device,
285            );
286            #[allow(clippy::cast_possible_wrap)]
287            let mut rs2: Vec<i64> = (0..pop).map(|i| i as i64).collect();
288            for (k, &slot) in worst.iter().enumerate() {
289                #[allow(clippy::cast_possible_wrap)]
290                {
291                    rs2[slot] = (pop + k) as i64;
292                }
293                state.fitness[slot] = f32::INFINITY;
294            }
295            let stacked2 = Tensor::cat(vec![state.nests.clone(), fresh], 0);
296            let idx2 = Tensor::<B, 1, Int>::from_data(TensorData::new(rs2, [pop]), &device);
297            state.nests = stacked2.select(0, idx2);
298        }
299
300        // Best-so-far from finite-fitness slots.
301        let best_idx = argmin(&state.fitness);
302        if state.fitness[best_idx].is_finite() && state.fitness[best_idx] < state.best_fitness {
303            state.best_fitness = state.fitness[best_idx];
304            #[allow(clippy::cast_possible_wrap)]
305            let idx = Tensor::<B, 1, Int>::from_data(
306                TensorData::new(vec![best_idx as i64], [1]),
307                &device,
308            );
309            state.best_genome = Some(state.nests.clone().select(0, idx));
310        }
311
312        state.generation += 1;
313        let m =
314            StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
315        state.best_fitness = m.best_fitness_ever;
316        (state, m)
317    }
318
319    fn best(&self, state: &CuckooState<B>) -> Option<(Tensor<B, 2>, f32)> {
320        state
321            .best_genome
322            .as_ref()
323            .map(|g| (g.clone(), state.best_fitness))
324    }
325}
326
327fn argmin(xs: &[f32]) -> usize {
328    let mut best_idx = 0usize;
329    let mut best = f32::INFINITY;
330    for (i, &v) in xs.iter().enumerate() {
331        if v < best {
332            best = v;
333            best_idx = i;
334        }
335    }
336    best_idx
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::fitness::FromFitnessEvaluable;
343    use crate::strategy::EvolutionaryHarness;
344    use burn::backend::NdArray;
345    use rlevo_core::fitness::FitnessEvaluable;
346
347    type TestBackend = NdArray;
348
349    struct Sphere;
350    struct SphereFit;
351    impl FitnessEvaluable for SphereFit {
352        type Individual = Vec<f64>;
353        type Landscape = Sphere;
354        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
355            x.iter().map(|v| v * v).sum()
356        }
357    }
358
359    #[test]
360    fn gamma_matches_known_values() {
361        // Γ(1) = 1, Γ(2) = 1, Γ(5) = 24, Γ(0.5) = √π.
362        approx::assert_relative_eq!(gamma(1.0), 1.0, epsilon = 1e-4);
363        approx::assert_relative_eq!(gamma(2.0), 1.0, epsilon = 1e-4);
364        approx::assert_relative_eq!(gamma(5.0), 24.0, epsilon = 1e-3);
365        approx::assert_relative_eq!(gamma(0.5), PI.sqrt(), epsilon = 1e-3);
366    }
367
368    #[test]
369    fn mantegna_sigma_u_is_finite() {
370        let s = CuckooSearch::<TestBackend>::mantegna_sigma_u(1.5);
371        assert!(s.is_finite() && s > 0.0);
372    }
373
374    #[test]
375    fn cuckoo_reduces_on_sphere_d10() {
376        // Pure-Lévy CS has no gradient-biased update — it's a biased
377        // random walk with abandonment. The Lévy flights are the
378        // interesting part; otherwise CS is a thin wrapper around
379        // random walk + abandonment, so convergence to machine
380        // precision is not expected within reasonable budgets on
381        // Sphere-D10. Threshold 20.0 in 800 generations is still a ~4×
382        // reduction from the uniform-random baseline (≈ 87) — it
383        // verifies the Lévy machinery composes correctly.
384        let device = Default::default();
385        let strategy = CuckooSearch::<TestBackend>::new();
386        let mut params = CuckooConfig::default_for(30, 10);
387        params.alpha = 0.2;
388        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
389        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
390            strategy, params, fitness_fn, 19, device, 800,
391        );
392        harness.reset();
393        while !harness.step(()).done {}
394        let best = harness.latest_metrics().unwrap().best_fitness_ever;
395        assert!(best < 20.0, "Cuckoo D10 best={best}");
396    }
397}