1use anyhow::{Result, anyhow};
43use burn::{
44 module::AutodiffModule,
45 optim::{GradientsParams, Optimizer},
46 tensor::{Int, Tensor, backend::AutodiffBackend},
47};
48use rand::{SeedableRng, rngs::StdRng};
49
50use super::{
51 config::PPOConfig,
52 loss::{
53 compute_entropy_loss, compute_policy_loss, compute_value_loss,
54 generate_minibatch_indices_with_rng, scalar_f64,
55 },
56 stats::TrainingStats,
57};
58use crate::train::{
59 grad_clip::clip_grads_by_global_norm,
60 optimizer::{BackendOptimizer, BurnOptimizer},
61};
62
63pub struct PPOTrainerBurn<B, P, O>
76where
77 B: AutodiffBackend,
78 P: AutodiffModule<B>,
79 O: Optimizer<P, B>,
80{
81 config: PPOConfig,
82 policy: Option<P>,
83 optimizer: BurnOptimizer<B, P, O>,
84 total_steps: usize,
85 total_episodes: usize,
86 low_entropy_count: usize,
87 rng: StdRng,
93}
94
95impl<B, P, O> PPOTrainerBurn<B, P, O>
96where
97 B: AutodiffBackend,
98 P: AutodiffModule<B> + Clone,
99 O: Optimizer<P, B>,
100{
101 pub fn new(
107 config: PPOConfig,
108 policy: P,
109 mut optimizer: BurnOptimizer<B, P, O>,
110 ) -> Result<Self> {
111 config.validate()?;
112 let rng = StdRng::seed_from_u64(config.seed);
113 optimizer.clip_grad_norm(config.max_grad_norm);
114 Ok(Self {
115 config,
116 policy: Some(policy),
117 optimizer,
118 total_steps: 0,
119 total_episodes: 0,
120 low_entropy_count: 0,
121 rng,
122 })
123 }
124
125 pub fn config(&self) -> &PPOConfig {
127 &self.config
128 }
129
130 pub fn policy(&self) -> &P {
134 self.policy.as_ref().expect("policy is None mid-step")
135 }
136
137 pub fn total_steps(&self) -> usize {
139 self.total_steps
140 }
141
142 pub fn total_episodes(&self) -> usize {
144 self.total_episodes
145 }
146
147 pub fn increment_episodes(&mut self, n: usize) {
149 self.total_episodes += n;
150 }
151
152 #[allow(clippy::too_many_arguments)]
169 pub fn train_step<F>(
170 &mut self,
171 observations: Tensor<B, 2>,
172 actions: Tensor<B, 1, Int>,
173 old_log_probs: Tensor<B, 1>,
174 old_values: Tensor<B, 1>,
175 advantages: Tensor<B, 1>,
176 returns: Tensor<B, 1>,
177 mut evaluate_fn: F,
178 ) -> Result<TrainingStats>
179 where
180 F: FnMut(&P, Tensor<B, 2>, Tensor<B, 1, Int>) -> (Tensor<B, 1>, Tensor<B, 1>, Tensor<B, 1>),
181 {
182 let device = observations.device();
183 let batch_size = observations.dims()[0];
184 let mut stats_sum = TrainingStats::zeros();
185 let mut num_updates = 0;
186
187 let adv_mean_scalar = scalar_f64(advantages.clone().mean()) as f32;
189 let adv_data: Vec<f32> = advantages.into_data().to_vec().unwrap_or_default();
190 let adv_std = host_std_biased(&adv_data, adv_mean_scalar as f64) as f32;
191 let advantages_normalized_host: Vec<f32> =
192 adv_data.iter().map(|&a| (a - adv_mean_scalar) / (adv_std + 1e-8)).collect();
193
194 for _epoch in 0..self.config.n_epochs {
195 let batch_indices = generate_minibatch_indices_with_rng(
198 batch_size,
199 self.config.batch_size,
200 &mut self.rng,
201 );
202
203 for indices in &batch_indices {
204 let mb_obs = select_rows_2d(observations.clone(), indices, &device);
205 let mb_actions = select_rows_int(actions.clone(), indices, &device);
206 let mb_old_log_probs = select_rows_1d(old_log_probs.clone(), indices, &device);
207 let mb_old_values = select_rows_1d(old_values.clone(), indices, &device);
208 let mb_returns = select_rows_1d(returns.clone(), indices, &device);
209 let mb_adv: Vec<f32> =
210 indices.iter().map(|&i| advantages_normalized_host[i]).collect();
211 let mb_advantages = Tensor::<B, 1>::from_data(
212 burn::tensor::TensorData::new(mb_adv, [indices.len()]),
213 &device,
214 );
215
216 let policy = self
218 .policy
219 .take()
220 .ok_or_else(|| anyhow!("policy is None; concurrent train_step calls?"))?;
221
222 let (log_probs, entropy, values) =
223 evaluate_fn(&policy, mb_obs.clone(), mb_actions.clone());
224
225 let (policy_loss, clip_fraction, approx_kl) = compute_policy_loss(
226 log_probs,
227 mb_old_log_probs,
228 mb_advantages,
229 self.config.clip_range,
230 );
231
232 let (value_loss, explained_var) = compute_value_loss(
233 values,
234 mb_old_values,
235 mb_returns,
236 self.config.clip_range_vf,
237 );
238
239 let entropy_loss = compute_entropy_loss(entropy.clone());
240
241 let policy_loss_val = scalar_f64(policy_loss.clone());
243 let value_loss_val = scalar_f64(value_loss.clone());
244 let entropy_val = scalar_f64(entropy.mean());
245
246 let total_loss = policy_loss
248 + value_loss.mul_scalar(self.config.vf_coef as f32)
249 + entropy_loss.mul_scalar(self.config.ent_coef as f32);
250 let total_loss_val = scalar_f64(total_loss.clone());
251
252 let grads = total_loss.backward();
254 let grads = GradientsParams::from_grads(grads, &policy);
255 let grads = match self.optimizer.grad_clip_norm() {
260 Some(max_norm) if max_norm > 0.0 => {
261 clip_grads_by_global_norm::<B, P>(&policy, grads, max_norm as f32)
262 }
263 _ => grads,
264 };
265 let lr = self.optimizer.learning_rate();
266 let policy = self.optimizer.inner_mut().step(lr, policy, grads);
267 self.policy = Some(policy);
268
269 let step_stats = TrainingStats::new(
270 policy_loss_val,
271 value_loss_val,
272 entropy_val,
273 total_loss_val,
274 clip_fraction,
275 approx_kl,
276 explained_var,
277 );
278 stats_sum.add(&step_stats);
279 num_updates += 1;
280
281 if approx_kl > self.config.target_kl {
282 break;
283 }
284 }
285 }
286
287 self.total_steps += num_updates;
288 let avg_stats = stats_sum.average();
289
290 const ENTROPY_THRESHOLD: f64 = 0.05;
292 const MAX_LOW_ENTROPY_COUNT: usize = 3;
293 if avg_stats.entropy < ENTROPY_THRESHOLD {
294 self.low_entropy_count += 1;
295 if self.low_entropy_count >= MAX_LOW_ENTROPY_COUNT {
296 return Err(anyhow!(
297 "Training stopped due to entropy collapse (entropy < {} for {} updates)",
298 ENTROPY_THRESHOLD,
299 MAX_LOW_ENTROPY_COUNT
300 ));
301 }
302 } else {
303 self.low_entropy_count = 0;
304 }
305
306 Ok(avg_stats)
307 }
308}
309
310fn host_std_biased(xs: &[f32], mean: f64) -> f64 {
312 if xs.is_empty() {
313 return 0.0;
314 }
315 let n = xs.len() as f64;
316 let sq_dev = xs.iter().map(|&x| (x as f64 - mean).powi(2)).sum::<f64>();
317 (sq_dev / n).sqrt()
318}
319
320fn select_rows_2d<B: AutodiffBackend>(
322 tensor: Tensor<B, 2>,
323 indices: &[usize],
324 device: &B::Device,
325) -> Tensor<B, 2> {
326 let cols = tensor.dims()[1];
327 let host: Vec<f32> = tensor.into_data().to_vec().unwrap_or_default();
328 let mut out = Vec::with_capacity(indices.len() * cols);
329 for &i in indices {
330 let start = i * cols;
331 out.extend_from_slice(&host[start..start + cols]);
332 }
333 Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(out, [indices.len(), cols]), device)
334}
335
336fn select_rows_1d<B: AutodiffBackend>(
338 tensor: Tensor<B, 1>,
339 indices: &[usize],
340 device: &B::Device,
341) -> Tensor<B, 1> {
342 let host: Vec<f32> = tensor.into_data().to_vec().unwrap_or_default();
343 let out: Vec<f32> = indices.iter().map(|&i| host[i]).collect();
344 Tensor::<B, 1>::from_data(burn::tensor::TensorData::new(out, [indices.len()]), device)
345}
346
347fn select_rows_int<B: AutodiffBackend>(
357 tensor: Tensor<B, 1, Int>,
358 indices: &[usize],
359 device: &B::Device,
360) -> Tensor<B, 1, Int> {
361 let data = tensor.into_data();
362 let host: Vec<i64> = data.iter::<i64>().collect();
363 let out: Vec<i64> = indices.iter().map(|&i| host[i]).collect();
364 Tensor::<B, 1, Int>::from_data(burn::tensor::TensorData::new(out, [indices.len()]), device)
365}
366
367#[cfg(test)]
368mod tests {
369 use burn::{
370 backend::{Autodiff, NdArray},
371 module::{Module, ModuleVisitor, Param},
372 optim::AdamConfig,
373 };
374
375 use super::*;
376 use crate::{policy::mlp::MlpBurnPolicy, train::optimizer::BurnOptimizer};
377
378 type B = Autodiff<NdArray<f32>>;
379
380 fn params_flat<M: Module<B>>(module: &M) -> Vec<f32> {
382 struct Collect {
383 out: Vec<f32>,
384 }
385 impl ModuleVisitor<B> for Collect {
386 fn visit_float<const D: usize>(&mut self, param: &Param<Tensor<B, D>>) {
387 let host: Vec<f32> = param.val().into_data().to_vec().unwrap_or_default();
388 self.out.extend(host);
389 }
390 }
391 let mut c = Collect { out: Vec::new() };
392 module.visit(&mut c);
393 c.out
394 }
395
396 fn update_norm(before: &[f32], after: &[f32]) -> f64 {
398 assert_eq!(before.len(), after.len());
399 before
400 .iter()
401 .zip(after)
402 .map(|(&a, &b)| ((b - a) as f64).powi(2))
403 .sum::<f64>()
404 .sqrt()
405 }
406
407 #[test]
410 fn ppo_trainer_burn_constructs() {
411 let device = Default::default();
412 let policy = MlpBurnPolicy::<B>::new(4, 2, 32, &device);
413 let inner_opt = AdamConfig::new().init();
414 let burn_opt: BurnOptimizer<B, MlpBurnPolicy<B>, _> = BurnOptimizer::new(inner_opt, 3e-4);
415 let trainer = PPOTrainerBurn::new(PPOConfig::default(), policy, burn_opt).unwrap();
416 assert_eq!(trainer.total_steps(), 0);
417 }
418
419 #[test]
423 fn ppo_trainer_burn_train_step_runs() {
424 let device = Default::default();
425 let policy = MlpBurnPolicy::<B>::new(4, 2, 16, &device);
426 let inner_opt = AdamConfig::new().init();
427 let burn_opt: BurnOptimizer<B, MlpBurnPolicy<B>, _> = BurnOptimizer::new(inner_opt, 1e-3);
428 let config = PPOConfig::default().batch_size(4).n_epochs(1);
431 let mut trainer = PPOTrainerBurn::new(config, policy, burn_opt).unwrap();
432
433 let batch = 8;
434 let obs_dim = 4;
435 let mut obs_data = Vec::with_capacity(batch * obs_dim);
436 for i in 0..batch * obs_dim {
437 obs_data.push((i as f32) * 0.01);
438 }
439 let observations = Tensor::<B, 2>::from_data(
440 burn::tensor::TensorData::new(obs_data, [batch, obs_dim]),
441 &device,
442 );
443 let actions = Tensor::<B, 1, Int>::from_data(
444 burn::tensor::TensorData::new(vec![0i64, 1, 0, 1, 0, 1, 0, 1], [batch]),
445 &device,
446 );
447 let old_log_probs = Tensor::<B, 1>::from_data(
448 burn::tensor::TensorData::new(vec![-0.7f32; batch], [batch]),
449 &device,
450 );
451 let old_values = Tensor::<B, 1>::from_data(
452 burn::tensor::TensorData::new(vec![0.0f32; batch], [batch]),
453 &device,
454 );
455 let advantages = Tensor::<B, 1>::from_data(
456 burn::tensor::TensorData::new(
457 vec![1.0f32, -1.0, 0.5, -0.5, 1.0, -1.0, 0.5, -0.5],
458 [batch],
459 ),
460 &device,
461 );
462 let returns = Tensor::<B, 1>::from_data(
463 burn::tensor::TensorData::new(vec![1.0f32; batch], [batch]),
464 &device,
465 );
466
467 let stats = trainer
468 .train_step(
469 observations,
470 actions,
471 old_log_probs,
472 old_values,
473 advantages,
474 returns,
475 |p, o, a| p.evaluate_actions(o, a),
476 )
477 .unwrap();
478 assert!(trainer.total_steps() > 0);
479 assert!(stats.policy_loss.is_finite());
481 assert!(stats.value_loss.is_finite());
482 }
483
484 #[test]
494 fn ppo_trainer_burn_applies_max_grad_norm() {
495 let device: burn::backend::ndarray::NdArrayDevice = Default::default();
496 let policy = MlpBurnPolicy::<B>::new(4, 2, 16, &device);
497
498 let batch = 8;
499 let make_batch = || {
500 let obs_dim = 4;
501 let obs_data: Vec<f32> = (0..batch * obs_dim).map(|i| (i as f32) * 0.01).collect();
502 let observations = Tensor::<B, 2>::from_data(
503 burn::tensor::TensorData::new(obs_data, [batch, obs_dim]),
504 &device,
505 );
506 let actions = Tensor::<B, 1, Int>::from_data(
507 burn::tensor::TensorData::new(vec![0i64, 1, 0, 1, 0, 1, 0, 1], [batch]),
508 &device,
509 );
510 let old_log_probs = Tensor::<B, 1>::from_data(
511 burn::tensor::TensorData::new(vec![-0.7f32; batch], [batch]),
512 &device,
513 );
514 let old_values = Tensor::<B, 1>::from_data(
515 burn::tensor::TensorData::new(vec![0.0f32; batch], [batch]),
516 &device,
517 );
518 let advantages = Tensor::<B, 1>::from_data(
519 burn::tensor::TensorData::new(
520 vec![1.0f32, -1.0, 0.5, -0.5, 1.0, -1.0, 0.5, -0.5],
521 [batch],
522 ),
523 &device,
524 );
525 let returns = Tensor::<B, 1>::from_data(
526 burn::tensor::TensorData::new(vec![1.0f32; batch], [batch]),
527 &device,
528 );
529 (observations, actions, old_log_probs, old_values, advantages, returns)
530 };
531
532 let run = |config: PPOConfig, policy: MlpBurnPolicy<B>| -> f64 {
533 let inner_opt = AdamConfig::new().init();
534 let burn_opt: BurnOptimizer<B, MlpBurnPolicy<B>, _> =
535 BurnOptimizer::new(inner_opt, 1e-3);
536 let mut trainer = PPOTrainerBurn::new(config, policy, burn_opt).unwrap();
537 let before = params_flat(trainer.policy());
538 let (observations, actions, old_log_probs, old_values, advantages, returns) =
539 make_batch();
540 trainer
541 .train_step(
542 observations,
543 actions,
544 old_log_probs,
545 old_values,
546 advantages,
547 returns,
548 |p, o, a| p.evaluate_actions(o, a),
549 )
550 .unwrap();
551 let after = params_flat(trainer.policy());
552 update_norm(&before, &after)
553 };
554
555 let base = PPOConfig::default().batch_size(batch).n_epochs(1);
558 let clipped = run(base.clone().max_grad_norm(1e-6), policy.clone());
559 let unclipped = run(base.max_grad_norm(1e9), policy);
560
561 assert!(unclipped > 0.0, "control update must move parameters");
562 assert!(clipped > 0.0, "clipped update should still move parameters");
563 assert!(
564 clipped < 0.2 * unclipped,
565 "tiny max_grad_norm must shrink the update: clipped {clipped} vs unclipped {unclipped}"
566 );
567 }
568}