Files
rust-optimizer/src/sampler/cma_es.rs
T
Manuel Raimann 8239cc58a1 refactor: replace rand 0.10 with fastrand 2.3
fastrand is smaller, faster, and has no dependencies. Add rng_util
helper for f64 range generation since fastrand lacks a built-in
equivalent. Migrate all samplers, KDE modules, and fANOVA to use
fastrand's concrete Rng type instead of rand's trait-based generics.
2026-02-11 21:54:34 +01:00

1004 lines
32 KiB
Rust

//! CMA-ES (Covariance Matrix Adaptation Evolution Strategy) sampler.
//!
//! CMA-ES maintains a multivariate Gaussian distribution over continuous
//! parameters and adapts its mean, covariance matrix, and step-size based
//! on trial rankings. It is one of the most effective derivative-free
//! optimizers for continuous search spaces.
//!
//! Categorical parameters are sampled uniformly at random (not part of
//! the CMA-ES vector). If all parameters are categorical, the sampler
//! falls back to pure random sampling.
//!
//! Requires the `cma-es` feature flag.
//!
//! # Examples
//!
//! ```
//! use optimizer::sampler::cma_es::CmaEsSampler;
//! use optimizer::{Direction, Study};
//!
//! let sampler = CmaEsSampler::with_seed(42);
//! let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
//! ```
use std::collections::HashMap;
use nalgebra::{DMatrix, DVector};
use parking_lot::Mutex;
use crate::distribution::Distribution;
use crate::param::ParamValue;
use crate::rng_util;
use crate::sampler::{CompletedTrial, Sampler};
/// CMA-ES sampler for continuous optimization.
///
/// Adapts a multivariate Gaussian to concentrate around promising regions
/// of the search space. Best suited for continuous (float/int) parameters
/// in moderate dimensions (up to ~100).
///
/// # Examples
///
/// ```
/// use optimizer::sampler::cma_es::CmaEsSampler;
/// use optimizer::{Direction, Study};
///
/// // Default configuration
/// let study: Study<f64> = Study::with_sampler(Direction::Minimize, CmaEsSampler::new());
///
/// // With seed for reproducibility
/// let study: Study<f64> = Study::with_sampler(Direction::Minimize, CmaEsSampler::with_seed(42));
///
/// // Custom configuration via builder
/// let sampler = CmaEsSampler::builder()
/// .sigma0(0.5)
/// .population_size(20)
/// .seed(42)
/// .build();
/// let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
/// ```
pub struct CmaEsSampler {
state: Mutex<CmaEsState>,
}
impl CmaEsSampler {
/// Creates a new CMA-ES sampler with a random seed.
#[must_use]
pub fn new() -> Self {
Self {
state: Mutex::new(CmaEsState::new(None, None, None)),
}
}
/// Creates a new CMA-ES sampler with a fixed seed for reproducibility.
#[must_use]
pub fn with_seed(seed: u64) -> Self {
Self {
state: Mutex::new(CmaEsState::new(None, None, Some(seed))),
}
}
/// Creates a builder for configuring a `CmaEsSampler`.
#[must_use]
pub fn builder() -> CmaEsSamplerBuilder {
CmaEsSamplerBuilder::new()
}
}
impl Default for CmaEsSampler {
fn default() -> Self {
Self::new()
}
}
/// Builder for configuring a [`CmaEsSampler`].
///
/// All options have sensible defaults:
/// - `sigma0`: auto-computed as average range / 4
/// - `population_size`: `4 + floor(3 * ln(n))`
/// - `seed`: random
///
/// # Examples
///
/// ```
/// use optimizer::sampler::cma_es::CmaEsSamplerBuilder;
///
/// let sampler = CmaEsSamplerBuilder::new()
/// .sigma0(0.3)
/// .population_size(10)
/// .seed(42)
/// .build();
/// ```
#[derive(Debug, Clone, Default)]
pub struct CmaEsSamplerBuilder {
sigma0: Option<f64>,
population_size: Option<usize>,
seed: Option<u64>,
}
impl CmaEsSamplerBuilder {
/// Creates a new builder with default settings.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Sets the initial step size (sigma).
///
/// Controls the initial spread of the search distribution. Larger values
/// explore more broadly; smaller values search more locally.
///
/// Default: `average_range / 4` (auto-computed from parameter bounds).
#[must_use]
pub fn sigma0(mut self, sigma0: f64) -> Self {
self.sigma0 = Some(sigma0);
self
}
/// Sets the population size (lambda).
///
/// Number of candidate solutions evaluated per generation.
/// Larger populations improve robustness but increase the number of
/// trials per generation.
///
/// Default: `4 + floor(3 * ln(n))` where `n` is the number of continuous dimensions.
#[must_use]
pub fn population_size(mut self, population_size: usize) -> Self {
self.population_size = Some(population_size);
self
}
/// Sets the random seed for reproducibility.
#[must_use]
pub fn seed(mut self, seed: u64) -> Self {
self.seed = Some(seed);
self
}
/// Builds the configured [`CmaEsSampler`].
#[must_use]
pub fn build(self) -> CmaEsSampler {
CmaEsSampler {
state: Mutex::new(CmaEsState::new(
self.sigma0,
self.population_size,
self.seed,
)),
}
}
}
// ---------------------------------------------------------------------------
// Internal types
// ---------------------------------------------------------------------------
/// Describes how a parameter dimension maps into the CMA-ES internal vector.
#[derive(Clone, Debug)]
struct DimensionInfo {
/// The distribution for this dimension (stored for decoding).
distribution: Distribution,
/// Whether this dimension participates in CMA-ES (Float/Int = true, Categorical = false).
is_continuous: bool,
/// Internal-space bounds for continuous dimensions: `(low, high)`.
/// For log-scale parameters these are in log-space.
bounds: Option<(f64, f64)>,
}
/// A candidate solution produced by the CMA-ES distribution.
#[derive(Clone, Debug)]
struct Candidate {
/// Internal-space vector (only continuous dimensions).
x: DVector<f64>,
/// Values for categorical dimensions (index in `dimensions` → categorical index).
categorical_values: HashMap<usize, usize>,
}
/// Tracks per-trial sampling progress.
#[derive(Clone, Debug)]
struct TrialProgress {
/// Index of the candidate assigned to this trial.
candidate_idx: usize,
/// Next dimension to return for this trial.
next_dim: usize,
}
/// The CMA-ES algorithm constants, derived from dimension count.
#[derive(Clone, Debug)]
struct CmaEsConstants {
/// Dimension of the continuous search space.
n: usize,
/// Population size (lambda).
lambda: usize,
/// Parent count (mu = lambda / 2).
mu: usize,
/// Recombination weights (length mu).
weights: Vec<f64>,
/// Variance effective selection mass.
mu_eff: f64,
/// Learning rate for the cumulation of the step-size control.
c_sigma: f64,
/// Damping for sigma.
d_sigma: f64,
/// Learning rate for the cumulation of the rank-one update.
c_c: f64,
/// Learning rate for the rank-one update of C.
c_1: f64,
/// Learning rate for the rank-mu update of C.
c_mu: f64,
/// Expected norm of N(0, I) in n dimensions.
chi_n: f64,
}
/// The mutable CMA-ES algorithm state.
struct CmaEsAlgorithm {
/// Distribution mean.
mean: DVector<f64>,
/// Step size.
sigma: f64,
/// Covariance matrix.
c: DMatrix<f64>,
/// Evolution path for sigma.
p_sigma: DVector<f64>,
/// Evolution path for rank-one update.
p_c: DVector<f64>,
/// Eigenvectors of C (columns of B).
b: DMatrix<f64>,
/// Sqrt of eigenvalues of C (diagonal of D).
d: DVector<f64>,
/// C^{-1/2} for sigma path update.
inv_sqrt_c: DMatrix<f64>,
/// Generation counter (for eigendecomposition scheduling).
generation: usize,
/// Last generation at which eigendecomposition was performed.
last_eigen_generation: usize,
/// Algorithm constants.
constants: CmaEsConstants,
}
/// Phase of the CMA-ES state machine.
enum Phase {
/// Discovering the search space structure (first trial).
Discovery,
/// Steady-state sampling and updating.
Active(Box<CmaEsAlgorithm>),
}
/// Top-level mutable state behind the `Mutex`.
struct CmaEsState {
/// The RNG used for sampling.
rng: fastrand::Rng,
/// User-provided initial sigma (None = auto).
sigma0: Option<f64>,
/// User-provided population size (None = auto).
user_lambda: Option<usize>,
/// Current phase.
phase: Phase,
/// Discovered dimension info (populated during discovery).
dimensions: Vec<DimensionInfo>,
/// Current generation's candidates.
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.
assigned_count: usize,
/// Trial IDs assigned in the current generation (for tracking completion).
generation_trial_ids: Vec<u64>,
/// Last `trial_id` seen during discovery (to detect when first trial ends).
discovery_trial_id: Option<u64>,
}
impl CmaEsState {
fn new(sigma0: Option<f64>, user_lambda: Option<usize>, seed: Option<u64>) -> Self {
let rng = seed.map_or_else(fastrand::Rng::new, fastrand::Rng::with_seed);
Self {
rng,
sigma0,
user_lambda,
phase: Phase::Discovery,
dimensions: Vec::new(),
candidates: Vec::new(),
trial_progress: HashMap::new(),
assigned_count: 0,
generation_trial_ids: Vec::new(),
discovery_trial_id: None,
}
}
}
// ---------------------------------------------------------------------------
// CMA-ES algorithm helpers
// ---------------------------------------------------------------------------
impl CmaEsConstants {
/// Compute all CMA-ES constants from dimension count and population size.
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
fn new(n: usize, user_lambda: Option<usize>) -> Self {
let n_f = n as f64;
// Population size
let lambda = user_lambda.unwrap_or_else(|| 4 + (3.0 * n_f.ln()).max(0.0).floor() as usize);
let lambda = lambda.max(4); // ensure at least 4
// Parent count
let mu = lambda / 2;
// Recombination weights (log-proportional)
let log_half_lambda = f64::midpoint(lambda as f64, 1.0).ln();
let raw_weights: Vec<f64> = (0..mu)
.map(|i| log_half_lambda - ((i + 1) as f64).ln())
.collect();
let w_sum: f64 = raw_weights.iter().sum();
let weights: Vec<f64> = raw_weights.iter().map(|w| w / w_sum).collect();
// Variance effective selection mass
let w_sq_sum: f64 = weights.iter().map(|w| w * w).sum();
let mu_eff = 1.0 / w_sq_sum;
// Learning rates
let c_sigma = (mu_eff + 2.0) / (n_f + mu_eff + 5.0);
let d_sigma = 1.0 + 2.0 * (((mu_eff - 1.0) / (n_f + 1.0)).sqrt() - 1.0).max(0.0) + c_sigma;
let c_c = (4.0 + mu_eff / n_f) / (n_f + 4.0 + 2.0 * mu_eff / n_f);
let c_1 = 2.0 / ((n_f + 1.3).powi(2) + mu_eff);
let c_mu_raw = (2.0 * (mu_eff - 2.0 + 1.0 / mu_eff)) / ((n_f + 2.0).powi(2) + mu_eff);
let c_mu = c_mu_raw.min(1.0 - c_1);
// Expected norm of N(0, I)
let chi_n = n_f.sqrt() * (1.0 - 1.0 / (4.0 * n_f) + 1.0 / (21.0 * n_f * n_f));
Self {
n,
lambda,
mu,
weights,
mu_eff,
c_sigma,
d_sigma,
c_c,
c_1,
c_mu,
chi_n,
}
}
}
impl CmaEsAlgorithm {
/// Initialize the CMA-ES algorithm state.
#[allow(clippy::cast_precision_loss)]
fn new(dimensions: &[DimensionInfo], sigma0: Option<f64>, user_lambda: Option<usize>) -> Self {
let n = dimensions.iter().filter(|d| d.is_continuous).count();
let constants = CmaEsConstants::new(n, user_lambda);
// Compute initial mean (center of bounds) and auto sigma
let mut mean = DVector::zeros(n);
let mut total_range = 0.0;
let mut ci = 0;
for dim in dimensions {
if dim.is_continuous {
if let Some((lo, hi)) = dim.bounds {
mean[ci] = f64::midpoint(lo, hi);
total_range += hi - lo;
}
ci += 1;
}
}
let sigma = sigma0.unwrap_or_else(|| {
if n > 0 {
(total_range / n as f64) / 4.0
} else {
1.0
}
});
let c = DMatrix::identity(n, n);
let p_sigma = DVector::zeros(n);
let p_c = DVector::zeros(n);
let b = DMatrix::identity(n, n);
let d = DVector::from_element(n, 1.0);
let inv_sqrt_c = DMatrix::identity(n, n);
Self {
mean,
sigma,
c,
p_sigma,
p_c,
b,
d,
inv_sqrt_c,
generation: 0,
last_eigen_generation: 0,
constants,
}
}
/// Generate `lambda` candidate vectors from the current distribution.
fn generate_candidates(
&self,
rng: &mut fastrand::Rng,
dimensions: &[DimensionInfo],
) -> Vec<Candidate> {
let n = self.constants.n;
let lambda = self.constants.lambda;
let mut candidates = Vec::with_capacity(lambda);
for _ in 0..lambda {
let candidate = self.generate_single_candidate(rng, dimensions, n);
candidates.push(candidate);
}
candidates
}
/// Generate a single candidate from the current distribution.
fn generate_single_candidate(
&self,
rng: &mut fastrand::Rng,
dimensions: &[DimensionInfo],
n: usize,
) -> Candidate {
// x = mean + sigma * B * D * z where z ~ N(0, I)
let x = self.sample_with_rejection(rng, dimensions, n);
// Sample categorical dimensions randomly
let mut categorical_values = HashMap::new();
for (i, dim) in dimensions.iter().enumerate() {
if !dim.is_continuous
&& let Distribution::Categorical(cat) = &dim.distribution
{
categorical_values.insert(i, rng.usize(0..cat.n_choices));
}
}
Candidate {
x,
categorical_values,
}
}
/// Sample a candidate vector with rejection sampling for bounds.
fn sample_with_rejection(
&self,
rng: &mut fastrand::Rng,
dimensions: &[DimensionInfo],
n: usize,
) -> DVector<f64> {
let max_attempts = 100;
for _ in 0..max_attempts {
let z = DVector::from_fn(n, |_, _| sample_standard_normal(rng));
let x = &self.mean + self.sigma * (&self.b * self.d.component_mul(&z));
if is_within_bounds(&x, dimensions) {
return x;
}
}
// Fallback: clip to bounds
let z = DVector::from_fn(n, |_, _| sample_standard_normal(rng));
let mut x = &self.mean + self.sigma * (&self.b * self.d.component_mul(&z));
clip_to_bounds(&mut x, dimensions);
x
}
/// Run the CMA-ES update step given ranked candidates.
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap
)]
fn update(&mut self, ranked_candidates: &[&DVector<f64>]) {
let n = self.constants.n;
let mu = self.constants.mu;
let sigma = self.sigma;
// New mean = weighted sum of top-mu candidates
let mut new_mean = DVector::zeros(n);
for (i, &x) in ranked_candidates.iter().take(mu).enumerate() {
new_mean += self.constants.weights[i] * x;
}
// Displacement in mean
let mean_diff = &new_mean - &self.mean;
// Update p_sigma (cumulation for sigma control)
let inv_sqrt_c_times_diff = &self.inv_sqrt_c * &mean_diff / sigma;
self.p_sigma = (1.0 - self.constants.c_sigma) * &self.p_sigma
+ (self.constants.c_sigma * (2.0 - self.constants.c_sigma) * self.constants.mu_eff)
.sqrt()
* &inv_sqrt_c_times_diff;
// h_sigma: stall indicator
let p_sigma_norm = self.p_sigma.norm();
let threshold =
(1.0 - (1.0 - self.constants.c_sigma).powi(2 * (self.generation as i32 + 1))).sqrt()
* (1.4 + 2.0 / (n as f64 + 1.0))
* self.constants.chi_n;
let h_sigma = if p_sigma_norm < threshold { 1.0 } else { 0.0 };
// Update p_c (cumulation for rank-one update)
self.p_c = (1.0 - self.constants.c_c) * &self.p_c
+ h_sigma
* (self.constants.c_c * (2.0 - self.constants.c_c) * self.constants.mu_eff).sqrt()
* &mean_diff
/ sigma;
// Rank-one and rank-mu update of C
let delta_h = (1.0 - h_sigma) * self.constants.c_c * (2.0 - self.constants.c_c);
let old_c_weight =
1.0 - self.constants.c_1 - self.constants.c_mu + self.constants.c_1 * delta_h;
// Rank-one term
let rank_one = self.constants.c_1 * &self.p_c * self.p_c.transpose();
// Rank-mu term
let mut rank_mu = DMatrix::zeros(n, n);
for (i, &x) in ranked_candidates.iter().take(mu).enumerate() {
let y = (x - &self.mean) / sigma;
rank_mu += self.constants.weights[i] * &y * y.transpose();
}
let rank_mu = self.constants.c_mu * rank_mu;
self.c = old_c_weight * &self.c + rank_one + rank_mu;
// Update sigma via CSA
self.sigma *= ((self.constants.c_sigma / self.constants.d_sigma)
* (p_sigma_norm / self.constants.chi_n - 1.0))
.exp();
self.sigma = self.sigma.clamp(1e-20, 1e10);
// Update mean
self.mean = new_mean;
self.generation += 1;
// Eigendecomposition (every n/10 generations, minimum every generation for small n)
let eigen_interval = (n / 10).max(1);
if self.generation - self.last_eigen_generation >= eigen_interval {
self.update_eigen();
}
}
/// Perform eigendecomposition of C and update B, D, `inv_sqrt_c`.
fn update_eigen(&mut self) {
let n = self.constants.n;
// Enforce symmetry
self.c = (&self.c + self.c.transpose()) / 2.0;
// Eigendecomposition
let eigen = self.c.clone().symmetric_eigen();
let eigenvalues = &eigen.eigenvalues;
let eigenvectors = &eigen.eigenvectors;
// Clamp eigenvalues for numerical stability
let mut d_vec = DVector::zeros(n);
for i in 0..n {
d_vec[i] = eigenvalues[i].max(1e-20).sqrt();
}
self.b = eigenvectors.clone();
self.d = d_vec;
// Compute C^{-1/2} = B * D^{-1} * B^T
let d_inv = DVector::from_fn(n, |i, _| 1.0 / self.d[i]);
let d_inv_diag = DMatrix::from_diagonal(&d_inv);
self.inv_sqrt_c = &self.b * d_inv_diag * self.b.transpose();
self.last_eigen_generation = self.generation;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Check whether a continuous candidate vector is within bounds.
fn is_within_bounds(x: &DVector<f64>, dimensions: &[DimensionInfo]) -> bool {
let mut ci = 0;
for dim in dimensions {
if dim.is_continuous {
if let Some((lo, hi)) = dim.bounds
&& (x[ci] < lo || x[ci] > hi)
{
return false;
}
ci += 1;
}
}
true
}
/// Clip a continuous candidate vector to bounds.
fn clip_to_bounds(x: &mut DVector<f64>, dimensions: &[DimensionInfo]) {
let mut ci = 0;
for dim in dimensions {
if dim.is_continuous {
if let Some((lo, hi)) = dim.bounds {
x[ci] = x[ci].clamp(lo, hi);
}
ci += 1;
}
}
}
/// Convert an internal-space value back to a `ParamValue`.
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
fn from_internal(value: f64, distribution: &Distribution) -> ParamValue {
match distribution {
Distribution::Float(d) => {
let v = if d.log_scale { value.exp() } else { value };
let v = if let Some(step) = d.step {
let k = ((v - d.low) / step).round();
d.low + k * step
} else {
v
};
ParamValue::Float(v.clamp(d.low, d.high))
}
Distribution::Int(d) => {
let v = if d.log_scale { value.exp() } else { value };
let v = if let Some(step) = d.step {
let k = ((v - d.low as f64) / step as f64).round() as i64;
d.low + k * step
} else {
v.round() as i64
};
ParamValue::Int(v.clamp(d.low, d.high))
}
Distribution::Categorical(_) => {
unreachable!("from_internal should not be called for categorical distributions")
}
}
}
/// Compute internal-space bounds for a distribution.
#[allow(clippy::cast_precision_loss)]
fn internal_bounds(distribution: &Distribution) -> Option<(f64, f64)> {
match distribution {
Distribution::Float(d) => {
if d.log_scale {
Some((d.low.ln(), d.high.ln()))
} else {
Some((d.low, d.high))
}
}
Distribution::Int(d) => {
if d.log_scale {
Some(((d.low as f64).ln(), (d.high as f64).ln()))
} else {
Some((d.low as f64, d.high as f64))
}
}
Distribution::Categorical(_) => None,
}
}
/// Sample a value from the standard normal distribution using Box-Muller transform.
fn sample_standard_normal(rng: &mut fastrand::Rng) -> f64 {
// Box-Muller transform
let u1: f64 = rng_util::f64_range(rng, f64::EPSILON, 1.0);
let u2: f64 = rng_util::f64_range(rng, 0.0_f64, core::f64::consts::TAU);
(-2.0 * u1.ln()).sqrt() * u2.cos()
}
/// Sample a categorical value randomly.
fn sample_random_categorical(rng: &mut fastrand::Rng, distribution: &Distribution) -> ParamValue {
match distribution {
Distribution::Categorical(d) => ParamValue::Categorical(rng.usize(0..d.n_choices)),
_ => unreachable!("sample_random_categorical called with non-categorical distribution"),
}
}
/// Sample a random value for any distribution (used during discovery phase).
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
fn sample_random(rng: &mut fastrand::Rng, distribution: &Distribution) -> ParamValue {
match distribution {
Distribution::Float(d) => {
let value = if d.log_scale {
let log_low = d.low.ln();
let log_high = d.high.ln();
rng_util::f64_range(rng, log_low, log_high).exp()
} else if let Some(step) = d.step {
let n_steps = ((d.high - d.low) / step).floor() as i64;
let k = rng.i64(0..=n_steps);
d.low + (k as f64) * step
} else {
rng_util::f64_range(rng, d.low, d.high)
};
ParamValue::Float(value)
}
Distribution::Int(d) => {
let value = if d.log_scale {
let log_low = (d.low as f64).ln();
let log_high = (d.high as f64).ln();
let raw = rng_util::f64_range(rng, log_low, log_high).exp().round() as i64;
raw.clamp(d.low, d.high)
} else if let Some(step) = d.step {
let n_steps = (d.high - d.low) / step;
let k = rng.i64(0..=n_steps);
d.low + k * step
} else {
rng.i64(d.low..=d.high)
};
ParamValue::Int(value)
}
Distribution::Categorical(d) => ParamValue::Categorical(rng.usize(0..d.n_choices)),
}
}
// ---------------------------------------------------------------------------
// Sampler trait implementation
// ---------------------------------------------------------------------------
impl Sampler for CmaEsSampler {
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
fn sample(
&self,
distribution: &Distribution,
trial_id: u64,
history: &[CompletedTrial],
) -> ParamValue {
let mut state = self.state.lock();
match &state.phase {
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 CmaEsState,
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
{
// First trial is done; we know the search space. Initialize CMA-ES.
finalize_discovery(state);
// Now delegate to active sampling
return sample_active(state, distribution, trial_id, &[]);
}
// Record this trial_id
state.discovery_trial_id = Some(trial_id);
// Record this dimension
let is_continuous = !matches!(distribution, Distribution::Categorical(_));
let bounds = internal_bounds(distribution);
state.dimensions.push(DimensionInfo {
distribution: distribution.clone(),
is_continuous,
bounds,
});
// Sample randomly for the discovery trial
sample_random(&mut state.rng, distribution)
}
/// Finalize discovery and transition to the active phase.
fn finalize_discovery(state: &mut CmaEsState) {
let n_continuous = state.dimensions.iter().filter(|d| d.is_continuous).count();
let algo = CmaEsAlgorithm::new(&state.dimensions, state.sigma0, state.user_lambda);
// Generate first batch of candidates
let candidates = if n_continuous > 0 {
algo.generate_candidates(&mut state.rng, &state.dimensions)
} else {
// Pure categorical: generate random categorical candidates
generate_pure_categorical_candidates(
&mut state.rng,
&state.dimensions,
algo.constants.lambda,
)
};
state.candidates = candidates;
state.assigned_count = 0;
state.generation_trial_ids.clear();
state.trial_progress.clear();
state.phase = Phase::Active(Box::new(algo));
}
/// Generate candidates that are purely categorical (no continuous dims).
fn generate_pure_categorical_candidates(
rng: &mut fastrand::Rng,
dimensions: &[DimensionInfo],
lambda: usize,
) -> Vec<Candidate> {
(0..lambda)
.map(|_| {
let mut categorical_values = HashMap::new();
for (i, dim) in dimensions.iter().enumerate() {
if let Distribution::Categorical(cat) = &dim.distribution {
categorical_values.insert(i, rng.usize(0..cat.n_choices));
}
}
Candidate {
x: DVector::zeros(0),
categorical_values,
}
})
.collect()
}
/// Handle sampling during the active phase.
fn sample_active(
state: &mut CmaEsState,
distribution: &Distribution,
trial_id: u64,
history: &[CompletedTrial],
) -> ParamValue {
// Check if we need to update (all generation candidates assigned and completed)
maybe_update_generation(state, history);
// Assign a candidate to this trial if not yet done
if !state.trial_progress.contains_key(&trial_id) {
assign_candidate(state, trial_id);
}
let progress = state.trial_progress.get_mut(&trial_id).unwrap();
let dim_idx = progress.next_dim;
progress.next_dim += 1;
// Safety check: dim_idx should be within dimensions
if dim_idx >= state.dimensions.len() {
// Extra dimension not seen during discovery; sample randomly
return sample_random(&mut state.rng, distribution);
}
let candidate = &state.candidates[progress.candidate_idx];
let dim_info = &state.dimensions[dim_idx];
if dim_info.is_continuous {
// Map from internal continuous index to the candidate's x vector
let ci = state.dimensions[..dim_idx]
.iter()
.filter(|d| d.is_continuous)
.count();
if ci < candidate.x.len() {
from_internal(candidate.x[ci], &dim_info.distribution)
} else {
sample_random(&mut state.rng, distribution)
}
} else {
// Categorical: use pre-sampled value
if let Some(&cat_idx) = candidate.categorical_values.get(&dim_idx) {
ParamValue::Categorical(cat_idx)
} else {
sample_random_categorical(&mut state.rng, distribution)
}
}
}
/// Assign a candidate to a trial.
fn assign_candidate(state: &mut CmaEsState, trial_id: u64) {
let candidate_idx = if state.assigned_count < state.candidates.len() {
let idx = state.assigned_count;
state.assigned_count += 1;
idx
} else {
// Overflow: generate an extra candidate from current distribution
let extra = generate_overflow_candidate(state);
state.candidates.push(extra);
let idx = state.candidates.len() - 1;
state.assigned_count = state.candidates.len();
idx
};
state.trial_progress.insert(
trial_id,
TrialProgress {
candidate_idx,
next_dim: 0,
},
);
state.generation_trial_ids.push(trial_id);
}
/// Generate an overflow candidate from the current distribution.
fn generate_overflow_candidate(state: &mut CmaEsState) -> Candidate {
let n_continuous = state.dimensions.iter().filter(|d| d.is_continuous).count();
if n_continuous == 0 {
let mut categorical_values = HashMap::new();
for (i, dim) in state.dimensions.iter().enumerate() {
if let Distribution::Categorical(cat) = &dim.distribution {
categorical_values.insert(i, state.rng.usize(0..cat.n_choices));
}
}
return Candidate {
x: DVector::zeros(0),
categorical_values,
};
}
match &state.phase {
Phase::Active(algo) => {
algo.generate_single_candidate(&mut state.rng, &state.dimensions, n_continuous)
}
Phase::Discovery => {
// Should not happen, but handle gracefully
Candidate {
x: DVector::zeros(n_continuous),
categorical_values: HashMap::new(),
}
}
}
}
/// Check if we should run the CMA-ES update and generate a new generation.
fn maybe_update_generation(state: &mut CmaEsState, history: &[CompletedTrial]) {
let Phase::Active(algo) = &state.phase else {
return;
};
let lambda = algo.constants.lambda;
let n_continuous = algo.constants.n;
// Only update when at least lambda candidates have been assigned
if state.generation_trial_ids.len() < lambda {
return;
}
// Check if the first lambda trial IDs are all completed
let trial_ids: Vec<u64> = state
.generation_trial_ids
.iter()
.take(lambda)
.copied()
.collect();
let history_ids: HashMap<u64, f64> = history.iter().map(|t| (t.id, t.value)).collect();
let all_completed = trial_ids.iter().all(|id| history_ids.contains_key(id));
if !all_completed {
return;
}
// No continuous dimensions: just regenerate random candidates
if n_continuous == 0 {
state.candidates =
generate_pure_categorical_candidates(&mut state.rng, &state.dimensions, lambda);
state.assigned_count = 0;
state.generation_trial_ids.clear();
state.trial_progress.clear();
return;
}
// Collect (candidate_x, value) for the generation
let mut ranked: Vec<(&DVector<f64>, f64)> = trial_ids
.iter()
.filter_map(|id| {
let progress = state.trial_progress.get(id)?;
let value = *history_ids.get(id)?;
Some((&state.candidates[progress.candidate_idx].x, value))
})
.collect();
// Sort ascending (minimize)
ranked.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal));
let ranked_xs: Vec<&DVector<f64>> = ranked.iter().map(|(x, _)| *x).collect();
// Run update
let Phase::Active(algo) = &mut state.phase else {
return;
};
algo.update(&ranked_xs);
// Generate new candidates
let new_candidates = algo.generate_candidates(&mut state.rng, &state.dimensions);
state.candidates = new_candidates;
state.assigned_count = 0;
state.generation_trial_ids.clear();
state.trial_progress.clear();
}