feat(algorithms): add OnePlusOneEs (1+1)-ES with Rechenberg's one-fifth rule

Rechenberg 1973's elemental evolution strategy: one parent, one child
each generation, accept the child if it is no worse, and adapt the
mutation step size by tracking the success rate. If more than 1/5 of
recent moves were accepted the search is too cautious — multiply σ by
`step_increase` (typical 1.22). Below 1/5 — divide by the same factor.
At 1/5 — leave it alone. The success window has length `adaptation_period`.

Single-objective only. Vec<f64> only. Generic Gaussian step bounded by
the embedded `RealBounds`.

Why ship it: it's the smallest possible self-adapting evolution strategy
and a useful pedagogical / baseline endpoint. Pairs well as the budget
floor ("give me anything cheaper than CMA-ES").
This commit is contained in:
2026-05-05 09:51:12 -06:00
parent 5a0475c678
commit 7d8a29df2b
3 changed files with 214 additions and 1 deletions
+2
View File
@@ -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::*;
+211
View File
@@ -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<f64>` 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<P> Optimizer<P> for OnePlusOneEs
where
P: Problem<Decision = Vec<f64>> + Sync,
{
fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision> {
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<f64> = 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);
}
}
+1 -1
View File
@@ -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,