Skip to main content

thrust_rl/train/bc/
trainer.rs

1//! Behavioral Cloning trainer (Burn backend).
2//!
3//! Sibling to [`crate::train::a2c::trainer::A2cTrainer`] and
4//! [`crate::train::ppo::trainer::PPOTrainerBurn`]. [`BcTrainer`] reuses the
5//! same policy ([`MlpBurnPolicy`]), optimizer ([`BurnOptimizer`]) and the
6//! `Option<P>` move-through-`step` ownership model (Burn's
7//! `Optimizer::step` consumes the module by value). It DIVERGES from every
8//! RL trainer in loop shape:
9//!
10//! - **No environment interaction.** Training is a supervised epoch loop over a
11//!   fixed [`Demonstrations`] dataset.
12//! - **No advantages / returns / entropy.** The objective is the plain
13//!   cross-entropy of the expert action ([`compute_bc_loss`]); the policy's
14//!   value head is ignored.
15//! - **Many gradient steps per call.** [`BcTrainer::train_epoch`] performs one
16//!   step per minibatch (`ceil(len / batch_size)` steps per epoch), shuffling
17//!   the example order from the trainer's seeded RNG so two runs with the same
18//!   [`BcConfig::seed`] produce identical minibatch order and stats.
19//!
20//! [`MlpBurnPolicy`]: crate::policy::mlp::MlpBurnPolicy
21
22use anyhow::{Result, anyhow};
23use burn::{
24    module::AutodiffModule,
25    optim::{GradientsParams, Optimizer},
26    tensor::{Tensor, backend::AutodiffBackend},
27};
28use rand::{SeedableRng, rngs::StdRng};
29
30use super::{
31    config::BcConfig,
32    dataset::Demonstrations,
33    loss::{compute_bc_loss, scalar_f64},
34};
35use crate::train::{
36    optimizer::{BackendOptimizer, BurnOptimizer},
37    ppo::loss::generate_minibatch_indices_with_rng,
38};
39
40/// Per-epoch behavioral-cloning statistics.
41///
42/// Both fields are finite host-side `f64`s aggregated over the epoch's
43/// minibatches. Purpose-built (rather than reusing the RL stats structs)
44/// because BC has no policy/value/entropy decomposition — just the
45/// supervised loss and the action-match accuracy.
46#[derive(Debug, Clone, Copy, Default)]
47pub struct BcEpochStats {
48    /// Mean cross-entropy loss over the epoch's minibatches.
49    pub loss: f64,
50    /// Mean action-match accuracy in `[0, 1]`: the fraction of examples whose
51    /// argmax-logit action matched the expert label, averaged over the epoch.
52    pub accuracy: f64,
53}
54
55/// Burn-backend Behavioral Cloning trainer.
56///
57/// Generic over:
58/// - `B: AutodiffBackend` — the Burn backend (e.g. `Autodiff<NdArray<f32>>`).
59/// - `P: AutodiffModule<B>` — the policy module (only its logits head is
60///   trained; the value head is ignored).
61/// - `O: Optimizer<P, B>` — the Burn optimizer (typically
62///   `AdamConfig::new().init()`).
63///
64/// The policy is held in `Option<P>` because Burn's `Optimizer::step`
65/// consumes the module by value; each minibatch `.take()`s it and puts back
66/// the updated copy.
67pub struct BcTrainer<B, P, O>
68where
69    B: AutodiffBackend,
70    P: AutodiffModule<B>,
71    O: Optimizer<P, B>,
72{
73    config: BcConfig,
74    policy: Option<P>,
75    optimizer: BurnOptimizer<B, P, O>,
76    /// Seedable RNG owned by the trainer, seeded from [`BcConfig::seed`], so
77    /// the per-epoch minibatch shuffle is reproducible.
78    rng: StdRng,
79    total_steps: usize,
80    total_epochs: usize,
81}
82
83impl<B, P, O> BcTrainer<B, P, O>
84where
85    B: AutodiffBackend,
86    P: AutodiffModule<B> + Clone,
87    O: Optimizer<P, B>,
88{
89    /// Build a new Burn behavioral-cloning trainer.
90    ///
91    /// Validates the config and seeds the trainer's minibatch-shuffle RNG
92    /// from [`BcConfig::seed`].
93    pub fn new(config: BcConfig, policy: P, optimizer: BurnOptimizer<B, P, O>) -> Result<Self> {
94        config.validate()?;
95        let rng = StdRng::seed_from_u64(config.seed);
96        Ok(Self { config, policy: Some(policy), optimizer, rng, total_steps: 0, total_epochs: 0 })
97    }
98
99    /// Borrow the configuration.
100    pub fn config(&self) -> &BcConfig {
101        &self.config
102    }
103
104    /// Borrow the policy. Panics if the trainer is mid-step (the policy has
105    /// been moved into the optimizer); only safe to call between
106    /// `train_epoch` invocations.
107    pub fn policy(&self) -> &P {
108        self.policy.as_ref().expect("policy is None mid-step")
109    }
110
111    /// Total completed gradient updates (one per minibatch across all epochs).
112    pub fn total_steps(&self) -> usize {
113        self.total_steps
114    }
115
116    /// Total completed epochs.
117    pub fn total_epochs(&self) -> usize {
118        self.total_epochs
119    }
120
121    /// Train for one full supervised pass over the demonstration dataset.
122    ///
123    /// 1. Draw a seeded shuffle of all example indices, partitioned into
124    ///    minibatches of [`BcConfig::batch_size`] (`ceil(len / batch_size)`
125    ///    minibatches).
126    /// 2. For each minibatch: gather `(obs, actions)`, produce logits via
127    ///    `forward_fn`, compute the cross-entropy [`compute_bc_loss`],
128    ///    backprop, and step the optimizer once.
129    /// 3. Aggregate the example-weighted mean loss and action-match accuracy
130    ///    over the epoch.
131    ///
132    /// `forward_fn` lets the caller pick how logits are produced from the
133    /// policy — e.g. `|p, o| p.forward(o).0` to drop
134    /// [`MlpBurnPolicy`](crate::policy::mlp::MlpBurnPolicy)'s value head.
135    ///
136    /// Returns finite `loss` and `accuracy in [0, 1]`. Returns an `Err` if the
137    /// dataset is empty.
138    pub fn train_epoch<F>(
139        &mut self,
140        demos: &Demonstrations,
141        mut forward_fn: F,
142    ) -> Result<BcEpochStats>
143    where
144        F: FnMut(&P, Tensor<B, 2>) -> Tensor<B, 2>,
145    {
146        if demos.is_empty() {
147            return Err(anyhow!("cannot train on an empty Demonstrations dataset"));
148        }
149
150        let device = B::Device::default();
151        let batches =
152            generate_minibatch_indices_with_rng(demos.len(), self.config.batch_size, &mut self.rng);
153
154        // Example-weighted accumulators so uneven final minibatches do not
155        // skew the epoch mean.
156        let mut loss_sum = 0.0_f64;
157        let mut correct = 0_usize;
158        let mut seen = 0_usize;
159
160        for indices in &batches {
161            if indices.is_empty() {
162                continue;
163            }
164            let k = indices.len();
165            let (obs, actions) = demos.batch::<B>(indices, &device);
166
167            // Take the policy out so we can move it through `step`.
168            let policy = self
169                .policy
170                .take()
171                .ok_or_else(|| anyhow!("policy is None; concurrent train_epoch calls?"))?;
172
173            let logits = forward_fn(&policy, obs);
174
175            // Host-side accuracy: argmax over the action dim vs expert label.
176            let predicted: Vec<i64> = logits.clone().argmax(1).into_data().to_vec().unwrap();
177            let expected: Vec<i64> = actions.clone().into_data().to_vec().unwrap();
178            correct += predicted.iter().zip(expected.iter()).filter(|(p, e)| p == e).count();
179
180            let loss = compute_bc_loss(logits, actions);
181            loss_sum += scalar_f64(loss.clone()) * k as f64;
182            seen += k;
183
184            // Burn gradient flow: backward -> GradientsParams -> single step.
185            let grads = loss.backward();
186            let grads = GradientsParams::from_grads(grads, &policy);
187            let lr = self.optimizer.learning_rate();
188            let policy = self.optimizer.inner_mut().step(lr, policy, grads);
189            self.policy = Some(policy);
190
191            self.total_steps += 1;
192        }
193
194        self.total_epochs += 1;
195
196        let n = seen.max(1) as f64;
197        Ok(BcEpochStats { loss: loss_sum / n, accuracy: correct as f64 / n })
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use burn::{
204        backend::{Autodiff, NdArray},
205        optim::AdamConfig,
206    };
207
208    use super::*;
209    use crate::{policy::mlp::MlpBurnPolicy, train::optimizer::BurnOptimizer};
210
211    type B = Autodiff<NdArray<f32>>;
212
213    /// Build a tiny synthetic demonstration set: 6 examples, obs_dim 4,
214    /// 2 discrete actions. Linearly separable-ish so accuracy is meaningful.
215    fn tiny_demos() -> Demonstrations {
216        let obs = vec![
217            0.0, 0.0, 0.0, 0.0, // ex 0 -> action 0
218            0.1, 0.0, 0.1, 0.0, // ex 1 -> action 0
219            0.0, 0.1, 0.0, 0.1, // ex 2 -> action 0
220            1.0, 1.0, 1.0, 1.0, // ex 3 -> action 1
221            0.9, 1.0, 0.9, 1.0, // ex 4 -> action 1
222            1.0, 0.9, 1.0, 0.9, // ex 5 -> action 1
223        ];
224        let actions = vec![0i64, 0, 0, 1, 1, 1];
225        Demonstrations::new(obs, actions, 4).unwrap()
226    }
227
228    fn build_trainer(
229        config: BcConfig,
230    ) -> BcTrainer<B, MlpBurnPolicy<B>, impl Optimizer<MlpBurnPolicy<B>, B>> {
231        let device = Default::default();
232        // Seed the policy init so reproducibility tests are deterministic.
233        let policy = MlpBurnPolicy::<B>::new_seeded(4, 2, 16, config.seed, &device);
234        let inner_opt = AdamConfig::new().init();
235        let burn_opt: BurnOptimizer<B, MlpBurnPolicy<B>, _> =
236            BurnOptimizer::new(inner_opt, config.learning_rate);
237        BcTrainer::new(config, policy, burn_opt).unwrap()
238    }
239
240    /// Smoke test: a BC trainer constructs and reports zero steps/epochs.
241    #[test]
242    fn bc_trainer_constructs() {
243        let trainer = build_trainer(BcConfig::default());
244        assert_eq!(trainer.total_steps(), 0);
245        assert_eq!(trainer.total_epochs(), 0);
246    }
247
248    /// Fast always-on smoke: one epoch over a tiny dataset performs at least
249    /// one gradient step and yields finite loss + accuracy in `[0, 1]`.
250    #[test]
251    fn bc_train_epoch_smoke() {
252        let config = BcConfig::default().batch_size(4).epochs(1).seed(1);
253        let mut trainer = build_trainer(config);
254        let demos = tiny_demos();
255
256        let stats = trainer.train_epoch(&demos, |p, o| p.forward(o).0).unwrap();
257
258        // 6 examples, batch_size 4 -> ceil(6/4) = 2 minibatches -> 2 steps.
259        assert_eq!(trainer.total_steps(), 2);
260        assert_eq!(trainer.total_epochs(), 1);
261        assert!(stats.loss.is_finite(), "loss should be finite, got {}", stats.loss);
262        assert!(stats.accuracy.is_finite(), "accuracy should be finite, got {}", stats.accuracy);
263        assert!(
264            (0.0..=1.0).contains(&stats.accuracy),
265            "accuracy must be in [0, 1], got {}",
266            stats.accuracy
267        );
268    }
269
270    /// Reproducibility: two trainers built with the same seed and the same
271    /// demos produce identical first-epoch stats.
272    #[test]
273    fn bc_train_epoch_is_reproducible() {
274        let config = BcConfig::default().batch_size(4).epochs(1).seed(99);
275        let demos = tiny_demos();
276
277        let mut a = build_trainer(config.clone());
278        let mut b = build_trainer(config);
279
280        let stats_a = a.train_epoch(&demos, |p, o| p.forward(o).0).unwrap();
281        let stats_b = b.train_epoch(&demos, |p, o| p.forward(o).0).unwrap();
282
283        assert_eq!(stats_a.loss, stats_b.loss);
284        assert_eq!(stats_a.accuracy, stats_b.accuracy);
285    }
286
287    /// An empty dataset is rejected rather than silently producing NaNs.
288    #[test]
289    fn bc_train_epoch_rejects_empty() {
290        let mut trainer = build_trainer(BcConfig::default());
291        let empty = Demonstrations::new(vec![], vec![], 4).unwrap();
292        assert!(trainer.train_epoch(&empty, |p, o| p.forward(o).0).is_err());
293    }
294}