Async coverage was incomplete in 0.7 (only RandomSearch and DifferentialEvolution had run_async). 0.8 closes the gap: every one of the 33 algorithms now exposes run_async(&problem, concurrency).await, gated on the async feature. - Population-based algorithms fan out per-generation evaluations through evaluate_batch_async with concurrency-bounded FuturesOrdered chunks. - Steady-state algorithms (HillClimber, SimulatedAnnealing, OnePlusOneEs, Paes, NelderMead) await each step sequentially; they accept the concurrency parameter for API uniformity. - TabuSearch fans out the K-neighbor batch each step. - Surrogate algorithms (BayesianOpt, Tpe) batch the initial design and await per-iteration acquisitions sequentially so the surrogate can update between picks. - Hyperband uses a new AsyncPartialProblem trait (mirroring PartialProblem for multi-fidelity workloads) and a parallel evaluate_batch_at_budget_async helper; each Successive-Halving rung fans out its budgeted evaluations. All paths preserve seeded determinism: RNG draws happen on the main task in the same order as the sync path, and only the evaluations are concurrent. Adds a dedicated cookbook recipe at docs/book/src/cookbook/async.md with a worked example (DifferentialEvolution under tokio) and guidance on picking concurrency. Cross-references in SUMMARY.md and cookbook.md are updated to surface the new recipe. The follow-up docs commit reconciles the rest of the user guide and README to describe the new feature; this commit is the bare async surface.
337 lines
11 KiB
Rust
337 lines
11 KiB
Rust
//! `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.
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```
|
||
/// use heuropt::prelude::*;
|
||
///
|
||
/// struct Sphere;
|
||
/// impl Problem for Sphere {
|
||
/// type Decision = Vec<f64>;
|
||
/// fn objectives(&self) -> ObjectiveSpace {
|
||
/// ObjectiveSpace::new(vec![Objective::minimize("f")])
|
||
/// }
|
||
/// fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
||
/// Evaluation::new(vec![x.iter().map(|v| v * v).sum::<f64>()])
|
||
/// }
|
||
/// }
|
||
///
|
||
/// let mut opt = OnePlusOneEs::new(
|
||
/// OnePlusOneEsConfig {
|
||
/// iterations: 1_000,
|
||
/// initial_sigma: 0.5,
|
||
/// adaptation_period: 50,
|
||
/// step_increase: 1.22,
|
||
/// seed: 42,
|
||
/// },
|
||
/// RealBounds::new(vec![(-5.0, 5.0); 3]),
|
||
/// );
|
||
/// let r = opt.run(&Sphere);
|
||
/// assert!(r.best.unwrap().evaluation.objectives[0] < 1e-3);
|
||
/// ```
|
||
#[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);
|
||
|
||
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, x) in child.iter_mut().enumerate() {
|
||
let (lo, hi) = self.bounds.bounds[j];
|
||
*x = (*x + 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(feature = "async")]
|
||
impl OnePlusOneEs {
|
||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||
/// the user-chosen async runtime. Available only with the `async`
|
||
/// feature.
|
||
///
|
||
/// `concurrency` is mostly inert here because (1+1)-ES evaluates
|
||
/// one child per iteration; it's accepted for API parity with
|
||
/// other algorithms.
|
||
pub async fn run_async<P>(
|
||
&mut self,
|
||
problem: &P,
|
||
concurrency: usize,
|
||
) -> OptimizationResult<Vec<f64>>
|
||
where
|
||
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
||
{
|
||
let _ = concurrency;
|
||
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);
|
||
|
||
let mut parent: Vec<f64> = self
|
||
.bounds
|
||
.bounds
|
||
.iter()
|
||
.map(|&(lo, hi)| 0.5 * (lo + hi))
|
||
.collect();
|
||
let mut parent_eval = problem.evaluate_async(&parent).await;
|
||
let mut evaluations = 1usize;
|
||
|
||
let mut sigma = self.config.initial_sigma;
|
||
let mut window = std::collections::VecDeque::with_capacity(self.config.adaptation_period);
|
||
|
||
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, x) in child.iter_mut().enumerate() {
|
||
let (lo, hi) = self.bounds.bounds[j];
|
||
*x = (*x + normal.sample(&mut rng)).clamp(lo, hi);
|
||
}
|
||
let child_eval = problem.evaluate_async(&child).await;
|
||
evaluations += 1;
|
||
|
||
let accepted = !worse_than(&child_eval, &parent_eval, direction);
|
||
if accepted {
|
||
parent = child;
|
||
parent_eval = child_eval;
|
||
}
|
||
|
||
window.push_back(if accepted { 1u8 } else { 0u8 });
|
||
if window.len() > self.config.adaptation_period {
|
||
window.pop_front();
|
||
}
|
||
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,
|
||
)
|
||
}
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|