use burn::{
module::{Module, Param},
nn::{Initializer, Linear},
tensor::{Int, Tensor, activation, backend::Backend},
};
pub(crate) fn linear_with_init<B: Backend>(
d_input: usize,
d_output: usize,
initializer: Initializer,
device: &B::Device,
) -> Linear<B> {
let weight: Param<Tensor<B, 2>> = initializer.init_with::<B, 2, _>(
[d_input, d_output],
Some(d_input),
Some(d_output),
device,
);
let bias_tensor = Tensor::<B, 1>::zeros([d_output], device);
Linear::<B> { weight, bias: Some(Param::from_tensor(bias_tensor)) }
}
pub(crate) fn linear_from_weights<B: Backend>(
d_input: usize,
d_output: usize,
weights: &[f32],
device: &B::Device,
) -> Linear<B> {
debug_assert_eq!(weights.len(), d_input * d_output, "weight buffer must be d_input * d_output");
let weight_tensor = Tensor::<B, 2>::from_data(
burn::tensor::TensorData::new(weights.to_vec(), [d_input, d_output]),
device,
);
let bias_tensor = Tensor::<B, 1>::zeros([d_output], device);
Linear::<B> {
weight: Param::from_tensor(weight_tensor),
bias: Some(Param::from_tensor(bias_tensor)),
}
}
pub(crate) fn seeded_layer_weights(
seed: u64,
d_in: usize,
d_out: usize,
use_orthogonal: bool,
is_head: bool,
) -> Vec<f32> {
use crate::policy::seeded_init::{seeded_kaiming_uniform, seeded_orthogonal};
if use_orthogonal {
let gain = if is_head { 0.01_f32 } else { 2.0_f32.sqrt() };
seeded_orthogonal(seed, d_in, d_out, gain)
} else {
let gain = 1.0_f32 / 3.0_f32.sqrt();
seeded_kaiming_uniform(seed, d_in, d_out, gain)
}
}
pub(crate) fn derive_layer_seed(base_seed: u64, layer_index: u64) -> u64 {
let mut z = base_seed.wrapping_add(layer_index.wrapping_mul(0x9E37_79B9_7F4A_7C15));
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BurnActivation {
ReLU,
Tanh,
}
struct HostCategoricalDist {
batch: usize,
n_actions: usize,
probs_flat: Vec<f32>,
log_probs_flat: Vec<f32>,
values_host: Vec<f32>,
}
impl HostCategoricalDist {
fn sample_actions(&self, rng: &mut rand::rngs::StdRng) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
use rand::Rng;
let mut actions = Vec::with_capacity(self.batch);
let mut log_probs = Vec::with_capacity(self.batch);
for row in 0..self.batch {
let u: f32 = rng.random();
let mut cum = 0.0;
let mut chosen = (self.n_actions - 1) as i64;
for j in 0..self.n_actions {
cum += self.probs_flat[row * self.n_actions + j];
if u < cum {
chosen = j as i64;
break;
}
}
actions.push(chosen);
log_probs.push(self.log_probs_flat[row * self.n_actions + chosen as usize]);
}
(actions, log_probs, self.values_host.clone())
}
}
#[derive(Debug, Clone, Copy)]
pub struct MlpBurnConfig {
pub num_layers: usize,
pub hidden_dim: usize,
pub use_orthogonal_init: bool,
pub activation: BurnActivation,
pub seed: Option<u64>,
}
impl Default for MlpBurnConfig {
fn default() -> Self {
Self {
num_layers: 2,
hidden_dim: 64,
use_orthogonal_init: true,
activation: BurnActivation::Tanh,
seed: None,
}
}
}
impl MlpBurnConfig {
pub fn with_seed(mut self, seed: u64) -> Self {
self.seed = Some(seed);
self
}
}
#[derive(Module, Debug)]
pub struct MlpBurnPolicy<B: Backend> {
fc1: Linear<B>,
fc2: Linear<B>,
fc3: Option<Linear<B>>,
policy_head: Linear<B>,
value_head: Linear<B>,
activation: BurnActivation,
}
impl<B: Backend> MlpBurnPolicy<B> {
pub fn new(obs_dim: usize, action_dim: usize, hidden_dim: usize, device: &B::Device) -> Self {
let config = MlpBurnConfig {
num_layers: 2,
hidden_dim,
use_orthogonal_init: false,
activation: BurnActivation::Tanh,
seed: None,
};
Self::with_config(obs_dim, action_dim, config, device)
}
pub fn new_seeded(
obs_dim: usize,
action_dim: usize,
hidden_dim: usize,
seed: u64,
device: &B::Device,
) -> Self {
let config = MlpBurnConfig {
num_layers: 2,
hidden_dim,
use_orthogonal_init: false,
activation: BurnActivation::Tanh,
seed: Some(seed),
};
Self::with_config(obs_dim, action_dim, config, device)
}
pub fn with_config(
obs_dim: usize,
action_dim: usize,
config: MlpBurnConfig,
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 policy_head = mk(3, config.hidden_dim, action_dim, true);
let value_head = mk(4, config.hidden_dim, 1, true);
return Self { fc1, fc2, fc3, policy_head, value_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 output_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 policy_head =
linear_with_init::<B>(config.hidden_dim, action_dim, output_init.clone(), device);
let value_head = linear_with_init::<B>(config.hidden_dim, 1, output_init, device);
Self { fc1, fc2, fc3, policy_head, value_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),
}
}
pub fn forward(&self, obs: Tensor<B, 2>) -> (Tensor<B, 2>, Tensor<B, 1>) {
let h = self.encoder_features(obs);
let logits = self.policy_head.forward(h.clone());
let value = self.value_head.forward(h).squeeze_dim::<1>(1);
(logits, value)
}
pub 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 policy_head_action_dim(&self) -> usize {
self.policy_head.weight.val().dims()[1]
}
pub fn fc1(&self) -> &Linear<B> {
&self.fc1
}
pub fn fc2(&self) -> &Linear<B> {
&self.fc2
}
pub fn policy_head(&self) -> &Linear<B> {
&self.policy_head
}
pub fn value_head(&self) -> &Linear<B> {
&self.value_head
}
pub fn get_action_host(&self, obs: Tensor<B, 2>) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
use rand::SeedableRng;
let mut rng = rand::rngs::StdRng::from_os_rng();
self.get_action_host_seeded(obs, &mut rng)
}
pub fn get_action_host_seeded(
&self,
obs: Tensor<B, 2>,
rng: &mut rand::rngs::StdRng,
) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
let dist = self.forward_to_host_dist(obs);
dist.sample_actions(rng)
}
fn forward_to_host_dist(&self, obs: Tensor<B, 2>) -> HostCategoricalDist {
let (logits, value) = self.forward(obs);
let probs = activation::softmax(logits.clone(), 1);
let log_probs_all = activation::log_softmax(logits, 1);
let dims = probs.dims();
let batch = dims[0];
let n_actions = dims[1];
let probs_flat: Vec<f32> = probs.into_data().to_vec().expect("probs to_vec");
let log_probs_flat: Vec<f32> =
log_probs_all.into_data().to_vec().expect("log_probs to_vec");
let values_host: Vec<f32> = value.into_data().to_vec().expect("values to_vec");
HostCategoricalDist { batch, n_actions, probs_flat, log_probs_flat, values_host }
}
pub fn get_actions_host_seeded_batched(
&self,
obs: Tensor<B, 2>,
rng: &mut rand::rngs::StdRng,
) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
self.forward_to_host_dist(obs).sample_actions(rng)
}
pub fn evaluate_actions(
&self,
obs: Tensor<B, 2>,
actions: Tensor<B, 1, Int>,
) -> (Tensor<B, 1>, Tensor<B, 1>, Tensor<B, 1>) {
let (logits, value) = self.forward(obs);
let log_probs = activation::log_softmax(logits, 1);
let probs = log_probs.clone().exp();
let action_log_probs =
log_probs.clone().gather(1, actions.unsqueeze_dim::<2>(1)).squeeze_dim::<1>(1);
let entropy = -(probs * log_probs).sum_dim(1).squeeze_dim::<1>(1);
(action_log_probs, entropy, value)
}
}
#[cfg(test)]
mod tests {
use burn::backend::{Autodiff, NdArray};
use super::*;
type B = Autodiff<NdArray<f32>>;
#[test]
fn test_policy_creation_default() {
let device = Default::default();
let _policy = MlpBurnPolicy::<B>::new(4, 2, 64, &device);
}
#[test]
fn test_with_config_two_layer() {
let device = Default::default();
let cfg = MlpBurnConfig::default();
let policy = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
assert!(policy.fc3.is_none());
}
#[test]
fn test_with_config_three_layer() {
let device = Default::default();
let cfg = MlpBurnConfig { num_layers: 3, ..Default::default() };
let policy = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
assert!(policy.fc3.is_some());
}
#[test]
fn test_forward_pass_two_layer() {
let device = Default::default();
let cfg = MlpBurnConfig::default();
let policy = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
let obs = Tensor::<B, 2>::zeros([8, 4], &device);
let (logits, values) = policy.forward(obs);
assert_eq!(logits.dims(), [8, 2]);
assert_eq!(values.dims(), [8]);
}
#[test]
fn test_forward_pass_three_layer() {
let device = Default::default();
let cfg = MlpBurnConfig { num_layers: 3, ..Default::default() };
let policy = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
let obs = Tensor::<B, 2>::zeros([8, 4], &device);
let (logits, values) = policy.forward(obs);
assert_eq!(logits.dims(), [8, 2]);
assert_eq!(values.dims(), [8]);
}
#[test]
fn test_evaluate_actions_shapes() {
let device = Default::default();
let policy = MlpBurnPolicy::<B>::with_config(4, 2, MlpBurnConfig::default(), &device);
let obs = Tensor::<B, 2>::zeros([8, 4], &device);
let actions = Tensor::<B, 1, Int>::from_data(
burn::tensor::TensorData::new(vec![0i64, 1, 0, 1, 0, 1, 0, 1], [8]),
&device,
);
let (log_probs, entropy, values) = policy.evaluate_actions(obs, actions);
assert_eq!(log_probs.dims(), [8]);
assert_eq!(entropy.dims(), [8]);
assert_eq!(values.dims(), [8]);
}
#[test]
fn test_relu_activation_branch() {
let device = Default::default();
let cfg = MlpBurnConfig {
activation: BurnActivation::ReLU,
use_orthogonal_init: false,
..Default::default()
};
let policy = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
let obs = Tensor::<B, 2>::zeros([2, 4], &device);
let (logits, _values) = policy.forward(obs);
assert_eq!(logits.dims(), [2, 2]);
}
#[test]
fn test_get_action_host_seeded_is_bit_exact() {
use rand::{SeedableRng, rngs::StdRng};
let device = Default::default();
let policy = MlpBurnPolicy::<B>::with_config(4, 3, MlpBurnConfig::default(), &device);
let obs_data = vec![0.1_f32, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8];
let obs_a = Tensor::<B, 2>::from_data(
burn::tensor::TensorData::new(obs_data.clone(), [2, 4]),
&device,
);
let obs_b =
Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(obs_data, [2, 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, v_a) = policy.get_action_host_seeded(obs_a, &mut rng_a);
let (a_b, lp_b, v_b) = policy.get_action_host_seeded(obs_b, &mut rng_b);
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");
assert_eq!(v_a, v_b, "same-seed values must be bit-identical");
let obs_c = Tensor::<B, 2>::from_data(
burn::tensor::TensorData::new(vec![0.1_f32, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8], [2, 4]),
&device,
);
let mut rng_c = StdRng::seed_from_u64(99);
let (a_c, _, _) = policy.get_action_host_seeded(obs_c, &mut rng_c);
assert_eq!(a_c.len(), 2, "two-row batch returns two actions");
}
fn collect_params(p: &MlpBurnPolicy<B>) -> Vec<f32> {
let mut out = Vec::new();
let mut push = |lin: &Linear<B>| {
out.extend::<Vec<f32>>(lin.weight.val().into_data().to_vec().unwrap());
if let Some(b) = &lin.bias {
out.extend::<Vec<f32>>(b.val().into_data().to_vec().unwrap());
}
};
push(&p.fc1);
push(&p.fc2);
if let Some(fc3) = &p.fc3 {
push(fc3);
}
push(&p.policy_head);
push(&p.value_head);
out
}
#[test]
fn test_with_seed_is_bit_identical_orthogonal() {
let device = Default::default();
let cfg = MlpBurnConfig { num_layers: 3, ..Default::default() }.with_seed(42);
let a = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
let b = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
assert_eq!(collect_params(&a), collect_params(&b), "same seed must be bit-identical");
let cfg_diff = MlpBurnConfig { num_layers: 3, ..Default::default() }.with_seed(43);
let c = MlpBurnPolicy::<B>::with_config(4, 2, cfg_diff, &device);
assert_ne!(collect_params(&a), collect_params(&c), "different seed must differ");
}
#[test]
fn test_new_seeded_is_bit_identical_kaiming() {
let device = Default::default();
let a = MlpBurnPolicy::<B>::new_seeded(4, 2, 16, 7, &device);
let b = MlpBurnPolicy::<B>::new_seeded(4, 2, 16, 7, &device);
assert_eq!(collect_params(&a), collect_params(&b), "same seed must be bit-identical");
let c = MlpBurnPolicy::<B>::new_seeded(4, 2, 16, 8, &device);
assert_ne!(collect_params(&a), collect_params(&c), "different seed must differ");
}
#[test]
fn test_seeded_layers_are_decorrelated() {
let device = Default::default();
let cfg = MlpBurnConfig { num_layers: 3, hidden_dim: 8, ..Default::default() }.with_seed(1);
let p = MlpBurnPolicy::<B>::with_config(8, 2, cfg, &device);
let fc2: Vec<f32> = p.fc2.weight.val().into_data().to_vec().unwrap();
let fc3: Vec<f32> = p.fc3.as_ref().unwrap().weight.val().into_data().to_vec().unwrap();
assert_ne!(fc2, fc3, "same-shape trunk layers must get distinct seeded weights");
}
#[test]
fn test_unseeded_path_still_constructs() {
let device = Default::default();
let cfg = MlpBurnConfig::default(); assert!(cfg.seed.is_none());
let p = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
let obs = Tensor::<B, 2>::zeros([3, 4], &device);
let (logits, values) = p.forward(obs);
assert_eq!(logits.dims(), [3, 2]);
assert_eq!(values.dims(), [3]);
}
}