Skip to main content

thrust_rl/policy/
continuous_q.rs

1//! Burn-backend continuous-action Q-critic for SAC (part of #136).
2//!
3//! [`ContinuousQNetwork`](crate::policy::continuous_q::ContinuousQNetwork) is
4//! the `Q(s, a)` critic SAC uses for its twin critics and the two target
5//! critics. It is structurally close to the
6//! discrete [`crate::policy::q_network::QNetworkBurn`], but differs in two
7//! ways that match the continuous-control setting:
8//!
9//! - **Input width is `obs_dim + action_dim`** — the forward pass concatenates
10//!   the observation and the continuous action along the feature axis before
11//!   the trunk, so the critic scores a specific `(state, action)` pair rather
12//!   than every discrete action at once.
13//! - **Output width is `1`** — a single scalar `Q(s, a)` per batch row.
14//!
15//! # Architecture
16//!
17//! ```text
18//! obs    [batch, obs_dim]   ┐
19//!                           ├─ concat → [batch, obs_dim + action_dim]
20//! action [batch, action_dim]┘
21//!     → fc1 →act→ fc2 →act→ (fc3 →act→)? q_head
22//!  Q(s, a) [batch]
23//! ```
24//!
25//! The trunk depth (2 or 3 layers), hidden width, activation, and init
26//! recipe are configurable via
27//! [`ContinuousQNetworkConfig`](crate::policy::continuous_q::ContinuousQNetworkConfig),
28//! mirroring [`crate::policy::mlp::MlpBurnConfig`]. Construction can be seeded
29//! for bit-exact reproducibility (see [`crate::policy::seeded_init`]).
30//!
31//! # Twin critics + targets
32//!
33//! The module owns no optimizer and no target state of its own — Burn's
34//! optimizer consumes a module by value per step, so SAC's future
35//! `SacTrainer` owns four independent instances (`q1`, `q2`, `q1_target`,
36//! `q2_target`) as separate fields and steps the two online critics
37//! independently. Instantiating two twins with different seeds (or
38//! different unseeded draws) yields decorrelated critics, which is the
39//! standard clipped-double-Q setup.
40//!
41//! # Target-net sync
42//!
43//! Two helpers mirror the record-based approach documented on
44//! [`crate::policy::q_network::QNetworkBurn`]:
45//!
46//! - [`copy_params_from`](crate::policy::continuous_q::ContinuousQNetwork::copy_params_from) — hard copy (`theta_target <-
47//!   theta_online`), used to initialize the targets.
48//! - [`soft_update_from`](crate::policy::continuous_q::ContinuousQNetwork::soft_update_from) — Polyak blend (`theta_target <-
49//!   tau * theta_online + (1 - tau) * theta_target`), used every step in SAC.
50//!   `tau = 1.0` reduces exactly to a hard copy.
51
52use burn::{
53    module::Module,
54    nn::{Initializer, Linear},
55    tensor::{Tensor, activation, backend::Backend},
56};
57
58use super::mlp::{
59    BurnActivation, derive_layer_seed, linear_from_weights, linear_with_init, seeded_layer_weights,
60};
61
62/// Configuration for [`ContinuousQNetwork`] architecture.
63///
64/// Mirrors [`crate::policy::q_network::QNetworkBurnConfig`] but adds the
65/// depth/activation knobs SAC critics want (the default SAC critic is a
66/// 2-layer, 256-wide ReLU MLP per Haarnoja et al. 2018). Held as a
67/// separate type so the critic can be tuned independently of the actor.
68#[derive(Debug, Clone, Copy)]
69pub struct ContinuousQNetworkConfig {
70    /// Number of hidden layers in the trunk. Only `2` or `3` are
71    /// supported; anything else is treated as `2`.
72    pub num_layers: usize,
73    /// Width of every hidden layer.
74    pub hidden_dim: usize,
75    /// If `true`, initialize hidden-layer weights with orthogonal
76    /// (gain `sqrt(2)`) and the Q-head with `gain = 0.01`. Set `false`
77    /// for Burn's stock Kaiming-uniform default.
78    pub use_orthogonal_init: bool,
79    /// Activation applied between hidden layers.
80    pub activation: BurnActivation,
81    /// Optional construction seed. When `Some`, every layer is built
82    /// from a deterministic, `StdRng`-driven weight buffer (see
83    /// [`crate::policy::seeded_init`]) so two constructions with the same
84    /// seed produce **bit-identical** critics. When `None` (the default)
85    /// Burn's unseedable [`Initializer`] path is used verbatim. Twin
86    /// critics should be seeded differently (or left unseeded) so they
87    /// are decorrelated.
88    pub seed: Option<u64>,
89}
90
91impl Default for ContinuousQNetworkConfig {
92    fn default() -> Self {
93        Self {
94            num_layers: 2,
95            hidden_dim: 256,
96            use_orthogonal_init: true,
97            activation: BurnActivation::ReLU,
98            seed: None,
99        }
100    }
101}
102
103impl ContinuousQNetworkConfig {
104    /// Set the construction seed, enabling the deterministic host-side
105    /// init path in [`ContinuousQNetwork::with_config`].
106    ///
107    /// Builder-style; returns `self` for chaining:
108    /// `ContinuousQNetworkConfig::default().with_seed(42)`.
109    pub fn with_seed(mut self, seed: u64) -> Self {
110        self.seed = Some(seed);
111        self
112    }
113}
114
115/// Continuous-action Q-critic on Burn: `Q(s, a) -> scalar`.
116#[derive(Module, Debug)]
117pub struct ContinuousQNetwork<B: Backend> {
118    fc1: Linear<B>,
119    fc2: Linear<B>,
120    fc3: Option<Linear<B>>,
121    q_head: Linear<B>,
122    activation: BurnActivation,
123}
124
125impl<B: Backend> ContinuousQNetwork<B> {
126    /// Build a fresh critic with the default config (2-layer ReLU,
127    /// orthogonal init) and the given hidden width.
128    pub fn new(obs_dim: usize, action_dim: usize, hidden_dim: usize, device: &B::Device) -> Self {
129        Self::with_config(
130            obs_dim,
131            action_dim,
132            ContinuousQNetworkConfig { hidden_dim, ..Default::default() },
133            device,
134        )
135    }
136
137    /// Build a fresh critic with the given configuration.
138    ///
139    /// The trunk input width is `obs_dim + action_dim` (the concatenated
140    /// `(state, action)` feature vector). When `config.seed` is `Some`,
141    /// every layer is drawn from a per-layer-derived `StdRng` stream so
142    /// the construction is bit-exact across runs and machines.
143    pub fn with_config(
144        obs_dim: usize,
145        action_dim: usize,
146        config: ContinuousQNetworkConfig,
147        device: &B::Device,
148    ) -> Self {
149        let input_dim = obs_dim + action_dim;
150        let hidden = config.hidden_dim;
151        let use_third = config.num_layers >= 3;
152
153        let (fc1, fc2, fc3, q_head) = if let Some(base_seed) = config.seed {
154            // Seeded host-side init: each layer pulls from a distinct,
155            // deterministically-derived RNG stream so equal-shaped layers
156            // don't collide.
157            let mut layer_idx = 0u64;
158            let mut next = || {
159                let s = derive_layer_seed(base_seed, layer_idx);
160                layer_idx += 1;
161                s
162            };
163
164            let w1 =
165                seeded_layer_weights(next(), input_dim, hidden, config.use_orthogonal_init, false);
166            let fc1 = linear_from_weights::<B>(input_dim, hidden, &w1, device);
167
168            let w2 =
169                seeded_layer_weights(next(), hidden, hidden, config.use_orthogonal_init, false);
170            let fc2 = linear_from_weights::<B>(hidden, hidden, &w2, device);
171
172            let fc3 = if use_third {
173                let w3 =
174                    seeded_layer_weights(next(), hidden, hidden, config.use_orthogonal_init, false);
175                Some(linear_from_weights::<B>(hidden, hidden, &w3, device))
176            } else {
177                None
178            };
179
180            let wq = seeded_layer_weights(next(), hidden, 1, config.use_orthogonal_init, true);
181            let q_head = linear_from_weights::<B>(hidden, 1, &wq, device);
182
183            (fc1, fc2, fc3, q_head)
184        } else {
185            // Unseeded: route through Burn's `Initializer` exactly like
186            // the discrete Q-network.
187            let hidden_init = if config.use_orthogonal_init {
188                Initializer::Orthogonal { gain: 2.0_f64.sqrt() }
189            } else {
190                Initializer::KaimingUniform { gain: 1.0_f64 / 3.0_f64.sqrt(), fan_out_only: false }
191            };
192            let output_init = if config.use_orthogonal_init {
193                Initializer::Orthogonal { gain: 0.01 }
194            } else {
195                Initializer::KaimingUniform { gain: 1.0_f64 / 3.0_f64.sqrt(), fan_out_only: false }
196            };
197
198            let fc1 = linear_with_init::<B>(input_dim, hidden, hidden_init.clone(), device);
199            let fc2 = linear_with_init::<B>(hidden, hidden, hidden_init.clone(), device);
200            let fc3 = if use_third {
201                Some(linear_with_init::<B>(hidden, hidden, hidden_init, device))
202            } else {
203                None
204            };
205            let q_head = linear_with_init::<B>(hidden, 1, output_init, device);
206
207            (fc1, fc2, fc3, q_head)
208        };
209
210        Self { fc1, fc2, fc3, q_head, activation: config.activation }
211    }
212
213    fn apply_activation<const D: usize>(&self, x: Tensor<B, D>) -> Tensor<B, D> {
214        match self.activation {
215            BurnActivation::ReLU => activation::relu(x),
216            BurnActivation::Tanh => activation::tanh(x),
217        }
218    }
219
220    /// Forward pass: compute `Q(s, a)` for a batch of `(state, action)`
221    /// pairs.
222    ///
223    /// * `obs` shape `[batch, obs_dim]`.
224    /// * `action` shape `[batch, action_dim]`.
225    /// * Returns Q-values of shape `[batch]` (the trailing singleton head
226    ///   dimension is squeezed).
227    ///
228    /// The observation and action are concatenated along the feature axis
229    /// before the trunk, so gradients flow into both inputs — SAC's actor
230    /// loss differentiates `Q(s, a)` with respect to the action.
231    pub fn forward(&self, obs: Tensor<B, 2>, action: Tensor<B, 2>) -> Tensor<B, 1> {
232        let input = Tensor::cat(vec![obs, action], 1);
233        let h = self.apply_activation(self.fc1.forward(input));
234        let h = self.apply_activation(self.fc2.forward(h));
235        let h = if let Some(fc3) = &self.fc3 {
236            self.apply_activation(fc3.forward(h))
237        } else {
238            h
239        };
240        self.q_head.forward(h).squeeze_dim::<1>(1)
241    }
242
243    /// Replace this critic's parameters with a deep copy of `source`'s
244    /// (hard target sync, `theta_target <- theta_online`).
245    ///
246    /// Returns a new module with the same architecture but `source`'s
247    /// records. Burn's optimizer ownership model (`step` consumes the
248    /// module by value) means we return `Self` rather than mutating
249    /// `&mut self`; the trainer holds each target critic as a field and
250    /// swaps it through this call when initializing the targets.
251    pub fn copy_params_from(self, source: &ContinuousQNetwork<B>) -> ContinuousQNetwork<B> {
252        // Burn modules clone their record cheaply (a tree of `Param`s
253        // over reference-counted tensors). `load_record` consumes the
254        // receiver and returns a new module with the source's params.
255        self.load_record(source.clone().into_record())
256    }
257
258    /// Polyak (soft) target update:
259    /// `theta_target <- tau * theta_online + (1 - tau) * theta_target`.
260    ///
261    /// Applied to every parameter tensor (every trunk layer's weight and
262    /// bias, plus the Q-head's). `tau = 1.0` reduces exactly to a hard
263    /// copy ([`copy_params_from`](Self::copy_params_from)); a `tau` in
264    /// `(0, 1)` nudges the target toward the online network — the
265    /// always-soft update SAC performs every gradient step.
266    ///
267    /// Mutates `self` in place. `self` (the target) and `online` must
268    /// share the same architecture (depth, widths); the trainer
269    /// constructs the targets as clones of the online critics, so this
270    /// holds by construction.
271    pub fn soft_update_from(&mut self, online: &ContinuousQNetwork<B>, tau: f64) {
272        debug_assert!(
273            (0.0..=1.0).contains(&tau),
274            "tau must lie in [0, 1] for a convex Polyak blend, got {tau}"
275        );
276        debug_assert_eq!(
277            self.fc3.is_some(),
278            online.fc3.is_some(),
279            "target and online critics must have the same depth"
280        );
281
282        // Move the target's layers out so we can consume their tensors in
283        // the blend (Burn tensor ops take ownership). We reconstruct the
284        // module afterwards.
285        let target = std::mem::replace(self, online.clone());
286
287        let fc1 = blend_linear(target.fc1, &online.fc1, tau);
288        let fc2 = blend_linear(target.fc2, &online.fc2, tau);
289        let fc3 = match (target.fc3, &online.fc3) {
290            (Some(t), Some(o)) => Some(blend_linear(t, o, tau)),
291            _ => None,
292        };
293        let q_head = blend_linear(target.q_head, &online.q_head, tau);
294
295        *self = Self { fc1, fc2, fc3, q_head, activation: target.activation };
296    }
297}
298
299/// Blend a single [`Linear`] layer's parameters as
300/// `tau * online + (1 - tau) * target`, returning a fresh layer.
301///
302/// Consumes `target` (Burn tensor arithmetic takes ownership) and reads
303/// `online` via cheap `val()` clones of its reference-counted tensors.
304fn blend_linear<B: Backend>(target: Linear<B>, online: &Linear<B>, tau: f64) -> Linear<B> {
305    use burn::module::Param;
306
307    let one_minus_tau = 1.0 - tau;
308
309    let target_w = target.weight.val();
310    let online_w = online.weight.val();
311    // `detach()` drops the autodiff graph so the blended tensor is a leaf
312    // again — `Param::from_tensor` requires a leaf, and target critics
313    // carry no gradients in SAC anyway (they are updated only by this
314    // Polyak blend, never by an optimizer step).
315    let weight = online_w.mul_scalar(tau).add(target_w.mul_scalar(one_minus_tau)).detach();
316
317    let bias = match (target.bias, &online.bias) {
318        (Some(target_b), Some(online_b)) => {
319            let blended = online_b
320                .val()
321                .mul_scalar(tau)
322                .add(target_b.val().mul_scalar(one_minus_tau))
323                .detach();
324            Some(Param::from_tensor(blended))
325        }
326        _ => None,
327    };
328
329    Linear::<B> { weight: Param::from_tensor(weight), bias }
330}
331
332#[cfg(test)]
333mod tests {
334    use burn::backend::{Autodiff, NdArray};
335
336    use super::*;
337
338    type B = Autodiff<NdArray<f32>>;
339
340    /// Build a deterministic `[batch, dim]` tensor with ascending values
341    /// on the default device.
342    fn ramp(batch: usize, dim: usize) -> Tensor<B, 2> {
343        let device = Default::default();
344        let data: Vec<f32> = (0..batch * dim).map(|i| 0.01 * i as f32).collect();
345        Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(data, [batch, dim]), &device)
346    }
347
348    #[test]
349    fn forward_shape_two_layer() {
350        let device = Default::default();
351        let q = ContinuousQNetwork::<B>::new(4, 2, 32, &device);
352        let obs = ramp(8, 4);
353        let action = ramp(8, 2);
354        let out = q.forward(obs, action);
355        assert_eq!(out.dims(), [8], "2-layer critic must return [batch]");
356    }
357
358    #[test]
359    fn forward_shape_three_layer() {
360        let device = Default::default();
361        let cfg = ContinuousQNetworkConfig { num_layers: 3, ..Default::default() };
362        let q = ContinuousQNetwork::<B>::with_config(5, 3, cfg, &device);
363        assert!(q.fc3.is_some(), "num_layers=3 must build a third trunk layer");
364        let obs = ramp(6, 5);
365        let action = ramp(6, 3);
366        let out = q.forward(obs, action);
367        assert_eq!(out.dims(), [6], "3-layer critic must return [batch]");
368    }
369
370    #[test]
371    fn tanh_activation_branch() {
372        let device = Default::default();
373        let cfg = ContinuousQNetworkConfig {
374            activation: BurnActivation::Tanh,
375            use_orthogonal_init: false,
376            ..Default::default()
377        };
378        let q = ContinuousQNetwork::<B>::with_config(3, 1, cfg, &device);
379        let obs = ramp(2, 3);
380        let action = ramp(2, 1);
381        assert_eq!(q.forward(obs, action).dims(), [2]);
382    }
383
384    #[test]
385    fn seeded_construction_is_bit_exact() {
386        let device = Default::default();
387        let cfg = ContinuousQNetworkConfig::default().with_seed(7);
388        let a = ContinuousQNetwork::<B>::with_config(4, 2, cfg, &device);
389        let b = ContinuousQNetwork::<B>::with_config(4, 2, cfg, &device);
390
391        let obs = ramp(4, 4);
392        let action = ramp(4, 2);
393        let qa: Vec<f32> = a.forward(obs.clone(), action.clone()).into_data().to_vec().unwrap();
394        let qb: Vec<f32> = b.forward(obs, action).into_data().to_vec().unwrap();
395        assert_eq!(qa, qb, "same seed must yield bit-identical critics");
396    }
397
398    /// `copy_params_from` makes the target reproduce the online critic's
399    /// outputs exactly on the same input.
400    #[test]
401    fn copy_params_from_matches_online() {
402        let device = Default::default();
403        let cfg = ContinuousQNetworkConfig {
404            hidden_dim: 16,
405            use_orthogonal_init: false,
406            ..Default::default()
407        };
408        let online = ContinuousQNetwork::<B>::with_config(4, 2, cfg, &device);
409        let target = ContinuousQNetwork::<B>::with_config(4, 2, cfg, &device);
410
411        let obs = ramp(3, 4);
412        let action = ramp(3, 2);
413
414        // Fresh independent draws should disagree.
415        let on_before: Vec<f32> =
416            online.forward(obs.clone(), action.clone()).into_data().to_vec().unwrap();
417        let tg_before: Vec<f32> =
418            target.forward(obs.clone(), action.clone()).into_data().to_vec().unwrap();
419        assert!(
420            on_before.iter().zip(&tg_before).any(|(a, b)| (a - b).abs() > 1e-6),
421            "fresh critics should disagree before copy"
422        );
423
424        let target = target.copy_params_from(&online);
425        let on_after: Vec<f32> =
426            online.forward(obs.clone(), action.clone()).into_data().to_vec().unwrap();
427        let tg_after: Vec<f32> = target.forward(obs, action).into_data().to_vec().unwrap();
428        for (a, b) in on_after.iter().zip(&tg_after) {
429            assert!((a - b).abs() < 1e-6, "after copy: online={a} target={b}");
430        }
431    }
432
433    /// `soft_update_from` with `tau = 1.0` is exactly a hard copy.
434    #[test]
435    fn soft_update_tau_one_equals_hard_copy() {
436        let device = Default::default();
437        let cfg = ContinuousQNetworkConfig {
438            hidden_dim: 16,
439            use_orthogonal_init: false,
440            ..Default::default()
441        };
442        let online = ContinuousQNetwork::<B>::with_config(4, 2, cfg, &device);
443        let mut target = ContinuousQNetwork::<B>::with_config(4, 2, cfg, &device);
444
445        let obs = ramp(3, 4);
446        let action = ramp(3, 2);
447
448        target.soft_update_from(&online, 1.0);
449        let on: Vec<f32> =
450            online.forward(obs.clone(), action.clone()).into_data().to_vec().unwrap();
451        let tg: Vec<f32> = target.forward(obs, action).into_data().to_vec().unwrap();
452        for (a, b) in on.iter().zip(&tg) {
453            assert!((a - b).abs() < 1e-6, "tau=1 soft update: online={a} target={b}");
454        }
455    }
456
457    /// Flatten every learnable parameter of a critic (weights then bias
458    /// for each layer, in a stable order) into a single `Vec<f32>`. Used
459    /// to assert the parameter-space Polyak invariant of `soft_update_from`.
460    fn all_params(net: &ContinuousQNetwork<B>) -> Vec<f32> {
461        let mut out = Vec::new();
462        let mut push_linear = |l: &Linear<B>| {
463            out.extend(l.weight.val().into_data().to_vec::<f32>().unwrap());
464            if let Some(b) = &l.bias {
465                out.extend(b.val().into_data().to_vec::<f32>().unwrap());
466            }
467        };
468        push_linear(&net.fc1);
469        push_linear(&net.fc2);
470        if let Some(fc3) = &net.fc3 {
471            push_linear(fc3);
472        }
473        push_linear(&net.q_head);
474        out
475    }
476
477    /// `soft_update_from` with `tau` in `(0, 1)` blends the target toward
478    /// the online network in PARAMETER space following the Polyak rule
479    /// `new_target = tau * online + (1 - tau) * old_target`.
480    ///
481    /// This is the invariant `soft_update_from` actually guarantees and it
482    /// is exact and deterministic. (An earlier version of this test asserted
483    /// that the *output* distance shrinks; that is not a valid invariant —
484    /// the Q output is a nonlinear MLP function of the parameters, so a
485    /// convex blend of parameters does not guarantee a monotone move of the
486    /// output toward online's output. That made the test flaky under the
487    /// unseeded RNG. Here we seed both critics for determinism and assert
488    /// the parameter-space invariant directly.)
489    #[test]
490    fn soft_update_moves_target_toward_online() {
491        let device = Default::default();
492        // Seed both critics with distinct fixed seeds so they start with
493        // distinct but fully deterministic weights every run.
494        let online = ContinuousQNetwork::<B>::with_config(
495            4,
496            2,
497            ContinuousQNetworkConfig {
498                hidden_dim: 16,
499                use_orthogonal_init: false,
500                seed: Some(11),
501                ..Default::default()
502            },
503            &device,
504        );
505        let mut target = ContinuousQNetwork::<B>::with_config(
506            4,
507            2,
508            ContinuousQNetworkConfig {
509                hidden_dim: 16,
510                use_orthogonal_init: false,
511                seed: Some(29),
512                ..Default::default()
513            },
514            &device,
515        );
516
517        let online_p = all_params(&online);
518        let target_before = all_params(&target);
519
520        // Critics must start distinct, else the test is vacuous.
521        let dist_before: f32 =
522            online_p.iter().zip(&target_before).map(|(o, t)| (o - t).abs()).sum();
523        assert!(dist_before > 1e-4, "test needs distinct critics to start");
524
525        let tau = 0.25_f64;
526        target.soft_update_from(&online, tau);
527        let target_after = all_params(&target);
528
529        assert_eq!(target_after.len(), target_before.len());
530        assert_eq!(target_after.len(), online_p.len());
531
532        // Parameter-space Polyak invariant, exact (modulo f32 rounding):
533        // new_target == tau * online + (1 - tau) * old_target.
534        let tau_f = tau as f32;
535        for (i, ((&onl, &old), &new)) in
536            online_p.iter().zip(&target_before).zip(&target_after).enumerate()
537        {
538            let expected = tau_f * onl + (1.0 - tau_f) * old;
539            assert!(
540                (new - expected).abs() < 1e-5,
541                "param {i}: Polyak blend mismatch new={new} expected={expected} \
542                 (online={onl} old_target={old} tau={tau})"
543            );
544        }
545
546        // Consequently the summed parameter distance to online shrinks by
547        // exactly the factor (1 - tau).
548        let dist_after: f32 = online_p.iter().zip(&target_after).map(|(o, t)| (o - t).abs()).sum();
549        let expected_after = (1.0 - tau_f) * dist_before;
550        assert!(
551            (dist_after - expected_after).abs() < 1e-4,
552            "param distance to online should scale by (1-tau): \
553             before={dist_before} after={dist_after} expected={expected_after}"
554        );
555
556        // Sanity: with tau > 0 the target parameters actually changed.
557        assert!(
558            target_before.iter().zip(&target_after).any(|(a, b)| (a - b).abs() > 1e-6),
559            "soft update with tau>0 must change the target"
560        );
561    }
562}