rlevo_evolution/algorithms/metaheuristic/
gwo.rs1use std::marker::PhantomData;
34
35use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
36use rand::Rng;
37use rand::RngExt;
38
39use crate::rng::{SeedPurpose, seed_stream};
40use crate::strategy::{Strategy, StrategyMetrics};
41
42#[derive(Debug, Clone)]
44pub struct GwoConfig {
45 pub pop_size: usize,
47 pub genome_dim: usize,
49 pub bounds: (f32, f32),
51 pub max_generations: usize,
56}
57
58impl GwoConfig {
59 #[must_use]
61 pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
62 Self {
63 pop_size,
64 genome_dim,
65 bounds: (-5.12, 5.12),
66 max_generations: 500,
67 }
68 }
69}
70
71#[derive(Debug, Clone)]
73pub struct GwoState<B: Backend> {
74 pub pack: Tensor<B, 2>,
76 pub fitness: Vec<f32>,
78 pub best_genome: Option<Tensor<B, 2>>,
80 pub best_fitness: f32,
82 pub generation: usize,
84}
85
86#[derive(Debug, Clone, Copy, Default)]
104pub struct GreyWolfOptimizer<B: Backend> {
105 _backend: PhantomData<fn() -> B>,
106}
107
108impl<B: Backend> GreyWolfOptimizer<B> {
109 #[must_use]
111 pub fn new() -> Self {
112 Self {
113 _backend: PhantomData,
114 }
115 }
116
117 fn sample_initial(params: &GwoConfig, rng: &mut dyn Rng, device: &<B as burn::tensor::backend::BackendTypes>::Device) -> Tensor<B, 2> {
118 let (lo, hi) = params.bounds;
119 let pop = params.pop_size;
124 let genome_dim = params.genome_dim;
125 let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
126 let mut rows = Vec::with_capacity(pop * genome_dim);
127 for _ in 0..pop * genome_dim {
128 rows.push(lo + (hi - lo) * stream.random::<f32>());
129 }
130 Tensor::<B, 2>::from_data(TensorData::new(rows, [pop, genome_dim]), device)
131 }
132}
133
134impl<B: Backend> Strategy<B> for GreyWolfOptimizer<B>
135where
136 B::Device: Clone,
137{
138 type Params = GwoConfig;
139 type State = GwoState<B>;
140 type Genome = Tensor<B, 2>;
141
142 fn init(&self, params: &GwoConfig, rng: &mut dyn Rng, device: &<B as burn::tensor::backend::BackendTypes>::Device) -> GwoState<B> {
149 assert!(params.pop_size >= 3, "GWO requires pop_size >= 3");
150 let pack = Self::sample_initial(params, rng, device);
151 GwoState {
152 pack,
153 fitness: Vec::new(),
154 best_genome: None,
155 best_fitness: f32::INFINITY,
156 generation: 0,
157 }
158 }
159
160 fn ask(
168 &self,
169 params: &GwoConfig,
170 state: &GwoState<B>,
171 rng: &mut dyn Rng,
172 device: &<B as burn::tensor::backend::BackendTypes>::Device,
173 ) -> (Tensor<B, 2>, GwoState<B>) {
174 if state.fitness.is_empty() {
176 return (state.pack.clone(), state.clone());
177 }
178
179 let pop_size = params.pop_size;
180 let genome_dim = params.genome_dim;
181
182 let top3 = argtop3_min(&state.fitness);
186
187 #[allow(clippy::cast_possible_wrap)]
188 let idx = Tensor::<B, 1, Int>::from_data(
189 TensorData::new(vec![top3[0] as i64, top3[1] as i64, top3[2] as i64], [3]),
190 device,
191 );
192 let leaders = state.pack.clone().select(0, idx); #[allow(clippy::cast_precision_loss)]
196 let t = state.generation as f32;
197 #[allow(clippy::cast_precision_loss)]
198 let max_t = params.max_generations.max(1) as f32;
199 let a = 2.0 * (1.0 - (t / max_t).min(1.0));
200
201 let mut update = Tensor::<B, 2>::zeros([pop_size, genome_dim], device);
202 #[allow(clippy::cast_sign_loss)]
206 for k in 0..3 {
207 let gen_k = state.generation as u64 * 3 + k as u64;
208 let r1 = {
209 let mut s = seed_stream(rng.next_u64(), gen_k, SeedPurpose::Other);
210 let mut rows = Vec::with_capacity(pop_size * genome_dim);
211 for _ in 0..pop_size * genome_dim {
212 rows.push(s.random::<f32>());
213 }
214 Tensor::<B, 2>::from_data(TensorData::new(rows, [pop_size, genome_dim]), device)
215 };
216 let r2 = {
217 let mut s = seed_stream(rng.next_u64(), gen_k, SeedPurpose::Mutation);
218 let mut rows = Vec::with_capacity(pop_size * genome_dim);
219 for _ in 0..pop_size * genome_dim {
220 rows.push(s.random::<f32>());
221 }
222 Tensor::<B, 2>::from_data(TensorData::new(rows, [pop_size, genome_dim]), device)
223 };
224 let a_mat = r1.mul_scalar(2.0 * a).sub_scalar(a);
225 let c_mat = r2.mul_scalar(2.0);
226
227 #[allow(clippy::single_range_in_vec_init)]
228 let leader_row = leaders.clone().slice([k..k + 1]);
229 let leader_exp = leader_row.expand([pop_size, genome_dim]);
230 let d_k = (c_mat.mul(leader_exp.clone()) - state.pack.clone()).abs();
231 let x_k_prime = leader_exp - a_mat.mul(d_k);
232 update = update + x_k_prime;
233 }
234 let new_pack = update.div_scalar(3.0);
235 let (lo, hi) = params.bounds;
236 let new_pack = new_pack.clamp(lo, hi);
237
238 let mut next = state.clone();
239 next.pack.clone_from(&new_pack);
240 (new_pack, next)
241 }
242
243 fn tell(
249 &self,
250 _params: &GwoConfig,
251 population: Tensor<B, 2>,
252 fitness: Tensor<B, 1>,
253 mut state: GwoState<B>,
254 _rng: &mut dyn Rng,
255 ) -> (GwoState<B>, StrategyMetrics) {
256 let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
257 state.fitness.clone_from(&fitness_host);
258 state.pack.clone_from(&population);
259 let best_idx = argmin(&fitness_host);
260 if fitness_host[best_idx] < state.best_fitness {
261 state.best_fitness = fitness_host[best_idx];
262 let device = population.device();
263 #[allow(clippy::cast_possible_wrap)]
264 let idx = Tensor::<B, 1, Int>::from_data(
265 TensorData::new(vec![best_idx as i64], [1]),
266 &device,
267 );
268 state.best_genome = Some(population.select(0, idx));
269 }
270 state.generation += 1;
271 let m =
272 StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
273 state.best_fitness = m.best_fitness_ever;
274 (state, m)
275 }
276
277 fn best(&self, state: &GwoState<B>) -> Option<(Tensor<B, 2>, f32)> {
280 state
281 .best_genome
282 .as_ref()
283 .map(|g| (g.clone(), state.best_fitness))
284 }
285}
286
287fn argmin(xs: &[f32]) -> usize {
288 let mut best_idx = 0usize;
289 let mut best = f32::INFINITY;
290 for (i, &v) in xs.iter().enumerate() {
291 if v < best {
292 best = v;
293 best_idx = i;
294 }
295 }
296 best_idx
297}
298
299fn argtop3_min(xs: &[f32]) -> [usize; 3] {
301 assert!(xs.len() >= 3, "argtop3_min requires at least 3 elements");
302 let mut idx = [0usize, 1, 2];
303 let mut vals = [xs[0], xs[1], xs[2]];
304 if vals[0] > vals[1] {
306 vals.swap(0, 1);
307 idx.swap(0, 1);
308 }
309 if vals[1] > vals[2] {
310 vals.swap(1, 2);
311 idx.swap(1, 2);
312 }
313 if vals[0] > vals[1] {
314 vals.swap(0, 1);
315 idx.swap(0, 1);
316 }
317 for (i, &v) in xs.iter().enumerate().skip(3) {
318 if v < vals[2] {
319 vals[2] = v;
320 idx[2] = i;
321 if vals[1] > vals[2] {
322 vals.swap(1, 2);
323 idx.swap(1, 2);
324 }
325 if vals[0] > vals[1] {
326 vals.swap(0, 1);
327 idx.swap(0, 1);
328 }
329 }
330 }
331 idx
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use crate::fitness::FromFitnessEvaluable;
338 use crate::strategy::EvolutionaryHarness;
339 use burn::backend::Flex;
340 use rlevo_core::fitness::FitnessEvaluable;
341
342 type TestBackend = Flex;
343
344 struct Sphere;
345 struct SphereFit;
346 impl FitnessEvaluable for SphereFit {
347 type Individual = Vec<f64>;
348 type Landscape = Sphere;
349 fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
350 x.iter().map(|v| v * v).sum()
351 }
352 }
353
354 #[test]
355 fn argtop3_min_finds_three_smallest() {
356 let xs = [5.0, 2.0, 8.0, 1.0, 3.0, 9.0, 0.5];
357 let top = argtop3_min(&xs);
358 assert_eq!(top, [6, 3, 1]);
360 }
361
362 #[test]
363 fn gwo_converges_on_sphere_d10() {
364 let device = Default::default();
370 let strategy = GreyWolfOptimizer::<TestBackend>::new();
371 let params = GwoConfig::default_for(32, 10);
372 let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
373 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
374 strategy, params, fitness_fn, 11, device, 600,
375 );
376 harness.reset();
377 while !harness.step(()).done {}
378 let best = harness.latest_metrics().unwrap().best_fitness_ever;
379 assert!(best < 1e-3, "GWO D10 best={best}");
380 }
381}