use crate::{Engine, Error, Matrix, Result, matrix};
use super::{
ItRolloutTraining, ItRolloutTrainingConfig, Optimizer, RolloutTrainingPhase, TrainingCallback,
TrainingMetric, TrainingSnapshot,
};
use crate::ml::{
ActorCritic, GradientTape, PolicyResult, RolloutBatch, RolloutBuffer, RolloutConfig,
RolloutTransition, advantage, loss, policy,
};
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PpoTrainerConfig {
pub rollouts: u64,
pub horizon: usize,
pub environments: usize,
pub update_epochs: u64,
pub observation_shape: Vec<usize>,
pub seed: u64,
pub enable_gpu_timing: bool,
pub gae: advantage::GaeConfig,
pub loss: loss::PpoLossConfig,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PpoTrainerMetrics {
pub rollout: u64,
pub update_epoch: u64,
pub total_loss: f32,
pub policy_loss: f32,
pub value_loss: f32,
pub entropy: f32,
}
#[must_use]
pub struct PpoTrainer<'engine, 'hooks> {
engine: &'engine Engine,
model: &'engine dyn ActorCritic,
config: PpoTrainerConfig,
observation_elements: usize,
rollout: RolloutBuffer,
training: ItRolloutTraining<'engine, 'hooks>,
metrics: PpoTrainerMetrics,
action_index: u64,
collection_action_index: u64,
collecting: bool,
collection_abortable: bool,
}
impl<'engine, 'hooks> PpoTrainer<'engine, 'hooks> {
pub fn new(
engine: &'engine Engine,
model: &'engine dyn ActorCritic,
optimizer: &'engine mut dyn Optimizer,
config: PpoTrainerConfig,
) -> Result<Self> {
let observation_elements = validate_config(&config)?;
for parameter in model.all_parameters()? {
if !engine.owns_matrix(¶meter.data()) {
return Err(Error::invalid_argument(
"PPO model parameters must belong to the selected engine",
));
}
}
let rollout = RolloutBuffer::new(
engine,
RolloutConfig {
time: config.horizon,
environments: config.environments,
observation_shape: config.observation_shape.clone(),
},
)?;
let training = ItRolloutTraining::new(
engine,
optimizer,
ItRolloutTrainingConfig {
rollouts: config.rollouts,
horizon: config.horizon,
environments: config.environments,
update_epochs: config.update_epochs,
timer_name: "ppo_update".into(),
enable_gpu_timing: config.enable_gpu_timing,
},
)?;
Ok(Self {
engine,
model,
config,
observation_elements,
rollout,
training,
metrics: PpoTrainerMetrics::default(),
action_index: 0,
collection_action_index: 0,
collecting: false,
collection_abortable: false,
})
}
pub fn add_metric(&mut self, metric: &'hooks mut dyn TrainingMetric) {
self.training.update_loop_mut().add_metric(metric);
}
pub fn add_callback(&mut self, callback: &'hooks mut dyn TrainingCallback) {
self.training.update_loop_mut().add_callback(callback);
}
pub fn begin_collection(&mut self) -> Result<()> {
if self.collecting || self.training.phase() != RolloutTrainingPhase::Collect {
return Err(Error::failed_precondition(
"PPO begin_collection requires an idle Collect phase",
));
}
self.training.begin_rollout(&mut self.rollout)?;
self.collection_action_index = self.action_index;
self.collecting = true;
self.collection_abortable = true;
Ok(())
}
pub fn act(&mut self, observation: &Matrix) -> Result<PolicyResult> {
if !self.collecting {
return Err(Error::failed_precondition(
"PPO act requires active collection",
));
}
let flat = matrix::reshape(
observation,
[self.config.environments, self.observation_elements],
)?;
let network = self.model.evaluate(&flat)?;
self.action_index = self
.action_index
.checked_add(1)
.ok_or_else(|| Error::resource_exhausted("PPO action seed index exhausted"))?;
let seed = self
.config
.seed
.checked_add(self.action_index)
.ok_or_else(|| Error::resource_exhausted("PPO action seed exhausted"))?;
policy::sample_categorical(&network.logits, &network.value, seed)
}
#[allow(
clippy::too_many_arguments,
reason = "the PPO transition boundary keeps observation, reward, and both terminal meanings explicit"
)]
pub fn observe(
&mut self,
observation: &Matrix,
next_observation: &Matrix,
reward: &Matrix,
terminated: &Matrix,
truncated: &Matrix,
policy_result: &PolicyResult,
) -> Result<()> {
if !self.collecting {
return Err(Error::failed_precondition(
"PPO observe requires active collection",
));
}
let next_flat = matrix::reshape(
next_observation,
[self.config.environments, self.observation_elements],
)?;
let next = self.model.evaluate(&next_flat)?;
self.rollout.append(&RolloutTransition::new(
observation.clone(),
policy_result.action.clone(),
reward.clone(),
policy_result.value.clone(),
next.value,
policy_result.log_probability.clone(),
terminated.clone(),
truncated.clone(),
))
}
pub fn end_collection(&mut self) -> Result<()> {
if !self.collecting {
return Err(Error::failed_precondition(
"PPO end_collection requires active collection",
));
}
self
.training
.finalize_rollout(&mut self.rollout, self.config.gae)?;
self.collecting = false;
Ok(())
}
pub fn abort_collection(&mut self) -> Result<()> {
if !self.collection_abortable {
return Err(Error::failed_precondition(
"PPO has no unsubmitted collection to abort",
));
}
self.training.abort_rollout(&mut self.rollout)?;
self.action_index = self.collection_action_index;
self.collecting = false;
self.collection_abortable = false;
Ok(())
}
pub fn update(&mut self) -> Result<bool> {
if self.training.phase() != RolloutTrainingPhase::Update {
return Err(Error::failed_precondition(
"PPO update requires the Update phase",
));
}
if !self.training.begin_update()? {
return Ok(false);
}
self.collection_abortable = false;
let result = self.record_update();
if let Err(error) = result {
self.engine.abort_pending_work();
return self.training.fail_update(error);
}
Ok(true)
}
fn record_update(&mut self) -> Result<()> {
let batch = self
.config
.horizon
.checked_mul(self.config.environments)
.ok_or_else(|| Error::resource_exhausted("PPO batch size overflows usize"))?;
let rollout = self.rollout.batch();
let observation = matrix::reshape(rollout.observation(), [batch, self.observation_elements])?;
let action = matrix::reshape(rollout.action(), [batch])?;
let old_log_probability = matrix::reshape(rollout.old_log_probability(), [batch])?;
let advantage = matrix::reshape(rollout.advantage(), [batch])?;
let target_return = matrix::reshape(rollout.returns(), [batch])?;
self.training.update_loop().zero_grad();
let tape = GradientTape::new();
let network = self.model.evaluate(&observation)?;
let policy = policy::evaluate_categorical(&network.logits, &action, &network.value)?;
let normalized_advantage = advantage::normalize(&advantage, 1.0e-8)?;
let loss = loss::ppo(
&policy.log_probability,
&old_log_probability,
&normalized_advantage,
&policy.value,
&target_return,
&policy.entropy,
self.config.loss,
)?;
tape.backward(&loss.total_loss)?;
self.training.complete_update(&loss.total_loss)?;
self.metrics = PpoTrainerMetrics {
rollout: self.training.rollout_index(),
update_epoch: self.training.update_epoch(),
total_loss: self
.training
.update_loop()
.snapshot()
.last_loss()
.unwrap_or(0.0),
policy_loss: loss.policy_loss.read_f32()?[0],
value_loss: loss.value_loss.read_f32()?[0],
entropy: loss.entropy.read_f32()?[0],
};
Ok(())
}
pub fn needs_collection(&self) -> bool {
!self.collecting && self.training.phase() == RolloutTrainingPhase::Collect
}
pub fn is_done(&self) -> bool {
self.training.is_done()
}
pub const fn phase(&self) -> RolloutTrainingPhase {
self.training.phase()
}
pub const fn config(&self) -> &PpoTrainerConfig {
&self.config
}
pub const fn metrics(&self) -> PpoTrainerMetrics {
self.metrics
}
pub const fn batch(&self) -> &RolloutBatch {
self.rollout.batch()
}
pub const fn training_loop(&self) -> &super::ItTraining<'engine, 'hooks> {
self.training.update_loop()
}
pub fn finish(self) -> Result<TrainingSnapshot> {
self.training.finish()
}
}
fn validate_config(config: &PpoTrainerConfig) -> Result<usize> {
if config.rollouts == 0
|| config.horizon == 0
|| config.environments == 0
|| config.update_epochs == 0
|| config.observation_shape.is_empty()
{
return Err(Error::invalid_argument(
"PPO requires nonzero rollout dimensions and a nonempty observation shape",
));
}
let elements = config
.observation_shape
.iter()
.try_fold(1_usize, |count, extent| count.checked_mul(*extent))
.ok_or_else(|| Error::resource_exhausted("PPO observation size overflows usize"))?;
if elements == 0 {
return Err(Error::invalid_argument(
"PPO observation shape must contain elements",
));
}
config
.horizon
.checked_mul(config.environments)
.ok_or_else(|| Error::resource_exhausted("PPO batch size overflows usize"))?;
if !config.gae.gamma.is_finite()
|| !(0.0..=1.0).contains(&config.gae.gamma)
|| !config.gae.lambda.is_finite()
|| !(0.0..=1.0).contains(&config.gae.lambda)
|| !config.loss.clip_epsilon.is_finite()
|| !(0.0..1.0).contains(&config.loss.clip_epsilon)
|| !config.loss.value_coefficient.is_finite()
|| config.loss.value_coefficient < 0.0
|| !config.loss.entropy_coefficient.is_finite()
|| config.loss.entropy_coefficient < 0.0
{
return Err(Error::invalid_argument("PPO GAE/loss policy is invalid"));
}
Ok(elements)
}