Skip to main content

thrust_rl/train/dqn/
config.rs

1//! DQN configuration and hyperparameters
2//!
3//! This module defines the configuration parameters for DQN training
4//! and provides validation and builder-pattern methods. Mirrors the
5//! structure of [`crate::train::ppo::PPOConfig`].
6
7use anyhow::{Result, anyhow};
8
9/// DQN configuration parameters.
10///
11/// Default values target classic CartPole-style discrete control:
12/// 50k-capacity replay, 64-sample batches, hard target sync every
13/// 500 env steps, linear ε-decay over 10k env steps. These are the
14/// same defaults the Stable-Baselines3 DQN baseline uses on CartPole.
15#[derive(Debug, Clone)]
16pub struct DQNConfig {
17    /// Adam learning rate for the online Q-network.
18    pub learning_rate: f64,
19
20    /// Number of transitions sampled per gradient update.
21    pub batch_size: usize,
22
23    /// Maximum number of transitions stored in the replay buffer.
24    /// Older transitions are evicted FIFO once capacity is reached.
25    pub buffer_capacity: usize,
26
27    /// Minimum number of transitions required before training starts.
28    /// Until the buffer holds this many transitions the trainer only
29    /// collects experience (via random or ε-greedy actions) and skips
30    /// gradient updates.
31    pub min_buffer_size: usize,
32
33    /// Number of environment steps between hard target-net syncs
34    /// (target ← online).
35    pub target_update_interval: usize,
36
37    /// Discount factor used in the TD target. With Double-DQN
38    /// (used unconditionally by [`crate::train::dqn::DQNTrainerBurn`]):
39    /// `y = r + γ · (1 - done) · Q_target(s', argmax_a' Q_online(s', a'))`.
40    pub gamma: f64,
41
42    /// Initial value of the ε-greedy exploration parameter.
43    pub epsilon_start: f64,
44
45    /// Final value of ε after the linear decay completes.
46    pub epsilon_end: f64,
47
48    /// Number of environment steps over which ε linearly anneals from
49    /// `epsilon_start` to `epsilon_end`. After this many steps ε stays
50    /// at `epsilon_end`.
51    pub epsilon_decay_steps: usize,
52
53    /// Maximum gradient norm for clipping the Q-network update.
54    pub max_grad_norm: f64,
55
56    /// Polyak (soft) target update coefficient `τ ∈ (0, 1]`.
57    ///
58    /// When `Some(τ)`, every call to
59    /// [`crate::train::dqn::DQNTrainerBurn::maybe_sync_target`] performs the
60    /// blend
61    ///
62    /// ```text
63    /// θ_target ← τ · θ_online + (1 − τ) · θ_target
64    /// ```
65    ///
66    /// across every parameter of the target network. This replaces the
67    /// hard copy that fires every `target_update_interval` env steps; in
68    /// soft-update mode `target_update_interval` is ignored.
69    ///
70    /// When `None` (default), the trainer falls back to the original hard
71    /// copy on the interval, preserving byte-for-byte backward
72    /// compatibility with vanilla DQN.
73    ///
74    /// A typical value is `0.005` (the SB3/Spinning Up default).
75    pub soft_update_tau: Option<f64>,
76
77    /// Use Prioritized Experience Replay (Schaul et al., 2015) in place
78    /// of the uniform [`crate::buffer::replay::ReplayBuffer`].
79    ///
80    /// When `true`, the trainer holds a
81    /// [`crate::buffer::replay::PrioritizedReplayBuffer`] and samples
82    /// transitions proportionally to `(|TD error| + ε)^α`. Per-sample
83    /// importance-sampling weights are applied to the Smooth-L1 loss
84    /// (so high-priority transitions get correspondingly down-weighted
85    /// updates), and the buffer's priorities are refreshed after each
86    /// gradient step with the new TD-error magnitudes.
87    ///
88    /// Defaults to `false` to preserve the vanilla uniform behavior.
89    pub prioritized_replay: bool,
90
91    /// PER priority exponent `α ∈ [0, 1]`. `0` recovers uniform sampling
92    /// (purely as a degenerate case — flip [`Self::prioritized_replay`]
93    /// off for the same effect without the sum-tree overhead). `1` is
94    /// fully proportional to priority. Typical value `0.6` (Schaul §3.2).
95    pub per_alpha: f64,
96
97    /// PER importance-sampling exponent at the start of training.
98    /// Typical value `0.4` (Schaul §3.4).
99    pub per_beta_start: f64,
100
101    /// PER importance-sampling exponent at the end of the annealing
102    /// schedule. Always `1.0` in the original paper — full bias
103    /// correction by the end of training.
104    pub per_beta_end: f64,
105
106    /// Number of environment steps over which β linearly anneals from
107    /// `per_beta_start` to `per_beta_end`. After this many steps β stays
108    /// at `per_beta_end`. Defaults to `epsilon_decay_steps` if set to
109    /// `0` (sentinel "follow the ε schedule").
110    pub per_beta_steps: usize,
111
112    /// Tiny constant added to `|TD error|` before raising to `α`, so
113    /// transitions with vanishing TD error still have a small chance of
114    /// being resampled. Typical value `1e-6`.
115    pub per_epsilon: f64,
116}
117
118impl Default for DQNConfig {
119    fn default() -> Self {
120        Self {
121            learning_rate: 1e-3,
122            batch_size: 64,
123            buffer_capacity: 50_000,
124            min_buffer_size: 1_000,
125            target_update_interval: 500,
126            gamma: 0.99,
127            epsilon_start: 1.0,
128            epsilon_end: 0.05,
129            epsilon_decay_steps: 10_000,
130            max_grad_norm: 10.0,
131            soft_update_tau: None,
132            prioritized_replay: false,
133            per_alpha: 0.6,
134            per_beta_start: 0.4,
135            per_beta_end: 1.0,
136            per_beta_steps: 0,
137            per_epsilon: 1e-6,
138        }
139    }
140}
141
142impl DQNConfig {
143    /// Create a new default configuration.
144    pub fn new() -> Self {
145        Self::default()
146    }
147
148    /// Validate configuration parameters.
149    ///
150    /// Returns an `Err` describing the first invalid field encountered.
151    pub fn validate(&self) -> Result<()> {
152        if self.learning_rate <= 0.0 {
153            return Err(anyhow!("learning_rate must be positive"));
154        }
155        if self.batch_size == 0 {
156            return Err(anyhow!("batch_size must be positive"));
157        }
158        if self.buffer_capacity == 0 {
159            return Err(anyhow!("buffer_capacity must be positive"));
160        }
161        if self.buffer_capacity < self.batch_size {
162            return Err(anyhow!(
163                "buffer_capacity ({}) must be at least batch_size ({})",
164                self.buffer_capacity,
165                self.batch_size
166            ));
167        }
168        if self.min_buffer_size > self.buffer_capacity {
169            return Err(anyhow!(
170                "min_buffer_size ({}) must be <= buffer_capacity ({})",
171                self.min_buffer_size,
172                self.buffer_capacity
173            ));
174        }
175        if self.target_update_interval == 0 {
176            return Err(anyhow!("target_update_interval must be positive"));
177        }
178        if !(0.0..=1.0).contains(&self.gamma) {
179            return Err(anyhow!("gamma must be in [0, 1]"));
180        }
181        if !(0.0..=1.0).contains(&self.epsilon_start) {
182            return Err(anyhow!("epsilon_start must be in [0, 1]"));
183        }
184        if !(0.0..=1.0).contains(&self.epsilon_end) {
185            return Err(anyhow!("epsilon_end must be in [0, 1]"));
186        }
187        if self.epsilon_end > self.epsilon_start {
188            return Err(anyhow!(
189                "epsilon_end ({}) must be <= epsilon_start ({})",
190                self.epsilon_end,
191                self.epsilon_start
192            ));
193        }
194        if self.epsilon_decay_steps == 0 {
195            return Err(anyhow!("epsilon_decay_steps must be positive"));
196        }
197        if self.max_grad_norm <= 0.0 {
198            return Err(anyhow!("max_grad_norm must be positive"));
199        }
200        if let Some(tau) = self.soft_update_tau
201            && !(tau > 0.0 && tau <= 1.0)
202        {
203            return Err(anyhow!("soft_update_tau must be in (0, 1], got {}", tau));
204        }
205        // Prioritized Experience Replay parameter ranges. We validate
206        // even when `prioritized_replay = false` so callers that flip
207        // the flag on later don't suddenly hit a runtime error from a
208        // stale, half-built config.
209        if !(0.0..=1.0).contains(&self.per_alpha) {
210            return Err(anyhow!("per_alpha must be in [0, 1], got {}", self.per_alpha));
211        }
212        if !(0.0..=1.0).contains(&self.per_beta_start) {
213            return Err(anyhow!("per_beta_start must be in [0, 1], got {}", self.per_beta_start));
214        }
215        if !(0.0..=1.0).contains(&self.per_beta_end) {
216            return Err(anyhow!("per_beta_end must be in [0, 1], got {}", self.per_beta_end));
217        }
218        if self.per_beta_start > self.per_beta_end {
219            return Err(anyhow!(
220                "per_beta_start ({}) must be <= per_beta_end ({})",
221                self.per_beta_start,
222                self.per_beta_end
223            ));
224        }
225        if self.per_epsilon < 0.0 || !self.per_epsilon.is_finite() {
226            return Err(anyhow!(
227                "per_epsilon must be finite and non-negative, got {}",
228                self.per_epsilon
229            ));
230        }
231        Ok(())
232    }
233
234    /// Compute β at a given env-step count under the linear schedule:
235    ///
236    /// ```text
237    /// β(t) = β_start + (β_end − β_start) · min(t / β_steps, 1)
238    /// ```
239    ///
240    /// If `per_beta_steps == 0` the trainer falls back to
241    /// `epsilon_decay_steps` so callers can leave the field at its
242    /// default and have β anneal over the same window as ε.
243    pub fn beta_at(&self, env_steps: usize) -> f64 {
244        let steps = if self.per_beta_steps == 0 {
245            self.epsilon_decay_steps.max(1)
246        } else {
247            self.per_beta_steps
248        };
249        let fraction = ((env_steps as f64) / (steps as f64)).clamp(0.0, 1.0);
250        self.per_beta_start + (self.per_beta_end - self.per_beta_start) * fraction
251    }
252
253    /// Compute the ε used at a given env-step count under the linear
254    /// schedule:
255    ///
256    /// ```text
257    /// ε(t) = max(ε_end, ε_start - (ε_start - ε_end) · t / decay_steps)
258    /// ```
259    pub fn epsilon_at(&self, env_steps: usize) -> f64 {
260        if self.epsilon_decay_steps == 0 {
261            return self.epsilon_end;
262        }
263        let fraction = (env_steps as f64) / (self.epsilon_decay_steps as f64);
264        let eps = self.epsilon_start - (self.epsilon_start - self.epsilon_end) * fraction;
265        eps.max(self.epsilon_end)
266    }
267
268    // ----- Builder-style setters (mirroring PPOConfig) -----
269
270    /// Set learning rate.
271    pub fn learning_rate(mut self, lr: f64) -> Self {
272        self.learning_rate = lr;
273        self
274    }
275
276    /// Set minibatch size.
277    pub fn batch_size(mut self, size: usize) -> Self {
278        self.batch_size = size;
279        self
280    }
281
282    /// Set replay buffer capacity.
283    pub fn buffer_capacity(mut self, capacity: usize) -> Self {
284        self.buffer_capacity = capacity;
285        self
286    }
287
288    /// Set minimum buffer size before training starts.
289    pub fn min_buffer_size(mut self, size: usize) -> Self {
290        self.min_buffer_size = size;
291        self
292    }
293
294    /// Set target update interval (env steps between hard target syncs).
295    pub fn target_update_interval(mut self, steps: usize) -> Self {
296        self.target_update_interval = steps;
297        self
298    }
299
300    /// Set discount factor γ.
301    pub fn gamma(mut self, gamma: f64) -> Self {
302        self.gamma = gamma;
303        self
304    }
305
306    /// Set initial ε for ε-greedy exploration.
307    pub fn epsilon_start(mut self, eps: f64) -> Self {
308        self.epsilon_start = eps;
309        self
310    }
311
312    /// Set final ε for ε-greedy exploration.
313    pub fn epsilon_end(mut self, eps: f64) -> Self {
314        self.epsilon_end = eps;
315        self
316    }
317
318    /// Set number of env steps over which ε anneals.
319    pub fn epsilon_decay_steps(mut self, steps: usize) -> Self {
320        self.epsilon_decay_steps = steps;
321        self
322    }
323
324    /// Set maximum gradient norm.
325    pub fn max_grad_norm(mut self, norm: f64) -> Self {
326        self.max_grad_norm = norm;
327        self
328    }
329
330    /// Enable Polyak (soft) target updates with coefficient `τ`.
331    ///
332    /// When set, [`crate::train::dqn::DQNTrainerBurn::maybe_sync_target`]
333    /// performs `θ_target ← τ · θ_online + (1 − τ) · θ_target` on every
334    /// call (i.e. every env step in the standard rollout loop) instead of
335    /// the hard copy gated by `target_update_interval`.
336    ///
337    /// A typical value is `0.005`.
338    pub fn soft_update_tau(mut self, tau: f64) -> Self {
339        self.soft_update_tau = Some(tau);
340        self
341    }
342
343    /// Enable or disable Prioritized Experience Replay.
344    pub fn prioritized_replay(mut self, enabled: bool) -> Self {
345        self.prioritized_replay = enabled;
346        self
347    }
348
349    /// Set the PER priority exponent `α`.
350    pub fn per_alpha(mut self, alpha: f64) -> Self {
351        self.per_alpha = alpha;
352        self
353    }
354
355    /// Set the PER importance-sampling exponent at the start of training.
356    pub fn per_beta_start(mut self, beta: f64) -> Self {
357        self.per_beta_start = beta;
358        self
359    }
360
361    /// Set the PER importance-sampling exponent at the end of training.
362    pub fn per_beta_end(mut self, beta: f64) -> Self {
363        self.per_beta_end = beta;
364        self
365    }
366
367    /// Set the number of env steps over which β linearly anneals.
368    /// Pass `0` to follow [`Self::epsilon_decay_steps`].
369    pub fn per_beta_steps(mut self, steps: usize) -> Self {
370        self.per_beta_steps = steps;
371        self
372    }
373
374    /// Set the PER priority floor `ε`.
375    pub fn per_epsilon(mut self, eps: f64) -> Self {
376        self.per_epsilon = eps;
377        self
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn test_default_config_validates() {
387        let cfg = DQNConfig::default();
388        assert!(cfg.validate().is_ok());
389        assert_eq!(cfg.learning_rate, 1e-3);
390        assert_eq!(cfg.batch_size, 64);
391        assert_eq!(cfg.buffer_capacity, 50_000);
392        assert_eq!(cfg.min_buffer_size, 1_000);
393        assert_eq!(cfg.target_update_interval, 500);
394        assert_eq!(cfg.gamma, 0.99);
395        assert_eq!(cfg.epsilon_start, 1.0);
396        assert_eq!(cfg.epsilon_end, 0.05);
397        assert_eq!(cfg.epsilon_decay_steps, 10_000);
398        assert_eq!(cfg.max_grad_norm, 10.0);
399    }
400
401    #[test]
402    fn test_builder() {
403        let cfg = DQNConfig::new()
404            .learning_rate(5e-4)
405            .batch_size(128)
406            .buffer_capacity(20_000)
407            .min_buffer_size(500)
408            .target_update_interval(250)
409            .gamma(0.95)
410            .epsilon_start(0.5)
411            .epsilon_end(0.01)
412            .epsilon_decay_steps(5_000)
413            .max_grad_norm(5.0);
414        assert!(cfg.validate().is_ok());
415        assert_eq!(cfg.learning_rate, 5e-4);
416        assert_eq!(cfg.batch_size, 128);
417        assert_eq!(cfg.buffer_capacity, 20_000);
418        assert_eq!(cfg.min_buffer_size, 500);
419        assert_eq!(cfg.target_update_interval, 250);
420        assert_eq!(cfg.gamma, 0.95);
421        assert_eq!(cfg.epsilon_start, 0.5);
422        assert_eq!(cfg.epsilon_end, 0.01);
423        assert_eq!(cfg.epsilon_decay_steps, 5_000);
424        assert_eq!(cfg.max_grad_norm, 5.0);
425    }
426
427    #[test]
428    fn test_validate_rejects_negative_lr() {
429        let cfg = DQNConfig::new().learning_rate(-1.0);
430        assert!(cfg.validate().is_err());
431    }
432
433    #[test]
434    fn test_validate_rejects_zero_batch() {
435        let cfg = DQNConfig::new().batch_size(0);
436        assert!(cfg.validate().is_err());
437    }
438
439    #[test]
440    fn test_validate_rejects_gamma_out_of_range() {
441        assert!(DQNConfig::new().gamma(-0.1).validate().is_err());
442        assert!(DQNConfig::new().gamma(1.5).validate().is_err());
443        assert!(DQNConfig::new().gamma(0.0).validate().is_ok());
444        assert!(DQNConfig::new().gamma(1.0).validate().is_ok());
445    }
446
447    #[test]
448    fn test_validate_rejects_epsilon_end_above_start() {
449        let cfg = DQNConfig::new().epsilon_start(0.1).epsilon_end(0.5);
450        assert!(cfg.validate().is_err());
451    }
452
453    #[test]
454    fn test_validate_rejects_zero_target_update() {
455        let cfg = DQNConfig::new().target_update_interval(0);
456        assert!(cfg.validate().is_err());
457    }
458
459    #[test]
460    fn test_validate_rejects_zero_epsilon_decay() {
461        let cfg = DQNConfig::new().epsilon_decay_steps(0);
462        assert!(cfg.validate().is_err());
463    }
464
465    #[test]
466    fn test_validate_rejects_capacity_below_batch() {
467        let cfg = DQNConfig::new().buffer_capacity(32).batch_size(64);
468        assert!(cfg.validate().is_err());
469    }
470
471    #[test]
472    fn test_validate_rejects_min_buffer_above_capacity() {
473        let cfg = DQNConfig::new().buffer_capacity(100).min_buffer_size(1000);
474        assert!(cfg.validate().is_err());
475    }
476
477    #[test]
478    fn test_validate_rejects_zero_max_grad_norm() {
479        let cfg = DQNConfig::new().max_grad_norm(0.0);
480        assert!(cfg.validate().is_err());
481    }
482
483    #[test]
484    fn test_default_soft_update_tau_is_none() {
485        let cfg = DQNConfig::default();
486        assert!(cfg.soft_update_tau.is_none());
487    }
488
489    #[test]
490    fn test_soft_update_tau_builder() {
491        let cfg = DQNConfig::new().soft_update_tau(0.005);
492        assert_eq!(cfg.soft_update_tau, Some(0.005));
493        assert!(cfg.validate().is_ok());
494    }
495
496    #[test]
497    fn test_validate_rejects_soft_update_tau_out_of_range() {
498        assert!(DQNConfig::new().soft_update_tau(0.0).validate().is_err());
499        assert!(DQNConfig::new().soft_update_tau(-0.1).validate().is_err());
500        assert!(DQNConfig::new().soft_update_tau(1.5).validate().is_err());
501        assert!(DQNConfig::new().soft_update_tau(1.0).validate().is_ok());
502        assert!(DQNConfig::new().soft_update_tau(0.005).validate().is_ok());
503    }
504
505    #[test]
506    fn test_default_per_fields() {
507        let cfg = DQNConfig::default();
508        assert!(!cfg.prioritized_replay);
509        assert!((cfg.per_alpha - 0.6).abs() < 1e-9);
510        assert!((cfg.per_beta_start - 0.4).abs() < 1e-9);
511        assert!((cfg.per_beta_end - 1.0).abs() < 1e-9);
512        assert_eq!(cfg.per_beta_steps, 0);
513        assert!((cfg.per_epsilon - 1e-6).abs() < 1e-12);
514    }
515
516    #[test]
517    fn test_per_builder_setters() {
518        let cfg = DQNConfig::new()
519            .prioritized_replay(true)
520            .per_alpha(0.7)
521            .per_beta_start(0.3)
522            .per_beta_end(1.0)
523            .per_beta_steps(20_000)
524            .per_epsilon(1e-5);
525        assert!(cfg.prioritized_replay);
526        assert!((cfg.per_alpha - 0.7).abs() < 1e-9);
527        assert!((cfg.per_beta_start - 0.3).abs() < 1e-9);
528        assert!((cfg.per_beta_end - 1.0).abs() < 1e-9);
529        assert_eq!(cfg.per_beta_steps, 20_000);
530        assert!((cfg.per_epsilon - 1e-5).abs() < 1e-12);
531        assert!(cfg.validate().is_ok());
532    }
533
534    #[test]
535    fn test_validate_rejects_per_alpha_out_of_range() {
536        assert!(DQNConfig::new().per_alpha(-0.1).validate().is_err());
537        assert!(DQNConfig::new().per_alpha(1.5).validate().is_err());
538        assert!(DQNConfig::new().per_alpha(0.0).validate().is_ok());
539        assert!(DQNConfig::new().per_alpha(1.0).validate().is_ok());
540    }
541
542    #[test]
543    fn test_validate_rejects_per_beta_out_of_range() {
544        assert!(DQNConfig::new().per_beta_start(-0.1).validate().is_err());
545        assert!(DQNConfig::new().per_beta_end(1.5).validate().is_err());
546    }
547
548    #[test]
549    fn test_validate_rejects_per_beta_start_above_end() {
550        let cfg = DQNConfig::new().per_beta_start(0.9).per_beta_end(0.5);
551        assert!(cfg.validate().is_err());
552    }
553
554    #[test]
555    fn test_validate_rejects_negative_per_epsilon() {
556        let cfg = DQNConfig::new().per_epsilon(-1e-6);
557        assert!(cfg.validate().is_err());
558    }
559
560    #[test]
561    fn test_beta_schedule_linear() {
562        let cfg = DQNConfig::new().per_beta_start(0.4).per_beta_end(1.0).per_beta_steps(1000);
563        assert!((cfg.beta_at(0) - 0.4).abs() < 1e-9);
564        assert!((cfg.beta_at(500) - 0.7).abs() < 1e-9);
565        assert!((cfg.beta_at(1000) - 1.0).abs() < 1e-9);
566        // Past the end of the schedule β floors at β_end.
567        assert!((cfg.beta_at(10_000) - 1.0).abs() < 1e-9);
568    }
569
570    #[test]
571    fn test_beta_schedule_falls_back_to_epsilon_decay() {
572        // If per_beta_steps == 0, β should anneal over epsilon_decay_steps.
573        let cfg = DQNConfig::new()
574            .per_beta_start(0.4)
575            .per_beta_end(1.0)
576            .per_beta_steps(0)
577            .epsilon_decay_steps(2_000);
578        assert!((cfg.beta_at(1_000) - 0.7).abs() < 1e-9);
579        assert!((cfg.beta_at(2_000) - 1.0).abs() < 1e-9);
580    }
581
582    #[test]
583    fn test_epsilon_schedule_linear() {
584        let cfg = DQNConfig::new().epsilon_start(1.0).epsilon_end(0.1).epsilon_decay_steps(1000);
585
586        assert!((cfg.epsilon_at(0) - 1.0).abs() < 1e-9);
587        // At halfway, ε should be 0.55.
588        assert!((cfg.epsilon_at(500) - 0.55).abs() < 1e-6);
589        // At full decay, ε should be at ε_end.
590        assert!((cfg.epsilon_at(1000) - 0.1).abs() < 1e-9);
591        // Past the decay window, ε floors at ε_end.
592        assert!((cfg.epsilon_at(10_000) - 0.1).abs() < 1e-9);
593    }
594}