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}