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::optimizer::{BackendOptimizer, BurnOptimizer};
59
60pub struct PPOTrainerBurn<B, P, O>
73where
74 B: AutodiffBackend,
75 P: AutodiffModule<B>,
76 O: Optimizer<P, B>,
77{
78 config: PPOConfig,
79 policy: Option<P>,
80 optimizer: BurnOptimizer<B, P, O>,
81 total_steps: usize,
82 total_episodes: usize,
83 low_entropy_count: usize,
84 rng: StdRng,
90}
91
92impl<B, P, O> PPOTrainerBurn<B, P, O>
93where
94 B: AutodiffBackend,
95 P: AutodiffModule<B> + Clone,
96 O: Optimizer<P, B>,
97{
98 pub fn new(config: PPOConfig, policy: P, optimizer: BurnOptimizer<B, P, O>) -> Result<Self> {
100 config.validate()?;
101 let rng = StdRng::seed_from_u64(config.seed);
102 Ok(Self {
103 config,
104 policy: Some(policy),
105 optimizer,
106 total_steps: 0,
107 total_episodes: 0,
108 low_entropy_count: 0,
109 rng,
110 })
111 }
112
113 pub fn config(&self) -> &PPOConfig {
115 &self.config
116 }
117
118 pub fn policy(&self) -> &P {
122 self.policy.as_ref().expect("policy is None mid-step")
123 }
124
125 pub fn total_steps(&self) -> usize {
127 self.total_steps
128 }
129
130 pub fn total_episodes(&self) -> usize {
132 self.total_episodes
133 }
134
135 pub fn increment_episodes(&mut self, n: usize) {
137 self.total_episodes += n;
138 }
139
140 #[allow(clippy::too_many_arguments)]
157 pub fn train_step<F>(
158 &mut self,
159 observations: Tensor<B, 2>,
160 actions: Tensor<B, 1, Int>,
161 old_log_probs: Tensor<B, 1>,
162 old_values: Tensor<B, 1>,
163 advantages: Tensor<B, 1>,
164 returns: Tensor<B, 1>,
165 mut evaluate_fn: F,
166 ) -> Result<TrainingStats>
167 where
168 F: FnMut(&P, Tensor<B, 2>, Tensor<B, 1, Int>) -> (Tensor<B, 1>, Tensor<B, 1>, Tensor<B, 1>),
169 {
170 let device = observations.device();
171 let batch_size = observations.dims()[0];
172 let mut stats_sum = TrainingStats::zeros();
173 let mut num_updates = 0;
174
175 let adv_mean_scalar = scalar_f64(advantages.clone().mean()) as f32;
177 let adv_data: Vec<f32> = advantages.into_data().to_vec().unwrap_or_default();
178 let adv_std = host_std_biased(&adv_data, adv_mean_scalar as f64) as f32;
179 let advantages_normalized_host: Vec<f32> =
180 adv_data.iter().map(|&a| (a - adv_mean_scalar) / (adv_std + 1e-8)).collect();
181
182 for _epoch in 0..self.config.n_epochs {
183 let batch_indices = generate_minibatch_indices_with_rng(
186 batch_size,
187 self.config.batch_size,
188 &mut self.rng,
189 );
190
191 for indices in &batch_indices {
192 let mb_obs = select_rows_2d(observations.clone(), indices, &device);
193 let mb_actions = select_rows_int(actions.clone(), indices, &device);
194 let mb_old_log_probs = select_rows_1d(old_log_probs.clone(), indices, &device);
195 let mb_old_values = select_rows_1d(old_values.clone(), indices, &device);
196 let mb_returns = select_rows_1d(returns.clone(), indices, &device);
197 let mb_adv: Vec<f32> =
198 indices.iter().map(|&i| advantages_normalized_host[i]).collect();
199 let mb_advantages = Tensor::<B, 1>::from_data(
200 burn::tensor::TensorData::new(mb_adv, [indices.len()]),
201 &device,
202 );
203
204 let policy = self
206 .policy
207 .take()
208 .ok_or_else(|| anyhow!("policy is None; concurrent train_step calls?"))?;
209
210 let (log_probs, entropy, values) =
211 evaluate_fn(&policy, mb_obs.clone(), mb_actions.clone());
212
213 let (policy_loss, clip_fraction, approx_kl) = compute_policy_loss(
214 log_probs,
215 mb_old_log_probs,
216 mb_advantages,
217 self.config.clip_range,
218 );
219
220 let (value_loss, explained_var) = compute_value_loss(
221 values,
222 mb_old_values,
223 mb_returns,
224 self.config.clip_range_vf,
225 );
226
227 let entropy_loss = compute_entropy_loss(entropy.clone());
228
229 let policy_loss_val = scalar_f64(policy_loss.clone());
231 let value_loss_val = scalar_f64(value_loss.clone());
232 let entropy_val = scalar_f64(entropy.mean());
233
234 let total_loss = policy_loss
236 + value_loss.mul_scalar(self.config.vf_coef as f32)
237 + entropy_loss.mul_scalar(self.config.ent_coef as f32);
238 let total_loss_val = scalar_f64(total_loss.clone());
239
240 let grads = total_loss.backward();
242 let grads = GradientsParams::from_grads(grads, &policy);
243 let lr = self.optimizer.learning_rate();
244 let policy = self.optimizer.inner_mut().step(lr, policy, grads);
245 self.policy = Some(policy);
246
247 let step_stats = TrainingStats::new(
248 policy_loss_val,
249 value_loss_val,
250 entropy_val,
251 total_loss_val,
252 clip_fraction,
253 approx_kl,
254 explained_var,
255 );
256 stats_sum.add(&step_stats);
257 num_updates += 1;
258
259 if approx_kl > self.config.target_kl {
260 break;
261 }
262 }
263 }
264
265 self.total_steps += num_updates;
266 let avg_stats = stats_sum.average();
267
268 const ENTROPY_THRESHOLD: f64 = 0.05;
270 const MAX_LOW_ENTROPY_COUNT: usize = 3;
271 if avg_stats.entropy < ENTROPY_THRESHOLD {
272 self.low_entropy_count += 1;
273 if self.low_entropy_count >= MAX_LOW_ENTROPY_COUNT {
274 return Err(anyhow!(
275 "Training stopped due to entropy collapse (entropy < {} for {} updates)",
276 ENTROPY_THRESHOLD,
277 MAX_LOW_ENTROPY_COUNT
278 ));
279 }
280 } else {
281 self.low_entropy_count = 0;
282 }
283
284 Ok(avg_stats)
285 }
286}
287
288fn host_std_biased(xs: &[f32], mean: f64) -> f64 {
290 if xs.is_empty() {
291 return 0.0;
292 }
293 let n = xs.len() as f64;
294 let sq_dev = xs.iter().map(|&x| (x as f64 - mean).powi(2)).sum::<f64>();
295 (sq_dev / n).sqrt()
296}
297
298fn select_rows_2d<B: AutodiffBackend>(
300 tensor: Tensor<B, 2>,
301 indices: &[usize],
302 device: &B::Device,
303) -> Tensor<B, 2> {
304 let cols = tensor.dims()[1];
305 let host: Vec<f32> = tensor.into_data().to_vec().unwrap_or_default();
306 let mut out = Vec::with_capacity(indices.len() * cols);
307 for &i in indices {
308 let start = i * cols;
309 out.extend_from_slice(&host[start..start + cols]);
310 }
311 Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(out, [indices.len(), cols]), device)
312}
313
314fn select_rows_1d<B: AutodiffBackend>(
316 tensor: Tensor<B, 1>,
317 indices: &[usize],
318 device: &B::Device,
319) -> Tensor<B, 1> {
320 let host: Vec<f32> = tensor.into_data().to_vec().unwrap_or_default();
321 let out: Vec<f32> = indices.iter().map(|&i| host[i]).collect();
322 Tensor::<B, 1>::from_data(burn::tensor::TensorData::new(out, [indices.len()]), device)
323}
324
325fn select_rows_int<B: AutodiffBackend>(
335 tensor: Tensor<B, 1, Int>,
336 indices: &[usize],
337 device: &B::Device,
338) -> Tensor<B, 1, Int> {
339 let data = tensor.into_data();
340 let host: Vec<i64> = data.iter::<i64>().collect();
341 let out: Vec<i64> = indices.iter().map(|&i| host[i]).collect();
342 Tensor::<B, 1, Int>::from_data(burn::tensor::TensorData::new(out, [indices.len()]), device)
343}
344
345#[cfg(test)]
346mod tests {
347 use burn::{
348 backend::{Autodiff, NdArray},
349 optim::AdamConfig,
350 };
351
352 use super::*;
353 use crate::{policy::mlp::MlpBurnPolicy, train::optimizer::BurnOptimizer};
354
355 type B = Autodiff<NdArray<f32>>;
356
357 #[test]
360 fn ppo_trainer_burn_constructs() {
361 let device = Default::default();
362 let policy = MlpBurnPolicy::<B>::new(4, 2, 32, &device);
363 let inner_opt = AdamConfig::new().init();
364 let burn_opt: BurnOptimizer<B, MlpBurnPolicy<B>, _> = BurnOptimizer::new(inner_opt, 3e-4);
365 let trainer = PPOTrainerBurn::new(PPOConfig::default(), policy, burn_opt).unwrap();
366 assert_eq!(trainer.total_steps(), 0);
367 }
368
369 #[test]
373 fn ppo_trainer_burn_train_step_runs() {
374 let device = Default::default();
375 let policy = MlpBurnPolicy::<B>::new(4, 2, 16, &device);
376 let inner_opt = AdamConfig::new().init();
377 let burn_opt: BurnOptimizer<B, MlpBurnPolicy<B>, _> = BurnOptimizer::new(inner_opt, 1e-3);
378 let config = PPOConfig::default().batch_size(4).n_epochs(1);
381 let mut trainer = PPOTrainerBurn::new(config, policy, burn_opt).unwrap();
382
383 let batch = 8;
384 let obs_dim = 4;
385 let mut obs_data = Vec::with_capacity(batch * obs_dim);
386 for i in 0..batch * obs_dim {
387 obs_data.push((i as f32) * 0.01);
388 }
389 let observations = Tensor::<B, 2>::from_data(
390 burn::tensor::TensorData::new(obs_data, [batch, obs_dim]),
391 &device,
392 );
393 let actions = Tensor::<B, 1, Int>::from_data(
394 burn::tensor::TensorData::new(vec![0i64, 1, 0, 1, 0, 1, 0, 1], [batch]),
395 &device,
396 );
397 let old_log_probs = Tensor::<B, 1>::from_data(
398 burn::tensor::TensorData::new(vec![-0.7f32; batch], [batch]),
399 &device,
400 );
401 let old_values = Tensor::<B, 1>::from_data(
402 burn::tensor::TensorData::new(vec![0.0f32; batch], [batch]),
403 &device,
404 );
405 let advantages = Tensor::<B, 1>::from_data(
406 burn::tensor::TensorData::new(
407 vec![1.0f32, -1.0, 0.5, -0.5, 1.0, -1.0, 0.5, -0.5],
408 [batch],
409 ),
410 &device,
411 );
412 let returns = Tensor::<B, 1>::from_data(
413 burn::tensor::TensorData::new(vec![1.0f32; batch], [batch]),
414 &device,
415 );
416
417 let stats = trainer
418 .train_step(
419 observations,
420 actions,
421 old_log_probs,
422 old_values,
423 advantages,
424 returns,
425 |p, o, a| p.evaluate_actions(o, a),
426 )
427 .unwrap();
428 assert!(trainer.total_steps() > 0);
429 assert!(stats.policy_loss.is_finite());
431 assert!(stats.value_loss.is_finite());
432 }
433}