1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum PsoVariant {
41 Inertia,
43 Constriction,
47}
48
49#[derive(Debug, Clone)]
51pub struct PsoConfig {
52 pub pop_size: usize,
54 pub genome_dim: usize,
56 pub bounds: (f32, f32),
58 pub inertia: f32,
60 pub c1: f32,
62 pub c2: f32,
64 pub v_max: f32,
67 pub variant: PsoVariant,
69}
70
71impl PsoConfig {
72 #[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 #[must_use]
92 pub fn constriction_chi(&self) -> f32 {
93 let phi = self.c1 + self.c2;
94 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#[derive(Debug, Clone)]
106pub struct PsoState<B: Backend> {
107 pub positions: Tensor<B, 2>,
109 pub velocities: Tensor<B, 2>,
111 pub personal_best: Tensor<B, 2>,
113 pub personal_best_fitness: Vec<f32>,
115 pub global_best: Option<Tensor<B, 2>>,
117 pub global_best_fitness: f32,
119 pub best_fitness: f32,
121 pub generation: usize,
123}
124
125#[derive(Debug, Clone, Copy, Default)]
138pub struct ParticleSwarm<B: Backend> {
139 _backend: PhantomData<fn() -> B>,
140}
141
142impl<B: Backend> ParticleSwarm<B> {
143 #[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 if state.personal_best_fitness.is_empty() {
196 return (state.positions.clone(), state.clone());
197 }
198
199 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 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 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 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 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 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 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}