Files
foxhunt/crates/ml/src/ppo/trainable_adapter.rs
jgrusewski 448b61d095 refactor: collapse 9-level to 7-level ExposureLevel — eliminate degenerate Flat variants
The 4-branch DQN (direction x magnitude) had 3 degenerate variants
(Short25, Flat, Long25) that all mapped to 0.0 target exposure when
direction=Flat, causing 82% Flat collapse. Collapse these into a
single Flat variant, giving 7 levels (ShortSmall/Half/Full, Flat,
LongSmall/Half/Full) and 63 total factored actions (7x3x3).

- ExposureLevel enum: 9 variants -> 7 (add direction/magnitude/from_dir_mag)
- FactoredAction: 81 -> 63 total actions, from_index/to_index updated
- DQN epsilon-greedy: use from_dir_mag() instead of dir*3+mag indexing
- DQN config: num_actions default 9 -> 7
- PPO action space: 45 -> 63 actions, action masking updated
- Signal adapter CUDA kernel: 5-bin -> 7-bin exposure aggregation
- All tests updated for new variant names and index ranges

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:54:09 +02:00

272 lines
8.5 KiB
Rust

//! UnifiedTrainable implementation for PPO model
//!
//! This module provides the UnifiedTrainable trait implementation for PPO,
//! enabling standardized training orchestration with checkpoint management,
//! metrics collection, and batch training support.
use ml_core::device::MlDevice;
use ml_core::native_types::NativeDevice;
use std::collections::HashMap;
use std::path::Path;
use tracing::info;
use super::ppo::{PPOConfig, PPO};
use super::trajectories::TrajectoryBatch;
use crate::training::unified_trainer::{CheckpointMetadata, TrainingMetrics, UnifiedTrainable};
use crate::MLError;
/// Unified PPO model for standardized training
pub struct UnifiedPPO {
/// Underlying PPO model
ppo: PPO,
/// Current training step
step: usize,
/// Policy learning rate
policy_lr: f64,
/// Value learning rate
value_lr: f64,
/// Last training loss (policy + value)
last_policy_loss: f64,
last_value_loss: f64,
/// Gradient norm from last backward pass
last_grad_norm: Option<f64>,
/// Custom metrics storage
custom_metrics: HashMap<String, f64>,
}
impl std::fmt::Debug for UnifiedPPO {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UnifiedPPO")
.field("step", &self.step)
.field("policy_lr", &self.policy_lr)
.field("value_lr", &self.value_lr)
.field("last_policy_loss", &self.last_policy_loss)
.field("last_value_loss", &self.last_value_loss)
.field("last_grad_norm", &self.last_grad_norm)
.field("custom_metrics", &self.custom_metrics)
.finish_non_exhaustive()
}
}
impl UnifiedPPO {
/// Create new UnifiedPPO from config and device
pub fn new(config: PPOConfig, _device: MlDevice) -> Result<Self, MLError> {
let policy_lr = config.policy_learning_rate;
let value_lr = config.value_learning_rate;
let ppo = PPO::new(config)?;
Ok(Self {
ppo,
step: 0,
policy_lr,
value_lr,
last_policy_loss: 0.0,
last_value_loss: 0.0,
last_grad_norm: None,
custom_metrics: HashMap::new(),
})
}
/// Get reference to underlying PPO model
pub fn inner(&self) -> &PPO {
&self.ppo
}
/// Get mutable reference to underlying PPO model
pub fn inner_mut(&mut self) -> &mut PPO {
&mut self.ppo
}
}
impl UnifiedTrainable for UnifiedPPO {
fn model_type(&self) -> &str {
"PPO"
}
fn device_name(&self) -> String {
NativeDevice::Cuda(0).to_string()
}
fn forward_loss(&mut self, _input: &[f32], _target: &[f32]) -> Result<f64, MLError> {
// PPO uses trajectory-based update, not supervised forward-loss
Ok(self.last_policy_loss + self.last_value_loss)
}
fn backward(&mut self, _loss_value: f64) -> Result<f64, MLError> {
// PPO backward pass is integrated into update() method
Ok(self.last_grad_norm.unwrap_or(0.0))
}
fn optimizer_step(&mut self) -> Result<(), MLError> {
// PPO optimizer step is integrated into update() method
Ok(())
}
fn zero_grad(&mut self) -> Result<(), MLError> {
// PPO uses Adam optimizer which handles gradient zeroing internally
Ok(())
}
fn get_learning_rate(&self) -> f64 {
self.policy_lr
}
fn set_learning_rate(&mut self, lr: f64) -> Result<(), MLError> {
self.policy_lr = lr;
self.value_lr = lr;
self.ppo.update_learning_rates(lr, lr)?;
info!("Updated PPO learning rate to {}", lr);
Ok(())
}
fn get_step(&self) -> usize {
self.step
}
fn collect_metrics(&self) -> TrainingMetrics {
let mut metrics = TrainingMetrics::default();
metrics.loss = self.last_policy_loss + self.last_value_loss;
metrics.learning_rate = self.policy_lr;
metrics.grad_norm = self.last_grad_norm;
metrics
.custom_metrics
.insert("policy_loss".to_owned(), self.last_policy_loss);
metrics
.custom_metrics
.insert("value_loss".to_owned(), self.last_value_loss);
metrics
.custom_metrics
.insert("policy_lr".to_owned(), self.policy_lr);
metrics
.custom_metrics
.insert("value_lr".to_owned(), self.value_lr);
for (key, value) in &self.custom_metrics {
metrics.custom_metrics.insert(key.clone(), *value);
}
metrics
}
fn save_checkpoint(&self, checkpoint_path: &str) -> Result<String, MLError> {
info!("Saving PPO checkpoint to {}", checkpoint_path);
if let Some(parent) = Path::new(checkpoint_path).parent() {
std::fs::create_dir_all(parent).map_err(|e| {
MLError::CheckpointError(format!("Failed to create checkpoint directory: {}", e))
})?;
}
// Save PPO checkpoint via the unified PPO::save_checkpoint API
let ckpt_path = std::path::PathBuf::from(checkpoint_path);
self.ppo.save_checkpoint(&ckpt_path).map_err(|e| {
MLError::CheckpointError(format!("Failed to save PPO checkpoint: {}", e))
})?;
let metadata = CheckpointMetadata {
model_type: "PPO".to_owned(),
version: "1.0.0".to_owned(),
epoch: self.step,
step: self.step,
timestamp: std::time::SystemTime::now(),
config: serde_json::to_value(self.ppo.get_config()).map_err(|e| {
MLError::CheckpointError(format!("Failed to serialize config: {}", e))
})?,
metrics: self.collect_metrics(),
};
crate::training::unified_trainer::checkpoint::save_metadata(&metadata, checkpoint_path)?;
info!("PPO checkpoint saved successfully");
Ok(checkpoint_path.to_string())
}
fn load_checkpoint(&mut self, checkpoint_path: &str) -> Result<CheckpointMetadata, MLError> {
info!("Loading PPO checkpoint from {}", checkpoint_path);
let metadata =
crate::training::unified_trainer::checkpoint::load_metadata(checkpoint_path)?;
if metadata.model_type != "PPO" {
return Err(MLError::CheckpointError(format!(
"Model type mismatch: expected PPO, got {}",
metadata.model_type
)));
}
let config: PPOConfig = serde_json::from_value(metadata.config.clone()).map_err(|e| {
MLError::CheckpointError(format!("Failed to deserialize config: {}", e))
})?;
let ckpt_path = std::path::PathBuf::from(checkpoint_path);
self.ppo = PPO::load_checkpoint(&ckpt_path)?;
self.step = metadata.step;
self.policy_lr = config.policy_learning_rate;
self.value_lr = config.value_learning_rate;
info!("PPO checkpoint loaded successfully");
Ok(metadata)
}
}
/// Train PPO model using trajectory batch
pub fn train_batch_trajectory(
unified_ppo: &mut UnifiedPPO,
trajectory_batch: &mut TrajectoryBatch,
) -> Result<(f64, f64), MLError> {
let (policy_loss, value_loss) = unified_ppo.inner_mut().update(trajectory_batch)?;
unified_ppo.last_policy_loss = policy_loss as f64;
unified_ppo.last_value_loss = value_loss as f64;
unified_ppo.step += 1;
unified_ppo.last_grad_norm = None;
Ok((policy_loss as f64, value_loss as f64))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unified_ppo_creation() -> Result<(), MLError> {
let config = PPOConfig {
state_dim: 16,
num_actions: 63,
policy_hidden_dims: vec![32],
value_hidden_dims: vec![32],
policy_learning_rate: 3e-4,
value_learning_rate: 3e-4,
..Default::default()
};
let device = MlDevice::cuda(0)?;
let ppo = UnifiedPPO::new(config, device)?;
assert_eq!(ppo.model_type(), "PPO");
assert_eq!(ppo.get_step(), 0);
assert_eq!(ppo.get_learning_rate(), 3e-4);
Ok(())
}
#[test]
fn test_unified_ppo_metrics() -> Result<(), MLError> {
let config = PPOConfig::default();
let device = MlDevice::cuda(0)?;
let ppo = UnifiedPPO::new(config, device)?;
let metrics = ppo.collect_metrics();
assert_eq!(metrics.learning_rate, ppo.get_learning_rate());
assert!(metrics.custom_metrics.contains_key("policy_loss"));
assert!(metrics.custom_metrics.contains_key("value_loss"));
Ok(())
}
}