use burn::{
module::Module,
nn::{Initializer, Linear, Lstm, LstmConfig, LstmState},
tensor::{Int, Tensor, activation, backend::Backend},
};
use crate::policy::mlp::{
derive_layer_seed, linear_from_weights, linear_with_init, seeded_layer_weights,
};
#[derive(Debug, Clone, Copy)]
pub struct LstmBurnConfig {
pub hidden_dim: usize,
pub use_orthogonal_init: bool,
pub seed: Option<u64>,
}
impl Default for LstmBurnConfig {
fn default() -> Self {
Self { hidden_dim: 64, use_orthogonal_init: true, seed: None }
}
}
impl LstmBurnConfig {
pub fn with_seed(mut self, seed: u64) -> Self {
self.seed = Some(seed);
self
}
}
#[derive(Module, Debug)]
pub struct LstmBurnPolicy<B: Backend> {
lstm: Lstm<B>,
policy_head: Linear<B>,
value_head: Linear<B>,
}
impl<B: Backend> LstmBurnPolicy<B> {
pub fn new(obs_dim: usize, action_dim: usize, hidden_dim: usize, device: &B::Device) -> Self {
let config = LstmBurnConfig { hidden_dim, ..Default::default() };
Self::with_config(obs_dim, action_dim, config, device)
}
pub fn with_config(
obs_dim: usize,
action_dim: usize,
config: LstmBurnConfig,
device: &B::Device,
) -> Self {
let hidden = config.hidden_dim;
let mut lstm = LstmConfig::new(obs_dim, hidden, true).init::<B>(device);
if let Some(seed) = config.seed {
let orth = config.use_orthogonal_init;
let mk = |idx: u64, d_in: usize, d_out: usize| -> Linear<B> {
let s = derive_layer_seed(seed, idx);
let w = seeded_layer_weights(s, d_in, d_out, orth, false);
linear_from_weights::<B>(d_in, d_out, &w, device)
};
lstm.input_gate.input_transform = mk(0, obs_dim, hidden);
lstm.input_gate.hidden_transform = mk(1, hidden, hidden);
lstm.forget_gate.input_transform = mk(2, obs_dim, hidden);
lstm.forget_gate.hidden_transform = mk(3, hidden, hidden);
lstm.cell_gate.input_transform = mk(4, obs_dim, hidden);
lstm.cell_gate.hidden_transform = mk(5, hidden, hidden);
lstm.output_gate.input_transform = mk(6, obs_dim, hidden);
lstm.output_gate.hidden_transform = mk(7, hidden, hidden);
let mk_head = |idx: u64, d_in: usize, d_out: usize| -> Linear<B> {
let s = derive_layer_seed(seed, idx);
let w = seeded_layer_weights(s, d_in, d_out, orth, true);
linear_from_weights::<B>(d_in, d_out, &w, device)
};
let policy_head = mk_head(8, hidden, action_dim);
let value_head = mk_head(9, hidden, 1);
return Self { lstm, policy_head, value_head };
}
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 policy_head = linear_with_init::<B>(hidden, action_dim, head_init.clone(), device);
let value_head = linear_with_init::<B>(hidden, 1, head_init, device);
Self { lstm, policy_head, value_head }
}
pub fn hidden_dim(&self) -> usize {
self.lstm.d_hidden
}
pub fn policy_head_action_dim(&self) -> usize {
self.policy_head.weight.val().dims()[1]
}
pub fn forward_step(
&self,
obs: Tensor<B, 2>,
state: Option<LstmState<B, 2>>,
) -> (Tensor<B, 2>, Tensor<B, 1>, LstmState<B, 2>) {
let obs_seq = obs.unsqueeze_dim::<3>(1);
let (out, new_state) = self.lstm.forward(obs_seq, state);
let feats = out.squeeze_dim::<2>(1);
let logits = self.policy_head.forward(feats.clone());
let value = self.value_head.forward(feats).squeeze_dim::<1>(1);
(logits, value, new_state)
}
pub fn evaluate_sequences(
&self,
obs_seq: Tensor<B, 3>,
actions: Tensor<B, 2, Int>,
initial_state: Option<LstmState<B, 2>>,
episode_starts: Tensor<B, 2>,
) -> (Tensor<B, 2>, Tensor<B, 2>, Tensor<B, 2>) {
let device = obs_seq.device();
let [n_env, seq_len, obs_dim] = obs_seq.dims();
let hidden = self.hidden_dim();
let (mut cell, mut hidden_state) = match initial_state {
Some(s) => (s.cell, s.hidden),
None => (
Tensor::<B, 2>::zeros([n_env, hidden], &device),
Tensor::<B, 2>::zeros([n_env, hidden], &device),
),
};
let mut feats: Vec<Tensor<B, 3>> = Vec::with_capacity(seq_len);
for t in 0..seq_len {
let flag = episode_starts.clone().slice([0..n_env, t..(t + 1)]);
let keep = flag.neg().add_scalar(1.0);
cell = cell.mul(keep.clone());
hidden_state = hidden_state.mul(keep);
let step_in = obs_seq.clone().slice([0..n_env, t..(t + 1), 0..obs_dim]);
let (out, new_state) =
self.lstm.forward(step_in, Some(LstmState::new(cell, hidden_state)));
feats.push(out);
cell = new_state.cell;
hidden_state = new_state.hidden;
}
let features = Tensor::cat(feats, 1);
let logits = self.policy_head.forward(features.clone());
let values = self.value_head.forward(features).squeeze_dim::<2>(2);
let log_probs_all = activation::log_softmax(logits, 2);
let probs = log_probs_all.clone().exp();
let action_log_probs = log_probs_all
.clone()
.gather(2, actions.unsqueeze_dim::<3>(2))
.squeeze_dim::<2>(2);
let entropy = -(probs * log_probs_all).sum_dim(2).squeeze_dim::<2>(2);
(action_log_probs, entropy, values)
}
}
#[cfg(test)]
mod tests {
use burn::backend::{Autodiff, NdArray};
use super::*;
type B = Autodiff<NdArray<f32>>;
#[test]
fn test_constructs_unseeded() {
let device = Default::default();
let policy = LstmBurnPolicy::<B>::with_config(4, 2, LstmBurnConfig::default(), &device);
assert_eq!(policy.hidden_dim(), 64);
assert_eq!(policy.policy_head_action_dim(), 2);
let obs = Tensor::<B, 2>::zeros([3, 4], &device);
let (logits, value, _state) = policy.forward_step(obs, None);
assert_eq!(logits.dims(), [3, 2]);
assert_eq!(value.dims(), [3]);
}
#[test]
fn test_forward_step_shapes() {
let device = Default::default();
let cfg = LstmBurnConfig { hidden_dim: 8, ..Default::default() };
let policy = LstmBurnPolicy::<B>::with_config(4, 3, cfg, &device);
let obs = Tensor::<B, 2>::from_data(
burn::tensor::TensorData::new(vec![0.1_f32, 0.2, 0.3, 0.4], [1, 4]),
&device,
);
let (logits, value, state) = policy.forward_step(obs, None);
assert_eq!(logits.dims(), [1, 3]);
assert_eq!(value.dims(), [1]);
assert_eq!(state.cell.dims(), [1, 8]);
assert_eq!(state.hidden.dims(), [1, 8]);
}
#[test]
fn test_evaluate_sequences_shapes() {
let device = Default::default();
let cfg = LstmBurnConfig { hidden_dim: 8, ..Default::default() };
let policy = LstmBurnPolicy::<B>::with_config(4, 2, cfg, &device);
let obs_seq = Tensor::<B, 3>::zeros([2, 3, 4], &device); let actions = Tensor::<B, 2, Int>::from_data(
burn::tensor::TensorData::new(vec![0i64, 1, 0, 1, 0, 1], [2, 3]),
&device,
);
let episode_starts = Tensor::<B, 2>::zeros([2, 3], &device);
let (log_probs, entropy, values) =
policy.evaluate_sequences(obs_seq, actions, None, episode_starts);
assert_eq!(log_probs.dims(), [2, 3]);
assert_eq!(entropy.dims(), [2, 3]);
assert_eq!(values.dims(), [2, 3]);
}
fn collect_params(p: &LstmBurnPolicy<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.lstm.input_gate.input_transform);
push(&p.lstm.input_gate.hidden_transform);
push(&p.lstm.forget_gate.input_transform);
push(&p.lstm.forget_gate.hidden_transform);
push(&p.lstm.cell_gate.input_transform);
push(&p.lstm.cell_gate.hidden_transform);
push(&p.lstm.output_gate.input_transform);
push(&p.lstm.output_gate.hidden_transform);
push(&p.policy_head);
push(&p.value_head);
out
}
#[test]
fn test_with_seed_is_bit_identical() {
let device = Default::default();
let cfg = LstmBurnConfig { hidden_dim: 8, ..Default::default() }.with_seed(42);
let a = LstmBurnPolicy::<B>::with_config(4, 2, cfg, &device);
let b = LstmBurnPolicy::<B>::with_config(4, 2, cfg, &device);
assert_eq!(collect_params(&a), collect_params(&b), "same seed must be bit-identical");
let cfg_diff = LstmBurnConfig { hidden_dim: 8, ..Default::default() }.with_seed(43);
let c = LstmBurnPolicy::<B>::with_config(4, 2, cfg_diff, &device);
assert_ne!(collect_params(&a), collect_params(&c), "different seed must differ");
}
#[test]
fn test_seeded_gates_are_decorrelated() {
let device = Default::default();
let cfg = LstmBurnConfig { hidden_dim: 6, ..Default::default() }.with_seed(1);
let p = LstmBurnPolicy::<B>::with_config(4, 2, cfg, &device);
let i: Vec<f32> =
p.lstm.input_gate.hidden_transform.weight.val().into_data().to_vec().unwrap();
let f: Vec<f32> =
p.lstm.forget_gate.hidden_transform.weight.val().into_data().to_vec().unwrap();
assert_ne!(i, f, "same-shape gate layers must get distinct seeded weights");
}
#[test]
fn test_evaluate_sequences_episode_boundary_reset() {
let device = Default::default();
let cfg = LstmBurnConfig { hidden_dim: 8, ..Default::default() }.with_seed(7);
let policy = LstmBurnPolicy::<B>::with_config(4, 2, cfg, &device);
let o0 = [0.9_f32, -0.4, 0.6, 0.2];
let o1 = [0.1_f32, 0.5, -0.3, 0.8];
let seq_data: Vec<f32> = o0.iter().chain(o1.iter()).copied().collect();
let obs_seq =
Tensor::<B, 3>::from_data(burn::tensor::TensorData::new(seq_data, [1, 2, 4]), &device);
let actions = Tensor::<B, 2, Int>::from_data(
burn::tensor::TensorData::new(vec![0i64, 0], [1, 2]),
&device,
);
let starts_reset = Tensor::<B, 2>::from_data(
burn::tensor::TensorData::new(vec![0.0_f32, 1.0], [1, 2]),
&device,
);
let (_, _, values_reset) =
policy.evaluate_sequences(obs_seq.clone(), actions.clone(), None, starts_reset);
let v_reset: Vec<f32> = values_reset.into_data().to_vec().unwrap();
let o1_tensor =
Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(o1.to_vec(), [1, 4]), &device);
let (_, value_fresh, _) = policy.forward_step(o1_tensor, None);
let v_fresh: Vec<f32> = value_fresh.into_data().to_vec().unwrap();
assert!(
(v_reset[1] - v_fresh[0]).abs() < 1e-5,
"reset step-1 value {} should match fresh zero-state value {}",
v_reset[1],
v_fresh[0]
);
let starts_carry = Tensor::<B, 2>::zeros([1, 2], &device);
let (_, _, values_carry) = policy.evaluate_sequences(obs_seq, actions, None, starts_carry);
let v_carry: Vec<f32> = values_carry.into_data().to_vec().unwrap();
assert!(
(v_carry[1] - v_reset[1]).abs() > 1e-6,
"carried step-1 value {} should differ from reset value {} (stale state must flow)",
v_carry[1],
v_reset[1]
);
}
}