Unit 8 — Memory & POMDPs
Trainiere FPS / RobotFPS — Umgebungen, in denen der Agent nicht alles auf einmal sehen kann. Lerne, warum Gedächtnis wichtig ist, wie LSTM-Policy-Netze funktionieren und wie du RecurrentPPO aus sb3-contrib benutzt.
← Unit 7: Multi-Agent · Kursstartseite
Voraussetzungen
- Unit 4 — PPO end-to-end, sicheres Lesen von
train/*-Kurven - Unit 6 — kontinuierliches Aktionsdesign (FPS verwendet kontinuierliches Zielen + diskretes Feuer)
- Visuelle Beobachtungen (optional) — nur wenn du aus Pixeln trainierst
- High-Level-Vertrautheit mit RNN- / LSTM-Ideen (wir erklären sie in §2 erneut)
Zeit
Lesen: ~35 min · Training: ~30 min GPU / ~2 Std CPU
Drei Wege, deine KI zu beobachten
Godot (beobachte, wie der Agent zögert, wenn ein Ziel aus dem Blickfeld gerät — ein Zeichen, dass Gedächtnis funktioniert) · TensorBoard (ep_rew_mean mit vs ohne RecurrentPPO) · Beobachtungsdesign: was der Agent sehen kann und was nicht
1 · Teilweise beobachtbare Umgebungen (POMDPs)
Alle vorherigen Units verwendeten vollständig beobachtbare Umgebungen — die Beobachtung des Agenten enthielt alles, was er zur Aktionswahl brauchte. Reale Umgebungen sind selten so sauber:
- Ein FPS-Charakter kann nicht durch Wände sehen
- Ein Roboter in einem Labyrinth weiß nicht, wo er gestartet ist
- Ein Lander mit einem verrauschten Sensor kann sich seiner exakten Höhe nicht sicher sein
Das sind Partially Observable MDPs (POMDPs). Die Markov-Eigenschaft (Beobachtung = vollständiger Zustand) gilt nicht mehr. Der Agent muss sich an vergangene Beobachtungen erinnern, um den verborgenen Zustand zu inferieren.
Standard-PPO bricht bei POMDPs — es behandelt jeden Schritt unabhängig. Bei nur der aktuellen (partiellen) Beobachtung ist die optimale Aktion mehrdeutig.
RecurrentPPO fügt dem Policy-Netz eine LSTM-Schicht (Long Short-Term Memory) hinzu. Das LSTM trägt einen verborgenen Zustand über Zeitschritte hinweg und gibt dem Agenten effektiv Gedächtnis.
2 · Wie LSTM-Gedächtnis in RecurrentPPO funktioniert
Der verborgene Zustand des LSTM ist:
- Zurückgesetzt an Episodengrenzen — Gedächtnis leckt nicht zwischen Episoden
- Geteilt über parallele Envs — jede Env-Instanz hat ihren eigenen verborgenen Zustand
- Feste Größe — gesteuert durch lstm_hidden_size (Standard: 256)
Der Agent lernt, relevante Informationen in den verborgenen Zustand zu schreiben (z. B. „Ziel wurde zuletzt links gesehen") und sie bei Bedarf zurückzulesen.
3 · sb3-contrib installieren
RecurrentPPO lebt in sb3-contrib, nicht im Basis-stable-baselines3:
4 · FPS oder RobotFPS öffnen
- Aus godot_rl_agents_examples, öffne
examples/FPSoderexamples/RobotFPS - Aktiviere das Godot RL Agents Plugin
- Lies
ai_controller.gd:
Was der Agent sehen kann (get_obs()):
func get_obs() -> Dictionary:
return {"obs": [
# RayCast readings — local perception only
ray_forward.get_collision_distance() / max_dist,
ray_left.get_collision_distance() / max_dist,
ray_right.get_collision_distance() / max_dist,
# No global position — agent can't see where it is on the map
linear_velocity.x / max_speed,
linear_velocity.z / max_speed,
# Target visible? (0 or 1)
float(target_in_sight),
]}
Beachte, was fehlt: globale Position, Kartenlayout, Zielposition, wenn außer Sicht. Der Agent muss diese aus dem Gedächtnis inferieren.
5 · Partielle Beobachtungen entwerfen (eigene bauen)
Wenn du einer existierenden Umgebung eine Gedächtnisanforderung hinzufügen willst:
Globale Information entfernen:
# Before (fully observable):
(global_position.x - target.global_position.x) / 100.0
# After (partial — agent must remember where it last saw the target):
float(target_in_sight) * (global_position.x - target.global_position.x) / 100.0
# When target is not in sight, this returns 0.0 — the agent loses the signal
Rauschen hinzufügen:
6 · Mit RecurrentPPO trainieren
from sb3_contrib import RecurrentPPO
from godot_rl.wrappers.stable_baselines_wrapper import StableBaselinesGodotEnv
env = StableBaselinesGodotEnv(
env_path="./RobotFPS.x86_64",
n_parallel=8,
speedup=20,
)
model = RecurrentPPO(
"MlpLstmPolicy",
env,
verbose=1,
tensorboard_log="logs/",
n_steps=512,
batch_size=256,
lstm_hidden_size=256,
n_lstm_layers=1,
)
model.learn(total_timesteps=3_000_000)
model.save("robotfps_recurrent")
env.close()
7 · PPO vs RecurrentPPO Vergleich
Führe beide auf derselben Umgebung aus und vergleiche in TensorBoard:
# Standard PPO baseline
from stable_baselines3 import PPO
model_ppo = PPO("MlpPolicy", env, verbose=1, tensorboard_log="logs/")
model_ppo.learn(total_timesteps=3_000_000)
Erwartetes Ergebnis: PPO plateauiert oder scheitert bei Aufgaben, die Gedächtnis erfordern. RecurrentPPO verbessert sich weiter. Die Lücke ist gewöhnlich nach 1–2M Schritten sichtbar.
| Metrik | PPO | RecurrentPPO |
|---|---|---|
ep_rew_mean-Peak |
Niedriger | Höher |
| Konvergenzgeschwindigkeit | Schneller früh | Langsamer früh, höhere Decke |
| RAM-Nutzung | Niedrig | Höher (LSTM-Zustände pro Env) |
Fertig, wenn
Mit beiden 3M-Schritte-Läufen in TensorBoard liegt ep_rew_mean von RecurrentPPO klar über der PPO-Baseline, und die Lücke öffnet sich ungefähr in dem Bereich von 1–2M Schritten, den dieser Abschnitt vorhersagt. Rechne mit Rauschen — beurteile den Trend über die letzte Million Schritte, nicht einzelne Ausschläge. Sind die beiden Kurven nicht zu unterscheiden, prüfe zuerst deine Beobachtungen, nicht die Hyperparameter: eine durchgesickerte globale Position (§4–§5) macht die Umgebung vollständig beobachtbar und löscht den Vorteil von RecurrentPPO aus.
Bau es · Frame Stacking auf maskiertem CartPole
Frame Stacking ist der günstigere Gedächtnismechanismus: Statt eines LSTM bekommt Standard-PPO einfach die letzten N Beobachtungen als Eingabe. Dieses Experiment läuft komplett auf dem gepinnten Kurs-Stack — kein sb3-contrib, kein Godot-Build nötig. Wir verstecken die Geschwindigkeiten von CartPole (aus dem MDP wird ein POMDP) und zeigen dann, dass vier gestapelte Frames die fehlende Information zurückbringen: Geschwindigkeit ist nur eine Differenz aufeinanderfolgender Positionen.
import gymnasium as gym
import numpy as np
from stable_baselines3 import PPO
from stable_baselines3.common.monitor import Monitor
from stable_baselines3.common.vec_env import DummyVecEnv, VecFrameStack
class MaskVelocity(gym.ObservationWrapper):
"""CartPole as a POMDP: keep cart position + pole angle, hide both velocities."""
def __init__(self, env):
super().__init__(env)
high = self.observation_space.high[[0, 2]]
self.observation_space = gym.spaces.Box(-high, high, dtype=np.float32)
def observation(self, obs):
return obs[[0, 2]].astype(np.float32)
def make_env():
# Monitor records episode returns — without it, ep_rew_mean never reaches TensorBoard
return Monitor(MaskVelocity(gym.make("CartPole-v1")))
# Baseline: one masked frame per step — velocity is unrecoverable
blind = DummyVecEnv([make_env] * 8)
model_blind = PPO("MlpPolicy", blind, verbose=1, tensorboard_log="logs/")
model_blind.learn(total_timesteps=200_000, tb_log_name="ppo_blind")
# Frame stacking: 4 masked frames — velocity becomes a finite difference
stacked = VecFrameStack(DummyVecEnv([make_env] * 8), n_stack=4)
model_stacked = PPO("MlpPolicy", stacked, verbose=1, tensorboard_log="logs/")
model_stacked.learn(total_timesteps=200_000, tb_log_name="ppo_stacked")
Fertig, wenn
In TensorBoard steigt ppo_stacked deutlich an, während ppo_blind weit darunter plateauiert — dieselbe Gedächtnis-vs-kein-Gedächtnis-Lücke wie im Vergleich aus §7, reproduziert mit der billigsten Form von Gedächtnis. Die Läufe sind verrauscht: Erwarte die Reihenfolge, nicht exakte Kurven. Sehen beide Läufe identisch aus, prüfe, ob MaskVelocity in beiden angewendet wird: der observation_space der Basis-Env sollte Shape (2,) haben, nicht (4,).
8 · Viz-Checkpoint
Beobachte den Agenten mit --viz nach dem Training:
- Sucht der Agent, wenn das Ziel aus dem Blickfeld gerät, oder friert er ein?
- Erinnert er sich an die Richtung, in der er das Ziel zuletzt gesehen hat?
- Behandelt er Sackgassen (umdrehen) oder bleibt er stecken?
Ein funktionierender LSTM-Agent wird kurz zögern, wenn er die Sicht verliert, dann sich in die letzte bekannte Richtung bewegen — klares, menschlich lesbares Gedächtnisverhalten.
9 · Stretch Goals
- Längeres Gedächtnis — erhöhe
lstm_hidden_sizeauf 512; miss, ob es bei einer labyrinthartigen Aufgabe hilft - Eine Gedächtnisaufgabe bauen — entwirf eine Umgebung, in der der Agent sich erinnern muss, welche von zwei Türen er in der letzten Episode geöffnet hat
Was kommt als Nächstes
Self-Play: Trainiere Agenten, indem sie gegen Kopien von sich selbst antreten — AirHockey, eingefrorene Checkpoints, League-basiertes Training und ELO-Tracking.
Selbstcheck, bevor du weitermachst
Kannst du diese in eigenen Worten beantworten?
- Welche Eigenschaft bricht ein POMDP, und was bedeutet das konkret für das Netz?
- Was trägt der verborgene Zustand des LSTM über Zeitschritte hinweg, was eine Feed-Forward-Policy nicht kann?
- Wann wäre Frame Stacking ausreichend, und wann brauchst du wirklich RecurrentPPO?
- Warum verwendet RecurrentPPO truncated BPTT, anstatt die volle Episode auszurollen?
- Nenne eine Godot-Umgebung in diesem Kurs, in der du kein Gedächtnis verwenden würdest, und eine, in der du es würdest.
Wenn du alle fünf beantworten kannst — bist du bereit.
Antworten zum Selbstcheck
- Ein POMDP bricht die Markov-Eigenschaft — die aktuelle Beobachtung ist nicht mehr der vollständige Zustand. Konkret steht ein Feed-Forward-Netz, das eine Beobachtung auf eine Aktion abbildet, vor Mehrdeutigkeit: Dieselbe Beobachtung kann je nach Vorgeschichte verschiedene Aktionen verlangen, also braucht das Netz einen Mechanismus, der vergangene Beobachtungen mitführt.
- Der verborgene Zustand trägt eine gelernte Zusammenfassung fester Größe von allem bisher gesehenen Relevanten — z. B. „Ziel wurde zuletzt links gesehen" —, die über Zeitschritte hinweg geschrieben und gelesen wird. Eine Feed-Forward-Policy fängt jeden Schritt bei null an und kann nur auf die aktuelle Beobachtung reagieren.
- Frame Stacking reicht, wenn die fehlende Information in einem kurzen, festen Fenster steckt — etwa eine Geschwindigkeit aus den letzten Positionen rekonstruieren. RecurrentPPO brauchst du, wenn das relevante Ereignis beliebig weit zurückliegen kann: ein Ziel, das vor vielen Schritten aus dem Blickfeld verschwand, oder die Frage, durch welchen Korridor du ein Labyrinth betreten hast.
- Die volle Episode auszurollen hieße, Aktivierungen über potenziell Tausende Schritte zu speichern und durch sie zurückzupropagieren — der Speicher explodiert, und Gradienten verschwinden oder explodieren. Truncated BPTT propagiert nur durch Stücke fester Länge zurück (höchstens das
n_steps=512-Rollout; Minibatching teilt weiter auf); die Updates bleiben billig und stabil, während der verborgene Zustand weiterhin über Stückgrenzen hinweg fließt. - Kein Gedächtnis: eine vollständig beobachtbare Umgebung wie JumperHard (Unit 4) — ihre Beobachtung enthält bereits alles, was für die optimale Aktion nötig ist. Gedächtnis: FPS/RobotFPS aus dieser Unit — das Ziel verlässt das Blickfeld, und der Agent hat keine globale Position.