test_optimization_rosenbrock and test_optimization_deterministic were marked #[ignore] with no valid reason. They complete in <50ms. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
752 lines
25 KiB
Rust
752 lines
25 KiB
Rust
//! Comprehensive Tests for Argmin-based Hyperparameter Optimization
|
|
//!
|
|
//! This module provides production-ready test coverage for the argmin-based
|
|
//! hyperparameter optimization framework, replacing the deprecated egobox tests.
|
|
//!
|
|
//! ## Test Coverage
|
|
//!
|
|
//! - **Optimizer Initialization**: Builder pattern, configuration validation
|
|
//! - **Parameter Space**: MAMBA-2, DQN, PPO, TFT parameter validation
|
|
//! - **Latin Hypercube Sampling**: Stratification, bounds checking
|
|
//! - **Nelder-Mead Optimization**: Convergence, multi-restart behavior
|
|
//! - **Error Handling**: Invalid parameters, training failures, edge cases
|
|
//! - **Integration**: End-to-end optimization runs with test models
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::super::optimizer::ArgminOptimizer;
|
|
use super::super::traits::{HyperparameterOptimizable, ParameterSpace};
|
|
use crate::MLError;
|
|
use approx::assert_relative_eq;
|
|
use tracing::info;
|
|
|
|
use rand::SeedableRng;
|
|
use rand_chacha::ChaCha8Rng;
|
|
|
|
// ============================================================================
|
|
// TEST MODELS
|
|
// ============================================================================
|
|
|
|
/// Simple 2D test function: sphere function
|
|
/// Minimum at (0, 0) with value 0
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
struct SphereParams {
|
|
x: f64,
|
|
y: f64,
|
|
}
|
|
|
|
impl ParameterSpace for SphereParams {
|
|
fn continuous_bounds() -> Vec<(f64, f64)> {
|
|
vec![(-5.0, 5.0), (-5.0, 5.0)]
|
|
}
|
|
|
|
fn from_continuous(x: &[f64]) -> Result<Self, MLError> {
|
|
if x.len() != 2 {
|
|
return Err(MLError::ConfigError(format!("Expected 2 params, got {}", x.len())));
|
|
}
|
|
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 SphereMetrics {
|
|
loss: f64,
|
|
}
|
|
|
|
struct SphereModel;
|
|
|
|
impl HyperparameterOptimizable for SphereModel {
|
|
type Params = SphereParams;
|
|
type Metrics = SphereMetrics;
|
|
|
|
fn train_with_params(&mut self, params: Self::Params) -> Result<Self::Metrics, MLError> {
|
|
let loss = params.x.powi(2) + params.y.powi(2);
|
|
Ok(SphereMetrics { loss })
|
|
}
|
|
|
|
fn extract_objective(metrics: &Self::Metrics) -> f64 {
|
|
metrics.loss
|
|
}
|
|
}
|
|
|
|
/// Rosenbrock function: challenging optimization problem
|
|
/// Minimum at (1, 1) with value 0
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
struct RosenbrockParams {
|
|
x: f64,
|
|
y: f64,
|
|
}
|
|
|
|
impl ParameterSpace for RosenbrockParams {
|
|
fn continuous_bounds() -> Vec<(f64, f64)> {
|
|
vec![(-2.0, 2.0), (-1.0, 3.0)]
|
|
}
|
|
|
|
fn from_continuous(x: &[f64]) -> Result<Self, MLError> {
|
|
if x.len() != 2 {
|
|
return Err(MLError::ConfigError(format!("Expected 2 params, got {}", x.len())));
|
|
}
|
|
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 RosenbrockMetrics {
|
|
loss: f64,
|
|
}
|
|
|
|
struct RosenbrockModel;
|
|
|
|
impl HyperparameterOptimizable for RosenbrockModel {
|
|
type Params = RosenbrockParams;
|
|
type Metrics = RosenbrockMetrics;
|
|
|
|
fn train_with_params(&mut self, params: Self::Params) -> Result<Self::Metrics, MLError> {
|
|
let loss = (1.0 - params.x).powi(2) + 100.0 * (params.y - params.x.powi(2)).powi(2);
|
|
Ok(RosenbrockMetrics { loss })
|
|
}
|
|
|
|
fn extract_objective(metrics: &Self::Metrics) -> f64 {
|
|
metrics.loss
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// OPTIMIZER INITIALIZATION TESTS
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_optimizer_default() {
|
|
let optimizer = ArgminOptimizer::new();
|
|
assert_eq!(optimizer.max_trials, 30);
|
|
assert_eq!(optimizer.n_initial, 5);
|
|
assert_eq!(optimizer.seed, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_optimizer_with_trials() {
|
|
let optimizer = ArgminOptimizer::with_trials(50, 10);
|
|
assert_eq!(optimizer.max_trials, 50);
|
|
assert_eq!(optimizer.n_initial, 10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_optimizer_with_seed() {
|
|
let optimizer = ArgminOptimizer::new().with_seed(42);
|
|
assert_eq!(optimizer.seed, Some(42));
|
|
}
|
|
|
|
#[test]
|
|
fn test_optimizer_builder() {
|
|
let optimizer = ArgminOptimizer::builder()
|
|
.max_trials(20)
|
|
.n_initial(3)
|
|
.seed(12345)
|
|
.max_iters_per_restart(100)
|
|
.build();
|
|
|
|
assert_eq!(optimizer.max_trials, 20);
|
|
assert_eq!(optimizer.n_initial, 3);
|
|
assert_eq!(optimizer.seed, Some(12345));
|
|
assert_eq!(optimizer.max_iters_per_restart, 100);
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "max_trials must be > n_initial")]
|
|
fn test_optimizer_invalid_trials() {
|
|
ArgminOptimizer::with_trials(5, 10);
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "n_initial must be > 0")]
|
|
fn test_optimizer_zero_initial() {
|
|
ArgminOptimizer::with_trials(10, 0);
|
|
}
|
|
|
|
// ============================================================================
|
|
// LATIN HYPERCUBE SAMPLING TESTS
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_lhs_basic() {
|
|
let mut rng = ChaCha8Rng::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);
|
|
}
|
|
|
|
#[test]
|
|
fn test_lhs_bounds_respected() {
|
|
let mut rng = ChaCha8Rng::seed_from_u64(42);
|
|
let bounds = vec![(-5.0, 5.0), (0.0, 1.0), (10.0, 20.0)];
|
|
let samples = ArgminOptimizer::latin_hypercube_sampling(20, &bounds, &mut rng);
|
|
|
|
for i in 0..20 {
|
|
assert!(
|
|
samples[[i, 0]] >= -5.0 && samples[[i, 0]] <= 5.0,
|
|
"Sample {} dim 0 out of bounds: {}",
|
|
i,
|
|
samples[[i, 0]]
|
|
);
|
|
assert!(
|
|
samples[[i, 1]] >= 0.0 && samples[[i, 1]] <= 1.0,
|
|
"Sample {} dim 1 out of bounds: {}",
|
|
i,
|
|
samples[[i, 1]]
|
|
);
|
|
assert!(
|
|
samples[[i, 2]] >= 10.0 && samples[[i, 2]] <= 20.0,
|
|
"Sample {} dim 2 out of bounds: {}",
|
|
i,
|
|
samples[[i, 2]]
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_lhs_stratification() {
|
|
let mut rng = ChaCha8Rng::seed_from_u64(42);
|
|
let bounds = vec![(0.0, 10.0)];
|
|
let n_samples = 10;
|
|
let samples = ArgminOptimizer::latin_hypercube_sampling(n_samples, &bounds, &mut rng);
|
|
|
|
// Check that each stratum has exactly one sample
|
|
let segment_size = 10.0 / n_samples as f64;
|
|
let mut strata_filled = vec![false; n_samples];
|
|
|
|
for i in 0..n_samples {
|
|
let value = samples[[i, 0]];
|
|
let stratum = (value / segment_size).floor() as usize;
|
|
assert!(
|
|
stratum < n_samples,
|
|
"Sample {} value {} in invalid stratum {}",
|
|
i,
|
|
value,
|
|
stratum
|
|
);
|
|
strata_filled[stratum] = true;
|
|
}
|
|
|
|
// All strata should be filled
|
|
assert!(
|
|
strata_filled.iter().all(|&filled| filled),
|
|
"Not all strata filled: {:?}",
|
|
strata_filled
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_lhs_deterministic_with_seed() {
|
|
let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
|
|
|
|
let mut rng1 = ChaCha8Rng::seed_from_u64(42);
|
|
let samples1 = ArgminOptimizer::latin_hypercube_sampling(10, &bounds, &mut rng1);
|
|
|
|
let mut rng2 = ChaCha8Rng::seed_from_u64(42);
|
|
let samples2 = ArgminOptimizer::latin_hypercube_sampling(10, &bounds, &mut rng2);
|
|
|
|
for i in 0..10 {
|
|
for j in 0..2 {
|
|
assert_relative_eq!(samples1[[i, j]], samples2[[i, j]], epsilon = 1e-10);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// PARAMETER SPACE TESTS - MAMBA-2
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_mamba2_params_roundtrip() {
|
|
use crate::hyperopt::adapters::mamba2::Mamba2Params;
|
|
|
|
let params = Mamba2Params {
|
|
learning_rate: 0.001,
|
|
batch_size: 64,
|
|
dropout: 0.2,
|
|
weight_decay: 0.0001,
|
|
grad_clip: 1.0,
|
|
warmup_steps: 100,
|
|
adam_beta1: 0.9,
|
|
adam_beta2: 0.999,
|
|
adam_epsilon: 1e-8,
|
|
lookback_window: 60,
|
|
sequence_stride: 1,
|
|
norm_eps: 1e-5,
|
|
signal_high_bps: 10.0,
|
|
signal_low_bps: 5.0,
|
|
};
|
|
|
|
let continuous = params.to_continuous();
|
|
let recovered = Mamba2Params::from_continuous(&continuous).unwrap();
|
|
|
|
assert_relative_eq!(
|
|
recovered.learning_rate,
|
|
params.learning_rate,
|
|
epsilon = 1e-10
|
|
);
|
|
assert_eq!(recovered.batch_size, params.batch_size);
|
|
assert_relative_eq!(recovered.dropout, params.dropout, epsilon = 1e-10);
|
|
assert_relative_eq!(recovered.weight_decay, params.weight_decay, epsilon = 1e-10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mamba2_params_bounds() {
|
|
use crate::hyperopt::adapters::mamba2::Mamba2Params;
|
|
|
|
let bounds = Mamba2Params::continuous_bounds();
|
|
assert_eq!(bounds.len(), 14); // 12 base + signal_high_bps + signal_low_bps
|
|
|
|
// Learning rate (log scale)
|
|
assert!(bounds[0].0 < bounds[0].1);
|
|
let lr_min = bounds[0].0.exp();
|
|
let lr_max = bounds[0].1.exp();
|
|
assert_relative_eq!(lr_min, 1e-5, epsilon = 1e-10);
|
|
assert_relative_eq!(lr_max, 1e-2, epsilon = 1e-10);
|
|
|
|
// Batch size (linear) - wide bounds for optimizer exploration (VRAM-capped at runtime)
|
|
assert_eq!(bounds[1], (4.0, 1024.0));
|
|
|
|
// Dropout (linear)
|
|
assert_eq!(bounds[2], (0.0, 0.5));
|
|
|
|
// Weight decay (log scale)
|
|
assert!(bounds[3].0 < bounds[3].1);
|
|
let wd_min = bounds[3].0.exp();
|
|
let wd_max = bounds[3].1.exp();
|
|
assert_relative_eq!(wd_min, 1e-6, epsilon = 1e-10);
|
|
assert_relative_eq!(wd_max, 1e-2, epsilon = 1e-10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mamba2_params_invalid_length() {
|
|
use crate::hyperopt::adapters::mamba2::Mamba2Params;
|
|
|
|
let result = Mamba2Params::from_continuous(&[1.0]);
|
|
assert!(result.is_err());
|
|
assert!(result
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("Expected 14 parameters"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_mamba2_params_names() {
|
|
use crate::hyperopt::adapters::mamba2::Mamba2Params;
|
|
|
|
let names = Mamba2Params::param_names();
|
|
assert_eq!(names.len(), 14); // 12 base + signal_high_bps + signal_low_bps
|
|
assert_eq!(names[0], "learning_rate");
|
|
assert_eq!(names[1], "batch_size");
|
|
assert_eq!(names[2], "dropout");
|
|
assert_eq!(names[3], "weight_decay");
|
|
assert_eq!(names[4], "grad_clip");
|
|
assert_eq!(names[5], "warmup_steps");
|
|
assert_eq!(names[6], "adam_beta1");
|
|
assert_eq!(names[7], "adam_beta2");
|
|
assert_eq!(names[8], "adam_epsilon");
|
|
assert_eq!(names[9], "lookback_window");
|
|
assert_eq!(names[10], "sequence_stride");
|
|
assert_eq!(names[11], "norm_eps");
|
|
assert_eq!(names[12], "signal_high_bps");
|
|
assert_eq!(names[13], "signal_low_bps");
|
|
}
|
|
|
|
#[test]
|
|
fn test_mamba2_params_batch_size_clamping() {
|
|
use crate::hyperopt::adapters::mamba2::Mamba2Params;
|
|
|
|
// Test batch_size clamped to at least 1
|
|
let continuous = vec![
|
|
0.001_f64.ln(), // learning_rate (log scale)
|
|
0.0, // batch_size (would round to 0, should clamp to 1)
|
|
0.1, // dropout
|
|
0.0001_f64.ln(), // weight_decay (log scale)
|
|
1.0, // grad_clip (log scale)
|
|
500.0, // warmup_steps
|
|
0.9, // adam_beta1
|
|
0.999, // adam_beta2
|
|
-18.0, // adam_epsilon (log scale)
|
|
60.0, // lookback_window
|
|
1.0, // sequence_stride
|
|
-11.5, // norm_eps (log scale)
|
|
10.0, // signal_high_bps
|
|
5.0, // signal_low_bps
|
|
];
|
|
|
|
let params = Mamba2Params::from_continuous(&continuous).unwrap();
|
|
assert!(params.batch_size >= 1, "Batch size should be at least 1");
|
|
}
|
|
|
|
#[test]
|
|
fn test_mamba2_params_dropout_clamping() {
|
|
use crate::hyperopt::adapters::mamba2::Mamba2Params;
|
|
|
|
// Test dropout clamped to [0.0, 0.5]
|
|
let continuous = vec![
|
|
0.001_f64.ln(), // learning_rate (log scale)
|
|
32.0, // batch_size
|
|
-0.1, // dropout (negative, should clamp to 0.0)
|
|
0.0001_f64.ln(), // weight_decay (log scale)
|
|
1.0, // grad_clip (log scale)
|
|
500.0, // warmup_steps
|
|
0.9, // adam_beta1
|
|
0.999, // adam_beta2
|
|
-18.0, // adam_epsilon (log scale)
|
|
60.0, // lookback_window
|
|
1.0, // sequence_stride
|
|
-11.5, // norm_eps (log scale)
|
|
10.0, // signal_high_bps
|
|
5.0, // signal_low_bps
|
|
];
|
|
|
|
let params = Mamba2Params::from_continuous(&continuous).unwrap();
|
|
assert_eq!(params.dropout, 0.0);
|
|
|
|
let continuous2 = vec![
|
|
0.001_f64.ln(), // learning_rate (log scale)
|
|
32.0, // batch_size
|
|
0.8, // dropout (> 0.5, should clamp to 0.5)
|
|
0.0001_f64.ln(), // weight_decay (log scale)
|
|
1.0, // grad_clip (log scale)
|
|
500.0, // warmup_steps
|
|
0.9, // adam_beta1
|
|
0.999, // adam_beta2
|
|
-18.0, // adam_epsilon (log scale)
|
|
60.0, // lookback_window
|
|
1.0, // sequence_stride
|
|
-11.5, // norm_eps (log scale)
|
|
10.0, // signal_high_bps
|
|
5.0, // signal_low_bps
|
|
];
|
|
|
|
let params2 = Mamba2Params::from_continuous(&continuous2).unwrap();
|
|
assert_eq!(params2.dropout, 0.5);
|
|
}
|
|
|
|
// ============================================================================
|
|
// ERROR HANDLING TESTS
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_optimize_zero_dimensions() {
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
struct ZeroDimParams;
|
|
|
|
impl ParameterSpace for ZeroDimParams {
|
|
fn continuous_bounds() -> Vec<(f64, f64)> {
|
|
vec![] // Zero dimensions
|
|
}
|
|
|
|
fn from_continuous(_x: &[f64]) -> Result<Self, MLError> {
|
|
Ok(ZeroDimParams)
|
|
}
|
|
|
|
fn to_continuous(&self) -> Vec<f64> {
|
|
vec![]
|
|
}
|
|
|
|
fn param_names() -> Vec<&'static str> {
|
|
vec![]
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct ZeroDimMetrics {
|
|
loss: f64,
|
|
}
|
|
|
|
struct ZeroDimModel;
|
|
|
|
impl HyperparameterOptimizable for ZeroDimModel {
|
|
type Params = ZeroDimParams;
|
|
type Metrics = ZeroDimMetrics;
|
|
|
|
fn train_with_params(
|
|
&mut self,
|
|
_params: Self::Params,
|
|
) -> Result<Self::Metrics, MLError> {
|
|
Ok(ZeroDimMetrics { loss: 0.0 })
|
|
}
|
|
|
|
fn extract_objective(metrics: &Self::Metrics) -> f64 {
|
|
metrics.loss
|
|
}
|
|
}
|
|
|
|
let model = ZeroDimModel;
|
|
let optimizer = ArgminOptimizer::with_trials(10, 2);
|
|
let result = optimizer.optimize(model);
|
|
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().to_string().contains("zero dimensions"));
|
|
}
|
|
|
|
// ============================================================================
|
|
// OPTIMIZATION RUNS - SMALL SCALE
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_optimization_sphere_convergence() {
|
|
let model = SphereModel;
|
|
let optimizer = ArgminOptimizer::builder()
|
|
.max_trials(50)
|
|
.n_initial(5)
|
|
.seed(42)
|
|
.build();
|
|
|
|
let result = optimizer.optimize(model).unwrap();
|
|
|
|
// Should improve over initial samples
|
|
assert!(result.best_objective < result.all_trials[0].objective);
|
|
|
|
// Should find near-optimal solution for sphere function
|
|
// Relaxed threshold for PSO with only 50 trials (stochastic, can vary across runs)
|
|
assert!(
|
|
result.best_objective < 5.0,
|
|
"Expected sphere function to find near-optimal solution, got {}",
|
|
result.best_objective
|
|
);
|
|
|
|
// NOTE: all_trials contains ALL function evaluations (every PSO particle evaluation),
|
|
// not just optimization iterations, so we don't assert on count
|
|
// What matters is convergence to a good solution (tested above)
|
|
}
|
|
|
|
#[test]
|
|
fn test_optimization_rosenbrock() {
|
|
let model = RosenbrockModel;
|
|
let optimizer = ArgminOptimizer::builder()
|
|
.max_trials(30)
|
|
.n_initial(5)
|
|
.max_iters_per_restart(50)
|
|
.seed(42)
|
|
.build();
|
|
|
|
let result = optimizer.optimize(model).unwrap();
|
|
|
|
// Should improve over initial samples
|
|
assert!(result.best_objective < result.all_trials[0].objective);
|
|
|
|
// Rosenbrock is harder, but should still make progress
|
|
assert!(
|
|
result.best_objective < 50.0,
|
|
"Expected Rosenbrock to make significant progress, got {}",
|
|
result.best_objective
|
|
);
|
|
|
|
// Check improvement metrics
|
|
assert!(result.total_improvement() < 0.0); // Loss should decrease
|
|
assert!(result.improvement_percentage() > 0.0);
|
|
}
|
|
|
|
// NOTE: This test is ignored because ParticleSwarm optimization is inherently non-deterministic.
|
|
// The argmin::solver::particleswarm::ParticleSwarm::new() API does not support RNG seeding,
|
|
// so even with the same optimizer seed (which only affects Latin Hypercube initial sampling),
|
|
// the PSO solver uses an internal non-deterministic RNG, leading to vastly different results
|
|
// across runs. Observed variation: 20x difference (0.18 vs 0.009), far exceeding any reasonable
|
|
// epsilon tolerance. This is expected behavior for PSO and does not indicate a bug.
|
|
#[test]
|
|
fn test_optimization_deterministic() {
|
|
let model1 = SphereModel;
|
|
let optimizer1 = ArgminOptimizer::builder()
|
|
.max_trials(10)
|
|
.n_initial(3)
|
|
.seed(42)
|
|
.build();
|
|
let result1 = optimizer1.optimize(model1).unwrap();
|
|
|
|
let model2 = SphereModel;
|
|
let optimizer2 = ArgminOptimizer::builder()
|
|
.max_trials(10)
|
|
.n_initial(3)
|
|
.seed(42)
|
|
.build();
|
|
let result2 = optimizer2.optimize(model2).unwrap();
|
|
|
|
// Cannot assert determinism - PSO solver is non-deterministic even with seed
|
|
// Seed only affects LHS initial sampling, not PSO particle swarm behavior
|
|
info!(best_objective = result1.best_objective, "Result 1");
|
|
info!(best_objective = result2.best_objective, "Result 2");
|
|
}
|
|
|
|
// ============================================================================
|
|
// TRIAL HISTORY TESTS
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_trial_history_ordering() {
|
|
let model = SphereModel;
|
|
let optimizer = ArgminOptimizer::builder()
|
|
.max_trials(10)
|
|
.n_initial(3)
|
|
.seed(42)
|
|
.build();
|
|
|
|
let result = optimizer.optimize(model).unwrap();
|
|
|
|
// PSO optimizer may run more trials than max_trials due to swarm evaluations
|
|
// and trials may complete out of order due to parallel execution
|
|
assert!(
|
|
!result.all_trials.is_empty(),
|
|
"Should have at least some trials"
|
|
);
|
|
|
|
// Check that trial numbers are unique (no duplicates)
|
|
use std::collections::HashSet;
|
|
let trial_nums: HashSet<usize> = result.all_trials.iter().map(|t| t.trial_num).collect();
|
|
assert_eq!(
|
|
trial_nums.len(),
|
|
result.all_trials.len(),
|
|
"All trial numbers should be unique"
|
|
);
|
|
|
|
// Check that trial numbers start from 1
|
|
let min_trial = result.all_trials.iter().map(|t| t.trial_num).min().unwrap();
|
|
assert_eq!(min_trial, 1, "Trial numbers should start from 1");
|
|
}
|
|
|
|
#[test]
|
|
fn test_convergence_plot_data() {
|
|
let model = SphereModel;
|
|
let optimizer = ArgminOptimizer::builder()
|
|
.max_trials(10)
|
|
.n_initial(3)
|
|
.seed(42)
|
|
.build();
|
|
|
|
let result = optimizer.optimize(model).unwrap();
|
|
|
|
// Convergence data should have same length as trials
|
|
assert_eq!(result.convergence_plot_data.len(), result.all_trials.len());
|
|
|
|
// Best-so-far should be non-increasing
|
|
let mut prev_best = f64::INFINITY;
|
|
for &(trial_num, best_so_far) in &result.convergence_plot_data {
|
|
assert!(trial_num > 0);
|
|
assert!(best_so_far <= prev_best);
|
|
prev_best = best_so_far;
|
|
}
|
|
|
|
// Final convergence value should match best objective
|
|
assert_relative_eq!(
|
|
result.convergence_plot_data.last().unwrap().1,
|
|
result.best_objective,
|
|
epsilon = 1e-10
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// EDGE CASE TESTS
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_optimization_single_trial() {
|
|
let model = SphereModel;
|
|
let optimizer = ArgminOptimizer::builder()
|
|
.max_trials(2)
|
|
.n_initial(1)
|
|
.seed(42)
|
|
.build();
|
|
|
|
let result = optimizer.optimize(model).unwrap();
|
|
|
|
// Should complete successfully even with minimal trials
|
|
assert!(result.all_trials.len() >= 1);
|
|
assert!(result.best_objective.is_finite());
|
|
}
|
|
|
|
#[test]
|
|
fn test_optimization_many_dimensions() {
|
|
// Test with higher-dimensional problem
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
struct HighDimParams {
|
|
values: Vec<f64>,
|
|
}
|
|
|
|
impl ParameterSpace for HighDimParams {
|
|
fn continuous_bounds() -> Vec<(f64, f64)> {
|
|
vec![(-5.0, 5.0); 10] // 10 dimensions
|
|
}
|
|
|
|
fn from_continuous(x: &[f64]) -> Result<Self, MLError> {
|
|
Ok(Self { values: x.to_vec() })
|
|
}
|
|
|
|
fn to_continuous(&self) -> Vec<f64> {
|
|
self.values.clone()
|
|
}
|
|
|
|
fn param_names() -> Vec<&'static str> {
|
|
vec!["d0", "d1", "d2", "d3", "d4", "d5", "d6", "d7", "d8", "d9"]
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct HighDimMetrics {
|
|
loss: f64,
|
|
}
|
|
|
|
struct HighDimModel;
|
|
|
|
impl HyperparameterOptimizable for HighDimModel {
|
|
type Params = HighDimParams;
|
|
type Metrics = HighDimMetrics;
|
|
|
|
fn train_with_params(
|
|
&mut self,
|
|
params: Self::Params,
|
|
) -> Result<Self::Metrics, MLError> {
|
|
let loss: f64 = params.values.iter().map(|&x| x.powi(2)).sum();
|
|
Ok(HighDimMetrics { loss })
|
|
}
|
|
|
|
fn extract_objective(metrics: &Self::Metrics) -> f64 {
|
|
metrics.loss
|
|
}
|
|
}
|
|
|
|
let model = HighDimModel;
|
|
let max_trials = 15;
|
|
let n_initial = 5;
|
|
let optimizer = ArgminOptimizer::builder()
|
|
.max_trials(max_trials)
|
|
.n_initial(n_initial)
|
|
.seed(42)
|
|
.build();
|
|
|
|
let result = optimizer.optimize(model).unwrap();
|
|
|
|
// Should complete successfully
|
|
assert!(result.best_objective.is_finite());
|
|
assert!(result.best_objective >= 0.0);
|
|
|
|
// NOTE: all_trials contains ALL function evaluations (every PSO particle evaluation),
|
|
// not just optimization iterations. For high-dimensional problems with PSO swarms,
|
|
// this can be 100s of evaluations. What matters is finite, valid results.
|
|
}
|
|
|
|
// ============================================================================
|
|
// BACKWARD COMPATIBILITY TESTS
|
|
// ============================================================================
|
|
|
|
}
|