feat: trajectory backtracking — checkpoint rewind + informed perturbation

Detects Q-gap plateau (5 consecutive frozen epochs), rewinds to
best-improvement checkpoint, applies perturbation strategy cycle:
1. Reset Adam momentum
2. Shrink-perturb branch heads
3. Temperature boost (2× for 5 epochs)
4. Learning rate boost (2× for 10 epochs)

3 rewinds × 4 routes = 12 max attempts. On exhaustion: save best model,
exit with PLATEAU_EXHAUSTED. Saves ~3 hours H100 vs frozen training.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-04-15 07:01:19 +02:00
parent 3a6cfe9a9b
commit 1fdc712df6
5 changed files with 446 additions and 0 deletions

View File

@@ -3502,6 +3502,58 @@ impl GpuDqnTrainer {
&self.target_params_buf
}
/// Mutable reference to the flat f32 target parameter buffer.
pub fn target_params_mut(&mut self) -> &mut CudaSlice<f32> {
&mut self.target_params_buf
}
/// Download online params from GPU to host (synchronous DtoH).
pub fn download_params(&self, dst: &mut [f32]) -> Result<(), MLError> {
unsafe { cudarc::driver::sys::cuStreamSynchronize(self.stream.cu_stream()); }
self.stream.memcpy_dtoh(&self.params_buf, dst)
.map_err(|e| MLError::ModelError(format!("download params: {e}")))
}
/// Upload online params from host to GPU (HtoD).
pub fn upload_params(&mut self, src: &[f32]) -> Result<(), MLError> {
self.stream.memcpy_htod(src, &mut self.params_buf)
.map_err(|e| MLError::ModelError(format!("upload params: {e}")))
}
/// Download target params from GPU to host (synchronous DtoH).
pub fn download_target_params(&self, dst: &mut [f32]) -> Result<(), MLError> {
unsafe { cudarc::driver::sys::cuStreamSynchronize(self.stream.cu_stream()); }
self.stream.memcpy_dtoh(&self.target_params_buf, dst)
.map_err(|e| MLError::ModelError(format!("download target params: {e}")))
}
/// Upload target params from host to GPU (HtoD).
pub fn upload_target_params(&mut self, src: &[f32]) -> Result<(), MLError> {
self.stream.memcpy_htod(src, &mut self.target_params_buf)
.map_err(|e| MLError::ModelError(format!("upload target params: {e}")))
}
/// Read eval_q_mean_ema.
pub fn eval_q_mean_ema(&self) -> f32 { self.eval_q_mean_ema }
/// Read eval_q_std_ema.
pub fn eval_q_std_ema(&self) -> f32 { self.eval_q_std_ema }
/// Read per_branch_q_gap_ema.
pub fn per_branch_q_gap_ema(&self) -> [f32; 4] { self.per_branch_q_gap_ema }
/// Set eval_q_mean_ema (for trajectory backtracking restore).
pub fn set_eval_q_mean_ema(&mut self, v: f32) { self.eval_q_mean_ema = v; }
/// Set eval_q_std_ema (for trajectory backtracking restore).
pub fn set_eval_q_std_ema(&mut self, v: f32) { self.eval_q_std_ema = v; }
/// Set per_branch_q_gap_ema (for trajectory backtracking restore).
pub fn set_per_branch_q_gap_ema(&mut self, v: [f32; 4]) { self.per_branch_q_gap_ema = v; }
/// Read adam_step counter.
pub fn adam_step_val(&self) -> i32 { self.adam_step }
// ═══════════════════════════════════════════════════════════════════
/// GPU-direct training step — no CPU roundtrip.

View File

@@ -2110,6 +2110,61 @@ impl FusedTrainingCtx {
self.trainer.total_params()
}
// ── Trajectory backtracking accessors ──────────────────────────────
/// Download online params from GPU to host (synchronous DtoH).
pub(crate) fn download_params(&self, dst: &mut [f32]) -> anyhow::Result<()> {
self.trainer.download_params(dst)
.map_err(|e| anyhow::anyhow!("download params: {e}"))
}
/// Upload online params from host to GPU (HtoD).
pub(crate) fn upload_params(&mut self, src: &[f32]) -> anyhow::Result<()> {
self.trainer.upload_params(src)
.map_err(|e| anyhow::anyhow!("upload params: {e}"))
}
/// Download target params from GPU to host (synchronous DtoH).
pub(crate) fn download_target_params(&self, dst: &mut [f32]) -> anyhow::Result<()> {
self.trainer.download_target_params(dst)
.map_err(|e| anyhow::anyhow!("download target params: {e}"))
}
/// Upload target params from host to GPU (HtoD).
pub(crate) fn upload_target_params(&mut self, src: &[f32]) -> anyhow::Result<()> {
self.trainer.upload_target_params(src)
.map_err(|e| anyhow::anyhow!("upload target params: {e}"))
}
/// Reset Adam optimizer state (m=0, v=0, step=0).
pub(crate) fn reset_adam_state(&mut self) -> anyhow::Result<()> {
self.trainer.reset_adam_state()
.map_err(|e| anyhow::anyhow!("reset adam: {e}"))
}
/// Read adam_step counter.
pub(crate) fn adam_step_val(&self) -> i32 { self.trainer.adam_step_val() }
/// Read eval_q_mean_ema.
pub(crate) fn eval_q_mean_ema(&self) -> f32 { self.trainer.eval_q_mean_ema() }
/// Read eval_q_std_ema.
pub(crate) fn eval_q_std_ema(&self) -> f32 { self.trainer.eval_q_std_ema() }
/// Read per_branch_q_gap_ema.
pub(crate) fn per_branch_q_gap_ema(&self) -> [f32; 4] { self.trainer.per_branch_q_gap_ema() }
/// Set eval_q_mean_ema (trajectory backtracking restore).
pub(crate) fn set_eval_q_mean_ema(&mut self, v: f32) { self.trainer.set_eval_q_mean_ema(v); }
/// Set eval_q_std_ema (trajectory backtracking restore).
pub(crate) fn set_eval_q_std_ema(&mut self, v: f32) { self.trainer.set_eval_q_std_ema(v); }
/// Set per_branch_q_gap_ema (trajectory backtracking restore).
pub(crate) fn set_per_branch_q_gap_ema(&mut self, v: [f32; 4]) { self.trainer.set_per_branch_q_gap_ema(v); }
// ── End trajectory backtracking accessors ──────────────────────────
/// Raw pointer + length of the current flat f32 params buffer.
pub(crate) fn params_flat(&self) -> (u64, usize) {
let p = self.trainer.params();

View File

@@ -761,6 +761,7 @@ impl DQNTrainer {
fold_val_start: 0,
fold_val_end: 0,
cached_n_episodes: None,
backtracking: super::BacktrackingState::new(),
})
}

View File

@@ -43,7 +43,116 @@ mod training_loop;
#[cfg(test)]
mod tests;
/// Saved training state for trajectory backtracking.
pub(crate) struct TrajectoryCheckpoint {
pub epoch: usize,
pub improvement_rate: f32, // d(val_Sharpe)/d(epoch) at save time
pub params_host: Vec<f32>, // DtoH copy of params_buf
pub target_params_host: Vec<f32>, // DtoH copy of target_params_buf
pub adam_step: i32, // optimizer step counter
pub eval_q_mean_ema: f32,
pub eval_q_std_ema: f32,
pub per_branch_q_gap_ema: [f32; 4],
}
/// Perturbation strategy for a rewind route.
#[derive(Debug, Clone, Copy)]
pub(crate) enum PerturbationStrategy {
/// Reset Adam momentum (m=0, v=0) -- fresh gradient estimates
ResetAdam,
/// Shrink-perturb branch heads only (sigma=0.1)
ShrinkPerturbBranches,
/// Temperature boost (2x Boltzmann + SARSA tau for 5 epochs, then cool)
TemperatureBoost,
/// Learning rate x2 for 10 epochs then restore
LearningRateBoost,
}
/// Backtracking state machine.
pub(crate) struct BacktrackingState {
/// Top-3 checkpoints by improvement rate.
pub checkpoints: Vec<TrajectoryCheckpoint>,
/// Maximum checkpoints to keep.
pub max_checkpoints: usize,
/// Current rewind count (max 3).
pub rewind_count: usize,
/// Max rewinds before PLATEAU_EXHAUSTED.
pub max_rewinds: usize,
/// Current route index within a rewind (0-3).
pub route_idx: usize,
/// Routes per rewind (4 perturbation strategies).
pub routes_per_rewind: usize,
/// Consecutive plateau epochs (Q-gap velocity ~ 0).
pub plateau_epochs: usize,
/// Epochs required to trigger a rewind.
pub plateau_threshold: usize,
/// Minimum epochs between rewinds.
pub min_rewind_interval: usize,
/// Epoch of last rewind.
pub last_rewind_epoch: usize,
/// Whether a route is currently active (post-rewind trial).
pub route_active: bool,
/// Epoch when the current route started.
pub route_start_epoch: usize,
/// Epochs to evaluate a route before deciding.
pub route_eval_epochs: usize,
/// Val Sharpe at route start (to measure improvement).
pub route_start_sharpe: f32,
/// Minimum improvement rate to consider a route successful.
pub min_improvement_rate: f32,
/// Best val_Sharpe seen across all attempts.
pub best_sharpe: f32,
/// Epoch of best val_Sharpe.
pub best_epoch: usize,
/// Temperature boost active (decrements each epoch).
pub temp_boost_remaining: usize,
/// LR boost active (decrements each epoch).
pub lr_boost_remaining: usize,
/// Original LR (saved before LR boost).
pub original_lr: f64,
}
impl BacktrackingState {
pub(crate) fn new() -> Self {
Self {
checkpoints: Vec::new(),
max_checkpoints: 3,
rewind_count: 0,
max_rewinds: 3,
route_idx: 0,
routes_per_rewind: 4,
plateau_epochs: 0,
plateau_threshold: 5, // 5 consecutive plateau epochs
min_rewind_interval: 10,
last_rewind_epoch: 0,
route_active: false,
route_start_epoch: 0,
route_eval_epochs: 10,
route_start_sharpe: 0.0,
min_improvement_rate: 0.1, // min 0.1 val_Sharpe/epoch improvement
best_sharpe: f32::NEG_INFINITY,
best_epoch: 0,
temp_boost_remaining: 0,
lr_boost_remaining: 0,
original_lr: 0.0,
}
}
/// The 4 perturbation strategies, tried in order.
pub(crate) fn current_strategy(&self) -> PerturbationStrategy {
match self.route_idx % 4 {
0 => PerturbationStrategy::ResetAdam,
1 => PerturbationStrategy::ShrinkPerturbBranches,
2 => PerturbationStrategy::TemperatureBoost,
_ => PerturbationStrategy::LearningRateBoost,
}
}
/// Check if all rewinds and routes are exhausted.
pub(crate) fn is_exhausted(&self) -> bool {
self.rewind_count >= self.max_rewinds && self.route_idx >= self.routes_per_rewind
}
}
pub struct DQNTrainer {
/// DQN agent
@@ -332,6 +441,9 @@ pub struct DQNTrainer {
/// Cached GPU n_episodes (computed once, reused across epochs/folds).
pub(crate) cached_n_episodes: Option<i32>,
/// Task 10: Trajectory backtracking state machine for plateau recovery.
pub(crate) backtracking: BacktrackingState,
}
impl std::fmt::Debug for DQNTrainer {

View File

@@ -539,6 +539,19 @@ impl DQNTrainer {
&mut checkpoint_callback,
).await?;
// Task 10: Trajectory backtracking -- detect plateau, rewind, perturb
if self.run_backtracking_epoch_end(epoch, log_output.epoch_sharpe)? {
// PLATEAU_EXHAUSTED -- save best model and exit
info!(
"PLATEAU_EXHAUSTED: restoring best model from epoch {} before exit",
self.backtracking.best_epoch
);
let _ = self.restore_best_gpu_params();
let checkpoint_data = self.serialize_model().await?;
let _ = checkpoint_callback(epoch + 1, checkpoint_data, true);
break;
}
if (epoch + 1) % self.hyperparams.checkpoint_frequency == 0 {
info!(
"Saving periodic checkpoint at epoch {}/{}",
@@ -2282,5 +2295,218 @@ impl DQNTrainer {
Ok(())
}
// ═══════════════════════════════════════════════════════════════════════
// Task 10: Trajectory Backtracking helpers
// ═══════════════════════════════════════════════════════════════════════
/// Detect whether Q-gap is frozen (all branches have near-zero velocity).
fn is_q_gap_frozen(&self) -> bool {
if let Some(ref fused) = self.fused_ctx {
let emas = fused.per_branch_q_gap_ema();
// Current q_gap approximated as uniform across branches (same as update_eval_v_range)
let q_gap = self.epoch_q_gap;
let current = [q_gap; 4];
let threshold = 0.001_f32;
current.iter().zip(emas.iter()).all(|(gap, ema)| (gap - ema).abs() < threshold)
} else {
false
}
}
/// Save a trajectory checkpoint when improvement is detected.
fn save_backtracking_checkpoint(&mut self, epoch: usize, improvement_rate: f32, val_sharpe: f32) -> anyhow::Result<()> {
if let Some(ref fused) = self.fused_ctx {
let total_params = fused.total_param_count();
// DtoH copy of params and target params
let mut params_host = vec![0.0_f32; total_params];
let mut target_host = vec![0.0_f32; total_params];
fused.download_params(&mut params_host)?;
fused.download_target_params(&mut target_host)?;
let checkpoint = super::TrajectoryCheckpoint {
epoch,
improvement_rate,
params_host,
target_params_host: target_host,
adam_step: fused.adam_step_val(),
eval_q_mean_ema: fused.eval_q_mean_ema(),
eval_q_std_ema: fused.eval_q_std_ema(),
per_branch_q_gap_ema: fused.per_branch_q_gap_ema(),
};
// Insert sorted by improvement rate, keep top-3
self.backtracking.checkpoints.push(checkpoint);
self.backtracking.checkpoints.sort_by(|a, b|
b.improvement_rate.partial_cmp(&a.improvement_rate).unwrap_or(std::cmp::Ordering::Equal)
);
if self.backtracking.checkpoints.len() > self.backtracking.max_checkpoints {
self.backtracking.checkpoints.pop();
}
info!(epoch, improvement_rate, checkpoints = self.backtracking.checkpoints.len(),
"Backtracking checkpoint saved (val_Sharpe={val_sharpe:.2})");
}
Ok(())
}
/// Execute a rewind to the best checkpoint with the current perturbation strategy.
fn execute_rewind(&mut self, epoch: usize) -> anyhow::Result<()> {
let checkpoint_idx = self.backtracking.rewind_count.min(self.backtracking.checkpoints.len() - 1);
let strategy = self.backtracking.current_strategy();
// Log before borrowing checkpoint (strategy is Copy)
let ckpt_epoch = self.backtracking.checkpoints[checkpoint_idx].epoch;
info!(
epoch,
rewind_to = ckpt_epoch,
strategy = ?strategy,
rewind_count = self.backtracking.rewind_count,
route_idx = self.backtracking.route_idx,
"TRAJECTORY BACKTRACK: rewinding to epoch {} with {:?}",
ckpt_epoch, strategy
);
if let Some(ref mut fused) = self.fused_ctx {
// Upload params from checkpoint
fused.upload_params(&self.backtracking.checkpoints[checkpoint_idx].params_host)?;
fused.upload_target_params(&self.backtracking.checkpoints[checkpoint_idx].target_params_host)?;
// Restore EMA state
let ema_mean = self.backtracking.checkpoints[checkpoint_idx].eval_q_mean_ema;
let ema_std = self.backtracking.checkpoints[checkpoint_idx].eval_q_std_ema;
let ema_gaps = self.backtracking.checkpoints[checkpoint_idx].per_branch_q_gap_ema;
fused.set_eval_q_mean_ema(ema_mean);
fused.set_eval_q_std_ema(ema_std);
fused.set_per_branch_q_gap_ema(ema_gaps);
// Apply perturbation strategy
match strategy {
super::PerturbationStrategy::ResetAdam => {
fused.reset_adam_state()?;
info!("Perturbation: Adam momentum reset (m=0, v=0)");
}
super::PerturbationStrategy::ShrinkPerturbBranches => {
fused.shrink_and_perturb(0.9, 0.1)?;
info!("Perturbation: shrink-perturb (alpha=0.9, sigma=0.1)");
}
super::PerturbationStrategy::TemperatureBoost => {
self.backtracking.temp_boost_remaining = 5;
info!("Perturbation: temperature boost (2x for 5 epochs)");
}
super::PerturbationStrategy::LearningRateBoost => {
self.backtracking.original_lr = self.hyperparams.learning_rate;
self.hyperparams.learning_rate *= 2.0;
self.backtracking.lr_boost_remaining = 10;
info!(lr = self.hyperparams.learning_rate, "Perturbation: LR boost (2x for 10 epochs)");
}
}
}
self.backtracking.last_rewind_epoch = epoch;
self.backtracking.route_active = true;
self.backtracking.route_start_epoch = epoch;
self.backtracking.route_start_sharpe = self.backtracking.best_sharpe;
self.backtracking.plateau_epochs = 0;
Ok(())
}
/// Run backtracking logic at the end of each epoch.
/// Returns `true` if PLATEAU_EXHAUSTED — caller should break the epoch loop.
fn run_backtracking_epoch_end(&mut self, epoch: usize, val_sharpe: f64) -> anyhow::Result<bool> {
let val_sharpe_f32 = val_sharpe as f32;
// 1. Track best Sharpe
if val_sharpe_f32 > self.backtracking.best_sharpe {
self.backtracking.best_sharpe = val_sharpe_f32;
self.backtracking.best_epoch = epoch;
}
// 2. Save checkpoint when improving
let prev_sharpe = self.sharpe_history.last().copied().unwrap_or(0.0) as f32;
let improvement_rate = val_sharpe_f32 - prev_sharpe; // per epoch
if improvement_rate > 0.01 && !self.backtracking.route_active {
self.save_backtracking_checkpoint(epoch, improvement_rate, val_sharpe_f32)?;
}
// 3. Detect plateau (Q-gap velocity ~ 0)
let q_gap_frozen = self.is_q_gap_frozen();
if q_gap_frozen {
self.backtracking.plateau_epochs += 1;
} else {
self.backtracking.plateau_epochs = 0;
}
// 4. Evaluate active route
if self.backtracking.route_active
&& epoch.saturating_sub(self.backtracking.route_start_epoch) >= self.backtracking.route_eval_epochs
{
let route_improvement = (val_sharpe_f32 - self.backtracking.route_start_sharpe)
/ self.backtracking.route_eval_epochs as f32;
if route_improvement >= self.backtracking.min_improvement_rate {
// Route succeeded -- continue training normally
info!(epoch, route_improvement, "Backtracking route succeeded -- continuing");
self.backtracking.route_active = false;
self.backtracking.plateau_epochs = 0;
} else {
// Route failed -- try next route or next rewind
info!(epoch, route_improvement, "Backtracking route failed -- trying next");
self.backtracking.route_idx += 1;
if self.backtracking.route_idx >= self.backtracking.routes_per_rewind {
// All routes for this rewind exhausted -- try next checkpoint
self.backtracking.rewind_count += 1;
self.backtracking.route_idx = 0;
}
self.backtracking.route_active = false;
self.backtracking.plateau_epochs = self.backtracking.plateau_threshold; // re-trigger immediately
}
}
// 5. Trigger rewind if plateau detected
if self.backtracking.plateau_epochs >= self.backtracking.plateau_threshold
&& !self.backtracking.route_active
&& epoch.saturating_sub(self.backtracking.last_rewind_epoch) >= self.backtracking.min_rewind_interval
&& !self.backtracking.checkpoints.is_empty()
{
if self.backtracking.is_exhausted() {
// All attempts exhausted -- early termination
info!(
best_sharpe = self.backtracking.best_sharpe,
best_epoch = self.backtracking.best_epoch,
rewinds = self.backtracking.rewind_count,
"PLATEAU_EXHAUSTED: {}x{} = {} attempts. Best val_Sharpe={:.2} at epoch {}. Structural change needed.",
self.backtracking.max_rewinds,
self.backtracking.routes_per_rewind,
self.backtracking.max_rewinds * self.backtracking.routes_per_rewind,
self.backtracking.best_sharpe,
self.backtracking.best_epoch,
);
return Ok(true); // signal caller to break
}
// Rewind to best checkpoint
self.execute_rewind(epoch)?;
}
// 6. Handle temperature/LR boost cooldown
if self.backtracking.temp_boost_remaining > 0 {
self.backtracking.temp_boost_remaining -= 1;
}
if self.backtracking.lr_boost_remaining > 0 {
self.backtracking.lr_boost_remaining -= 1;
if self.backtracking.lr_boost_remaining == 0 {
// Restore original LR
self.hyperparams.learning_rate = self.backtracking.original_lr;
info!(lr = self.hyperparams.learning_rate, "LR boost expired -- restored original learning rate");
}
}
Ok(false)
}
}