Home Deep Rl Sac Ppo Artikel 5
Teil 5 Generative AI

Soft Actor-Critic (SAC)

Bild: KI-generiert mit Beuys


Soft Actor-Critic (SAC)

SAC kombiniert Maximum-Entropy-RL mit Off-Policy-Lernen, Replay-Buffer und Twin-Q-Critics. Das Ergebnis ist eine der robustesten Methoden für kontinuierliche Steuerung – sample-effizient, weitgehend tuning-frei, und mit nativer Exploration ohne $\epsilon$-Schedule. Wir entwickeln das Maximum-Entropy-Objektiv, zeigen den Datenfluss als Mermaid, implementieren ein lauffähiges SAC-Toy in numpy und ergänzen ein realistisches PyTorch-Skeleton.


1. Das Problem: PPO ist sample-ineffizient

PPO ist on-policy. Jeder Batch wird nach ein paar Epochen verworfen, jeder neue Trainingsschritt braucht frische Rollouts. Für einen Roboter, der eine Sekunde pro Trajektorie braucht, ist das tödlich – Hunderttausende Episoden werden zu Wochen physischer Laufzeit.

Off-Policy-Methoden lösen das Problem prinzipiell: Erfahrungen aus früheren Policies bleiben verwertbar. Klassische Ansätze haben aber eigene Probleme:

Verfahren Schwäche
DQN nur diskrete Aktionen
DDPG extrem hyperparameter-empfindlich, Overestimation des Critics
TD3 besser als DDPG, aber deterministische Policy → schlechte Exploration
SAC adressiert alle drei Punkte gleichzeitig

SAC (Haarnoja et al., 2018) ist nicht inkrementell – es kombiniert drei Ideen, die jeweils einen klassischen Schmerzpunkt lösen.

2. Maximum-Entropy-RL: Reward + Stochastik belohnen

Die Standard-RL-Zielfunktion maximiert nur den Reward. SAC fügt einen Entropie-Bonus hinzu:

$$J(\pi) = \sum_{t} \mathbb{E}_{(s_t, a_t) \sim \pi}\left[r_t + \alpha \cdot \mathcal{H}(\pi(\cdot \mid s_t))\right]$$

mit der Entropie $\mathcal{H}(\pi(\cdot \mid s)) = -\mathbb{E}_{a \sim \pi}[\log \pi(a \mid s)]$ und der Temperatur $\alpha \ge 0$.

In Worten: Bevorzuge Policies, die hohe Rewards und gleichzeitig hohe Entropie haben. Die Konsequenzen sind tiefgreifend:

Der entscheidende Punkt: $\alpha$ kann automatisch getunt werden, so dass eine Ziel-Entropie eingehalten wird (siehe Abschnitt 6).

Quick-Check: Wozu dient der Entropie-Term in SACs Zielfunktion?

3. Off-Policy mit Replay-Buffer

SAC sammelt Erfahrungen kontinuierlich und legt sie in einem Replay-Buffer ab (typischerweise 1M Transitionen). Updates ziehen zufällige Minibatches aus dem Buffer – damit ist jede Interaktion mit der Welt mehrfach nutzbar.

graph LR E[Environment] -->|s,a,r,s'| B[Replay Buffer] B -->|sample| Q[Twin Q-Critics] B -->|sample| A[Actor pi] A -->|action| E Q -->|target Q| A

Das macht SAC dramatisch sample-effizienter als PPO. Auf typischen MuJoCo-Benchmarks erreicht SAC vergleichbare Performance mit 5–10x weniger Umweltinteraktionen.

4. Twin-Q-Critics: gegen Overestimation

Q-Learning hat ein bekanntes Problem: der max-Operator über noisy Q-Schätzungen produziert systematische Overestimation. Bei DQN ist das oft tolerabel, in continuous control ruinieren die divergierenden Q-Werte das Training.

SAC nutzt zwei unabhängige Q-Netze $Q_{\phi_1}, Q_{\phi_2}$ (Idee aus TD3, Fujimoto et al. 2018). Das TD-Target verwendet das Minimum:

$$y = r + \gamma \left(\min_{i=1,2} Q_{\bar{\phi}_i}(s', a') - \alpha \log \pi(a' \mid s')\right), \quad a' \sim \pi(\cdot \mid s')$$

Das pessimistische min dämpft Overestimation systematisch. $\bar{\phi}$ bezeichnet Target-Netze – langsam mit Polyak-Mittelung gefolgte Kopien der eigentlichen Q-Netze.

Quick-Check: Wofür dient das Twin-Q-Design in SAC?

5. Reparametrization Trick: Gradienten durch das Sampling

SACs Policy ist eine Tanh-squashed Gaussian: Das Netz gibt $\mu(s), \sigma(s)$ aus, die Aktion entsteht via

$$a = \tanh\left(\mu(s) + \sigma(s) \odot \epsilon\right), \quad \epsilon \sim \mathcal{N}(0, I)$$

Der Tanh-Squash hält die Aktion in $[-1, 1]$. Der entscheidende Effekt: Der Gradient von $a$ bzgl. der Netzgewichte ist wohldefiniert, weil das Sampling nicht im Berechnungsgraph liegt – $\epsilon$ ist eine externe Zufallsquelle. Damit lässt sich der Policy-Loss

$$L_\pi = \mathbb{E}s\left[\alpha \log \pi(a \mid s) - Q(s, a)\right], \quad a \sim \pi\theta(\cdot \mid s)$$

direkt mit Backprop minimieren – ohne den High-Variance-Policy-Gradient aus REINFORCE/PPO.

6. Lauffähig: SAC-Toy auf einer 1D-Regulator-Welt

Ein vollständiges SAC mit neuronalen Netzen passt nicht in einen Pyodide-Snippet. Aber wir können die Kern-Mechanik – Replay-Buffer, Twin-Q, Maximum-Entropy-Update – auf einer winzigen Welt zeigen: Der Agent steht auf einer 1D-Linie und soll möglichst nahe an target = 0 bleiben. Aktion ist ein kontinuierlicher Schub in $[-1, 1]$.

import numpy as np
from collections import deque

rng = np.random.default_rng(0)
GAMMA = 0.95
ALPHA = 0.2          # Entropie-Temperatur
LR    = 0.05
TAU   = 0.01         # Polyak-Mittelung für Target-Netze
BUFFER_SIZE = 5000
BATCH = 64
STEPS = 4000

# Vereinfachte "Netze": lineare Q(s, a) = w_s * s + w_a * a + b. Zwei Stück.
def init_q():  return np.zeros(3)            # [w_s, w_a, b]
def q(params, s, a):  return params[0]*s + params[1]*a + params[2]

Q1, Q2 = init_q(), init_q()
Q1_t, Q2_t = Q1.copy(), Q2.copy()

# Policy: gaussisch mit gelernten mu(s)=k*s, log_std konstant.
mu_k = 0.0
log_std = np.log(0.5)

def sample_action(s):
    mu = mu_k * s
    std = np.exp(log_std)
    eps = rng.standard_normal()
    a_pre = mu + std * eps
    a = np.tanh(a_pre)
    # log_prob nach Tanh-Squash (klassische SAC-Korrektur).
    log_prob = -0.5*((a_pre - mu)/std)**2 - log_std - 0.5*np.log(2*np.pi) - np.log(1 - a**2 + 1e-6)
    return a, log_prob, a_pre

def env_step(s, a):
    s_next = s + a + rng.normal(0, 0.1)
    s_next = np.clip(s_next, -3, 3)
    r = -s_next**2                        # je näher an 0, desto besser
    done = False
    return s_next, r, done

buffer = deque(maxlen=BUFFER_SIZE)
s = rng.uniform(-2, 2)
returns_window = deque(maxlen=200)
ep_return = 0.0

for step in range(STEPS):
    a, _, _ = sample_action(s)
    s_next, r, done = env_step(s, a)
    buffer.append((s, a, r, s_next))
    ep_return += r
    s = s_next
    if step % 50 == 49:
        returns_window.append(ep_return); ep_return = 0.0; s = rng.uniform(-2, 2)

    if len(buffer) < BATCH:
        continue

    idx = rng.integers(0, len(buffer), BATCH)
    batch = [buffer[i] for i in idx]
    S  = np.array([b[0] for b in batch])
    A  = np.array([b[1] for b in batch])
    R  = np.array([b[2] for b in batch])
    Sn = np.array([b[3] for b in batch])

    # Target: min(Q1_t, Q2_t)(s', a') - alpha * log_pi(a'|s')
    A_next, logp_next = [], []
    for sn in Sn:
        a_n, lp, _ = sample_action(sn)
        A_next.append(a_n); logp_next.append(lp)
    A_next = np.array(A_next); logp_next = np.array(logp_next)
    q1_t = Q1_t[0]*Sn + Q1_t[1]*A_next + Q1_t[2]
    q2_t = Q2_t[0]*Sn + Q2_t[1]*A_next + Q2_t[2]
    target = R + GAMMA * (np.minimum(q1_t, q2_t) - ALPHA * logp_next)

    # Critic-Update (MSE-Gradient auf lineare Q).
    for Q in (Q1, Q2):
        pred = Q[0]*S + Q[1]*A + Q[2]
        err  = pred - target
        Q[0] -= LR * (err * S).mean()
        Q[1] -= LR * (err * A).mean()
        Q[2] -= LR * err.mean()

    # Actor-Update via Reparametrization: maximiere Q - alpha * log_pi.
    # Hier ein 1-Sample-Gradient für mu_k.
    a_s, lp_s, _ = sample_action(S[0])
    q_min = min(q(Q1, S[0], a_s), q(Q2, S[0], a_s))
    # Gradient bzgl. mu_k: d(Q - alpha*log_pi)/d(mu_k)  --  Heuristik:
    # mehr Schub in Richtung -s (näher an 0) ist besser → mu_k sollte negativ werden.
    grad_mu = (q_min - ALPHA * lp_s) * S[0]
    mu_k += LR * 0.01 * grad_mu

    # Polyak-Mittelung der Targets.
    Q1_t = (1 - TAU) * Q1_t + TAU * Q1
    Q2_t = (1 - TAU) * Q2_t + TAU * Q2

print(f"Mittlerer Return (letzte 200 'Episoden'): {np.mean(returns_window):+.2f}")
print(f"Gelernter Policy-Slope mu_k             : {mu_k:+.3f}  (erwartet < 0)")
print(f"Q1-Koeffs                               : w_s={Q1[0]:+.2f}  w_a={Q1[1]:+.2f}")

Die Welt ist absichtlich klein, aber die SAC-Bausteine sind alle da:

Erwartetes Ergebnis: Der Slope mu_k wird negativ – die gelernte Policy schiebt zurück zum Ziel s = 0.

Quick-Check: Warum ist SAC off-policy?

7. PyTorch-Skeleton (Konzept, nicht im Browser lauffähig)

So sieht SACs Policy-Update in echtem PyTorch aus:

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.distributions import Normal

class SquashedGaussianPolicy(nn.Module):
    def __init__(self, obs_dim, act_dim, hidden=256):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(obs_dim, hidden), nn.ReLU(),
            nn.Linear(hidden, hidden), nn.ReLU(),
        )
        self.mu_head      = nn.Linear(hidden, act_dim)
        self.log_std_head = nn.Linear(hidden, act_dim)

    def forward(self, obs):
        h = self.net(obs)
        mu = self.mu_head(h)
        log_std = self.log_std_head(h).clamp(-20, 2)
        std = log_std.exp()
        dist = Normal(mu, std)
        a_pre = dist.rsample()                  # Reparametrization!
        a = torch.tanh(a_pre)
        # Tanh-Korrektur für log_prob (numerisch stabil).
        log_prob = dist.log_prob(a_pre).sum(-1) - torch.log(1 - a.pow(2) + 1e-6).sum(-1)
        return a, log_prob


def sac_update(policy, q1, q2, q1_t, q2_t, opt_pi, opt_q, batch, alpha, gamma=0.99, tau=0.005):
    s, a, r, s_next, done = batch

    # Critic-Update.
    with torch.no_grad():
        a_next, logp_next = policy(s_next)
        q_target = torch.min(q1_t(s_next, a_next), q2_t(s_next, a_next)) - alpha * logp_next
        target = r + gamma * (1 - done) * q_target

    q1_loss = F.mse_loss(q1(s, a), target)
    q2_loss = F.mse_loss(q2(s, a), target)
    opt_q.zero_grad(); (q1_loss + q2_loss).backward(); opt_q.step()

    # Actor-Update.
    a_new, logp = policy(s)
    q_new = torch.min(q1(s, a_new), q2(s, a_new))
    pi_loss = (alpha * logp - q_new).mean()
    opt_pi.zero_grad(); pi_loss.backward(); opt_pi.step()

    # Polyak-Mittelung.
    with torch.no_grad():
        for p, p_t in zip(q1.parameters(), q1_t.parameters()):
            p_t.data.mul_(1 - tau).add_(tau * p.data)
        for p, p_t in zip(q2.parameters(), q2_t.parameters()):
            p_t.data.mul_(1 - tau).add_(tau * p.data)

Die rsample()-Methode markiert das Reparametrization-Sampling. Ohne sie wäre der Gradient durch das Sample blockiert.

8. Automatisches Alpha-Tuning

Hand-gewähltes $\alpha$ ist heikel: zu hoch → Policy bleibt zu stochastisch, zu niedrig → Exploration kollabiert. Haarnoja et al. (2018) lösen das, indem $\alpha$ selbst gelernt wird – mit einer Ziel-Entropie $\mathcal{H}_{\text{target}} \approx -\dim(A)$:

$$L_\alpha = -\alpha \cdot (\log \pi(a \mid s) + \mathcal{H}_{\text{target}})$$

In der Praxis ist das die Default-Variante. Sie macht SAC weitgehend tuning-frei – einer der Hauptgründe für die Beliebtheit.

9. Diskussion: warum SAC für Robotik dominant ist

Eigenschaft Wirkung
Off-policy + Replay Sample-Effizienz, kritisch bei teurer Welt-Interaktion
Maximum-Entropy Native Exploration, robuste Policies, multi-modale Lösungen
Twin-Q Stabilität gegen Overestimation-Cascade
Reparametrization Saubere Gradienten durch stochastische Policy
Auto-$\alpha$ Weitgehend tuning-frei out-of-the-box

Genau diese Kombination macht SAC zum Default für Sim-to-Real-Robotik (Boston Dynamics, OpenAI Hand, viele MuJoCo-Benchmarks).

10. Abschluss-Quiz

Welche Kombination charakterisiert SAC am besten?

Warum nutzt SAC `min(Q1, Q2)` im TD-Target?

Was leistet der Reparametrization Trick?

Welcher Vorteil ergibt sich aus Auto-Tuning der Entropie-Temperatur $\alpha$?

Welche Aussage zur Tanh-squashed Gaussian-Policy ist korrekt?

11. Weiterführend

Quellen

📄 Als PDF