use std::marker::PhantomData;
use burn::tensor::{Distribution, Int, Tensor, TensorData, backend::Backend};
use rand::Rng;
use crate::rng::{SeedPurpose, seed_stream};
use crate::strategy::{Strategy, StrategyMetrics};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PsoVariant {
Inertia,
Constriction,
}
#[derive(Debug, Clone)]
pub struct PsoConfig {
pub pop_size: usize,
pub genome_dim: usize,
pub bounds: (f32, f32),
pub inertia: f32,
pub c1: f32,
pub c2: f32,
pub v_max: f32,
pub variant: PsoVariant,
}
impl PsoConfig {
#[must_use]
pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
Self {
pop_size,
genome_dim,
bounds: (-5.12, 5.12),
inertia: 0.7298,
c1: 1.49618,
c2: 1.49618,
v_max: 5.12,
variant: PsoVariant::Inertia,
}
}
#[must_use]
pub fn constriction_chi(&self) -> f32 {
let phi = self.c1 + self.c2;
debug_assert!(phi > 4.0, "PSO constriction requires c1 + c2 > 4");
let disc = (phi * phi - 4.0 * phi).max(0.0);
2.0 / (2.0 - phi - disc.sqrt()).abs()
}
}
#[derive(Debug, Clone)]
pub struct PsoState<B: Backend> {
pub positions: Tensor<B, 2>,
pub velocities: Tensor<B, 2>,
pub personal_best: Tensor<B, 2>,
pub personal_best_fitness: Vec<f32>,
pub global_best: Option<Tensor<B, 2>>,
pub global_best_fitness: f32,
pub best_fitness: f32,
pub generation: usize,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ParticleSwarm<B: Backend> {
_backend: PhantomData<fn() -> B>,
}
impl<B: Backend> ParticleSwarm<B> {
#[must_use]
pub fn new() -> Self {
Self {
_backend: PhantomData,
}
}
fn sample_positions(params: &PsoConfig, rng: &mut dyn Rng, device: &B::Device) -> Tensor<B, 2> {
let (lo, hi) = params.bounds;
B::seed(device, rng.next_u64());
Tensor::<B, 2>::random(
[params.pop_size, params.genome_dim],
Distribution::Uniform(f64::from(lo), f64::from(hi)),
device,
)
}
}
impl<B: Backend> Strategy<B> for ParticleSwarm<B>
where
B::Device: Clone,
{
type Params = PsoConfig;
type State = PsoState<B>;
type Genome = Tensor<B, 2>;
fn init(&self, params: &PsoConfig, rng: &mut dyn Rng, device: &B::Device) -> PsoState<B> {
let positions = Self::sample_positions(params, rng, device);
let velocities = Tensor::<B, 2>::zeros([params.pop_size, params.genome_dim], device);
let personal_best = positions.clone();
PsoState {
positions,
velocities,
personal_best,
personal_best_fitness: Vec::new(),
global_best: None,
global_best_fitness: f32::INFINITY,
best_fitness: f32::INFINITY,
generation: 0,
}
}
fn ask(
&self,
params: &PsoConfig,
state: &PsoState<B>,
rng: &mut dyn Rng,
device: &B::Device,
) -> (Tensor<B, 2>, PsoState<B>) {
if state.personal_best_fitness.is_empty() {
return (state.positions.clone(), state.clone());
}
B::seed(
device,
seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Other).next_u64(),
);
let r1 = Tensor::<B, 2>::random(
[params.pop_size, params.genome_dim],
Distribution::Uniform(0.0, 1.0),
device,
);
B::seed(
device,
seed_stream(
rng.next_u64(),
state.generation as u64,
SeedPurpose::Mutation,
)
.next_u64(),
);
let r2 = Tensor::<B, 2>::random(
[params.pop_size, params.genome_dim],
Distribution::Uniform(0.0, 1.0),
device,
);
let gbest = state
.global_best
.as_ref()
.expect("global_best populated after the first tell")
.clone()
.expand([params.pop_size, params.genome_dim]);
let cognitive = (state.personal_best.clone() - state.positions.clone())
.mul(r1)
.mul_scalar(params.c1);
let social = (gbest - state.positions.clone())
.mul(r2)
.mul_scalar(params.c2);
let new_velocities = match params.variant {
PsoVariant::Inertia => {
state.velocities.clone().mul_scalar(params.inertia) + cognitive + social
}
PsoVariant::Constriction => {
let chi = params.constriction_chi();
(state.velocities.clone() + cognitive + social).mul_scalar(chi)
}
};
let new_velocities = new_velocities.clamp(-params.v_max, params.v_max);
let (lo, hi) = params.bounds;
let new_positions = (state.positions.clone() + new_velocities.clone()).clamp(lo, hi);
let mut next = state.clone();
next.positions.clone_from(&new_positions);
next.velocities = new_velocities;
(new_positions, next)
}
fn tell(
&self,
params: &PsoConfig,
population: Tensor<B, 2>,
fitness: Tensor<B, 1>,
mut state: PsoState<B>,
_rng: &mut dyn Rng,
) -> (PsoState<B>, StrategyMetrics) {
let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
let device = population.device();
if state.personal_best_fitness.is_empty() {
state.personal_best.clone_from(&population);
state.personal_best_fitness.clone_from(&fitness_host);
let best_idx = argmin(&fitness_host);
state.global_best_fitness = fitness_host[best_idx];
#[allow(clippy::cast_possible_wrap)]
let idx = Tensor::<B, 1, Int>::from_data(
TensorData::new(vec![best_idx as i64], [1]),
&device,
);
state.global_best = Some(population.clone().select(0, idx));
state.best_fitness = state.global_best_fitness;
state.generation += 1;
state.positions = population;
let m = StrategyMetrics::from_host_fitness(
state.generation,
&fitness_host,
state.best_fitness,
);
state.best_fitness = m.best_fitness_ever;
return (state, m);
}
let pop_size = params.pop_size;
let genome_dim = params.genome_dim;
let mut improved = vec![0i64; pop_size];
let mut new_pbest_fit = state.personal_best_fitness.clone();
for i in 0..pop_size {
if fitness_host[i] < state.personal_best_fitness[i] {
improved[i] = 1;
new_pbest_fit[i] = fitness_host[i];
}
}
let mask_row =
Tensor::<B, 1, Int>::from_data(TensorData::new(improved, [pop_size]), &device)
.equal_elem(1);
let mask = mask_row
.unsqueeze_dim::<2>(1)
.expand([pop_size, genome_dim]);
state.personal_best = state
.personal_best
.clone()
.mask_where(mask, population.clone());
state.personal_best_fitness.clone_from(&new_pbest_fit);
let best_idx = argmin(&new_pbest_fit);
if new_pbest_fit[best_idx] < state.global_best_fitness {
state.global_best_fitness = new_pbest_fit[best_idx];
#[allow(clippy::cast_possible_wrap)]
let idx = Tensor::<B, 1, Int>::from_data(
TensorData::new(vec![best_idx as i64], [1]),
&device,
);
state.global_best = Some(state.personal_best.clone().select(0, idx));
}
state.positions = population;
state.generation += 1;
let m = StrategyMetrics::from_host_fitness(
state.generation,
&fitness_host,
state.best_fitness.min(state.global_best_fitness),
);
state.best_fitness = m.best_fitness_ever;
(state, m)
}
fn best(&self, state: &PsoState<B>) -> Option<(Tensor<B, 2>, f32)> {
state
.global_best
.as_ref()
.map(|g| (g.clone(), state.global_best_fitness))
}
}
fn argmin(xs: &[f32]) -> usize {
let mut best_idx = 0usize;
let mut best = f32::INFINITY;
for (i, &v) in xs.iter().enumerate() {
if v < best {
best = v;
best_idx = i;
}
}
best_idx
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fitness::FromFitnessEvaluable;
use crate::strategy::EvolutionaryHarness;
use burn::backend::NdArray;
use rlevo_core::fitness::FitnessEvaluable;
type TestBackend = NdArray;
struct Sphere;
struct SphereFit;
impl FitnessEvaluable for SphereFit {
type Individual = Vec<f64>;
type Landscape = Sphere;
fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
x.iter().map(|v| v * v).sum()
}
}
fn run_pso(variant: PsoVariant, dim: usize, generations: usize, seed: u64) -> f32 {
let device = Default::default();
let strategy = ParticleSwarm::<TestBackend>::new();
let mut params = PsoConfig::default_for(32, dim);
params.variant = variant;
if variant == PsoVariant::Constriction {
params.c1 = 2.05;
params.c2 = 2.05;
}
let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
strategy,
params,
fitness_fn,
seed,
device,
generations,
);
harness.reset();
loop {
let step = harness.step(());
if step.done {
break;
}
}
harness.latest_metrics().unwrap().best_fitness_ever
}
#[test]
fn inertia_converges_on_sphere_d10() {
let best = run_pso(PsoVariant::Inertia, 10, 500, 42);
assert!(best < 1e-6, "PSO inertia D10 best={best}");
}
#[test]
fn constriction_converges_on_sphere_d10() {
let best = run_pso(PsoVariant::Constriction, 10, 500, 7);
assert!(best < 1e-6, "PSO constriction D10 best={best}");
}
#[test]
fn constriction_chi_matches_canonical_value() {
let mut cfg = PsoConfig::default_for(2, 2);
cfg.c1 = 2.05;
cfg.c2 = 2.05;
approx::assert_relative_eq!(cfg.constriction_chi(), 0.7298, epsilon = 1e-3);
}
}