Files
foxhunt/crates/ml/src/hyperopt/tests_argmin.rs
jgrusewski 7e48497ddd fix: unignore 2 argmin optimization tests — they just run
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>
2026-03-18 17:14:12 +01:00

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
// ============================================================================
}