Deep-Learning-Grundlagen
Deep Learning ist keine mysteriöse Blackbox-Technologie; es ist im Wesentlichen ein mathematischer Funktionsanpasser – geben Sie ihm große Mengen an Eingabe-Ausgabe-Daten, und es findet automatisch die komplexen Beziehungen zwischen ihnen.
Zum Beispiel: Sie geben ihm eine Million Katzenbilder, wobei jedes Bild dem Label "cat" entspricht. Deep Learning findet automatisch heraus, welche Pixelkombinationen wie eine Katze aussehen.
Ein weiteres Beispiel: Sie geben ihm eine Milliarde Sätze menschlicher Sprache, wobei das nächste Wort jedes Satzes das Ziel ist. Deep Learning lernt automatisch, welches Wort angesichts des vorangehenden Kontexts am wahrscheinlichsten als Nächstes kommt.
Der Kernvorteil des Deep Learning besteht darin, dass seine Leistung mit steigender Datenmenge weiter zunimmt.Dazu ist traditionelles maschinelles Lernen nicht in der Lage.
In diesem Kapitel beginnen wir bei null und zerlegen jede Komponente des Deep Learning Schritt für Schritt:
- Mathematische Grundlagen neuronaler Netze
- Der Berechnungsprozess der Vorwärtspropagation
- Die Rolle von Aktivierungsfunktionen
- Entwurf der Verlustfunktion
- Das Prinzip der Backpropagation
- Auswahl des Optimierers
- Regularisierungstechniken
- Normalisierungsmethoden
- Lernratenplanung
Schließlich implementieren wir ein vollständiges mehrschichtiges Perzeptron (MLP) mit PyTorch und verbinden alle Wissenspunkte miteinander.
Dieses Kapitel enthält etwas Mathematik, aber keine Sorge – wir werden jede Formel intuitiv erklären, statt von Ihnen zu verlangen, sie auswendig zu lernen. Der Schwerpunkt liegt darauf zu verstehen, warum sie so entworfen wurde, nicht wie man sie berechnet.
Mathematische Grundlagen neuronaler Netze
Deep Learning baut auf drei mathematischen Teilgebieten auf: linearer Algebra, Analysis und Wahrscheinlichkeitstheorie.
Sie müssen kein Mathe-Experte sein, aber Sie müssen ein paar Kernkonzepte verstehen.
Lineare Algebra: Matrixmultiplikation
Die grundlegendste Operation in neuronalen Netzen ist die Matrixmultiplikation.
Warum Matrizen verwenden? Weil sie den Prozess, dass "mehrere Eingaben durch mehrere Neuronen transformiert werden", prägnant darstellen können.
Schauen wir uns zunächst ein intuitives Beispiel an:
Beispiel
# Intuitives Verständnis der Matrixmultiplikation
# ============================================
import numpy as np
# Angenommen, die Eingabe ist ein 3-dimensionaler Vektor: [Größe, Gewicht, Alter]
# Einheiten sind: Zentimeter, Kilogramm, Jahre
x = np.array([175, 70, 25]) # Eingabevektor, Form (3,)
print(f"Eingabe x: {x}")
print(f"Form der Eingabe: {x.shape}")
# Gewichtsmatrix W: 2 Neuronen, jedes Neuron empfängt 3 Eingaben
# Form ist (Ausgabedimension, Eingabedimension) = (2, 3)
W = np.array([
[0.1, 0.2, 0.3], # Gewichte des 1. Neurons
[0.4, 0.5, 0.6], # Gewichte des 2. Neurons
])
print(f"\nGewichtsmatrix W:\n{W}")
print(f"Form der Gewichte: {W.shape}")
# Bias b: ein Bias pro Neuron
b = np.array([0.1, 0.2]) # Form (2,)
print(f"\nBias b: {b}")
print(f"Form des Bias: {b.shape}")
# Matrixmultiplikation: y = W · x + b
# Hinweis: Der @-Operator von numpy steht für Matrixmultiplikation
y = W @ x + b
print(f"\nAusgabe y: {y}")
print(f"Ausgabeform: {y.shape}")
# Manuell verifizieren
y1 = 0.1 * 175 + 0.2 * 70 + 0.3 * 25 + 0.1 # Das 1. Neuron
y2 = 0.4 * 175 + 0.5 * 70 + 0.6 * 25 + 0.2 # Das 2. Neuron
print(f"\nManuelle Berechnungsverifikation: [{y1}, {y2}]")
Die geometrische Bedeutung der Matrixmultiplikation ist eine "lineare Transformation"—sie kann Vektorräume drehen, skalieren und strecken.
Aber lineare Transformationen allein reichen nicht aus.
Wenn jede Schicht nur Matrixmultiplikation ist, dann ist das gesamte Netzwerk, egal wie tief es ist, äquivalent zu einem einschichtigen Netzwerk, weil die Verkettung mehrerer linearer Transformationen immer noch eine lineare Transformation ist.
Deshalb brauchen wir Aktivierungsfunktionen—um Nichtlinearität einzuführen.
Analysis: Ableitungen und die Kettenregel
Der Kern des Trainings neuronaler Netzwerke ist "Gradientenabstieg"—das Finden der Richtung von Parametern, die den Verlust minimiert.
Der Gradient ist die "Ableitung einer multivariablen Funktion"—er sagt uns, wie stark sich der Verlust ändert, wenn sich jeder Parameter ein wenig ändert.
Schauen wir uns ein einfaches Beispiel an:
Beispiel
# Intuitives Verständnis von Ableitungen
# ============================================
def f(x):
"""Eine einfache Funktion: f(x) = x²"""
return x ** 2
def numerical_derivative(f, x, h=1e-6):
"""Numerische Ableitung: Verwende ein kleines Inkrement h, um die Ableitung anzunähern
Definition der Ableitung: f'(x) = lim(h→0) [f(x+h) - f(x)] / h
"""
return (f(x + h) - f(x)) / h
# Berechne die Ableitung bei x=3
x = 3.0
df_dx = numerical_derivative(f, x)
print(f"f({x}) = {f(x)}")
print(f"f'({x}) ≈ {df_dx}")
print(f"Analytische Lösung (exakter Wert): 2 * {x} = {2 * x}")
# Die Bedeutung der Ableitung verstehen:
# Die Ableitung 6 bedeutet: Bei x=3, wenn x um 1 zunimmt, nimmt f(x) um etwa 6 zu
x_new = x + 0.01
f_new = f(x_new)
print(f"\nx steigt von {x} auf {x_new}")
print(f"f(x) ändert sich von {f(x)} zu {f_new}")
print(f"Tatsächliche Zunahme: {f_new - f(x)}")
print(f"Ableitungsvorhersage: {df_dx * 0.01}")
Für multivariable Funktionen müssen wir die partielle Ableitung nach jeder Variablen berechnen und sie dann zu einem Vektor kombinieren—das ist der Gradient.
Die Kettenregel ist die mathematische Grundlage der Backpropagation. Sie erlaubt uns, die Ableitungen zusammengesetzter Funktionen "Schicht für Schicht zu zerlegen".
Beispiel
# Intuitives Verständnis der Kettenregel
# ============================================
# Betrachte eine zusammengesetzte Funktion: y = f(g(x))
# Wobei: g(x) = x², f(z) = z³
# Dann: y = (x²)³ = x⁶
def g(x):
return x ** 2
def f(z):
return z ** 3
def y(x):
return f(g(x))
# Berechne die Ableitung manuell (Kettenregel):
# dy/dx = df/dz * dz/dx = 3*z² * 2*x = 3*(x²)² * 2*x = 6*x⁵
x = 2.0
z = g(x) # z = 4
dy_dx_chain = 3 * (z ** 2) * 2 * x # Kettenregel-Berechnung
print(f"Kettenregel-Berechnung: dy/dx = {dy_dx_chain}")
# Numerische Ableitungsverifikation
def numerical_derivative_y(x, h=1e-6):
return (y(x + h) - y(x)) / h
dy_dx_numerical = numerical_derivative_y(x)
print(f"Numerische Ableitungsverifikation: dy/dx ≈ {dy_dx_numerical}")
# Analytische Lösung: 6*x^5 = 6*32 = 192
print(f"Analytische Lösung: 6 * {x}^5 = {6 * (x ** 5)}")
Die Kernidee der Kettenregel ist:Zerlege eine komplexe Funktion in einfache Funktionen, differenziere jede und multipliziere sie dann miteinander.。
Backpropagation ist die Anwendung der Kettenregel in neuronalen Netzwerken—sie propagiert Gradienten von der Ausgabe aus schichtweise rückwärts.
Wahrscheinlichkeitstheorie: Bedingte Wahrscheinlichkeit
In Klassifikationsproblemen geben neuronale Netzwerke oft eine "Wahrscheinlichkeitsverteilung" aus—gegeben eine Eingabe, wie groß ist die Wahrscheinlichkeit für jede Klasse.
Die bedingte Wahrscheinlichkeit P(Y|X) repräsentiert "die Wahrscheinlichkeit, dass Y eintritt, gegeben dass X eingetreten ist."
:.2%}
:.2%}
Beispiel
# Verwende ein neuronales Netzwerk, um eine Wahrscheinlichkeitsverteilung auszugeben
# ============================================
import numpy as np
def softmax(x):
"""Softmax-Funktion: wandelt beliebige reelle Zahlen in eine Wahrscheinlichkeitsverteilung um
Die Ausgabewerte summieren sich zu 1, und jeder Wert liegt zwischen [0, 1]
"""
# Subtrahiere den Maximalwert, um numerischen Überlauf zu vermeiden
exp_x = np.exp(x - np.max(x))
return exp_x / np.sum(exp_x)
# Angenommen, die letzte Schicht des neuronalen Netzes gibt 3 „Scores“ (Logits) aus
# Sie entsprechen jeweils „Katze“, „Hund“, „Vogel“
logits = np.array([2.0, 1.0, 0.5])
print(fAusgabe des neuronalen Netzes (Logits): {logits})
# Verwende Softmax, um in Wahrscheinlichkeiten umzuwandeln
probabilities = softmax(logits)
print(fWahrscheinlichkeitsverteilung: {probabilities})
print(fSumme der Wahrscheinlichkeiten: {np.sum(probabilities)})
# Interpretation:
# P(Katze|Eingabebild) ≈ 0.67
# P(Hund|Eingabebild) ≈ 0.24
# P(Vogel|Eingabebild) ≈ 0.09
print("\nKategoriewahrscheinlichkeiten:)
print(fKatze: {probabilities)
print(fHund: {probabilities)
print(fVogel: {probabilities)
Kernpunkte der drei mathematischen Grundlagen:
| Mathematisches Teilgebiet | Kernkonzept | Verwendung im Deep Learning |
|---|---|---|
| Lineare Algebra | Matrixmultiplikation, Vektoren, Tensoren | Netzwerkstruktur darstellen, Vorwärtspropagation effizient berechnen |
| Analysis | Ableitungen, Kettenregel, Gradienten | Backpropagation, Parameter aktualisieren |
| Wahrscheinlichkeitstheorie | Bedingte Wahrscheinlichkeit, Wahrscheinlichkeitsverteilung | Modellunsicherheit, Verlustfunktionen entwerfen |
Vorwärtspropagation
Vorwärtspropagation ist der Berechnungsprozess, bei dem Eingabedaten durch das neuronale Netz „fließen“, von der Eingabeschicht über die versteckte Schicht zur Ausgabeschicht.
Berechnungsgraph
Die Darstellung eines neuronalen Netzes als Berechnungsgraph macht die Logik von Vorwärtspropagation und Backpropagation klarer.
Ein Berechnungsgraph besteht aus Knoten (Operationen) und Kanten (Datenfluss).
Berechnungsgraph eines einfachen zweischichtigen neuronalen Netzes:
Beispiel
# Verwende einen Berechnungsgraphen, um die Vorwärtspropagation zu verstehen
# ============================================
import numpy as np
def relu(x):
"""ReLU-Aktivierungsfunktion: max(0, x)"""
return np.maximum(0, x)
# Ein einfaches zweischichtiges neuronales Netz
# Eingabeschicht (2) → versteckte Schicht (3) → Ausgabeschicht (2)
# Eingabe: x = [x1, x2]
x = np.array([1.0, 2.0])
print(fEingabe x: {x})
# Erste Schicht: Eingabe → versteckte Schicht
W1 = np.array([
[0.1, 0.2], # 1. verstecktes Neuron
[0.3, 0.4], # 2. verstecktes Neuron
[0.5, 0.6], # 3. verstecktes Neuron
])
b1 = np.array([0.1, 0.2, 0.3])
# Zweite Schicht: versteckte Schicht → Ausgabeschicht
W2 = np.array([
[0.1, 0.2, 0.3], # 1. Ausgabeneuron
[0.4, 0.5, 0.6], # 2. Ausgabeneuron
])
b2 = np.array([0.1, 0.2])
# Berechnungsgraph der Vorwärtspropagation
# Schritt 1: lineare Transformation der versteckten Schicht
z1 = W1 @ x + b1
print(f"\nLineare Transformation der versteckten Schicht z1: {z1})
# Schritt 2: Aktivierungsfunktion der versteckten Schicht
a1 = relu(z1)
print(fAktivierung der versteckten Schicht a1: {a1})
# Schritt 3: lineare Transformation der Ausgabeschicht
z2 = W2 @ a1 + b2
print(fLineare Transformation der Ausgabeschicht z2: {z2})
# Schritt 4: Aktivierung der Ausgabeschicht (bei Klassifikationsproblemen normalerweise Softmax)
a2 = softmax(z2) # Verwende die zuvor definierte Softmax-Funktion
print(fAktivierung der Ausgabeschicht a2: {a2})
Der Vorteil des Berechnungsgraphen ist, dass jeder Schritt klar ist, und bei der Backpropagation berechnet man die Gradienten einfach in umgekehrter Reihenfolge.
Neuronale Netze aus der Perspektive der Matrixmultiplikation
Aus der Perspektive der Matrixmultiplikation ist ein neuronales Netz eine Abfolge von "linearen Transformationen + nichtlinearen Aktivierungen".
Jede Schicht kann abstrahiert werden als:
aᵢ = activation(Wᵢ · aᵢ₋₁ + bᵢ)
wobei a₀ die Eingabe x ist.
Diese Formel ist prägnant und dennoch leistungsstark – sie kann fast alle neuronalen Netzwerkstrukturen darstellen, von einfacher logistischer Regression bis hin zu komplexen Transformern.
Beachten Sie die Dimensionsübereinstimmung bei der Matrixmultiplikation: Wenn der Eingabevektor n-dimensional ist und die nächste Schicht m Neuronen hat, muss die Gewichtsmatrix W die Form m×n haben. Auf diese Weise erhält man nach W·x einen m-dimensionalen Vektor.
Die Rolle von Aktivierungsfunktionen
Wie bereits erwähnt: Ohne Aktivierungsfunktionen ist ein tiefes Netz äquivalent zu einem einschichtigen Netz.
Ein einfaches Beispiel soll dies beweisen:
Beispiel
# Warum Aktivierungsfunktionen benötigt werden
# ============================================
import numpy as np
# Angenommen, es gibt ein zweischichtiges Netz, aber keine der Schichten hat eine Aktivierungsfunktion
# Eingabe
x = np.array([1.0, 2.0])
# Gewichte und Bias der ersten Schicht
W1 = np.array([[0.1, 0.2], [0.3, 0.4]])
b1 = np.array([0.1, 0.2])
# Gewichte und Bias der zweiten Schicht
W2 = np.array([[0.5, 0.6], [0.7, 0.8]])
b2 = np.array([0.3, 0.4])
# Die beiden Schichten getrennt berechnen
z1 = W1 @ x + b1
z2 = W2 @ z1 + b2
print(f"Ergebnisse der zweischichtigen Berechnung: {z2}")
# Zu einer einzigen Schicht für die Berechnung zusammenfassen
# Mathematisch kann bewiesen werden: z2 = (W2·W1)·x + (W2·b1 + b2)
W_combined = W2 @ W1
b_combined = W2 @ b1 + b2
z_combined = W_combined @ x + b_combined
print(f"Ergebnis der zusammengefassten einschichtigen Berechnung: {z_combined}")
# Die Ergebnisse sind genau gleich!
print(f"\n"Die beiden Ergebnisse sind gleich: {np.allclose(z2, z_combined)}")
print("Fazit: Ohne Aktivierungsfunktionen ist ein tiefes Netz = ein einschichtiges Netz")
Aktivierungsfunktionen sind die Schlüsselkomponente, die tiefe Netze sinnvoll macht – sie führen Nichtlinearität ein und ermöglichen es dem Netz, komplexe Muster zu lernen.
Aktivierungsfunktionen im Detail
Eine Aktivierungsfunktion bestimmt, wie die Ausgabe eines Neurons auf seine Eingabe reagiert.
Eine gute Aktivierungsfunktion sollte mehrere Bedingungen erfüllen:
- Nichtlinearität: Dies ist ein Muss
- Differenzierbarkeit: damit Gradienten berechnet werden können
- Einfache Berechnung: sowohl Vorwärts- als auch Rückwärtspropagation sollten schnell sein
- Keine Sättigung: Gradienten verschwinden oder explodieren nicht
Schauen wir uns die am häufigsten verwendeten Aktivierungsfunktionen an.
Sigmoid: Sättigung und verschwindende Gradienten
Sigmoid ist eine der frühesten Aktivierungsfunktionen. Sie komprimiert jede reelle Zahl auf einen Wert zwischen (0, 1).
Formel: σ(x) = 1 / (1 + e⁻ˣ)
Beispiel
# Sigmoid-Aktivierungsfunktion
# ============================================
import numpy as np
import matplotlib.pyplot as plt
def sigmoid(x):
"""Sigmoid-Funktion"""
return 1 / (1 + np.exp(-x))
def sigmoid_derivative(x):
"""Ableitung von Sigmoid: σ'(x) = σ(x) * (1 - σ(x))"""
s = sigmoid(x)
return s * (1 - s)
# Einige Werte testen
x_values = [-10, -5, -2, 0, 2, 5, 10]
print("x | sigmoid(x) | sigmoid'(x)")
print("-" * 40)
for x in x_values:
s = sigmoid(x)
d = sigmoid_derivative(x)
print(f"{x:4} | {s:10.6f} | {d:12.6f}")
# Beobachtung: Wenn x sehr groß oder sehr klein ist, nähert sich die Ableitung 0
# Dies ist das Problem des "verschwindenden Gradienten"
x_big = 10.0
print(f"\n"Wenn x = {x_big}:")
print(f" sigmoid(x) = {sigmoid(x_big):.10f} (fast 1)")
print(f" sigmoid'(x) = {sigmoid_derivative(x_big):.10f} (fast 0)")
print("Der Gradient verschwindet! Während der Backpropagation kann der Gradient nicht weitergegeben werden.")
Die Probleme mit Sigmoid sind offensichtlich:
- Wenn |x| > 6, ist der Gradient fast 0, was zu verschwindenden Gradienten führt
- Die Ausgabe ist nicht um 0 zentriert, was die Optimierung beeinträchtigt
- Die Berechnung der Exponentialfunktion ist relativ langsam
Aufgrund dieser Probleme wird Sigmoid im modernen Deep Learning selten in verborgenen Schichten verwendet.
ReLU und Varianten
ReLU (Rectified Linear Unit) ist heute die am häufigsten verwendete Aktivierungsfunktion.
Formel: ReLU(x) = max(0, x)
Einfach, aber sehr effektiv.
Beispiel
# ReLU und seine Varianten
# ============================================
import numpy as np
def relu(x):
"""ReLU:max(0, x)"""
return np.maximum(0, x)
def relu_derivative(x):
"""Ableitung von ReLU: 1, wenn x > 0, sonst 0"""
return (x > 0).astype(float)
def leaky_relu(x, alpha=0.01):
"""Leaky ReLU: x, wenn x > 0, sonst alpha*x"""
return np.where(x > 0, x, alpha * x)
def gelu(x):
"""GELU:Gaußsche Fehler-Lineare Einheit
Dies ist die Aktivierungsfunktion, die üblicherweise in Transformer verwendet wird
Näherungsformel: 0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715*x^3)))
"""
cdf = 0.5 * (1.0 + np.tanh(np.sqrt(2 / np.pi) * (x + 0.044715 * np.power(x, 3))))
return x * cdf
# Test
x_values = [-5, -2, -1, 0, 1, 2, 5]
print("x | ReLU | Leaky | GELU")
print("-" * 45)
for x in x_values:
r = relu(x)
lr = leaky_relu(x)
g = gelu(x)
print(f"{x:4} | {r:4.2f} | {lr:5.2f} | {g:5.2f}")
# Vorteile von ReLU:
print("\n"Vorteile von ReLU:")
print("1. Einfache Berechnung: benötigt nur die max-Operation")
print("2. Keine Sättigung (positives Intervall): Der Gradient ist immer 1")
print("3. Sparsität: einige Neuron-Ausgaben sind 0, was die Robustheit des Modells erhöht")
# Problem mit ReLU: "sterbende ReLU"
print("\n"Problem mit ReLU: sterbende ReLU")
print(Wenn der Input eines Neurons immer negativ ist, dann:)
print(- Ausgabe ist immer 0)
print(- Gradient ist immer 0)
print(- Parameter werden nie aktualisiert)
print(- Dieses Neuron ist 'tot')
Vergleich einiger gängiger Aktivierungsfunktionen:
| Aktivierungsfunktion | Formel | Vorteile | Nachteile | Anwendbare Szenarien |
|---|---|---|---|---|
| Sigmoid | 1/(1+e⁻ˣ) | Ausgabe in (0,1), interpretierbar als Wahrscheinlichkeit | Verschwindender Gradient, nicht nullzentriert, langsame Berechnung | Ausgabeschicht für binäre Klassifikation (selten für versteckte Schichten verwendet) |
| ReLU | max(0, x) | Schnelle Berechnung, Nichtsättigung, Sparsität | Tote ReLU, nicht nullzentrierte Ausgabe | Die meisten versteckten Schichten (Standardwahl) |
| Leaky ReLU | max(αx, x) | Löst das Problem der sterbenden ReLU | α muss manuell ausgewählt werden | Alternative, wenn ReLU versagt |
| GELU | x·Φ(x) | Glatt, funktioniert gut in Transformer | Etwas komplexere Berechnung | Transformer, BERT, GPT usw. |
| Swish/SwiGLU | x·σ(βx) | Überlegen gegenüber ReLU in tiefen Netzwerken | Komplexe Berechnung | Große Modelle wie PaLM, LLaMA |
Auswahlprinzipien
Empfohlene Auswahl für Aktivierungsfunktionen:
- Versteckte Schicht: ReLU bevorzugen, einfach und effektiv
- Wenn du auf Dead ReLU stößt: Versuche Leaky ReLU oder GELU
- Transformer: GELU oder SwiGLU ist die Standardwahl
- Ausgabeschicht für binäre Klassifikation: Sigmoid (gibt Wahrscheinlichkeit aus)
- Ausgabeschicht für Mehrklassenklassifikation: Softmax (gibt Wahrscheinlichkeitsverteilung aus)
- Regressions-Ausgabeschicht: Keine Aktivierung (gibt eine beliebige reelle Zahl aus)
Überdenke die Wahl der Aktivierungsfunktion nicht zu sehr. Normalerweise ist ReLU gut genug. Wenn du bestätigst, dass ReLU der Engpass ist, ist es nicht zu spät, zu anderen zu wechseln.
Verlustfunktionen
Die Verlustfunktion misst die Lücke zwischen der "Vorhersage des Modells" und der "wahren Antwort".
Das Ziel des Trainings ist es, diese Lücke so klein wie möglich zu machen.
Cross-Entropy-Verlust (Klassifikation)
Die am häufigsten verwendete Verlustfunktion für Klassifikationsprobleme ist der Cross-Entropy-Verlust.
Ihre intuitive Bedeutung ist: "Wie sehr unterscheidet sich die vom Modell vorhergesagte Wahrscheinlichkeitsverteilung von der wahren Verteilung."
Beispiel
# Cross-Entropy-Verlust
# ============================================
import numpy as np
def cross_entropy_loss(probs, target_one_hot):
"""Cross-Entropy-Verlust
probs: vom Modell vorhergesagte Wahrscheinlichkeitsverteilung, Form (batch_size, num_classes)
target_one_hot: One-Hot-Kodierung der wahren Labels, gleiche Form wie oben
"""
# Epsilon hinzufügen, um log(0) zu vermeiden
Epsilon = 1e-12
probs = np.clip(probs, epsilon, 1.0 - epsilon)
# cross_entropy = -sum(target * log(pred))
return -np.sum(target_one_hot * np.log(probs)) / len(probs)
def softmax(x):
"""Softmax-Funktion"""
exp_x = np.exp(x - np.max(x, axis=-1, keepdims=True))
return exp_x / np.sum(exp_x, axis=-1, keepdims=True)
# Beispiel: Drei-Klassen-Klassifikationsproblem, Klassen sind "Katze", "Hund", "Vogel"
# Angenommen, das wahre Label ist "Katze" (Index 0), one-hot-kodiert
target = np.array([[1, 0, 0]]) # Die richtige Antwort ist Klasse 0
# Szenario 1: Das Modell sagt sicher "Katze" voraus
logits1 = np.array([[5.0, 0.5, 0.1]]) # Katze hat den höchsten Score
probs1 = softmax(logits1)
loss1 = cross_entropy_loss(probs1, target)
print("Szenario 1: Modell sagt sicher richtig voraus")
print(f" Wahrscheinlichkeitsverteilung: {probs1)
print(f" Verlust: {loss1:.6f} (sehr klein)")
# Szenario 2: Das Modell ist unsicher
logits2 = np.array([[1.0, 0.9, 0.8]]) # Die Scores der drei Klassen sind ähnlich
probs2 = softmax(logits2)
loss2 = cross_entropy_loss(probs2, target)
print("\n"Szenario 2: Modell ist unsicher")
print(f" Wahrscheinlichkeitsverteilung: {probs2)
print(f" Verlust: {loss2:.6f} (mittel)")
# Szenario 3: Das Modell sagt falsch voraus
logits3 = np.array([[0.1, 5.0, 0.5]]) # Hund hat den höchsten Score
probs3 = softmax(logits3)
loss3 = cross_entropy_loss(probs3, target)
print("\n"Szenario 3: Modell sagt falsch voraus")
print(f" Wahrscheinlichkeitsverteilung: {probs3)
print(f" Verlust: {loss3:.6f} (sehr groß)")
Eine vereinfachte Version des Cross-Entropy-Verlusts: Wenn Labels Klassenindizes statt One-Hot-Kodierung sind, kann eine effizientere Berechnungsmethode verwendet werden:
loss = -log(probs[target_class])
Deshalb akzeptiert CrossEntropyLoss in PyTorch direkt Klassenindizes als Labels.
MSE-Verlust (Regression)
Für Regressionsprobleme (Vorhersage kontinuierlicher Werte) ist der am häufigsten verwendete Verlust der Mean Squared Error (MSE).
Formel: MSE = mean((pred - target)²)
Beispiel
# MSE-Verlust
# ============================================
import numpy as np
def mse_loss(pred, target):
"""Mean-Squared-Error-Verlust"""
return np.mean((pred - target) ** 2)
def mae_loss(pred, target):
"""Verlust des mittleren absoluten Fehlers"""
return np.mean(np.abs(pred - target))
# Beispiel: Vorhersage von Hauspreisen (Einheit: Zehntausend Yuan)
# Tatsächliche Hauspreise
target = np.array([100, 200, 150])
# Szenario 1: Vorhersage ist sehr genau
pred1 = np.array([102, 195, 148])
mse1 = mse_loss(pred1, target)
mae1 = mae_loss(pred1, target)
print("Szenario 1: Genaue Vorhersage")
print(f" Vorhersage: {pred1}")
print(f" Tatsächlich: {target}")
print(f" MSE: {mse1:.2f}")
print(f" MAE: {mae1:.2f}")
# Szenario 2: Vorhersage hat einen großen Fehler
pred2 = np.array([102, 280, 148]) # Die Vorhersage für das zweite Haus ist 800,000 höher
mse2 = mse_loss(pred2, target)
mae2 = mae_loss(pred2, target)
print("\n"Szenario 2: Es gibt einen großen Fehler")
print(f" Vorhersage: {pred2}")
print(f" Tatsächlich: {target}")
print(f" MSE: {mse2:.2f} (durch den großen Fehler stark verstärkt)")
print(f" MAE: {mae2:.2f}")
# MSE vs MAE
print("\nMSE vs MAE:")
print("- MSE reagiert empfindlicher auf große Fehler (wegen der Quadrierung)")
print("- MAE ist robuster gegenüber Ausreißern")
print("- Im Allgemeinen wird MSE häufiger verwendet")
Verlust für die Vorhersage des nächsten Wortes bei Sprachmodellen
Das Trainingsziel großer Sprachmodelle (z. B. GPT) ist einfach: Sage anhand des vorausgehenden Kontexts das nächste Wort voraus.
Dies ist im Wesentlichen ein Mehrklassenklassifikationsproblem – jedes Wort im Vokabular ist eine Klasse.
Beispiel
# Verlustfunktion für Sprachmodelle
# ============================================
import numpy as np
def language_model_loss(logits, targets, vocab_size):
"""Sprachmodellverlust
Im Wesentlichen ist es der Kreuzentropieverlust an jeder Position.
"""
batch_size, seq_len, _ = logits.shape
total_loss = 0.0
# Berechne den Verlust an jeder Position in der Sequenz
for i in range(seq_len):
# Vorhergesagte Scores an dieser Position
step_logits = logits[:, i, :]
# In Wahrscheinlichkeiten umwandeln
step_probs = softmax(step_logits)
# Index des Zielworts
step_target = targets[:, i]
# Nur die Wahrscheinlichkeit des Zielworts nehmen
for b in range(batch_size):
target_idx = step_target[b]
target_prob = step_probs[b, target_idx]
# Verlust = -log(p)
total_loss += -np.log(target_prob + 1e-12)
return total_loss / (batch_size * seq_len)
# Ein einfaches Beispiel
vocab_size = 10000 # Angenommen, die Vokabulargröße ist 10,000
batch_size = 2
seq_len = 3
# Modellausgabe: (batch_size, seq_len, vocab_size)
logits = np.random.randn(batch_size, seq_len, vocab_size)
# Wahre Ziele: der Wortindex, der an jeder Position vorhergesagt werden soll
targets = np.array([
[123, 456, 789], # Zielwort für die erste Sequenz
[234, 567, 890], # Zielwort für die zweite Sequenz
])
loss = language_model_loss(logits, targets, vocab_size)
print(f"Sprachmodellverlust: {loss:.4f}")
print("\n"Die Bedeutung dieses Verlusts:")
print("- Für jede Position in der Sequenz")
print("- Das nächste Wort vorhersagen")
print("- Die durchschnittliche Kreuzentropie über alle Positionen berechnen")
Zusammenfassung gängiger Verlustfunktionen:
| Aufgabentyp | Verlustfunktion | Aktivierung der Ausgabeschicht | Label-Format |
|---|---|---|---|
| Binäre Klassifikation | Binäre Kreuzentropie | Sigmoid | 0 oder 1 |
| Mehrklassenklassifikation | Cross-Entropy | Softmax | Klassenindex |
| Multilabel-Klassifikation | Binäre Kreuzentropie | Sigmoid | Mehrere 0/1 |
| Regression | MSE / MAE | Keine | Kontinuierlicher Wert |
| Sprachmodell | Cross-Entropy | Softmax | Wortindex |
Backpropagation
Backpropagation ist der Kernalgorithmus zum Trainieren neuronaler Netze.
Ihr Ziel ist es, den Gradienten der Verlustfunktion bezüglich jedes Parameters zu berechnen und dann Gradientenabstieg zu verwenden, um die Parameter zu aktualisieren.
Herleitung über die Kettenregel
Wir verwenden ein einfaches Beispiel, um Backpropagation Schritt für Schritt herzuleiten.
Beispiel
# Backpropagation manuell implementieren
# ============================================
import numpy as np
def relu(x):
return np.maximum(0, x)
def relu_derivative(x):
return (x > 0).astype(float)
def mse_loss(pred, target):
return np.mean((pred - target) ** 2)
# Das einfachste neuronale Netz: eine lineare Transformation
# y = w * x + b
# Verlust = (y - target)^2
# Eingabe und Ziel
x = np.array(2.0)
target = np.array(7.0)
# Parameter initialisieren
w = np.array(1.0)
b = np.array(0.0)
print(f"Initiale Parameter: w = {w}, b = {b}")
print(f"Ziel: {target}")
# ============================================
# Schritt 1: Vorwärtspropagation
# ============================================
y = w * x + b
loss = (y - target) ** 2
print(f"\n"Vorwärtspropagation:")
print(f" y = {y}")
print(f" loss = {loss}")
# ============================================
# Schritt 2: Backpropagation (Gradienten manuell berechnen)
# ============================================
# Wir müssen berechnen: d_loss/d_w, d_loss/d_b
# Kettenregel Schritt für Schritt anwenden:
# loss = (y - target)^2
# d_loss/d_y = 2 * (y - target)
d_loss_d_y = 2 * (y - target)
# y = w * x + b
# d_y/d_w = x
# d_y/d_b = 1
d_y_d_w = x
d_y_d_b = 1.0
# Kettenregel:
# d_loss/d_w = d_loss/d_y * d_y/d_w
# d_loss/d_b = d_loss/d_y * d_y/d_b
d_loss_d_w = d_loss_d_y * d_y_d_w
d_loss_d_b = d_loss_d_y * d_y_d_b
print(f"\n"Backpropagation:")
print(f" d_loss/d_y = {d_loss_d_y}")
print(f" d_loss/d_w = {d_loss_d_w}")
print(f" d_loss/d_b = {d_loss_d_b}")
# ============================================
# Schritt 3: Parameter mit Gradientenabstieg aktualisieren
# ============================================
learning_rate = 0.1
w_new = w - learning_rate * d_loss_d_w
b_new = b - learning_rate * d_loss_d_b
print(f"\n"Parameter-Update:")
print(f" w: {w} -> {w_new}")
print(f" b: {b} -> {b_new}")
# Überprüfen: useNewParameter vor Vorwärtspropagation verwenden # Verifizierung: Vorwärtspropagation mit neuen Parametern
y_new = w_new * x + b_new
loss_new = (y_new - target) ** 2
print(f"\nVerifizierung: Verifizierung:)
print(f" Alte Vorhersage: {y}, alter Verlust: {loss}" " Alte Vorhersage: {y}, alter Verlust: {loss}")
print(f" Neue Vorhersage: {y_new}, neuer Verlust: {loss_new}" " Neue Vorhersage: {y_new}, neuer Verlust: {loss_new}")
print(f" Verlust gesunken!" " Verlust gesunken!")
Obwohl dieses Beispiel einfach ist, enthält es alle Kernideen der Backpropagation:
- Vorwärtspropagation: Alle Zwischenvariablen berechnen Vorwärtspropagation: alle Zwischenvariablen berechnen
- Backpropagation: von der Ausgabe ausgehend, die Kettenregel verwenden, um Gradienten Schicht für Schicht zu berechnen Backpropagation: von der Ausgabe ausgehend, die Kettenregel verwenden, um Gradienten Schicht für Schicht zu berechnen
- Parameteraktualisierung: Parameter mithilfe des Gradientenabstiegs aktualisieren Parameteraktualisierung: Parameter mithilfe des Gradientenabstiegs aktualisieren
Gradientenfluss in Berechnungsgraphen
Für mehrschichtige neuronale Netzwerke fließen Gradienten rückwärts durch den Berechnungsgraphen. Für mehrschichtige neuronale Netzwerke fließen Gradienten rückwärts durch den Berechnungsgraphen.
Der Gradient jeder Schicht hängt von dem Gradienten ab, der von der nächsten Schicht weitergegeben wird. Der Gradient jeder Schicht hängt von dem Gradienten ab, der von der nächsten Schicht weitergegeben wird.
Beispiel
# Backpropagation für ein zweischichtiges Netzwerk # Backpropagation für ein zweischichtiges Netzwerk
# ============================================
import numpy as np
def relu(x):
return np.maximum(0, x)
def relu_derivative(x):
return (x > 0).astype(float)
# Ein zweischichtiges Netzwerk # Ein zweischichtiges Netzwerk
# Eingabe (2) → versteckte Schicht (3) → Ausgabe (1) # Eingabe (2) → versteckte Schicht (3) → Ausgabe (1)
# Daten # Daten
x = np.array([1.0, 2.0]) # Eingabe # Eingabe
target = np.array([5.0]) # Zielausgabe # Zielausgabe
# Parameterinitialisierung # Parameterinitialisierung
W1 = np.array([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]]) # (3, 2)
b1 = np.array([0.1, 0.2, 0.3]) # (3,)
W2 = np.array([[0.7, 0.8, 0.9]]) # (1, 3)
b2 = np.array([0.4]) # (1,)
print(Initiale Parameter wurden festgelegt.)
# ============================================
# Vorwärtspropagation # Vorwärtspropagation
# ============================================
print("\n=== Vorwärtspropagation ===" === Vorwärtspropagation ===)
# Erste Schicht # Erste Schicht
z1 = W1 @ x + b1 # (3,)
a1 = relu(z1) # (3,)
print(f"z1 = {z1}")
print(f"a1 = {a1}")
# Zweite Schicht # Zweite Schicht
z2 = W2 @ a1 + b2 # (1,)
a2 = z2 # Regressionsaufgabe, keine Aktivierung auf der Ausgabeschicht # Regressionsaufgabe, keine Aktivierung auf der Ausgabeschicht
print(f"z2 = {z2}")
print(f"a2 = {a2}")
# Verlust # Verlust
loss = np.mean((a2 - target) ** 2)
print(f"Verlust = {loss}")
# ============================================
# Backpropagation # Backpropagation
# ============================================
print("\n=== Backpropagation ===" === Backpropagation ===)
# Gradient der Ausgabeschicht # Gradient der Ausgabeschicht
d_loss_d_a2 = 2 * (a2 - target) # (1,)
d_a2_d_z2 = 1.0 # Keine Aktivierungsfunktion # Keine Aktivierungsfunktion
d_loss_d_z2 = d_loss_d_a2 * d_a2_d_z2 # (1,)
print(f"d_loss_d_z2 = {d_loss_d_z2}")
# Gradienten der Parameter der zweiten Schicht # Gradienten der Parameter der zweiten Schicht
d_z2_d_W2 = a1 # (3,), weil z2 = W2@a1 + b2 # (3,), weil z2 = W2@a1 + b2
d_z2_d_b2 = 1.0 # (1,)
d_z2_d_a1 = W2[0] # (3,)
# Parametergradienten berechnen # Parametergradienten berechnen
d_loss_d_W2 = d_loss_d_z2.reshape(-1, 1) @ d_z2_d_W2.reshape(1, -1) # (1, 3)
d_loss_d_b2 = d_loss_d_z2 * d_z2_d_b2 # (1,)
print(f"d_loss_d_W2 = {d_loss_d_W2}")
print(f"d_loss_d_b2 = {d_loss_d_b2}")
# An die versteckte Schicht weitergegebener Gradient # An die versteckte Schicht weitergegebener Gradient
d_loss_d_a1 = d_loss_d_z2 @ d_z2_d_a1 # (3,)
print(f"d_loss_d_a1 = {d_loss_d_a1}")
# Gradient der versteckten Schicht # Gradient der versteckten Schicht
d_a1_d_z1 = relu_derivative(z1) # (3,)
d_loss_d_z1 = d_loss_d_a1 * d_a1_d_z1 # (3,)
print(f"d_loss_d_z1 = {d_loss_d_z1}")
# Gradienten der Parameter der ersten Schicht # Gradienten der Parameter der ersten Schicht
d_z1_d_W1 = x # (2,)
d_z1_d_b1 = 1.0 # (3,)
# Parametergradienten berechnen # Parametergradienten berechnen
d_loss_d_W1 = d_loss_d_z1.reshape(-1, 1) @ d_z1_d_W1.reshape(1, -1) # (3, 2)
d_loss_d_b1 = d_loss_d_z1 * d_z1_d_b1 # (3,)
print(f"d_loss_d_W1 = {d_loss_d_W1}")
print(f"d_loss_d_b1 = {d_loss_d_b1}")
# ============================================
# Parameteraktualisierung # Parameteraktualisierung
# ============================================
print("\n=== Parameteraktualisierung === === Parameteraktualisierung ===)
learning_rate = 0.01
W1_new = W1 - learning_rate * d_loss_d_W1
b1_new = b1 - learning_rate * d_loss_d_b1
W2_new = W2 - learning_rate * d_loss_d_W2
b2_new = b2 - learning_rate * d_loss_d_b2
print("Die Parameter wurden aktualisiert." "Die Parameter wurden aktualisiert.")
Dieses Beispiel zeigt den vollständigen Prozess der Backpropagation. Dieses Beispiel zeigt den vollständigen Prozess der Backpropagation.
Die Kernregel ist: Die Kernregeln sind:
- Für eine lineare Schicht y = Wx + b: dL/dW = (dL/dy)·xᵀ, dL/db = dL/dy Für eine lineare Schicht y = Wx + b: dL/dW = (dL/dy)·xᵀ, dL/db = dL/dy
- Für eine Aktivierungsfunktion y = f(z): dL/dz = dL/dy ⊙ f'(z) (⊙ ist elementweise Multiplikation) Für eine Aktivierungsfunktion y = f(z): dL/dz = dL/dy ⊙ f'(z) (⊙ ist elementweise Multiplikation)
- Gradienten breiten sich von hinten nach vorne aus, und jede Schicht verwendet die Kettenregel, um Gradienten zu "akkumulieren". Gradienten breiten sich von hinten nach vorne aus, und jede Schicht verwendet die Kettenregel, um Gradienten zu "akkumulieren".
Gute Nachricht: Moderne Deep-Learning-Frameworks (wie PyTorch, TensorFlow) berechnen die Backpropagation automatisch für Sie. Sie müssen nur den Vorwärtsdurchlauf definieren, und das Framework übernimmt die gesamte Gradientenberechnung mit automatischer Differenzierung (autograd). Gute Nachricht: Moderne Deep-Learning-Frameworks (wie PyTorch, TensorFlow) berechnen die Backpropagation automatisch für Sie. Sie müssen nur den Vorwärtsdurchlauf definieren, und das Framework übernimmt die gesamte Gradientenberechnung mit automatischer Differenzierung (autograd).
Gradientenabstiegs-Optimierer
Nach der Berechnung der Gradienten benötigen wir einen Optimierer, um die Parameter zu aktualisieren. Nach der Berechnung der Gradienten benötigen wir einen Optimierer, um die Parameter zu aktualisieren.
Der einfachste Optimierer ist der stochastische Gradientenabstieg (SGD), aber es gibt viele fortgeschrittenere Optimierer. Der einfachste Optimierer ist der stochastische Gradientenabstieg (SGD), aber es gibt viele fortgeschrittenere Optimierer.
SGD: Stochastischer Gradientenabstieg
Formel: θ = θ - η·∇L(θ) Formel: θ = θ - η·∇L(θ)
wobei η die Lernrate ist. wobei η die Lernrate ist.
Beispiel
# SGD-Optimierer # SGD-Optimierer
# ============================================
import numpy as np
def f(x):
"""Die Funktion, die wir minimieren wollen: f(x) = x²""" """Die Funktion, die wir minimieren wollen: f(x) = x²"""
return x ** 2
def f_grad(x):
"""Gradient: f'(x) = 2x""" """Gradient: f'(x) = 2x"""
return 2 * x
# SGD-Optimierung # SGD-Optimierung
x = 10.0 # Anfangswert # Anfangswert
learning_rate = 0.1 # Lernrate # Lernrate
steps = 20
print(f"Startwert x = {x}, f(x) = {f(x)}" "Startwert x = {x}, f(x) = {f(x)}")
print("-" * 40)
for step in range(steps):
grad = f_grad(x)
x = x - learning_rate * grad # SGD-Aktualisierung # SGD-Aktualisierung
print(f"Schritt {step+1}: x = {x:.6f}, f(x) = {f(x):.6f}")
print("-" * 40)
print(f"Endwert x = {x:.6f}, f(x) = {f(x):.6f}" "Endwert x = {x:.6f}, f(x) = {f(x):.6f}")
print("Theoretisches Optimum: x = 0, f(x) = 0" "Theoretisches Optimum: x = 0, f(x) = 0")
SGD ist einfach, hat aber Nachteile: SGD ist einfach, hat aber Nachteile:
- Verwendet dieselbe Lernrate für alle Parameter
- Die Konvergenz kann langsam sein
- Bleibt leicht in lokalen Optima oder Sattelpunkten stecken.
- Empfindlich gegenüber Rauschen
Momentum: Beschleunigung mit Momentum
Momentum führt das Konzept der "Geschwindigkeit" ein — es verleiht Gradienten-Updates Trägheit. Momentum führt das Konzept der "Geschwindigkeit" ein — es verleiht Gradienten-Updates Trägheit.
Formel: Formel:
v = β·v + (1-β)·∇L(θ) θ = θ - η·v
β wird normalerweise auf 0.9 gesetzt. β wird normalerweise auf 0.9 gesetzt.
Beispiel
# Momentum-Optimierer # Momentum-Optimierer
# ============================================
import numpy as np
def f(x):
"""Zielfunktion""" """Zielfunktion"""
return x ** 2
def f_grad(x):
"""Gradient""" """Gradient"""
return 2 * x
def sgd_optimize(x_init, learning_rate, steps):
"""Reines SGD""" """Reines SGD"""
x = x_init
history = [x]
for _ in range(steps):
grad = f_grad(x)
x = x - learning_rate * grad
history.append(x)
return np.array(history)
def momentum_optimize(x_init, learning_rate, beta, steps):
"""Momentum"""
x = x_init
v = 0.0 # Anfangsgeschwindigkeit # Anfangsgeschwindigkeit
history = [x]
for _ in range(steps):
grad = f_grad(x)
v = beta * v + (1 - beta) * grad
x = x - learning_rate * v
history.append(x)
return np.array(history)
# Vergleich # Vergleich
x_init = 10.0
learning_rate = 0.1
Beta = 0.9
steps = 20
sgd_history = sgd_optimize(x_init, learning_rate, steps)
momentum_history = momentum_optimize(x_init, learning_rate, beta, steps)
print("Schritt | SGD | Momentum")
print("-" * 35)
for i in range(steps + 1):
print(f"{i:4} | {sgd_history[i]:9.6f} | {momentum_history[i]:9.6f}")
print("\nBeobachtung: Momentum konvergiert schneller!)
print(Weil es vergangene Gradienten akkumuliert und ihm dadurch Trägheit verleiht.)
Adam: Adaptive Lernrate
Adam (Adaptive Moment Estimation) ist derzeit einer der am häufigsten verwendeten Optimierer.
Es kombiniert die Ideen von Momentum (erstes Moment) und RMSProp (zweites Moment), um für jeden Parameter eine adaptive Lernrate beizubehalten.
Formel:
m = β₁·m + (1-β₁)·∇L(θ) v = β₂·v + (1-β₂)·(∇L(θ))² m̂ = m / (1-β₁ᵗ) v̂ = v / (1-β₂ᵗ) θ = θ - η·m̂ / (√v̂ + ε)
Standard-Hyperparameter: β₁=0.9, β₂=0.999, ε=1e-8, η=1e-3.
Beispiel
# Adam-Optimierer
# ============================================
import numpy as np
def adam_optimize(x_init, learning_rate, beta1, beta2, epsilon, steps):
"""Adam-Optimierung"""
x = x_init
m = 0.0 # Erstes Moment
v = 0.0 # Zweites Moment
history = [x]
for t in range(1, steps + 1):
grad = 2 * x # Gradient von f(x) = x²
# Erstes und zweites Moment aktualisieren
m = beta1 * m + (1 - beta1) * grad
v = beta2 * v + (1 - beta2) * (grad ** 2)
# Bias-Korrektur
m_hat = m / (1 - beta1 ** t)
v_hat = v / (1 - beta2 ** t)
# Parameteraktualisierung
x = x - learning_rate * m_hat / (np.sqrt(v_hat) + epsilon)
history.append(x)
return np.array(history)
# Parameter
x_init = 10.0
learning_rate = 0.5
beta1 = 0.9
beta2 = 0.999
Epsilon = 1e-8
steps = 20
# Optimierung
history = adam_optimize(x_init, learning_rate, beta1, beta2, epsilon, steps)
print("Adam-Optimierungsprozess:")
print("Schritt | x | f(x)")
print("-" * 35)
for i in range(steps + 1):
print(f"{i:4} | {history[i]:9.6f} | {history[i]**2:9.6f}")
AdamW: Weight-Decay-Korrektur
AdamW ist eine verbesserte Version von Adam — sie trennt Weight Decay vom Gradienten.
Dies ist nützlich für die Regularisierung.
Optimierer-Vergleich:
| Optimierer | Formel | Vorteile | Nachteile | Anwendbare Szenarien |
|---|---|---|---|---|
| SGD | θ = θ - η·∇L | Einfach, stabil | Langsame Konvergenz, empfindlich gegenüber der Lernrate | Große Datensätze, umfangreiche Erfahrung mit Hyperparameter-Tuning |
| SGD + Momentum | v = βv + (1-β)∇L θ = θ - ηv | Beschleunigt die Konvergenz, reduziert Oszillation | Erfordert Tuning von β | Im Allgemeinen besser als reines SGD |
| Adam | Adaptive Lernrate | Schnelle Konvergenz, erfordert weniger Hyperparameter-Tuning | Kann schlechter generalisieren als SGD | Die meisten Szenarien (Standardwahl) |
| AdamW | Adam + Weight Decay | Bessere Regularisierung | Erfordert Tuning des Weight-Decay-Koeffizienten | Transformers, große Modelle |
Vorschlag zur Optimierer-Auswahl: Probieren Sie zuerst Adam, mit einer Lernrate von 1e-3 (3e-4 ist eine sicherere Wahl). Wenn die Ergebnisse unbefriedigend sind, versuchen Sie, Hyperparameter abzustimmen oder zu einem anderen Optimierer zu wechseln.
Regularisierungstechniken
Das Ziel der Regularisierung ist es, Overfitting zu verhindern — und dem Modell zu helfen, bei ungesehenen Daten gut abzuschneiden.
L1/L2-Regularisierung
L1- und L2-Regularisierung sind die grundlegendsten Regularisierungsmethoden.
Sie fügen der Verlustfunktion eine Strafe für Parameter hinzu:
L_total = L + λ·L_reg
L1-Regularisierung (Lasso): L_reg = |w|
L2-Regularisierung (Ridge): L_reg = w²
Beispiel
# L1/L2-Regularisierung
# ============================================
import numpy as np
def l1_regularization(weights, lambda_l1):
"""L1-Regularisierung: Gibt den Regularisierungsterm und den Gradienten zurück"""
reg = lambda_l1 * np.sum(np.abs(weights))
grad = lambda_l1 * np.sign(weights)
return reg, grad
def l2_regularization(weights, lambda_l2):
"""L2-Regularisierung: Gibt den Regularisierungsterm und den Gradienten zurück"""
reg = 0.5 * lambda_l2 * np.sum(weights ** 2)
grad = lambda_l2 * weights
return reg, grad
# Beispiel
weights = np.array([1.0, -2.0, 3.0, 0.5])
print(f"Gewichte: {weights}")
lambda_l1 = 0.1
lambda_l2 = 0.1
l1_reg, l1_grad = l1_regularization(weights, lambda_l1)
print(f"\nL1-Regularisierungsterm: {l1_reg:.4f}")
print(f"L1-Gradient: {l1_grad}")
l2_reg, l2_grad = l2_regularization(weights, lambda_l2)
print(f"\nL2-Regularisierungsterm: {l2_reg:.4f}")
print(f"L2-Gradient: {l2_grad}")
print("\nL1 vs L2:")
print("- L1: neigt dazu, dünnbesetzte Lösungen zu erzeugen (viele Parameter werden 0), kann zur Merkmalsauswahl verwendet werden")
print("- L2: neigt dazu, alle Parameter sehr klein zu machen, aber nicht null")
print("- In Deep Learning wird L2 häufiger verwendet (oder Weight Decay über AdamW)")
Dropout
Dropout ist eine einfache, aber effektive Regularisierungstechnik: Während des Trainings wird zufällig ein Teil der Neuronen "ausgeschaltet".
Dies zwingt das Modell, robusterere Merkmale zu lernen — es kann sich nicht auf ein bestimmtes Neuron verlassen.
Beispiel
# Dropout
# ============================================
import numpy as np
def dropout_forward(x, dropout_rate, training=True):
"""Dropout-Vorwärtsdurchlauf"""
if not training:
# Während des Tests kein Dropout, direkt zurückgeben
return x
# Maske erzeugen: zufällig einen Teil der Neuronen auf 0 setzen
mask = (np.random.rand(*x.shape) > dropout_rate).astype(float)
# Skalierung: Beim Testen ist keine Skalierung nötig, daher während des Trainings durch (1 - dropout_rate) teilen
scale = 1.0 / (1.0 - dropout_rate)
# Dropout anwenden
out = x * mask * scale
return out, mask
# Test
x = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
dropout_rate = 0.4
print(f"Eingabe x: {x}")
print(f"Dropout-Rate: {dropout_rate}")
# Mehrfach ausführen, um die Zufälligkeit zu beobachten
print("\nMehrere Dropout-Ergebnisse:")
for i in range(3):
out, mask = dropout_forward(x, dropout_rate, training=True)
print(f" Runde {i+1}: mask={mask}, out={out}")
# Testmodus
out_test = dropout_forward(x, dropout_rate, training=False)
print(f"\nTestmodus (kein Dropout): {out_test}")
Datenaugmentierung
Daten-Augmentierung ist eine der effektivsten Regularisierungsmethoden — sie erzeugt mehr Trainingsbeispiele, indem vorhandene Daten transformiert werden.
Für Bilder gehören zu den üblichen Daten-Augmentierungstechniken: Spiegeln, Drehen, Skalieren, Zuschneiden, Farb-Jittering usw.
Für Text gehören zu den üblichen Daten-Augmentierungstechniken: Synonymersetzung, zufälliges Einfügen, zufälliges Löschen, Rückübersetzung usw.
Zusammenfassung der Regularisierungstechniken:
| Methode | Prinzip | Vorteile | Nachteile |
|---|---|---|---|
| L2-Regularisierung | Bestraft große Gewichte | Einfach und stabil | Begrenzte Wirkung |
| Dropout | Lässt Neuronen zufällig ausfallen | Einfach und effektiv | Verlangsamt das Training |
| Datenaugmentierung | Erhöht die Vielfalt der Trainingsdaten | Signifikanter Effekt | Erfordert Domänenwissen |
| Frühzeitiges Stoppen | Stoppen, wenn sich der Validierungssatz nicht mehr verbessert | Einfach und effektiv | Erfordert die Überwachung des Validierungssatzes |
| Batch-Normalisierung | Layer-Eingaben normalisieren | Beschleunigt das Training, regularisiert | Erzeugt zusätzlichen Rechenaufwand |
Batch-Normalisierung vs. Layer-Normalisierung
Normalisierungstechniken können das Training beschleunigen und die Optimierung stabiler machen.
Interne Kovariatenverschiebung
Beim Training tiefer Netzwerke ändert sich die Eingabeverteilung jeder Schicht, wenn sich die Parameter der vorherigen Schicht ändern. Dies ist die "interne Kovariatenverschiebung" (Internal Covariate Shift).
Dies verlangsamt das Training, weil sich jede Schicht ständig an neue Verteilungen anpassen muss.
Das Ziel von Normalisierungstechniken ist es, die Eingabeverteilung jeder Schicht stabil zu halten.
Batch-Normalisierung
Batch-Normalisierung (BatchNorm) normalisiert entlang der Batch-Dimension.
Formel:
μ = 1/N · sum(xᵢ) σ² = 1/N · sum((xᵢ - μ)²) x̂ᵢ = (xᵢ - μ) / √(σ² + ε) yᵢ = γ·x̂ᵢ + β
wobei γ und β lernbare Parameter sind.
Beispiel
# Batch-Normalisierung
# ============================================
import numpy as np
def batch_norm(x, gamma, beta, epsilon=1e-5, training=True, running_mean=None, running_var=None, momentum=0.1):
"""Batch-Normalisierung
x: Eingabe, Form (batch_size, features)
gamma: Skalierungsparameter
beta: Offset-Parameter
"""
if training:
# Trainingsmodus: Statistiken des aktuellen Batches verwenden
batch_mean = np.mean(x, axis=0)
batch_var = np.var(x, axis=0)
# Laufende Statistiken aktualisieren (zur Verwendung zur Testzeit)
if running_mean is not None and running_var is not None:
running_mean[:] = momentum * running_mean + (1 - momentum) * batch_mean
running_var[:] = momentum * running_var + (1 - momentum) * batch_var
# Normalisieren
x_normalized = (x - batch_mean) / np.sqrt(batch_var + epsilon)
else:
# Testmodus: laufende Statistiken verwenden
x_normalized = (x - running_mean) / np.sqrt(running_var + epsilon)
# Skalieren und Verschieben
out = gamma * x_normalized + beta
return out
# Test
batch_size = 4
features = 3
# Ein Batch von Daten
x = np.array([
[1.0, 2.0, 3.0],
[2.0, 3.0, 4.0],
[3.0, 4.0, 5.0],
[4.0, 5.0, 6.0],
])
print(f"Eingabe x:\n{x}")
# Initiale Parameter
gamma = np.ones(features) # Anfangs keine Skalierung
beta = np.zeros(features) # Anfangs kein Offset
print(f"\ngamma: {gamma}")
print(f"beta: {beta}")
# Batch-Normalisierung anwenden
out = batch_norm(x, gamma, beta, training=True)
print(f"\nNach der Batch-Normalisierung:\n{out}")
# Überprüfen: Der Mittelwert jedes Features sollte nahe 0 liegen, die Varianz nahe 1
print(f"\nNormalisierter Mittelwert: {np.mean(out, axis=0)}")
print(f"Normalisierte Varianz: {np.var(out, axis=0)}")
Vorteile der Batch-Normalisierung:
- Beschleunigt das Training
- Ermöglicht größere Lernraten
- Reduziert die Empfindlichkeit gegenüber der Initialisierung
- Hat eine leichte Regularisierungswirkung
Nachteile der Batch-Normalisierung:
- Hängt von der Batch-Größe ab – funktioniert schlecht bei zu kleinen Batches
- Verhalten unterscheidet sich zwischen Training und Test
- Nicht geeignet für Sequenzmodelle (z. B. RNN, Transformer)
Layer-Normalisierung
Layer-Normalisierung (LayerNorm) normalisiert entlang der Feature-Dimension anstelle der Batch-Dimension.
Dadurch ist sie unabhängig von der Batch-Größe und sehr gut für Sequenzmodelle geeignet.
Beispiel
# Layer-Normalisierung
# ============================================
import numpy as np
def layer_norm(x, gamma, beta, epsilon=1e-5):
"""Layer-Normalisierung
x: Eingabe, Form (batch_size, features) oder (batch_size, seq_len, features)
"""
# Mittelwert und Varianz über die letzte Dimension (Feature-Dimension) berechnen
mean = np.mean(x, axis=-1, keepdims=True)
var = np.var(x, axis=-1, keepdims=True)
# Normalisieren
x_normalized = (x - mean) / np.sqrt(var + epsilon)
# Skalieren und Verschieben
out = gamma * x_normalized + beta
return out
# Test
batch_size = 2
seq_len = 3
features = 4
# Eingabe in Transformer simulieren: (batch_size, seq_len, features)
x = np.array([
[[1.0, 2.0, 3.0, 4.0],
[2.0, 3.0, 4.0, 5.0],
[3.0, 4.0, 5.0, 6.0]],
[[4.0, 5.0, 6.0, 7.0],
[5.0, 6.0, 7.0, 8.0],
[6.0, 7.0, 8.0, 9.0]],
])
print(f"Eingabe x-Form: {x.shape}")
# Parameter initialisieren
gamma = np.ones(features)
beta = np.zeros(features)
# Layer-Normalisierung anwenden
out = layer_norm(x, gamma, beta)
print(f"Form nach Layer-Normalisierung: {out.shape}")
# Überprüfen: Mittelwert an jeder Position nahe 0, Varianz nahe 1
mean_check = np.mean(out, axis=-1)
var_check = np.var(out, axis=-1)
print(f"\nMittelwert an jeder Position:\n{mean_check}")
print(f"Varianz an jeder Position:\n{var_check}")
Vergleich der beiden Normalisierungsmethoden:
| Methode | Normalisierungsdimension | Hängt vom Batch ab | Anwendbare Szenarien | Repräsentative Modelle |
|---|---|---|---|---|
| BatchNorm | Batch-Dimension | Ja | CNN, feste Batch-Größe | ResNet、VGG |
| LayerNorm | Feature-Dimension | nein | Transformer, RNN, Sequenzen variabler Länge | GPT、BERT、LLaMA |
Für Transformer und große Sprachmodelle ist LayerNorm die Standardwahl. Es berechnet Statistiken unabhängig an jeder Position, ist nicht von der Batch-Größe abhängig und muss keine laufenden Statistiken pflegen.
Lernratenplanung
Die Lernrate ist einer der wichtigsten Hyperparameter — zu groß, und es konvergiert nicht, zu klein, und die Konvergenz ist zu langsam.
Learning-Rate-Scheduling passt die Lernrate während des Trainings dynamisch an.
Aufwärmphase
In der frühen Trainingsphase eine kleine Lernrate verwenden, um "warm up" durchzuführen und das Modell zu stabilisieren, dann auf die Ziel-Lernrate erhöhen.
Dies ist besonders wichtig für Transformer.
Kosinus-Annealing
Kosinus-Annealing verringert die Lernrate schrittweise entsprechend der Form der Kosinusfunktion.
Beispiel
# Learning-Rate-Scheduling
# ============================================
import numpy as np
def warmup_schedule(step, warmup_steps, base_lr):
"""Linearer Warmup-Zeitplan"""
if step < warmup_steps:
return base_lr * (step + 1) / warmup_steps
return base_lr
def cosine_annealing_schedule(step, total_steps, base_lr, min_lr=0.0):
"""Kosinus-Annealing-Zeitplan"""
return min_lr + 0.5 * (base_lr - min_lr) * (1 + np.cos(np.pi * step / total_steps))
def warmup_cosine_schedule(step, warmup_steps, total_steps, base_lr, min_lr=0.0):
"""Warmup + Kosinus-Annealing"""
if step < warmup_steps:
return base_lr * (step + 1) / warmup_steps
return min_lr + 0.5 * (base_lr - min_lr) * (1 + np.cos(np.pi * (step - warmup_steps) / (total_steps - warmup_steps)))
# Parameter
total_steps = 100
warmup_steps = 10
base_lr = 1e-3
min_lr = 1e-5
print("Verschiedene Lernraten-Zeitpläne:")
print("Schritt | Warmup | Cosine | Warmup+Cosine")
print("-" * 55)
for step in range(0, total_steps + 1, 10):
lr_warmup = warmup_schedule(step, warmup_steps, base_lr)
lr_cosine = cosine_annealing_schedule(step, total_steps, base_lr, min_lr)
lr_combined = warmup_cosine_schedule(step, warmup_steps, total_steps, base_lr, min_lr)
print(f"{step:4} | {lr_warmup:.6f} | {lr_cosine:.6f} | {lr_combined:.6f}")
Der Einfluss der Lernrate auf das Training
Der Einfluss der Lernrate:
- Zu groß: Training instabil, kann divergieren
- Zu klein: langsame Konvergenz, kann in lokalen Optima hängen bleiben
- Angemessen: schnelle und stabile Konvergenz
Eine gängige Strategie ist der "Learning Rate Finder": Zuerst trainiert man einige Schritte mit Lernraten, die von klein nach groß ansteigen, und schaut, bei welcher Lernrate der Verlust am schnellsten sinkt.
Zusammenfassung der Methoden zur Lernratenplanung:
| Planungsmethode | Eigenschaften | Anwendbare Szenarien |
|---|---|---|
| Feste Lernrate | Am einfachsten | Kleine Modelle, einfache Aufgaben |
| Step Decay | Alle paar Schritte verringern | CNN usw. |
| Kosinus-Annealing | Sanfte Verringerung | Die meisten Szenarien |
| Warmup + Cosine | Zuerst erhöhen, dann verringern | Transformer, große Modelle (empfohlen) |
| Reduce on Plateau | Verringern, wenn sich das Validierungsset nicht verbessert | Erfordert die Überwachung des Validierungssatzes |
Praxis: Implementierung eines MLP von Grund auf mit PyTorch
Jetzt integrieren wir alle bisherigen Wissenspunkte, implementieren mit PyTorch ein vollständiges mehrschichtiges Perzeptron (MLP) und trainieren es auf einer einfachen Klassifikationsaufgabe.
Beispiel
# Vollständige MLP-Trainingspipeline in PyTorch implementiert
# ============================================
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
import numpy as np
# ============================================
# 1. Daten vorbereiten
# ============================================
class SimpleDataset(Dataset):
"""Ein einfacher binärer Klassifikationsdatensatz"""
def __init__(self, n_samples=1000, n_features=10):
# Synthetische Daten generieren
np.random.seed(42)
self.X = np.random.randn(n_samples, n_features).astype(np.float32)
# Einfache Klassifikationsregel: y = 1 if sum(x[:5]) > 0 else 0
self.y = (np.sum(self.X[:, :5], axis=1) > 0).astype(np.int64)
def __len__(self):
return len(self.X)
def __getitem__(self, idx):
return torch.tensor(self.X[idx]), torch.tensor(self.y[idx])
# Datensatz und DataLoader erstellen
train_dataset = SimpleDataset(n_samples=800)
val_dataset = SimpleDataset(n_samples=200)
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False)
print(f"Trainingsset-Größe: {len(train_dataset)}")
print(f"Validierungsset-Größe: {len(val_dataset)}")
# ============================================
# 2. Modell definieren
# ============================================
class MLP(nn.Module):
"""Mehrschichtiges Perzeptron"""
def __init__(self, input_dim, hidden_dims, output_dim, dropout_rate=0.1):
super().__init__()
layers = []
prev_dim = input_dim
# Versteckte Schicht
for hidden_dim in hidden_dims:
layers.append(nn.Linear(prev_dim, hidden_dim))
layers.append(nn.ReLU())
layers.append(nn.LayerNorm(hidden_dim)) # Layer-Normalisierung
layers.append(nn.Dropout(dropout_rate)) # Dropout
prev_dim = hidden_dim
# Ausgabeschicht
layers.append(nn.Linear(prev_dim, output_dim))
self.model = nn.Sequential(*layers)
def forward(self, x):
return self.model(x)
# Modell erstellen
model = MLP(
input_dim=10,
hidden_dims=[64, 32],
output_dim=2,
dropout_rate=0.1
)
print("\nModellstruktur:")
print(model)
# ============================================
# 3. Verlustfunktion und Optimierer definieren
# ============================================
criterion = nn.CrossEntropyLoss() # Kreuzentropie-Verlust
optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) # AdamW-Optimierer
# Lernraten-Scheduler: Warmup + Cosinus-Annealing
total_epochs = 20
warmup_epochs = 2
total_steps = len(train_loader) * total_epochs
warmup_steps = len(train_loader) * warmup_epochs
def lr_lambda(step):
if step < warmup_steps:
return (step + 1) / warmup_steps
else:
progress = (step - warmup_steps) / (total_steps - warmup_steps)
return 0.5 * (1 + np.cos(np.pi * progress))
scheduler = optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=lr_lambda)
print("\nTrainingskonfiguration:")
print(f" Epochen: {total_epochs}")
print(f" Warmup-Epochen: {warmup_epochs}")
print(f" Gesamtschritte: {total_steps}")
print(f" Warmup-Schritte: {warmup_steps}")
# ============================================
# 4. Trainingsschleife
# ============================================
print("\nStarte Training...")
print("-" * 50)
global_step = 0
best_val_acc = 0.0
for epoch in range(total_epochs):
# Trainingsphase
model.train()
train_loss = 0.0
train_correct = 0
train_total = 0
for batch_X, batch_y in train_loader:
# Vorwärtsdurchlauf
logits = model(batch_X)
loss = criterion(logits, batch_y)
# Rückwärtsdurchlauf
optimizer.zero_grad()
loss.backward()
optimizer.step()
scheduler.step()
# Statistiken
train_loss += loss.item()
predicted = torch.argmax(logits, dim=1)
train_total += batch_y.size(0)
train_correct += (predicted == batch_y).sum().item()
global_step += 1
train_loss /= len(train_loader)
train_acc = train_correct / train_total
# Validierungsphase
model.eval()
val_loss = 0.0
val_correct = 0
val_total = 0
with torch.no_grad():
for batch_X, batch_y in val_loader:
logits = model(batch_X)
loss = criterion(logits, batch_y)
val_loss += loss.item()
predicted = torch.argmax(logits, dim=1)
val_total += batch_y.size(0)
val_correct += (predicted == batch_y).sum().item()
val_loss /= len(val_loader)
val_acc = val_correct / val_total
# Bestes Modell speichern
if val_acc > best_val_acc:
best_val_acc = val_acc
# Hier können Sie einen Modell-Checkpoint speichern
# Log ausgeben
current_lr = optimizer.param_groups[0]['lr']
print(f"Epoche {epoch+1:2d} | lr: {current_lr:.6f} | "
f"Trainingsverlust: {train_loss:.4f} | Trainingsgenauigkeit: {train_acc:.4f} | "
f"Validierungsverlust: {val_loss:.4f} | Validierungsgenauigkeit: {val_acc:.4f}")
print("-" * 50)
print(f"Training abgeschlossen! Beste Validierungsgenauigkeit: {best_val_acc:.4f}")
# ============================================
# 5. Inferenzbeispiel
# ============================================
print("\nInferenzbeispiel:")
model.eval()
# Ein paar Testbeispiele
test_samples = [
np.array([1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float32), # Sollte 1 sein
np.array([-1.0, -1.0, -1.0, -1.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float32), # Sollte 0 sein
]
for i, sample in enumerate(test_samples):
with torch.no_grad():
input_tensor = torch.tensor(sample).unsqueeze(0)
logits = model(input_tensor)
probs = torch.softmax(logits, dim=1)
predicted_class = torch.argmax(probs, dim=1).item()
print(f" Beispiel {i+1}: {sample[:5]}...")
print(f" Vorhergesagte Klasse: {predicted_class}")
print(f" Wahrscheinlichkeiten: Klasse 0={probs[0,0]:.4f}, Klasse 1={probs[0,1]:.4f}")
Dieses Beispiel enthält alle Standardkomponenten des Deep-Learning-Trainings:
- Datenverarbeitung: Dataset, DataLoader
- Modelldefinition: MLP mit LayerNorm und Dropout
- Verlustfunktion: CrossEntropyLoss
- Optimierer: AdamW
- Lernraten-Scheduler: Warmup + Cosine
- Trainingsschleife: Trainingsphase + Validierungsphase
- Modellspeicherung und Inferenz
Dieser Workflow kann direkt auf komplexere Aufgaben und Modelle erweitert werden.
Weitere Erweiterungen