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.
This commit is contained in:
Manuel Raimann
2026-02-11 22:04:23 +01:00
parent 8239cc58a1
commit 24a0bdd473
4 changed files with 448 additions and 149 deletions
+6 -2
View File
@@ -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;
@@ -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<f64> = 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<f64> = Study::with_sampler(Direction::Minimize, DeSampler::new());
/// let study: Study<f64> =
/// Study::with_sampler(Direction::Minimize, DifferentialEvolutionSampler::new());
///
/// // With seed for reproducibility
/// let study: Study<f64> = Study::with_sampler(Direction::Minimize, DeSampler::with_seed(42));
/// let study: Study<f64> = 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<f64> = Study::with_sampler(Direction::Minimize, sampler);
/// ```
pub struct DeSampler {
state: Mutex<DeState>,
pub struct DifferentialEvolutionSampler {
state: Mutex<State>,
}
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<usize>,
mutation_factor: f64,
crossover_rate: f64,
strategy: DeStrategy,
strategy: DifferentialEvolutionStrategy,
seed: Option<u64>,
}
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<f64>,
/// 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<DimensionInfo>,
/// Last `trial_id` seen during discovery.
@@ -309,7 +327,7 @@ struct DeState {
// --- Current generation ---
/// Current generation's candidates.
candidates: Vec<DeCandidate>,
candidates: Vec<Candidate>,
/// Mapping from `trial_id` to its progress.
trial_progress: HashMap<u64, TrialProgress>,
/// Number of candidates assigned so far in the current generation.
@@ -318,12 +336,12 @@ struct DeState {
generation_trial_ids: Vec<u64>,
}
impl DeState {
impl State {
fn new(
user_population_size: Option<usize>,
mutation_factor: f64,
crossover_rate: f64,
strategy: DeStrategy,
strategy: DifferentialEvolutionStrategy,
seed: Option<u64>,
) -> 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<f64> {
// 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<DeCandidate> {
fn generate_trial_vectors(state: &mut State) -> Vec<Candidate> {
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<DeCandidate> {
}
}
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<DeCandidate> {
}
/// Create a mutant vector, consuming RNG from state.
fn create_mutant_with_rng(state: &mut DeState, target_idx: usize, n_continuous: usize) -> Vec<f64> {
fn create_mutant_with_rng(state: &mut State, target_idx: usize, n_continuous: usize) -> Vec<f64> {
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<DeCandidate> {
fn generate_initial_population(state: &mut State) -> Vec<Candidate> {
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<DeCandidate> {
for i in 0..state.population_size {
let x: Vec<f64> = 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<DeCandidate> {
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<DeCandidate> {
}
}
candidates.push(DeCandidate {
candidates.push(Candidate {
x,
categorical_values,
target_idx: i,
@@ -712,7 +651,7 @@ fn generate_initial_population(state: &mut DeState) -> Vec<DeCandidate> {
// 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<u64, f64>,
_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<u64, f64>) {
fn perform_selection(state: &mut State, trial_ids: &[u64], history_map: &HashMap<u64, f64>) {
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::<Vec<_>>()
@@ -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());
}
}
+1 -1
View File
@@ -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;
+350
View File
@@ -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<f64> = 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<f64> = 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<f64> = 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<f64> = 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<f64> = 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<f64> = 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<f64> = 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<f64> = 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::<Vec<_>>()
};
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<f64> = 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::<Vec<_>>()
};
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<f64> = 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<f64> = 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<f64> = 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<f64> = 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
);
}