use burn::{
nn::{Initializer, Linear},
tensor::{Tensor, activation, backend::Backend},
};
use rand::{Rng, rngs::StdRng};
use crate::policy::mlp::{
BurnActivation, derive_layer_seed, linear_from_weights, linear_with_init, seeded_layer_weights,
};
pub const LOG_STD_MIN: f32 = -20.0;
pub const LOG_STD_MAX: f32 = 2.0;
const TANH_CORRECTION_EPS: f32 = 1e-6;
#[derive(Debug, Clone, Copy)]
pub struct SacActorConfig {
pub num_layers: usize,
pub hidden_dim: usize,
pub use_orthogonal_init: bool,
pub activation: BurnActivation,
pub seed: Option<u64>,
}
impl Default for SacActorConfig {
fn default() -> Self {
Self {
num_layers: 2,
hidden_dim: 256,
use_orthogonal_init: true,
activation: BurnActivation::ReLU,
seed: None,
}
}
}
impl SacActorConfig {
pub fn with_seed(mut self, seed: u64) -> Self {
self.seed = Some(seed);
self
}
}
#[derive(burn::module::Module, Debug)]
pub struct SacActor<B: Backend> {
fc1: Linear<B>,
fc2: Linear<B>,
fc3: Option<Linear<B>>,
mean_head: Linear<B>,
log_std_head: Linear<B>,
activation: BurnActivation,
}
impl<B: Backend> SacActor<B> {
pub fn with_config(
obs_dim: usize,
action_dim: usize,
config: SacActorConfig,
device: &B::Device,
) -> Self {
if let Some(seed) = config.seed {
let orth = config.use_orthogonal_init;
let mk = |idx: u64, d_in: usize, d_out: usize, is_head: bool| {
let s = derive_layer_seed(seed, idx);
let w = seeded_layer_weights(s, d_in, d_out, orth, is_head);
linear_from_weights::<B>(d_in, d_out, &w, device)
};
let fc1 = mk(0, obs_dim, config.hidden_dim, false);
let fc2 = mk(1, config.hidden_dim, config.hidden_dim, false);
let fc3 = if config.num_layers >= 3 {
Some(mk(2, config.hidden_dim, config.hidden_dim, false))
} else {
None
};
let mean_head = mk(3, config.hidden_dim, action_dim, true);
let log_std_head = mk(4, config.hidden_dim, action_dim, true);
return Self { fc1, fc2, fc3, mean_head, log_std_head, activation: config.activation };
}
let hidden_init = if config.use_orthogonal_init {
Initializer::Orthogonal { gain: 2.0_f64.sqrt() }
} else {
Initializer::KaimingUniform { gain: 1.0_f64 / 3.0_f64.sqrt(), fan_out_only: false }
};
let head_init = if config.use_orthogonal_init {
Initializer::Orthogonal { gain: 0.01 }
} else {
Initializer::KaimingUniform { gain: 1.0_f64 / 3.0_f64.sqrt(), fan_out_only: false }
};
let fc1 = linear_with_init::<B>(obs_dim, config.hidden_dim, hidden_init.clone(), device);
let fc2 = linear_with_init::<B>(
config.hidden_dim,
config.hidden_dim,
hidden_init.clone(),
device,
);
let fc3 = if config.num_layers >= 3 {
Some(linear_with_init::<B>(config.hidden_dim, config.hidden_dim, hidden_init, device))
} else {
None
};
let mean_head =
linear_with_init::<B>(config.hidden_dim, action_dim, head_init.clone(), device);
let log_std_head = linear_with_init::<B>(config.hidden_dim, action_dim, head_init, device);
Self { fc1, fc2, fc3, mean_head, log_std_head, activation: config.activation }
}
fn apply_activation<const D: usize>(&self, x: Tensor<B, D>) -> Tensor<B, D> {
match self.activation {
BurnActivation::ReLU => activation::relu(x),
BurnActivation::Tanh => activation::tanh(x),
}
}
fn encoder_features(&self, obs: Tensor<B, 2>) -> Tensor<B, 2> {
let h = self.apply_activation(self.fc1.forward(obs));
let h = self.apply_activation(self.fc2.forward(h));
if let Some(fc3) = &self.fc3 {
self.apply_activation(fc3.forward(h))
} else {
h
}
}
pub fn forward(&self, obs: Tensor<B, 2>) -> (Tensor<B, 2>, Tensor<B, 2>) {
let h = self.encoder_features(obs);
let mean = self.mean_head.forward(h.clone());
let log_std = self.log_std_head.forward(h).clamp(LOG_STD_MIN, LOG_STD_MAX);
(mean, log_std)
}
pub fn mean_action(&self, obs: Tensor<B, 2>) -> Tensor<B, 2> {
let (mean, _log_std) = self.forward(obs);
activation::tanh(mean)
}
pub fn sample(&self, obs: Tensor<B, 2>, rng: &mut StdRng) -> (Tensor<B, 2>, Tensor<B, 1>) {
let (mean, log_std) = self.forward(obs);
let dims = mean.dims();
let [batch, action_dim] = dims;
let device = mean.device();
let mut eps_data = Vec::with_capacity(batch * action_dim);
for _ in 0..(batch * action_dim) {
eps_data.push(standard_normal(rng));
}
let eps = Tensor::<B, 2>::from_data(
burn::tensor::TensorData::new(eps_data, [batch, action_dim]),
&device,
);
let std = log_std.clone().exp();
let u = mean.clone() + std.clone() * eps;
let action = activation::tanh(u.clone());
let norm = (u.clone() - mean) / std;
let log_two_pi = (2.0 * std::f32::consts::PI).ln();
let gaussian_log_prob = (norm.clone() * norm) * (-0.5) - log_std - (0.5 * log_two_pi);
let gaussian_log_prob = gaussian_log_prob.sum_dim(1).squeeze_dim::<1>(1);
let tanh_u = action.clone();
let one_minus_sq = -(tanh_u.clone() * tanh_u) + 1.0 + TANH_CORRECTION_EPS;
let correction = one_minus_sq.log().sum_dim(1).squeeze_dim::<1>(1);
let log_prob = gaussian_log_prob - correction;
(action, log_prob)
}
pub fn fc1(&self) -> &Linear<B> {
&self.fc1
}
pub fn fc2(&self) -> &Linear<B> {
&self.fc2
}
pub fn mean_head(&self) -> &Linear<B> {
&self.mean_head
}
pub fn log_std_head(&self) -> &Linear<B> {
&self.log_std_head
}
}
fn standard_normal(rng: &mut StdRng) -> f32 {
let u1: f32 = {
let x: f32 = rng.random();
if x <= f32::MIN_POSITIVE {
f32::MIN_POSITIVE
} else {
x
}
};
let u2: f32 = rng.random();
let r = (-2.0_f32 * u1.ln()).sqrt();
let theta = 2.0_f32 * std::f32::consts::PI * u2;
r * theta.cos()
}
#[cfg(test)]
mod tests {
use burn::backend::{Autodiff, NdArray};
use rand::SeedableRng;
use super::*;
type B = Autodiff<NdArray<f32>>;
fn obs_batch(
batch: usize,
obs_dim: usize,
device: &burn::backend::ndarray::NdArrayDevice,
) -> Tensor<B, 2> {
let n = batch * obs_dim;
let data: Vec<f32> = (0..n).map(|i| (i as f32) * 0.01 - 0.3).collect();
Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(data, [batch, obs_dim]), device)
}
#[test]
fn test_construction_two_layer() {
let device = Default::default();
let actor = SacActor::<B>::with_config(3, 1, SacActorConfig::default(), &device);
assert!(actor.fc3.is_none());
}
#[test]
fn test_construction_three_layer() {
let device = Default::default();
let cfg = SacActorConfig { num_layers: 3, ..Default::default() };
let actor = SacActor::<B>::with_config(3, 2, cfg, &device);
assert!(actor.fc3.is_some());
}
#[test]
fn test_forward_shapes_two_layer() {
let device = Default::default();
let actor = SacActor::<B>::with_config(5, 3, SacActorConfig::default(), &device);
let obs = obs_batch(8, 5, &device);
let (mean, log_std) = actor.forward(obs);
assert_eq!(mean.dims(), [8, 3]);
assert_eq!(log_std.dims(), [8, 3]);
}
#[test]
fn test_forward_shapes_three_layer() {
let device = Default::default();
let cfg = SacActorConfig { num_layers: 3, hidden_dim: 32, ..Default::default() };
let actor = SacActor::<B>::with_config(5, 3, cfg, &device);
let obs = obs_batch(8, 5, &device);
let (mean, log_std) = actor.forward(obs);
assert_eq!(mean.dims(), [8, 3]);
assert_eq!(log_std.dims(), [8, 3]);
}
#[test]
fn test_log_std_is_clamped() {
let device = Default::default();
let cfg =
SacActorConfig { hidden_dim: 16, use_orthogonal_init: false, ..Default::default() };
let actor = SacActor::<B>::with_config(4, 2, cfg, &device);
let data: Vec<f32> = vec![100.0; 4 * 4];
let obs = Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(data, [4, 4]), &device);
let (_mean, log_std) = actor.forward(obs);
let vals: Vec<f32> = log_std.into_data().to_vec().unwrap();
for v in vals {
assert!((LOG_STD_MIN..=LOG_STD_MAX).contains(&v), "log_std {v} outside clamp range");
}
}
#[test]
fn test_mean_action_in_range_and_shape() {
let device = Default::default();
let actor = SacActor::<B>::with_config(4, 3, SacActorConfig::default(), &device);
let obs = obs_batch(6, 4, &device);
let action = actor.mean_action(obs);
assert_eq!(action.dims(), [6, 3]);
let vals: Vec<f32> = action.into_data().to_vec().unwrap();
for v in vals {
assert!(v > -1.0 && v < 1.0, "mean action {v} not in (-1, 1)");
}
}
#[test]
fn test_sample_actions_in_range_logprob_finite() {
let device = Default::default();
let actor =
SacActor::<B>::with_config(4, 3, SacActorConfig::default().with_seed(7), &device);
let obs = obs_batch(10, 4, &device);
let mut rng = StdRng::seed_from_u64(123);
let (action, log_prob) = actor.sample(obs, &mut rng);
assert_eq!(action.dims(), [10, 3]);
assert_eq!(log_prob.dims(), [10]);
let acts: Vec<f32> = action.into_data().to_vec().unwrap();
for v in acts {
assert!(v > -1.0 && v < 1.0, "sampled action {v} not in (-1, 1)");
}
let lps: Vec<f32> = log_prob.into_data().to_vec().unwrap();
for v in lps {
assert!(v.is_finite(), "log_prob {v} not finite");
}
}
#[test]
fn test_sample_is_bit_exact_in_seed() {
let device = Default::default();
let actor =
SacActor::<B>::with_config(4, 2, SacActorConfig::default().with_seed(11), &device);
let obs = obs_batch(5, 4, &device);
let mut rng_a = StdRng::seed_from_u64(42);
let mut rng_b = StdRng::seed_from_u64(42);
let (a_a, lp_a) = actor.sample(obs.clone(), &mut rng_a);
let (a_b, lp_b) = actor.sample(obs.clone(), &mut rng_b);
let a_a: Vec<f32> = a_a.into_data().to_vec().unwrap();
let a_b: Vec<f32> = a_b.into_data().to_vec().unwrap();
let lp_a: Vec<f32> = lp_a.into_data().to_vec().unwrap();
let lp_b: Vec<f32> = lp_b.into_data().to_vec().unwrap();
assert_eq!(a_a, a_b, "same-seed actions must be bit-identical");
assert_eq!(lp_a, lp_b, "same-seed log_probs must be bit-identical");
let mut rng_c = StdRng::seed_from_u64(99);
let (a_c, _) = actor.sample(obs, &mut rng_c);
let a_c: Vec<f32> = a_c.into_data().to_vec().unwrap();
assert_ne!(a_a, a_c, "different-seed actions must differ");
}
#[test]
fn test_with_seed_construction_is_bit_exact() {
let device = Default::default();
let obs = obs_batch(4, 4, &device);
let a = SacActor::<B>::with_config(4, 2, SacActorConfig::default().with_seed(5), &device);
let b = SacActor::<B>::with_config(4, 2, SacActorConfig::default().with_seed(5), &device);
let c = SacActor::<B>::with_config(4, 2, SacActorConfig::default().with_seed(6), &device);
let (ma, _) = a.forward(obs.clone());
let (mb, _) = b.forward(obs.clone());
let (mc, _) = c.forward(obs);
let ma: Vec<f32> = ma.into_data().to_vec().unwrap();
let mb: Vec<f32> = mb.into_data().to_vec().unwrap();
let mc: Vec<f32> = mc.into_data().to_vec().unwrap();
assert_eq!(ma, mb, "same seed must build bit-identical actors");
assert_ne!(ma, mc, "different seed must build different actors");
}
}