thrust_rl/policy/atari_cnn.rs
1//! Burn-backend Nature-DQN-scale CNN policy for the Atari (ALE) workload
2//! (Epic #306, Phase 3 — issue #327).
3//!
4//! Implements the classic Nature-DQN convolutional stack (Mnih et al.,
5//! *Human-level control through deep reinforcement learning*, 2015) as two
6//! Burn modules that share the same conv trunk:
7//!
8//! - [`crate::policy::atari_cnn::NatureDqnBurnPolicy`] — actor-critic variant
9//! (policy + value heads), consumed by
10//! [`crate::train::ppo::trainer::PPOTrainerBurn`].
11//! - [`crate::policy::atari_cnn::NatureDqnQNetwork`] — single-Q-head variant,
12//! consumed by [`crate::train::dqn::DQNTrainerBurn`] (with a
13//! `copy_params_from` target-net sync, mirroring
14//! [`crate::policy::q_network::QNetworkBurn`]).
15//!
16//! # Architecture
17//!
18//! ```text
19//! obs [B, 4, 84, 84]
20//! → conv1 (32 ch, 8x8, stride 4) → ReLU → [B, 32, 20, 20]
21//! → conv2 (64 ch, 4x4, stride 2) → ReLU → [B, 64, 9, 9]
22//! → conv3 (64 ch, 3x3, stride 1) → ReLU → [B, 64, 7, 7]
23//! → flatten (64*7*7 = 3136)
24//! → fc_common (3136 -> 512) → ReLU
25//! → heads:
26//! actor-critic: policy_head (512 -> A) [logits], value_head (512 -> 1)
27//! q-network: q_head (512 -> A) [Q(s, a)]
28//! ```
29//!
30//! Convolutions use Burn's default `PaddingConfig2d::Valid` (no padding),
31//! matching the Nature-DQN spec; the spatial reductions are therefore
32//! `84 → 20 → 9 → 7`, giving a cached `flat_size` of `64 * 7 * 7 = 3136`.
33//!
34//! # Input contract
35//!
36//! - Layout: **NCHW** `[batch, channels, height, width]` — same convention as
37//! [`crate::policy::snake_cnn::SnakeCnnBurnPolicy`] and Burn's `Conv2d`.
38//! - Channels: 4 (frame-stack dimension, produced by the preprocessor — not
39//! this module).
40//! - Spatial: 84 × 84.
41//! - Dtype: `f32`, pixel-scaled to **0.0–1.0** (uint8 ÷ 255). The network is
42//! scale-agnostic, but this is the expected convention.
43//! - No batch-size constraint.
44//!
45//! # Trainer integration (closure-based, flat rollout buffers)
46//!
47//! Both Burn trainers are closure-based, not trait-based; the only module
48//! bounds are `AutodiffModule<B> + Clone`, satisfied automatically by
49//! `#[derive(Module, Debug)]`. The rollout buffers hand the closure a
50//! **flat** observation tensor `[B, C*H*W]`, so the closure must reshape to
51//! `[B, C, H, W]` before calling `forward`/`evaluate_actions` — the same
52//! pattern used by `examples/games/snake/train_snake_multi_v2.rs`
53//! (lines 239–253):
54//!
55//! ```ignore
56//! // PPO (actor-critic):
57//! let evaluate_fn = |p: &NatureDqnBurnPolicy<B>, o_flat: Tensor<B, 2>, acts: Tensor<B, 1, Int>| {
58//! let b = o_flat.dims()[0];
59//! let o4 = o_flat.reshape([b, 4, 84, 84]);
60//! p.evaluate_actions(o4, acts) // (log_probs [B], entropy [B], values [B])
61//! };
62//!
63//! // DQN (Q-network):
64//! let forward_fn = |q: &NatureDqnQNetwork<B>, o_flat: Tensor<B, 2>| {
65//! let b = o_flat.dims()[0];
66//! q.forward(o_flat.reshape([b, 4, 84, 84])) // Q-values [B, A]
67//! };
68//! ```
69//!
70//! # Seeded initialization
71//!
72//! Seeded construction (see [`crate::policy::atari_cnn::NatureDqnConfig`])
73//! drives the three FC
74//! layers from deterministically-derived host-side RNG streams via the
75//! shared `mlp.rs` helpers (`derive_layer_seed` / `seeded_layer_weights`
76//! / `linear_from_weights`), so two constructions with the same seed
77//! produce **bit-identical** FC weights. Fixed per-variant layer indices:
78//!
79//! - `NatureDqnBurnPolicy`: `0 = fc_common`, `1 = policy_head`, `2 =
80//! value_head`
81//! - `NatureDqnQNetwork`: `0 = fc_common`, `1 = q_head`
82//!
83//! **Conv layers are intentionally unseeded.** Burn's `Conv2dConfig` — like
84//! `LinearConfig` — exposes no seed parameter, so the seeded path cannot
85//! reach the convolutions. This is a deliberate, second-order concern: the
86//! conv parameters total ~78K versus ~1.6M for `fc_common` alone, so the FC
87//! layers dominate reproducibility. The seeded path therefore covers only
88//! the three FC layers; the unseeded (`seed: None`) path routes every layer
89//! through Burn's stock `Initializer`.
90
91use burn::{
92 module::Module,
93 nn::{
94 Initializer, Linear,
95 conv::{Conv2d, Conv2dConfig},
96 },
97 tensor::{Int, Tensor, activation, backend::Backend},
98};
99
100use super::mlp::{derive_layer_seed, linear_from_weights, linear_with_init, seeded_layer_weights};
101
102/// Number of input channels (frame-stack depth) the Nature-DQN policies
103/// expect. Fixed by the Atari preprocessor convention.
104const INPUT_CHANNELS: usize = 4;
105
106/// Flattened post-conv feature width: `64 * 7 * 7`. Cached on each module as
107/// a plain field so it survives `Module::load_record` (same trick as
108/// [`crate::policy::snake_cnn::SnakeCnnBurnPolicy`]).
109const FLAT_SIZE: usize = 64 * 7 * 7; // 3136
110
111/// Hidden width of the shared `fc_common` layer.
112const FC_HIDDEN: usize = 512;
113
114/// Configuration for the Nature-DQN policies.
115///
116/// Deliberately minimal (only a seed) — the conv/FC topology is fixed by the
117/// Nature-DQN spec, so unlike [`crate::policy::mlp::MlpBurnConfig`] there is
118/// nothing else to tune. Mirrors the `seed` reproducibility hook on
119/// [`crate::policy::q_network::QNetworkBurnConfig`].
120#[derive(Debug, Clone, Copy, Default)]
121pub struct NatureDqnConfig {
122 /// Optional construction seed. When `Some`, the three FC layers are
123 /// built from deterministically-derived host-side RNG streams (see
124 /// [`crate::policy::seeded_init`]) so two constructions with the same
125 /// seed produce **bit-identical** FC weights. When `None` (the default)
126 /// Burn's unseedable [`Initializer`] path is used verbatim. Conv layers
127 /// are unseeded in either case (see the module-level docs).
128 pub seed: Option<u64>,
129}
130
131impl NatureDqnConfig {
132 /// Set the construction seed, enabling the deterministic host-side FC
133 /// init path in `with_config`.
134 ///
135 /// ```
136 /// # use thrust_rl::policy::atari_cnn::NatureDqnConfig;
137 /// let cfg = NatureDqnConfig::default().with_seed(42);
138 /// assert_eq!(cfg.seed, Some(42));
139 /// ```
140 pub fn with_seed(mut self, seed: u64) -> Self {
141 self.seed = Some(seed);
142 self
143 }
144}
145
146/// Build the three shared conv layers (unseeded — Burn `Conv2dConfig` has no
147/// seed parameter). Kernels/strides fixed by the Nature-DQN spec; default
148/// `PaddingConfig2d::Valid` (no padding).
149fn build_convs<B: Backend>(device: &B::Device) -> (Conv2d<B>, Conv2d<B>, Conv2d<B>) {
150 let conv1 = Conv2dConfig::new([INPUT_CHANNELS, 32], [8, 8]).with_stride([4, 4]).init(device);
151 let conv2 = Conv2dConfig::new([32, 64], [4, 4]).with_stride([2, 2]).init(device);
152 let conv3 = Conv2dConfig::new([64, 64], [3, 3]).with_stride([1, 1]).init(device);
153 (conv1, conv2, conv3)
154}
155
156/// Default Kaiming-uniform initializer for the unseeded FC path — matches
157/// Burn's stock `LinearConfig::default()` weight init (and the convention
158/// used by [`crate::policy::snake_cnn::SnakeCnnBurnPolicy`]).
159fn default_fc_init() -> Initializer {
160 Initializer::KaimingUniform { gain: 1.0_f64 / 3.0_f64.sqrt(), fan_out_only: false }
161}
162
163/// Run the shared conv trunk and flatten to `[B, FLAT_SIZE]`.
164fn conv_features<B: Backend>(
165 conv1: &Conv2d<B>,
166 conv2: &Conv2d<B>,
167 conv3: &Conv2d<B>,
168 flat_size: usize,
169 obs: Tensor<B, 4>,
170) -> Tensor<B, 2> {
171 let x = activation::relu(conv1.forward(obs));
172 let x = activation::relu(conv2.forward(x));
173 let x = activation::relu(conv3.forward(x));
174 let batch = x.dims()[0];
175 x.reshape([batch, flat_size])
176}
177
178/// Nature-DQN-scale actor-critic CNN policy on Burn.
179///
180/// Conv trunk (`conv1/conv2/conv3`) → `fc_common` → `{policy_head,
181/// value_head}`. See the [module docs](self) for the full architecture and I/O
182/// contract.
183#[derive(Module, Debug)]
184pub struct NatureDqnBurnPolicy<B: Backend> {
185 conv1: Conv2d<B>,
186 conv2: Conv2d<B>,
187 conv3: Conv2d<B>,
188 fc_common: Linear<B>,
189 policy_head: Linear<B>,
190 value_head: Linear<B>,
191 /// Cached `64 * 7 * 7 = 3136`. Stored as a plain field so it lands in the
192 /// `Record` and survives `Module::load_record`.
193 flat_size: usize,
194}
195
196impl<B: Backend> NatureDqnBurnPolicy<B> {
197 /// Construct a fresh actor-critic policy with unseeded (Burn-default)
198 /// weight initialization.
199 ///
200 /// * `n_actions` — size of the discrete action space (policy-head width).
201 /// * `device` — Burn backend device.
202 pub fn new(n_actions: usize, device: &B::Device) -> Self {
203 Self::with_config(n_actions, NatureDqnConfig::default(), device)
204 }
205
206 /// Construct a fresh actor-critic policy with the given configuration.
207 ///
208 /// When `config.seed` is `Some`, the three FC layers are built from
209 /// deterministically-derived host-side RNG streams (bit-exact across
210 /// constructions with the same seed). Conv layers are always unseeded.
211 pub fn with_config(n_actions: usize, config: NatureDqnConfig, device: &B::Device) -> Self {
212 let (conv1, conv2, conv3) = build_convs::<B>(device);
213
214 let (fc_common, policy_head, value_head) = if let Some(base_seed) = config.seed {
215 // Seeded host-side FC init. Fixed layer indices:
216 // 0 = fc_common, 1 = policy_head, 2 = value_head.
217 let mut layer_idx = 0u64;
218 let mut next = || {
219 let s = derive_layer_seed(base_seed, layer_idx);
220 layer_idx += 1;
221 s
222 };
223
224 let wc = seeded_layer_weights(next(), FLAT_SIZE, FC_HIDDEN, false, false);
225 let fc_common = linear_from_weights::<B>(FLAT_SIZE, FC_HIDDEN, &wc, device);
226
227 let wp = seeded_layer_weights(next(), FC_HIDDEN, n_actions, false, true);
228 let policy_head = linear_from_weights::<B>(FC_HIDDEN, n_actions, &wp, device);
229
230 let wv = seeded_layer_weights(next(), FC_HIDDEN, 1, false, true);
231 let value_head = linear_from_weights::<B>(FC_HIDDEN, 1, &wv, device);
232
233 (fc_common, policy_head, value_head)
234 } else {
235 let init = default_fc_init();
236 let fc_common = linear_with_init::<B>(FLAT_SIZE, FC_HIDDEN, init.clone(), device);
237 let policy_head = linear_with_init::<B>(FC_HIDDEN, n_actions, init.clone(), device);
238 let value_head = linear_with_init::<B>(FC_HIDDEN, 1, init, device);
239 (fc_common, policy_head, value_head)
240 };
241
242 Self { conv1, conv2, conv3, fc_common, policy_head, value_head, flat_size: FLAT_SIZE }
243 }
244
245 /// Forward pass.
246 ///
247 /// * `obs` shape `[batch, 4, 84, 84]` (NCHW, pixels in `0.0..=1.0`).
248 /// * Returns `(action_logits [batch, n_actions], values [batch, 1])` —
249 /// value retains the rank-2 layout used by
250 /// [`crate::policy::snake_cnn::SnakeCnnBurnPolicy`], so the trainer
251 /// closure squeezes it itself.
252 pub fn forward(&self, obs: Tensor<B, 4>) -> (Tensor<B, 2>, Tensor<B, 2>) {
253 let flat = conv_features(&self.conv1, &self.conv2, &self.conv3, self.flat_size, obs);
254 let features = activation::relu(self.fc_common.forward(flat));
255 let logits = self.policy_head.forward(features.clone());
256 let values = self.value_head.forward(features);
257 (logits, values)
258 }
259
260 /// PPO-facing evaluation, mirroring
261 /// [`crate::policy::mlp::MlpBurnPolicy::evaluate_actions`].
262 ///
263 /// * `obs` shape `[batch, 4, 84, 84]`; `actions` shape `[batch]`.
264 /// * Returns `(action_log_probs [batch], entropy [batch], values [batch])`
265 /// — all rank-1. The value head's rank-2 `[batch, 1]` output is squeezed
266 /// here.
267 pub fn evaluate_actions(
268 &self,
269 obs: Tensor<B, 4>,
270 actions: Tensor<B, 1, Int>,
271 ) -> (Tensor<B, 1>, Tensor<B, 1>, Tensor<B, 1>) {
272 let (logits, values) = self.forward(obs);
273 let log_probs = activation::log_softmax(logits, 1);
274 let probs = log_probs.clone().exp();
275
276 let action_log_probs =
277 log_probs.clone().gather(1, actions.unsqueeze_dim::<2>(1)).squeeze_dim::<1>(1);
278 // H = -Σ p * log p over the action axis.
279 let entropy = -(probs * log_probs).sum_dim(1).squeeze_dim::<1>(1);
280 let values = values.squeeze_dim::<1>(1);
281
282 (action_log_probs, entropy, values)
283 }
284}
285
286/// Nature-DQN-scale Q-network CNN on Burn.
287///
288/// Same conv trunk as [`NatureDqnBurnPolicy`], but with a single `q_head`
289/// whose outputs are interpreted directly as `Q(s, a)` (no softmax). Includes
290/// a record-based `copy_params_from` for target-net sync, mirroring
291/// [`crate::policy::q_network::QNetworkBurn`].
292#[derive(Module, Debug)]
293pub struct NatureDqnQNetwork<B: Backend> {
294 conv1: Conv2d<B>,
295 conv2: Conv2d<B>,
296 conv3: Conv2d<B>,
297 fc_common: Linear<B>,
298 q_head: Linear<B>,
299 /// Cached `64 * 7 * 7 = 3136`; see `NatureDqnBurnPolicy`'s `flat_size`.
300 flat_size: usize,
301}
302
303impl<B: Backend> NatureDqnQNetwork<B> {
304 /// Construct a fresh Q-network with unseeded (Burn-default) init.
305 pub fn new(n_actions: usize, device: &B::Device) -> Self {
306 Self::with_config(n_actions, NatureDqnConfig::default(), device)
307 }
308
309 /// Construct a fresh Q-network with the given configuration.
310 ///
311 /// When `config.seed` is `Some`, the two FC layers (`0 = fc_common`,
312 /// `1 = q_head`) are built from deterministically-derived host-side RNG
313 /// streams. Conv layers are always unseeded.
314 pub fn with_config(n_actions: usize, config: NatureDqnConfig, device: &B::Device) -> Self {
315 let (conv1, conv2, conv3) = build_convs::<B>(device);
316
317 let (fc_common, q_head) = if let Some(base_seed) = config.seed {
318 // Seeded host-side FC init. Fixed layer indices:
319 // 0 = fc_common, 1 = q_head.
320 let mut layer_idx = 0u64;
321 let mut next = || {
322 let s = derive_layer_seed(base_seed, layer_idx);
323 layer_idx += 1;
324 s
325 };
326
327 let wc = seeded_layer_weights(next(), FLAT_SIZE, FC_HIDDEN, false, false);
328 let fc_common = linear_from_weights::<B>(FLAT_SIZE, FC_HIDDEN, &wc, device);
329
330 let wq = seeded_layer_weights(next(), FC_HIDDEN, n_actions, false, true);
331 let q_head = linear_from_weights::<B>(FC_HIDDEN, n_actions, &wq, device);
332
333 (fc_common, q_head)
334 } else {
335 let init = default_fc_init();
336 let fc_common = linear_with_init::<B>(FLAT_SIZE, FC_HIDDEN, init.clone(), device);
337 let q_head = linear_with_init::<B>(FC_HIDDEN, n_actions, init, device);
338 (fc_common, q_head)
339 };
340
341 Self { conv1, conv2, conv3, fc_common, q_head, flat_size: FLAT_SIZE }
342 }
343
344 /// Forward pass: compute `Q(s, a)` for every action `a`.
345 ///
346 /// * `obs` shape `[batch, 4, 84, 84]` (NCHW, pixels in `0.0..=1.0`).
347 /// * Returns Q-values of shape `[batch, n_actions]`.
348 pub fn forward(&self, obs: Tensor<B, 4>) -> Tensor<B, 2> {
349 let flat = conv_features(&self.conv1, &self.conv2, &self.conv3, self.flat_size, obs);
350 let features = activation::relu(self.fc_common.forward(flat));
351 self.q_head.forward(features)
352 }
353
354 /// Replace this network's parameters with a deep copy of `source`'s
355 /// parameters (target-net sync). Returns a new module, mirroring
356 /// [`crate::policy::q_network::QNetworkBurn::copy_params_from`].
357 pub fn copy_params_from(self, source: &NatureDqnQNetwork<B>) -> NatureDqnQNetwork<B> {
358 self.load_record(source.clone().into_record())
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use burn::{
365 backend::{Autodiff, NdArray},
366 module::Module,
367 };
368
369 use super::*;
370
371 type B = Autodiff<NdArray<f32>>;
372
373 /// Sum the element counts of every `Linear`/`Conv2d` weight and bias in a
374 /// module, using Burn's `num_params` (which counts exactly the learnable
375 /// parameter tensors). `flat_size` is a plain `usize` field, not a
376 /// `Param`, so it is correctly excluded.
377 fn count_params<M: Module<B>>(module: &M) -> usize {
378 module.num_params()
379 }
380
381 #[test]
382 fn test_nature_dqn_ac_forward_single() {
383 let device = Default::default();
384 let policy = NatureDqnBurnPolicy::<B>::new(4, &device);
385 let obs = Tensor::<B, 4>::zeros([1, 4, 84, 84], &device);
386 let (logits, values) = policy.forward(obs);
387 assert_eq!(logits.dims(), [1, 4]);
388 assert_eq!(values.dims(), [1, 1]);
389 }
390
391 #[test]
392 fn test_nature_dqn_ac_forward_batch() {
393 let device = Default::default();
394 let policy = NatureDqnBurnPolicy::<B>::new(4, &device);
395 let obs = Tensor::<B, 4>::zeros([32, 4, 84, 84], &device);
396 let (logits, values) = policy.forward(obs);
397 assert_eq!(logits.dims(), [32, 4]);
398 assert_eq!(values.dims(), [32, 1]);
399 }
400
401 #[test]
402 fn test_nature_dqn_q_forward() {
403 let device = Default::default();
404 let q_net = NatureDqnQNetwork::<B>::new(4, &device);
405 let obs = Tensor::<B, 4>::zeros([1, 4, 84, 84], &device);
406 let q_values = q_net.forward(obs);
407 assert_eq!(q_values.dims(), [1, 4]);
408 }
409
410 #[test]
411 fn test_nature_dqn_evaluate_actions_shapes() {
412 let device = Default::default();
413 let policy = NatureDqnBurnPolicy::<B>::new(4, &device);
414 let obs = Tensor::<B, 4>::zeros([8, 4, 84, 84], &device);
415 let actions = Tensor::<B, 1, Int>::from_data(
416 burn::tensor::TensorData::new(vec![0i64, 1, 2, 3, 0, 1, 2, 3], [8]),
417 &device,
418 );
419 let (log_probs, entropy, values) = policy.evaluate_actions(obs, actions);
420 assert_eq!(log_probs.dims(), [8]);
421 assert_eq!(entropy.dims(), [8]);
422 assert_eq!(values.dims(), [8]);
423 }
424
425 /// Two seeded constructions with the same seed must yield bit-identical FC
426 /// weights (`fc_common`, `policy_head`, `value_head`); a different seed
427 /// must differ. Conv weights are unseeded and not compared.
428 #[test]
429 fn test_nature_dqn_seeded_fc_identical() {
430 let device = Default::default();
431 let cfg = NatureDqnConfig::default().with_seed(42);
432 let a = NatureDqnBurnPolicy::<B>::with_config(4, cfg, &device);
433 let b = NatureDqnBurnPolicy::<B>::with_config(4, cfg, &device);
434 let c = NatureDqnBurnPolicy::<B>::with_config(
435 4,
436 NatureDqnConfig::default().with_seed(43),
437 &device,
438 );
439
440 let fc_a: Vec<f32> = a.fc_common.weight.val().into_data().to_vec().unwrap();
441 let fc_b: Vec<f32> = b.fc_common.weight.val().into_data().to_vec().unwrap();
442 let fc_c: Vec<f32> = c.fc_common.weight.val().into_data().to_vec().unwrap();
443 assert_eq!(fc_a, fc_b, "same seed must yield identical fc_common weights");
444 assert!(fc_a != fc_c, "different seed must yield different fc_common weights");
445
446 let ph_a: Vec<f32> = a.policy_head.weight.val().into_data().to_vec().unwrap();
447 let ph_b: Vec<f32> = b.policy_head.weight.val().into_data().to_vec().unwrap();
448 assert_eq!(ph_a, ph_b, "same seed must yield identical policy_head weights");
449
450 let vh_a: Vec<f32> = a.value_head.weight.val().into_data().to_vec().unwrap();
451 let vh_b: Vec<f32> = b.value_head.weight.val().into_data().to_vec().unwrap();
452 assert_eq!(vh_a, vh_b, "same seed must yield identical value_head weights");
453 }
454
455 /// Distinct per-layer seeds (via `derive_layer_seed`) must decorrelate
456 /// layers of different shape: `fc_common` and `policy_head` share no
457 /// weight values by construction. (Shapes differ, so we compare the
458 /// leading overlap.)
459 #[test]
460 fn test_nature_dqn_seeded_layers_decorrelated() {
461 let device = Default::default();
462 let cfg = NatureDqnConfig::default().with_seed(7);
463 let policy = NatureDqnBurnPolicy::<B>::with_config(4, cfg, &device);
464
465 let fc: Vec<f32> = policy.fc_common.weight.val().into_data().to_vec().unwrap();
466 let ph: Vec<f32> = policy.policy_head.weight.val().into_data().to_vec().unwrap();
467 let n = ph.len().min(fc.len());
468 assert!(
469 fc[..n].iter().zip(&ph[..n]).any(|(x, y)| (x - y).abs() > 1e-9),
470 "fc_common and policy_head must not share weights within one seeded construction"
471 );
472 }
473
474 /// After `copy_params_from`, the target's forward output must match the
475 /// source's element-wise.
476 #[test]
477 fn test_nature_dqn_q_copy_params_from() {
478 let device = Default::default();
479 let source = NatureDqnQNetwork::<B>::with_config(
480 4,
481 NatureDqnConfig::default().with_seed(11),
482 &device,
483 );
484 let target = NatureDqnQNetwork::<B>::with_config(
485 4,
486 NatureDqnConfig::default().with_seed(99),
487 &device,
488 );
489
490 let obs = Tensor::<B, 4>::ones([2, 4, 84, 84], &device) * 0.5;
491
492 let q_source_before: Vec<f32> = source.forward(obs.clone()).into_data().to_vec().unwrap();
493 let q_target_before: Vec<f32> = target.forward(obs.clone()).into_data().to_vec().unwrap();
494 assert!(
495 q_source_before.iter().zip(&q_target_before).any(|(a, b)| (a - b).abs() > 1e-6),
496 "expected fresh nets to disagree before copy"
497 );
498
499 let target_copied = target.copy_params_from(&source);
500 let q_source_after: Vec<f32> = source.forward(obs.clone()).into_data().to_vec().unwrap();
501 let q_target_after: Vec<f32> = target_copied.forward(obs).into_data().to_vec().unwrap();
502 for (a, b) in q_source_after.iter().zip(&q_target_after) {
503 assert!(
504 (a - b).abs() < 1e-6,
505 "Q output mismatch after copy_params_from: source={a} target={b}"
506 );
507 }
508 }
509
510 #[test]
511 fn test_nature_dqn_param_count_ac() {
512 let device = Default::default();
513 let policy = NatureDqnBurnPolicy::<B>::new(4, &device);
514 assert_eq!(count_params(&policy), 1_686_693);
515 }
516
517 #[test]
518 fn test_nature_dqn_param_count_q() {
519 let device = Default::default();
520 let q_net = NatureDqnQNetwork::<B>::new(4, &device);
521 assert_eq!(count_params(&q_net), 1_686_180);
522 }
523}