Kapitel 4. Unter der Haube: Training eines Ziffernklassifikators

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

Nachdem wir inKapitel 2 gesehen haben, wie es aussieht, wenn man verschiedene Modelle trainiert, wollen wir nun einen Blick unter die Haube werfen und sehen, was genau vor sich geht. Wir beginnen mit der Computer Vision, um grundlegende Werkzeuge und Konzepte für Deep Learning vorzustellen.

Um genau zu sein, werden wir die Rolle von Arrays und Tensoren und das Broadcasting, eine leistungsstarke Technik, um sie ausdrucksstark zu nutzen, besprechen. Wir werden den stochastischen Gradientenabstieg (SGD) erklären, den Mechanismus für das Lernen durch automatische Aktualisierung der Gewichte. Wir werden die Wahl einer Verlustfunktion für unsere grundlegende Klassifizierungsaufgabe und die Rolle von Mini-Batches diskutieren. Wir beschreiben auch die Mathematik, die ein neuronales Netzwerk durchführt, und fügen schließlich all diese Teile zusammen.

In späteren Kapiteln werden wir uns auch mit anderen Anwendungen befassen und sehen, wie diese Konzepte und Werkzeuge verallgemeinert werden können. Aber in diesem Kapitel geht es darum, den Grundstein zu legen. Um ehrlich zu sein, ist dies auch eines der schwierigsten Kapitel, weil diese Konzepte alle voneinander abhängen. Wie bei einem Bogen müssen alle Steine an ihrem Platz sein, damit das Bauwerk steht. Und wie bei einem Bogen ist es ein starkes Gebilde, das andere Dinge tragen kann, wenn das einmal geschehen ist. Aber es erfordert etwas Geduld, um es aufzubauen.

Fangen wir an. Der erste Schritt besteht darin, zu überlegen, wie Bilder in einem Computer dargestellt werden.

Pixels: Die Grundlagen der Computer Vision

Um zu verstehen, was in einem Computer Vision Modell passiert, müssen wir zuerst verstehen, wie Computer mit Bildern umgehen. Für unsere Experimente verwenden wir einen der berühmtesten Datensätze in der Computer Vision,MNIST. MNIST enthält Bilder von handgeschriebenen Ziffern, die vom National Institute of Standards and Technology gesammelt und von Yann Lecun und seinen Kollegen zu einem Datensatz für maschinelles Lernen zusammengestellt wurden. Lecun verwendete MNIST 1998 in LeNet-5, dem ersten Computersystem, das eine praktisch brauchbare Erkennung von handschriftlichen Ziffernfolgen demonstrierte. Dies war einer der wichtigsten Durchbrüche in der Geschichte der KI.

In diesem ersten Tutorial wollen wir versuchen, ein Modell zu erstellen, das jedes beliebige Bild als 3 oder 7 klassifizieren kann. Laden wir also ein Beispiel von MNIST herunter, das Bilder mit genau diesen Ziffern enthält:

path = untar_data(URLs.MNIST_SAMPLE)

Wir können sehen, was sich in diesem Verzeichnis befindet, indem wir ls verwenden, eineMethode, die von fastai hinzugefügt wurde. Diese Methode gibt ein Objekt einer speziellen fastai-Klasse namens L zurück, die die gleichen Funktionen wie die in Python eingebaute list hat und noch viel mehr. Eine der praktischen Funktionen ist, dass beim Ausdruck die Anzahl der Einträge angezeigt wird, bevor die Einträge selbst aufgelistet werden (wenn es mehr als 10 Einträge gibt, werden nur die ersten angezeigt):

path.ls()
(#9) [Path('cleaned.csv'),Path('item_list.txt'),Path('trained_model.pkl'),Path('
 > models'),Path('valid'),Path('labels.csv'),Path('export.pkl'),Path('history.cs
 > v'),Path('train')]

Der MNIST-Datensatz folgt dem üblichen Aufbau von Datensätzen für maschinelles Lernen: getrennte Ordner für den Trainingssatz und den Validierungssatz (und/oder Testsatz). Schauen wir uns an, was im Trainingsset enthalten ist:

(path/'train').ls()
(#2) [Path('train/7'),Path('train/3')]

Es gibt einen Ordner mit 3en und einen Ordner mit 7en. Im Sprachgebrauch des maschinellen Lernens sagen wir, dass "3" und "7" die Labels(oder Ziele) in diesem Datensatz sind. Werfen wir einen Blick in einen dieser Ordner (mit sorted, um sicherzustellen, dass wir alle die gleiche Reihenfolge der Dateien erhalten):

threes = (path/'train'/'3').ls().sorted()
sevens = (path/'train'/'7').ls().sorted()
threes
(#6131) [Path('train/3/10.png'),Path('train/3/10000.png'),Path('train/3/10011.pn
 > g'),Path('train/3/10031.png'),Path('train/3/10034.png'),Path('train/3/10042.p
 > ng'),Path('train/3/10052.png'),Path('train/3/1007.png'),Path('train/3/10074.p
 > ng'),Path('train/3/10091.png')...]

Wie nicht anders zu erwarten, ist sie voll mit Bilddateien. Werfen wir einen Blick auf eine davon. Hier ist ein Bild der handgeschriebenen Zahl 3, das aus dem berühmten MNIST-Datensatz handgeschriebener Zahlen stammt:

im3_path = threes[1]
im3 = Image.open(im3_path)
im3

Hier verwenden wir die Klasse Image aus der Python Imaging Library(PIL), dem am weitesten verbreiteten Python-Paket zum Öffnen, Manipulieren und Anzeigen von Bildern. Jupyter kennt die PIL-Bilder und zeigt sie daher automatisch an.

In einem Computer wird alles als Zahl dargestellt. Um die Zahlen zu sehen, aus denen dieses Bild besteht, müssen wir es in ein NumPy-Array oder einen PyTorch-Tensor umwandeln. So sieht zum Beispiel ein Ausschnitt des Bildes aus, der in ein NumPy-Array umgewandelt wurde:

array(im3)[4:10,4:10]
array([[  0,   0,   0,   0,   0,   0],
       [  0,   0,   0,   0,   0,  29],
       [  0,   0,   0,  48, 166, 224],
       [  0,  93, 244, 249, 253, 187],
       [  0, 107, 253, 253, 230,  48],
       [  0,   3,  20,  20,  15,   0]], dtype=uint8)

Die 4:10 zeigt an, dass wir die Zeilen von Index 4 (einschließlich) bis 10 (nicht einschließlich) angefordert haben, und dasselbe gilt für die Spalten. NumPy indiziert von oben nach unten und von links nach rechts, also befindet sich dieser Abschnitt in der Nähe der oberen linken Ecke des Bildes. Hier ist das Gleiche als PyTorch-Tensor:

tensor(im3)[4:10,4:10]
tensor([[  0,   0,   0,   0,   0,   0],
        [  0,   0,   0,   0,   0,  29],
        [  0,   0,   0,  48, 166, 224],
        [  0,  93, 244, 249, 253, 187],
        [  0, 107, 253, 253, 230,  48],
        [  0,   3,  20,  20,  15,   0]], dtype=torch.uint8)

Wir können das Array zerschneiden, um nur den Teil mit der obersten Ziffer herauszufiltern, und dann einen Pandas-Datenrahmen verwenden, um die Werte mit einem Farbverlauf zu kodieren, der uns deutlich zeigt, wie das Bild aus den Pixelwerten entsteht:

im3_t = tensor(im3)
df = pd.DataFrame(im3_t[4:15,4:22])
df.style.set_properties(**{'font-size':'6pt'}).background_gradient('Greys')

Du kannst sehen, dass die weißen Hintergrundpixel als Zahl 0 gespeichert sind, schwarz ist die Zahl 255 und die Grautöne liegen dazwischen. Das gesamte Bild enthält 28 Pixel in der Breite und 28 Pixel in der Tiefe, also insgesamt 784 Pixel. (Das ist viel kleiner als ein Bild, das du von einer Handykamera bekommst, die Millionen von Pixeln hat, aber es ist eine geeignete Größe für unsere ersten Lern- und Experimentierphasen. Wir werden bald auf größere, vollfarbige Bilder umsteigen).

Nachdem du nun gesehen hast, wie ein Bild für einen Computer aussieht, erinnern wir uns an unser Ziel: ein Modell zu erstellen, das 3er und 7er erkennen kann. Wie könntest du einen Computer dazu bringen, das zu tun?

Halte inne und denke nach!

Bevor du weiterliest, nimm dir einen Moment Zeit und überlege, wie ein Computer diese beiden Ziffern erkennen könnte. Auf welche Merkmale könnte er achten? Wie könnte er diese Merkmale erkennen? Wie könnte er sie kombinieren? Lernen funktioniert am besten, wenn du versuchst, Probleme selbst zu lösen, anstatt nur die Antworten anderer zu lesen. Also nimm dir ein paar Minuten Zeit, nimm dir ein Blatt Papier und einen Stift und schreibe ein paar Ideen auf.

Erster Versuch: Pixel-Ähnlichkeit

Hier ist also eine erste Idee: Wie wäre es, wenn wir den durchschnittlichen Pixelwert für jedes Pixel der 3er und dann das Gleiche für die 7er ermitteln. So erhalten wir zwei Gruppendurchschnittswerte, die wir als "ideale" 3 und 7 bezeichnen können. Um ein Bild der einen oder anderen Ziffer zuzuordnen, schauen wir dann, welcher dieser beiden idealen Ziffern das Bild am ähnlichsten ist. Das scheint auf jeden Fall besser zu sein als gar nichts, also ist es eine gute Grundlage.

Jargon: Baseline

Ein einfaches Modell, von dem du überzeugt bist, dass es einigermaßen gut funktioniert. Es sollte einfach zu implementieren und leicht zu testen sein, so dass du jede deiner verbesserten Ideen testen und sicherstellen kannst, dass sie immer besser sind als dein Ausgangsmodell. Wenn du nicht mit einer vernünftigen Basislinie beginnst, ist es schwierig herauszufinden, ob deine superschönen Modelle überhaupt etwas taugen. Ein guter Ansatz, um eine Grundlage zu schaffen, ist das, was wir hier getan haben: Denk dir ein einfaches, leicht umzusetzendes Modell aus. Ein anderer guter Ansatz ist, nach anderen Personen zu suchen, die ähnliche Probleme wie du gelöst haben, und ihren Code herunterzuladen und mit deinem Datensatz zu testen. Am besten probierst du beides aus!

Schritt 1 für unser einfaches Modell besteht darin, den Durchschnitt der Pixelwerte für jede unserer beiden Gruppen zu ermitteln. Dabei werden wir eine Menge netter numerischer Python-Programmiertricks lernen!

Lass uns einen Tensor erstellen, der alle unsere 3er Bilder enthält, die aufgestapelt sind. Wir wissen bereits, wie man einen Tensor erstellt, der ein einzelnes Bild enthält. Um einen Tensor zu erstellen, der alle Bilder in einem Verzeichnis enthält, verwenden wir zunächst ein Python-Listenverständnis, um eine einfache Liste der Einzelbild-Tensoren zu erstellen.

Wir werden Jupyter nutzen, um unsere Arbeit zu überprüfen - in diesem Fall, um sicherzustellen, dass die Anzahl der zurückgegebenen Artikel angemessen ist:

seven_tensors = [tensor(Image.open(o)) for o in sevens]
three_tensors = [tensor(Image.open(o)) for o in threes]
len(three_tensors),len(seven_tensors)
(6131, 6265)

List Comprehensions

Listen- und Wörterbuchversteher sind eine wunderbare Funktion von Python. Viele Python-Programmiererinnen und -Programmierer nutzen sie täglich, auch die Autorinnen und Autoren dieses Buches - sie sind Teil des "idiomatischen Python". Aber Programmierer, die aus anderen Sprachen kommen, haben sie vielleicht noch nie gesehen. Viele gute Tutorials sind nur eine Websuche entfernt, deshalb werden wir sie jetzt nicht lange besprechen. Hier ist eine kurze Erklärung und ein Beispiel für den Anfang. Ein Listenverständnis sieht so aus: new_list = [f(o) for o in a_list if o>0]. Es gibt jedes Element von a_list zurück, das größer als 0 ist, nachdem es an die Funktion f übergeben wurde. Es gibt drei Teile: die Sammlung, über die du iterierst (a_list), einen optionalen Filter (if o>0) und etwas, das mit jedem Element gemacht wird (f(o)). Das ist nicht nur kürzer, sondern auch viel schneller als die alternativen Möglichkeiten, dieselbe Liste mit einer Schleife zu erstellen.

Wir werden auch überprüfen, ob eines der Bilder in Ordnung ist. Da wir jetzt Tensoren haben (die Jupyter standardmäßig als Werte ausgibt) und keine PIL-Bilder (die Jupyter standardmäßig als Bilder anzeigt), müssen wir die Funktion show_image von fastai verwenden, um sie anzuzeigen:

show_image(three_tensors[1]);

Für jede Pixelposition wollen wir den Durchschnitt über alle Bilder der Intensität dieses Pixels berechnen. Dazu fassen wir zunächst alle Bilder in dieser Liste zu einem einzigen dreidimensionalen Tensor zusammen. Die gängigste Art, einen solchen Tensor zu beschreiben, ist, ihn als Rang-3-Tensor zu bezeichnen. Oft müssen wir einzelne Tensoren in einer Sammlung zu einem einzigen Tensor zusammenfassen. Es überrascht nicht, dass PyTorch eine Funktion namens stack mitbringt, die wir für diesen Zweck verwenden können.

Für einige Operationen in PyTorch, wie z. B. die Ermittlung des Mittelwerts, müssen wir unsere Integer-Typen in Float-Typen umwandeln. Da wir das später noch brauchen werden, wandeln wir jetzt auch unseren gestapelten Tensor in floatum. Das Casting in PyTorch ist ganz einfach: Du schreibst den Namen des Typs, in den du casten möchtest, und behandelst ihn wie eine Methode.

Wenn es sich bei den Bildern um Fließkommazahlen handelt, werden normalerweise Pixelwerte zwischen 0 und 1 erwartet, also teilen wir auch hier durch 255:

stacked_sevens = torch.stack(seven_tensors).float()/255
stacked_threes = torch.stack(three_tensors).float()/255
stacked_threes.shape
torch.Size([6131, 28, 28])

Das vielleicht wichtigste Attribut eines Tensors ist seine Form. Unterfindest du die Länge der einzelnen Achsen. In diesem Fall können wir sehen, dass wir 6.131 Bilder haben, die jeweils 28×28 Pixel groß sind. Dieser Tensor sagt nichts darüber aus, dass die erste Achse die Anzahl der Bilder, die zweite die Höhe und die dritte die Breite ist - die Semantik eines Tensors hängt ganz von uns ab und davon, wie wir ihn konstruieren. Für PyTorch ist er einfach nur ein Haufen Zahlen im Speicher.

Die Länge der Form eines Tensors ist sein Rang:

len(stacked_threes.shape)
3

Es ist wirklich wichtig, dass du dir diese Teile des Tensorjargons einprägst und übst: Rang ist die Anzahl der Achsen oder Dimensionen in einem Tensor; Form ist die Größe der einzelnen Achsen eines Tensors.

Alexis Sagt

Pass auf, denn der Begriff "Dimension" wird manchmal auf zwei Arten verwendet. Stell dir vor, wir leben im "dreidimensionalen Raum", in dem eine physische Position durch einen Vektor v mit der Länge 3 beschrieben werden kann. Aber laut PyTorch ist das Attribut v.ndim (das sicher wie die "Anzahl der Dimensionen" von v aussieht) gleich eins, nicht drei! Warum? Weil v ein Vektor ist, der ein Tensor vom Rang eins ist, was bedeutet, dass er nur eine Achse hat (auch wenn diese Achse eine Länge von drei hat). Mit anderen Worten: Manchmal wird Dimension für die Größe einer Achse verwendet ("der Raum ist dreidimensional"), ein anderes Mal für den Rang oder die Anzahl der Achsen ("eine Matrix hat zwei Dimensionen"). Wenn ich verwirrt bin, finde ich es hilfreich, alle Aussagen in die Begriffe Rang, Achse und Länge zu übersetzen, denn das sind eindeutige Begriffe.

Wir können den Rang eines Tensors auch direkt mit ndim ermitteln:

stacked_threes.ndim
3

Schließlich können wir berechnen, wie die ideale 3 aussieht. Wir berechnen den Mittelwert aller Bildtensoren, indem wir den Mittelwert entlang der Dimension 0 unseres gestapelten Rang-3-Tensors nehmen. Dies ist die Dimension, die über alle Bilder hinweg indiziert.

Mit anderen Worten: Für jede Pixelposition wird der Durchschnitt dieses Pixels über alle Bilder berechnet. Das Ergebnis ist ein Wert für jede Pixelposition oder ein einzelnes Bild. Hier ist es:

mean3 = stacked_threes.mean(0)
show_image(mean3);

Laut diesem Datensatz ist dies die ideale Nummer 3! (Vielleicht gefällt es dir nicht, aber so sieht die Spitzenleistung der Nummer 3 aus.) Du kannst sehen, dass sie dort, wo alle Bilder übereinstimmen, sehr dunkel ist, aber dort, wo die Bilder nicht übereinstimmen, wird sie verschwommen und unscharf.

Wir machen das Gleiche für die 7er, fassen aber alle Schritte auf einmal zusammen, um Zeit zu sparen:

mean7 = stacked_sevens.mean(0)
show_image(mean7);

Wählen wir nun eine beliebige 3 und messen ihrenAbstand zu unseren "idealen Ziffern".

Halte inne und denke nach!

Wie würdest du berechnen, wie ähnlich ein bestimmtes Bild unseren idealen Ziffern ist? Vergiss nicht, dich von diesem Buch zu entfernen und ein paar Ideen aufzuschreiben, bevor du weitermachst! Die Forschung hat gezeigt, dass sich das Erinnerungsvermögen und das Verständnis dramatisch verbessern, wenn du dich auf den Lernprozess einlässt, indem du Probleme löst, experimentierst und selbst neue Ideen ausprobierst.

Hier ist ein Beispiel 3:

a_3 = stacked_threes[1]
show_image(a_3);

Wie können wir ihren Abstand zu unserer idealen 3 bestimmen? Wir können nicht einfach die Unterschiede zwischen den Pixeln dieses Bildes und der idealen Ziffer zusammenzählen. Einige Unterschiede werden positiv, andere negativ sein, und diese Unterschiede werden sich aufheben, was dazu führt, dass ein Bild, das an einigen Stellen zu dunkel und an anderen zu hell ist, so dargestellt wird, als hätte es insgesamt keine Unterschiede zum Ideal. Das wäreirreführend!

Um dies zu vermeiden, verwenden Datenwissenschaftler in diesem Zusammenhang vor allem zwei Methoden zur Messung der Entfernung:

  • Nimm den Mittelwert der absoluten Werte der Differenzen (der Absolutwert ist die Funktion, die negative Werte durch positive Werte ersetzt). Dies wird als mittlere absolute Differenz oder L1-Norm bezeichnet.

  • Nimm den Mittelwert des Quadrats der Differenzen (was alles positiv macht) und ziehe dann die Quadratwurzel (was die Quadrierung rückgängig macht). Das nennt man den mittleren quadratischen Fehler (RMSE) oder L2-Norm.

Es ist okay, Mathe vergessen zu haben

In diesem Buch gehen wir im Allgemeinen davon aus, dass du die Highschool-Mathematik abgeschlossen hast und dich zumindest an einen Teil davon erinnerst - aber jeder vergisst einige Dinge! Es kommt ganz darauf an, was du in der Zwischenzeit geübt hast. Vielleicht hast du vergessen, was eine Quadratwurzel ist oder wie sie genau funktioniert. Das ist kein Problem! Wann immer du auf ein mathematisches Konzept stößt, das in diesem Buch nicht vollständig erklärt wird, mach nicht einfach weiter, sondern halte inne und schlage es nach. Vergewissere dich, dass du die Grundidee verstanden hast, wie sie funktioniert und warum wir sie verwenden. Einer der besten Orte, um dein Wissen aufzufrischen, ist Khan Academy. Auf der Khan Academy gibt es zum Beispiel eine tolle Einführung in die Quadratwurzel.

Lass uns jetzt beides ausprobieren:

dist_3_abs = (a_3 - mean3).abs().mean()
dist_3_sqr = ((a_3 - mean3)**2).mean().sqrt()
dist_3_abs,dist_3_sqr
(tensor(0.1114), tensor(0.2021))
dist_7_abs = (a_3 - mean7).abs().mean()
dist_7_sqr = ((a_3 - mean7)**2).mean().sqrt()
dist_7_abs,dist_7_sqr
(tensor(0.1586), tensor(0.3021))

In beiden Fällen ist der Abstand zwischen unserer 3 und der "idealen" 3 geringer als der Abstand zur idealen 7, sodass unser einfaches Modell in diesem Fall die richtige Vorhersage trifft.

PyTorch bietet diese beiden Funktionen bereits als Verlustfunktionen an. Du findest sie in torch.nn.functional, das das PyTorch-Team empfiehlt, als F zu importieren (und das standardmäßig unter diesem Namen in fastai verfügbar ist):

F.l1_loss(a_3.float(),mean7), F.mse_loss(a_3,mean7).sqrt()
(tensor(0.1586), tensor(0.3021))

Dabei steht MSE für den mittleren quadratischen Fehler,und l1 für den mittleren absoluten Wert (in der Mathematik L1-Norm genannt).

Sylvain Sagt

Intuitiv besteht der Unterschied zwischen der L1-Norm und dem mittleren quadratischen Fehler (MSE) darin, dass letzterer größere Fehler stärker bestraft als ersterer (und bei kleinen Fehlern nachsichtiger ist).

Jeremy Sagt

Als ich das erste Mal auf dieses L1-Ding gestoßen bin, habe ich es nachgeschlagen, um herauszufinden, was es überhaupt bedeutet. Bei Google fand ich heraus, dass es sich um eine Vektornorm handelt, die den absoluten Wert verwendet, also schlug ich "Vektornorm" nach und begann zu lesen: Bei einem Vektorraum V über einem Feld F der reellen oder komplexen Zahlen ist eine Norm auf V eine beliebige Funktion p mit nichtnegativem Wert: V → \[0,+∞) mit den folgenden Eigenschaften: Für alle a ∈ F und alle u, v ∈ V, p(u + v) ≤ p(u) + p(v)...Dann habe ich aufgehört zu lesen. "Igitt, ich werde Mathe nie verstehen!" dachte ich zum tausendsten Mal. Seitdem habe ich gelernt, dass ich jedes Mal, wenn diese komplizierten mathematischen Ausdrücke in der Praxis auftauchen, sie durch ein kleines Stück Code ersetzen kann! Zum Beispiel ist der L1-Verlust einfach gleich (a-b).abs().mean(), wobei a und b Tensoren sind. Ich schätze, die Mathematiker denken einfach anders als ich... Ich werde in diesem Buch dafür sorgen, dass ich jedes Mal, wenn ein mathematischer Fachausdruck auftaucht, auch das kleine Stückchen Code nenne, das ihm entspricht, und mit gesundem Menschenverstand erkläre, was los ist.

Wir haben gerade verschiedene mathematische Operationen mit PyTorch-Tensoren durchgeführt. Wenn du schon einmal numerische Programmierung in NumPy gemacht hast, wirst du vielleicht erkennen, dass sie NumPy-Arrays ähneln. Werfen wir einen Blick auf diese beiden wichtigen Datenstrukturen.

NumPy-Arrays und PyTorch-Tensoren

NumPy ist die am häufigsten verwendete Bibliothek für wissenschaftliche und numerische Programmierung in Python. Sie bietet ähnliche Funktionen und eine ähnliche API wie PyTorch, unterstützt aber weder die Nutzung der GPU noch die Berechnung von Gradienten, die beide für Deep Learning entscheidend sind. Deshalb werden wir in diesem Buch, wenn möglich, PyTorch-Tensoren anstelle von NumPy-Arrays verwenden.

(Beachte, dass fastai einige Funktionen zu NumPy und PyTorch hinzufügt, um sie einander ein wenig ähnlicher zu machen. Wenn ein Code in diesem Buch auf deinem Computer nicht funktioniert, hast du vielleicht vergessen, eine Zeile wie diese am Anfang deines Notizbuchs einzufügen:fromfastai.vision.all import *.)

Aber was sind Arrays und Tensoren, und warum solltest du dich dafür interessieren?

Python ist im Vergleich zu vielen anderen Sprachen langsam. Alles, was in Python, NumPy oder PyTorch schnell ist, ist wahrscheinlich ein Wrapper für ein kompiliertes Objekt, das in einer anderen Sprache geschrieben (und optimiert) wurde - insbesondere in C. Tatsächlich können NumPy-Arrays und PyTorch-Tensoren Berechnungen viele tausend Mal schneller abschließen als reines Python.

Ein NumPy-Array ist eine mehrdimensionale Datentabelle, bei der alle Elemente den gleichen Typ haben. Da dies jeder beliebige Typ sein kann, kann es sich sogar um Arrays von Arrays handeln, wobei die innersten Arrays unterschiedlich groß sein können - das nennt man ein gezacktes Array. Unter einer "mehrdimensionalen Tabelle" verstehen wir zum Beispiel eine Liste (Dimension eins), eine Tabelle oder Matrix (Dimension zwei), eine Tabelle von Tabellen oder Würfeln (Dimension drei) und so weiter. Wenn die Elemente alle von einem einfachen Typ wie Integer oder Float sind, speichert NumPy sie als kompakte C-Datenstruktur im Speicher. Das ist der Punkt, an dem NumPy glänzt. NumPy verfügt über eine Vielzahl von Operatoren und Methoden, mit denen Berechnungen auf diesen kompakten Strukturen genauso schnell ausgeführt werden können wie in optimiertem C, da sie in optimiertem C geschrieben sind.

Ein PyTorch-Tensor ist fast dasselbe wie ein NumPy-Array, allerdings mit einer zusätzlichen Einschränkung, die zusätzliche Fähigkeiten freischaltet. Auch er ist eine mehrdimensionale Datentabelle, bei der alle Elemente denselben Typ haben. Die Einschränkung besteht jedoch darin, dass ein Tensor nicht irgendeinen Typ verwenden kann, sondern für alle Komponenten einen einzigen numerischen Grundtyp verwenden muss. Daher ist ein Tensor nicht so flexibel wie ein echtes Array aus Arrays. Ein PyTorch Tensor kann zum Beispiel nicht zackig sein. Er ist immer eine regelmäßig geformte mehrdimensionale rechteckige Struktur.

Die meisten Methoden und Operatoren, die von NumPy für diese Strukturen unterstützt werden, werden auch von PyTorch unterstützt, aber PyTorch-Tensoren haben zusätzliche Fähigkeiten. Eine wichtige Fähigkeit ist, dass diese Strukturen auf dem Grafikprozessor (GPU) laufen können. In diesem Fall wird ihre Berechnungfür den GPU optimiert und kann viel schneller ablaufen (vorausgesetzt, es gibt viele Werte, mit denen gearbeitet werden kann). Außerdem kann PyTorch automatisch Ableitungen dieser Operationen berechnen, einschließlich Kombinationen von Operationen. Wie du sehen wirst, wäre Deep Learning ohne diese Fähigkeit in der Praxis nicht möglich.

Sylvain Sagt

Wenn du nicht weißt, was C ist, mach dir keine Sorgen: Du wirst es gar nicht brauchen. Kurz gesagt: ist eineLow-Level-Sprache (Low-Level bedeutet, dass sie der Sprache ähnelt, die Computer intern verwenden), die im Vergleich zu Python sehr schnell ist. Um bei der Programmierung in Python von der Geschwindigkeit zu profitieren, solltest du Schleifen so weit wie möglich vermeiden und sie durch Befehle ersetzen, die direkt mit Arrays oder Tensoren arbeiten.

Die vielleicht wichtigste neue Programmierfertigkeit, die ein Python-Programmierer erlernen sollte, ist die effektive Nutzung der Array/Tensor-APIs. Wir werden später in diesem Buch noch viele weitere Tricks zeigen, aber hier ist eine Zusammenfassung der wichtigsten Dinge, die du jetzt wissen musst.

Um ein Array oder einen Tensor zu erstellen, übergibst du eine Liste (oder eine Liste von Listen, oder eine Liste von Listen von Listen, usw.) an array oder tensor:

data = [[1,2,3],[4,5,6]]
arr = array (data)
tns = tensor(data)
arr  # numpy
array([[1, 2, 3],
       [4, 5, 6]])
tns  # pytorch
tensor([[1, 2, 3],
        [4, 5, 6]])

Alle folgenden Operationen werden für Tensoren gezeigt, aber die Syntax und die Ergebnisse für NumPy-Arrays sind identisch.

Du kannst eine Zeile auswählen (beachte, dass Tensoren, wie Listen in Python, mit 0 indiziert sind, d.h. 1 bezieht sich auf die zweite Zeile/Spalte):

tns[1]
tensor([4, 5, 6])

Oder eine Spalte, indem du : verwendest, um die gesamte erste Achse zu bezeichnen (wir bezeichnen die Dimensionen von Tensoren/Arrays manchmal als Achsen):

tns[:,1]
tensor([2, 5])

Du kannst diese mit der Python-Slice-Syntax ([start:end], wobei end ausgeschlossen), um einen Teil einer Zeile oder Spalte auszuwählen:

tns[1,1:3]
tensor([5, 6])

Und du kannst die Standardoperatoren verwenden, wie +, -, * und /:

tns+1
tensor([[2, 3, 4],
        [5, 6, 7]])

Tensoren haben einen Typ:

tns.type()
'torch.LongTensor'

Und ändert den Typ bei Bedarf automatisch, zum Beispiel von int auf float:

tns*1.5
tensor([[1.5000, 3.0000, 4.5000],
        [6.0000, 7.5000, 9.0000]])

Ist unser Basismodell also überhaupt gut? Um das zu quantifizieren, müssen wir eine Kennzahl definieren.

Berechnung von Metriken mithilfe von Broadcasting

Erinnere dich daran, dass eine Metrik eine Zahl ist, die auf der Grundlage der Vorhersagen unseres Modells und der richtigen Kennzeichnungen in unserem Datensatz berechnet wird, um uns zu sagen, wie gut unser Modell ist. Wir könnten zum Beispiel eine der Funktionen aus dem vorherigen Abschnitt verwenden, den mittleren quadratischen Fehler oder den mittleren absoluten Fehler, und den Durchschnitt über den gesamten Datensatz bilden. Beides sind jedoch keine Zahlen, die für die meisten Menschen sehr verständlich sind; in der Praxis verwenden wir normalerweise die Genauigkeitals Maßstab für Klassifizierungsmodelle.

Wie wir bereits besprochen haben, wollen wir unsere Metrik über eine Validierungsmenge berechnen. So vermeiden wir eine versehentliche Überanpassung, d. h. wir trainieren ein Modell, das nur mit unseren Trainingsdaten gut funktioniert. Bei dem Pixelähnlichkeitsmodell, das wir hier für den ersten Versuch verwenden, besteht dieses Risiko nicht, da es keine trainierten Komponenten hat, aber wir verwenden trotzdem ein Validierungsset, um der üblichen Praxis zu folgen und für den zweiten Versuch gerüstet zu sein.

Um einen Validierungsdatensatz zu erhalten, müssen wir einen Teil der Daten komplett aus dem Training herausnehmen, damit sie vom Modell überhaupt nicht gesehen werden. Wie sich herausstellt, haben die Macher des MNIST-Datensatzes dies bereits für uns getan. Erinnerst du dich an das separate Verzeichnismit dem Namen valid? Dafür ist dieses Verzeichnis da!

Als Erstes erstellen wir also Tensoren für unsere 3er und 7er aus diesem Verzeichnis. Diese Tensoren werden wir verwenden, um eine Metrik zu berechnen, die die Qualität unseres Erstversuchsmodells misst und den Abstand zu einem idealen Bild angibt:

valid_3_tens = torch.stack([tensor(Image.open(o))
                            for o in (path/'valid'/'3').ls()])
valid_3_tens = valid_3_tens.float()/255
valid_7_tens = torch.stack([tensor(Image.open(o))
                            for o in (path/'valid'/'7').ls()])
valid_7_tens = valid_7_tens.float()/255
valid_3_tens.shape,valid_7_tens.shape
(torch.Size([1010, 28, 28]), torch.Size([1028, 28, 28]))

Es ist gut, wenn du dir angewöhnst, die Formen nach und nach zu überprüfen. Hier sehen wir zwei Tensoren, einen für die 3s-Validierungsmenge von 1.010 Bildern der Größe 28×28 und einen für die 7s-Validierungsmenge von 1.028 Bildern der Größe 28×28.

Schließlich wollen wir eine Funktion schreiben, is_3, die entscheidet, ob ein beliebiges Bild eine 3 oder eine 7 ist. Dazu entscheidet sie, welcher unserer beiden "idealen Ziffern" das beliebige Bild näher ist. Dazu müssen wir den Begriff " Abstand" definieren, d. h.eine Funktion, die den Abstand zwischen zwei Bildern berechnet.

Wir können eine einfache Funktion schreiben, die den mittleren absoluten Fehler mit einem Ausdruck berechnet, der dem im letzten Abschnitt beschriebenen sehr ähnlich ist:

def mnist_distance(a,b): return (a-b).abs().mean((-1,-2))
mnist_distance(a_3, mean3)
tensor(0.1114)

Dies ist derselbe Wert, den wir zuvor für den Abstand zwischen den beiden Bildern, dem idealen 3 mean3 und dem willkürlichen Muster 3 a_3, berechnet haben, die beide Einzelbild-Tensoren mit der Form[28,28] sind.

Aber um eine Metrik für die Gesamtgenauigkeit zu berechnen, müssen wir den Abstand zur idealen 3 für jedes Bild in der Validierungsmenge berechnen. Wie können wir diese Berechnung durchführen? Wir könnten eine Schleife über alle Einzelbild-Tensoren schreiben, die in unserem Validierungssatz-Tensor valid_3_tens gestapelt sind, der eine Form von [1010,28,28]hat, die 1.010 Bilder repräsentiert. Aber es gibt einen besseren Weg.

Etwas Interessantes passiert, wenn wir genau dieselbe Abstandsfunktion nehmen, die für den Vergleich von zwei Einzelbildern gedacht ist, aber als Argument valid_3_tens übergeben, den Tensor, der die 3s-Validierungsmenge darstellt:

valid_3_dist = mnist_distance(valid_3_tens, mean3)
valid_3_dist, valid_3_dist.shape
(tensor([0.1050, 0.1526, 0.1186,  ..., 0.1122, 0.1170, 0.1086]),
 torch.Size([1010]))

Anstatt sich darüber zu beschweren, dass die Formen nicht übereinstimmen, wurde der Abstand für jedes einzelne Bild als Vektor (d.h. ein Rang-1-Tensor) der Länge 1.010 (die Anzahl der 3er in unserer Validierungsmenge) zurückgegeben. Wie ist das passiert?

Schau dir noch einmal unsere Funktion mnist_distance an, und du wirst sehen, dass wir dort die Subtraktion (a-b) haben. Der Zaubertrick besteht darin, dass PyTorch, wenn es versucht, eine einfache Subtraktion zwischen zwei Tensoren unterschiedlichen Ranges durchzuführen,Broadcasting verwendet: Es erweitert den Tensor mit dem kleineren Rang automatisch so, dass er die gleiche Größe hat wie der mit dem größeren Rang. Broadcasting ist eine wichtige Fähigkeit, die das Schreiben von Tensor-Code viel einfacher macht.

Nach der Übertragung, damit die beiden Argument-Tensoren den gleichen Rang haben, wendet PyTorch seine übliche Logik für zwei Tensoren gleichen Rangs an: Er führt die Operation an jedem entsprechenden Element der beiden Tensoren durch und gibt das Tensorergebnis zurück. Ein Beispiel:

tensor([1,2,3]) + tensor(1)
tensor([2, 3, 4])

In diesem Fall behandelt PyTorch mean3, einen Rang-2-Tensor, der ein einzelnes Bild repräsentiert, so, als wären es 1.010 Kopien desselben Bildes, und subtrahiert dann jede dieser Kopien von jeder 3 in unserer Validierungsmenge. Welche Form würdest du erwarten, dass dieser Tensor hat? Versuche, es selbst herauszufinden, bevor du dir die Antwort hier ansiehst:

(valid_3_tens-mean3).shape
torch.Size([1010, 28, 28])

Wir berechnen die Differenz zwischen unserer idealen 3 und jeder der 1.010 3en in der Validierungsmenge, für jedes der 28×28 Bilder, was zu der Form [1010,28,28] führt.

Es gibt ein paar wichtige Punkte bei der Implementierung von Broadcasting , die es nicht nur für die Aussagekraft, sondern auch für die Leistung wertvoll machen:

  • PyTorch kopiert mean3 nicht wirklich 1.010 Mal. Es tut so, als wäre es ein Tensor dieser Form, aber es wird kein zusätzlicher Speicher zugewiesen.

  • Es führt die gesamte Berechnung in C durch (oder, wenn du einen Grafikprozessor verwendest, in CUDA, dem Äquivalent von C auf dem Grafikprozessor), und zwar zehntausendmal schneller als reines Python (auf einem Grafikprozessor bis zu Millionen Mal schneller!).

Das gilt für alle Broadcasting- und elementweisen Operationen und Funktionen in PyTorch. Es ist die wichtigsteTechnik, die du kennen musst, um effizienten PyTorch-Code zu erstellen.

Als nächstes sehen wir in mnist_distance abs . Du kannst dir jetzt vielleicht vorstellen, was diese Methode bei der Anwendung auf einen Tensor macht. Sie wendet die Methode auf jedes einzelne Element des Tensors an und gibt einen Tensor mit den Ergebnissen zurück (das heißt, sie wendet die Methode elementweise an). In diesem Fall bekommen wir also 1.010 Matrizen mit absoluten Werten zurück.

Schließlich ruft unsere Funktion mean((-1,-2)) auf. Das Tupel (-1,-2)stellt einen Bereich von Achsen dar. In Python bezieht sich -1 auf das letzte Element und -2 auf das vorletzte. In diesem Fall bedeutet das für PyTorch, dass wir den Mittelwert über die Werte nehmen wollen, die durch die letzten beiden Achsen des Tensors indiziert sind. Die letzten beiden Achsen sind die horizontale und vertikale Dimension eines Bildes. Nachdem wir den Mittelwert über die letzten beiden Achsen gebildet haben, bleibt nur noch die erste Tensorachse übrig, die unsere Bilder indiziert, weshalb unsere endgültige Größe (1010) ist. Mit anderen Worten: Für jedes Bild haben wir die Intensität aller Pixel des Bildes gemittelt.

Wir werden in diesem Buch noch viel mehr über das Senden lernen, vor allem in Kapitel 17, und wir werden es auch regelmäßig üben.

Wir können mnist_distance verwenden, um herauszufinden, ob ein Bild eine 3 ist, indem wir folgende Logik anwenden: Wenn der Abstand zwischen der fraglichen Ziffer und der idealen 3 kleiner ist als der Abstand zur idealen 7, dann ist es eine 3. Diese Funktion wird automatisch übertragen und elementweise angewendet, genau wie alle PyTorch-Funktionen und -Operatoren:

def is_3(x): return mnist_distance(x,mean3) < mnist_distance(x,mean7)

Testen wir es an unserem Beispielfall:

is_3(a_3), is_3(a_3).float()
(tensor(True), tensor(1.))

Wenn wir die boolesche Antwort in eine Fließkommazahl umwandeln, erhalten wir1.0 für True und 0.0 für False.

Dank des Rundfunks können wir ihn auch mit dem vollständigen Validierungssatz von 3s testen:

is_3(valid_3_tens)
tensor([True, True, True,  ..., True, True, True])

Jetzt können wir die Genauigkeit für jede der 3er und 7er berechnen, indem wir den Durchschnitt dieser Funktion für alle 3er und ihre Umkehrung für alle 7er nehmen:

accuracy_3s =      is_3(valid_3_tens).float() .mean()
accuracy_7s = (1 - is_3(valid_7_tens).float()).mean()

accuracy_3s,accuracy_7s,(accuracy_3s+accuracy_7s)/2
(tensor(0.9168), tensor(0.9854), tensor(0.9511))

Das sieht nach einem ziemlich guten Start aus! Wir erreichen eine Genauigkeit von über 90 % sowohl bei 3 als auch bei 7 und wir haben gesehen, wie man eine Metrik bequem mit Hilfe von Broadcasting definiert. Aber seien wir mal ehrlich: 3er und 7er sind sehr unterschiedlich aussehende Ziffern. Und wir haben bisher nur 2 der 10 möglichen Ziffern klassifiziert. Wir müssen uns also noch verbessern!

Um besser abzuschneiden, ist es vielleicht an der Zeit, ein System auszuprobieren, das wirklich lernt - eines, das sich automatisch anpassen kann, um seine Leistung zu verbessern. Mit anderen Worten: Es ist an der Zeit, über den Trainingsprozess und SGD zu sprechen.

Stochastischer Gradientenabstieg

Erinnerst du dich daran, wie Arthur Samuel das maschinelle Lernen beschrieben hat, das wir in Kapitel 1 zitiert haben?

Nehmen wir an, wir haben ein automatisches Verfahren, mit dem wir die Effektivität der aktuellen Gewichtungszuweisung anhand der tatsächlichen Leistung testen können, und einen Mechanismus, mit dem wir die Gewichtungszuweisung so ändern können, dass die Leistung maximiert wird. Wir brauchen nicht auf die Details eines solchen Verfahrens einzugehen, um zu sehen, dass es völlig automatisch ablaufen kann und dass eine so programmierte Maschine aus ihren Erfahrungen "lernt".

Wie wir bereits besprochen haben, ist dies der Schlüssel zu einem Modell, das immer besser werden kann - das lernen kann. Aber unser Pixelähnlichkeitsansatz tut dies nicht wirklich. Wir haben weder eine Art von Gewichtungszuweisung noch eine Möglichkeit zur Verbesserung, indem wir die Wirksamkeit einer Gewichtungszuweisung testen. Mit anderen Worten: Wir können unseren Pixelähnlichkeitsansatz nicht wirklich verbessern, indem wir eine Reihe von Parametern ändern. Um die Möglichkeiten des Deep Learning zu nutzen, müssen wir unsere Aufgabe zunächst so darstellen, wie Samuel sie beschrieben hat.

Anstatt zu versuchen, die Ähnlichkeit zwischen einem Bild und einem "idealen Bild" zu finden, könnten wir stattdessen jedes einzelne Pixel betrachten und eine Reihe von Gewichtungen für jedes Pixel festlegen, so dass die höchsten Gewichtungen mit den Pixeln verbunden sind, die am wahrscheinlichsten für eine bestimmte Kategorie schwarz sind. So ist es zum Beispiel unwahrscheinlich, dass die Pixel unten rechts für eine 7 aktiviert werden, also sollten sie ein niedriges Gewicht für eine 7 haben, aber sie werden wahrscheinlich für eine 3 aktiviert, also sollten sie ein hohes Gewicht für eine 3 haben. Dies kann als Funktion und als eine Reihe von Gewichtungswerten für jede mögliche Kategorie dargestellt werden - zum Beispiel die Wahrscheinlichkeit, die Zahl 3 zu sein:

def pr_three(x, w): return (x*w).sum()

Hier gehen wir davon aus, dass x das Bild ist, das als Vektor dargestellt wird - mit anderen Worten, alle Zeilen sind zu einer einzigen langen Linie aufgereiht. Und wir gehen davon aus, dass die Gewichte ein Vektor w sind. Wenn wir diese Funktion haben, brauchen wir nur eine Möglichkeit, die Gewichte zu aktualisieren, um sie ein bisschen besser zu machen. Mit einem solchen Ansatz können wir diesen Schritt mehrmals wiederholen und die Gewichte immer besser machen, bis sie so gut sind, wie wir sie machen können.

Wir wollen die spezifischen Werte für den Vektor w finden, die dazu führen, dass das Ergebnis unserer Funktion bei den Bildern, die 3er sind, hoch und bei den Bildern, die keine 3er sind, niedrig ist. Die Suche nach dem besten Vektor w ist ein Weg, um die beste Funktion für die Erkennung von 3en zu finden. (Da wir noch kein tiefes neuronales Netzwerk verwenden, sind wir durch die Möglichkeiten unserer Funktion eingeschränkt - diese Einschränkung werden wir später in diesem Kapitel beheben).

Im Folgenden werden die Schritte beschrieben, die erforderlich sind, um diese Funktion in einen Machine Learning Classifier zu verwandeln:

  1. Initialisiere die Gewichte.

  2. Verwende diese Gewichte für jedes Bild, um vorherzusagen, ob es eine 3 oder eine 7 ist.

  3. Berechne auf der Grundlage dieser Vorhersagen, wie gut das Modell ist (sein Verlust).

  4. Berechne den Gradienten, der für jedes Gewicht angibt, wie eine Änderung des Gewichts den Verlust verändern würde.

  5. Schritt (d.h. Änderung) aller Gewichte auf der Grundlage dieser Berechnung.

  6. Gehe zurück zu Schritt 2 und wiederhole den Vorgang.

  7. Iteriere so lange, bis du beschließt, den Trainingsprozess zu beenden (zum Beispiel, weil das Modell gut genug ist oder du nicht länger warten willst).

Diese sieben Schritte, die in Abbildung 4-1 dargestellt sind, sind der Schlüssel zum Training aller Deep Learning-Modelle. Die Tatsache, dass sich Deep Learning ausschließlich auf diese Schritte stützt, ist äußerst überraschend und kontraintuitiv. Es ist erstaunlich, dass dieser Prozess so komplexe Probleme lösen kann. Aber wie du sehen wirst, ist es wirklich möglich!

Graph showing the steps for Gradient Descent
Abbildung 4-1. Der Prozess des Gradientenabstiegs

Es gibt viele Möglichkeiten, jeden dieser sieben Schritte auszuführen, und wir werden sie im weiteren Verlauf dieses Buches kennenlernen. Es sind die Details, die für Deep Learning-Praktiker/innen einen großen Unterschied machen, aber es zeigt sich, dass die allgemeine Herangehensweise an jeden einzelnen Schritt einigen Grundprinzipien folgt. Hier sind ein paar Richtlinien:

Initialisiere

Wir initialisieren die Parameter mit zufälligen Werten. Das mag überraschend klingen. Es gibt sicherlich noch andere Möglichkeiten, wie z. B. die Initialisierung mit dem Prozentsatz der Aktivierung eines Pixels für diese Kategorie - aber da wir bereits wissen, dass wir eine Routine zur Verbesserung dieser Gewichte haben, stellt sich heraus, dass der Start mit zufälligen Gewichten perfekt funktioniert.

Verlust

Darauf bezog sich Samuel, als er davon sprach, die Effektivität einer aktuellen Gewichtungszuweisung anhand der tatsächlichen Leistung zu testen. Wir brauchen eine Funktion, die eine kleine Zahl zurückgibt, wenn die Leistung des Modells gut ist (der Standardansatz ist, einen kleinen Verlust als gut und einen großen Verlust als schlecht zu behandeln, obwohl das nur eine Konvention ist).

Schritt

Eine einfache Methode, um herauszufinden, ob ein Gewicht ein wenig erhöht oder verringert werden sollte, wäre, es einfach auszuprobieren: Erhöhe das Gewicht um einen kleinen Betrag und schaue, ob der Verlust nach oben oder unten geht. Wenn du die richtige Richtung gefunden hast, kannst du den Betrag um ein bisschen mehr oder ein bisschen weniger ändern, bis du einen Betrag gefunden hast, der gut funktioniert. Das ist allerdings langsam! Wie wir sehen werden, können wir mit der Magie der Infinitesimalrechnung direkt herausfinden, in welche Richtung und um wie viel wir die einzelnen Gewichte verändern müssen, ohne all diese kleinen Änderungen ausprobieren zu müssen. Dies geschieht durch die Berechnung von Gradienten. Das ist nur eine Leistungsoptimierung; wir würden genau die gleichen Ergebnisse erhalten, wenn wir den langsameren manuellen Prozess verwenden würden.

Stopp

Sobald wir entschieden haben, für wie viele Epochen wir das Modell trainieren wollen (ein paar Vorschläge dazu wurden in der vorherigen Liste gemacht), wenden wir diese Entscheidung an. Für unseren Ziffernklassifikator würden wir so lange trainieren, bis die Genauigkeit des Modells schlechter wird oder uns die Zeit ausgeht.

Bevor wir diese Schritte auf unser Bildklassifizierungsproblem anwenden, wollen wir veranschaulichen, wie sie in einem einfacheren Fall aussehen. Zunächst definieren wir eine sehr einfache Funktion, die quadratische - nehmen wir an, dass dies unsere Verlustfunktion ist und x ein Gewichtungsparameter der Funktion ist:

def f(x): return x**2

Hier ist ein Diagramm dieser Funktion:

plot_function(f, 'x', 'x**2')

Die Abfolge der Schritte, die wir zuvor beschrieben haben, beginnt damit, dass wir einen Zufallswert für einen Parameter auswählen und den Wert des Verlustes berechnen:

plot_function(f, 'x', 'x**2')
plt.scatter(-1.5, f(-1.5), color='red');

Jetzt schauen wir uns an, was passieren würde, wenn wir unseren Parameter ein wenig erhöhen oder verringern - die Anpassung. Das ist einfach die Steigung an einem bestimmten Punkt:

A graph showing the squared function with the slope at one point

Wir können unser Gewicht ein wenig in Richtung der Steigung verändern, unseren Verlust und die Anpassung erneut berechnen und dies ein paar Mal wiederholen. Schließlich werden wir den niedrigsten Punkt auf unserer Kurve erreichen:

An illustration of gradient descent

Diese Grundidee geht auf Isaac Newton zurück, der darauf hinwies, dass wir beliebige Funktionen auf diese Weise optimieren können. Unabhängig davon, wie kompliziert unsere Funktionen werden, wird sich dieser grundlegende Ansatz des Gradientenabstiegs nicht wesentlich ändern. Die einzigen kleinen Änderungen, die wir später in diesem Buch sehen werden, sind einige praktische Möglichkeiten, wie wir es schneller machen können, indem wir bessere Schritte finden.

Berechnen von Gradienten

Der einzige magische Schritt ist der Teil, in dem wir die Gradienten berechnen. Wie wir bereits erwähnt haben, nutzen wir die Kalkulation zur Leistungsoptimierung. Sie ermöglicht es uns, schneller zu berechnen, ob unser Verlust größer oder kleiner wird, wenn wir unsere Parameter nach oben oder unten anpassen. Mit anderen Worten: Die Gradienten sagen uns, wie stark wir die einzelnen Gewichte verändern müssen, um unser Modell zu verbessern.

Vielleicht erinnerst du dich noch aus dem Matheunterricht in der Schule daran, dass die Ableitung einer Funktion angibt, wie stark sich das Ergebnis ändert, wenn du die Parameter veränderst. Wenn nicht, mach dir keine Sorgen, denn viele von uns vergessen das Rechnen, sobald die Schule hinter uns liegt! Aber du brauchst ein gewisses intuitives Verständnis davon, was eine Ableitung ist, bevor du weitermachst. Wenn du das alles noch nicht verinnerlicht hast, dann besuche die Khan Academy und lerne die Lektionen über die grundlegenden Ableitungen. Du musst nicht wissen, wie du sie selbst berechnen kannst, du musst nur wissen, was eine Ableitung ist.

Das Wichtigste an der Ableitung ist Folgendes: Von jeder Funktion, wie zum Beispiel der quadratischen Funktion, die wir im vorigen Abschnitt gesehen haben, können wir ihre Ableitung berechnen. Die Ableitung ist eine andere Funktion. Sie berechnet die Veränderung und nicht den Wert. Die Ableitung der quadratischen Funktion bei dem Wert 3 sagt uns zum Beispiel, wie schnell sich die Funktion bei dem Wert 3 verändert. Du erinnerst dich vielleicht daran, dass die Steigung als Anstieg/Lauf definiert ist, d.h. die Veränderung des Werts der Funktiongeteilt durch die Veränderung des Werts des Parameters. Wenn wir wissen, wie sich unsere Funktion verändern wird, wissen wir auch, was wir tun müssen, um sie zu verkleinern. Das ist der Schlüssel zum maschinellen Lernen: eine Möglichkeit zu haben,die Parameter einer Funktion zu ändern, um sie zu verkleinern. Die Infinitesimalrechnung bietet uns eine rechnerische Abkürzung, die Ableitung, mit der wir die Gradienten unserer Funktionen direkt berechnen können.

Ein wichtiger Punkt ist, dass unsere Funktion viele Gewichte hat, die wir anpassen müssen. Wenn wir also die Ableitung berechnen, bekommen wir nicht nur eine Zahl zurück, sondern viele davon - eine Steigung für jedes Gewicht. Aber das ist mathematisch gar nicht so kompliziert. Du kannst die Ableitung nach einem Gewicht berechnen und alle anderen als konstant betrachten und das dann für jedes andere Gewicht wiederholen. Auf diese Weise werden alle Gradienten für jedes Gewicht berechnet.

Wir haben vorhin erwähnt, dass du keine Steigungen selbst berechnen musst. Wie kann das sein? Erstaunlicherweise ist PyTorch in der Lage, die Ableitung von fast jeder Funktion automatisch zu berechnen! Und das sogar sehr schnell. In den meisten Fällen ist sie mindestens so schnell wie jede Ableitungsfunktion, die du von Hand erstellen kannst. Sehen wir uns ein Beispiel an.

Zuerst wählen wir einen Tensorwert, an dem wir Gradienten haben wollen:

xt = tensor(3.).requires_grad_()

Hast du die spezielle Methode requires_grad_ bemerkt? Das ist die magische Beschwörungsformel, mit der wir PyTorch mitteilen, dass wir den Gradienten für diese Variable bei diesem Wert berechnen wollen. Damit wird die Variable markiert, so dass PyTorch sich daran erinnert, wie man die Gradienten anderer direkter Berechnungen berechnet, die du von ihr verlangst.

Alexis Sagt

Diese API könnte dich verwirren, wenn du aus der Mathematik oder Physik kommst. In diesen Kontexten ist der "Gradient" einer Funktion einfach eine andere Funktion (d. h. ihre Ableitung), sodass du erwarten könntest, dass dir APIs, die sich auf den Gradienten beziehen, eine neue Funktion liefern. Beim Deep Learning ist mit "Gradient" jedoch in der Regel der Wert der Ableitung einer Funktion bei einem bestimmten Argumentwert gemeint. Die PyTorch-API legt den Fokus auf das Argument und nicht auf die Funktion, deren Gradienten du gerade berechnest. Das mag sich zunächst rückständig anfühlen, aber es ist einfach eine andere Perspektive.

Jetzt berechnen wir unsere Funktion mit diesem Wert. Beachte, dass PyTorch nicht nur den berechneten Wert ausgibt, sondern auch einen Hinweis, dass es eine Gradientenfunktion hat, mit der es bei Bedarf unsere Gradienten berechnet:

yt = f(xt)
yt
tensor(9., grad_fn=<PowBackward0>)

Zum Schluss weisen wir PyTorch an, die Gradienten für uns zu berechnen:

yt.backward()

Das "rückwärts" bezieht sich hier auf Backpropagation, so nennt den Prozess der Berechnung der Ableitung jeder Schicht. Wie das genau funktioniert, werden wir in Kapitel 17 sehen, wenn wir die Gradienten eines tiefen neuronalen Netzes von Grund auf berechnen. Dies wird als Backward Pass des Netzes bezeichnet, im Gegensatz zum Forward Pass, bei dem die Aktivierungen berechnet werden. Das Leben wäre wahrscheinlich einfacher, wenn backward einfach calculate_grad hieße, aber die Deep-Learning-Leute fügen wirklich gerne überall, wo sie können, Jargon hinzu!

Wir können uns jetzt die Gradienten ansehen, indem wir das Attribut grad unseres Tensors überprüfen:

xt.grad
tensor(6.)

Wenn du dich an deine Rechenregeln in der Schule erinnerst, ist die Ableitung vonx**2 2*x , und wir haben x=3, also sollten die Steigungen 2*3=6 sein, was PyTorch für uns berechnet hat!

Jetzt wiederholen wir die vorangegangenen Schritte, aber mit einem Vektorargument für unsere Funktion:

xt = tensor([3.,4.,10.]).requires_grad_()
xt
tensor([ 3.,  4., 10.], requires_grad=True)

Und wir fügen sum zu unserer Funktion hinzu, damit sie einen Vektor (d.h. einen Rang-1-Tensor) annehmen und einen Skalar (d.h. einen Rang-0-Tensor) zurückgeben kann:

def f(x): return (x**2).sum()

yt = f(xt)
yt
tensor(125., grad_fn=<SumBackward0>)

Unsere Gradienten sind 2*xt, wie zu erwarten!

yt.backward()
xt.grad
tensor([ 6.,  8., 20.])

Die Steigungen sagen uns nur die Neigung unserer Funktion; sie sagen uns nicht genau, wie weit wir die Parameter anpassen müssen. Aber sie geben uns eine Vorstellung davon, wie weit wir gehen müssen: Wenn die Steigung sehr groß ist, deutet das darauf hin, dass wir noch mehr Anpassungen vornehmen müssen, während eine sehr kleine Steigung darauf hindeutet, dass wir nahe am optimalen Wert sind.

Treten mit einer Lernrate

Die Entscheidung, wie wir unsere Parameter auf der Grundlage der Werte der Gradienten ändern, ist ein wichtiger Teil des Deep Learning-Prozesses. Fast alle Ansätze beginnen mit der Grundidee, den Gradienten mit einer kleinen Zahl zu multiplizieren, der sogenannten Lernrate (LR). Die Lernrate ist oft eine Zahl zwischen 0,001 und 0,1, kann aber auch eine beliebige andere sein. Oft wird eine Lernrate ausgewählt, indem man einige ausprobiert und herausfindet, welche nach dem Training das beste Modell ergibt (wir werden dir später in diesem Buch einen besseren Ansatz zeigen, den sogenannten Lernratenfinder). Wenn du dich für eine Lernrate entschieden hast, kannst du deine Parameter mit dieser einfachen Funktion anpassen:

w -= w.grad * lr

Dies wird als " Steppen" deiner Parameter bezeichnet, wobei ein Optimierungsschritt verwendet wird.

Wenn du eine zu niedrige Lernrate wählst, kann das bedeuten, dass du sehr viele Schritte machen musst. Abbildung 4-2 veranschaulicht das.

An illustration of gradient descent with a LR too low
Abbildung 4-2. Gradientenabstieg mit niedriger LR

Noch schlimmer ist es, eine zu hohe Lernrate zu wählen - sie kann dazu führen, dass der Verlust noch größer wird, wie wir inAbbildung 4-3 sehen!

An illustration of gradient descent with a LR too high
Abbildung 4-3. Gradientenabstieg mit hoher LR

Wenn die Lernrate zu hoch ist, kann es auch sein, dass sie "herumspringt", anstatt zu divergieren; Abbildung 4-4 zeigt, wie dies dazu führt, dass man viele Schritte braucht, um erfolgreich zu trainieren.

An illustation of gradient descent with a bouncy LR
Abbildung 4-4. Gradientenabstieg mit hüpfendem LR

Wenden wir all dies nun in einem End-to-End-Beispiel an.

Ein End-to-End SGD-Beispiel

Wir haben gesehen, wie wir Gradienten verwenden, um unseren Verlust zu minimieren. Jetzt ist es an der Zeit, ein SGD-Beispiel zu betrachten und zu sehen, wie das Finden eines Minimums genutzt werden kann, um ein Modell zu trainieren, dasbesser zu den Daten passt.

Beginnen wir mit einem einfachen, synthetischen Beispielmodell. Stell dir vor, du würdest die Geschwindigkeit einer Achterbahn messen, während sie über einen Buckel fährt. Sie würde schnell starten und dann langsamer werden, während sie den Hügel hinauffährt; oben wäre sie am langsamsten und würde dann wieder schneller werden, während sie bergab fährt. Du möchtest ein Modell erstellen, das zeigt, wie sich die Geschwindigkeit mit der Zeit verändert. Wenn du die Geschwindigkeit 20 Sekunden lang jede Sekunde von Hand messen würdest, könnte es etwa so aussehen:

time = torch.arange(0,20).float(); time
tensor([ 0.,  1.,  2.,  3.,  4.,  5.,  6.,  7.,  8.,  9., 10., 11., 12., 13.,
 > 14., 15., 16., 17., 18., 19.])
speed = torch.randn(20)*3 + 0.75*(time-9.5)**2 + 1
plt.scatter(time,speed);

Wir haben ein wenig zufälliges Rauschen hinzugefügt, da manuelle Messungen nicht sehr präzise sind. Das bedeutet, dass es nicht so einfach ist, die Frage zu beantworten: Wie hoch war die Geschwindigkeit der Achterbahn? Mit SGD können wir versuchen, eine Funktion zu finden, die unseren Beobachtungen entspricht. Da wir nicht alle möglichen Funktionen in Betracht ziehen können, gehen wir davon aus, dass es sich um eine quadratische Funktion handelt, d. h. um eine Funktion der Form a*(time**2)+(b*time)+c.

Wir wollen klar zwischen der Eingabe der Funktion (die Zeit, in der wir die Geschwindigkeit der Achterbahn messen) und ihren Parametern (die Werte, die festlegen, welches Quadrat wir versuchen) unterscheiden. Deshalb fassen wir die Parameter in einem Argument zusammen und trennen so die Eingabe, t, und die Parameter, params, in der Signatur der Funktion:

def f(t, params):
    a,b,c = params
    return a*(t**2) + (b*t) + c

Mit anderen Worten: Wir haben das Problem, die beste denkbare Funktion zu finden, die zu den Daten passt, auf die Suche nach der bestenquadratischen Funktion beschränkt. Das vereinfacht das Problem erheblich, da jede quadratische Funktion vollständig durch die drei Parameter a, b und c definiert ist. Um die beste quadratische Funktion zu finden, müssen wir also nur die besten Werte für a, b und c finden.

Wenn wir dieses Problem für die drei Parameter einer quadratischen Funktion lösen können, sind wir in der Lage, den gleichen Ansatz für andere, komplexere Funktionen mit mehr Parametern anzuwenden - zum Beispiel für ein neuronales Netz. Lass uns zuerst die Parameter für f finden, und dann kommen wir zurück und machen das Gleiche für den MNIST-Datensatz mit einem neuronalen Netz.

Zunächst müssen wir definieren, was wir mit "am besten" meinen. Wir definieren dies genau, indem wir eine Verlustfunktion wählen, die einen Wert auf der Grundlage einer Vorhersage und eines Ziels liefert, wobei niedrigere Werte der Funktion "besseren" Vorhersagen entsprechen. Bei kontinuierlichen Daten ist es üblich, den mittleren quadratischen Fehler zu verwenden:

def mse(preds, targets): return ((preds-targets)**2).mean().sqrt()

Lass uns jetzt unseren Sieben-Schritte-Prozess durchgehen.

Schritt 1: Initialisiere die Parameter

Zuerst initialisieren wir die Parameter auf zufällige Werte und teilen PyTorch mit, dass wir ihre Gradienten mit requires_grad_ verfolgen wollen:

params = torch.randn(3).requires_grad_()

Schritt 2: Berechne die Vorhersagen

Als Nächstes berechnen wir die Vorhersagen:

preds = f(time, params)

Lass uns eine kleine Funktion erstellen, um zu sehen, wie nah unsere Vorhersagen an unseren Zielen liegen, und einen Blick darauf werfen:

def show_preds(preds, ax=None):
    if ax is None: ax=plt.subplots()[1]
    ax.scatter(time, speed)
    ax.scatter(time, to_np(preds), color='red')
    ax.set_ylim(-300,100)
show_preds(preds)

Das sieht nicht sehr gut aus - unsere Zufallsparameter deuten darauf hin, dass die Achterbahn am Ende rückwärts fahren wird, da wir negative Geschwindigkeiten haben!

Schritt 3: Berechne den Verlust

Wir berechnen den Verlust wie folgt:

loss = mse(preds, speed)
loss
tensor(25823.8086, grad_fn=<MeanBackward0>)

Unser Ziel ist es nun, dies zu verbessern. Dafür müssen wir die Farbverläufe kennen.

Schritt 4: Berechne die Gradienten

Der nächste Schritt ist die Berechnung der Gradienten oder eine Annäherung daran, wie sich die Parameter ändern müssen:

loss.backward()
params.grad
tensor([-53195.8594,  -3419.7146,   -253.8908])
params.grad * 1e-5
tensor([-0.5320, -0.0342, -0.0025])

Wir können diese Gradienten nutzen, um unsere Parameter zu verbessern. Wir müssen eine Lernrate festlegen (wie das in der Praxis funktioniert, besprechen wir im nächsten Kapitel; im Moment nehmen wir einfach 1e-5 oder 0,00001):

params
tensor([-0.7658, -0.7506,  1.3525], requires_grad=True)

Schritt 5: Schritt die Gewichte

Jetzt müssen wir die Parameter auf der Grundlage der soeben berechneten Gradienten aktualisieren:

lr = 1e-5
params.data -= lr * params.grad.data
params.grad = None

Alexis Sagt

Um das zu verstehen, muss man sich an die jüngste Geschichte erinnern. Um die Gradienten zu berechnen, rufen wir backward auf loss auf. Aber dieses loss wurde selbst von mse berechnet, das wiederum preds als Eingabe nahm, das wiederum von f berechnet wurde, das wiederum params als Eingabe nahm, das das Objekt war, auf dem wir ursprünglich required_grads_aufriefen - was der ursprüngliche Aufruf ist, der es uns nun ermöglicht, backward auf loss aufzurufen. Diese Kette von Funktionsaufrufen stellt die mathematische Komposition von Funktionen dar, die es PyTorch ermöglicht, die Kettenregel der Infinitesimalrechnung zu nutzen, um diese Gradienten zu berechnen.

Mal sehen, ob sich der Verlust verbessert hat:

preds = f(time,params)
mse(preds, speed)
tensor(5435.5366, grad_fn=<MeanBackward0>)

Und wirf einen Blick auf die Handlung:

show_preds(preds)

Wir müssen das ein paar Mal wiederholen, also erstellen wir eine Funktion, um einen Schritt anzuwenden:

def apply_step(params, prn=True):
    preds = f(time, params)
    loss = mse(preds, speed)
    loss.backward()
    params.data -= lr * params.grad.data
    params.grad = None
    if prn: print(loss.item())
    return preds

Schritt 6: Wiederhole den Vorgang

Jetzt iterieren wir. Indem wir Schleifen bilden und viele Verbesserungen vornehmen, hoffen wir, ein gutes Ergebnis zu erzielen:

for i in range(10): apply_step(params)
5435.53662109375
1577.4495849609375
847.3780517578125
709.22265625
683.0757446289062
678.12451171875
677.1839599609375
677.0025024414062
676.96435546875
676.9537353515625

Der Verlust geht zurück, genau wie wir gehofft hatten! Aber wenn man sich nur die Verlustzahlen ansieht, verdeckt man die Tatsache, dass bei jeder Iteration eine völlig andere quadratische Funktion ausprobiert wird, um die bestmögliche quadratische Funktion zu finden. Wir können diesen Prozess visuell nachvollziehen, wenn wir die Verlustfunktion nicht ausdrucken, sondern die Funktion bei jedem Schritt aufzeichnen. Dann können wir sehen, wie sich die Form der bestmöglichen quadratischen Funktion für unsere Daten annähert:

_,axs = plt.subplots(1,4,figsize=(12,3))
for ax in axs: show_preds(apply_step(params, False), ax)
plt.tight_layout()

Schritt 7: Stopp

Wir haben einfach beschlossen, nach 10 Epochen willkürlich aufzuhören. In der Praxis würden wir die Trainings- und Validierungsverluste und unsere Metriken beobachten, um zu entscheiden, wann wir aufhören, wie wires besprochen haben.

Zusammenfassender Gradientenabstieg

Nachdem du nun gesehen hast, was in den einzelnen Schritten passiert, lass uns noch einmal einen Blick auf unsere grafische Darstellung des Gradientenabstiegs werfen(Abbildung 4-5) und eine kurze Zusammenfassung machen.

Graph showing the steps for Gradient Descent
Abbildung 4-5. Der Prozess des Gradientenabstiegs

Zu Beginn können die Gewichte unseres Modells zufällig sein (Training von Grund auf) oder von einem vortrainierten Modell stammen(Transferlernen). Im ersten Fall hat die Ausgabe, die wir aus unseren Eingaben erhalten, nichts mit dem zu tun, was wir wollen, und selbst im zweiten Fall ist es wahrscheinlich, dass das trainierte Modell für die spezielle Aufgabe, die wir anstreben, nicht sehr gut ist. Also muss das Modell bessere Gewichte lernen.

Wir beginnen damit, dass wir die Ergebnisse, die das Modell liefert, mit unseren Zielen vergleichen (wir haben beschriftete Daten, also wissen wir, welches Ergebnis das Modell liefern sollte), indem wir eine Verlustfunktion verwenden, die eine Zahl liefert, die wir so niedrig wie möglich machen wollen, indem wir unsere Gewichte verbessern. Dazu nehmen wir einige Daten (z. B. Bilder) aus der Trainingsmenge und füttern unser Modell mit ihnen. Wir vergleichen die entsprechenden Ziele mit Hilfe unserer Verlustfunktion, und der Wert, den wir erhalten, zeigt uns, wie falsch unsere Vorhersagen waren. Dann ändern wir die Gewichte ein wenig, um es etwas besser zu machen.

Um herauszufinden, wie wir die Gewichte ändern können, um den Verlust ein bisschen besser zu machen, verwenden wir die Infinitesimalrechnung, um die Steigungen zu berechnen. (Eigentlich lassen wir das PyTorch für uns machen!) Lass uns eine Analogie betrachten. Stell dir vor, du hast dich in den Bergen verirrt und dein Auto ist am tiefsten Punkt geparkt. Um den Weg dorthin zu finden, könntest du in eine beliebige Richtung wandern, aber das würde dir wahrscheinlich nicht viel nützen. Da du weißt, dass dein Fahrzeug am tiefsten Punkt steht, wäre es besser, wenn du bergab gehst. Indem du immer einen Schritt in Richtung des steilsten Abhangs machst, solltest du schließlich an deinem Ziel ankommen. Wir verwenden die Größe der Steigung (d.h. die Steilheit des Abhangs), um zu bestimmen, wie groß der Schritt sein soll. Wir multiplizieren die Steigung mit einer Zahl, die wir als Lernrate bezeichnen, um die Schrittgröße zu bestimmen. Dann iterieren wir, bis wir den niedrigsten Punkt erreicht haben, der unser Parkplatz sein wird.

Alles, was wir gerade gesehen haben, kann direkt auf den MNIST-Datensatz übertragen werden, mit Ausnahme der Verlustfunktion. Schauen wir uns nun an, wie wir ein gutes Trainingsziel definieren können.

Die MNIST-Verlustfunktion

Wir haben bereits unsere xs, d.h. unsere unabhängigen Variablen, die Bilder selbst. Wir werden sie alle zu einem einzigen Tensor verketten und sie außerdem von einer Liste von Matrizen (einem Rang-3-Tensor) in eine Liste von Vektoren (einem Rang-2-Tensor) umwandeln. Dazu verwenden wir view, eine PyTorch-Methode, die die Form eines Tensors ändert, ohne seinen Inhalt zu verändern. -1 ist ein spezieller Parameter für view, der bedeutet: "Mach diese Achse so groß wie nötig, damit alle Daten hineinpassen":

train_x = torch.cat([stacked_threes, stacked_sevens]).view(-1, 28*28)

Wir brauchen ein Label für jedes Bild. Wir verwenden 1 für 3s und 0für 7s:

train_y = tensor([1]*len(threes) + [0]*len(sevens)).unsqueeze(1)
train_x.shape,train_y.shape
(torch.Size([12396, 784]), torch.Size([12396, 1]))

Ein Dataset in PyTorch muss ein Tupel von (x,y) zurückgeben, wenn es indiziert ist. Python bietet eine zip Funktion, die in Kombination mitlist eine einfache Möglichkeit bietet, diese Funktionalität zu erhalten:

dset = list(zip(train_x,train_y))
x,y = dset[0]
x.shape,y
(torch.Size([784]), tensor([1]))
valid_x = torch.cat([valid_3_tens, valid_7_tens]).view(-1, 28*28)
valid_y = tensor([1]*len(valid_3_tens) + [0]*len(valid_7_tens)).unsqueeze(1)
valid_dset = list(zip(valid_x,valid_y))

Jetzt brauchen wir ein (zunächst zufälliges) Gewicht für jedes Pixel (dies ist derInitialisierungsschritt in unserem siebenstufigen Prozess):

def init_params(size, std=1.0): return (torch.randn(size)*std).requires_grad_()
weights = init_params((28*28,1))

Die Funktion weights*pixels ist nicht flexibel genug - sie ist immer gleich 0, wenn die Pixel gleich 0 sind (d.h. ihr Achsenabschnitt ist 0). Vielleicht erinnerst du dich noch daran, dass die Formel für eine Linie y=w*x+b lautet; wir brauchen aber noch b. Wir werden auch sie mit einer Zufallszahl initialisieren:

bias = init_params(1)

In neuronalen Netzen werden die w in der Gleichung y=w*x+b alsGewichte und die b als Vorspannung bezeichnet. Zusammen bilden die Gewichte und der Bias die Parameter.

Jargon: Parameter

Die Gewichte und Verzerrungen eines Modells. Die Gewichte sind die w in der Gleichung w*x+b, und die Verzerrungen sind die b in dieser Gleichung.

Wir können nun eine Vorhersage für ein Bild berechnen:

(train_x[0]*weights.T).sum() + bias
tensor([20.2336], grad_fn=<AddBackward0>)

Wir könnten zwar eine Python-Schleife ( for ) verwenden, um die Vorhersage für jedes Bild () zu berechnen, aber das wäre sehr langsam. Da Python-Schleifen nicht auf dem Grafikprozessor laufen und Python generell eine langsame Sprache für Schleifen ist, müssen wir einen möglichst großen Teil der Berechnungen in einem Modell durch Funktionen auf höherer Ebene darstellen.

In diesem Fall gibt es eine äußerst bequeme mathematischeOperation, die w*x für jede Zeile einer Matrix berechnet - sie wird Matrixmultiplikation genannt.Abbildung 4-6 zeigt, wie die Matrixmultiplikation aussieht.

Matrix multiplication
Abbildung 4-6. Matrix-Multiplikation

Dieses Bild zeigt, wie zwei Matrizen, A und B, miteinander multipliziert werden. Jedes Element des Ergebnisses, das wir AB nennen, enthält jedes Element der entsprechenden Zeile von A multipliziert mit jedem Element der entsprechenden Spalte von B, addiert. Zum Beispiel wird Zeile 1, Spalte 2 (der gelbe Punkt mit dem roten Rand) wie folgt berechnet a 1,1 * b 1,2 + a 1,2 * b 2,2 . Wenn du eine Auffrischung der Matrixmultiplikation brauchst, empfehlen wir dir die"Einführung in die Matrixmultiplikation" auf Khan Academy, da dies die wichtigste mathematische Operation beim Deep Learning ist.

In Python wird die Matrixmultiplikation mit dem Operator @ dargestellt. Probieren wir es aus:

def linear1(xb): return xb@weights + bias
preds = linear1(train_x)
preds
tensor([[20.2336],
        [17.0644],
        [15.2384],
        ...,
        [18.3804],
        [23.8567],
        [28.6816]], grad_fn=<AddBackward0>)

Das erste Element ist dasselbe, das wir zuvor berechnet haben, wiezu erwarten war. Diese Gleichung, batch @ weights + bias, ist eine der beiden grundlegenden Gleichungen eines neuronalen Netzes (die andere ist die Aktivierungsfunktion, die wir gleich sehen werden).

Lass uns unsere Genauigkeit überprüfen. Um zu entscheiden, ob eine Ausgabe eine 3 oder eine 7 darstellt, können wir einfach prüfen, ob sie größer als 0 ist. Unsere Genauigkeit für jedes Element kann also wie folgt berechnet werden (mit Broadcasting, also ohne Schleifen!):

corrects = (preds>0.0).float() == train_y
corrects
tensor([[ True],
        [ True],
        [ True],
        ...,
        [False],
        [False],
        [False]])
corrects.float().mean().item()
0.4912068545818329

Jetzt wollen wir sehen, wie sich die Genauigkeit bei einer kleinen Änderung eines der Gewichte verändert:

weights[0] *= 1.0001
preds = linear1(train_x)
((preds>0.0).float() == train_y).float().mean().item()
0.4912068545818329

Wie wir gesehen haben, brauchen wir Gradienten, um unser Modell mit SGD zu verbessern, und um Gradienten zu berechnen, brauchen wir eine Verlustfunktion, die darstellt, wie gut unser Modell ist. Denn die Gradienten sind ein Maß dafür, wie sich die Verlustfunktion bei kleinen Änderungen an den Gewichten verändert.

Wir müssen also eine Verlustfunktion wählen. Der naheliegende Ansatz wäre, die Genauigkeit, die unsere Metrik ist, auch als Verlustfunktion zu verwenden. In diesem Fall würden wir unsere Vorhersage für jedes Bild berechnen, diese Werte sammeln, um eine Gesamtgenauigkeit zu ermitteln, und dann die Steigungen der einzelnen Gewichte in Bezug auf diese Gesamtgenauigkeit berechnen.

Leider haben wir hier ein großes technisches Problem. Die Steigung einer Funktion ist ihr Gefälle oder ihre Steilheit, die als Anstieg gegenüber dem Verlaufdefiniert werden kann , d.h.wie stark der Wert der Funktionansteigt oder abfällt, geteilt durch die Veränderung des Eingangswertes. Wir können dies mathematisch so ausdrücken:

(y_new – y_old) / (x_new – x_old)

Dies ergibt eine gute Annäherung an die Steigung, wenn x_new sehr ähnlich zu x_old ist, was bedeutet, dass ihr Unterschied sehr klein ist. Die Genauigkeit ändert sich aber nur, wenn eine Vorhersage von einer 3 zu einer 7 wechselt oder umgekehrt. Das Problem ist, dass eine kleine Änderung der Gewichte von x_old zu x_new wahrscheinlich keine Änderung der Vorhersage zur Folge hat, sodass (y_new – y_old) fast immer 0 sein wird.

Eine sehr kleine Änderung des Wertes einer Gewichtung ändert die Genauigkeit oft überhaupt nicht. Das bedeutet, dass es nicht sinnvoll ist, die Genauigkeit als Verlustfunktion zu verwenden - wenn wir das tun, werden unsere Gradienten meistens 0 sein, und das Modell wird nicht in der Lage sein, von dieserZahl zu lernen.

Sylvain Sagt

Mathematisch gesehen ist die Genauigkeit eine Funktion, die fast überall konstant ist (außer am Schwellenwert 0,5), sodass ihre Ableitung fast überall gleich Null ist (und am Schwellenwert unendlich). Daraus ergeben sich dann Gradienten, die 0 oder unendlich sind, was für die Aktualisierung des Modells nutzlos ist.

Stattdessen brauchen wir eine Verlustfunktion, die uns, wenn unsere Gewichte zu etwas besseren Vorhersagen führen, einen etwas besseren Verlust beschert. Wie sieht eine "etwas bessere Vorhersage" genau aus? Nun, in diesem Fall bedeutet es, dass die Punktzahl etwas höher ist, wenn die richtige Antwort eine 3 ist, oder etwas niedriger, wenn die richtige Antwort eine 7 ist.

Lass uns jetzt eine solche Funktion schreiben. Welche Form hat sie?

Die Verlustfunktion erhält nicht die Bilder selbst, sondern die Vorhersagen des Modells. Machen wir also ein Argument, prds, mit Werten zwischen 0 und 1, wobei jeder Wert die Vorhersage ist, dass ein Bild eine 3 ist. Es ist ein Vektor (d.h. ein Rang-1-Tensor), der über die Bilder indiziert ist.

Der Zweck der Verlustfunktion ist es, den Unterschied zwischen den vorhergesagten Werten und den wahren Werten - also den Zielwerten (aka Labels) - zu messen. Nehmen wir also ein weiteres Argument, trgts, mit Werten von 0 oder 1, das angibt, ob ein Bild tatsächlich eine 3 ist oder nicht. Es ist ebenfalls ein Vektor (d. h. ein weiterer Rang-1-Tensor), der über die Bilder indiziert wird.

Nehmen wir zum Beispiel an, wir hätten drei Bilder, von denen wir wissen, dass sie eine 3, eine 7 und eine 3 sind. Und nehmen wir an, dass unser Modell mit hoher Wahrscheinlichkeit (0.9) vorhersagt, dass das erste Bild eine 3 ist, mit geringer Wahrscheinlichkeit (0.4), dass das zweite Bild eine 7 ist, und mit ziemlich hoher Wahrscheinlichkeit (0.2), aber fälschlicherweise, dass das letzte Bild eine 7 ist. Das würde bedeuten, dass unsere Verlustfunktion diese Werte als Eingaben erhält:

trgts  = tensor([1,0,1])
prds   = tensor([0.9, 0.4, 0.2])

Hier ist ein erster Versuch mit einer Verlustfunktion, die den Abstand zwischen predictions und targets misst:

def mnist_loss(predictions, targets):
    return torch.where(targets==1, 1-predictions, predictions).mean()

Wir verwenden eine neue Funktion, torch.where(a,b,c). Sie ist dasselbe wie das Listenverständnis[b[i] if a[i] else c[i] for i in range(len(a))], nur dass sie mit Tensoren arbeitet, und zwar in C/CUDA-Geschwindigkeit. Im Klartext: Diese Funktion misst, wie weit jede Vorhersage von 1 entfernt ist, wenn sie 1 sein sollte, und wie weit sie von 0 entfernt ist, wenn sie 0 sein sollte, und bildet dann den Mittelwert dieser Abstände.

Lies die Docs

Es ist wichtig, sich mit PyTorch-Funktionen wie dieser vertraut zu machen, denn die Schleifenbildung über Tensoren in Python erfolgt in Python-Geschwindigkeit, nicht in C/CUDA-Geschwindigkeit! Rufe jetzt help(torch.where) auf, um die Dokumentation zu dieser Funktion zu lesen, oder noch besser, sie auf der PyTorch-Dokumentationsseite nachzuschlagen.

Probieren wir es auf unserer prds und trgts aus:

torch.where(trgts==1, 1-prds, prds)
tensor([0.1000, 0.4000, 0.8000])

Du kannst sehen, dass diese Funktion eine niedrigere Zahl zurückgibt, wenn die Vorhersagen genauer sind, wenn die genauen Vorhersagen sicherer sind (höhere absolute Werte) und wenn die ungenauen Vorhersagen weniger sicher sind. In PyTorch gehen wir immer davon aus, dass ein niedrigerer Wert einer Verlustfunktion besser ist. Da wir einen Skalar für den endgültigen Verlust benötigen, nimmt mnist_loss den Mittelwert des vorherigen Tensors:

mnist_loss(prds,trgts)
tensor(0.4333)

Wenn wir zum Beispiel unsere Vorhersage für das eine "falsche" Ziel von 0.2 auf 0.8 ändern, sinkt der Verlust, was bedeutet, dass dies eine bessere Vorhersage ist:

mnist_loss(tensor([0.9, 0.4, 0.8]),trgts)
tensor(0.2333)

Ein Problem von mnist_loss ist, dass es davon ausgeht, dass die Vorhersagen immer zwischen 0 und 1 liegen. Wir müssen also sicherstellen, dass dies tatsächlich der Fall ist! Zufälligerweise gibt es eine Funktion, die genau das tut - schauen wir sie uns mal an.

Sigmoid

Die Funktion sigmoid gibt immer eine Zahl zwischen 0 und 1 aus. Sie ist wiefolgt definiert:

def sigmoid(x): return 1/(1+torch.exp(-x))

PyTorch definiert eine beschleunigte Version für uns, so dass wir nicht wirklich eine eigene brauchen. Dies ist eine wichtige Funktion beim Deep Learning, da wir oft sicherstellen wollen, dass die Werte zwischen 0 und 1 liegen. So sieht sie aus:

plot_function(torch.sigmoid, title='Sigmoid', min=-4, max=4)

Wie du siehst, wird jeder beliebige Eingabewert, egal ob positiv oder negativ, in einen Ausgabewert zwischen 0 und 1 umgewandelt. Es handelt sich außerdem um eine glatte Kurve, die nur nach oben geht, was es für SGD einfacher macht, sinnvolle Steigungen zu finden.

Aktualisieren wir mnist_loss, um zuerst sigmoid auf die Eingänge anzuwenden:

def mnist_loss(predictions, targets):
    predictions = predictions.sigmoid()
    return torch.where(targets==1, 1-predictions, predictions).mean()

Jetzt können wir sicher sein, dass unsere Verlustfunktion funktioniert, auch wenn die Vorhersagen nicht zwischen 0 und 1 liegen. Alles, was wir brauchen, ist, dass eine höhere Vorhersage einem höheren Vertrauen entspricht.

Nachdem wir eine Verlustfunktion definiert haben, ist jetzt ein guter Zeitpunkt, um zu rekapitulieren, warum wir das getan haben. Schließlich hatten wir bereits eine Metrik, nämlich die Gesamtgenauigkeit. Warum haben wir also eine Verlustfunktion definiert?

Der entscheidende Unterschied besteht darin, dass die Metrik das menschliche Verständnis und der Verlust das automatische Lernen steuern soll. Um automatisiertes Lernen zu ermöglichen, muss die Verlustfunktion eine sinnvolle Ableitung haben. Sie darf keine großen flachen Abschnitte und große Sprünge haben, sondern muss einigermaßen glatt sein. Aus diesem Grund haben wir eine Verlustfunktion entwickelt, die auf kleine Änderungen des Konfidenzniveaus reagiert. Diese Anforderung bedeutet, dass sie manchmal nicht genau das widerspiegelt, was wir zu erreichen versuchen, sondern eher ein Kompromiss zwischen unserem eigentlichen Ziel und einer Funktion ist, die mithilfe ihres Gradienten optimiert werden kann. Die Verlustfunktion wird für jedes Element in unserem Datensatz berechnet, und am Ende einer Epoche werden die Verlustwerte gemittelt und der Gesamtmittelwert für die Epoche angegeben.

Metriken hingegen sind die Zahlen, die uns interessieren. Das sind die Werte, die am Ende jeder Epoche ausgegeben werden und uns sagen, wie unser Modell abschneidet. Es ist wichtig, dass wir lernen, uns bei der Beurteilung der Leistung eines Modells auf diese Metriken zu konzentrieren und nicht auf den Verlust.

SGD und Mini-Batches

Da wir nun eine Verlustfunktion haben, die für die Steuerung der SGD geeignet ist, können wir einige Details für die nächste Phase des Lernprozesses berücksichtigen, nämlich die Änderung oder Aktualisierung der Gewichte auf der Grundlage der Gradienten. Dies wird als Optimierungsschritt bezeichnet.

Um einen Optimierungsschritt zu machen, müssen wir den Verlust über ein oder mehrere Datenelemente berechnen. Wie viele sollten wir verwenden? Wir könnten ihn für den gesamten Datensatz berechnen und den Durchschnitt nehmen, oder wir könnten ihn für ein einzelnes Datenelement berechnen. Aber beides ist nicht ideal. Die Berechnung für den gesamten Datensatz würde sehr lange dauern. Bei der Berechnung für ein einzelnes Element würden nicht viele Informationen verwendet, sodass ein ungenauer und instabiler Gradient entstehen würde. Du würdest dir die Mühe machen, die Gewichte zu aktualisieren, dabei aber nur berücksichtigen, wie sich die Leistung des Modells für dieses einzelne Element verbessern würde.

Stattdessen gehen wir einen Kompromiss ein: Wir berechnen den durchschnittlichen Verlust für ein paar Daten auf einmal. Das nennt man einenMini-Batch. Die Anzahl der Daten im Mini-Batch wird alsStapelgröße bezeichnet. Eine größere Stapelgröße bedeutet, dass du eine genauere und stabilere Schätzung der Gradienten deines Datensatzes aus der Verlustfunktion erhältst, aber es dauert länger und du musst weniger Ministapel pro Epoche verarbeiten. Die Wahl einer guten Stapelgröße ist eine der Entscheidungen, die du als Deep Learning-Praktiker/in treffen musst, um dein Modell schnell und genau zu trainieren. Wir werden im Laufe dieses Buches darüber sprechen, wie du diese Entscheidung triffst.

Ein weiterer guter Grund für die Verwendung von Mini-Batches anstelle der Berechnung des Gradienten für einzelne Datenelemente ist, dass wir in der Praxis fast immer auf einem Beschleuniger wie einem Grafikprozessor trainieren. Diese Beschleuniger sind nur dann gut, wenn sie viel zu tun haben. Daher ist es hilfreich, wenn wir ihnen viele Daten zur Verfügung stellen können, mit denen sie arbeiten können. Die Verwendung von Mini-Batches ist eine der besten Möglichkeiten, dies zu tun. Wenn du ihnen jedoch zu viele Daten auf einmal zur Verfügung stellst, geht ihnen der Speicher aus - und das macht die GPUs glücklich!

Wie du in unserer Diskussion über die Datenerweiterung in Kapitel 2 gesehen hast, erhalten wir eine bessere Verallgemeinerung, wenn wir während des Trainings Dinge variieren können. Eine einfache und effektive Möglichkeit ist, die Daten in jedem Mini-Batch zu variieren. Anstatt unseren Datensatz für jede Epoche einfach der Reihe nach aufzuzählen, mischen wir ihn normalerweise bei jeder Epoche zufällig, bevor wir Mini-Batches erstellen. PyTorch und fastai bieten eine Klasse, die das Mischen und Zusammenstellen von Mini-Batches für dich übernimmt: DataLoader.

Eine DataLoader kann eine beliebige Python-Sammlung in einen Iterator über viele Stapel verwandeln, etwa so:

coll = range(15)
dl = DataLoader(coll, batch_size=5, shuffle=True)
list(dl)
[tensor([ 3, 12,  8, 10,  2]),
 tensor([ 9,  4,  7, 14,  5]),
 tensor([ 1, 13,  0,  6, 11])]

Um ein Modell zu trainieren, brauchen wir nicht irgendeine Python-Sammlung, sondern eine Sammlung, die unabhängige und abhängige Variablen enthält (die Eingaben und Ziele des Modells). Eine Sammlung, die Tupel von unabhängigen und abhängigen Variablen enthält, wird in PyTorch als Dataset bezeichnet. Hier ist ein Beispiel für eine sehr einfache Dataset:

ds = L(enumerate(string.ascii_lowercase))
ds
(#26) [(0, 'a'),(1, 'b'),(2, 'c'),(3, 'd'),(4, 'e'),(5, 'f'),(6, 'g'),(7,
 > 'h'),(8, 'i'),(9, 'j')...]

Wenn wir eine Dataset an eine DataLoader übergeben, bekommen wir viele Stapel zurück, die ihrerseits Tupel von Tensoren sind, die Stapel von unabhängigen und abhängigenVariablen darstellen:

dl = DataLoader(ds, batch_size=6, shuffle=True)
list(dl)
[(tensor([17, 18, 10, 22,  8, 14]), ('r', 's', 'k', 'w', 'i', 'o')),
 (tensor([20, 15,  9, 13, 21, 12]), ('u', 'p', 'j', 'n', 'v', 'm')),
 (tensor([ 7, 25,  6,  5, 11, 23]), ('h', 'z', 'g', 'f', 'l', 'x')),
 (tensor([ 1,  3,  0, 24, 19, 16]), ('b', 'd', 'a', 'y', 't', 'q')),
 (tensor([2, 4]), ('c', 'e'))]

Wir sind jetzt bereit, unsere erste Trainingsschleife für ein Modell mit SGD zu schreiben!

Alles zusammenfügen

Es ist an der Zeit, den inAbbildung 4-1 gezeigten Prozess zu implementieren. Im Code wird unser Prozess für jede Epoche etwa so implementiert:

for x,y in dl:
    pred = model(x)
    loss = loss_func(pred, y)
    loss.backward()
    parameters -= parameters.grad * lr

Zuerst müssen wir unsere Parameter neu initialisieren:

weights = init_params((28*28,1))
bias = init_params(1)

Eine DataLoader kann aus einer Dataset erstellt werden:

dl = DataLoader(dset, batch_size=256)
xb,yb = first(dl)
xb.shape,yb.shape
(torch.Size([256, 784]), torch.Size([256, 1]))

Das Gleiche machen wir mit dem Validierungsset:

valid_dl = DataLoader(valid_dset, batch_size=256)

Lass uns einen Mini-Batch der Größe 4 zum Testen erstellen:

batch = train_x[:4]
batch.shape
torch.Size([4, 784])
preds = linear1(batch)
preds
tensor([[-11.1002],
        [  5.9263],
        [  9.9627],
        [ -8.1484]], grad_fn=<AddBackward0>)
loss = mnist_loss(preds, train_y[:4])
loss
tensor(0.5006, grad_fn=<MeanBackward0>)

Jetzt können wir die Gradienten berechnen:

loss.backward()
weights.grad.shape,weights.grad.mean(),bias.grad
(torch.Size([784, 1]), tensor(-0.0001), tensor([-0.0008]))

Lass uns das alles in eine Funktion packen:

def calc_grad(xb, yb, model):
    preds = model(xb)
    loss = mnist_loss(preds, yb)
    loss.backward()

Und teste sie:

calc_grad(batch, train_y[:4], linear1)
weights.grad.mean(),bias.grad
(tensor(-0.0002), tensor([-0.0015]))

Aber sieh mal, was passiert, wenn wir es zweimal anrufen:

calc_grad(batch, train_y[:4], linear1)
weights.grad.mean(),bias.grad
(tensor(-0.0003), tensor([-0.0023]))

Die Farbverläufe haben sich geändert! Der Grund dafür ist, dass loss.backward die Farbverläufe von loss zu den aktuell gespeicherten Farbverläufen hinzufügt. Wir müssen also zuerst die aktuellen Gradienten auf 0 setzen:

weights.grad.zero_()
bias.grad.zero_();

Vor-Ort-Einsätze

Methoden in PyTorch, deren Namen mit einem Unterstrich enden, ändern ihre Objekte an Ort und Stelle. Zum Beispiel setzt bias.zero_ alle Elemente des Tensors bias auf 0.

Unser einziger verbleibender Schritt ist die Aktualisierung der Gewichte und Verzerrungen auf der Grundlage des Gradienten und der Lernrate. Wenn wir das tun, müssen wir PyTorch sagen, dass es den Gradienten dieses Schritts nicht übernehmen soll - sonst wird es verwirrend, wenn wir versuchen, die Ableitung beim nächsten Batch zu berechnen! Wenn wir das Attribut data eines Tensors zuweisen, übernimmt PyTorch den Gradienten dieses Schritts nicht. Hier ist unsere grundlegende Trainingsschleife für eine Epoche:

def train_epoch(model, lr, params):
    for xb,yb in dl:
        calc_grad(xb, yb, model)
        for p in params:
            p.data -= p.grad*lr
            p.grad.zero_()

Wir wollen auch überprüfen, wie gut wir abschneiden, indem wir uns die Genauigkeit der Validierungsmenge ansehen. Um zu entscheiden, ob eine Ausgabe eine 3 oder eine 7 darstellt, können wir einfach prüfen, ob sie größer als 0,5 ist. Unsere Genauigkeit für jedes Element kann also wie folgt berechnet werden (mit Hilfe von Broadcasting, also ohne Schleifen!):

(preds>0.5).float() == train_y[:4]
tensor([[False],
        [ True],
        [ True],
        [False]])

So erhalten wir diese Funktion, mit der wir unsere Validierungsgenauigkeit berechnen können:

def batch_accuracy(xb, yb):
    preds = xb.sigmoid()
    correct = (preds>0.5) == yb
    return correct.float().mean()

Wir können überprüfen, ob es funktioniert:

batch_accuracy(linear1(batch), train_y[:4])
tensor(0.5000)

Und dann legst du die Stapel zusammen:

def validate_epoch(model):
    accs = [batch_accuracy(model(xb), yb) for xb,yb in valid_dl]
    return round(torch.stack(accs).mean().item(), 4)
validate_epoch(linear1)
0.5219

Das ist unser Startpunkt. Lass uns eine Epoche lang trainieren und sehen, ob sich die Genauigkeit verbessert:

lr = 1.
params = weights,bias
train_epoch(linear1, lr, params)
validate_epoch(linear1)
0.6883

Dann mach ein paar mehr:

for i in range(20):
    train_epoch(linear1, lr, params)
    print(validate_epoch(linear1), end=' ')
0.8314 0.9017 0.9227 0.9349 0.9438 0.9501 0.9535 0.9564 0.9594 0.9618 0.9613
 > 0.9638 0.9643 0.9652 0.9662 0.9677 0.9687 0.9691 0.9691 0.9696

Sieht gut aus! Wir haben bereits ungefähr die gleiche Genauigkeit wie bei unserem "Pixelähnlichkeits"-Ansatz erreicht und eine allgemeine Grundlage geschaffen, auf der wir aufbauen können. Unser nächster Schritt ist die Erstellung eines Objekts, das den SGD-Schritt für uns übernimmt. In PyTorch heißt das ein Optimierer.

Einen Optimierer erstellen

Weil dies eine so allgemeine Grundlage ist, bietet PyTorch einige nützliche Klassen, die die Implementierung erleichtern. Als Erstes können wir unsere linear1 Funktion durch dasnn.Linear Modul von PyTorch ersetzen. Ein Modul ist ein Objekt einer Klasse, die von der PyTorch nn.Module Klasse erbt. Objekte dieser Klasse verhalten sich genauso wie normale Python-Funktionen, d.h. du kannst sie mit Klammern aufrufen und sie geben die Aktivierungen eines Modells zurück.

nn.Linear macht das Gleiche wie unsere init_params und linearzusammen. Sie enthält sowohl die Gewichte als auch die Verzerrungen in einer einzigen Klasse. So wiederholen wir unser Modell aus dem vorherigen Abschnitt:

linear_model = nn.Linear(28*28,1)

Jedes PyTorch-Modul weiß, welche Parameter es hat, die trainiert werden können; sie sind über die Methode parameters verfügbar:

w,b = linear_model.parameters()
w.shape,b.shape
(torch.Size([1, 784]), torch.Size([1]))

Wir können diese Informationen nutzen, um einen Optimierer zu erstellen:

class BasicOptim:
    def __init__(self,params,lr): self.params,self.lr = list(params),lr

    def step(self, *args, **kwargs):
        for p in self.params: p.data -= p.grad.data * self.lr

    def zero_grad(self, *args, **kwargs):
        for p in self.params: p.grad = None

Wir können unseren Optimierer erstellen, indem wir die Parameter des Modells übergeben:

opt = BasicOptim(linear_model.parameters(), lr)

Unsere Ausbildungsschleife kann nun vereinfacht werden:

def train_epoch(model):
    for xb,yb in dl:
        calc_grad(xb, yb, model)
        opt.step()
        opt.zero_grad()

Unsere Validierungsfunktion muss sich überhaupt nicht ändern:

validate_epoch(linear_model)
0.4157

Um die Sache zu vereinfachen, packen wir unsere kleine Trainingsschleife in eine Funktion:

def train_model(model, epochs):
    for i in range(epochs):
        train_epoch(model)
        print(validate_epoch(model), end=' ')

Die Ergebnisse sind dieselben wie im vorherigen Abschnitt:

train_model(linear_model, 20)
0.4932 0.8618 0.8203 0.9102 0.9331 0.9468 0.9555 0.9629 0.9658 0.9673 0.9687
 > 0.9707 0.9726 0.9751 0.9761 0.9761 0.9775 0.978 0.9785 0.9785

fastai stellt die Klasse SGD zur Verfügung, die standardmäßig das Gleiche tutwie unsereBasicOptim:

linear_model = nn.Linear(28*28,1)
opt = SGD(linear_model.parameters(), lr)
train_model(linear_model, 20)
0.4932 0.852 0.8335 0.9116 0.9326 0.9473 0.9555 0.9624 0.9648 0.9668 0.9692
 > 0.9712 0.9731 0.9746 0.9761 0.9765 0.9775 0.978 0.9785 0.9785

fastai bietet auch Learner.fit an, das wir anstelle vontrain_model verwenden können. Um eine Learner zu erstellen, müssen wir zunächst eineDataLoaders erstellen, indem wir unsere Trainings- und Validierungsdaten DataLoaderübergeben:

dls = DataLoaders(dl, valid_dl)

Um eine Learner zu erstellen, ohne eine Anwendung (wiecnn_learner ) zu verwenden, müssen wir alle Elemente, die wir in diesem Kapitel erstellt haben, übergeben: das DataLoaders, das Modell, die Optimierungsfunktion (der die Parameter übergeben werden), die Verlustfunktion und optional die zu druckenden Metriken:

learn = Learner(dls, nn.Linear(28*28,1), opt_func=SGD,
                loss_func=mnist_loss, metrics=batch_accuracy)

Jetzt können wir fit aufrufen:

learn.fit(10, lr=lr)
Epoche train_loss gültig_verlust Batch_Genauigkeit Zeit
0 0.636857 0.503549 0.495584 00:00
1 0.545725 0.170281 0.866045 00:00
2 0.199223 0.184893 0.831207 00:00
3 0.086580 0.107836 0.911187 00:00
4 0.045185 0.078481 0.932777 00:00
5 0.029108 0.062792 0.946516 00:00
6 0.022560 0.053017 0.955348 00:00
7 0.019687 0.046500 0.962218 00:00
8 0.018252 0.041929 0.965162 00:00
9 0.017402 0.038573 0.967615 00:00

Wie du siehst, haben die Klassen PyTorch und fastai nichts Magisches an sich. Sie sind einfach nur praktische Fertigpakete, die dein Leben ein bisschen einfacher machen! (Außerdem bieten sie eine Menge zusätzlicher Funktionen, die wir in späteren Kapiteln verwenden werden).

Mit diesen Klassen können wir nun unser lineares Modell durch ein neuronales Netz ersetzen.

Hinzufügen einer Nichtlinearität

Bisher haben wir ein allgemeines Verfahren zur Optimierung der Parameter einer Funktion entwickelt und es an einer langweiligen Funktion ausprobiert: einem einfachen linearen Klassifikator. Ein linearer Klassifikator ist in seinen Möglichkeiten eingeschränkt. Um ihn etwas komplexer zu machen (und mehr Aufgaben bewältigen zu können), müssen wir etwas Nichtlineares (d. h. etwas anderes als ax+b) zwischen zwei lineare Klassifikatoren einfügen - so entsteht ein neuronales Netzwerk.

Hier ist die vollständige Definition eines grundlegenden neuronalen Netzes:

def simple_net(xb):
    res = xb@w1 + b1
    res = res.max(tensor(0.0))
    res = res@w2 + b2
    return res

Das war's! Alles, was wir in simple_net haben, sind zwei lineare Klassifikatoren mit einer max Funktion zwischen ihnen.

Hier sind w1 und w2 Gewichtstensoren und b1 und b2 Vorspannungstensoren, d.h. Parameter, die anfangs zufällig initialisiert werden, wie wir es im vorherigen Abschnitt getan haben:

w1 = init_params((28*28,30))
b1 = init_params(30)
w2 = init_params((30,1))
b2 = init_params(1)

Der entscheidende Punkt ist, dass w1 30 Ausgangsaktivierungen hat (was bedeutet, dass w2 30 Eingangsaktivierungen haben muss, damit sie übereinstimmen). Das bedeutet, dass die erste Schicht 30 verschiedene Merkmale konstruieren kann, die jeweils eine andere Mischung von Pixeln darstellen. Du kannst 30 nach Belieben ändern, um das Modell mehr oder weniger komplex zu machen.

Diese kleine Funktion res.max(tensor(0.0)) wird gleichgerichtete lineare Einheit genannt, auch bekannt als ReLU. Wir sind uns wohl alle einig, dassrectified linear unit ziemlich schick und kompliziert klingt... Aber eigentlich ist es nicht mehr alsres.max(tensor(0.0))- mit anderen Worten: Ersetze jede negative Zahl durch eine Null. Diese kleine Funktion ist auch in PyTorch alsF.relu verfügbar:

plot_function(F.relu)

Jeremy Sagt

Es gibt eine enorme Menge an Jargon im Deep Learning, darunter Begriffe wie " rectified linear unit". Der Großteil dieses Jargons ist nicht komplizierter, als er in einer kurzen Codezeile implementiert werden kann, wie wir in diesem Beispiel gesehen haben. Die Realität sieht so aus, dass Akademikerinnen und Akademiker ihre Arbeiten so beeindruckend und anspruchsvoll wie möglich gestalten müssen, um sie zu veröffentlichen. Eine Möglichkeit, das zu erreichen, ist die Einführung von Fachjargon. Leider führt das dazu, dass das Fachgebiet viel einschüchternder und schwieriger zu betreten ist, als es sein sollte. Du musst den Fachjargon lernen, denn sonst werden dir Papiere und Tutorials nicht viel nützen. Das heißt aber nicht, dass du den Fachjargon einschüchternd finden musst. Denke daran: Wenn du auf ein Wort oder einen Satz stößt, den du noch nicht kennst, wird sich mit ziemlicher Sicherheit herausstellen, dass er sich auf ein sehr einfaches Konzept bezieht.

Der Grundgedanke ist, dass wir durch die Verwendung mehrerer linearer Schichten unserModell mehr Berechnungen durchführen lassen und somit komplexere Funktionen modellieren können. Es macht aber keinen Sinn, einfach eine lineare Schicht direkt hinter eine andere zu setzen, denn wenn wir Dinge miteinander multiplizieren und dann mehrfach addieren, könnte das durch die Multiplikation verschiedener Dinge miteinander und deren einmalige Addition ersetzt werden! Das heißt, eine Reihe von beliebig vielen linearen Ebenen in einer Reihe kann durch eine einzige lineare Ebene mit einem anderen Satz von Parametern ersetzt werden.

Wenn wir aber eine nichtlineare Funktion dazwischen schalten, wie z. B. max, ist diesenicht mehr wahr. Jetzt ist jede lineare Ebene etwas von den anderen entkoppelt und kann ihre eigene nützliche Arbeit verrichten. Die Funktion max ist besonders interessant, weil sie wie eine einfache ifAnweisung funktioniert.

Sylvain Sagt

Mathematisch gesehen ist die Zusammensetzung von zwei linearen Funktionen eine weitere lineare Funktion. Wir können also so viele lineare Klassifikatoren übereinander stapeln, wie wir wollen, und ohne nichtlineare Funktionen dazwischen ist es genau dasselbe wie ein linearer Klassifikator.

Erstaunlicherweise kann mathematisch bewiesen werden, dass diese kleine Funktion jedes berechenbare Problem mit einer beliebig hohen Genauigkeit lösen kann, wenn du die richtigen Parameter für w1 und w2 findest und diese Matrizen groß genug machst. Für jede beliebige wackelige Funktion können wir sie als ein Bündel miteinander verbundener Linien annähern; um sie näher an die wackeligeFunktion zu bringen, müssen wir nur kürzere Linien verwenden. Das ist das universelle Approximationstheorem. Die drei Codezeilen, die wir hier haben, werden als Schichten bezeichnet. Die erste und die dritte Schicht werden als lineare Schichten bezeichnet, die zweite Codezeile wird als Nichtlinearität oderAktivierungsfunktion bezeichnet.

Genau wie im vorherigen Abschnitt können wir diesen Code durch etwas einfacheres ersetzen, indem wir PyTorch nutzen:

simple_net = nn.Sequential(
    nn.Linear(28*28,30),
    nn.ReLU(),
    nn.Linear(30,1)
)

nn.Sequential erstellt ein Modul, das nacheinander alle aufgelisteten Schichten oder Funktionen aufruft.

nn.ReLU ist ein PyTorch-Modul, das genau dasselbe tut wie die Funktion F.relu. Die meisten Funktionen, die in einem Modell vorkommen können, haben auch identische Formen, die Module sind. Im Allgemeinen muss man nur F durch nn ersetzen und die Großschreibung ändern. Wenn wir nn.Sequential verwenden, verlangt PyTorch, dass wir die Modulversion verwenden. Da Module Klassen sind, müssen wir sie instanziieren, weshalb du in diesemBeispiel nn.ReLU siehst.

Da nn.Sequential ein Modul ist, können wir seine Parameter abfragen, was eine Liste aller Parameter aller enthaltenen Module ergibt. Lass es uns ausprobieren! Da es sich um ein tieferes Modell handelt, verwenden wir eine niedrigere Lernrate und ein paar mehr Epochen:

learn = Learner(dls, simple_net, opt_func=SGD,
                loss_func=mnist_loss, metrics=batch_accuracy)
learn.fit(40, 0.1)

Wir zeigen die 40 Zeilen der Ausgabe hier nicht, um Platz zu sparen. Der Trainingsprozess wird in learn.recorder aufgezeichnet, wobei die Tabelle mit der Ausgabe im Attribut values gespeichert wird, damit wir die Genauigkeit im Verlauf des Trainings darstellen können:

plt.plot(L(learn.recorder.values).itemgot(2));

Und wir können die endgültige Genauigkeit sehen:

learn.recorder.values[-1][2]
0.982826292514801

An diesem Punkt haben wir etwas, das ziemlich magisch ist:

  • Eine Funktion, die jedes Problem mit beliebiger Genauigkeit lösen kann (das neuronale Netz), wenn die richtigen Parameter eingestellt sind

  • Ein Weg, den besten Parametersatz für jede Funktion zu finden (stochastischer Gradientenabstieg)

Deshalb kann Deep Learning so fantastische Dinge bewirken. Der Glaube, dass diese Kombination einfacher Techniken wirklich jedes Problem lösen kann, ist einer der größten Schritte, den viele Schüler/innen machen müssen. Es scheint zu schön, um wahr zu sein - die Dinge müssten doch eigentlich viel schwieriger und komplizierter sein? Unsere Empfehlung: Probiere es aus! Wir haben es gerade mit dem MNIST-Datensatz ausprobiert, und du hast die Ergebnisse gesehen. Und da wir alles von Grund auf selbst machen (außer der Berechnung der Gradienten), weißt du, dass sich hinter den Kulissen keine besondere Magie verbirgt.

Tiefer gehen

Es gibt keinen Grund, sich auf zwei lineare Ebenen zu beschränken. Wir können so viele hinzufügen, wie wir wollen, solange wir zwischen jedem Paar linearerSchichten eine Nichtlinearität hinzufügen. Je tiefer das Modell jedoch wird, desto schwieriger ist es, die Parameter in der Praxis zu optimieren, wie du noch lernen wirst. Später in diesem Buch wirst du einige einfache, aber äußerst effektive Techniken kennenlernen, um tiefere Modelle zu trainieren.

Wir wissen bereits, dass eine einzige Nichtlinearität mit zwei linearen Schichten ausreicht, um jede Funktion zu approximieren. Warum sollten wir also tiefere Modelle verwenden? Der Grund ist die Leistung. Bei einem tieferen Modell (mit mehr Schichten) müssen wir nicht so viele Parameter verwenden. Es stellt sich heraus, dass wir mit kleineren Matrizen und mehr Schichten bessere Ergebnisse erzielen können als mit größeren Matrizen und weniger Schichten.

Das bedeutet, dass wir das Modell schneller trainieren können und esweniger Speicherplatz benötigt. In den 1990er Jahren waren die Forscherinnen und Forscher so sehr auf das universelle Approximationstheorem konzentriert, dass nur wenige mit mehr als einer Nichtlinearität experimentierten. Diese theoretische, aber nicht praktische Grundlage hielt das Feld jahrelang zurück. Einige Forscherinnen und Forscher experimentierten jedoch mit tiefen Modellen und konnten schließlich zeigen, dass diese Modelle in der Praxis viel besser abschneiden können. Schließlich wurden theoretische Ergebnisse entwickelt, die zeigten, warum das so ist. Heute ist es äußerst selten, dass jemand ein neuronales Netz mit nur einer Nichtlinearität verwendet.

So sieht es aus, wenn wir ein 18-Schichten-Modell mit dem gleichen Ansatz trainieren, den wir in Kapitel 1 gesehen haben:

dls = ImageDataLoaders.from_folder(path)
learn = cnn_learner(dls, resnet18, pretrained=False,
                    loss_func=F.cross_entropy, metrics=accuracy)
learn.fit_one_cycle(1, 0.1)
Epoche train_loss gültig_verlust Genauigkeit Zeit
0 0.082089 0.009578 0.997056 00:11

Nahezu 100% Genauigkeit! Das ist ein großer Unterschied im Vergleich zu unserem einfachen neuronalen Netz. Aber wie du im weiteren Verlauf dieses Buches lernen wirst, gibt es nur ein paar kleine Tricks, die du anwenden musst, um selbst so gute Ergebnisse zu erzielen. Die wichtigsten Grundlagen kennst du bereits. (Natürlich wirst du auch dann, wenn du alle Tricks kennst, fast immer mit den vorgefertigten Klassen von PyTorch und fastai arbeiten wollen, damit du dich nicht selbst um all die kleinen Details kümmern musst).

Jargon Rekapitulation

Herzlichen Glückwunsch: Du weißt jetzt, wie du ein tiefes neuronales Netzwerk von Grund auf erstellen und trainieren kannst! Wir haben einige Schritte durchlaufen, um zu diesem Punkt zu gelangen, aber du wirst überrascht sein, wie einfach es wirklich ist.

Jetzt, wo wir an diesem Punkt angelangt sind, ist es eine gute Gelegenheit, einige Fachbegriffe und Schlüsselkonzepte zu definieren und zu überprüfen.

Ein neuronales Netzwerk enthält eine Menge Zahlen, aber es gibt nur zwei Arten von Zahlen: Zahlen, die berechnet werden, und die Parameter, aus denen diese Zahlen berechnet werden. Das sind die zwei wichtigsten Begriffe, die wir lernen müssen:

Aktivierungen

Zahlen, die berechnet werden (sowohl von linearen als auch von nichtlinearen Schichten)

Parameter

Zahlen, die zufällig initialisiert und optimiert werden (d.h. die Zahlen, die das Modell definieren)

Wir werden in diesem Buch oft über Aktivierungen und Parameter sprechen. Erinnere dich daran, dass sie bestimmte Bedeutungen haben. Sie sind Zahlen. Sie sind keine abstrakten Konzepte, sondern konkrete Zahlen, die in deinem Modell vorkommen. Um ein guter Deep Learning-Praktiker zu werden, musst du dich an den Gedanken gewöhnen, deine Aktivierungen und Parameter zu betrachten, sie aufzuzeichnen und zu testen, ob sie sich richtig verhalten.

Unsere Aktivierungen und Parameter sind alle in Tensoren enthalten. Diese sindeinfach regelmäßig geformte Arrays - zum Beispiel eine Matrix. Matrizen haben Zeilen und Spalten; wir nennen sie die Achsen oder Dimensionen. Die Anzahl der Dimensionen eines Tensors ist sein Rang. Es gibt einige besondere Tensoren:

  • Rang-0: skalar

  • Rang-1: Vektor

  • Rang-2: Matrix

Ein neuronales Netz besteht aus einer Reihe von Schichten. Jede Schicht ist entwederlinear oder nichtlinear. In der Regel wechseln wir in einem neuronalen Netz zwischen diesen beiden Arten von Schichten ab. Manchmal werden eine lineare Schicht und die darauf folgende nichtlineare Schicht zusammen als eine einzige Schicht bezeichnet. Ja, das ist verwirrend. Manchmal wird eine Nichtlinearität auch alsAktivierungsfunktion bezeichnet.

Tabelle 4-1 fasst die wichtigsten Konzepte im Zusammenhang mit SGD zusammen.

Tabelle 4-1. Deep Learning-Vokabular
Begriff Bedeutung

ReLU

Funktion, die bei negativen Zahlen 0 zurückgibt und positive Zahlen nicht verändert.

Mini-Batch

Eine kleine Gruppe von Eingaben und Bezeichnungen, die in zwei Arrays zusammengefasst sind. Ein Gradientenabstiegsschritt wird auf diesem Stapel aktualisiert (statt auf einer ganzen Epoche).

Vorwärtspass

Anwenden des Modells auf eine Eingabe und Berechnen der Vorhersagen.

Verlust

Ein Wert, der angibt, wie gut (oder schlecht) unser Modell abschneidet.

Steigung

Die Ableitung des Verlusts in Bezug auf einen Parameter des Modells.

Rückwärtspass

Berechne die Gradienten des Verlustes in Abhängigkeit von allen Modellparametern.

Gradientenabstieg

Einen Schritt in die entgegengesetzte Richtung zu den Gradienten machen, um die Modellparameter ein wenig zu verbessern.

Lernrate

Die Größe des Schrittes, den wir bei der Anwendung von SGD zur Aktualisierung der Parameter des Modells machen.

Wähle dein eigenes Abenteuer Erinnerung

Hast du in deiner Aufregung, einen Blick unter die Haube zu werfen, die Kapitel 2 und 3 übersprungen? Dann erinnere dich jetzt daran, zuKapitel 2 zurückzukehren, denn das wirst du bald wissen müssen!

Fragebogen

  1. Wie wird ein Graustufenbild auf einem Computer dargestellt? Wie sieht es mit einem Farbbild aus?

  2. Wie sind die Dateien und Ordner im MNIST_SAMPLE Datensatz strukturiert? Warum?

  3. Erkläre, wie der Ansatz der "Pixelähnlichkeit" zur Klassifizierung von Zahlen funktioniert.

  4. Was ist ein Listenverstehen? Erstelle nun einen, der ungerade Zahlen aus einer Liste auswählt und sie verdoppelt.

  5. Was ist ein Rang-3-Tensor?

  6. Was ist der Unterschied zwischen Tensorrang und Form? Wie erhältst du den Rang aus der Form?

  7. Was sind RMSE und L1-Norm?

  8. Wie kannst du eine Berechnung auf Tausende von Zahlen gleichzeitig anwenden, die viele tausend Mal schneller ist als eine Python-Schleife?

  9. Erstelle einen 3×3 Tensor oder ein Feld, das die Zahlen von 1 bis 9 enthält. Verdopple ihn. Wähle die vier Zahlen unten rechts aus.

  10. Was ist Broadcasting?

  11. Werden die Metriken in der Regel anhand des Trainingssets oder des Validierungssets berechnet? Warum?

  12. Was ist SGD?

  13. Warum verwendet die SGD Mini-Batches?

  14. Was sind die sieben Schritte im SGD für maschinelles Lernen?

  15. Wie initialisieren wir die Gewichte in einem Modell?

  16. Was ist Verlust?

  17. Warum können wir nicht immer eine hohe Lernrate verwenden?

  18. Was ist ein Gefälle?

  19. Willst du wissen, wie du Steigungen selbst berechnen kannst?

  20. Warum können wir die Genauigkeit nicht als Verlustfunktion verwenden?

  21. Zeichne die sigmoide Funktion. Was ist das Besondere an seiner Form?

  22. Was ist der Unterschied zwischen einer Verlustfunktion und einer Metrik?

  23. Wie lautet die Funktion zur Berechnung neuer Gewichte mithilfe einer Lernrate?

  24. Was macht die Klasse DataLoader?

  25. Schreibe einen Pseudocode, der die grundlegenden Schritte zeigt, die in jeder Epoche für SGD ausgeführt werden.

  26. Erstelle eine Funktion, die, wenn sie zwei Argumente [1,2,3,4] und 'abcd' erhält, [(1, 'a'), (2, 'b'), (3, 'c'), (4, 'd')] zurückgibt. Was ist das Besondere an dieser Datenstruktur?

  27. Was macht view in PyTorch?

  28. Was sind die Bias-Parameter in einem neuronalen Netz? Warum brauchen wir sie?

  29. Was macht der @ Operator in Python?

  30. Was macht die Methode backward?

  31. Warum müssen wir die Gradienten auf Null setzen?

  32. Welche Informationen müssen wir an Learner weitergeben?

  33. Zeige Python oder Pseudocode für die grundlegenden Schritte einer Trainingsschleife.

  34. Was ist ReLU? Zeichne ein Diagramm für die Werte von -2 bis +2.

  35. Was ist eine Aktivierungsfunktion?

  36. Was ist der Unterschied zwischen F.relu und nn.ReLU?

  37. Das universelle Approximationstheorem zeigt, dass jede Funktion mit nur einer Nichtlinearität so genau wie nötig approximiert werden kann. Warum verwenden wir also normalerweise mehr?

Weitere Forschung

  1. Erstelle deine eigene Implementierung von Learner von Grund auf, basierend auf der in diesem Kapitel gezeigten Trainingsschleife.

  2. Führe alle Schritte in diesem Kapitel mit den vollständigen MNIST-Datensätzen durch (für alle Ziffern, nicht nur 3er und 7er). Das ist ein umfangreiches Projekt, für das du viel Zeit brauchen wirst! Du wirst selbst recherchieren müssen, um herauszufinden, wie du die Hindernisse überwinden kannst, auf die du auf deinem Weg stößt.

Get Deep Learning für Programmierer mit fastai und PyTorch now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.