Neuronale Netze trainieren: Gradientenabstieg & Backpropagation in C++/CUDA
Dieser Artikel ist der vierte Teil eines tiefgehenden Einblicks in die Welt der neuronalen Netze, in dem wir eine Transformer-Architektur Schritt für Schritt aufbauen, beginnend mit den grundlegendsten Komponenten in CUDA. In diesem Teil gehen wir von der Implementierung des Forward Pass zur entscheidenden Phase des Modelltrainings über. Wir werden die fundamentalen Mechanismen des Gradientenabstiegs (Gradient Descent) und der Backpropagation erkunden und sie auf niedriger Ebene mit C++ und CUDA implementieren, um unser neuronales Netz darauf zu trainieren, handgeschriebene Ziffern aus dem MNIST-Datensatz zu erkennen. Ziel ist es nicht nur, fertige Bibliotheken zu nutzen, sondern ein tiefgreifendes Verständnis dafür zu entwickeln, wie diese komplexen Berechnungsprozesse "unter der Haube" funktionieren, was für ein umfassendes Verständnis moderner Modelle des maschinellen Lernens und der künstlichen Intelligenz entscheidend ist.
Grundlagen des Trainings neuronaler Netze: Gradientenabstieg
Nachdem wir im vorherigen Teil den Forward Pass erfolgreich implementiert haben, ist unser Modell in der Lage, Vorhersagen zu treffen. Im Anfangsstadium, ohne Training, werden diese Vorhersagen jedoch zufällig sein. Damit das Modell korrekte Ergebnisse liefert, müssen seine internen Parameter (Gewichte und Biases) so angepasst werden, dass der Fehler zwischen Vorhersagen und wahren Labels minimiert wird. Algorithmen wie der Gradientenabstieg und die Backpropagation werden genau für diesen Zweck eingesetzt.
Stellen Sie sich vor, Sie befinden sich auf einem Berggipfel in dichtem Nebel, und Ihre Aufgabe ist es, zum tiefsten Punkt des Tals abzusteigen. Sie können die gesamte Landschaft nicht sehen, aber Sie spüren die Steigung unter Ihren Füßen. Der Gradient ist ein Vektor, der die Richtung des steilsten Anstiegs anzeigt. Folglich müssen Sie sich, um abzusteigen, in die entgegengesetzte Richtung des Gradienten bewegen. Im Kontext neuronaler Netze ist der "Berg" die Oberfläche der Fehlerfunktion (oder Verlustfunktion), und das "Tal" ist ihr globales Minimum. Das Ziel des Gradientenabstiegs ist es, dieses Minimum zu finden, indem die Gewichte des Netzes iterativ in die entgegengesetzte Richtung des Gradienten der Verlustfunktion in Bezug auf diese Gewichte angepasst werden.
Backpropagation ist ein Algorithmus, der diese Gradienten effizient berechnet. Er funktioniert, indem er den Fehler von der Ausgabeschicht des Netzes zurück zur Eingabeschicht propagiert und die Verantwortung für den Fehler auf alle Gewichte verteilt. Mathematisch wird dies durch die Kettenregel der Differentiation implementiert, die es uns ermöglicht zu berechnen, wie eine Änderung jedes Gewichts den Gesamtfehler des Modells beeinflusst.
Verlustfunktion: Kreuzentropie
Um die "Schlechtigkeit" der Modellvorhersagen zu messen, wird eine Verlustfunktion verwendet. Bei Klassifizierungsaufgaben, wie der Erkennung handgeschriebener MNIST-Ziffern, ist die Kreuzentropie-Verlustfunktion (Cross-Entropy Loss) eine Standardwahl. Sie quantifiziert die Divergenz zwischen der vom Modell vorhergesagten Wahrscheinlichkeitsverteilung und der wahren Verteilung (wobei nur die korrekte Klasse eine Wahrscheinlichkeit von 1 hat und andere 0). Je größer diese Divergenz, desto höher ist der Wert der Verlustfunktion.
Für ein einzelnes Beispiel wird die Kreuzentropie mit der Formel berechnet:

Wobei:
-  — die Anzahl der Klassen ist (für MNIST ist dies 10).
-  — der wahre Wert ist (1 für die korrekte Klasse, 0 für andere).
-  — die Vorhersage des Modells ist (Wahrscheinlichkeit, die nach der Softmax-Schicht erhalten wird).
Da das Training normalerweise auf Datenbatches und nicht auf einzelnen Beispielen erfolgt, wird die gesamte Verlustfunktion für einen Batch über dessen Größe gemittelt ( — Batch-Größe):

Unten ist der C++-Code zur Berechnung der Kreuzentropie auf der CPU, was das Debuggen und Verstehen vereinfacht:
float cross_entropy_loss_cpu(
const Tensor& predictions, const std::vector<int>& labels
) {
int batch_size = predictions.shape()[0];
int num_classes = predictions.shape()[1];
float loss = 0.0f;
for (int i = 0; i < batch_size; i++) {
for (int j = 0; j < num_classes; j++) {
float prob = predictions.get({i, j});
float target = (labels[i] == j) ? 1.0f : 0.0f;
loss += -std::log(prob) * target;
}
}
return loss / batch_size;
}
Backpropagation und die Kettenregel
Um die Verlustfunktion  zu minimieren, müssen wir wissen, wie sich ihr Wert bei einer kleinen Änderung jedes Gewichts im Netz ändert. Dies ist der Gradient der Verlustfunktion in Bezug auf die Gewichte. Zum Beispiel müssen wir für das Gewicht  der ersten Schicht  finden.
Die Architektur unseres Netzes, die in früheren Artikeln entwickelt wurde, sieht wie folgt aus:
- Erste Lineare Schicht: 
- Aktivierung (ReLU): 
- Zweite Lineare Schicht: 
- Ausgabe (Softmax + Verlust): 
Um den Gradienten  zu berechnen, wenden wir die Kettenregel an:

Jeder Term in dieser Kette repräsentiert einen lokalen Gradienten, der zeigt, wie der Output einer gegebenen Schicht oder Funktion ihren Input beeinflusst. Backpropagation beginnt mit der Berechnung des Gradienten der Verlustfunktion in Bezug auf die Ausgaben der letzten Schicht und propagiert diesen Gradienten dann sequenziell rückwärts durch alle Schichten des Netzes.
Detaillierte Ableitung der Gradienten für Softmax und Kreuzentropie
Beginnen wir ganz am Ende der Kette – die Ableitung der Verlustfunktion  in Bezug auf die Ausgaben der zweiten linearen Schicht  nach Anwendung von Softmax. Dies ist einer der wichtigsten und am häufigsten verwendeten Gradienten. Erinnern Sie sich, wie Softmax für jedes Element  im Vektor  aussieht:

Wobei  die Ausgabe der zweiten linearen Schicht (Logits) ist und  das Ergebnis von Softmax (Wahrscheinlichkeiten). Der Gradient  kann mit der Kettenregel zerlegt werden:

Zuerst finden wir die Ableitung der Verlustfunktion  in Bezug auf :

Nun kommen wir zur Ableitung von Softmax in Bezug auf . Hierfür wird die Quotientenregel der Differentiation benötigt. Die Softmax-Formel: 
Wenn , ist die Ableitung:

Wenn , ist die Ableitung:

Mit dem Kronecker-Delta-Symbol  können diese beiden Fälle kombiniert werden:

Kombiniert man die Ableitungen von Kreuzentropie und Softmax, erhält man:

Eine weitere Vereinfachung, unter Berücksichtigung, dass die Summe der wahren Wahrscheinlichkeiten  immer gleich 1 ist, führt zu einer überraschend einfachen und eleganten Formel:

Dies bedeutet, dass der Gradient der Verlustfunktion in Bezug auf die Logits (Ausgaben vor Softmax) einfach die Differenz zwischen den vorhergesagten Wahrscheinlichkeiten und den wahren Labels ist. Dieses Ergebnis ist ein Eckpfeiler für die Implementierung der Backpropagation in Klassifizierungsmodellen.
Implementierung des Gradienten in C++
Unter Anwendung der abgeleiteten Formel können wir die Gradientenberechnung für die letzte Schicht unseres neuronalen Netzes implementieren. Dieser Gradient wird dann verwendet, um die Gewichte zu aktualisieren und das Modell in Richtung eines kleineren Fehlers zu bewegen. So sieht es im C++-Code aus:
auto grad = Tensor::create_zeros({ batch_size, num_classes });
for (int i = 0; i < batch_size; ++i) {
for (int j = 0; j < num_classes; ++j) {
float prob = fc2.output->get({i, j});
float target = (targets[i] == j) ? 1.0f : 0.0f;
float gradient = (prob - target) / batch_size; // Division by batch_size for averaging
grad->set({i, j}, gradient);
}
}
Dieser Code berechnet den Fehlergradienten für jedes Element im Batch und mittelt ihn dann über die Batch-Größe. Der resultierende grad-Tensor enthält Informationen darüber, wie stark die Logits  geändert werden müssen, um den Fehler zu reduzieren. Dieser Gradient wird dann durch die vorhergehenden Schichten des Netzes propagiert, wodurch die Gewichte , ,  und  unter Verwendung der entsprechenden lokalen Ableitungen angepasst werden können.
Wichtige Erkenntnisse:
- Gradientenabstieg ist eine iterative Optimierungsmethode, die Modellparameter in die entgegengesetzte Richtung des Gradienten der Verlustfunktion anpasst, um deren Minimum zu finden.
- Backpropagation ist ein Algorithmus, der die Kettenregel verwendet, um die Gradienten der Verlustfunktion in Bezug auf alle Gewichte in einem neuronalen Netz effizient zu berechnen.
- Kreuzentropie ist eine Standard-Verlustfunktion für Klassifizierungsaufgaben, die die Divergenz zwischen vorhergesagten und wahren Wahrscheinlichkeitsverteilungen misst.
- Die Ableitung von Softmax + Kreuzentropie in Bezug auf Logits vereinfacht sich zur Differenz zwischen vorhergesagten Wahrscheinlichkeiten und wahren Labels, was die Implementierung erheblich vereinfacht.
- Die Implementierung von Grund auf in C++/CUDA ermöglicht ein tiefes Verständnis der internen Mechanismen von Modellen wie Transformer, ohne auf hochrangige Abstraktionen angewiesen zu sein.
— Editorial Team
Noch keine Kommentare.