Add ObjectiveMode enum (EpisodeReward/Sharpe) to DQNTrainer and a TwoPhaseObjective trait + optimize_two_phase() method to ArgminOptimizer. Phase A optimizes episode reward for fast convergence, Phase B (pending model Clone support) refines with Sharpe ratio. This keeps the static extract_objective trait method untouched by separating objective switching into instance-level state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1038 lines
36 KiB
Rust
1038 lines
36 KiB
Rust
//! Generic Bayesian Optimizer using Argmin
|
|
//!
|
|
//! This module provides a production-ready optimization implementation that works
|
|
//! with any model implementing `HyperparameterOptimizable`. It uses the argmin
|
|
//! library with Particle Swarm Optimization (PSO) for efficient parameter search.
|
|
//!
|
|
//! ## Key Features
|
|
//!
|
|
//! - **Particle Swarm Optimization**: Derivative-free, population-based optimization
|
|
//! - **Latin Hypercube Sampling**: Smart initialization for first N trials
|
|
//! - **Type-safe**: Works with any `ParameterSpace` implementation
|
|
//! - **Production-ready**: Comprehensive error handling and logging
|
|
//!
|
|
//! ## Algorithm Overview
|
|
//!
|
|
//! 1. **Initialization**: Generate `n_initial` diverse samples via LHS
|
|
//! 2. **Evaluation**: Train model with each parameter set, record objective
|
|
//! 3. **Optimization**: Use Particle Swarm to find global optima
|
|
//! 4. **Multi-restart**: Swarm automatically explores multiple regions
|
|
//! 5. **Repeat**: Continue until `max_trials` reached
|
|
//!
|
|
//! ## Usage Example
|
|
//!
|
|
//! ```rust,no_run
|
|
//! use ml::hyperopt::{ArgminOptimizer, HyperparameterOptimizable};
|
|
//! use ml::hyperopt::adapters::mamba2::{Mamba2Trainer, Mamba2Params};
|
|
//!
|
|
//! # fn example() -> anyhow::Result<()> {
|
|
//! let trainer = Mamba2Trainer::new("data.parquet", 50)?;
|
|
//!
|
|
//! let optimizer = ArgminOptimizer::builder()
|
|
//! .max_trials(30)
|
|
//! .n_initial(5)
|
|
//! .seed(42)
|
|
//! .build();
|
|
//!
|
|
//! let result = optimizer.optimize(trainer)?;
|
|
//! println!("Best loss: {:.6}", result.best_objective);
|
|
//! # Ok(())
|
|
//! # }
|
|
//! ```
|
|
|
|
use anyhow::{Context, Result};
|
|
use argmin::core::{CostFunction, Executor, State};
|
|
use argmin::solver::particleswarm::ParticleSwarm;
|
|
use ndarray::Array2;
|
|
use rand::prelude::*;
|
|
use rand::SeedableRng;
|
|
use rand_distr::Uniform;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Instant;
|
|
use tracing::{info, warn};
|
|
|
|
use super::traits::{HyperparameterOptimizable, OptimizationResult, ParameterSpace, TrialResult};
|
|
use crate::hyperopt::adapters::dqn::ObjectiveMode;
|
|
use crate::MLError;
|
|
|
|
/// Bayesian optimizer using argmin library
|
|
///
|
|
/// This optimizer uses Particle Swarm Optimization (PSO) with Latin Hypercube Sampling
|
|
/// for initialization. It's designed to work with any model implementing
|
|
/// `HyperparameterOptimizable`.
|
|
///
|
|
/// ## Configuration
|
|
///
|
|
/// - `max_trials`: Total evaluations (including initial samples)
|
|
/// - `n_initial`: Number of Latin Hypercube samples for initialization
|
|
/// - `n_particles`: Number of particles in the swarm (default: 20)
|
|
/// - `seed`: Random seed for reproducibility (optional)
|
|
///
|
|
/// ## Performance Characteristics
|
|
///
|
|
/// - Initial samples: ~O(n_initial * training_time)
|
|
/// - Per-iteration overhead: ~1-10ms for swarm updates
|
|
/// - Memory usage: O(max_trials) for trial history
|
|
///
|
|
/// ## Advantages over Nelder-Mead
|
|
///
|
|
/// - Supports multi-dimensional vector parameters (Vec<f64>)
|
|
/// - Better at avoiding local minima through swarm intelligence
|
|
/// - Scales well to higher dimensions (20-50 parameters)
|
|
/// - No gradient information required
|
|
#[derive(Debug, Clone)]
|
|
pub struct ArgminOptimizer {
|
|
/// Maximum number of trials (evaluations)
|
|
pub(crate) max_trials: usize,
|
|
/// Number of initial samples via Latin Hypercube Sampling
|
|
pub(crate) n_initial: usize,
|
|
/// Number of particles in the swarm
|
|
pub(crate) n_particles: usize,
|
|
/// Random seed for reproducibility
|
|
pub(crate) seed: Option<u64>,
|
|
/// Maximum iterations per restart
|
|
pub(crate) max_iters_per_restart: usize,
|
|
}
|
|
|
|
impl Default for ArgminOptimizer {
|
|
fn default() -> Self {
|
|
Self {
|
|
max_trials: 30,
|
|
n_initial: 5,
|
|
n_particles: 20,
|
|
seed: None,
|
|
max_iters_per_restart: 50,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ArgminOptimizer {
|
|
/// Create a new optimizer with default settings
|
|
///
|
|
/// Uses 30 max trials and 5 initial samples.
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Create optimizer with specified settings
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `max_trials` - Total number of trials (must be > n_initial)
|
|
/// * `n_initial` - Number of initial LHS samples (typically 3-10)
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Panics if `max_trials <= n_initial` or if either is zero.
|
|
pub fn with_trials(max_trials: usize, n_initial: usize) -> Self {
|
|
assert!(max_trials > n_initial, "max_trials must be > n_initial");
|
|
assert!(n_initial > 0, "n_initial must be > 0");
|
|
|
|
Self {
|
|
max_trials,
|
|
n_initial,
|
|
n_particles: 20,
|
|
seed: None,
|
|
max_iters_per_restart: 50,
|
|
}
|
|
}
|
|
|
|
/// Set random seed for reproducibility
|
|
pub fn with_seed(mut self, seed: u64) -> Self {
|
|
self.seed = Some(seed);
|
|
self
|
|
}
|
|
|
|
/// Start building an optimizer with custom configuration
|
|
pub fn builder() -> ArgminOptimizerBuilder {
|
|
ArgminOptimizerBuilder::new()
|
|
}
|
|
|
|
/// Generate Latin Hypercube samples for initialization
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `n_samples` - Number of samples to generate
|
|
/// * `bounds` - Parameter bounds [(min, max), ...]
|
|
/// * `rng` - Random number generator
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// Array of shape (n_samples, n_params) with LHS samples
|
|
pub(crate) fn latin_hypercube_sampling<R: Rng>(
|
|
n_samples: usize,
|
|
bounds: &[(f64, f64)],
|
|
rng: &mut R,
|
|
) -> Array2<f64> {
|
|
let n_params = bounds.len();
|
|
let mut samples = Array2::zeros((n_samples, n_params));
|
|
|
|
// For each parameter dimension
|
|
for dim in 0..n_params {
|
|
// Create permutation of [0, 1, 2, ..., n_samples-1]
|
|
let mut indices: Vec<usize> = (0..n_samples).collect();
|
|
indices.shuffle(rng);
|
|
|
|
// Generate stratified samples
|
|
let uniform = Uniform::new(0.0, 1.0);
|
|
for (sample_idx, &perm_idx) in indices.iter().enumerate() {
|
|
let segment_start = perm_idx as f64 / n_samples as f64;
|
|
let segment_end = (perm_idx + 1) as f64 / n_samples as f64;
|
|
let random_offset: f64 = rng.sample(uniform);
|
|
let normalized = segment_start + random_offset * (segment_end - segment_start);
|
|
|
|
// Denormalize to actual bounds
|
|
let (min, max) = bounds[dim];
|
|
samples[[sample_idx, dim]] = min + normalized * (max - min);
|
|
}
|
|
}
|
|
|
|
samples
|
|
}
|
|
|
|
/// Run optimization on a model
|
|
///
|
|
/// This method:
|
|
/// 1. Extracts parameter bounds from `P::continuous_bounds()`
|
|
/// 2. Generates initial samples via Latin Hypercube
|
|
/// 3. Evaluates each sample by calling `model.train_with_params()`
|
|
/// 4. Uses Nelder-Mead to optimize from best initial point
|
|
/// 5. Performs multi-restart from other good points
|
|
/// 6. Repeats until `max_trials` reached
|
|
///
|
|
/// # Type Parameters
|
|
///
|
|
/// - `M`: Model type implementing `HyperparameterOptimizable`
|
|
/// - `P`: Parameter type implementing `ParameterSpace`
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `model` - Mutable reference to model (needed for training)
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// `OptimizationResult<P>` containing best parameters and full trial history
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns error if:
|
|
/// - Parameter space has zero dimensions
|
|
/// - Initial sampling fails
|
|
/// - All trials produce errors (no valid observations)
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```rust,no_run
|
|
/// # use ml::hyperopt::{ArgminOptimizer, HyperparameterOptimizable};
|
|
/// # use ml::hyperopt::adapters::mamba2::Mamba2Trainer;
|
|
/// # fn example() -> anyhow::Result<()> {
|
|
/// let mut trainer = Mamba2Trainer::new("data.parquet", 50)?;
|
|
/// let optimizer = ArgminOptimizer::new();
|
|
/// let result = optimizer.optimize(trainer)?;
|
|
/// # Ok(())
|
|
/// # }
|
|
/// ```
|
|
pub fn optimize<M>(&self, mut model: M) -> Result<OptimizationResult<M::Params>>
|
|
where
|
|
M: HyperparameterOptimizable + Send,
|
|
M::Params: ParameterSpace + Send,
|
|
{
|
|
info!("╔═══════════════════════════════════════════════════════════╗");
|
|
info!("║ Bayesian Hyperparameter Optimization (Argmin) ║");
|
|
info!("╚═══════════════════════════════════════════════════════════╝");
|
|
|
|
// Extract parameter space bounds
|
|
let bounds = M::Params::continuous_bounds();
|
|
let n_params = bounds.len();
|
|
|
|
if n_params == 0 {
|
|
return Err(MLError::ConfigError {
|
|
reason: "Parameter space has zero dimensions".to_string(),
|
|
}
|
|
.into());
|
|
}
|
|
|
|
info!("Configuration:");
|
|
info!(" Max Trials: {}", self.max_trials);
|
|
info!(" Initial Samples: {}", self.n_initial);
|
|
info!(" Swarm Particles: {}", self.n_particles);
|
|
info!(" Parameters: {}", n_params);
|
|
info!(" Max Iters/Restart: {}", self.max_iters_per_restart);
|
|
|
|
let param_names = M::Params::param_names();
|
|
for (i, name) in param_names.iter().enumerate() {
|
|
info!(" {} - [{:.6}, {:.6}]", name, bounds[i].0, bounds[i].1);
|
|
}
|
|
|
|
// Initialize RNG
|
|
let mut rng = match self.seed {
|
|
Some(seed) => StdRng::seed_from_u64(seed),
|
|
None => StdRng::from_entropy(),
|
|
};
|
|
|
|
// Generate initial samples using Latin Hypercube
|
|
info!(
|
|
"Generating {} initial samples with Latin Hypercube Sampling...",
|
|
self.n_initial
|
|
);
|
|
let initial_samples = Self::latin_hypercube_sampling(self.n_initial, &bounds, &mut rng);
|
|
info!("✓ Generated {} initial samples", initial_samples.nrows());
|
|
|
|
// Shared state for collecting trial results
|
|
let trial_results = Arc::new(Mutex::new(Vec::new()));
|
|
let trial_counter = Arc::new(Mutex::new(0));
|
|
|
|
// Evaluate initial samples
|
|
info!("Evaluating initial samples...");
|
|
for i in 0..self.n_initial {
|
|
let continuous_vec: Vec<f64> = initial_samples.row(i).to_vec();
|
|
Self::evaluate_point(
|
|
&continuous_vec,
|
|
&mut model,
|
|
&trial_results,
|
|
&trial_counter,
|
|
¶m_names,
|
|
)?;
|
|
}
|
|
|
|
// Create trial budget observer BEFORE creating cost function
|
|
// This observer enforces strict trial limits (fixes PSO infinite iteration bug)
|
|
let observer = crate::hyperopt::TrialBudgetObserver::new(self.max_trials);
|
|
|
|
// Create cost function wrapper
|
|
let cost_fn = ObjectiveFunction {
|
|
model: Arc::new(Mutex::new(model)),
|
|
trial_results: Arc::clone(&trial_results),
|
|
trial_counter: Arc::clone(&trial_counter),
|
|
param_names: param_names.clone(),
|
|
bounds: bounds.clone(),
|
|
observer: observer.clone(),
|
|
};
|
|
|
|
// Find best initial point to start optimization
|
|
let trials = trial_results.lock().unwrap().clone();
|
|
let best_initial = trials
|
|
.iter()
|
|
.min_by(|a, b| {
|
|
a.objective
|
|
.partial_cmp(&b.objective)
|
|
.unwrap_or(std::cmp::Ordering::Equal)
|
|
})
|
|
.ok_or_else(|| {
|
|
anyhow::anyhow!("No initial trials completed successfully — cannot start PSO")
|
|
})?;
|
|
|
|
info!("╔═══════════════════════════════════════════════════════════╗");
|
|
info!("║ Starting Particle Swarm Optimization ║");
|
|
info!("╚═══════════════════════════════════════════════════════════╝");
|
|
info!("Best initial objective: {:.6}", best_initial.objective);
|
|
info!("Execution mode: Sequential trials (model locked by Mutex, rayon for swarm only)");
|
|
|
|
// Determine how many iterations we can afford
|
|
let trials_used = trials.len();
|
|
let remaining_trials = self.max_trials.saturating_sub(trials_used);
|
|
|
|
// FIX: Each PSO iteration evaluates n_particles candidates (sequentially via mutex)
|
|
// PSO evaluates ALL particles in swarm per iteration, so divide remaining budget
|
|
// by swarm size to prevent trial count overflow (fixes 962 trial bug)
|
|
// CRITICAL FIX (2025-11-07): Use CEILING division to ensure all trials complete
|
|
// Example: 8 remaining ÷ 20 particles = 0.4 → ceil to 1 iteration (not 0)
|
|
let max_iters_by_budget =
|
|
((remaining_trials as f64) / (self.n_particles as f64)).ceil() as usize;
|
|
|
|
let max_iters = max_iters_by_budget.min(self.max_iters_per_restart);
|
|
|
|
info!(
|
|
"PSO Budget: {} iterations ({} remaining trials ÷ {} particles = {} max iters)",
|
|
max_iters, remaining_trials, self.n_particles, max_iters_by_budget
|
|
);
|
|
|
|
if max_iters > 0 {
|
|
// Create bounds vectors for ParticleSwarm
|
|
let lower_bounds: Vec<f64> = bounds.iter().map(|(min, _)| *min).collect();
|
|
let upper_bounds: Vec<f64> = bounds.iter().map(|(_, max)| *max).collect();
|
|
|
|
// Create Particle Swarm solver
|
|
let solver = ParticleSwarm::new((lower_bounds, upper_bounds), self.n_particles);
|
|
|
|
// Run optimization (parallel execution enabled via rayon feature)
|
|
// CRITICAL FIX (2025-11-03): Removed .target_cost(0.0) to prevent early termination
|
|
// PSO must run for exactly max_iters iterations to complete all requested trials
|
|
// CRITICAL FIX (2025-11-07): Added TrialBudgetObserver to enforce strict trial limits
|
|
let res = Executor::new(cost_fn, solver)
|
|
.configure(|state| state.max_iters(max_iters as u64))
|
|
.add_observer(
|
|
observer.clone(),
|
|
argmin::core::observers::ObserverMode::Always,
|
|
)
|
|
.run();
|
|
|
|
// Handle budget exhaustion gracefully (not an error condition)
|
|
match res {
|
|
Ok(result) => {
|
|
info!("Optimization complete:");
|
|
info!(" Final cost: {:.6}", result.state().get_best_cost());
|
|
info!(" Iterations: {}", result.state().get_iter());
|
|
info!(" Evaluations: {}", trial_counter.lock().unwrap());
|
|
},
|
|
Err(e) if e.to_string().contains("Trial budget exhausted") => {
|
|
info!(
|
|
"PSO terminated: trial budget reached ({} trials)",
|
|
self.max_trials
|
|
);
|
|
info!(" Evaluations: {}", trial_counter.lock().unwrap());
|
|
},
|
|
Err(e) => return Err(e.into()),
|
|
}
|
|
} else {
|
|
info!("No remaining budget for Particle Swarm optimization");
|
|
}
|
|
|
|
info!("╔═══════════════════════════════════════════════════════════╗");
|
|
info!("║ Optimization Complete ║");
|
|
info!("╚═══════════════════════════════════════════════════════════╝");
|
|
|
|
// Extract final results
|
|
let trials = match Arc::try_unwrap(trial_results) {
|
|
Ok(mutex) => mutex.into_inner().unwrap(),
|
|
Err(arc) => arc.lock().unwrap().clone(),
|
|
};
|
|
|
|
if trials.is_empty() {
|
|
return Err(MLError::TrainingError(
|
|
"All trials failed - no valid observations".to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
|
|
// Create optimization result
|
|
let result = OptimizationResult::from_trials(trials);
|
|
|
|
info!("Best Parameters Found:");
|
|
let best_continuous = result.best_params.to_continuous();
|
|
for (i, name) in param_names.iter().enumerate() {
|
|
info!(" {}: {:.6}", name, best_continuous[i]);
|
|
}
|
|
info!("Best Objective: {:.6}", result.best_objective);
|
|
info!("Total Improvement: {:.6}", result.total_improvement());
|
|
info!("Improvement: {:.2}%", result.improvement_percentage());
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
/// Evaluate a single point
|
|
fn evaluate_point<M>(
|
|
continuous_vec: &[f64],
|
|
model: &mut M,
|
|
trial_results: &Arc<Mutex<Vec<TrialResult<M::Params>>>>,
|
|
trial_counter: &Arc<Mutex<usize>>,
|
|
_param_names: &[&'static str],
|
|
) -> Result<f64>
|
|
where
|
|
M: HyperparameterOptimizable,
|
|
M::Params: ParameterSpace,
|
|
{
|
|
let start_time = Instant::now();
|
|
|
|
// Increment trial counter
|
|
let trial_num = {
|
|
let mut counter = trial_counter.lock().unwrap();
|
|
*counter += 1;
|
|
*counter
|
|
};
|
|
|
|
info!("╔═══════════════════════════════════════════════════════════╗");
|
|
info!(
|
|
"║ Trial {}: Evaluating Parameters ║",
|
|
trial_num
|
|
);
|
|
info!("╚═══════════════════════════════════════════════════════════╝");
|
|
|
|
// Convert continuous vector to parameters BEFORE logging
|
|
let params =
|
|
M::Params::from_continuous(continuous_vec).context("Failed to convert parameters")?;
|
|
|
|
// Log CONVERTED parameters (shows actual values: learning_rate ~1e-4, not -11)
|
|
info!(" Parameters (converted): {:?}", params);
|
|
|
|
// Train model with parameters
|
|
let metrics = model
|
|
.train_with_params(params.clone())
|
|
.context(format!("Training failed for trial {}", trial_num))?;
|
|
|
|
// Extract objective (replace NaN/Inf with large penalty)
|
|
let raw_objective = M::extract_objective(&metrics);
|
|
let objective = if raw_objective.is_finite() {
|
|
raw_objective
|
|
} else {
|
|
warn!("Trial {} produced non-finite objective ({:?}), using penalty 1e6", trial_num, raw_objective);
|
|
1e6
|
|
};
|
|
let duration_secs = start_time.elapsed().as_secs_f64();
|
|
|
|
info!("✓ Trial {} completed in {:.1}s", trial_num, duration_secs);
|
|
info!(" Objective: {:.6}", objective);
|
|
|
|
// Record trial result
|
|
{
|
|
let mut results = trial_results.lock().unwrap();
|
|
results.push(TrialResult {
|
|
trial_num,
|
|
params,
|
|
objective,
|
|
duration_secs,
|
|
});
|
|
}
|
|
|
|
Ok(objective)
|
|
}
|
|
|
|
/// Run two-phase optimization: episode reward then Sharpe ratio.
|
|
///
|
|
/// Phase A: `max_trials/2` trials optimizing for fast convergence (episode reward).
|
|
/// Phase B: remaining trials optimizing for financial quality (Sharpe ratio),
|
|
/// seeded from Phase A's best parameter configuration.
|
|
///
|
|
/// This reuses the existing `optimize()` method for each phase. The objective
|
|
/// mode is set on the model before each phase via `TwoPhaseObjective`.
|
|
///
|
|
/// # Current Limitations
|
|
///
|
|
/// Full two-phase with Phase B seeding requires `M: Clone`. Currently runs
|
|
/// Phase A only and returns its result. Phase B will be enabled once the
|
|
/// model types implement `Clone`.
|
|
pub fn optimize_two_phase<M>(&self, mut model: M) -> Result<OptimizationResult<M::Params>>
|
|
where
|
|
M: HyperparameterOptimizable + TwoPhaseObjective + Send,
|
|
M::Params: ParameterSpace + Send,
|
|
{
|
|
let phase_a_trials = self.max_trials / 2;
|
|
let _phase_b_trials = self.max_trials - phase_a_trials;
|
|
|
|
info!("======= Two-Phase Optimization =======");
|
|
info!("Phase A: {} trials optimizing episode reward", phase_a_trials);
|
|
info!("Phase B: {} trials optimizing Sharpe ratio (pending Clone support)", _phase_b_trials);
|
|
|
|
// Phase A: Episode Reward
|
|
model.set_objective_mode(ObjectiveMode::EpisodeReward);
|
|
let phase_a_optimizer = ArgminOptimizer::with_trials(
|
|
phase_a_trials,
|
|
self.n_initial.min(phase_a_trials - 1).max(1),
|
|
);
|
|
let phase_a_result = phase_a_optimizer.optimize(model)?;
|
|
|
|
info!(
|
|
"Phase A complete: best_objective={:.6}, evaluated {} trials",
|
|
phase_a_result.best_objective,
|
|
phase_a_result.all_trials.len()
|
|
);
|
|
|
|
// TODO: Phase B requires M: Clone to reconstruct model from Phase A result.
|
|
// Once DQNTrainer implements Clone, Phase B will:
|
|
// 1. Extract the model back (currently consumed by optimize())
|
|
// 2. Set objective mode to Sharpe
|
|
// 3. Seed Phase B with top-3 parameter configs from Phase A
|
|
// 4. Run remaining trials with Sharpe-based objective
|
|
|
|
info!(
|
|
"======= Two-Phase Complete: best_objective={:.6} =======",
|
|
phase_a_result.best_objective
|
|
);
|
|
|
|
Ok(phase_a_result)
|
|
}
|
|
}
|
|
|
|
/// Trait for models supporting two-phase objective switching.
|
|
///
|
|
/// Used by [`ArgminOptimizer::optimize_two_phase()`] to switch between
|
|
/// episode reward (fast convergence) and Sharpe ratio (financial quality).
|
|
///
|
|
/// This trait is intentionally separate from [`HyperparameterOptimizable`]
|
|
/// because `extract_objective` is a static method that cannot access instance
|
|
/// state. Two-phase optimization instead sets the objective mode on the model
|
|
/// instance before each phase.
|
|
pub trait TwoPhaseObjective {
|
|
/// Set the objective mode for the current optimization phase.
|
|
fn set_objective_mode(&mut self, mode: ObjectiveMode);
|
|
/// Get the current objective mode.
|
|
fn objective_mode(&self) -> ObjectiveMode;
|
|
}
|
|
|
|
/// Cost function wrapper for argmin
|
|
struct ObjectiveFunction<M>
|
|
where
|
|
M: HyperparameterOptimizable,
|
|
M::Params: ParameterSpace,
|
|
{
|
|
model: Arc<Mutex<M>>,
|
|
trial_results: Arc<Mutex<Vec<TrialResult<M::Params>>>>,
|
|
trial_counter: Arc<Mutex<usize>>,
|
|
param_names: Vec<&'static str>,
|
|
bounds: Vec<(f64, f64)>,
|
|
observer: crate::hyperopt::TrialBudgetObserver,
|
|
}
|
|
|
|
impl<M> CostFunction for ObjectiveFunction<M>
|
|
where
|
|
M: HyperparameterOptimizable,
|
|
M::Params: ParameterSpace,
|
|
{
|
|
type Param = Vec<f64>;
|
|
type Output = f64;
|
|
|
|
fn cost(&self, param: &Self::Param) -> Result<Self::Output, argmin::core::Error> {
|
|
// Increment observer trial count FIRST
|
|
self.observer.increment_trial();
|
|
|
|
// Check if budget exhausted (prevents runaway PSO)
|
|
if self.observer.should_terminate() {
|
|
warn!(
|
|
"Trial budget exhausted: {}/{} trials. Stopping PSO.",
|
|
self.observer.get_trials_used(),
|
|
self.observer.get_trials_used()
|
|
);
|
|
return Err(argmin::core::Error::msg("Trial budget exhausted"));
|
|
}
|
|
|
|
// Clamp parameters to bounds
|
|
let mut clamped = param.clone();
|
|
for (i, (min, max)) in self.bounds.iter().enumerate() {
|
|
clamped[i] = clamped[i].clamp(*min, *max);
|
|
}
|
|
|
|
let start_time = Instant::now();
|
|
|
|
// Increment trial counter
|
|
let trial_num = {
|
|
let mut counter = self.trial_counter.lock().unwrap();
|
|
*counter += 1;
|
|
*counter
|
|
};
|
|
|
|
info!("╔═══════════════════════════════════════════════════════════╗");
|
|
info!(
|
|
"║ Trial {}: Evaluating Parameters ║",
|
|
trial_num
|
|
);
|
|
info!("╚═══════════════════════════════════════════════════════════╝");
|
|
|
|
// Convert continuous vector to parameters BEFORE logging
|
|
let params = match M::Params::from_continuous(&clamped) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
warn!(
|
|
"Failed to convert parameters for trial {}: {}",
|
|
trial_num, e
|
|
);
|
|
return Ok(1e6); // Penalty for invalid parameters
|
|
},
|
|
};
|
|
|
|
// Log CONVERTED parameters (shows actual values: learning_rate ~1e-4, not -11)
|
|
info!(" Parameters (converted): {:?}", params);
|
|
|
|
// Train model with parameters
|
|
let mut model = self.model.lock().unwrap();
|
|
let metrics = match model.train_with_params(params.clone()) {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
warn!("Training failed for trial {}: {}", trial_num, e);
|
|
return Ok(1e6); // Penalty for training failure
|
|
},
|
|
};
|
|
|
|
// Extract objective (replace NaN/Inf with large penalty)
|
|
let raw_objective = M::extract_objective(&metrics);
|
|
let objective = if raw_objective.is_finite() {
|
|
raw_objective
|
|
} else {
|
|
warn!("Trial {} produced non-finite objective ({:?}), using penalty 1e6", trial_num, raw_objective);
|
|
1e6
|
|
};
|
|
let duration_secs = start_time.elapsed().as_secs_f64();
|
|
|
|
info!("✓ Trial {} completed in {:.1}s", trial_num, duration_secs);
|
|
info!(" Objective: {:.6}", objective);
|
|
|
|
// Record trial result
|
|
{
|
|
let mut results = self.trial_results.lock().unwrap();
|
|
results.push(TrialResult {
|
|
trial_num,
|
|
params,
|
|
objective,
|
|
duration_secs,
|
|
});
|
|
}
|
|
|
|
Ok(objective)
|
|
}
|
|
}
|
|
|
|
/// Builder for `ArgminOptimizer` with fluent API
|
|
///
|
|
/// Provides a convenient way to configure optimizer settings.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```rust
|
|
/// use ml::hyperopt::ArgminOptimizer;
|
|
///
|
|
/// let optimizer = ArgminOptimizer::builder()
|
|
/// .max_trials(50)
|
|
/// .n_initial(10)
|
|
/// .n_particles(30)
|
|
/// .seed(12345)
|
|
/// .build();
|
|
/// ```
|
|
#[derive(Debug, Clone)]
|
|
pub struct ArgminOptimizerBuilder {
|
|
max_trials: usize,
|
|
n_initial: usize,
|
|
n_particles: usize,
|
|
seed: Option<u64>,
|
|
max_iters_per_restart: usize,
|
|
}
|
|
|
|
impl Default for ArgminOptimizerBuilder {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl ArgminOptimizerBuilder {
|
|
/// Create a new builder with default settings
|
|
pub fn new() -> Self {
|
|
Self {
|
|
max_trials: 30,
|
|
n_initial: 5,
|
|
n_particles: 20,
|
|
seed: None,
|
|
max_iters_per_restart: 50,
|
|
}
|
|
}
|
|
|
|
/// Set maximum number of trials
|
|
pub fn max_trials(mut self, max_trials: usize) -> Self {
|
|
self.max_trials = max_trials;
|
|
self
|
|
}
|
|
|
|
/// Set number of initial samples
|
|
pub fn n_initial(mut self, n_initial: usize) -> Self {
|
|
self.n_initial = n_initial;
|
|
self
|
|
}
|
|
|
|
/// Set number of particles in the swarm
|
|
pub fn n_particles(mut self, n_particles: usize) -> Self {
|
|
self.n_particles = n_particles;
|
|
self
|
|
}
|
|
|
|
/// Set random seed for reproducibility
|
|
pub fn seed(mut self, seed: u64) -> Self {
|
|
self.seed = Some(seed);
|
|
self
|
|
}
|
|
|
|
/// Set maximum iterations per restart
|
|
pub fn max_iters_per_restart(mut self, max_iters: usize) -> Self {
|
|
self.max_iters_per_restart = max_iters;
|
|
self
|
|
}
|
|
|
|
/// Build the optimizer
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Panics if `max_trials <= n_initial` or if either is zero.
|
|
pub fn build(self) -> ArgminOptimizer {
|
|
assert!(
|
|
self.max_trials > self.n_initial,
|
|
"max_trials must be > n_initial"
|
|
);
|
|
assert!(self.n_initial > 0, "n_initial must be > 0");
|
|
assert!(self.n_particles > 0, "n_particles must be > 0");
|
|
|
|
ArgminOptimizer {
|
|
max_trials: self.max_trials,
|
|
n_initial: self.n_initial,
|
|
n_particles: self.n_particles,
|
|
seed: self.seed,
|
|
max_iters_per_restart: self.max_iters_per_restart,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Re-export for backward compatibility
|
|
pub type EgoboxOptimizer = ArgminOptimizer;
|
|
pub type EgoboxOptimizerBuilder = ArgminOptimizerBuilder;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct TestParams {
|
|
x: f64,
|
|
y: f64,
|
|
}
|
|
|
|
impl ParameterSpace for TestParams {
|
|
fn continuous_bounds() -> Vec<(f64, f64)> {
|
|
vec![(-5.0, 5.0), (-5.0, 5.0)]
|
|
}
|
|
|
|
fn from_continuous(x: &[f64]) -> Result<Self, MLError> {
|
|
Ok(Self { x: x[0], y: x[1] })
|
|
}
|
|
|
|
fn to_continuous(&self) -> Vec<f64> {
|
|
vec![self.x, self.y]
|
|
}
|
|
|
|
fn param_names() -> Vec<&'static str> {
|
|
vec!["x", "y"]
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct TestMetrics {
|
|
loss: f64,
|
|
}
|
|
|
|
struct TestModel;
|
|
|
|
impl HyperparameterOptimizable for TestModel {
|
|
type Params = TestParams;
|
|
type Metrics = TestMetrics;
|
|
|
|
fn train_with_params(&mut self, params: Self::Params) -> Result<Self::Metrics, MLError> {
|
|
// Rosenbrock function: (1-x)^2 + 100(y-x^2)^2
|
|
// Minimum at (1, 1) with value 0
|
|
let loss = (1.0 - params.x).powi(2) + 100.0 * (params.y - params.x.powi(2)).powi(2);
|
|
Ok(TestMetrics { loss })
|
|
}
|
|
|
|
fn extract_objective(metrics: &Self::Metrics) -> f64 {
|
|
metrics.loss
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_optimizer_builder() {
|
|
let optimizer = ArgminOptimizer::builder()
|
|
.max_trials(20)
|
|
.n_initial(3)
|
|
.seed(42)
|
|
.build();
|
|
|
|
assert_eq!(optimizer.max_trials, 20);
|
|
assert_eq!(optimizer.n_initial, 3);
|
|
assert_eq!(optimizer.seed, Some(42));
|
|
}
|
|
|
|
#[test]
|
|
fn test_latin_hypercube_sampling() {
|
|
let mut rng = StdRng::seed_from_u64(42);
|
|
let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
|
|
let samples = ArgminOptimizer::latin_hypercube_sampling(10, &bounds, &mut rng);
|
|
|
|
assert_eq!(samples.nrows(), 10);
|
|
assert_eq!(samples.ncols(), 2);
|
|
|
|
// Check all samples are within bounds
|
|
for i in 0..10 {
|
|
assert!(samples[[i, 0]] >= -5.0 && samples[[i, 0]] <= 5.0);
|
|
assert!(samples[[i, 1]] >= -5.0 && samples[[i, 1]] <= 5.0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
#[ignore] // Expensive test - run manually
|
|
fn test_optimizer_rosenbrock() {
|
|
let model = TestModel;
|
|
let optimizer = ArgminOptimizer::with_trials(15, 3);
|
|
|
|
let result = optimizer.optimize(model).unwrap();
|
|
|
|
// Should find minimum near (1, 1)
|
|
assert!(
|
|
result.best_objective < 1.0,
|
|
"Expected to find near-optimal solution"
|
|
);
|
|
assert!(
|
|
result.all_trials.len() <= 15,
|
|
"Should have at most 15 trials"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parameter_conversion_with_log_scale() {
|
|
// Test that log-scale parameters are converted correctly
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
struct LogScaleParams {
|
|
learning_rate: f64,
|
|
weight_decay: f64,
|
|
batch_size: usize,
|
|
}
|
|
|
|
impl ParameterSpace for LogScaleParams {
|
|
fn continuous_bounds() -> Vec<(f64, f64)> {
|
|
vec![
|
|
(1e-5_f64.ln(), 1e-2_f64.ln()), // learning_rate (log scale)
|
|
(1e-6_f64.ln(), 1e-2_f64.ln()), // weight_decay (log scale)
|
|
(16.0, 256.0), // batch_size (linear)
|
|
]
|
|
}
|
|
|
|
fn from_continuous(x: &[f64]) -> Result<Self, MLError> {
|
|
Ok(Self {
|
|
learning_rate: x[0].exp(),
|
|
weight_decay: x[1].exp(),
|
|
batch_size: x[2].round() as usize,
|
|
})
|
|
}
|
|
|
|
fn to_continuous(&self) -> Vec<f64> {
|
|
vec![
|
|
self.learning_rate.ln(),
|
|
self.weight_decay.ln(),
|
|
self.batch_size as f64,
|
|
]
|
|
}
|
|
|
|
fn param_names() -> Vec<&'static str> {
|
|
vec!["learning_rate", "weight_decay", "batch_size"]
|
|
}
|
|
}
|
|
|
|
// Test continuous space values (negative for log-scale)
|
|
let continuous = vec![-9.21, -11.51, 64.0]; // ln(1e-4), ln(1e-5), 64
|
|
|
|
// Convert to actual parameters
|
|
let params = LogScaleParams::from_continuous(&continuous).unwrap();
|
|
|
|
// Verify conversions (use relative tolerance for floating point)
|
|
// Allow 1% relative error due to log/exp floating point precision
|
|
assert!(
|
|
(params.learning_rate - 1e-4).abs() / 1e-4 < 0.01,
|
|
"Learning rate should be ~1e-4 (±1%), got {}",
|
|
params.learning_rate
|
|
);
|
|
assert!(
|
|
(params.weight_decay - 1e-5).abs() / 1e-5 < 0.01,
|
|
"Weight decay should be ~1e-5 (±1%), got {}",
|
|
params.weight_decay
|
|
);
|
|
assert_eq!(params.batch_size, 64, "Batch size should be 64");
|
|
|
|
// Verify round-trip
|
|
let recovered = params.to_continuous();
|
|
assert!((recovered[0] - continuous[0]).abs() < 1e-6);
|
|
assert!((recovered[1] - continuous[1]).abs() < 1e-6);
|
|
assert!((recovered[2] - continuous[2]).abs() < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parameter_logging_shows_converted_values() {
|
|
// This test verifies that parameters are logged AFTER conversion,
|
|
// so Debug output shows actual values (learning_rate ~1e-4, not -11)
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct LogParams {
|
|
value: f64,
|
|
}
|
|
|
|
impl ParameterSpace for LogParams {
|
|
fn continuous_bounds() -> Vec<(f64, f64)> {
|
|
vec![(1e-5_f64.ln(), 1e-2_f64.ln())]
|
|
}
|
|
|
|
fn from_continuous(x: &[f64]) -> Result<Self, MLError> {
|
|
Ok(Self { value: x[0].exp() })
|
|
}
|
|
|
|
fn to_continuous(&self) -> Vec<f64> {
|
|
vec![self.value.ln()]
|
|
}
|
|
|
|
fn param_names() -> Vec<&'static str> {
|
|
vec!["value"]
|
|
}
|
|
}
|
|
|
|
// Raw continuous value (log scale)
|
|
let continuous = vec![-11.51]; // ln(1e-5)
|
|
|
|
// Convert to actual parameter
|
|
let params = LogParams::from_continuous(&continuous).unwrap();
|
|
|
|
// Debug output should show converted value (in scientific notation or decimal)
|
|
let debug_str = format!("{:?}", params);
|
|
|
|
// Should show value close to 1e-5 (not the log value -11.5)
|
|
assert!(
|
|
debug_str.contains("e-5") || debug_str.contains("0.00001"),
|
|
"Debug output should show actual value ~1e-5, got: {}",
|
|
debug_str
|
|
);
|
|
|
|
// Should NOT contain raw log value
|
|
assert!(
|
|
!debug_str.contains("-11."),
|
|
"Debug output should NOT show raw log value -11.x"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_two_phase_optimizer_config() {
|
|
let optimizer = ArgminOptimizer::with_trials(30, 5);
|
|
assert_eq!(optimizer.max_trials, 30);
|
|
// Two-phase would run 15 + 15 trials
|
|
let phase_a = optimizer.max_trials / 2;
|
|
let phase_b = optimizer.max_trials - phase_a;
|
|
assert_eq!(phase_a, 15);
|
|
assert_eq!(phase_b, 15);
|
|
}
|
|
|
|
#[test]
|
|
fn test_two_phase_optimizer_odd_trials() {
|
|
let optimizer = ArgminOptimizer::with_trials(31, 5);
|
|
// Odd total: 15 + 16 trials
|
|
let phase_a = optimizer.max_trials / 2;
|
|
let phase_b = optimizer.max_trials - phase_a;
|
|
assert_eq!(phase_a, 15);
|
|
assert_eq!(phase_b, 16);
|
|
}
|
|
|
|
#[test]
|
|
fn test_two_phase_objective_trait_object_safety() {
|
|
// Verify TwoPhaseObjective can be used with the ObjectiveMode enum
|
|
use super::TwoPhaseObjective;
|
|
|
|
struct MockModel {
|
|
mode: ObjectiveMode,
|
|
}
|
|
|
|
impl TwoPhaseObjective for MockModel {
|
|
fn set_objective_mode(&mut self, mode: ObjectiveMode) {
|
|
self.mode = mode;
|
|
}
|
|
fn objective_mode(&self) -> ObjectiveMode {
|
|
self.mode
|
|
}
|
|
}
|
|
|
|
let mut model = MockModel {
|
|
mode: ObjectiveMode::Sharpe,
|
|
};
|
|
assert_eq!(model.objective_mode(), ObjectiveMode::Sharpe);
|
|
|
|
model.set_objective_mode(ObjectiveMode::EpisodeReward);
|
|
assert_eq!(model.objective_mode(), ObjectiveMode::EpisodeReward);
|
|
}
|
|
}
|