1use anyhow::{Result, anyhow};
46use burn::{
47 grad_clipping::GradientClippingConfig,
48 module::{Module, Param},
49 optim::{Adam, AdamConfig, GradientsParams, Optimizer, adaptor::OptimizerAdaptor},
50 prelude::ToElement,
51 tensor::{
52 Tensor,
53 backend::{AutodiffBackend, Backend},
54 },
55};
56use rand::{Rng, SeedableRng, rngs::StdRng};
57
58use super::config::SacConfig;
59use crate::{
60 buffer::replay::{ContinuousReplayBuffer, sample_continuous},
61 policy::{
62 continuous_q::{ContinuousQNetwork, ContinuousQNetworkConfig},
63 mlp::BurnActivation,
64 sac_actor::{SacActor, SacActorConfig},
65 },
66};
67
68#[derive(Module, Debug)]
76pub struct LogAlpha<B: Backend> {
77 value: Param<Tensor<B, 1>>,
78}
79
80impl<B: Backend> LogAlpha<B> {
81 pub fn new(init_alpha: f32, device: &B::Device) -> Self {
83 let data = burn::tensor::TensorData::new(vec![init_alpha.ln()], [1]);
84 let tensor = Tensor::<B, 1>::from_data(data, device);
85 Self { value: Param::from_tensor(tensor) }
86 }
87
88 pub fn value(&self) -> Tensor<B, 1> {
90 self.value.val()
91 }
92
93 pub fn alpha_scalar(&self) -> f64 {
95 self.value.val().exp().into_scalar().to_f64()
96 }
97}
98
99#[derive(Debug, Clone, Copy)]
104pub struct SacStepStats {
105 pub critic_loss: f64,
107 pub actor_loss: f64,
109 pub alpha_loss: f64,
111 pub alpha: f64,
113 pub mean_q: f64,
115 pub buffer_len: usize,
117}
118
119type SacAdam<B, M> = OptimizerAdaptor<Adam, M, B>;
121
122pub struct SacTrainer<B: AutodiffBackend> {
130 config: SacConfig,
131 obs_dim: usize,
132 action_dim: usize,
133 target_entropy: f32,
134
135 actor: Option<SacActor<B>>,
136 q1: Option<ContinuousQNetwork<B>>,
137 q2: Option<ContinuousQNetwork<B>>,
138 q1_target: ContinuousQNetwork<B>,
139 q2_target: ContinuousQNetwork<B>,
140 log_alpha: Option<LogAlpha<B>>,
141
142 actor_opt: SacAdam<B, SacActor<B>>,
143 critic_opt: SacAdam<B, ContinuousQNetwork<B>>,
144 alpha_opt: SacAdam<B, LogAlpha<B>>,
145
146 buffer: ContinuousReplayBuffer,
147 rng: StdRng,
148 device: B::Device,
149 total_env_steps: usize,
150 total_train_steps: usize,
151 total_episodes: usize,
152}
153
154impl<B: AutodiffBackend> SacTrainer<B> {
155 pub fn new(
163 config: SacConfig,
164 obs_dim: usize,
165 action_dim: usize,
166 device: B::Device,
167 ) -> Result<Self> {
168 config.validate()?;
169 if obs_dim == 0 {
170 return Err(anyhow!("obs_dim must be positive"));
171 }
172 if action_dim == 0 {
173 return Err(anyhow!("action_dim must be positive"));
174 }
175
176 let target_entropy = config.resolved_target_entropy(action_dim);
177
178 let actor_cfg = SacActorConfig {
179 num_layers: config.num_hidden_layers,
180 hidden_dim: config.hidden_dim,
181 use_orthogonal_init: true,
182 activation: BurnActivation::ReLU,
183 seed: Some(config.seed),
184 };
185 let actor = SacActor::<B>::with_config(obs_dim, action_dim, actor_cfg, &device);
186
187 let q1_cfg = ContinuousQNetworkConfig {
189 num_layers: config.num_hidden_layers,
190 hidden_dim: config.hidden_dim,
191 use_orthogonal_init: true,
192 activation: BurnActivation::ReLU,
193 seed: Some(config.seed.wrapping_add(1)),
194 };
195 let q2_cfg = ContinuousQNetworkConfig { seed: Some(config.seed.wrapping_add(2)), ..q1_cfg };
196 let q1 = ContinuousQNetwork::<B>::with_config(obs_dim, action_dim, q1_cfg, &device);
197 let q2 = ContinuousQNetwork::<B>::with_config(obs_dim, action_dim, q2_cfg, &device);
198
199 let q1_target = ContinuousQNetwork::<B>::with_config(obs_dim, action_dim, q1_cfg, &device)
201 .copy_params_from(&q1);
202 let q2_target = ContinuousQNetwork::<B>::with_config(obs_dim, action_dim, q2_cfg, &device)
203 .copy_params_from(&q2);
204
205 let log_alpha = LogAlpha::<B>::new(config.init_alpha, &device);
206
207 let actor_opt = build_adam::<B, SacActor<B>>(config.max_grad_norm);
208 let critic_opt = build_adam::<B, ContinuousQNetwork<B>>(config.max_grad_norm);
209 let alpha_opt = build_adam::<B, LogAlpha<B>>(config.max_grad_norm);
210
211 let buffer = ContinuousReplayBuffer::new(config.buffer_capacity, obs_dim, action_dim);
212 let rng = StdRng::seed_from_u64(config.seed);
213
214 Ok(Self {
215 config,
216 obs_dim,
217 action_dim,
218 target_entropy,
219 actor: Some(actor),
220 q1: Some(q1),
221 q2: Some(q2),
222 q1_target,
223 q2_target,
224 log_alpha: Some(log_alpha),
225 actor_opt,
226 critic_opt,
227 alpha_opt,
228 buffer,
229 rng,
230 device,
231 total_env_steps: 0,
232 total_train_steps: 0,
233 total_episodes: 0,
234 })
235 }
236
237 pub fn config(&self) -> &SacConfig {
239 &self.config
240 }
241
242 pub fn obs_dim(&self) -> usize {
244 self.obs_dim
245 }
246
247 pub fn action_dim(&self) -> usize {
249 self.action_dim
250 }
251
252 pub fn target_entropy(&self) -> f32 {
254 self.target_entropy
255 }
256
257 pub fn actor(&self) -> &SacActor<B> {
259 self.actor.as_ref().expect("actor is None mid-step")
260 }
261
262 pub fn q1(&self) -> &ContinuousQNetwork<B> {
264 self.q1.as_ref().expect("q1 is None mid-step")
265 }
266
267 pub fn q2(&self) -> &ContinuousQNetwork<B> {
269 self.q2.as_ref().expect("q2 is None mid-step")
270 }
271
272 pub fn buffer(&self) -> &ContinuousReplayBuffer {
274 &self.buffer
275 }
276
277 pub fn buffer_mut(&mut self) -> &mut ContinuousReplayBuffer {
279 &mut self.buffer
280 }
281
282 pub fn buffer_len(&self) -> usize {
284 self.buffer.len()
285 }
286
287 pub fn alpha(&self) -> f64 {
289 self.log_alpha.as_ref().expect("log_alpha is None mid-step").alpha_scalar()
290 }
291
292 pub fn total_env_steps(&self) -> usize {
294 self.total_env_steps
295 }
296
297 pub fn total_train_steps(&self) -> usize {
299 self.total_train_steps
300 }
301
302 pub fn total_episodes(&self) -> usize {
304 self.total_episodes
305 }
306
307 pub fn increment_env_step(&mut self) {
309 self.total_env_steps += 1;
310 }
311
312 pub fn increment_episodes(&mut self, n: usize) {
314 self.total_episodes += n;
315 }
316
317 pub fn in_warmup(&self) -> bool {
320 self.total_env_steps < self.config.learning_starts
321 }
322
323 pub fn select_action(&mut self, obs: &[f32]) -> Vec<f32> {
331 if self.in_warmup() {
332 (0..self.action_dim).map(|_| self.rng.random_range(-1.0..1.0)).collect()
333 } else {
334 let obs_t = Tensor::<B, 2>::from_data(
335 burn::tensor::TensorData::new(obs.to_vec(), [1, self.obs_dim]),
336 &self.device,
337 );
338 let actor = self.actor.as_ref().expect("actor is None mid-step");
339 let (action, _log_prob) = actor.sample(obs_t, &mut self.rng);
340 action.into_data().to_vec().expect("action tensor to host vec")
341 }
342 }
343
344 pub fn eval_action(&self, obs: &[f32]) -> Vec<f32> {
347 let obs_t = Tensor::<B, 2>::from_data(
348 burn::tensor::TensorData::new(obs.to_vec(), [1, self.obs_dim]),
349 &self.device,
350 );
351 let actor = self.actor.as_ref().expect("actor is None mid-step");
352 actor
353 .mean_action(obs_t)
354 .into_data()
355 .to_vec()
356 .expect("action tensor to host vec")
357 }
358
359 pub fn train(&mut self) -> Result<Option<SacStepStats>> {
366 if !self.buffer.is_ready(self.config.min_buffer_size) {
367 return Ok(None);
368 }
369 let mut last = None;
370 for _ in 0..self.config.gradient_steps_per_env_step {
371 last = Some(self.train_step()?);
372 }
373 Ok(last)
374 }
375
376 pub fn train_step(&mut self) -> Result<SacStepStats> {
383 let batch = sample_continuous(&self.buffer, self.config.batch_size, &mut self.rng);
384 let buffer_len = self.buffer.len();
385 let t = batch.to_burn_tensors::<B>(&self.device);
386
387 let gamma = self.config.gamma as f32;
388
389 let mut actor = self.actor.take().ok_or_else(|| anyhow!("actor None; reentrant step?"))?;
390 let mut q1 = self.q1.take().ok_or_else(|| anyhow!("q1 None; reentrant step?"))?;
391 let mut q2 = self.q2.take().ok_or_else(|| anyhow!("q2 None; reentrant step?"))?;
392 let mut log_alpha = self
393 .log_alpha
394 .take()
395 .ok_or_else(|| anyhow!("log_alpha None; reentrant step?"))?;
396
397 let alpha = log_alpha.value().exp().detach().into_scalar().to_f32();
398
399 let (next_action, next_log_prob) = actor.sample(t.next_observations.clone(), &mut self.rng);
404 let next_action = next_action.detach();
405 let next_log_prob = next_log_prob.detach();
406
407 let q1_t = self.q1_target.forward(t.next_observations.clone(), next_action.clone());
408 let q2_t = self.q2_target.forward(t.next_observations.clone(), next_action);
409 let min_q_next = min_pair(q1_t, q2_t);
410 let soft_value = min_q_next - next_log_prob.mul_scalar(alpha);
411
412 let not_done = -t.dones.clone() + 1.0;
414 let td_target = (t.rewards.clone() + soft_value.mul_scalar(gamma) * not_done).detach();
415
416 let q1_pred = q1.forward(t.observations.clone(), t.actions.clone());
417 let q2_pred = q2.forward(t.observations.clone(), t.actions.clone());
418
419 let mean_q_val = min_pair(q1_pred.clone(), q2_pred.clone())
422 .mean()
423 .detach()
424 .into_scalar()
425 .to_f64();
426
427 let critic1_loss = mse(q1_pred, td_target.clone());
428 let critic2_loss = mse(q2_pred, td_target);
429 let critic_loss = critic1_loss + critic2_loss;
436 let critic_loss_val = critic_loss.clone().detach().into_scalar().to_f64() / 2.0;
437 if !critic_loss_val.is_finite() {
438 return Err(anyhow!("Non-finite critic loss: {}", critic_loss_val));
439 }
440 let mut critic_grads = critic_loss.backward();
441 let grads1 = GradientsParams::from_module(&mut critic_grads, &q1);
442 let grads2 = GradientsParams::from_module(&mut critic_grads, &q2);
443 q1 = self.critic_opt.step(self.config.critic_lr, q1, grads1);
444 q2 = self.critic_opt.step(self.config.critic_lr, q2, grads2);
445
446 let (pi_action, pi_log_prob) = actor.sample(t.observations.clone(), &mut self.rng);
452 let q1_pi = q1.forward(t.observations.clone(), pi_action.clone());
453 let q2_pi = q2.forward(t.observations.clone(), pi_action);
454 let min_q_pi = min_pair(q1_pi, q2_pi);
455 let entropy_gap = pi_log_prob.clone().add_scalar(self.target_entropy).detach();
459 let actor_loss = (pi_log_prob.mul_scalar(alpha) - min_q_pi).mean();
461 let actor_loss_val = actor_loss.clone().detach().into_scalar().to_f64();
462 if !actor_loss_val.is_finite() {
463 return Err(anyhow!("Non-finite actor loss: {}", actor_loss_val));
464 }
465 let actor_grads = GradientsParams::from_grads(actor_loss.backward(), &actor);
466 actor = self.actor_opt.step(self.config.actor_lr, actor, actor_grads);
467
468 let mut alpha_loss_val = 0.0;
470 if self.config.auto_alpha {
471 let log_alpha_t = log_alpha.value();
473 let alpha_loss = -(log_alpha_t * entropy_gap).mean();
475 alpha_loss_val = alpha_loss.clone().detach().into_scalar().to_f64();
476 if !alpha_loss_val.is_finite() {
477 return Err(anyhow!("Non-finite alpha loss: {}", alpha_loss_val));
478 }
479 let alpha_grads = GradientsParams::from_grads(alpha_loss.backward(), &log_alpha);
480 log_alpha = self.alpha_opt.step(self.config.alpha_lr, log_alpha, alpha_grads);
481 }
482
483 self.q1_target.soft_update_from(&q1, self.config.tau);
485 self.q2_target.soft_update_from(&q2, self.config.tau);
486
487 let alpha_after = log_alpha.alpha_scalar();
488
489 self.actor = Some(actor);
490 self.q1 = Some(q1);
491 self.q2 = Some(q2);
492 self.log_alpha = Some(log_alpha);
493 self.total_train_steps += 1;
494
495 Ok(SacStepStats {
496 critic_loss: critic_loss_val,
497 actor_loss: actor_loss_val,
498 alpha_loss: alpha_loss_val,
499 alpha: alpha_after,
500 mean_q: mean_q_val,
501 buffer_len,
502 })
503 }
504}
505
506fn build_adam<B, M>(max_grad_norm: Option<f64>) -> SacAdam<B, M>
509where
510 B: AutodiffBackend,
511 M: burn::module::AutodiffModule<B>,
512{
513 let mut cfg = AdamConfig::new();
514 if let Some(norm) = max_grad_norm {
515 cfg = cfg.with_grad_clipping(Some(GradientClippingConfig::Norm(norm as f32)));
516 }
517 cfg.init()
518}
519
520fn min_pair<B: AutodiffBackend>(a: Tensor<B, 1>, b: Tensor<B, 1>) -> Tensor<B, 1> {
523 let diff = a - b.clone();
524 b + diff.clamp_max(0.0)
525}
526
527fn mse<B: AutodiffBackend>(pred: Tensor<B, 1>, target: Tensor<B, 1>) -> Tensor<B, 1> {
530 let diff = pred - target;
531 (diff.clone() * diff).mean()
532}
533
534#[cfg(test)]
535mod tests {
536 use burn::backend::{Autodiff, NdArray};
537
538 use super::*;
539
540 type B = Autodiff<NdArray<f32>>;
541
542 fn tiny_config() -> SacConfig {
543 SacConfig::new()
544 .buffer_capacity(256)
545 .min_buffer_size(8)
546 .batch_size(8)
547 .learning_starts(4)
548 .hidden_dim(16)
549 .seed(7)
550 }
551
552 fn fill_buffer(trainer: &mut SacTrainer<B>, n: usize) {
553 for i in 0..n {
554 let phase = i as f32 * 0.1;
555 let obs = [phase.cos(), phase.sin(), phase * 0.2];
556 let next_obs = [(phase + 0.1).cos(), (phase + 0.1).sin(), phase * 0.2];
557 let action = [(phase.sin()).clamp(-0.99, 0.99)];
558 let reward = -(phase * phase);
559 let done = i % 5 == 4;
560 trainer.buffer_mut().push(&obs, &action, reward, &next_obs, done);
561 }
562 }
563
564 #[test]
565 fn trainer_constructs_and_copies_targets() {
566 let device = Default::default();
567 let trainer = SacTrainer::<B>::new(tiny_config(), 3, 1, device).unwrap();
568 assert_eq!(trainer.total_env_steps(), 0);
569 assert_eq!(trainer.buffer_len(), 0);
570 assert_eq!(trainer.target_entropy(), -1.0);
571
572 let obs = Tensor::<B, 2>::from_data(
574 burn::tensor::TensorData::new(vec![0.1, 0.2, 0.3], [1, 3]),
575 &Default::default(),
576 );
577 let act = Tensor::<B, 2>::from_data(
578 burn::tensor::TensorData::new(vec![0.4], [1, 1]),
579 &Default::default(),
580 );
581 let on: f32 = trainer.q1().forward(obs.clone(), act.clone()).into_scalar().to_f32();
582 let tg: f32 = trainer.q1_target.forward(obs, act).into_scalar().to_f32();
583 assert!((on - tg).abs() < 1e-6, "target must start equal to online critic");
584 }
585
586 #[test]
587 fn rejects_invalid_config() {
588 let device = Default::default();
589 let bad = SacConfig::new().gamma(2.0);
590 assert!(SacTrainer::<B>::new(bad, 3, 1, device).is_err());
591 }
592
593 #[test]
594 fn rejects_zero_dims() {
595 let device = Default::default();
596 assert!(SacTrainer::<B>::new(tiny_config(), 0, 1, device).is_err());
597 let device2 = Default::default();
598 assert!(SacTrainer::<B>::new(tiny_config(), 3, 0, device2).is_err());
599 }
600
601 #[test]
602 fn train_returns_none_until_ready() {
603 let device = Default::default();
604 let mut trainer = SacTrainer::<B>::new(tiny_config(), 3, 1, device).unwrap();
605 fill_buffer(&mut trainer, 4); assert!(trainer.train().unwrap().is_none());
607 }
608
609 #[test]
610 fn one_train_step_runs_with_finite_losses() {
611 let device = Default::default();
612 let mut trainer = SacTrainer::<B>::new(tiny_config(), 3, 1, device).unwrap();
613 fill_buffer(&mut trainer, 32);
614 let stats = trainer.train().unwrap().expect("should train once buffer ready");
615 assert!(stats.critic_loss.is_finite(), "critic loss finite");
616 assert!(stats.actor_loss.is_finite(), "actor loss finite");
617 assert!(stats.alpha_loss.is_finite(), "alpha loss finite");
618 assert!(stats.alpha.is_finite() && stats.alpha > 0.0, "alpha positive finite");
619 assert!(stats.mean_q.is_finite(), "mean_q finite");
620 assert_eq!(stats.buffer_len, 32);
621 assert_eq!(trainer.total_train_steps(), 1);
622 }
623
624 #[test]
625 fn fixed_alpha_keeps_alpha_constant() {
626 let device = Default::default();
627 let cfg = tiny_config().auto_alpha(false).init_alpha(0.3);
628 let mut trainer = SacTrainer::<B>::new(cfg, 3, 1, device).unwrap();
629 fill_buffer(&mut trainer, 32);
630 let before = trainer.alpha();
631 for _ in 0..5 {
632 trainer.train().unwrap();
633 }
634 let after = trainer.alpha();
635 assert!((before - after).abs() < 1e-9, "fixed alpha must not move: {before} -> {after}");
636 assert!((after - 0.3).abs() < 1e-6);
637 }
638
639 #[test]
640 fn target_moves_after_step() {
641 let device = Default::default();
642 let mut trainer = SacTrainer::<B>::new(tiny_config(), 3, 1, device).unwrap();
643 fill_buffer(&mut trainer, 32);
644
645 let obs = Tensor::<B, 2>::from_data(
646 burn::tensor::TensorData::new(vec![0.1, 0.2, 0.3], [1, 3]),
647 &Default::default(),
648 );
649 let act = Tensor::<B, 2>::from_data(
650 burn::tensor::TensorData::new(vec![0.4], [1, 1]),
651 &Default::default(),
652 );
653 let tg_before: f32 =
654 trainer.q1_target.forward(obs.clone(), act.clone()).into_scalar().to_f32();
655 for _ in 0..5 {
656 trainer.train().unwrap();
657 }
658 let tg_after: f32 = trainer.q1_target.forward(obs, act).into_scalar().to_f32();
659 assert!(
660 (tg_before - tg_after).abs() > 1e-7,
661 "soft update should move target: {tg_before} -> {tg_after}"
662 );
663 }
664
665 #[test]
666 fn select_action_warmup_then_policy_in_range() {
667 let device = Default::default();
668 let mut trainer = SacTrainer::<B>::new(tiny_config(), 3, 1, device).unwrap();
669 let a = trainer.select_action(&[0.1, 0.2, 0.3]);
671 assert_eq!(a.len(), 1);
672 assert!(a[0] > -1.0 && a[0] < 1.0);
673 for _ in 0..trainer.config().learning_starts {
675 trainer.increment_env_step();
676 }
677 assert!(!trainer.in_warmup());
678 let a = trainer.select_action(&[0.1, 0.2, 0.3]);
679 assert_eq!(a.len(), 1);
680 assert!(a[0] > -1.0 && a[0] < 1.0);
681 let e = trainer.eval_action(&[0.1, 0.2, 0.3]);
683 assert!(e[0] > -1.0 && e[0] < 1.0);
684 }
685}