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:
+6
-2
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user