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:
- Native Exploration: Die Policy bleibt absichtlich stochastisch. Kein externes $\epsilon$-greedy-Schema mehr.
- Multi-Modale Policies: Wenn zwei Aktionen gleichermaßen gut sind, hält SAC beide Modi aufrecht.
- Robustheit: Eine breite Policy ist weniger anfällig gegen leichte Reward-Verschiebungen.
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.
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:
- Replay-Buffer (
deque) speichert alte Transitionen. - Twin-Q (
Q1, Q2) plus Target-Netze (Q1_t, Q2_t) mit Polyak-Mittelung. - Maximum-Entropy-Target (
min(Q1_t, Q2_t) - ALPHA * logp_next). - Reparametrisiertes Sampling mit Tanh-Squash und korrigiertem log-prob.
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
- Nächster Artikel: PPO vs. SAC – wann welcher Algorithmus? (Artikel 06 dieser Serie).
- Querverweis: self-learning-agents/02-rl-fuer-code-agenten.
- Knowledgebase:
masteringpytorch-secondedition.md– Kapitel zu Continuous Control.
Quellen
- Haarnoja, T. et al. (2018) – Soft Actor-Critic: Off-Policy Maximum Entropy Deep RL with a Stochastic Actor, ICML.
- Haarnoja, T. et al. (2018) – Soft Actor-Critic Algorithms and Applications, arXiv:1812.05905 (Auto-Alpha-Variante).
- Fujimoto, S. et al. (2018) – Addressing Function Approximation Error in Actor-Critic Methods (TD3, Twin-Q-Idee).