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