fix: eval v_range tracks Q-stats — was [-100,100] producing constant val_Sharpe

Root cause: eval compute_expected_q used [-100,100] atom range. With 51
atoms that's delta_z=4.0 — all Q-values collapsed to ~0. Model weights
changed but eval actions didn't → identical val_Sharpe=-9.16 every epoch.

Fix: eval_v_range_buf initialized from config v_min/v_max, then updated
per-epoch from observed Q-stats (q_mean ± 3σ). Eval atoms now track
the actual Q-value distribution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-04-13 21:26:46 +02:00
parent 68bbdd050c
commit e306f9f0e0
4 changed files with 31 additions and 4 deletions

View File

@@ -1009,6 +1009,25 @@ impl GpuDqnTrainer {
}
pub fn eval_v_range_ptr(&self) -> u64 { self.eval_v_range_ptr }
/// Update eval v_range from observed Q-value statistics.
/// Called at epoch boundary after compute_q_stats.
pub fn update_eval_v_range(&mut self, q_mean: f32, q_std: f32) {
let half = (3.0 * q_std + 1.0).max(1.0); // 3σ + floor
let v_min = q_mean - half;
let v_max = q_mean + half;
unsafe {
let ptr = self.eval_v_range_ptr as *mut f32;
// Pinned device-mapped: write directly from host
// eval_v_range_buf is regular device mem — use async HtoD
cudarc::driver::sys::cuMemcpyHtoDAsync_v2(
self.eval_v_range_ptr,
[v_min, v_max].as_ptr().cast(),
2 * std::mem::size_of::<f32>(),
self.stream.cu_stream(),
);
}
}
pub fn set_per_sample_support_ptr(&mut self, ptr: u64) {
self.per_sample_support_ptr = ptr;
}
@@ -2426,7 +2445,8 @@ impl GpuDqnTrainer {
// Fixed wide v_range for compute_expected_q (argmax, not C51 loss).
let mut eval_v_range_buf = stream.alloc_zeros::<f32>(2)
.map_err(|e| MLError::ModelError(format!("alloc eval_v_range: {e}")))?;
let eval_range = [-100.0_f32, 100.0_f32];
// Eval v_range: initial from config, updated per-epoch from Q-stats.
let eval_range = [config.v_min, config.v_max];
stream.memcpy_htod(&eval_range, &mut eval_v_range_buf)
.map_err(|e| MLError::ModelError(format!("eval_v_range HtoD: {e}")))?;
let eval_v_range_ptr = eval_v_range_buf.raw_ptr();

View File

@@ -2083,6 +2083,10 @@ 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() }
/// 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);
}
/// Per-sample epsilon from IQL expectile gap.
pub(crate) fn per_sample_epsilon_ptr(&self) -> u64 { self.gpu_iql.per_sample_epsilon_ptr() }
pub(crate) fn num_atoms(&self) -> usize { self.trainer.config().num_atoms }

View File

@@ -513,8 +513,8 @@ impl DQNTrainer {
let f = &*fused_ptr;
(f.online_dueling_ref() as *const _, f.online_branching_ref() as *const _)
};
// Wide fixed range for eval expected_q argmax — per-sample support only used in training loss.
let vr = [-100.0_f32, 100.0_f32];
// 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];
let agent = self.agent.read().await;
let network_dims = agent.network_dims();

View File

@@ -1388,7 +1388,10 @@ impl DQNTrainer {
self.epoch_q_gap = self.epoch_q_gap.max(stats.avg_max_q as f32 - stats.q_mean);
self.epoch_atom_entropy = stats.atom_entropy;
self.epoch_atom_utilization = stats.atom_utilization;
let _q_gap = stats.avg_max_q as f32 - stats.q_mean;
// Update eval v_range from observed Q-stats
let q_std = stats.q_variance.max(0.0).sqrt();
fused.update_eval_v_range(stats.q_mean, q_std);
}
}
train_step_count += 1;