thrust_rl/train/a2c/trainer.rs
1//! Synchronous Advantage Actor-Critic (A2C) trainer (Burn backend).
2//!
3//! Sibling to [`crate::train::ppo::trainer::PPOTrainerBurn`]. A2C reuses
4//! the same policy ([`MlpBurnPolicy`]), optimizer ([`BurnOptimizer`]) and
5//! GAE/advantage path, and shares PPO's `Option<P>` move-through
6//! ownership model (Burn's `Optimizer::step` consumes the module by
7//! value). It diverges from PPO in exactly two places:
8//!
9//! 1. **Loss** — un-clipped policy-gradient loss + plain-MSE value loss (see
10//! [`crate::train::a2c::loss`]); no importance ratio, no value clipping.
11//! 2. **Loop** — exactly **one** gradient step per rollout: no epoch loop, no
12//! minibatch shuffle, no KL early-stop. Consequently
13//! [`A2cTrainer::train_step`] drops the `old_log_probs` / `old_values`
14//! arguments PPO needs for its importance ratio and value clipping.
15//!
16//! [`MlpBurnPolicy`]: crate::policy::mlp::MlpBurnPolicy
17
18use anyhow::{Result, anyhow};
19use burn::{
20 module::AutodiffModule,
21 optim::{GradientsParams, Optimizer},
22 tensor::{Int, Tensor, backend::AutodiffBackend},
23};
24use rand::{SeedableRng, rngs::StdRng};
25
26use super::{
27 config::A2cConfig,
28 loss::{compute_a2c_policy_loss, compute_a2c_value_loss, compute_entropy_loss, scalar_f64},
29};
30use crate::train::optimizer::{BackendOptimizer, BurnOptimizer};
31
32/// Per-update A2C training statistics.
33///
34/// Purpose-built (rather than reusing PPO's
35/// [`TrainingStats`](crate::train::ppo::TrainingStats)) because A2C has
36/// no clip-fraction / KL / explained-variance diagnostics — the loop
37/// performs a single un-clipped update. All fields are finite host-side
38/// `f64`s pulled off the autograd tape after the gradient step.
39#[derive(Debug, Clone, Copy, Default)]
40pub struct A2cStats {
41 /// Policy-gradient loss `-mean(log_prob * advantage)`.
42 pub policy_loss: f64,
43 /// Plain-MSE value loss `mean((V(s) - returns)^2)`.
44 pub value_loss: f64,
45 /// Mean per-row policy entropy (the bonus, before negation/scaling).
46 pub entropy: f64,
47 /// Total optimized loss
48 /// `policy_loss + value_coef * value_loss + entropy_coef * entropy_loss`.
49 pub total_loss: f64,
50}
51
52/// Burn-backend A2C trainer.
53///
54/// Generic over:
55/// - `B: AutodiffBackend` — the Burn backend (e.g. `Autodiff<NdArray<f32>>`).
56/// - `P: AutodiffModule<B>` — the shared actor-critic policy module.
57/// - `O: Optimizer<P, B>` — the Burn optimizer (typically
58/// `AdamConfig::new().init()`).
59///
60/// The policy is held in `Option<P>` because Burn's `Optimizer::step`
61/// consumes the module by value; we `.take()` it and put back the updated
62/// copy across the single gradient step.
63pub struct A2cTrainer<B, P, O>
64where
65 B: AutodiffBackend,
66 P: AutodiffModule<B>,
67 O: Optimizer<P, B>,
68{
69 config: A2cConfig,
70 policy: Option<P>,
71 optimizer: BurnOptimizer<B, P, O>,
72 total_steps: usize,
73 total_episodes: usize,
74 low_entropy_count: usize,
75 /// Seedable RNG owned by the trainer so any stochastic step (e.g.
76 /// future seeded advantage handling) is reproducible under
77 /// [`A2cConfig::seed`]. Kept for parity with
78 /// [`PPOTrainerBurn`](crate::train::ppo::trainer::PPOTrainerBurn),
79 /// whose minibatch shuffle draws from this RNG.
80 #[allow(dead_code)]
81 rng: StdRng,
82}
83
84impl<B, P, O> A2cTrainer<B, P, O>
85where
86 B: AutodiffBackend,
87 P: AutodiffModule<B> + Clone,
88 O: Optimizer<P, B>,
89{
90 /// Build a new Burn A2C trainer.
91 ///
92 /// Validates the config and stages the global gradient-norm clip
93 /// ([`A2cConfig::max_grad_norm`]) on the optimizer wrapper.
94 pub fn new(
95 config: A2cConfig,
96 policy: P,
97 mut optimizer: BurnOptimizer<B, P, O>,
98 ) -> Result<Self> {
99 config.validate()?;
100 let rng = StdRng::seed_from_u64(config.seed);
101 // Record the global-norm cap on the wrapper. The actual clip is
102 // honored by the Burn optimizer built with
103 // `AdamConfig::with_grad_clipping`; staging it here keeps the
104 // trainer's view of the cap consistent with PPO.
105 optimizer.clip_grad_norm(config.max_grad_norm);
106 Ok(Self {
107 config,
108 policy: Some(policy),
109 optimizer,
110 total_steps: 0,
111 total_episodes: 0,
112 low_entropy_count: 0,
113 rng,
114 })
115 }
116
117 /// Borrow the configuration.
118 pub fn config(&self) -> &A2cConfig {
119 &self.config
120 }
121
122 /// Borrow the policy. Panics if the trainer is mid-step (the policy
123 /// has been moved into the optimizer); only safe to call between
124 /// `train_step` invocations.
125 pub fn policy(&self) -> &P {
126 self.policy.as_ref().expect("policy is None mid-step")
127 }
128
129 /// Total completed gradient updates (one per `train_step`).
130 pub fn total_steps(&self) -> usize {
131 self.total_steps
132 }
133
134 /// Total completed episodes (caller increments).
135 pub fn total_episodes(&self) -> usize {
136 self.total_episodes
137 }
138
139 /// Increment the episode counter.
140 pub fn increment_episodes(&mut self, n: usize) {
141 self.total_episodes += n;
142 }
143
144 /// Train for one A2C update.
145 ///
146 /// Performs exactly **one** gradient step over the whole rollout:
147 /// 1. Optionally normalize advantages to zero-mean/unit-variance when
148 /// [`A2cConfig::normalize_advantages`] is set.
149 /// 2. Evaluate the policy to get `(log_probs, entropy, values)`.
150 /// 3. `policy_loss = -mean(log_prob * advantage)` (no ratio, no clip).
151 /// 4. `value_loss = mean((V(s) - returns)^2)` (plain MSE, no clip).
152 /// 5. `entropy_loss = -mean(entropy)`.
153 /// 6. `total = policy_loss + value_coef * value_loss
154 /// + entropy_coef * entropy_loss`.
155 /// 7. Backprop, build `GradientsParams`, step the optimizer once.
156 /// 8. Entropy-collapse guard (shared with PPO).
157 ///
158 /// Note the **absence** of `old_log_probs` / `old_values`: A2C is
159 /// on-policy with a single update, so there is no behaviour policy to
160 /// form an importance ratio against, and no old value baseline to
161 /// clip against.
162 ///
163 /// The `evaluate_fn` closure receives `(&policy, obs, actions)` and
164 /// must return `(log_probs, entropy, values)` — exactly the shape of
165 /// [`MlpBurnPolicy::evaluate_actions`](crate::policy::mlp::MlpBurnPolicy::evaluate_actions).
166 pub fn train_step<F>(
167 &mut self,
168 observations: Tensor<B, 2>,
169 actions: Tensor<B, 1, Int>,
170 advantages: Tensor<B, 1>,
171 returns: Tensor<B, 1>,
172 mut evaluate_fn: F,
173 ) -> Result<A2cStats>
174 where
175 F: FnMut(&P, Tensor<B, 2>, Tensor<B, 1, Int>) -> (Tensor<B, 1>, Tensor<B, 1>, Tensor<B, 1>),
176 {
177 let device = observations.device();
178
179 // Advantage normalization (matches the PPO trainer's host-side
180 // biased normalization). Done on the host so the advantages stay
181 // detached constants w.r.t. the policy parameters.
182 let advantages = if self.config.normalize_advantages {
183 let adv_mean = scalar_f64(advantages.clone().mean());
184 let adv_data: Vec<f32> = advantages.into_data().to_vec().unwrap_or_default();
185 let adv_std = host_std_biased(&adv_data, adv_mean) as f32;
186 let normalized: Vec<f32> =
187 adv_data.iter().map(|&a| (a - adv_mean as f32) / (adv_std + 1e-8)).collect();
188 let n = normalized.len();
189 Tensor::<B, 1>::from_data(burn::tensor::TensorData::new(normalized, [n]), &device)
190 } else {
191 advantages
192 };
193
194 // Take the policy out so we can move it through `step`.
195 let policy = self
196 .policy
197 .take()
198 .ok_or_else(|| anyhow!("policy is None; concurrent train_step calls?"))?;
199
200 let (log_probs, entropy, values) = evaluate_fn(&policy, observations, actions);
201
202 let policy_loss = compute_a2c_policy_loss(log_probs, advantages);
203 let value_loss = compute_a2c_value_loss(values, returns);
204 let entropy_loss = compute_entropy_loss(entropy.clone());
205
206 // Host-side scalars for stat collection.
207 let policy_loss_val = scalar_f64(policy_loss.clone());
208 let value_loss_val = scalar_f64(value_loss.clone());
209 let entropy_val = scalar_f64(entropy.mean());
210
211 // total = policy_loss + value_coef * value_loss + entropy_coef * entropy_loss
212 let total_loss = policy_loss
213 + value_loss.mul_scalar(self.config.value_coef as f32)
214 + entropy_loss.mul_scalar(self.config.entropy_coef as f32);
215 let total_loss_val = scalar_f64(total_loss.clone());
216
217 // Burn gradient flow: backward → GradientsParams → single step.
218 let grads = total_loss.backward();
219 let grads = GradientsParams::from_grads(grads, &policy);
220 let lr = self.optimizer.learning_rate();
221 let policy = self.optimizer.inner_mut().step(lr, policy, grads);
222 self.policy = Some(policy);
223
224 self.total_steps += 1;
225
226 let stats = A2cStats {
227 policy_loss: policy_loss_val,
228 value_loss: value_loss_val,
229 entropy: entropy_val,
230 total_loss: total_loss_val,
231 };
232
233 // Entropy-collapse guard (matches the PPO trainer).
234 const ENTROPY_THRESHOLD: f64 = 0.05;
235 const MAX_LOW_ENTROPY_COUNT: usize = 3;
236 if stats.entropy < ENTROPY_THRESHOLD {
237 self.low_entropy_count += 1;
238 if self.low_entropy_count >= MAX_LOW_ENTROPY_COUNT {
239 return Err(anyhow!(
240 "Training stopped due to entropy collapse (entropy < {} for {} updates)",
241 ENTROPY_THRESHOLD,
242 MAX_LOW_ENTROPY_COUNT
243 ));
244 }
245 } else {
246 self.low_entropy_count = 0;
247 }
248
249 Ok(stats)
250 }
251}
252
253/// Biased standard deviation (denominator `n`). Mirrors the PPO
254/// trainer's host-side advantage-normalization helper.
255fn host_std_biased(xs: &[f32], mean: f64) -> f64 {
256 if xs.is_empty() {
257 return 0.0;
258 }
259 let n = xs.len() as f64;
260 let sq_dev = xs.iter().map(|&x| (x as f64 - mean).powi(2)).sum::<f64>();
261 (sq_dev / n).sqrt()
262}
263
264#[cfg(test)]
265mod tests {
266 use burn::{
267 backend::{Autodiff, NdArray},
268 optim::AdamConfig,
269 tensor::TensorData,
270 };
271
272 use super::*;
273 use crate::{policy::mlp::MlpBurnPolicy, train::optimizer::BurnOptimizer};
274
275 type B = Autodiff<NdArray<f32>>;
276
277 /// Smoke test: an A2C trainer constructs and reports zero steps.
278 #[test]
279 fn a2c_trainer_constructs() {
280 let device = Default::default();
281 let policy = MlpBurnPolicy::<B>::new(4, 2, 32, &device);
282 let inner_opt = AdamConfig::new().init();
283 let burn_opt: BurnOptimizer<B, MlpBurnPolicy<B>, _> = BurnOptimizer::new(inner_opt, 7e-4);
284 let trainer = A2cTrainer::new(A2cConfig::default(), policy, burn_opt).unwrap();
285 assert_eq!(trainer.total_steps(), 0);
286 assert_eq!(trainer.total_episodes(), 0);
287 }
288
289 /// End-to-end: a single `train_step` against a synthetic batch
290 /// completes, performs exactly one update, and yields finite stats.
291 #[test]
292 fn a2c_train_step_runs() {
293 let device = Default::default();
294 let policy = MlpBurnPolicy::<B>::new(4, 2, 16, &device);
295 let inner_opt = AdamConfig::new().init();
296 let burn_opt: BurnOptimizer<B, MlpBurnPolicy<B>, _> = BurnOptimizer::new(inner_opt, 1e-3);
297 let mut trainer = A2cTrainer::new(A2cConfig::default(), policy, burn_opt).unwrap();
298
299 let batch = 8;
300 let obs_dim = 4;
301 let obs_data: Vec<f32> = (0..batch * obs_dim).map(|i| (i as f32) * 0.01).collect();
302 let observations =
303 Tensor::<B, 2>::from_data(TensorData::new(obs_data, [batch, obs_dim]), &device);
304 let actions = Tensor::<B, 1, Int>::from_data(
305 TensorData::new(vec![0i64, 1, 0, 1, 0, 1, 0, 1], [batch]),
306 &device,
307 );
308 let advantages = Tensor::<B, 1>::from_data(
309 TensorData::new(vec![1.0f32, -1.0, 0.5, -0.5, 1.0, -1.0, 0.5, -0.5], [batch]),
310 &device,
311 );
312 let returns =
313 Tensor::<B, 1>::from_data(TensorData::new(vec![1.0f32; batch], [batch]), &device);
314
315 let stats = trainer
316 .train_step(observations, actions, advantages, returns, |p, o, a| {
317 p.evaluate_actions(o, a)
318 })
319 .unwrap();
320
321 // Exactly one gradient step per rollout.
322 assert_eq!(trainer.total_steps(), 1);
323 assert!(stats.policy_loss.is_finite());
324 assert!(stats.value_loss.is_finite());
325 assert!(stats.entropy.is_finite());
326 assert!(stats.total_loss.is_finite());
327 }
328}