Skip to main content

thrust_rl/train/ppo/
loss.rs

1//! PPO loss math (Burn backend).
2//!
3//! After phase 5 of the Burn migration (#82), Burn is the only tensor
4//! backend in the workspace; this module hosts the canonical PPO loss
5//! surface used by [`crate::train::ppo::trainer::PPOTrainerBurn`].
6//!
7//! # API notes
8//!
9//! - Burn's `mean()` returns a rank-1 scalar tensor — there is no `Kind::Float`
10//!   argument, and the resulting tensor still carries the backend parameter. We
11//!   expose the scalar-extracting helper ([`scalar_f64`]) that the trainer
12//!   needs at the loss boundary.
13//! - Burn carries the const rank in the type, so the policy-loss / value-loss
14//!   tensors carry their concrete rank (`Tensor<B, 1>` for per-row, scalar at
15//!   the end).
16//! - We extract scalars via [`scalar_f64`] which detaches implicitly via
17//!   `.into_scalar()`; no explicit `no_grad` scope is needed because the
18//!   autograd tape is not extended beyond the loss tensors we *return*.
19
20use burn::{
21    prelude::ToElement,
22    tensor::{Tensor, backend::Backend},
23};
24
25/// Read a scalar tensor's value into an `f64`.
26///
27/// Burn's `into_scalar()` consumes the tensor and returns
28/// `B::FloatElem`. We use Burn's [`ToElement`] trait to convert it to
29/// `f64` regardless of whether the underlying float type is `f32` or
30/// `f64`. Equivalent to tch's `f64::try_from(&tensor)` pattern, and
31/// (critically) does not extend the autograd tape — the returned `f64`
32/// is a host-side primitive.
33pub fn scalar_f64<B: Backend>(tensor: Tensor<B, 1>) -> f64 {
34    tensor.into_scalar().to_f64()
35}
36
37/// Compute PPO policy (surrogate) loss with clipping.
38///
39/// Returns `(policy_loss, clip_fraction, approx_kl)`. The two `f64`
40/// values are host-side metrics (already detached from the autograd
41/// graph) for trainer-side reporting and KL early stopping.
42///
43/// # Arguments
44///
45/// * `log_probs` - Log-probabilities of the actions under the *current* policy.
46///   `[batch]`.
47/// * `old_log_probs` - Log-probabilities under the *behaviour* policy.
48///   `[batch]`.
49/// * `advantages` - Normalized advantages `[batch]`.
50/// * `clip_range` - PPO clipping parameter `ε`.
51///
52/// # Returns
53///
54/// 1. `policy_loss` — scalar tensor (rank 1, shape `[1]`) carrying the
55///    grad-bearing surrogate loss.
56/// 2. `clip_fraction` — fraction of samples where `|ratio − 1| > ε`. Diagnostic
57///    only.
58/// 3. `approx_kl` — `E[old_log_prob − new_log_prob]`, the standard biased KL
59///    estimator used by SB3/CleanRL for early stopping.
60pub fn compute_policy_loss<B: Backend>(
61    log_probs: Tensor<B, 1>,
62    old_log_probs: Tensor<B, 1>,
63    advantages: Tensor<B, 1>,
64    clip_range: f64,
65) -> (Tensor<B, 1>, f64, f64) {
66    // ratio = exp(new − old)
67    let log_ratio = log_probs.clone() - old_log_probs.clone();
68    let ratio = log_ratio.clone().exp();
69
70    // Clipped surrogate objective (pessimistic min).
71    let clipped_ratio = ratio.clone().clamp(1.0 - clip_range, 1.0 + clip_range);
72    let surrogate_1 = advantages.clone() * ratio.clone();
73    let surrogate_2 = advantages * clipped_ratio;
74    let per_sample = surrogate_1.min_pair(surrogate_2);
75    let policy_loss = per_sample.mean().neg();
76
77    // Clip fraction: mean of (|ratio − 1| > ε).
78    //
79    // Burn 0.21's bool→float cast goes via `.float()`. The threshold
80    // comparison gives a `Bool` tensor; we materialize it to a float,
81    // take the mean, and pull the scalar to the host.
82    let one = Tensor::<B, 1>::ones_like(&ratio);
83    let abs_dev = (ratio - one).abs();
84    let clipped_mask = abs_dev.greater_elem(clip_range as f32).float();
85    let clip_fraction = scalar_f64(clipped_mask.mean());
86
87    // Biased KL estimator: E[old − new].
88    let approx_kl = scalar_f64((old_log_probs - log_probs).mean());
89
90    (policy_loss, clip_fraction, approx_kl)
91}
92
93/// Compute the value-function loss with optional clipping.
94///
95/// Implements the *pessimistic* clipped value loss (the SB3/CleanRL
96/// recipe). When `clip_range_vf` is non-finite the clipped branch is
97/// skipped and we fall through to the plain MSE — semantically
98/// identical to the tch path.
99///
100/// # Arguments
101///
102/// * `values` - Current value-function predictions `V(s_t)`. `[batch]`.
103/// * `old_values` - Behaviour-policy value predictions. `[batch]`.
104/// * `returns` - Bootstrap targets (advantages + old_values). `[batch]`.
105/// * `clip_range_vf` - Value clipping parameter; `f64::INFINITY` to disable.
106///
107/// # Returns
108///
109/// 1. `value_loss` — scalar tensor carrying the grad-bearing value loss.
110/// 2. `explained_var` — host-side `1 − Var(returns − values) / Var(returns)`.
111pub fn compute_value_loss<B: Backend>(
112    values: Tensor<B, 1>,
113    old_values: Tensor<B, 1>,
114    returns: Tensor<B, 1>,
115    clip_range_vf: f64,
116) -> (Tensor<B, 1>, f64) {
117    let value_loss = if clip_range_vf.is_finite() {
118        let clipped_dev =
119            (values.clone() - old_values.clone()).clamp(-clip_range_vf, clip_range_vf);
120        let values_clipped = old_values + clipped_dev;
121        let vf_loss_1 = (values.clone() - returns.clone()).powf_scalar(2.0_f32);
122        let vf_loss_2 = (values_clipped - returns.clone()).powf_scalar(2.0_f32);
123        vf_loss_1.max_pair(vf_loss_2).mean()
124    } else {
125        (values.clone() - returns.clone()).powf_scalar(2.0_f32).mean()
126    };
127
128    // Explained variance is a pure host-side metric.
129    let returns_vec: Vec<f32> = returns.clone().into_data().to_vec().unwrap_or_default();
130    let values_vec: Vec<f32> = values.into_data().to_vec().unwrap_or_default();
131    let explained_var = host_explained_variance(&returns_vec, &values_vec);
132
133    (value_loss, explained_var)
134}
135
136/// Host-side explained-variance computation.
137///
138/// `1 − Var(returns − values) / Var(returns)`. Mirrors the tch path's
139/// `var(false)` (biased estimator with denominator `n`) so the two
140/// implementations agree numerically.
141fn host_explained_variance(returns: &[f32], values: &[f32]) -> f64 {
142    if returns.is_empty() {
143        return 1.0;
144    }
145    let var_returns = host_variance_biased(returns);
146    if var_returns == 0.0 {
147        return 1.0;
148    }
149    let residual: Vec<f32> = returns.iter().zip(values).map(|(r, v)| r - v).collect();
150    let var_residual = host_variance_biased(&residual);
151    1.0 - var_residual / var_returns
152}
153
154fn host_variance_biased(xs: &[f32]) -> f64 {
155    if xs.is_empty() {
156        return 0.0;
157    }
158    let n = xs.len() as f64;
159    let mean = xs.iter().map(|&x| x as f64).sum::<f64>() / n;
160    let sq_dev = xs.iter().map(|&x| (x as f64 - mean).powi(2)).sum::<f64>();
161    sq_dev / n
162}
163
164/// Entropy loss = `-mean(entropy)` (we minimize loss, so we negate the
165/// entropy bonus before adding it into the total loss).
166pub fn compute_entropy_loss<B: Backend>(entropy: Tensor<B, 1>) -> Tensor<B, 1> {
167    entropy.mean().neg()
168}
169
170/// Generate randomized minibatch indices from a flat rollout buffer.
171///
172/// Backend-agnostic helper (uses `rand` only). Each minibatch contains
173/// approximately `batch_size` samples; the last batch is short if
174/// `buffer_size % batch_size != 0`.
175///
176/// This convenience overload draws shuffle randomness from the
177/// thread-local `rand::rng()`. **Prefer
178/// [`generate_minibatch_indices_with_rng`] when seedable
179/// reproducibility matters** (e.g. PSRO / NFSP inner loops that pipe
180/// a [`rand::rngs::StdRng`] seeded from the trainer config; see issue #109).
181///
182/// # Arguments
183///
184/// * `buffer_size` - Total number of samples in the buffer.
185/// * `batch_size` - Desired size of each minibatch.
186///
187/// # Returns
188///
189/// Vector of vectors, where each inner vector contains indices for one
190/// minibatch.
191pub fn generate_minibatch_indices(buffer_size: usize, batch_size: usize) -> Vec<Vec<usize>> {
192    let mut rng = rand::rng();
193    generate_minibatch_indices_with_rng(buffer_size, batch_size, &mut rng)
194}
195
196/// Generate randomized minibatch indices from a flat rollout buffer
197/// using a caller-supplied RNG.
198///
199/// Identical to [`generate_minibatch_indices`] except the shuffle
200/// randomness is drawn from `rng`. Pass a `StdRng` seeded from your
201/// trainer config to make PPO / PSRO / NFSP inner loops
202/// bit-reproducible (issue #109).
203///
204/// # Arguments
205///
206/// * `buffer_size` - Total number of samples in the buffer.
207/// * `batch_size` - Desired size of each minibatch.
208/// * `rng` - Caller-owned RNG used for the shuffle.
209///
210/// # Returns
211///
212/// Vector of vectors, where each inner vector contains indices for one
213/// minibatch.
214pub fn generate_minibatch_indices_with_rng<R: rand::Rng + ?Sized>(
215    buffer_size: usize,
216    batch_size: usize,
217    rng: &mut R,
218) -> Vec<Vec<usize>> {
219    use rand::seq::SliceRandom;
220
221    let mut indices: Vec<usize> = (0..buffer_size).collect();
222    indices.shuffle(rng);
223
224    indices.chunks(batch_size).map(|chunk| chunk.to_vec()).collect()
225}
226
227#[cfg(test)]
228mod tests {
229    use burn::{
230        backend::{Autodiff, NdArray},
231        tensor::{Tensor, TensorData},
232    };
233
234    use super::*;
235
236    type B = Autodiff<NdArray<f32>>;
237
238    fn tensor1d(data: &[f32]) -> Tensor<B, 1> {
239        let device = Default::default();
240        Tensor::<B, 1>::from_data(TensorData::new(data.to_vec(), [data.len()]), &device)
241    }
242
243    #[test]
244    fn test_compute_policy_loss_shapes_and_ranges() {
245        let log_probs = tensor1d(&[0.0, 0.5, -0.5]);
246        let old_log_probs = tensor1d(&[0.0, 0.0, 0.0]);
247        let advantages = tensor1d(&[1.0, -1.0, 0.5]);
248        let clip_range = 0.2;
249
250        let (loss, clip_frac, kl) =
251            compute_policy_loss(log_probs, old_log_probs, advantages, clip_range);
252
253        // Loss should be a rank-1 scalar tensor of shape [1].
254        assert_eq!(loss.dims(), [1]);
255        assert!((0.0..=1.0).contains(&clip_frac));
256        // The biased KL estimator can go either direction; just sanity
257        // check it's finite.
258        assert!(kl.is_finite());
259    }
260
261    #[test]
262    fn test_compute_value_loss_pessimistic_clip() {
263        // Same construction as the tch test: clipped loss is tiny vs
264        // unclipped, so the pessimistic max must reflect the unclipped
265        // MSE.
266        let old_values = tensor1d(&[0.0_f32, 0.0, 0.0]);
267        let values = tensor1d(&[5.0_f32, 5.0, 5.0]);
268        let returns = tensor1d(&[0.0_f32, 0.0, 0.0]);
269        let clip_range_vf = 0.2;
270
271        let (loss, _) = compute_value_loss(values, old_values, returns, clip_range_vf);
272        let loss_val = scalar_f64(loss);
273
274        let unclipped_mse = 25.0_f64;
275        let eps = 1e-4_f64;
276        assert!(
277            loss_val >= unclipped_mse - eps,
278            "expected pessimistic clipped value loss >= unclipped MSE ({}), got {}",
279            unclipped_mse,
280            loss_val
281        );
282    }
283
284    #[test]
285    fn test_compute_value_loss_infinite_clip_is_plain_mse() {
286        let old_values = tensor1d(&[1.0_f32, 1.5, 0.8]);
287        let values = tensor1d(&[1.0_f32, 2.0, 0.5]);
288        let returns = tensor1d(&[1.2_f32, 2.1, 0.6]);
289
290        let (loss_inf, _) = compute_value_loss(values, old_values, returns, f64::INFINITY);
291        let loss_inf_val = scalar_f64(loss_inf);
292
293        let expected = 0.02_f64;
294        assert!(
295            (loss_inf_val - expected).abs() < 1e-4,
296            "infinite clip should yield plain MSE {}, got {}",
297            expected,
298            loss_inf_val
299        );
300    }
301
302    #[test]
303    fn test_compute_entropy_loss_negates_mean() {
304        let entropy = tensor1d(&[0.5, 1.0, 0.1]);
305        let loss = compute_entropy_loss(entropy);
306        assert_eq!(loss.dims(), [1]);
307        let loss_val = scalar_f64(loss);
308        // mean = (0.5 + 1.0 + 0.1) / 3 = 0.5333...; negated = -0.5333...
309        assert!(loss_val < 0.0);
310        assert!((loss_val - (-0.5333333_f64)).abs() < 1e-4);
311    }
312
313    /// Issue #109 determinism guard: two RNGs constructed with the same
314    /// seed must produce bit-identical minibatch index lists.
315    ///
316    /// This is the load-bearing test for the inner-loop seedable
317    /// shuffle introduced in #109. Both
318    /// `JointMultiAgentTrainer::update_with_active_agents` and
319    /// `PPOTrainerBurn::train_step` ultimately consume this helper,
320    /// so a stable contract here is sufficient to guarantee that
321    /// same-seed PSRO / NFSP / single-agent PPO runs see the same
322    /// minibatch order at every gradient step.
323    #[test]
324    fn test_generate_minibatch_indices_with_rng_is_deterministic() {
325        use rand::{SeedableRng, rngs::StdRng};
326
327        let buffer_size = 256;
328        let batch_size = 32;
329        let seed: u64 = 0xC0FFEE;
330
331        let mut rng_a = StdRng::seed_from_u64(seed);
332        let mut rng_b = StdRng::seed_from_u64(seed);
333
334        let a = generate_minibatch_indices_with_rng(buffer_size, batch_size, &mut rng_a);
335        let b = generate_minibatch_indices_with_rng(buffer_size, batch_size, &mut rng_b);
336
337        assert_eq!(a, b, "same-seed RNG must yield bit-identical minibatch indices");
338
339        // Also check that a *different* seed produces a *different*
340        // index ordering — i.e. the seed actually controls the shuffle.
341        let mut rng_c = StdRng::seed_from_u64(seed.wrapping_add(1));
342        let c = generate_minibatch_indices_with_rng(buffer_size, batch_size, &mut rng_c);
343        assert_ne!(a, c, "different seeds should produce different minibatch index orderings");
344    }
345
346    /// Multi-step determinism check: drawing several rounds of
347    /// minibatch indices from the same RNG state on two parallel
348    /// trainers must stay synchronized. This is the property that
349    /// `update_with_active_agents` relies on across `n_epochs` epochs
350    /// inside one PSRO/NFSP iteration.
351    #[test]
352    fn test_generate_minibatch_indices_with_rng_stays_synchronized() {
353        use rand::{SeedableRng, rngs::StdRng};
354
355        let mut rng_a = StdRng::seed_from_u64(7);
356        let mut rng_b = StdRng::seed_from_u64(7);
357        for epoch in 0..8 {
358            let a = generate_minibatch_indices_with_rng(64, 8, &mut rng_a);
359            let b = generate_minibatch_indices_with_rng(64, 8, &mut rng_b);
360            assert_eq!(a, b, "epoch {epoch}: parallel RNGs diverged");
361        }
362    }
363}