Eine Laufstrategie für humanoide Roboter meldete während des Trainings Episoden mit 1000 Schritten, bei der Offline-Auswertung des gespeicherten Checkpoints waren es jedoch nur 84 Schritte. Gleiches Modell, gleiche Konfiguration, gleicher Auswertungsrahmen – eine 12-fache Diskrepanz bei der Reproduzierbarkeit, deren Diagnose zwei Wochen in Anspruch nahm.
Zwei Fehler waren dafür verantwortlich. Erstens führt VecNormalize von Stable-Baselines3 fortlaufend Beobachtungsstatistiken durch, die während des Trainings kontinuierlich abweichen und den Speicher-/Ladezyklus nicht sauber überstehen. Der gespeicherte vecnorm.pkl-Snapshot stimmte nicht mit den Erwartungen des gespeicherten Modells überein. Zweitens stimmte die CLI-Standardeinstellung für den Referenz-Bewegungsclip beim Training nicht mit der Standardeinstellung der env-Klasse überein, sodass die Offline-Auswertung stillschweigend eine andere Referenz verwendete als das Training.
Jeder dieser Fehler allein hätte eine Regression verursacht. Zusammen überdeckten sie sich gegenseitig: Die Behebung des einen ohne den anderen zeigte keine Verbesserung, was die Suche noch schwieriger machte. Die kombinierte Korrektur ist nun in Produktion und liefert über alle Seeds hinweg 5/5. Die Richtlinie war immer gut. Unsere Messung war immer falsch.
Wir trainierten eine DeepMimic-ähnliche Policy auf dem Unitree G1-Humanoiden unter Verwendung von PPO aus Stable-Baselines3. Der Trainings-Callback löste seine periodische Bewertung aus und meldete das Erwartete:
Eval num_timesteps=3950000, episode_reward=1052.34
mean_ep_length: 1000.0
Eine Episode mit 1000 Schritten bei 50 Hz entspricht 20 Sekunden aufrechtem Gehen. Die Belohnung entsprach unseren Erwartungen. Alles sah gut aus. Wir haben das Modell gespeichert:
archives/v9b_sync2/best_model.zip # die PPO-Policy
archives/v9b_sync2/best_vecnorm.pkl # die VecNormalize-Statistiken
Dann führten wir das Offline-Auswertungsskript aus:
python compute_gait_metrics.py --model archives/v9b_sync2/best_model.zip
Episodenlänge 84. Der Roboter fiel nach etwa 1,7 Sekunden.
Dasselbe Modell. Derselbe Checkpoint. Dieselbe Konfiguration. Geladen mit VecNormalize.load() aus derselben .pkl-Datei, die der Trainings-Callback intern verwendet hatte. Eine 12-fache Reproduzierbarkeitslücke ohne erkennbare Ursache.
Zwischen „84 Schritte offline“ und „wir haben eine Hypothese“ lagen zwei Wochen Debugging:
training=False war gesetzt. Keine Hilfe.DummyVecEnv, derselbe VecNormalize, dasselbe clip_obs-Argument. Keine Hilfe.numpy.random.seed. Keine Hilfe.An diesem Punkt waren wir bereit anzunehmen, dass PPO auf irgendeine neue Weise nicht-deterministisch sei, und einen Fehlerbericht auf dem SB3-GitHub zu veröffentlichen. Bemerkenswert: Diese Annahme ist fast immer falsch. PPO ist bei identischen Eingaben durchaus deterministisch. Das Problem ist fast immer, dass die Eingaben nicht identisch sind und man es nicht bemerkt hat.
Wir fügten zwei Diagnoseausgaben hinzu. Eine innerhalb von train_mimic.py direkt nach der Rückgabe von model.learn(), als der Eval-Callback ep_len=1000 gemeldet hatte. Eine innerhalb des Offline-Eval-Skripts, direkt nach VecNormalize.load().
Beide haben vec_env.obs_rms.mean und vec_env.obs_rms.var ausgegeben – die laufenden Statistiken, die VecNormalize zur Normalisierung der Beobachtungen verwendet.
training-time obs_rms.mean[5:10]: [-0.012, 0.183, -0.087, 0.412, 0.044]
offline obs_rms.mean[5:10]: [-0.014, 0.169, -0.092, 0.388, 0.041]
5–15 % Abweichung in den meisten Dimensionen. Gering, aber ausreichend, um die Eingabeverteilung der Policy bei einem Humanoiden mit hoher DOF aus der Mannigfaltigkeit zu verschieben.
Die Hauptursache: VecNormalize führt einen gleitenden Mittelwert und eine Varianz der Beobachtungen, die bei jedem Aufruf von vec_env.step() aktualisiert werden. Die Trainingsumgebung wird während des Trainings ständig aktualisiert. Die Evaluierungsumgebung, die über SB3s EvalCallback ausgeführt wird, wird ebenfalls aktualisiert – da EvalCallback bei jedem Callback-Aufruf den obs_rms-Wert der Trainingsumgebung in die Evaluierungsumgebung synchronisiert.
Wenn das Modell am Punkt mit der besten Bewertung (z. B. nach 3,95 Millionen Schritten) gespeichert wird, werden die Modellgewichte in diesem Moment eingefroren. Das „vecnorm“-Pickle wird jedoch am Ende des Trainings (z. B. nach 30 Millionen Schritten) gespeichert – also 26 Millionen Schritte später. Der Mittelwert und die Varianz, die die gespeicherte Policy erwartet, sind nicht der Mittelwert und die Varianz, die sie erhält, wenn man die gespeicherte Pickle-Datei neu lädt.
Die Policy wurde darauf trainiert, auf eine bestimmte Weise normalisierte Beobachtungen zu erwarten. Sie erhält jedoch anders normalisierte Beobachtungen. „Laufen wie trainiert“ wird zu „Stürzen beim Laden“.
Dies ist kein Fehler in SB3 an sich – es ist eine Folge der Art und Weise, wie VecNormalize konzipiert ist, sowie der Art und Weise, wie EvalCallback Momentaufnahmen speichert. Die Bibliothek tut genau das, was sie verspricht. Der Preis dafür ist, dass das „Speichern eines Checkpoints“ nun ein heikler Vorgang mit zwei Dateien ist, bei dem die beiden Dateien genau aus demselben Moment des Trainings stammen müssen, und SB3 erzwingt dies nicht.
Selbst nach der Behebung der VecNormalize-Abweichung erzeugte die Offline-Auswertung immer noch Episoden mit ~250 Schritten statt 1000. Näher am Ziel, aber immer noch falsch. Nach zwei weiteren Tagen der Bisektion:
# train_mimic.py CLI default parser.add_argument("--clip-csv", type=str, default="data/lafan1_g1/g1/walk1_subject5.csv")
# g1_mimic_env.py __init__ default if clip_csv is None: clip_csv = "data/lafan1_g1/g1/walk3_subject1.csv"
Das Trainingsskript wurde mit --clip-csv walk1_subject5.csv aufgerufen – seinem eigenen CLI-Standardwert. Das Offline-Auswertungsskript erstellte UnitreeG1MimicEnv() ohne Übergabe von clip_csv, wodurch der Standardwert der __init__-Methode der env-Klasse, nämlich walk3_subject1.csv, übernommen wurde. Ein anderer Referenzclip bedeutet unterschiedliche Zielposen bei jedem Zeitschritt, was bedeutet, dass die Policy versucht, eine Sequenz nachzuahmen, die sie im Training nie gesehen hat.
Die beiden Fehler überdeckten sich gegenseitig. Die Behebung nur der VecNormalize-Abweichung zeigte eine marginale Verbesserung. Die Behebung nur des Clip-Pfads zeigte keine Verbesserung. Die Behebung beider Fehler – sofort reproduzierbar.
Der Beobachtungsraum des Unitree G1 ist vollständig charakterisiert. Jede Dimension hat bekannte physikalische Grenzen – Gelenkwinkel sind durch ihre Aktuatorbereiche begrenzt, lineare Geschwindigkeit durch die Geschwindigkeitsbegrenzung des Simulators, Fußkräfte durch das Körpergewicht. Es gibt keinen guten Grund, die Normalisierung zu lernen, wenn wir sie aufschreiben können:
# Gelenkpositionen: Normalisierung auf [-1, 1] über den Steuerbereich der Aktuatoren joint_lo, joint_hi = ctrl_range[:, 0], ctrl_range[:, 1] obs_normalized[5:34] = (joint_pos - 0.5*(joint_lo+joint_hi)) / (0.5*(joint_hi-joint_lo)) # Lineare Geschwindigkeit: typischer Bereich [-2, 2] m/s obs_normalized[34:37] = lin_vel / 2.0 # Fußkräfte: Bereich [0, ~500] N, zentriert auf das halbe Körpergewicht obs_normalized[40:42] = (foot_force - 150.0) / 150.0 # ... usw. für alle 50 Beobachtungsdimensionen
Dies ist eine feste, physikalisch abgeleitete Normalisierung. Sie ändert sich nicht beim Speichern, Laden, Verarbeiten oder während Trainingsläufen. Sie ersetzt VecNormalize vollständig für den Beobachtungsraum. (Die Belohnungsnormalisierung kann, sofern zutreffend, weiterhin adaptiv durchgeführt oder einfach übersprungen werden – adaptive Belohnungsstatistiken sind weniger reproduktionsempfindlich, da sie nur die Wert-Baseline beeinflussen, nicht die Policy-Eingabe.)
Wir haben dies über ein --normalize-obs-Flag in train_mimic.py integriert. Wenn es gesetzt ist, gibt die Umgebung bereits normalisierte Beobachtungen aus und der VecNormalize-Wrapper wird überhaupt nicht verwendet.
Stelle sicher, dass die env-Klasse clip_csv=None nur akzeptiert, wenn der Aufrufer dies bewusst beabsichtigt. Protokolliere den aufgelösten Pfad bei der Erstellung deutlich sichtbar, damit jede Abweichung in den Protokollen erkennbar ist, auch wenn niemand danach sucht:
def __init__(self, clip_csv=None, ...): if clip_csv is None: raise ValueError("clip_csv muss explizit angegeben werden") print(f"[env] Referenz-Clip: {clip_csv}")
Die subtilere Version dieser Lektion: Jeder CLI-Standardwert sollte bei jedem Aufruf mit dem entsprechenden Standardwert der Bibliothek abgeglichen werden. Wenn ein Parameter an zwei Stellen einen Standardwert hat, gibt es zwei Quellen der Wahrheit. Entweder gleicht man sie ab und überprüft die Gleichheit beim Start, oder man lässt eine auf die andere verweisen.
Wir haben eine Startprüfung hinzugefügt: Wenn --clip-csv in der CLI angegeben wird, der Standardwert der Umgebung jedoch abweicht, wird der Vorgang mit einer deutlichen Fehlermeldung sofort abgebrochen, anstatt stillschweigend mit der falschen Referenz fortzufahren.
Einige Teamkollegen haben gefragt: Warum liefert SB3 VecNormalize aus, wenn es diese „Footgun“ enthält? Die Antwort lautet: Bei allgemeinen RL-Aufgaben – Atari, MuJoCo Humanoid v4, klassische Gym-Umgebungen – sind die Beobachtungsbereiche dem Nutzer unbekannt, der Nutzer möchte sie nicht charakterisieren, und ein adaptiver Normalisierer macht den Unterschied zwischen „PPO trainiert“ und „PPO divergiert“. Für den durchschnittlichen Nutzer der Bibliothek ist dies die richtige Standardeinstellung.
Sie wird jedoch falsch, sobald man zwei Schwellenwerte überschreitet. Erstens: Man kennt den Beobachtungsraum gut genug, um die Normalisierung manuell zu schreiben. Zweitens: Man legt Wert darauf, einen Checkpoint laden und später ausführen zu können – für die Offline-Bewertung, für nachgelagerte Aufgaben, für den Sim-to-Real-Einsatz oder einfach nur, um ein Modell an einen Teamkollegen zu übermitteln.
In der Forschung zur humanoiden Fortbewegung werden beide Schwellenwerte routinemäßig überschritten. Daher steht die Standardeinstellung der Bibliothek im Widerspruch zum Anwendungsfall, und der richtige Schritt ist, sich sauber davon zu lösen. Die meisten Teams, mit denen ich darüber gesprochen habe, umgehen das Problem mit verschiedenen Varianten von „speichere immer die vecnorm neben dem Modell und hoffe, dass sie synchron bleiben“ – was funktioniert, bis es nicht mehr funktioniert. Der Ansatz, das Problem an der Quelle zu beheben, erfordert zwar mehr Vorarbeit, macht aber aus einem wiederkehrenden Problem ein Nicht-Problem.
Nachdem beide Korrekturen angewendet und das Modell mit identischen Hyperparametern neu trainiert wurde:
Eine Anmerkung zum gemeldeten Checkpoint: Es handelt sich um eine neu trainierte Policy unter der korrigierten Pipeline, nicht um dasselbe v12_gold-Archiv, das als Vollreferenz-Baseline im Mocap-Ablation-Beitrag verwendet wird. v12_gold wurde früher trainiert und erreicht 0,69 m/s; die untenstehende Neutrainierung nach der Korrektur erreicht 0,90 m/s im gleichen Setup. Unterschiedliche Checkpoints, beide reproduzierbar.
| Metrik | Bewertung während des Trainings (gemeldet) | Offline-Bewertung (5 Seeds, deterministisch) |
|---|---|---|
| Episodenlänge | 1000 | 1000 / 1000 (5 von 5) |
| Vorwärtsgeschwindigkeit | 0,59 m/s | 0,90 m/s |
| Hüftbewegungsbereich | 0,46 rad | 1,17 rad |
| Reproduzierbarkeit | — | 5/5 identisch |
Zwei Dinge sind bei dieser Tabelle zu beachten.
Erstens sind die Offline-Werte besser als die Werte aus der Trainingsphase – höhere Geschwindigkeit, größerer Hüftbewegungsbereich. Das liegt daran, dass die ursprünglich gemeldeten Trainingswerte ebenfalls durch denselben Abweichungsfehler falsch berechnet wurden. Die Strategie erzeugte stets größere Auslenkungen, als die fehlerhafte Protokollierung angab. Wir konnten sie aufgrund unserer eigenen Normalisierung nur nicht erkennen.
Zweitens ist die Spalte „5/5 über alle Seeds“ die aussagekräftige Spalte. Vor der Korrektur war „Hat die Policy funktioniert?“ eine Frage, die wir nicht beantworten konnten, da sich die Antwort ständig änderte. Jetzt bilden das Modell + die deterministische Normalisierung + der explizite Referenzclip einen geschlossenen Regelkreis. Jeder Offline-Lauf liefert dieselben Zahlen wie der vorherige. Das Modell ist das Modell.
Zwei Wochen Debugging erscheinen als eine Zeile im Protokoll. Die tatsächlichen Kosten sind höher.
Jedes Ergebnis, das wir im vergangenen Monat der Trainingsläufe gesammelt hatten, war plötzlich fragwürdig. Hat der V8-Lehrplan wirklich funktioniert, oder hat die Evaluierungs-Pipeline diesbezüglich gelogen? Haben die Ablationen im Aktionsraum (29-dim, 14-dim, 6-dim) echte Unterschiede hervorgebracht, oder haben wir Normalisierungsrauschen über verschiedene Modellgrößen hinweg gemessen? Wir mussten eine Teilmenge der früheren Ergebnisse auswählen, um sie mit der Korrektur erneut auszuführen. Einige davon hielten stand; andere änderten sich erheblich. Die Differenz von 0,59 gegenüber 0,90 m/s in der obigen Validierungstabelle ist eine der geringeren Überraschungen – andere waren Ablationen, deren Reihenfolge sich nach der Korrektur umkehrte.
Die versteckten Kosten eines Evaluierungsfehlers sind nicht die Zeit, die man mit dem Fehler verbringt. Es ist die Zeit, die man damit verbracht hat, Zahlen zu sammeln, denen man hinterher nicht mehr trauen kann. Entdecken Sie Auswertungsfehler frühzeitig oder zahlen Sie sie mit Zinseszinsen zurück.
Vier Erkenntnisse, die über diesen speziellen Vorfall hinausgehen:
VecNormalize ist gefährlich für die Reproduzierbarkeit. Wenn Ihr Beobachtungsraum gut charakterisiert ist – physikalische Grenzen bekannt, Sensorbereiche festgelegt –, bevorzugen Sie eine feste Normalisierung. Sie verzichten auf automatische Statistik zugunsten von Determinismus, und für Simulationsarbeiten lohnt sich dieser Tausch fast immer.
CLI-Standardwerte und Bibliotheksstandardwerte müssen exakt übereinstimmen. Wenn ein Parameter an zwei Stellen einen Standardwert hat, haben Sie zwei Wahrheitsquellen, und diese werden unbemerkt voneinander abweichen. Verweisen Sie entweder die eine auf die andere oder stellen Sie die Gleichheit beim Start sicher. Eine Diff-Prüfung nur der geänderten Datei übersieht Standardwerte, die an anderer Stelle stillschweigend gewählt wurden.
Das Zwei-Fehler-Syndrom ist real. Wenn die Behebung von Fehler A nicht hilft, ist die wahrscheinlichste Erklärung, dass sich dahinter Fehler B verbirgt. Das kombinierte Symptom ist „nichts hilft“, bis beide gleichzeitig behoben sind. Betrachten Sie „keine Änderung nach Behebung des Offensichtlichen“ als Hinweis auf einen zweiten Fehler, nicht als Hinweis darauf, dass Sie das Falsche behoben haben.
Behandeln Sie Trainings- und Offline-Bewertungen als separate Experimente, bis ihre Gleichwertigkeit nachgewiesen ist. Gleiche Codepfade können aufgrund des aktuellen Prozesszustands (laufende Statistiken, JIT-Caches, Reihenfolge der Initialisierung von Zufallszuständen) unterschiedliche Ergebnisse liefern. Führen Sie jedes Modell den gesamten Zyklus durch (Serialisieren → Neuladen → Auswerten), bevor Sie irgendwelchen Zahlen vertrauen, selbst wenn es sich um informelle Metriken handelt.
Die hier beschriebene Lösung tauscht automatische Statistiken gegen eine handcodierte, physikalisch abgeleitete Normalisierung ein. Dies ist der richtige Kompromiss, wenn Ihr Beobachtungsraum vollständig charakterisiert ist – was bei den meisten simulierten Roboteraufgaben der Fall ist.
Wenn Ihr Beobachtungsraum Dimensionen enthält, deren Bereiche Sie nicht im Voraus kennen, ist VecNormalize weiterhin die sinnvolle Standardeinstellung. Häufige Fälle: ein benutzerdefinierter propriozeptiver Kanal von einem realen Sensor mit unmodellierter Drift, ein Vision-Backbone, bei dem die Einbettungsverteilung datenabhängig ist, oder ein Residual-Policy-Setup, das auf der Ausgabe eines anderen Netzwerks mit nichtstationären Statistiken operiert. In diesen Situationen verfügen Sie nicht über Ground Truth für die manuelle Programmierung, und adaptive Normalisierung ist der Preis, den Sie dafür zahlen müssen.
Die Entscheidungsregel ist einfach: Wie gut ist Ihr Beobachtungsraum charakterisiert? Bei MuJoCo-/Gymnasium-Aufgaben, bei denen Sie die physikalischen Grenzen kennen, gewinnt die deterministische Variante. Bei gemischten Pipelines aus simulierten und realen Daten oder gelernten Merkmalen hat VecNormalize nach wie vor seine Berechtigung – aber Sie sollten die gespeicherte pkl-Datei als kritischen Bestandteil des Checkpoints behandeln und sie genauso sorgfältig schützen wie die Modellgewichte.
Zwei Folgebeiträge nutzen die Reproduzierbarkeit, die dieser Beitrag ermöglicht hat.
Die vollständigen Mocap-Ablationsergebnisse – einschließlich der überraschenden Schlagzeile „2 Posen schlagen 50 Frames“ – waren erst dann vertrauenswürdig, als wir einen Checkpoint deterministisch reproduzieren konnten. Vor der Korrektur konnten wir nicht sagen, ob „2 Posen schneller als 50“ ein echtes Ergebnis oder ein Normalisierungsartefakt war.
Und als wir erst einmal ehrlich messen konnten, stellten wir fest, dass die Policy kreative Wege gefunden hatte, um zu „laufen“, die kein Laufen sind – Laufen auf den Knien, Rückwärtsschlurfen, Einbeinhüpfen, getarnt als zweibeinige Bewegung. Das ist die Taxonomie des Reward-Hackings.
Das Muster, das sich durch alle drei Beiträge zieht: Jede quantitative Behauptung über eine RL-Richtlinie für Humanoide ist das Ergebnis eines Evaluierungs-Harness, und der Evaluierungs-Harness ist Software, die du geschrieben hast. Vertraue ihr als Letztes.