1use burn::{
53 module::Module,
54 nn::{Initializer, Linear},
55 tensor::{Tensor, activation, backend::Backend},
56};
57
58use super::mlp::{
59 BurnActivation, derive_layer_seed, linear_from_weights, linear_with_init, seeded_layer_weights,
60};
61
62#[derive(Debug, Clone, Copy)]
69pub struct ContinuousQNetworkConfig {
70 pub num_layers: usize,
73 pub hidden_dim: usize,
75 pub use_orthogonal_init: bool,
79 pub activation: BurnActivation,
81 pub seed: Option<u64>,
89}
90
91impl Default for ContinuousQNetworkConfig {
92 fn default() -> Self {
93 Self {
94 num_layers: 2,
95 hidden_dim: 256,
96 use_orthogonal_init: true,
97 activation: BurnActivation::ReLU,
98 seed: None,
99 }
100 }
101}
102
103impl ContinuousQNetworkConfig {
104 pub fn with_seed(mut self, seed: u64) -> Self {
110 self.seed = Some(seed);
111 self
112 }
113}
114
115#[derive(Module, Debug)]
117pub struct ContinuousQNetwork<B: Backend> {
118 fc1: Linear<B>,
119 fc2: Linear<B>,
120 fc3: Option<Linear<B>>,
121 q_head: Linear<B>,
122 activation: BurnActivation,
123}
124
125impl<B: Backend> ContinuousQNetwork<B> {
126 pub fn new(obs_dim: usize, action_dim: usize, hidden_dim: usize, device: &B::Device) -> Self {
129 Self::with_config(
130 obs_dim,
131 action_dim,
132 ContinuousQNetworkConfig { hidden_dim, ..Default::default() },
133 device,
134 )
135 }
136
137 pub fn with_config(
144 obs_dim: usize,
145 action_dim: usize,
146 config: ContinuousQNetworkConfig,
147 device: &B::Device,
148 ) -> Self {
149 let input_dim = obs_dim + action_dim;
150 let hidden = config.hidden_dim;
151 let use_third = config.num_layers >= 3;
152
153 let (fc1, fc2, fc3, q_head) = if let Some(base_seed) = config.seed {
154 let mut layer_idx = 0u64;
158 let mut next = || {
159 let s = derive_layer_seed(base_seed, layer_idx);
160 layer_idx += 1;
161 s
162 };
163
164 let w1 =
165 seeded_layer_weights(next(), input_dim, hidden, config.use_orthogonal_init, false);
166 let fc1 = linear_from_weights::<B>(input_dim, hidden, &w1, device);
167
168 let w2 =
169 seeded_layer_weights(next(), hidden, hidden, config.use_orthogonal_init, false);
170 let fc2 = linear_from_weights::<B>(hidden, hidden, &w2, device);
171
172 let fc3 = if use_third {
173 let w3 =
174 seeded_layer_weights(next(), hidden, hidden, config.use_orthogonal_init, false);
175 Some(linear_from_weights::<B>(hidden, hidden, &w3, device))
176 } else {
177 None
178 };
179
180 let wq = seeded_layer_weights(next(), hidden, 1, config.use_orthogonal_init, true);
181 let q_head = linear_from_weights::<B>(hidden, 1, &wq, device);
182
183 (fc1, fc2, fc3, q_head)
184 } else {
185 let hidden_init = if config.use_orthogonal_init {
188 Initializer::Orthogonal { gain: 2.0_f64.sqrt() }
189 } else {
190 Initializer::KaimingUniform { gain: 1.0_f64 / 3.0_f64.sqrt(), fan_out_only: false }
191 };
192 let output_init = if config.use_orthogonal_init {
193 Initializer::Orthogonal { gain: 0.01 }
194 } else {
195 Initializer::KaimingUniform { gain: 1.0_f64 / 3.0_f64.sqrt(), fan_out_only: false }
196 };
197
198 let fc1 = linear_with_init::<B>(input_dim, hidden, hidden_init.clone(), device);
199 let fc2 = linear_with_init::<B>(hidden, hidden, hidden_init.clone(), device);
200 let fc3 = if use_third {
201 Some(linear_with_init::<B>(hidden, hidden, hidden_init, device))
202 } else {
203 None
204 };
205 let q_head = linear_with_init::<B>(hidden, 1, output_init, device);
206
207 (fc1, fc2, fc3, q_head)
208 };
209
210 Self { fc1, fc2, fc3, q_head, activation: config.activation }
211 }
212
213 fn apply_activation<const D: usize>(&self, x: Tensor<B, D>) -> Tensor<B, D> {
214 match self.activation {
215 BurnActivation::ReLU => activation::relu(x),
216 BurnActivation::Tanh => activation::tanh(x),
217 }
218 }
219
220 pub fn forward(&self, obs: Tensor<B, 2>, action: Tensor<B, 2>) -> Tensor<B, 1> {
232 let input = Tensor::cat(vec![obs, action], 1);
233 let h = self.apply_activation(self.fc1.forward(input));
234 let h = self.apply_activation(self.fc2.forward(h));
235 let h = if let Some(fc3) = &self.fc3 {
236 self.apply_activation(fc3.forward(h))
237 } else {
238 h
239 };
240 self.q_head.forward(h).squeeze_dim::<1>(1)
241 }
242
243 pub fn copy_params_from(self, source: &ContinuousQNetwork<B>) -> ContinuousQNetwork<B> {
252 self.load_record(source.clone().into_record())
256 }
257
258 pub fn soft_update_from(&mut self, online: &ContinuousQNetwork<B>, tau: f64) {
272 debug_assert!(
273 (0.0..=1.0).contains(&tau),
274 "tau must lie in [0, 1] for a convex Polyak blend, got {tau}"
275 );
276 debug_assert_eq!(
277 self.fc3.is_some(),
278 online.fc3.is_some(),
279 "target and online critics must have the same depth"
280 );
281
282 let target = std::mem::replace(self, online.clone());
286
287 let fc1 = blend_linear(target.fc1, &online.fc1, tau);
288 let fc2 = blend_linear(target.fc2, &online.fc2, tau);
289 let fc3 = match (target.fc3, &online.fc3) {
290 (Some(t), Some(o)) => Some(blend_linear(t, o, tau)),
291 _ => None,
292 };
293 let q_head = blend_linear(target.q_head, &online.q_head, tau);
294
295 *self = Self { fc1, fc2, fc3, q_head, activation: target.activation };
296 }
297}
298
299fn blend_linear<B: Backend>(target: Linear<B>, online: &Linear<B>, tau: f64) -> Linear<B> {
305 use burn::module::Param;
306
307 let one_minus_tau = 1.0 - tau;
308
309 let target_w = target.weight.val();
310 let online_w = online.weight.val();
311 let weight = online_w.mul_scalar(tau).add(target_w.mul_scalar(one_minus_tau)).detach();
316
317 let bias = match (target.bias, &online.bias) {
318 (Some(target_b), Some(online_b)) => {
319 let blended = online_b
320 .val()
321 .mul_scalar(tau)
322 .add(target_b.val().mul_scalar(one_minus_tau))
323 .detach();
324 Some(Param::from_tensor(blended))
325 }
326 _ => None,
327 };
328
329 Linear::<B> { weight: Param::from_tensor(weight), bias }
330}
331
332#[cfg(test)]
333mod tests {
334 use burn::backend::{Autodiff, NdArray};
335
336 use super::*;
337
338 type B = Autodiff<NdArray<f32>>;
339
340 fn ramp(batch: usize, dim: usize) -> Tensor<B, 2> {
343 let device = Default::default();
344 let data: Vec<f32> = (0..batch * dim).map(|i| 0.01 * i as f32).collect();
345 Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(data, [batch, dim]), &device)
346 }
347
348 #[test]
349 fn forward_shape_two_layer() {
350 let device = Default::default();
351 let q = ContinuousQNetwork::<B>::new(4, 2, 32, &device);
352 let obs = ramp(8, 4);
353 let action = ramp(8, 2);
354 let out = q.forward(obs, action);
355 assert_eq!(out.dims(), [8], "2-layer critic must return [batch]");
356 }
357
358 #[test]
359 fn forward_shape_three_layer() {
360 let device = Default::default();
361 let cfg = ContinuousQNetworkConfig { num_layers: 3, ..Default::default() };
362 let q = ContinuousQNetwork::<B>::with_config(5, 3, cfg, &device);
363 assert!(q.fc3.is_some(), "num_layers=3 must build a third trunk layer");
364 let obs = ramp(6, 5);
365 let action = ramp(6, 3);
366 let out = q.forward(obs, action);
367 assert_eq!(out.dims(), [6], "3-layer critic must return [batch]");
368 }
369
370 #[test]
371 fn tanh_activation_branch() {
372 let device = Default::default();
373 let cfg = ContinuousQNetworkConfig {
374 activation: BurnActivation::Tanh,
375 use_orthogonal_init: false,
376 ..Default::default()
377 };
378 let q = ContinuousQNetwork::<B>::with_config(3, 1, cfg, &device);
379 let obs = ramp(2, 3);
380 let action = ramp(2, 1);
381 assert_eq!(q.forward(obs, action).dims(), [2]);
382 }
383
384 #[test]
385 fn seeded_construction_is_bit_exact() {
386 let device = Default::default();
387 let cfg = ContinuousQNetworkConfig::default().with_seed(7);
388 let a = ContinuousQNetwork::<B>::with_config(4, 2, cfg, &device);
389 let b = ContinuousQNetwork::<B>::with_config(4, 2, cfg, &device);
390
391 let obs = ramp(4, 4);
392 let action = ramp(4, 2);
393 let qa: Vec<f32> = a.forward(obs.clone(), action.clone()).into_data().to_vec().unwrap();
394 let qb: Vec<f32> = b.forward(obs, action).into_data().to_vec().unwrap();
395 assert_eq!(qa, qb, "same seed must yield bit-identical critics");
396 }
397
398 #[test]
401 fn copy_params_from_matches_online() {
402 let device = Default::default();
403 let cfg = ContinuousQNetworkConfig {
404 hidden_dim: 16,
405 use_orthogonal_init: false,
406 ..Default::default()
407 };
408 let online = ContinuousQNetwork::<B>::with_config(4, 2, cfg, &device);
409 let target = ContinuousQNetwork::<B>::with_config(4, 2, cfg, &device);
410
411 let obs = ramp(3, 4);
412 let action = ramp(3, 2);
413
414 let on_before: Vec<f32> =
416 online.forward(obs.clone(), action.clone()).into_data().to_vec().unwrap();
417 let tg_before: Vec<f32> =
418 target.forward(obs.clone(), action.clone()).into_data().to_vec().unwrap();
419 assert!(
420 on_before.iter().zip(&tg_before).any(|(a, b)| (a - b).abs() > 1e-6),
421 "fresh critics should disagree before copy"
422 );
423
424 let target = target.copy_params_from(&online);
425 let on_after: Vec<f32> =
426 online.forward(obs.clone(), action.clone()).into_data().to_vec().unwrap();
427 let tg_after: Vec<f32> = target.forward(obs, action).into_data().to_vec().unwrap();
428 for (a, b) in on_after.iter().zip(&tg_after) {
429 assert!((a - b).abs() < 1e-6, "after copy: online={a} target={b}");
430 }
431 }
432
433 #[test]
435 fn soft_update_tau_one_equals_hard_copy() {
436 let device = Default::default();
437 let cfg = ContinuousQNetworkConfig {
438 hidden_dim: 16,
439 use_orthogonal_init: false,
440 ..Default::default()
441 };
442 let online = ContinuousQNetwork::<B>::with_config(4, 2, cfg, &device);
443 let mut target = ContinuousQNetwork::<B>::with_config(4, 2, cfg, &device);
444
445 let obs = ramp(3, 4);
446 let action = ramp(3, 2);
447
448 target.soft_update_from(&online, 1.0);
449 let on: Vec<f32> =
450 online.forward(obs.clone(), action.clone()).into_data().to_vec().unwrap();
451 let tg: Vec<f32> = target.forward(obs, action).into_data().to_vec().unwrap();
452 for (a, b) in on.iter().zip(&tg) {
453 assert!((a - b).abs() < 1e-6, "tau=1 soft update: online={a} target={b}");
454 }
455 }
456
457 fn all_params(net: &ContinuousQNetwork<B>) -> Vec<f32> {
461 let mut out = Vec::new();
462 let mut push_linear = |l: &Linear<B>| {
463 out.extend(l.weight.val().into_data().to_vec::<f32>().unwrap());
464 if let Some(b) = &l.bias {
465 out.extend(b.val().into_data().to_vec::<f32>().unwrap());
466 }
467 };
468 push_linear(&net.fc1);
469 push_linear(&net.fc2);
470 if let Some(fc3) = &net.fc3 {
471 push_linear(fc3);
472 }
473 push_linear(&net.q_head);
474 out
475 }
476
477 #[test]
490 fn soft_update_moves_target_toward_online() {
491 let device = Default::default();
492 let online = ContinuousQNetwork::<B>::with_config(
495 4,
496 2,
497 ContinuousQNetworkConfig {
498 hidden_dim: 16,
499 use_orthogonal_init: false,
500 seed: Some(11),
501 ..Default::default()
502 },
503 &device,
504 );
505 let mut target = ContinuousQNetwork::<B>::with_config(
506 4,
507 2,
508 ContinuousQNetworkConfig {
509 hidden_dim: 16,
510 use_orthogonal_init: false,
511 seed: Some(29),
512 ..Default::default()
513 },
514 &device,
515 );
516
517 let online_p = all_params(&online);
518 let target_before = all_params(&target);
519
520 let dist_before: f32 =
522 online_p.iter().zip(&target_before).map(|(o, t)| (o - t).abs()).sum();
523 assert!(dist_before > 1e-4, "test needs distinct critics to start");
524
525 let tau = 0.25_f64;
526 target.soft_update_from(&online, tau);
527 let target_after = all_params(&target);
528
529 assert_eq!(target_after.len(), target_before.len());
530 assert_eq!(target_after.len(), online_p.len());
531
532 let tau_f = tau as f32;
535 for (i, ((&onl, &old), &new)) in
536 online_p.iter().zip(&target_before).zip(&target_after).enumerate()
537 {
538 let expected = tau_f * onl + (1.0 - tau_f) * old;
539 assert!(
540 (new - expected).abs() < 1e-5,
541 "param {i}: Polyak blend mismatch new={new} expected={expected} \
542 (online={onl} old_target={old} tau={tau})"
543 );
544 }
545
546 let dist_after: f32 = online_p.iter().zip(&target_after).map(|(o, t)| (o - t).abs()).sum();
549 let expected_after = (1.0 - tau_f) * dist_before;
550 assert!(
551 (dist_after - expected_after).abs() < 1e-4,
552 "param distance to online should scale by (1-tau): \
553 before={dist_before} after={dist_after} expected={expected_after}"
554 );
555
556 assert!(
558 target_before.iter().zip(&target_after).any(|(a, b)| (a - b).abs() > 1e-6),
559 "soft update with tau>0 must change the target"
560 );
561 }
562}