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