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 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-03 21:13:24 +01:00
parent e338eda7ac
commit 4ecb20ab25

View File

@@ -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<Arc<crate::dqn::multi_asset::MultiAssetPortfolioTracker>> = None;
// Stress testing for robustness validation
let stress_tester: Option<Arc<crate::dqn::stress_testing::DQNStressTester>> = 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<Arc<crate::dqn::stress_testing::DQNStressTester>> = 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::<f64>() / n;
let variance = self
.q_value_history
.iter()
.map(|&q| (q - mean).powi(2))
.sum::<f64>()
/ 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);
}