use burn::{
module::Module,
nn::{Initializer, Linear},
tensor::{Tensor, activation, backend::Backend},
};
use super::mlp::{
BurnActivation, derive_layer_seed, linear_from_weights, linear_with_init, seeded_layer_weights,
};
#[derive(Debug, Clone, Copy)]
pub struct ContinuousQNetworkConfig {
pub num_layers: usize,
pub hidden_dim: usize,
pub use_orthogonal_init: bool,
pub activation: BurnActivation,
pub seed: Option<u64>,
}
impl Default for ContinuousQNetworkConfig {
fn default() -> Self {
Self {
num_layers: 2,
hidden_dim: 256,
use_orthogonal_init: true,
activation: BurnActivation::ReLU,
seed: None,
}
}
}
impl ContinuousQNetworkConfig {
pub fn with_seed(mut self, seed: u64) -> Self {
self.seed = Some(seed);
self
}
}
#[derive(Module, Debug)]
pub struct ContinuousQNetwork<B: Backend> {
fc1: Linear<B>,
fc2: Linear<B>,
fc3: Option<Linear<B>>,
q_head: Linear<B>,
activation: BurnActivation,
}
impl<B: Backend> ContinuousQNetwork<B> {
pub fn new(obs_dim: usize, action_dim: usize, hidden_dim: usize, device: &B::Device) -> Self {
Self::with_config(
obs_dim,
action_dim,
ContinuousQNetworkConfig { hidden_dim, ..Default::default() },
device,
)
}
pub fn with_config(
obs_dim: usize,
action_dim: usize,
config: ContinuousQNetworkConfig,
device: &B::Device,
) -> Self {
let input_dim = obs_dim + action_dim;
let hidden = config.hidden_dim;
let use_third = config.num_layers >= 3;
let (fc1, fc2, fc3, q_head) = if let Some(base_seed) = config.seed {
let mut layer_idx = 0u64;
let mut next = || {
let s = derive_layer_seed(base_seed, layer_idx);
layer_idx += 1;
s
};
let w1 =
seeded_layer_weights(next(), input_dim, hidden, config.use_orthogonal_init, false);
let fc1 = linear_from_weights::<B>(input_dim, hidden, &w1, device);
let w2 =
seeded_layer_weights(next(), hidden, hidden, config.use_orthogonal_init, false);
let fc2 = linear_from_weights::<B>(hidden, hidden, &w2, device);
let fc3 = if use_third {
let w3 =
seeded_layer_weights(next(), hidden, hidden, config.use_orthogonal_init, false);
Some(linear_from_weights::<B>(hidden, hidden, &w3, device))
} else {
None
};
let wq = seeded_layer_weights(next(), hidden, 1, config.use_orthogonal_init, true);
let q_head = linear_from_weights::<B>(hidden, 1, &wq, device);
(fc1, fc2, fc3, q_head)
} else {
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>(input_dim, hidden, hidden_init.clone(), device);
let fc2 = linear_with_init::<B>(hidden, hidden, hidden_init.clone(), device);
let fc3 = if use_third {
Some(linear_with_init::<B>(hidden, hidden, hidden_init, device))
} else {
None
};
let q_head = linear_with_init::<B>(hidden, 1, output_init, device);
(fc1, fc2, fc3, q_head)
};
Self { fc1, fc2, fc3, q_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>, action: Tensor<B, 2>) -> Tensor<B, 1> {
let input = Tensor::cat(vec![obs, action], 1);
let h = self.apply_activation(self.fc1.forward(input));
let h = self.apply_activation(self.fc2.forward(h));
let h = if let Some(fc3) = &self.fc3 {
self.apply_activation(fc3.forward(h))
} else {
h
};
self.q_head.forward(h).squeeze_dim::<1>(1)
}
pub fn copy_params_from(self, source: &ContinuousQNetwork<B>) -> ContinuousQNetwork<B> {
self.load_record(source.clone().into_record())
}
pub fn soft_update_from(&mut self, online: &ContinuousQNetwork<B>, tau: f64) {
debug_assert!(
(0.0..=1.0).contains(&tau),
"tau must lie in [0, 1] for a convex Polyak blend, got {tau}"
);
debug_assert_eq!(
self.fc3.is_some(),
online.fc3.is_some(),
"target and online critics must have the same depth"
);
let target = std::mem::replace(self, online.clone());
let fc1 = blend_linear(target.fc1, &online.fc1, tau);
let fc2 = blend_linear(target.fc2, &online.fc2, tau);
let fc3 = match (target.fc3, &online.fc3) {
(Some(t), Some(o)) => Some(blend_linear(t, o, tau)),
_ => None,
};
let q_head = blend_linear(target.q_head, &online.q_head, tau);
*self = Self { fc1, fc2, fc3, q_head, activation: target.activation };
}
}
fn blend_linear<B: Backend>(target: Linear<B>, online: &Linear<B>, tau: f64) -> Linear<B> {
use burn::module::Param;
let one_minus_tau = 1.0 - tau;
let target_w = target.weight.val();
let online_w = online.weight.val();
let weight = online_w.mul_scalar(tau).add(target_w.mul_scalar(one_minus_tau)).detach();
let bias = match (target.bias, &online.bias) {
(Some(target_b), Some(online_b)) => {
let blended = online_b
.val()
.mul_scalar(tau)
.add(target_b.val().mul_scalar(one_minus_tau))
.detach();
Some(Param::from_tensor(blended))
}
_ => None,
};
Linear::<B> { weight: Param::from_tensor(weight), bias }
}
#[cfg(test)]
mod tests {
use burn::backend::{Autodiff, NdArray};
use super::*;
type B = Autodiff<NdArray<f32>>;
fn ramp(batch: usize, dim: usize) -> Tensor<B, 2> {
let device = Default::default();
let data: Vec<f32> = (0..batch * dim).map(|i| 0.01 * i as f32).collect();
Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(data, [batch, dim]), &device)
}
#[test]
fn forward_shape_two_layer() {
let device = Default::default();
let q = ContinuousQNetwork::<B>::new(4, 2, 32, &device);
let obs = ramp(8, 4);
let action = ramp(8, 2);
let out = q.forward(obs, action);
assert_eq!(out.dims(), [8], "2-layer critic must return [batch]");
}
#[test]
fn forward_shape_three_layer() {
let device = Default::default();
let cfg = ContinuousQNetworkConfig { num_layers: 3, ..Default::default() };
let q = ContinuousQNetwork::<B>::with_config(5, 3, cfg, &device);
assert!(q.fc3.is_some(), "num_layers=3 must build a third trunk layer");
let obs = ramp(6, 5);
let action = ramp(6, 3);
let out = q.forward(obs, action);
assert_eq!(out.dims(), [6], "3-layer critic must return [batch]");
}
#[test]
fn tanh_activation_branch() {
let device = Default::default();
let cfg = ContinuousQNetworkConfig {
activation: BurnActivation::Tanh,
use_orthogonal_init: false,
..Default::default()
};
let q = ContinuousQNetwork::<B>::with_config(3, 1, cfg, &device);
let obs = ramp(2, 3);
let action = ramp(2, 1);
assert_eq!(q.forward(obs, action).dims(), [2]);
}
#[test]
fn seeded_construction_is_bit_exact() {
let device = Default::default();
let cfg = ContinuousQNetworkConfig::default().with_seed(7);
let a = ContinuousQNetwork::<B>::with_config(4, 2, cfg, &device);
let b = ContinuousQNetwork::<B>::with_config(4, 2, cfg, &device);
let obs = ramp(4, 4);
let action = ramp(4, 2);
let qa: Vec<f32> = a.forward(obs.clone(), action.clone()).into_data().to_vec().unwrap();
let qb: Vec<f32> = b.forward(obs, action).into_data().to_vec().unwrap();
assert_eq!(qa, qb, "same seed must yield bit-identical critics");
}
#[test]
fn copy_params_from_matches_online() {
let device = Default::default();
let cfg = ContinuousQNetworkConfig {
hidden_dim: 16,
use_orthogonal_init: false,
..Default::default()
};
let online = ContinuousQNetwork::<B>::with_config(4, 2, cfg, &device);
let target = ContinuousQNetwork::<B>::with_config(4, 2, cfg, &device);
let obs = ramp(3, 4);
let action = ramp(3, 2);
let on_before: Vec<f32> =
online.forward(obs.clone(), action.clone()).into_data().to_vec().unwrap();
let tg_before: Vec<f32> =
target.forward(obs.clone(), action.clone()).into_data().to_vec().unwrap();
assert!(
on_before.iter().zip(&tg_before).any(|(a, b)| (a - b).abs() > 1e-6),
"fresh critics should disagree before copy"
);
let target = target.copy_params_from(&online);
let on_after: Vec<f32> =
online.forward(obs.clone(), action.clone()).into_data().to_vec().unwrap();
let tg_after: Vec<f32> = target.forward(obs, action).into_data().to_vec().unwrap();
for (a, b) in on_after.iter().zip(&tg_after) {
assert!((a - b).abs() < 1e-6, "after copy: online={a} target={b}");
}
}
#[test]
fn soft_update_tau_one_equals_hard_copy() {
let device = Default::default();
let cfg = ContinuousQNetworkConfig {
hidden_dim: 16,
use_orthogonal_init: false,
..Default::default()
};
let online = ContinuousQNetwork::<B>::with_config(4, 2, cfg, &device);
let mut target = ContinuousQNetwork::<B>::with_config(4, 2, cfg, &device);
let obs = ramp(3, 4);
let action = ramp(3, 2);
target.soft_update_from(&online, 1.0);
let on: Vec<f32> =
online.forward(obs.clone(), action.clone()).into_data().to_vec().unwrap();
let tg: Vec<f32> = target.forward(obs, action).into_data().to_vec().unwrap();
for (a, b) in on.iter().zip(&tg) {
assert!((a - b).abs() < 1e-6, "tau=1 soft update: online={a} target={b}");
}
}
fn all_params(net: &ContinuousQNetwork<B>) -> Vec<f32> {
let mut out = Vec::new();
let mut push_linear = |l: &Linear<B>| {
out.extend(l.weight.val().into_data().to_vec::<f32>().unwrap());
if let Some(b) = &l.bias {
out.extend(b.val().into_data().to_vec::<f32>().unwrap());
}
};
push_linear(&net.fc1);
push_linear(&net.fc2);
if let Some(fc3) = &net.fc3 {
push_linear(fc3);
}
push_linear(&net.q_head);
out
}
#[test]
fn soft_update_moves_target_toward_online() {
let device = Default::default();
let online = ContinuousQNetwork::<B>::with_config(
4,
2,
ContinuousQNetworkConfig {
hidden_dim: 16,
use_orthogonal_init: false,
seed: Some(11),
..Default::default()
},
&device,
);
let mut target = ContinuousQNetwork::<B>::with_config(
4,
2,
ContinuousQNetworkConfig {
hidden_dim: 16,
use_orthogonal_init: false,
seed: Some(29),
..Default::default()
},
&device,
);
let online_p = all_params(&online);
let target_before = all_params(&target);
let dist_before: f32 =
online_p.iter().zip(&target_before).map(|(o, t)| (o - t).abs()).sum();
assert!(dist_before > 1e-4, "test needs distinct critics to start");
let tau = 0.25_f64;
target.soft_update_from(&online, tau);
let target_after = all_params(&target);
assert_eq!(target_after.len(), target_before.len());
assert_eq!(target_after.len(), online_p.len());
let tau_f = tau as f32;
for (i, ((&onl, &old), &new)) in
online_p.iter().zip(&target_before).zip(&target_after).enumerate()
{
let expected = tau_f * onl + (1.0 - tau_f) * old;
assert!(
(new - expected).abs() < 1e-5,
"param {i}: Polyak blend mismatch new={new} expected={expected} \
(online={onl} old_target={old} tau={tau})"
);
}
let dist_after: f32 = online_p.iter().zip(&target_after).map(|(o, t)| (o - t).abs()).sum();
let expected_after = (1.0 - tau_f) * dist_before;
assert!(
(dist_after - expected_after).abs() < 1e-4,
"param distance to online should scale by (1-tau): \
before={dist_before} after={dist_after} expected={expected_after}"
);
assert!(
target_before.iter().zip(&target_after).any(|(a, b)| (a - b).abs() > 1e-6),
"soft update with tau>0 must change the target"
);
}
}