Skip to main content

thrust_rl/policy/
q_network.rs

1//! Burn-backend Q-Network for DQN training (phase 4 of the Burn
2//! migration, #65).
3//!
4//! Sibling to [`crate::policy::q_network::QNetworkBurn`] (tch path). The two
5//! modules share the same 2-layer Tanh backbone as
6//! [`crate::policy::mlp::MlpBurnPolicy`] /
7//! [`crate::policy::mlp::MlpBurnPolicy`] with PPO-style orthogonal
8//! initialization (gain `sqrt(2)` on the trunk, `0.01` on the Q-head). Unlike
9//! the MLP policy this network has a single output head — its outputs are
10//! interpreted directly as `Q(s, a)` values (no softmax).
11//!
12//! # Architecture
13//!
14//! ```text
15//! Input [batch, obs_dim]
16//!     → fc1 → Tanh
17//!     → fc2 → Tanh
18//!     → q_head
19//!  Q-values [batch, n_actions]
20//! ```
21//!
22//! # Target-net sync
23//!
24//! The tch path's `VarStore::copy(&source)` is replaced by Burn's
25//! record-based clone:
26//!
27//! ```ignore
28//! let snapshot = online.clone();           // cheap — Burn Modules clone
29//! target = target.load_record(snapshot.into_record());
30//! ```
31//!
32//! This is exposed as
33//! [`crate::policy::q_network::QNetworkBurn::copy_params_from`]
34//! so the Burn DQN trainer (phase 5) can drop in the same
35//! `target.copy_params_from(&online)` call site shape the tch trainer uses.
36
37use burn::{
38    module::Module,
39    nn::{Initializer, Linear},
40    tensor::{Tensor, activation, backend::Backend},
41};
42
43use super::mlp::{derive_layer_seed, linear_from_weights, linear_with_init, seeded_layer_weights};
44
45/// Configuration for [`QNetworkBurn`] architecture.
46///
47/// Held as a separate type from
48/// [`crate::policy::mlp::MlpBurnConfig`] so that callers can
49/// independently tune the Q-network (e.g. wider hidden_dim for richer
50/// observation spaces) without dragging the policy module along.
51#[derive(Debug, Clone, Copy)]
52pub struct QNetworkBurnConfig {
53    /// Width of every hidden layer.
54    pub hidden_dim: usize,
55    /// If `true`, initialize hidden-layer weights with orthogonal
56    /// (gain `sqrt(2)`) and the Q-head with `gain = 0.01`. Set
57    /// `false` for Burn's stock Kaiming-uniform default.
58    pub use_orthogonal_init: bool,
59    /// Optional construction seed. When `Some`, every layer is built from a
60    /// deterministically-derived host-side RNG stream (see
61    /// [`crate::policy::seeded_init`]) so two constructions with the same seed
62    /// produce **bit-identical** networks. When `None` (the default) Burn's
63    /// unseedable [`Initializer`] path is used verbatim. This mirrors
64    /// [`crate::policy::continuous_q::ContinuousQNetworkConfig::seed`] so the
65    /// discrete and continuous Q-networks share the same reproducibility hook.
66    pub seed: Option<u64>,
67}
68
69impl Default for QNetworkBurnConfig {
70    fn default() -> Self {
71        Self { hidden_dim: 64, use_orthogonal_init: true, seed: None }
72    }
73}
74
75impl QNetworkBurnConfig {
76    /// Set the construction seed, enabling the deterministic host-side init
77    /// path in [`QNetworkBurn::with_config`].
78    ///
79    /// ```
80    /// # use thrust_rl::policy::q_network::QNetworkBurnConfig;
81    /// let cfg = QNetworkBurnConfig::default().with_seed(42);
82    /// assert_eq!(cfg.seed, Some(42));
83    /// ```
84    pub fn with_seed(mut self, seed: u64) -> Self {
85        self.seed = Some(seed);
86        self
87    }
88}
89
90/// Two-layer Tanh Q-network on Burn.
91#[derive(Module, Debug)]
92pub struct QNetworkBurn<B: Backend> {
93    fc1: Linear<B>,
94    fc2: Linear<B>,
95    q_head: Linear<B>,
96}
97
98impl<B: Backend> QNetworkBurn<B> {
99    /// Build a fresh Q-network with the default orthogonal-init config.
100    pub fn new(obs_dim: usize, n_actions: usize, hidden_dim: usize, device: &B::Device) -> Self {
101        Self::with_config(
102            obs_dim,
103            n_actions,
104            QNetworkBurnConfig { hidden_dim, ..Default::default() },
105            device,
106        )
107    }
108
109    /// Build a fresh Q-network whose weights are seeded for bit-exact
110    /// reproducibility. Two calls with the same `seed` (and shapes) produce
111    /// byte-identical networks; mirrors
112    /// [`crate::policy::continuous_q::ContinuousQNetwork`].
113    pub fn with_seed(
114        obs_dim: usize,
115        n_actions: usize,
116        hidden_dim: usize,
117        seed: u64,
118        device: &B::Device,
119    ) -> Self {
120        Self::with_config(
121            obs_dim,
122            n_actions,
123            QNetworkBurnConfig { hidden_dim, ..Default::default() }.with_seed(seed),
124            device,
125        )
126    }
127
128    /// Build a fresh Q-network with the given configuration.
129    pub fn with_config(
130        obs_dim: usize,
131        n_actions: usize,
132        config: QNetworkBurnConfig,
133        device: &B::Device,
134    ) -> Self {
135        let hidden = config.hidden_dim;
136
137        let (fc1, fc2, q_head) = if let Some(base_seed) = config.seed {
138            // Seeded host-side init: each layer pulls from a distinct,
139            // deterministically-derived RNG stream so equal-shaped layers
140            // don't collide. Mirrors `ContinuousQNetwork::with_config`.
141            let mut layer_idx = 0u64;
142            let mut next = || {
143                let s = derive_layer_seed(base_seed, layer_idx);
144                layer_idx += 1;
145                s
146            };
147
148            let w1 =
149                seeded_layer_weights(next(), obs_dim, hidden, config.use_orthogonal_init, false);
150            let fc1 = linear_from_weights::<B>(obs_dim, hidden, &w1, device);
151
152            let w2 =
153                seeded_layer_weights(next(), hidden, hidden, config.use_orthogonal_init, false);
154            let fc2 = linear_from_weights::<B>(hidden, hidden, &w2, device);
155
156            let wq =
157                seeded_layer_weights(next(), hidden, n_actions, config.use_orthogonal_init, true);
158            let q_head = linear_from_weights::<B>(hidden, n_actions, &wq, device);
159
160            (fc1, fc2, q_head)
161        } else {
162            // Unseeded: route through Burn's `Initializer` verbatim.
163            let hidden_init = if config.use_orthogonal_init {
164                Initializer::Orthogonal { gain: 2.0_f64.sqrt() }
165            } else {
166                Initializer::KaimingUniform { gain: 1.0_f64 / 3.0_f64.sqrt(), fan_out_only: false }
167            };
168            let output_init = if config.use_orthogonal_init {
169                Initializer::Orthogonal { gain: 0.01 }
170            } else {
171                Initializer::KaimingUniform { gain: 1.0_f64 / 3.0_f64.sqrt(), fan_out_only: false }
172            };
173
174            let fc1 = linear_with_init::<B>(obs_dim, hidden, hidden_init.clone(), device);
175            let fc2 = linear_with_init::<B>(hidden, hidden, hidden_init, device);
176            let q_head = linear_with_init::<B>(hidden, n_actions, output_init, device);
177
178            (fc1, fc2, q_head)
179        };
180
181        Self { fc1, fc2, q_head }
182    }
183
184    /// Forward pass: compute `Q(s, a)` for every action `a`.
185    ///
186    /// * `obs` shape `[batch, obs_dim]`.
187    /// * Returns Q-values of shape `[batch, n_actions]`.
188    pub fn forward(&self, obs: Tensor<B, 2>) -> Tensor<B, 2> {
189        let h = activation::tanh(self.fc1.forward(obs));
190        let h = activation::tanh(self.fc2.forward(h));
191        self.q_head.forward(h)
192    }
193
194    /// Replace this network's parameters with a deep copy of `source`'s
195    /// parameters.
196    ///
197    /// Returns a new module with the same architecture but the
198    /// source's records. Burn's `Optimizer` ownership model (`step`
199    /// consumes the module by value) means we return `Self` rather
200    /// than mutating `&mut self`; the DQN trainer holds the target
201    /// net in an `Option<Self>` and swaps it through this call.
202    pub fn copy_params_from(self, source: &QNetworkBurn<B>) -> QNetworkBurn<B>
203    where
204        B: Backend,
205    {
206        // Burn modules can clone their record cheaply (the record is a
207        // tree of `Param`s; each `Param` is cheap to clone since the
208        // underlying tensors are reference-counted on the autodiff
209        // path). `load_record` consumes the receiver and returns a new
210        // module with the source's parameters.
211        self.load_record(source.clone().into_record())
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use burn::backend::{Autodiff, NdArray};
218
219    use super::*;
220
221    type B = Autodiff<NdArray<f32>>;
222
223    #[test]
224    fn test_q_network_burn_creation() {
225        let device = Default::default();
226        let _q_net = QNetworkBurn::<B>::new(4, 2, 64, &device);
227    }
228
229    #[test]
230    fn test_q_network_burn_forward_shape() {
231        let device = Default::default();
232        let q_net = QNetworkBurn::<B>::new(4, 3, 32, &device);
233        let obs = Tensor::<B, 2>::zeros([8, 4], &device);
234        let q_values = q_net.forward(obs);
235        assert_eq!(q_values.dims(), [8, 3]);
236    }
237
238    /// Two seeded constructions with the same seed must yield bit-identical
239    /// Q-values, while different seeds must disagree. Mirrors
240    /// `ContinuousQNetwork`'s `seeded_construction_is_bit_exact`.
241    #[test]
242    fn seeded_construction_is_bit_exact() {
243        let device = Default::default();
244        let a = QNetworkBurn::<B>::with_seed(4, 2, 16, 7, &device);
245        let b = QNetworkBurn::<B>::with_seed(4, 2, 16, 7, &device);
246        let c = QNetworkBurn::<B>::with_seed(4, 2, 16, 8, &device);
247
248        let obs = Tensor::<B, 2>::from_data(
249            burn::tensor::TensorData::new(vec![0.1f32, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8], [2, 4]),
250            &device,
251        );
252        let qa: Vec<f32> = a.forward(obs.clone()).into_data().to_vec().unwrap();
253        let qb: Vec<f32> = b.forward(obs.clone()).into_data().to_vec().unwrap();
254        let qc: Vec<f32> = c.forward(obs).into_data().to_vec().unwrap();
255
256        assert_eq!(qa, qb, "same seed must yield bit-identical Q-networks");
257        assert!(
258            qa.iter().zip(&qc).any(|(x, y)| (x - y).abs() > 1e-6),
259            "different seeds should yield different Q-networks"
260        );
261    }
262
263    /// Mirrors `q_network::tests::test_copy_params_from_byte_equal`
264    /// from the tch path: after copying online → target, their forward
265    /// outputs must agree exactly.
266    #[test]
267    fn test_copy_params_from_matches_online() {
268        let device = Default::default();
269        let online = QNetworkBurn::<B>::with_config(
270            4,
271            2,
272            QNetworkBurnConfig { hidden_dim: 16, use_orthogonal_init: false, ..Default::default() },
273            &device,
274        );
275        let target = QNetworkBurn::<B>::with_config(
276            4,
277            2,
278            QNetworkBurnConfig { hidden_dim: 16, use_orthogonal_init: false, ..Default::default() },
279            &device,
280        );
281
282        // Build a simple synthetic batch.
283        let obs = Tensor::<B, 2>::from_data(
284            burn::tensor::TensorData::new(vec![0.1f32, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8], [2, 4]),
285            &device,
286        );
287
288        // Sanity check: fresh nets should disagree (different orthogonal
289        // draws). We compare via host floats since we don't have a
290        // direct |a - b| reduction here.
291        let q_online_before: Vec<f32> = online.forward(obs.clone()).into_data().to_vec().unwrap();
292        let q_target_before: Vec<f32> = target.forward(obs.clone()).into_data().to_vec().unwrap();
293        let any_diff_before =
294            q_online_before.iter().zip(&q_target_before).any(|(a, b)| (a - b).abs() > 1e-6);
295        assert!(any_diff_before, "expected fresh nets to disagree before copy");
296
297        // Sync target ← online.
298        let online_for_recall = QNetworkBurn::<B>::with_config(
299            4,
300            2,
301            QNetworkBurnConfig { hidden_dim: 16, use_orthogonal_init: false, ..Default::default() },
302            &device,
303        );
304        // To compare, we want the sync to make `target` match `online`
305        // exactly. The Burn idiom returns a fresh module, which we
306        // re-bind:
307        let target_copied = target.copy_params_from(&online);
308        let q_online_after: Vec<f32> = online.forward(obs.clone()).into_data().to_vec().unwrap();
309        let q_target_after: Vec<f32> =
310            target_copied.forward(obs.clone()).into_data().to_vec().unwrap();
311        for (a, b) in q_online_after.iter().zip(&q_target_after) {
312            assert!(
313                (a - b).abs() < 1e-6,
314                "Q output mismatch after copy_params_from: online={a} target={b}"
315            );
316        }
317
318        // And a *fresh* `online_for_recall` (independent draws) should
319        // still disagree with the synced target — confirms we copied
320        // online's specific draws, not "any zero-init".
321        let q_fresh: Vec<f32> = online_for_recall.forward(obs).into_data().to_vec().unwrap();
322        let still_differs = q_fresh.iter().zip(&q_target_after).any(|(a, b)| (a - b).abs() > 1e-6);
323        assert!(still_differs, "synced target unexpectedly matched a *different* fresh net");
324    }
325}