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::optimizer::{BackendOptimizer, BurnOptimizer};
59
60/// Burn-backend PPO trainer.
61///
62/// Generic over:
63/// - `B: AutodiffBackend` — the Burn backend (e.g. `Autodiff<NdArray<f32>>`,
64///   `Autodiff<Wgpu>`, etc.).
65/// - `P: AutodiffModule<B>` — the policy module type.
66/// - `O: Optimizer<P, B>` — the Burn optimizer (typically built from
67///   `AdamConfig::new().init()`).
68///
69/// The policy is held in `Option<P>` because Burn's `Optimizer::step`
70/// consumes the module by value. We use `.take()` / put-back across
71/// each gradient step.
72pub 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    /// Seedable RNG for the per-epoch minibatch shuffle. Owned by the
85    /// trainer so the shuffle order is reproducible under
86    /// `config.seed` (issue #109). Previously the shuffle drew from
87    /// the thread-local `rand::rng()`, which defeated any
88    /// upstream seed plumbing (e.g. `PsroConfig::seed`).
89    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    /// Build a new Burn PPO trainer.
99    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    /// Borrow the configuration.
114    pub fn config(&self) -> &PPOConfig {
115        &self.config
116    }
117
118    /// Borrow the policy. Panics if the trainer is mid-step (the policy
119    /// has been moved into the optimizer); only safe to call between
120    /// `train_step` invocations.
121    pub fn policy(&self) -> &P {
122        self.policy.as_ref().expect("policy is None mid-step")
123    }
124
125    /// Total completed gradient updates.
126    pub fn total_steps(&self) -> usize {
127        self.total_steps
128    }
129
130    /// Total completed episodes (caller increments).
131    pub fn total_episodes(&self) -> usize {
132        self.total_episodes
133    }
134
135    /// Increment the episode counter.
136    pub fn increment_episodes(&mut self, n: usize) {
137        self.total_episodes += n;
138    }
139
140    /// Train for one PPO update.
141    ///
142    /// Implements the same algorithm as
143    /// `PPOTrainer::train_step_with_policy` on the tch path:
144    /// 1. Normalize advantages to zero mean / unit variance.
145    /// 2. For each of `n_epochs` epochs, shuffle the buffer and iterate
146    ///    minibatches.
147    /// 3. Compute surrogate / value / entropy losses.
148    /// 4. `total_loss = policy + vf_coef * value + ent_coef * entropy`.
149    /// 5. Backprop, build `GradientsParams`, step the optimizer.
150    /// 6. KL early stop if `approx_kl > target_kl`.
151    /// 7. Entropy-collapse guard.
152    ///
153    /// The `evaluate_fn` closure receives `(&policy, obs, actions)` and
154    /// must return `(log_probs, entropy, values)` — exactly the same
155    /// shape as the policy network's `evaluate_actions` method.
156    #[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        // Advantage normalization (matches the tch trainer).
176        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            // Seedable RNG → reproducible minibatch shuffle order
184            // under `config.seed` (issue #109).
185            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                // Take the policy out so we can move it through `step`.
205                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                // Scalars for stat collection.
230                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                // total_loss = policy_loss + vf_coef * value_loss + ent_coef * entropy_loss
235                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                // Burn gradient flow: backward → GradientsParams → step.
241                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        // Entropy-collapse guard (matches the tch trainer).
269        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
288/// Biased standard deviation (denominator `n`).
289fn 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
298/// Select `indices` rows from a rank-2 tensor.
299fn 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
314/// Select `indices` rows from a rank-1 float tensor.
315fn 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
325/// Select `indices` rows from a rank-1 int tensor.
326///
327/// NOTE: We can't call `to_vec::<i64>()` directly here because Burn's
328/// integer dtype is backend-dependent — `NdArray` uses `i64`, but `Wgpu`
329/// uses `i32`. `to_vec` requires the requested `E` to match the stored
330/// dtype exactly, so on wgpu it returns `DataError::TypeMismatch` and
331/// `unwrap_or_default()` silently yields an empty vector, triggering an
332/// out-of-bounds panic. Using `.iter::<i64>()` instead lets Burn handle
333/// the per-element cast, so the host buffer is always populated.
334fn 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    /// Smoke test: a Burn PPO trainer constructs and exposes the same
358    /// config back through `config()`.
359    #[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    /// End-to-end: a single train_step against a synthetic batch
370    /// completes without error, moves through the optimizer, and
371    /// records `num_updates > 0`.
372    #[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        // Smaller batch_size so the synthetic 8-row batch produces > 1
379        // minibatch per epoch.
380        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        // Stats should be finite.
430        assert!(stats.policy_loss.is_finite());
431        assert!(stats.value_loss.is_finite());
432    }
433}