Skip to main content

thrust_rl/train/bc/
loss.rs

1//! Behavioral Cloning loss math (Burn backend).
2//!
3//! Behavioral cloning is supervised learning: the policy's action head is
4//! trained to reproduce expert action labels via cross-entropy. Unlike the
5//! RL losses ([`crate::train::a2c::loss`], [`crate::train::ppo::loss`])
6//! there is no advantage, no importance ratio, no entropy bonus, and no
7//! value term — just the negative log-likelihood of the expert action under
8//! the current policy.
9//!
10//! The loss is discrete-only, matching
11//! [`MlpBurnPolicy`](crate::policy::mlp::MlpBurnPolicy)'s categorical action
12//! head. We re-export PPO's host-side [`scalar_f64`] so callers can pull the
13//! whole BC loss surface from `bc::loss` without reaching into `ppo::loss`.
14
15use burn::tensor::{Int, Tensor, activation::log_softmax, backend::Backend};
16
17// Re-export the shared host-side scalar extractor from the PPO loss module
18// (the same one A2C re-exports), so the BC trainer and downstream callers
19// can pull stats off the autograd tape without reaching into `ppo::loss`.
20pub use crate::train::ppo::loss::scalar_f64;
21
22/// Compute the supervised behavioral-cloning cross-entropy loss.
23///
24/// This is the mean negative log-likelihood of the expert action under the
25/// current policy:
26///
27/// ```text
28/// loss = -mean_i( log_softmax(logits_i)[action_i] )
29/// ```
30///
31/// Concretely we take `log_softmax` over the action dimension, gather the
32/// log-probability assigned to each example's expert action label, and
33/// negate the batch mean. Minimizing this drives the policy's categorical
34/// distribution toward the one-hot expert labels — the core of behavioral
35/// cloning. It is the standard classification cross-entropy with integer
36/// targets, specialized to the discrete action head of
37/// [`MlpBurnPolicy`](crate::policy::mlp::MlpBurnPolicy) (the value head is
38/// ignored for BC).
39///
40/// # Arguments
41///
42/// * `logits` - Pre-softmax action logits `[batch, action_dim]` from the policy
43///   head (grad-bearing).
44/// * `actions` - Expert action label per example `[batch]`, each in
45///   `0..action_dim`.
46///
47/// # Returns
48///
49/// A scalar tensor (rank 1, shape `[1]`) carrying the grad-bearing
50/// cross-entropy loss.
51pub fn compute_bc_loss<B: Backend>(
52    logits: Tensor<B, 2>,
53    actions: Tensor<B, 1, Int>,
54) -> Tensor<B, 1> {
55    let [batch, _action_dim] = logits.dims();
56
57    // Log-probabilities over the action dimension: [batch, action_dim].
58    let log_probs = log_softmax(logits, 1);
59
60    // Gather the log-prob of each example's expert action. `gather` needs a
61    // matching-rank index tensor, so reshape labels [batch] -> [batch, 1].
62    let action_index = actions.reshape([batch, 1]);
63    let chosen = log_probs.gather(1, action_index); // [batch, 1]
64
65    // NLL of the expert action, averaged over the batch.
66    chosen.reshape([batch]).mean().neg()
67}
68
69#[cfg(test)]
70mod tests {
71    use burn::{
72        backend::{Autodiff, NdArray},
73        tensor::TensorData,
74    };
75
76    use super::*;
77
78    type B = Autodiff<NdArray<f32>>;
79
80    fn logits2d(data: &[f32], rows: usize, cols: usize) -> Tensor<B, 2> {
81        let device = Default::default();
82        Tensor::<B, 2>::from_data(TensorData::new(data.to_vec(), [rows, cols]), &device)
83    }
84
85    fn actions1d(data: &[i64]) -> Tensor<B, 1, Int> {
86        let device = Default::default();
87        Tensor::<B, 1, Int>::from_data(TensorData::new(data.to_vec(), [data.len()]), &device)
88    }
89
90    #[test]
91    fn bc_loss_is_finite_scalar() {
92        // 2 examples, 3 actions.
93        let logits = logits2d(&[0.1, 0.2, 0.3, -0.5, 0.0, 0.5], 2, 3);
94        let actions = actions1d(&[2, 0]);
95        let loss = compute_bc_loss(logits, actions);
96        assert_eq!(loss.dims(), [1]);
97        assert!(scalar_f64(loss).is_finite());
98    }
99
100    #[test]
101    fn bc_loss_is_nonnegative() {
102        // Cross-entropy / NLL is always >= 0.
103        let logits = logits2d(&[1.0, 2.0, -1.0, 0.0, 3.0, 0.5], 2, 3);
104        let actions = actions1d(&[1, 1]);
105        let loss_val = scalar_f64(compute_bc_loss(logits, actions));
106        assert!(loss_val >= 0.0, "cross-entropy loss must be non-negative, got {loss_val}");
107    }
108
109    /// Pushing the logits toward the expert label must *decrease* the loss
110    /// (the supervised-learning analogue of the A2C loss-direction tests).
111    #[test]
112    fn bc_loss_decreases_when_logits_match_labels() {
113        // Both examples have expert action 0.
114        let actions = actions1d(&[0, 0]);
115
116        // Flat logits: uniform distribution over 3 actions -> loss ~= ln(3).
117        let flat = scalar_f64(compute_bc_loss(logits2d(&[0.0; 6], 2, 3), actions.clone()));
118
119        // Confident logits favoring action 0 -> much lower loss.
120        let confident =
121            scalar_f64(compute_bc_loss(logits2d(&[5.0, 0.0, 0.0, 5.0, 0.0, 0.0], 2, 3), actions));
122
123        assert!(
124            confident < flat,
125            "logits favoring the expert action should lower loss: {confident} !< {flat}"
126        );
127    }
128
129    /// A near-perfect classifier (huge logit on the correct action) drives
130    /// the loss toward zero.
131    #[test]
132    fn bc_loss_approaches_zero_when_perfect() {
133        let logits = logits2d(&[20.0, 0.0, 0.0, 0.0, 20.0, 0.0], 2, 3);
134        let actions = actions1d(&[0, 1]);
135        let loss_val = scalar_f64(compute_bc_loss(logits, actions));
136        assert!(loss_val < 1e-3, "confident-correct logits should give ~0 loss, got {loss_val}");
137    }
138}