Skip to main content

rlevo_evolution/algorithms/metaheuristic/
woa.rs

1//! Whale Optimization Algorithm.
2//!
3//! Each whale chooses per generation between two behaviours, driven by a
4//! uniform random `p ∈ U[0, 1]`:
5//!
6//! - `p < 0.5` — **encircle / search**:
7//!     - `|A| < 1`: exploit the current best (`X ← X_best − A·|C·X_best − X|`),
8//!     - `|A| ≥ 1`: explore by pulling toward a random other whale
9//!       (`X ← X_rand − A·|C·X_rand − X|`).
10//! - `p ≥ 0.5` — **spiral bubble-net**:
11//!   `X ← |X_best − X|·exp(b·l)·cos(2π·l) + X_best`, `l ∈ U[−1, 1]`,
12//!   `b = 1` (canonical).
13//!
14//! `A = 2a·r − a`, `C = 2r`, with `a` linearly decreased from 2 to 0
15//! over the budget.
16//!
17//! The branches are realized as two boolean masks and three tensor
18//! candidates — no divergent kernel paths.
19//!
20//! # Candor
21//!
22//! Legacy comparator. The spiral bubble-net and encircle-best operators
23//! compose to a motion pattern that is equivalent in expectation to a
24//! weighted PSO update toward the current best (Camacho Villalón et al.
25//! 2020 review the structural similarities). Ship it for API coverage;
26//! prefer CMA-ES or LSHADE when available.
27//!
28//! # References
29//!
30//! - Mirjalili & Lewis (2016), *The Whale Optimization Algorithm*.
31
32use std::f32::consts::PI;
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 [`WhaleOptimization`].
43#[derive(Debug, Clone)]
44pub struct WoaConfig {
45    /// Number of whales.
46    pub pop_size: usize,
47    /// Genome dimensionality.
48    pub genome_dim: usize,
49    /// Search-space bounds.
50    pub bounds: (f32, f32),
51    /// Budget pacing `a = 2·(1 − t/max_generations)`.
52    pub max_generations: usize,
53    /// Spiral shape constant (Mirjalili's canonical `b = 1`).
54    pub b: f32,
55}
56
57impl WoaConfig {
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            b: 1.0,
67        }
68    }
69}
70
71/// Generation state for [`WhaleOptimization`].
72#[derive(Debug, Clone)]
73pub struct WoaState<B: Backend> {
74    /// Current positions, shape `(pop_size, D)`.
75    pub positions: Tensor<B, 2>,
76    /// Host-side fitness cache.
77    pub fitness: Vec<f32>,
78    /// Best-so-far genome.
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/// Whale Optimization Algorithm strategy.
87///
88/// # Panics
89///
90/// [`Strategy::ask`] panics if called a second time without an intervening
91/// [`Strategy::tell`] — specifically, if `state.best_genome` is `None` after
92/// the first generation. In normal harness-driven usage this cannot happen:
93/// `ask` on the first call returns the initial positions unevaluated;
94/// `tell` then sets `best_genome`; and every subsequent `ask` finds it
95/// populated. Bypassing the harness and calling `ask` twice in a row without
96/// a `tell` in between will trigger the assert.
97///
98/// # Example
99///
100/// ```no_run
101/// use burn::backend::Flex;
102/// use rlevo_evolution::algorithms::metaheuristic::woa::{WhaleOptimization, WoaConfig};
103///
104/// let strategy = WhaleOptimization::<Flex>::new();
105/// let params = WoaConfig::default_for(32, 10);
106/// let _ = (strategy, params);
107/// ```
108#[derive(Debug, Clone, Copy, Default)]
109pub struct WhaleOptimization<B: Backend> {
110    _backend: PhantomData<fn() -> B>,
111}
112
113impl<B: Backend> WhaleOptimization<B> {
114    /// Builds a new (stateless) strategy object.
115    #[must_use]
116    pub fn new() -> Self {
117        Self {
118            _backend: PhantomData,
119        }
120    }
121}
122
123impl<B: Backend> Strategy<B> for WhaleOptimization<B>
124where
125    B::Device: Clone,
126{
127    type Params = WoaConfig;
128    type State = WoaState<B>;
129    type Genome = Tensor<B, 2>;
130
131    /// Samples the initial whale positions uniformly within
132    /// [`WoaConfig::bounds`] using the host-RNG convention and sets the
133    /// generation counter to zero.
134    fn init(&self, params: &WoaConfig, rng: &mut dyn Rng, device: &<B as burn::tensor::backend::BackendTypes>::Device) -> WoaState<B> {
135        let (lo, hi) = params.bounds;
136        // Host-sample the initial population from a deterministic
137        // `seed_stream` rather than the process-wide Flex RNG (`B::seed` +
138        // `Tensor::random`), whose draws interleave with sibling tests under
139        // the parallel runner and are not reproducible across schedules.
140        let pop = params.pop_size;
141        let genome_dim = params.genome_dim;
142        let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
143        let mut position_rows = Vec::with_capacity(pop * genome_dim);
144        for _ in 0..pop * genome_dim {
145            position_rows.push(lo + (hi - lo) * stream.random::<f32>());
146        }
147        let positions =
148            Tensor::<B, 2>::from_data(TensorData::new(position_rows, [pop, genome_dim]), device);
149        WoaState {
150            positions,
151            fitness: Vec::new(),
152            best_genome: None,
153            best_fitness: f32::INFINITY,
154            generation: 0,
155        }
156    }
157
158    /// Proposes the next whale positions.
159    ///
160    /// On the first call returns the initial positions unchanged. On
161    /// subsequent calls, each whale independently selects one of three
162    /// moves based on host-sampled scalars `p` and `|A|`:
163    ///
164    /// - `p < 0.5, |A| < 1` — encircle the current best,
165    /// - `p < 0.5, |A| ≥ 1` — search toward a random other whale,
166    /// - `p ≥ 0.5` — spiral toward the current best.
167    ///
168    /// The three candidate tensors are computed in parallel and composed
169    /// with boolean masks; no divergent kernel paths are used. Results are
170    /// clamped to [`WoaConfig::bounds`].
171    #[allow(clippy::many_single_char_names)]
172    fn ask(
173        &self,
174        params: &WoaConfig,
175        state: &WoaState<B>,
176        rng: &mut dyn Rng,
177        device: &<B as burn::tensor::backend::BackendTypes>::Device,
178    ) -> (Tensor<B, 2>, WoaState<B>) {
179        // First call: evaluate initial whales so `tell` can record fitness.
180        if state.fitness.is_empty() {
181            return (state.positions.clone(), state.clone());
182        }
183
184        let pop_size = params.pop_size;
185        let genome_dim = params.genome_dim;
186
187        // Linear schedule for a.
188        #[allow(clippy::cast_precision_loss)]
189        let t = state.generation as f32;
190        #[allow(clippy::cast_precision_loss)]
191        let max_t = params.max_generations.max(1) as f32;
192        let a = 2.0 * (1.0 - (t / max_t).min(1.0));
193
194        // Per-whale scalars: A, C, p, l. Sample on host via the scope
195        // splitmix stream so the seed contract is fully reproducible.
196        let mut stream = seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Other);
197        let mut rand_idx: Vec<i64> = Vec::with_capacity(pop_size);
198        let mut a_scalar: Vec<f32> = Vec::with_capacity(pop_size);
199        let mut c_scalar: Vec<f32> = Vec::with_capacity(pop_size);
200        let mut p_scalar: Vec<f32> = Vec::with_capacity(pop_size);
201        let mut l_scalar: Vec<f32> = Vec::with_capacity(pop_size);
202        let mut abs_a_lt_one: Vec<i64> = Vec::with_capacity(pop_size);
203        let mut p_lt_half: Vec<i64> = Vec::with_capacity(pop_size);
204        for i in 0..pop_size {
205            let r_a: f32 = stream.random::<f32>();
206            let r_c: f32 = stream.random::<f32>();
207            let p: f32 = stream.random::<f32>();
208            let l: f32 = 2.0 * stream.random::<f32>() - 1.0;
209            let a_val = 2.0 * a * r_a - a;
210            let c_val = 2.0 * r_c;
211            a_scalar.push(a_val);
212            c_scalar.push(c_val);
213            p_scalar.push(p);
214            l_scalar.push(l);
215            abs_a_lt_one.push(i64::from(a_val.abs() < 1.0));
216            p_lt_half.push(i64::from(p < 0.5));
217            // Pick a different index for the "search" branch.
218            let mut r = stream.random_range(0..pop_size);
219            if r == i {
220                r = (r + 1) % pop_size;
221            }
222            #[allow(clippy::cast_possible_wrap)]
223            rand_idx.push(r as i64);
224        }
225
226        let a_row = Tensor::<B, 1>::from_data(TensorData::new(a_scalar, [pop_size]), device)
227            .unsqueeze_dim::<2>(1)
228            .expand([pop_size, genome_dim]);
229        let c_row = Tensor::<B, 1>::from_data(TensorData::new(c_scalar, [pop_size]), device)
230            .unsqueeze_dim::<2>(1)
231            .expand([pop_size, genome_dim]);
232        let l_vec = Tensor::<B, 1>::from_data(TensorData::new(l_scalar, [pop_size]), device);
233        let rand_idx_t =
234            Tensor::<B, 1, Int>::from_data(TensorData::new(rand_idx, [pop_size]), device);
235        let x_rand = state.positions.clone().select(0, rand_idx_t);
236
237        let x_best = state
238            .best_genome
239            .as_ref()
240            .expect("best_genome populated after the first tell")
241            .clone()
242            .expand([pop_size, genome_dim]);
243
244        // Encircle toward X_best:  X_best − A · |C · X_best − X|
245        let enc_best = x_best.clone()
246            - a_row
247                .clone()
248                .mul((c_row.clone().mul(x_best.clone()) - state.positions.clone()).abs());
249        // Search toward X_rand:    X_rand − A · |C · X_rand − X|
250        let enc_rand =
251            x_rand.clone() - a_row.mul((c_row.mul(x_rand) - state.positions.clone()).abs());
252        // Spiral toward X_best:    |X_best − X| · exp(b·l) · cos(2π·l) + X_best
253        let dist = (x_best.clone() - state.positions.clone()).abs();
254        let factor = l_vec
255            .clone()
256            .mul_scalar(params.b)
257            .exp()
258            .mul(l_vec.mul_scalar(2.0 * PI).cos());
259        let factor_mat = factor.unsqueeze_dim::<2>(1).expand([pop_size, genome_dim]);
260        let spiral = dist.mul(factor_mat) + x_best;
261
262        // Compose: p < 0.5 ? (|A|<1 ? enc_best : enc_rand) : spiral.
263        let m_abs_a_lt_one =
264            Tensor::<B, 1, Int>::from_data(TensorData::new(abs_a_lt_one, [pop_size]), device)
265                .equal_elem(1)
266                .unsqueeze_dim::<2>(1)
267                .expand([pop_size, genome_dim]);
268        let m_p_lt_half =
269            Tensor::<B, 1, Int>::from_data(TensorData::new(p_lt_half, [pop_size]), device)
270                .equal_elem(1)
271                .unsqueeze_dim::<2>(1)
272                .expand([pop_size, genome_dim]);
273
274        let encircle = enc_rand.mask_where(m_abs_a_lt_one, enc_best);
275        let new_positions = spiral.mask_where(m_p_lt_half, encircle);
276
277        let (lo, hi) = params.bounds;
278        let new_positions = new_positions.clamp(lo, hi);
279
280        let mut next = state.clone();
281        next.positions.clone_from(&new_positions);
282        (new_positions, next)
283    }
284
285    /// Records evaluated fitness, updates the best-so-far (food source), and
286    /// increments the generation counter.
287    ///
288    /// Returns the updated [`WoaState`] and a [`StrategyMetrics`] snapshot
289    /// for the completed generation.
290    fn tell(
291        &self,
292        _params: &WoaConfig,
293        population: Tensor<B, 2>,
294        fitness: Tensor<B, 1>,
295        mut state: WoaState<B>,
296        _rng: &mut dyn Rng,
297    ) -> (WoaState<B>, StrategyMetrics) {
298        let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
299        state.fitness.clone_from(&fitness_host);
300        state.positions.clone_from(&population);
301        let best_idx = argmin(&fitness_host);
302        if fitness_host[best_idx] < state.best_fitness {
303            state.best_fitness = fitness_host[best_idx];
304            let device = population.device();
305            #[allow(clippy::cast_possible_wrap)]
306            let idx = Tensor::<B, 1, Int>::from_data(
307                TensorData::new(vec![best_idx as i64], [1]),
308                &device,
309            );
310            state.best_genome = Some(population.select(0, idx));
311        }
312        state.generation += 1;
313        let m =
314            StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
315        state.best_fitness = m.best_fitness_ever;
316        (state, m)
317    }
318
319    /// Returns the best-so-far (food-source) genome and its fitness, or
320    /// `None` before the first [`tell`](Strategy::tell) call.
321    fn best(&self, state: &WoaState<B>) -> Option<(Tensor<B, 2>, f32)> {
322        state
323            .best_genome
324            .as_ref()
325            .map(|g| (g.clone(), state.best_fitness))
326    }
327}
328
329fn argmin(xs: &[f32]) -> usize {
330    let mut best_idx = 0usize;
331    let mut best = f32::INFINITY;
332    for (i, &v) in xs.iter().enumerate() {
333        if v < best {
334            best = v;
335            best_idx = i;
336        }
337    }
338    best_idx
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::fitness::FromFitnessEvaluable;
345    use crate::strategy::EvolutionaryHarness;
346    use burn::backend::Flex;
347    use rlevo_core::fitness::FitnessEvaluable;
348
349    type TestBackend = Flex;
350
351    struct Sphere;
352    struct SphereFit;
353    impl FitnessEvaluable for SphereFit {
354        type Individual = Vec<f64>;
355        type Landscape = Sphere;
356        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
357            x.iter().map(|v| v * v).sum()
358        }
359    }
360
361    #[test]
362    fn woa_converges_on_sphere_d10() {
363        // WOA as "legacy comparator" per module-level candor note. We
364        // verify convergence direction — reaching within 1e-4 of the
365        // optimum on Sphere-D10 in 600 generations confirms the
366        // spiral/encircle composition functions correctly.
367        let device = Default::default();
368        let strategy = WhaleOptimization::<TestBackend>::new();
369        let params = WoaConfig::default_for(32, 10);
370        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
371        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
372            strategy, params, fitness_fn, 5, device, 600,
373        );
374        harness.reset();
375        while !harness.step(()).done {}
376        let best = harness.latest_metrics().unwrap().best_fitness_ever;
377        assert!(best < 1e-4, "WOA D10 best={best}");
378    }
379}