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