Skip to main content

thrust_rl/policy/
sac_actor.rs

1//! Burn-backend stochastic Gaussian actor for SAC (Soft Actor-Critic).
2//!
3//! This module implements [`SacActor`](crate::policy::sac_actor::SacActor),
4//! the policy network half of the
5//! SAC trainer for **continuous** control (parent proposal #136, PR C of
6//! the decomposition). SAC's actor is a *reparameterized, tanh-squashed
7//! Gaussian* policy: a shared MLP trunk feeds two heads — a `mean` head
8//! and a `log_std` head — and an action is drawn as
9//!
10//! ```text
11//! u = mean + std * eps        (eps ~ N(0, I), reparameterization trick)
12//! a = tanh(u)                 (squash into the (-1, 1) box)
13//! ```
14//!
15//! The squashing requires the standard change-of-variables correction to
16//! the log-probability (Haarnoja et al. 2018, "Soft Actor-Critic
17//! Algorithms and Applications", arXiv:1812.05905, Appendix C):
18//!
19//! ```text
20//! log_prob(a) = gaussian_log_prob(u) - sum_i log(1 - tanh(u_i)^2 + eps)
21//! ```
22//!
23//! # Why a dedicated small module (not a monolithic 5-net `SacPolicy`)?
24//!
25//! Burn's optimizer is move-through — it consumes a `Module` by value
26//! each `step`. SAC needs **three** independently-stepped optimizers
27//! (actor, critics, alpha), so the actor, the four critics, and the
28//! entropy temperature must be owned as *separate* module fields by the
29//! future `SacTrainer`, not fused into one module. This file therefore
30//! provides only the actor; the critics live in `continuous_q` (PR D)
31//! and the trainer assembles them (PR E).
32//!
33//! # Reproducibility
34//!
35//! [`SacActor::sample`](crate::policy::sac_actor::SacActor::sample)
36//! consumes a caller-supplied [`StdRng`](rand::rngs::StdRng) for the
37//! Gaussian noise, mirroring the seeding contract of
38//! [`MlpBurnPolicy::get_action_host_seeded`](crate::policy::mlp::MlpBurnPolicy::get_action_host_seeded):
39//! two calls with the same actor state, same `obs`, and same-seeded RNG
40//! produce bit-identical `(action, log_prob)`. Construction is likewise
41//! seedable via
42//! [`SacActorConfig::with_seed`](crate::policy::sac_actor::SacActorConfig::with_seed),
43//! reusing the host-side init helpers from [`crate::policy::seeded_init`].
44
45use burn::{
46    nn::{Initializer, Linear},
47    tensor::{Tensor, activation, backend::Backend},
48};
49use rand::{Rng, rngs::StdRng};
50
51use crate::policy::mlp::{
52    BurnActivation, derive_layer_seed, linear_from_weights, linear_with_init, seeded_layer_weights,
53};
54
55/// Lower clamp for the `log_std` head output.
56///
57/// Mirrors the canonical SAC reference implementations (`[-20, 2]`):
58/// clamping keeps the standard deviation in a numerically sane range
59/// (`std` in roughly `[2e-9, 7.4]`) so the reparameterized sample and
60/// its log-probability never overflow or collapse to a degenerate
61/// distribution early in training.
62pub const LOG_STD_MIN: f32 = -20.0;
63
64/// Upper clamp for the `log_std` head output. See [`LOG_STD_MIN`].
65pub const LOG_STD_MAX: f32 = 2.0;
66
67/// Numerical-stability epsilon added inside the `log(1 - tanh(u)^2)`
68/// tanh-squash correction so the log never sees an exact zero argument
69/// when `|tanh(u)|` is driven to `1`.
70const TANH_CORRECTION_EPS: f32 = 1e-6;
71
72/// Configuration for the [`SacActor`] architecture.
73///
74/// Analogous to [`crate::policy::mlp::MlpBurnConfig`]: depth, width,
75/// activation, an orthogonal-vs-Kaiming init toggle, and an optional
76/// construction seed for bit-exact reproducibility.
77#[derive(Debug, Clone, Copy)]
78pub struct SacActorConfig {
79    /// Number of hidden layers in the shared trunk. Only `2` or `3` are
80    /// supported; anything else is treated as `2` (matching
81    /// [`crate::policy::mlp::MlpBurnConfig`]).
82    pub num_layers: usize,
83    /// Width of every hidden layer.
84    pub hidden_dim: usize,
85    /// If `true`, initialize hidden-layer weights with
86    /// [`Initializer::Orthogonal`] (gain `sqrt(2)`) and the output heads
87    /// with `gain = 0.01`. Set `false` to fall back to Burn's default
88    /// Kaiming-uniform init.
89    pub use_orthogonal_init: bool,
90    /// Activation applied between hidden layers.
91    pub activation: BurnActivation,
92    /// Optional construction seed. When `Some`,
93    /// [`SacActor::with_config`](crate::policy::sac_actor::SacActor::with_config)
94    /// builds every layer from a deterministic, [`StdRng`]-driven weight
95    /// buffer (see [`crate::policy::seeded_init`]) so two constructions
96    /// with the same seed produce **bit-identical** actors. When `None`
97    /// (the default) Burn's unseedable [`Initializer`] path is used
98    /// verbatim.
99    pub seed: Option<u64>,
100}
101
102impl Default for SacActorConfig {
103    fn default() -> Self {
104        // SAC convention: 256-wide, 2-layer ReLU trunk.
105        Self {
106            num_layers: 2,
107            hidden_dim: 256,
108            use_orthogonal_init: true,
109            activation: BurnActivation::ReLU,
110            seed: None,
111        }
112    }
113}
114
115impl SacActorConfig {
116    /// Set the construction seed, enabling the deterministic host-side
117    /// init path in [`SacActor::with_config`].
118    ///
119    /// Builder-style; returns `self` for chaining:
120    /// `SacActorConfig::default().with_seed(42)`.
121    pub fn with_seed(mut self, seed: u64) -> Self {
122        self.seed = Some(seed);
123        self
124    }
125}
126
127/// Stochastic, tanh-squashed Gaussian actor for SAC continuous control.
128///
129/// Layout:
130///
131/// ```text
132/// obs → fc1 →act→ fc2 →act→ (fc3 →act→)? ┬─ mean_head    (μ)
133///                                        └─ log_std_head (log σ, clamped)
134/// ```
135///
136/// Both heads share the trunk activations. Use
137/// [`sample`](Self::sample) for a reparameterized stochastic action with
138/// its (squash-corrected) log-probability, and
139/// [`mean_action`](Self::mean_action) for the deterministic
140/// evaluation action `tanh(μ)`.
141#[derive(burn::module::Module, Debug)]
142pub struct SacActor<B: Backend> {
143    fc1: Linear<B>,
144    fc2: Linear<B>,
145    fc3: Option<Linear<B>>,
146    mean_head: Linear<B>,
147    log_std_head: Linear<B>,
148    activation: BurnActivation,
149}
150
151impl<B: Backend> SacActor<B> {
152    /// Build a fresh actor on `device` with the given configuration.
153    ///
154    /// `obs_dim` is the observation width; `action_dim` is the number of
155    /// continuous action dimensions (each squashed into `(-1, 1)`).
156    pub fn with_config(
157        obs_dim: usize,
158        action_dim: usize,
159        config: SacActorConfig,
160        device: &B::Device,
161    ) -> Self {
162        // Seeded path: build every layer from a deterministic
163        // `StdRng`-driven weight buffer so two constructions with the same
164        // seed are bit-identical. Layer indices are fixed:
165        // 0=fc1, 1=fc2, 2=fc3, 3=mean_head, 4=log_std_head.
166        if let Some(seed) = config.seed {
167            let orth = config.use_orthogonal_init;
168            let mk = |idx: u64, d_in: usize, d_out: usize, is_head: bool| {
169                let s = derive_layer_seed(seed, idx);
170                let w = seeded_layer_weights(s, d_in, d_out, orth, is_head);
171                linear_from_weights::<B>(d_in, d_out, &w, device)
172            };
173            let fc1 = mk(0, obs_dim, config.hidden_dim, false);
174            let fc2 = mk(1, config.hidden_dim, config.hidden_dim, false);
175            let fc3 = if config.num_layers >= 3 {
176                Some(mk(2, config.hidden_dim, config.hidden_dim, false))
177            } else {
178                None
179            };
180            let mean_head = mk(3, config.hidden_dim, action_dim, true);
181            let log_std_head = mk(4, config.hidden_dim, action_dim, true);
182            return Self { fc1, fc2, fc3, mean_head, log_std_head, activation: config.activation };
183        }
184
185        let hidden_init = if config.use_orthogonal_init {
186            Initializer::Orthogonal { gain: 2.0_f64.sqrt() }
187        } else {
188            Initializer::KaimingUniform { gain: 1.0_f64 / 3.0_f64.sqrt(), fan_out_only: false }
189        };
190        let head_init = if config.use_orthogonal_init {
191            Initializer::Orthogonal { gain: 0.01 }
192        } else {
193            Initializer::KaimingUniform { gain: 1.0_f64 / 3.0_f64.sqrt(), fan_out_only: false }
194        };
195
196        let fc1 = linear_with_init::<B>(obs_dim, config.hidden_dim, hidden_init.clone(), device);
197        let fc2 = linear_with_init::<B>(
198            config.hidden_dim,
199            config.hidden_dim,
200            hidden_init.clone(),
201            device,
202        );
203        let fc3 = if config.num_layers >= 3 {
204            Some(linear_with_init::<B>(config.hidden_dim, config.hidden_dim, hidden_init, device))
205        } else {
206            None
207        };
208        let mean_head =
209            linear_with_init::<B>(config.hidden_dim, action_dim, head_init.clone(), device);
210        let log_std_head = linear_with_init::<B>(config.hidden_dim, action_dim, head_init, device);
211
212        Self { fc1, fc2, fc3, mean_head, log_std_head, activation: config.activation }
213    }
214
215    fn apply_activation<const D: usize>(&self, x: Tensor<B, D>) -> Tensor<B, D> {
216        match self.activation {
217            BurnActivation::ReLU => activation::relu(x),
218            BurnActivation::Tanh => activation::tanh(x),
219        }
220    }
221
222    /// Shared-trunk feature representation for `obs` (shape
223    /// `[batch, obs_dim]` → `[batch, hidden_dim]`). Gradients flow back
224    /// into the trunk.
225    fn encoder_features(&self, obs: Tensor<B, 2>) -> Tensor<B, 2> {
226        let h = self.apply_activation(self.fc1.forward(obs));
227        let h = self.apply_activation(self.fc2.forward(h));
228        if let Some(fc3) = &self.fc3 {
229            self.apply_activation(fc3.forward(h))
230        } else {
231            h
232        }
233    }
234
235    /// Forward pass producing the Gaussian parameters
236    /// `(mean, log_std)`, each shape `[batch, action_dim]`.
237    ///
238    /// `log_std` is clamped to `[`[`LOG_STD_MIN`]`, `[`LOG_STD_MAX`]`]`
239    /// before being returned.
240    pub fn forward(&self, obs: Tensor<B, 2>) -> (Tensor<B, 2>, Tensor<B, 2>) {
241        let h = self.encoder_features(obs);
242        let mean = self.mean_head.forward(h.clone());
243        let log_std = self.log_std_head.forward(h).clamp(LOG_STD_MIN, LOG_STD_MAX);
244        (mean, log_std)
245    }
246
247    /// Deterministic evaluation action `tanh(μ)`, shape
248    /// `[batch, action_dim]`, lying in the open box `(-1, 1)`.
249    ///
250    /// Used at evaluation time when the stochastic exploration of
251    /// [`sample`](Self::sample) is undesirable.
252    pub fn mean_action(&self, obs: Tensor<B, 2>) -> Tensor<B, 2> {
253        let (mean, _log_std) = self.forward(obs);
254        activation::tanh(mean)
255    }
256
257    /// Draw one reparameterized, tanh-squashed action per row and return
258    /// `(action, log_prob)`.
259    ///
260    /// * `action` is shape `[batch, action_dim]`, every element in the open box
261    ///   `(-1, 1)`.
262    /// * `log_prob` is shape `[batch]` — the joint log-density over all action
263    ///   dimensions, with the tanh-squash change-of-variables correction
264    ///   applied.
265    ///
266    /// The Gaussian noise `eps` is drawn on the host from `rng` (via the
267    /// Box–Muller transform, matching [`crate::policy::seeded_init`]) and
268    /// injected as a constant tensor, so gradients still flow through
269    /// `mean` and `std` (the reparameterization trick) while the sample
270    /// stays bit-exactly reproducible for a given seed — mirroring
271    /// [`crate::policy::mlp::MlpBurnPolicy::get_action_host_seeded`].
272    pub fn sample(&self, obs: Tensor<B, 2>, rng: &mut StdRng) -> (Tensor<B, 2>, Tensor<B, 1>) {
273        let (mean, log_std) = self.forward(obs);
274        let dims = mean.dims();
275        let [batch, action_dim] = dims;
276        let device = mean.device();
277
278        // Host-side standard-normal noise, one draw per (row, dim).
279        let mut eps_data = Vec::with_capacity(batch * action_dim);
280        for _ in 0..(batch * action_dim) {
281            eps_data.push(standard_normal(rng));
282        }
283        let eps = Tensor::<B, 2>::from_data(
284            burn::tensor::TensorData::new(eps_data, [batch, action_dim]),
285            &device,
286        );
287
288        let std = log_std.clone().exp();
289        // Reparameterized pre-squash sample u = mean + std * eps.
290        let u = mean.clone() + std.clone() * eps;
291        let action = activation::tanh(u.clone());
292
293        // Gaussian log-prob of u under N(mean, std^2), summed over dims:
294        //   log N(u) = -0.5 * ((u - mean)/std)^2 - log_std - 0.5*log(2π)
295        let norm = (u.clone() - mean) / std;
296        let log_two_pi = (2.0 * std::f32::consts::PI).ln();
297        let gaussian_log_prob = (norm.clone() * norm) * (-0.5) - log_std - (0.5 * log_two_pi);
298        let gaussian_log_prob = gaussian_log_prob.sum_dim(1).squeeze_dim::<1>(1);
299
300        // Tanh-squash correction: subtract sum_i log(1 - tanh(u_i)^2 + eps).
301        let tanh_u = action.clone();
302        let one_minus_sq = -(tanh_u.clone() * tanh_u) + 1.0 + TANH_CORRECTION_EPS;
303        let correction = one_minus_sq.log().sum_dim(1).squeeze_dim::<1>(1);
304
305        let log_prob = gaussian_log_prob - correction;
306        (action, log_prob)
307    }
308
309    /// Borrow the first shared-trunk linear layer.
310    pub fn fc1(&self) -> &Linear<B> {
311        &self.fc1
312    }
313
314    /// Borrow the second shared-trunk linear layer.
315    pub fn fc2(&self) -> &Linear<B> {
316        &self.fc2
317    }
318
319    /// Borrow the mean head.
320    pub fn mean_head(&self) -> &Linear<B> {
321        &self.mean_head
322    }
323
324    /// Borrow the log-std head.
325    pub fn log_std_head(&self) -> &Linear<B> {
326        &self.log_std_head
327    }
328}
329
330/// Draw a single `N(0, 1)` sample from `rng` via the Box–Muller
331/// transform.
332///
333/// Identical recipe to [`crate::policy::seeded_init`]'s private sampler:
334/// the only RNG draws are `f32` uniforms, so two identically-seeded
335/// [`StdRng`](rand::rngs::StdRng)s yield identical noise sequences (and
336/// hence identical reparameterized actions).
337fn standard_normal(rng: &mut StdRng) -> f32 {
338    let u1: f32 = {
339        let x: f32 = rng.random();
340        if x <= f32::MIN_POSITIVE {
341            f32::MIN_POSITIVE
342        } else {
343            x
344        }
345    };
346    let u2: f32 = rng.random();
347    let r = (-2.0_f32 * u1.ln()).sqrt();
348    let theta = 2.0_f32 * std::f32::consts::PI * u2;
349    r * theta.cos()
350}
351
352#[cfg(test)]
353mod tests {
354    use burn::backend::{Autodiff, NdArray};
355    use rand::SeedableRng;
356
357    use super::*;
358
359    type B = Autodiff<NdArray<f32>>;
360
361    fn obs_batch(
362        batch: usize,
363        obs_dim: usize,
364        device: &burn::backend::ndarray::NdArrayDevice,
365    ) -> Tensor<B, 2> {
366        let n = batch * obs_dim;
367        let data: Vec<f32> = (0..n).map(|i| (i as f32) * 0.01 - 0.3).collect();
368        Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(data, [batch, obs_dim]), device)
369    }
370
371    #[test]
372    fn test_construction_two_layer() {
373        let device = Default::default();
374        let actor = SacActor::<B>::with_config(3, 1, SacActorConfig::default(), &device);
375        assert!(actor.fc3.is_none());
376    }
377
378    #[test]
379    fn test_construction_three_layer() {
380        let device = Default::default();
381        let cfg = SacActorConfig { num_layers: 3, ..Default::default() };
382        let actor = SacActor::<B>::with_config(3, 2, cfg, &device);
383        assert!(actor.fc3.is_some());
384    }
385
386    #[test]
387    fn test_forward_shapes_two_layer() {
388        let device = Default::default();
389        let actor = SacActor::<B>::with_config(5, 3, SacActorConfig::default(), &device);
390        let obs = obs_batch(8, 5, &device);
391        let (mean, log_std) = actor.forward(obs);
392        assert_eq!(mean.dims(), [8, 3]);
393        assert_eq!(log_std.dims(), [8, 3]);
394    }
395
396    #[test]
397    fn test_forward_shapes_three_layer() {
398        let device = Default::default();
399        let cfg = SacActorConfig { num_layers: 3, hidden_dim: 32, ..Default::default() };
400        let actor = SacActor::<B>::with_config(5, 3, cfg, &device);
401        let obs = obs_batch(8, 5, &device);
402        let (mean, log_std) = actor.forward(obs);
403        assert_eq!(mean.dims(), [8, 3]);
404        assert_eq!(log_std.dims(), [8, 3]);
405    }
406
407    #[test]
408    fn test_log_std_is_clamped() {
409        // Large-magnitude obs would push an unclamped log_std head far
410        // out of range; verify the clamp holds.
411        let device = Default::default();
412        let cfg =
413            SacActorConfig { hidden_dim: 16, use_orthogonal_init: false, ..Default::default() };
414        let actor = SacActor::<B>::with_config(4, 2, cfg, &device);
415        let data: Vec<f32> = vec![100.0; 4 * 4];
416        let obs = Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(data, [4, 4]), &device);
417        let (_mean, log_std) = actor.forward(obs);
418        let vals: Vec<f32> = log_std.into_data().to_vec().unwrap();
419        for v in vals {
420            assert!((LOG_STD_MIN..=LOG_STD_MAX).contains(&v), "log_std {v} outside clamp range");
421        }
422    }
423
424    #[test]
425    fn test_mean_action_in_range_and_shape() {
426        let device = Default::default();
427        let actor = SacActor::<B>::with_config(4, 3, SacActorConfig::default(), &device);
428        let obs = obs_batch(6, 4, &device);
429        let action = actor.mean_action(obs);
430        assert_eq!(action.dims(), [6, 3]);
431        let vals: Vec<f32> = action.into_data().to_vec().unwrap();
432        for v in vals {
433            assert!(v > -1.0 && v < 1.0, "mean action {v} not in (-1, 1)");
434        }
435    }
436
437    #[test]
438    fn test_sample_actions_in_range_logprob_finite() {
439        let device = Default::default();
440        let actor =
441            SacActor::<B>::with_config(4, 3, SacActorConfig::default().with_seed(7), &device);
442        let obs = obs_batch(10, 4, &device);
443        let mut rng = StdRng::seed_from_u64(123);
444        let (action, log_prob) = actor.sample(obs, &mut rng);
445        assert_eq!(action.dims(), [10, 3]);
446        assert_eq!(log_prob.dims(), [10]);
447
448        let acts: Vec<f32> = action.into_data().to_vec().unwrap();
449        for v in acts {
450            assert!(v > -1.0 && v < 1.0, "sampled action {v} not in (-1, 1)");
451        }
452        let lps: Vec<f32> = log_prob.into_data().to_vec().unwrap();
453        for v in lps {
454            assert!(v.is_finite(), "log_prob {v} not finite");
455        }
456    }
457
458    /// Same-seed [`SacActor::sample`] is bit-identical; a different seed
459    /// differs — mirroring the `get_action_host_seeded` reproducibility
460    /// guarantee that the SAC trainer's `seed` will rely on.
461    #[test]
462    fn test_sample_is_bit_exact_in_seed() {
463        let device = Default::default();
464        let actor =
465            SacActor::<B>::with_config(4, 2, SacActorConfig::default().with_seed(11), &device);
466        let obs = obs_batch(5, 4, &device);
467
468        let mut rng_a = StdRng::seed_from_u64(42);
469        let mut rng_b = StdRng::seed_from_u64(42);
470        let (a_a, lp_a) = actor.sample(obs.clone(), &mut rng_a);
471        let (a_b, lp_b) = actor.sample(obs.clone(), &mut rng_b);
472        let a_a: Vec<f32> = a_a.into_data().to_vec().unwrap();
473        let a_b: Vec<f32> = a_b.into_data().to_vec().unwrap();
474        let lp_a: Vec<f32> = lp_a.into_data().to_vec().unwrap();
475        let lp_b: Vec<f32> = lp_b.into_data().to_vec().unwrap();
476        assert_eq!(a_a, a_b, "same-seed actions must be bit-identical");
477        assert_eq!(lp_a, lp_b, "same-seed log_probs must be bit-identical");
478
479        let mut rng_c = StdRng::seed_from_u64(99);
480        let (a_c, _) = actor.sample(obs, &mut rng_c);
481        let a_c: Vec<f32> = a_c.into_data().to_vec().unwrap();
482        assert_ne!(a_a, a_c, "different-seed actions must differ");
483    }
484
485    /// Construction is bit-identical under the same seed and differs
486    /// under a different seed.
487    #[test]
488    fn test_with_seed_construction_is_bit_exact() {
489        let device = Default::default();
490        let obs = obs_batch(4, 4, &device);
491
492        let a = SacActor::<B>::with_config(4, 2, SacActorConfig::default().with_seed(5), &device);
493        let b = SacActor::<B>::with_config(4, 2, SacActorConfig::default().with_seed(5), &device);
494        let c = SacActor::<B>::with_config(4, 2, SacActorConfig::default().with_seed(6), &device);
495
496        let (ma, _) = a.forward(obs.clone());
497        let (mb, _) = b.forward(obs.clone());
498        let (mc, _) = c.forward(obs);
499        let ma: Vec<f32> = ma.into_data().to_vec().unwrap();
500        let mb: Vec<f32> = mb.into_data().to_vec().unwrap();
501        let mc: Vec<f32> = mc.into_data().to_vec().unwrap();
502        assert_eq!(ma, mb, "same seed must build bit-identical actors");
503        assert_ne!(ma, mc, "different seed must build different actors");
504    }
505}