- Der Artikel zeigt, wie man die für das Training neuronaler Netze zentrale automatische Differenzierung mit einer skalaren
Tensor-Klasse selbst implementiert – und wie Wertberechnung und Ableitungsberechnung auf demselben Berechnungsgraphen zusammenhängen
- Bei normalen Python-Variablen bleibt bei
z = x + y nur der Ergebniswert erhalten und die Beziehung geht verloren; daher muss Tensor sowohl den Wert als auch die Operationshistorie speichern
- Mit
Children(a, b, op) und rekursiven forward()-Aufrufen entsteht ein binärer Berechnungsbaum; durch Überladen von Addition und Multiplikation lässt sich der Ausdruck auch dann neu berechnen, wenn Werte erst später eingesetzt werden
grad(deriv_to) setzt die Ableitung nach sich selbst auf 1 und nach anderen Skalaren auf 0 und wendet die Ableitungsregeln der Grundoperationen rekursiv an, um einen neuen Berechnungsgraphen zu erzeugen
- Die Implementierung verarbeitet nur Skalare und kann langsam sein; als Verbesserungen bleiben Array-Operationen, Pruning von Multiplikationszweigen mit 0, Behandlung von Konstantenknoten und ein Cache zur Reduktion wiederholter Berechnungen
Bei normalen Python-Variablen geht die Beziehung verloren
- Wenn man wie
x = 3, y = 5, z = x + y rechnet, bleibt in z nur der Ergebniswert 8 erhalten
- Selbst wenn sich später der Wert von
x oder y ändert, kann z nicht mehr nachverfolgen, aus welchen Variablen es erzeugt wurde
- Da die Beziehungen zwischen Variablen nicht erhalten bleiben, ist es schwierig, Ableitungen nach einer bestimmten Variablen automatisch zu berechnen
Operationshistorie mit Tensor erhalten
- Der neue Typ
Tensor speichert einen Wert (value) und überlädt Operatoren, sodass Berechnungen zwischen Tensor-Objekten einen neuen Tensor zurückgeben
- Die erste Implementierung überlädt nur
__add__, sodass Tensor(3) + Tensor(5) ein T:8 erzeugen kann
- In diesem Schritt wird die Operationshistorie, dass
z das Ergebnis von x + y ist, aber noch nicht erhalten
Berechnungsgraph und forward()
- Um die Operationshistorie zu bewahren, wird
Children = namedtuple('Children', ['a', 'b', 'op']) eingeführt
a: linker Eingabe-Tensor
b: rechter Eingabe-Tensor
op: die eigentliche Operation, etwa np.add oder np.multiply
- Jeder
Tensor kann nicht nur einen Zahlenwert, sondern auch children haben; darüber wird ein Berechnungsgraph in Form eines binären Baums aufgebaut
forward() besucht rekursiv die Kindknoten und berechnet den tatsächlichen Wert
- Aus
x = Tensor(3), y = Tensor(5) sowie z1 = x + y, z2 = z1 * y wird T:40
- Auch wenn man den Graphen zuerst mit
x = Tensor(None), y = Tensor(None) erstellt, später x.value = 3, y.value = 5 setzt und z2.forward() aufruft, wird T:40 berechnet
Automatische Differenzierung als Berechnungsgraph erstellen
- Automatische Differenzierung wird implementiert, indem für jede von
Tensor unterstützte Grundoperation eine Ableitungsregel ergänzt wird
grad(self, deriv_to) durchläuft den Berechnungsgraphen rekursiv und zerlegt eine komplexe Funktion in eine Kombination einfacher Funktionen
- Die Grundregeln lauten:
- Leitet man einen Tensor nach sich selbst ab, ergibt das
Tensor(1)
- Leitet man einen Skalar ohne Kinder nach einem anderen Tensor ab, ergibt das
Tensor(0)
- Addition:
(a + b)' = a' + b'
- Multiplikation:
(ab)' = a'b + ab'
- Leitet man
z2 = (x + y) * y nach y ab, ist das Ergebnis g kein einfacher Wert, sondern ein neuer Berechnungsgraph für die partielle Ableitung
- Als Ausdruck:
g = ∂z2/∂y = x + 2*y
- Für
x = 3, y = 5 hat g den Wert 13
Erweiterung um Subtraktion, Division und Exponentialfunktion
- Um komplexere Ausdrücke zu verarbeiten, werden
Tensor Subtraktion, Division, Exponentialfunktion und Negation hinzugefügt
grad() enthält die zugehörigen Ableitungsregeln für jede Operation
- Subtraktion:
(a - b)' = a' - b'
- Division:
(a/b)' = (a'b - ab') / b²
- Exponentialfunktion:
exp(a)' = a' * exp(a)
- Auch
forward() wird angepasst, um Operationen zu behandeln, die nur einen Operanden benötigen
- Beispiel:
exp(a) benötigt keinen zweiten Operanden b
-x wird als 0 - x verarbeitet
Beispielausdruck und Verifikation mit Sympy
- Der folgende Ausdruck wird mit
Tensor geschrieben, und die partiellen Ableitungen nach x und y werden berechnet
z = (12 - (x * e^y)) / (45 + x * y * e^-x)
- Im Code wird das so ausgedrückt:
x = Tensor(3)
y = Tensor(5)
z = (Tensor(12) - (x * y.exp())) / (Tensor(45) + x * y * (-x).exp())
- Die berechneten Werte der partiellen Ableitungen lauten:
z.grad(x) → T:-3.34729777301069
z.grad(y) → T:-9.70176956641438
- Das Ergebnis derselben Formel, berechnet mit
diff() und evalf() von Sympy, ist identisch
- Für
xs = 3, ys = 5 beträgt der Ableitungswert nach x -3.34729777301069
- Der Ableitungswert nach
y beträgt -9.70176956641438
Einschränkungen der einfachen Implementierung und Optimierungspunkte
- Diese Implementierung kommt einem sehr einfachen System für automatische Differenzierung nahe und kann zugleich sehr langsam sein
- Die aktuelle Klasse verarbeitet nur Skalare
- Damit daraus eine nützlichere Library wird, müssten Array-Operationen beliebiger Größe ergänzt werden
- Am Berechnungsgraphen erkennt man mehrere mögliche Optimierungen
- Wenn bei einem Multiplikationsknoten eines der Kinder
0 ist, muss man nicht tiefer weiter suchen
- Wenn ein Knoten und seine Kinder nicht vom Tensor
x abhängen, nach dem abgeleitet wird, kann man diesen Knoten als Konstante betrachten und den Durchlauf stoppen
- Bei wiederholten gleichen Operationen kann ein Cache verhindern, dass dieselbe Berechnung mehrfach ausgeführt wird
1 Kommentare
Hacker-News-Meinungen
Ich mag solche kleinen, eleganten Code-Demos. Sie helfen dabei, Konzepte zu verstehen, indem man sich selbst die Hände schmutzig macht.
Sasha Rushs GPU-Puzzles und Tensor-Puzzles sind ähnliche Beispiele.
https://github.com/srush/GPU-Puzzles
https://github.com/srush/Tensor-Puzzles
Der Originalcode ist hier: https://github.com/jaymody/picoGPT/blob/main/gpt2.py
Wer glaubt, damit automatische Differentiation vollständig verstanden zu haben, macht sich etwas vor.
Wenn der Graph ein Baum ist, ist alles so einfach wie in diesem Artikel. Wenn der Graph aber ein allgemeinerer gerichteter azyklischer Graph ist, zum Beispiel
x = 5; y = 2x; z = xy, dann bleibt die Implementierung zwar sehr simpel, aber zu verstehen, warum diese Implementierung korrekt ist, ist nicht trivial. Wer denkt, das sei „einfach die normale Kettenregel“, macht sich ebenfalls etwas vor.Eine der frühen Erklärungen stammt von Paul Werbos; er nannte die benötigte Regel die Kettenregel geordneter Ableitungen und bewies sie per Induktion aus der normalen Kettenregel. Trotzdem folgt sie nicht unmittelbar und offensichtlich aus der normalen Kettenregel. Falls jemand vom Gegenteil überzeugt ist, würde ich mich freuen, wenn er mir beweist, dass ich falschliege.
Wenn man die normale Kettenregel entlang der Kanten eines Berechnungsgraphen, also eines gerichteten azyklischen Graphen, anwendet, erhält man in jedem Schritt die richtigen Werte. Die zusätzliche Regel, die man braucht, lautet im Wesentlichen: „Wenn eine Variable in einer Berechnung mehrfach verwendet wird, also mehrere Kanten von demselben Knoten ausgehen oder in Rückwärtsrichtung mehrere Kanten hineingehen, muss man die jeweils berechneten Gradienten addieren.“ Das halte ich ebenfalls für ziemlich grundlegend und intuitiv.
Wenn man zum Beispiel in
f(x, y)sowohl fürxals auch füryzeinsetzt, giltd/dz f(z, z) = f_x(z, z) + f_y(z, z), wobei die tiefgestellten Indizes partielle Ableitungen bezeichnen. Für mich ist dieser Ansatz mathematisch einfacher, als beides zu vermischen und es so wirken zu lassen, als gehe es „über die Kettenregel hinaus“. Außerdem scheint er näher an der tatsächlichen Implementierung zu sein, insbesondere an dem, was PyTorch, womit ich am vertrautesten bin, macht.Automatische Differentiation fühlt sich wie Magie an.
Viele Informatiker waren davon fasziniert und haben Artikel geschrieben, die die Technik aus einer breiteren Perspektive vorstellen. Mein eigener Text gehört auch dazu und enthält außerdem eine „Arme-Leute-Variante“, die komplexe Zahlen ohne Operator-Overloading verwendet.
https://pizzaseminar.speicherleck.de/automatic-differentiati...
[0]: https://doi.org/10.1145/838250.838251
Ich habe eine 26-Zeilen-Implementierung automatischer Differentiation in Python geschrieben: https://gist.github.com/sradc/d9d66e3898ffe3a02e0b6b266629b0...
Das ist den Techniken sehr ähnlich, die in wissensbasierten Engineering-Systemen verwendet werden; dort nennt man es Dependency Tracking. In Kombination mit Caching von Knoten oder Tensoren kann es den Rechenaufwand reduzieren, was besonders bei großen parametrischen 3D-Modellen nützlich ist.
Beim Abrufen eines Werts ruft man rekursiv den binären/Dependency-Baum auf, prüft, welche Variablen sich geändert haben, und berechnet nur das Nötige neu. Mit eigenen Python-Objekten und Attributen, die
__set__- und__get__-Methoden haben, kann man es wie eine eingebaute Funktion eines objektorientierten Modells wirken lassen.x = Tensor(3)y = Tensor(5)z = x + yprint(x, y) # 3, 5print(z) # 8x.value = 4 # Beim Setzen des Werts wird nichts neu berechnetprint(z) # 9, weil die geänderte Abhängigkeit in dem Moment neu berechnet wird, in dem der Wert abgerufen wirdAndrej Karpathy hat ein interessantes Video, in dem er eine Autograd-Engine baut; es ist ziemlich aufschlussreich.
https://youtu.be/VMj-3S1tku0?si=wuKhELwOwoYbzpt7
Repository:
https://github.com/karpathy/micrograd
Die Variante von automatischer Differenzierung, die ich kenne, baut keinen Rechengraphen auf. Stattdessen wird der jeweilige Wert on the fly berechnet.
Beide funktionieren, aber je nach Situation ist die eine effizienter. Bei Dingen wie „Training neuronaler Netze“ optimiert man häufig eine einzelne Verlustausgabe über viele Ziele hinweg, daher verwendet man normalerweise den Reverse Mode.
Ich wünschte, automatische Differenzierung würde einfach numerische Kettenregel genannt oder zumindest so erklärt. Wörtlich genommen ist das alles, mit ein paar Tricks, um bei bestimmten Operationen die Jacobi-Matrix nicht explizit berechnen zu müssen; das wäre viel klarer.
Wenn man tatsächlich aufgefordert wird, die Kettenregel zu verwenden, um Gradienten durch einen Rechengraphen zu propagieren, würden die meisten wohl intuitiv den Forward Mode als Standard annehmen. Ich auch.
https://en.wikipedia.org/wiki/Automatic_differentiation#Beyo...
Unter diesem Gesichtspunkt scheint es nützlich, den Begriff für eine bestimmte Methode zu verwenden, mit der man die durch die Kettenregel gelieferten Ausdrücke durchläuft und dabei Gradienten akkumuliert.
Siehe den Abschnitt „Unterschiede zu anderen Methoden“: https://en.m.wikipedia.org/wiki/Automatic_differentiation
Wie in den Kommentaren in der Nähe gesagt: Der Kernpunkt ist, dass die Implementierung wirklich wichtig und ein Studium wert ist. Automatische Differenzierung als Bündel von Methoden zur Implementierung der Kettenregel zu bezeichnen, ist in Ordnung; sie „einfach“ numerische Kettenregel zu nennen, ist aber falsch.
Automatische Differenzierung ist doch in der Kategorie glatter Funktionen nur die kartesische Linse der Jacobi-Matrix und des totalen Differentials – wo ist das Problem? https://www.youtube.com/watch?v=ne99laPUxN4
Ich frage mich, warum der Klassenname Tensor ist. Gibt es eine Möglichkeit, Ausdrücke oder deren Ableitungen als Tensoren zu betrachten? Oder liegt es daran, dass auch Skalare Tensoren sind und man das so erweitern kann, dass auch andere Tensor-Typen unterstützt werden?
Da der beschriebene Algorithmus der automatischen Differenzierung für beliebige höherdimensionale Objekte funktioniert, scheint es sinnvoll, solche Objekte Tensoren zu nennen.