Files
foxhunt/crates/ml/tests/unit/early_stopping_unit_tests.rs
jgrusewski ca4c38d921 fix(tests): CI GPU test stability, walltime reduction, BF16 tolerance
- Reduce CI GPU test datasets 16x for walltime reduction
- Reduce early-stop epochs 50→10, add --test-threads=1
- Serialize all GPU lib tests to prevent cuBLAS init race
- Align state_dim to 16 for BF16 tensor core HMMA dispatch
- BF16 precision tolerance in ml-dqn tests
- Enable branching DQN + tracing subscriber in smoke tests
- Prevent min_replay_size > buffer_size deadlock in early-stop tests
- Prevent AutoReplaySizer from breaking gradient collapse warmup
- Replace racy tokio::spawn checkpoint counter with AtomicUsize
- Set warmup_steps=0 and max_training_steps_per_epoch=300 in early-stop tests
- RealDataLoader respects TEST_DATA_DIR for CI PVC layout
- Add collapse_warmup_capacity to gpu_smoketest DQNConfig
- Drain CUDA context between test binaries
- Detached HEAD checkout prevents local branch corruption
- GPU pipeline tests: fix BF16 dtype and rank-1 squeeze assertions
- OOD input handling tests use use_gpu: true

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 12:00:13 +01:00

587 lines
20 KiB
Rust

//! Unit Tests for Early Stopping Components
//!
//! This module contains comprehensive unit tests for early stopping functionality
//! across all ML adapters (PPO, TFT, MAMBA-2, DQN).
//!
//! Test Categories:
//! 1. Configuration Tests - Default values, custom configs, validation
//! 2. State Management Tests - Loss tracking, patience counters, history
//! 3. Strategy Tests - Plateau detection, pruning algorithms
//! 4. Edge Cases - Zero variance, NaN/Inf handling, boundary conditions
use std::collections::HashMap;
use tracing::info;
// ============================================================================
// TEST CATEGORY 1: CONFIGURATION TESTS
// ============================================================================
/// Test that default early stopping configuration values are sensible
#[test]
fn test_default_early_stopping_config() {
// DQN early stopping defaults
let dqn_config = crate::trainers::dqn::DQNHyperparameters::conservative();
assert!(dqn_config.early_stopping_enabled, "Early stopping should be enabled by default");
assert_eq!(dqn_config.min_epochs_before_stopping, 50, "Minimum epochs should be 50");
assert_eq!(dqn_config.plateau_window, 30, "Plateau window should be 30");
assert_eq!(dqn_config.q_value_floor, 0.5, "Q-value floor should be 0.5");
assert_eq!(dqn_config.min_loss_improvement_pct, 2.0, "Min loss improvement should be 2%");
// Verify minimum epochs prevents premature stopping
assert!(dqn_config.min_epochs_before_stopping >= 20,
"Must allow at least 20 epochs for model to warm up");
// Verify plateau window is reasonable
assert!(dqn_config.plateau_window >= 10 && dqn_config.plateau_window <= 50,
"Plateau window should be between 10-50 epochs");
}
/// Test that custom early stopping configurations validate correctly
#[test]
fn test_custom_early_stopping_config_validation() {
// Valid custom configuration
let custom_config = crate::trainers::dqn::DQNHyperparameters {
early_stopping_enabled: true,
min_epochs_before_stopping: 100,
plateau_window: 20,
q_value_floor: 0.3,
min_loss_improvement_pct: 1.0,
..Default::default()
};
assert_eq!(custom_config.min_epochs_before_stopping, 100);
assert_eq!(custom_config.plateau_window, 20);
assert_eq!(custom_config.q_value_floor, 0.3);
assert_eq!(custom_config.min_loss_improvement_pct, 1.0);
}
/// Test that early stopping can be disabled
#[test]
fn test_early_stopping_disable() {
let config = crate::trainers::dqn::DQNHyperparameters {
early_stopping_enabled: false,
..Default::default()
};
assert!(!config.early_stopping_enabled, "Early stopping should be disabled");
}
/// Test configuration boundaries and constraints
#[test]
fn test_early_stopping_config_boundaries() {
// Test minimum values
let min_config = crate::trainers::dqn::DQNHyperparameters {
min_epochs_before_stopping: 1,
plateau_window: 1,
q_value_floor: 0.0,
min_loss_improvement_pct: 0.0,
..Default::default()
};
assert_eq!(min_config.min_epochs_before_stopping, 1);
assert_eq!(min_config.plateau_window, 1);
assert_eq!(min_config.q_value_floor, 0.0);
assert_eq!(min_config.min_loss_improvement_pct, 0.0);
// Test maximum values
let max_config = crate::trainers::dqn::DQNHyperparameters {
min_epochs_before_stopping: 500,
plateau_window: 100,
q_value_floor: 10.0,
min_loss_improvement_pct: 50.0,
..Default::default()
};
assert_eq!(max_config.min_epochs_before_stopping, 500);
assert_eq!(max_config.plateau_window, 100);
assert_eq!(max_config.q_value_floor, 10.0);
assert_eq!(max_config.min_loss_improvement_pct, 50.0);
}
// ============================================================================
// TEST CATEGORY 2: STATE MANAGEMENT TESTS
// ============================================================================
/// Test loss history tracking
#[test]
fn test_loss_history_tracking() {
let losses = vec![1.0, 0.9, 0.8, 0.7, 0.6, 0.5];
// Verify history length
assert_eq!(losses.len(), 6);
// Verify monotonic decrease (ideal case)
for i in 1..losses.len() {
assert!(losses[i] < losses[i-1], "Loss should decrease over time");
}
// Calculate rolling average (window=3)
let window_size = 3;
let recent_avg: f64 = losses[losses.len()-window_size..].iter().sum::<f64>() / window_size as f64;
let older_avg: f64 = losses[losses.len()-2*window_size..losses.len()-window_size].iter().sum::<f64>() / window_size as f64;
assert!(recent_avg < older_avg, "Recent average should be lower than older average");
}
/// Test loss history with noise
#[test]
fn test_loss_history_with_noise() {
// Realistic loss history with noise
let losses = vec![
1.0, 0.95, 1.02, 0.90, 0.88, 0.93, // Early training: high variance
0.85, 0.82, 0.84, 0.80, 0.78, 0.81, // Mid training: decreasing with noise
0.75, 0.74, 0.76, 0.73, 0.74, 0.75, // Late training: plateau with noise
];
// Calculate trend (simple linear regression)
let n = losses.len() as f64;
let sum_x: f64 = (0..losses.len()).map(|i| i as f64).sum();
let sum_y: f64 = losses.iter().sum();
let sum_xy: f64 = losses.iter().enumerate().map(|(i, &y)| i as f64 * y).sum();
let sum_x2: f64 = (0..losses.len()).map(|i| (i * i) as f64).sum();
let slope = (n * sum_xy - sum_x * sum_y) / (n * sum_x2 - sum_x * sum_x);
// Overall trend should be decreasing (negative slope)
assert!(slope < 0.0, "Overall trend should be decreasing despite noise");
// Check variance in different phases
let early_variance = calculate_variance(&losses[0..6]);
let late_variance = calculate_variance(&losses[12..18]);
// Early training should have higher variance
assert!(early_variance > late_variance * 0.5,
"Early training variance should be higher");
}
fn calculate_variance(values: &[f64]) -> f64 {
let mean = values.iter().sum::<f64>() / values.len() as f64;
let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64;
variance
}
/// Test patience counter increment and reset
#[test]
fn test_patience_counter_behavior() {
struct PatienceState {
counter: usize,
best_loss: f64,
patience_limit: usize,
}
impl PatienceState {
fn new(patience: usize) -> Self {
Self {
counter: 0,
best_loss: f64::INFINITY,
patience_limit: patience,
}
}
fn update(&mut self, loss: f64) -> bool {
if loss < self.best_loss {
// Improvement: reset counter
self.best_loss = loss;
self.counter = 0;
false
} else {
// No improvement: increment counter
self.counter += 1;
self.counter >= self.patience_limit
}
}
}
let mut state = PatienceState::new(3);
// Test improvement resets counter
assert!(!state.update(1.0)); // Best: 1.0, counter: 0
assert!(!state.update(0.9)); // Best: 0.9, counter: 0 (reset)
assert!(!state.update(0.8)); // Best: 0.8, counter: 0 (reset)
// Test no improvement increments counter
assert!(!state.update(0.9)); // No improvement, counter: 1
assert!(!state.update(1.0)); // No improvement, counter: 2
assert!(state.update(1.1)); // No improvement, counter: 3 -> trigger stop
// Test partial improvement
let mut state2 = PatienceState::new(5);
assert!(!state2.update(1.0)); // Best: 1.0
assert!(!state2.update(1.1)); // Counter: 1
assert!(!state2.update(1.2)); // Counter: 2
assert!(!state2.update(0.95)); // Best: 0.95, counter: 0 (reset)
assert!(!state2.update(1.0)); // Counter: 1
assert!(!state2.update(1.0)); // Counter: 2
assert!(!state2.update(1.0)); // Counter: 3
assert!(!state2.update(1.0)); // Counter: 4
assert!(state2.update(1.0)); // Counter: 5 -> trigger stop
}
/// Test best loss tracking with floating point precision
#[test]
fn test_best_loss_tracking_precision() {
let mut best_loss = f64::INFINITY;
let epsilon = 1e-10;
// Test significant improvement
let new_loss_1 = 1.0;
assert!(new_loss_1 < best_loss);
best_loss = new_loss_1;
// Test tiny improvement (below epsilon)
let new_loss_2 = best_loss - epsilon / 10.0;
assert!(new_loss_2 < best_loss, "Even tiny improvements should be detected");
best_loss = new_loss_2;
// Test no improvement (within epsilon)
let new_loss_3 = best_loss + epsilon / 10.0;
assert!(new_loss_3 > best_loss, "Tiny increases should be detected");
}
/// Test epoch history storage
#[test]
fn test_epoch_history_storage() {
#[derive(Debug, Clone)]
struct EpochHistory {
epoch: usize,
train_loss: f64,
val_loss: f64,
metrics: HashMap<String, f64>,
}
let mut history = Vec::new();
// Simulate 10 epochs
for epoch in 0..10 {
let mut metrics = HashMap::new();
metrics.insert("q_value".to_string(), 0.5 + epoch as f64 * 0.05);
metrics.insert("grad_norm".to_string(), 1.0 - epoch as f64 * 0.05);
history.push(EpochHistory {
epoch,
train_loss: 1.0 - epoch as f64 * 0.08,
val_loss: 1.05 - epoch as f64 * 0.09,
metrics,
});
}
// Verify history length
assert_eq!(history.len(), 10);
// Verify chronological order
for i in 1..history.len() {
assert!(history[i].epoch > history[i-1].epoch);
}
// Verify metrics are stored correctly
assert_eq!(history[5].metrics.get("q_value"), Some(&0.75));
assert!(history[5].metrics.contains_key("grad_norm"));
}
// ============================================================================
// TEST CATEGORY 3: STRATEGY TESTS
// ============================================================================
/// Test plateau detection algorithm accuracy
#[test]
fn test_plateau_detection_accuracy() {
// Case 1: Clear plateau (constant loss)
let plateau_losses = vec![0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5];
assert!(is_plateau(&plateau_losses, 4, 0.1), "Should detect constant loss as plateau");
// Case 2: Slight improvement (not a plateau)
let improving_losses = vec![0.5, 0.48, 0.46, 0.44, 0.42, 0.40, 0.38, 0.36];
assert!(!is_plateau(&improving_losses, 4, 2.0), "Should not detect improving loss as plateau");
// Case 3: Noisy plateau (small oscillations around mean)
let noisy_plateau = vec![0.50, 0.51, 0.49, 0.50, 0.51, 0.49, 0.50, 0.51];
assert!(is_plateau(&noisy_plateau, 4, 2.0), "Should detect noisy plateau");
// Case 4: Degrading performance (getting worse)
let degrading_losses = vec![0.5, 0.52, 0.54, 0.56, 0.58, 0.60, 0.62, 0.64];
assert!(is_plateau(&degrading_losses, 4, 2.0), "Should detect degrading performance as plateau (no improvement)");
}
fn is_plateau(losses: &[f64], window: usize, min_improvement_pct: f64) -> bool {
if losses.len() < window * 2 {
return false;
}
let recent_avg = losses[losses.len()-window..].iter().sum::<f64>() / window as f64;
let older_avg = losses[losses.len()-2*window..losses.len()-window].iter().sum::<f64>() / window as f64;
let improvement_pct = if older_avg > 0.0 {
(older_avg - recent_avg) / older_avg * 100.0
} else {
0.0
};
improvement_pct < min_improvement_pct
}
/// Test median pruner correctness
#[test]
fn test_median_pruner_strategy() {
// Simulate 5 concurrent trials
let trial_losses = vec![
vec![1.0, 0.9, 0.8, 0.7], // Trial 0: Good
vec![1.0, 0.95, 0.92, 0.90], // Trial 1: Below median
vec![1.0, 0.85, 0.75, 0.65], // Trial 2: Best
vec![1.0, 0.98, 0.97, 0.96], // Trial 3: Worst
vec![1.0, 0.93, 0.88, 0.83], // Trial 4: Median
];
// At epoch 3, calculate median loss
let epoch_3_losses: Vec<f64> = trial_losses.iter().map(|t| t[3]).collect();
let mut sorted_losses = epoch_3_losses.clone();
sorted_losses.sort_by(|a, b| a.partial_cmp(b).unwrap());
let median = sorted_losses[sorted_losses.len() / 2];
assert_eq!(median, 0.83, "Median should be trial 4's loss");
// Trials below median should be pruned
for (i, trial) in trial_losses.iter().enumerate() {
let loss = trial[3];
if loss > median {
// Trial 1 (0.90) and Trial 3 (0.96) should be pruned
assert!(i == 1 || i == 3, "Trial {} with loss {} should be pruned", i, loss);
}
}
}
/// Test percentile pruner correctness
#[test]
fn test_percentile_pruner_strategy() {
// Simulate 10 trials
let trial_losses = vec![
0.95, 0.90, 0.85, 0.80, 0.75, 0.70, 0.65, 0.60, 0.55, 0.50
];
// Keep top 50% (prune bottom 50%)
let percentile = 50;
let cutoff_index = trial_losses.len() * percentile / 100;
let mut sorted = trial_losses.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let cutoff_value = sorted[cutoff_index];
// Trials with loss > 0.75 should be pruned
let pruned: Vec<f64> = trial_losses.iter().filter(|&&l| l > cutoff_value).copied().collect();
let kept: Vec<f64> = trial_losses.iter().filter(|&&l| l <= cutoff_value).copied().collect();
assert_eq!(kept.len(), 5, "Should keep top 50%");
assert_eq!(pruned.len(), 5, "Should prune bottom 50%");
}
/// Test successive halving algorithm
#[test]
fn test_successive_halving_strategy() {
let initial_trials = 16;
let min_trials = 1;
let epochs_per_rung = 10;
// Successive halving schedule
let mut trials_remaining = initial_trials;
let mut rung = 0;
let mut schedule = Vec::new();
while trials_remaining > min_trials {
schedule.push((rung, trials_remaining, rung * epochs_per_rung));
trials_remaining /= 2;
rung += 1;
}
schedule.push((rung, trials_remaining, rung * epochs_per_rung));
// Verify schedule
assert_eq!(schedule.len(), 5, "Should have 5 rungs");
assert_eq!(schedule[0], (0, 16, 0)); // Rung 0: 16 trials, 0 epochs
assert_eq!(schedule[1], (1, 8, 10)); // Rung 1: 8 trials, 10 epochs
assert_eq!(schedule[2], (2, 4, 20)); // Rung 2: 4 trials, 20 epochs
assert_eq!(schedule[3], (3, 2, 30)); // Rung 3: 2 trials, 30 epochs
assert_eq!(schedule[4], (4, 1, 40)); // Rung 4: 1 trial, 40 epochs
}
/// Test hyperband bracket logic
#[test]
fn test_hyperband_bracket_logic() {
let max_iter = 81; // Maximum epochs per trial
let eta = 3; // Reduction factor
// Calculate number of brackets (s_max + 1)
let s_max = (max_iter as f64).log(eta as f64).floor() as usize;
assert_eq!(s_max, 4, "Should have 5 brackets (s_max=4)");
// For each bracket, calculate resource allocation
for s in (0..=s_max).rev() {
let n = ((s_max + 1) as f64 / (s + 1) as f64 * eta.pow(s as u32) as f64).ceil() as usize;
let r = max_iter / eta.pow(s as u32) as usize;
info!(bracket = s, trials = n, epochs_per_trial = r, "Bracket resource allocation");
// Verify reasonable resource allocation
assert!(n >= 1, "Should have at least 1 trial");
assert!(r >= 1, "Should have at least 1 epoch");
assert!(n * r <= max_iter * eta.pow(s_max as u32), "Total resources should be bounded");
}
}
// ============================================================================
// TEST CATEGORY 4: EDGE CASES
// ============================================================================
/// Test early stopping with zero variance data (constant loss)
#[test]
fn test_early_stopping_zero_variance() {
let constant_losses = vec![0.5; 100]; // 100 epochs, all same loss
let window = 10;
let min_improvement = 0.1; // 0.1% improvement required
// Should detect plateau immediately after 2*window epochs
assert!(constant_losses.len() >= window * 2);
assert!(is_plateau(&constant_losses[..window*2], window, min_improvement));
}
/// Test early stopping with NaN loss
#[test]
fn test_early_stopping_nan_loss() {
let losses = vec![1.0, 0.9, 0.8, f64::NAN, 0.6];
// Check if NaN is present
let has_nan = losses.iter().any(|&l| l.is_nan());
assert!(has_nan, "Should detect NaN in loss history");
// NaN should cause immediate failure (not counted as improvement)
for &loss in &losses {
if loss.is_nan() {
// In production, this would trigger trial failure
assert!(loss.is_nan());
}
}
}
/// Test early stopping with Inf loss
#[test]
fn test_early_stopping_inf_loss() {
let losses = vec![1.0, 0.9, 0.8, f64::INFINITY, 0.6];
// Check if Inf is present
let has_inf = losses.iter().any(|&l| l.is_infinite());
assert!(has_inf, "Should detect Inf in loss history");
// Inf should cause immediate failure
for &loss in &losses {
if loss.is_infinite() {
assert!(loss.is_infinite());
}
}
}
/// Test early stopping with single epoch training
#[test]
fn test_early_stopping_single_epoch() {
let config = crate::trainers::dqn::DQNHyperparameters {
epochs: 1,
min_epochs_before_stopping: 50,
..Default::default()
};
// Should not trigger early stopping (epochs < min_epochs)
assert!(config.epochs < config.min_epochs_before_stopping);
}
/// Test early stopping with very small improvements
#[test]
fn test_early_stopping_tiny_improvements() {
let epsilon = 1e-8;
let losses = vec![
1.0,
1.0 - epsilon,
1.0 - 2.0 * epsilon,
1.0 - 3.0 * epsilon,
1.0 - 4.0 * epsilon,
];
// Calculate improvement percentage
let window = 2;
let recent_avg = losses[losses.len()-window..].iter().sum::<f64>() / window as f64;
let older_avg = losses[losses.len()-2*window..losses.len()-window].iter().sum::<f64>() / window as f64;
let improvement_pct = ((older_avg - recent_avg) / older_avg * 100.0).abs();
// Improvement should be very small (< 0.001%)
assert!(improvement_pct < 0.001, "Improvement should be negligible");
}
/// Test early stopping with large batch size edge case
#[test]
fn test_early_stopping_large_batch_size() {
// Test that batch size doesn't affect early stopping logic
let config_small_batch = crate::trainers::dqn::DQNHyperparameters {
batch_size: 32,
..Default::default()
};
let config_large_batch = crate::trainers::dqn::DQNHyperparameters {
batch_size: 230, // Max for RTX 3050 Ti
..Default::default()
};
// Early stopping parameters should be independent of batch size
assert_eq!(config_small_batch.min_epochs_before_stopping,
config_large_batch.min_epochs_before_stopping);
assert_eq!(config_small_batch.plateau_window,
config_large_batch.plateau_window);
}
/// Test early stopping with extreme learning rates
#[test]
fn test_early_stopping_extreme_learning_rates() {
// Very low learning rate (slow convergence)
let config_low_lr = crate::trainers::dqn::DQNHyperparameters {
learning_rate: 1e-6,
..Default::default()
};
// Very high learning rate (unstable training)
let config_high_lr = crate::trainers::dqn::DQNHyperparameters {
learning_rate: 1e-1,
..Default::default()
};
// Early stopping should be same regardless of LR
assert_eq!(config_low_lr.plateau_window, config_high_lr.plateau_window);
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/// Helper: Calculate improvement percentage between two values
fn calculate_improvement_pct(old_value: f64, new_value: f64) -> f64 {
if old_value > 0.0 {
(old_value - new_value) / old_value * 100.0
} else {
0.0
}
}
/// Helper: Check if value is valid (not NaN or Inf)
fn is_valid_loss(loss: f64) -> bool {
!loss.is_nan() && !loss.is_infinite()
}
#[test]
fn test_helpers() {
assert_eq!(calculate_improvement_pct(1.0, 0.9), 10.0);
assert_eq!(calculate_improvement_pct(1.0, 1.1), -10.0);
assert_eq!(calculate_improvement_pct(0.0, 0.5), 0.0);
assert!(is_valid_loss(1.0));
assert!(is_valid_loss(0.0));
assert!(!is_valid_loss(f64::NAN));
assert!(!is_valid_loss(f64::INFINITY));
}