feat(ml): Add weight_decay L2 regularization to DQN (P0.1 fix)

Implements critical P0.1 gap from DQN 2025 upgrade roadmap to address
overfitting in hyperopt/trainer by enabling proper weight decay in AdamW.

Changes:
- Add weight_decay field to DQNHyperparameters (default: 1e-4)
- Wire weight_decay to AdamW via Decay::DecoupledWeightDecay at 3 optimizer
  initialization points in agent.rs
- Expand hyperopt search space to 40D with weight_decay [1e-5, 1e-3] log scale
- Update from_continuous(), to_continuous(), param_names() for roundtrip

This enables the modern best practice of decoupled weight decay (AdamW paper)
which prevents co-adaptation of network weights and improves generalization.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2025-11-28 09:29:38 +01:00
parent 4080f73ba4
commit b0a5146885
3 changed files with 125 additions and 62 deletions

View File

@@ -180,6 +180,9 @@ pub struct DQNConfig {
pub minimum_profit_factor: f32,
/// BUG #4 FIX: Soft target update coefficient (Polyak averaging rate, default 0.005)
pub tau: f64,
/// WAVE 30: L2 weight decay for AdamW optimizer (default: 1e-4, range: [1e-5, 1e-3])
/// Prevents overfitting by penalizing large weights via L2 regularization.
pub weight_decay: f64,
}
impl Default for DQNConfig {
@@ -198,6 +201,7 @@ impl Default for DQNConfig {
epsilon_decay: 0.995,
minimum_profit_factor: 1.5, // BUG #7 FIX: 50% margin above breakeven
tau: 0.005, // BUG #4 FIX: Polyak averaging coefficient (0.5% new weights per update)
weight_decay: 1e-4, // WAVE 30: Standard L2 regularization strength
}
}
}
@@ -337,12 +341,13 @@ impl DQNAgent {
// Initialize optimizer if not already done
if self.optimizer.is_none() {
use candle_optimisers::Decay;
let adam_params = ParamsAdam {
lr: self.config.learning_rate,
beta_1: 0.9,
beta_2: 0.999,
eps: 1e-8,
weight_decay: None,
weight_decay: Some(Decay::DecoupledWeightDecay(self.config.weight_decay)),
amsgrad: false,
};
self.optimizer = Some(
@@ -739,12 +744,13 @@ impl DQNAgent {
self.q_network.set_epsilon(checkpoint.epsilon);
// Re-initialize optimizer with loaded parameters
use candle_optimisers::Decay;
let adam_params = ParamsAdam {
lr: self.config.learning_rate,
beta_1: 0.9,
beta_2: 0.999,
eps: 1e-8,
weight_decay: None,
weight_decay: Some(Decay::DecoupledWeightDecay(self.config.weight_decay)),
amsgrad: false,
};
self.optimizer = Some(
@@ -781,12 +787,13 @@ impl DQNAgent {
let new_lr = current_lr * decay_factor;
// Recreate optimizer with new learning rate
use candle_optimisers::Decay;
let adam_params = ParamsAdam {
lr: new_lr,
beta_1: 0.9,
beta_2: 0.999,
eps: 1e-8,
weight_decay: None,
weight_decay: Some(Decay::DecoupledWeightDecay(self.config.weight_decay)),
amsgrad: false,
};
@@ -1316,6 +1323,7 @@ mod tests {
epsilon_decay: 0.99,
minimum_profit_factor: 1.5,
tau: 0.005,
weight_decay: 1e-4,
};
let agent = DQNAgent::new(config.clone())?;

View File

@@ -248,7 +248,11 @@ pub struct DQNParams {
/// BUG #7: Profit must exceed cost by this factor (1.5 = 50% margin above breakeven)
/// Protects against marginal trades vulnerable to slippage
pub minimum_profit_factor: f64,
/// Weight decay (L2 regularization) for Adam optimizer (log-scale: 1e-5 to 1e-3)
/// Prevents overfitting by penalizing large weights
pub weight_decay: f64,
// WAVE 19: Tunable Kelly risk parameters
pub kelly_fractional: f64,
pub kelly_max_fraction: f64,
@@ -359,6 +363,7 @@ impl Default for DQNParams {
use_noisy_nets: true, // Wave 8: Default ENABLED for full Rainbow DQN
noisy_sigma_init: 0.5, // Wave 2.4: Rainbow DQN standard
minimum_profit_factor: 1.5, // BUG #7: Default 50% margin above breakeven
weight_decay: 1e-4, // Default: 0.0001 (standard L2 regularization strength)
kelly_fractional: 0.5, // WAVE 19: Default half-Kelly
kelly_max_fraction: 0.25, // WAVE 19: Default 25% max position
kelly_min_trades: 20, // WAVE 19: Default 20 trades minimum
@@ -392,11 +397,12 @@ impl Default for DQNParams {
impl ParameterSpace for DQNParams {
fn continuous_bounds() -> Vec<(f64, f64)> {
// BUG #7: Expanded to 18D continuous space (added minimum_profit_factor)
// CRITICAL GAP FIX: Expanded to 40D continuous space (added weight_decay)
// Base parameters (11D from Wave 1-2): LR, batch, gamma, buffer, hold_penalty, max_pos, huber, entropy, tx_cost, per_alpha, per_beta
// Rainbow extensions (6D): v_min, v_max, noisy_sigma_init, dueling_hidden_dim, n_steps, num_atoms
// NOTE: v_min/v_max/num_atoms still tunable but UNUSED (C51 disabled due to BUG #36)
// Bug #7 addition (1D): minimum_profit_factor
// Weight decay addition (1D): weight_decay (L2 regularization)
// Rainbow booleans: use_dueling=TRUE, use_distributional=FALSE (BUG #36), use_noisy_nets=TRUE
//
// WAVE 26 P1.5 FIX: Learning rate range EXPANDED to include production default 1e-4
@@ -430,52 +436,55 @@ impl ParameterSpace for DQNParams {
// BUG #7: Minimum profit threshold (1D)
(1.1, 2.0), // 17: minimum_profit_factor (linear) - Profit margin requirement
// WAVE 19: Kelly risk parameters (18D → 22D)
(0.25, 1.0), // 18: kelly_fractional
(0.1, 0.5), // 19: kelly_max_fraction
(10.0, 50.0), // 20: kelly_min_trades
(10.0, 30.0), // 21: volatility_window
// Weight decay (L2 regularization) (1D)
(1e-5_f64.ln(), 1e-3_f64.ln()), // 18: weight_decay (log scale) - L2 regularization strength
// WAVE 26 P1.4: Ensemble Uncertainty (22D → 27D)
(3.0, 10.0), // 22: ensemble_size (will be rounded to int)
(0.1, 1.0), // 23: beta_variance
(0.1, 1.0), // 24: beta_disagreement
(0.05, 0.5), // 25: beta_entropy
(0.1, 2.0), // 26: variance_cap (fixed in DQNHyperparameters, not tuned per-trial)
// WAVE 19: Kelly risk parameters (19D → 23D)
(0.25, 1.0), // 19: kelly_fractional
(0.1, 0.5), // 20: kelly_max_fraction
(10.0, 50.0), // 21: kelly_min_trades
(10.0, 30.0), // 22: volatility_window
// WAVE 26 P1.5: Learning rate warmup ratio (27D → 28D)
(0.0, 0.2), // 27: warmup_ratio (0-20% warmup)
// WAVE 26 P1.4: Ensemble Uncertainty (23D → 28D)
(3.0, 10.0), // 23: ensemble_size (will be rounded to int)
(0.1, 1.0), // 24: beta_variance
(0.1, 1.0), // 25: beta_disagreement
(0.05, 0.5), // 26: beta_entropy
(0.1, 2.0), // 27: variance_cap (fixed in DQNHyperparameters, not tuned per-trial)
// WAVE 26 P1.8: Curiosity-driven exploration (28D → 29D)
(0.0, 0.5), // 28: curiosity_weight (intrinsic reward scaling)
// WAVE 26 P1.5: Learning rate warmup ratio (28D → 29D)
(0.0, 0.2), // 28: warmup_ratio (0-20% warmup)
// WAVE 26 P1.12: Polyak soft update coefficient (29D → 30D)
(0.0001_f64.ln(), 0.01_f64.ln()), // 29: tau (log scale, 0.0001-0.01, Rainbow default: 0.001)
// WAVE 26 P1.8: Curiosity-driven exploration (29D → 30D)
(0.0, 0.5), // 29: curiosity_weight (intrinsic reward scaling)
// WAVE 26 P0: TD Error and Batch Diversity (30D → 32D)
(1.0, 100.0), // 30: td_error_clamp_max (linear, prevents extreme TD errors)
(10.0, 100.0), // 31: batch_diversity_cooldown (linear, diversity sampling frequency)
// WAVE 26 P1.12: Polyak soft update coefficient (30D → 31D)
(0.0001_f64.ln(), 0.01_f64.ln()), // 30: tau (log scale, 0.0001-0.01, Rainbow default: 0.001)
// WAVE 26 P1: Advanced Training Parameters (32D → 37D)
(0.0, 2.0), // 32: lr_decay_type (0=constant, 1=linear, 2=cosine)
(0.0, 0.5), // 33: sharpe_weight (risk-adjusted return weight)
(0.9, 0.99), // 34: gae_lambda (GAE bias-variance tradeoff)
(0.4, 0.8), // 35: noisy_sigma_initial (initial exploration noise)
(0.2, 0.5), // 36: noisy_sigma_final (final exploration noise)
// WAVE 26 P0: TD Error and Batch Diversity (31D → 33D)
(1.0, 100.0), // 31: td_error_clamp_max (linear, prevents extreme TD errors)
(10.0, 100.0), // 32: batch_diversity_cooldown (linear, diversity sampling frequency)
// WAVE 26 P1: Network Architecture (37D → 39D)
// WAVE 26 P1: Advanced Training Parameters (33D → 38D)
(0.0, 2.0), // 33: lr_decay_type (0=constant, 1=linear, 2=cosine)
(0.0, 0.5), // 34: sharpe_weight (risk-adjusted return weight)
(0.9, 0.99), // 35: gae_lambda (GAE bias-variance tradeoff)
(0.4, 0.8), // 36: noisy_sigma_initial (initial exploration noise)
(0.2, 0.5), // 37: noisy_sigma_final (final exploration noise)
// WAVE 26 P1: Network Architecture (38D → 40D)
// Note: Booleans (use_spectral_norm, use_attention, use_residual) NOT in search space
// They default to false and can be enabled via CLI or config
(0.0, 2.0), // 37: norm_type (0=LayerNorm, 1=RMSNorm, 2=None)
(0.0, 3.0), // 38: activation_type (0=ReLU, 1=LeakyReLU, 2=GELU, 3=Mish)
(0.0, 2.0), // 38: norm_type (0=LayerNorm, 1=RMSNorm, 2=None)
(0.0, 3.0), // 39: activation_type (0=ReLU, 1=LeakyReLU, 2=GELU, 3=Mish)
// WAVE 11: Rainbow DQN boolean parameters REMOVED from search space (always TRUE)
]
}
fn from_continuous(x: &[f64]) -> Result<Self, MLError> {
if x.len() != 39 {
if x.len() != 40 {
return Err(MLError::ConfigError {
reason: format!("Expected 39 continuous parameters (WAVE 26: full integration), got {}", x.len()),
reason: format!("Expected 40 continuous parameters (CRITICAL GAP FIX: added weight_decay), got {}", x.len()),
});
}
@@ -506,42 +515,45 @@ impl ParameterSpace for DQNParams {
// BUG #7: Minimum profit factor (18th parameter)
let minimum_profit_factor = x[17].clamp(1.1, 2.0);
// WAVE 19: Extract Kelly parameters
let kelly_fractional = x[18].clamp(0.25, 1.0);
let kelly_max_fraction = x[19].clamp(0.1, 0.5);
let kelly_min_trades = x[20].round().clamp(10.0, 50.0) as usize;
let volatility_window = x[21].round().clamp(10.0, 30.0) as usize;
// CRITICAL GAP FIX: Weight decay (L2 regularization) (19th parameter)
let weight_decay = x[18].exp().clamp(1e-5, 1e-3);
// WAVE 26 P1.4: Extract ensemble uncertainty parameters
let ensemble_size = x[22].round().clamp(3.0, 10.0);
let beta_variance = x[23].clamp(0.1, 1.0);
let beta_disagreement = x[24].clamp(0.1, 1.0);
let beta_entropy = x[25].clamp(0.05, 0.5);
// Note: x[26] is variance_cap, but it's NOT in DQNParams (fixed in DQNHyperparameters)
// WAVE 19: Extract Kelly parameters (shifted by +1 due to weight_decay)
let kelly_fractional = x[19].clamp(0.25, 1.0);
let kelly_max_fraction = x[20].clamp(0.1, 0.5);
let kelly_min_trades = x[21].round().clamp(10.0, 50.0) as usize;
let volatility_window = x[22].round().clamp(10.0, 30.0) as usize;
// WAVE 26 P1.4: Extract ensemble uncertainty parameters (shifted by +1 due to weight_decay)
let ensemble_size = x[23].round().clamp(3.0, 10.0);
let beta_variance = x[24].clamp(0.1, 1.0);
let beta_disagreement = x[25].clamp(0.1, 1.0);
let beta_entropy = x[26].clamp(0.05, 0.5);
// Note: x[27] is variance_cap, but it's NOT in DQNParams (fixed in DQNHyperparameters)
// WAVE 26 P1.5: Extract warmup ratio
let warmup_ratio = x[27].clamp(0.0, 0.2);
let warmup_ratio = x[28].clamp(0.0, 0.2);
// WAVE 26 P1.8: Extract curiosity weight
let curiosity_weight = x[28].clamp(0.0, 0.5);
let curiosity_weight = x[29].clamp(0.0, 0.5);
// WAVE 26 P1.12: Extract tau (Polyak soft update coefficient)
let tau = x[29].exp().clamp(0.0001, 0.01); // Log scale: 0.0001-0.01, default: 0.001
let tau = x[30].exp().clamp(0.0001, 0.01); // Log scale: 0.0001-0.01, default: 0.001
// WAVE 26 P0: Extract TD error and batch diversity parameters
let td_error_clamp_max = x[30].clamp(1.0, 100.0);
let batch_diversity_cooldown = x[31].clamp(10.0, 100.0);
let td_error_clamp_max = x[31].clamp(1.0, 100.0);
let batch_diversity_cooldown = x[32].clamp(10.0, 100.0);
// WAVE 26 P1: Extract advanced training parameters
let lr_decay_type = x[32].round().clamp(0.0, 2.0); // 0=constant, 1=linear, 2=cosine
let sharpe_weight = x[33].clamp(0.0, 0.5);
let gae_lambda = x[34].clamp(0.9, 0.99);
let noisy_sigma_initial = x[35].clamp(0.4, 0.8);
let noisy_sigma_final = x[36].clamp(0.2, 0.5);
let lr_decay_type = x[33].round().clamp(0.0, 2.0); // 0=constant, 1=linear, 2=cosine
let sharpe_weight = x[34].clamp(0.0, 0.5);
let gae_lambda = x[35].clamp(0.9, 0.99);
let noisy_sigma_initial = x[36].clamp(0.4, 0.8);
let noisy_sigma_final = x[37].clamp(0.2, 0.5);
// WAVE 26 P1: Extract network architecture parameters
let norm_type = x[37].round().clamp(0.0, 2.0); // 0=LayerNorm, 1=RMSNorm, 2=None
let activation_type = x[38].round().clamp(0.0, 3.0); // 0=ReLU, 1=LeakyReLU, 2=GELU, 3=Mish
let norm_type = x[38].round().clamp(0.0, 2.0); // 0=LayerNorm, 1=RMSNorm, 2=None
let activation_type = x[39].round().clamp(0.0, 3.0); // 0=ReLU, 1=LeakyReLU, 2=GELU, 3=Mish
// WAVE 11: Rainbow DQN boolean parameters are ALWAYS TRUE (removed from search space)
// User requirement: "I want them enabled!" - no point in tuning boolean flags
@@ -590,6 +602,7 @@ impl ParameterSpace for DQNParams {
use_noisy_nets: true, // WAVE 11: Always enabled for full Rainbow DQN
noisy_sigma_init,
minimum_profit_factor, // BUG #7: Configurable profit margin (1.1-2.0)
weight_decay, // CRITICAL GAP FIX: Now tunable in hyperopt search space (1e-5 to 1e-3)
// WAVE 19: Kelly risk parameters
kelly_fractional,
kelly_max_fraction,
@@ -649,7 +662,9 @@ impl ParameterSpace for DQNParams {
self.num_atoms as f64, // Distributional atoms count
// BUG #7: Minimum profit factor (18D)
self.minimum_profit_factor,
// WAVE 19: Kelly risk parameters (22D)
// CRITICAL GAP FIX: Weight decay (19D)
self.weight_decay.ln(),
// WAVE 19: Kelly risk parameters (23D)
self.kelly_fractional,
self.kelly_max_fraction,
self.kelly_min_trades as f64,
@@ -662,6 +677,19 @@ impl ParameterSpace for DQNParams {
1.0, // variance_cap placeholder (not in DQNParams, fixed in DQNHyperparameters)
self.warmup_ratio,
self.curiosity_weight,
self.tau.ln(), // WAVE 26 P1.12: Polyak soft update coefficient (log scale)
// WAVE 26 P0: TD error and batch diversity
self.td_error_clamp_max,
self.batch_diversity_cooldown,
// WAVE 26 P1: Advanced training parameters
self.lr_decay_type,
self.sharpe_weight,
self.gae_lambda,
self.noisy_sigma_initial,
self.noisy_sigma_final,
// WAVE 26 P1: Network architecture
self.norm_type,
self.activation_type,
// WAVE 11: Rainbow DQN boolean parameters REMOVED (always TRUE, not tunable)
]
}
@@ -688,7 +716,9 @@ impl ParameterSpace for DQNParams {
"num_atoms", // Distributional atoms count
// BUG #7: Minimum profit factor (18D)
"minimum_profit_factor",
// WAVE 19: Kelly risk parameters (22D)
// CRITICAL GAP FIX: Weight decay (19D)
"weight_decay",
// WAVE 19: Kelly risk parameters (23D)
"kelly_fractional",
"kelly_max_fraction",
"kelly_min_trades",
@@ -701,6 +731,19 @@ impl ParameterSpace for DQNParams {
"variance_cap",
"warmup_ratio",
"curiosity_weight",
"tau", // WAVE 26 P1.12: Polyak soft update coefficient
// WAVE 26 P0: TD error and batch diversity
"td_error_clamp_max",
"batch_diversity_cooldown",
// WAVE 26 P1: Advanced training parameters
"lr_decay_type",
"sharpe_weight",
"gae_lambda",
"noisy_sigma_initial",
"noisy_sigma_final",
// WAVE 26 P1: Network architecture
"norm_type",
"activation_type",
// WAVE 11: Rainbow DQN boolean parameters REMOVED (always TRUE, not tunable)
]
}
@@ -2074,6 +2117,9 @@ impl HyperparameterOptimizable for DQNTrainer {
lr_decay_steps: 1000, // Steps for LR decay
lr_decay_rate: 0.99, // LR decay rate
// CRITICAL GAP FIX: Weight decay (L2 regularization) - now tunable via hyperopt
weight_decay: params.weight_decay,
// WAVE 26 P1.4: Ensemble Uncertainty Exploration (tunable via hyperopt)
use_ensemble_uncertainty: params.use_ensemble_uncertainty,
ensemble_size: params.ensemble_size.round() as usize, // Cast f64 to usize
@@ -3008,7 +3054,7 @@ mod tests {
#[test]
fn test_param_names() {
let names = DQNParams::param_names();
assert_eq!(names.len(), 28); // WAVE 26 P1.5: 28 tunable hyperparameters (22 + 5 ensemble + 1 warmup_ratio)
assert_eq!(names.len(), 40); // CRITICAL GAP FIX: 40 tunable hyperparameters (added weight_decay + all WAVE 26 params)
assert_eq!(names[0], "learning_rate");
assert_eq!(names[1], "batch_size");
assert_eq!(names[2], "gamma");

View File

@@ -532,6 +532,12 @@ pub struct DQNHyperparameters {
pub noisy_sigma_final: f64,
/// Steps for sigma annealing (default: 10000)
pub noisy_sigma_anneal_steps: usize,
// WAVE 30: L2 Weight Decay for Overfitting Prevention
/// L2 weight decay for AdamW optimizer (default: 1e-4, range: [1e-5, 1e-3])
/// Prevents overfitting by penalizing large weights via L2 regularization.
/// Recommended: 1e-4 for standard training, 1e-3 for aggressive regularization.
pub weight_decay: f64,
}
impl Default for DQNHyperparameters {
@@ -698,6 +704,9 @@ impl DQNHyperparameters {
noisy_sigma_initial: 0.6, // Default: 60% initial noise
noisy_sigma_final: 0.4, // Default: 40% final noise
noisy_sigma_anneal_steps: 10000, // Default: 10K steps for annealing
// WAVE 30: L2 Weight Decay
weight_decay: 1e-4, // Default: 0.0001 (standard regularization strength)
}
}
}