use burn::{
prelude::ToElement,
tensor::{Tensor, backend::Backend},
};
pub fn scalar_f64<B: Backend>(tensor: Tensor<B, 1>) -> f64 {
tensor.into_scalar().to_f64()
}
pub fn compute_policy_loss<B: Backend>(
log_probs: Tensor<B, 1>,
old_log_probs: Tensor<B, 1>,
advantages: Tensor<B, 1>,
clip_range: f64,
) -> (Tensor<B, 1>, f64, f64) {
let log_ratio = log_probs.clone() - old_log_probs.clone();
let ratio = log_ratio.clone().exp();
let clipped_ratio = ratio.clone().clamp(1.0 - clip_range, 1.0 + clip_range);
let surrogate_1 = advantages.clone() * ratio.clone();
let surrogate_2 = advantages * clipped_ratio;
let per_sample = surrogate_1.min_pair(surrogate_2);
let policy_loss = per_sample.mean().neg();
let one = Tensor::<B, 1>::ones_like(&ratio);
let abs_dev = (ratio - one).abs();
let clipped_mask = abs_dev.greater_elem(clip_range as f32).float();
let clip_fraction = scalar_f64(clipped_mask.mean());
let approx_kl = scalar_f64((old_log_probs - log_probs).mean());
(policy_loss, clip_fraction, approx_kl)
}
pub fn compute_value_loss<B: Backend>(
values: Tensor<B, 1>,
old_values: Tensor<B, 1>,
returns: Tensor<B, 1>,
clip_range_vf: f64,
) -> (Tensor<B, 1>, f64) {
let value_loss = if clip_range_vf.is_finite() {
let clipped_dev =
(values.clone() - old_values.clone()).clamp(-clip_range_vf, clip_range_vf);
let values_clipped = old_values + clipped_dev;
let vf_loss_1 = (values.clone() - returns.clone()).powf_scalar(2.0_f32);
let vf_loss_2 = (values_clipped - returns.clone()).powf_scalar(2.0_f32);
vf_loss_1.max_pair(vf_loss_2).mean()
} else {
(values.clone() - returns.clone()).powf_scalar(2.0_f32).mean()
};
let returns_vec: Vec<f32> = returns.clone().into_data().to_vec().unwrap_or_default();
let values_vec: Vec<f32> = values.into_data().to_vec().unwrap_or_default();
let explained_var = host_explained_variance(&returns_vec, &values_vec);
(value_loss, explained_var)
}
fn host_explained_variance(returns: &[f32], values: &[f32]) -> f64 {
if returns.is_empty() {
return 1.0;
}
let var_returns = host_variance_biased(returns);
if var_returns == 0.0 {
return 1.0;
}
let residual: Vec<f32> = returns.iter().zip(values).map(|(r, v)| r - v).collect();
let var_residual = host_variance_biased(&residual);
1.0 - var_residual / var_returns
}
fn host_variance_biased(xs: &[f32]) -> f64 {
if xs.is_empty() {
return 0.0;
}
let n = xs.len() as f64;
let mean = xs.iter().map(|&x| x as f64).sum::<f64>() / n;
let sq_dev = xs.iter().map(|&x| (x as f64 - mean).powi(2)).sum::<f64>();
sq_dev / n
}
pub fn compute_entropy_loss<B: Backend>(entropy: Tensor<B, 1>) -> Tensor<B, 1> {
entropy.mean().neg()
}
pub fn generate_minibatch_indices(buffer_size: usize, batch_size: usize) -> Vec<Vec<usize>> {
let mut rng = rand::rng();
generate_minibatch_indices_with_rng(buffer_size, batch_size, &mut rng)
}
pub fn generate_minibatch_indices_with_rng<R: rand::Rng + ?Sized>(
buffer_size: usize,
batch_size: usize,
rng: &mut R,
) -> Vec<Vec<usize>> {
use rand::seq::SliceRandom;
let mut indices: Vec<usize> = (0..buffer_size).collect();
indices.shuffle(rng);
indices.chunks(batch_size).map(|chunk| chunk.to_vec()).collect()
}
#[cfg(test)]
mod tests {
use burn::{
backend::{Autodiff, NdArray},
tensor::{Tensor, TensorData},
};
use super::*;
type B = Autodiff<NdArray<f32>>;
fn tensor1d(data: &[f32]) -> Tensor<B, 1> {
let device = Default::default();
Tensor::<B, 1>::from_data(TensorData::new(data.to_vec(), [data.len()]), &device)
}
#[test]
fn test_compute_policy_loss_shapes_and_ranges() {
let log_probs = tensor1d(&[0.0, 0.5, -0.5]);
let old_log_probs = tensor1d(&[0.0, 0.0, 0.0]);
let advantages = tensor1d(&[1.0, -1.0, 0.5]);
let clip_range = 0.2;
let (loss, clip_frac, kl) =
compute_policy_loss(log_probs, old_log_probs, advantages, clip_range);
assert_eq!(loss.dims(), [1]);
assert!((0.0..=1.0).contains(&clip_frac));
assert!(kl.is_finite());
}
#[test]
fn test_compute_value_loss_pessimistic_clip() {
let old_values = tensor1d(&[0.0_f32, 0.0, 0.0]);
let values = tensor1d(&[5.0_f32, 5.0, 5.0]);
let returns = tensor1d(&[0.0_f32, 0.0, 0.0]);
let clip_range_vf = 0.2;
let (loss, _) = compute_value_loss(values, old_values, returns, clip_range_vf);
let loss_val = scalar_f64(loss);
let unclipped_mse = 25.0_f64;
let eps = 1e-4_f64;
assert!(
loss_val >= unclipped_mse - eps,
"expected pessimistic clipped value loss >= unclipped MSE ({}), got {}",
unclipped_mse,
loss_val
);
}
#[test]
fn test_compute_value_loss_infinite_clip_is_plain_mse() {
let old_values = tensor1d(&[1.0_f32, 1.5, 0.8]);
let values = tensor1d(&[1.0_f32, 2.0, 0.5]);
let returns = tensor1d(&[1.2_f32, 2.1, 0.6]);
let (loss_inf, _) = compute_value_loss(values, old_values, returns, f64::INFINITY);
let loss_inf_val = scalar_f64(loss_inf);
let expected = 0.02_f64;
assert!(
(loss_inf_val - expected).abs() < 1e-4,
"infinite clip should yield plain MSE {}, got {}",
expected,
loss_inf_val
);
}
#[test]
fn test_compute_entropy_loss_negates_mean() {
let entropy = tensor1d(&[0.5, 1.0, 0.1]);
let loss = compute_entropy_loss(entropy);
assert_eq!(loss.dims(), [1]);
let loss_val = scalar_f64(loss);
assert!(loss_val < 0.0);
assert!((loss_val - (-0.5333333_f64)).abs() < 1e-4);
}
#[test]
fn test_generate_minibatch_indices_with_rng_is_deterministic() {
use rand::{SeedableRng, rngs::StdRng};
let buffer_size = 256;
let batch_size = 32;
let seed: u64 = 0xC0FFEE;
let mut rng_a = StdRng::seed_from_u64(seed);
let mut rng_b = StdRng::seed_from_u64(seed);
let a = generate_minibatch_indices_with_rng(buffer_size, batch_size, &mut rng_a);
let b = generate_minibatch_indices_with_rng(buffer_size, batch_size, &mut rng_b);
assert_eq!(a, b, "same-seed RNG must yield bit-identical minibatch indices");
let mut rng_c = StdRng::seed_from_u64(seed.wrapping_add(1));
let c = generate_minibatch_indices_with_rng(buffer_size, batch_size, &mut rng_c);
assert_ne!(a, c, "different seeds should produce different minibatch index orderings");
}
#[test]
fn test_generate_minibatch_indices_with_rng_stays_synchronized() {
use rand::{SeedableRng, rngs::StdRng};
let mut rng_a = StdRng::seed_from_u64(7);
let mut rng_b = StdRng::seed_from_u64(7);
for epoch in 0..8 {
let a = generate_minibatch_indices_with_rng(64, 8, &mut rng_a);
let b = generate_minibatch_indices_with_rng(64, 8, &mut rng_b);
assert_eq!(a, b, "epoch {epoch}: parallel RNGs diverged");
}
}
}