Transformer im Detail
Im Jahr 2017 veröffentlichte Google das Papier „Attention Is All You Need“ und führte damit die Transformer-Architektur ein. Niemand hatte erwartet, dass dieses Papier den KI-Bereich komplett verändern würde.
Die heutigen GPT-, Claude-, Gemini-, Llama-... fast alle Mainstream-Large-Language-Modelle sind im Wesentlichen Varianten des Transformers.
Verstehen Sie den Transformer, und Sie verstehen 90 % der modernen LLMs.
In diesem Artikel tauchen wir tief in jede Komponente des Transformers ein: Self-Attention, Multi-Head-Attention, positionelle Enkodierung, Feed-Forward-Netzwerke, Layer-Normalisierung... und decken dabei nicht nur das „Was“, sondern auch das „Warum“ ab.
Dies ist eines der technisch tiefgreifendsten Module. Wir verwenden Code, um die Kernberechnungen zu demonstrieren, um sicherzustellen, dass Sie die Formeln nicht nur verstehen, sondern sie auch selbst implementieren können.
Transformer-Prequel: Grenzen von RNN
Vor dem Transformer wurden Sequenzaufgaben (wie Übersetzung und Texterstellung) hauptsächlich von RNNs (Recurrent Neural Networks) und deren Varianten LSTM und GRU bearbeitet.
Wie RNN funktioniert
Die Kernidee von RNN ist das „Schritt-für-Schritt-Verarbeiten“: Die Eingabesequenz gelangt Wort für Wort in das Netzwerk, und die Ausgabe bei jedem Schritt enthält Informationen von allen vorherigen Wörtern.
Beispiel
# Vereinfachte Demonstration der RNN-Vorwärtsausbreitung
# ============================================
import math
class SimpleRNN:
"""Vereinfachte RNN-Implementierung zur Demonstration des Prinzips"""
def __init__(self, input_size: int, hidden_size: int):
"""RNN-Parameter initialisieren"""
import random
random.seed(42) # Zufallsstartwert für Reproduzierbarkeit festlegen
# Gewichte von der Eingabe zur verdeckten Schicht
self.Wx = [[random.uniform(-0.1, 0.1) for _ in range(hidden_size)]
for _ in range(input_size)]
# Gewichte von der verdeckten Schicht zur verdeckten Schicht
self.Wh = [[random.uniform(-0.1, 0.1) for _ in range(hidden_size)]
for _ in range(hidden_size)]
# Bias
self.b = [0.0 for _ in range(hidden_size)]
def step(self, x: list, h_prev: list) -> Liste:
"""Einzelschritt-RNN: nimmt Eingabe x und vorherigen verborgenen Zustand h_prev, gibt neuen verborgenen Zustand aus"""
hidden_size = len(h_prev)
h_new = [0.0 for _ in range(hidden_size)]
# h_new = tanh(Wx·x + Wh·h_prev + b)
for i in range(hidden_size):
# Wx·x berechnen
wx_sum = sum(x[j] * self.Wx[j][i] for j in range(len(x)))
# Wh·h_prev berechnen
wh_sum = sum(h_prev[j] * self.Wh[j][i] for j in range(hidden_size))
# Bias hinzufügen, durch tanh leiten
h_new[i] = math.tanh(wx_sum + wh_sum + self.b[i])
return h_new
def forward(self, sequence: list) -> list:
"""Die gesamte Sequenz verarbeiten"""
hidden_size = len(self.b)
h = [0.0 for _ in range(hidden_size)] # Anfänglicher verborgener Zustand
hidden_states = []
for x in sequence:
h = self.step(x, h)
hidden_states.append(h)
return hidden_states
# Test: mit einer einfachen Vektorsequenz demonstrieren
input_size = 4
hidden_size = 3
rnn = SimpleRNN(input_size, hidden_size)
# Angenommen, die Eingabesequenz besteht aus 4 Wörtern, die jeweils durch einen 4-dimensionalen Vektor dargestellt werden
sequence = [
[1.0, 0.0, 0.0, 0.0], # Wort 1
[0.0, 1.0, 0.0, 0.0], # Wort 2
[0.0, 0.0, 1.0, 0.0], # Wort 3
[0.0, 0.0, 0.0, 1.0], # Wort 4
]
hidden_states = rnn.forward(sequence)
print("Example Simple RNN Demo")
print("=" * 40)
for i, h in enumerate(hidden_states):
print(f"Hidden state at step {i+1}: {[f'{v:.4f}' for v in h]}")
# Output:
# Beispiel für eine einfache RNN-Demo
# ========================================
# Verborgener Zustand bei Schritt 1: ['0.0204', '-0.0434', '0.0556']
# Verborgener Zustand bei Schritt 2: ['-0.0061', '-0.0529', '0.0775']
# Verborgener Zustand bei Schritt 3: ['0.0641', '-0.0409', '0.0655']
# Verborgener Zustand bei Schritt 4: ['0.0143', '-0.0805', '0.0277']
RNN scheint vernünftig, hat aber drei schwerwiegende Probleme:
Problem 1: Verschwindende Gradienten, Schwierigkeiten beim Erinnern an langfristige Abhängigkeiten
RNN ist eine „Ketten“-Struktur; der Gradient bei jedem Schritt muss zum ersten Schritt zurückpropagiert werden. Nach wiederholten Multiplikationen klingt der Gradient exponentiell ab und wird fast 0.
Zum Beispiel im Satz „I went to Paris in 2010, ... that was my favorite city“ – „city“ muss sich auf „Paris“ beziehen, aber es liegen zu viele Wörter dazwischen, sodass RNNs Schwierigkeiten haben, solche langfristigen Abhängigkeiten zu lernen.
LSTM und GRU mildern dieses Problem ab, lösen es aber nicht vollständig.
Problem 2: Kann nicht parallel berechnet werden, langsames Training
Ein RNN muss warten, bis Schritt t-1 beendet ist, bevor es Schritt t berechnen kann. Das bedeutet:
- Selbst mit 100 GPUs können Sie immer nur ein Wort gleichzeitig berechnen
- Je länger die Sequenz, desto länger die Trainingszeit
- Schwer auf sehr große Datensätze zu skalieren
Problem 3: Frühere Informationen werden leicht „überschrieben“
Der verborgene Zustand eines RNN wird Schritt für Schritt aktualisiert, und spätere Informationen überschreiben immer wieder frühere Informationen. Wichtige Informationen am Anfang eines Satzes können am Ende weitgehend „verwässert“ sein.
Das Aufkommen des Transformers hat alle drei Probleme auf einmal gelöst.
| Merkmal | RNN/LSTM | Transformer |
|---|---|---|
| Berechnungsmethode | Seriell, Schritt für Schritt | Parallel, alles auf einmal berechnen |
| Langreichweitige Abhängigkeiten | Schwach, verschwindender Gradient | Stark, direkte Verbindung in beliebiger Entfernung |
| Positionelle Informationen | Von Natur aus sequenziell | Erfordert Positionskodierung |
| Trainingsgeschwindigkeit | Langsam | Schnell (parallelisierbar) |
Self-Attention-Mechanismus
Self-Attention ist das Herzstück des Transformers. Seine Idee ist einfach:Jedes Wort muss mit allen Wörtern im Satz „kommunizieren“, sehen, wer für es selbst wichtig ist, und dann eine gewichtete Summe nach Wichtigkeit bilden.
Zuerst das Gesamtarchitekturdiagramm ansehen:
Intuitives Verständnis von Q, K, V
Self-Attention verwendet drei Vektoren, um jedes Wort zu beschreiben:
- Q (Query)——„Wen suche ich?“—— auf welches Wort sich dieses Wort beziehen möchte
- K (Key)——„Wer bin ich?“—— die Identität dieses Wortes
- V (Value)——„Was habe ich?“—— der eigentliche Inhalt dieses Wortes
Der Berechnungsprozess ist:
- Jedes Wort verwendet sein Q, um mit dem K aller Wörter zu „matchen“, wodurch Aufmerksamkeitswerte (Attention Scores) erhalten werden
- Verwenden Sie Softmax, um die Werte zu normalisieren, sodass sie sich zu 1 summieren
- Verwenden Sie die normalisierten Scores, um das V aller Wörter zu gewichten und zu summieren
Das Diagramm verdeutlicht dies:

Formel zur Berechnung des Attention-Scores
Die vollständige Formel lautet:
Attention(Q, K, V) = softmax( Q·Kᵀ / √dₖ ) · V
Implementieren wir es Schritt für Schritt in reinem Python:
Beispiel
# Reine Python-Implementierung von Self-Attention
# ============================================
import math
def softmax(x: list) -> list:
"""Softmax berechnen: eine Reihe von Zahlen in eine Wahrscheinlichkeitsverteilung umwandeln, die sich zu 1 summiert"""
# Subtrahiere den Maximalwert, um numerischen Überlauf zu vermeiden
max_val = max(x)
exp_x = [math.exp(v - max_val) for v in x]
sum_exp = sum(exp_x)
return [v / sum_exp for v in exp_x]
def matrix_multiply(A: list, B: list) -> list:
"""Matrizenmultiplikation: A ist m×n, B ist n×p, Ausgabe ist m×p"""
m = len(A)
n = len(B)
p = len(B[0])
result = [[0.0 for _ in range(p)] for _ in range(m)]
for i in range(m):
for j in range(p):
for k in range(n):
result[i][j] += A[i][k] * B[k][j]
return result
def transpose(matrix: list) -> list:
"""Matrix-Transposition"""
return list(map(list, zip(*matrix)))
class SelfAttention:
"""Reine Python-Implementierung der Self-Attention-Schicht"""
def __init__(self, d_model: int, d_k: int):
"""
Self-Attention-Schicht initialisieren
d_model: Eingabe-/Ausgabedimension
d_k: Dimension von Q/K (normalerweise d_k = d_model / num_heads)
"""
import random
random.seed(42) # Beispiel: fester Zufallsstartwert
self.d_model = d_model
self.d_k = d_k
# Initialisieren Sie die Projektionsmatrizen für Q, K, V
# In der Praxis werden diese trainiert
self.Wq = [[random.normalvariate(0, 0.1) for _ in range(d_k)]
for _ in range(d_model)]
self.Wk = [[random.normalvariate(0, 0.1) for _ in range(d_k)]
for _ in range(d_model)]
self.Wv = [[random.normalvariate(0, 0.1) for _ in range(d_k)]
for _ in range(d_model)]
def forward(self, X: list) -> list:
"""
Self-Attention Forward-Propagation
X: Eingabesequenz, Form [seq_len, d_model]
Gibt zurück: Ausgabequenz, Form [seq_len, d_k]
"""
seq_len = len(X)
# Schritt 1: Q, K, V berechnen
# Q = X·Wq, K = X·Wk, V = X·Wv
Q = matrix_multiply(X, self.Wq)
K = matrix_multiply(X, self.Wk)
V = matrix_multiply(X, self.Wv)
print(" Form von Q:", len(Q), "×", len(Q[0]))
print(" Form von K:", len(K), "×", len(K[0]))
print(" Form von V:", len(V), "×", len(V[0]))
# Schritt 2: Attention-Scores Q·Kᵀ berechnen
K_transposed = transpose(K)
scores = matrix_multiply(Q, K_transposed)
print("\nRoh-Attention-Scores:")
for i, row in enumerate(scores):
print(fZeile {i}: {[f'{v:.4f}' for v in row]})
# Schritt 3: Skalierung: Durch √d_k teilen
# Dies verhindert, dass Softmax in den Gradientensättigungsbereich gerät.
scale_factor = math.sqrt(self.d_k)
scores_scaled = [[v / scale_factor for v in row] for row in scores]
print("\nSkalierte Attention-Scores:)
for i, row in enumerate(scores_scaled):
print(fZeile {i}: {[f'{v:.4f}' for v in row]})
# Schritt 4: Softmax-Normalisierung
attention_weights = []
for row in scores_scaled:
attention_weights.append(softmax(row))
print("\nAttention-Gewichte (nach Softmax): ")
for i, row in enumerate(attention_weights):
print(fZeile {i}: {[f'{v:.4f}' for v in row]})
# Schritt 5: Gewichtere Summe V
output = matrix_multiply(attention_weights, V)
return output, attention_weights
# ============================================
# Demo: Self-Attention mit einem einfachen Beispiel ausführen
# ============================================
# Angenommen, wir haben einen Satz mit 4 Wörtern, wobei jedes Wort durch einen 8-dimensionalen Vektor dargestellt wird
# Zu Demonstrationszwecken verwenden wir One-Hot + kleines zufälliges Rauschen
d_model = 8
d_k = 6
# Eingabesequenz: 4 Wörter, jeweils 8-dimensional
X = [
[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], # Wort 0
[0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], # Wort 1
[0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0], # Wort 2
[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0], # Wort 3
]
print("=" * 50)
print("Beispiel für Self-Attention-Demo")
print("=" * 50)
print(f"Länge der Eingabesequenz: {len(X)}")
print(f"Eingabedimension: {len(X)
print()
attn = SelfAttention(d_model=d_model, d_k=d_k)
output, attention_weights = attn.forward(X)
print("\n" + "=" * 50)
print("Self-Attention-Ausgabe:")
print("=" * 50)
for i, row in enumerate(output):
print(f"Ausgabe von Wort {i}: {[f'{v:.4f}' for v in row]}")
Führen Sie diesen Code aus und Sie sehen den vollständigen Berechnungsprozess: Q/K/V-Ergenerierung, Berechnung der Attention-Scores, Skalierung, Softmax und schließlich die gewichtete Ausgabe.
Warum durch √dₖ teilen?
Dies ist ein wichtiges Detail. Unter Annahme, dass jedes Element von Q und K eine Zufallsvariable mit Mittelwert 0 und Varianz 1 ist, beträgt die Varianz ihres Skalarprodukts dₖ.
Wenn dₖ groß ist (z. B. 512), werden die Werte des Skalarprodukts sehr groß, wodurch Softmax in den „Sättigungsbereich“ gerät – der Gradient ist nahe bei 0 und das Training kann nicht fortgesetzt werden.
Nach dem Teilen durch √dₖ kehrt die Varianz zu 1 zurück und der Softmax-Gradient funktioniert wieder einwandfrei.
Scaled Dot-Product Attention ist eine der Schlüsseltechniken für ein stabiles Transformer-Training.
Multi-Head Attention
Single-head self-attention ist mächtig, hat aber ein Problem: Jedes Wort kann nur einmal „aufmerksam sein“ (attend). Was ist, wenn wir möchten, dass ein Wort gleichzeitig auf mehrere verschiedene Aspekte achtet?
Zum Beispiel im Satz „In the zoo, the tiger is chasing the rabbit“:
- „Tiger“ benötigt möglicherweise einen Kopf, der auf „zoo“ achtet (Ort).
- Ein anderer Kopf achtet auf „rabbit“ (Objekt der Handlung).
- Der dritte Kopf konzentriert sich auf „chasing“ (Handlung).
Multi-Head Attention teilt Q/K/V in mehrere „Köpfe“ (Heads) auf, von denen jeder ein anderes Aufmerksamkeitsmuster lernt, und führt sie schließlich zusammen.
Schritte zur Berechnung von Multi-Head Attention
- Teile Q/K/V entlang der letzten Dimension in h Teile auf (h ist die Anzahl der Köpfe).
- Jeder Kopf führt unabhängig Self-Attention aus.
- Konkateniere die Ausgaben aller Köpfe.
- Leite sie durch eine lineare Projektionsschicht, um die finale Ausgabe zu erhalten.
Die Formel lautet:
MultiHead(Q, K, V) = Concat(head₁, head₂, ..., headₕ) · Wₒ 其中 headᵢ = Attention(Q·Wqᵢ, K·Wkᵢ, V·Wvᵢ)
Beispiel
# Reine Python-Implementierung von Multi-Head Attention
# ============================================
import math
import random
random.seed(42) # Beispiel: fester Zufallsstartwert
def softmax(x: list) -> list:
"""Softmax berechnen"""
max_val = max(x)
exp_x = [math.exp(v - max_val) for v in x]
sum_exp = sum(exp_x)
return [v / sum_exp for v in exp_x]
def matrix_multiply(A: list, B: list) -> list:
"""Matrixmultiplikation"""
m = len(A)
n = len(B)
p = len(B[0])
result = [[0.0 for _ in range(p)] for _ in range(m)]
for i in range(m):
for j in range(p):
for k in range(n):
result[i][j] += A[i][k] * B[k][j]
return result
def transpose(matrix: list) -> list:
"""Matrix-Transposition"""
return list(map(list, zip(*matrix)))
class MultiHeadAttention:
"""Reine Python-Implementierung der Multi-Head-Attention-Schicht"""
def __init__(self, d_model: int, num_heads: int):
"""
Multi-Head Attention initialisieren
d_model: Modelldimension (muss durch num_heads teilbar sein)
num_heads: Anzahl der Attention-Köpfe
"""
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 jedes Kopfes
# Projektionsmatrizen initialisieren
self.Wq = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
self.Wk = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
self.Wv = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
self.Wo = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
def split_heads(self, X: list) -> list:
"""
Die letzte Dimension in num_heads Köpfe aufteilen
Eingabeform: [seq_len, d_model]
Ausgabeform: [num_heads, seq_len, d_k]
"""
seq_len = len(X)
# Reshape: [seq_len, num_heads, d_k]
reshaped = []
for row in X:
head_rows = []
for h in range(self.num_heads):
start = h * self.d_k
end = start + self.d_k
head_rows.append(row[start:end])
reshaped.append(head_rows)
# Transponieren: [num_heads, seq_len, d_k]
result = []
for h in range(self.num_heads):
head_data = [reshaped[i][h] for i in range(seq_len)]
result.append(head_data)
return result
def combine_heads(self, heads: list) -> list:
"""
Die Heads wieder zusammenfügen
Input shape: [num_heads, seq_len, d_k]
Output shape: [seq_len, d_model]
"""
seq_len = len(heads[0])
result = []
for i in range(seq_len):
combined = []
for h in range(self.num_heads):
combined.extend(heads[h][i])
result.append(combined)
return result
def attention_single_head(self, Q: list, K: list, V: list) -> list:
"""Single-head self-attention"""
# Q·K^T
scores = matrix_multiply(Q, transpose(K))
# Scaling
scale = math.sqrt(self.d_k)
scores_scaled = [[v / scale for v in row] for row in scores]
# Softmax
weights = [softmax(row) for row in scores_scaled]
# Weighted sum of V
output = matrix_multiply(weights, V)
return output, weights
def forward(self, X: list) -> list:
"""Multi-head attention forward pass"""
seq_len = len(X)
# Schritt 1: Q, K, V berechnen
Q = matrix_multiply(X, self.Wq)
K = matrix_multiply(X, self.Wk)
V = matrix_multiply(X, self.Wv)
# Step 2: Split into multiple heads
Q_heads = self.split_heads(Q)
K_heads = self.split_heads(K)
V_heads = self.split_heads(V)
# Step 3: Each head performs self-attention independently
output_heads = []
all_weights = []
for h in range(self.num_heads):
out, weights = self.attention_single_head(
Q_heads[h], K_heads[h], V_heads[h]
)
output_heads.append(out)
all_weights.append(weights)
# Step 4: Concatenate all heads
combined = self.combine_heads(output_heads)
# Step 5: Final linear projection
output = matrix_multiply(combined, self.Wo)
return output, all_weights
# ============================================
# Demonstrate multi-head attention
# ============================================
d_model = 12
num_heads = 3 # 12 / 3 = 4 dimensions per head
# Input sequence: 4 tokens, each with 12 dimensions
X = [
[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
]
print("=" * 50)
print("Example Multi-Head Attention Demo")
print("=" * 50)
print(f"d_model = {d_model}, num_heads = {num_heads}")
print(f"Dimension per head: d_k = {d_model // num_heads}")
print()
mha = MultiHeadAttention(d_model=d_model, num_heads=num_heads)
output, all_weights = mha.forward(X)
print("\n"Attention weights for each head:")
for h in range(num_heads):
print(f"\n"Head {h}:")
for i, row in enumerate(all_weights[h]):
print(f" Position {i}: {[f'{v:.4f}' for v in row]}")
print("\n" + "=" * 50)
print("Multi-head attention final output shape:", len(output), "×", len(output[0]))
print("=" * 50)
for i, row in enumerate(output):
print(f"Position {i}: {[f'{v:.4f}' for v in row[:6]]}...")
Die Stärke der Multi-Head-Attention liegt darin:Verschiedene Heads lernen automatisch unterschiedliche Arten von Aufmerksamkeitsmustern.
Zum Beispiel bei Übersetzungsaufgaben:
- Einige Heads konzentrieren sich auf syntaktische Beziehungen (Subjekt-Verb-Objekt)
- Einige Heads konzentrieren sich auf Koreferenz (auf welches Nomen sich ein Pronomen bezieht)
- Einige Heads konzentrieren sich auf lokale benachbarte Wörter
Diese Muster werden nicht manuell entworfen; sie werden vom Modell während des Trainings automatisch gelernt.
Positionale Codierung
Self-Attention hat ein Problem: Es ist ein „Bag-of-Words“-Modell – die Wortreihenfolge ist ihm egal. „Ich liebe dich“ und „Du liebst mich“ können für Self-Attention gleich aussehen.
Aber die Reihenfolge ist für Sprache entscheidend. Wir müssen positionelle Informationen in das Modell „einbringen“.
Sinusförmige positionale Codierung
Der ursprüngliche Transformer verwendet eine sinusförmige positionale Codierung:
PE(pos, 2i) = sin(pos / 10000^(2i/d_model)) PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))
Wobei:
- pos die Position ist (0, 1, 2, ...)
- i ist der Dimensionsindex
- d_model ist die Modelldimension
Dieses Design ist genial:
- Jede Position hat eine eindeutige Kodierung
- Relative Positionen können durch Linearkombinationen dargestellt werden (sin(a+b) = sin a cos b + cos a sin b)
- Es kann auf längere Sequenzen extrapolieren als die während des Trainings gesehenen
Beispiel
# Sinusförmige Positionalkodierung implementieren
# ============================================
import math
def sinusoidal_positional_encoding(seq_len: int, d_model: int) -> Liste:
"""
Sinusförmige Positionalkodierung berechnen
Gibt zurück: Positionalkodierungsmatrix der Form [seq_len, d_model]
"""
pe = [[0.0 for _ in range(d_model)] for _ in range(seq_len)]
for pos in range(seq_len):
for i in range(0, d_model, 2):
# Sinus für gerade Dimensionen verwenden
div_term = math.pow(10000, 2 * i / d_model)
pe[pos][i] = math.sin(pos / div_term)
# Cosinus für ungerade Dimensionen verwenden (falls die nächste Dimension existiert)
if i + 1 < d_model:
pe[pos][i + 1] = math.cos(pos / div_term)
return pe
# Demo: Positionalkodierung generieren
seq_len = 10
d_model = 8
pe = sinusoidal_positional_encoding(seq_len, d_model)
print("=" * 50)
print("Beispiel für sinusoidale Positionscodierung")
print("=" * 50)
print(f"Sequenzlänge: {seq_len}, Dimension: {d_model}")
print()
for pos in range(seq_len):
print(f"Position {pos:2d}: {[f'{v:.4f}' for v in pe[pos]]}")
# Untersuchung der Beziehung relativer Positionen
print("\n" + "=" * 50)
print("Lineare Beziehung relativer Positionen verifizieren")
print("=" * 50)
print("Codierungsdifferenz zwischen Position 0 und Position 1:")
diff = [pe[1][i] - pe[0][i] for i in range(d_model)]
print([f'{v:.4f}' for v in diff])
RoPE: Rotary Positional Encoding
Obwohl die sinusoidale Positionscodierung ein Klassiker ist, entstanden später bessere Lösungen. Die heutigen Mainstream-Modelle (wie LLaMA) verwendenRoPE (Rotary Positional Embedding)。
Die Idee von RoPE ist:Positionsinformationen in Query und Key hineinzurollen.
Konkret wird jedes Dimensionspaar von Q und K gedreht:
[ q₁, q₂, q₃, q₄, ... ] ↓ 旋转 [ q₁·cos mθ - q₂·sin mθ, q₁·sin mθ + q₂·cos mθ, q₃·cos mθ' - q₄·sin mθ', q₃·sin mθ' + q₄·cos mθ', ... ]
Vorteile von RoPE:
- Enthält natürlich relative Positionsinformationen
- Gute Extrapolation (funktioniert für Längen jenseits des Trainings)
- Funktioniert normalerweise besser als sinusoidale Positionscodierung
ALiBi: Decaying Attention by Distance
Ein weiterer einfacher und effektiver Ansatz istALiBi (Attention with Linear Biases)。
Es fügt keine Positionscodierung hinzu, sondern direkt einen Bias zu den Attention-Scores: Je weiter entfernt, desto niedriger der Score.
Der Vorteil von ALiBi liegt in seiner Einfachheit, und es ist besonders gut für Längenextrapolation geeignet.
| Positionscodierungsmethoden | Prinzip | Vorteile | Repräsentative Modelle |
|---|---|---|---|
| Sinusoidale Positionscodierung | sin/cos-Funktionen | Extrapolierbar, einfache Berechnung | Ursprünglicher Transformer, BERT |
| Lernbare Positionscodierung | Direktes Trainieren eines Embeddings | Starke Ausdruckskraft | GPT-1/2 |
| RoPE | Q/K rotieren | Relative Positionen, gute Leistung | LLaMA, PaLM |
| ALiBi | Bias zu Attention-Scores hinzufügen | Einfach, gut für lange Sequenzen | MPT, BLOOM |
Feed-Forward Network (FFN) Schicht
In jeder Transformer-Schicht gibt es nach der Attention auch ein Feed-Forward-Netzwerk.
Struktur von FFN
Das FFN des ursprünglichen Transformers ist:
FFN(x) = max(0, x·W₁ + b₁) · W₂ + b₂
Das heißt: Lineare Schicht → ReLU-Aktivierung → Lineare Schicht.
Normalerweise ist die Zwischendimension größer als die Eingangsdimension (z. B. d_model=512, Zwischendimension=2048).
ReLU vs SwiGLU
Das Original verwendete ReLU, aber heute ist der StandardSwiGLU:
SwiGLU(x) = (x·W₁ ⊗ sigmoid(x·V₁)) · W₂
wobei ⊗ die elementweise Multiplikation ist.
SwiGLU schneidet bei vielen Aufgaben besser ab als ReLU; LLaMA, PaLM usw. verwenden es alle.
Warum ist FFN wichtig?
- Attention ist das „Aggregieren von Informationen“, FFN ist das „Verarbeiten von Informationen“
- FFN hat eine große Anzahl von Parametern und ist der Hauptort, an dem das Modell Wissen „speichert“
- Ohne FFN würde die Ausdrucksfähigkeit von Transformer stark reduziert werden
Beispiel
# FFN implementieren: ReLU-Version und SwiGLU-Version
# ============================================
import math
import random
random.seed(42) # Beispiel
def relu(x: float) -> float:
"""ReLU-Aktivierungsfunktion: max(0, x)"""
return max(0.0, x)
def sigmoid(x: float) -> float:
"""Sigmoid-Aktivierungsfunktion"""
if x < -50: # Numerischen Überlauf verhindern
return 0.0
return 1.0 / (1.0 + math.exp(-x))
def linear(x: list, W: list, b: list) -> Liste:
"""Lineare Schicht: y = x·W + b"""
output_size = len(b)
output = [0.0 for _ in range(output_size)]
for i in range(output_size):
sum_wx = sum(x[j] * W[j][i] for j in range(len(x)))
output[i] = sum_wx + b[i]
return output
class FFNReLU:
"""ReLU-Version von FFN"""
def __init__(self, d_model: int, d_ff: int):
self.d_model = d_model
self.d_ff = d_ff
# Parameter zufällig initialisieren (in der Praxis durch Training erhalten)
self.W1 = [[random.normalvariate(0, 0.1) for _ in range(d_ff)]
for _ in range(d_model)]
self.b1 = [0.0 for _ in range(d_ff)]
self.W2 = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_ff)]
self.b2 = [0.0 for _ in range(d_model)]
def forward(self, x: list) -> list:
"""Vorwärtspass (Forward Pass)"""
# Erste Schicht: linear + ReLU
hidden = linear(x, self.W1, self.b1)
hidden = [relu(v) for v in hidden]
# Zweite Schicht: linear
output = linear(hidden, self.W2, self.b2)
return output
class FFNSwiGLU:
"""SwiGLU-Version von FFN (eine modernere Wahl)"""
def __init__(self, d_model: int, d_ff: int):
self.d_model = d_model
self.d_ff = d_ff
# SwiGLU benötigt zwei lineare Schichten für das Gating sowie eine Ausgabeschicht
self.W1 = [[random.normalvariate(0, 0.1) for _ in range(d_ff)]
for _ in range(d_model)]
self.V1 = [[random.normalvariate(0, 0.1) for _ in range(d_ff)]
for _ in range(d_model)]
self.W2 = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_ff)]
def forward(self, x: list) -> list:
"""Vorwärtspass: SwiGLU"""
# Berechne zwei lineare Transformationen
x1 = linear(x, self.W1, [0.0 for _ in range(self.d_ff)])
x2 = linear(x, self.V1, [0.0 for _ in range(self.d_ff)])
# SwiGLU: x1 * sigmoid(x2)
hidden = [a * sigmoid(b) for a, b in zip(x1, x2)]
# Ausgabeschicht
output = linear(hidden, self.W2, [0.0 for _ in range(self.d_model)])
return output
# FFN demonstrieren
d_model = 8
d_ff = 16
# Testeingabe
x = [0.1, -0.2, 0.3, -0.4, 0.5, -0.6, 0.7, -0.8]
print("=" * 50)
print("Beispiel FFN-Demo")
print("=" * 50)
print(f"Eingabe: {[f'{v:.4f}' for v in x]}")
ffn_relu = FFNReLU(d_model, d_ff)
output_relu = ffn_relu.forward(x)
print(f"\n"ReLU FFN-Ausgabe: {[f'{v:.4f}' for v in output_relu]}")
ffn_swiglu = FFNSwiGLU(d_model, d_ff)
output_swiglu = ffn_swiglu.forward(x)
print(f"SwiGLU FFN-Ausgabe: {[f'{v:.4f}' for v in output_swiglu]}")
Schicht-Normalisierung (Layer Norm)
Es gibt eine weitere wichtige Komponente im Transformer: die Schicht-Normalisierung.
Was die Schicht-Normalisierung tut
Die Schicht-Normalisierung normalisiert die Merkmale jedes Samples auf den Mittelwert 0 und die Varianz 1:
LN(x) = γ ⊗ ((x - μ) / √σ²) + β
wobei:
- μ der Mittelwert von x ist, σ² die Varianz von x ist
- γ (Skalierung) und β (Verschiebung) lernbare Parameter sind
- ⊗ elementweise Multiplikation ist
Beispiel
# Schicht-Normalisierung implementieren
# ============================================
import math
def layer_norm(x: list, gamma: list, beta: list, eps: float = 1e-6) -> list:
"""
Layer-Normalisierung
x: Eingabevektor
gamma: Skalierungsparameter
beta: Offset-Parameter
eps: kleine Konstante zur Vermeidung einer Division durch Null
"""
# Mittelwert berechnen
mean = sum(x) / len(x)
# Varianz berechnen
var = sum((v - mean) ** 2 for v in x) / len(x)
# Normalisierung + Skalierung und Verschiebung
normalized = []
for xi, gi, bi in zip(x, gamma, beta):
normalized.append(gi * (xi - mean) / math.sqrt(var + eps) + bi)
return normalized
# Schicht-Normalisierung demonstrieren
d_model = 6
# Eingabevektor
x = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
# gamma auf 1 initialisiert, beta auf 0 initialisiert
gamma = [1.0 for _ in range(d_model)]
beta = [0.0 for _ in range(d_model)]
print("=" * 50)
print("Example Layer Normalization Demo")
print("=" * 50)
print(f"Eingabe: {[f'{v:.4f}' for v in x]}")
output = layer_norm(x, gamma, beta)
print(f"Output: {[f'{v:.4f}' for v in output]}")
# Verify mean and variance
mean_out = sum(output) / len(output)
var_out = sum((v - mean_out) ** 2 for v in output) / len(output)
print(f"\n"Output mean: {mean_out:.4f}")
print(f"Output variance: {var_out:.4f}")
Pre-Norm vs Post-Norm
Layer normalization has two placement methods:
- Post-Norm: Sublayer → Residual → Normalization (ursprünglicher Transformer)
- Pre-Norm: Normalization → Sublayer → Residual (heute Mainstream)
Pre-Norm-Training ist stabiler und fast alle großen Modelle verwenden es inzwischen.
RMS Norm
RMS NormDies ist eine vereinfachte Version der Layer-Normalisierung, die nur den quadratischen Mittelwert berechnet, ohne den Mittelwert zu subtrahieren:
RMS(x) = sqrt(mean(x²)) RMSNorm(x) = γ ⊗ (x / RMS(x))
RMS Norm ist schneller zu berechnen bei ähnlichen Ergebnissen; Modelle wie LLaMA verwenden es.
Encoder-Decoder- vs. Decoder-Only-Architektur
Der ursprüngliche Transformer ist eine Encoder-Decoder-Architektur, aber die meisten großen Sprachmodelle heute sind Decoder-Only.
Encoder-Decoder: Wird für Aufgaben wie Übersetzung verwendet
Encoder-Decoder-Architektur:
- Encoder: Liest die Eingabesequenz (z. B. Quellsprachensatz) und generiert eine Kontextrepräsentation
- Decoder: Generiert die Zielsequenz (z. B. Übersetzungsergebnis) basierend auf der Ausgabe des Encoders
Es gibt zwei Arten von Aufmerksamkeit im Decoder:
- Masked Self-Attention: Kann nur vorherige Wörter betrachten (kann die Zukunft nicht sehen)
- Cross-Attention: Um auf die Ausgabe des Encoders zu achten
Geeignete Aufgaben: Maschinelle Übersetzung, Spracherkennung, Zusammenfassung (erfordert das „Umschreiben“ der Eingabe in die Ausgabe).
Decoder-Only: GPT-Serie, wird für die Generierung verwendet
Die Decoder-Only-Architektur hat nur einen Decoder, keinen Encoder. Ihre Eigenschaften:
- Nur maskierte Self-Attention (kausale Aufmerksamkeit)
- Autoregressive Generierung: Gibt ein Wort nach dem anderen aus
- Geeignet für das Pretraining- + Fine-Tuning-Paradigma
Aktuelle Modelle wie GPT, LLaMA, Claude, Gemini... sind fast alle Decoder-Only.
Encoder-Only: BERT-Serie, wird zum Verstehen verwendet
Die Encoder-Only-Architektur hat nur einen Encoder. Ihre Eigenschaften:
- Bidirektionale Aufmerksamkeit: Jedes Wort kann alle Wörter sehen
- Geeignet für Verstehensaufgaben: Klassifizierung, Named Entity Recognition, extractive Fragebeantwortung
- Vorab trainiert mit „Masked Language Model“ (MLM)
| Architektur | Komponente | Attention-Typ | Repräsentatives Modell | Anwendbare Aufgaben |
|---|---|---|---|---|
| Encoder-Decoder | Encoder + Decoder | Bidirektional + maskiert + cross | T5, BART | Übersetzung, Zusammenfassung |
| Decoder-Only | Decoder | Maskiert (kausal) | GPT, LLaMA, Claude | Texterstellung |
| Encoder-Only | Encoder | Bidirektional | BERT, RoBERTa | Klassifizierung, NER |
Warum wurde Decoder-Only zum Mainstream? Weil es einfach ist und unter groß angelegtem Vortraining seine Generierungsfähigkeit extrem stark ist. „One size fits all“ funktioniert tatsächlich besser als „spezialisiertes Design“.
Flash Attention: Berechnung von Attention schnell und speichereffizient machen
Die Zeit- und Speicherkomplexität der Standard-Self-Attention beträgt O(n²); wenn die Sequenz lang wird (z. B. n=8192), ist sie langsam und der GPU-Speicher reicht nicht aus.
Flash AttentionEs handelt sich um eine Schlüsseloptimierung; ihr Aufkommen im Jahr 2022 machte das Training mit langen Sequenzen praktikabel.
Speicherengpass der Standard-Attention
Standard-Self-Attention muss Zwischenergebnisse (Attention-Gewichtsmatrizen) für die Backpropagation speichern. Für n=8192 sind das 8192×8192 ≈ 67 Millionen Elemente, was allein Dutzende von GB an GPU-Speicher belegt.
Optimierungsprinzipien von Flash Attention
Kernidee von Flash Attention:
- Blockweise Berechnung: Q/K/V in kleine Blöcke aufteilen und Block für Block berechnen
- Online Softmax: Die vollständige Attention-Matrix nicht speichern; mathematische Tricks verwenden, um sie während der Backpropagation neu zu berechnen
- SRAM nutzen: Die Blöcke in den Hochgeschwindigkeits-Cache (SRAM) der GPU laden, um HBM-Zugriffe zu reduzieren
Das Ergebnis:
- Die Speicherkomplexität sinkt von O(n²) auf O(n)
- 2-4x schneller
- Identische Präzision (mathematisch äquivalent)
Fast das gesamte Training großer Modelle verwendet heute Flash Attention oder dessen Varianten (Flash Attention v2, Memory-Efficient Attention usw.).
KV Cache: Prinzipien der Inferenzbeschleunigung
Sobald die Trainingsoptimierung abgeschlossen ist, muss auch die Inferenz (Generierung) optimiert werden.KV CacheEr ist der Schlüssel zur Inferenzbeschleunigung.
Problem: Wiederholte Berechnungen bei der autoregressiven Generierung
Während der autoregressiven Generierung müssen bei jeder Generierung eines neuen Tokens die K- und V-Werte aller vorherigen Token neu berechnet werden – das ist sehr verschwenderisch.
Wenn beispielsweise das 100. Token generiert wird, wurden die K/V-Werte der ersten 99 Token bereits berechnet und müssen nicht neu berechnet werden.
Wie der KV Cache funktioniert
Die Idee des KV Cache ist einfach:
- Die K- und V-Werte aller vorherigen Positionen zwischenspeichern
- Beim Generieren eines neuen Tokens nur das K/V des neuen Tokens berechnen und an den Cache anhängen
- Das vollständige K/V für die Aufmerksamkeit verwenden
Auf diese Weise sinkt die Berechnung pro Schritt von O(n²) auf O(n), was die Generierungsgeschwindigkeit erheblich verbessert.
Beispiel
# Das Prinzip des KV Cache demonstrieren
# ============================================
import math
import random
random.seed(42)
def softmax(x):
max_val = max(x)
exp_x = [math.exp(v - max_val) for v in x]
sum_exp = sum(exp_x)
return [v / sum_exp for v in exp_x]
def matmul(A, B):
m = len(A)
n = len(B)
p = len(B[0])
result = [[0.0 for _ in range(p)] for _ in range(m)]
for i in range(m):
for j in range(p):
for k in range(n):
result[i][j] += A[i][k] * B[k][j]
return result
def transpose(matrix):
return list(map(list, zip(*matrix)))
class SimpleAttentionWithKVCache:
"""Einfache Self-Attention mit KV Cache"""
def __init__(self, d_model: int):
self.d_model = d_model
self.Wq = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
self.Wk = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
self.Wv = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
def forward(self, x_new: list, kv_cache: tuple = None) -> tuple:
"""
Ein-Schritt-Generierung mit KV-Cache
x_new: aktuelle neue Eingabe (einzelne Position)
kv_cache: vorheriges K und V, (K_cache, V_cache)
Gibt zurück: (Ausgabe, neuer kv_cache)
"""
# Berechne Q, K, V für die aktuelle Position
q = matmul([x_new], self.Wq)
k = matmul([x_new], self.Wk)
v = matmul([x_new], self.Wv)
# Aktualisiere den KV-Cache
if kv_cache is None:
K_full = k
V_full = v
else:
K_cache, V_cache = kv_cache
K_full = K_cache + k
V_full = V_cache + v
# Attention: Verwende nur das Q der aktuellen Position
scores = matmul(q, transpose(K_full))
scale = math.sqrt(self.d_model)
scores_scaled = [[v / scale for v in row] for row in scores]
weights = [softmax(row) for row in scores_scaled]
output = matmul(weights, V_full)
return output[0], (K_full, V_full)
# Demonstriere den KV-Cache-Generierungsprozess
d_model = 4
# Simuliere 4 Generierungsschritte
inputs = [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
]
attn = SimpleAttentionWithKVCache(d_model)
print("=" * 50)
print("Beispiel für eine KV-Cache-Demonstration")
print("=" * 50)
kv_cache = None
for step, x in enumerate(inputs):
output, kv_cache = attn.forward(x, kv_cache)
K_cache, V_cache = kv_cache
print(f"\n"Schritt {step}:")
print(f" KV-Cache-Größe: {len(K_cache)} Positionen")
print(f" Ausgabe: {[f'{v:.4f}' for v in output]}")
print("\n" + "=" * 50)
print("Hinweis: Jeder Schritt berechnet nur das K/V der aktuellen Position,")
print("hängt es dann an den Cache an, sodass die vorherigen nicht neu berechnet werden müssen!")
print("=" * 50)
Der KV-Cache hat einen geringen Preis: Er benötigt zusätzlichen GPU-Speicher zum Speichern von K und V. Für ein 7B-Modell mit einem 4k-Kontext benötigt der KV-Cache etwa 1-2 GB GPU-Speicher – das ist es wert.
Es gibt auch fortgeschrittenere Optimierungen:
- Multi-Query Attention: Mehrere Heads teilen sich K/V
- Grouped Query Attention: Gruppierte gemeinsame Nutzung von K/V
Diese reduzieren den Speicherbedarf weiter und verbessern die Inferenzgeschwindigkeit.
Zusammenfassung: Komplette Transformer-Pipeline
Führen wir alle Komponenten zusammen und fassen den vollständigen Transformer-Ablauf zusammen.
Eine Decoder-Only-Transformer-Schicht
x → LayerNorm → Masked Multi-Head Attention → 残差连接 →
→ LayerNorm → FFN → 残差连接 → 输出
Vollständiges Decoder-Only-Modell
- Eingabe-Token-IDs → Wort-Embedding
- Positionelle Enkodierung hinzufügen
- Durchlaufen von N Transformer-Schichten
- Finale LayerNorm
- Lineare Schicht zur Abbildung auf die Vokabulargröße
- Softmax, um die Wahrscheinlichkeit des nächsten Tokens zu erhalten
Wichtige Hyperparameter
| Hyperparameter | Bedeutung | GPT-2 (124M) | LLaMA-7B | GPT-3 (175B) |
|---|---|---|---|---|
| d_model | Modell-Dimension | 768 | 4096 | 12288 |
| num_heads | Anzahl der Attention-Heads | 12 | 32 | 96 |
| num_layers | Anzahl der Schichten | 12 | 32 | 96 |
| d_ff | FFN-Zwischendimension | 3072 | 11008 | 49152 |
| context_len | Kontextlänge | 1024 | 2048/4096 | 2048 |
Code-Implementierungsleitfaden
Wenn Sie einen vereinfachten Transformer von Grund auf implementieren möchten, sind die Schritte:
- Layer-Normalisierung implementieren
- Self-Attention implementieren (mit Maskierung)
- Multi-Head-Attention implementieren
- FFN implementieren
- Transformer-Schicht implementieren
- Positionelle Enkodierung implementieren
- Vollständiges Modell zusammenbauen
- Trainingsloop implementieren
In der Praxis verwenden wir Frameworks wie PyTorch anstelle von reinem Python. Die reine Python-Implementierung hier dient dazu, die Prinzipien zu demonstrieren und Ihnen zu helfen, zu verstehen, was jeder Schritt tut.
Weitere ErweiterungenDer beste Weg, den Transformer zu verstehen, besteht darin, ihn selbst zu implementieren. Selbst eine vereinfachte Version wird Ihnen eine neue Perspektive auf viele Details eröffnen.