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