Skip to main content

thrust_rl/train/ppo/
recurrent_trainer.rs

1//! Recurrent PPO trainer (Burn backend).
2//!
3//! Phase 3 of the recurrent-policy epic (#262). [`RecurrentPPOTrainer`] is the
4//! rank-3 sibling of [`crate::train::ppo::trainer::PPOTrainerBurn`]: it drives
5//! the same clipped-surrogate / value-clip / entropy-bonus / KL-early-stop
6//! recipe, reusing the **unchanged** loss functions in
7//! [`crate::train::ppo::loss`], but consumes whole-trajectory sequence batches
8//! from a [`RecurrentRolloutBuffer`] instead of flattened rank-2 transitions.
9//!
10//! # Why a separate trainer
11//!
12//! The feedforward trainer's `evaluate_fn` closure is hard-typed to rank-2
13//! observations (`Tensor<B, 2>`) and rank-1 actions — it flattens time and env
14//! together, structurally erasing the temporal order a recurrent policy needs.
15//! Recurrence requires a rank-3 `[N_env, T, obs_dim]` forward that runs the
16//! LSTM step-by-step and resets `(h, c)` at episode boundaries. Rather than
17//! overload the feedforward closure, this trainer takes a rank-3 analogue:
18//!
19//! ```text
20//! evaluate_fn(&policy, obs_seq [N_env,T,obs_dim], actions [N_env,T], episode_starts [N_env,T])
21//!     -> (log_probs [N_env,T], entropy [N_env,T], values [N_env,T])
22//! ```
23//!
24//! # Rank-2 → rank-1 shape adapter
25//!
26//! [`crate::train::ppo::loss`]'s `compute_policy_loss` / `compute_value_loss` /
27//! `compute_entropy_loss` all operate on rank-1 tensors. `evaluate_sequences`
28//! returns rank-2 `[N_env, T]`. The trainer therefore flattens
29//! (`reshape([N_env * T])`) **inside** the minibatch loop, *after* the forward
30//! — the LSTM forward needs the `[N_env, T]` shape intact to run its
31//! step-by-step loop with per-step state resets. Advantage normalization runs
32//! on the flattened rank-1 view, exactly as in `PPOTrainerBurn`.
33//!
34//! # Episode-boundary contract
35//!
36//! `episode_starts` is consumed **directly** from the buffer's materialized
37//! batch (`terminated[t-1] || truncated[t-1]` shifted one step, with the
38//! cross-iteration carry at `t == 0`). GAE stays terminated-only, computed
39//! upstream by the buffer. The two masks are intentionally distinct; this
40//! trainer does not re-derive or touch either (see
41//! `docs/RECURRENT_POLICY_DESIGN.md`, Q2).
42//!
43//! # Ownership model
44//!
45//! Identical to `PPOTrainerBurn`: the policy is held in `Option<P>` and swapped
46//! through Burn's move-through optimizer on every gradient step.
47
48use anyhow::{Result, anyhow};
49use burn::{
50    module::AutodiffModule,
51    optim::{GradientsParams, Optimizer},
52    tensor::{Int, Tensor, TensorData, backend::AutodiffBackend},
53};
54
55use super::{
56    config::PPOConfig,
57    loss::{compute_entropy_loss, compute_policy_loss, compute_value_loss, scalar_f64},
58    stats::TrainingStats,
59};
60use crate::{
61    buffer::rollout::RecurrentRolloutBuffer,
62    train::{
63        grad_clip::clip_grads_by_global_norm,
64        optimizer::{BackendOptimizer, BurnOptimizer},
65    },
66};
67
68/// Recurrent PPO trainer (Burn backend).
69///
70/// Generic over the same three parameters as
71/// [`crate::train::ppo::trainer::PPOTrainerBurn`]:
72/// - `B: AutodiffBackend` — the Burn backend.
73/// - `P: AutodiffModule<B>` — the recurrent policy module (e.g.
74///   [`crate::policy::lstm::LstmBurnPolicy`]).
75/// - `O: Optimizer<P, B>` — the Burn optimizer.
76///
77/// The policy is held in `Option<P>` because Burn's `Optimizer::step` consumes
78/// the module by value; we `.take()` / put-back across each gradient step.
79pub struct RecurrentPPOTrainer<B, P, O>
80where
81    B: AutodiffBackend,
82    P: AutodiffModule<B>,
83    O: Optimizer<P, B>,
84{
85    config: PPOConfig,
86    policy: Option<P>,
87    optimizer: BurnOptimizer<B, P, O>,
88    device: B::Device,
89    /// Optional learning-rate override applied on the next `train_step`,
90    /// `None` ⇒ use the optimizer's configured rate. Set by
91    /// [`Self::set_learning_rate`] to support caller-driven LR schedules
92    /// (e.g. linear annealing), which stabilize the end of long PPO runs.
93    lr_override: Option<f64>,
94    total_steps: usize,
95    total_episodes: usize,
96    low_entropy_count: usize,
97}
98
99impl<B, P, O> RecurrentPPOTrainer<B, P, O>
100where
101    B: AutodiffBackend,
102    P: AutodiffModule<B> + Clone,
103    O: Optimizer<P, B>,
104{
105    /// Build a new recurrent PPO trainer.
106    ///
107    /// `device` is the Burn device the sequence-batch tensors are
108    /// materialized on (the buffer stores host `Vec` data, so — unlike the
109    /// feedforward trainer, which reads the device off the observation tensor
110    /// passed to `train_step` — the recurrent trainer must be told which
111    /// device to build minibatches on).
112    ///
113    /// Validates the config and stages the global gradient-norm cap
114    /// ([`PPOConfig::max_grad_norm`]) on the optimizer wrapper; `train_step`
115    /// applies it to the gradients of every minibatch step (issue #299).
116    pub fn new(
117        config: PPOConfig,
118        policy: P,
119        mut optimizer: BurnOptimizer<B, P, O>,
120        device: B::Device,
121    ) -> Result<Self> {
122        config.validate()?;
123        optimizer.clip_grad_norm(config.max_grad_norm);
124        Ok(Self {
125            config,
126            policy: Some(policy),
127            optimizer,
128            device,
129            lr_override: None,
130            total_steps: 0,
131            total_episodes: 0,
132            low_entropy_count: 0,
133        })
134    }
135
136    /// Override the learning rate used by subsequent `train_step` calls.
137    ///
138    /// Enables caller-driven schedules (linear annealing, warmup, …). Pass the
139    /// per-update rate before each `train_step`. Without a call, the
140    /// optimizer's configured rate is used.
141    pub fn set_learning_rate(&mut self, lr: f64) {
142        self.lr_override = Some(lr);
143    }
144
145    /// Borrow the configuration.
146    pub fn config(&self) -> &PPOConfig {
147        &self.config
148    }
149
150    /// Borrow the policy. Panics if called mid-step (the policy has been moved
151    /// into the optimizer); only safe between `train_step` invocations.
152    pub fn policy(&self) -> &P {
153        self.policy.as_ref().expect("policy is None mid-step")
154    }
155
156    /// Total completed gradient updates.
157    pub fn total_steps(&self) -> usize {
158        self.total_steps
159    }
160
161    /// Total completed episodes (caller increments).
162    pub fn total_episodes(&self) -> usize {
163        self.total_episodes
164    }
165
166    /// Increment the episode counter.
167    pub fn increment_episodes(&mut self, n: usize) {
168        self.total_episodes += n;
169    }
170
171    /// Train for one recurrent PPO update.
172    ///
173    /// Iterates `n_epochs` passes over the buffer's env-major minibatches
174    /// (whole trajectories, `envs_per_minibatch` at a time), running the
175    /// rank-3 `evaluate_fn` forward, flattening to rank-1, and applying the
176    /// shared PPO surrogate / value / entropy losses. Mirrors
177    /// [`crate::train::ppo::trainer::PPOTrainerBurn::train_step`] step-for-step
178    /// except for the sequence-batch assembly and the rank-2 → rank-1 flatten.
179    ///
180    /// * `buffer` — the recurrent rollout buffer; advantages/returns must
181    ///   already be computed (via `buffer.compute_advantages`).
182    /// * `envs_per_minibatch` — whole env-trajectories per minibatch (the
183    ///   recurrent analogue of the feedforward `batch_size`).
184    /// * `evaluate_fn` — receives `(&policy, obs_seq, actions, episode_starts)`
185    ///   and returns `(log_probs, entropy, values)`, each `[N_env, T]`.
186    pub fn train_step<F>(
187        &mut self,
188        buffer: &RecurrentRolloutBuffer,
189        envs_per_minibatch: usize,
190        mut evaluate_fn: F,
191    ) -> Result<TrainingStats>
192    where
193        F: FnMut(
194            &P,
195            Tensor<B, 3>,
196            Tensor<B, 2, Int>,
197            Tensor<B, 2>,
198        ) -> (Tensor<B, 2>, Tensor<B, 2>, Tensor<B, 2>),
199    {
200        let device = self.device.clone();
201        let mut stats_sum = TrainingStats::zeros();
202        let mut num_updates = 0;
203
204        for _epoch in 0..self.config.n_epochs {
205            for batch in buffer.to_minibatches::<B>(envs_per_minibatch, true, &device) {
206                // Take the policy out so we can move it through `step`.
207                let policy = self
208                    .policy
209                    .take()
210                    .ok_or_else(|| anyhow!("policy is None; concurrent train_step calls?"))?;
211
212                // Rank-3 forward: the LSTM needs `[N_env, T]` intact to run its
213                // per-step loop with episode-boundary resets.
214                let (log_probs, entropy, values) = evaluate_fn(
215                    &policy,
216                    batch.obs_seq.clone(),
217                    batch.actions.clone(),
218                    batch.episode_starts.clone(),
219                );
220
221                // Flatten rank-2 `[N_env, T]` → rank-1 `[N_env * T]` for the
222                // loss functions (which operate on rank-1 tensors).
223                let [n_env, t] = log_probs.dims();
224                let flat = n_env * t;
225                let log_probs = log_probs.reshape([flat]);
226                let entropy = entropy.reshape([flat]);
227                let values = values.reshape([flat]);
228                let old_log_probs = batch.old_log_probs.clone().reshape([flat]);
229                let old_values = batch.old_values.clone().reshape([flat]);
230                let advantages = batch.advantages.clone().reshape([flat]);
231                let returns = batch.returns.clone().reshape([flat]);
232
233                // Advantage normalization on the flattened 1D view (matches
234                // `PPOTrainerBurn`). Advantages come from the buffer as data —
235                // no autograd tape — so a host round-trip is safe.
236                let adv_data: Vec<f32> = advantages.into_data().to_vec().unwrap_or_default();
237                let adv_mean = host_mean(&adv_data);
238                let adv_std = host_std_biased(&adv_data, adv_mean);
239                let adv_norm: Vec<f32> = adv_data
240                    .iter()
241                    .map(|&a| (a - adv_mean as f32) / (adv_std as f32 + 1e-8))
242                    .collect();
243                let advantages =
244                    Tensor::<B, 1>::from_data(TensorData::new(adv_norm, [flat]), &device);
245
246                let (policy_loss, clip_fraction, approx_kl) = compute_policy_loss(
247                    log_probs,
248                    old_log_probs,
249                    advantages,
250                    self.config.clip_range,
251                );
252
253                let (value_loss, explained_var) =
254                    compute_value_loss(values, old_values, returns, self.config.clip_range_vf);
255
256                let entropy_loss = compute_entropy_loss(entropy.clone());
257
258                // Scalars for stat collection.
259                let policy_loss_val = scalar_f64(policy_loss.clone());
260                let value_loss_val = scalar_f64(value_loss.clone());
261                let entropy_val = scalar_f64(entropy.mean());
262
263                // total_loss = policy_loss + vf_coef * value_loss + ent_coef * entropy_loss
264                let total_loss = policy_loss
265                    + value_loss.mul_scalar(self.config.vf_coef as f32)
266                    + entropy_loss.mul_scalar(self.config.ent_coef as f32);
267                let total_loss_val = scalar_f64(total_loss.clone());
268
269                // Burn gradient flow: backward → GradientsParams → step.
270                let grads = total_loss.backward();
271                let grads = GradientsParams::from_grads(grads, &policy);
272                // Global gradient-norm clip (issue #299):
273                // `PPOConfig::max_grad_norm` is staged on the wrapper in
274                // `new` and applied to the gradient slice before the
275                // move-through step, mirroring the joint trainer (#239).
276                let grads = match self.optimizer.grad_clip_norm() {
277                    Some(max_norm) if max_norm > 0.0 => {
278                        clip_grads_by_global_norm::<B, P>(&policy, grads, max_norm as f32)
279                    }
280                    _ => grads,
281                };
282                let lr = self.lr_override.unwrap_or_else(|| self.optimizer.learning_rate());
283                let policy = self.optimizer.inner_mut().step(lr, policy, grads);
284                self.policy = Some(policy);
285
286                let step_stats = TrainingStats::new(
287                    policy_loss_val,
288                    value_loss_val,
289                    entropy_val,
290                    total_loss_val,
291                    clip_fraction,
292                    approx_kl,
293                    explained_var,
294                );
295                stats_sum.add(&step_stats);
296                num_updates += 1;
297
298                if approx_kl > self.config.target_kl {
299                    break;
300                }
301            }
302        }
303
304        self.total_steps += num_updates;
305        let avg_stats = stats_sum.average();
306
307        // Entropy-collapse guard (matches the feedforward trainer).
308        const ENTROPY_THRESHOLD: f64 = 0.05;
309        const MAX_LOW_ENTROPY_COUNT: usize = 3;
310        if avg_stats.entropy < ENTROPY_THRESHOLD {
311            self.low_entropy_count += 1;
312            if self.low_entropy_count >= MAX_LOW_ENTROPY_COUNT {
313                return Err(anyhow!(
314                    "Training stopped due to entropy collapse (entropy < {} for {} updates)",
315                    ENTROPY_THRESHOLD,
316                    MAX_LOW_ENTROPY_COUNT
317                ));
318            }
319        } else {
320            self.low_entropy_count = 0;
321        }
322
323        Ok(avg_stats)
324    }
325}
326
327/// Host-side arithmetic mean.
328fn host_mean(xs: &[f32]) -> f64 {
329    if xs.is_empty() {
330        return 0.0;
331    }
332    xs.iter().map(|&x| x as f64).sum::<f64>() / xs.len() as f64
333}
334
335/// Biased standard deviation (denominator `n`).
336fn host_std_biased(xs: &[f32], mean: f64) -> f64 {
337    if xs.is_empty() {
338        return 0.0;
339    }
340    let n = xs.len() as f64;
341    let sq_dev = xs.iter().map(|&x| (x as f64 - mean).powi(2)).sum::<f64>();
342    (sq_dev / n).sqrt()
343}
344
345#[cfg(test)]
346mod tests {
347    use burn::{
348        backend::{Autodiff, NdArray},
349        module::{Module, ModuleVisitor, Param},
350        optim::AdamConfig,
351    };
352
353    use super::*;
354    use crate::{policy::lstm::LstmBurnPolicy, train::optimizer::BurnOptimizer};
355
356    type B = Autodiff<NdArray<f32>>;
357
358    /// Flatten every float parameter of a module into one host vector.
359    fn params_flat<M: Module<B>>(module: &M) -> Vec<f32> {
360        struct Collect {
361            out: Vec<f32>,
362        }
363        impl ModuleVisitor<B> for Collect {
364            fn visit_float<const D: usize>(&mut self, param: &Param<Tensor<B, D>>) {
365                let host: Vec<f32> = param.val().into_data().to_vec().unwrap_or_default();
366                self.out.extend(host);
367            }
368        }
369        let mut c = Collect { out: Vec::new() };
370        module.visit(&mut c);
371        c.out
372    }
373
374    /// L2 norm of the parameter update `after - before`.
375    fn update_norm(before: &[f32], after: &[f32]) -> f64 {
376        assert_eq!(before.len(), after.len());
377        before
378            .iter()
379            .zip(after)
380            .map(|(&a, &b)| ((b - a) as f64).powi(2))
381            .sum::<f64>()
382            .sqrt()
383    }
384
385    /// Build a small synthetic recurrent buffer with deterministic data and a
386    /// computed GAE, ready to feed `train_step`.
387    fn synthetic_buffer(
388        num_steps: usize,
389        num_envs: usize,
390        obs_dim: usize,
391    ) -> RecurrentRolloutBuffer {
392        let hidden_dim = 8;
393        let mut buf = RecurrentRolloutBuffer::new(num_steps, num_envs, obs_dim, hidden_dim);
394        for step in 0..num_steps {
395            for env in 0..num_envs {
396                let obs: Vec<f32> = (0..obs_dim).map(|d| 0.1 * (step + env + d) as f32).collect();
397                // Terminate one env midway to exercise the episode-start mask.
398                let term = env == 0 && step == num_steps / 2;
399                buf.add(step, env, &obs, (step % 2) as i64, 1.0, 0.0, -0.7, term, false);
400            }
401        }
402        let last_values = vec![0.0_f32; num_envs];
403        buf.compute_advantages(&last_values, 0.99, 0.95);
404        buf
405    }
406
407    /// The recurrent trainer constructs and reports zeroed counters.
408    #[test]
409    fn recurrent_trainer_constructs() {
410        let device = Default::default();
411        let policy = LstmBurnPolicy::<B>::new(2, 2, 8, &device);
412        let inner_opt = AdamConfig::new().init();
413        let burn_opt: BurnOptimizer<B, LstmBurnPolicy<B>, _> = BurnOptimizer::new(inner_opt, 3e-4);
414        let trainer =
415            RecurrentPPOTrainer::new(PPOConfig::default(), policy, burn_opt, device).unwrap();
416        assert_eq!(trainer.total_steps(), 0);
417    }
418
419    /// End-to-end: a single `train_step` over a synthetic rank-3 batch runs
420    /// without panic, moves the policy through the optimizer, and produces
421    /// finite stats.
422    #[test]
423    fn recurrent_trainer_train_step_runs() {
424        let device = Default::default();
425        let (num_steps, num_envs, obs_dim, action_dim) = (4, 4, 2, 2);
426        let policy = LstmBurnPolicy::<B>::new(obs_dim, action_dim, 8, &device);
427        let inner_opt = AdamConfig::new().init();
428        let burn_opt: BurnOptimizer<B, LstmBurnPolicy<B>, _> = BurnOptimizer::new(inner_opt, 1e-3);
429        let config = PPOConfig::default().n_epochs(2).target_kl(1.0);
430        let mut trainer = RecurrentPPOTrainer::new(config, policy, burn_opt, device).unwrap();
431
432        let buffer = synthetic_buffer(num_steps, num_envs, obs_dim);
433
434        let stats = trainer
435            .train_step(&buffer, 2, |p, obs_seq, actions, episode_starts| {
436                p.evaluate_sequences(obs_seq, actions, None, episode_starts)
437            })
438            .unwrap();
439
440        assert!(trainer.total_steps() > 0, "at least one gradient step taken");
441        assert!(stats.policy_loss.is_finite());
442        assert!(stats.value_loss.is_finite());
443        assert!(stats.entropy.is_finite());
444    }
445
446    /// Issue #299: the recurrent trainer must apply
447    /// `PPOConfig::max_grad_norm`. Two trainers start from identical
448    /// (cloned) policies and the same synthetic buffer; the only difference
449    /// is the cap. The tiny cap scales the gradients far below Adam's
450    /// epsilon, so its parameter update must come out much smaller than the
451    /// effectively-unbounded control's (the huge-cap control doubles as the
452    /// no-clip baseline; see `train::grad_clip::tests` for the direct no-op
453    /// assertion).
454    #[test]
455    fn recurrent_trainer_applies_max_grad_norm() {
456        let device: burn::backend::ndarray::NdArrayDevice = Default::default();
457        let (num_steps, num_envs, obs_dim, action_dim) = (4, 4, 2, 2);
458        let policy = LstmBurnPolicy::<B>::new(obs_dim, action_dim, 8, &device);
459        let buffer = synthetic_buffer(num_steps, num_envs, obs_dim);
460
461        let run = |config: PPOConfig, policy: LstmBurnPolicy<B>| -> f64 {
462            let inner_opt = AdamConfig::new().init();
463            let burn_opt: BurnOptimizer<B, LstmBurnPolicy<B>, _> =
464                BurnOptimizer::new(inner_opt, 1e-3);
465            let mut trainer = RecurrentPPOTrainer::new(config, policy, burn_opt, device).unwrap();
466            let before = params_flat(trainer.policy());
467            trainer
468                .train_step(&buffer, num_envs, |p, obs_seq, actions, episode_starts| {
469                    p.evaluate_sequences(obs_seq, actions, None, episode_starts)
470                })
471                .unwrap();
472            let after = params_flat(trainer.policy());
473            update_norm(&before, &after)
474        };
475
476        // `envs_per_minibatch == num_envs` → one minibatch; `n_epochs == 1`
477        // → exactly one gradient step per trainer.
478        let base = PPOConfig::default().n_epochs(1).target_kl(1.0);
479        let clipped = run(base.clone().max_grad_norm(1e-6), policy.clone());
480        let unclipped = run(base.max_grad_norm(1e9), policy);
481
482        assert!(unclipped > 0.0, "control update must move parameters");
483        assert!(clipped > 0.0, "clipped update should still move parameters");
484        assert!(
485            clipped < 0.2 * unclipped,
486            "tiny max_grad_norm must shrink the update: clipped {clipped} vs unclipped {unclipped}"
487        );
488    }
489}