Skip to main content

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}