Home Deep Rl Sac Ppo Artikel 4
Teil 4 Generative AI

Proximal Policy Optimization (PPO)

Bild: KI-generiert mit Beuys


Proximal Policy Optimization (PPO)

PPO ist seit 2017 der De-facto-Standard für on-policy Reinforcement Learning. Der Trick: Statt eine harte KL-Beschränkung wie TRPO zu lösen, deckelt PPO das Update durch einfaches Clipping. Wir entwickeln das Clipped Surrogate Objective Schritt für Schritt, zeigen die PPO-Update-Schleife als Mermaid-Diagramm, implementieren ein lauffähiges Mini-PPO auf der numpy-GridWorld und ergänzen einen realistischen PyTorch-Skeleton.


1. Das Problem: Policy-Gradient-Schritte können tödlich sein

Vanilla Policy-Gradient hat ein Sicherheitsproblem: Ein einzelner zu großer Update-Schritt kann die Policy in einen Bereich werfen, aus dem sie nicht zurückfindet. Das Lernen kollabiert – auch dann, wenn der Schritt mathematisch in die richtige Richtung zeigt.

Lernrate Verhalten
Zu klein Konvergiert, aber unerträglich langsam
Mittel Funktioniert in einfachen Umgebungen
Zu groß Catastrophic collapse: Policy wird zufällig, Recovery selten

Die mathematisch saubere Antwort: Trust Region Policy Optimization (TRPO, Schulman et al. 2015). TRPO formuliert die Update-Regel als constrained optimization:

$$\max_\theta \mathbb{E}\left[\frac{\pi_\theta(a \mid s)}{\pi_{\theta_{\text{old}}}(a \mid s)} \hat{A}t\right] \quad \text{u.B.} \quad \mathbb{E}[\text{KL}(\pi{\theta_{\text{old}}} \,|\, \pi_\theta)] \le \delta$$

In Worten: Maximiere den erwarteten Vorteil, aber bleibe in einer KL-Kugel um die alte Policy. Theoretisch elegant – praktisch braucht TRPO konjugierte Gradienten und Hessian-Vector-Products. Wenige Frameworks implementieren es robust.

2. Die PPO-Idee: Clipping statt KL-Constraint

Schulman et al. (2017) stellten die pragmatische Frage: Was, wenn wir die Trust-Region-Garantie durch eine einfache, harte Deckelung des Ratios ersetzen? Das Ergebnis ist das Clipped Surrogate Objective:

$$L^{\text{CLIP}}(\theta) = \mathbb{E}\left[\min\left(r_t(\theta) \hat{A}_t,\; \text{clip}(r_t(\theta), 1 - \epsilon, 1 + \epsilon) \hat{A}_t\right)\right]$$

mit dem Importance-Sampling-Ratio

$$r_t(\theta) = \frac{\pi_\theta(a_t \mid s_t)}{\pi_{\theta_{\text{old}}}(a_t \mid s_t)}.$$

Die Logik in Klartext:

Typische Werte: $\epsilon = 0.2$.

graph LR R[Rollout pi_old] -->|s,a,r| B[Compute GAE] B -->|A_t| L[Clipped Loss] L -->|grad| U[Update theta] U -->|repeat K epochs| L U -->|next iteration| R

Quick-Check: Was bewirkt das Clipping im PPO-Objective?

3. Mehrere Epochen pro Batch: PPOs Sample-Effizienz

Klassisches REINFORCE wirft jeden Batch nach genau einem Gradient-Step weg. PPO ist deutlich großzügiger: Auf einem einmal gesammelten Batch von typischerweise 2048–8192 Schritten laufen $K$ Epochen mit Minibatches ($K = 4 \dots 10$).

Das funktioniert, weil das Clipping nach den ersten paar Updates greift: Sobald die neue Policy weit genug von $\pi_{\theta_\text{old}}$ entfernt ist, liefert der geklippte Pfad einen 0-Gradienten – die weiteren Epochen ändern dann nichts mehr. PPO ist damit halb-on-policy: nicht vollständig off-policy wie SAC, aber deutlich effizienter als reines REINFORCE.

Quick-Check: Warum darf PPO mehrere Epochen über denselben Batch laufen lassen?

4. Lauffähig: Mini-PPO auf der GridWorld

Wir bauen ein vollständiges PPO – ohne PyTorch, mit reiner numpy-Policy-Tabelle. Die Schritte folgen dem klassischen PPO-Rezept:

  1. Rollout unter $\pi_{\theta_\text{old}}$ sammeln.
  2. GAE-Advantage berechnen (vereinfacht hier: einfacher TD-Fehler).
  3. $K$ Epochen mit Clipped Loss updaten.
import numpy as np

rng = np.random.default_rng(0)
LENGTH = 7
GAMMA = 0.95
LR_A, LR_C = 0.05, 0.1
EPS = 0.2          # Clip-Range
K_EPOCHS = 4
ITERATIONS = 200
STEPS_PER_ITER = 256

theta = np.zeros((LENGTH, 2))
V     = np.zeros(LENGTH)

def softmax(x):
    z = x - x.max(); e = np.exp(z); return e / e.sum()

def policy_probs_from(theta_arr, s): return softmax(theta_arr[s])

def step(s, a):
    s_next = max(0, min(LENGTH - 1, s + (1 if a == 1 else -1)))
    done = s_next in (0, LENGTH - 1)
    if done:
        r = 1.0 if s_next == LENGTH - 1 else -1.0
    else:
        r = 0.0
    return s_next, r, done

def collect_rollout(theta_old, n_steps):
    """Sammelt n_steps Transitionen, startet Episoden in der Mitte."""
    data = []
    s = LENGTH // 2
    for _ in range(n_steps):
        probs = policy_probs_from(theta_old, s)
        a = int(rng.choice(2, p=probs))
        s_next, r, done = step(s, a)
        data.append((s, a, r, s_next, done, probs[a]))
        s = LENGTH // 2 if done else s_next
    return data

def compute_advantages(data):
    advs = []
    for (s, a, r, s_next, done, _) in data:
        target = r + (0.0 if done else GAMMA * V[s_next])
        advs.append(target - V[s])
    return np.array(advs)

ep_returns_per_iter = []
for it in range(ITERATIONS):
    theta_old = theta.copy()
    data = collect_rollout(theta_old, STEPS_PER_ITER)
    advs = compute_advantages(data)
    # Normalisieren: stabilisiert den Loss.
    advs = (advs - advs.mean()) / (advs.std() + 1e-8)

    # K Epochen Clipped Update.
    for _ in range(K_EPOCHS):
        for (s, a, r, s_next, done, p_old_a), adv in zip(data, advs):
            probs_new = policy_probs_from(theta, s)
            p_new_a = probs_new[a]
            ratio = p_new_a / max(p_old_a, 1e-8)
            clipped = min(max(ratio, 1 - EPS), 1 + EPS)

            # Surrogate-Beitrag: das min(...) bestimmt, welcher Pfad aktiv ist.
            unclipped_obj = ratio * adv
            clipped_obj   = clipped * adv
            # Wenn der unclipped Pfad das min liefert: regulärer PG-Gradient.
            # Wenn der clipped Pfad das min liefert: Gradient = 0.
            if unclipped_obj <= clipped_obj:
                grad_log = -probs_new.copy(); grad_log[a] += 1.0
                # Gradient von ratio*adv bzgl theta entspricht ratio * grad_log * adv.
                theta[s] += LR_A * ratio * grad_log * adv

        # Critic-Update (TD).
        for (s, a, r, s_next, done, _) in data:
            target = r + (0.0 if done else GAMMA * V[s_next])
            V[s] += LR_C * (target - V[s])

    # Episoden-Returns aus den done-Markern extrahieren (Approximation).
    done_returns = [r for (_, _, r, _, done, _) in data if done]
    if done_returns:
        ep_returns_per_iter.append(np.mean(done_returns))

print(f"Mittlerer Terminal-Reward erste 20 Iter : {np.mean(ep_returns_per_iter[:20]):+.3f}")
print(f"Mittlerer Terminal-Reward letzte 20 Iter: {np.mean(ep_returns_per_iter[-20:]):+.3f}")
print("Gelernte P(rechts):")
for s in range(LENGTH):
    print(f"  s={s}: {policy_probs_from(theta, s)[1]:.2f}  V={V[s]:+.2f}")

Der Clip-Mechanismus ist in der if unclipped_obj <= clipped_obj-Verzweigung sichtbar: Sobald das Ratio aus dem $[1-\epsilon, 1+\epsilon]$-Korridor läuft und dieser Pfad das ungünstigere Surrogate liefert, fällt der Gradient auf 0. Genau das ist PPOs Sicherheitsnetz.

Quick-Check zum Code: Wofür steht `ratio` im Snippet?

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

Für einen realistischen Eindruck, wie PPO in PyTorch aussieht – diese Variante setzt gymnasium voraus und läuft nur lokal:

import torch
import torch.nn as nn
from torch.distributions import Categorical

class ActorCritic(nn.Module):
    def __init__(self, obs_dim, n_actions, hidden=64):
        super().__init__()
        self.shared = nn.Sequential(
            nn.Linear(obs_dim, hidden), nn.Tanh(),
            nn.Linear(hidden, hidden), nn.Tanh(),
        )
        self.actor_head  = nn.Linear(hidden, n_actions)
        self.critic_head = nn.Linear(hidden, 1)

    def forward(self, x):
        h = self.shared(x)
        return Categorical(logits=self.actor_head(h)), self.critic_head(h).squeeze(-1)


def ppo_update(model, optimizer, batch, eps=0.2, k_epochs=4):
    """batch = (obs, actions, old_log_probs, advantages, returns)."""
    obs, acts, old_log_probs, advs, returns = batch
    advs = (advs - advs.mean()) / (advs.std() + 1e-8)

    for _ in range(k_epochs):
        dist, values = model(obs)
        new_log_probs = dist.log_prob(acts)
        ratio = (new_log_probs - old_log_probs).exp()

        unclipped = ratio * advs
        clipped   = torch.clamp(ratio, 1 - eps, 1 + eps) * advs
        policy_loss = -torch.min(unclipped, clipped).mean()

        value_loss = ((returns - values) ** 2).mean()
        entropy    = dist.entropy().mean()

        loss = policy_loss + 0.5 * value_loss - 0.01 * entropy
        optimizer.zero_grad()
        loss.backward()
        nn.utils.clip_grad_norm_(model.parameters(), 0.5)
        optimizer.step()

Drei produktive Details, die im numpy-Toy fehlen:

6. Hyperparameter-Cheat-Sheet

Parameter Typischer Wert Wirkung
Clip-Range $\epsilon$ 0.1 – 0.3 Größer → schnellere Updates, instabiler
K Epochen 4 – 10 Mehr → höhere Sample-Effizienz, mehr Off-Policy-Bias
Batch-Size 2048 – 8192 Schritte Mehr → stabilere Schätzer, weniger Updates pro Stunde
Minibatch-Size 64 – 256 Klassisch SGD-Tuning
GAE-$\lambda$ 0.9 – 0.97 Bias-Varianz-Hebel (Artikel 03)
Entropie-Koeff. 0.0 – 0.01 Höher → mehr Exploration
LR (Adam) 3e-4 (Standard) Mit linearer Decay zu 0 hin am Ende

7. Diskussion: warum PPO so dominant wurde

PPO ist nicht der theoretisch eleganteste Algorithmus. Er gewinnt durch Engineering-Eigenschaften:

Genau diese Eigenschaften haben PPO zur Wahl für RLHF in modernen LLMs (ChatGPT, Llama, Claude) gemacht – allerdings mit eigenen Anpassungen (Reference-KL-Penalty, Reward-Model-basierter Reward).

8. Abschluss-Quiz

Was ist der Hauptvorteil von PPO gegenüber TRPO?

Welche Aussage zum Clipping ist korrekt?

Wozu dient der Entropie-Bonus im PPO-Loss?

PPO wird oft als *halb-on-policy* beschrieben. Warum?

Warum normalisiert man die Advantages in jedem PPO-Update typischerweise auf Mittelwert 0 und Standardabweichung 1?

9. Weiterführend

Quellen

📄 Als PDF