From 8c3b7f5c5e58f52bb24bfef5f6d4e21c3907b87a Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Tue, 14 Apr 2026 13:39:44 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20validation=20backtest=20uses=20EMA-smoot?= =?UTF-8?q?hed=20eval=20v=5Frange=20=E2=80=94=20was=20fixed=20[-240,=20240?= =?UTF-8?q?]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed v_range from hyperparams (reward_scale/gamma=±240) was 4800x too wide for Q-values near 0. All Q-values mapped to the same 2-3 center atoms, making backtest action selection essentially random → val_Sharpe jumped between -11 and +0.14 arbitrarily. Now uses the same EMA-smoothed eval_v_range that the experience collector uses. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs | 4 ++-- crates/ml/src/trainers/dqn/fused_training.rs | 5 +++++ crates/ml/src/trainers/dqn/trainer/metrics.rs | 7 +++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs b/crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs index 572e47ee1..5028f8385 100644 --- a/crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs +++ b/crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs @@ -633,8 +633,8 @@ pub struct GpuDqnTrainer { eval_v_range_buf: CudaSlice, eval_v_range_ptr: u64, /// EMA-smoothed Q-stats for stable eval v_range updates. - eval_q_mean_ema: f32, - eval_q_std_ema: f32, + pub(crate) eval_q_mean_ema: f32, + pub(crate) eval_q_std_ema: f32, /// Adaptive gradient clip norm — pinned device-mapped host memory. /// GPU reads via device pointer (graph-safe), host writes directly (no HtoD copy). diff --git a/crates/ml/src/trainers/dqn/fused_training.rs b/crates/ml/src/trainers/dqn/fused_training.rs index 0ba2ead87..1e2586b7e 100644 --- a/crates/ml/src/trainers/dqn/fused_training.rs +++ b/crates/ml/src/trainers/dqn/fused_training.rs @@ -2037,6 +2037,11 @@ impl FusedTrainingCtx { pub(crate) fn per_sample_support_ptr(&self) -> u64 { self.gpu_iql.per_sample_support_ptr() } /// Fixed wide v_range pointer for eval compute_expected_q (not C51 loss). pub(crate) fn eval_v_range_ptr(&self) -> u64 { self.trainer.eval_v_range_ptr() } + /// Return EMA-smoothed eval v_range as [v_min, v_max]. + pub(crate) fn eval_v_range(&self) -> [f32; 2] { + let half = (3.0 * self.trainer.eval_q_std_ema + 1.0_f32).max(1.0); + [self.trainer.eval_q_mean_ema - half, self.trainer.eval_q_mean_ema + half] + } /// Update eval v_range from observed Q-value statistics. pub(crate) fn update_eval_v_range(&mut self, q_mean: f32, q_std: f32) { self.trainer.update_eval_v_range(q_mean, q_std); diff --git a/crates/ml/src/trainers/dqn/trainer/metrics.rs b/crates/ml/src/trainers/dqn/trainer/metrics.rs index 97d7d2e97..1496323c1 100644 --- a/crates/ml/src/trainers/dqn/trainer/metrics.rs +++ b/crates/ml/src/trainers/dqn/trainer/metrics.rs @@ -513,8 +513,11 @@ impl DQNTrainer { let f = &*fused_ptr; (f.online_dueling_ref() as *const _, f.online_branching_ref() as *const _) }; - // Eval v_range from hyperparams — must match the training config's v_min/v_max. - let vr = [self.hyperparams.computed_v_min() as f32, self.hyperparams.computed_v_max() as f32]; + // Eval v_range from EMA-smoothed Q-stats — adaptive to actual Q-value scale. + // Fixed [-240, 240] was 4800x too wide for Q-values near 0, making backtest + // action selection essentially random (all Q-values map to same center atoms). + let fused_ref = unsafe { &*fused_ptr }; + let vr = fused_ref.eval_v_range(); let agent = self.agent.read().await; let network_dims = agent.network_dims();