Zurück zur Startseite

Gradientenabstieg und Backpropagation in neuronalen Netzwerken: CUDA/C++

Vertiefung in Gradientenabstieg und Backpropagation. Implementierung von Trainingsalgorithmen neuronaler Netze von Grund auf mit CUDA/C++ für MNIST. Detaillierte Aufschlüsselung von Mathematik und Code.

Gradientenabstieg und Backpropagation: Training neuronaler Netze auf CUDA/C++ von Grund auf
Advertisement 728x90

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.

Google AdInline article slot

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:

Google AdInline article slot

![L = -\sum_{i=1}^{K} y_i \log(\hat{y}_i)](./images/image-1.svg)

Wobei:

  • ![K](./images/image-2.svg) — die Anzahl der Klassen ist (für MNIST ist dies 10).
  • ![y_i](./images/image-3.svg) — der wahre Wert ist (1 für die korrekte Klasse, 0 für andere).
  • ![

\hat{y}_i](./images/image-4.svg) — die Vorhersage des Modells ist (Wahrscheinlichkeit, die nach der Softmax-Schicht erhalten wird).

Google AdInline article slot

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 (![N](./images/image-5.svg) — Batch-Größe):

![L = -\frac{1}{N} \sum_{j=1}^{N} \sum_{i=1}^{K} y_{j,i} \log(\hat{y}_{j,i})](./images/image-6.svg)

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 ![L](./images/image-9.svg) 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 ![W_1](./images/image-10.svg) der ersten Schicht ![

\frac{\partial L}{\partial W_1}](./images/image-8.svg) finden.

Die Architektur unseres Netzes, die in früheren Artikeln entwickelt wurde, sieht wie folgt aus:

  • Erste Lineare Schicht: ![z_1 = W_1 \cdot x + b_1](./images/image-11.svg)
  • Aktivierung (ReLU): ![a_1 = \sigma(z_1)](./images/image-12.svg)
  • Zweite Lineare Schicht: ![z_2 = W_2 \cdot a_1 + b_2](./images/image-13.svg)
  • Ausgabe (Softmax + Verlust): ![L = Loss(Softmax(z_2))](./images/image-14.svg)

Um den Gradienten ![\frac{\partial L}{\partial W_1}](./images/image-16.svg) zu berechnen, wenden wir die Kettenregel an:

![\frac{\partial L}{\partial W_1} = \frac{\partial L}{\partial z_2} \cdot \frac{\partial z_2}{\partial a_1} \cdot \frac{\partial a_1}{\partial z_1} \cdot \frac{\partial z_1}{\partial W_1}](./images/image-17.svg)

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 ![L](./images/image-25.svg) in Bezug auf die Ausgaben der zweiten linearen Schicht ![z_2](./images/image-20.svg) nach Anwendung von Softmax. Dies ist einer der wichtigsten und am häufigsten verwendeten Gradienten. Erinnern Sie sich, wie Softmax für jedes Element ![i](./images/image-19.svg) im Vektor ![z_2](./images/image-20.svg) aussieht:

![a_{2, i} = \frac{e^{z_i}}{\sum_{j=1}^{K} e^{z_j}}](./images/image-21.svg)

Wobei ![z_2](./images/image-22.svg) die Ausgabe der zweiten linearen Schicht (Logits) ist und ![a_2](./images/image-23.svg) das Ergebnis von Softmax (Wahrscheinlichkeiten). Der Gradient ![\frac{\partial L}{\partial z_{2,i}}](./images/image-18.svg) kann mit der Kettenregel zerlegt werden:

![\frac{\partial L}{\partial z_{2,i}} = \sum_{k=1}^{K} \frac{\partial L}{\partial a_{2,k}} \cdot \frac{\partial a_{2, k}}{\partial z_{2,i}}](./images/image-24.svg)

Zuerst finden wir die Ableitung der Verlustfunktion ![L](./images/image-27.svg) in Bezug auf ![a_i](./images/image-26.svg):

![\frac{\partial L}{\partial a_i} = \frac{\partial}{\partial a_i} \left( -y_i \ln(a_i) \right) = -y_i \cdot \frac{1}{a_i} = -\frac{y_i}{a_i}](./images/image-29.svg)

Nun kommen wir zur Ableitung von Softmax in Bezug auf ![z](./images/image-30.svg). Hierfür wird die Quotientenregel der Differentiation benötigt. Die Softmax-Formel: ![a_i = \frac{e^{z_i}}{\sum_{k=1}^K e^{z_k}}](./images/image-32.svg)

Wenn ![i=j](./images/image-43.svg), ist die Ableitung:

![\frac{\partial a_i}{\partial z_i} = a_i (1 - a_i)](./images/image-36.svg)

Wenn ![i \neq j](./images/image-37.svg), ist die Ableitung:

![\frac{\partial a_i}{\partial z_j} = -a_i a_j](./images/image-41.svg)

Mit dem Kronecker-Delta-Symbol ![\delta_{ij}](./images/image-42.svg) können diese beiden Fälle kombiniert werden:

![\frac{\partial a_i}{\partial z_j} = a_i (\delta_{ij} - a_j)](./images/image-44.svg)

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

![\frac{\partial L}{\partial z_i} = \sum_{k=1}^{K} \left( -\frac{y_k}{a_k} \right) \cdot \left( a_k(\delta_{ki} - a_i) \right) = \sum_{k=1}^{K} -y_k (\delta_{ki} - a_i)](./images/image-49.svg)

Eine weitere Vereinfachung, unter Berücksichtigung, dass die Summe der wahren Wahrscheinlichkeiten ![\sum_{k=1}^{K} y_k](./images/image-51.svg) immer gleich 1 ist, führt zu einer überraschend einfachen und eleganten Formel:

![\frac{\partial L}{\partial z_i} = a_i - y_i](./images/image-52.svg)

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 ![z_2](./images/image-54.svg) geändert werden müssen, um den Fehler zu reduzieren. Dieser Gradient wird dann durch die vorhergehenden Schichten des Netzes propagiert, wodurch die Gewichte ![W_2](./images/image-13.svg), ![b_2](./images/image-13.svg), ![W_1](./images/image-11.svg) und ![b_1](./images/image-11.svg) 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

Advertisement 728x90

Weiterlesen