use anyhow::{Result, anyhow};
use burn::{
module::AutodiffModule,
optim::{GradientsParams, Optimizer},
tensor::{Int, Tensor, TensorData, backend::AutodiffBackend},
};
use super::{
config::PPOConfig,
loss::{compute_entropy_loss, compute_policy_loss, compute_value_loss, scalar_f64},
stats::TrainingStats,
};
use crate::{
buffer::rollout::RecurrentRolloutBuffer,
train::{
grad_clip::clip_grads_by_global_norm,
optimizer::{BackendOptimizer, BurnOptimizer},
},
};
pub struct RecurrentPPOTrainer<B, P, O>
where
B: AutodiffBackend,
P: AutodiffModule<B>,
O: Optimizer<P, B>,
{
config: PPOConfig,
policy: Option<P>,
optimizer: BurnOptimizer<B, P, O>,
device: B::Device,
lr_override: Option<f64>,
total_steps: usize,
total_episodes: usize,
low_entropy_count: usize,
}
impl<B, P, O> RecurrentPPOTrainer<B, P, O>
where
B: AutodiffBackend,
P: AutodiffModule<B> + Clone,
O: Optimizer<P, B>,
{
pub fn new(
config: PPOConfig,
policy: P,
mut optimizer: BurnOptimizer<B, P, O>,
device: B::Device,
) -> Result<Self> {
config.validate()?;
optimizer.clip_grad_norm(config.max_grad_norm);
Ok(Self {
config,
policy: Some(policy),
optimizer,
device,
lr_override: None,
total_steps: 0,
total_episodes: 0,
low_entropy_count: 0,
})
}
pub fn set_learning_rate(&mut self, lr: f64) {
self.lr_override = Some(lr);
}
pub fn config(&self) -> &PPOConfig {
&self.config
}
pub fn policy(&self) -> &P {
self.policy.as_ref().expect("policy is None mid-step")
}
pub fn total_steps(&self) -> usize {
self.total_steps
}
pub fn total_episodes(&self) -> usize {
self.total_episodes
}
pub fn increment_episodes(&mut self, n: usize) {
self.total_episodes += n;
}
pub fn train_step<F>(
&mut self,
buffer: &RecurrentRolloutBuffer,
envs_per_minibatch: usize,
mut evaluate_fn: F,
) -> Result<TrainingStats>
where
F: FnMut(
&P,
Tensor<B, 3>,
Tensor<B, 2, Int>,
Tensor<B, 2>,
) -> (Tensor<B, 2>, Tensor<B, 2>, Tensor<B, 2>),
{
let device = self.device.clone();
let mut stats_sum = TrainingStats::zeros();
let mut num_updates = 0;
for _epoch in 0..self.config.n_epochs {
for batch in buffer.to_minibatches::<B>(envs_per_minibatch, true, &device) {
let policy = self
.policy
.take()
.ok_or_else(|| anyhow!("policy is None; concurrent train_step calls?"))?;
let (log_probs, entropy, values) = evaluate_fn(
&policy,
batch.obs_seq.clone(),
batch.actions.clone(),
batch.episode_starts.clone(),
);
let [n_env, t] = log_probs.dims();
let flat = n_env * t;
let log_probs = log_probs.reshape([flat]);
let entropy = entropy.reshape([flat]);
let values = values.reshape([flat]);
let old_log_probs = batch.old_log_probs.clone().reshape([flat]);
let old_values = batch.old_values.clone().reshape([flat]);
let advantages = batch.advantages.clone().reshape([flat]);
let returns = batch.returns.clone().reshape([flat]);
let adv_data: Vec<f32> = advantages.into_data().to_vec().unwrap_or_default();
let adv_mean = host_mean(&adv_data);
let adv_std = host_std_biased(&adv_data, adv_mean);
let adv_norm: Vec<f32> = adv_data
.iter()
.map(|&a| (a - adv_mean as f32) / (adv_std as f32 + 1e-8))
.collect();
let advantages =
Tensor::<B, 1>::from_data(TensorData::new(adv_norm, [flat]), &device);
let (policy_loss, clip_fraction, approx_kl) = compute_policy_loss(
log_probs,
old_log_probs,
advantages,
self.config.clip_range,
);
let (value_loss, explained_var) =
compute_value_loss(values, old_values, returns, self.config.clip_range_vf);
let entropy_loss = compute_entropy_loss(entropy.clone());
let policy_loss_val = scalar_f64(policy_loss.clone());
let value_loss_val = scalar_f64(value_loss.clone());
let entropy_val = scalar_f64(entropy.mean());
let total_loss = policy_loss
+ value_loss.mul_scalar(self.config.vf_coef as f32)
+ entropy_loss.mul_scalar(self.config.ent_coef as f32);
let total_loss_val = scalar_f64(total_loss.clone());
let grads = total_loss.backward();
let grads = GradientsParams::from_grads(grads, &policy);
let grads = match self.optimizer.grad_clip_norm() {
Some(max_norm) if max_norm > 0.0 => {
clip_grads_by_global_norm::<B, P>(&policy, grads, max_norm as f32)
}
_ => grads,
};
let lr = self.lr_override.unwrap_or_else(|| self.optimizer.learning_rate());
let policy = self.optimizer.inner_mut().step(lr, policy, grads);
self.policy = Some(policy);
let step_stats = TrainingStats::new(
policy_loss_val,
value_loss_val,
entropy_val,
total_loss_val,
clip_fraction,
approx_kl,
explained_var,
);
stats_sum.add(&step_stats);
num_updates += 1;
if approx_kl > self.config.target_kl {
break;
}
}
}
self.total_steps += num_updates;
let avg_stats = stats_sum.average();
const ENTROPY_THRESHOLD: f64 = 0.05;
const MAX_LOW_ENTROPY_COUNT: usize = 3;
if avg_stats.entropy < ENTROPY_THRESHOLD {
self.low_entropy_count += 1;
if self.low_entropy_count >= MAX_LOW_ENTROPY_COUNT {
return Err(anyhow!(
"Training stopped due to entropy collapse (entropy < {} for {} updates)",
ENTROPY_THRESHOLD,
MAX_LOW_ENTROPY_COUNT
));
}
} else {
self.low_entropy_count = 0;
}
Ok(avg_stats)
}
}
fn host_mean(xs: &[f32]) -> f64 {
if xs.is_empty() {
return 0.0;
}
xs.iter().map(|&x| x as f64).sum::<f64>() / xs.len() as f64
}
fn host_std_biased(xs: &[f32], mean: f64) -> f64 {
if xs.is_empty() {
return 0.0;
}
let n = xs.len() as f64;
let sq_dev = xs.iter().map(|&x| (x as f64 - mean).powi(2)).sum::<f64>();
(sq_dev / n).sqrt()
}
#[cfg(test)]
mod tests {
use burn::{
backend::{Autodiff, NdArray},
module::{Module, ModuleVisitor, Param},
optim::AdamConfig,
};
use super::*;
use crate::{policy::lstm::LstmBurnPolicy, train::optimizer::BurnOptimizer};
type B = Autodiff<NdArray<f32>>;
fn params_flat<M: Module<B>>(module: &M) -> Vec<f32> {
struct Collect {
out: Vec<f32>,
}
impl ModuleVisitor<B> for Collect {
fn visit_float<const D: usize>(&mut self, param: &Param<Tensor<B, D>>) {
let host: Vec<f32> = param.val().into_data().to_vec().unwrap_or_default();
self.out.extend(host);
}
}
let mut c = Collect { out: Vec::new() };
module.visit(&mut c);
c.out
}
fn update_norm(before: &[f32], after: &[f32]) -> f64 {
assert_eq!(before.len(), after.len());
before
.iter()
.zip(after)
.map(|(&a, &b)| ((b - a) as f64).powi(2))
.sum::<f64>()
.sqrt()
}
fn synthetic_buffer(
num_steps: usize,
num_envs: usize,
obs_dim: usize,
) -> RecurrentRolloutBuffer {
let hidden_dim = 8;
let mut buf = RecurrentRolloutBuffer::new(num_steps, num_envs, obs_dim, hidden_dim);
for step in 0..num_steps {
for env in 0..num_envs {
let obs: Vec<f32> = (0..obs_dim).map(|d| 0.1 * (step + env + d) as f32).collect();
let term = env == 0 && step == num_steps / 2;
buf.add(step, env, &obs, (step % 2) as i64, 1.0, 0.0, -0.7, term, false);
}
}
let last_values = vec![0.0_f32; num_envs];
buf.compute_advantages(&last_values, 0.99, 0.95);
buf
}
#[test]
fn recurrent_trainer_constructs() {
let device = Default::default();
let policy = LstmBurnPolicy::<B>::new(2, 2, 8, &device);
let inner_opt = AdamConfig::new().init();
let burn_opt: BurnOptimizer<B, LstmBurnPolicy<B>, _> = BurnOptimizer::new(inner_opt, 3e-4);
let trainer =
RecurrentPPOTrainer::new(PPOConfig::default(), policy, burn_opt, device).unwrap();
assert_eq!(trainer.total_steps(), 0);
}
#[test]
fn recurrent_trainer_train_step_runs() {
let device = Default::default();
let (num_steps, num_envs, obs_dim, action_dim) = (4, 4, 2, 2);
let policy = LstmBurnPolicy::<B>::new(obs_dim, action_dim, 8, &device);
let inner_opt = AdamConfig::new().init();
let burn_opt: BurnOptimizer<B, LstmBurnPolicy<B>, _> = BurnOptimizer::new(inner_opt, 1e-3);
let config = PPOConfig::default().n_epochs(2).target_kl(1.0);
let mut trainer = RecurrentPPOTrainer::new(config, policy, burn_opt, device).unwrap();
let buffer = synthetic_buffer(num_steps, num_envs, obs_dim);
let stats = trainer
.train_step(&buffer, 2, |p, obs_seq, actions, episode_starts| {
p.evaluate_sequences(obs_seq, actions, None, episode_starts)
})
.unwrap();
assert!(trainer.total_steps() > 0, "at least one gradient step taken");
assert!(stats.policy_loss.is_finite());
assert!(stats.value_loss.is_finite());
assert!(stats.entropy.is_finite());
}
#[test]
fn recurrent_trainer_applies_max_grad_norm() {
let device: burn::backend::ndarray::NdArrayDevice = Default::default();
let (num_steps, num_envs, obs_dim, action_dim) = (4, 4, 2, 2);
let policy = LstmBurnPolicy::<B>::new(obs_dim, action_dim, 8, &device);
let buffer = synthetic_buffer(num_steps, num_envs, obs_dim);
let run = |config: PPOConfig, policy: LstmBurnPolicy<B>| -> f64 {
let inner_opt = AdamConfig::new().init();
let burn_opt: BurnOptimizer<B, LstmBurnPolicy<B>, _> =
BurnOptimizer::new(inner_opt, 1e-3);
let mut trainer = RecurrentPPOTrainer::new(config, policy, burn_opt, device).unwrap();
let before = params_flat(trainer.policy());
trainer
.train_step(&buffer, num_envs, |p, obs_seq, actions, episode_starts| {
p.evaluate_sequences(obs_seq, actions, None, episode_starts)
})
.unwrap();
let after = params_flat(trainer.policy());
update_norm(&before, &after)
};
let base = PPOConfig::default().n_epochs(1).target_kl(1.0);
let clipped = run(base.clone().max_grad_norm(1e-6), policy.clone());
let unclipped = run(base.max_grad_norm(1e9), policy);
assert!(unclipped > 0.0, "control update must move parameters");
assert!(clipped > 0.0, "clipped update should still move parameters");
assert!(
clipped < 0.2 * unclipped,
"tiny max_grad_norm must shrink the update: clipped {clipped} vs unclipped {unclipped}"
);
}
}