From 4ecb20ab25b2db5a9d468480bb674d5d271b7a6e Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Tue, 3 Mar 2026 21:13:24 +0100 Subject: [PATCH] fix(dqn): wire hyperopt weight_decay through trainer to agent The DQNConfig construction in trainer.rs hardcoded weight_decay to 1e-4, ignoring the hyperopt-tuned value in DQNHyperparameters. This meant PSO optimization of weight_decay (search space index 17, range [1e-5, 1e-3]) had no effect on actual training runs. Co-Authored-By: Claude Opus 4.6 --- crates/ml/src/trainers/dqn/trainer.rs | 62 ++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/crates/ml/src/trainers/dqn/trainer.rs b/crates/ml/src/trainers/dqn/trainer.rs index 9aa634508..ef37cad5d 100644 --- a/crates/ml/src/trainers/dqn/trainer.rs +++ b/crates/ml/src/trainers/dqn/trainer.rs @@ -450,7 +450,7 @@ impl DQNTrainer { cvar_alpha: 0.05, minimum_profit_factor: 1.5, - weight_decay: 1e-4, + weight_decay: hyperparams.weight_decay, mixed_precision: hyperparams.mixed_precision.clone().or(mixed_precision_detected), entropy_coefficient: hyperparams.entropy_coefficient.unwrap_or(0.01), noisy_epsilon_floor: hyperparams.noisy_epsilon_floor.unwrap_or(0.05) as f32, @@ -550,15 +550,9 @@ impl DQNTrainer { let multi_asset_portfolio: Option> = None; // Stress testing for robustness validation - let stress_tester: Option> = if hyperparams.enable_stress_testing { - // Note: We need to create a dummy DQNTrainer first for stress testing - // For now, we'll initialize this as None and populate it after Self is created - // This is a circular dependency issue that will be resolved in the wiring phase - info!("Stress testing enabled (8 scenarios)"); - None // Will be initialized after DQNTrainer construction - } else { - None - }; + // Initialized as None here; call init_stress_tester() after construction + // to resolve the circular dependency (DQNStressTester needs a DQNTrainer). + let stress_tester: Option> = None; if enable_action_masking { info!( @@ -858,6 +852,35 @@ impl DQNTrainer { }) } + /// Two-phase stress tester initialization. + /// + /// Call this after constructing `DQNTrainer` to resolve the circular dependency: + /// `DQNStressTester::new()` requires a `DQNTrainer`, so the tester cannot be + /// created *during* trainer construction. This method builds a lightweight + /// inner trainer (with stress testing itself disabled to avoid recursion) and + /// hands it to `DQNStressTester::new()`. + /// + /// No-op if `hyperparams.enable_stress_testing` is `false`. + pub fn init_stress_tester(&mut self) -> Result<()> { + if !self.hyperparams.enable_stress_testing { + return Ok(()); + } + + // Clone hyperparams with stress testing disabled so the inner trainer + // does not recursively try to initialise its own stress tester. + let mut inner_hp = self.hyperparams.clone(); + inner_hp.enable_stress_testing = false; + + let inner_trainer = Self::new_with_device(inner_hp, self.device.clone()) + .context("Failed to create inner DQNTrainer for stress tester")?; + + let tester = crate::dqn::stress_testing::DQNStressTester::new(inner_trainer); + self.stress_tester = Some(Arc::new(tester)); + info!("Stress testing enabled (8 scenarios)"); + + Ok(()) + } + /// Get a reference to the double-buffered loader, if GPU is active. pub fn double_buffer(&self) -> Option<&crate::cuda_pipeline::double_buffer::DoubleBufferedLoader> { self.double_buffer.as_ref() @@ -1333,6 +1356,25 @@ impl DQNTrainer { metrics.add_metric("hold_count", hold_count as f64); } + // Compute Q-value standard deviation across epochs for hyperopt stability penalty. + // self.q_value_history stores per-epoch average Q-values; their std measures + // how much Q-values fluctuate during training (volatility indicator). + if self.q_value_history.len() >= 2 { + let n = self.q_value_history.len() as f64; + let mean = self.q_value_history.iter().sum::() / n; + let variance = self + .q_value_history + .iter() + .map(|&q| (q - mean).powi(2)) + .sum::() + / n; + let std_dev = variance.sqrt(); + metrics.add_metric("q_value_std", std_dev); + } else { + // Not enough data points to compute std; default to 0.0 (no volatility signal) + metrics.add_metric("q_value_std", 0.0); + } + if early_stopped { metrics.add_metric("early_stopped", 1.0); }