From 24a0bdd473a7932b43ad75d6e2dfd982229f18e4 Mon Sep 17 00:00:00 2001 From: Manuel Raimann Date: Wed, 11 Feb 2026 22:04:23 +0100 Subject: [PATCH] feat: add Differential Evolution sampler Population-based optimizer with mutation + crossover. Supports three strategies (Rand1, Best1, CurrentToBest1), configurable F and CR, and auto-sized populations. No extra dependencies needed. --- src/lib.rs | 8 +- .../{de.rs => differential_evolution.rs} | 237 +++++------- src/sampler/mod.rs | 2 +- tests/differential_evolution_tests.rs | 350 ++++++++++++++++++ 4 files changed, 448 insertions(+), 149 deletions(-) rename src/sampler/{de.rs => differential_evolution.rs} (82%) create mode 100644 tests/differential_evolution_tests.rs diff --git a/src/lib.rs b/src/lib.rs index e0756a8..1187440 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -257,7 +257,9 @@ pub use sampler::CompletedTrial; pub use sampler::bohb::BohbSampler; #[cfg(feature = "cma-es")] pub use sampler::cma_es::CmaEsSampler; -pub use sampler::de::{DeSampler, DeStrategy}; +pub use sampler::differential_evolution::{ + DifferentialEvolutionSampler, DifferentialEvolutionStrategy, +}; #[cfg(feature = "gp")] pub use sampler::gp::GpSampler; pub use sampler::grid::GridSearchSampler; @@ -300,7 +302,9 @@ pub mod prelude { pub use crate::sampler::bohb::BohbSampler; #[cfg(feature = "cma-es")] pub use crate::sampler::cma_es::CmaEsSampler; - pub use crate::sampler::de::{DeSampler, DeStrategy}; + pub use crate::sampler::differential_evolution::{ + DifferentialEvolutionSampler, DifferentialEvolutionStrategy, + }; #[cfg(feature = "gp")] pub use crate::sampler::gp::GpSampler; pub use crate::sampler::grid::GridSearchSampler; diff --git a/src/sampler/de.rs b/src/sampler/differential_evolution.rs similarity index 82% rename from src/sampler/de.rs rename to src/sampler/differential_evolution.rs index 949463c..0be8ffc 100644 --- a/src/sampler/de.rs +++ b/src/sampler/differential_evolution.rs @@ -12,10 +12,10 @@ //! # Examples //! //! ``` -//! use optimizer::sampler::de::DeSampler; +//! use optimizer::sampler::differential_evolution::DifferentialEvolutionSampler; //! use optimizer::{Direction, Study}; //! -//! let sampler = DeSampler::with_seed(42); +//! let sampler = DifferentialEvolutionSampler::with_seed(42); //! let study: Study = Study::with_sampler(Direction::Minimize, sampler); //! ``` @@ -32,7 +32,7 @@ use crate::sampler::{CompletedTrial, Sampler}; /// /// Controls how mutant vectors are created from the current population. #[derive(Clone, Copy, Debug, Default)] -pub enum DeStrategy { +pub enum DifferentialEvolutionStrategy { /// DE/rand/1: `v = x_r1 + F * (x_r2 - x_r3)` /// /// The most robust strategy. Uses three random population members. @@ -57,36 +57,46 @@ pub enum DeStrategy { /// # Examples /// /// ``` -/// use optimizer::sampler::de::DeSampler; +/// use optimizer::sampler::differential_evolution::DifferentialEvolutionSampler; /// use optimizer::{Direction, Study}; /// /// // Default configuration -/// let study: Study = Study::with_sampler(Direction::Minimize, DeSampler::new()); +/// let study: Study = +/// Study::with_sampler(Direction::Minimize, DifferentialEvolutionSampler::new()); /// /// // With seed for reproducibility -/// let study: Study = Study::with_sampler(Direction::Minimize, DeSampler::with_seed(42)); +/// let study: Study = Study::with_sampler( +/// Direction::Minimize, +/// DifferentialEvolutionSampler::with_seed(42), +/// ); /// /// // Custom configuration via builder -/// use optimizer::sampler::de::DeStrategy; -/// let sampler = DeSampler::builder() +/// use optimizer::sampler::differential_evolution::DifferentialEvolutionStrategy; +/// let sampler = DifferentialEvolutionSampler::builder() /// .mutation_factor(0.8) /// .crossover_rate(0.9) -/// .strategy(DeStrategy::Best1) +/// .strategy(DifferentialEvolutionStrategy::Best1) /// .population_size(30) /// .seed(42) /// .build(); /// let study: Study = Study::with_sampler(Direction::Minimize, sampler); /// ``` -pub struct DeSampler { - state: Mutex, +pub struct DifferentialEvolutionSampler { + state: Mutex, } -impl DeSampler { +impl DifferentialEvolutionSampler { /// Creates a new DE sampler with default settings and a random seed. #[must_use] pub fn new() -> Self { Self { - state: Mutex::new(DeState::new(None, 0.8, 0.9, DeStrategy::Rand1, None)), + state: Mutex::new(State::new( + None, + 0.8, + 0.9, + DifferentialEvolutionStrategy::Rand1, + None, + )), } } @@ -94,24 +104,30 @@ impl DeSampler { #[must_use] pub fn with_seed(seed: u64) -> Self { Self { - state: Mutex::new(DeState::new(None, 0.8, 0.9, DeStrategy::Rand1, Some(seed))), + state: Mutex::new(State::new( + None, + 0.8, + 0.9, + DifferentialEvolutionStrategy::Rand1, + Some(seed), + )), } } - /// Creates a builder for configuring a `DeSampler`. + /// Creates a builder for configuring a `DifferentialEvolutionSampler`. #[must_use] - pub fn builder() -> DeSamplerBuilder { - DeSamplerBuilder::new() + pub fn builder() -> DifferentialEvolutionSamplerBuilder { + DifferentialEvolutionSamplerBuilder::new() } } -impl Default for DeSampler { +impl Default for DifferentialEvolutionSampler { fn default() -> Self { Self::new() } } -/// Builder for configuring a [`DeSampler`]. +/// Builder for configuring a [`DifferentialEvolutionSampler`]. /// /// All options have sensible defaults: /// - `population_size`: `max(10 * n_dims, 15)` (auto-computed from parameter count) @@ -123,32 +139,34 @@ impl Default for DeSampler { /// # Examples /// /// ``` -/// use optimizer::sampler::de::{DeSamplerBuilder, DeStrategy}; +/// use optimizer::sampler::differential_evolution::{ +/// DifferentialEvolutionSamplerBuilder, DifferentialEvolutionStrategy, +/// }; /// -/// let sampler = DeSamplerBuilder::new() +/// let sampler = DifferentialEvolutionSamplerBuilder::new() /// .mutation_factor(0.5) /// .crossover_rate(0.7) -/// .strategy(DeStrategy::CurrentToBest1) +/// .strategy(DifferentialEvolutionStrategy::CurrentToBest1) /// .population_size(20) /// .seed(42) /// .build(); /// ``` #[derive(Debug, Clone)] -pub struct DeSamplerBuilder { +pub struct DifferentialEvolutionSamplerBuilder { population_size: Option, mutation_factor: f64, crossover_rate: f64, - strategy: DeStrategy, + strategy: DifferentialEvolutionStrategy, seed: Option, } -impl Default for DeSamplerBuilder { +impl Default for DifferentialEvolutionSamplerBuilder { fn default() -> Self { Self::new() } } -impl DeSamplerBuilder { +impl DifferentialEvolutionSamplerBuilder { /// Creates a new builder with default settings. #[must_use] pub fn new() -> Self { @@ -156,7 +174,7 @@ impl DeSamplerBuilder { population_size: None, mutation_factor: 0.8, crossover_rate: 0.9, - strategy: DeStrategy::Rand1, + strategy: DifferentialEvolutionStrategy::Rand1, seed: None, } } @@ -201,9 +219,9 @@ impl DeSamplerBuilder { /// Sets the mutation strategy. /// - /// Default: [`DeStrategy::Rand1`]. + /// Default: [`DifferentialEvolutionStrategy::Rand1`]. #[must_use] - pub fn strategy(mut self, strategy: DeStrategy) -> Self { + pub fn strategy(mut self, strategy: DifferentialEvolutionStrategy) -> Self { self.strategy = strategy; self } @@ -215,11 +233,11 @@ impl DeSamplerBuilder { self } - /// Builds the configured [`DeSampler`]. + /// Builds the configured [`DifferentialEvolutionSampler`]. #[must_use] - pub fn build(self) -> DeSampler { - DeSampler { - state: Mutex::new(DeState::new( + pub fn build(self) -> DifferentialEvolutionSampler { + DifferentialEvolutionSampler { + state: Mutex::new(State::new( self.population_size, self.mutation_factor, self.crossover_rate, @@ -248,7 +266,7 @@ struct DimensionInfo { /// A candidate solution produced by mutation + crossover. #[derive(Clone, Debug)] -struct DeCandidate { +struct Candidate { /// Internal-space vector (only continuous dimensions). x: Vec, /// Values for categorical dimensions (index in `dimensions` -> categorical index). @@ -267,7 +285,7 @@ struct TrialProgress { } /// Phase of the DE state machine. -enum DePhase { +enum Phase { /// Discovering the search space structure (first trial). Discovery, /// Active sampling and evolving. @@ -275,7 +293,7 @@ enum DePhase { } /// Top-level mutable state behind the `Mutex`. -struct DeState { +struct State { /// The RNG used for sampling. rng: fastrand::Rng, /// User-provided population size (None = auto). @@ -285,9 +303,9 @@ struct DeState { /// Crossover rate (CR). crossover_rate: f64, /// Mutation strategy. - strategy: DeStrategy, + strategy: DifferentialEvolutionStrategy, /// Current phase. - phase: DePhase, + phase: Phase, /// Discovered dimension info (populated during discovery). dimensions: Vec, /// Last `trial_id` seen during discovery. @@ -309,7 +327,7 @@ struct DeState { // --- Current generation --- /// Current generation's candidates. - candidates: Vec, + candidates: Vec, /// Mapping from `trial_id` to its progress. trial_progress: HashMap, /// Number of candidates assigned so far in the current generation. @@ -318,12 +336,12 @@ struct DeState { generation_trial_ids: Vec, } -impl DeState { +impl State { fn new( user_population_size: Option, mutation_factor: f64, crossover_rate: f64, - strategy: DeStrategy, + strategy: DifferentialEvolutionStrategy, seed: Option, ) -> Self { let rng = seed.map_or_else(fastrand::Rng::new, fastrand::Rng::with_seed); @@ -333,7 +351,7 @@ impl DeState { mutation_factor, crossover_rate, strategy, - phase: DePhase::Discovery, + phase: Phase::Discovery, dimensions: Vec::new(), discovery_trial_id: None, population: Vec::new(), @@ -457,28 +475,6 @@ fn clamp_to_bounds(value: f64, bounds: Option<(f64, f64)>) -> f64 { } } -/// Convert a `ParamValue` to its internal-space representation. -#[allow(dead_code, clippy::cast_precision_loss)] -fn to_internal(value: &ParamValue, distribution: &Distribution) -> f64 { - match (value, distribution) { - (ParamValue::Float(v), Distribution::Float(d)) => { - if d.log_scale { - v.ln() - } else { - *v - } - } - (ParamValue::Int(v), Distribution::Int(d)) => { - if d.log_scale { - (*v as f64).ln() - } else { - *v as f64 - } - } - _ => unreachable!("to_internal: mismatched value and distribution"), - } -} - // --------------------------------------------------------------------------- // DE algorithm // --------------------------------------------------------------------------- @@ -500,62 +496,8 @@ fn select_random_indices( selected } -/// Create a mutant vector using the specified DE strategy. -#[allow(dead_code)] -fn create_mutant(state: &DeState, target_idx: usize, n_continuous: usize) -> Vec { - // We need to work with a mutable reference to state for the rng, - // but this function is called from a context where state is already mutable. - // So we'll take the necessary data and return the result. - let pop = &state.population; - let best = &state.population[state.best_idx]; - - match state.strategy { - DeStrategy::Rand1 => { - let indices = select_random_indices( - &mut state.rng.clone(), - state.population_size, - 3, - &[target_idx], - ); - let (r1, r2, r3) = (indices[0], indices[1], indices[2]); - (0..n_continuous) - .map(|j| pop[r1][j] + state.mutation_factor * (pop[r2][j] - pop[r3][j])) - .collect() - } - DeStrategy::Best1 => { - let indices = select_random_indices( - &mut state.rng.clone(), - state.population_size, - 2, - &[target_idx], - ); - let (r1, r2) = (indices[0], indices[1]); - (0..n_continuous) - .map(|j| best[j] + state.mutation_factor * (pop[r1][j] - pop[r2][j])) - .collect() - } - DeStrategy::CurrentToBest1 => { - let indices = select_random_indices( - &mut state.rng.clone(), - state.population_size, - 2, - &[target_idx], - ); - let (r1, r2) = (indices[0], indices[1]); - let target = &pop[target_idx]; - (0..n_continuous) - .map(|j| { - target[j] - + state.mutation_factor * (best[j] - target[j]) - + state.mutation_factor * (pop[r1][j] - pop[r2][j]) - }) - .collect() - } - } -} - /// Generate trial vectors (mutation + crossover) for the current population. -fn generate_trial_vectors(state: &mut DeState) -> Vec { +fn generate_trial_vectors(state: &mut State) -> Vec { let n_continuous = state.dimensions.iter().filter(|d| d.is_continuous).count(); let pop_size = state.population_size; @@ -595,7 +537,7 @@ fn generate_trial_vectors(state: &mut DeState) -> Vec { } } - candidates.push(DeCandidate { + candidates.push(Candidate { x: trial_x, categorical_values, target_idx: i, @@ -606,7 +548,7 @@ fn generate_trial_vectors(state: &mut DeState) -> Vec { } /// Create a mutant vector, consuming RNG from state. -fn create_mutant_with_rng(state: &mut DeState, target_idx: usize, n_continuous: usize) -> Vec { +fn create_mutant_with_rng(state: &mut State, target_idx: usize, n_continuous: usize) -> Vec { if n_continuous == 0 { return Vec::new(); } @@ -617,21 +559,21 @@ fn create_mutant_with_rng(state: &mut DeState, target_idx: usize, n_continuous: let pop_size = state.population_size; match state.strategy { - DeStrategy::Rand1 => { + DifferentialEvolutionStrategy::Rand1 => { let indices = select_random_indices(&mut state.rng, pop_size, 3, &[target_idx]); let (r1, r2, r3) = (indices[0], indices[1], indices[2]); (0..n_continuous) .map(|j| pop[r1][j] + f * (pop[r2][j] - pop[r3][j])) .collect() } - DeStrategy::Best1 => { + DifferentialEvolutionStrategy::Best1 => { let indices = select_random_indices(&mut state.rng, pop_size, 2, &[target_idx]); let (r1, r2) = (indices[0], indices[1]); (0..n_continuous) .map(|j| pop[best_idx][j] + f * (pop[r1][j] - pop[r2][j])) .collect() } - DeStrategy::CurrentToBest1 => { + DifferentialEvolutionStrategy::CurrentToBest1 => { let indices = select_random_indices(&mut state.rng, pop_size, 2, &[target_idx]); let (r1, r2) = (indices[0], indices[1]); (0..n_continuous) @@ -663,7 +605,7 @@ fn continuous_dim_bounds( } /// Generate the initial random population. -fn generate_initial_population(state: &mut DeState) -> Vec { +fn generate_initial_population(state: &mut State) -> Vec { let n_continuous = state.dimensions.iter().filter(|d| d.is_continuous).count(); let mut candidates = Vec::with_capacity(state.population_size); @@ -671,7 +613,6 @@ fn generate_initial_population(state: &mut DeState) -> Vec { for i in 0..state.population_size { let x: Vec = if n_continuous > 0 { let mut v = Vec::with_capacity(n_continuous); - let mut ci = 0; for dim in &state.dimensions { if dim.is_continuous { let val = if let Some(bounds) = dim.bounds { @@ -680,10 +621,8 @@ fn generate_initial_population(state: &mut DeState) -> Vec { 0.0 }; v.push(val); - ci += 1; } } - let _ = ci; v } else { Vec::new() @@ -698,7 +637,7 @@ fn generate_initial_population(state: &mut DeState) -> Vec { } } - candidates.push(DeCandidate { + candidates.push(Candidate { x, categorical_values, target_idx: i, @@ -712,7 +651,7 @@ fn generate_initial_population(state: &mut DeState) -> Vec { // Sampler trait implementation // --------------------------------------------------------------------------- -impl Sampler for DeSampler { +impl Sampler for DifferentialEvolutionSampler { #[allow(clippy::cast_precision_loss)] fn sample( &self, @@ -723,14 +662,14 @@ impl Sampler for DeSampler { let mut state = self.state.lock(); match &state.phase { - DePhase::Discovery => sample_discovery(&mut state, distribution, trial_id), - DePhase::Active => sample_active(&mut state, distribution, trial_id, history), + Phase::Discovery => sample_discovery(&mut state, distribution, trial_id), + Phase::Active => sample_active(&mut state, distribution, trial_id, history), } } } /// Handle sampling during the discovery phase. -fn sample_discovery(state: &mut DeState, distribution: &Distribution, trial_id: u64) -> ParamValue { +fn sample_discovery(state: &mut State, distribution: &Distribution, trial_id: u64) -> ParamValue { // Check if this is a new trial (discovery phase ended for previous trial) if let Some(prev_id) = state.discovery_trial_id && trial_id != prev_id @@ -758,7 +697,7 @@ fn sample_discovery(state: &mut DeState, distribution: &Distribution, trial_id: /// Finalize discovery and transition to the active phase. #[allow(clippy::cast_precision_loss)] -fn finalize_discovery(state: &mut DeState) { +fn finalize_discovery(state: &mut State) { let n_continuous = state.dimensions.iter().filter(|d| d.is_continuous).count(); // Resolve population size @@ -774,12 +713,12 @@ fn finalize_discovery(state: &mut DeState) { state.assigned_count = 0; state.generation_trial_ids.clear(); state.trial_progress.clear(); - state.phase = DePhase::Active; + state.phase = Phase::Active; } /// Handle sampling during the active phase. fn sample_active( - state: &mut DeState, + state: &mut State, distribution: &Distribution, trial_id: u64, history: &[CompletedTrial], @@ -826,7 +765,7 @@ fn sample_active( } /// Assign a candidate to a trial. -fn assign_candidate(state: &mut DeState, trial_id: u64) { +fn assign_candidate(state: &mut State, trial_id: u64) { let candidate_idx = if state.assigned_count < state.candidates.len() { let idx = state.assigned_count; state.assigned_count += 1; @@ -852,7 +791,7 @@ fn assign_candidate(state: &mut DeState, trial_id: u64) { categorical_values.insert(dim_idx, state.rng.usize(0..cat.n_choices)); } } - state.candidates.push(DeCandidate { + state.candidates.push(Candidate { x, categorical_values, target_idx: 0, // overflow candidates don't compete @@ -873,7 +812,7 @@ fn assign_candidate(state: &mut DeState, trial_id: u64) { } /// Check if we should process completed trials and start a new generation. -fn maybe_update_generation(state: &mut DeState, history: &[CompletedTrial]) { +fn maybe_update_generation(state: &mut State, history: &[CompletedTrial]) { let pop_size = state.population_size; // Only update when at least pop_size candidates have been assigned @@ -918,7 +857,7 @@ fn maybe_update_generation(state: &mut DeState, history: &[CompletedTrial]) { /// Initialize the population from the first generation's results. fn initialize_population( - state: &mut DeState, + state: &mut State, trial_ids: &[u64], history_map: &HashMap, _n_continuous: usize, @@ -952,7 +891,7 @@ fn initialize_population( } /// Perform DE selection: replace parent if trial vector is better. -fn perform_selection(state: &mut DeState, trial_ids: &[u64], history_map: &HashMap) { +fn perform_selection(state: &mut State, trial_ids: &[u64], history_map: &HashMap) { for &trial_id in trial_ids { let progress = &state.trial_progress[&trial_id]; let candidate = &state.candidates[progress.candidate_idx]; @@ -987,7 +926,7 @@ mod tests { #[test] fn test_de_sampler_basic_float() { - let sampler = DeSampler::with_seed(42); + let sampler = DifferentialEvolutionSampler::with_seed(42); let dist = Distribution::Float(FloatDistribution { low: -5.0, high: 5.0, @@ -1019,7 +958,7 @@ mod tests { }); let sample_values = |seed: u64| { - let sampler = DeSampler::with_seed(seed); + let sampler = DifferentialEvolutionSampler::with_seed(seed); (0..20) .map(|i| sampler.sample(&dist, i, &[])) .collect::>() @@ -1035,16 +974,22 @@ mod tests { #[test] fn test_de_strategy_default() { - assert!(matches!(DeStrategy::default(), DeStrategy::Rand1)); + assert!(matches!( + DifferentialEvolutionStrategy::default(), + DifferentialEvolutionStrategy::Rand1 + )); } #[test] fn test_builder_defaults() { - let builder = DeSamplerBuilder::new(); + let builder = DifferentialEvolutionSamplerBuilder::new(); assert!(builder.population_size.is_none()); assert!((builder.mutation_factor - 0.8).abs() < f64::EPSILON); assert!((builder.crossover_rate - 0.9).abs() < f64::EPSILON); - assert!(matches!(builder.strategy, DeStrategy::Rand1)); + assert!(matches!( + builder.strategy, + DifferentialEvolutionStrategy::Rand1 + )); assert!(builder.seed.is_none()); } } diff --git a/src/sampler/mod.rs b/src/sampler/mod.rs index db1c55f..b6c78fd 100644 --- a/src/sampler/mod.rs +++ b/src/sampler/mod.rs @@ -3,7 +3,7 @@ pub mod bohb; #[cfg(feature = "cma-es")] pub mod cma_es; -pub mod de; +pub mod differential_evolution; #[cfg(feature = "gp")] pub mod gp; pub mod grid; diff --git a/tests/differential_evolution_tests.rs b/tests/differential_evolution_tests.rs new file mode 100644 index 0000000..75328bc --- /dev/null +++ b/tests/differential_evolution_tests.rs @@ -0,0 +1,350 @@ +use optimizer::prelude::*; +use optimizer::sampler::differential_evolution::{ + DifferentialEvolutionSampler, DifferentialEvolutionStrategy, +}; + +#[test] +fn sphere_function() { + let sampler = DifferentialEvolutionSampler::with_seed(42); + let study: Study = Study::with_sampler(Direction::Minimize, sampler); + + let x = FloatParam::new(-5.0, 5.0).name("x"); + let y = FloatParam::new(-5.0, 5.0).name("y"); + + study + .optimize(200, |trial| { + let xv = x.suggest(trial)?; + let yv = y.suggest(trial)?; + Ok::<_, Error>(xv * xv + yv * yv) + }) + .unwrap(); + + let best = study.best_trial().unwrap(); + assert!( + best.value < 1.0, + "sphere best value should be < 1.0, got {}", + best.value + ); +} + +#[test] +fn rosenbrock_function() { + let sampler = DifferentialEvolutionSampler::builder() + .population_size(20) + .seed(42) + .build(); + let study: Study = Study::with_sampler(Direction::Minimize, sampler); + + let x = FloatParam::new(-5.0, 5.0).name("x"); + let y = FloatParam::new(-5.0, 5.0).name("y"); + + study + .optimize(400, |trial| { + let xv = x.suggest(trial)?; + let yv = y.suggest(trial)?; + let val = (1.0 - xv).powi(2) + 100.0 * (yv - xv * xv).powi(2); + Ok::<_, Error>(val) + }) + .unwrap(); + + let best = study.best_trial().unwrap(); + assert!( + best.value < 50.0, + "rosenbrock best value should be < 50.0, got {}", + best.value + ); +} + +#[test] +fn rastrigin_function() { + let sampler = DifferentialEvolutionSampler::builder() + .population_size(30) + .mutation_factor(0.7) + .crossover_rate(0.9) + .seed(42) + .build(); + let study: Study = Study::with_sampler(Direction::Minimize, sampler); + + let x = FloatParam::new(-5.12, 5.12).name("x"); + let y = FloatParam::new(-5.12, 5.12).name("y"); + + study + .optimize(500, |trial| { + let xv = x.suggest(trial)?; + let yv = y.suggest(trial)?; + let val = 20.0 + + (xv * xv - 10.0 * (2.0 * std::f64::consts::PI * xv).cos()) + + (yv * yv - 10.0 * (2.0 * std::f64::consts::PI * yv).cos()); + Ok::<_, Error>(val) + }) + .unwrap(); + + let best = study.best_trial().unwrap(); + // Rastrigin is multimodal; just check reasonable convergence + assert!( + best.value < 10.0, + "rastrigin best value should be < 10.0, got {}", + best.value + ); +} + +#[test] +fn bounds_respected() { + let sampler = DifferentialEvolutionSampler::with_seed(123); + let study: Study = Study::with_sampler(Direction::Minimize, sampler); + + let x = FloatParam::new(-2.0, 3.0).name("x"); + let y = FloatParam::new(0.0, 10.0).name("y"); + + study + .optimize(100, |trial| { + let xv = x.suggest(trial)?; + let yv = y.suggest(trial)?; + Ok::<_, Error>(xv + yv) + }) + .unwrap(); + + for trial in study.trials() { + let xv: f64 = trial.get(&x).unwrap(); + let yv: f64 = trial.get(&y).unwrap(); + assert!((-2.0..=3.0).contains(&xv), "x = {xv} out of bounds [-2, 3]"); + assert!((0.0..=10.0).contains(&yv), "y = {yv} out of bounds [0, 10]"); + } +} + +#[test] +fn strategy_best1() { + let sampler = DifferentialEvolutionSampler::builder() + .strategy(DifferentialEvolutionStrategy::Best1) + .population_size(15) + .seed(42) + .build(); + let study: Study = Study::with_sampler(Direction::Minimize, sampler); + + let x = FloatParam::new(-5.0, 5.0).name("x"); + let y = FloatParam::new(-5.0, 5.0).name("y"); + + study + .optimize(200, |trial| { + let xv = x.suggest(trial)?; + let yv = y.suggest(trial)?; + Ok::<_, Error>(xv * xv + yv * yv) + }) + .unwrap(); + + let best = study.best_trial().unwrap(); + assert!( + best.value < 5.0, + "Best1 strategy should converge, got {}", + best.value + ); +} + +#[test] +fn strategy_current_to_best1() { + let sampler = DifferentialEvolutionSampler::builder() + .strategy(DifferentialEvolutionStrategy::CurrentToBest1) + .population_size(15) + .seed(42) + .build(); + let study: Study = Study::with_sampler(Direction::Minimize, sampler); + + let x = FloatParam::new(-5.0, 5.0).name("x"); + let y = FloatParam::new(-5.0, 5.0).name("y"); + + study + .optimize(200, |trial| { + let xv = x.suggest(trial)?; + let yv = y.suggest(trial)?; + Ok::<_, Error>(xv * xv + yv * yv) + }) + .unwrap(); + + let best = study.best_trial().unwrap(); + assert!( + best.value < 5.0, + "CurrentToBest1 strategy should converge, got {}", + best.value + ); +} + +#[test] +fn mixed_params_float_and_categorical() { + let sampler = DifferentialEvolutionSampler::with_seed(42); + let study: Study = Study::with_sampler(Direction::Minimize, sampler); + + let x = FloatParam::new(-5.0, 5.0).name("x"); + let cat = CategoricalParam::new(vec!["a", "b", "c"]).name("cat"); + + study + .optimize(100, |trial| { + let xv = x.suggest(trial)?; + let cv = cat.suggest(trial)?; + let penalty = match cv { + "a" => 0.0, + "b" => 1.0, + _ => 2.0, + }; + Ok::<_, Error>(xv * xv + penalty) + }) + .unwrap(); + + let best = study.best_trial().unwrap(); + assert!( + best.value < 10.0, + "best value should be < 10.0, got {}", + best.value + ); +} + +#[test] +fn seeded_reproducibility() { + let x = FloatParam::new(-5.0, 5.0).name("x"); + let y = FloatParam::new(-5.0, 5.0).name("y"); + + let run = |seed: u64| { + let sampler = DifferentialEvolutionSampler::with_seed(seed); + let study: Study = Study::with_sampler(Direction::Minimize, sampler); + study + .optimize(50, |trial| { + let xv = x.suggest(trial)?; + let yv = y.suggest(trial)?; + Ok::<_, Error>(xv * xv + yv * yv) + }) + .unwrap(); + study.trials().iter().map(|t| t.value).collect::>() + }; + + let results1 = run(42); + let results2 = run(42); + assert_eq!(results1, results2, "same seed should produce same results"); +} + +#[test] +fn different_seeds_different_results() { + let x = FloatParam::new(-5.0, 5.0).name("x"); + let y = FloatParam::new(-5.0, 5.0).name("y"); + + let run = |seed: u64| { + let sampler = DifferentialEvolutionSampler::with_seed(seed); + let study: Study = Study::with_sampler(Direction::Minimize, sampler); + study + .optimize(20, |trial| { + let xv = x.suggest(trial)?; + let yv = y.suggest(trial)?; + Ok::<_, Error>(xv * xv + yv * yv) + }) + .unwrap(); + study.trials().iter().map(|t| t.value).collect::>() + }; + + let results1 = run(42); + let results2 = run(99); + assert_ne!( + results1, results2, + "different seeds should produce different results" + ); +} + +#[test] +fn single_dimension() { + let sampler = DifferentialEvolutionSampler::with_seed(42); + let study: Study = Study::with_sampler(Direction::Minimize, sampler); + + let x = FloatParam::new(-10.0, 10.0).name("x"); + + study + .optimize(100, |trial| { + let xv = x.suggest(trial)?; + Ok::<_, Error>((xv - 3.0).powi(2)) + }) + .unwrap(); + + let best = study.best_trial().unwrap(); + assert!( + best.value < 1.0, + "1-D optimization should converge, got {}", + best.value + ); +} + +#[test] +fn integer_params() { + let sampler = DifferentialEvolutionSampler::with_seed(42); + let study: Study = Study::with_sampler(Direction::Minimize, sampler); + + let n = IntParam::new(1, 20).name("n"); + + study + .optimize(100, |trial| { + let nv = n.suggest(trial)?; + // Minimum at n = 10 + Ok::<_, Error>(((nv - 10) * (nv - 10)) as f64) + }) + .unwrap(); + + let best = study.best_trial().unwrap(); + let best_n: i64 = best.get(&n).unwrap(); + assert!( + (1..=20).contains(&best_n), + "integer value {best_n} out of bounds" + ); + assert!( + best.value < 10.0, + "integer optimization should converge, got {}", + best.value + ); +} + +#[test] +fn log_scale_params() { + let sampler = DifferentialEvolutionSampler::with_seed(42); + let study: Study = Study::with_sampler(Direction::Minimize, sampler); + + let lr = FloatParam::new(1e-5, 1.0).log_scale().name("lr"); + + study + .optimize(100, |trial| { + let lrv = lr.suggest(trial)?; + // Minimum at lr = 0.01 + Ok::<_, Error>((lrv.ln() - 0.01_f64.ln()).powi(2)) + }) + .unwrap(); + + for trial in study.trials() { + let lrv: f64 = trial.get(&lr).unwrap(); + assert!( + (1e-5..=1.0).contains(&lrv), + "log-scale value {lrv} out of bounds" + ); + } +} + +#[test] +fn custom_mutation_and_crossover() { + let sampler = DifferentialEvolutionSampler::builder() + .mutation_factor(0.5) + .crossover_rate(0.7) + .population_size(10) + .seed(42) + .build(); + let study: Study = Study::with_sampler(Direction::Minimize, sampler); + + let x = FloatParam::new(-5.0, 5.0).name("x"); + let y = FloatParam::new(-5.0, 5.0).name("y"); + + study + .optimize(100, |trial| { + let xv = x.suggest(trial)?; + let yv = y.suggest(trial)?; + Ok::<_, Error>(xv * xv + yv * yv) + }) + .unwrap(); + + let best = study.best_trial().unwrap(); + assert!( + best.value < 5.0, + "custom F/CR optimization should work, got {}", + best.value + ); +}