Skip to main content

rlevo_evolution/algorithms/metaheuristic/
gwo.rs

1//! Grey Wolf Optimizer.
2//!
3//! Each generation ranks the pack by fitness, promotes the top three
4//! wolves to `α`, `β`, `δ`, and updates every wolf toward a weighted
5//! average of the three. A linearly decreasing coefficient `a ∈ [2, 0]`
6//! drives the exploration-to-exploitation transition.
7//!
8//! # Update rule
9//!
10//! For each wolf `i` and each leader `k ∈ {α, β, δ}`:
11//!
12//! - `A_k = 2·a·r1 − a`, `C_k = 2·r2` with `r1, r2 ∈ U[0, 1]`,
13//! - `D_k = |C_k · X_k − X_i|`,
14//! - `X_k' = X_k − A_k · D_k`,
15//! - `X_i ← (X_α' + X_β' + X_δ') / 3`.
16//!
17//! # Candor
18//!
19//! Legacy comparator. Camacho Villalón, Dorigo & Stützle (2020)
20//! demonstrate that GWO is algorithmically equivalent to a weighted
21//! PSO-like update; it has no novel search mechanism over Kennedy &
22//! Eberhart (1995). Ship it for API completeness — users who expect a
23//! GWO implementation will find one — but prefer CMA-ES or LSHADE for
24//! serious work when those are available.
25//!
26//! # References
27//!
28//! - Mirjalili, Mirjalili & Lewis (2014), *Grey Wolf Optimizer*.
29//! - Camacho Villalón, Dorigo & Stützle (2020), *Grey Wolf, Firefly and
30//!   Bat Algorithms: Three Widespread Algorithms that Do Not Contain
31//!   Any Novelty*.
32
33use 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/// Static configuration for [`GreyWolfOptimizer`].
42#[derive(Debug, Clone)]
43pub struct GwoConfig {
44    /// Pack size. The algorithm requires `pop_size ≥ 3`.
45    pub pop_size: usize,
46    /// Genome dimensionality.
47    pub genome_dim: usize,
48    /// Search-space bounds.
49    pub bounds: (f32, f32),
50    /// Budget used to schedule `a = a_start · (1 − t/max_generations)`.
51    /// The strategy itself is memoryless with respect to the harness's
52    /// stopping criterion, so this value simply paces the exploration
53    /// coefficient and need not equal the harness budget.
54    pub max_generations: usize,
55}
56
57impl GwoConfig {
58    /// Default configuration for a given population size and genome dimensionality.
59    #[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/// Generation state for [`GreyWolfOptimizer`].
71#[derive(Debug, Clone)]
72pub struct GwoState<B: Backend> {
73    /// Current pack positions, shape `(pop_size, D)`.
74    pub pack: Tensor<B, 2>,
75    /// Host-side fitness cache.
76    pub fitness: Vec<f32>,
77    /// Best-so-far genome, shape `(1, D)` — corresponds to α.
78    pub best_genome: Option<Tensor<B, 2>>,
79    /// Best-so-far fitness.
80    pub best_fitness: f32,
81    /// Generation counter.
82    pub generation: usize,
83}
84
85/// Grey Wolf Optimizer strategy.
86///
87/// # Panics
88///
89/// [`Strategy::init`] panics if `params.pop_size < 3`, since the update
90/// rule needs three distinct leaders (`α`, `β`, `δ`).
91///
92/// # Example
93///
94/// ```no_run
95/// use burn::backend::NdArray;
96/// use rlevo_evolution::algorithms::metaheuristic::gwo::{GreyWolfOptimizer, GwoConfig};
97///
98/// let strategy = GreyWolfOptimizer::<NdArray>::new();
99/// let params = GwoConfig::default_for(32, 10);
100/// let _ = (strategy, params);
101/// ```
102#[derive(Debug, Clone, Copy, Default)]
103pub struct GreyWolfOptimizer<B: Backend> {
104    _backend: PhantomData<fn() -> B>,
105}
106
107impl<B: Backend> GreyWolfOptimizer<B> {
108    /// Builds a new (stateless) strategy object.
109    #[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        // First call: evaluate initial pack so `tell` can rank it.
155        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        // Rank the pack host-side — O(N log N) is noise compared to the
163        // device ops below, and sorting on the host keeps us free of
164        // backend-specific `argsort` quirks.
165        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); // (3, D)
173
174        // Linearly decrease a from 2 to 0.
175        #[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
280/// Indices of the three smallest values in `xs`. Panics if `xs.len() < 3`.
281fn 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    // Sort the initial three ascending.
286    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        // Values at indices: 6 (0.5), 3 (1.0), 1 (2.0).
340        assert_eq!(top, [6, 3, 1]);
341    }
342
343    #[test]
344    fn gwo_converges_on_sphere_d10() {
345        // GWO is a "legacy comparator" per the module-level candor note;
346        // it converges strongly on Sphere but does not reach machine
347        // precision as quickly as DE/Rand1/bin. Budget 600 gens keeps
348        // the test within the acceptance bar (rank-1 acceptable within
349        // 2× of the classical baselines).
350        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}