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::{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/// Static configuration for [`GreyWolfOptimizer`].
43#[derive(Debug, Clone)]
44pub struct GwoConfig {
45    /// Pack size. The algorithm requires `pop_size ≥ 3`.
46    pub pop_size: usize,
47    /// Genome dimensionality.
48    pub genome_dim: usize,
49    /// Search-space bounds.
50    pub bounds: (f32, f32),
51    /// Budget used to schedule `a = 2·(1 − t/max_generations)`.
52    /// The strategy itself is memoryless with respect to the harness's
53    /// stopping criterion, so this value simply paces the exploration
54    /// coefficient and need not equal the harness budget.
55    pub max_generations: usize,
56}
57
58impl GwoConfig {
59    /// Default configuration for a given population size and genome dimensionality.
60    #[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/// Generation state for [`GreyWolfOptimizer`].
72#[derive(Debug, Clone)]
73pub struct GwoState<B: Backend> {
74    /// Current pack positions, shape `(pop_size, D)`.
75    pub pack: Tensor<B, 2>,
76    /// Host-side fitness cache.
77    pub fitness: Vec<f32>,
78    /// Best-so-far genome, shape `(1, D)` — corresponds to α.
79    pub best_genome: Option<Tensor<B, 2>>,
80    /// Best-so-far fitness.
81    pub best_fitness: f32,
82    /// Generation counter.
83    pub generation: usize,
84}
85
86/// Grey Wolf Optimizer strategy.
87///
88/// # Panics
89///
90/// [`Strategy::init`] panics if `params.pop_size < 3`, since the update
91/// rule needs three distinct leaders (`α`, `β`, `δ`).
92///
93/// # Example
94///
95/// ```no_run
96/// use burn::backend::Flex;
97/// use rlevo_evolution::algorithms::metaheuristic::gwo::{GreyWolfOptimizer, GwoConfig};
98///
99/// let strategy = GreyWolfOptimizer::<Flex>::new();
100/// let params = GwoConfig::default_for(32, 10);
101/// let _ = (strategy, params);
102/// ```
103#[derive(Debug, Clone, Copy, Default)]
104pub struct GreyWolfOptimizer<B: Backend> {
105    _backend: PhantomData<fn() -> B>,
106}
107
108impl<B: Backend> GreyWolfOptimizer<B> {
109    /// Builds a new (stateless) strategy object.
110    #[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        // Host-sample from a deterministic `seed_stream` rather than the
120        // process-wide Flex RNG (`B::seed` + `Tensor::random`), whose draws
121        // interleave with sibling tests under the parallel runner and are
122        // not reproducible across thread schedules.
123        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    /// Samples the initial pack uniformly within [`GwoConfig::bounds`] using
143    /// the host-RNG convention and sets the generation counter to zero.
144    ///
145    /// # Panics
146    ///
147    /// Panics if `params.pop_size < 3`.
148    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    /// Proposes the next pack positions.
161    ///
162    /// On the first call (before any [`tell`](Strategy::tell)), returns the
163    /// initial pack unchanged so it can be evaluated before the first rank
164    /// step. On subsequent calls, promotes the three lowest-fitness wolves to
165    /// `α`, `β`, `δ` and computes the weighted-average update, then clamps
166    /// the result to [`GwoConfig::bounds`].
167    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        // First call: evaluate initial pack so `tell` can rank it.
175        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        // Rank the pack host-side — O(N log N) is noise compared to the
183        // device ops below, and sorting on the host keeps us free of
184        // backend-specific `argsort` quirks.
185        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); // (3, D)
193
194        // Linearly decrease a from 2 to 0.
195        #[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        // Host-sample r1, r2 ∈ U[0,1) per leader from deterministic
203        // `seed_stream`s (distinct purposes) so the draws stay reproducible
204        // across thread schedules, rather than racing the global Flex RNG.
205        #[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    /// Records evaluated fitness, updates best-so-far, and increments the
244    /// generation counter.
245    ///
246    /// Returns the updated [`GwoState`] and a [`StrategyMetrics`] snapshot
247    /// for the completed generation.
248    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    /// Returns the α (best-so-far) genome and its fitness, or `None` before
278    /// the first [`tell`](Strategy::tell) call.
279    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
299/// Indices of the three smallest values in `xs`. Panics if `xs.len() < 3`.
300fn 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    // Sort the initial three ascending.
305    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        // Values at indices: 6 (0.5), 3 (1.0), 1 (2.0).
359        assert_eq!(top, [6, 3, 1]);
360    }
361
362    #[test]
363    fn gwo_converges_on_sphere_d10() {
364        // GWO is a "legacy comparator" per the module-level candor note;
365        // it converges strongly on Sphere but does not reach machine
366        // precision as quickly as DE/Rand1/bin. Budget 600 gens keeps
367        // the test within the acceptance bar (rank-1 acceptable within
368        // 2× of the classical baselines).
369        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}