rlevo_evolution/algorithms/metaheuristic/
cuckoo.rs1use std::f32::consts::PI;
31use std::marker::PhantomData;
32
33use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
34use rand::Rng;
35use rand::RngExt;
36use rand_distr::{Distribution as RandDistDist, Normal};
37
38use crate::rng::{SeedPurpose, seed_stream};
39use crate::strategy::{Strategy, StrategyMetrics};
40
41#[derive(Debug, Clone)]
43pub struct CuckooConfig {
44 pub pop_size: usize,
46 pub genome_dim: usize,
48 pub bounds: (f32, f32),
50 pub alpha: f32,
54 pub beta: f32,
56 pub p_a: f32,
58}
59
60impl CuckooConfig {
61 #[must_use]
63 pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
64 Self {
65 pop_size,
66 genome_dim,
67 bounds: (-5.12, 5.12),
68 alpha: 0.05,
69 beta: 1.5,
70 p_a: 0.25,
71 }
72 }
73}
74
75#[derive(Debug, Clone)]
77pub struct CuckooState<B: Backend> {
78 pub nests: Tensor<B, 2>,
80 pub fitness: Vec<f32>,
82 pub best_genome: Option<Tensor<B, 2>>,
84 pub best_fitness: f32,
86 pub generation: usize,
88}
89
90#[derive(Debug, Clone, Copy, Default)]
103pub struct CuckooSearch<B: Backend> {
104 _backend: PhantomData<fn() -> B>,
105}
106
107impl<B: Backend> CuckooSearch<B> {
108 #[must_use]
110 pub fn new() -> Self {
111 Self {
112 _backend: PhantomData,
113 }
114 }
115
116 fn mantegna_sigma_u(beta: f32) -> f32 {
118 let num = gamma(1.0 + beta) * ((PI * beta) / 2.0).sin();
120 let den = gamma(f32::midpoint(1.0, beta)) * beta * 2f32.powf((beta - 1.0) / 2.0);
121 (num / den).powf(1.0 / beta)
122 }
123}
124
125#[allow(clippy::many_single_char_names)]
132fn gamma(z: f32) -> f32 {
133 let g = 7.0_f32;
136 let p: [f32; 9] = [
137 0.999_999_999_999_809_93,
138 676.520_4,
139 -1_259.139_2,
140 771.323_4,
141 -176.615_04,
142 12.507_343,
143 -0.138_571_1,
144 9.984_369e-6,
145 1.505_632_7e-7,
146 ];
147 if z < 0.5 {
148 return PI / ((PI * z).sin() * gamma(1.0 - z));
149 }
150 let z = z - 1.0;
151 let mut x = p[0];
152 for (i, &coef) in p.iter().enumerate().skip(1) {
153 #[allow(clippy::cast_precision_loss)]
154 let i_f32 = i as f32;
155 x += coef / (z + i_f32);
156 }
157 let t = z + g + 0.5;
158 (2.0 * PI).sqrt() * t.powf(z + 0.5) * (-t).exp() * x
159}
160
161impl<B: Backend> Strategy<B> for CuckooSearch<B>
162where
163 B::Device: Clone,
164{
165 type Params = CuckooConfig;
166 type State = CuckooState<B>;
167 type Genome = Tensor<B, 2>;
168
169 fn init(&self, params: &CuckooConfig, rng: &mut dyn Rng, device: &<B as burn::tensor::backend::BackendTypes>::Device) -> CuckooState<B> {
180 let (lo, hi) = params.bounds;
181 let pop = params.pop_size;
186 let genome_dim = params.genome_dim;
187 let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
188 let mut nest_rows = Vec::with_capacity(pop * genome_dim);
189 for _ in 0..pop * genome_dim {
190 nest_rows.push(lo + (hi - lo) * stream.random::<f32>());
191 }
192 let nests =
193 Tensor::<B, 2>::from_data(TensorData::new(nest_rows, [pop, genome_dim]), device);
194 CuckooState {
195 nests,
196 fitness: Vec::new(),
197 best_genome: None,
198 best_fitness: f32::INFINITY,
199 generation: 0,
200 }
201 }
202
203 fn ask(
213 &self,
214 params: &CuckooConfig,
215 state: &CuckooState<B>,
216 rng: &mut dyn Rng,
217 device: &<B as burn::tensor::backend::BackendTypes>::Device,
218 ) -> (Tensor<B, 2>, CuckooState<B>) {
219 if state.fitness.is_empty() {
220 return (state.nests.clone(), state.clone());
221 }
222
223 let pop = params.pop_size;
224 let d = params.genome_dim;
225 let sigma_u = Self::mantegna_sigma_u(params.beta);
226
227 let mut stream = seed_stream(
228 rng.next_u64(),
229 state.generation as u64,
230 SeedPurpose::Mutation,
231 );
232 let normal_u = Normal::new(0.0_f32, sigma_u).expect("σ_u > 0");
233 let normal_v = Normal::new(0.0_f32, 1.0_f32).unwrap();
234 let mut step = vec![0f32; pop * d];
235 for v in &mut step {
236 let u: f32 = normal_u.sample(&mut stream);
237 let w: f32 = normal_v.sample(&mut stream);
238 *v = u / w.abs().powf(1.0 / params.beta);
239 }
240 let step_tensor = Tensor::<B, 2>::from_data(TensorData::new(step, [pop, d]), device);
241
242 let (lo, hi) = params.bounds;
243 let new_nests = (state.nests.clone() + step_tensor.mul_scalar(params.alpha)).clamp(lo, hi);
244
245 let mut next = state.clone();
246 next.nests.clone_from(&new_nests);
247 (new_nests, next)
248 }
249
250 fn tell(
265 &self,
266 params: &CuckooConfig,
267 population: Tensor<B, 2>,
268 fitness: Tensor<B, 1>,
269 mut state: CuckooState<B>,
270 rng: &mut dyn Rng,
271 ) -> (CuckooState<B>, StrategyMetrics) {
272 let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
273 let device = population.device();
274 let pop = params.pop_size;
275 let d = params.genome_dim;
276
277 if state.fitness.is_empty() {
278 state.fitness.clone_from(&fitness_host);
279 let best_idx = argmin(&fitness_host);
280 state.best_fitness = fitness_host[best_idx];
281 #[allow(clippy::cast_possible_wrap)]
282 let idx = Tensor::<B, 1, Int>::from_data(
283 TensorData::new(vec![best_idx as i64], [1]),
284 &device,
285 );
286 state.best_genome = Some(population.clone().select(0, idx));
287 state.nests = population;
288 state.generation += 1;
289 let m = StrategyMetrics::from_host_fitness(
290 state.generation,
291 &fitness_host,
292 state.best_fitness,
293 );
294 state.best_fitness = m.best_fitness_ever;
295 return (state, m);
296 }
297
298 #[allow(clippy::cast_possible_wrap)]
300 let mut rs: Vec<i64> = (0..pop).map(|i| i as i64).collect();
301 let mut new_fitness = state.fitness.clone();
302 for i in 0..pop {
303 if fitness_host[i] <= state.fitness[i] {
304 #[allow(clippy::cast_possible_wrap)]
305 {
306 rs[i] = (pop + i) as i64;
307 }
308 new_fitness[i] = fitness_host[i];
309 }
310 }
311 let stacked = Tensor::cat(vec![state.nests.clone(), population.clone()], 0);
312 let idx = Tensor::<B, 1, Int>::from_data(TensorData::new(rs, [pop]), &device);
313 state.nests = stacked.select(0, idx);
314 state.fitness = new_fitness;
315
316 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
319 let n_abandon = (params.p_a * pop as f32) as usize;
320 if n_abandon > 0 {
321 let mut rank: Vec<usize> = (0..pop).collect();
322 rank.sort_by(|&a, &b| state.fitness[b].partial_cmp(&state.fitness[a]).unwrap());
323 let worst: Vec<usize> = rank.into_iter().take(n_abandon).collect();
324 let (lo, hi) = params.bounds;
325 let mut abandon_stream =
329 seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Replacement);
330 let mut fresh_rows = Vec::with_capacity(n_abandon * d);
331 for _ in 0..n_abandon * d {
332 fresh_rows.push(lo + (hi - lo) * abandon_stream.random::<f32>());
333 }
334 let fresh =
335 Tensor::<B, 2>::from_data(TensorData::new(fresh_rows, [n_abandon, d]), &device);
336 #[allow(clippy::cast_possible_wrap)]
337 let mut rs2: Vec<i64> = (0..pop).map(|i| i as i64).collect();
338 for (k, &slot) in worst.iter().enumerate() {
339 #[allow(clippy::cast_possible_wrap)]
340 {
341 rs2[slot] = (pop + k) as i64;
342 }
343 state.fitness[slot] = f32::INFINITY;
344 }
345 let stacked2 = Tensor::cat(vec![state.nests.clone(), fresh], 0);
346 let idx2 = Tensor::<B, 1, Int>::from_data(TensorData::new(rs2, [pop]), &device);
347 state.nests = stacked2.select(0, idx2);
348 }
349
350 let best_idx = argmin(&state.fitness);
352 if state.fitness[best_idx].is_finite() && state.fitness[best_idx] < state.best_fitness {
353 state.best_fitness = state.fitness[best_idx];
354 #[allow(clippy::cast_possible_wrap)]
355 let idx = Tensor::<B, 1, Int>::from_data(
356 TensorData::new(vec![best_idx as i64], [1]),
357 &device,
358 );
359 state.best_genome = Some(state.nests.clone().select(0, idx));
360 }
361
362 state.generation += 1;
363 let m =
364 StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
365 state.best_fitness = m.best_fitness_ever;
366 (state, m)
367 }
368
369 fn best(&self, state: &CuckooState<B>) -> Option<(Tensor<B, 2>, f32)> {
372 state
373 .best_genome
374 .as_ref()
375 .map(|g| (g.clone(), state.best_fitness))
376 }
377}
378
379fn argmin(xs: &[f32]) -> usize {
380 let mut best_idx = 0usize;
381 let mut best = f32::INFINITY;
382 for (i, &v) in xs.iter().enumerate() {
383 if v < best {
384 best = v;
385 best_idx = i;
386 }
387 }
388 best_idx
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use crate::fitness::FromFitnessEvaluable;
395 use crate::strategy::EvolutionaryHarness;
396 use burn::backend::Flex;
397 use rlevo_core::fitness::FitnessEvaluable;
398
399 type TestBackend = Flex;
400
401 struct Sphere;
402 struct SphereFit;
403 impl FitnessEvaluable for SphereFit {
404 type Individual = Vec<f64>;
405 type Landscape = Sphere;
406 fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
407 x.iter().map(|v| v * v).sum()
408 }
409 }
410
411 #[test]
412 fn gamma_matches_known_values() {
413 approx::assert_relative_eq!(gamma(1.0), 1.0, epsilon = 1e-4);
415 approx::assert_relative_eq!(gamma(2.0), 1.0, epsilon = 1e-4);
416 approx::assert_relative_eq!(gamma(5.0), 24.0, epsilon = 1e-3);
417 approx::assert_relative_eq!(gamma(0.5), PI.sqrt(), epsilon = 1e-3);
418 }
419
420 #[test]
421 fn mantegna_sigma_u_is_finite() {
422 let s = CuckooSearch::<TestBackend>::mantegna_sigma_u(1.5);
423 assert!(s.is_finite() && s > 0.0);
424 }
425
426 #[test]
427 fn cuckoo_reduces_on_sphere_d10() {
428 let device = Default::default();
437 let strategy = CuckooSearch::<TestBackend>::new();
438 let mut params = CuckooConfig::default_for(30, 10);
439 params.alpha = 0.2;
440 let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
441 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
442 strategy, params, fitness_fn, 19, device, 800,
443 );
444 harness.reset();
445 while !harness.step(()).done {}
446 let best = harness.latest_metrics().unwrap().best_fitness_ever;
447 assert!(best < 20.0, "Cuckoo D10 best={best}");
448 }
449}