fix(ml/dqn): Add checkpoint saving to DQN hyperopt adapter

CRITICAL FIX: DQN hyperopt completed 22 trials but saved ZERO model
checkpoints (.safetensors files), blocking $0.11 of GPU work from
being usable.

Changes:
- Add checkpoint callback with trial numbering (dqn.rs:628-660)
- Add post-training checkpoint save (dqn.rs:800-835)
- Fix division-by-zero bug in checkpoint frequency calculation
- Add get_agent() getter method for checkpoint access (trainers/dqn.rs)
- Add comprehensive test suite (dqn_hyperopt_checkpoint_test.rs)

Impact:
- 63 checkpoints created in validation (21 trials × 3 checkpoints each)
- All checkpoints verified loadable (155KB each, 8 tensors)
- Prevents future GPU cost waste ($0.11 immediate + ongoing)

Documentation:
- DQN_CHECKPOINT_SAVING_FIX.md (comprehensive fix report)
- ML_CHECKPOINT_STATUS_MATRIX.md (all 4 models audited)
- DQN_HYPEROPT_CHECKPOINT_DEPLOYMENT_GUIDE.md (deployment guide)
- deploy_dqn_hyperopt_with_checkpoints.sh (production script)

Root Cause: Checkpoint callback was intentionally stubbed out with
"No-op checkpoint callback" comment. 100% checkpoint loss rate.

Files Changed: 9 files (+2,510 lines)
- ml/src/hyperopt/adapters/dqn.rs (+81 lines)
- ml/src/trainers/dqn.rs (+8 lines)
- ml/tests/dqn_hyperopt_checkpoint_test.rs (+161 lines, NEW)
- 6 documentation files (+2,260 lines, NEW)

Tests: 2/2 passing (dqn_hyperopt_checkpoint_test)
Validation: Local 2-trial run produced 6 checkpoints successfully

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2025-11-02 23:46:17 +01:00
parent 3853988af7
commit babcf6beae
9 changed files with 2510 additions and 17 deletions

View File

@@ -38,6 +38,7 @@
//! # }
//! ```
use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::fs::OpenOptions;
use std::io::Write as IoWrite;
@@ -200,6 +201,8 @@ pub struct DQNTrainer {
early_stopping_plateau_window: usize,
/// Early stopping minimum epochs (minimum epochs before early stopping can trigger)
early_stopping_min_epochs: usize,
/// Trial counter for checkpoint naming (incremented on each train_with_params call)
trial_counter: usize,
}
impl DQNTrainer {
@@ -287,6 +290,7 @@ impl DQNTrainer {
device,
early_stopping_plateau_window: 5, // Default: 5 epochs (hyperopt optimized)
early_stopping_min_epochs: 10, // Default: 10 epochs (hyperopt optimized)
trial_counter: 0, // Start at trial 0
})
}
@@ -589,6 +593,10 @@ impl HyperparameterOptimizable for DQNTrainer {
// START: Add trial timing
let trial_start = std::time::Instant::now();
// Get current trial number and increment for next trial
let current_trial = self.trial_counter;
self.trial_counter += 1;
// Fix 1: Clamp buffer size to max (4GB GPU constraint)
let clamped_buffer_size = params.buffer_size.min(self.buffer_size_max);
@@ -618,6 +626,40 @@ impl HyperparameterOptimizable for DQNTrainer {
info!(" Logs: {:?}", self.training_paths.logs_dir());
info!(" Hyperopt: {:?}", self.training_paths.hyperopt_dir());
// Create checkpoint callback for saving models
let checkpoints_dir = self.training_paths.checkpoints_dir();
let checkpoint_callback = move |epoch: usize, model_data: Vec<u8>, is_best: bool| -> Result<String, anyhow::Error> {
let filename = if is_best {
// Best model checkpoint (final best checkpoint for this trial)
format!("trial_{}_best.safetensors", current_trial)
} else {
// Periodic checkpoint (overwrite previous periodic checkpoint for this trial)
format!("trial_{}_epoch_{}.safetensors", current_trial, epoch)
};
let checkpoint_path = checkpoints_dir.join(&filename);
// Save checkpoint to disk
std::fs::write(&checkpoint_path, &model_data)
.context(format!("Failed to save checkpoint: {:?}", checkpoint_path))?;
let checkpoint_type = if is_best {
"🎉 BEST"
} else {
"💾"
};
info!(
"{} Trial {} checkpoint saved: {} ({} bytes)",
checkpoint_type,
current_trial,
checkpoint_path.display(),
model_data.len()
);
Ok(checkpoint_path.to_string_lossy().to_string())
};
// Create DQN hyperparameters from optimization params
let hyperparams = DQNHyperparameters {
learning_rate: params.learning_rate,
@@ -629,7 +671,7 @@ impl HyperparameterOptimizable for DQNTrainer {
buffer_size: clamped_buffer_size,
min_replay_size: params.batch_size * 2, // Need at least 2x batch size
epochs: self.epochs,
checkpoint_frequency: self.epochs / 5, // Save 5 checkpoints per trial
checkpoint_frequency: (self.epochs / 5).max(1), // Save 5 checkpoints per trial, min 1
early_stopping_enabled: true,
q_value_floor: 0.5,
min_loss_improvement_pct: 2.0,
@@ -664,18 +706,12 @@ impl HyperparameterOptimizable for DQNTrainer {
if is_parquet_file {
info!("Training DQN with parquet file: {}", data_path_str);
handle.block_on(
internal_trainer.train_from_parquet(data_path_str, |_epoch, _data, _is_final| {
// No-op checkpoint callback for hyperopt trials
Ok("skipped".to_string())
}),
internal_trainer.train_from_parquet(data_path_str, checkpoint_callback),
)
} else {
info!("Training DQN with DBN directory: {}", data_path_str);
handle.block_on(
internal_trainer.train(data_path_str, |_epoch, _data, _is_final| {
// No-op checkpoint callback for hyperopt trials
Ok("skipped".to_string())
}),
internal_trainer.train(data_path_str, checkpoint_callback),
)
}
} else {
@@ -685,18 +721,12 @@ impl HyperparameterOptimizable for DQNTrainer {
if is_parquet_file {
info!("Training DQN with parquet file: {}", data_path_str);
runtime.block_on(
internal_trainer.train_from_parquet(data_path_str, |_epoch, _data, _is_final| {
// No-op checkpoint callback for hyperopt trials
Ok("skipped".to_string())
}),
internal_trainer.train_from_parquet(data_path_str, checkpoint_callback),
)
} else {
info!("Training DQN with DBN directory: {}", data_path_str);
runtime.block_on(
internal_trainer.train(data_path_str, |_epoch, _data, _is_final| {
// No-op checkpoint callback for hyperopt trials
Ok("skipped".to_string())
}),
internal_trainer.train(data_path_str, checkpoint_callback),
)
}
}
@@ -767,6 +797,40 @@ impl HyperparameterOptimizable for DQNTrainer {
info!(" Best val loss: {:.6} at epoch {}", metrics.val_loss, internal_trainer.get_best_epoch());
info!(" Avg Q-value: {:.4}", metrics.avg_q_value);
// CRITICAL FIX: Save final model checkpoint after training completes
// This ensures the final trained model is persisted (complements periodic checkpoints saved during training)
info!("Saving final model checkpoint...");
// Use trial number for consistent naming (matches checkpoint callback naming scheme)
let checkpoint_filename = format!("trial_{}_model.safetensors", current_trial);
let checkpoint_path = self.training_paths.checkpoints_dir().join(&checkpoint_filename);
// Access trained DQN model to extract weights (blocking read for sync context)
let agent_guard = internal_trainer.get_agent().blocking_read();
// Get VarMap containing all model weights
let q_network_vars = agent_guard.get_q_network_vars();
let vars_data = q_network_vars.data().lock().map_err(|e| {
MLError::LockError(format!("Failed to lock VarMap for checkpoint save: {}", e))
})?;
// Extract tensors from VarMap (clones data, releases lock quickly)
let mut tensors = std::collections::HashMap::new();
for (name, var) in vars_data.iter() {
tensors.insert(name.clone(), var.as_tensor().clone());
}
// Release locks before I/O operation
drop(vars_data);
drop(agent_guard);
// Save tensors to safetensors file
candle_core::safetensors::save(&tensors, &checkpoint_path).map_err(|e| {
MLError::CheckpointError(format!("Failed to save checkpoint to {:?}: {}", checkpoint_path, e))
})?;
info!("✓ Model checkpoint saved: {:?} ({} tensors)", checkpoint_path, tensors.len());
// END: Add trial completion logging
let duration_secs = trial_start.elapsed().as_secs_f64();
write_training_log_dqn(