Large Language Models: Die Mathematik hinter Transformers
Die Transformer-Architektur findet sich heute in allen Large Language Models. Aber wie genau funktioniert sie? Der Artikel klärt die mathematischen Hintergründe
Die Mathematik hinter den Transformers
(Bild: DALL-E)
- Dr. Michael Stal
Im Jahr 2017 veröffentlichte ein Team bei Google ein Paper mit einem gewagten Titel: „Attention Is All You Need.“ Das war nicht nur akademische Prahlerei. Die vorgestellte Transformer-Architektur veränderte grundlegend, wie wir KI-Systeme bauen. Heute basiert jedes große Sprachmodell von GPT über Claude bis Gemini auf diesem Fundament. Wenn Sie ein modernes KI-Tool nutzen, haben Sie bereits mit einem Transformer interagiert.
Als Software Engineer haben Sie vielleicht gehört, dass Transformers „nur aus Matrixmultiplikationen“ bestehen oder dass sie „Aufmerksamkeitsmechanismen verwenden“. Diese Beschreibungen sind zwar technisch korrekt, verfehlen aber die elegante mathematische Argumentation, die Transformers funktionieren lässt. Dieser Artikel nimmt Sie mit auf eine Reise zu den Problemen, die Transformers motivierten, bis zu den hochmodernen Optimierungen in Produktionssystemen. Am Ende verstehen Sie nicht nur, was die Mathematik hinter Transformers bedeutet, sondern warum sie genau so sein muss. Damit erlangen Sie das Wissen, um einen Transformer von Grund auf zu implementieren.
Wir folgen einem roten Faden: Wie bauen wir ein System, das Beziehungen zwischen Elementen in einer Sequenz verstehen kann, unabhängig davon, wie weit diese Elemente auseinanderliegen, und dies effizient genug, um auf Milliarden von Beispielen zu trainieren? Jede Formel, jede architektonische Entscheidung resultiert aus der Beantwortung dieser Frage.
Das Problem: Warum Recurrent Networks uns im Stich ließen
Vor Transformers dominierte der Ansatz der Recurrent Neural Networks (RNN) die Sequenzverarbeitung. Um zu verstehen, warum Transformers existieren, müssen wir verstehen, warum wir RNNs trotz ihrer Eleganz nicht auf die Probleme skalieren können, die wir lösen wollen.
Stellen Sie sich vor, Sie bauen ein Übersetzungssystem. Sie erhalten den englischen Satz „The cat sat on the mat“ und müssen die französische Übersetzung produzieren. Ein RNN verarbeitet dies sequenziell. Es liest „The“, aktualisiert seinen internen Zustand, liest dann „cat“, aktualisiert seinen Zustand erneut, und so weiter. Die Idee: Wenn das Übersetzungssystem den Satz zu Ende gelesen hat, enthält sein versteckter Zustand eine komprimierte Repräsentation von allem, was es gesehen hat.
(Bild: Golden Sikorka/Shutterstock)
Die Online-Konferenz LLMs im Unternehmen zeigt am 19. März, wie KI-Agenten Arbeitsprozesse übernehmen können, wie LLMs beim Extrahieren der Daten helfen und wie man Modelle effizient im eigenen Rechenzentrum betreibt.
So aktualisiert ein RNN seinen versteckten Zustand bei jedem Zeitschritt:
h_t = tanh(W_hh * h_{t-1} + W_xh * x_t + b_h)
In dieser Formel repräsentiert h_t den versteckten Zustand zum Zeitpunkt t, x_t ist die Eingabe zum Zeitpunkt t, W_hh ist eine Gewichtsmatrix, die den vorherigen versteckten Zustand transformiert, W_xh transformiert die aktuelle Eingabe, und b_h ist ein Bias-Term. Die tanh-Funktion beschränkt das Ergebnis, um die Werte begrenzt zu halten.
Das Problem zeigt sich, wenn Sie das Ganze aus der Perspektive vieler Zeitschritte betrachten. Angenommen, Sie übersetzen ein langes Dokument, und eine kritische Information erscheint im ersten Satz, aber Sie benötigen diese Information, um den hundertsten Satz korrekt zu übersetzen. Diese Information muss somit eine hundertfache Multiplikation mit W_HH überleben.
Videos by heise
In der Praxis führt dies zu zwei katastrophalen Problemen:
Erstens das Problem verschwindender Gradienten. Beim Training neuronaler Netzwerke berechnen wir Gradienten, die uns sagen, wie wir Gewichte anpassen sollen. Diese Gradienten müssen rückwärts durch die Zeit fließen. Wenn der Gradient bei Schritt 100 die Gewichte beeinflussen muss, die wir im Schritt 1 verarbeitet haben, muss er hundertmal mit der Ableitung der tanh-Funktion multipliziert werden. Da die Ableitung von tanh immer kleiner als eins ist, schrumpft der Gradient exponentiell. Wenn er die frühen Schritte erreicht, bleibt er im Wesentlichen bei null und die Gewichte somit unverändert, ohne dazuzulernen.
Zweitens gibt es, selbst wenn wir verschwindende Gradienten mit Techniken wie LSTMs oder GRUs lösen, ein fundamentaleres Problem: Sequenzielle Verarbeitung ist langsam. Moderne GPUs zeichnen sich durch parallele Berechnung aus. Sie können massive Matrizen unglaublich schnell multiplizieren. Aber RNNs zwingen uns, Sequenzen Schritt für Schritt zu verarbeiten, weil jeder Schritt vom vorherigen abhängt. Sie können h_100 nicht berechnen, bis Sie h_99 berechnet haben, was h_98 erfordert, und so weiter, bis wir schlussendlich h_1 erreichen. Diese sequenzielle Abhängigkeit macht das Training auf den massiven Datensätzen, die wir für moderne KI benötigen, unerträglich langsam.
Die Transformer-Architektur löst beide Probleme mit einer radikalen Idee: Was, wenn wir alle Positionen in der Sequenz gleichzeitig betrachten und das Modell lernen lassen könnten, welche Positionen füreinander relevant sind? Hier kommt Attention ins Spiel.
Die Kernidee: Attention als differenzierbare Lookup-Tabelle
Bevor wir in mathematische Formeln eintauchen, bauen wir Intuition darüber auf, was Attention tatsächlich macht. Stellen Sie sich vor, Sie lesen diesen Satz: „Die Trophäe passte nicht in die Truhe, weil sie zu groß war.“ Worauf bezieht sich „sie“? Ihr Gehirn verarbeitet dies nicht Wort für Wort unter Beibehaltung eines versteckten Zustands. Stattdessen schauen Sie, wenn Sie auf „sie“ stoßen, durch den Satz zurück, finden relevanten Kontext (Trophäe und Truhe) und bestimmen, was angesichts von „zu groß“ sinnvoll ist.
Aufmerksamkeitsmechanismen formalisieren diesen intuitiven Prozess. Im Kern ist Attention ein differenzierbarer Nachschlagemechanismus (Lookup-Mechanismus). In einer traditionellen Lookup-Tabelle oder Hash-Map geben Sie einen Schlüssel an und erhalten einen Wert zurück. Attention macht etwas Ähnliches, aber mit einem entscheidenden Unterschied: Statt exakter Übereinstimmungen berechnet es einen gewichteten Durchschnitt aller Werte, wobei die Gewichte davon abhängen, wie gut jeder Schlüssel zu Ihrer Abfrage passt.
Konkretisieren wir dies mit einem einfachen Beispiel. Angenommen, Sie haben drei Wörter in einem Satz, und jedes Wort repräsentiert sich als Vektor. Sie möchten eine neue Repräsentation für das zweite Wort berechnen, die Informationen von den anderen Wörtern basierend auf ihrer Relevanz einbezieht.
# Einfaches Beispiel mit drei Wortvektoren
wort1 = [1.0, 0.0] # "Die"
wort2 = [0.5, 0.5] # "Katze"
wort3 = [0.0, 1.0] # "saß"
Um nun eine attention-erweiterte Repräsentation für wort2 zu berechnen, müssen wir beantworten: Wie relevant ist jedes andere Wort im Textfragment für wort2? Wir könnten diese Relevanz als Skalarprodukt berechnen, das Ähnlichkeit misst:
relevanz_1_zu_2 = skalarprodukt(wort1, wort2) = 1.0 * 0.5 + 0.0 * 0.5 = 0.5
relevanz_2_zu_2 = skalarprodukt(wort2, wort2) = 0.5 * 0.5 + 0.5 * 0.5 = 0.5
relevanz_3_zu_2 = skalarprodukt(wort3, wort2) = 0.0 * 0.5 + 1.0 * 0.5 = 0.5
Diese rohen Relevanzwerte sind nicht sehr nützlich, weil sie sich nicht zu eins summieren lassen. Wir wollen Gewichte, die wir für einen gewichteten Durchschnitt nutzen können. Hier kommt die Softmax-Funktion ins Spiel. Softmax konvertiert beliebige Werte in eine Wahrscheinlichkeitsverteilung:
def softmax(werte):
exp_werte = [exp(w) for w in werte]
summe_exp = sum(exp_werte)
return [e / summe_exp for e in exp_werte]
Die Softmax-Funktion besitzt eine schöne Eigenschaft: Sie ist differenzierbar, sodass wir sie mit Backpropagation trainieren können, und sie produziert immer Ausgaben, die zu eins summieren und allesamt positive Werte besitzen. Die Exponentialfunktion stellt sicher, dass größere Werte exponentiell mehr Gewicht erhalten, was einen weichen Auswahlmechanismus schafft.
Wenden wir Softmax auf unsere Relevanzwerte an, …
gewichte = softmax([0.5, 0.5, 0.5]) = [0.33, 0.33, 0.33]
…, können wir die Attention-erweiterte Repräsentation als gewichtete Summe berechnen:
erweitertes_wort2 = 0.33 * wort1 + 0.33 * wort2 + 0.33 * wort3
= 0.33 * [1.0, 0.0] + 0.33 * [0.5, 0.5] + 0.33 * [0.0, 1.0]
= [0.495, 0.495]
Dies ist die Essenz von Attention: Wir berechnen, wie relevant jede Position für die aktuelle Position ist, normalisieren diese Relevanzen zu Gewichten und nehmen einen gewichteten Durchschnitt. Das Genie der Transformer liegt darin, diesen Prozess lernbar und effizient zu machen.
Scaled Dot-Product Attention: Das mathematische Fundament
Jetzt sind wir bereit, die tatsächliche Attention-Formel abzuleiten, die Transformers nutzen. Wir bauen sie Schritt für Schritt auf und bekommen Einblick, warum jede Komponente existiert.
Unser Ziel: Einen Mechanismus schaffen, bei dem wir für jede Position in einer Sequenz eine Repräsentation berechnen können, die Informationen von allen anderen Positionen basierend auf gelernter Relevanz einbezieht. Wir benötigen drei Dinge:
- eine Möglichkeit auszudrücken, "wonach ich suche" und das an jeder Position. Wir nennen dies die Query (Q). Denken Sie zum Beispiel an eine Suchanfrage in einer Datenbank.
- eine Möglichkeit auszudrücken, "was ich anzubieten habe" an jeder Position. Wir nennen dies den Key (K). Denken Sie an den Index in einer Datenbank, gegen den wir abgleichen.
- die tatsächliche Information, die wir abrufen möchten. Wir nennen dies den Value (V). Denken Sie an die in der Datenbank gespeicherten Daten.
Warum Keys und Values trennen? Weil das, was wir zur Bestimmung der Relevanz verwenden (der Key), sich von dem unterscheiden kann, was wir tatsächlich abrufen möchten (der Value). Zum Beispiel könnte beim Übersetzen von „Die Katze saß“ das Wort „Katze“ relevant sein, weil es ein Substantiv ist (das erfasst der Key), aber was wir tatsächlich abrufen möchten, ist seine volle semantische Bedeutung (das erfasst der Value).
Wir erzeugen Q, K und V, indem wir unsere Eingabe mit gelernten Gewichtsmatrizen multiplizieren:
Q = X * W_Q
K = X * W_K
V = X * W_V
Hier ist X unsere Eingabematrix, wobei jede Zeile ein Wort-Embedding ist, und W_Q, W_K, W_V lernbare Gewichtsmatrizen. Diese Matrizen lernen während des Trainings, die richtige Art von Informationen für Queries, Keys und Values zu extrahieren.
Um nun Attention zu berechnen, müssen wir messen, wie gut jede Query zu jedem Key passt. Die natürliche Wahl ist das Skalarprodukt, weil es Ähnlichkeit misst: Wenn zwei Vektoren in die gleiche Richtung zeigen, ist ihr Skalarprodukt groß; wenn sie orthogonal sind, ist es Null; wenn sie in entgegengesetzte Richtungen zeigen, ist es negativ.
Wir berechnen alle Query-Key-Ähnlichkeiten auf einmal, indem wir Q und K transponiert multiplizieren:
scores = Q * K^T
Dies gibt uns eine Matrix, bei der Eintrag (i,j) beschreibt, wie intensiv Position i (die Query) auf Position j (den Key) achten sollte. Die Dimensionen ergeben sich daraus wie folgt: Wenn wir n Positionen betrachten und jede Query/jeder Key d-dimensional ist, dann erhalten wir eine Matrix Q mit der Größe n mal d, K^T mit der Größe d mal n, und ihr Produkt mit der Größe n mal n.
Hier stoßen wir auf unser erstes Problem. Erhöht sich die Dimension d wachsen die Skalarprodukte ebenfalls. Um zu sehen, warum, bedenken Sie, dass ein Skalarprodukt eine Summe von d Termen ist. Wenn jeder Term die Varianz sigma zum Quadrat hat, hat die Summe die Varianz d mal sigma zum Quadrat aufgrund der Eigenschaften der Varianz. Dies bedeutet, das Skalarprodukt wächst mit der Quadratwurzel von d.
Warum ist dies ein Problem? Weil wir Softmax auf diese Werte anwenden. Softmax mit sehr großen Eingaben führt allerdings zu extrem „spitzen Werten“. Um dies zu sehen, betrachten Sie:
softmax([10, 9, 8]) = [0.665, 0.245, 0.090]
softmax([100, 90, 80]) = [0.9999, 0.0001, 0.0000]
Wenn die Eingaben für Softmax riesengroß sind, ergibt sich im Wesentlichen eine harte Auswahl, die nur den größten Wert auswählt und alles andere ignoriert. Dies löscht Gradienten während des Trainings aus, weil die Ableitung von Softmax fast überall Null erreicht, außer am Maximum.
Die Lösung besteht darin, die Skalarprodukte durch die Quadratwurzel der Dimension zu skalieren:
skalierte_scores = (Q * K^T) / sqrt(d_k)
Diese Skalierung stellt sicher, dass unabhängig von der Dimension die Varianz der Werte ungefähr konstant bleibt. Die Quadratwurzel wirkt speziell dem Quadratwurzelwachstum entgegen, das wir früher identifiziert haben.
Jetzt wenden wir Softmax an, um Attention-Gewichte zu erhalten:
attention_gewichte = softmax(skalierte_scores)
Schließlich verwenden wir diese Gewichte, um einen gewichteten Durchschnitt der Values zu berechnen:
ausgabe = attention_gewichte * V
Alles zusammengesetzt erhalten wir die Scaled Dot-Product Attention-Formel:
Attention(Q, K, V) = softmax((Q * K^T) / sqrt(d_k)) * V
Implementieren wir das in Code, um es zu veranschaulichen:
import numpy as np
def scaled_dot_product_attention(Q, K, V):
"""
Berechnet Scaled Dot-Product Attention.
Args:
Q: Query-Matrix der Form (n, d_k), wobei n die Sequenzlänge ist
K: Key-Matrix der Form (n, d_k)
V: Value-Matrix der Form (n, d_v)
Returns:
ausgabe: Attention-Ausgabe der Form (n, d_v)
attention_gewichte: Attention-Gewichtsmatrix der Form (n, n)
"""
# Holt die Dimension der Keys für die Skalierung
d_k = Q.shape[-1]
# Berechnet Attention-Scores durch Skalarprodukt von Queries und Keys
# Form: (n, d_k) @ (d_k, n) = (n, n)
scores = np.matmul(Q, K.transpose(-2, -1))
# Skaliert Scores durch Quadratwurzel der Key-Dimension
# Dies verhindert, dass Softmax zu spitz wird
skalierte_scores = scores / np.sqrt(d_k)
# Wendet Softmax an, um Attention-Gewichte zu erhalten
# Jede Zeile summiert zu 1, repräsentiert eine Wahrscheinlichkeitsverteilung
attention_gewichte = softmax(skalierte_scores)
# Berechnet gewichtete Summe der Values
# Form: (n, n) @ (n, d_v) = (n, d_v)
ausgabe = np.matmul(attention_gewichte, V)
return ausgabe, attention_gewichte
def softmax(x):
"""
Berechnet Softmax-Werte für jede Zeile der Matrix x.
Numerisch stabile Implementierung, die den Maximalwert subtrahiert.
"""
# Subtrahiert Maximum für numerische Stabilität
# Dies verhindert Overflow durch exp von großen Zahlen
exp_x = np.exp(x - np.max(x, axis=-1, keepdims=True))
return exp_x / np.sum(exp_x, axis=-1, keepdims=True)
Dies ist der Kern des Transformers. Alles andere baut auf diesem Fundament auf. Beachten Sie, wie die Formel aus ersten Prinzipien entstand: Wir wollten einen differenzierbaren Lookup-Mechanismus, wir wählten Skalarprodukte für Ähnlichkeit, wir skalierten, um Sättigung zu verhindern, und wir verwendeten Softmax, um normalisierte Gewichte zu erhalten.
Multi-Head Attention: Mehrere Perspektiven lernen
Wir haben jetzt einen funktionierenden Attention-Mechanismus, aber es gibt eine Einschränkung. Eine einzelne Attention-Operation kann nur eine Art von Beziehung zwischen Positionen lernen. In der Sprache möchten wir möglicherweise viele verschiedene Arten von Beziehungen gleichzeitig erfassen. Zum Beispiel könnte ein Attention Head sich auf syntaktische Beziehungen konzentrieren (welche Wörter welche anderen modifizieren), ein anderer auf semantische Beziehungen (welche Wörter ähnliche Bedeutungen haben) und wiederum ein anderer auf positionelle Beziehungen (welche Wörter in der Nähe sind).
Dies ist die Motivation für Multi-Head Attention. Anstatt Attention einmal zu berechnen, berechnen wir sie mehrmals parallel mit unterschiedlichen gelernten Projektionen. Jeder „Head“ kann lernen, auf verschiedene Aspekte der Eingabe zu achten.
So funktioniert es mathematisch. Anstatt einzelne Gewichtsmatrizen W_Q, W_K und W_V zu nutzen, verwenden wir h Sätze davon, wobei h der Anzahl der Heads entspricht:
Für Head i:
Q_i = X * W_Q^i
K_i = X * W_K^i
V_i = X * W_V^i
Jeder Head berechnet seine eigene Attention:
head_i = Attention(Q_i, K_i, V_i)
Jetzt erhalten wir h verschiedene Attention-Ausgaben, wobei jede unterschiedliche Beziehungen erfasst. Wir konkatenieren sie und projizieren danach zurück zur ursprünglichen Dimension:
MultiHead(Q, K, V) = Concat(head_1, head_2, ..., head_h) * W_O
Die Konkatenation kombiniert alle verschiedenen Perspektiven, wobei W_O eine gelernte Gewichtsmatrix beinhaltet, die sie auf nützliche Weise mischt.
Es gibt ein wichtiges Implementierungsdetail. Wenn wir d_model als unsere Modelldimension und h Heads besitzen, machen wir typischerweise die Dimension jedes Heads zu d_model / h. Auf diese Weise entsprechen die gesamten Rechenkosten denen der Single-Head Attention, aber wir erhalten den Vorteil mehrerer Perspektiven.
Implementieren wir nun die Multi-Head Attention:
class MultiHeadAttention:
"""
Multi-Head Attention-Mechanismus.
Ermöglicht dem Modell, gemeinsam auf Informationen aus verschiedenen
Repräsentations-Unterräumen an verschiedenen Positionen zu achten.
"""
def __init__(self, d_model, num_heads):
"""
Initialisiert Multi-Head Attention.
Args:
d_model: Dimension des Modells (muss durch num_heads teilbar sein)
num_heads: Anzahl der Attention-Heads
"""
assert d_model % num_heads == 0, "d_model muss durch num_heads teilbar sein"
self.d_model = d_model
self.num_heads = num_heads
self.d_k = d_model // num_heads # Dimension pro Head
# Gewichtsmatrizen für alle Heads kombiniert
# Wir verwenden eine einzelne große Matrix und teilen sie, was effizienter ist
self.W_Q = np.random.randn(d_model, d_model) * 0.01
self.W_K = np.random.randn(d_model, d_model) * 0.01
self.W_V = np.random.randn(d_model, d_model) * 0.01
self.W_O = np.random.randn(d_model, d_model) * 0.01
def split_heads(self, x):
"""
Teilt die letzte Dimension in (num_heads, d_k).
Formt um von (batch_size, seq_len, d_model) zu
(batch_size, num_heads, seq_len, d_k)
"""
batch_size, seq_len, _ = x.shape
# Formt um zu (batch_size, seq_len, num_heads, d_k)
x = x.reshape(batch_size, seq_len, self.num_heads, self.d_k)
# Transponiert zu (batch_size, num_heads, seq_len, d_k)
return x.transpose(0, 2, 1, 3)
def combine_heads(self, x):
"""
Umkehrung von split_heads.
Formt um von (batch_size, num_heads, seq_len, d_k) zu
(batch_size, seq_len, d_model)
"""
batch_size, _, seq_len, _ = x.shape
# Transponiert zu (batch_size, seq_len, num_heads, d_k)
x = x.transpose(0, 2, 1, 3)
# Formt um zu (batch_size, seq_len, d_model)
return x.reshape(batch_size, seq_len, self.d_model)
def forward(self, X):
"""
Berechnet Multi-Head Attention.
Args:
X: Eingabetensor der Form (batch_size, seq_len, d_model)
Returns:
ausgabe: Attention-Ausgabe der Form (batch_size, seq_len, d_model)
"""
batch_size = X.shape[0]
# Lineare Projektionen für alle Heads auf einmal
Q = np.matmul(X, self.W_Q) # (batch_size, seq_len, d_model)
K = np.matmul(X, self.W_K)
V = np.matmul(X, self.W_V)
# Teilt in mehrere Heads
Q = self.split_heads(Q) # (batch_size, num_heads, seq_len, d_k)
K = self.split_heads(K)
V = self.split_heads(V)
# Berechnet Attention für alle Heads parallel
# Scores-Form: (batch_size, num_heads, seq_len, seq_len)
scores = np.matmul(Q, K.transpose(0, 1, 3, 2))
scores = scores / np.sqrt(self.d_k)
# Wendet Softmax an
attention_gewichte = softmax(scores)
# Wendet Attention-Gewichte auf Values an
# Ausgabe-Form: (batch_size, num_heads, seq_len, d_k)
attention_ausgabe = np.matmul(attention_gewichte, V)
# Kombiniert Heads
# Form: (batch_size, seq_len, d_model)
kombiniert = self.combine_heads(attention_ausgabe)
# Finale lineare Projektion
ausgabe = np.matmul(kombiniert, self.W_O)
return ausgabe
Die Schönheit von Multi-Head Attention liegt darin, dass es eine einfache Erweiterung von Single-Head Attention ist, aber die Kapazität des Modells, komplexe Beziehungen zu lernen, dramatisch erhöht. In der Praxis verwenden Transformers typischerweise acht oder sechzehn Heads, was ihnen erlaubt, viele verschiedene Arten von Mustern gleichzeitig zu erfassen.
Positional Encoding: Dem Modell eine Reihenfolge beibringen
Wir stehen nun vor einem kritischen Problem. Unser Attention-Mechanismus ist völlig positionsagnostisch. Wenn Sie die Wörter in einem Satz mischen, erhalten Sie genau die gleiche Attention-Ausgabe. Um zu sehen warum, beachten Sie, dass die Attention-Formel nur vom Inhalt von Q, K und V abhängt, nicht davon, wo sie in der Sequenz erscheinen.
Dies ist tatsächlich ein Feature von Attention: Es erlaubt dem Modell, Beziehungen unabhängig von der Distanz zu betrachten. Aber es birgt auch ein Problem: Wortreihenfolge ist wichtig in der Sprache. "Hund beißt Mann" bedeutet etwas ganz anderes als "Mann beißt Hund."
Wir müssen also Positionsinformationen in unser Modell einfügen. Die Frage lautet: wie? Wir könnten einfach ein gelerntes Positions-Embedding für jede Position hinzufügen, aber dies führt zu einem Problem. Wenn wir auf Sequenzen bis Länge 1000 trainieren, was passiert, wenn wir zur Testzeit auf eine Sequenz der Länge 1001 stoßen? Wir verfügen über kein gelerntes Embedding für Position 1001.
Das Transformer-Paper führt eine clevere Lösung ein: sinusförmige Positional Encodings. Das sind deterministische Funktionen der Position, die mehrere wünschenswerte Eigenschaften haben. Die Codierung für Position pos und Dimension i lautet:
PE(pos, 2i) = sin(pos / 10000^(2i/d_model))
PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))
Entschlüsseln wir, warum diese Formel so gestaltet ist. Erstens stellen wir fest, dass wir verschiedene Frequenzen für verschiedene Dimensionen verwenden. Der Nenner 10000^(2i/d_model) wächst exponentiell mit Dimension i. Das bedeutet, frühe Dimensionen oszillieren schnell (hohe Frequenz), während spätere Dimensionen langsam oszillieren (niedrige Frequenz).
Warum verwenden wir mehrere Frequenzen? Weil es dem Modell erlaubt, leicht zu lernen, nach relativer Position zu achten. Angenommen, Sie möchten wissen, ob zwei Positionen genau drei Schritte voneinander entfernt liegen. Mit mehreren Frequenzen gibt es eine Frequenz, bei der die Phasendifferenz zwischen Positionen, die drei Schritte auseinander liegen, konsistent ist, unabhängig von der absoluten Position.
Die Verwendung sowohl von Sinus als auch Kosinus für alternierende Dimensionen ist ebenfalls bewusst gewählt. Für jede Position pos können wir die Codierung bei Position pos + k (für jeden Offset k) als lineare Funktion der Codierung bei Position pos ausdrücken. Dies liegt an den trigonometrischen Identitäten:
sin(a + b) = sin(a)cos(b) + cos(a)sin(b)
cos(a + b) = cos(a)cos(b) - sin(a)sin(b)
Das bedeutet, das Modell kann lernen, auf relative Positionen durch lineare Transformationen zu achten, was neuronale Netzwerke sehr gut beherrschen.
Implementieren wir also Positional Encoding:
def get_positional_encoding(seq_len, d_model):
"""
Erzeugt sinusförmige Positional Encodings.
Args:
seq_len: Länge der Sequenz
d_model: Dimension des Modells
Returns:
pos_encoding: Positional Encoding-Matrix der Form (seq_len, d_model)
"""
# Erstellt eine Matrix für Positional Encodings
pos_encoding = np.zeros((seq_len, d_model))
# Erstellt Positionsindizes: [0, 1, 2, ..., seq_len-1]
position = np.arange(seq_len).reshape(-1, 1)
# Erstellt Dimensionsindizes: [0, 2, 4, ..., d_model-2]
# Wir verwenden jede zweite Dimension für Sinus und Kosinus
div_term = np.exp(np.arange(0, d_model, 2) *
-(np.log(10000.0) / d_model))
# Wendet Sinus auf gerade Indizes an
pos_encoding[:, 0::2] = np.sin(position * div_term)
# Wendet Kosinus auf ungerade Indizes an
pos_encoding[:, 1::2] = np.cos(position * div_term)
return pos_encoding
Die Addition erfolgt elementweise. Jetzt hat jede Position eine einzigartige Signatur basierend auf ihrer Position, und das Modell kann lernen, diese Information zu verwenden, um Wortreihenfolge zu verstehen.
Um Positional Encoding zu verwenden, addieren wir seinen Wert einfach zu unseren Eingabe-Embeddings:
# Holt Wort-Embeddings aus Embedding-Layer
wort_embeddings = embedding_layer(eingabe_tokens) # Form: (batch, seq_len, d_model)
# Holt Positional Encodings
pos_encodings = get_positional_encoding(seq_len, d_model) # Form: (seq_len, d_model)
# Fügt Positionsinformationen zu Wort-Embeddings hinzu
# Broadcasting behandelt die Batch-Dimension automatisch
eingabe_mit_position = wort_embeddings + pos_encodings
Moderne Varianten von Transformers verwenden manchmal gelernte Positional Embeddings oder relative Positional Encodings, aber der sinusförmige Ansatz bleibt beliebt, weil er auf jede Sequenzlänge ohne erneutes Training generalisiert.
Feed-Forward Networks: Rechentiefe hinzufügen
Attention erlaubt Positionen zu kommunizieren und Informationen zu teilen, aber sie ist grundsätzlich als eine lineare Operation gefolgt von einem Softmax implementiert. Selbst mit mehreren Heads errechnen wir nur gewichtete Durchschnitte. Um dem Modell mehr Fähigkeiten zu verschaffen, brauchen wir Nichtlinearität.
Hier kommt das Feed-Forward Network ins Spiel. Nach Attention wenden wir ein einfaches zweischichtiges neuronales Netzwerk auf jede Position unabhängig an. Die Formel lautet:
FFN(x) = max(0, x * W_1 + b_1) * W_2 + b_2
Die erste Schicht erweitert die Dimension von d_model zu einer größeren Dimension d_ff (typischerweise viermal größer). Diese Erweiterung gibt dem Netzwerk mehr Kapazität zur Berechnung, komplexer Funktionen. Wir wenden eine ReLU-Aktivierung an (der max(0, ...)-Teil), die Nicht-Linearität einführt. Dann projizieren wir zurück auf d_model.
Warum erweitern und dann kontrahieren? Das ist ein Standardmuster in neuronalen Netzwerken, das die Bezeichnung Bottleneck besitzt. Die Erweiterung erlaubt dem Netzwerk, in einem höherdimensionalen Raum zu rechnen, wo es einfacher ist, Features zu trennen und zu transformieren, und die Kontraktion zwingt es, die nützliche Information zurück auf die ursprüngliche Dimension zu transformieren.
Die Schlüsselerkenntnis: Dieses Netzwerk findet unabhängig auf jede Position Anwendung. Anders als Attention, die Informationen über Positionen mischt, verarbeitet das Feed-Forward Network jede Position separat. Diese Arbeitsteilung ist wichtig: Attention behandelt Kommunikation zwischen Positionen, und das Feed-Forward Network behandelt Berechnung innerhalb jeder Position.
Hier ist die Implementierung:
class FeedForwardNetwork:
"""
Positionsweises Feed-Forward Network.
Wendet das gleiche zweischichtige Netzwerk auf jede Position unabhängig an.
"""
def __init__(self, d_model, d_ff):
"""
Initialisiert Feed-Forward Network.
Args:
d_model: Dimension des Modells
d_ff: Dimension der versteckten Schicht (typischerweise 4 * d_model)
"""
self.d_model = d_model
self.d_ff = d_ff
# Erste Schicht erweitert Dimension
self.W_1 = np.random.randn(d_model, d_ff) * np.sqrt(2.0 / d_model)
self.b_1 = np.zeros(d_ff)
# Zweite Schicht kontrahiert zurück zur ursprünglichen Dimension
self.W_2 = np.random.randn(d_ff, d_model) * np.sqrt(2.0 / d_ff)
self.b_2 = np.zeros(d_model)
def forward(self, x):
"""
Wendet Feed-Forward Network an.
Args:
x: Eingabetensor der Form (batch_size, seq_len, d_model)
Returns:
ausgabe: Transformierter Tensor gleicher Form wie Eingabe
"""
# Erste Schicht mit ReLU-Aktivierung
# Form: (batch, seq_len, d_model) -> (batch, seq_len, d_ff)
hidden = np.matmul(x, self.W_1) + self.b_1
hidden = np.maximum(0, hidden) # ReLU-Aktivierung
# Zweite Schicht
# Form: (batch, seq_len, d_ff) -> (batch, seq_len, d_model)
ausgabe = np.matmul(hidden, self.W_2) + self.b_2
return ausgabe
Die ReLU-Aktivierung ist entscheidend. Ohne sie wäre das gesamte Netzwerk eine lineare Transformation, die zu einer einzelnen Matrixmultiplikation kollabiert. Die Nicht-Linearität erlaubt dem Netzwerk, komplexe, nicht lineare Transformationen der Eingabe zu lernen.
In modernen Transformers sehen Sie manchmal andere Aktivierungsfunktionen wie GELU (Gaussian Error Linear Unit), die eine glatte Annäherung von ReLU darstellt. Die Wahl der Aktivierungsfunktion kann sowohl die Trainingsdynamik als auch die finale Leistung beeinflussen, aber das Grundprinzip bleibt gleich: Wir brauchen Nichtlinearität, um die Modellkapazität zu erhöhen.
Layer Normalization: Training stabilisieren
Wenn wir mehrere Transformer-Schichten stapeln, stoßen wir auf ein Trainingsstabilitätsproblem. Die Ausgaben jeder Schicht können in der Praxis unterschiedliche Skalen besitzen, was das Training schwierig macht. Gradienten können explodieren oder verschwinden, und das Modell kann sehr empfindlich auf Initialisierung reagieren.
Layer Normalization löst dieses Problem, indem es die Eingaben zu jeder Schicht normalisiert, um den Mittelwert null und die Varianz eins zu erhalten. Für einen Vektor x berechnet Layer Normalization:
LayerNorm(x) = gamma * ((x - mu) / sqrt(sigma^2 + epsilon)) + beta
Betrachten wir jede Komponente. Zuerst berechnen wir den Mittelwert mu und die Varianz sigma^2 über die Features:
mu = (1/d) * sum(x_i)
sigma^2 = (1/d) * sum((x_i - mu)^2)
Wir subtrahieren den Mittelwert und dividieren durch die Standardabweichung. Das normalisiert die Verteilung auf Mittelwert null und Varianz eins. Das Epsilon (typischerweise 1e-6) kommt zugunsten numerischer Stabilität hinzu, um Divisionen durch Null zu verhindern.
Aber hier kommt der clevere Teil: Wir wenden daraufhin eine gelernte affine Transformation mit Parametern gamma und beta an. Diese lassen sich während des Trainings erlernen und erlauben dem Modell, die Normalisierung bei Bedarf rückgängig zu machen. Warum würden wir Normalisierung rückgängig machen wollen? Weil manchmal die optimale Verteilung nicht Mittelwert null und Einheitsvarianz ist. Indem wir gamma und beta lernbar machen, geben wir dem Modell die Flexibilität, die beste Verteilung für jede Schicht zu lernen.
Der Schlüsselunterschied zwischen Layer Normalization und Batch Normalization (verwendet in CNNs) liegt darin, worüber wir normalisieren.
Batch Normalization normalisiert über die Batch-Dimension, was Abhängigkeiten zwischen Beispielen in einem Batch schafft.
Layer Normalization normalisiert über die Feature-Dimension für jedes Beispiel unabhängig. Das macht es geeigneter für Sequenzmodelle, bei denen Sequenzlängen variieren.
Daraus ergibt sich die Implementierung:
class LayerNormalization:
"""
Layer Normalization.
Normalisiert Eingaben über die Feature-Dimension für jedes Beispiel.
"""
def __init__(self, d_model, epsilon=1e-6):
"""
Initialisiert Layer Normalization.
Args:
d_model: Dimension des Modells
epsilon: Kleine Konstante für numerische Stabilität
"""
self.epsilon = epsilon
# Lernbarer Skalierungsparameter (initialisiert auf 1)
self.gamma = np.ones(d_model)
# Lernbarer Verschiebungsparameter (initialisiert auf 0)
self.beta = np.zeros(d_model)
def forward(self, x):
"""
Wendet Layer Normalization an.
Args:
x: Eingabetensor der Form (batch_size, seq_len, d_model)
Returns:
normalisiert: Normalisierter Tensor gleicher Form wie Eingabe
"""
# Berechnet Mittelwert und Varianz über die Feature-Dimension
# keepdims=True erhält Dimensionen für Broadcasting
mittelwert = np.mean(x, axis=-1, keepdims=True)
varianz = np.var(x, axis=-1, keepdims=True)
# Normalisiert auf Mittelwert null und Einheitsvarianz
x_normalisiert = (x - mittelwert) / np.sqrt(varianz + self.epsilon)
# Wendet gelernte affine Transformation an
# gamma und beta werden über Batch- und Sequenzdimensionen gebroadcastet
ausgabe = self.gamma * x_normalisiert + self.beta
return ausgabe
Layer Normalization findet typischerweise vor oder nach jeder Unterschicht im Transformer Anwendung. Das ursprüngliche Paper wendete es danach an (Post-Norm), aber moderne Implementierungen wenden es oft davor an (Pre-Norm), weil dies tendenziell das Training für sehr tiefe Netzwerke stabilisiert.
Residual Connections: Tiefe Netzwerke ermöglichen
Selbst mit Layer Normalization ist das Training sehr tiefer Netzwerke herausfordernd. Wenn wir mehrere Schichten stapeln, müssen Gradienten während der Backpropagation durch alle Schichten fließen. Jede Schichttransformation kann den Gradienten verzerren, was das Lernen für frühe Schichten schwerer macht.
Residual Connections, eingeführt in ResNet für Computer Vision, bieten eine elegante Lösung. Anstatt eine Transformation F(x) zu lernen, lernen wir eine Residualfunktion F(x), die wir zur Eingabe addieren:
ausgabe = x + F(x)
Diese einfache Addition führt zu tiefgreifenden Effekten. Erstens schafft sie einen direkten Pfad für das Rückwärtsfließen von Gradienten. Während der Backpropagation ist der Gradient der Additionsoperation einfach eins, sodass Gradienten unverändert durch die Residual Connection fließen können. Dies hilft, verschwindende Gradienten in tiefen Netzwerken zu verhindern.
Zweitens vereinfacht es die Optimierung. F(x) = 0 zu lernen (die Identitätsfunktion) ist viel einfacher als eine beliebige Transformation zu lernen, die zufällig nahe an der Identität liegt. Mit Residual Connections kann das Netzwerk von der Identität ausgehen und kleine Verfeinerungen lernen, anstatt die gesamte Transformation von Grund auf lernen zu müssen.
In Transformers verwenden wir Residual Connections sowohl für die Attention- als auch für die Feed-Forward-Unterschichten:
# Nach Multi-Head Attention
x = x + MultiHeadAttention(x)
x = LayerNorm(x)
# Nach Feed-Forward Network
x = x + FeedForwardNetwork(x)
x = LayerNorm(x)
Die Kombination von Residual Connections und Layer Normalization nennt man in der Transformer-Literatur oft "Add & Norm".
Implementieren wir nun eine vollständige Transformer-Schicht:
class TransformerEncoderLayer:
"""
Eine einzelne Transformer-Encoder-Schicht.
Besteht aus Multi-Head Attention und Feed-Forward Network,
jeweils mit Residual Connections und Layer Normalization.
"""
def __init__(self, d_model, num_heads, d_ff, dropout_rate=0.1):
"""
Initialisiert Transformer-Encoder-Schicht.
Args:
d_model: Dimension des Modells
num_heads: Anzahl der Attention-Heads
d_ff: Dimension der Feed-Forward-Hidden-Schicht
dropout_rate: Dropout-Wahrscheinlichkeit für Regularisierung
"""
self.attention = MultiHeadAttention(d_model, num_heads)
self.feed_forward = FeedForwardNetwork(d_model, d_ff)
self.norm1 = LayerNormalization(d_model)
self.norm2 = LayerNormalization(d_model)
self.dropout_rate = dropout_rate
def dropout(self, x, training=True):
"""
Wendet Dropout für Regularisierung an.
Setzt Elemente während des Trainings zufällig auf null.
"""
if not training:
return x
# Erstellt eine Maske aus Zufallswerten
maske = np.random.binomial(1, 1 - self.dropout_rate, x.shape)
# Skaliert mit 1/(1-p), um Erwartungswert zu erhalten
return x * maske / (1 - self.dropout_rate)
def forward(self, x, training=True):
"""
Verarbeitet Eingabe durch die Transformer-Schicht.
Args:
x: Eingabetensor der Form (batch_size, seq_len, d_model)
training: Ob wir im Trainingsmodus sind (beeinflusst Dropout)
Returns:
ausgabe: Transformierter Tensor gleicher Form wie Eingabe
"""
# Multi-Head Attention mit Residual Connection und Normalisierung
# Pre-Norm-Variante: normalisiert vor der Unterschicht
attention_ausgabe = self.attention.forward(self.norm1.forward(x))
attention_ausgabe = self.dropout(attention_ausgabe, training)
x = x + attention_ausgabe # Residual Connection
# Feed-Forward Network mit Residual Connection und Normalisierung
ff_ausgabe = self.feed_forward.forward(self.norm2.forward(x))
ff_ausgabe = self.dropout(ff_ausgabe, training)
x = x + ff_ausgabe # Residual Connection
return x
Beachten Sie, dass wir auch Dropout hinzugefügt haben, das während des Trainings einige Aktivierungen zufällig auf Null setzt. Dies ist eine Regularisierungstechnik, die Überanpassung (Overfitting) verhindert, indem sie das Netzwerk zwingt, redundante Repräsentationen zu lernen.
Die vollständige Transformer-Architektur: Alles zusammenfügen
Wir verfügen jetzt über alle Bausteine, die wir jetzt zu einem vollständigen Transformer kombinieren. Der ursprüngliche Transformer war für Sequenz-zu-Sequenz-Aufgaben wie Übersetzung konzipiert, daher hat er einen Encoder und einen Decoder. Wir konzentrieren uns auf den Encoder, der in Modellen wie BERT verwendet wird, und besprechen kurz den Decoder.
Der Encoder besteht aus einem Stapel identischer Schichten, wobei jede Multi-Head Attention und ein Feed-Forward Network mit Residual Connections und Layer Normalization enthält. Die Eingabe durchläuft eine Embedding-Schicht, bekommt Positional Encodings hinzugefügt und fließt dann durch alle Encoder-Schichten.
Hier ist der vollständige Encoder:
class TransformerEncoder:
"""
Vollständiger Transformer-Encoder.
Stapelt mehrere Encoder-Schichten und behandelt Eingabe-Embedding.
"""
def __init__(self, vocab_size, d_model, num_layers, num_heads,
d_ff, max_seq_len, dropout_rate=0.1):
"""
Initialisiert Transformer-Encoder.
Args:
vocab_size: Größe des Vokabulars
d_model: Dimension des Modells
num_layers: Anzahl der zu stapelnden Encoder-Schichten
num_heads: Anzahl der Attention-Heads in jeder Schicht
d_ff: Dimension der Feed-Forward-Hidden-Schicht
max_seq_len: Maximale Sequenzlänge (für Positional Encoding)
dropout_rate: Dropout-Wahrscheinlichkeit
"""
self.d_model = d_model
self.num_layers = num_layers
# Embedding-Schicht konvertiert Token-IDs zu dichten Vektoren
# Wir initialisieren mit kleinen Zufallswerten
self.embedding = np.random.randn(vocab_size, d_model) * 0.01
# Positional Encoding ist fest (nicht gelernt)
self.pos_encoding = get_positional_encoding(max_seq_len, d_model)
# Stapel von Encoder-Schichten
self.layers = [
TransformerEncoderLayer(d_model, num_heads, d_ff, dropout_rate)
for _ in range(num_layers)
]
# Finale Layer Normalization
self.final_norm = LayerNormalization(d_model)
self.dropout_rate = dropout_rate
def embed_tokens(self, token_ids):
"""
Konvertiert Token-IDs zu Embeddings.
Args:
token_ids: Integer-Array der Form (batch_size, seq_len)
Returns:
embeddings: Dichte Vektoren der Form (batch_size, seq_len, d_model)
"""
# Schlägt Embeddings für jedes Token nach
embeddings = self.embedding[token_ids]
# Skaliert Embeddings mit sqrt(d_model) wie im ursprünglichen Paper
# Dies verhindert, dass Positional Encoding dominiert
embeddings = embeddings * np.sqrt(self.d_model)
return embeddings
def add_positional_encoding(self, embeddings):
"""
Fügt Positional Encodings zu Embeddings hinzu.
Args:
embeddings: Token-Embeddings der Form (batch, seq_len, d_model)
Returns:
embeddings_mit_pos: Embeddings mit hinzugefügter Positionsinfo
"""
seq_len = embeddings.shape[1]
# Fügt Positional Encoding hinzu (Broadcasting behandelt Batch-Dimension)
return embeddings + self.pos_encoding[:seq_len, :]
def forward(self, token_ids, training=True):
"""
Verarbeitet Eingabe-Tokens durch den gesamten Encoder.
Args:
token_ids: Eingabe-Token-IDs der Form (batch_size, seq_len)
training: Ob wir im Trainingsmodus sind
Returns:
ausgabe: Kodierte Repräsentationen der Form (batch, seq_len, d_model)
"""
# Konvertiert Tokens zu Embeddings
x = self.embed_tokens(token_ids)
# Fügt Positionsinformationen hinzu
x = self.add_positional_encoding(x)
# Wendet Dropout auf Embeddings an
if training:
maske = np.random.binomial(1, 1 - self.dropout_rate, x.shape)
x = x * maske / (1 - self.dropout_rate)
# Durchläuft alle Encoder-Schichten
for layer in self.layers:
x = layer.forward(x, training)
# Finale Normalisierung
x = self.final_norm.forward(x)
return x
Der Decoder ähnelt dem Encoder, hat aber eine entscheidende Ergänzung: maskierte Self-Attention. Im Decoder können wir beim Generieren der Ausgabesequenz nur auf Positionen achten, die bereits generiert wurden. Dies erzwingt man durch Hinzufügen einer Maske zu den Attention-Scores vor dem Softmax:
# Erstellt eine Maske, die verhindert, auf zukünftige Positionen zu achten
maske = np.triu(np.ones((seq_len, seq_len)) * -1e9, k=1)
# Fügt Maske zu Attention-Scores vor Softmax hinzu
scores = scores + maske
Die Maske setzt zukünftige Positionen auf negativ unendlich, sodass sie nach Anwendung von Softmax Null erreichen. Das stellt sicher, dass das Modell nur vergangenen Kontext verwenden kann, wenn es jedes Token generiert.
Der Decoder hat auch einen zweiten Attention-Mechanismus namens Cross-Attention, bei dem Queries vom Decoder kommen, aber Keys und Values von der Encoder-Ausgabe. Das erlaubt dem Decoder, auf die Eingabesequenz zu achten, während er die Ausgabe generiert.
Den Transformer trainieren: Loss-Funktionen und Optimierung
Jetzt haben wir eine vollständige Architektur, die wir noch trainieren müssen. Der Trainingsprozess hängt von der Aufgabe ab, aber betrachten wir den häufigsten Fall: Sprachmodellierung, bei der wir das nächste Token bei vorgegebenen vorherigen Tokens vorhersagen.
Für Sprachmodellierung verwenden wir Cross-Entropy-Loss. Gegeben die vorhergesagte Wahrscheinlichkeitsverteilung des Modells über das Vokabular und das wahre nächste Token. Daraus berechnet sich der Verlust (Loss) wie folgt:
Loss = -log(P(wahres_token | kontext))
Warum negative Log-Wahrscheinlichkeit? Weil wir die Wahrscheinlichkeit des korrekten Tokens maximieren wollen, was äquivalent ist zur Minimierung der negativen Log-Wahrscheinlichkeit. Der Logarithmus konvertiert Produkte von Wahrscheinlichkeiten (für eine Sequenz) in Summen, die numerisch einfacher zu handhaben sind.
Für einen Batch von Beispielen mitteln wir den Loss:
Gesamt_Loss = -(1/N) * sum(log(P(wahres_token_i | kontext_i)))
So berechnen wir dies in der Praxis:
def compute_language_modeling_loss(logits, ziel_tokens):
"""
Berechnet Cross-Entropy-Loss für Sprachmodellierung.
Args:
logits: Modellausgaben der Form (batch_size, seq_len, vocab_size)
Dies sind unnormalisierte Scores für jedes Token
ziel_tokens: Wahre nächste Tokens der Form (batch_size, seq_len)
Integer-Indizes ins Vokabular
Returns:
loss: Skalarer Loss-Wert
"""
batch_size, seq_len, vocab_size = logits.shape
# Formt um für einfachere Verarbeitung
logits_flach = logits.reshape(-1, vocab_size) # (batch*seq_len, vocab)
ziele_flach = ziel_tokens.reshape(-1) # (batch*seq_len,)
# Berechnet Softmax-Wahrscheinlichkeiten
# Subtrahiert Maximum für numerische Stabilität
logits_verschoben = logits_flach - np.max(logits_flach, axis=1, keepdims=True)
exp_logits = np.exp(logits_verschoben)
probs = exp_logits / np.sum(exp_logits, axis=1, keepdims=True)
# Holt Wahrscheinlichkeit des korrekten Tokens für jede Position
# Wir verwenden fortgeschrittene Indizierung, um die Wahrscheinlichkeit des Ziel-Tokens auszuwählen
batch_indizes = np.arange(batch_size * seq_len)
korrekte_probs = probs[batch_indizes, ziele_flach]
# Berechnet negative Log-Likelihood
# Fügt kleines Epsilon hinzu, um log(0) zu verhindern
loss = -np.mean(np.log(korrekte_probs + 1e-10))
return loss
Um das Modell zu optimieren, verwenden wir Gradientenabstieg. Das ursprüngliche Transformer-Paper verwendete den Adam-Optimizer mit einem spezifischen Lernraten-Schedule. Die Lernrate steigt linear für die ersten warmup_steps, dann sinkt sie proportional zur inversen Quadratwurzel der Schrittnummer:
lernrate = (d_model^(-0.5)) * min(schritt^(-0.5), schritt * warmup_steps^(-1.5))
Dieser Prozess besitzt eine Warm-up-Phase, in der die Lernrate allmählich steigt, was hilft, das Training in den frühen Phasen zu stabilisieren, wenn die Parameter des Modells zufällig sind. Nach dem Warm-up sinkt die Lernrate allmählich, was dem Modell erlaubt, seine Parameter feinzujustieren.
Warum dieser spezifische Prozess? Das Warm-up verhindert, dass das Modell große Updates basierend auf den anfänglichen zufälligen Gradienten macht, die es in eine schlechte Region des Parameterraums schieben könnten. Der Verfall erlaubt dem Modell, kleinere Anpassungen zu machen, wenn es konvergiert, was Oszillation um das Optimum verhindert.
Hier ist eine vereinfachte Trainingsschleife:
def train_transformer(modell, train_daten, num_epochen, warmup_schritte):
"""
Trainiert ein Transformer-Modell.
Args:
modell: TransformerEncoder-Instanz
train_daten: Liste von (eingabe_tokens, ziel_tokens)-Paaren
num_epochen: Anzahl der Durchläufe durch die Trainingsdaten
warmup_schritte: Anzahl der Schritte für Lernraten-Warmup
"""
schritt = 0
d_model = modell.d_model
for epoche in range(num_epochen):
epochen_loss = 0
for eingabe_tokens, ziel_tokens in train_daten:
schritt += 1
# Berechnet Lernrate mit Warmup
lr = (d_model ** -0.5) * min(
schritt ** -0.5,
schritt * (warmup_schritte ** -1.5)
)
# Forward-Pass
ausgabe = modell.forward(eingabe_tokens, training=True)
# Berechnet Loss
# Wir müssen eine lineare Schicht hinzufügen, um auf Vokabulargröße zu projizieren
logits = np.matmul(ausgabe, modell.embedding.T) # Weight Sharing
loss = compute_language_modeling_loss(logits, ziel_tokens)
epochen_loss += loss
# Backward-Pass (berechnet Gradienten)
# In der Praxis würde dies automatische Differentiation verwenden
gradienten = compute_gradients(loss, modell)
# Aktualisiert Parameter mit Adam-Optimizer
update_parameters_adam(modell, gradienten, lr, schritt)
if schritt % 100 == 0:
print(f"Schritt {schritt}, Loss: {loss:.4f}, LR: {lr:.6f}")
avg_loss = epochen_loss / len(train_daten)
print(f"Epoche {epoche + 1}, Durchschnittlicher Loss: {avg_loss:.4f}")
In der Praxis würden wir ein Framework wie PyTorch oder TensorFlow verwenden, das die Gradientenberechnung automatisch behandelt. Die Schlüsselerkenntnis: Der gesamte Transformer ist differenzierbar, sodass wir Backpropagation verwenden können, um Gradienten zu berechnen und alle Parameter Ende-zu-Ende zu aktualisieren.
Fortgeschrittenes Thema: Mixture of Experts
Wenn wir Transformers auf Milliarden oder Billionen von Parametern skalieren, stehen wir vor einer rechnerischen Herausforderung. Größere Modelle sind leistungsfähiger, aber sie sind auch viel langsamer und teurer zu betreiben. Mixture of Experts (MoE) bietet eine Lösung: Wir können ein riesiges Modell einsetzen, aber nur einen kleinen Teil davon für jede Eingabe verwenden.
Die Kernidee besteht darin, das Feed-Forward Network in jeder Transformer-Schicht durch mehrere Experten-Netzwerke und einen Gating-Mechanismus zu ersetzen, der entscheidet, welche Experten für jedes Token verwendet werden. Anstatt …
ausgabe = FeedForward(x)
… erhalten wir:
gate_scores = Softmax(x * W_gate)
ausgabe = sum(gate_scores[i] * Expert_i(x) for i in top_k(gate_scores))
Analysieren wir, was hier genau passiert. Zuerst berechnen wir Gate-Scores für jeden Experten mit einer gelernten Gewichtsmatrix W_gate. Diese Scores sagen uns, wie relevant jeder Experte für die aktuelle Eingabe ist. Wir wenden Softmax an, um sie in Wahrscheinlichkeiten zu konvertieren.
Dann wählen wir, anstatt alle Experten zu verwenden, nur die Top-k-Experten mit den höchsten Gate-Scores aus. Dies nennt man Sparse Routing. Für jeden ausgewählten Experten berechnen wir seine Ausgabe und gewichten sie mit dem Gate-Score. Die finale Ausgabe ist die gewichtete Summe der Ausgaben der ausgewählten Experten.
Warum funktioniert dies? Verschiedene Experten können sich auf verschiedene Arten von Eingaben spezialisieren. Zum Beispiel könnte sich in einem Sprachmodell ein Experte auf technischen Text spezialisieren, ein anderer auf Konversationstext und ein anderer auf formales Schreiben. Das Gating-Netzwerk lernt, jede Eingabe zu den am besten geeigneten Experten zu routen.
Der Schlüsselvorteil ist rechnerische Effizienz. Wenn wir 128 Experten haben, aber nur die Top 2 für jedes Token verwenden, führen wir ungefähr die gleiche Berechnung aus wie ein regulärer Transformer mit zwei Feed-Forward Networks, aber wir erhalten die Kapazität von 128 Netzwerken. Dies erlaubt uns, auf viel größere Modelle zu skalieren, was zu folgender Implementierung führt:
class MixtureOfExpertsLayer:
"""
Mixture of Experts-Schicht.
Routet jedes Token zu einer Teilmenge von Experten-Netzwerken basierend auf gelerntem Gating.
"""
def __init__(self, d_model, d_ff, num_experts, top_k):
"""
Initialisiert MoE-Schicht.
Args:
d_model: Modelldimension
d_ff: Experten-Hidden-Dimension
num_experts: Gesamtanzahl der Experten-Netzwerke
top_k: Anzahl der zu verwendenden Experten pro Token
"""
self.d_model = d_model
self.num_experts = num_experts
self.top_k = top_k
# Gating-Netzwerk, das entscheidet, welche Experten zu verwenden sind
self.W_gate = np.random.randn(d_model, num_experts) * 0.01
# Erstellt mehrere Experten-Netzwerke
self.experts = [
FeedForwardNetwork(d_model, d_ff)
for _ in range(num_experts)
]
def forward(self, x):
"""
Routet Eingaben durch Mixture of Experts.
Args:
x: Eingabe der Form (batch_size, seq_len, d_model)
Returns:
ausgabe: Verarbeitete Eingabe gleicher Form
"""
batch_size, seq_len, d_model = x.shape
# Flacht Batch- und Sequenzdimensionen für Verarbeitung ab
x_flach = x.reshape(-1, d_model) # (batch*seq_len, d_model)
# Berechnet Gate-Scores für alle Experten
gate_logits = np.matmul(x_flach, self.W_gate) # (batch*seq_len, num_experts)
gate_scores = softmax(gate_logits)
# Wählt Top-k-Experten für jedes Token aus
# Holt Indizes der Top-k-Experten
top_k_indizes = np.argsort(gate_scores, axis=1)[:, -self.top_k:]
# Holt die Gate-Scores für ausgewählte Experten
batch_indizes = np.arange(x_flach.shape[0])[:, None]
top_k_gates = gate_scores[batch_indizes, top_k_indizes]
# Normalisiert Gate-Scores der ausgewählten Experten auf Summe 1
top_k_gates = top_k_gates / np.sum(top_k_gates, axis=1, keepdims=True)
# Berechnet Ausgabe als gewichtete Kombination von Expertenausgaben
ausgabe_flach = np.zeros_like(x_flach)
for i in range(self.top_k):
# Holt den Expertenindex für jedes Token
experten_indizes = top_k_indizes[:, i]
# Verarbeitet jedes Token mit seinem ausgewählten Experten
for token_idx in range(x_flach.shape[0]):
experten_idx = experten_indizes[token_idx]
experten_ausgabe = self.experts[experten_idx].forward(
x_flach[token_idx:token_idx+1]
)
gate_gewicht = top_k_gates[token_idx, i]
ausgabe_flach[token_idx] += gate_gewicht * experten_ausgabe[0]
# Formt zurück zu ursprünglichen Dimensionen
ausgabe = ausgabe_flach.reshape(batch_size, seq_len, d_model)
return ausgabe
Es gibt eine subtile Herausforderung beim Training von MoE-Modellen: Load Balancing. Wenn das Gating-Netzwerk immer zu den gleichen wenigen Experten routet, bleiben die meisten Experten untrainiert, wodurch wir die Vorteile vieler Experten verlieren. Um dies anzugehen, fügen wir einen „Hilfs-Loss“ hinzu, der ausgewogenes Routing fördert:
load_balance_loss = num_experts * sum(f_i * P_i)
Hier ist f_i der Anteil der Tokens, die das Gate zu Experte i umleitet, und P_i ist die durchschnittliche Gate-Wahrscheinlichkeit für Experte i. Dieser Loss minimiert sich, wenn alle Experten gleichzeitige Verwendung finden. Wir fügen dies zum Haupt-Loss mit einem kleinen Gewicht hinzu, um ausgewogenes Routing zu fördern, ohne das Hauptziel zu überschreiben.
Moderne MoE-Modelle wie GPT und Gemini verwenden diese Architektur, um massive Skalierung zu erreichen, während die Inferenzkosten handhabbar bleiben. Die Schlüsselerkenntnis lautet: Nicht jeder Parameter muss für jede Eingabe aktiv sein, was uns erlaubt, viel größere Modelle als sonst machbar zu bauen.
Fortgeschrittenes Thema: Reasoning-Modelle und Chain-of-Thought
Jüngste Fortschritte bei Sprachmodellen konzentrierten sich auf die Verbesserung ihrer Reasoning-Fähigkeiten. Die Schlüsselerkenntnis: Komplexes Reasoning erfordert oft Zwischenschritte. Wenn Menschen ein schwieriges Problem lösen, springen sie nicht direkt zur Antwort, sondern erarbeiten sie Schritt für Schritt.
Chain-of-Thought (CoT) Prompting ermutigt Modelle, diese Zwischenschritte zu generieren. Anstatt nach "Was ist 47 mal 23?" zu fragen und die Antwort direkt zu erwarten, fordern wir das Modell auf, seine Arbeit zu zeigen:
Frage: Was ist 47 mal 23?
Lösen wir dies Schritt für Schritt:
47 * 23 = 47 * 20 + 47 * 3
= 940 + 141
= 1081
Diese einfache Änderung verbessert die Leistung bei Reasoning-Aufgaben dramatisch. Aber warum funktioniert dies mathematisch? Der Schlüssel: Transformers haben begrenzte Rechentiefe pro Token. Jede Schicht kann nur eine feste Menge an Berechnung durchführen. Durch Generierung von Zwischenschritten geben wir dem Modell effektiv mehr Rechenschritte, um das Problem zu lösen.
Wenn das Modell 24 Schichten hat und 10 Token des Reasonings generiert, bekommt es 24 mal 10 gleich 240 Schichten an Berechnung, versus nur 24 Schichten, wenn es versucht, direkt zu antworten. Deshalb führen längere Reasoning-Ketten oft zu besseren Antworten.
Jüngste Modelle wie OpenAIs Reasoning-Sprachmodelle gehen noch weiter und arbeiten mit gelerntem Reasoning. Anstatt sich auf Prompting zu verlassen, trainieren sie, Reasoning-Schritte automatisch zu generieren.
Der Trainingsprozess enthält folgende Schritte:
- generieren wir viele mögliche Reasoning-Ketten für jedes Problem mit Techniken wie Tree Search oder Sampling. Einige Ketten führen zu korrekten Antworten, andere zu inkorrekten.
- verwenden wir Reinforcement Learning, um das Modell zu trainieren, Reasoning-Ketten zu bevorzugen, die zu korrekten Antworten führen. Das Belohnungssignal ist binär: Stimmte die finale Antwort mit der Ground Truth überein? Das mathematische Schlüsselframework ist Policy Gradient Reinforcement Learning. Wir behandeln das Modell als Policy, die eine Sequenz von Reasoning-Tokens generiert. Das Ziel lautet, die erwartete Belohnung zu maximieren:
J(theta) = E[R(y) | x, theta.Hier repräsentiertthetadie Modellparameter,xist das Eingabeproblem,yist die generierte Reasoning-Kette und Antwort, undR(y)ist die Belohnung (1für korrekt,0für inkorrekt). Wir berechnen den Gradienten:gradient J(theta) = E[R(y) * gradient log P(y | x, theta)]
Das illustriert, wie wir die Parameter anpassen, um die Wahrscheinlichkeit von hochwertigen Reasoning-Ketten zu erhöhen. In der Praxis schätzen wir diesen Erwartungswert, indem wir mehrere Reasoning-Ketten sampeln und ihre Belohnungen berechnen.
Die Herausforderung: Die meisten Reasoning-Ketten sind inkorrekt, sodass das Belohnungssignal spärlich ist. Um dies anzugehen, verwenden wir mehrere Techniken:
- Wir verwenden Wertfunktionen, um die erwartete Belohnung partieller Reasoning-Ketten zu schätzen, was uns erlaubt, die Suche in vielversprechende Richtungen zu lenken.
- Wir verwenden Curriculum Learning, beginnend mit einfacheren Problemen und allmählich steigender Schwierigkeit.
- Wir verwenden Prozessbelohnungen, die Kredite für korrekte Zwischenschritte verteilen, selbst wenn die finale Antwort falsch ist. Das liefert dichteres Feedback.
Hier ist eine vereinfachte Implementierung der Trainingsschleife:
def train_reasoning_model(modell, probleme, num_iterationen):
"""
Trainiert ein Modell, Reasoning-Ketten mit Reinforcement Learning zu generieren.
Args:
modell: Transformer-Modell
probleme: Liste von (problem, korrekte_antwort)-Paaren
num_iterationen: Anzahl der Trainingsiterationen
"""
for iteration in range(num_iterationen):
gesamt_belohnung = 0
for problem, korrekte_antwort in probleme:
# Sampelt mehrere Reasoning-Ketten
num_samples = 4
ketten = []
belohnungen = []
for _ in range(num_samples):
# Generiert eine Reasoning-Kette
reasoning_kette = modell.generate(
problem,
max_length=200,
temperature=0.8 # Etwas Zufälligkeit für Exploration
)
# Extrahiert die finale Antwort aus der Kette
finale_antwort = extract_answer(reasoning_kette)
# Berechnet Belohnung
belohnung = 1.0 if finale_antwort == korrekte_antwort else 0.0
ketten.append(reasoning_kette)
belohnungen.append(belohnung)
gesamt_belohnung += belohnung
# Berechnet Baseline (durchschnittliche Belohnung) für Varianzreduktion
baseline = np.mean(belohnungen)
# Aktualisiert Modell, um Wahrscheinlichkeit hochbelohnter Ketten zu erhöhen
for kette, belohnung in zip(ketten, belohnungen):
# Berechnet Advantage (Belohnung minus Baseline)
advantage = belohnung - baseline
# Berechnet Log-Wahrscheinlichkeit der Kette
log_prob = modell.compute_log_probability(problem, kette)
# Policy Gradient: erhöht Log-Prob proportional zum Advantage
loss = -advantage * log_prob
# Berechnet Gradienten und aktualisiert Parameter
gradienten = compute_gradients(loss, modell)
update_parameters(modell, gradienten, learning_rate=1e-5)
avg_belohnung = gesamt_belohnung / (len(probleme) * num_samples)
print(f"Iteration {iteration}, Durchschnittliche Belohnung: {avg_belohnung:.3f}")
Die Schlüsselerkenntnis: Wir trainieren das Modell nicht nur darauf, das nächste Token basierend auf überwachten Daten vorherzusagen. Wir trainieren es, verschiedene Reasoning-Strategien zu erkunden und diejenigen zu verstärken, die zu korrekten Antworten führen. Dies erlaubt dem Modell, Reasoning-Muster zu entdecken, die möglicherweise nicht in den Trainingsdaten vorhanden sind.
Eine wichtige Überlegung: Längere Reasoning-Ketten kosten mehr Inferenzzeit. Jedes generierte Token erfordert einen Forward-Pass durch das gesamte Modell. Dies schafft einen Trade-off: Längeres Reasoning verbessert die Genauigkeit, erhöht aber Latenz und Kosten. In der Praxis können wir daher eine adaptive Berechnung verwenden, bei der das Modell lernt, längere Ketten nur für schwierige Probleme zu generieren.
Fortgeschrittenes Thema: Hochwertige Trainingsdaten erstellen
Die Qualität eines Transformer-Modells ist fundamental durch die Qualität seiner Trainingsdaten begrenzt. Das Sprichwort der Informatik besagt: Garbage in, garbage out. Die Erstellung hochwertiger Trainingsdaten im großen Maßstab ist einer der wichtigsten und herausforderndsten Aspekte beim Bau moderner KI-Systeme.
Für Sprachmodelle beginnen wir typischerweise mit Webtextdaten. Allerdings hat unbearbeiteter Webtext viele Probleme. Er enthält faktische Fehler, toxische Inhalte, Spam, Duplikate und schlechten Schreibstil.
Die Datenerstellungspipeline beinhaltet mehrere Stufen der Filterung und Verarbeitung:
Die erste Stufe ist Deduplizierung. Web-Crawls enthalten massive Mengen an doppelten oder nahezu doppelten Inhalten. Training auf Duplikaten verschwendet Berechnung und kann dazu führen, dass das Modell spezifische Beispiele auswendig lernt, anstatt allgemeine Muster zu lernen.
Wir verwenden Techniken wie MinHash, um effizient Duplikate beziehungsweise fast identische Trainingsinhalte zu finden:
def compute_minhash_signature(text, num_hashes=128):
"""
Berechnet MinHash-Signatur für approximative Duplikaterkennung.
Args:
text: Eingabetextstring
num_hashes: Anzahl der zu verwendenden Hash-Funktionen
Returns:
signatur: Array von minimalen Hash-Werten
"""
# Tokenisiert Text in Shingles (überlappende n-Gramme)
# Wir verwenden Zeichen-Level-5-Gramme für Robustheit
shingles = set()
for i in range(len(text) - 4):
shingle = text[i:i+5]
shingles.add(shingle)
# Initialisiert Signatur mit Unendlich
signatur = np.full(num_hashes, np.inf)
# Für jedes Shingle berechnet mehrere Hash-Werte
for shingle in shingles:
# Konvertiert Shingle zu Bytes für Hashing
shingle_bytes = shingle.encode('utf-8')
for i in range(num_hashes):
# Verwendet verschiedene Hash-Funktionen durch Hinzufügen von Salt
hash_wert = hash((shingle_bytes, i))
# Behält minimalen Hash-Wert für jede Hash-Funktion
signatur[i] = min(signatur[i], hash_wert)
return signatur
def estimate_jaccard_similarity(sig1, sig2):
"""
Schätzt Jaccard-Ähnlichkeit aus MinHash-Signaturen.
Der Anteil übereinstimmender Hash-Werte approximiert die Jaccard-
Ähnlichkeit der ursprünglichen Shingle-Mengen.
"""
uebereinstimmungen = np.sum(sig1 == sig2)
return uebereinstimmungen / len(sig1)
Die mathematische Grundlage von MinHash ist elegant. Die Jaccard-Ähnlichkeit zwischen zwei Mengen A und B definiert sich als:
J(A, B) = |A Schnittmenge B| / |A Vereinigung B|
MinHash liefert eine unverzerrte Schätzung dieser Ähnlichkeit. Die Wahrscheinlichkeit, dass der minimale Hash-Wert für zwei Mengen gleich ist, entspricht ihrer Jaccard-Ähnlichkeit. Durch Verwendung mehrerer Hash-Funktionen können wir die Ähnlichkeit mit hoher Genauigkeit schätzen.
Nach der Deduplizierung wenden wir Qualitätsfilterung an. Hier gestalten sich die Dinge aus maschineller Lernperspektive interessant. Wir können nicht manuell Milliarden von Dokumenten überprüfen, also brauchen wir automatisierte Qualitätsbewertung. Ein Ansatz verfolgt die Strategie, einen Klassifikator zu trainieren, um Qualität vorherzusagen:
def train_quality_classifier(hochwertige_beispiele, minderwertige_beispiele):
"""
Trainiert einen Klassifikator, um hochwertigen von minderwertigem Text zu unterscheiden.
Args:
hochwertige_beispiele: Liste hochwertiger Dokumente
minderwertige_beispiele: Liste minderwertiger Dokumente
Returns:
klassifikator: Trainiertes Modell, das Qualitätswerte vorhersagt
"""
# Extrahiert Features aus Text
def extract_features(text):
features = {}
# Längen-Features
features['num_woerter'] = len(text.split())
features['num_saetze'] = text.count('.') + text.count('!') + text.count('?')
features['avg_wort_laenge'] = np.mean([len(w) for w in text.split()])
# Vokabular-Diversität (Type-Token-Ratio)
woerter = text.lower().split()
features['vokabular_diversitaet'] = len(set(woerter)) / max(len(woerter), 1)
# Interpunktion und Formatierung
features['interpunktions_ratio'] = sum(c in '.,!?;:' for c in text) / max(len(text), 1)
features['grossschreibungs_ratio'] = sum(c.isupper() for c in text) / max(len(text), 1)
# Lesbarkeit (vereinfachtes Flesch-Kincaid)
avg_satz_laenge = features['num_woerter'] / max(features['num_saetze'], 1)
features['lesbarkeit'] = avg_satz_laenge * features['avg_wort_laenge']
return np.array(list(features.values()))
# Bereitet Trainingsdaten vor
X_train = []
y_train = []
for text in hochwertige_beispiele:
X_train.append(extract_features(text))
y_train.append(1) # Hohe Qualität
for text in minderwertige_beispiele:
X_train.append(extract_features(text))
y_train.append(0) # Niedrige Qualität
X_train = np.array(X_train)
y_train = np.array(y_train)
# Trainiert einen einfachen logistischen Regressions-Klassifikator
# In der Praxis könnten wir ein ausgefeilteres Modell verwenden
klassifikator = train_logistic_regression(X_train, y_train)
return klassifikator
Die Herausforderung besteht darin, gelabelte Beispiele für hochwertigen und minderwertigen Text zu erhalten. Ein Ansatz könnte sein, Proxy-Signale zu verwenden. Wir könnten etwa Text aus seriösen Quellen wie Wikipedia oder akademischen Papers als hochwertig betrachten und Text von Spam-Seiten als minderwertig. Wir können auch Engagement-Signale verwenden: Dokumente, mit denen Benutzer mehr Zeit verbringen, könnten höherwertig sein.
Ein weiterer entscheidender Aspekt der Datenerstellung ist Diversität. Wenn alle unsere Trainingsdaten aus ähnlichen Quellen stammen, hat das Modell blinde Flecken. Wir wollen aber Daten, die viele Themen, Schreibstile und Perspektiven abdecken. Diese Diversität können wir mit Topic Modeling messen:
def measure_topic_diversity(dokumente, num_topics=100):
"""
Misst die Diversität der Themen in einer Dokumentensammlung.
Verwendet Latent Dirichlet Allocation (LDA), um Themen zu entdecken
und misst, wie gleichmäßig Dokumente über Themen verteilt sind.
"""
# Erstellt Vokabular und Dokument-Term-Matrix
vokabular = build_vocabulary(dokumente)
dok_term_matrix = create_doc_term_matrix(dokumente, vokabular)
# Führt LDA aus, um Themen zu entdecken
# Jedes Thema ist eine Verteilung über Wörter
# Jedes Dokument ist eine Verteilung über Themen
topic_verteilungen = run_lda(dok_term_matrix, num_topics)
# Berechnet Entropie der Topic-Verteilung
# Hohe Entropie bedeutet, Dokumente sind über viele Themen verteilt
topic_zaehler = np.sum(topic_verteilungen > 0.1, axis=0)
topic_probs = topic_zaehler / np.sum(topic_zaehler)
# Shannon-Entropie misst Diversität
entropie = -np.sum(topic_probs * np.log(topic_probs + 1e-10))
max_entropie = np.log(num_topics)
# Normalisiert auf 0-1-Bereich
diversitaets_score = entropie / max_entropie
return diversitaets_score
Für Instruction-Tuning-Daten, bei denen Modelle lernen Anweisungen zu folgen, ist der Erstellungsprozess noch ausgefeilter. Wir brauchen Beispiele von Anweisungen, gepaart mit hochwertigen Antworten.
Es gibt mehrere Ansätze:
Menschliche Annotation ist der Goldstandard, aber teuer.
- Wir stellen menschliche Annotatoren ein, um Anweisungen und Antworten zu schreiben oder modellgenerierte Antworten zu bewerten. Die Herausforderung besteht darin, Konsistenz über Annotatoren hinweg sicherzustellen.
- Wir verwenden detaillierte Richtlinien und regelmäßige Kalibrierungssitzungen.
Synthetische Datengenerierung verwendet existierende Modelle, um Trainingsdaten für neue Modelle zu erstellen. Das scheint zirkulär, aber es funktioniert, wenn wir dies sorgfältig durchführen. Wir könnten etwa ein großes Modell verwenden, um diverse Anweisungen zu generieren und lassen dann Menschen Antworten schreiben oder umgekehrt.
Der Schlüssel: Menschliche Beteiligung stellt Qualität an kritischen Punkten sicher.
Destillation beinhaltet die Verwendung eines großen, leistungsstarken Modells, um Trainingsdaten für ein kleineres Modell zu erstellen. Wir generieren viele Antworten vom großen Modell, filtern nach Qualität und verwenden sie, um das kleinere Modell zu trainieren. Das mathematische Framework dazu nennt sich Knowledge Distillation:
def distillation_loss(schueler_logits, lehrer_logits, temperatur):
"""
Berechnet Destillations-Loss, der Wissen vom Lehrer zum Schüler überträgt.
Args:
schueler_logits: Unnormalisierte Vorhersagen vom Schüler-Modell
lehrer_logits: Unnormalisierte Vorhersagen vom Lehrer-Modell
temperatur: Softmax-Temperatur für Glättung der Verteilungen
Returns:
loss: Destillations-Loss-Wert
"""
# Wendet Temperaturskalierung an, um die Verteilungen zu glätten
# Höhere Temperatur macht Verteilungen gleichmäßiger
schueler_probs = softmax(schueler_logits / temperatur)
lehrer_probs = softmax(lehrer_logits / temperatur)
# Berechnet KL-Divergenz zwischen Verteilungen
# Dies misst, wie unterschiedlich die Vorhersagen des Schülers vom Lehrer sind
kl_div = np.sum(lehrer_probs * np.log(lehrer_probs / (schueler_probs + 1e-10)))
# Skaliert mit Temperatur zum Quadrat (aus der Ableitung des Softmax)
loss = kl_div * (temperatur ** 2)
return loss
Der Temperaturparameter ist entscheidend. Mit Temperatur gleich eins erhalten wir das normale Softmax. Mit höheren Temperaturen erweist sich die Verteilung als gleichmäßiger, was mehr Informationen über die Unsicherheit des Trainers offenbart. Der Schüler lernt nicht nur die wahrscheinlichste Antwort, sondern die vollständige Wahrscheinlichkeitsverteilung des Teacher für die Antworten.
Für Reinforcement Learning from Human Feedback (RLHF), das Informatiker verwenden, um Modelle mit menschlichen Präferenzen auszurichten, brauchen wir Präferenzdaten. Menschen vergleichen mehrere Modellausgaben und geben an, welche besser ist. Daraus entsteht ein Datensatz von Vergleichen statt absoluter Labels.
Wir trainieren ein Belohnungsmodell, um menschliche Präferenzen vorherzusagen:
def train_reward_model(vergleiche):
"""
Trainiert ein Modell, vorherzusagen, welche Antwort Menschen bevorzugen.
Args:
vergleiche: Liste von (prompt, antwort_a, antwort_b, praeferenz)-Tupeln
wobei praeferenz 0 ist, wenn A bevorzugt wird, 1 wenn B bevorzugt wird
"""
# Das Belohnungsmodell nimmt einen Prompt und eine Antwort und gibt einen skalaren Score aus
belohnungsmodell = create_reward_model()
for prompt, antwort_a, antwort_b, praeferenz in vergleiche:
# Holt Belohnungs-Scores für beide Antworten
score_a = belohnungsmodell.forward(prompt, antwort_a)
score_b = belohnungsmodell.forward(prompt, antwort_b)
# Berechnet Wahrscheinlichkeit, dass A bevorzugt wird, mit Bradley-Terry-Modell
# Dies nimmt an, dass Präferenzen einer logistischen Verteilung folgen
prob_a_bevorzugt = 1 / (1 + np.exp(score_b - score_a))
# Berechnet Loss basierend auf tatsächlicher Präferenz
if praeferenz == 0: # A wird bevorzugt
loss = -np.log(prob_a_bevorzugt + 1e-10)
else: # B wird bevorzugt
loss = -np.log(1 - prob_a_bevorzugt + 1e-10)
# Aktualisiert Belohnungsmodell-Parameter
gradienten = compute_gradients(loss, belohnungsmodell)
update_parameters(belohnungsmodell, gradienten)
return belohnungsmodell
Das Bradley-Terry-Modell ist ein klassisches Modell aus der Psychometrie, das Folgendes annimmt: Wenn Item A den Qualitätsscore r_A hat und Item B den Qualitätsscore r_B, ist die Wahrscheinlichkeit einer Bevorzugung von A folgendermaßen:
P(A bevorzugt über B) = exp(r_A) / (exp(r_A) + exp(r_B)) = 1 / (1 + exp(r_B - r_A))
Das stellt uns eine prinzipielle Methode zur Verfügung, um paarweise Vergleiche in skalare Belohnungs-Scores zu konvertieren, die wir für Reinforcement Learning verwenden können.
Die finale Stufe der Datenerstellung ist oft die wichtigste: menschliche Überprüfung und Iteration. Wir sampeln aus unserem gefilterten Datensatz, überprüfen ihn manuell, identifizieren verbleibende Probleme und aktualisieren unsere Filterkriterien. Dieser iterative Prozess verbessert allmählich die Datenqualität. Die Schlüsselerkenntnis: Datenerstellung ist kein einmaliger Prozess, sondern eine fortlaufende Anstrengung, die die Modellqualität direkt beeinflusst.
Alles zusammenfügen: Einen vollständigen Transformer implementieren
Jetzt, da wir alle Komponenten verstehen, implementieren wir einen vollständigen, funktionierenden Transformer, den Sie tatsächlich trainieren könnten. Wir erstellen eine vereinfachte, aber funktionale Version, die alle Schlüsselkonzepte demonstriert:
class Transformer:
"""
Vollständiges Transformer-Modell für Sprachmodellierung.
Kombiniert alle Komponenten: Embeddings, Positional Encoding,
Encoder-Schichten und Ausgabeprojektion.
"""
def __init__(self, vocab_size, d_model=512, num_layers=6,
num_heads=8, d_ff=2048, max_seq_len=512,
dropout_rate=0.1):
"""
Initialisiert vollständigen Transformer.
Args:
vocab_size: Größe des Vokabulars
d_model: Modelldimension (muss durch num_heads teilbar sein)
num_layers: Anzahl der Transformer-Schichten
num_heads: Anzahl der Attention-Heads pro Schicht
d_ff: Feed-Forward-Hidden-Dimension
max_seq_len: Maximale Sequenzlänge
dropout_rate: Dropout-Wahrscheinlichkeit
"""
self.vocab_size = vocab_size
self.d_model = d_model
self.max_seq_len = max_seq_len
# Token-Embedding-Schicht
# Bildet Token-IDs auf dichte Vektoren ab
self.token_embedding = np.random.randn(vocab_size, d_model) * 0.02
# Positional Encoding (fest, nicht gelernt)
self.positional_encoding = get_positional_encoding(max_seq_len, d_model)
# Stapel von Transformer-Encoder-Schichten
self.encoder_layers = [
TransformerEncoderLayer(d_model, num_heads, d_ff, dropout_rate)
for _ in range(num_layers)
]
# Finale Layer Normalization
self.output_norm = LayerNormalization(d_model)
# Ausgabeprojektion auf Vokabular
# Wir verwenden Weight Tying: teilen Gewichte mit Embedding-Schicht
# Dies reduziert Parameter und verbessert oft die Leistung
self.output_projection = self.token_embedding.T
self.dropout_rate = dropout_rate
def encode(self, token_ids, training=True):
"""
Kodiert Eingabe-Tokens zu kontextualisierten Repräsentationen.
Args:
token_ids: Integer-Array der Form (batch_size, seq_len)
training: Ob im Trainingsmodus (beeinflusst Dropout)
Returns:
kodiert: Repräsentationen der Form (batch_size, seq_len, d_model)
"""
batch_size, seq_len = token_ids.shape
# Embeddet Tokens
# Schlägt Embedding für jede Token-ID nach
embedded = self.token_embedding[token_ids]
# Skaliert Embeddings mit sqrt(d_model)
# Dies verhindert, dass Positional Encodings dominieren
embedded = embedded * np.sqrt(self.d_model)
# Fügt Positional Encodings hinzu
# Broadcasting behandelt Batch-Dimension
pos_enc = self.positional_encoding[:seq_len, :]
x = embedded + pos_enc
# Wendet Dropout auf Embeddings an
if training:
maske = np.random.binomial(1, 1 - self.dropout_rate, x.shape)
x = x * maske / (1 - self.dropout_rate)
# Durchläuft Encoder-Schichten
for layer in self.encoder_layers:
x = layer.forward(x, training)
# Finale Normalisierung
x = self.output_norm.forward(x)
return x
def forward(self, token_ids, training=True):
"""
Vollständiger Forward-Pass: kodiert und projiziert auf Vokabular.
Args:
token_ids: Eingabe-Token-IDs der Form (batch_size, seq_len)
training: Ob im Trainingsmodus
Returns:
logits: Unnormalisierte Scores über Vokabular
Form: (batch_size, seq_len, vocab_size)
"""
# Kodiert Eingabe
kodiert = self.encode(token_ids, training)
# Projiziert auf Vokabular
# Form: (batch, seq_len, d_model) @ (d_model, vocab)
# = (batch, seq_len, vocab)
logits = np.matmul(kodiert, self.output_projection)
return logits
def generate(self, prompt_tokens, max_neue_tokens=50, temperatur=1.0):
"""
Generiert Text autoregressiv gegeben einen Prompt.
Args:
prompt_tokens: Initiale Token-IDs der Form (1, prompt_len)
max_neue_tokens: Maximale Anzahl zu generierender Tokens
temperatur: Sampling-Temperatur (höher = zufälliger)
Returns:
generierte_tokens: Vollständige Sequenz inklusive Prompt
"""
# Beginnt mit dem Prompt
aktuelle_tokens = prompt_tokens.copy()
for _ in range(max_neue_tokens):
# Holt Vorhersagen für nächstes Token
# Wir verwenden nur die Logits der letzten Position
logits = self.forward(aktuelle_tokens, training=False)
naechstes_token_logits = logits[0, -1, :] # Letzte Position
# Wendet Temperaturskalierung an
# Höhere Temperatur macht Verteilung gleichmäßiger
skalierte_logits = naechstes_token_logits / temperatur
# Konvertiert zu Wahrscheinlichkeiten
probs = softmax(skalierte_logits.reshape(1, -1))[0]
# Sampelt nächstes Token
naechstes_token = np.random.choice(self.vocab_size, p=probs)
# Hängt an Sequenz an
aktuelle_tokens = np.concatenate([
aktuelle_tokens,
np.array([[naechstes_token]])
], axis=1)
# Stoppt, wenn wir maximale Länge überschreiten
if aktuelle_tokens.shape[1] >= self.max_seq_len:
break
return aktuelle_tokens
def compute_loss(self, eingabe_tokens, ziel_tokens):
"""
Berechnet Cross-Entropy-Loss für Sprachmodellierung.
Args:
eingabe_tokens: Eingabesequenz der Form (batch_size, seq_len)
ziel_tokens: Zielsequenz (um 1 verschoben) gleicher Form
Returns:
loss: Skalarer Loss-Wert
"""
# Forward-Pass
logits = self.forward(eingabe_tokens, training=True)
# Berechnet Cross-Entropy-Loss
batch_size, seq_len, vocab_size = logits.shape
# Formt um für einfachere Berechnung
logits_flach = logits.reshape(-1, vocab_size)
ziele_flach = ziel_tokens.reshape(-1)
# Berechnet Softmax-Wahrscheinlichkeiten
logits_verschoben = logits_flach - np.max(logits_flach, axis=1, keepdims=True)
exp_logits = np.exp(logits_verschoben)
probs = exp_logits / np.sum(exp_logits, axis=1, keepdims=True)
# Holt Wahrscheinlichkeit des korrekten Tokens
batch_indizes = np.arange(batch_size * seq_len)
korrekte_probs = probs[batch_indizes, ziele_flach]
# Berechnet negative Log-Likelihood
loss = -np.mean(np.log(korrekte_probs + 1e-10))
return loss
Diese Implementierung enthält alle Schlüsselkomponenten, die wir besprochen haben: Token-Embeddings, Positional Encodings, Multi-Head Attention, Feed-Forward Networks, Layer Normalization und Residual Connections. Sie können dieses Modell auf Textdaten mit der Trainingsschleife trainieren, die der Beitrag bereits skizziert hat.
Um dieses Modell in der Praxis zu verwenden, würden Sie:
- Ihre Daten vorbereiten, indem Sie Text in Integer-IDs tokenisieren. Sie würden einen Tokenizer wie Byte-Pair Encoding (BPE) verwenden, der Text in Subwort-Einheiten zerlegt und Vokabulargröße mit der Fähigkeit ausbalanciert, jeden Text zu repräsentieren.
- Trainings-Batches erstellen, wobei jedes Beispiel eine Sequenz von Tokens ist. Für Sprachmodellierung ist die Eingabe Tokens
Nullbisn minus eins, und das Ziel ist Tokens eins bisn(um eine Position verschoben). - die Trainingsschleife ausführen, Loss und Gradienten für jeden Batch berechnen und Parameter mit einem Optimizer wie Adam aktualisieren.
- auf einem Validierungsset evaluieren, um auf Overfitting zu achten. Wenn der Validierungs-Loss aufhört sich zu verbessern, während der Trainings-Loss weiter sinkt, erhalten Sie Überanpassung und sollten das Training stoppen oder die Regularisierung erhöhen.
- das trainierte Modell für Generierung verwenden, indem Sie einen Prompt bereitstellen und Tokens autoregressiv sampeln, bis Sie eine Stoppbedingung erreichen.
Optimierungstechniken für Produktivsysteme
Beim Deployment von Transformers in Produktion müssen wir für Geschwindigkeit und Speichereffizienz optimieren. Mehrere Techniken finden häufig Verwendung:
Flash Attention ist ein Algorithmus, der Attention effizienter berechnet, indem er sich der GPU-Speicherhierarchie bewusst ist. Standard-Attention berechnet die vollständige Attention-Matrix, was quadratischen Speicher in Sequenzlänge erfordert. Flash Attention berechnet Attention in Blöcken und hält Zwischenergebnisse im schnellen SRAM statt im langsamen HBM-Speicher.
Die Schlüsselerkenntnis: Wir müssen die vollständige Attention-Matrix nicht umsetzen. Wir können stattdessen die Ausgabe in Chunks berechnen:
def flash_attention(Q, K, V, block_size=64):
"""
Speichereffiziente Attention mit blockweiser Berechnung.
Args:
Q, K, V: Query-, Key-, Value-Matrizen
block_size: Größe der Blöcke für Chunk-Berechnung
Returns:
ausgabe: Attention-Ausgabe
"""
seq_len, d = Q.shape
num_blocks = (seq_len + block_size - 1) // block_size
# Initialisiert Ausgabe und Normalisierungsstatistiken
ausgabe = np.zeros_like(Q)
zeilen_max = np.full(seq_len, -np.inf)
zeilen_summe = np.zeros(seq_len)
# Verarbeitet in Blöcken
for i in range(num_blocks):
# Holt Query-Block
q_start = i * block_size
q_end = min((i + 1) * block_size, seq_len)
Q_block = Q[q_start:q_end]
for j in range(num_blocks):
# Holt Key-Value-Block
k_start = j * block_size
k_end = min((j + 1) * block_size, seq_len)
K_block = K[k_start:k_end]
V_block = V[k_start:k_end]
# Berechnet Attention-Scores für diesen Block
scores = np.matmul(Q_block, K_block.T) / np.sqrt(d)
# Aktualisiert laufendes Maximum für numerische Stabilität
block_max = np.max(scores, axis=1)
neues_max = np.maximum(zeilen_max[q_start:q_end], block_max)
# Berechnet Exponentialfunktionen mit aktualisiertem Maximum
exp_scores = np.exp(scores - neues_max[:, None])
# Aktualisiert laufende Summe
korrektur = np.exp(zeilen_max[q_start:q_end] - neues_max)
zeilen_summe[q_start:q_end] = zeilen_summe[q_start:q_end] * korrektur + np.sum(exp_scores, axis=1)
# Aktualisiert Ausgabe
ausgabe[q_start:q_end] = ausgabe[q_start:q_end] * korrektur[:, None] + np.matmul(exp_scores, V_block)
# Aktualisiert Maximum
zeilen_max[q_start:q_end] = neues_max
# Normalisiert Ausgabe
ausgabe[q_start:q_end] = ausgabe[q_start:q_end] / zeilen_summe[q_start:q_end, None]
return ausgabe
Diese blockweise Berechnung reduziert die Speichernutzung von O(n zum Quadrat) auf O(n), was es möglich macht, viel längere Sequenzen zu verarbeiten.
Quantisierung reduziert die Präzision von Gewichten und Aktivierungen, um Speicher zu sparen und Geschwindigkeit zu erhöhen. Anstatt 32-Bit-Gleitkommazahlen zu verwenden, könnten wir 8-Bit-Integer verwenden. Die Herausforderung besteht darin, Genauigkeit trotz reduzierter Präzision zu erhalten.
Die Grundidee: Gleitkommawerte auf Integer mit einer Skala und einem Nullpunkt abzubilden:
quantisierter_wert = round(gleitkomma_wert / skala) + nullpunkt
Zum Dequantisieren:
gleitkomma_wert = (quantisierter_wert - nullpunkt) * skala
Die Skala und der Nullpunkt wählen sich so, dass sich der Quantisierungsfehler minimiert. Für einen Wertebereich von min_val bis max_val:
skala = (max_val - min_val) / (2^bits - 1)
nullpunkt = round(-min_val / skala)
Hier ist eine Implementierung:
def quantize_tensor(tensor, num_bits=8):
"""
Quantisiert Gleitkomma-Tensor zu niedrigerer Präzision.
Args:
tensor: Zu quantisierendes Float-Array
num_bits: Anzahl der Bits für quantisierte Repräsentation
Returns:
quantisiert: Integer-Array
skala: Skalierungsfaktor für Dequantisierung
nullpunkt: Nullpunkt für Dequantisierung
"""
# Berechnet Wertebereich
min_val = np.min(tensor)
max_val = np.max(tensor)
# Berechnet Skala und Nullpunkt
qmin = 0
qmax = 2 ** num_bits - 1
skala = (max_val - min_val) / (qmax - qmin)
# Behandelt Grenzfall, wenn alle Werte gleich sind
if skala == 0:
skala = 1.0
nullpunkt = qmin - round(min_val / skala)
nullpunkt = np.clip(nullpunkt, qmin, qmax)
# Quantisiert
quantisiert = np.round(tensor / skala + nullpunkt)
quantisiert = np.clip(quantisiert, qmin, qmax).astype(np.int8)
return quantisiert, skala, nullpunkt
def dequantize_tensor(quantisiert, skala, nullpunkt):
"""
Konvertiert quantisierten Tensor zurück zu Gleitkomma.
"""
return (quantisiert.astype(np.float32) - nullpunkt) * skala
Moderne Quantisierungstechniken wie GPTQ und AWQ verwenden ausgefeiltere Methoden, die den Einfluss auf die Modellqualität minimieren. Sie könnten unterschiedliche Skalen für verschiedene Teile des Netzwerks verwenden oder auf tatsächlichen Daten kalibrieren, um optimale Quantisierungsparameter zu finden.
Kernel Fusion kombiniert mehrere Operationen in einem einzelnen GPU-Kernel, um Speicherbandbreite zu reduzieren. Wir können unter anderem, anstatt Layer Normalization als separate Operationen zu berechnen (Mittelwert subtrahieren, durch Standardabweichung dividieren, mit Gamma multiplizieren, Beta addieren), diese in einen einzelnen Kernel fusionieren, der die Eingabe einmal liest und die Ausgabe einmal schreibt.
Die mathematischen Operationen sind gleich, aber das Speicherzugriffsmuster ist viel effizienter. Dies ist besonders wichtig auf modernen GPUs, in denen Speicherbandbreite oft den Engpass darstellt.
Die Transformer-Revolution geht weiter
Wir sind von den fundamentalen Problemen, die Transformers motivierten, durch die mathematischen Details jeder Komponente bis zu fortgeschrittenen Themen wie Mixture of Experts und Reasoning-Modellen gereist. Die Schlüsselerkenntnisse, die Transformers funktionieren lassen, lauten zusammengefasst:
- Attention bietet einen differenzierbaren Mechanismus, um relevante Informationen überall in einer Sequenz nachzuschlagen, und löst das Langstreckenabhängigkeitsproblem, das RNNs plagte.
- Die Skalierung des Skalarprodukts durch die Quadratwurzel der Dimension verhindert Sättigung der Softmax-Funktion und ermöglicht stabiles Training.
- Multi-Head Attention erlaubt dem Modell, mehrere Arten von Beziehungen gleichzeitig zu lernen, was die Kapazität dramatisch erhöht, ohne die Rechenkosten proportional zu erhöhen.
- Positional Encodings fügen Informationen über Token-Position in eine ansonsten positionsagnostische Architektur ein, wobei wir sinusförmige Funktionen verwenden, die auf jede Sequenzlänge generalisieren.
- Residual Connections und Layer Normalization ermöglichen das Training sehr tiefer Netzwerke, indem sie für Gradientenfluss sorgen und Aktivierungen stabilisieren.
- Die Feed-Forward Networks fügen Rechentiefe und Nicht-Linearität hinzu, was dem Modell erlaubt, komplexe Funktionen seiner Eingaben zu berechnen.
Diese Komponenten kombinieren sich zu einer Architektur, die sowohl leistungsstark als auch effizient ist. Die Fähigkeit des Transformers, Sequenzen parallel zu verarbeiten, macht ihn dramatisch schneller zu trainieren als RNNs, während sein Attention-Mechanismus ihm die Kapazität gibt, Langstreckenabhängigkeiten zu erfassen.
Die Mathematik hinter Transformers ist nicht willkürlich. Jede Formel, jede architektonische Entscheidung entstand aus der Lösung konkreter Probleme.
- Die Scaled Dot-Product Attention-Formel existiert, weil wir einen differenzierbaren Lookup-Mechanismus brauchten, der nicht sättigt.
- Multi-Head Attention existiert, weil wir mehrere Arten von Beziehungen erfassen müssen.
- Positional Encoding existiert, weil Attention positionsagnostisch ist.
Das Verständnis dieser mathematischen Grundlagen befähigt Software-Engineers, nicht nur Transformers zu verwenden, sondern sie zu modifizieren und zu verbessern.
Sie können darüber nachdenken, warum bestimmte Designentscheidungen getroffen wurden und wie Sie die Architektur für spezifische Bedürfnisse anpassen könnten.
Das Feld entwickelt sich weiterhin rasant. Neue Optimierungen wie Flash Attention machen Modelle schneller und speichereffizienter. Neue Trainingstechniken wie RLHF richten Modelle an menschlichen Präferenzen aus. Neue Architekturen wie Mixture of Experts skalieren auf Billionen von Parametern. Aber all diese bauen auf der fundamentalen Transformer-Architektur auf, die wir erkundet haben.
Wenn Sie Transformers implementieren und mit ihnen experimentieren, erinnern Sie sich daran, dass die Mathematik einem Zweck dient. Jede Formel löst ein spezifisches Problem. Durch das Verständnis der kausalen Kette von Problem zu Lösung gewinnen Sie die Einsicht, die nötig ist, um die Grenzen dessen zu verschieben, was diese bemerkenswerten Modelle leisten können.
(rme)