use crate::{DType, Engine, Error, Matrix, Result, matrix};
use super::{ItTraining, ItTrainingConfig, Optimizer, target};
use crate::ml::{GradientTape, Module, ReplayBuffer, loss, policy};
#[derive(Clone, Debug, PartialEq)]
pub struct SacTrainerConfig {
pub updates: u64,
pub batch_size: usize,
pub action_dimensions: usize,
pub target_update_interval: u64,
pub observation_shape: Vec<usize>,
pub action_minimum: f32,
pub action_maximum: f32,
pub seed: u64,
pub loss: loss::SacLossConfig,
}
impl Default for SacTrainerConfig {
fn default() -> Self {
Self {
updates: 0,
batch_size: 0,
action_dimensions: 0,
target_update_interval: 1,
observation_shape: Vec::new(),
action_minimum: -1.0,
action_maximum: 1.0,
seed: 0,
loss: loss::SacLossConfig::default(),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct SacTrainerMetrics {
pub update: u64,
pub actor_loss: f32,
pub critic_loss: f32,
}
#[must_use]
pub struct SacTrainer<'engine, 'hooks> {
engine: &'engine Engine,
actor: &'engine dyn Module,
critic1: &'engine dyn Module,
critic2: &'engine dyn Module,
target_critic1: &'engine dyn Module,
target_critic2: &'engine dyn Module,
replay: &'engine ReplayBuffer,
config: SacTrainerConfig,
observation_elements: usize,
critic_training: ItTraining<'engine, 'hooks>,
actor_training: ItTraining<'engine, 'hooks>,
metrics: SacTrainerMetrics,
}
impl<'engine, 'hooks> SacTrainer<'engine, 'hooks> {
#[allow(
clippy::too_many_arguments,
reason = "the SAC owner contract keeps actor, twin critics, twin targets, and optimizers explicit"
)]
pub fn new(
engine: &'engine Engine,
actor: &'engine dyn Module,
critic1: &'engine dyn Module,
critic2: &'engine dyn Module,
target_critic1: &'engine dyn Module,
target_critic2: &'engine dyn Module,
actor_optimizer: &'engine mut dyn Optimizer,
critic_optimizer: &'engine mut dyn Optimizer,
replay: &'engine ReplayBuffer,
config: SacTrainerConfig,
) -> Result<Self> {
validate_config(replay, &config)?;
let observation_elements = shape_elements(&config.observation_shape, "observation")?;
target::sync_exact(
engine,
&[(critic1, target_critic1), (critic2, target_critic2)],
"SAC critic",
)?;
let batch_size = u64::try_from(config.batch_size)
.map_err(|_| Error::resource_exhausted("SAC batch size exceeds u64"))?;
let critic_training = ItTraining::new_eager(
engine,
critic_optimizer,
ItTrainingConfig {
total_steps: config.updates,
batch_size,
timer_name: "sac_critic_update".into(),
..ItTrainingConfig::default()
},
)?;
let actor_training = ItTraining::new_eager(
engine,
actor_optimizer,
ItTrainingConfig {
total_steps: config.updates,
batch_size,
timer_name: "sac_actor_update".into(),
..ItTrainingConfig::default()
},
)?;
Ok(Self {
engine,
actor,
critic1,
critic2,
target_critic1,
target_critic2,
replay,
config,
observation_elements,
critic_training,
actor_training,
metrics: SacTrainerMetrics::default(),
})
}
pub fn update(&mut self) -> Result<bool> {
if self.is_done() {
return Ok(false);
}
if self.replay.len() < self.config.batch_size {
return Err(Error::failed_precondition(
"SAC replay does not contain one complete batch",
));
}
if !self.actor_training.begin_step()? {
self.critic_training.request_stop();
return Ok(false);
}
if !self.critic_training.begin_step()? {
self.actor_training.cancel_composed_step();
return Ok(false);
}
if let Err(error) = self.record_update() {
self.engine.abort_pending_work();
self.actor_training.cancel_composed_step();
return self.critic_training.fail_composed_step(error);
}
let update = self.critic_training.snapshot().step_count();
self.metrics.update = update;
if update.is_multiple_of(self.config.target_update_interval) {
self.sync_targets()?;
}
Ok(true)
}
fn record_update(&mut self) -> Result<()> {
let update = self.metrics.update;
let sample_seed = checked_seed(self.config.seed, update + 1, "replay")?;
let sampled = self.replay.sample(self.config.batch_size, sample_seed)?;
let shape = vec![self.config.batch_size, self.observation_elements];
let observation = matrix::reshape(sampled.observation(), shape.clone())?;
let next_observation = matrix::reshape(sampled.next_observation(), shape)?;
let next_policy = self.actor_policy(
&next_observation,
checked_seed(self.config.seed, 0x1_0000_0001 + update, "next policy")?,
)?;
let next_input = critic_input(&next_observation, &next_policy.action)?;
let next_q1 = vector_q(
self.target_critic1.forward(&next_input)?,
self.config.batch_size,
)?;
let next_q2 = vector_q(
self.target_critic2.forward(&next_input)?,
self.config.batch_size,
)?;
self.critic_training.zero_grad();
let critic_tape = GradientTape::new();
let stored_input = critic_input(&observation, sampled.action())?;
let q1 = vector_q(self.critic1.forward(&stored_input)?, self.config.batch_size)?;
let q2 = vector_q(self.critic2.forward(&stored_input)?, self.config.batch_size)?;
let critic = loss::sac_critic(
&q1,
&q2,
sampled.reward(),
&next_q1,
&next_q2,
&next_policy.log_probability,
sampled.terminated(),
sampled.truncated(),
self.config.loss,
)?;
critic_tape.backward(&critic.total_loss)?;
self.critic_training.complete_step(&critic.total_loss)?;
self.metrics.critic_loss = self.critic_training.snapshot().last_loss().unwrap_or(0.0);
self.actor_training.zero_grad();
self.critic_training.zero_grad();
let actor_tape = GradientTape::new();
let actor_policy = self.actor_policy(
&observation,
checked_seed(self.config.seed, 0x2_0000_0001 + update, "actor policy")?,
)?;
let policy_input = critic_input(&observation, &actor_policy.action)?;
let actor_q1 = vector_q(self.critic1.forward(&policy_input)?, self.config.batch_size)?;
let actor_q2 = vector_q(self.critic2.forward(&policy_input)?, self.config.batch_size)?;
let actor_loss = loss::sac_actor(
&actor_q1,
&actor_q2,
&actor_policy.log_probability,
self.config.loss.entropy_coefficient,
)?;
actor_tape.backward(&actor_loss)?;
self.actor_training.complete_step(&actor_loss)?;
self.metrics.actor_loss = self.actor_training.snapshot().last_loss().unwrap_or(0.0);
Ok(())
}
fn actor_policy(
&self,
observation: &Matrix,
seed: u64,
) -> Result<policy::ContinuousPolicyResult> {
let output = self.actor.forward(observation)?;
let expected_width = self
.config
.action_dimensions
.checked_mul(2)
.ok_or_else(|| Error::resource_exhausted("SAC actor width overflows usize"))?;
if output.shape() != [self.config.batch_size, expected_width] || output.dtype() != DType::F32 {
return Err(Error::invalid_argument(
"SAC actor must return F32 [batch, 2 * action_dimensions]",
));
}
let split = i64::try_from(self.config.action_dimensions)
.map_err(|_| Error::resource_exhausted("SAC action width exceeds i64"))?;
let end = i64::try_from(expected_width)
.map_err(|_| Error::resource_exhausted("SAC actor width exceeds i64"))?;
let mean = matrix::slice(&output, 1, 0, split)?;
let log_stddev = matrix::slice(&output, 1, split, end)?;
let value = matrix::full(self.engine, [self.config.batch_size], 0.0)?;
policy::sample_tanh_normal(
&mean,
&log_stddev,
&value,
self.config.action_minimum,
self.config.action_maximum,
seed,
1.0e-6,
)
}
pub fn sync_targets(&self) -> Result<()> {
target::sync_exact(
self.engine,
&[
(self.critic1, self.target_critic1),
(self.critic2, self.target_critic2),
],
"SAC critic",
)
}
pub fn is_done(&self) -> bool {
let critic = self.critic_training.snapshot().step_count();
let actor = self.actor_training.snapshot().step_count();
self.critic_training.stop_requested()
|| self.actor_training.stop_requested()
|| critic >= self.config.updates
|| actor >= self.config.updates
}
pub const fn metrics(&self) -> SacTrainerMetrics {
self.metrics
}
pub const fn training_loop(&self) -> &ItTraining<'engine, 'hooks> {
&self.critic_training
}
pub fn training_loop_mut(&mut self) -> &mut ItTraining<'engine, 'hooks> {
&mut self.critic_training
}
pub const fn actor_training_loop(&self) -> &ItTraining<'engine, 'hooks> {
&self.actor_training
}
pub fn actor_training_loop_mut(&mut self) -> &mut ItTraining<'engine, 'hooks> {
&mut self.actor_training
}
pub fn finish(self) -> Result<(super::TrainingSnapshot, super::TrainingSnapshot)> {
let critic = self.critic_training.finish()?;
let actor = self.actor_training.finish()?;
Ok((critic, actor))
}
}
fn validate_config(replay: &ReplayBuffer, config: &SacTrainerConfig) -> Result<()> {
let replay_config = replay.config();
if config.updates == 0
|| config.batch_size == 0
|| config.action_dimensions == 0
|| config.target_update_interval == 0
|| config.observation_shape != replay_config.observation_shape
|| replay_config.action_dtype != DType::F32
|| replay_config.action_shape != [config.action_dimensions]
|| !config.action_minimum.is_finite()
|| !config.action_maximum.is_finite()
|| config.action_minimum >= config.action_maximum
{
return Err(Error::invalid_argument(
"SAC trainer configuration does not match continuous replay storage",
));
}
shape_elements(&config.observation_shape, "observation")?;
Ok(())
}
fn shape_elements(shape: &[usize], label: &'static str) -> Result<usize> {
shape.iter().try_fold(1_usize, |count, extent| {
count
.checked_mul(*extent)
.ok_or_else(|| Error::resource_exhausted(format!("SAC {label} size overflows usize")))
})
}
fn checked_seed(base: u64, offset: u64, label: &'static str) -> Result<u64> {
base
.checked_add(offset)
.ok_or_else(|| Error::resource_exhausted(format!("SAC {label} seed exhausted")))
}
fn critic_input(observation: &Matrix, action: &Matrix) -> Result<Matrix> {
matrix::concat(&[observation.clone(), action.clone()], 1)
}
fn vector_q(input: Matrix, batch_size: usize) -> Result<Matrix> {
if input.dtype() != DType::F32 {
return Err(Error::invalid_argument("SAC critic output must be F32"));
}
if input.shape() == [batch_size] {
return Ok(input);
}
if input.shape() == [batch_size, 1] {
return matrix::reshape(&input, [batch_size]);
}
Err(Error::invalid_argument(
"SAC critics must return [batch] or [batch, 1]",
))
}