thrust_rl/train/dqn/trainer.rs
1//! Burn-backend DQN trainer (phase 3 of the Burn migration, #80).
2//!
3//! Sibling to [`crate::train::dqn::DQNTrainerBurn`] (tch path). Implements
4//! the same Double-DQN target / Smooth-L1 loss / gradient-step recipe
5//! but holds the Q-network as a Burn module that flows through the
6//! optimizer on every step.
7//!
8//! # Scope (phase 3)
9//!
10//! The Burn trainer:
11//!
12//! - Owns an online Q-network module `Q` and a target Q-network module of the
13//! same type. The trainer is generic over `Q: AutodiffModule<B> + Clone` so
14//! the actual network shape (CartPole MLP, Snake CNN, etc.) ships in phase 4.
15//! - Provides ε-greedy action selection through a caller-supplied `greedy_fn`
16//! (the network's forward + argmax). The trainer owns the ε schedule and the
17//! env-step counter — the same as the tch trainer's `select_action` API.
18//! - Performs the Smooth-L1 / Double-DQN training step inside
19//! [`DQNTrainerBurn::train_step`]. Caller-supplied closures hand off the
20//! forward pass — exactly the shape the tch trainer uses when `train_step` is
21//! called against an external loss closure.
22//! - Pushes transitions into a [`crate::buffer::replay::ReplayBuffer`] and
23//! samples minibatches the same way as the tch trainer.
24//! - Soft / Polyak target updates are folded into
25//! [`DQNTrainerBurn::maybe_sync_target`], which the caller invokes with a
26//! blend closure that applies the `tau` from config.
27//!
28//! # NOT in scope for phase 3
29//!
30//! - Prioritized replay is intentionally **not** ported in this phase — it
31//! would inflate the LOC delta past the 800-line budget called out on issue
32//! #80. The hooks are still there (`huber_per_sample` is exposed on the loss
33//! module) and phase 4 / 5 can add the IS-weighted path.
34//! - Hard / interval-based target sync is implemented as `clone()` of the
35//! online module into the target slot when the env-step counter hits the
36//! interval; the soft path uses Burn's module-tree mutation through the
37//! trainer-provided `soft_blend_fn`.
38//!
39//! The acceptance test for phase 3 is that `train_cartpole_dqn` can be
40//! ported to this trainer with no algorithmic divergence; the
41//! prioritized-replay diagnostics live with `crate::train::dqn` until
42//! phase 5 drops them.
43
44use anyhow::{Result, anyhow};
45use burn::{
46 module::{AutodiffModule, list_param_ids},
47 optim::{GradientsParams, Optimizer},
48 prelude::ToElement,
49 tensor::{Tensor, backend::AutodiffBackend},
50};
51use rand::Rng;
52
53use super::{
54 config::DQNConfig,
55 loss::{compute_loss, compute_td_target, compute_td_target_double, gather_action_q},
56};
57use crate::{
58 buffer::replay::{ReplayBuffer, sample},
59 train::optimizer::{BackendOptimizer, BurnOptimizer},
60};
61
62/// Per-step training statistics for the Burn DQN trainer.
63///
64/// Mirrors `crate::train::dqn::DQNStepStats` (tch path) field-for-field
65/// where the fields make sense for the Burn / non-prioritized path.
66/// Prioritized-replay fields (`beta`, `mean_abs_td_error`) are dropped
67/// because the Burn trainer does not yet implement prioritized replay
68/// (see module doc).
69#[derive(Debug, Clone, Copy)]
70pub struct DQNStepStatsBurn {
71 /// Mean Smooth-L1 (Huber) loss across the minibatch.
72 pub td_loss: f64,
73 /// Mean of `Q(s, a)` across the minibatch.
74 pub mean_q: f64,
75 /// ε used to draw the most recent action.
76 pub epsilon: f64,
77 /// Replay buffer fill level at the time of this update.
78 pub buffer_len: usize,
79}
80
81/// Burn-backend DQN trainer.
82///
83/// Generic over:
84/// - `B: AutodiffBackend`,
85/// - `Q: AutodiffModule<B> + Clone` — the Q-network module type (single
86/// backbone + action-dim head; ports in phase 4),
87/// - `O: Optimizer<Q, B>` — the Burn optimizer.
88pub struct DQNTrainerBurn<B, Q, O>
89where
90 B: AutodiffBackend,
91 Q: AutodiffModule<B> + Clone,
92 O: Optimizer<Q, B>,
93{
94 config: DQNConfig,
95 n_actions: i64,
96 online: Option<Q>,
97 target: Q,
98 optimizer: BurnOptimizer<B, Q, O>,
99 buffer: ReplayBuffer,
100 device: B::Device,
101 total_env_steps: usize,
102 total_train_steps: usize,
103 total_episodes: usize,
104 last_epsilon: f64,
105}
106
107impl<B, Q, O> DQNTrainerBurn<B, Q, O>
108where
109 B: AutodiffBackend,
110 Q: AutodiffModule<B> + Clone,
111 O: Optimizer<Q, B>,
112{
113 /// Build a new Burn DQN trainer.
114 ///
115 /// The caller supplies the online network already initialized; the
116 /// trainer clones it once to seed the target network so the two
117 /// start byte-equal (matching the tch trainer's
118 /// `target.copy_params_from(&online)` semantics).
119 pub fn new(
120 config: DQNConfig,
121 online: Q,
122 optimizer: BurnOptimizer<B, Q, O>,
123 obs_dim: usize,
124 n_actions: i64,
125 device: B::Device,
126 ) -> Result<Self> {
127 config.validate()?;
128 if config.prioritized_replay {
129 return Err(anyhow!(
130 "DQNTrainerBurn does not yet implement prioritized replay (phase 3 \
131 scope on issue #80). Use the tch DQNTrainer or wait for phase 5."
132 ));
133 }
134 let target = online.clone();
135 let buffer = ReplayBuffer::new(config.buffer_capacity, obs_dim);
136 let last_epsilon = config.epsilon_start;
137 Ok(Self {
138 config,
139 n_actions,
140 online: Some(online),
141 target,
142 optimizer,
143 buffer,
144 device,
145 total_env_steps: 0,
146 total_train_steps: 0,
147 total_episodes: 0,
148 last_epsilon,
149 })
150 }
151
152 /// Number of discrete actions in this trainer's action space.
153 pub fn n_actions(&self) -> i64 {
154 self.n_actions
155 }
156
157 /// Borrow the configuration.
158 pub fn config(&self) -> &DQNConfig {
159 &self.config
160 }
161
162 /// Borrow the online network. Panics if mid-step.
163 pub fn online(&self) -> &Q {
164 self.online.as_ref().expect("online network is None mid-step")
165 }
166
167 /// Borrow the target network.
168 pub fn target(&self) -> &Q {
169 &self.target
170 }
171
172 /// Borrow the replay buffer.
173 pub fn buffer(&self) -> &ReplayBuffer {
174 &self.buffer
175 }
176
177 /// Mutably borrow the replay buffer.
178 pub fn buffer_mut(&mut self) -> &mut ReplayBuffer {
179 &mut self.buffer
180 }
181
182 /// Number of transitions currently in the buffer.
183 pub fn buffer_len(&self) -> usize {
184 self.buffer.len()
185 }
186
187 /// Caller invokes this once per environment step.
188 pub fn increment_env_step(&mut self) {
189 self.total_env_steps += 1;
190 }
191
192 /// Caller invokes this when an episode terminates / truncates.
193 pub fn increment_episodes(&mut self, n: usize) {
194 self.total_episodes += n;
195 }
196
197 /// Current env-step counter.
198 pub fn total_env_steps(&self) -> usize {
199 self.total_env_steps
200 }
201
202 /// Number of completed gradient updates.
203 pub fn total_train_steps(&self) -> usize {
204 self.total_train_steps
205 }
206
207 /// Number of completed episodes.
208 pub fn total_episodes(&self) -> usize {
209 self.total_episodes
210 }
211
212 /// ε used to draw the most recent action.
213 pub fn last_epsilon(&self) -> f64 {
214 self.last_epsilon
215 }
216
217 /// ε-greedy action selection.
218 ///
219 /// Computes the current ε using the config's
220 /// [`DQNConfig::epsilon_at`] schedule, then either picks a uniform
221 /// random action with probability ε or invokes `greedy_fn` for the
222 /// argmax-Q action.
223 pub fn select_action<R: Rng, F>(&mut self, obs: &[f32], rng: &mut R, greedy_fn: F) -> i64
224 where
225 F: FnOnce(&Q, &[f32]) -> i64,
226 {
227 let eps = self.config.epsilon_at(self.total_env_steps);
228 self.last_epsilon = eps;
229 if rng.random::<f64>() < eps {
230 rng.random_range(0..self.n_actions)
231 } else {
232 greedy_fn(self.online(), obs)
233 }
234 }
235
236 /// Sync the target network from the online network.
237 ///
238 /// Two modes:
239 /// 1. **Hard sync** (default): clones online → target every
240 /// `target_update_interval` env steps.
241 /// 2. **Soft / Polyak** (`config.soft_update_tau = Some(τ)`): the
242 /// caller-supplied `blend_fn` is invoked every step. The caller is
243 /// responsible for implementing the per-parameter blend `θ_target ← τ ·
244 /// θ_online + (1 − τ) · θ_target`. Burn 0.21 does not expose a uniform
245 /// `map_params` API, so the blend is parameterized over the concrete
246 /// module type by the caller.
247 pub fn maybe_sync_target<F>(&mut self, blend_fn: F) -> bool
248 where
249 F: FnOnce(&Q, Q, f64) -> Q,
250 {
251 match self.config.soft_update_tau {
252 Some(tau) => {
253 let online = self.online().clone();
254 let target = std::mem::replace(&mut self.target, online.clone());
255 self.target = blend_fn(&online, target, tau);
256 true
257 }
258 None => {
259 if self.total_env_steps > 0
260 && self.total_env_steps.is_multiple_of(self.config.target_update_interval)
261 {
262 self.target = self.online().clone();
263 true
264 } else {
265 false
266 }
267 }
268 }
269 }
270
271 /// Sample a minibatch and run one gradient step against the
272 /// Double-DQN TD target.
273 ///
274 /// Caller supplies two closures:
275 /// - `forward_fn(&Q, obs)` — forward pass returning the `[batch,
276 /// n_actions]` Q-values, with grad bearing iff the module is the online
277 /// network.
278 /// - `forward_target_fn(&Q, obs)` — forward pass returning the `[batch,
279 /// n_actions]` target-net Q-values. The trainer takes care of detaching
280 /// these for the TD target — caller doesn't need to manage `no_grad`.
281 ///
282 /// Returns `Ok(None)` if the buffer doesn't yet hold
283 /// `min_buffer_size` transitions.
284 pub fn train_step<R: Rng, FOnline, FTarget>(
285 &mut self,
286 rng: &mut R,
287 forward_fn: FOnline,
288 forward_target_fn: FTarget,
289 ) -> Result<Option<DQNStepStatsBurn>>
290 where
291 FOnline: Fn(&Q, Tensor<B, 2>) -> Tensor<B, 2>,
292 FTarget: Fn(&Q, Tensor<B, 2>) -> Tensor<B, 2>,
293 {
294 if !self.buffer.is_ready(self.config.min_buffer_size) {
295 return Ok(None);
296 }
297
298 let batch = sample(&self.buffer, self.config.batch_size, rng);
299 let buffer_len = self.buffer.len();
300
301 // Lift the replay batch into Burn tensors via the buffer's
302 // built-in `to_burn_tensors` helper (phase 2a, #79).
303 let t = batch.to_burn_tensors::<B>(&self.device);
304
305 let online = self
306 .online
307 .take()
308 .ok_or_else(|| anyhow!("online network is None; concurrent train_step calls?"))?;
309
310 let q_online_all = forward_fn(&online, t.observations);
311 let q_taken = gather_action_q(q_online_all.clone(), t.actions);
312
313 // Target / Double-DQN target — both forward passes use the
314 // online + target networks but the resulting target tensor is
315 // detached inside `compute_td_target_double`, so no gradient
316 // flows through them.
317 let next_q_online_all = forward_fn(&online, t.next_observations.clone());
318 let next_q_target_all = forward_target_fn(&self.target, t.next_observations);
319 let td_target = compute_td_target_double(
320 t.rewards,
321 t.dones,
322 next_q_online_all,
323 next_q_target_all,
324 self.config.gamma,
325 );
326
327 let td_loss = compute_loss(q_taken.clone(), td_target);
328 let td_loss_val: f64 = td_loss.clone().into_scalar().to_f64();
329 let mean_q_val: f64 = q_taken.mean().into_scalar().to_f64();
330
331 if !td_loss_val.is_finite() {
332 return Err(anyhow!("Non-finite TD loss: {}", td_loss_val));
333 }
334
335 // Burn optimizer step.
336 let grads = td_loss.backward();
337 let grads = GradientsParams::from_grads(grads, &online);
338 let lr = self.optimizer.learning_rate();
339 let online = self.optimizer.inner_mut().step(lr, online, grads);
340 self.online = Some(online);
341
342 self.total_train_steps += 1;
343
344 Ok(Some(DQNStepStatsBurn {
345 td_loss: td_loss_val,
346 mean_q: mean_q_val,
347 epsilon: self.last_epsilon,
348 buffer_len,
349 }))
350 }
351
352 /// Loss-scaled Double-DQN train step for reduced-precision (f16)
353 /// backends.
354 ///
355 /// This is an **additive** sibling of [`DQNTrainerBurn::train_step`]
356 /// used only by the opt-in `training-fp16` example
357 /// (`examples/games/atari/train_pong_dqn_fp16.rs`). The full-precision
358 /// `train_step` above is left bit-identical to its pre-fp16 behavior —
359 /// callers that do not opt into loss scaling keep the exact same code
360 /// path.
361 ///
362 /// # Why loss scaling exists
363 ///
364 /// f16 has only a ~5-bit exponent (min normal ≈ 6.1e-5). Gradients that
365 /// backpropagate through the Nature-DQN's conv stack routinely fall below
366 /// that floor and underflow to zero, stalling learning. Multiplying the
367 /// loss by `loss_scale` before `.backward()` shifts every gradient up by
368 /// the same factor into f16's representable range; dividing the gradients
369 /// back down by `loss_scale` before the optimizer step recovers the true
370 /// update. bf16 (full f32 exponent range) would not need this, but bf16
371 /// matmul is unavailable on the wgpu/Metal runtime in Burn 0.21 (see #305)
372 /// — CUDA f16 is the verified path.
373 ///
374 /// # Overflow handling
375 ///
376 /// The caller owns the *dynamic* scale schedule (halve on overflow, grow
377 /// after a clean streak). This method reports back whether the step was
378 /// numerically clean so the caller can adjust:
379 ///
380 /// - Returns `Ok(None)` if the buffer has not reached `min_buffer_size`.
381 /// - Returns `Ok(Some((stats, applied)))` otherwise. `applied == false`
382 /// means the step **overflowed** and was skipped (the online network is
383 /// left unchanged); the caller should **halve** the scale and retry.
384 ///
385 /// # How overflow is detected (the important subtlety)
386 ///
387 /// The overflow that matters happens *inside the scaled backward pass*, not
388 /// in the raw loss. With an f16 backend the loss tensor is itself f16
389 /// (max ≈ 65504), so `loss × loss_scale` **overflows to ±inf on the
390 /// device** once the scale is large — and that inf then poisons the
391 /// gradients. A finiteness check on the *unscaled* loss would miss this
392 /// entirely (the unscaled loss is a small finite number). We therefore
393 /// compute the **scaled** loss on-device and read *its* host scalar: if
394 /// `loss × scale` is non-finite, the backward pass would produce
395 /// non-finite gradients, so we skip. We also guard the unscaled loss
396 /// for a genuine NaN in the forward pass. This is the cheapest reliable
397 /// overflow proxy — Burn 0.21 exposes no per-gradient finiteness API.
398 ///
399 /// `stats.td_loss` is always the **unscaled** loss (comparable to the f32
400 /// path's `td_loss`), regardless of `loss_scale`.
401 pub fn train_step_scaled<R: Rng, FOnline, FTarget>(
402 &mut self,
403 rng: &mut R,
404 loss_scale: f64,
405 forward_fn: FOnline,
406 forward_target_fn: FTarget,
407 ) -> Result<Option<(DQNStepStatsBurn, bool)>>
408 where
409 FOnline: Fn(&Q, Tensor<B, 2>) -> Tensor<B, 2>,
410 FTarget: Fn(&Q, Tensor<B, 2>) -> Tensor<B, 2>,
411 {
412 if !self.buffer.is_ready(self.config.min_buffer_size) {
413 return Ok(None);
414 }
415
416 let batch = sample(&self.buffer, self.config.batch_size, rng);
417 let buffer_len = self.buffer.len();
418
419 let t = batch.to_burn_tensors::<B>(&self.device);
420
421 let online = self
422 .online
423 .take()
424 .ok_or_else(|| anyhow!("online network is None; concurrent train_step calls?"))?;
425
426 let q_online_all = forward_fn(&online, t.observations);
427 let q_taken = gather_action_q(q_online_all.clone(), t.actions);
428
429 let next_q_online_all = forward_fn(&online, t.next_observations.clone());
430 let next_q_target_all = forward_target_fn(&self.target, t.next_observations);
431 let td_target = compute_td_target_double(
432 t.rewards,
433 t.dones,
434 next_q_online_all,
435 next_q_target_all,
436 self.config.gamma,
437 );
438
439 let td_loss = compute_loss(q_taken.clone(), td_target);
440
441 // Scale the loss *on-device* first, then read back both the unscaled
442 // and scaled host scalars. `scaled_loss` overflows f16 to ±inf exactly
443 // when `loss × scale` exceeds the f16 max — this is the overflow we
444 // must catch before the backward pass poisons the gradients.
445 let scaled_loss = td_loss.clone().mul_scalar(loss_scale as f32);
446 let td_loss_val: f64 = td_loss.into_scalar().to_f64();
447 let scaled_loss_val: f64 = scaled_loss.clone().into_scalar().to_f64();
448 let mean_q_val: f64 = q_taken.mean().into_scalar().to_f64();
449
450 let stats = DQNStepStatsBurn {
451 td_loss: td_loss_val,
452 mean_q: mean_q_val,
453 epsilon: self.last_epsilon,
454 buffer_len,
455 };
456
457 // Overflow / non-finite guard: skip the optimizer step and leave the
458 // network untouched so the caller can shrink the scale and retry. We
459 // require BOTH the unscaled loss (catches forward-pass NaN) and the
460 // scaled loss (catches the `loss × scale` f16 overflow) to be finite.
461 if !td_loss_val.is_finite() || !scaled_loss_val.is_finite() {
462 // Restore the online network we `take()`-en above (unmodified).
463 self.online = Some(online);
464 return Ok(Some((stats, false)));
465 }
466
467 // Backward on the scaled loss so f16 gradients stay above the underflow
468 // floor, then unscale the gradients before the optimizer consumes them.
469 let grads = scaled_loss.backward();
470 let grads = GradientsParams::from_grads(grads, &online);
471 let grads = unscale_grads::<B, Q>(grads, &online, loss_scale);
472
473 let lr = self.optimizer.learning_rate();
474 let online = self.optimizer.inner_mut().step(lr, online, grads);
475 self.online = Some(online);
476
477 self.total_train_steps += 1;
478
479 Ok(Some((stats, true)))
480 }
481
482 /// Vanilla-DQN train step (uses [`compute_td_target`] instead of
483 /// the Double-DQN target). Exposed for completeness; the default
484 /// `train_step` uses Double-DQN to match the tch trainer's default.
485 pub fn train_step_vanilla<R: Rng, FOnline, FTarget>(
486 &mut self,
487 rng: &mut R,
488 forward_fn: FOnline,
489 forward_target_fn: FTarget,
490 ) -> Result<Option<DQNStepStatsBurn>>
491 where
492 FOnline: Fn(&Q, Tensor<B, 2>) -> Tensor<B, 2>,
493 FTarget: Fn(&Q, Tensor<B, 2>) -> Tensor<B, 2>,
494 {
495 if !self.buffer.is_ready(self.config.min_buffer_size) {
496 return Ok(None);
497 }
498
499 let batch = sample(&self.buffer, self.config.batch_size, rng);
500 let buffer_len = self.buffer.len();
501
502 let t = batch.to_burn_tensors::<B>(&self.device);
503
504 let online = self
505 .online
506 .take()
507 .ok_or_else(|| anyhow!("online network is None; concurrent train_step calls?"))?;
508
509 let q_online_all = forward_fn(&online, t.observations);
510 let q_taken = gather_action_q(q_online_all.clone(), t.actions);
511 let next_q_target_all = forward_target_fn(&self.target, t.next_observations);
512 let td_target = compute_td_target(t.rewards, t.dones, next_q_target_all, self.config.gamma);
513
514 let td_loss = compute_loss(q_taken.clone(), td_target);
515 let td_loss_val: f64 = td_loss.clone().into_scalar().to_f64();
516 let mean_q_val: f64 = q_taken.mean().into_scalar().to_f64();
517
518 if !td_loss_val.is_finite() {
519 return Err(anyhow!("Non-finite TD loss: {}", td_loss_val));
520 }
521
522 let grads = td_loss.backward();
523 let grads = GradientsParams::from_grads(grads, &online);
524 let lr = self.optimizer.learning_rate();
525 let online = self.optimizer.inner_mut().step(lr, online, grads);
526 self.online = Some(online);
527
528 self.total_train_steps += 1;
529
530 Ok(Some(DQNStepStatsBurn {
531 td_loss: td_loss_val,
532 mean_q: mean_q_val,
533 epsilon: self.last_epsilon,
534 buffer_len,
535 }))
536 }
537}
538
539/// Divide every gradient tensor in `grads` by `loss_scale`, recovering the
540/// true (unscaled) gradient after a loss-scaled backward pass.
541///
542/// Burn 0.21's [`GradientsParams`] is keyed by `ParamId` and its tensors are
543/// stored dimension-erased, so the unscale walks the module's parameter ids
544/// (via [`list_param_ids`]) and dispatches on the three tensor ranks the
545/// Nature-DQN uses: rank-1 (biases), rank-2 (`Linear` weights), and rank-4
546/// (`Conv2d` weights). Ids whose gradient is absent (e.g. a frozen param) or of
547/// an unexpected rank are passed through untouched. Uses the *inner*
548/// (non-autodiff) backend `B::InnerBackend`, since gradients are inner tensors.
549fn unscale_grads<B, Q>(mut grads: GradientsParams, module: &Q, loss_scale: f64) -> GradientsParams
550where
551 B: AutodiffBackend,
552 Q: AutodiffModule<B> + Clone,
553{
554 type Inner<B> = <B as AutodiffBackend>::InnerBackend;
555 let inv = (1.0 / loss_scale) as f32;
556 for id in list_param_ids(module) {
557 if let Some(g) = grads.remove::<Inner<B>, 1>(id) {
558 grads.register::<Inner<B>, 1>(id, g.mul_scalar(inv));
559 } else if let Some(g) = grads.remove::<Inner<B>, 2>(id) {
560 grads.register::<Inner<B>, 2>(id, g.mul_scalar(inv));
561 } else if let Some(g) = grads.remove::<Inner<B>, 4>(id) {
562 grads.register::<Inner<B>, 4>(id, g.mul_scalar(inv));
563 }
564 }
565 grads
566}
567
568#[cfg(test)]
569mod tests {
570 use burn::{
571 backend::{Autodiff, NdArray},
572 optim::AdamConfig,
573 };
574 use rand::SeedableRng;
575
576 use super::*;
577 use crate::{policy::mlp::MlpBurnPolicy, train::optimizer::BurnOptimizer};
578
579 type B = Autodiff<NdArray<f32>>;
580
581 fn small_config() -> DQNConfig {
582 DQNConfig::new()
583 .buffer_capacity(128)
584 .min_buffer_size(8)
585 .batch_size(8)
586 .target_update_interval(4)
587 .epsilon_decay_steps(100)
588 }
589
590 /// Smoke test: a Burn DQN trainer constructs without error using
591 /// `MlpBurnPolicy` as a Q-network stand-in (phase 4 ports the
592 /// proper `QNetworkBurn`).
593 #[test]
594 fn dqn_trainer_burn_constructs() {
595 let device = Default::default();
596 let online = MlpBurnPolicy::<B>::new(4, 2, 16, &device);
597 let inner_opt = AdamConfig::new().init();
598 let burn_opt = BurnOptimizer::new(inner_opt, small_config().learning_rate);
599 let trainer = DQNTrainerBurn::new(small_config(), online, burn_opt, 4, 2, device).unwrap();
600 assert_eq!(trainer.total_env_steps(), 0);
601 assert_eq!(trainer.buffer_len(), 0);
602 }
603
604 /// Prioritized replay is intentionally not yet supported on the
605 /// Burn path.
606 #[test]
607 fn dqn_trainer_burn_rejects_prioritized_config() {
608 let device = Default::default();
609 let online = MlpBurnPolicy::<B>::new(4, 2, 16, &device);
610 let inner_opt = AdamConfig::new().init();
611 let burn_opt = BurnOptimizer::new(inner_opt, 1e-3);
612 let cfg = small_config().prioritized_replay(true);
613 assert!(DQNTrainerBurn::new(cfg, online, burn_opt, 4, 2, device).is_err());
614 }
615
616 /// End-to-end: pushing transitions and calling `train_step` runs
617 /// the Smooth-L1 / Double-DQN gradient step without panicking.
618 /// Uses `MlpBurnPolicy` as a Q-network stand-in — its `forward`
619 /// returns `(logits, value)`; we use the logits as the Q-values.
620 #[test]
621 fn dqn_trainer_burn_train_step_runs() {
622 let device = Default::default();
623 let online = MlpBurnPolicy::<B>::new(4, 2, 16, &device);
624 let inner_opt = AdamConfig::new().init();
625 let burn_opt = BurnOptimizer::new(inner_opt, 1e-3);
626 let mut trainer =
627 DQNTrainerBurn::new(small_config(), online, burn_opt, 4, 2, device).unwrap();
628
629 // Push enough transitions to clear min_buffer_size.
630 for i in 0..32 {
631 let phase = (i as f32) * 0.1;
632 let obs = [phase.sin(), phase.cos(), phase * 0.5, phase * -0.3];
633 let next_obs = [(phase + 0.1).sin(), (phase + 0.1).cos(), phase * 0.5, phase * -0.3];
634 let action = (i % 2) as i64;
635 let reward = if action == 0 { 1.0 } else { -1.0 };
636 let done = i % 8 == 7;
637 trainer.buffer_mut().push(&obs, action, reward, &next_obs, done);
638 }
639
640 let mut rng = rand::rngs::StdRng::seed_from_u64(7);
641 let forward_fn = |q: &MlpBurnPolicy<B>, o: Tensor<B, 2>| -> Tensor<B, 2> {
642 let (logits, _) = q.forward(o);
643 logits
644 };
645 let stats = trainer.train_step(&mut rng, forward_fn, forward_fn).unwrap();
646 assert!(stats.is_some());
647 let s = stats.unwrap();
648 assert!(s.td_loss.is_finite());
649 }
650
651 /// The additive loss-scaled step (used by the `training-fp16` example)
652 /// runs the same Double-DQN update with a scale/unscale wrapper around the
653 /// backward pass. On the f32 NdArray backend it must produce a finite,
654 /// applied step and increment the train-step counter.
655 #[test]
656 fn dqn_trainer_burn_train_step_scaled_runs() {
657 let device = Default::default();
658 let online = MlpBurnPolicy::<B>::new(4, 2, 16, &device);
659 let inner_opt = AdamConfig::new().init();
660 let burn_opt = BurnOptimizer::new(inner_opt, 1e-3);
661 let mut trainer =
662 DQNTrainerBurn::new(small_config(), online, burn_opt, 4, 2, device).unwrap();
663
664 for i in 0..32 {
665 let phase = (i as f32) * 0.1;
666 let obs = [phase.sin(), phase.cos(), phase * 0.5, phase * -0.3];
667 let next_obs = [(phase + 0.1).sin(), (phase + 0.1).cos(), phase * 0.5, phase * -0.3];
668 let action = (i % 2) as i64;
669 let reward = if action == 0 { 1.0 } else { -1.0 };
670 let done = i % 8 == 7;
671 trainer.buffer_mut().push(&obs, action, reward, &next_obs, done);
672 }
673
674 let mut rng = rand::rngs::StdRng::seed_from_u64(7);
675 let forward_fn = |q: &MlpBurnPolicy<B>, o: Tensor<B, 2>| -> Tensor<B, 2> {
676 let (logits, _) = q.forward(o);
677 logits
678 };
679 let out = trainer.train_step_scaled(&mut rng, 32_768.0, forward_fn, forward_fn).unwrap();
680 assert!(out.is_some());
681 let (s, applied) = out.unwrap();
682 assert!(applied, "scaled step should apply on finite loss");
683 assert!(s.td_loss.is_finite());
684 assert_eq!(trainer.total_train_steps(), 1);
685 }
686}