Files
foxhunt/ml/src/ppo/ppo.rs
jgrusewski c8c58f24c2 🚀 MAJOR FIX: Parallel agents eliminate 330+ compilation errors
- Fixed all FromPrimitive imports across codebase
- Resolved all common::types import paths (219+ files)
- Fixed Volume constructor issues (type alias vs struct)
- Resolved all E0308 type mismatches
- Fixed ExecutionReport and BrokerError imports
- Added missing Price arithmetic assignment traits
- Fixed Decimal to_f64 method calls with ToPrimitive
- Eliminated all re-exports per architectural rules

Errors reduced from 436 to 106 - 76% reduction achieved
2025-09-26 20:36:21 +02:00

594 lines
19 KiB
Rust

//! ACTUAL Working Proximal Policy Optimization (PPO) Implementation
//!
//! This module provides a complete, working PPO implementation with:
//! - Actor-Critic architecture with separate policy and value networks
//! - Real mathematical operations using candle-core v0.9.1
//! - Clipped surrogate objective function
//! - Generalized Advantage Estimation (GAE)
//! - Mini-batch SGD training with multiple epochs
//! - NO productions, todo!(), or unimplemented!() macros
use candle_core::{DType, Device, Tensor};
use candle_nn::{linear, Linear, Module, Optimizer, VarBuilder, VarMap};
use candle_optimisers::adam::{Adam, ParamsAdam};
use rand::{thread_rng, Rng};
use serde::{Deserialize, Serialize};
use crate::tensor_ops::TensorOps;
use super::gae::GAEConfig;
use super::trajectories::{TrajectoryBatch, TrajectoryTensors};
use crate::dqn::TradingAction;
use crate::MLError;
/// Configuration for PPO algorithm
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PPOConfig {
/// State dimension
pub state_dim: usize,
/// Number of actions
pub num_actions: usize,
/// Policy network hidden dimensions
pub policy_hidden_dims: Vec<usize>,
/// Value network hidden dimensions
pub value_hidden_dims: Vec<usize>,
/// Learning rates
pub policy_learning_rate: f64,
pub value_learning_rate: f64,
/// PPO clip parameter (epsilon)
pub clip_epsilon: f32,
/// Value function loss coefficient
pub value_loss_coeff: f32,
/// Entropy coefficient for exploration
pub entropy_coeff: f32,
/// GAE configuration
pub gae_config: GAEConfig,
/// Training parameters
pub batch_size: usize,
pub mini_batch_size: usize,
pub num_epochs: usize,
/// Maximum gradient norm for clipping
pub max_grad_norm: f32,
}
impl Default for PPOConfig {
fn default() -> Self {
Self {
state_dim: 64,
num_actions: 3,
policy_hidden_dims: vec![128, 64],
value_hidden_dims: vec![128, 64],
policy_learning_rate: 3e-4,
value_learning_rate: 3e-4,
clip_epsilon: 0.2,
value_loss_coeff: 0.5,
entropy_coeff: 0.01,
gae_config: GAEConfig::default(),
batch_size: 2048,
mini_batch_size: 64,
num_epochs: 10,
max_grad_norm: 0.5,
}
}
}
/// Policy network for action probability distribution
pub struct PolicyNetwork {
layers: Vec<Linear>,
device: Device,
vars: VarMap,
}
impl PolicyNetwork {
/// Create new policy network
pub fn new(
input_dim: usize,
hidden_dims: &[usize],
output_dim: usize,
device: Device,
) -> Result<Self, MLError> {
let vars = VarMap::new();
let var_builder = VarBuilder::from_varmap(&vars, DType::F32, &device);
let mut layers = Vec::new();
let mut current_dim = input_dim;
// Hidden layers
for (i, &hidden_dim) in hidden_dims.iter().enumerate() {
let layer = linear(
current_dim,
hidden_dim,
var_builder.pp(&format!("policy_layer_{}", i)),
)
.map_err(|e| {
MLError::ModelError(format!("Failed to create policy layer {}: {}", i, e))
})?;
layers.push(layer);
current_dim = hidden_dim;
}
// Output layer (logits for softmax)
let output_layer = linear(current_dim, output_dim, var_builder.pp("policy_output"))
.map_err(|e| {
MLError::ModelError(format!("Failed to create policy output layer: {}", e))
})?;
layers.push(output_layer);
Ok(Self {
layers,
device,
vars,
})
}
/// Forward pass returning action logits
pub fn forward(&self, input: &Tensor) -> Result<Tensor, MLError> {
let mut x = input.clone();
// Pass through hidden layers with ReLU activation
for (i, layer) in self.layers.iter().enumerate() {
x = layer.forward(&x).map_err(|e| {
MLError::ModelError(format!("Policy forward pass failed at layer {}: {}", i, e))
})?;
// Apply ReLU to all layers except the last
if i < self.layers.len() - 1 {
x = x
.relu()
.map_err(|e| MLError::ModelError(format!("ReLU activation failed: {}", e)))?;
}
}
Ok(x)
}
/// Get action probabilities (softmax of logits)
pub fn action_probabilities(&self, input: &Tensor) -> Result<Tensor, MLError> {
let logits = self.forward(input)?;
let probs = candle_nn::ops::softmax(&logits, candle_core::D::Minus1)
.map_err(|e| MLError::ModelError(format!("Softmax failed: {}", e)))?;
Ok(probs)
}
/// Sample action from policy
pub fn sample_action(&self, input: &Tensor) -> Result<(TradingAction, f32), MLError> {
let probs = self.action_probabilities(input)?;
let probs_vec = probs
.flatten_all()?
.to_vec1::<f32>()
.map_err(|e| MLError::ModelError(format!("Failed to extract probabilities: {}", e)))?;
// Sample from categorical distribution
let mut rng = thread_rng();
let sample: f32 = rng.gen();
let mut cumulative = 0.0;
for (i, &prob) in probs_vec.iter().enumerate() {
cumulative += prob;
if sample <= cumulative {
let action = TradingAction::from_int(i as u8)
.ok_or_else(|| MLError::InvalidInput(format!("Invalid action index: {}", i)))?;
let log_prob = prob.ln();
return Ok((action, log_prob));
}
}
// Fallback to last action if rounding errors occur
let last_idx = probs_vec.len() - 1;
let action = TradingAction::from_int(last_idx as u8)
.ok_or_else(|| MLError::InvalidInput(format!("Invalid action index: {}", last_idx)))?;
let log_prob = probs_vec[last_idx].ln();
Ok((action, log_prob))
}
/// Compute log probabilities for given actions
pub fn log_probs(&self, states: &Tensor, actions: &Tensor) -> Result<Tensor, MLError> {
let logits = self.forward(states)?;
let log_probs = candle_nn::ops::log_softmax(&logits, candle_core::D::Minus1)
.map_err(|e| MLError::ModelError(format!("Log softmax failed: {}", e)))?;
// Gather log probabilities for taken actions
let actions_unsqueezed = actions.unsqueeze(1)?;
let selected_log_probs = log_probs.gather(&actions_unsqueezed, 1)?.squeeze(1)?;
Ok(selected_log_probs)
}
/// Compute entropy of action distribution
pub fn entropy(&self, states: &Tensor) -> Result<Tensor, MLError> {
let probs = self.action_probabilities(states)?;
let log_probs =
candle_nn::ops::log_softmax(&self.forward(states)?, candle_core::D::Minus1)?;
// Entropy = -sum(p * log(p))
let entropy_inner = (probs * log_probs)?.sum(candle_core::D::Minus1)?;
let entropy = TensorOps::negate(&entropy_inner)?;
Ok(entropy)
}
/// Get network variables
pub fn vars(&self) -> &VarMap {
&self.vars
}
/// Get device
pub fn device(&self) -> &Device {
&self.device
}
}
/// Value network for state value estimation
pub struct ValueNetwork {
layers: Vec<Linear>,
device: Device,
vars: VarMap,
}
impl ValueNetwork {
/// Create new value network
pub fn new(input_dim: usize, hidden_dims: &[usize], device: Device) -> Result<Self, MLError> {
let vars = VarMap::new();
let var_builder = VarBuilder::from_varmap(&vars, DType::F32, &device);
let mut layers = Vec::new();
let mut current_dim = input_dim;
// Hidden layers
for (i, &hidden_dim) in hidden_dims.iter().enumerate() {
let layer = linear(
current_dim,
hidden_dim,
var_builder.pp(&format!("value_layer_{}", i)),
)
.map_err(|e| {
MLError::ModelError(format!("Failed to create value layer {}: {}", i, e))
})?;
layers.push(layer);
current_dim = hidden_dim;
}
// Output layer (single value)
let output_layer = linear(current_dim, 1, var_builder.pp("value_output")).map_err(|e| {
MLError::ModelError(format!("Failed to create value output layer: {}", e))
})?;
layers.push(output_layer);
Ok(Self {
layers,
device,
vars,
})
}
/// Forward pass returning state values
pub fn forward(&self, input: &Tensor) -> Result<Tensor, MLError> {
let mut x = input.clone();
// Pass through hidden layers with ReLU activation
for (i, layer) in self.layers.iter().enumerate() {
x = layer.forward(&x).map_err(|e| {
MLError::ModelError(format!("Value forward pass failed at layer {}: {}", i, e))
})?;
// Apply ReLU to all layers except the last
if i < self.layers.len() - 1 {
x = x
.relu()
.map_err(|e| MLError::ModelError(format!("ReLU activation failed: {}", e)))?;
}
}
// Squeeze the last dimension (from [batch, 1] to [batch])
x = x.squeeze(1)?;
Ok(x)
}
/// Get network variables
pub fn vars(&self) -> &VarMap {
&self.vars
}
/// Get device
pub fn device(&self) -> &Device {
&self.device
}
}
/// Working PPO implementation
pub struct WorkingPPO {
/// PPO configuration
config: PPOConfig,
/// Policy network (actor)
pub actor: PolicyNetwork,
/// Value network (critic)
pub critic: ValueNetwork,
/// Policy optimizer
policy_optimizer: Option<Adam>,
/// Value optimizer
value_optimizer: Option<Adam>,
/// Training step counter
training_steps: u64,
}
impl WorkingPPO {
/// Create new working PPO
pub fn new(config: PPOConfig) -> Result<Self, MLError> {
let device = Device::Cpu; // Using CPU for compatibility
// Create actor network
let actor = PolicyNetwork::new(
config.state_dim,
&config.policy_hidden_dims,
config.num_actions,
device.clone(),
)?;
// Create critic network
let critic = ValueNetwork::new(config.state_dim, &config.value_hidden_dims, device)?;
Ok(Self {
config,
actor,
critic,
policy_optimizer: None,
value_optimizer: None,
training_steps: 0,
})
}
/// Select action and get value estimate
pub fn act(&self, state: &[f32]) -> Result<(TradingAction, f32), MLError> {
let state_tensor = Tensor::from_vec(
state.to_vec(),
(1, self.config.state_dim),
self.actor.device(),
)
.map_err(|e| MLError::ModelError(format!("Failed to create state tensor: {}", e)))?;
// Get action from policy
let (action, _log_prob) = self.actor.sample_action(&state_tensor)?;
// Get value estimate
let value = self
.critic
.forward(&state_tensor)?
.to_scalar::<f32>()
.map_err(|e| MLError::ModelError(format!("Failed to extract value: {}", e)))?;
Ok((action, value))
}
/// Update PPO networks with trajectory batch
pub fn update(&mut self, batch: &mut TrajectoryBatch) -> Result<(f32, f32), MLError> {
// Initialize optimizers if not done
self.init_optimizers()?;
// Normalize advantages
batch.normalize_advantages()?;
// Convert batch to tensors
let device = self.actor.device();
let _batch_tensors = batch.to_tensors(device, self.config.state_dim)?;
let mut total_policy_loss = 0.0;
let mut total_value_loss = 0.0;
let mut num_updates = 0;
// Train for multiple epochs
for _epoch in 0..self.config.num_epochs {
// Create mini-batches
let mini_batches = batch.create_mini_batches(self.config.mini_batch_size);
for mini_batch in mini_batches {
let mini_tensors = mini_batch.to_tensors(device, self.config.state_dim)?;
// Compute losses
let policy_loss = self.compute_policy_loss(&mini_tensors)?;
let value_loss = self.compute_value_loss(&mini_tensors)?;
// Update policy network
if let Some(ref mut optimizer) = self.policy_optimizer {
optimizer.backward_step(&policy_loss).map_err(|e| {
MLError::TrainingError(format!("Policy backward step failed: {}", e))
})?;
}
// Update value network
if let Some(ref mut optimizer) = self.value_optimizer {
optimizer.backward_step(&value_loss).map_err(|e| {
MLError::TrainingError(format!("Value backward step failed: {}", e))
})?;
}
total_policy_loss += policy_loss.to_scalar::<f32>().map_err(|e| {
MLError::TrainingError(format!("Failed to extract policy loss: {}", e))
})?;
total_value_loss += value_loss.to_scalar::<f32>().map_err(|e| {
MLError::TrainingError(format!("Failed to extract value loss: {}", e))
})?;
num_updates += 1;
}
}
self.training_steps += 1;
let avg_policy_loss = total_policy_loss / num_updates as f32;
let avg_value_loss = total_value_loss / num_updates as f32;
Ok((avg_policy_loss, avg_value_loss))
}
/// Compute PPO policy loss with clipping
fn compute_policy_loss(&self, batch: &TrajectoryTensors) -> Result<Tensor, MLError> {
// Get current log probabilities
let new_log_probs = self.actor.log_probs(&batch.states, &batch.actions)?;
// Compute probability ratio
let log_ratio = (&new_log_probs - &batch.log_probs)?;
let ratio = log_ratio.exp()?;
// Clipped surrogate objective
let clip_epsilon_tensor = Tensor::from_vec(
vec![self.config.clip_epsilon; batch.advantages.dims()[0]],
batch.advantages.dims(),
self.actor.device(),
)
.map_err(|e| MLError::TrainingError(format!("Failed to create clip tensor: {}", e)))?;
let one_tensor = Tensor::ones(batch.advantages.dims(), DType::F32, self.actor.device())?;
let clip_min = (&one_tensor - &clip_epsilon_tensor)?;
let clip_max = (&one_tensor + &clip_epsilon_tensor)?;
// Clamp ratio to [1-ε, 1+ε]
let clipped_ratio = ratio.clamp(&clip_min, &clip_max)?;
// PPO objective: min(ratio * advantage, clipped_ratio * advantage)
let surr1 = (&ratio * &batch.advantages)?;
let surr2 = (&clipped_ratio * &batch.advantages)?;
let policy_loss_raw = TensorOps::elementwise_min(&surr1, &surr2)?;
// Add entropy bonus
let entropy = self.actor.entropy(&batch.states)?;
let entropy_bonus = TensorOps::scalar_mul(&entropy, self.config.entropy_coeff as f64)?;
// Final loss (negative because we want to maximize)
let policy_loss_inner = (policy_loss_raw + entropy_bonus)?.mean_all()?;
let policy_loss = TensorOps::negate(&policy_loss_inner)?;
Ok(policy_loss)
}
/// Compute value function loss
fn compute_value_loss(&self, batch: &TrajectoryTensors) -> Result<Tensor, MLError> {
let predicted_values = self.critic.forward(&batch.states)?;
let value_loss = (&predicted_values - &batch.returns)?
.powf(2.0)?
.mean_all()?;
let scaled_loss = TensorOps::scalar_mul(&value_loss, self.config.value_loss_coeff as f64)?;
Ok(scaled_loss)
}
/// Initialize optimizers
fn init_optimizers(&mut self) -> Result<(), MLError> {
if self.policy_optimizer.is_none() {
let policy_params = ParamsAdam {
lr: self.config.policy_learning_rate,
beta_1: 0.9,
beta_2: 0.999,
eps: 1e-8,
weight_decay: None,
amsgrad: false,
};
self.policy_optimizer = Some(
Adam::new(self.actor.vars().all_vars(), policy_params).map_err(|e| {
MLError::TrainingError(format!("Failed to create policy optimizer: {}", e))
})?,
);
}
if self.value_optimizer.is_none() {
let value_params = ParamsAdam {
lr: self.config.value_learning_rate,
beta_1: 0.9,
beta_2: 0.999,
eps: 1e-8,
weight_decay: None,
amsgrad: false,
};
self.value_optimizer = Some(
Adam::new(self.critic.vars().all_vars(), value_params).map_err(|e| {
MLError::TrainingError(format!("Failed to create value optimizer: {}", e))
})?,
);
}
Ok(())
}
/// Get training steps
pub fn get_training_steps(&self) -> u64 {
self.training_steps
}
/// Get configuration
pub fn get_config(&self) -> &PPOConfig {
&self.config
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use common::*;
#[test]
fn test_policy_network_creation() -> Result<()> {
let device = Device::Cpu;
let _policy = PolicyNetwork::new(10, &[32, 16], 3, device)
.map_err(|_| anyhow::anyhow!("Failed to create policy network"))?;
// Policy network created successfully
Ok(())
}
#[test]
fn test_value_network_creation() -> Result<()> {
let device = Device::Cpu;
let _value = ValueNetwork::new(10, &[32, 16], device)
.map_err(|_| anyhow::anyhow!("Failed to create value network"))?;
// Value network created successfully
Ok(())
}
#[test]
fn test_ppo_creation() -> Result<()> {
let config = PPOConfig::default();
let ppo = WorkingPPO::new(config).map_err(|_| anyhow::anyhow!("Failed to create PPO"))?;
// PPO created successfully
assert_eq!(ppo.get_training_steps(), 0);
Ok(())
}
#[test]
fn test_ppo_config_default() -> Result<()> {
let config = PPOConfig::default();
assert!(config.state_dim > 0);
assert!(config.num_actions > 0);
assert!(config.policy_learning_rate > 0.0);
assert!(config.value_learning_rate > 0.0);
Ok(())
}
#[test]
fn test_ppo_training_steps() -> Result<()> {
let config = PPOConfig::default();
let mut ppo =
WorkingPPO::new(config).map_err(|_| anyhow::anyhow!("Failed to create PPO"))?;
assert_eq!(ppo.get_training_steps(), 0);
ppo.training_steps = 5;
assert_eq!(ppo.get_training_steps(), 5);
Ok(())
}
#[test]
fn test_ppo_config_validation() -> Result<()> {
let config = PPOConfig {
clip_epsilon: 0.2,
value_loss_coeff: 0.5,
entropy_coeff: 0.01,
..Default::default()
};
let ppo = WorkingPPO::new(config).map_err(|_| anyhow::anyhow!("Failed to create PPO"))?;
assert_eq!(ppo.get_config().clip_epsilon, 0.2);
Ok(())
}
}