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}