Transformer-Architektur implementieren: Code-Erklärung Zeile für Zeile (Teil 3 von 3)
Das Training eines Transformer-Modells im Detail - Ein umfassender Leitfaden
1. Einleitung - Was ist ein Transformer und warum ist das Training so komplex?
Stell dir vor, du möchtest einem Computer beibringen, Texte zu verstehen und zu generieren - genau wie ChatGPT. Das ist die Aufgabe des Transformer-Trainings! Die Transformer-Architektur ist wie ein hochkomplexes Gehirn aus mathematischen Operationen, das lernt, Muster in Sprache zu erkennen und vorherzusagen.
Dieser Artikel ist der dritte und letzte Teil unserer Serie über die Transformer-Architektur. Nach der theoretischen Grundlage im ersten Teil, wo wir die Konzepte wie Attention-Mechanismen und die mathematischen Hintergründe erklärt haben, und der praktischen Anleitung zum Training im zweiten Teil, wo du gelernt hast, wie man ein Modell trainiert, tauchen wir nun tief in den Code ein. Hier verstehen wir jede Zeile der Implementierung und sehen, wie die Theorie in funktionierenden Python-Code umgesetzt wird.
Der gesamte Quellcode zu dieser Serie ist verfügbar unter: https://github.com/ChristophKind/LargeLanguageModelsPublic
Was genau ist ein Transformer?
Ein Transformer ist im Kern eine spezielle Art von neuronalem Netzwerk. Stell dir ein neuronales Netzwerk wie ein riesiges Netz aus miteinander verbundenen Knotenpunkten vor - ähnlich wie Neuronen im menschlichen Gehirn. Jeder Knotenpunkt führt einfache mathematische Berechnungen durch, aber zusammen können sie unglaublich komplexe Aufgaben lösen.
Der Transformer wurde 2017 in dem bahnbrechenden Paper "Attention is All You Need" vorgestellt und hat seitdem die KI-Welt revolutioniert. Seine Superkraft liegt im sogenannten Attention-Mechanismus - die Fähigkeit, zu entscheiden, welche Teile eines Textes für welche anderen Teile wichtig sind.
GPT - Ein spezieller Transformer
In unserem Code implementieren wir GPT (Generative Pre-trained Transformer), eine spezielle Variante des Transformers. GPT ist autoregressiv, was bedeutet: Es generiert Text Wort für Wort (genauer: Token für Token), wobei jedes neue Wort auf allen vorherigen Wörtern basiert.
Schauen wir uns die grundlegende Struktur in model.py an:
# Aus model.py, Zeile 227-238
class GPT(nn.Module):
"""
Das komplette GPT (Generative Pre-trained Transformer) Sprachmodell.
Diese Klasse implementiert die vollständige GPT-Architektur mit:
- Token- und Positions-Embeddings
- Gestapelten Transformer-Blöcken
- Finaler Projektionsschicht für Vorhersagen
"""
def __init__(self, config):
super().__init__()
self.config = configWas bedeutet das?
class GPT(nn.Module): Wir erstellen eine neue Klasse namens GPT, die von PyTorchs nn.Module erbt. Das ist wie eine Blaupause für unser Modell.def __init__(self, config): Dies ist der Konstruktor - die Funktion, die aufgerufen wird, wenn wir ein neues GPT-Modell erstellen.self.config = config: Wir speichern die Konfiguration (Einstellungen wie Modellgröße, Anzahl der Schichten etc.)
Die Herausforderungen beim Training
Das Training eines Transformers ist aus mehreren Gründen komplex:
- Massive Parameteranzahl: Ein GPT-2 Small Modell hat bereits 124 Millionen Parameter (das sind 124 Millionen Zahlen, die das Modell lernen muss!). Schauen wir uns an, wie das berechnet wird:
# Aus model.py, Zeilen 271-290
def calculate_parameter_count(self, non_embedding=True):
"""
Berechnet die Anzahl der Parameter im Modell.
"""
total_params = sum(param.numel() for param in self.parameters())
if non_embedding:
# Positions-Embeddings nicht mitzählen, da sie nicht zum 'Rechnen' beitragen
total_params -= self.transformer.wpe.weight.numel()
return total_params
# Bei der Initialisierung wird das ausgegeben:
print("Anzahl der Parameter: %.2fM" % (self.calculate_parameter_count()/1e6,))Was passiert hier?
sum(param.numel() for param in self.parameters()): Für jeden Parameter im Modell zählen wir die Anzahl der Elemente (numel = number of elements) und summieren alles auftotal_params -= self.transformer.wpe.weight.numel(): Wir ziehen die Positions-Embeddings ab, weil diese "statisch" sind und nicht wirklich zum Rechenaufwand beitragen
Rechenintensität: Jeder Trainingsschritt erfordert Milliarden von Berechnungen. Die Attention-Berechnung allein hat eine quadratische Komplexität zur Sequenzlänge!
Speicherbedarf: Die Modelle und ihre Gradienten müssen im GPU-Speicher gehalten werden. Deshalb nutzen wir Tricks wie Gradient Accumulation:
# Aus train.py, Zeilen 42-43 und 77-78
gradient_accumulation_steps = 5 * 8 # = 40 Steps
batch_size = 12
# Das ergibt eine effektive Batch-Größe von 12 * 40 = 480 Sequenzen
tokens_per_iteration = gradient_accumulation_steps * batch_size * block_size
print(f"Tokens pro Iteration: {tokens_per_iteration:,}") # = 491,520 Tokens!Der Trainingsablauf im Überblick
Das Training folgt einem sich wiederholenden Zyklus:
- Daten laden: Wir nehmen einen Batch (eine Gruppe) von Textsequenzen
- Forward Pass: Das Modell verarbeitet die Texte und macht Vorhersagen
- Loss berechnen: Wir messen, wie falsch die Vorhersagen waren
- Backward Pass: Wir berechnen Gradienten (wie müssen wir die Parameter ändern?)
- Parameter-Update: Wir passen die Modellparameter an, um besser zu werden
Dieser Zyklus wird hunderttausende Male wiederholt, bis das Modell gut genug ist.
Die zwei zentralen Dateien
Unsere Implementierung besteht aus zwei Hauptdateien:
- model.py (522 Zeilen): Definiert die Architektur - WAS das Modell ist
- train.py (373 Zeilen): Definiert den Trainingsprozess - WIE wir das Modell trainieren
Im nächsten Abschnitt werden wir tief in die einzelnen Komponenten der Architektur eintauchen und verstehen, wie jede einzelne funktioniert.
2. Die Architektur verstehen - Komponenten im Training
2.1 Token und Position Embeddings - Wie Text zu Zahlen wird
Computer können nicht direkt mit Wörtern arbeiten - sie verstehen nur Zahlen. Der erste Schritt beim Training eines Transformers ist daher, Text in Zahlen umzuwandeln. Das geschieht in zwei Stufen: Tokenisierung (Text zu Token-IDs) und Embeddings (Token-IDs zu Vektoren).
Was sind Tokens?
Ein Token ist die kleinste Einheit, mit der unser Modell arbeitet. Das kann ein ganzes Wort sein ("Hund"), ein Teilwort ("un" aus "unglaublich") oder sogar nur ein einzelnes Zeichen. GPT-2 verwendet ein Vokabular von 50.257 verschiedenen Tokens.
Beispiel für Tokenisierung:
- "Ich liebe Transformer!" ? [1273, 28486, 39185, 0]
- Jedes Wort/Teilwort bekommt eine eindeutige ID-Nummer
Token-Embeddings: Von IDs zu Vektoren
Jetzt kommt der spannende Teil. Jede Token-ID wird in einen hochdimensionalen Vektor umgewandelt - eine Liste von Zahlen, die die "Bedeutung" des Tokens repräsentiert:
# Aus model.py, Zeilen 246-247
self.transformer = nn.ModuleDict(dict(
wte = nn.Embedding(config.vocab_size, config.n_embd), # Token-EmbeddingsWas bedeutet dieser Code genau?
nn.Embeddingerstellt eine Lookup-Tabelle (wie ein Wörterbuch)config.vocab_size= 50.257 - so viele verschiedene Tokens kennt unser Modellconfig.n_embd= 768 - jedes Token wird zu einem Vektor mit 768 Zahlen
Stell dir das wie eine riesige Excel-Tabelle vor:
- Zeilen: 50.257 verschiedene Tokens
- Spalten: 768 Dimensionen
- Jede Zelle enthält eine Zahl, die gelernt wird
Wenn wir Token-ID 1273 ("Ich") nachschlagen, bekommen wir einen Vektor wie: [0.23, -1.45, 0.89, ..., 2.11] mit genau 768 Zahlen.
Diese Zahlen sind nicht zufällig! Sie werden während des Trainings so angepasst, dass ähnliche Wörter ähnliche Vektoren bekommen. Nach dem Training liegen "Hund" und "Katze" im Vektorraum näher beieinander als "Hund" und "Demokratie".
Positions-Embeddings: Wo steht das Wort?
Hier kommt ein kritischer Punkt: Der Transformer hat von sich aus KEIN Verständnis von Reihenfolge! Für ihn wäre "Der Hund jagt die Katze" dasselbe wie "Die Katze jagt den Hund", wenn wir nicht extra die Position kodieren würden.
# Aus model.py, Zeile 248
wpe = nn.Embedding(config.block_size, config.n_embd), # Positions-EmbeddingsWas passiert hier?
config.block_size= 1024 - die maximale Sequenzlänge- Jede Position (0, 1, 2, ..., 1023) bekommt auch einen 768-dimensionalen Vektor
Das ist wie eine zweite Tabelle:
- Zeilen: 1024 Positionen (Position 0 bis Position 1023)
- Spalten: 768 Dimensionen
- Position 0 lernt "ich bin das erste Token"
- Position 1 lernt "ich bin das zweite Token"
- usw.
Die magische Addition
Jetzt kommt der geniale Trick - wir addieren einfach beide Embeddings:
# Aus model.py, Zeilen 329-331
def forward(self, input_ids, targets=None):
# ...
token_embeddings = self.transformer.wte(input_ids) # Shape: (Batch, Sequenz, 768)
position_embeddings = self.transformer.wpe(position_ids) # Shape: (Sequenz, 768)
hidden_states = self.transformer.drop(token_embeddings + position_embeddings)Schritt für Schritt erklärt:
input_idsenthält die Token-IDs, z.B. [1273, 28486, 39185, 0]self.transformer.wte(input_ids)schlägt für jede ID den Embedding-Vektor nachposition_idssind einfach [0, 1, 2, 3] für die vier Positionenself.transformer.wpe(position_ids)holt die Positions-Vektoren- Wir addieren beide: Token-Vektor + Positions-Vektor = Finaler Vektor
Beispiel für Token "Ich" an Position 0:
- Token-Embedding für "Ich": [0.23, -1.45, 0.89, ...]
- Positions-Embedding für Position 0: [0.11, 0.33, -0.22, ...]
- Resultat nach Addition: [0.34, -1.12, 0.67, ...]
Dieser kombinierte Vektor enthält jetzt beide Informationen: WAS das Token ist UND WO es steht!
Dropout für Regularisierung
Beachte das drop in der letzten Zeile:
hidden_states = self.transformer.drop(token_embeddings + position_embeddings)Dropout ist eine Regularisierungstechnik. Während des Trainings werden zufällig einige Werte auf 0 gesetzt (wie wenn wir zufällig einige Neuronen "ausschalten"). Das zwingt das Modell, robuster zu werden und nicht zu sehr von einzelnen Features abhängig zu sein. Das verhindert Overfitting (Auswendiglernen statt Verstehen).
Die Embeddings sind die Grundlage für alles Weitere. Diese kombinierten Vektoren wandern nun durch alle Transformer-Blöcke und werden dabei immer weiter verfeinert und mit Kontext angereichert.
2.2 Multi-Head Self-Attention - Das Herzstück des Transformers
Jetzt kommen wir zum absoluten Kernstück des Transformers: dem Attention-Mechanismus. Attention ist die Fähigkeit des Modells zu entscheiden, welche Wörter für welche anderen Wörter wichtig sind. Das ist wie wenn du einen Text liest und automatisch verstehst, dass sich "er" auf "Peter" bezieht, der drei Sätze vorher erwähnt wurde.
Was ist Attention eigentlich?
Stell dir vor, du liest den Satz: "Die Katze, die gestern im Garten war, hat heute eine Maus gefangen."
Wenn du das Wort "hat" verstehen willst, ist "Katze" wichtiger als "Garten". Das Modell muss lernen, diese Verbindungen herzustellen. Genau das macht Attention: Es berechnet für jedes Wort, wie stark es auf alle anderen Wörter "achten" soll.
Die drei Hauptakteure: Query, Key und Value
Der Attention-Mechanismus arbeitet mit drei Konzepten, die aus der Datenbankwelt stammen:
# Aus model.py, Zeilen 67-75 (CausalSelfAttention.__init__)
def __init__(self, config):
super().__init__()
assert config.n_embd % config.n_head == 0 # Embedding-Dimension muss durch Anzahl Heads teilbar sein
# Kombinierte Projektion für Query, Key und Value für alle Attention-Heads
# Dies ist effizienter als separate Projektionen
self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd, bias=config.bias)
# Output-Projektion nach der Attention-Berechnung
self.c_proj = nn.Linear(config.n_embd, config.n_embd, bias=config.bias)Was passiert hier genau?
Die kombinierte Projektion (
self.c_attn):- Input: Ein Vektor mit 768 Dimensionen (n_embd)
- Output: Ein Vektor mit 3 × 768 = 2304 Dimensionen
- Warum 3×? Weil wir Query, Key UND Value auf einmal berechnen!
nn.Linearist eine Matrix-Multiplikation: Output = Input × Gewichtsmatrix + Bias
Query, Key, Value - Was sind das?
- Query (Abfrage): "Wonach suche ich?" - Repräsentiert das aktuelle Wort
- Key (Schlüssel): "Was biete ich an?" - Repräsentiert potentielle Bezugswörter
- Value (Wert): "Was gebe ich weiter?" - Die tatsächliche Information
Analogie: Stell dir eine Bibliothek vor:
- Query = Deine Suchanfrage ("Bücher über Transformers")
- Key = Die Etiketten auf den Büchern
- Value = Der Inhalt der Bücher
- Du vergleichst deine Suchanfrage mit allen Etiketten und nehmen die Bücher mit den besten Übereinstimmungen
Die eigentliche Attention-Berechnung
Schauen wir uns den Forward-Pass genau an:
# Aus model.py, Zeilen 93-110 (CausalSelfAttention.forward)
def forward(self, hidden_states):
batch_size, sequence_length, embedding_dim = hidden_states.size()
# hidden_states hat Form: (Batch=32, Sequenz=1024, Embedding=768)
# Berechne Query, Key und Value für alle Heads gleichzeitig
queries, keys, values = self.c_attn(hidden_states).split(self.n_embd, dim=2)
# Jeder hat Form: (32, 1024, 768)Schritt 1: Die Projektion
self.c_attn(hidden_states)erzeugt einen großen Vektor (32, 1024, 2304).split(self.n_embd, dim=2)teilt ihn in drei gleiche Teile:- queries: Dimensionen 0-767
- keys: Dimensionen 768-1535
- values: Dimensionen 1536-2303
Multi-Head: Warum mehrere Köpfe?
Jetzt wird es interessant - wir teilen unsere Attention in mehrere "Köpfe" auf:
# Fortsetzung aus model.py
keys = keys.view(batch_size, sequence_length, self.n_head, embedding_dim // self.n_head).transpose(1, 2)
queries = queries.view(batch_size, sequence_length, self.n_head, embedding_dim // self.n_head).transpose(1, 2)
values = values.view(batch_size, sequence_length, self.n_head, embedding_dim // self.n_head).transpose(1, 2)
# Neue Form: (32, 12, 1024, 64) - 12 Heads mit je 64 DimensionenWas passiert hier?
- Wir haben 768 Dimensionen und 12 Heads
- Jeder Head bekommt 768 ÷ 12 = 64 Dimensionen
.view()reshapet den Tensor ohne Daten zu kopieren.transpose(1, 2)vertauscht Sequenz- und Head-Dimension
Warum mehrere Heads? Jeder Head kann sich auf andere Aspekte konzentrieren:
- Head 1: Grammatikalische Struktur (Subjekt-Verb-Beziehungen)
- Head 2: Semantische Ähnlichkeit (Synonyme, Themenverwandtschaft)
- Head 3: Positionale Nähe (benachbarte Wörter)
- Head 4: Korreferenzen (Pronomen-Auflösung)
- usw.
Die Attention-Formel
Jetzt kommt die eigentliche Magie - die Attention-Berechnung:
# Aus model.py, Zeilen 119-123
# Matrixmultiplikation: (B, n_heads, T, head_size) × (B, n_heads, head_size, T) ? (B, n_heads, T, T)
attention_weights = (queries @ keys.transpose(-2, -1)) * (1.0 / math.sqrt(keys.size(-1)))Diese eine Zeile macht Folgendes:
queries @ keys.transpose(-2, -1): Berechnet die Ähnlichkeit zwischen allen Query-Key-Paaren- @ ist Matrix-Multiplikation
- Für jede Query-Position: "Wie ähnlich bin ich zu jeder Key-Position?"
- Ergebnis: Eine (1024 × 1024) Matrix pro Head
* (1.0 / math.sqrt(keys.size(-1))): Skalierung durch v64- Warum? Ohne Skalierung würden die Werte zu groß werden
- Zu große Werte ? Softmax produziert extreme Wahrscheinlichkeiten (fast 0 oder 1)
- Das macht Gradienten sehr klein und Training langsam
Kausale Maskierung - Die Zukunft verbergen
GPT ist autoregressiv - es darf nur in die Vergangenheit schauen, nicht in die Zukunft:
# Aus model.py, Zeilen 88-91 (im __init__)
# Kausale Maske: Stellt sicher, dass jede Position nur auf vorherige Positionen achten kann
self.register_buffer("bias", torch.tril(torch.ones(config.block_size, config.block_size))
.view(1, 1, config.block_size, config.block_size))
# Aus model.py, Zeilen 120-121 (im forward)
attention_weights = attention_weights.masked_fill(
self.bias[:,:,:sequence_length,:sequence_length] == 0, float('-inf'))Was macht die Maske?
torch.trilerzeugt eine untere Dreiecksmatrix:[[1, 0, 0, 0], [1, 1, 0, 0], [1, 1, 1, 0], [1, 1, 1, 1]]Position 0 kann nur Position 0 sehen
Position 1 kann Positionen 0-1 sehen
Position 2 kann Positionen 0-2 sehen
usw.
masked_fill(..., float('-inf')): Setzt verbotene Positionen auf minus unendlichNach Softmax werden diese zu 0 (e^(-8) = 0)
Softmax - Von Scores zu Wahrscheinlichkeiten
# Aus model.py, Zeile 121
attention_weights = F.softmax(attention_weights, dim=-1)
attention_weights = self.attn_dropout(attention_weights)Softmax wandelt die Attention-Scores in Wahrscheinlichkeiten um:
- Alle Werte zwischen 0 und 1
- Summe pro Zeile = 1
- Höhere Scores ? Höhere Wahrscheinlichkeiten
Dropout wird wieder zur Regularisierung angewendet.
Der finale Schritt - Gewichtete Summe
# Aus model.py, Zeile 123
attention_output = attention_weights @ values
# Form: (32, 12, 1024, 64)Jetzt verwenden wir die Attention-Gewichte, um eine gewichtete Summe der Values zu berechnen:
- Wenn Token A zu 70% auf Token B und zu 30% auf Token C achtet
- Dann ist das Ergebnis: 0.7 × Value_B + 0.3 × Value_C
Zusammenführung und Output-Projektion
# Aus model.py, Zeilen 124-128
attention_output = attention_output.transpose(1, 2).contiguous().view(
batch_size, sequence_length, embedding_dim)
# Von (32, 12, 1024, 64) zurück zu (32, 1024, 768)
# Output-Projektion und Dropout für Regularisierung
attention_output = self.resid_dropout(self.c_proj(attention_output))Die Heads werden wieder zusammengeführt:
.transpose(1, 2): Head-Dimension zurück an Position 2.contiguous(): Stellt sicher, dass der Speicher zusammenhängend ist.view(): Reshapet zu ursprünglicher Formself.c_proj: Finale lineare Transformationself.resid_dropout: Dropout für Regularisierung
Flash Attention - Die Turbo-Version
Für noch schnelleres Training gibt es Flash Attention:
# Aus model.py, Zeilen 114-116
if self.flash:
attention_output = torch.nn.functional.scaled_dot_product_attention(
queries, keys, values, attn_mask=None,
dropout_p=self.dropout if self.training else 0, is_causal=True)Flash Attention ist eine hochoptimierte Implementation, die:
- Weniger Speicher braucht
- Schneller rechnet (bis zu 3× schneller!)
- Die gleichen Ergebnisse liefert
Das war der Attention-Mechanismus - das absolute Herzstück des Transformers. Durch diese Mechanismen kann das Modell komplexe Beziehungen zwischen Wörtern lernen und verstehen.
2.3 Feed-Forward Networks (MLP) - Die Verarbeitungseinheit
Nach der Attention-Schicht kommt in jedem Transformer-Block ein Feed-Forward Netzwerk, auch MLP (Multi-Layer Perceptron) genannt. Während Attention die Beziehungen zwischen Tokens herausfinden, ist die MLP-Schicht dafür da, diese Informationen zu verarbeiten und zu transformieren.
Was macht die MLP-Schicht?
Stell dir die MLP wie einen Datenverarbeitungsprozessor vor:
- Attention sagt: "Diese Wörter sind wichtig füreinander"
- MLP sagt: "Jetzt verarbeite ich diese Information und extrahiere komplexere Muster"
Die MLP arbeitet positionsweise - das bedeutet, sie verarbeitet jede Position in der Sequenz unabhängig von den anderen. Das ist ein wichtiger Unterschied zur Attention, die alle Positionen miteinander in Beziehung setzt.
Der Aufbau der MLP
Schauen wir uns den Code genau an:
# Aus model.py, Zeilen 130-147 (MLP Klasse)
class MLP(nn.Module):
"""
Multi-Layer Perceptron (Feed-Forward Network) des Transformer-Blocks.
Implementiert ein zweischichtiges neuronales Netzwerk mit GELU-Aktivierung.
Die versteckte Schicht hat typischerweise die 4-fache Dimension der Eingabe.
"""
def __init__(self, config):
super().__init__()
# Erste lineare Schicht: Expansion von n_embd auf 4*n_embd
self.c_fc = nn.Linear(config.n_embd, 4 * config.n_embd, bias=config.bias)
# GELU Aktivierungsfunktion
self.gelu = nn.GELU()
# Zweite lineare Schicht: Projektion zurück von 4*n_embd auf n_embd
self.c_proj = nn.Linear(4 * config.n_embd, config.n_embd, bias=config.bias)
# Dropout zur Regularisierung
self.dropout = nn.Dropout(config.dropout)Die MLP besteht aus zwei linearen Transformationen mit einer Aktivierungsfunktion dazwischen:
Expansion (
self.c_fc):- Input: 768 Dimensionen
- Output: 3072 Dimensionen (4 × 768)
- Das ist eine 4-fache Vergrößerung!
Aktivierung (
self.gelu):- GELU (Gaussian Error Linear Unit)
- Eine sanfte Version von ReLU
Projektion (
self.c_proj):- Input: 3072 Dimensionen
- Output: 768 Dimensionen
- Zurück zur ursprünglichen Größe
Warum die 4-fache Expansion?
Die Expansion auf die 4-fache Größe ist ein bewährtes Design-Pattern in Transformers:
# Die Dimensionen im Detail:
# Eingabe: (Batch=32, Sequenz=1024, Embedding=768)
# Nach c_fc: (32, 1024, 3072) # 4× größer!
# Nach GELU: (32, 1024, 3072) # gleiche Größe
# Nach c_proj: (32, 1024, 768) # wieder originalWarum macht man das?
- Mehr Kapazität: Mit 3072 Dimensionen kann das Netzwerk komplexere Funktionen lernen
- Informations-Flaschenhals: Die Expansion und Kontraktion zwingt das Modell, wichtige Features zu komprimieren
- Nicht-Linearität: Die GELU-Aktivierung in der Mitte ermöglicht nicht-lineare Transformationen
Analogie: Stell dir vor, du musst ein komplexes Problem lösen:
- Du breitest alle Informationen auf einem großen Tisch aus (Expansion)
- Du arbeitest mit den Informationen, kombinierst und transformierst sie (GELU)
- Du fasst die Ergebnisse kompakt zusammen (Projektion)
Der Forward Pass durch die MLP
# Aus model.py, Zeilen 149-163 (MLP.forward)
def forward(self, hidden_states):
"""
Vorwärtsdurchlauf durch das Feed-Forward Netzwerk.
Args:
hidden_states: Eingabe-Tensor der Form (Batch, Sequenzlänge, n_embd)
Returns:
Transformierter Tensor gleicher Form wie die Eingabe
"""
hidden_states = self.c_fc(hidden_states) # Projektion auf 4*n_embd
hidden_states = self.gelu(hidden_states) # GELU Aktivierung
hidden_states = self.c_proj(hidden_states) # Projektion zurück auf n_embd
hidden_states = self.dropout(hidden_states) # Dropout für Regularisierung
return hidden_statesSchritt für Schritt:
Lineare Transformation 1 (
self.c_fc):hidden_states = self.c_fc(hidden_states)- Mathematisch:
output = input @ weight.T + bias - Jedes der 768 Input-Features wird mit 3072 verschiedenen Gewichten multipliziert
- Das ergibt 768 × 3072 = 2.359.296 Parameter allein in dieser Schicht!
- Mathematisch:
GELU Aktivierung (
self.gelu):hidden_states = self.gelu(hidden_states)GELU ist definiert als:
GELU(x) = x * F(x)wo F die kumulative Normalverteilung ist.Vereinfacht: GELU ist wie ReLU, aber mit einer sanften Kurve statt einer harten Kante:
- Negative Werte werden nicht komplett auf 0 gesetzt
- Es gibt einen sanften Übergang
- Das führt zu besseren Gradienten beim Training
Lineare Transformation 2 (
self.c_proj):hidden_states = self.c_proj(hidden_states)- Reduziert von 3072 zurück auf 768 Dimensionen
- Weitere 3072 × 768 = 2.359.296 Parameter
Dropout (
self.dropout):hidden_states = self.dropout(hidden_states)- Während des Trainings: Setzt zufällig Werte auf 0
- Verhindert Overfitting
- Macht das Modell robuster
Die Rolle der MLP im Gesamtkontext
Die MLP-Schicht hat mehrere wichtige Funktionen:
- Feature-Extraktion: Sie lernt, komplexe Features aus den Attention-Outputs zu extrahieren
- Nicht-lineare Transformation: Durch GELU kann sie nicht-lineare Muster lernen
- Positions-unabhängige Verarbeitung: Jede Position wird einzeln verarbeitet
Ein konkretes Beispiel:
- Nach Attention weiß das Modell: "Token 5 ist stark mit Token 2 verbunden"
- Die MLP verarbeitet das: "Wenn diese Verbindung existiert, könnte das ein Relativsatz sein"
- Sie transformiert die rohe Attention-Information in höhere semantische Konzepte
Parameteranzahl in der MLP
Die MLP ist tatsächlich der größte Teil des Modells bezüglich Parametern:
# Pro MLP-Block:
# c_fc: 768 × 3072 = 2.359.296 Parameter
# c_proj: 3072 × 768 = 2.359.296 Parameter
# Gesamt pro Block: 4.718.592 Parameter
# Bei 12 Transformer-Blöcken:
# 12 × 4.718.592 = 56.623.104 Parameter nur für MLPs!Das sind etwa 45% aller Parameter im GPT-2 Small Modell!
2.4 Layer Normalization und Residual Connections - Die Stabilisatoren
Diese beiden Techniken sind absolut essentiell für das Training tiefer Netzwerke. Ohne sie wäre es praktisch unmöglich, Transformer mit 12, 24 oder sogar 96 Schichten zu trainieren.
Das Problem mit tiefen Netzwerken
Stell dir vor, du multiplizierst eine Zahl immer wieder:
- 1.1 × 1.1 × 1.1 × ... (12 mal) = 3.14 (exponentielles Wachstum)
- 0.9 × 0.9 × 0.9 × ... (12 mal) = 0.28 (exponentieller Verfall)
Das gleiche passiert in tiefen Netzwerken:
- Werte können explodieren (zu groß werden) ? Gradient Explosion
- Werte können verschwinden (zu klein werden) ? Vanishing Gradients
Layer Normalization - Der Stabilisator
Layer Normalization normalisiert die Aktivierungen, sodass sie immer eine stabile Verteilung haben:
# Aus model.py, Zeilen 29-50 (LayerNorm Klasse)
class LayerNorm(nn.Module):
"""
Layer Normalization mit optionalem Bias-Parameter.
Layer Normalization normalisiert die Aktivierungen über die Feature-Dimension,
was zu stabilerer und schnellerer Konvergenz beim Training führt.
"""
def __init__(self, ndim, bias):
super().__init__()
# Lernbare Skalierung (gamma)
self.weight = nn.Parameter(torch.ones(ndim))
# Lernbarer Bias (beta) - optional
self.bias = nn.Parameter(torch.zeros(ndim)) if bias else None
def forward(self, input):
return F.layer_norm(input, self.weight.shape, self.weight, self.bias, 1e-5)Was macht Layer Norm genau?
Berechne Mittelwert und Varianz für jede Position:
mean = input.mean(dim=-1, keepdim=True) # Mittelwert über Features var = input.var(dim=-1, keepdim=True) # Varianz über FeaturesNormalisiere:
normalized = (input - mean) / sqrt(var + 1e-5) # 1e-5 verhindert Division durch 0Skaliere und verschiebe (mit lernbaren Parametern):
output = normalized * self.weight + self.bias
Beispiel mit konkreten Zahlen:
- Input: [100, 200, 50] (sehr unterschiedliche Größenordnungen)
- Nach Normalisierung: [-0.78, 1.09, -0.31] (standardisiert)
- Nach Skalierung: Angepasst wie vom Modell gelernt
Residual Connections - Die Abkürzungen
Residual Connections (auch Skip Connections genannt) sind genial einfach: Sie addieren einfach den Input zum Output!
# Aus model.py, Zeilen 185-200 (Block.forward)
def forward(self, hidden_states):
"""
Verarbeitet die Eingabe durch Attention und Feed-Forward Schichten.
Verwendet Pre-Normalization (Layer Norm vor der Transformation)
und Residual Connections für stabiles Training.
"""
# Attention-Block mit Residual Connection
hidden_states = hidden_states + self.attn(self.ln_1(hidden_states))
# MLP-Block mit Residual Connection
hidden_states = hidden_states + self.mlp(self.ln_2(hidden_states))
return hidden_statesWas passiert hier im Detail?
Pre-Norm Attention:
# Originalwert speichern residual = hidden_states # Normalisieren normalized = self.ln_1(hidden_states) # Durch Attention attention_out = self.attn(normalized) # Residual addieren hidden_states = residual + attention_outPre-Norm MLP:
# Wieder Original speichern residual = hidden_states # Normalisieren normalized = self.ln_2(hidden_states) # Durch MLP mlp_out = self.mlp(normalized) # Residual addieren hidden_states = residual + mlp_out
Warum sind Residual Connections so wichtig?
Gradient Flow: Gradienten können direkt durch die Addition fließen
- Ohne Residual: Gradient muss durch alle Schichten
- Mit Residual: Gradient hat eine "Autobahn" durch die Addition
Identitätsfunktion: Im Worst Case kann das Netzwerk einfach die Identität lernen
- Wenn eine Schicht nichts Nützliches lernt, kann sie einfach 0 ausgeben
- Der Residual sorgt dafür, dass trotzdem die Information durchkommt
Ensemble-Effekt: Residual Connections erzeugen implizit ein Ensemble
- Verschiedene "Pfade" durch das Netzwerk
- Mehr Robustheit
Analogie: Stell dir ein mehrstöckiges Gebäude vor:
- Ohne Residual: Du musst jede Etage durchlaufen (Treppe)
- Mit Residual: Es gibt zusätzlich einen Aufzug direkt nach oben
- Informationen können den schnellsten Weg nehmen
Das Zusammenspiel im Transformer-Block
# Aus model.py, Zeilen 165-184 (Block Klasse)
class Block(nn.Module):
"""
Transformer-Block: Die Kernkomponente der GPT-Architektur.
Jeder Block besteht aus:
1. Multi-Head Self-Attention mit kausaler Maskierung
2. Feed-Forward Netzwerk (MLP)
3. Zwei Layer-Normalization Schichten
4. Residual Connections um beide Hauptkomponenten
"""
def __init__(self, config):
super().__init__()
self.ln_1 = LayerNorm(config.n_embd, bias=config.bias) # Norm vor Attention
self.attn = CausalSelfAttention(config) # Attention
self.ln_2 = LayerNorm(config.n_embd, bias=config.bias) # Norm vor MLP
self.mlp = MLP(config) # Feed-ForwardDie Reihenfolge ist wichtig:
- Layer Norm ? Stabilisiert Input
- Attention/MLP ? Transformation
- Residual Add ? Erhält Gradient Flow
Diese Struktur wird 12 mal (bei GPT-2 Small) wiederholt, wodurch das Modell immer komplexere Repräsentationen aufbaut!
3. Vorbereitung des Trainings
Bevor wir mit dem eigentlichen Training beginnen können, müssen wir vier wichtige Vorbereitungen treffen: Daten laden, Modell initialisieren, Hyperparameter setzen und den Optimizer konfigurieren.
3.1 Datenaufbereitung und Tokenisierung - Wie Texte zu Trainingsmaterial werden
Das Training beginnt mit Daten - in unserem Fall tokenisierter Text, der als binäre Dateien vorliegt. Schauen wir uns genau an, wie das funktioniert.
Die Datenstruktur
Unsere Trainingsdaten liegen in zwei Dateien:
train.bin: Trainingsdaten (meist 90% der Gesamtdaten)val.bin: Validierungsdaten (meist 10% der Gesamtdaten)
Diese Dateien enthalten vorverarbeitete Token-IDs als 16-bit Integers:
# Aus train.py, Zeilen 103-108
data_directory = dataset # dataset ist 'data'
train_data_path = os.path.join(data_directory, 'train.bin')
validation_data_path = os.path.join(data_directory, 'val.bin')
print(f"Trainingsdaten-Datei: {train_data_path}")
print(f"Validierungsdaten-Datei: {validation_data_path}")Memory-Mapped Files - Der Trick für große Datensätze
Hier kommt ein cleverer Trick: Memory-Mapped Files. Statt die gesamten Daten in den RAM zu laden, mappen wir sie direkt von der Festplatte:
# Aus train.py, Zeilen 110-138 (load_data_batch Funktion)
def load_data_batch(split):
"""
Lädt einen zufälligen Batch aus den Trainingsdaten.
Verwendet memory-mapped Dateien für effizienten Zugriff auf große Datensätze.
Erstellt np.memmap bei jedem Aufruf neu um Memory Leaks zu vermeiden.
"""
# Neu erstellen von memmap vermeidet Memory Leak
if split == 'train':
data = np.memmap(os.path.join(data_directory, 'train.bin'), dtype=np.uint16, mode='r')
else:
data = np.memmap(os.path.join(data_directory, 'val.bin'), dtype=np.uint16, mode='r')Was ist np.memmap?
- Es ist wie eine virtuelle Ansicht auf die Datei
- Die Datei bleibt auf der Festplatte
- Nur die Teile, die wir gerade brauchen, werden in den RAM geladen
- Perfekt für Datensätze, die größer als der RAM sind!
Wichtiger Trick: Wir erstellen memmap bei jedem Aufruf neu. Warum?
- Python's Garbage Collector kann sonst Memory Leaks verursachen
- Siehe Kommentar: "vermeidet Memory Leak"
Batch-Erstellung - Zufällige Sequenzen
Jetzt extrahieren wir zufällige Sequenzen aus den Daten:
# Fortsetzung load_data_batch
batch_indices = torch.randint(len(data) - block_size, (batch_size,))
input_sequences = torch.stack([torch.from_numpy((data[i:i+block_size]).astype(np.int64)) for i in batch_indices])
target_sequences = torch.stack([torch.from_numpy((data[i+1:i+1+block_size]).astype(np.int64)) for i in batch_indices])Was passiert hier im Detail?
Zufällige Startpositionen:
batch_indices = torch.randint(len(data) - block_size, (batch_size,))- Wählt
batch_size(z.B. 12) zufällige Startpositionen len(data) - block_sizestellt sicher, dass wir nicht über das Ende hinauslesen- Beispiel: Bei 1 Million Token und block_size=1024 können wir von Position 0 bis 998.976 starten
- Wählt
Input-Sequenzen:
data[i:i+block_size] # z.B. Token 1000 bis 2023- Extrahiert eine Sequenz der Länge
block_size(1024) .astype(np.int64)konvertiert von uint16 zu int64 (PyTorch Standard)
- Extrahiert eine Sequenz der Länge
Target-Sequenzen:
data[i+1:i+1+block_size] # z.B. Token 1001 bis 2024- Die Targets sind um 1 Position verschoben!
- Warum? Wir trainieren das Modell, das NÄCHSTE Token vorherzusagen
- Input: "Der Hund ist" ? Target: "Hund ist groß"
GPU-Optimierung beim Datentransfer
if device_type == 'cuda':
# Arrays im RAM pinnen für asynchronen GPU-Transfer
input_sequences = input_sequences.pin_memory().to(device, non_blocking=True)
target_sequences = target_sequences.pin_memory().to(device, non_blocking=True)
else:
input_sequences = input_sequences.to(device)
target_sequences = target_sequences.to(device)Pinned Memory - Was ist das?
- Normaler RAM kann vom OS verschoben werden (paging)
- Pinned Memory ist "festgenagelt" im RAM
- GPU kann direkt darauf zugreifen ohne Kopie
non_blocking=Truemacht den Transfer asynchron- Die CPU kann weiterarbeiten während die GPU die Daten empfängt!
Das Vokabular - Meta-Informationen
# Aus train.py, Zeilen 144-151
metadata_path = os.path.join(data_directory, 'meta.pkl')
metadata_vocab_size = None
if os.path.exists(metadata_path):
with open(metadata_path, 'rb') as f:
metadata = pickle.load(f)
metadata_vocab_size = metadata['vocab_size']
print(f"vocab_size gefunden = {metadata_vocab_size} (in {metadata_path})")Die meta.pkl Datei enthält wichtige Informationen:
vocab_size: Wie viele verschiedene Tokens gibt es?- Eventuell auch: Tokenizer-Informationen, Sonderzeichen-Mappings etc.
3.2 Modell-Initialisierung - Von Grund auf oder vortrainiert?
Das Modell kann auf drei verschiedene Arten initialisiert werden. Jede hat ihre Vor- und Nachteile:
Modus 1: Training von Grund auf ("scratch")
# Aus train.py, Zeilen 156-164
if initialization_mode == 'scratch':
# Initialisiere ein neues Modell von Grund auf
print("Initialisiere ein neues Modell von Grund auf")
# Bestimme die vocab_size
if metadata_vocab_size is None:
print("Verwende Standard vocab_size von GPT-2: 50304")
model_arguments['vocab_size'] = metadata_vocab_size if metadata_vocab_size is not None else 50304
gpt_configuration = GPTConfig(**model_arguments)
model = GPT(gpt_configuration)Warum 50304 statt 50257?
- GPT-2 hat eigentlich 50.257 Token
- 50.304 = 50.257 + 47 (aufgerundet auf Vielfaches von 64)
- Warum aufrunden? GPU-Effizienz!
- GPUs arbeiten am effizientesten mit Vielfachen von 32 oder 64
Die Gewichtsinitialisierung - Kritisch für den Erfolg
# Aus model.py, Zeilen 292-309
def _init_weights(self, module):
"""
Initialisiert die Gewichte eines Moduls nach GPT-2 Spezifikation.
"""
if isinstance(module, nn.Linear):
# Normalverteilung mit Standardabweichung 0.02
torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
if module.bias is not None:
torch.nn.init.zeros_(module.bias)
elif isinstance(module, nn.Embedding):
torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)Warum genau std=0.02?
- Zu klein (0.001): Signale verschwinden in tiefen Netzwerken
- Zu groß (0.1): Aktivierungen explodieren
- 0.02 ist der Sweetspot für Transformer (empirisch ermittelt)
Spezialbehandlung für Residual-Projektionen:
# Aus model.py, Zeilen 264-266
for param_name, param in self.named_parameters():
if param_name.endswith('c_proj.weight'):
# Skalierung mit 1/v(2*n_layer)
torch.nn.init.normal_(param, mean=0.0, std=0.02/math.sqrt(2 * config.n_layer))Warum diese spezielle Skalierung?
- Residual-Pfade addieren sich über alle Schichten
- Bei 12 Schichten würden sich die Varianzen aufaddieren
- Die Skalierung kompensiert das: std = 0.02/v(2×12) ˜ 0.0041
- Hält die Gesamtvarianz konstant!
Modus 2: Training fortsetzen ("resume")
# Aus train.py, Zeilen 165-187
elif initialization_mode == 'resume':
print(f"Setze Training fort von {output_directory}")
# Lade Checkpoint
checkpoint_path = os.path.join(output_directory, 'my_own_llm.pt')
checkpoint = torch.load(checkpoint_path, map_location=device)
# Validiere kritische Parameter
checkpoint_model_arguments = checkpoint['model_args']
for key in ['n_layer', 'n_head', 'n_embd', 'block_size', 'bias', 'vocab_size']:
model_arguments[key] = checkpoint_model_arguments[key]Wichtig: Bestimmte Parameter MÜSSEN übereinstimmen:
n_layer,n_head,n_embd: Architektur muss gleich seinvocab_size: Gleiche Token-Anzahl- Andere Parameter (wie dropout) können geändert werden
Der mysteriöse Präfix-Bug:
# Korrigiere die Schlüssel des State Dictionary
unwanted_prefix = '_orig_mod.'
for k,v in list(state_dict.items()):
if k.startswith(unwanted_prefix):
state_dict[k[len(unwanted_prefix):]] = state_dict.pop(k)Was ist das?
- PyTorch's
torch.compile()fügt manchmal '_orig_mod.' Präfix hinzu - Muss entfernt werden für Kompatibilität
- Ein bekannter PyTorch-Quirk
Modus 3: Mit vortrainierten Gewichten ("gpt2")
# Aus train.py, Zeilen 188-195
elif initialization_mode.startswith('gpt2'):
print(f"Initialisiere mit OpenAI GPT-2 Gewichten: {initialization_mode}")
override_arguments = dict(dropout=dropout_rate)
model = GPT.load_pretrained_weights(initialization_mode, override_arguments)Die load_pretrained_weights Funktion lädt echte GPT-2 Gewichte von HuggingFace:
# Aus model.py, Zeilen 386-440
@classmethod
def load_pretrained_weights(cls, model_type, override_args=None):
"""
Lädt vortrainierte GPT-2 Gewichte von HuggingFace.
Modellgrößen:
- gpt2: 124M Parameter
- gpt2-medium: 350M Parameter
- gpt2-large: 774M Parameter
- gpt2-xl: 1.5B Parameter
"""
config_args = {
'gpt2': dict(n_layer=12, n_head=12, n_embd=768),
'gpt2-medium': dict(n_layer=24, n_head=16, n_embd=1024),
'gpt2-large': dict(n_layer=36, n_head=20, n_embd=1280),
'gpt2-xl': dict(n_layer=48, n_head=25, n_embd=1600),
}[model_type]Der Gewichts-Transponierungstrick:
# OpenAI verwendet "Conv1D" Module, wir verwenden Standard Linear
transposed_weights = ['attn.c_attn.weight', 'attn.c_proj.weight',
'mlp.c_fc.weight', 'mlp.c_proj.weight']
for key in huggingface_keys:
if any(key.endswith(weight_name) for weight_name in transposed_weights):
# Transponiere die Gewichte
state_dict[key].copy_(huggingface_state_dict[key].t())Warum transponieren?
- OpenAI's originale Implementierung nutzt 1D-Convolutions
- PyTorch's Linear erwartet transponierte Gewichte
.t()transponiert die Matrix
3.3 Hyperparameter-Konfiguration - Die Stellschrauben des Trainings
Hyperparameter sind die Einstellungen, die wir VOR dem Training festlegen müssen. Sie bestimmen, wie das Training abläuft:
# Aus train.py, Zeilen 30-68
# === Modell-Konfiguration ===
num_layers = 12 # Anzahl der Transformer-Blöcke
num_attention_heads = 12 # Anzahl der Attention-Heads
embedding_dimension = 768 # Dimension der Embeddings
dropout_rate = 0.0 # Dropout (0.0 für Pretraining, 0.1+ für Finetuning)
use_bias = False # Bias in Linear/LayerNorm Schichten?
# === Trainings-Konfiguration ===
gradient_accumulation_steps = 5 * 8 # = 40
batch_size = 12
block_size = 1024Batch-Größe und Gradient Accumulation
Die effektive Batch-Größe ist eines der wichtigsten Hyperparameter:
# Aus train.py, Zeilen 77-78
tokens_per_iteration = gradient_accumulation_steps * batch_size * block_size
print(f"Tokens pro Iteration: {tokens_per_iteration:,}") # = 491,520!Warum Gradient Accumulation?
- Wir wollen große Batches (bessere Gradienten)
- GPU-Speicher ist begrenzt
- Lösung: Mehrere kleine Batches, Gradienten akkumulieren
Beispiel:
- Ohne Accumulation: Batch-Size 12 (passt in GPU)
- Mit Accumulation: Effektiv 12 × 40 = 480 Sequenzen
- Das sind 480 × 1024 = 491.520 Token pro Update!
Learning Rate und Optimizer-Einstellungen
# === AdamW Optimizer Konfiguration ===
learning_rate = 6e-4 # Maximale Lernrate (0.0006)
weight_decay = 1e-1 # L2-Regularisierung (0.1)
adam_beta1 = 0.9 # Momentum-Koeffizient
adam_beta2 = 0.95 # RMSprop-Koeffizient
gradient_clipping_value = 1.0 # Gradient-Clipping SchwelleLearning Rate 6e-4:
- Zu hoch (1e-2): Training instabil, Loss explodiert
- Zu niedrig (1e-5): Training zu langsam
- 6e-4 ist optimal für GPT-2 Größe
Weight Decay 0.1:
- Regularisierung gegen Overfitting
- "Bestraft" große Gewichte
- 0.1 ist überraschend hoch, funktioniert aber!
Adam Betas (0.9, 0.95):
- Beta1=0.9: Momentum über letzte ~10 Steps
- Beta2=0.95: Varianz-Anpassung über ~20 Steps
- Niedriger als Standard (0.9, 0.999) ? weniger "Gedächtnis"
Learning Rate Schedule
# Lernraten-Decay Einstellungen
enable_learning_rate_decay = True
warmup_iterations = 2000 # Aufwärmphase
learning_rate_decay_iterations = 600000 # Decay-Dauer
minimum_learning_rate = 6e-5 # Min LR (1/10 der Max)Der Schedule hat drei Phasen:
- Warmup (0-2000): Linear von 0 auf 6e-4
- Cosine Decay (2000-600000): Sanft von 6e-4 auf 6e-5
- Konstant (600000+): Bleibt bei 6e-5
System-Einstellungen
# System-Einstellungen
device = 'cuda' # oder 'cpu', 'mps' für Apple Silicon
data_type = 'bfloat16' if torch.cuda.is_bf16_supported() else 'float16'
enable_model_compilation = True # PyTorch 2.0 CompileDatentypen:
float32: Volle Präzision, aber langsam und speicherhungrigfloat16: Halbe Präzision, schnell aber numerisch instabilbfloat16: Beste Option - größerer Exponent-Bereich als float16
3.4 Optimizer-Setup mit Weight Decay - Der Lernalgorithmus
Der Optimizer ist das Herzstück des Lernprozesses. Er bestimmt, WIE die Gewichte basierend auf den Gradienten aktualisiert werden.
AdamW - Der Standard für Transformer
# Aus model.py, Zeilen 442-481
def create_optimizer_with_weight_decay(self, weight_decay, learning_rate, betas, device_type):
"""
Erstellt einen AdamW-Optimizer mit selektivem Weight Decay.
Weight Decay wird nur auf 2D-Parameter (Gewichtsmatrizen) angewendet,
nicht auf 1D-Parameter (Bias, LayerNorm).
"""
# Sammle alle trainierbaren Parameter
param_dict = {param_name: param for param_name, param in self.named_parameters()}
param_dict = {param_name: param for param_name, param in param_dict.items()
if param.requires_grad}Die Weight Decay Strategie
Hier kommt ein wichtiger Trick - nicht alle Parameter bekommen Weight Decay:
# Erstelle Optimizer-Gruppen basierend auf Parameter-Dimensionen
decay_params = [param for name, param in param_dict.items() if param.dim() >= 2]
nodecay_params = [param for name, param in param_dict.items() if param.dim() < 2]
optim_groups = [
{'params': decay_params, 'weight_decay': weight_decay},
{'params': nodecay_params, 'weight_decay': 0.0}
]Warum diese Unterscheidung?
MIT Weight Decay (2D+ Parameter = Matrizen):
- Alle Gewichtsmatrizen in Linear-Schichten
- Embedding-Matrizen
- Diese können zu groß werden ? Regularisierung nötig
OHNE Weight Decay (1D Parameter):
- Bias-Terme (Verschiebungen)
- LayerNorm Parameter (gamma, beta)
- Diese sind klein und wichtig für Flexibilität
Beispiel der Aufteilung:
num_decay_params = sum(param.numel() for param in decay_params)
num_nodecay_params = sum(param.numel() for param in nodecay_params)
print(f"Decay Parameter: {len(decay_params)} Tensoren, {num_decay_params:,} Parameter")
print(f"No-Decay Parameter: {len(nodecay_params)} Tensoren, {num_nodecay_params:,} Parameter")
# Typische Ausgabe für GPT-2 Small:
# Decay Parameter: 98 Tensoren, 123,654,144 Parameter
# No-Decay Parameter: 50 Tensoren, 62,976 ParameterAdamW vs Adam
# Erstelle AdamW-Optimizer
fused_available = 'fused' in inspect.signature(torch.optim.AdamW).parameters
use_fused = fused_available and device_type == 'cuda'
extra_args = dict(fused=True) if use_fused else dict()
optimizer = torch.optim.AdamW(optim_groups, lr=learning_rate, betas=betas, **extra_args)Was ist der Unterschied?
- Adam: Weight Decay wird auf Gradienten angewendet
- AdamW: Weight Decay direkt auf Gewichte (korrekter!)
- AdamW entkoppelt Weight Decay von der Lernrate
Fused AdamW:
- Kombiniert mehrere Operationen in einem CUDA-Kernel
- Bis zu 2× schneller als Standard AdamW
- Weniger Speicher-Overhead
Wie AdamW funktioniert
AdamW kombiniert drei Ideen:
Momentum (Beta1=0.9):
m = beta1 * m + (1-beta1) * gradient # Exponentieller Durchschnitt der GradientenRMSprop (Beta2=0.95):
v = beta2 * v + (1-beta2) * gradient² # Exponentieller Durchschnitt der quadrierten GradientenAdaptive Learning Rate:
parameter = parameter - lr * m / (sqrt(v) + epsilon) - weight_decay * parameter
Die Kombination macht AdamW so effektiv:
- Momentum: Überwindet lokale Minima
- RMSprop: Passt Lernrate pro Parameter an
- Weight Decay: Verhindert Overfitting
4. Der Trainingsloop im Detail - Das Herz des Lernprozesses
Jetzt kommen wir zum eigentlichen Training - dem sich wiederholenden Zyklus, in dem das Modell lernt. Der Trainingsloop ist das Herzstück des gesamten Prozesses.
4.1 Die Hauptschleife - Struktur und Ablauf
Der Trainingsloop beginnt mit der Initialisierung und läuft dann in einer endlosen Schleife:
# Aus train.py, Zeilen 274-281
input_batch, target_batch = load_data_batch('train') # Erster Batch
start_time = time.time()
local_iteration_number = 0
raw_model = model # Kein DDP-Container, direkt das Modell
# Variablen für ETA-Berechnung
iteration_times = [] # Speichere Iterationszeiten für gleitenden Durchschnitt
while True: # Endlos-Schleife!Die Hauptschleife läuft endlos, bis wir eine Abbruchbedingung erreichen:
max_iterationserreicht (z.B. 600.000 Iterationen)- Manueller Abbruch mit Ctrl+C
- Early Stopping (wenn implementiert)
Learning Rate Anpassung
Als erstes in jeder Iteration wird die Lernrate angepasst:
# Aus train.py, Zeilen 284-286
# Bestimme und setze die Lernrate für diese Iteration
current_learning_rate = get_scheduled_learning_rate(iteration_number) if enable_learning_rate_decay else learning_rate
for param_group in optimizer.param_groups:
param_group['lr'] = current_learning_rateDie Funktion get_scheduled_learning_rate implementiert den dreiphasigen Schedule:
# Aus train.py, Zeilen 244-269
def get_scheduled_learning_rate(current_iteration):
# Phase 1: Linearer Warmup von 0 bis learning_rate
if current_iteration < warmup_iterations:
return learning_rate * (current_iteration + 1) / (warmup_iterations + 1)
# Phase 2: Nach Decay-Phase konstant bei Minimal-Lernrate
if current_iteration > learning_rate_decay_iterations:
return minimum_learning_rate
# Phase 3: Cosine Decay zwischen Warmup und finaler Phase
decay_ratio = (current_iteration - warmup_iterations) /
(learning_rate_decay_iterations - warmup_iterations)
coefficient = 0.5 * (1.0 + math.cos(math.pi * decay_ratio))
return minimum_learning_rate + coefficient * (learning_rate - minimum_learning_rate)Warum dieser komplexe Schedule?
Warmup Phase (0-2000 Iterationen):
- Start bei LR=0, linear auf 6e-4
- Verhindert zu große Updates am Anfang
- Modell "gewöhnt sich" an die Daten
Cosine Decay (2000-600.000):
- Sanfte Reduktion von 6e-4 auf 6e-5
- Cosine-Kurve ist sanfter als linear
- Ermöglicht Feinabstimmung
Konstante Phase (600.000+):
- Bleibt bei minimaler LR
- Weitere Verfeinerung ohne Instabilität
4.2 Forward Pass - Die Vorhersage
Der Forward Pass ist der Durchlauf der Daten durch das Modell:
# Aus train.py, Zeilen 313-316
for gradient_accumulation_step in range(gradient_accumulation_steps):
with autocast_context:
logits, loss = model(input_batch, target_batch)
loss = loss / gradient_accumulation_stepsWas passiert im model() Aufruf? Schauen wir in model.py:
# Aus model.py, Zeilen 311-346 (GPT.forward)
def forward(self, input_ids, targets=None):
device = input_ids.device
batch_size, sequence_length = input_ids.size()
# Positions-IDs erstellen: [0, 1, 2, ..., sequence_length-1]
position_ids = torch.arange(0, sequence_length, dtype=torch.long, device=device)
# Schritt 1: Embeddings
token_embeddings = self.transformer.wte(input_ids) # (B, T, 768)
position_embeddings = self.transformer.wpe(position_ids) # (T, 768)
hidden_states = self.transformer.drop(token_embeddings + position_embeddings)
# Schritt 2: Durch alle Transformer-Blöcke
for block in self.transformer.blocks:
hidden_states = block(hidden_states)
# Schritt 3: Finale Layer Norm
hidden_states = self.transformer.ln_f(hidden_states)Der Datenfluss im Detail:
- Input: Token-IDs, z.B. [42, 318, 1623, ...] für "The cat sat..."
- Embeddings: Jedes Token wird zu 768-dimensionalem Vektor
- 12 Transformer-Blöcke: Jeder verfeinert die Repräsentation
- Output: Versteckte Zustände für jede Position
Die Logits-Berechnung
# Fortsetzung model.py forward
if targets is not None:
# Training: Berechne Logits für alle Positionen
logits = self.lm_head(hidden_states) # (B, T, vocab_size)
# Logits Shape: (32, 1024, 50257)
else:
# Inferenz: Nur letzte Position
logits = self.lm_head(hidden_states[:, [-1], :])Was sind Logits?
- Unnormalisierte Vorhersage-Scores
- Ein Score für jedes mögliche nächste Token
- Shape: (Batch, Sequenz, Vokabular)
- Beispiel: logits[0, 5, 1423] = Score für Token 1423 an Position 6
4.3 Loss-Berechnung - Wie falsch lagen wir?
Der Loss misst, wie falsch unsere Vorhersagen waren:
# Aus model.py, Zeilen 336-339
if targets is not None:
logits = self.lm_head(hidden_states)
loss = F.cross_entropy(logits.view(-1, logits.size(-1)),
targets.view(-1), ignore_index=-1)Cross-Entropy Loss im Detail:
Reshape für Loss-Berechnung:
logits.view(-1, logits.size(-1)) # Von (B, T, V) zu (B*T, V) targets.view(-1) # Von (B, T) zu (B*T,)Was berechnet Cross-Entropy?
# Vereinfacht: probabilities = softmax(logits) # Konvertiere zu Wahrscheinlichkeiten loss = -log(probabilities[correct_token]) # Negative Log-WahrscheinlichkeitBeispiel:
Modell sagt: "cat" (70%), "dog" (20%), "bird" (10%)
Richtiges Token: "cat"
Loss = -log(0.7) ˜ 0.36 (niedriger Loss, gute Vorhersage!)
Modell sagt: "cat" (5%), "dog" (90%), "bird" (5%)
Richtiges Token: "cat"
Loss = -log(0.05) ˜ 3.0 (hoher Loss, schlechte Vorhersage!)
Loss-Skalierung für Gradient Accumulation
loss = loss / gradient_accumulation_steps # Wichtig!Warum teilen?
- Wir akkumulieren Gradienten über 40 Mini-Batches
- Ohne Division würden sich die Gradienten aufsummieren
- Mit Division bekommen wir den Durchschnitt
- Entspricht einem großen Batch
4.4 Backward Pass - Gradientenberechnung
Der Backward Pass berechnet die Gradienten - wie müssen wir die Gewichte ändern?
# Aus train.py, Zeile 320
gradient_scaler.scale(loss).backward()Was passiert bei .backward()?
Kettenregel wird angewendet:
- Start: Gradient des Loss = 1
- Rückwärts durch jede Operation
- Berechnet ?Loss/?Parameter für JEDEN Parameter
Automatic Differentiation:
- PyTorch speichert alle Operationen
- Baut einen Berechnungsgraph
- Traversiert rückwärts durch den Graph
Gradient Accumulation:
# Gradienten werden ADDIERT zu existierenden! parameter.grad += new_gradient
Beispiel für einen Parameter:
- Gewicht W hat aktuellen Wert 0.5
- Loss = 2.3
- Gradient ?Loss/?W = -0.1
- Bedeutung: "Wenn W größer wäre, wäre der Loss kleiner"
Mixed Precision und Gradient Scaling
# Der gradient_scaler ist wichtig für float16 Training
gradient_scaler = torch.cuda.amp.GradScaler(enabled=(data_type == 'float16'))
gradient_scaler.scale(loss).backward()Warum Gradient Scaling?
- float16 hat kleinen Wertebereich (±65.504)
- Kleine Gradienten können zu 0 werden (Underflow)
- Lösung: Skaliere Loss hoch ? Gradienten werden größer
- Später: Skaliere Gradienten wieder runter
4.5 Gradient Accumulation - Große Batches simulieren
Gradient Accumulation ist ein cleverer Trick für große effektive Batch-Größen:
# Aus train.py, Zeilen 313-320
for gradient_accumulation_step in range(gradient_accumulation_steps): # 40 mal!
with autocast_context:
logits, loss = model(input_batch, target_batch)
loss = loss / gradient_accumulation_steps # Division!
# Lade NÄCHSTEN Batch während GPU noch rechnet
input_batch, target_batch = load_data_batch('train')
# Backward Pass (Gradienten werden ADDIERT)
gradient_scaler.scale(loss).backward()Der Ablauf im Detail:
Iteration 1:
- Batch 1: 12 Sequenzen
- Forward ? Loss ? Backward
- Gradienten werden berechnet und gespeichert
Iteration 2-40:
- Batch 2-40: Je 12 Sequenzen
- Forward ? Loss ? Backward
- Gradienten werden zu existierenden ADDIERT
Nach 40 Iterationen:
- Effektiv: 480 Sequenzen verarbeitet
- Gradienten repräsentieren den Durchschnitt
Warum nicht einfach große Batches?
- GPU-Speicher-Limit!
- 12 Sequenzen: ~4GB VRAM
- 480 Sequenzen: ~160GB VRAM (unmöglich!)
- Mit Accumulation: Immer nur 4GB gleichzeitig
4.6 Gradient Clipping - Stabilität sichern
Gradient Clipping verhindert explodierende Gradienten:
# Aus train.py, Zeilen 322-324
if gradient_clipping_value != 0.0:
gradient_scaler.unscale_(optimizer) # Skalierung rückgängig machen
torch.nn.utils.clip_grad_norm_(model.parameters(), gradient_clipping_value)Was macht clip_grad_norm_?
Berechne die Norm aller Gradienten:
total_norm = sqrt(sum(p.grad.norm()**2 for p in parameters))Wenn zu groß, skaliere runter:
if total_norm > max_norm: for p in parameters: p.grad *= max_norm / total_norm
Beispiel:
- Gradient-Norm = 10.0
- Max-Norm = 1.0
- Alle Gradienten werden durch 10 geteilt
- Richtung bleibt gleich, nur Größe ändert sich
4.7 Optimizer-Update - Die Gewichte anpassen
Endlich! Nach 40 Mini-Batches führen wir das Update durch:
# Aus train.py, Zeilen 325-329
gradient_scaler.step(optimizer) # Führe Update durch
gradient_scaler.update() # Update Skalierungsfaktor
optimizer.zero_grad(set_to_none=True) # Lösche GradientenWas passiert bei optimizer.step()?
Der AdamW-Optimizer macht für jeden Parameter:
# Vereinfachte AdamW Update-Regel
m = beta1 * m + (1-beta1) * grad # Momentum
v = beta2 * v + (1-beta2) * grad**2 # Zweites Moment
m_hat = m / (1 - beta1**t) # Bias-Korrektur
v_hat = v / (1 - beta2**t) # Bias-Korrektur
# Parameter-Update
param = param - lr * m_hat / (sqrt(v_hat) + eps) - weight_decay * lr * paramKonkretes Beispiel:
- Parameter: 0.5
- Gradient: -0.1 (Loss würde sinken wenn Parameter größer)
- Learning Rate: 0.0006
- Update: 0.5 - 0.0006 × (-0.1) = 0.50006
- Parameter wird minimal größer!
Zero Grad - Aufräumen für nächste Iteration
optimizer.zero_grad(set_to_none=True)Warum set_to_none=True?
- Standard:
grad = torch.zeros_like(param)(Speicher allokieren) - Mit
set_to_none:grad = None(Speicher freigeben) - Spart Speicher und ist schneller!
4.8 Monitoring und Logging
Nach jedem Update wird der Fortschritt geloggt:
# Aus train.py, Zeilen 332-363
# Zeit-Messung
end_time = time.time()
iteration_duration = end_time - start_time
start_time = end_time
# ETA-Berechnung
iteration_times.append(iteration_duration)
if len(iteration_times) > 100: # Nur letzte 100 behalten
iteration_times.pop(0)
if iteration_number % logging_interval == 0: # Alle 10 Iterationen
# Fortschritt berechnen
progress_percentage = (iteration_number / max_iterations) * 100
# ETA berechnen
if len(iteration_times) > 0:
average_iteration_time = sum(iteration_times) / len(iteration_times)
remaining_iterations = max_iterations - iteration_number
estimated_time_seconds = remaining_iterations * average_iteration_time
# Formatiere als HH:MM:SS
estimated_hours = int(estimated_time_seconds // 3600)
estimated_minutes = int((estimated_time_seconds % 3600) // 60)
estimated_seconds = int(estimated_time_seconds % 60)
estimated_time_string = f"{estimated_hours:02d}:{estimated_minutes:02d}:{estimated_seconds:02d}"Die Log-Nachricht:
Iteration 1000: Loss 4.2341, Zeit 523.45ms | Fortschritt 0.2% | ETA 87:23:15Bedeutung:
- Iteration 1000 von 600.000
- Loss ist 4.2341 (wird hoffentlich kleiner!)
- Eine Iteration dauerte 523ms
- 0.2% fertig
- Noch ~87 Stunden Training!
5. Optimierungstechniken - Schneller und besser trainieren
Moderne Transformer-Trainings nutzen viele Optimierungstechniken für Geschwindigkeit und Qualität. Schauen wir uns die wichtigsten im Detail an.
5.1 Mixed Precision Training - Halbe Präzision, doppelte Geschwindigkeit
Mixed Precision Training ist eine der wichtigsten Optimierungen für modernes Deep Learning:
# Aus train.py, Zeilen 67-68, 99-100
data_type = 'bfloat16' if torch.cuda.is_bf16_supported() else 'float16'
pytorch_dtype = {'float32': torch.float32, 'bfloat16': torch.bfloat16,
'float16': torch.float16}[data_type]
autocast_context = torch.amp.autocast(device_type=device_type, dtype=pytorch_dtype)Die drei Datentypen im Vergleich
float32 (Standard):
- 32 Bit pro Zahl (1 Sign, 8 Exponent, 23 Mantisse)
- Wertebereich: ±1.2e-38 bis ±3.4e38
- Sehr präzise, aber langsam und speicherhungrig
float16 (Half Precision):
- 16 Bit pro Zahl (1 Sign, 5 Exponent, 10 Mantisse)
- Wertebereich: ±6.1e-5 bis ±65,504
- Problem: Kleiner Wertebereich ? Overflow/Underflow
bfloat16 (Brain Float):
- 16 Bit pro Zahl (1 Sign, 8 Exponent, 7 Mantisse)
- Wertebereich: ±1.2e-38 bis ±3.4e38 (wie float32!)
- Weniger präzise Mantisse, aber größerer Wertebereich
# Vergleich der Formate:
# float32: SEEEEEEE EMMMMMMM MMMMMMMM MMMMMMMM
# float16: SEEEE EMMM MMMM MM
# bfloat16: SEEEEEEE EMMMMMMMAutomatic Mixed Precision (AMP)
# Aus train.py, Zeilen 314-316
with autocast_context: # Automatische Präzisions-Wahl
logits, loss = model(input_batch, target_batch)
loss = loss / gradient_accumulation_stepsWas macht autocast?
- Matrix-Multiplikationen: float16/bfloat16 (schnell!)
- Loss-Berechnung: float32 (präzise!)
- Normalisierungen: float32 (numerisch stabil)
- Automatische Konversion zwischen Typen
Gradient Scaling für float16
# Aus train.py, Zeile 203
gradient_scaler = torch.cuda.amp.GradScaler(enabled=(data_type == 'float16'))
# Bei Backward Pass
gradient_scaler.scale(loss).backward()
# Bei Optimizer Update
gradient_scaler.unscale_(optimizer)
gradient_scaler.step(optimizer)
gradient_scaler.update()Der Gradient Scaling Workflow:
Scale Up (vor Backward):
scaled_loss = loss * scale_factor # z.B. loss * 65536- Verhindert Gradient Underflow
Backward Pass:
- Gradienten werden mit skaliertem Loss berechnet
- Sind auch skaliert!
Unscale (vor Optimizer):
gradient = gradient / scale_factor- Gradienten wieder in normalen Bereich
Dynamic Scaling:
- Wenn keine NaNs/Infs: scale_factor *= 2 (alle 2000 Steps)
- Wenn NaNs/Infs: scale_factor *= 0.5, Skip Update
Geschwindigkeitsgewinn:
- Tensor Cores auf NVIDIA GPUs: 2-3× schneller
- Halber Speicherbedarf: Größere Batches möglich
- Typisch: 50-70% schnelleres Training!
5.2 Flash Attention - Die Turbo-Attention
Flash Attention ist eine revolutionäre Optimierung des Attention-Mechanismus:
# Aus model.py, Zeilen 85-91
self.flash = hasattr(torch.nn.functional, 'scaled_dot_product_attention')
if not self.flash:
print("WARNUNG: Verwende langsame Attention. Flash Attention benötigt PyTorch >= 2.0")
# Zeilen 114-116
if self.flash:
attention_output = torch.nn.functional.scaled_dot_product_attention(
queries, keys, values, attn_mask=None,
dropout_p=self.dropout if self.training else 0, is_causal=True)Was macht Flash Attention anders?
Standard Attention (vereinfacht):
# Schritt 1: Berechne Attention-Matrix (RIESIG!)
scores = queries @ keys.T # (1024, 1024) Matrix!
# Schritt 2: Softmax
attention = softmax(scores)
# Schritt 3: Gewichtete Summe
output = attention @ valuesProblem: Die (1024 × 1024) Attention-Matrix muss komplett im Speicher gehalten werden!
Flash Attention:
- Berechnet Attention blockweise
- Hält nie die komplette Attention-Matrix im Speicher
- Nutzt schnellen SRAM statt langsamen HBM
- Fusioniert Operationen in einem Kernel
Die Speicher-Hierarchie verstehen
CPU ? RAM ? GPU HBM ? GPU SRAM ? GPU Register
? ? ? ?
~100GB ~40GB ~20MB ~256KB
100GB/s 1.5TB/s 19TB/s Fast!Flash Attention nutzt den schnellen SRAM optimal:
- Lädt kleine Blöcke in SRAM
- Berechnet Attention für diesen Block
- Schreibt Ergebnis zurück
- Nächster Block
Performance-Gewinn:
- Speicher: O(n) statt O(n²)
- Geschwindigkeit: 2-4× schneller
- Längere Sequenzen möglich (bis 16k+)
5.3 Model Compilation - JIT für Geschwindigkeit
PyTorch 2.0 führte torch.compile ein:
# Aus train.py, Zeilen 212-215
if enable_model_compilation:
print("Kompiliere das Modell... (dauert ~1 Minute)")
unoptimized_model = model
model = torch.compile(model)Was macht torch.compile?
Ohne Compilation (Eager Mode):
- Jede Operation einzeln ausgeführt
- Python Overhead bei jeder Op
- Viele kleine CUDA Kernel Launches
Mit Compilation:
- Graph Capture: Analysiert den Berechnungsgraph
- Optimization:
- Fusioniert Operationen
- Eliminiert redundante Berechnungen
- Optimiert Memory Access Patterns
- Code Generation: Generiert optimierten CUDA/CPU Code
Beispiel einer Fusion:
# Vorher: 3 separate Kernel
x = self.linear(x) # Kernel 1
x = self.gelu(x) # Kernel 2
x = self.dropout(x) # Kernel 3
# Nach Compilation: 1 fusionierter Kernel!
x = fused_linear_gelu_dropout(x)Performance-Gewinn:
- 20-50% schneller
- Weniger Kernel Launches
- Bessere Memory Locality
- Automatische Operator Fusion
5.4 Gradient Checkpointing - Speicher vs Zeit Trade-off
Gradient Checkpointing (nicht in unserem Code, aber wichtig zu verstehen):
# Pseudo-Code für Gradient Checkpointing
class CheckpointedBlock(nn.Module):
def forward(self, x):
if self.training:
# Speichere nur Input, berechne Output
return checkpoint(self._forward, x)
else:
return self._forward(x)
def _forward(self, x):
x = self.attention(x)
x = self.mlp(x)
return xDas Problem:
- Backward Pass braucht alle Zwischenergebnisse
- Bei 96 Layers: Gigabytes an Aktivierungen!
Die Lösung:
- Speichere nur Checkpoints (z.B. jeden 4. Layer)
- Bei Backward: Recompute von letztem Checkpoint
- Trade-off: 33% langsamer, 75% weniger Speicher
5.5 Optimierte Datenlade-Pipeline
Unser Code nutzt mehrere Optimierungen beim Datenladen:
# Aus train.py, Zeilen 318-319
# Lade nächsten Batch WÄHREND GPU rechnet!
input_batch, target_batch = load_data_batch('train')Pipeline-Parallelismus:
GPU: |--Forward--|--Backward--|--Update--|--Forward--|
CPU: |--Load-----| |--Load-----|Die CPU lädt den nächsten Batch während die GPU noch rechnet!
Pinned Memory für schnellen Transfer
# Aus train.py, Zeilen 134-136
if device_type == 'cuda':
input_sequences = input_sequences.pin_memory().to(device, non_blocking=True)
target_sequences = target_sequences.pin_memory().to(device, non_blocking=True)Pinned vs Pageable Memory:
Pageable (Standard):
- CPU ? Staging Buffer ? GPU
- Zwei Kopien nötig
- Kann vom OS verschoben werden
Pinned:
- CPU ? GPU direkt (DMA Transfer)
- Eine Kopie
- Fest im RAM
non_blocking=True: Asynchroner Transfer!
5.6 Fused Optimizer
# Aus model.py, Zeilen 475-478
fused_available = 'fused' in inspect.signature(torch.optim.AdamW).parameters
use_fused = fused_available and device_type == 'cuda'
optimizer = torch.optim.AdamW(optim_groups, lr=learning_rate, betas=betas,
fused=use_fused)Standard AdamW:
- Jeder Parameter: Separater Kernel Launch
- Bei 124M Parametern: Tausende Kernel!
Fused AdamW:
- Ein großer Kernel für alle Parameter
- Weniger Overhead
- Bessere Memory Coalescing
- 20-30% schnellerer Optimizer Step
6. Evaluierung und Checkpointing - Fortschritt überwachen und sichern
6.1 Evaluierung während des Trainings
Regelmäßige Evaluierung ist essentiell um den Trainingsfortschritt zu überwachen:
# Aus train.py, Zeilen 289-307
if iteration_number % evaluation_interval == 0: # Alle 1000 Iterationen
losses = evaluate_loss_on_splits()
log_message = f"Schritt {iteration_number}: Train-Loss {losses['train']:.4f}, Val-Loss {losses['val']:.4f}"
print(log_message)Die Evaluierungsfunktion im Detail
# Aus train.py, Zeilen 220-242
@torch.no_grad() # WICHTIG: Keine Gradienten während Evaluierung!
def evaluate_loss_on_splits():
"""
Evaluiert den Modell-Loss auf Training- und Validierungsdaten.
"""
results = {}
model.eval() # Eval-Modus: Dropout aus, etc.
for split in ['train', 'val']:
losses = torch.zeros(evaluation_iterations) # 200 Iterationen
for evaluation_step in range(evaluation_iterations):
input_batch, target_batch = load_data_batch(split)
with autocast_context:
logits, loss = model(input_batch, target_batch)
losses[evaluation_step] = loss.item() # .item() holt Skalar von GPU
results[split] = losses.mean()
model.train() # Zurück in Train-Modus
return resultsWichtige Details:
@torch.no_grad():- Deaktiviert Gradient-Berechnung
- Spart Speicher (keine Zwischenergebnisse)
- Schneller (kein Berechnungsgraph)
model.eval()vsmodel.train():# Train-Modus: dropout_rate = 0.1 # Dropout aktiv # Eval-Modus: dropout_rate = 0.0 # Dropout deaktiviertMehrere Batches evaluieren:
- 200 Batches für stabilere Schätzung
- Mittelwert über alle Batches
- Reduziert Varianz der Schätzung
Overfitting erkennen
Train-Loss: 2.34 Val-Loss: 2.41 # Gut! Ähnliche Werte
Train-Loss: 1.23 Val-Loss: 3.45 # Overfitting! Val viel höherWarnsignale für Overfitting:
- Val-Loss steigt während Train-Loss sinkt
- Große Lücke zwischen Train und Val
- Val-Loss oszilliert stark
6.2 Checkpoint-System
Checkpoints speichern den kompletten Trainingszustand:
# Aus train.py, Zeilen 295-307
if losses['val'] < best_validation_loss or always_save_checkpoint:
best_validation_loss = losses['val']
if iteration_number > 0:
checkpoint = {
'model': raw_model.state_dict(),
'optimizer': optimizer.state_dict(),
'model_args': model_arguments,
'iter_num': iteration_number,
'best_val_loss': best_validation_loss,
'config': config,
}
print(f"Speichere Checkpoint in {output_directory}")
torch.save(checkpoint, os.path.join(output_directory, 'my_own_llm.pt'))Was wird gespeichert?
model.state_dict():- Alle Modell-Parameter
- Gewichte und Bias aller Layer
- Embeddings
- ~500MB für GPT-2 Small
optimizer.state_dict():- Momentum-Buffers (m)
- Zweite Momente (v)
- Step Count
- ~1GB (doppelt so groß wie Modell!)
Metadaten:
- Iteration Nummer
- Beste Validation Loss
- Modell-Konfiguration
- Training-Konfiguration
Checkpoint laden und fortsetzen
# Aus train.py, Zeilen 165-187 (Resume Mode)
elif initialization_mode == 'resume':
checkpoint_path = os.path.join(output_directory, 'my_own_llm.pt')
checkpoint = torch.load(checkpoint_path, map_location=device)
# Modell wiederherstellen
model.load_state_dict(checkpoint['model'])
# Optimizer wiederherstellen
optimizer.load_state_dict(checkpoint['optimizer'])
# Training-Status wiederherstellen
iteration_number = checkpoint['iter_num']
best_validation_loss = checkpoint['best_val_loss']Warum Optimizer State speichern?
- Momentum-Informationen gehen sonst verloren
- Training würde "von vorne" anfangen
- Besonders wichtig nach Warmup-Phase
6.3 Logging-System
Detailliertes Logging für Debugging und Monitoring:
# Aus train.py, Zeilen 81-87
# Log-Datei einrichten
import datetime
log_filename = os.path.join(output_directory,
f'training_log_{datetime.datetime.now().strftime("%Y%m%d_%H%M%S")}.txt')
log_file = open(log_filename, 'w', buffering=1) # Zeilen-Pufferung!buffering=1 - Zeilen-Pufferung:
- Jede Zeile wird sofort geschrieben
- Auch bei Crash ist Log aktuell
- Wichtig für lange Trainings!
Log-Format
# Aus train.py, Zeilen 360-362
log_message = (f"Iteration {iteration_number}: Loss {total_loss_value:.4f}, "
f"Zeit {iteration_duration*1000:.2f}ms | "
f"Fortschritt {progress_percentage:.1f}% | ETA {estimated_time_string}")Beispiel-Log:
Training gestartet um 2024-01-15 10:23:45
Konfiguration: {'batch_size': 12, 'learning_rate': 0.0006, ...}
Iteration 0: Train-Loss 11.2341, Val-Loss 11.2156
Iteration 10: Loss 10.8932, Zeit 523.45ms | Fortschritt 0.0% | ETA 87:23:15
Iteration 20: Loss 10.4521, Zeit 521.23ms | Fortschritt 0.0% | ETA 87:18:42
...
Iteration 1000: Train-Loss 4.5632, Val-Loss 4.6234
Speichere Checkpoint in out/6.4 Early Stopping (Optional)
Obwohl nicht im Code implementiert, ist Early Stopping wichtig:
# Pseudo-Code für Early Stopping
patience = 10
patience_counter = 0
best_val_loss = float('inf')
for iteration in range(max_iterations):
# ... Training ...
if iteration % eval_interval == 0:
val_loss = evaluate()
if val_loss < best_val_loss:
best_val_loss = val_loss
patience_counter = 0
save_checkpoint()
else:
patience_counter += 1
if patience_counter >= patience:
print("Early Stopping!")
breakWann Early Stopping sinnvoll ist:
- Finetuning auf kleinen Datensätzen
- Wenn Overfitting wahrscheinlich
- Bei begrenzter Rechenzeit
Wann NICHT:
- Pretraining auf großen Datensätzen
- Wenn mehr Daten als Parameter
- Bei Curriculum Learning
7. Praktische Tipps und Troubleshooting
7.1 Häufige Probleme und Lösungen
Problem: Loss explodiert (NaN/Inf)
Symptome:
Iteration 100: Loss nanUrsachen und Lösungen:
Zu hohe Learning Rate:
# Reduziere Learning Rate learning_rate = 6e-5 # statt 6e-4Gradient Explosion:
# Aktiviere/Reduziere Gradient Clipping gradient_clipping_value = 0.5 # statt 1.0Numerische Instabilität:
# Wechsel zu float32 data_type = 'float32' # statt float16
Problem: Training zu langsam
Lösungen:
Aktiviere alle Optimierungen:
enable_model_compilation = True data_type = 'bfloat16' # Flash Attention automatisch wenn PyTorch >= 2.0Gradient Accumulation anpassen:
# Weniger Accumulation Steps, größere Batches gradient_accumulation_steps = 20 # statt 40 batch_size = 24 # wenn Speicher reichtMixed Precision Debugging:
# Check ob AMP richtig funktioniert print(f"Autocast enabled: {autocast_context}") print(f"Scaler enabled: {gradient_scaler.is_enabled()}")
Problem: Out of Memory (OOM)
Sofortmaßnahmen:
# 1. Reduziere Batch-Größe
batch_size = 4 # statt 12
# 2. Erhöhe Gradient Accumulation
gradient_accumulation_steps = 120 # statt 40
# 3. Reduziere Sequenzlänge
block_size = 512 # statt 1024
# 4. Aktiviere Memory-Optimierungen
torch.cuda.empty_cache() # Cache leeren7.2 Performance-Monitoring
GPU-Auslastung prüfen
# Terminal Command
nvidia-smi -l 1 # Update jede Sekunde
# Optimal:
# GPU-Util: >90%
# Memory: Fast voll aber nicht 100%Profiling für Bottlenecks
# Profiler einbauen
from torch.profiler import profile, ProfilerActivity
with profile(activities=[ProfilerActivity.CUDA]) as prof:
for i in range(10):
logits, loss = model(input_batch, target_batch)
loss.backward()
print(prof.key_averages().table(sort_by="cuda_time_total"))7.3 Hyperparameter-Tuning Tipps
Learning Rate Finding
# Learning Rate Range Test
lr_min, lr_max = 1e-6, 1e-1
iterations = 1000
for i in range(iterations):
# Exponentiell erhöhen
lr = lr_min * (lr_max/lr_min) ** (i/iterations)
# Train one batch
loss = train_step(lr)
# Plot loss vs lr
losses.append(loss)
lrs.append(lr)
# Beste LR: Kurz bevor Loss explodiertBatch Size vs Learning Rate Scaling
# Linear Scaling Rule
# Wenn Batch-Größe verdoppelt ? LR verdoppeln
base_batch = 32
base_lr = 3e-4
new_batch = 128
new_lr = base_lr * (new_batch / base_batch) # 1.2e-3
# Aber: Warmup wichtiger bei großen Batches!
warmup_iterations = 4000 # statt 20007.4 Best Practices
1. Immer mit kleinem Test beginnen
# Debug-Konfiguration
if DEBUG:
max_iterations = 100
evaluation_interval = 10
logging_interval = 1
batch_size = 2
gradient_accumulation_steps = 22. Gradient Checking
# Gradienten-Statistiken loggen
for name, param in model.named_parameters():
if param.grad is not None:
grad_norm = param.grad.norm().item()
print(f"{name}: {grad_norm:.6f}")3. Deterministisches Training
# Für Reproduzierbarkeit
torch.manual_seed(42)
torch.cuda.manual_seed(42)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False7.5 Skalierung auf Multi-GPU
Obwohl unser Code Single-GPU ist, hier die Konzepte für Multi-GPU:
Data Parallel (DP):
model = nn.DataParallel(model)
# Batch wird auf GPUs aufgeteiltDistributed Data Parallel (DDP):
model = nn.parallel.DistributedDataParallel(model)
# Effizienter, jede GPU hat eigene KopieModel Parallel:
# Layer auf verschiedene GPUs
model.layer1.to('cuda:0')
model.layer2.to('cuda:1')Zusammenfassung
Das Training eines Transformer-Modells ist ein komplexes Zusammenspiel vieler Komponenten:
- Architektur: Embeddings ? Attention ? MLP ? Layer Norm ? Residuals
- Datenfluss: Memory-Mapped Files ? Batching ? GPU Transfer
- Training Loop: Forward ? Loss ? Backward ? Gradient Accumulation ? Update
- Optimierungen: Mixed Precision ? Flash Attention ? Compilation
- Monitoring: Evaluierung ? Checkpointing ? Logging
Der Schlüssel zum erfolgreichen Training liegt im Verständnis dieser Komponenten und ihrer Interaktionen. Mit diesem Wissen kannst du:
- Probleme diagnostizieren und lösen
- Performance optimieren
- Eigene Modelle trainieren
- State-of-the-Art Ergebnisse erzielen
Das Training großer Sprachmodelle mag komplex erscheinen, aber mit dem richtigen Verständnis der Grundlagen wird es zu einem mächtigen Werkzeug in deinem KI-Arsenal!