BREAKING CHANGES: - Removed orphaned dqn.rs monolithic trainer (4,975 lines) - Removed orphaned dqn_ensemble.rs module (816 lines) - Removed orphaned tft.rs and tft_complete_int8_integration_test.rs - TFT trainer split into modular directory structure DQN Module Refactoring: - Split trainers/dqn.rs into modular structure (config.rs, statistics.rs, trainer.rs) - Fixed hyperopt 39D search space (continuous params only) - Boolean flags (use_dueling, use_double_dqn, use_per, use_noisy_nets) are now FIXED architectural decisions - use_distributional defaults to false (Candle BUG #36 - scatter_add gradient issues) Clean Module Structure: - ml/src/trainers/dqn/ directory with proper mod.rs exports - ml/src/trainers/tft/ directory with config.rs, types.rs, model.rs, trainer.rs, tests.rs - All P0 features validated: TD-error clamping, batch diversity, LR scheduler, priority staleness Documentation: - Added comprehensive docs in docs/codebase-cleanup/ - ADR-001 for DQN refactoring decisions - Rainbow DQN component matrix and quick reference guides Build Status: Compiles with zero errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
305 lines
12 KiB
Rust
305 lines
12 KiB
Rust
// PROPOSED IMPLEMENTATION: Overfitting Detection for DQN Hyperopt
|
|
// File: ml/src/hyperopt/adapters/dqn.rs
|
|
// Author: Agent 7 (Hive-Mind Swarm)
|
|
// Date: 2025-11-27
|
|
|
|
// ============================================================================
|
|
// CHANGE 1: Add ValidationMetrics Import (after line 56)
|
|
// ============================================================================
|
|
|
|
use crate::trainers::validation_metrics::{ValidationMetrics, EarlyStopCriteria};
|
|
|
|
// ============================================================================
|
|
// CHANGE 2: Add Helper Function (before impl HyperparameterOptimizable)
|
|
// ============================================================================
|
|
|
|
/// Calculate Shannon entropy of action distribution H = -Σ p_i log(p_i)
|
|
///
|
|
/// Used to measure exploration diversity in policy.
|
|
/// - 0.0: Deterministic (one action always chosen)
|
|
/// - 1.099: Uniform distribution over 3 actions (maximum entropy)
|
|
///
|
|
/// # Arguments
|
|
/// * `distribution` - Action probabilities [buy%, sell%, hold%]
|
|
///
|
|
/// # Returns
|
|
/// Entropy in bits (range: 0.0 to 1.099)
|
|
fn calculate_entropy(distribution: &[f32; 3]) -> f32 {
|
|
distribution
|
|
.iter()
|
|
.filter(|&&p| p > 1e-8) // Avoid log(0)
|
|
.map(|&p| -p * p.log2())
|
|
.sum()
|
|
}
|
|
|
|
// ============================================================================
|
|
// CHANGE 3: Add Overfitting Detection to train_with_params()
|
|
// ============================================================================
|
|
|
|
// This code should be inserted AFTER line ~1900 where trainer.train().await completes
|
|
// and BEFORE the final metrics are returned.
|
|
|
|
impl HyperparameterOptimizable for DQNTrainer {
|
|
type Params = DQNParams;
|
|
type Metrics = DQNMetrics;
|
|
|
|
fn train_with_params(&mut self, params: Self::Params) -> Result<Self::Metrics, MLError> {
|
|
// ... existing code up to trainer.train().await ...
|
|
|
|
// ========================================================================
|
|
// INSERTION POINT: After training completes, before returning metrics
|
|
// ========================================================================
|
|
|
|
// Get metrics from completed training
|
|
let final_train_loss = trainer.get_avg_train_loss();
|
|
let final_val_loss = trainer.get_best_val_loss();
|
|
let final_q_value = trainer.get_avg_q_value();
|
|
let final_epsilon = trainer.get_epsilon().await.unwrap_or(0.0);
|
|
let epochs_completed = trainer.get_epochs_trained() as u32;
|
|
|
|
// Extract gradient norm and Q-value std from training statistics
|
|
let gradient_norm = trainer.get_final_gradient_norm().unwrap_or(0.0);
|
|
let q_value_std = trainer.get_q_value_std().unwrap_or(1.0);
|
|
|
|
// Get action distribution from trainer's final statistics
|
|
let (buy_pct, sell_pct, hold_pct) = trainer.get_action_distribution()
|
|
.unwrap_or((0.33, 0.33, 0.34));
|
|
|
|
// ========================================================================
|
|
// NEW CODE: Build ValidationMetrics history for overfitting detection
|
|
// ========================================================================
|
|
|
|
let mut val_metrics_history = Vec::new();
|
|
|
|
// Get historical data from trainer
|
|
let loss_history = trainer.get_loss_history(); // Vec<f64>
|
|
let val_loss_history = trainer.get_val_loss_history(); // Vec<f64>
|
|
let q_value_history = trainer.get_q_value_history(); // Vec<f64>
|
|
|
|
let epochs_to_check = loss_history.len().min(val_loss_history.len());
|
|
|
|
for epoch in 0..epochs_to_check {
|
|
// Get train/val losses for this epoch
|
|
let train_loss = loss_history[epoch] as f32;
|
|
let val_loss = val_loss_history[epoch] as f32;
|
|
let q_mean = q_value_history.get(epoch).copied().unwrap_or(0.0) as f32;
|
|
|
|
// Estimate Q-value std (can be improved with per-epoch tracking)
|
|
let q_std = (q_value_std * 0.1) as f32; // Conservative estimate
|
|
|
|
// Estimate action distribution (use final distribution as proxy)
|
|
// TODO: Track per-epoch distributions in DQNTrainer for accuracy
|
|
let action_dist = [buy_pct as f32, sell_pct as f32, hold_pct as f32];
|
|
|
|
// Calculate policy entropy from action distribution
|
|
let policy_entropy = calculate_entropy(&action_dist);
|
|
|
|
// Get win rate and Sharpe from backtest (if available)
|
|
let (win_rate, sharpe) = if let Some(ref backtest) = backtest_metrics {
|
|
(backtest.win_rate as f32, backtest.sharpe_ratio as f32)
|
|
} else {
|
|
(0.5, 0.0) // Default neutral values
|
|
};
|
|
|
|
// Get gradient norm (use final value as proxy for now)
|
|
// TODO: Track per-epoch gradient norms in DQNTrainer
|
|
let grad_norm = gradient_norm as f32;
|
|
|
|
// Build ValidationMetrics for this epoch
|
|
let vm = ValidationMetrics::new(
|
|
epoch,
|
|
train_loss,
|
|
val_loss,
|
|
q_mean,
|
|
q_std,
|
|
action_dist,
|
|
policy_entropy,
|
|
win_rate,
|
|
sharpe,
|
|
grad_norm,
|
|
);
|
|
|
|
val_metrics_history.push(vm);
|
|
}
|
|
|
|
// ========================================================================
|
|
// NEW CODE: Check for overfitting using ValidationMetrics
|
|
// ========================================================================
|
|
|
|
if let Some(latest_vm) = val_metrics_history.last() {
|
|
// Use EarlyStopCriteria::Overfitting to detect train/val divergence
|
|
let criteria = EarlyStopCriteria::Overfitting;
|
|
|
|
if let Some(reason) = criteria.should_stop(&latest_vm, &val_metrics_history) {
|
|
tracing::warn!(
|
|
"⚠️ Trial {} PRUNED (overfitting detected): {}",
|
|
current_trial,
|
|
reason
|
|
);
|
|
|
|
// Log overfitting detection to training logs
|
|
write_training_log_dqn(
|
|
&self.training_paths.logs_dir(),
|
|
&format!(
|
|
"Trial {} PRUNED (overfitting): {}\nTrain loss: {:.4}, Val loss: {:.4}, Ratio: {:.2}",
|
|
current_trial,
|
|
reason,
|
|
latest_vm.train_loss,
|
|
latest_vm.val_loss,
|
|
latest_vm.train_val_ratio()
|
|
),
|
|
)
|
|
.ok();
|
|
|
|
// Return heavily penalized metrics to prune this trial
|
|
return Ok(DQNMetrics {
|
|
train_loss: latest_vm.train_loss as f64,
|
|
val_loss: latest_vm.val_loss as f64,
|
|
avg_q_value: latest_vm.q_value_mean as f64,
|
|
final_epsilon,
|
|
epochs_completed,
|
|
avg_episode_reward: -1000.0, // Heavy penalty (will give objective = +1000)
|
|
buy_action_pct: latest_vm.action_distribution[0] as f64,
|
|
sell_action_pct: latest_vm.action_distribution[1] as f64,
|
|
hold_action_pct: latest_vm.action_distribution[2] as f64,
|
|
gradient_norm: latest_vm.gradient_norm as f64,
|
|
q_value_std: latest_vm.q_value_std as f64,
|
|
backtest_metrics: None, // No backtest for pruned trials
|
|
});
|
|
} else {
|
|
tracing::info!(
|
|
"✅ Trial {} passed overfitting check (train/val ratio: {:.2})",
|
|
current_trial,
|
|
latest_vm.train_val_ratio()
|
|
);
|
|
}
|
|
}
|
|
|
|
// ========================================================================
|
|
// EXISTING CODE: Continue with normal metrics return
|
|
// ========================================================================
|
|
|
|
// Run backtest if enabled
|
|
let backtest_metrics = if self.enable_backtest {
|
|
// ... existing backtest code ...
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// ... rest of existing code to build and return final metrics ...
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// EXPECTED BEHAVIOR
|
|
// ============================================================================
|
|
|
|
/*
|
|
SCENARIO 1: Trial with overfitting (train↓, val↑ for 5 epochs)
|
|
|
|
Epoch 10: train_loss=2.0, val_loss=2.0 ✓
|
|
Epoch 11: train_loss=1.8, val_loss=2.1 ✓
|
|
Epoch 12: train_loss=1.6, val_loss=2.2 ✓
|
|
Epoch 13: train_loss=1.4, val_loss=2.3 ✓
|
|
Epoch 14: train_loss=1.2, val_loss=2.4 ✓
|
|
|
|
→ is_overfitting() returns true
|
|
→ Trial PRUNED with avg_episode_reward = -1000.0
|
|
→ Log message: "⚠️ Trial 5 PRUNED (overfitting detected): train/val ratio: 2.00"
|
|
→ Hyperopt will not select this trial as best
|
|
|
|
|
|
SCENARIO 2: Trial with high train/val ratio
|
|
|
|
Epoch 20: train_loss=1.0, val_loss=3.5 (ratio = 3.5 > 2.0)
|
|
|
|
→ is_overfitting() returns true (Signal 2: ratio check)
|
|
→ Trial PRUNED immediately
|
|
→ Saves remaining epochs from being wasted
|
|
|
|
|
|
SCENARIO 3: Healthy trial (no overfitting)
|
|
|
|
Epoch 1-50: train_loss and val_loss both decreasing
|
|
train_loss=1.2, val_loss=1.5 (ratio = 0.8 < 2.0)
|
|
|
|
→ is_overfitting() returns false
|
|
→ Trial completes normally
|
|
→ Returns actual backtest metrics
|
|
→ May be selected as best trial by hyperopt
|
|
*/
|
|
|
|
// ============================================================================
|
|
// TESTING
|
|
// ============================================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_calculate_entropy_uniform() {
|
|
let uniform_dist = [0.33, 0.33, 0.34];
|
|
let entropy = calculate_entropy(&uniform_dist);
|
|
// log2(3) ≈ 1.585, so H(uniform) ≈ 1.585 * 0.33 ≈ 1.58
|
|
assert!((entropy - 1.58).abs() < 0.1, "Entropy should be ~1.58 for uniform");
|
|
}
|
|
|
|
#[test]
|
|
fn test_calculate_entropy_deterministic() {
|
|
let deterministic = [0.0, 0.0, 1.0];
|
|
let entropy = calculate_entropy(&deterministic);
|
|
assert_eq!(entropy, 0.0, "Entropy should be 0 for deterministic");
|
|
}
|
|
|
|
#[test]
|
|
fn test_calculate_entropy_mixed() {
|
|
let mixed = [0.5, 0.3, 0.2];
|
|
let entropy = calculate_entropy(&mixed);
|
|
assert!(entropy > 0.0 && entropy < 1.6, "Entropy should be between 0 and log2(3)");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_hyperopt_detects_overfitting() {
|
|
// This test would need a full integration test setup
|
|
// Verifying that train_with_params() returns penalized metrics
|
|
// when is_overfitting() triggers
|
|
|
|
// Mock: Create DQNTrainer with test data
|
|
// Mock: Create params that cause overfitting (high LR, low regularization)
|
|
// Assert: metrics.avg_episode_reward == -1000.0
|
|
// Assert: Log contains "PRUNED (overfitting)"
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// INTEGRATION CHECKLIST
|
|
// ============================================================================
|
|
|
|
/*
|
|
BEFORE MERGING:
|
|
|
|
1. ✓ Add import for ValidationMetrics, EarlyStopCriteria
|
|
2. ✓ Add calculate_entropy() helper function
|
|
3. ✓ Insert ValidationMetrics history construction in train_with_params()
|
|
4. ✓ Insert overfitting check before returning metrics
|
|
5. □ Verify DQNTrainer exposes required getters:
|
|
- get_loss_history() -> &[f64]
|
|
- get_val_loss_history() -> &[f64]
|
|
- get_q_value_history() -> &[f64]
|
|
- get_action_distribution() -> (f64, f64, f64)
|
|
- get_final_gradient_norm() -> Option<f64>
|
|
- get_q_value_std() -> Option<f64>
|
|
6. □ Run cargo test --package ml validation_metrics
|
|
7. □ Run hyperopt trial with known overfitting params
|
|
8. □ Verify log message appears
|
|
9. □ Verify penalized metrics returned
|
|
10. □ Update hyperopt documentation
|
|
|
|
NICE TO HAVE (Phase 2):
|
|
- Track per-epoch action distributions in DQNTrainer
|
|
- Track per-epoch gradient norms in DQNTrainer
|
|
- Expose ValidationMetrics natively from DQNTrainer
|
|
- Add comprehensive EarlyStopCriteria::All checking
|
|
*/
|