Mastering Continuous Control: A Deep Dive into Soft Actor-Critic (SAC)
Mastering Continuous Control: A Deep Dive into Soft Actor-Critic (SAC)
In the world of Reinforcement Learning (RL), continuous action spaces—like controlling a robotic arm or steering a self-driving car—present a significant challenge. Traditional algorithms often struggle with a "winner-take-all" mentality, where the agent prematurely converges on a single action, missing out on potentially better strategies.
Enter Soft Actor-Critic (SAC).
SAC represents a paradigm shift by introducing Maximum Entropy RL. Instead of just maximizing reward, SAC encourages the agent to be "as random as possible" while still succeeding. In this post, we will break down the intuition, the mathematics, and a production-ready PyTorch implementation of SAC.
The Core Intuition: Why Entropy Matters?
Most RL algorithms are designed to find the optimal policy $\pi^*$ that maximizes the expected sum of rewards. However, this often leads to brittle policies that collapse into local optima.
SAC changes the objective. It optimizes for a trade-off between the expected reward and the entropy of the policy.
$$\text{Objective: } \mathbb{E}_{\mathbf{s}t, \mathbf{a}t \sim \rho\pi} \left[\sum{t=0}^{\infty} \gamma^t (r(\mathbf{s}_t, \mathbf{a}_t) + \alpha \mathcal{H}(\pi(\cdot|\mathbf{s}_t)))\right]$$
Where $\mathcal{H}(\pi(\cdot|\mathbf{s}_t))$ is the entropy, defined as: $$\mathcal{H}(\pi(\cdot|\mathbf{s}t)) = -\mathbb{E}{\mathbf{a}_t \sim \pi} [\log \pi(\mathbf{a}_t|\mathbf{s}_t)]$$
Why is this powerful?
- Enhanced Exploration: The agent is incentivized to explore all promising actions, not just the current best.
- Robustness: By learning a stochastic policy, the agent is less sensitive to noise and hyperparameters.
- Avoidance of Local Optima: The entropy term prevents the policy from collapsing into a deterministic peak too early in training.
Architecture & Workflow
SAC is an off-policy actor-critic algorithm. It uses a replay buffer to reuse past experiences, making it significantly more sample-efficient than on-policy methods like PPO.
The High-Level Pipeline
Key Technical Components
- The Stochastic Actor: Instead of outputting a single action, the actor outputs the $\mu$ (mean) and $\sigma$ (standard deviation) of a Gaussian distribution.
- The Reparameterization Trick: To allow gradients to flow through the stochastic sampling process, SAC uses $a = \mu + \sigma \cdot \epsilon$, where $\epsilon \sim \mathcal{N}(0, 1)$.
- Clipped Double-Q Learning: To prevent the overestimation of Q-values (a common failure mode in RL), SAC maintains two independent Critic networks and uses the minimum of their predictions for the target.
- Soft Target Updates: Rather than copying weights every $N$ steps, SAC uses a Polyak average: $\theta_{target} \leftarrow \tau \theta + (1-\tau) \theta_{target}$.
Implementation in PyTorch
Below is a complete, modular implementation of the SAC agent.
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
from torch.distributions import Normal
import numpy as np
import random
from collections import deque
class ReplayBuffer:
def __init__(self, capacity):
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):
state, action, reward, next_state, done = zip(*random.sample(self.buffer, batch_size))
return (torch.FloatTensor(np.array(state)),
torch.FloatTensor(np.array(action)),
torch.FloatTensor(np.array(reward)).unsqueeze(1),
torch.FloatTensor(np.array(next_state)),
torch.FloatTensor(np.array(done)).unsqueeze(1))
def __len__(self):
return len(self.buffer)
class Actor(nn.Module):
def __init__(self, state_dim, action_dim, hidden_dim=256):
super(Actor, self).__init__()
self.net = nn.Sequential(
nn.Linear(state_dim, hidden_dim), nn.ReLU(),
nn.Linear(hidden_dim, hidden_dim), nn.ReLU()
)
self.mu = nn.Linear(hidden_dim, action_dim)
self.log_std = nn.Linear(hidden_dim, action_dim)
def forward(self, state):
x = self.net(state)
mu = self.mu(x)
log_std = torch.clamp(self.log_std(x), -20, 2)
return mu, log_std
def sample(self, state):
mu, log_std = self.forward(state)
std = log_std.exp()
dist = Normal(mu, std)
x_t = dist.rsample()
action = torch.tanh(x_t)
# Correct log probability for tanh squashing
log_prob = dist.log_prob(x_t) - torch.log(1 - action.pow(2) + 1e-6)
return action, log_prob.sum(1, keepdim=True)
class Critic(nn.Module):
def __init__(self, state_dim, action_dim, hidden_dim=256):
super(Critic, self).__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, action):
return self.net(torch.cat([state, action], dim=1))
class SACAgent:
def __init__(self, state_dim, action_dim, lr=3e-4, gamma=0.99, tau=0.005, alpha=0.2):
self.gamma, self.tau, self.alpha = gamma, tau, alpha
self.actor = Actor(state_dim, action_dim)
self.critic1 = Critic(state_dim, action_dim)
self.critic2 = Critic(state_dim, action_dim)
self.target_critic1 = Critic(state_dim, action_dim)
self.target_critic2 = Critic(state_dim, action_dim)
self.target_critic1.load_state_dict(self.critic1.state_dict())
self.target_critic2.load_state_dict(self.critic2.state_dict())
self.actor_opt = optim.Adam(self.actor.parameters(), lr=lr)
self.critic_opt = optim.Adam(list(self.critic1.parameters()) +
list(self.critic2.parameters()), lr=lr)
def select_action(self, state):
state = torch.FloatTensor(state).unsqueeze(0)
with torch.no_grad():
action, _ = self.actor.sample(state)
return action.cpu().numpy()[0]
def update(self, buffer, batch_size):
if len(buffer) < batch_size: return 0, 0
s, a, r, ns, d = buffer.sample(batch_size)
# Critic Update
with torch.no_grad():
next_a, next_log_p = self.actor.sample(ns)
target_q = r + (1 - d) * self.gamma * (
torch.min(self.target_critic1(ns, next_a), self.target_critic2(ns, next_a))
- self.alpha * next_log_p
)
critic_loss = F.mse_loss(self.critic1(s, a), target_q) + F.mse_loss(self.critic2(s, a), target_q)
self.critic_opt.zero_grad()
critic_loss.backward()
self.critic_opt.step()
# Actor Update
new_a, log_p = self.actor.sample(s)
actor_loss = (self.alpha * log_p - torch.min(self.critic1(s, new_a), self.critic2(s, new_a))).mean()
self.actor_opt.zero_grad()
actor_loss.backward()
self.actor_opt.step()
# Soft Target Update
for p, tp in zip(self.critic1.parameters(), self.target_critic1.parameters()):
tp.data.copy_(self.tau * p.data + (1 - self.tau) * tp.data)
for p, tp in zip(self.critic2.parameters(), self.target_critic2.parameters()):
tp.data.copy_(self.tau * p.data + (1 - self.tau) * tp.data)
return critic_loss.item(), actor_loss.item()
Summary & Key Takeaways
Soft Actor-Critic is a powerhouse for continuous control because it balances exploitation (maximizing reward) with exploration (maximizing entropy).
| Feature | Benefit |
|---|---|
| Maximum Entropy | Prevents premature convergence and encourages exploration. |
| Off-Policy Learning | High sample efficiency via the Replay Buffer. |
| Clipped Double-Q | Reduces overestimation bias for more stable value estimates. |
| Stochastic Policy | Provides a smoother learning signal and better robustness. |
Whether you are building a robotics simulator or optimizing a financial trading agent, SAC provides the stability and efficiency needed to tackle complex, high-dimensional action spaces.