SAC: Actor-Critic with Maximum Entropy

SAC : Guide Complet — Actor-Critic avec Entropie Maximale

Soft Actor-Critic (SAC): The Complete Guide

Summary

Soft Actor-Critic (SAC) is one of the most performant reinforcement learning algorithms for continuous action spaces. Developed by Tuomas Haarnoja and his collaborators at UC Berkeley in 2018, SAC stands out for a fundamental idea: maximizing not only expected reward, but also policy entropy. This dual objective enables the agent to explore its environment efficiently while converging toward robust and generalizable policies.

Unlike DDPG, which exploits deterministically and risks getting stuck in local optima, or PPO, which requires extensive hyperparameter tuning, Soft Actor-Critic offers remarkable stability through several combined technical innovations: double Q-learning, the reparameterization trick, and automatic entropy temperature adjustment. These mechanisms make it the algorithm of choice for many industrial applications, from robotics to quantitative finance.

This complete guide explores the mathematical principle behind Soft Actor-Critic, its deep intuition, and provides a working PyTorch implementation with automatic alpha tuning.


Mathematical Principle of Soft Actor-Critic

The Entropic Objective

In classical reinforcement learning, the agent seeks a policy π* that maximizes the sum of discounted rewards:

J(π) = E[Σ γ^t · r(s_t, a_t)]

Soft Actor-Critic radically modifies this formula by adding an entropy term:

J_SAC(π) = E[Σ γ^t · (r(s_t, a_t) + α · H(π(·|s_t)))]

where H(π(·|s)) = -E[log π(a|s)] is the policy entropy, measuring the degree of uncertainty or “surprise” of the action distribution. The coefficient α (alpha) controls the exploration-exploitation tradeoff: a high value pushes the agent to explore more, while a low value makes it more exploitative.

This entropy term plays a crucial role. By encouraging the policy to remain “uncertain” about its actions, SAC avoids premature convergence to suboptimal strategies. The agent is rewarded not only for acting well, but also for staying curious about its environment.

Modified Bellman Equations

In the entropic framework, the soft value is defined as:

V_soft(s) = E_a[Q_soft(s, a) - α · log π(a|s)]

and the soft Q-function satisfies the modified Bellman equation:

Q_soft(s, a) = r(s, a) + γ · E_s'[V_soft(s')]

This modification means that each state intrinsically carries a value related to the diversity of available actions — not just the immediate reward.

Double Q-Learning: Reducing Overestimation

Like DQN before it, Soft Actor-Critic suffers from the Q-value overestimation problem. To address this, SAC maintains two critic networks Q₁ and Q₂ with their own parameters. When computing the target, the minimum of the two is taken:

Q_target = r + γ · (min(Q₁(s', π(s')), Q₂(s', π')) - α · log π(a'|s'))

This conservative approach, borrowed from Double DQN and TD3, significantly reduces the overestimation bias that destabilizes learning.

The Reparameterization Trick

One of the major difficulties of Soft Actor-Critic is differentiating the expectation over actions with respect to policy parameters. SAC solves this problem through the reparameterization trick:

Instead of sampling directly a ~ π(·|s), the action is expressed as a differentiable function of noise:

a = f_φ(ε, s)  where ε ~ N(0, I)

Concretely, the parameterized Gaussian policy produces a mean μ(s) and a standard deviation σ(s), then:

a = μ(s) + σ(s) ⊙ ε

where ⊙ denotes element-wise multiplication. This reformulation allows computing the gradient of the expectation by differentiating directly through the sampling process, which considerably reduces the variance of the gradient estimator compared to the classical REINFORCE estimator.

Automatic Temperature Adjustment

Instead of manually setting α, Soft Actor-Critic automatically adjusts it by solving the following minimization problem:

J(α) = E_{a~π}[-α · log π(a|s) - α · H_target]

where H_target = -dim(A) is the target entropy, typically set to the negative of the action space dimension. This approach allows the algorithm to automatically find the right exploration-exploitation balance throughout training, without human intervention.

The optimal policy in the entropic framework is written:

π*(a|s) ∝ exp(Q_soft(s, a) / α)

This Boltzmann form clearly shows how entropy influences the policy: the larger α is, the more uniform the action distribution (exploration); the smaller α is, the more the policy concentrates on higher-value actions (exploitation).


Intuition: Why Entropy Changes Everything

Imagine two agents facing a complex maze. The first agent is extremely confident: as soon as it finds a working path, it sticks to it rigidly and explores no further. It may perfectly miss a hidden shortcut or a far more efficient strategy. This the flaw of purely exploitative algorithms like DDPG.

The second agent, by contrast, explores in a chaotic manner. It tests everything without ever really focusing on what works. Its performance remains mediocre because it fails to consolidate its discoveries. This is the risk of unstructured exploration.

Soft Actor-Critic strikes the perfect balance between these two extremes. It maximizes reward while remaining as uncertain as possible — exactly like a curious scientist who seeks concrete results but remains open to surprises and unexpected discoveries.

Entropy is not an arbitrary addition: it is the mechanism that enables the agent to maintain sufficient behavioral diversity to discover better strategies throughout training. It is this property that gives SAC its superior robustness in complex environments.

In practice, this entropic approach offers a decisive advantage: learned policies generalize better to situations not encountered during training. A robot trained with SAC will adapt to unforeseen disturbances (different friction, varying loads) better than a robot trained with a deterministic algorithm.


Python Implementation with PyTorch

Here is a complete implementation of Soft Actor-Critic with double Q-learning, Gaussian policy network, and automatic entropy temperature adjustment.

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import numpy as np
import gymnasium as gym
from collections import deque
import random
import math

# ============================================================
# Neural Networks
# ============================================================

class CriticNetwork(nn.Module):
    """Q(s, a) critic network for double Q-learning."""

    def __init__(self, state_dim, action_dim, hidden_dim=256):
        super().__init__()
        self.q1 = 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)
        )
        self.q2 = 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):
        x = torch.cat([state, action], dim=-1)
        q1 = self.q1(x)
        q2 = self.q2(x)
        return q1, q2

    def q_min(self, state, action):
        q1, q2 = self.forward(state, action)
        return torch.min(q1, q2)


class ActorNetwork(nn.Module):
    """Gaussian policy network with reparameterization trick."""

    def __init__(self, state_dim, action_dim, hidden_dim=256,
                 action_scale=1.0, action_bias=0.0):
        super().__init__()
        self.action_scale = action_scale
        self.action_bias = action_bias

        self.shared = 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):
        x = self.shared(state)
        mean = self.mean_layer(x)
        log_std = self.log_std_layer(x)
        log_std = torch.clamp(log_std, min=-20, max=2)
        std = log_std.exp()
        return mean, std

    def sample(self, state, epsilon=1e-6):
        """Sample an action using the reparameterization trick."""
        mean, std = self.forward(state)

        # Reparameterization trick: a = mu + sigma * epsilon
        normal = torch.distributions.Normal(mean, std)
        x_t = normal.rsample()

        # Tanh transformation to bound the action
        y_t = torch.tanh(x_t)
        action = y_t * self.action_scale + self.action_bias

        # Entropy correction due to tanh
        log_prob = normal.log_prob(x_t)
        log_prob = log_prob - torch.log(
            self.action_scale * (1 - y_t.pow(2)) + epsilon
        )
        log_prob = log_prob.sum(dim=-1, keepdim=True)

        # Tanh of the mean for target computation
        mean_action = torch.tanh(mean) * self.action_scale + self.action_bias

        return action, log_prob, mean_action


# ============================================================
# SAC Agent
# ============================================================

class SACAgent:
    """Soft Actor-Critic agent with automatic temperature adjustment."""

    def __init__(self, state_dim, action_dim, action_low, action_high,
                 lr=3e-4, gamma=0.99, tau=0.005,
                 target_entropy_ratio=0.89,
                 hidden_dim=256):

        self.gamma = gamma
        self.tau = tau
        self.action_dim = action_dim

        # Compute target entropy
        self.target_entropy = -target_entropy_ratio * action_dim

        # Initialize temperature coefficient alpha
        self.log_alpha = torch.tensor(
            [0.0], dtype=torch.float32, requires_grad=True
        )
        self.alpha = self.log_alpha.exp().item()
        self.alpha_optimizer = optim.Adam([self.log_alpha], lr=lr)

        # Main critic networks
        self.critic = CriticNetwork(state_dim, action_dim, hidden_dim)
        self.critic_optimizer = optim.Adam(
            self.critic.parameters(), lr=lr
        )

        # Target critic networks (copied)
        self.critic_target = CriticNetwork(state_dim, action_dim, hidden_dim)
        self.critic_target.load_state_dict(self.critic.state_dict())

        # Policy network (actor)
        action_scale = torch.tensor(
            (action_high - action_low) / 2.0, dtype=torch.float32
        )
        action_bias = torch.tensor(
            (action_high + action_low) / 2.0, dtype=torch.float32
        )
        self.actor = ActorNetwork(
            state_dim, action_dim, hidden_dim,
            action_scale, action_bias
        )
        self.actor_optimizer = optim.Adam(
            self.actor.parameters(), lr=lr
        )

        # Experience buffer
        self.replay_buffer = deque(maxlen=1000000)

    def select_action(self, state, evaluate=False):
        """Select an action according to the current policy."""
        state = torch.FloatTensor(state).unsqueeze(0)
        with torch.no_grad():
            action, _, mean_action = self.actor.sample(state)
            if evaluate:
                return mean_action.squeeze(0).numpy()
            return action.squeeze(0).numpy()

    def store_transition(self, state, action, reward, next_state, done):
        """Store a transition in the experience buffer."""
        self.replay_buffer.append({
            'state': state,
            'action': action,
            'reward': reward,
            'next_state': next_state,
            'done': done
        })

    def sample_batch(self, batch_size):
        """Sample a mini-batch from the replay buffer."""
        batch = random.sample(self.replay_buffer, batch_size)
        states = torch.FloatTensor([t['state'] for t in batch])
        actions = torch.FloatTensor([t['action'] for t in batch])
        rewards = torch.FloatTensor([t['reward'] for t in batch]).unsqueeze(1)
        next_states = torch.FloatTensor([t['next_state'] for t in batch])
        dones = torch.FloatTensor([t['done'] for t in batch]).unsqueeze(1)
        return states, actions, rewards, next_states, dones

    def update(self, batch_size=256):
        """Update all networks from a mini-batch."""
        if len(self.replay_buffer) < batch_size:
            return 0

        states, actions, rewards, next_states, dones = \
            self.sample_batch(batch_size)

        # ---- Update Critics (Double Q) ----
        with torch.no_grad():
            # Sample next actions with the target policy
            next_actions, next_log_probs, _ = self.actor.sample(next_states)

            # Minimum Q of the two target networks
            q1_target, q2_target = self.critic_target(
                next_states, next_actions
            )
            q_target_min = torch.min(q1_target, q2_target)

            # Target with entropy term
            q_target = rewards + (1 - dones) * self.gamma * (
                q_target_min - self.alpha * next_log_probs
            )

        # Compute loss for both critics
        q1_current, q2_current = self.critic(states, actions)
        q1_loss = F.mse_loss(q1_current, q_target)
        q2_loss = F.mse_loss(q2_current, q_target)
        critic_loss = q1_loss + q2_loss

        # Optimize critics
        self.critic_optimizer.zero_grad()
        critic_loss.backward()
        self.critic_optimizer.step()

        # ---- Update Policy (Actor) ----
        new_actions, log_probs, _ = self.actor.sample(states)

        # New Q evaluation with conservative minimum
        q_min = self.critic.q_min(states, new_actions)

        # Policy loss: we maximize Q - alpha * log_pi
        actor_loss = (self.alpha * log_probs - q_min).mean()

        self.actor_optimizer.zero_grad()
        actor_loss.backward()
        self.actor_optimizer.step()

        # ---- Automatic Alpha Adjustment ----
        alpha_loss = -(self.log_alpha.exp() * (
            log_probs.detach() + self.target_entropy
        )).mean()

        self.alpha_optimizer.zero_grad()
        alpha_loss.backward()
        self.alpha_optimizer.step()
        self.alpha = self.log_alpha.exp().item()

        # ---- Soft Target Update ----
        self._soft_update(self.critic, self.critic_target, self.tau)

        return critic_loss.item()

    def _soft_update(self, source, target, tau):
        """Soft update of target parameters."""
        for target_param, source_param in zip(
            target.parameters(), source.parameters()
        ):
            target_param.data.copy_(
                tau * source_param.data + (1.0 - tau) * target_param.data
            )


# ============================================================
# Training Loop
# ============================================================

def train_sac(env_name="Pendulum-v1", episodes=500, batch_size=256,
              start_steps=1000, hidden_dim=256):
    """Train a SAC agent on a Gymnasium environment."""

    env = gym.make(env_name)
    state_dim = env.observation_space.shape[0]
    action_dim = env.action_space.shape[0]
    action_low = env.action_space.low
    action_high = env.action_space.high

    agent = SACAgent(
        state_dim=state_dim,
        action_dim=action_dim,
        action_low=action_low,
        action_high=action_high,
        lr=3e-4,
        gamma=0.99,
        tau=0.005,
        target_entropy_ratio=0.89,
        hidden_dim=hidden_dim
    )

    rewards_history = []
    alpha_history = []

    for episode in range(episodes):
        state, _ = env.reset()
        episode_reward = 0
        done = False

        while not done:
            # Exploration at the beginning of training
            if episode * 200 + len(agent.replay_buffer) < start_steps:
                action = env.action_space.sample()
            else:
                action = agent.select_action(state)

            next_state, reward, terminated, truncated, _ = env.step(action)
            done = terminated or truncated

            agent.store_transition(state, action, reward, next_state, done)
            agent.update(batch_size)

            state = next_state
            episode_reward += reward

        rewards_history.append(episode_reward)
        alpha_history.append(agent.alpha)

        if (episode + 1) % 50 == 0:
            avg_reward = np.mean(rewards_history[-50:])
            print(f"Episode {episode+1}/{episodes} | "
                  f"Avg reward (last 50): {avg_reward:.2f} | "
                  f"Alpha: {agent.alpha:.4f}")

    env.close()
    return agent, rewards_history, alpha_history


if __name__ == "__main__":
    agent, rewards, alphas = train_sac(
        env_name="Pendulum-v1",
        episodes=300,
        batch_size=256,
        start_steps=1000
    )
    print(f"Training complete. Final average reward: "
          f"{np.mean(rewards[-50:]):.2f}")

Key Hyperparameters

Soft Actor-Critic depends on several crucial hyperparameters:

Hyperparameter Typical value Role
α (alpha) Automatically adjusted Temperature coefficient, controls exploration
Learning rate 3 × 10⁻⁴ Network update speed
γ (gamma) 0.99 Discount factor for future rewards
τ (tau) 0.005 Soft update rate for target networks
Buffer size 1,000,000 Replay buffer capacity
Batch size 256 Number of transitions per update
H_target -dim(A) Target entropy for automatic adjustment
Hidden dimensions 256 Size of network hidden layers

Tuning tips:

  • For simple environments like Pendulum, reduce hidden_dim to 128 to speed up training without losing performance.
  • For complex environments like HalfCheetah or Humanoid, increase hidden_dim to 512 and start_steps to 10,000 for longer initial exploration.
  • If the agent doesn’t explore enough, reduce target_entropy_ratio to 0.5 to force higher entropy.
  • If the agent diverges, reduce the learning rate to 1 × 10⁻⁴ and increase tau to 0.01 for more gradual updates.

Advantages and Limitations of Soft Actor-Critic

Advantages

  • Exceptional sample efficiency: SAC takes full advantage of the replay buffer, often requiring fewer interactions with the environment than PPO to converge.
  • Robustness to hyperparameters: automatic alpha adjustment considerably reduces the need for manual tuning compared to DDPG or TD3.
  • Intrinsic exploration: the entropy term naturally encourages the discovery of new strategies without artificial exploration mechanisms like ε-greedy.
  • Superior generalization: policies trained with SAC adapt better to conditions not encountered during training due to the encouraged behavioral diversity.
  • State-of-the-art performance: on standard MuJoCo benchmarks, SAC achieves or exceeds the performance of TD3 and PPO on most environments.

Limitations

  • Computational complexity: SAC requires maintaining four networks (two critics + two targets) plus the actor, significantly increasing the memory footprint compared to simpler algorithms.
  • Sensitivity to discrete environments: designed for continuous spaces, Soft Actor-Critic requires substantial adaptations to work effectively with discrete action spaces.
  • Computation time per step: each update involves computing two Q-networks and backpropagating through the reparameterization trick, making training slower per iteration.
  • Initial steps required: the start_steps mechanism demands a minimum number of random interactions before learning begins, which can be problematic in environments where failures are costly.

4 Concrete Use Cases

1. Advanced Robotics Control

Soft Actor-Critic excels in controlling robots with multiple joints. DeepMind has used SAC variants to train object manipulation policies on real robotic arms, with sim-to-real transfer. The intrinsic entropy enables the robot to discover creative gripping strategies that deterministic approaches would miss. For example, learning to flip an object in the hand without visual feedback becomes feasible thanks to entropic exploration that discovers non-intuitive finger configurations.

2. Algorithmic Trading and Quantitative Finance

In automated portfolio management, the action space is continuous (weights allocated to each asset) and the environment is highly noisy. SAC’s entropy term acts as a natural regularizer, preventing the agent from concentrating excessively on a small number of market signals. This implicit strategy diversification leads to trading policies more robust to regime changes, reducing the risk of overfitting on temporary patterns.

3. Autonomous Driving and Navigation

For autonomous navigation systems, SAC enables the learning of continuous control policies (acceleration, steering) in partially observable environments. Entropic exploration is particularly valuable here: an autonomous vehicle trained with SAC will maintain a wider range of defensive behaviors in ambiguous situations, rather than converging to a single rigid strategy. Simulations on CARLA environments have shown that SAC policies generalize better to varied weather conditions.

4. Physical Systems Optimization

Tuning parameters in industrial systems — power plants, production lines, HVAC systems — naturally benefits from SAC. The control space is continuous, rewards are often noisy or delayed, and robustness is crucial. Entropic exploration enables the discovery of unconventional operating configurations that reduce energy consumption while maintaining production quality, where deterministic methods would remain stuck in suboptimal configurations.


See Also


About Salah YAHIAOUI

Salah YAHIAOUI publie sur The Pingouin des tutoriels Python et des décryptages sur l’intelligence artificielle. Les cas pratiques récents précisent leurs données, leurs tests et leurs limites. À propos, méthode éditoriale et contact.

View all posts by Salah YAHIAOUI →