Navigation

Introduction to AI

Machine Learning

Deep Learning

Generative AI

Tools & Frameworks

General

Soft Actor-Critic Algorithm Deep Dive

Soft Actor-Critic (SAC) is one of the most practically effective deep reinforcement learning algorithms for continuous action spaces. Introduced by Haarnoja et al. at UC Berkeley in 2018, SAC addresses the two biggest pain points of model-free RL: sample inefficiency and fragile hyperparameter sensitivity. It achieves this through a principled framework called maximum entropy reinforcement learning.

Understanding SAC deeply requires grasping what “soft” means in this context — it refers to entropy regularization, not network architecture. The “soft” in SAC is a mathematical commitment to policies that aren’t just optimal, but optimally uncertain in a well-defined sense.

The Standard RL Objective — and Its Problem

In standard reinforcement learning, the agent learns a policy $\pi$ to maximize expected cumulative discounted reward:

$$J(\pi) = \mathbb{E}{\tau \sim \pi}\left[\sum{t=0}^{T} \gamma^t r(s_t, a_t)\right]$$

This is a clean objective, but it has a fundamental flaw: it drives the policy to collapse onto a single deterministic action per state. A fully greedy policy ignores uncertainty, fails to explore, and is brittle to environment stochasticity.

Practitioners compensate with tricks: $\epsilon$-greedy exploration, entropy bonuses, Gaussian noise injection. These are ad-hoc. SAC formalizes a better objective.

Maximum Entropy Reinforcement Learning

The maximum entropy RL objective augments rewards with an entropy term at every step:

$$J(\pi) = \mathbb{E}{\tau \sim \pi}\left[\sum{t=0}^{T} \gamma^t \left(r(s_t, a_t) + \alpha \mathcal{H}(\pi(\cdot | s_t))\right)\right]$$

Where:

  • $\mathcal{H}(\pi(\cdot|s)) = -\mathbb{E}_{a \sim \pi}[\log \pi(a|s)]$ is the entropy of the policy at state $s$
  • $\alpha > 0$ is the temperature parameter that controls the entropy-reward tradeoff

The entropy term incentivizes the policy to be as random as possible while still getting high reward. The result is a policy that:

  1. Explores broadly in regions where all actions have similar values
  2. Acts decisively only when there’s a clear best action
  3. Maintains multiple modes — if two action sequences both lead to high reward, SAC learns both

This is fundamentally different from just adding noise. The entropy bonus changes the target of optimization, not just the behavior during exploration.

The Soft Q-Function and Soft Value Function

In the entropy-augmented framework, the Q-function and value function gain corresponding entropy terms:

Soft Q-function: $$Q^{\pi}(s_t, a_t) = \mathbb{E}\left[\sum_{l=0}^{\infty} \gamma^l \left(r_{t+l} + \alpha \mathcal{H}(\pi(\cdot | s_{t+l+1}))\right)\right]$$

Soft Value function: $$V^{\pi}(s_t) = \mathbb{E}_{a \sim \pi}\left[Q^{\pi}(s_t, a) - \alpha \log \pi(a|s_t)\right]$$

These satisfy the soft Bellman equation:

$$Q^{\pi}(s_t, a_t) = r(s_t, a_t) + \gamma \mathbb{E}{s{t+1}}\left[V^{\pi}(s_{t+1})\right]$$

The policy update under maximum entropy RL becomes:

$$\pi^* = \arg\max_{\pi} \mathbb{E}_{s \sim \mathcal{D}, a \sim \pi}\left[Q^{\pi}(s, a) - \alpha \log \pi(a|s)\right]$$

This has a closed-form solution proportional to the softmax (Boltzmann) distribution:

$$\pi^(a|s) \propto \exp\left(\frac{1}{\alpha} Q^(s,a)\right)$$

The SAC Architecture

SAC maintains the following neural networks:

NetworkSymbolPurpose
Policy (Actor)$\pi_\phi(a|s)$Parameterized stochastic policy
Soft Q-Network 1$Q_{\theta_1}(s,a)$Action-value estimate
Soft Q-Network 2$Q_{\theta_2}(s,a)$Second Q-network (for clipped double-Q)
Target Q-Network 1$Q_{\bar{\theta}_1}(s,a)$Slow-moving target (EMA of $Q_{\theta_1}$)
Target Q-Network 2$Q_{\bar{\theta}_2}(s,a)$Slow-moving target (EMA of $Q_{\theta_2}$)

The policy outputs the parameters of a distribution (mean $\mu$ and log-standard-deviation $\log\sigma$ for a Gaussian) rather than a deterministic action. Actions are sampled and transformed through a squashing function:

$$a = \tanh(\mu_\phi(s) + \epsilon \odot \sigma_\phi(s)), \quad \epsilon \sim \mathcal{N}(0, I)$$

The $\tanh$ squashing bounds actions to $[-1, 1]$, compatible with most continuous control environments. The log-probability requires a change-of-variables correction:

$$\log \pi(a|s) = \sum_{i=1}^{d} \left[\log \mathcal{N}(u_i; \mu_i, \sigma_i) - \log(1 - \tanh^2(u_i))\right]$$

where $u = \mu + \epsilon \odot \sigma$ (the pre-squash action).

The SAC Algorithm

SAC is off-policy — it learns from a replay buffer of past transitions $(s, a, r, s’, \text{done})$ collected by any policy.

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
from collections import deque
import random

LOG_STD_MAX = 2
LOG_STD_MIN = -20

class GaussianPolicy(nn.Module):
    def __init__(self, state_dim: int, action_dim: int, hidden_dim: int = 256):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(state_dim, hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim), nn.ReLU(),
        )
        self.mean_layer = nn.Linear(hidden_dim, action_dim)
        self.log_std_layer = nn.Linear(hidden_dim, action_dim)
    
    def forward(self, state: torch.Tensor):
        x = self.net(state)
        mean = self.mean_layer(x)
        log_std = self.log_std_layer(x).clamp(LOG_STD_MIN, LOG_STD_MAX)
        return mean, log_std
    
    def sample(self, state: torch.Tensor):
        mean, log_std = self.forward(state)
        std = log_std.exp()
        
        # Reparameterization trick
        normal = torch.distributions.Normal(mean, std)
        u = normal.rsample()  # Differentiable sample
        a = torch.tanh(u)
        
        # Log probability with tanh correction
        log_prob = normal.log_prob(u) - torch.log(1 - a.pow(2) + 1e-6)
        log_prob = log_prob.sum(dim=-1, keepdim=True)
        
        return a, log_prob, torch.tanh(mean)


class SoftQNetwork(nn.Module):
    def __init__(self, state_dim: int, action_dim: int, hidden_dim: int = 256):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(state_dim + action_dim, hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, 1),
        )
    
    def forward(self, state: torch.Tensor, action: torch.Tensor) -> torch.Tensor:
        return self.net(torch.cat([state, action], dim=-1))


class ReplayBuffer:
    def __init__(self, capacity: int = 1_000_000):
        self.buffer = deque(maxlen=capacity)
    
    def push(self, state, action, reward, next_state, done):
        self.buffer.append((state, action, reward, next_state, done))
    
    def sample(self, batch_size: int):
        batch = random.sample(self.buffer, batch_size)
        states, actions, rewards, next_states, dones = zip(*batch)
        return (
            torch.FloatTensor(np.array(states)),
            torch.FloatTensor(np.array(actions)),
            torch.FloatTensor(np.array(rewards)).unsqueeze(1),
            torch.FloatTensor(np.array(next_states)),
            torch.FloatTensor(np.array(dones)).unsqueeze(1),
        )


class SAC:
    def __init__(
        self,
        state_dim: int,
        action_dim: int,
        lr: float = 3e-4,
        gamma: float = 0.99,
        tau: float = 0.005,     # Target network EMA coefficient
        alpha: float = 0.2,     # Temperature (or auto-tuned)
        auto_tune_alpha: bool = True,
    ):
        self.gamma = gamma
        self.tau = tau
        self.auto_tune_alpha = auto_tune_alpha
        
        # Networks
        self.policy = GaussianPolicy(state_dim, action_dim)
        self.q1 = SoftQNetwork(state_dim, action_dim)
        self.q2 = SoftQNetwork(state_dim, action_dim)
        self.q1_target = SoftQNetwork(state_dim, action_dim)
        self.q2_target = SoftQNetwork(state_dim, action_dim)
        
        # Initialize targets to match online networks
        self.q1_target.load_state_dict(self.q1.state_dict())
        self.q2_target.load_state_dict(self.q2.state_dict())
        
        # Optimizers
        self.policy_optimizer = torch.optim.Adam(self.policy.parameters(), lr=lr)
        self.q1_optimizer = torch.optim.Adam(self.q1.parameters(), lr=lr)
        self.q2_optimizer = torch.optim.Adam(self.q2.parameters(), lr=lr)
        
        # Automatic temperature tuning
        if auto_tune_alpha:
            self.target_entropy = -action_dim  # Heuristic: -dim(A)
            self.log_alpha = torch.zeros(1, requires_grad=True)
            self.alpha = self.log_alpha.exp().item()
            self.alpha_optimizer = torch.optim.Adam([self.log_alpha], lr=lr)
        else:
            self.alpha = alpha
    
    def update(self, replay_buffer: ReplayBuffer, batch_size: int = 256):
        states, actions, rewards, next_states, dones = replay_buffer.sample(batch_size)
        
        with torch.no_grad():
            # Sample next actions from current policy
            next_actions, next_log_probs, _ = self.policy.sample(next_states)
            
            # Compute target Q-values (clipped double-Q)
            q1_next = self.q1_target(next_states, next_actions)
            q2_next = self.q2_target(next_states, next_actions)
            q_next = torch.min(q1_next, q2_next) - self.alpha * next_log_probs
            
            # Bellman target
            target_q = rewards + (1 - dones) * self.gamma * q_next
        
        # --- Critic update ---
        q1_loss = F.mse_loss(self.q1(states, actions), target_q)
        q2_loss = F.mse_loss(self.q2(states, actions), target_q)
        
        self.q1_optimizer.zero_grad()
        q1_loss.backward()
        self.q1_optimizer.step()
        
        self.q2_optimizer.zero_grad()
        q2_loss.backward()
        self.q2_optimizer.step()
        
        # --- Actor update ---
        sampled_actions, log_probs, _ = self.policy.sample(states)
        q1_val = self.q1(states, sampled_actions)
        q2_val = self.q2(states, sampled_actions)
        q_val = torch.min(q1_val, q2_val)
        
        # Maximize E[Q(s,a) - alpha * log pi(a|s)]
        policy_loss = (self.alpha * log_probs - q_val).mean()
        
        self.policy_optimizer.zero_grad()
        policy_loss.backward()
        self.policy_optimizer.step()
        
        # --- Temperature update (if auto-tuning) ---
        if self.auto_tune_alpha:
            alpha_loss = -(self.log_alpha * (log_probs + self.target_entropy).detach()).mean()
            self.alpha_optimizer.zero_grad()
            alpha_loss.backward()
            self.alpha_optimizer.step()
            self.alpha = self.log_alpha.exp().item()
        
        # --- Soft update of target networks ---
        for param, target_param in zip(self.q1.parameters(), self.q1_target.parameters()):
            target_param.data.copy_(self.tau * param.data + (1 - self.tau) * target_param.data)
        for param, target_param in zip(self.q2.parameters(), self.q2_target.parameters()):
            target_param.data.copy_(self.tau * param.data + (1 - self.tau) * target_param.data)
        
        return {
            "q1_loss": q1_loss.item(),
            "q2_loss": q2_loss.item(),
            "policy_loss": policy_loss.item(),
            "alpha": self.alpha,
        }

Automatic Temperature Tuning

A key practical innovation in the SAC v2 paper (Haarnoja et al., 2018b) is automatic entropy tuning — treating $\alpha$ as a dual variable in a constrained optimization problem rather than a fixed hyperparameter.

The constraint: maintain the policy entropy above a minimum threshold $\mathcal{H}^*$:

$$\max_{\pi} \mathbb{E}\left[\sum_t r_t\right] \text{ s.t. } \mathcal{H}(\pi) \geq \mathcal{H}^*$$

The Lagrangian dual converts this into updating $\alpha$ to satisfy the constraint:

$$\alpha^* = \arg\min_{\alpha} \mathbb{E}_{a \sim \pi}\left[-\alpha \log \pi(a|s) - \alpha \mathcal{H}^*\right]$$

The heuristic for the minimum entropy threshold is $\mathcal{H}^* = -\dim(\mathcal{A})$ — set the target entropy to negative the action dimensionality. This works surprisingly well across a wide range of environments.

This eliminates one of the most painful hyperparameters in the original SAC. Auto-tuned $\alpha$ starts high (encouraging exploration) and decreases as the policy learns a well-defined optimum.

Why Clipped Double-Q?

SAC uses two separate Q-networks and takes their minimum when computing targets:

$$y = r + \gamma\left(\min(Q_{\theta_1’}(s’, a’), Q_{\theta_2’}(s’, a’)) - \alpha \log \pi(a’|s’)\right)$$

This is the clipped double-Q trick from TD3. Without it, Q-value overestimation causes the policy to over-exploit noisy Q-value estimates, leading to instability and poor performance. Two Q-networks provides a pessimistic lower bound on value estimates — the policy learns to be good by the most conservative estimate, reducing exploitation of approximation errors.

AlgorithmPolicyOn/Off PolicyContinuous ActionsKey Feature
SACStochasticOff-policyMax entropy, auto-α
TD3DeterministicOff-policyClipped double-Q, delayed policy update
PPOStochasticOn-policyClipped surrogate objective
DDPGDeterministicOff-policyOriginal actor-critic for continuous
DQNGreedyOff-policyDiscrete actions only

SAC vs. TD3: Both are off-policy, continuous-action algorithms. SAC’s stochastic policy naturally produces exploration; TD3 requires explicit Gaussian noise injection. SAC generally achieves better asymptotic performance; TD3 sometimes converges faster.

SAC vs. PPO: PPO is on-policy — it learns from the current policy’s experience only, discarding old data. This makes it more sample-hungry but often more stable for some tasks. SAC is more sample-efficient due to experience replay but requires more careful tuning of the replay buffer.

Practical Hyperparameter Guide

SAC is robust but these settings matter:

HyperparameterDefaultNotes
Learning rate3e-4Works for most tasks; try 1e-4 for unstable envs
Batch size2561024 helps on GPU
Replay buffer size1,000,000Larger is generally better
τ (target update)0.005Smaller = more stable but slower
γ (discount)0.990.999 for long-horizon tasks
Hidden dim256512 for complex observations
Updates per step12–4 for sample efficiency boost
Warmup steps10,000Fill buffer before learning

Extensions and Variants

SAC for Discrete Actions: The standard SAC assumes continuous actions. For discrete action spaces, you can replace the squashed Gaussian with a categorical distribution and compute entropy analytically.

SAC + Hindsight Experience Replay (HER): For sparse reward goal-conditioned tasks, HER relabels failed trajectories with the goals actually achieved. Combining HER with SAC’s sample efficiency works well for robotics manipulation.

Model-Based SAC (MBPO): Model-Based Policy Optimization (Janner et al., 2019) combines SAC with a learned dynamics model. Short rollouts from the model supplement real environment data, achieving dramatic sample efficiency improvements.

Offline SAC: Conservative Q-Learning (CQL) and TD3+BC adapt the SAC framework for offline RL — learning from fixed datasets without environment interaction.

Applications

SAC’s combination of stability, sample efficiency, and performance has made it the go-to algorithm for:

  • Robot locomotion: Mujoco tasks (HalfCheetah, Ant, Humanoid) — SAC matches or exceeds PPO with 10–100× fewer samples
  • Robotic manipulation: Grasping, pick-and-place, dexterous manipulation
  • Autonomous driving simulation: Continuous steering and acceleration control
  • HVAC and energy optimization: Continuous setpoint control for building systems
  • Game playing with continuous actions: Racing games, physics simulations

Common Failure Modes

Reward scaling sensitivity: SAC’s entropy-reward tradeoff depends on the scale of rewards. A reward range of $[-1, 1]$ vs. $[-100, 100]$ changes the relative importance of entropy. Normalize rewards or tune $\alpha$ accordingly when auto-tuning fails.

Slow convergence with sparse rewards: Like all model-free algorithms, SAC struggles with very sparse rewards. Add reward shaping or use HER for goal-conditioned tasks.

Q-value divergence in extrapolation: Off-policy learning can cause Q-values to diverge if the policy distribution diverges from the data distribution in the replay buffer. Increase batch size or reduce the learning rate if you observe Q-values growing without bound.

Memory and compute cost: Running two Q-networks and a stochastic policy with replay buffer sampling adds overhead compared to on-policy methods. On CPU, SAC can be slower than PPO due to the extra networks.

Summary

SAC’s core insight — that optimal policies should maximize both reward and entropy — unifies exploration, robustness, and sample efficiency into a single principled objective. The resulting algorithm:

  1. Learns efficiently via off-policy experience replay
  2. Explores automatically via entropy maximization
  3. Is robust to hyperparameters via automatic temperature tuning
  4. Avoids overestimation via clipped double-Q
  5. Generalizes well across locomotion, manipulation, and control tasks

For practitioners working on continuous control problems, SAC should be the first algorithm tried after establishing a working environment and reward signal.