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}