Skip to main content

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}