Skip to main content

thrust_rl/train/ppo/
trainer.rs

1//! Burn-backend PPO trainer (phase 3 of the Burn migration, #80).
2//!
3//! Sibling to [`crate::train::ppo::PPOTrainerBurn`] (tch path). Both
4//! trainers implement the same clipped-surrogate, value-clip,
5//! entropy-bonus, KL-early-stop recipe; the only difference is the
6//! tensor backend and the optimizer ownership model.
7//!
8//! # Ownership model
9//!
10//! Burn's `Optimizer<M, B>` is move-through: every gradient step
11//! consumes the module by value and returns the updated copy. Phase
12//! 1's scout (#78) confirmed this is the **single biggest** structural
13//! divergence between the two backends — see
14//! `docs/BURN_MIGRATION_PHASE1_REPORT.md` friction point #1.
15//!
16//! The Burn trainer therefore *owns* the policy module via an
17//! `Option<P>` field and swaps it through the optimizer on every
18//! step:
19//!
20//! ```text
21//! let module = self.policy.take().unwrap();
22//! let grads = loss.backward();
23//! let grads = GradientsParams::from_grads(grads, &module);
24//! let module = self.optimizer.inner_mut().step(lr, module, grads);
25//! self.policy = Some(module);
26//! ```
27//!
28//! The tch trainer is `struct PPOTrainer<P>` with `policy: P`; the
29//! Burn trainer is `struct PPOTrainerBurn<B, P, O>` with the policy
30//! held in `Option<P>`. Phase 5 (#82) collapses the two when the
31//! ownership-model asymmetry goes away (only Burn remains).
32//!
33//! # Evaluating the policy
34//!
35//! The trainer takes a closure `evaluate_fn(&P, observations, actions)`
36//! that returns `(log_probs, entropy, values)` exactly as the tch
37//! trainer does (see `PPOTrainer::train_step_with_policy`). This keeps
38//! the loss math identical and lets the caller plug in any module
39//! whose forward pass yields the right tensor shapes — including, for
40//! phase 4, the proper `MlpBurnPolicy`/`SnakeCnnBurn` ports.
41
42use 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
63/// Burn-backend PPO trainer.
64///
65/// Generic over:
66/// - `B: AutodiffBackend` — the Burn backend (e.g. `Autodiff<NdArray<f32>>`,
67///   `Autodiff<Wgpu>`, etc.).
68/// - `P: AutodiffModule<B>` — the policy module type.
69/// - `O: Optimizer<P, B>` — the Burn optimizer (typically built from
70///   `AdamConfig::new().init()`).
71///
72/// The policy is held in `Option<P>` because Burn's `Optimizer::step`
73/// consumes the module by value. We use `.take()` / put-back across
74/// each gradient step.
75pub 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    /// Seedable RNG for the per-epoch minibatch shuffle. Owned by the
88    /// trainer so the shuffle order is reproducible under
89    /// `config.seed` (issue #109). Previously the shuffle drew from
90    /// the thread-local `rand::rng()`, which defeated any
91    /// upstream seed plumbing (e.g. `PsroConfig::seed`).
92    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    /// Build a new Burn PPO trainer.
102    ///
103    /// Validates the config and stages the global gradient-norm cap
104    /// ([`PPOConfig::max_grad_norm`]) on the optimizer wrapper; `train_step`
105    /// applies it to the gradients of every minibatch step (issue #299).
106    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    /// Borrow the configuration.
126    pub fn config(&self) -> &PPOConfig {
127        &self.config
128    }
129
130    /// Borrow the policy. Panics if the trainer is mid-step (the policy
131    /// has been moved into the optimizer); only safe to call between
132    /// `train_step` invocations.
133    pub fn policy(&self) -> &P {
134        self.policy.as_ref().expect("policy is None mid-step")
135    }
136
137    /// Total completed gradient updates.
138    pub fn total_steps(&self) -> usize {
139        self.total_steps
140    }
141
142    /// Total completed episodes (caller increments).
143    pub fn total_episodes(&self) -> usize {
144        self.total_episodes
145    }
146
147    /// Increment the episode counter.
148    pub fn increment_episodes(&mut self, n: usize) {
149        self.total_episodes += n;
150    }
151
152    /// Train for one PPO update.
153    ///
154    /// Implements the same algorithm as
155    /// `PPOTrainer::train_step_with_policy` on the tch path:
156    /// 1. Normalize advantages to zero mean / unit variance.
157    /// 2. For each of `n_epochs` epochs, shuffle the buffer and iterate
158    ///    minibatches.
159    /// 3. Compute surrogate / value / entropy losses.
160    /// 4. `total_loss = policy + vf_coef * value + ent_coef * entropy`.
161    /// 5. Backprop, build `GradientsParams`, step the optimizer.
162    /// 6. KL early stop if `approx_kl > target_kl`.
163    /// 7. Entropy-collapse guard.
164    ///
165    /// The `evaluate_fn` closure receives `(&policy, obs, actions)` and
166    /// must return `(log_probs, entropy, values)` — exactly the same
167    /// shape as the policy network's `evaluate_actions` method.
168    #[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        // Advantage normalization (matches the tch trainer).
188        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            // Seedable RNG → reproducible minibatch shuffle order
196            // under `config.seed` (issue #109).
197            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                // Take the policy out so we can move it through `step`.
217                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                // Scalars for stat collection.
242                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                // total_loss = policy_loss + vf_coef * value_loss + ent_coef * entropy_loss
247                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                // Burn gradient flow: backward → GradientsParams → step.
253                let grads = total_loss.backward();
254                let grads = GradientsParams::from_grads(grads, &policy);
255                // Global gradient-norm clip (issue #299):
256                // `PPOConfig::max_grad_norm` is staged on the wrapper in
257                // `new` and applied to the gradient slice before the
258                // move-through step, mirroring the joint trainer (#239).
259                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        // Entropy-collapse guard (matches the tch trainer).
291        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
310/// Biased standard deviation (denominator `n`).
311fn 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
320/// Select `indices` rows from a rank-2 tensor.
321fn 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
336/// Select `indices` rows from a rank-1 float tensor.
337fn 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
347/// Select `indices` rows from a rank-1 int tensor.
348///
349/// NOTE: We can't call `to_vec::<i64>()` directly here because Burn's
350/// integer dtype is backend-dependent — `NdArray` uses `i64`, but `Wgpu`
351/// uses `i32`. `to_vec` requires the requested `E` to match the stored
352/// dtype exactly, so on wgpu it returns `DataError::TypeMismatch` and
353/// `unwrap_or_default()` silently yields an empty vector, triggering an
354/// out-of-bounds panic. Using `.iter::<i64>()` instead lets Burn handle
355/// the per-element cast, so the host buffer is always populated.
356fn 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    /// Flatten every float parameter of a module into one host vector.
381    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    /// L2 norm of the parameter update `after - before`.
397    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    /// Smoke test: a Burn PPO trainer constructs and exposes the same
408    /// config back through `config()`.
409    #[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    /// End-to-end: a single train_step against a synthetic batch
420    /// completes without error, moves through the optimizer, and
421    /// records `num_updates > 0`.
422    #[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        // Smaller batch_size so the synthetic 8-row batch produces > 1
429        // minibatch per epoch.
430        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        // Stats should be finite.
480        assert!(stats.policy_loss.is_finite());
481        assert!(stats.value_loss.is_finite());
482    }
483
484    /// Issue #299: `PPOConfig::max_grad_norm` must actually be applied.
485    ///
486    /// Two trainers start from identical (cloned) policies, identical
487    /// synthetic data, and identical seeds; the only difference is the cap.
488    /// The tiny cap scales the gradients far below Adam's epsilon, so its
489    /// parameter update must come out much smaller than the
490    /// effectively-unbounded control's. The huge-cap control doubles as the
491    /// no-clip baseline: gradients below the cap pass through untouched (see
492    /// `train::grad_clip::tests` for the direct no-op assertion).
493    #[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        // `batch_size == batch` → one minibatch; `n_epochs == 1` → exactly
556        // one gradient step per trainer.
557        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}