use anyhow::{Result, anyhow};
use burn::{
module::AutodiffModule,
optim::{GradientsParams, Optimizer},
tensor::{Tensor, backend::AutodiffBackend},
};
use rand::{SeedableRng, rngs::StdRng};
use super::{
config::BcConfig,
dataset::Demonstrations,
loss::{compute_bc_loss, scalar_f64},
};
use crate::train::{
optimizer::{BackendOptimizer, BurnOptimizer},
ppo::loss::generate_minibatch_indices_with_rng,
};
#[derive(Debug, Clone, Copy, Default)]
pub struct BcEpochStats {
pub loss: f64,
pub accuracy: f64,
}
pub struct BcTrainer<B, P, O>
where
B: AutodiffBackend,
P: AutodiffModule<B>,
O: Optimizer<P, B>,
{
config: BcConfig,
policy: Option<P>,
optimizer: BurnOptimizer<B, P, O>,
rng: StdRng,
total_steps: usize,
total_epochs: usize,
}
impl<B, P, O> BcTrainer<B, P, O>
where
B: AutodiffBackend,
P: AutodiffModule<B> + Clone,
O: Optimizer<P, B>,
{
pub fn new(config: BcConfig, policy: P, optimizer: BurnOptimizer<B, P, O>) -> Result<Self> {
config.validate()?;
let rng = StdRng::seed_from_u64(config.seed);
Ok(Self { config, policy: Some(policy), optimizer, rng, total_steps: 0, total_epochs: 0 })
}
pub fn config(&self) -> &BcConfig {
&self.config
}
pub fn policy(&self) -> &P {
self.policy.as_ref().expect("policy is None mid-step")
}
pub fn total_steps(&self) -> usize {
self.total_steps
}
pub fn total_epochs(&self) -> usize {
self.total_epochs
}
pub fn train_epoch<F>(
&mut self,
demos: &Demonstrations,
mut forward_fn: F,
) -> Result<BcEpochStats>
where
F: FnMut(&P, Tensor<B, 2>) -> Tensor<B, 2>,
{
if demos.is_empty() {
return Err(anyhow!("cannot train on an empty Demonstrations dataset"));
}
let device = B::Device::default();
let batches =
generate_minibatch_indices_with_rng(demos.len(), self.config.batch_size, &mut self.rng);
let mut loss_sum = 0.0_f64;
let mut correct = 0_usize;
let mut seen = 0_usize;
for indices in &batches {
if indices.is_empty() {
continue;
}
let k = indices.len();
let (obs, actions) = demos.batch::<B>(indices, &device);
let policy = self
.policy
.take()
.ok_or_else(|| anyhow!("policy is None; concurrent train_epoch calls?"))?;
let logits = forward_fn(&policy, obs);
let predicted: Vec<i64> = logits.clone().argmax(1).into_data().to_vec().unwrap();
let expected: Vec<i64> = actions.clone().into_data().to_vec().unwrap();
correct += predicted.iter().zip(expected.iter()).filter(|(p, e)| p == e).count();
let loss = compute_bc_loss(logits, actions);
loss_sum += scalar_f64(loss.clone()) * k as f64;
seen += k;
let grads = loss.backward();
let grads = GradientsParams::from_grads(grads, &policy);
let lr = self.optimizer.learning_rate();
let policy = self.optimizer.inner_mut().step(lr, policy, grads);
self.policy = Some(policy);
self.total_steps += 1;
}
self.total_epochs += 1;
let n = seen.max(1) as f64;
Ok(BcEpochStats { loss: loss_sum / n, accuracy: correct as f64 / n })
}
}
#[cfg(test)]
mod tests {
use burn::{
backend::{Autodiff, NdArray},
optim::AdamConfig,
};
use super::*;
use crate::{policy::mlp::MlpBurnPolicy, train::optimizer::BurnOptimizer};
type B = Autodiff<NdArray<f32>>;
fn tiny_demos() -> Demonstrations {
let obs = vec![
0.0, 0.0, 0.0, 0.0, 0.1, 0.0, 0.1, 0.0, 0.0, 0.1, 0.0, 0.1, 1.0, 1.0, 1.0, 1.0, 0.9, 1.0, 0.9, 1.0, 1.0, 0.9, 1.0, 0.9, ];
let actions = vec![0i64, 0, 0, 1, 1, 1];
Demonstrations::new(obs, actions, 4).unwrap()
}
fn build_trainer(
config: BcConfig,
) -> BcTrainer<B, MlpBurnPolicy<B>, impl Optimizer<MlpBurnPolicy<B>, B>> {
let device = Default::default();
let policy = MlpBurnPolicy::<B>::new_seeded(4, 2, 16, config.seed, &device);
let inner_opt = AdamConfig::new().init();
let burn_opt: BurnOptimizer<B, MlpBurnPolicy<B>, _> =
BurnOptimizer::new(inner_opt, config.learning_rate);
BcTrainer::new(config, policy, burn_opt).unwrap()
}
#[test]
fn bc_trainer_constructs() {
let trainer = build_trainer(BcConfig::default());
assert_eq!(trainer.total_steps(), 0);
assert_eq!(trainer.total_epochs(), 0);
}
#[test]
fn bc_train_epoch_smoke() {
let config = BcConfig::default().batch_size(4).epochs(1).seed(1);
let mut trainer = build_trainer(config);
let demos = tiny_demos();
let stats = trainer.train_epoch(&demos, |p, o| p.forward(o).0).unwrap();
assert_eq!(trainer.total_steps(), 2);
assert_eq!(trainer.total_epochs(), 1);
assert!(stats.loss.is_finite(), "loss should be finite, got {}", stats.loss);
assert!(stats.accuracy.is_finite(), "accuracy should be finite, got {}", stats.accuracy);
assert!(
(0.0..=1.0).contains(&stats.accuracy),
"accuracy must be in [0, 1], got {}",
stats.accuracy
);
}
#[test]
fn bc_train_epoch_is_reproducible() {
let config = BcConfig::default().batch_size(4).epochs(1).seed(99);
let demos = tiny_demos();
let mut a = build_trainer(config.clone());
let mut b = build_trainer(config);
let stats_a = a.train_epoch(&demos, |p, o| p.forward(o).0).unwrap();
let stats_b = b.train_epoch(&demos, |p, o| p.forward(o).0).unwrap();
assert_eq!(stats_a.loss, stats_b.loss);
assert_eq!(stats_a.accuracy, stats_b.accuracy);
}
#[test]
fn bc_train_epoch_rejects_empty() {
let mut trainer = build_trainer(BcConfig::default());
let empty = Demonstrations::new(vec![], vec![], 4).unwrap();
assert!(trainer.train_epoch(&empty, |p, o| p.forward(o).0).is_err());
}
}