1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum PsoVariant {
61 Inertia,
63 Constriction,
67}
68
69#[derive(Debug, Clone)]
71pub struct PsoConfig {
72 pub pop_size: usize,
74 pub genome_dim: usize,
76 pub bounds: (f32, f32),
78 pub inertia: f32,
80 pub c1: f32,
82 pub c2: f32,
84 pub v_max: f32,
87 pub variant: PsoVariant,
89}
90
91impl PsoConfig {
92 #[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 #[must_use]
125 pub fn constriction_chi(&self) -> f32 {
126 let phi = self.c1 + self.c2;
127 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#[derive(Debug, Clone)]
139pub struct PsoState<B: Backend> {
140 pub positions: Tensor<B, 2>,
142 pub velocities: Tensor<B, 2>,
144 pub personal_best: Tensor<B, 2>,
146 pub personal_best_fitness: Vec<f32>,
148 pub global_best: Option<Tensor<B, 2>>,
150 pub global_best_fitness: f32,
152 pub best_fitness: f32,
154 pub generation: usize,
156}
157
158#[derive(Debug, Clone, Copy, Default)]
171pub struct ParticleSwarm<B: Backend> {
172 _backend: PhantomData<fn() -> B>,
173}
174
175impl<B: Backend> ParticleSwarm<B> {
176 #[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 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 if state.personal_best_fitness.is_empty() {
235 return (state.positions.clone(), state.clone());
236 }
237
238 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 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 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 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 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 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 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}