diff --git a/src/algorithms/mod.rs b/src/algorithms/mod.rs index a1f43d5..20ef7b2 100644 --- a/src/algorithms/mod.rs +++ b/src/algorithms/mod.rs @@ -15,6 +15,7 @@ pub mod moead; pub mod mopso; pub mod nsga2; pub mod nsga3; +pub mod one_plus_one_es; pub mod paes; pub(crate) mod parallel_eval; pub mod pesa2; @@ -43,6 +44,7 @@ pub use moead::*; pub use mopso::*; pub use nsga2::*; pub use nsga3::*; +pub use one_plus_one_es::*; pub use paes::*; pub use particle_swarm::*; pub use pesa2::*; diff --git a/src/algorithms/one_plus_one_es.rs b/src/algorithms/one_plus_one_es.rs new file mode 100644 index 0000000..1a9939d --- /dev/null +++ b/src/algorithms/one_plus_one_es.rs @@ -0,0 +1,211 @@ +//! `OnePlusOneEs` — the (1+1) evolution strategy with Rechenberg's +//! one-fifth success rule for σ adaptation. + +use rand_distr::{Distribution, Normal}; + +use crate::core::candidate::Candidate; +use crate::core::evaluation::Evaluation; +use crate::core::objective::Direction; +use crate::core::population::Population; +use crate::core::problem::Problem; +use crate::core::result::OptimizationResult; +use crate::core::rng::rng_from_seed; +use crate::operators::real::RealBounds; +use crate::traits::Optimizer; + +/// Configuration for [`OnePlusOneEs`]. +#[derive(Debug, Clone)] +pub struct OnePlusOneEsConfig { + /// Number of mutation iterations. + pub iterations: usize, + /// Initial mutation step size (`σ_0`). + pub initial_sigma: f64, + /// Number of recent iterations the success-rate is computed over. + /// The classic value is 10·dim; 50 is a fine default for low-dim + /// problems. + pub adaptation_period: usize, + /// Step-size multiplier when the success rate exceeds 1/5. Reciprocal + /// is applied when the rate is below 1/5. Rechenberg's analytical + /// derivation gives ≈ `0.817^(-1/n)` for dim n; 1.22 is a popular + /// dimension-agnostic value. + pub step_increase: f64, + /// Seed for the deterministic RNG. + pub seed: u64, +} + +impl Default for OnePlusOneEsConfig { + fn default() -> Self { + Self { + iterations: 5_000, + initial_sigma: 0.5, + adaptation_period: 50, + step_increase: 1.22, + seed: 42, + } + } +} + +/// (1+1)-ES with the one-fifth rule: tiny, parameter-light continuous +/// optimizer. `Vec` decisions only; single-objective only. +#[derive(Debug, Clone)] +pub struct OnePlusOneEs { + /// Algorithm configuration. + pub config: OnePlusOneEsConfig, + /// Per-variable bounds — used to seed the parent at the box midpoint + /// and clamp every mutated child. + pub bounds: RealBounds, +} + +impl OnePlusOneEs { + /// Construct a `OnePlusOneEs`. + pub fn new(config: OnePlusOneEsConfig, bounds: RealBounds) -> Self { + Self { config, bounds } + } +} + +impl

Optimizer

for OnePlusOneEs +where + P: Problem> + Sync, +{ + fn run(&mut self, problem: &P) -> OptimizationResult { + assert!(self.config.initial_sigma > 0.0, "OnePlusOneEs initial_sigma must be > 0"); + assert!( + self.config.step_increase > 1.0, + "OnePlusOneEs step_increase must be > 1", + ); + assert!( + self.config.adaptation_period >= 1, + "OnePlusOneEs adaptation_period must be >= 1", + ); + let objectives = problem.objectives(); + assert!( + objectives.is_single_objective(), + "OnePlusOneEs requires exactly one objective", + ); + let direction = objectives.objectives[0].direction; + let mut rng = rng_from_seed(self.config.seed); + + // Seed parent at midpoint of bounds. + let mut parent: Vec = self + .bounds + .bounds + .iter() + .map(|&(lo, hi)| 0.5 * (lo + hi)) + .collect(); + let mut parent_eval = problem.evaluate(&parent); + let mut evaluations = 1usize; + + let mut sigma = self.config.initial_sigma; + let mut window = std::collections::VecDeque::with_capacity(self.config.adaptation_period); + let n = parent.len(); + + for _ in 0..self.config.iterations { + let normal = Normal::new(0.0, sigma).expect("Normal::new(0, sigma)"); + let mut child = parent.clone(); + for j in 0..n { + let (lo, hi) = self.bounds.bounds[j]; + child[j] = (child[j] + normal.sample(&mut rng)).clamp(lo, hi); + } + let child_eval = problem.evaluate(&child); + evaluations += 1; + + // Accept if not strictly worse (so neutral moves are kept and + // can drive σ up when on a plateau). + let accepted = !worse_than(&child_eval, &parent_eval, direction); + if accepted { + parent = child; + parent_eval = child_eval; + } + + // Update success window. + window.push_back(if accepted { 1u8 } else { 0u8 }); + if window.len() > self.config.adaptation_period { + window.pop_front(); + } + // Apply one-fifth rule once we have a full window. + if window.len() == self.config.adaptation_period { + let success_count: usize = + window.iter().map(|&b| b as usize).sum(); + let rate = success_count as f64 / window.len() as f64; + if rate > 0.2 { + sigma *= self.config.step_increase; + } else if rate < 0.2 { + sigma /= self.config.step_increase; + } + } + } + + let best = Candidate::new(parent, parent_eval); + let population = Population::new(vec![best.clone()]); + let front = vec![best.clone()]; + OptimizationResult::new( + population, + front, + Some(best), + evaluations, + self.config.iterations, + ) + } +} + +fn worse_than(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool { + match (a.is_feasible(), b.is_feasible()) { + (false, true) => true, + (true, false) => false, + (false, false) => a.constraint_violation > b.constraint_violation, + (true, true) => match direction { + Direction::Minimize => a.objectives[0] > b.objectives[0], + Direction::Maximize => a.objectives[0] < b.objectives[0], + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tests_support::{SchafferN1, Sphere1D}; + + fn make_optimizer(seed: u64) -> OnePlusOneEs { + OnePlusOneEs::new( + OnePlusOneEsConfig { + iterations: 2_000, + initial_sigma: 1.0, + adaptation_period: 30, + step_increase: 1.22, + seed, + }, + RealBounds::new(vec![(-5.0, 5.0)]), + ) + } + + #[test] + fn finds_minimum_of_sphere() { + let mut opt = make_optimizer(1); + let r = opt.run(&Sphere1D); + let best = r.best.unwrap(); + assert!( + best.evaluation.objectives[0] < 1e-6, + "got f = {}", + best.evaluation.objectives[0], + ); + } + + #[test] + fn deterministic_with_same_seed() { + let mut a = make_optimizer(99); + let mut b = make_optimizer(99); + let ra = a.run(&Sphere1D); + let rb = b.run(&Sphere1D); + assert_eq!( + ra.best.unwrap().evaluation.objectives, + rb.best.unwrap().evaluation.objectives, + ); + } + + #[test] + #[should_panic(expected = "exactly one objective")] + fn multi_objective_panics() { + let mut opt = make_optimizer(0); + let _ = opt.run(&SchafferN1); + } +} diff --git a/src/prelude.rs b/src/prelude.rs index bd07f2e..0e257b0 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -26,7 +26,7 @@ pub use crate::algorithms::{ DifferentialEvolutionConfig, EpsilonMoea, EpsilonMoeaConfig, GeneticAlgorithm, GeneticAlgorithmConfig, Grea, GreaConfig, HillClimber, HillClimberConfig, Hype, HypeConfig, Ibea, IbeaConfig, Knea, KneaConfig, Moead, MoeadConfig, Mopso, MopsoConfig, Nsga2, - Nsga2Config, Nsga3, Nsga3Config, Paes, PaesConfig, ParticleSwarm, PesaII, PesaIIConfig, + Nsga2Config, Nsga3, Nsga3Config, OnePlusOneEs, OnePlusOneEsConfig, Paes, PaesConfig, ParticleSwarm, PesaII, PesaIIConfig, ParticleSwarmConfig, RandomSearch, RandomSearchConfig, Rvea, RveaConfig, SimulatedAnnealing, SimulatedAnnealingConfig, SmsEmoa, SmsEmoaConfig, Spea2, Spea2Config, TabuSearch,