1use 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
55pub const LOG_STD_MIN: f32 = -20.0;
63
64pub const LOG_STD_MAX: f32 = 2.0;
66
67const TANH_CORRECTION_EPS: f32 = 1e-6;
71
72#[derive(Debug, Clone, Copy)]
78pub struct SacActorConfig {
79 pub num_layers: usize,
83 pub hidden_dim: usize,
85 pub use_orthogonal_init: bool,
90 pub activation: BurnActivation,
92 pub seed: Option<u64>,
100}
101
102impl Default for SacActorConfig {
103 fn default() -> Self {
104 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 pub fn with_seed(mut self, seed: u64) -> Self {
122 self.seed = Some(seed);
123 self
124 }
125}
126
127#[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 pub fn with_config(
157 obs_dim: usize,
158 action_dim: usize,
159 config: SacActorConfig,
160 device: &B::Device,
161 ) -> Self {
162 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 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 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 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 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 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 let u = mean.clone() + std.clone() * eps;
291 let action = activation::tanh(u.clone());
292
293 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 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 pub fn fc1(&self) -> &Linear<B> {
311 &self.fc1
312 }
313
314 pub fn fc2(&self) -> &Linear<B> {
316 &self.fc2
317 }
318
319 pub fn mean_head(&self) -> &Linear<B> {
321 &self.mean_head
322 }
323
324 pub fn log_std_head(&self) -> &Linear<B> {
326 &self.log_std_head
327 }
328}
329
330fn 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 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 #[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 #[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}