import torch
8 Automatische Differenzierung
In diesem Kapitel leiten wir das Backpropagation-Verfahren aus dem letzten Kapitel her. Genauer gesagt zeigen wir, dass Backpropagation das Konzept des Gradientenabstiegs realisiert. Zunächst entwickeln wir eine Intuition für die Formeln, dann sehen wir uns die mathematische Herleitung der Backpropagation-Formeln an. Dabei wird der Zusammenhang zwischen Zielfunktion, partieller Ableitungen (Gradienten) und Gewichtsanpassung klar. Eine integrale Rolle spielt die Kettenregel und das Konzept der Fehlerwerte \(\delta\).
Nach Abschluss dieses Kapitels können Sie
- den Begriff des Berechnungsgraphen erläutern und dessen Rolle im Kontext der automatischen Differenzierung beschreiben.
- erklären, wie lokale Ableitungen bei Addition und Multiplikation an den Knoten eines Berechnungsgraphen berechnet und entlang des Graphen rückwärts propagiert werden.
- einfache Berechnungsgraphen analysieren und die Ableitungen einzelner Knotenwerte manuell berechnen.
- automatische Differenzierung mit PyTorch (autograd) und Keras (autodiff) praktisch anwenden, um Gradienten zu bestimmen.
- die Ergebnisse der automatischen Differenzierung mit klassischen Backpropagation-Formeln vergleichen und deren Zusammenhang erkennen.
- den praktischen Nutzen automatischer Differenzierung für das Training neuronaler Netze reflektieren.
Wenn man sich die Herleitung im letzten Kapitel ansieht, könnte man denken, dass Backpropagation sehr speziell auf die Struktur eines Neuronalen Netzes und seinen Schichten mit Gewichtsmatrizen zugeschnitten ist. Tatsächlich ist es aber so, dass man Backpropagation viel allgemeiner verstehen kann, wenn man die Berechnungen in einem Feedforward-Netz als Berechnungsgraph versteht (computational graph). In diesem Berechnungsgraphen können dann Gradienten für jeden “Knoten” im Graphen bei jeder Rechnung automatisch berechnet werden, das nennt man auch Automatic Differentiation und wird bei Keras und PyTorch als “Motor” für Backpropagation benutzt. Bei PyTorch heißt dieses Feature auch Autograd. Diese Gradienten entsprechen den partiellen Ableitungen, die wir besprochen haben, und auch bei Automatic Differentiation gibt es immer eine Vorwärtsberechnung und eine Rückwärtsberechnung.
Ein allgemeines Verständnis von Gradienten und Backpropagation, das nicht direkt an Feedforward-Netze gekoppelt ist, wird ganz konkret relevant bei Techniken wie
- Konvolutionsschichten in Kapitel 10
- Rekurrenten Netzen in Kapitel 15
- Transformern und Self-Attention
Überall dort werden an vielen Stellen Parameter eingeführt, die durch Training “gelernt” werden und man fragt sich an diesen Stellen vielleicht, wie denn genau das umgesetzt wird.
Andrej Karpathy erklärt in seinem Video zu Backpropagation (s. unten) das Konzept von Automatic Differentiation mit verblüffend einfachen Mitteln, indem er den Berechnungsgraphen und die Gradientenberechnung in wenigen Zeilen Code programmiert. Bei diesem Erklärungsansatz versteht man sehr intuitiv, dass man für beliebige Rechenoperationen die entsprechenden Paramter über Backpropagation “trainieren” kann.
Insofern kann ich Ihnen das Video nur sehr ans Herz legen. Der Code zu Karpathy’s Video liegt bei GitHub unter karpathy/micrograd (MIT-Lizenz).
Zur Person: Andrej Karpathy ist Informatiker und Spezialist für KI und Computer Vision. Er hat 2016 an der Stanford Universität bei Fei-Fei Li promoviert. 2017-2022 hat er die KI-Abteilung von Tesla aufgebaut, weltweit eine der führenden KI-Abteilungen, die die Autopilot-Mechanismen entwickelt. Er arbeitet seit 2023 wieder bei OpenAI, wo er bereits 2015-17 Research Scientist war. Alle Erklär-Videos seines YouTube-Kanals sind sehr sehenswert.
Ein gute Gelegenheit, Karpathy kennenzulernen, ist auch das Interview von Lex Fridman aus dem Jahr 2022 (etwas 3,5 Stunden, auch als Podcast verfügbar):
8.1 Berechnungsgraph
Wir können alle Funktionen und ihre Formeln, also insbesondere die Formeln zur Berechnung des Outputs \(\hat{y}\) eines Neuronalen Netzes, als Berechnungsgraphen verstehen. Auch die Berechnung der Verlustfunktion \(J\) ist im Grunde genommen eine große Berechnungsformel und kann somit mit einem Berechnungsgraphen dargestellt werden (das sollte auch aus den Abbildungen 7.1 und 7.2 hervorgehen).
Um zu verstehen, was mit einem Berechnungsgraphen gemeint ist, sehen wir uns das einfache Beispiel aus Karpathy’s Video an.
Nehmen wir folgende Gleichung, in der drei Variablen vorkommen:
\[ e = a \cdot b \]
Und jetzt nehmen wir an, dass die Variablen \(a\) und \(b\) festgelegte Werte haben.
\[ \begin{align*} a &:= 2 \\[2mm] b &:= -3 \end{align*} \]
Die Formel kann man in einen Berechnungsgraphen überführen, der die Variablen, ihre Werte und die Rechenoperation darstellt.
Wir könnten den Graphen benutzen, um den Wert von \(e\) zu berechnen (grüne Zahl). Wir gehen dazu von links nach rechts. Das nennt man auch Forward Pass (pass im Sinne von Durchgang):
Wir unterscheiden in unserem Graphen also bislang:
- Variablen, die festgelegte Werte haben (Box gelb unterlegt); in der Welt der Graphen sind diese Boxen sogenannte Blätter (engl. leaf), wohingegen die anderen Boxen Zwischenknoten sind
- Werte, die berechnet werden (grüne Zahl)
Jetzt ergänzen wir zwei weitere Formeln:
\[ \begin{align*} e &= a \cdot b \\[2mm] d &= e + c \\[2mm] L &= d \cdot f \end{align*} \]
Die Berechnung von \(d\) hängt ab von \(e\) und die Berechnung von \(L\) hängt von \(d\) ab. Einige dieser Variablen (die Blätter) sollen wieder festgelegte Werte haben:
\[ \begin{align*} a &:= 2 \\[2mm] b &:= -3 \\[2mm] c &:= 10 \\[2mm] f &:= -2 \end{align*} \]
Wir können alles in einem einzigen Graphen darstellen, die Blätter sind wieder gelb markiert.
Wir führen einen Forward Pass von links nach rechts durch, um alle Werte zu berechnen. Die berechneten Werte sind wieder grün.
Man kann diesen Vorgang wie beim Neuronalen Netz auch Forward Propagation nennen.
8.2 Gradienten
Jetzt möchten wir sehen, wie der Einfluss einer Variablen wie \(a\) oder \(c\) auf unsere “Ausgangsvariable” \(L\) ist. Wenn wir Variable \(a\) ein wenig ändern, also von \(2\) zum Bespiel auf \(2.01\) oder auf \(1.99\), welche Auswirkung hätte das auf \(L\)?
Natürlich könnte man einfach \(a\) ändern und die Auswirkung quasi “messen”, aber das wäre umständlich, wenn man mehrere Milliarden von Parametern hat. Zum Glück wird dieser Zusammenhang eben genau von einer (partiellen) Ableitung beantwortet. Wie bekommen wir diese Ableitungen? Wir fangen zunächst “hinten” an mit unseren Überlegungen. Was ist der Wert der folgenden Ableitung?
\[ \frac{\partial L}{\partial L} \]
Ganz einfach: Diese Ableitung hat den Wert 1. Denn wenn ich \(L\) um 1 erhöhe, erhöht sich \(L\) um 1. Das ist genau der Zusammengang, den eine Ableitung bemisst.
Welchen Wert hat jetzt diese Ableitung?
\[ \frac{\partial L}{\partial d} \]
Hier nehmen leiten wir ganz einfach ab und setzen ein:
\[ \begin{align*} L &= d \cdot f \\[1mm] \frac{\partial L}{\partial d} &= \frac{\partial }{\partial d} (d \cdot f ) \\[1mm] &= f \\[1mm] &= -2.0 \end{align*} \]
Im Graphen sehen wir, dass wir für diese Ableitung den berechneten Wert des Geschwisterknotens benötigen.
In der Abbildung unten sehen Sie auch die Berechnung von
\[ \frac{\partial L}{\partial c} \]
Dieser Fall ist besonders interessant, weil hier zum ersten Mal die Kettenregel zum Zug kommt. Wir verwenden hier nämlich \(\frac{\partial L}{\partial d}\), das wir ja bereits berechnet haben. Bei der Berechnung der Gradienten müssen wir also rückwärts arbeiten, um der Kettenregel Rechnung zu tragen.
Wenn wir die Berechnung der Gradienten für alle Werte fortführen, sieht unser Graph so aus:
Man nennt die Berechnung der Gradienten auch Backward Pass oder Backpropagation.
Karathy zeigt in seinem Video (ab 1:09:00) mit seinem Code Micrograd, wie man diese Gradienten ganz leicht berechnen kann. Dazu berechnet man für jede Rechenoperation (Addition, Multiplikation, Potenz, …) den entsprechenden Gradienten auf Basis der Werte von Geschwisterknoten und mit Hilfe des Gradienten des Elternknotens (wegen der Kettenregel). Die Implementation besprechen wir hier nicht, aber in dem Video wird das ganz ausgezeichnet erläutert.
8.3 Autograd in PyTorch
Wie bereits erwähnt, verfügt PyTorch über einen internen Mechanismus, um Gradienten automatisch zu berechnen. Wir zeigen das hier mit den obigen Beispielformeln und -werten.
Zunächst importieren wir PyTorch.
Wir legen zunächst die Blätter an:
\[ \begin{align*} a &:= 2 \\[2mm] b &:= -3 \\[2mm] c &:= 10 \\[2mm] f &:= -2 \end{align*} \]
Bei diesen Variablen müssen wir PyTorch signalisieren, dass der jeweilige Gradient immer mitberechnet werden soll.
= torch.tensor(-2.0, requires_grad=True)
f = torch.tensor(2.0, requires_grad=True)
a = torch.tensor(-3.0, requires_grad=True)
b = torch.tensor(10.0, requires_grad=True) c
PyTorch kann nur für “Blätter” den Gradienten speichern. Der Grund ist, dass man bei Optimierungsproblemen nur die Gradienten von Blättern benötigt und PyTorch die Ressourcen schonen will. Parameter wie \(w_{i,j}\) oder \(b_i\) sind ja Blätter. Zwischenknoten wären z.B. die Rohinputs \(z\) oder die Aktivierungen \(a\) und die dortigen Gradienten sind nicht relevant (außer als Zwischenergebnis). Siehe auch die Discussion unter Why cant I see .grad of an intermediate variable?
Bei Keras gibt es diese Einschränkung nicht.
Jetzt berechnen wir die anderen Werte mit Hilfe dieser Formeln:
\[ \begin{align*} e &= a \cdot b \\[2mm] d &= e + c \\[2mm] L &= d \cdot f \end{align*} \]
Diese Variablen sind automatisch PyTorch-Tensoren. Hier findet natürlich direkt die Forward Propagation statt, denn die Werte der Variablen werden ausgerechnet.
= a * b
e = e + c
d = d * f
L
print('e = ', e)
print('d = ', d)
print('L = ', L)
e = tensor(-6., grad_fn=<MulBackward0>)
d = tensor(4., grad_fn=<AddBackward0>)
L = tensor(-8., grad_fn=<MulBackward0>)
Mit der Funktion backward
stoßen wir die Berechnung der Gradienten an. Wir geben uns diese auch direkt aus.
L.backward()
print('a.grad = ', a.grad)
print('b.grad = ', b.grad)
print('c.grad = ', c.grad)
print('f.grad = ', f.grad)
a.grad = tensor(6.)
b.grad = tensor(-4.)
c.grad = tensor(-2.)
f.grad = tensor(4.)
Sie sehen, dass die Werte mit unserer Rechnung übereinstimmt:
8.4 Autodiff in TensorFlow
Auch in Keras bzw. TensorFlow gibt es Automatic Differentiation. Sinnvolle Dokumentationen finden Sie unter Introduction to gradients and automatic differentiation und tf.GradientTape.
Zunächst importieren wir TensorFlow.
import tensorflow as tf
Jetzt legen wir Variablen an. Dies sind unsere Blätter. Bei diesen Variablen legt TensorFlow bei Berechnungen im Hintergrund einen Graphen an.
= tf.Variable(2.0)
a = tf.Variable(-3.0)
b = tf.Variable(10.0)
c = tf.Variable(-2.0) f
In TensorFlow benutzt man jetzt GradientTape
als eine “Umgebung”, innerhalb derer Gradienten mitberechnet werden sollen. Wir können dann mit tape.gradient(L, a)
die partielle Ableitung von \(L\) bezüglich \(a\) berechnen lassen.
with tf.GradientTape(persistent=True) as tape:
= a * b
e = e + c
d = d * f
L
print('a grad = ', tape.gradient(L, a))
print('b grad = ', tape.gradient(L, b))
print('c grad = ', tape.gradient(L, c))
print('f grad = ', tape.gradient(L, f))
print('\nd grad = ', tape.gradient(L, d))
print('e grad = ', tape.gradient(L, e))
print('L grad = ', tape.gradient(L, L))
a grad = tf.Tensor(6.0, shape=(), dtype=float32)
b grad = tf.Tensor(-4.0, shape=(), dtype=float32)
c grad = tf.Tensor(-2.0, shape=(), dtype=float32)
f grad = tf.Tensor(4.0, shape=(), dtype=float32)
d grad = tf.Tensor(-2.0, shape=(), dtype=float32)
e grad = tf.Tensor(-2.0, shape=(), dtype=float32)
L grad = tf.Tensor(1.0, shape=(), dtype=float32)
Nach dem ersten Aufruf von gradient
(hier tape.gradient(L, a)
) kann man standardmäßig keine weiteren Gradienten abfragen. Daher haben wir hier persistent=True
eingestellt, damit wir alle Gradienten auslesen können. Da tape
persistent ist, kann man tape.gradient
auch außerhalb des with
aufrufen (das wird sogar ausdrücklich empfohlen).
Auch bei diesem TensorFlow-Beispiel können Sie kurz checken, ob die berechneten Werte stimmen (Gradienten in rot).
Sie sehen hier, dass auch an den Zwischenknoten \(d\), \(e\) und \(L\) die Gradienten ausgegeben werden können, auch wenn das in der Anwendung nicht notwendig ist (Gewichte sind alle Blätter).
8.5 Relevanz für Neuronale Netze und Backpropagation
Nachdem wir viele Beispiele für ein paar sehr simple Gleichungen gesehen haben, kommen wir nochmal auf die Neuronalen Netze zurück. Führen wir uns nochmal die Formeln für Forward Propagation vor Augen:
\[ \begin{align*} z^{(0)} &= x \\[3mm] z^{(l)} &= W^{(l)} \; a^{(l-1)} + b^{(l)} \\[3mm] a^{(l)} &= g(z^{(l)}) \\[3mm] \hat{y} &= a^{(L)} \\[3mm] J &= - \frac{1}{N} \sum_{k=1}^N \sum_{i=1}^m y_i^k \; log(\hat{y}_i^k) \end{align*} \]
Sie sehen vielleicht, dass dies einem großen Berechnungsgraphen entspricht. Sie müssen sich diesen Graphen so vorstellen, dass “links” in der Eingabe aller Trainingsbeispiele eines Batch (also z.B. 32 Trainingsbeispiele) anliegen. Auf der Ausgabeseite, also “rechts”, haben wir eine große Summe in Form der Fehlerformel \(J\).
8.5.1 Einfaches Netz
Wir sehen uns jetzt ein Beispiel an, das zwar auch stark vereinfacht ist, aber eher an ein Neuronales Netz erinnert. Wir nehmen uns zwei Eingabeneuronen \(x_1\) und \(x_2\), die über Gewichte direkt mit dem Output \(\hat{y}\) verbunden sind:
\[ \hat{y} = w_1 x_1 + w_2 x_2 \]
Wir würden das wie folgt in einen Graphen übertragen:
Wir haben Zwischenvariablen \(z_1\) und \(z_2\) eingefügt. Sie erinnern ein bisschen an den Rohinput.
Jetzt wollen wir noch eine Art Fehlerfunktion einbauen:
\[ J = \left( \hat{y} - y \right)^2 \]
Diese Fehlerfunktion ist wirklich sehr einfach. Sie summiert nicht über alle Trainingsbeispiele, sondern misst nur den Fehler des aktuellen Beispiels. Für die Darstellung im Graphen spendieren wir noch eine Zwischenvariable \(d\) für die Differenz \(\hat{y} - y\):
Dieser Graph hat schon einige Ähnlichkeit mit dem, was in einem Neuronalen Netz passiert. Interessant ist hier die Unterscheidung zwischen “Blatt” (gelb) und “Zwischenknoten” (weiß). Die beiden Parameter \(w_1\) und \(w_2\), die wir ja am Ende des Tages anpassen wollen, sind beide Blätter. Die anderen Blätter – \(x_1\), \(x_2\) und \(y\) – kommen aus den Trainingsbeispielen.
Jetzt sehen wir uns die Berechnung für zwei Trainingsbeispiele an. Die Gewichte sind dabei immer gleich. Wir setzen sie auf:
\[ \begin{align*} w_1 &= 0.1\\[3mm] w_2 &= 0.8 \end{align*} \]
8.5.2 Trainingsbeispiel 1
Das erste Trainingsbeispiel hat folgende Werte für Input \(x\) und gewünschten Output \(y\):
\[ \begin{align*} x_1 &= 1.0 \\[3mm] x_2 &= 0.5 \\[3mm] y &= 1 \end{align*} \]
Wir übertragen die Werte für alle “Blätter” in unseren Graphen:
Im Graphen werden jetzt von links nach rechts die Werte an allen Zwischenknoten berechnet:
Anschließend werden über Backpropagation für alle Knoten die Gradienten berechnet. Sie sehen, dass für die Werte des Trainingsbeispiels, also \(x_1\), \(x_2\) und \(y\), die Gradienten uninteressant sind und deshalb hier weggellassen sind.
Sie sehen auch, dass die Gradienten bei \(w_1\) und \(w_2\) uns sagen, ob wir die Gewichte erhöhen oder verringern sollen. In beiden Fällen ist der Gradient negativ, d.h. wenn wir die Gewichte verringern erhöht sich der Fehler. Daher müssen wir die Gewichte erhöhen (umgekehrter Gradient).
8.5.3 Trainingsbeispiel 2
Wir behalten immer die alten Gewichte, aber ändern das Trainingsbeispiel, also das \(x\) und das \(y\):
\[ \begin{align*} x_1 &= 0.1 \\[3mm] x_2 &= 0.8 \\[3mm] y &= 0 \end{align*} \]
Der Graph sieht jetzt wie folgt aus (die alten Gradienten wurden gelöscht):
Im Graphen werden die Werte mit Forward Propagation berechnet:
Anschließend werden die Gradienten mit Backpropagation ermittelt:
Bei \(w_1\) sehen wir, dass das Gewicht keinen Einfluss hat. Das ist klar, weil \(x_1 = 0\) und daher keine Aktivierung transportiert werden kann, egal, wie hoch oder niedrig \(w_1\) wäre. Bei \(w_2\) müssen wir das Gewicht verringern, da der Gradient positiv ist.
Wir hatten oben gesagt, dass die Gradienten beim zweiten Trainingsbeispiel gelöscht wurden. Es kann sinnvoll sein, dies nicht zu tun und stattdessen den neuen Gradienten zum alten zu addieren. Dies wird beim Batch-Training für alle Beispiele im Batch getan, um anschließend den Gradienten über die Batchgröße zu mitteln.
8.6 Fazit
Wie wir gesehen haben, haben Keras und PyTorch Mechanismen, wo die Gradienten an jedem Knoten automatisch berechnet werden. Diese Gradienten können wir direkt für Backpropagation einsetzen, da wir für Backpropagation im Grund “nur” für jedes Gewicht \(w\) die partielle Ableitung des Fehler bezüglich dieses Gewichts benötigen, also
\[ \frac{\partial J}{\partial w} \]
Genau dies bekommen wir aber durch Automatic Differentiation. Die automatische Berechnung von Gradienten in Berechnungsgraphen ist die Verallgemeinerung unseres Backpropagation-Verfahrens und daher für jegliche Art der Parameteranpassung geeignet.