Files
foxhunt/ml/tests/hyperopt_early_stopping_termination_test.rs
jgrusewski 2c1acda2f3 feat: DQN Rainbow enhancements with hyperopt results and test coverage
- Update DQN trainer with gradient collapse detection warmup
- Add portfolio tracker improvements
- Include hyperopt trial results (multiple Sharpe ratio experiments)
- Add new test files for action/position sign convention, early stopping,
  cash reserve bugs, and portfolio execution
- Update trained model files
- Add Claude Code configuration and skills

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 14:45:25 +01:00

629 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Hyperopt Early Stopping Termination Tests
//!
//! **Objective**: Validate that early stopping ACTUALLY TERMINATES training when gradient collapse
//! is detected, not just logs warnings.
//!
//! **Bug Description**:
//! - Detection works: Gradient collapse warnings are logged correctly
//! - Termination BROKEN: Training continues to completion despite exhausted patience
//! - Expected: Training stops at epoch 3-5 (patience=3)
//! - Actual: Training runs to epoch 20 (max_epochs)
//!
//! **Root Cause Hypothesis**:
//! The error returned by `WorkingDQN::train_step()` at line 1704 is being caught/ignored
//! somewhere in the training loop instead of propagating up to terminate training.
//!
//! **Expected Behavior**:
//! 1. Gradient collapse detected for 3 consecutive epochs
//! 2. `MLError::TrainingError` returned from `train_step()`
//! 3. Error propagates through `train_epoch()` → `train_from_parquet()`
//! 4. Training terminates with error message
//! 5. Final epoch count: 3-5 (NOT 20)
//!
//! **Test Strategy**:
//! - Configure DQN with parameters GUARANTEED to cause gradient collapse:
//! - Very low learning rate (1e-8) → gradients near zero
//! - Narrow C51 bounds (v_min=-0.001, v_max=0.001) → value range collapse
//! - Small batch size (8) → high variance, unstable gradients
//! - Set gradient_collapse_patience = 3 (stop after 3 consecutive epochs)
//! - Run training for max 20 epochs
//! - Assert training stops at epoch 3-5 (NOT 20)
//!
//! **Current Status**: EXPECTED TO FAIL (bug exists)
//! **After Fix**: Should PASS (training terminates correctly)
use ml::dqn::{Experience, WorkingDQN, WorkingDQNConfig};
use ml::MLError;
/// Test 1: Verify that gradient collapse detection actually terminates training
///
/// **Expected**: Training stops at epoch 3-5 with "Gradient collapse detected" error
/// **Actual** (BEFORE FIX): Training runs to epoch 20, warnings logged but ignored
#[test]
fn test_hyperopt_early_stopping_actually_terminates() -> Result<(), MLError> {
println!("\n🧪 TEST: Hyperopt Early Stopping Termination");
println!("============================================");
println!("Objective: Verify training STOPS when gradient collapse detected");
println!();
// ========================================
// STEP 1: Configure DQN for Guaranteed Gradient Collapse
// ========================================
println!("📝 STEP 1: Configuring DQN with gradient collapse parameters");
let mut config = WorkingDQNConfig::conservative();
// Basic architecture
config.state_dim = 32;
config.num_actions = 3;
// CRITICAL: Parameters guaranteed to cause gradient collapse
config.learning_rate = 1e-8; // Extremely low → gradients near zero
config.batch_size = 8; // Small → high variance, unstable
// C51 Distributional RL with narrow bounds (causes value range collapse)
config.use_distributional = true;
config.v_min = -0.001; // Extremely narrow range
config.v_max = 0.001; // → distribution collapse → zero gradients
config.num_atoms = 51;
// Early stopping configuration
config.gradient_collapse_patience = 3; // Stop after 3 consecutive epochs
config.gradient_collapse_multiplier = 100.0; // Threshold = LR × 100 = 1e-6
// Replay buffer
config.replay_buffer_capacity = 500;
config.min_replay_size = 100;
config.warmup_steps = 0; // Disable warmup for fast testing
println!(" ✓ Learning rate: {:.2e} (extremely low → gradient collapse)", config.learning_rate);
println!(" ✓ Batch size: {} (small → unstable gradients)", config.batch_size);
println!(" ✓ C51 bounds: [{}, {}] (narrow → value collapse)", config.v_min, config.v_max);
println!(" ✓ Collapse patience: {} epochs", config.gradient_collapse_patience);
println!(" ✓ Collapse threshold: {:.2e}", config.learning_rate * config.gradient_collapse_multiplier);
println!();
// Save config values before moving
let threshold = config.learning_rate * config.gradient_collapse_multiplier;
// ========================================
// STEP 2: Initialize DQN and Fill Replay Buffer
// ========================================
println!("📝 STEP 2: Initializing DQN and filling replay buffer");
let mut dqn = WorkingDQN::new(config)?;
// Generate enough synthetic experiences to start training
let num_experiences = 200;
for i in 0..num_experiences {
let state = vec![0.01 * (i as f32); 32];
let next_state = vec![0.01 * (i as f32 + 1.0); 32];
let action = (i % 3) as u8;
let reward = if i % 10 == 0 { 0.1 } else { -0.01 };
let done = i % 50 == 49;
let experience = Experience::new(state, action, reward, next_state, done);
dqn.store_experience(experience)?;
}
println!(" ✓ Generated {} experiences", num_experiences);
println!(" ✓ Replay buffer size: {}", dqn.get_replay_buffer_size()?);
println!();
// ========================================
// STEP 3: Run Training and Monitor for Early Termination
// ========================================
println!("📝 STEP 3: Running training (max 20 epochs)");
println!("Expected: Training stops at epoch 3-5 (patience=3)");
println!();
let max_epochs = 20;
let mut epoch_count = 0;
let mut gradient_norms = Vec::new();
let mut losses = Vec::new();
let mut training_terminated_early = false;
let mut termination_error_message = String::new();
for epoch in 0..max_epochs {
epoch_count = epoch + 1;
// Run 10 training steps per epoch
let mut epoch_grad_norm_sum = 0.0;
let mut epoch_loss_sum = 0.0;
let steps_per_epoch = 10;
for step in 0..steps_per_epoch {
// CRITICAL: This should return an error after patience is exhausted
let train_result = dqn.train_step(None);
match train_result {
Ok((_loss, grad_norm)) => {
epoch_grad_norm_sum += grad_norm;
epoch_loss_sum += _loss;
},
Err(e) => {
// SUCCESS: Training terminated with error!
training_terminated_early = true;
termination_error_message = e.to_string();
println!("✅ Training terminated at epoch {}, step {}", epoch_count, step + 1);
println!("✅ Error message: {}", termination_error_message);
break;
}
}
}
// If training terminated, break out of epoch loop
if training_terminated_early {
break;
}
// Calculate epoch averages
let avg_grad_norm = epoch_grad_norm_sum / steps_per_epoch as f32;
let avg_loss = epoch_loss_sum / steps_per_epoch as f32;
gradient_norms.push(avg_grad_norm);
losses.push(avg_loss);
println!(
" Epoch {}/{}: avg_grad_norm={:.8}, avg_loss={:.6}",
epoch_count, max_epochs, avg_grad_norm, avg_loss
);
}
println!();
// ========================================
// VALIDATION 1: Training Terminated Early
// ========================================
println!("✅ VALIDATION 1: Early Termination");
println!("----------------------------------");
if training_terminated_early {
println!(" ✓ Training terminated early at epoch {}", epoch_count);
println!(" ✓ Error message received");
} else {
println!(" ✗ FAILED: Training ran to completion (epoch {})", epoch_count);
println!(" ✗ Expected: Training should stop at epoch 3-5");
println!(" ✗ Actual: Training ran to epoch {}", epoch_count);
}
assert!(
training_terminated_early,
"BUG: Early stopping did NOT terminate training!\n\
Expected: Training stops at epoch 3-5 (patience=3)\n\
Actual: Training ran to epoch {}\n\
This indicates the error is being caught/ignored instead of propagating.",
epoch_count
);
println!();
// ========================================
// VALIDATION 2: Stopped at Correct Epoch
// ========================================
println!("✅ VALIDATION 2: Termination Timing");
println!("-----------------------------------");
// Should stop at epoch 3-5 (patience=3, allow some variance for detection timing)
let expected_min_epoch = 3;
let expected_max_epoch = 5;
println!(" Expected epoch range: {}-{}", expected_min_epoch, expected_max_epoch);
println!(" Actual epoch: {}", epoch_count);
assert!(
epoch_count >= expected_min_epoch && epoch_count <= expected_max_epoch,
"BUG: Training stopped at wrong epoch!\n\
Expected: epoch {}-{} (patience=3 + detection lag)\n\
Actual: epoch {}\n\
This indicates incorrect patience counting or threshold calculation.",
expected_min_epoch, expected_max_epoch, epoch_count
);
println!(" ✓ Training stopped at epoch {} (within expected range)", epoch_count);
println!();
// ========================================
// VALIDATION 3: Error Message Content
// ========================================
println!("✅ VALIDATION 3: Error Message");
println!("------------------------------");
println!(" Error: {}", termination_error_message);
assert!(
termination_error_message.contains("Gradient collapse detected"),
"BUG: Error message does not contain 'Gradient collapse detected'!\n\
Expected: Error message should explain gradient collapse\n\
Actual: '{}'\n\
This indicates the wrong error type or incorrect error propagation.",
termination_error_message
);
println!(" ✓ Error message contains 'Gradient collapse detected'");
// Should also contain helpful suggestions
let contains_suggestions = termination_error_message.contains("learning rate")
|| termination_error_message.contains("batch size");
if contains_suggestions {
println!(" ✓ Error message contains helpful suggestions");
}
println!();
// ========================================
// VALIDATION 4: Gradient Collapse Observed
// ========================================
println!("✅ VALIDATION 4: Gradient Collapse Evidence");
println!("-------------------------------------------");
// Gradients should be near zero (below threshold) - use saved value from earlier
let collapsed_epochs = gradient_norms.iter().filter(|&&g| g < threshold as f32).count();
println!(" Collapse threshold: {:.8}", threshold);
println!(" Epochs trained: {}", gradient_norms.len());
println!(" Epochs with collapsed gradients: {}", collapsed_epochs);
for (i, &grad_norm) in gradient_norms.iter().enumerate() {
let status = if grad_norm < threshold as f32 { "✗ COLLAPSED" } else { "✓ OK" };
println!(" Epoch {}: grad_norm={:.8} {}", i + 1, grad_norm, status);
}
// At least 3 consecutive epochs should have collapsed gradients (patience=3)
assert!(
collapsed_epochs >= 3,
"BUG: Insufficient gradient collapse detected!\n\
Expected: At least 3 collapsed epochs (patience=3)\n\
Actual: {} collapsed epochs\n\
This indicates the test configuration didn't trigger collapse correctly.",
collapsed_epochs
);
println!(" ✓ Sufficient gradient collapse detected ({} epochs)", collapsed_epochs);
println!();
// ========================================
// TEST SUMMARY
// ========================================
println!("============================================");
println!("🎉 TEST PASSED: Early Stopping Termination");
println!("============================================");
println!("✓ Training terminated early (epoch {})", epoch_count);
println!("✓ Stopped at correct time ({}-{} epoch range)", expected_min_epoch, expected_max_epoch);
println!("✓ Error message correct: 'Gradient collapse detected'");
println!("✓ Gradient collapse observed ({} epochs)", collapsed_epochs);
println!();
Ok(())
}
/// Test 2: Verify that the gradient collapse counter increments correctly
///
/// **Expected**: Counter increments on collapse, resets on recovery
/// **Tests**: Internal state management of collapse detection
#[test]
fn test_gradient_collapse_counter_increments() -> Result<(), MLError> {
println!("\n🧪 TEST: Gradient Collapse Counter Logic");
println!("=========================================");
println!("Objective: Verify collapse counter increments and resets correctly");
println!();
// ========================================
// STEP 1: Configure DQN with Gradient Collapse Parameters
// ========================================
println!("📝 STEP 1: Configuring DQN");
let mut config = WorkingDQNConfig::conservative();
config.state_dim = 32;
config.num_actions = 3;
config.learning_rate = 1e-8; // Low enough to trigger collapse
config.batch_size = 8;
config.gradient_collapse_patience = 5; // Higher patience to test counter logic
config.gradient_collapse_multiplier = 100.0;
config.replay_buffer_capacity = 500;
config.min_replay_size = 100;
config.warmup_steps = 0;
// Use C51 with narrow bounds to guarantee collapse
config.use_distributional = true;
config.v_min = -0.001;
config.v_max = 0.001;
config.num_atoms = 51;
println!(" ✓ Collapse patience: {} epochs", config.gradient_collapse_patience);
println!(" ✓ Collapse threshold: {:.8}", config.learning_rate * config.gradient_collapse_multiplier);
println!();
// Save config values before moving
let threshold = config.learning_rate * config.gradient_collapse_multiplier;
let collapse_patience = config.gradient_collapse_patience;
// ========================================
// STEP 2: Initialize and Fill Buffer
// ========================================
println!("📝 STEP 2: Filling replay buffer");
let mut dqn = WorkingDQN::new(config)?;
for i in 0..200 {
let state = vec![0.01 * (i as f32); 32];
let next_state = vec![0.01 * (i as f32 + 1.0); 32];
let action = (i % 3) as u8;
let reward = if i % 10 == 0 { 0.1 } else { -0.01 };
let done = i % 50 == 49;
let experience = Experience::new(state, action, reward, next_state, done);
dqn.store_experience(experience)?;
}
println!(" ✓ Buffer size: {}", dqn.get_replay_buffer_size()?);
println!();
// ========================================
// STEP 3: Run Training and Monitor Counter
// ========================================
println!("📝 STEP 3: Running training and monitoring counter");
println!();
let mut consecutive_collapsed = 0;
let mut saw_collapse_warning = false;
// Train until we see the error (or hit max epochs)
for epoch in 0..10 {
let mut epoch_had_collapse = false;
// Run 5 steps per epoch
for step in 0..5 {
match dqn.train_step(None) {
Ok((_loss, grad_norm)) => {
// Check if gradient collapsed
if grad_norm < threshold as f32 {
epoch_had_collapse = true;
saw_collapse_warning = true;
println!(
" Epoch {}, Step {}: grad_norm={:.8} < threshold={:.8} ✗ COLLAPSED",
epoch + 1, step + 1, grad_norm, threshold
);
} else {
println!(
" Epoch {}, Step {}: grad_norm={:.8} ✓ OK",
epoch + 1, step + 1, grad_norm
);
}
},
Err(e) => {
println!(" Epoch {}: Training terminated with error", epoch + 1);
println!(" Error: {}", e);
break;
}
}
}
// Update consecutive counter
if epoch_had_collapse {
consecutive_collapsed += 1;
println!(" → Consecutive collapsed epochs: {}/{}", consecutive_collapsed, collapse_patience);
} else {
if consecutive_collapsed > 0 {
println!(" → Gradients recovered! Resetting counter (was: {})", consecutive_collapsed);
}
consecutive_collapsed = 0;
}
println!();
// If we hit patience, next epoch should terminate
if consecutive_collapsed >= collapse_patience {
println!(" → Patience exhausted! Next step should terminate training");
break;
}
}
// ========================================
// VALIDATION 1: Collapse Detected
// ========================================
println!("✅ VALIDATION 1: Collapse Detection");
println!("-----------------------------------");
assert!(
saw_collapse_warning,
"BUG: No gradient collapse detected!\n\
Expected: Gradients < {:.8}\n\
This indicates test configuration is incorrect.",
threshold
);
println!(" ✓ Gradient collapse detected");
println!();
// ========================================
// VALIDATION 2: Counter Incremented
// ========================================
println!("✅ VALIDATION 2: Counter Increment");
println!("----------------------------------");
println!(" Final consecutive collapsed epochs: {}", consecutive_collapsed);
assert!(
consecutive_collapsed >= 3,
"BUG: Counter didn't increment sufficiently!\n\
Expected: At least 3 consecutive collapsed epochs\n\
Actual: {}\n\
This indicates the counter logic is broken.",
consecutive_collapsed
);
println!(" ✓ Counter incremented to {}", consecutive_collapsed);
println!();
// ========================================
// TEST SUMMARY
// ========================================
println!("=========================================");
println!("🎉 TEST PASSED: Counter Logic");
println!("=========================================");
println!("✓ Gradient collapse detected");
println!("✓ Counter incremented correctly");
println!();
Ok(())
}
/// Test 3: Verify that training continues when gradients recover
///
/// **Expected**: Counter resets to 0 when gradients recover, training continues
/// **Tests**: Counter reset logic and recovery behavior
#[test]
fn test_gradient_recovery_resets_counter() -> Result<(), MLError> {
println!("\n🧪 TEST: Gradient Recovery Resets Counter");
println!("==========================================");
println!("Objective: Verify counter resets when gradients recover");
println!();
// ========================================
// STEP 1: Configure DQN with MODERATE Parameters
// ========================================
println!("📝 STEP 1: Configuring DQN");
let mut config = WorkingDQNConfig::conservative();
config.state_dim = 32;
config.num_actions = 3;
// Use moderate learning rate (might collapse, might recover)
config.learning_rate = 5e-6; // Not as extreme as 1e-8
config.batch_size = 16; // Larger than 8 (more stable)
config.gradient_collapse_patience = 3;
config.gradient_collapse_multiplier = 100.0;
config.replay_buffer_capacity = 500;
config.min_replay_size = 100;
config.warmup_steps = 0;
// Don't use C51 narrow bounds (want potential for recovery)
config.use_distributional = false;
println!(" ✓ Learning rate: {:.2e} (moderate)", config.learning_rate);
println!(" ✓ Batch size: {} (moderate)", config.batch_size);
println!(" ✓ Collapse patience: {} epochs", config.gradient_collapse_patience);
println!();
// Save config values before moving
let threshold = config.learning_rate * config.gradient_collapse_multiplier;
// ========================================
// STEP 2: Initialize and Fill Buffer
// ========================================
println!("📝 STEP 2: Filling replay buffer with diverse experiences");
let mut dqn = WorkingDQN::new(config)?;
// Generate more diverse experiences (helps with potential recovery)
for i in 0..300 {
let state = vec![
0.01 * (i as f32),
0.02 * ((i % 50) as f32),
-0.01 * (i as f32),
]
.into_iter()
.cycle()
.take(32)
.collect::<Vec<_>>();
let next_state = vec![
0.01 * (i as f32 + 1.0),
0.02 * ((i % 50 + 1) as f32),
-0.01 * (i as f32 + 1.0),
]
.into_iter()
.cycle()
.take(32)
.collect::<Vec<_>>();
let action = (i % 3) as u8;
let reward = ((i % 20) as f32 - 10.0) / 10.0; // Diverse rewards: -1.0 to +1.0
let done = i % 50 == 49;
let experience = Experience::new(state, action, reward, next_state, done);
dqn.store_experience(experience)?;
}
println!(" ✓ Buffer size: {}", dqn.get_replay_buffer_size()?);
println!();
// ========================================
// STEP 3: Monitor for Recovery
// ========================================
println!("📝 STEP 3: Training and monitoring for recovery");
println!();
let mut saw_recovery = false;
for epoch in 0..15 {
let mut epoch_grad_norms = Vec::new();
// Run 10 steps per epoch
for _ in 0..10 {
match dqn.train_step(None) {
Ok((_, grad_norm)) => {
epoch_grad_norms.push(grad_norm);
},
Err(e) => {
println!(" Epoch {}: Training terminated: {}", epoch + 1, e);
break;
}
}
}
if epoch_grad_norms.is_empty() {
break;
}
let avg_grad_norm = epoch_grad_norms.iter().sum::<f32>() / epoch_grad_norms.len() as f32;
let is_collapsed = avg_grad_norm < threshold as f32;
if !is_collapsed {
saw_recovery = true;
}
println!(
" Epoch {}: avg_grad_norm={:.8} {} (threshold: {:.8})",
epoch + 1,
avg_grad_norm,
if is_collapsed { "✗ COLLAPSED" } else { "✓ OK" },
threshold
);
}
println!();
// ========================================
// VALIDATION: Recovery Observed
// ========================================
println!("✅ VALIDATION: Recovery Behavior");
println!("--------------------------------");
// Note: With moderate parameters, we MIGHT see recovery
// This test is informational - shows counter reset logic exists
if saw_recovery {
println!(" ✓ Gradient recovery observed");
println!(" Counter reset logic would have been triggered");
} else {
println!(" No recovery observed (parameters too extreme)");
println!(" Test configuration note: Use higher LR or larger batch for recovery");
}
println!();
// ========================================
// TEST SUMMARY
// ========================================
println!("==========================================");
println!("🎉 TEST COMPLETE: Recovery Monitoring");
println!("==========================================");
println!(" This test demonstrates recovery detection logic");
println!(" Adjust parameters (LR, batch size) to observe recovery");
println!();
Ok(())
}