Skip to main content

rlevo_evolution/algorithms/metaheuristic/
pso.rs

1//! Particle Swarm Optimization.
2//!
3//! Canonical PSO with two selectable velocity-update variants:
4//!
5//! - [`PsoVariant::Inertia`] — the Shi & Eberhart (1998) inertia-weight
6//!   form: `v ← ω·v + c1·r1·(pbest − x) + c2·r2·(gbest − x)`.
7//! - [`PsoVariant::Constriction`] — the Clerc & Kennedy (2002)
8//!   constriction-factor form: `v ← χ·(v + c1·r1·(pbest − x) + c2·r2·(gbest − x))`.
9//!
10//! Both variants use the canonical *global-best* topology. Ring / local
11//! neighbourhoods are not currently implemented; only the fully-connected
12//! social structure is exposed.
13//!
14//! # Initialization
15//!
16//! Positions are drawn uniformly from the configured search bounds.
17//! Velocities are initialized to **zero**, not to a random slice of
18//! `[-v_max, v_max]` as several reference implementations do — the zero
19//! initialization converges slightly faster on Sphere and produces
20//! bit-reproducible initial populations independent of the velocity
21//! clamp.
22//!
23//! # First-generation protocol
24//!
25//! `ask` detects the first call by checking whether `personal_best_fitness`
26//! is empty and, if so, returns the current positions unchanged (no velocity
27//! update). `tell` detects the same condition and uses the received fitness
28//! to seed `personal_best_fitness` and `global_best` before returning.
29//! Any caller that bypasses [`EvolutionaryHarness`] must therefore call
30//! `ask` → evaluate → `tell` **twice** before the velocity update is live.
31//!
32//! [`EvolutionaryHarness`]: crate::strategy::EvolutionaryHarness
33//!
34//! # Position and velocity clamping
35//!
36//! After every velocity update the velocities are clamped to
37//! `[−v_max, v_max]` (per [`PsoConfig::v_max`]), and the resulting
38//! positions are clamped to [`PsoConfig::bounds`]. Particles that hit a
39//! boundary keep their clamped position but retain the (clamped) velocity,
40//! so they may escape on the next step.
41//!
42//! # References
43//!
44//! - Kennedy & Eberhart (1995), *Particle Swarm Optimization*.
45//! - Shi & Eberhart (1998), *A modified particle swarm optimizer*.
46//! - Clerc & Kennedy (2002), *The particle swarm — explosion, stability,
47//!   and convergence in a multidimensional complex space*.
48
49use std::marker::PhantomData;
50
51use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
52use rand::Rng;
53use rand::RngExt;
54
55use crate::rng::{SeedPurpose, seed_stream};
56use crate::strategy::{Strategy, StrategyMetrics};
57
58/// Which velocity-update rule PSO applies.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum PsoVariant {
61    /// `v ← ω·v + c1·r1·(pbest − x) + c2·r2·(gbest − x)`.
62    Inertia,
63    /// Clerc & Kennedy constriction form with factor
64    /// `χ = 2 / |2 − φ − √(φ² − 4φ)|`, where `φ = c1 + c2` and must be
65    /// `> 4`.
66    Constriction,
67}
68
69/// Static configuration for a [`ParticleSwarm`] run.
70#[derive(Debug, Clone)]
71pub struct PsoConfig {
72    /// Number of particles in the swarm.
73    pub pop_size: usize,
74    /// Genome (position) dimensionality.
75    pub genome_dim: usize,
76    /// Search-space bounds for initialization and position clamping.
77    pub bounds: (f32, f32),
78    /// Inertia weight (only used by [`PsoVariant::Inertia`]).
79    pub inertia: f32,
80    /// Cognitive coefficient (personal-best pull).
81    pub c1: f32,
82    /// Social coefficient (global-best pull).
83    pub c2: f32,
84    /// Per-dimension velocity clamp. Classical PSO literature recommends
85    /// ~half the search-space extent.
86    pub v_max: f32,
87    /// Variant.
88    pub variant: PsoVariant,
89}
90
91impl PsoConfig {
92    /// Default configuration matching Shi & Eberhart's canonical settings
93    /// (`ω = 0.7298`, `c1 = c2 = 1.49618`) — the constriction-equivalent
94    /// values so Inertia and Constriction variants agree in behaviour
95    /// under the same default.
96    ///
97    /// The default variant is [`PsoVariant::Inertia`]. To switch to the
98    /// constriction form, set `variant = PsoVariant::Constriction` and
99    /// update `c1` and `c2` so that `c1 + c2 > 4` (the Clerc & Kennedy
100    /// requirement — the default `c1 = c2 = 1.49618` gives `φ ≈ 2.99`,
101    /// which violates this); a canonical choice is `c1 = c2 = 2.05`.
102    #[must_use]
103    pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
104        Self {
105            pop_size,
106            genome_dim,
107            bounds: (-5.12, 5.12),
108            inertia: 0.7298,
109            c1: 1.49618,
110            c2: 1.49618,
111            v_max: 5.12,
112            variant: PsoVariant::Inertia,
113        }
114    }
115
116    /// Computes the constriction factor `χ = 2 / |2 − φ − √(φ² − 4φ)|`
117    /// where `φ = c1 + c2`.
118    ///
119    /// Clerc & Kennedy (2002) require `φ > 4` for the closed form to be
120    /// real-valued. If `φ ≤ 4` the discriminant is clamped to zero and
121    /// `χ` falls back to `1.0` (no contraction) so the strategy remains
122    /// numerically well-defined. A `debug_assert!` fires in debug builds
123    /// when this fallback is triggered; it is silent in release builds.
124    #[must_use]
125    pub fn constriction_chi(&self) -> f32 {
126        let phi = self.c1 + self.c2;
127        // Clerc & Kennedy require phi > 4; below that the closed form
128        // becomes imaginary. Fall back to 1.0 (i.e. no contraction) so
129        // the strategy stays numerically well-defined; user-supplied
130        // configs violating the contract are covered by debug_assert.
131        debug_assert!(phi > 4.0, "PSO constriction requires c1 + c2 > 4");
132        let disc = (phi * phi - 4.0 * phi).max(0.0);
133        2.0 / (2.0 - phi - disc.sqrt()).abs()
134    }
135}
136
137/// Generation state for [`ParticleSwarm`].
138#[derive(Debug, Clone)]
139pub struct PsoState<B: Backend> {
140    /// Current particle positions, shape `(pop_size, D)`.
141    pub positions: Tensor<B, 2>,
142    /// Current particle velocities, shape `(pop_size, D)`.
143    pub velocities: Tensor<B, 2>,
144    /// Personal-best positions, shape `(pop_size, D)`.
145    pub personal_best: Tensor<B, 2>,
146    /// Personal-best fitnesses (host-side cache).
147    pub personal_best_fitness: Vec<f32>,
148    /// Global-best position, shape `(1, D)`.
149    pub global_best: Option<Tensor<B, 2>>,
150    /// Global-best fitness.
151    pub global_best_fitness: f32,
152    /// Best-so-far fitness (mirrors `global_best_fitness` for PSO).
153    pub best_fitness: f32,
154    /// Generation counter.
155    pub generation: usize,
156}
157
158/// Particle Swarm Optimization strategy.
159///
160/// # Example
161///
162/// ```no_run
163/// use burn::backend::Flex;
164/// use rlevo_evolution::algorithms::metaheuristic::pso::{ParticleSwarm, PsoConfig};
165///
166/// let strategy = ParticleSwarm::<Flex>::new();
167/// let params = PsoConfig::default_for(32, 10);
168/// let _ = (strategy, params);
169/// ```
170#[derive(Debug, Clone, Copy, Default)]
171pub struct ParticleSwarm<B: Backend> {
172    _backend: PhantomData<fn() -> B>,
173}
174
175impl<B: Backend> ParticleSwarm<B> {
176    /// Builds a new (stateless) strategy object.
177    #[must_use]
178    pub fn new() -> Self {
179        Self {
180            _backend: PhantomData,
181        }
182    }
183
184    fn sample_positions(params: &PsoConfig, rng: &mut dyn Rng, device: &<B as burn::tensor::backend::BackendTypes>::Device) -> Tensor<B, 2> {
185        let (lo, hi) = params.bounds;
186        // Host-sample from a deterministic `seed_stream` rather than the
187        // process-wide Flex RNG (`B::seed` + `Tensor::random`), whose draws
188        // interleave with sibling tests under the parallel runner and are
189        // not reproducible across thread schedules.
190        let pop = params.pop_size;
191        let genome_dim = params.genome_dim;
192        let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
193        let mut rows = Vec::with_capacity(pop * genome_dim);
194        for _ in 0..pop * genome_dim {
195            rows.push(lo + (hi - lo) * stream.random::<f32>());
196        }
197        Tensor::<B, 2>::from_data(TensorData::new(rows, [pop, genome_dim]), device)
198    }
199}
200
201impl<B: Backend> Strategy<B> for ParticleSwarm<B>
202where
203    B::Device: Clone,
204{
205    type Params = PsoConfig;
206    type State = PsoState<B>;
207    type Genome = Tensor<B, 2>;
208
209    fn init(&self, params: &PsoConfig, rng: &mut dyn Rng, device: &<B as burn::tensor::backend::BackendTypes>::Device) -> PsoState<B> {
210        let positions = Self::sample_positions(params, rng, device);
211        let velocities = Tensor::<B, 2>::zeros([params.pop_size, params.genome_dim], device);
212        let personal_best = positions.clone();
213        PsoState {
214            positions,
215            velocities,
216            personal_best,
217            personal_best_fitness: Vec::new(),
218            global_best: None,
219            global_best_fitness: f32::INFINITY,
220            best_fitness: f32::INFINITY,
221            generation: 0,
222        }
223    }
224
225    fn ask(
226        &self,
227        params: &PsoConfig,
228        state: &PsoState<B>,
229        rng: &mut dyn Rng,
230        device: &<B as burn::tensor::backend::BackendTypes>::Device,
231    ) -> (Tensor<B, 2>, PsoState<B>) {
232        // First call: evaluate the initial positions so `tell` can
233        // populate personal_best_fitness / global_best.
234        if state.personal_best_fitness.is_empty() {
235            return (state.positions.clone(), state.clone());
236        }
237
238        // Sample r1, r2 ∈ U[0,1) — one matrix each per generation. Host
239        // sampling (distinct `seed_stream` purposes) keeps the draws
240        // reproducible across thread schedules; the global Flex RNG path
241        // could be interleaved by a concurrent test between seed and draw.
242        let pop = params.pop_size;
243        let genome_dim = params.genome_dim;
244        let r1 = {
245            let mut s = seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Other);
246            let mut rows = Vec::with_capacity(pop * genome_dim);
247            for _ in 0..pop * genome_dim {
248                rows.push(s.random::<f32>());
249            }
250            Tensor::<B, 2>::from_data(TensorData::new(rows, [pop, genome_dim]), device)
251        };
252        let r2 = {
253            let mut s =
254                seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Mutation);
255            let mut rows = Vec::with_capacity(pop * genome_dim);
256            for _ in 0..pop * genome_dim {
257                rows.push(s.random::<f32>());
258            }
259            Tensor::<B, 2>::from_data(TensorData::new(rows, [pop, genome_dim]), device)
260        };
261
262        let gbest = state
263            .global_best
264            .as_ref()
265            .expect("global_best populated after the first tell")
266            .clone()
267            .expand([params.pop_size, params.genome_dim]);
268
269        let cognitive = (state.personal_best.clone() - state.positions.clone())
270            .mul(r1)
271            .mul_scalar(params.c1);
272        let social = (gbest - state.positions.clone())
273            .mul(r2)
274            .mul_scalar(params.c2);
275
276        let new_velocities = match params.variant {
277            PsoVariant::Inertia => {
278                state.velocities.clone().mul_scalar(params.inertia) + cognitive + social
279            }
280            PsoVariant::Constriction => {
281                let chi = params.constriction_chi();
282                (state.velocities.clone() + cognitive + social).mul_scalar(chi)
283            }
284        };
285        let new_velocities = new_velocities.clamp(-params.v_max, params.v_max);
286        let (lo, hi) = params.bounds;
287        let new_positions = (state.positions.clone() + new_velocities.clone()).clamp(lo, hi);
288
289        let mut next = state.clone();
290        next.positions.clone_from(&new_positions);
291        next.velocities = new_velocities;
292        (new_positions, next)
293    }
294
295    fn tell(
296        &self,
297        params: &PsoConfig,
298        population: Tensor<B, 2>,
299        fitness: Tensor<B, 1>,
300        mut state: PsoState<B>,
301        _rng: &mut dyn Rng,
302    ) -> (PsoState<B>, StrategyMetrics) {
303        let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
304        let device = population.device();
305
306        // First tell: seed personal-bests.
307        if state.personal_best_fitness.is_empty() {
308            state.personal_best.clone_from(&population);
309            state.personal_best_fitness.clone_from(&fitness_host);
310            let best_idx = argmin(&fitness_host);
311            state.global_best_fitness = fitness_host[best_idx];
312            #[allow(clippy::cast_possible_wrap)]
313            let idx = Tensor::<B, 1, Int>::from_data(
314                TensorData::new(vec![best_idx as i64], [1]),
315                &device,
316            );
317            state.global_best = Some(population.clone().select(0, idx));
318            state.best_fitness = state.global_best_fitness;
319            state.generation += 1;
320            state.positions = population;
321            let m = StrategyMetrics::from_host_fitness(
322                state.generation,
323                &fitness_host,
324                state.best_fitness,
325            );
326            state.best_fitness = m.best_fitness_ever;
327            return (state, m);
328        }
329
330        let pop_size = params.pop_size;
331        let genome_dim = params.genome_dim;
332
333        // Update personal bests — greedy: replace when fitness improves.
334        let mut improved = vec![0i64; pop_size];
335        let mut new_pbest_fit = state.personal_best_fitness.clone();
336        for i in 0..pop_size {
337            if fitness_host[i] < state.personal_best_fitness[i] {
338                improved[i] = 1;
339                new_pbest_fit[i] = fitness_host[i];
340            }
341        }
342        let mask_row =
343            Tensor::<B, 1, Int>::from_data(TensorData::new(improved, [pop_size]), &device)
344                .equal_elem(1);
345        let mask = mask_row
346            .unsqueeze_dim::<2>(1)
347            .expand([pop_size, genome_dim]);
348        state.personal_best = state
349            .personal_best
350            .clone()
351            .mask_where(mask, population.clone());
352        state.personal_best_fitness.clone_from(&new_pbest_fit);
353
354        // Update global best from the new personal bests.
355        let best_idx = argmin(&new_pbest_fit);
356        if new_pbest_fit[best_idx] < state.global_best_fitness {
357            state.global_best_fitness = new_pbest_fit[best_idx];
358            #[allow(clippy::cast_possible_wrap)]
359            let idx = Tensor::<B, 1, Int>::from_data(
360                TensorData::new(vec![best_idx as i64], [1]),
361                &device,
362            );
363            state.global_best = Some(state.personal_best.clone().select(0, idx));
364        }
365
366        state.positions = population;
367        state.generation += 1;
368        let m = StrategyMetrics::from_host_fitness(
369            state.generation,
370            &fitness_host,
371            state.best_fitness.min(state.global_best_fitness),
372        );
373        state.best_fitness = m.best_fitness_ever;
374        (state, m)
375    }
376
377    fn best(&self, state: &PsoState<B>) -> Option<(Tensor<B, 2>, f32)> {
378        state
379            .global_best
380            .as_ref()
381            .map(|g| (g.clone(), state.global_best_fitness))
382    }
383}
384
385fn argmin(xs: &[f32]) -> usize {
386    let mut best_idx = 0usize;
387    let mut best = f32::INFINITY;
388    for (i, &v) in xs.iter().enumerate() {
389        if v < best {
390            best = v;
391            best_idx = i;
392        }
393    }
394    best_idx
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use crate::fitness::FromFitnessEvaluable;
401    use crate::strategy::EvolutionaryHarness;
402    use burn::backend::Flex;
403    use rlevo_core::fitness::FitnessEvaluable;
404
405    type TestBackend = Flex;
406
407    struct Sphere;
408    struct SphereFit;
409    impl FitnessEvaluable for SphereFit {
410        type Individual = Vec<f64>;
411        type Landscape = Sphere;
412        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
413            x.iter().map(|v| v * v).sum()
414        }
415    }
416
417    fn run_pso(variant: PsoVariant, dim: usize, generations: usize, seed: u64) -> f32 {
418        let device = Default::default();
419        let strategy = ParticleSwarm::<TestBackend>::new();
420        let mut params = PsoConfig::default_for(32, dim);
421        params.variant = variant;
422        if variant == PsoVariant::Constriction {
423            // Constriction requires φ = c1 + c2 > 4 (Clerc & Kennedy 2002).
424            params.c1 = 2.05;
425            params.c2 = 2.05;
426        }
427        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
428        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
429            strategy,
430            params,
431            fitness_fn,
432            seed,
433            device,
434            generations,
435        );
436        harness.reset();
437        loop {
438            let step = harness.step(());
439            if step.done {
440                break;
441            }
442        }
443        harness.latest_metrics().unwrap().best_fitness_ever
444    }
445
446    #[test]
447    fn inertia_converges_on_sphere_d10() {
448        // PSO on Sphere D=10: inertia variant. Budget 500 generations
449        // chosen to stay well within the acceptance envelope (within 2×
450        // of the best-in-class classical baseline on Rastrigin; on
451        // Sphere we want < 1e-6).
452        let best = run_pso(PsoVariant::Inertia, 10, 500, 42);
453        assert!(best < 1e-6, "PSO inertia D10 best={best}");
454    }
455
456    #[test]
457    fn constriction_converges_on_sphere_d10() {
458        let best = run_pso(PsoVariant::Constriction, 10, 500, 7);
459        assert!(best < 1e-6, "PSO constriction D10 best={best}");
460    }
461
462    #[test]
463    fn constriction_chi_matches_canonical_value() {
464        // φ = 4.1 → χ ≈ 0.729843788...
465        let mut cfg = PsoConfig::default_for(2, 2);
466        cfg.c1 = 2.05;
467        cfg.c2 = 2.05;
468        approx::assert_relative_eq!(cfg.constriction_chi(), 0.7298, epsilon = 1e-3);
469    }
470}