import numpy as np
import matplotlib.pyplot as plt
3 Perzeptron
Wir lernen die grundlegenden Konzepte Neuronaler Netze für Klassifikationsprobleme anhand des Perzeptrons kennen. Wir sehen uns die Berechnung des Outputs an und insbesondere den Lernschritt und das Lernverfahren. Das Lernverfahren leiten wir auch mit Hilfe von Gradientenabstieg her. Wir nehmen eine eigene Implementierung in Python vor, um die Verarbeitung der Daten und die Umsetzung des Lernverfahrens zu verstehen. Dann führen wir das Python-Framework Keras (TensorFlow) ein und lernen, wie man dort Netze erstellt und trainiert.
Künstliche Neuronen, Perzeptron, Roheingabe, Aktivierung, Bias-Neuron, Perzeptron-Lernregel, Feature-Scaling (Standardisieren, Normalisieren), Stochastic Gradient Descent, Minibatch
- Sie kennen Unterschiede und Gemeinsamkeiten zwischen biologischen und künstlichen Neuronalen Netzen
- Sie verstehen das Perzeptron, können den Klassfikationsmechanismus mit Hilfe mathematischer Notation erklären und verstehen Lernregeln sowie den Lernalgorithmus I
- Sie können die Implementation von logischen Operatoren wie OR und AND als Perzeptron formulieren
- Sie verstehen, wie Lernalgorithmus II durch Gradientenabstieg hergeleitet wird und können Konzepte wie Zielfunktion und Fehlerlandschaft sowie den Lernalgorithmus II erklären
- Sie verstehen die Python-Implementierung des Perzeptrons und können diese erklären
- Sie verstehen die Feature-Scaling-Techniken der Normalisierung und der Standardisierung
- Sie kennen die Trainingsmethoden Stochastic Gradient Descent (SGD) und Minibatch und können diese erklären
- Sie kennen TensorFlow/Keras als Werkzeuge zu Implementierung und Training von Neuronalen Netzen und sind in der Lage, diese für die Lösung von Klassifikationsproblemen (z.B. Datensatz Iris) zu nutzen
Datensatz
Iris-Datensatz: Abschnitt 3.4.3
Importe
3.1 Klassifikation
Nachdem wir gesehen haben, wie wir mit Regression einen Wert vorhersagen können, kommen wir jetzt zur Frage, wie man eine Klasse, Kategorie oder Label vorhersagt. Genauer gesagt: Wir suchen zu einem Feature-Vektor die dazugehörige Klasse oder Kategorie. Ein klassisches Beispiel ist die Klassifikation von e-Mails in eine der zwei Klassen SPAM oder nicht-SPAM. Bei zwei möglichen Klassen spricht man von binärer Klassifikation.
Wir nutzen das Szenario der binären Klassifikation, um unser erstes künstliche Neuronales Netz einzuführen, das Perzeptron.
Formal haben einen Datensatz von \(N\) gelabelten Beispielen \((x^{k}, y^{k})\) mit \(k = 1, \ldots, N\). Das \(x^k \in \mathbb{R}^n\) ist ein \(n\)-dimensionaler Feature-Vektor. Im Fall der binären Klassifikation ist der Ausgabewert \(y \in \{0, 1\}\) ein binärer Wert, z.B. SPAM (= 1) oder nicht-SPAM (= 0).
Zwei klassische Beispiele sind
- Klassfikation von Bildern: der Feature-Vektor \(x\) enthält alle Pixelwerte eines Bildes in Form eines Arrays und Kategorie \(y\) gibt z.B. an, ob es sich um das Bild einer Katze handelt (\(y=1\)) oder nicht (\(y=0\)).
- Klassfikation von e-Mails: der Feature-Vektor \(x\) repräsentiert die “Eigenschaften” einer e-Mail. Man nehme etwa 2000 ausgewählte, alphabetisch sortierte Wörter, die also einen eindeutigen Wortindex haben. Vektor \(x \in \mathbb{R}^{2000}\) hat für jedes Wort, das in der entsprechenden Mail enthalten ist, an dem entsprechenden Wortindex eine 1 stehen (überall sonst eine 0). Kategorie \(y\) gibt z.B. an, ob es sich um SPAM (\(y=1\)) handelt oder nicht (\(y=0\)).
3.2 Künstliche Neuronale Netze
Künstliche Neuronale Netze sind inspiriert von biologischen Gehirnen, z.B. das des Menschen oder das der Katze. Die wichtigsten Bestandteile eines biologischen Gehirns sind die Gehirnzellen, auch Neuronen genannt, und die Verbindungen zwischen den Neuronen. Ein wichtiger Teil einer Verbindung ist die Synapse.
Das menschliche Gehirn besteht aus etwa 86 Milliarden Neuronen und ca. \(10^{14}\) (100 Trillionen) Verbindungen, also Synapsen. Zum Vergleich: ein Schimpanse hat 7 Milliarden, eine Katze 250 Millionen, eine Fruchtfliege 100 Tausend Neuronen.
Wir beschäftigen uns hier kurz mit dem Übergang vom biologischen zum künstlichen Neuron.
Die Zahl der Neuronen stammt von Herculano-Houzel (2009).
3.2.1 Biologische Neuronen
Die Neuronen sind die atomaren Einheiten des Gehirns. Neuronen empfangen elektrische Signale über Dendriten und laden sich gewissermaßen auf, man spricht vom Aktionspotential oder Erregung. Erst wenn ein bestimmter Schwellwert erreicht ist, “feuert” das Neuron über das Axon ein Signal nach außen. Beim Feuern wird eine (elektrische) Erregung von einem Neuron auf ein anderes übertragen.
Die Erregung eines Neurons wird über sein Axon weitergegeben und über Synapsen auf die Dendriten anderer Neuronen weitergegeben (Abb. 3.1). Eine Synapse bezeichnet eine Stelle mit einer physikalischen Lücke, die durch Neurotransmitter - das sind chemische Botenstoffe - überbrückt werden. Neurotransmitter können das Signal verstärken (exzitatorisch) oder hemmen (inhibitorisch). Diese Veränderbarkeit der Informationsübertragung spiegelt sich in den Gewichten künstlicher Neuronaler Netze wider.
Das ist natürlich eine stark vereinfachte Darstellung der Funktionsweise biologischer Neuronen. Der Wikipedia-Artikel über die Nervenzelle geht etwas mehr ins Detail.
Zu den am besten erforschten Regionen im Gehirn gehört der visuelle Cortex, auf den wir im Kapitel über Konvolutionsnetze noch sprechen werden. Ein besonders interessantes ungeklärtes Phänomen im Gehirn ist die Frage, wie verschiedene Hirnareale sich untereinander koordinieren. Eine Hypothese ist, dass die Frequenz des implusartigen Feuerns der Neuronen damit zusammenhängt. Hier sei der kurze Wikipedia-Artikel zur Functional integration empfohlen. Dies ist auch ein gutes Beispiel für ein Phänomen, das bislang nicht in künstlichen Neuronalen Netzen abgebildet ist.
3.2.2 Künstliche Neuronen
Bereits 1943 schlugen Warren McCulloch und Walter Pitts ein informationstechnisches Modell des biologischen Neurons vor: das McCulloch-Pitts-Neuron (McCulloch and Pitts 1943). Das Modell verarbeitete eingehende Signale durch Aufsummieren und kontrollierte die Weiterleitung durch einen Schwellwert. Es wurde gezeigt, dass die grundlegenden booleschen Operatoren (AND, OR, NOT) realisiert werden können. Ein Aspekt, der noch nicht vorhanden war, war die Frage, wie Lernen funktioniert.
Das Perzeptron (engl. perceptron) hatte bereits ein verfeinertes Modell und vor allem einen Lernalgorithmus (allerdings nur für ein Perzeptron mit einer Schicht). Es wurde 1958 von Frank Rosenblatt in einer Publikation der Öffentlichkeit vorgestellt (Rosenblatt 1958). Er hatte es bereits 1957 in einem technischen Bericht beschrieben (Rosenblatt 1957). 1969 gewann das Perzeptron größere Bekanntheit durch eine Buchpublikation der KI-Pioniere Marvin Minsky und Seymour Papert (1969; 2017). Durch das Buch wurden auch die Grenzen des Perzeptrons (sogenanntes XOR-Problem) bekannt. Das Adaline (ADAptive Linear NEuron) wurde 1960 von Bernard Widrow eingeführt (Widrow 1960). Es handelt sich um eine Parallelentwicklung zu Rosenblatts Perzeptron (laut Widrow and Lehr 1990) und unterscheidet sich lediglich durch eine andere Aktivierungsfunktion, die im Gegensatz zur Stufenfunktion beim Perzeptron differenzierbar ist.
Im weiteren sprechen wir einfach von Neuronalen Netzen (kurz NN) und meinen damit künstliche Neuronale Netze.
Auch wenn künstliche NN von biologischen Neuronen inspiriert sind, ist es doch wichtig festzuhalten, dass die Funktionsweise eines künstlichen NN wenig Rückschlüsse auf das menschliche Denken oder auf die Funktionsweise des menschlichen Hirns zulässt. In einem natürlichen Neuron spielen viele Faktoren (biologische, chemische, physikalische) eine Rolle, die in einem künstlichen Neuronalen Netz nicht modelliert sind. Das Lernverfahren “Backpropagation”, mit dem künstliche NN trainiert werden, hat wenig mit biologischen Vorgängen zu tun. Mit der Frage, inwiefern menschliches Denken mit Hilfe von informationstechnischen Modellen erforscht werden kann, beschäftigt sich die Kognitionswissenschaft. Für Modelle, die von biologischen neuronalen Netzen inspiriert sind, hat sich der Konnektionismus als Unterdisziplin der Kognitionswissenschaften herausgebildet (sehr guter Wikipedia-Artikel).
Lesenswertes zum Thema
- McCulloch-Pitts-Neuron: Wikipedia und Blogartikel
- Perzeptron: Wikipedia
- Adaline: Wikipedia
3.3 Einfaches Perzeptron
Das Perzeptron ist nach dem McCulloch-Pitts-Neuron der erste Versuch, einen lernendes Netzwerk nach dem Vorbild biologischer Neuronen zu erstellen. Es wurde von Frank Rosenblatt entwickelt und 1958 der Öffentlichkeit vorgestellt. Stark verwandt mit dem Perzeptron ist das ADALINE-Netz von Widrow (1960). Die Inhalte dieses Kapitels sind eine Kombination beider Verfahren. Wir nennen das Netz aber der Einfachheit halber immer Perzeptron.
Das Video erläutert kurz die Funktionsweise eines Perzeptrons.
3.3.1 Modell
Das Perzeptron ist ein Netzwerk aus Neuronen und gerichteten Verbindungen. Die Neuronen sind in Schichten (engl. layers) organisiert, der Eingabeschicht und der Ausgabeschicht. Die Verbindungen laufen von Eingabeschicht zu Ausgabeschicht. Ein solches Netz ist somit ein gerichteter azyklischer Graph (engl. directed acyclic graph, kurz DAG).
Die Ausgabeschicht besteht aus einem einzigen Neuron. Das Netz kann zur binären Klassifikation verwendet werden, d.h. für einen Input \(x\) (wir nennen dies auch Featurevektor, z.B. die Pixel eines Bildes) kann das Netz entscheiden, ob dieser Input zu einer bestimmten Kategorie gehört (Katzenbild) oder nicht (kein Katzenbild). Dies wird entsprechend mit dem Wert \(y \in \{0, 1\}\) ausgedrückt.
Manchmal wird das Perzeptron auch mit der Outputmenge \(y \in \{-1, 1\}\) eingeführt.
Der Wert \(z\) ist eine “Zwischenberechnung”, der so genannte Rohinput, den wir gleich erklären.
Wir haben gerade von “Schichten” gesprochen. Es zeigt sich, dass es sinnvoll ist, eine Schicht so zu definieren, dass eine Schicht mehrere Parameter/Gewichte enthält. In diesem Sinne hat das Perzeptron nur eine Schicht. Die Eingabe zählt nicht als eigenständige Schicht.
Vektordarstellung
Der Input durch die Eingabeneuronen wird repräsentiert durch einen Vektor \(x = (x_1, \ldots, x_n)\) der Länge \(n\), wobei jedes einzelne Feature \(x_i \in \mathbb{R}\). Auch wenn wir im Text Vektoren oft als Zeilenvektor schreiben, ist in Berechnungen immer ein Spaltenvektor gemeint, d.h. Vektor \(x\) sieht so aus:
\[ x = \left( \begin{array}{c} x_1 \\ \vdots \\ x_n \end{array} \right) \]
Der gewünschte Output am Ausgabeneuron ist ein Skalar \(y\), das entweder 0 oder 1 ist.
Die Parameter des Perzeptrons nennen wir Gewichte (engl. weights). Sie sind ein Vektor \(w\), wobei \(w_i \in \mathbb{R}\). Auch hier gilt in den Berechnungen, dass es sich um einen Spaltenvektor handelt:
\[ w = \left( \begin{array}{c} w_1 \\ \vdots \\ w_n \end{array} \right) \]
3.3.2 Verarbeitung
Wenn wir an den Eingabeneuronen Werte in Form des Vektors \(x\) anlegen, wie wird der Output \(y\) berechnet? Diese Verarbeitungsrichtung nennt man auch Forward Propagation.
Roheingabe
In einem ersten Schritt berechnen wir für einen gegebenen Featurevektor \(x\) die Roheingabe \(z\) (engl. net input) des Ausgabeneurons (siehe Abb. oben). Dies ist die Summe der Werte der Eingabeneuronen, jeweils multipliziert mit den jeweiligen Gewichten.
\[\begin{align*} z & = \sum_{i=1}^n w_i \: x_i\\[3mm] &= w_1 x_1 + \ldots + w_n x_n \end{align*}\]
In Vektorform können wir die Vektoren \(x\) und \(w\) wie folgt multiplizieren:
\[\begin{align*}\tag{Z} z & = w^T x\\[2mm] &= (w_1, \ldots, w_n) \left( \begin{array}{c} x_1 \\ \vdots \\ x_n \end{array} \right) \\[3mm] &= w_1 x_1 + \ldots + w_n x_n \end{align*}\]
Vektor \(w^T\) ist dabei der transponierte Vektor von \(w\) und daher ein Zeilenvektor. Nur so ist die Multiplikation zulässig (wir sehen hier alles als Matrizenmultiplikation).
Aktivierung und Ausgabe
Im zweiten Schritt berechnen wir die Aktivierung \(y\) des Ausgabeneurons. Dies ist gleichzeitig der Gesamtoutput des Neuronalen Netzes.
Die Aktivierung wird berechnet, indem wir eine Aktivierungsfunktion \(g\) auf die Roheingabe \(z\) anwenden.
\[ \tag{Y} y = g(z) \]
Für \(g\) verwenden wir die sogenannte Heaviside- oder Stufenfunktion (auch step oder threshold function):
\[ g_\theta(z) = \begin{cases} 1 & \quad \text{falls } z \geq \theta \\[3mm] 0 & \quad \text{sonst} \end{cases} \]
wobei \(\theta\) (griech. Buchstabe Theta) auch der Schwellwert (engl. threshold) genannt wird.
Wenn der Schwellwert \(\theta = 0\) ist, sieht die Aktivierungsfunktion so aus:
= [-10, -5, 0, 0, 5, 10]
x = [0, 0, 0, 1, 1, 1]
y 'r')
plt.plot(x,y,'x')
plt.xlabel('y')
plt.ylabel('Heaviside-Aktivierungsfunktion')
plt.title(-10,-5,0,5,10])
plt.xticks([0,.5,1])
plt.yticks([
plt.grid() plt.show()
Diese Funktion ist nicht differenzierbar bei \(x = 0\).
Modellierung des logischen AND
Das Perzeptron kann grundlegende logische Operationen nachbilden. Wir können etwa den logischen Operator AND mit einem Perzeptron modellieren:
Bei einem AND haben wir einen sehr überschaubaren Datensatz:
x1 | x2 | y |
---|---|---|
0 | 0 | 0 |
0 | 1 | 0 |
1 | 0 | 0 |
1 | 1 | 1 |
Ein Perzeptron mit den folgenden Parametern und Schwellwert leistet genau die gewünscht Operation:
\[\begin{align*} w_1 &= 1\\ w_2 &= 1\\ \theta &= 2 \end{align*}\]
Modellierung des logischen OR
Ähnlich wie mit dem AND verhält es sich mit dem OR. Hier der Datensatz:
x1 | x2 | y |
---|---|---|
0 | 0 | 0 |
0 | 1 | 1 |
1 | 0 | 1 |
1 | 1 | 1 |
Im Vergleich zum AND-Perzeptron kann man z.B. einfach den Schwellwert absenken:
\[\begin{align*} w_1 &= 1\\ w_2 &= 1\\ \theta &= 1 \end{align*}\]
Es gibt natürlich noch weitere Lösungen für AND und OR.
Das Video zeigt die Realisierung des OR im Perzeptron.
XOR
Wenn Sie probieren, das XOR mit diesem Netz zu lösen, werden Sie feststellen, dass es nicht geht. Hier der dazugehörige Datensatz:
x1 | x2 | y |
---|---|---|
0 | 0 | 0 |
0 | 1 | 1 |
1 | 0 | 1 |
1 | 1 | 0 |
Dies zeigten auch 1969 Marvin Minsky und Seymour Papert in ihrem Buch Perceptrons: an introduction to computational geometry (Minsky and Papert 1969).
Man benötigt für das XOR eine weitere Schicht. Probieren Sie doch mal, ein solches Netz zu entwerfen. Vergessen Sie nicht, Gewichte und Schwellwert anzugeben.
Bias-Neuron
Der Schwellwert \(\theta\) ist etwas umständlich für weitere Berechnungen. Deshalb versuchen wir, ihn leicht zu verschieben.
Unsere Aktivierungsfunktion \(g\) prüft, ob
\[ z = w_1 x_1 + \ldots + w_n x_n \geq \theta \]
Jetzt können wir das \(\theta\) auf die andere Seite holen:
\[ -\theta + w_1 x_1 + \ldots + w_n x_n \geq 0 \]
Wir erweitern jetzt einfach die Vektoren \(x\) und \(w\) um jeweils eine Stelle mit Index \(0\). Dabei ist \(x_0 = 1\) und \(w_0 = -\theta\):
\[ w_0 x_0 + w_1 x_1 + \ldots + w_n x_n \geq 0 \]
Man nennt das Neuron \(x_0\), das immer gleich \(1\) ist, auch das Bias-Neuron.
Jetzt können wir die Funktion \(g\) immer mit Null als Schwellwert formulieren:
\[ g(z) = \begin{cases} 1 & \quad \text{falls } z \geq 0 \\ 0 & \quad \text{sonst} \end{cases} \]
Die Formeln (Z) und (Y) können wir einfach so beibehalten in dem Wissen, dass die Komponente 0 in \(x\) immer gleich eins ist.
Unser Netz sieht mit Bias-Neuron jetzt so aus:
Das Video erläutert die Herleitung des Bias-Neurons.
3.3.3 Einfaches Perzeptron in Python
Wir sehen uns an, wie wir die Funktionsweise eines einfaches Perzeptrons in Python mit Hilfe von NumPy realisieren würden.
Zuerst erstellen wir einen Eingabevektor als NumPy-Array (siehe auch Abschnitt 1.1.4).
= np.array([0, 1]) x
Dann einen Vektor mit Gewichten:
= np.array([1, 1]) w
Die Aktivierung können wir mit dem Skalarprodukt berechnen:
= w.dot(x)
z z
1
Wir können auch den Operator @
verwenden:
= w @ x
z z
1
Ob die Aktivierung größer-gleich dem Schwellwert 1 ist, können wir so testen:
>= 1 z
True
Funktion für ein Perzeptron
Für eine beliebige Konfiguration von Eingabe, Gewichten und Schwellwert können wir eine Funktion schreiben. Es muss nur sichergestellt werden, dass \(x\) und \(w\) die gleiche Länge haben.
def perceptron_process(x, w, theta):
return w @ x >= theta
Wir testen das Beispiel von oben:
1) perceptron_process(x, w,
True
AND- und OR-Netze
Wir können jetzt die Netze für AND und OR nachbauen. Zunächst bilden wir alle vier möglichen Inputvektoren:
= np.array([0, 0])
x1 = np.array([0, 1])
x2 = np.array([1, 0])
x3 = np.array([1, 1]) x4
Wir definieren Gewichte und Schwellwert für AND und testen unser Netz:
= np.array([1, 1])
w_and = 2
theta_and
print(perceptron_process(x1, w_and, theta_and))
print(perceptron_process(x2, w_and, theta_and))
print(perceptron_process(x3, w_and, theta_and))
print(perceptron_process(x4, w_and, theta_and))
False
False
False
True
Das sieht korrekt aus!
Das gleiche für OR:
= np.array([1, 1])
w_or = 1
theta_or
print(perceptron_process(x1, w_or, theta_or))
print(perceptron_process(x2, w_or, theta_or))
print(perceptron_process(x3, w_or, theta_or))
print(perceptron_process(x4, w_or, theta_or))
False
True
True
True
Effiziente Verarbeitung mit einer Matrix
Jetzt packen wir alle vier Input-Vektoren in eine Matrix \(X\).
= np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
X X
array([[0, 0],
[0, 1],
[1, 0],
[1, 1]])
Jetzt kommt das Kunststück: Für eine einzelne Eingabe nehmen wir das Skalarprodukt von Eingabevektor und Gewichtsvektor. Für mehrere Eingaben, die in eine Matrix verpackt sind, können wir alle Eingaben “parallel” mit einer einzigen Matrixmultiplikation berechnen und erhalten die vier Resultate (Rohinputs) in einem Vektor:
@ w_and X
array([0, 1, 1, 2])
Jetzt können wir jedes Element wieder mit dem Schwellwert vergleichen, um die finale Aktivierung pro Eingabe zu bekommen.
Einmal für AND:
@ w_and >= theta_and X
array([False, False, False, True])
Und für OR:
@ w_or >= theta_or X
array([False, True, True, True])
Sie ahnen hier vielleicht schon, dass das “Verpacken” von Trainingsdaten in eine Matrix zu sehr eleganten (und tatsächlich auch effizienten) Berechnungen führt.
Falls Sie der Operator @
in den Beispielen oben verwirrt. Es handelt sich über eine Abkürzung für die Funktion matmul
bzw. für die Funktion dot
. Siehe auch Abschnit 1.1.4.
3.3.4 Lernen
Lernen bedeutet, dass die Gewichte \(w\) mit Hilfe von Trainingsdaten schrittweise angepasst werden, so dass unser Netzwerk sich der gewünschten (idealen) Funktion \(h^*\) annähert.
Trainingsdaten
Trainingsdaten sind z.B. eine Reihe von Bildern mit Label (z.B. Katze und Nicht-Katze). Wir gehen von \(N\) Traningsdaten aus. Für \(N\) Traningsdaten schreiben wir die Paare von Featurevektor und Label so:
\[(x^k, y^k) \quad\quad k \in \{1,\ldots , N\}\]
Bei einem Neuronalen Netz müssen wir zwischen dem berechneten Output des aktuellen Netzwerks und dem korrekten Output eines Traningsbeispiels unterscheiden.
Wir schreiben:
- \(\hat{y}^k\) für die berechnete Ausgabe des aktuellen Netzwerks für Trainingsbeispiel \(k\)
- \(y^k\) für die korrekte Ausgabe des Trainingsbeispiels \(k\)
Sowohl \(y\) als auch \(\hat{y}\) sind aus der Menge \(\{0,1\}\).
Lernschritt
Lernen bedeutet, dass wir für jedes Trainingsbeispiel die Ausgabe \(\hat{y}\) berechnen und anschließend die Gewichte anpassen. Man nennt dies einen Lernschritt.
Für ein konkretes Trainingsbeispiel \((x^k, y^k)\) passen wir alle Gewichte \(w = (w_0, \ldots, w_n)\) an, indem wir zu jedem \(w_i\) ein \(\Delta w_i\) addieren:
\[ w_i := w_i + \Delta w_i \]
Dies gilt für die meisten Neuronalen Netze. Der Unterschied besteht in der Berechnung des Delta.
Beim Perzeptron berechnet sich das Delta wie folgt:
\[ \Delta w_i := \alpha \, (y^k - \hat{y}^k ) x_i^k \]
wobei \(\alpha \in [0, 1]\) die Lernrate ist.
Überlegen wir uns, ob diese Regel plausibel ist:
- Wenn die berechnete Ausgabe \(\hat{y}\) und die korrekte Ausgabe \(y\) gleich sind, wird kein Update durchgeführt, denn \(\Delta w_i\) ist null.
- Wenn die berechnete Ausgabe \(\hat{y}\) größer ist als die korrekte Ausgabe \(y\), dann wird \(\Delta w\) negativ, also wird das Gewicht abgeschwächt. Wie stark sich das Gewicht verkleinert, hängt von der Differenz von korrekter und berechneter Ausgabe ab und, genauso wichtig, vom Wert \(x\). Das \(x\) zeigt nämlich an, wie relevant diese Verbindung für die Ausgabe war. Insgesamt wird aber \(\hat{y}\) “beim nächsten Mal” kleiner und damit näher an der korrekten Ausgabe sein.
- Wenn die berechnete Ausgabe \(\hat{y}\) kleiner ist als die korrekte Ausgabe \(y\), dann wird \(\Delta w\) positiv, d.h. das Gewicht wird gestärkt. Dies wird wie im obigen Fall moduliert durch Differenz und Stärke von \(x\). Insgesamt wird aber \(\hat{y}\) “beim nächsten Mal” größer und damit näher an der korrekten Ausgabe sein.
Man beachte, dass für das Gewicht des Bias-Neurons \(x_0\) gilt:
\[ \Delta w_0 := \alpha \, (y^k - \hat{y}^k ) \]
da immer \(x_0 = 1\).
Eine zu hohe Lernrate kann dazu führen, dass das Optimum immer wieder “übersprungen” wird, so dass das Lernen doch wieder langsamer wird oder sogar nie das Optimum erreicht. Eine zu niedrige Lernrate kann zu sehr langsamen Lernprozessen führen. Als Daumenregel sollte man mit niedrigen Lernraten beginnen und diese dann schrittweise erhöhen. Erfahrungsgemäß funktionieren Lernraten zwischen \(0.1\) und \(0.3\) gut als Startpunkt.
3.3.5 Lernalgorithmus I
Jetzt formulieren wir den Lernalgorithmus in einer ersten Version:
- Initialisierung: Setze Gewichte \(w = (w_0, \ldots, w_n)\) z.B. auf 0
- Für jede Epoche:
- Für jedes Trainingsbeispiel \((x^k, y^k)\) mit \(k = 1,\ldots, N\) führen wir einen Lernschritt durch:
- berechne Output \(\hat{y}^k\) (Forward Propagation)
- berechne die Änderungen \(\Delta w_i := \alpha \, (y^k - \hat{y}^k ) x_i^k\) für \(i = 1,\ldots, n\)
- berechne die Änderung für das Bias-Neuron: \(\Delta w_0 := \alpha \, (y^k - \hat{y}^k )\)
- führe für alle Gewichte ein Update durch mit \(w_i := w_i + \Delta w_i\)
- Für jedes Trainingsbeispiel \((x^k, y^k)\) mit \(k = 1,\ldots, N\) führen wir einen Lernschritt durch:
Man beachte, dass die Gewichte immer nach der Verarbeitung eines einzelnen Trainingsbeispiels angepasst werden.
Wenn wir die Formel unten per Gradientenabstieg herleiten, passen wir diesen Algorithmus noch einmal an (siehe Lernalgorithmus II).
Das Video erläutert den Lernalgorithmus des Perzeptron.
3.4 Lernen mit Gradientenabstieg
Die Perzeptron-Lernregel ist nicht wirklich hergeleitet, Sie wurde Ihnen einfach vorgestellt und sie erscheint plausibel. Jetzt fragen wir uns: Gibt es eine mathematische Herleitung für den Lernalgorithmus? Eine solche Herleitung würde uns nicht nur zeigen, dass auch wirklich Lernen stattfindet, sondern gibt uns potentiell auch für komplexere Netze ein “Rezept”, mit dem wir Lernen errreichen können.
3.4.1 Gradientenabstieg
Unsere Herleitung folgt der Idee des Gradientenabstieg und dieses Verfahren bedeutet etwas ganz Einfaches: Wenn wir einen Parameter \(w\) haben und diesen leicht vergrößern, wird dann der Fehler kleiner oder größer? Diese Frage wird genau mit dem Gradienten beantwortet und erlaubt uns die zielgerichtete Anpassung von \(w\).
Für unsere Herleitung ist es leichter, wenn wir als Aktivierungsfunktion \(g\) die Identität nehmen, also:
\[ \tag{A} g(z) = z \]
Sie erinnern sich: Das originale Perzeptron hat hier die Heaviside-Funktion. Der entscheidende Unterschied ist, dass die Identitäts-Funktion differenzierbar ist, d.h. man kann eine Ableitung bilden. Dies ist für das Verfahren des Gradientenabstiegs wesentlich.
Man beachte, dass die berechnete Ausgabe \(\hat{y} = g(z) = z\) jetzt eine Dezimalzahl \(\in \mathbb{R}\) ist, da wir den Rohinput einfach “durchschleifen”. Zuvor hatten wir die Ausgabe in die Form 0 oder 1 gezwungen.
Zielfunktion
Dazu müssen wir die Abweichung zwischen den berechneten Ouputs und den korrekten Outputs berechnen. Dies tut man über eine Zielfunktion \(J\). Diese wird auch Fehlerfunktion genannt, im Englischen meistens loss function.
Wir folgen hier der Literatur, wo die Fehlerfunktion sehr oft mit dem Buchstaben \(J\) repräsentiert wird. Das hängt vermutlich mit dem Konzept der Jacobi-Matrix zusammen. Die Jacobi-Matrix einer Funktion \(f\) enthält alle partiellen Ableitungen von \(f\). Insofern ist die Benennung der Fehlerfunktion mit \(J\) nicht ganz logisch, weil wir später die Jacobi-Matrix von \(J\) betrachten und diese dann \(\nabla J\) heißt.
Für unser \(J\) wählen wir den Mittelwert der Fehlerquadrate (engl. mean of squared errors oder MSE). Der Faktor \(\frac{1}{2}\) dient nur der Kosmetik (weil sich die 2 im Nenner bei der Ableitung rauskürzt) und beeinträchtigt nicht den Nutzen von \(J\):
\[ \tag{J} J(w) = \frac{1}{2N} \sum_{k=1}^N \left( y^k - g(z^k) \right)^2 \]
Vergegenwärtigen Sie sich, dass dieser Fehler in der Hauptsache davon abhängt, in welcher “Konfiguration” sich das Netz befindet, also wie die Gewichte \(w\) eingestellt ist. Wir sind daran interessiert, die Gewichte so einzustellen, dass der Gesamtfehler \(J\) möglichst klein wird. Im Training soll das automatisiert ablaufen.
Diese Abhängigkeit von \(w\) zu \(J\) spannt eine Fehlerlandschaft auf. Bei dem Beispiel in Abbildung 3.2 kann man sich zwei beliebige Gewichte (z.B. \(w_0\) und \(w_1\)) als 2-dimensionale Ebene vorstellen, wohingegen der Fehlerwert \(J\) nach oben zeigt. Da wir einen möglichst geringen Fehler anstreben, suchen wir also nach dem tiefsten Tal.
Update der Gewichte
Jetzt kommt der Gradientenabstieg zum Zug. Wir wollen die Gewichte so anpassen, dass der Fehler sich verringert. Dazu gehen wir in Richtung des negativen Gradienten, denn der Gradient gibt uns den größten Anstieg. Den Verlauf der Updates kann man sich anhand der Fehlerlandschaft so vorstellen wie in Abbildung 3.3 (die wir schon im letzten Kapitel gesehen haben).
Die Update-Regel bleibt gleich, wir sehen hier Vektoren statt der Komponenten:
\[ w := w + \Delta w \]
Das Delta definieren wir jetzt aber über den Gradienten, genauer gesagt als negativen Gradienten des Fehlers \(J\), modifiziert durch die Lernrate \(\alpha\):
\[ \Delta w := - \alpha \nabla J(w) = - \alpha \left( \begin{array}{c} \frac{\partial J}{\partial w_1} (w) \\ \vdots \\ \frac{\partial J}{\partial w_n} (w) \end{array} \right) \]
Denken Sie immer daran, dass sich die Gewichte \(w\) im Verlauf des Trainings ändern. Das \(w\) in dem Ausdruck \(-\alpha \nabla J(w)\) soll deutlich machen, dass wir in die Ableitungen immer konkrete Werte im Form des Gewichtsvektors \(w\) einsetzen. Die Gewichte sind sozusagen die “aktuelle Konfiguration” des Netzwerks. Die Ableitungen \(\frac{\partial J}{\partial w_i} (w)\) sind ebenfalls Funktionen mit Parameter \(w\) und werden an der aktuellen Stelle \(w\) evaluiert.
Wie man in der Gleichung oben sieht, benötigen wir für \(\nabla J\) alle partiellen Ableitungen von \(J\) hinsichtlich der Einzelgewichte \(w_1, \ldots, w_n\).
Für eine einzelne partielle Ableitung des Gradienten gilt:
\[ \tag{G} \frac{\partial J}{\partial w_i} (w) = - \frac{1}{N} \sum_{k=1}^N \left( y^k - g(z^k) \right) x_i^k \]
Woher kommt diese Formel? Die Herleitung für (G) zeigen wir weiter unten.
Für ein Gewicht \(w_i\) sieht das Delta entsprechend wie folgt aus:
\[\begin{align*} \Delta w_i &= - \alpha \frac{\partial J}{\partial w_i} (w) \\[1mm] &= \alpha \frac{1}{N} \sum_{k=1}^N \left( y^k - g(z^k) \right) x_i^k \\[1mm] &= \alpha \frac{1}{N} \sum_{k=1}^N \left( y^k - \hat{y}^k \right) x_i^k \end{align*}\]
Die Update-Regel sieht genauso aus wie zuvor, nur dass wir die Regel über den Gradientenabstieg hergeleitet haben und dass wir hier davon ausgehen, dass wir erst nach Abarbeiten aller Trainingsdaten ein Update durchführen. Das heißt übrigens, dass wir die Deltas für jedes Samples aufaddieren müssen, um dann am Schluss das finale Delta auf ein Gewicht anzuwenden.
3.4.2 Lernalgorithmus II
Angesichts unserer Herleitung passen wir den Lernalgorithmus I von oben an. Man beachte, dass wir das \(\Delta w_i\) innerhalb einer Epoche als Speicher benutzen, um alle Deltas für alle Trainingsdaten “einzusammeln”.
- Initialisiere alle Gewichte \(w = (w_0, \ldots, w_n)\), z.B. mit 0 oder niedrigen Werten
- Für jede Epoche:
- setze \(\Delta w_i := 0\)
- Für jedes Trainingsbeispiel \((x^k, y^k)\), \(k = 1,\ldots, N\):
- berechne Output \(\hat{y}^k\)
- berechne \(\Delta w_i := \Delta w_i + (y^k - \hat{y}^k)\, x_i^k\)
- berechne Mittelwert aller Deltas und wende Lernrate an: \(\Delta w_i := \alpha \frac{1}{N}\Delta w_i\)
- führe ein Update aller Gewichte durch mit \(w_i := w_i + \Delta w_i\)
Der Unterschied zum Lernalgorithmus beim originalen Perzeptron ist, dass erst alle Trainingsdaten durchlaufen werden, bevor die Gewichte angepasst werden.
Herleitung von (G)
Gesucht: Wir suchen alle partiellen Ableitungen von \(J\), also \(\frac{\partial J}{\partial w_i}\) für alle \(i\in {0, \ldots, n}\).
Ausgangspunkt ist die Fehlerfunktion (J), die wir erstmal umformen.
\[ J(w) = \frac{1}{2N} \sum_{k=1}^N \left( y^k - g(z^k) \right)^2 \]
Da \(g\) die Identitätsfunktion ist (A):
\[ J(w) = \frac{1}{2N} \sum_{k=1}^N \left( y^k - z^k \right)^2 \]
Jetzt setzen wir \(z^k\) ein, wie in (Z) definiert:
\[ J(w) = \frac{1}{2N} \sum_{k=1}^N \left( y^k - w^T x^k \right)^2 \]
Jetzt können wir \(\frac{\partial J}{\partial w_i}\) berechnen:
\[\begin{align*} \frac{\partial J}{\partial w_i} & = \frac{\partial}{\partial w_i} \frac{1}{2N} \sum_{k=1}^N \left( y^k - w^T x^k \right)^2 \\[2mm] &= \frac{1}{2N} \sum_{k=1}^N \frac{\partial}{\partial w_i} \left( y^k - w^T x^k \right)^2\\[2mm] &= \frac{1}{2N} \sum_{k=1}^N 2 \left( y^k - w^T x^k \right) \frac{\partial}{\partial w_i} - w^T x^k\\[2mm] &= - \frac{1}{N} \sum_{k=1}^N \left( y^k - w^T x^k\right) x_i^k \\[2mm] &= - \frac{1}{N} \sum_{k=1}^N \left( y^k - g(z^k) \right) x_i^k \end{align*}\]
Damit wäre (G) gezeigt.
3.4.3 Iris-Datensatz
Im Iris-Datensatz geht es darum, eine Blume aufgrund von Abmessungen der Blüte zu klassifizieren. Es gibt drei Blumen-Klassen, die es vorherzusagen gilt: iris setosa, iris virginica und iris versicolor. Es gibt vier Eingabe-Features als Input: sepal length, sepal width, petal length und petal width. Der Datensatz enthält 150 Trainingsbeispiele, 50 pro Klasse.
Jetzt möchten wir ein Perzeptron in Python programmieren. Dazu nehmen wir einen Datensatz aus der Scikit-learn-Bibliothek. Wir nutzen den beliebten, sehr kleinen Iris-Datensatz, der aus dem Jahr 1936 stammt. Bei diesem Datensatz geht es um Blumen, genauer gesagt um die Gattung der “Schwertlilien”, deren lateinischer Name Iris lautet. Der Datensatz besteht nur aus 150 Trainingsbeispielen. Siehe auch den Wikipedia-Artikel.
In den Daten geht es um die Unterscheidung von drei Klassen (Pflanzenarten) von Schwertlilien:
- Klasse 0: iris setosa
- Klasse 1: iris virginica
- Klasse 2: iris versicolor
Wir nutzen nur die ersten zwei Klassen und haben somit ein binäres Klassifikationsproblem (Klassen 0 und 1).
Als Features wurden vier Eigenschaften der Blüte gemessen (“sepal” ist das Kelchblatt und “petal” das Kronblatt; siehe https://de.wikipedia.org/wiki/Kelchblatt):
- Feature 0: sepal length
- Feature 1: sepal width
- Feature 2: petal length
- Feature 3: petal width
Wir nutzen zur Vereinfachung nur zwei Features (Features 0 und 1). Zwei Features kann man besser visualisieren.
Daten laden und vorbereiten
In der Sammlung sind 150 Dateneinträge, jeweils 50 pro Klasse. Jeder Eintrag besteht aus einem Inputvektor der Länge 4 und einer Zahl 0, 1, 2 für die Klasse.
Zunächst beziehen wir die Daten:
Daten
Wir laden die Daten aus der Bibliothek Scikit-learn.
from sklearn import datasets
= datasets.load_iris() iris
Wir erhalten ein Dictionary (Hashtabelle) und sehen uns die Schlüssel an:
iris.keys()
dict_keys(['data', 'target', 'frame', 'target_names', 'DESCR', 'feature_names', 'filename', 'data_module'])
Die Featurevektoren sind in data, die Labels in der Spalte target.
Wir prüfen den Umfang des Datensatzes:
len(iris.data)
150
Jetzt schauen wir uns die ersten drei Featurevektoren an:
3] iris.data[:
array([[5.1, 3.5, 1.4, 0.2],
[4.9, 3. , 1.4, 0.2],
[4.7, 3.2, 1.3, 0.2]])
Und werfen noch einen Blick auf die Label, die mit 0, 1, 2 kodiert sind:
iris.target
array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2])
Diese Zahlen bilden den Index für den Array von Klassennamen. Das stellt sicher, dass kein Fehler bei der Zuordnung von Zahl und Klasse unterläuft.
iris.target_names
array(['setosa', 'versicolor', 'virginica'], dtype='<U10')
Das gleiche gilt für die Bezeichnungen und die Reihenfolge der vier Features:
iris.feature_names
['sepal length (cm)',
'sepal width (cm)',
'petal length (cm)',
'petal width (cm)']
Für unser Beispiel möchten wir nur zwei Features verwenden:
- sepal length (Index 0)
- petal length (Index 2)
Dazu transponieren wir die Daten, dann erhalten wir vier Vektoren mit jeweils allen Daten für ein Feature.
= iris.data.T
features 0][:50] features[
array([5.1, 4.9, 4.7, 4.6, 5. , 5.4, 4.6, 5. , 4.4, 4.9, 5.4, 4.8, 4.8,
4.3, 5.8, 5.7, 5.4, 5.1, 5.7, 5.1, 5.4, 5.1, 4.6, 5.1, 4.8, 5. ,
5. , 5.2, 5.2, 4.7, 4.8, 5.4, 5.2, 5.5, 4.9, 5. , 5.5, 4.9, 4.4,
5.1, 5. , 4.5, 4.4, 5. , 5.1, 4.8, 5.1, 4.6, 5.3, 5. ])
Jetzt picken wir uns die zwei Features 0 und 2 heraus:
= features[[0,2]]
features features
array([[5.1, 4.9, 4.7, 4.6, 5. , 5.4, 4.6, 5. , 4.4, 4.9, 5.4, 4.8, 4.8,
4.3, 5.8, 5.7, 5.4, 5.1, 5.7, 5.1, 5.4, 5.1, 4.6, 5.1, 4.8, 5. ,
5. , 5.2, 5.2, 4.7, 4.8, 5.4, 5.2, 5.5, 4.9, 5. , 5.5, 4.9, 4.4,
5.1, 5. , 4.5, 4.4, 5. , 5.1, 4.8, 5.1, 4.6, 5.3, 5. , 7. , 6.4,
6.9, 5.5, 6.5, 5.7, 6.3, 4.9, 6.6, 5.2, 5. , 5.9, 6. , 6.1, 5.6,
6.7, 5.6, 5.8, 6.2, 5.6, 5.9, 6.1, 6.3, 6.1, 6.4, 6.6, 6.8, 6.7,
6. , 5.7, 5.5, 5.5, 5.8, 6. , 5.4, 6. , 6.7, 6.3, 5.6, 5.5, 5.5,
6.1, 5.8, 5. , 5.6, 5.7, 5.7, 6.2, 5.1, 5.7, 6.3, 5.8, 7.1, 6.3,
6.5, 7.6, 4.9, 7.3, 6.7, 7.2, 6.5, 6.4, 6.8, 5.7, 5.8, 6.4, 6.5,
7.7, 7.7, 6. , 6.9, 5.6, 7.7, 6.3, 6.7, 7.2, 6.2, 6.1, 6.4, 7.2,
7.4, 7.9, 6.4, 6.3, 6.1, 7.7, 6.3, 6.4, 6. , 6.9, 6.7, 6.9, 5.8,
6.8, 6.7, 6.7, 6.3, 6.5, 6.2, 5.9],
[1.4, 1.4, 1.3, 1.5, 1.4, 1.7, 1.4, 1.5, 1.4, 1.5, 1.5, 1.6, 1.4,
1.1, 1.2, 1.5, 1.3, 1.4, 1.7, 1.5, 1.7, 1.5, 1. , 1.7, 1.9, 1.6,
1.6, 1.5, 1.4, 1.6, 1.6, 1.5, 1.5, 1.4, 1.5, 1.2, 1.3, 1.4, 1.3,
1.5, 1.3, 1.3, 1.3, 1.6, 1.9, 1.4, 1.6, 1.4, 1.5, 1.4, 4.7, 4.5,
4.9, 4. , 4.6, 4.5, 4.7, 3.3, 4.6, 3.9, 3.5, 4.2, 4. , 4.7, 3.6,
4.4, 4.5, 4.1, 4.5, 3.9, 4.8, 4. , 4.9, 4.7, 4.3, 4.4, 4.8, 5. ,
4.5, 3.5, 3.8, 3.7, 3.9, 5.1, 4.5, 4.5, 4.7, 4.4, 4.1, 4. , 4.4,
4.6, 4. , 3.3, 4.2, 4.2, 4.2, 4.3, 3. , 4.1, 6. , 5.1, 5.9, 5.6,
5.8, 6.6, 4.5, 6.3, 5.8, 6.1, 5.1, 5.3, 5.5, 5. , 5.1, 5.3, 5.5,
6.7, 6.9, 5. , 5.7, 4.9, 6.7, 4.9, 5.7, 6. , 4.8, 4.9, 5.6, 5.8,
6.1, 6.4, 5.6, 5.1, 5.6, 6.1, 5.6, 5.5, 4.8, 5.4, 5.6, 5.1, 5.1,
5.9, 5.7, 5.2, 5. , 5.2, 5.4, 5.1]])
Anschließend transponieren wir das ganze zurück:
= features.T
iris_x 5] # Testausgabe iris_x[:
array([[5.1, 1.4],
[4.9, 1.4],
[4.7, 1.3],
[4.6, 1.5],
[5. , 1.4]])
Wir beschränken uns auf die ersten 100 Vektoren, da wir nur 2 der 3 Klassen betrachten (jeweils 50 Daten pro Klasse).
= iris_x[:100] # nur die ersten 100 Elemente iris_x
Jetzt beschränken wir noch die Klassen auf die ersten 100 Daten. Zufälligerweise sind die Label der ja genau 0 und 1, so dass wir die Labelwerte nicht weiter anpassen müssen.
= iris.target[:100]
iris_y iris_y
array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1])
Visualisierung der Daten
Da wir nur zwei Features haben, können wir diese auf x- und y-Achse abbilden und für die zwei Klassen eine jeweils andere Farbe wählen.
0][:50], features[1][:50], color='red', marker='o', label='setosa')
plt.scatter(features[0][50:], features[1][50:], color='blue', marker='x', label='versicolor')
plt.scatter(features['sepal length')
plt.xlabel('petal length')
plt.ylabel(='upper left')
plt.legend(loc plt.show()
Wir sehen, dass die Daten sehr schön “linear separierbar” sind, d.h. man kann die zwei Klassen durch eine Gerade trennen.
Die Daten müssen später als NumPy-Arrays vorliegen:
= np.array(iris_x)
iris_x = np.array(iris_y) iris_y
3.5 Implementierung eines Perzeptrons in Python
Wir implementieren das Perzeptron in Python, um die Funktionsweise besser zu verstehen. Beachten Sie, dass wir den ursprünglichen Lernalgorithmus I verwenden:
- Initialisierung: Setze Gewichte \(w = (w_0, \ldots, w_n)\) z.B. auf 0
- Für jede Epoche:
- Für jedes Trainingsbeispiel \((x^k, y^k)\) mit \(k = 1,\ldots, N\) führen wir einen Lernschritt durch:
- berechne Output \(\hat{y}^k\) (Forward Propagation)
- berechne die Änderungen \(\Delta w_i := \alpha \, (y^k - \hat{y}^k ) x_i^k\) für \(i = 1,\ldots, n\)
- berechne die Änderung für das Bias-Neuron: \(\Delta w_0 := \alpha \, (y^k - \hat{y}^k )\)
- führe für alle Gewichte ein Update durch mit \(w_i := w_i + \Delta w_i\)
- Für jedes Trainingsbeispiel \((x^k, y^k)\) mit \(k = 1,\ldots, N\) führen wir einen Lernschritt durch:
Zum Training verwenden wir die Iris-Daten.
3.5.1 Klasse Perceptron
Wir definieren zunächst die Klasse Perceptron
. Schauen Sie gern nochmal in Abschnitt 1.1.4, wenn Sie bestimmte Numpy-Konstrukte nicht verstehen (auch np.where
ist dort erläutert).
class Perceptron():
# Konstruktor mit Defaultwerten
def __init__(self, alpha=0.01):
self.alpha = alpha
# Training mit x (Matrix von Featurevektoren) und y (Labels)
def fit(self, x, y, epochs=5):
= np.random.RandomState(42)
ran = x.shape[0]
n_samples print(f"Train on {n_samples} samples")
# Gewichte: 1 + dim(x) für den Bias
self.w = ran.normal(loc=0, scale=0.01, size=1 + x.shape[1])
# Speicher für Kosten (loss function) pro Epoche
self.loss = []
# Epochen durchlaufen
for i in range(epochs):
= self.net_input(x) # Rohinput für alle Trainingsdaten
z = self.activation(z) # Aktivierung für alle Trainingsdaten
y_hat = y - y_hat # Fehlervektor für alle Trainingsdaten
diff
# Update der Gewichte
self.w[1:] += self.alpha * x.T.dot(diff)
# Update des Gewichts für das Bias-Neuron
self.w[0] += self.alpha * diff.sum()
# Kosten für diese Epoche (SSE) berechnen und in Liste speichern
= (diff**2).sum() / 2.0
l self.loss.append(l)
print(f"Epoch {i+1}/{epochs} - loss: {l:.4f}")
return self
# Aktivierungsfunktion: Identität (linear)
def activation(self, z):
return z
def net_input(self, x):
return np.dot(x, self.w[1:]) + self.w[0]
def predict(self, x):
return np.where(self.net_input(x) >= 0, 1, 0)
Besonders interessant ist natürlich die Methode fit, wo das Training in folgender Schleife stattfindet:
for i in range(epochs):
z = self.net_input(x)
y_hat = self.activation(z)
diff = y - y_hat
self.w[1:] += self.alpha * x.T.dot(diff)
self.w[0] += self.alpha * diff.sum()
Matrix für alle Trainingsdaten
Zu beachten ist hier, dass die Matrix \(X\) alle Featurevektoren der Trainingsdaten enthält. Dadurch erreichen wir ganz elegant die Batch-Verarbeitung im Training, d.h. alle Trainingsdaten werden durchlaufen, bevor wir ein Update an den Gewichten vornehmen.
Man kann sich das so vorstellen, dass alle Featurevektoren übereinander gestapelt sind und somit eine Nx3-Matrix \(X\) ergeben (siehe Abbildung). Man bedenke, dass die erste Spalte der Matrix \(X\) das Bias-Neuron \(x_0\) repräsentiert, also nur Einsen enthält. Bei der Matrixmultiplikation der Nx3-Eingabematrix \(X\) mit dem 3x1-Gewichtsvektor \(w\) erhalten wir einen Nx1-Ausgabevektor \(\hat{Y}\), der alle Ausgaben für alle Trainingsbeispiele enthält.
Die Berechnung der Roheingabe ist in (Z) definiert:
\[ z = w^T x = w_1 x_1 + \ldots + w_n x_n \]
Im Code wird das in net_input berechnet:
def net_input(self, x):
return np.dot(x, self.w[1:]) + self.w[0]
Hinweis: Die Multiplikationsreihenfolge bei Formel vs. Code ist unterschiedlich. In der Formel wird \(x\) von rechts mit \(w^T\) multipliziert, während im Code \(x\) von links an \(w\) multipliziert wird. Dann muss man eigentlich schreiben \(x^T w\), damit das \(x\) ein Zeilenvektor ist und \(w\) der Spaltenvektor. Und wenn Sie die Beispiele unten ansehen, werden Sie merken, dass die x-Vektoren tatsächlich Zeilenvektoren sind. Beide Repräsentationen sind möglich. Die Implementation folgt dem Konzept \(x^T w\).
Jetzt sehen wir uns an einem Beispiel an, wie die Berechnung im Code durchgeführt wird. Die folgende Abbildung zeigt das schematisch. Die x-Vektoren sind “übereinander gestapelt”. Man beachte, dass es Zeilenvektoren sind, also streng genommen ist jedes \(x\) eigentlich \(x^T\).
Die Abbildung entspricht noch nicht ganz dem Code, denn im Code wird das Gewicht des Bias-Neurons anders verarbeitet. Das schauen wir uns im Folgenden an.
Alternative Behandlung des Bias-Neurons
Im Code ist das mit dem Bias-Neuron etwas anders realisiert. Wir gehen jetzt davon aus, dass \(X\) die Matrix der übereinandergestapelten Featurevektoren ohne die jeweilige 1 für das Biasneuron ist. Wir können uns die ersten 3 Featurevektoren der Irisdaten ansehen:
3] iris_x[:
array([[5.1, 1.4],
[4.9, 1.4],
[4.7, 1.3]])
Wir möchten keine neue Matrix bauen, wo die erste Spalte aus Einsen besteht. Stattdessen vollziehen wir eine Matrixmultiplikation mit unserer Nx2-Eingabematrix \(X\) und dem 2x1-Gewichtsvektor \(w\) (wir lassen das \(w_0\) für das Biasneuron raus). Wir bekommen einen Nx1-“Pseudoausgabevektor”. Hier fehlt an jeder Stelle noch das \(x_0 \cdot w_0 = w_0\), das wir noch addieren müssen.
Für die Addition müssten wir eigentlich einen Vektor aus lauter \(w_0\) bauen, der so lang ist, wie es Trainingsbeispiele gibt. Stattdessen können wir komponentenweise Addition anwenden, ausgedrückt durch das Plus im Kreis. In Numpy geht das ganz leicht mit broadcasting.
Das ist also gemeint mit
np.dot(x, self.w[1:]) + self.w[0]
Gewichts-Updates
Kehren wir zurück zur Trainingsschleife:
z = self.net_input(x)
y_hat = self.activation(z)
diff = y - y_hat
self.w[1:] += self.alpha * x.T.dot(diff)
self.w[0] += self.alpha * diff.sum()
Oben haben wir gesehen, dass in \(z\) alle Rohinputs stecken. Mit y_hat ist hier \(\hat{y} = g(z)\) gemeint. Dieser Vektor enthält alle berechneten Ausgaben für alle Trainingsbeispiele. Entsprechend enthält der Vektor diff alle Differenzen zwischen berechneter Ausgabe und echter Ausgabe.
Hier findet das Gewichtsupdate statt (ausgenommen das Bias-Neuron, das in der nächsten Zeile mit w[0] geupdatet wird), das auch den entsprechenden Gradienten beinhaltet.
self.w[1:] += self.alpha * x.T.dot(diff)
Der Code folgt den Formeln (A 4) und (A 7), in denen das Update definiert wurde:
\[ w := w + \Delta w \]
\[ \Delta w_i = - \alpha \frac{\partial J}{\partial w_i} = \alpha \frac{1}{N} \sum_k \left( y^k - g(z^k) \right) x_i^k \]
Wenn wir das mit dem Code vergleichen, sollte also dieser Teil
x.T.dot(diff)
dieser Formel entsprechen
\[ \sum_k \left( y^k - g(z^k) \right) x_i^k \]
Die Matrix \(X\) wird zunächst transponiert:
Anschließend wird \(X^T\) mit dem Vektor aller Differenzen über alle Trainingsbeispiele multipliziert:
x.T.dot(diff)
Hier als Matrizen:
Die rechte Matrix enthält in Zeile 1 alle addierten Anpassungen für \(w_1\) für alle Trainingsbeispiele. Zeile 2 entsprechend für \(w_2\). Daher
self.w[1:] += self.alpha * x.T.dot(diff)
Die Codezeile
self.w[0] += self.alpha * diff.sum()
updatet das Bias-Neuron-Gewicht \(w_0\). Hier reicht es, die Differenzen zusammen zu addieren (Abb. oben, der mittlere Vektor), da ja alle \(x_i = 1\) sind.
3.5.2 Training mit zwei Lernraten
Jetzt, wo wir die Perceptron-Klasse verstanden haben, wenden wir uns dem konkreten Einsatz zu. Wir trainieren zwei Perzeptron-Netze, eines mit Lernrate 0.01, eines mit Lernrate 0.0001. Die Entwicklung der Kostenfunktion wird dabei in den jeweiligen Objekten mitprotokolliert (Instanzvariable loss
).
= 20 EPOCHS
Training mit Lernrate 0.01
Die Lernrate wird beim Erstellen der Klasse mitgegeben.
= Perceptron(alpha=0.01)
model1 =EPOCHS) model1.fit(iris_x, iris_y, epochs
Train on 100 samples
Epoch 1/20 - loss: 23.7989
Epoch 2/20 - loss: 24334.9132
Epoch 3/20 - loss: 37957919.7157
Epoch 4/20 - loss: 59208969090.5766
Epoch 5/20 - loss: 92357592414365.8594
Epoch 6/20 - loss: 144064742347031200.0000
Epoch 7/20 - loss: 224720561081757958144.0000
Epoch 8/20 - loss: 350532196498533588664320.0000
Epoch 9/20 - loss: 546780500149173669010079744.0000
Epoch 10/20 - loss: 852900014120760577106351489024.0000
Epoch 11/20 - loss: 1330403029897247964531781365923840.0000
Epoch 12/20 - loss: 2075239996079037720535038940854353920.0000
Epoch 13/20 - loss: 3237080000981913939835227627413569536000.0000
Epoch 14/20 - loss: 5049385590368112287706191504279880492646400.0000
Epoch 15/20 - loss: 7876325216702475760091903151316391867944271872.0000
Epoch 16/20 - loss: 12285950005006588998342183226694253397848432312320.0000
Epoch 17/20 - loss: 19164339126757914840331203369558938099531433098870784.0000
Epoch 18/20 - loss: 29893650390545246871046127287161095178251919536785719296.0000
Epoch 19/20 - loss: 46629853905289572200217874598033507686980745771985403904000.0000
Epoch 20/20 - loss: 72735957195657475175019364582358249508301312339071839319883776.0000
<__main__.Perceptron at 0x7f8ec9b81d90>
Was man hier sieht ist, dass der Fehler immer mehr ansteigt.
Training mit Lernrate 0.0001
Wir probieren es mit einer deutlich niedrigeren Lernrate.
= Perceptron(alpha=0.0001)
model2 =EPOCHS) model2.fit(iris_x, iris_y, epochs
Train on 100 samples
Epoch 1/20 - loss: 23.7989
Epoch 2/20 - loss: 13.5561
Epoch 3/20 - loss: 9.8254
Epoch 4/20 - loss: 8.4030
Epoch 5/20 - loss: 7.8000
Epoch 6/20 - loss: 7.4894
Epoch 7/20 - loss: 7.2845
Epoch 8/20 - loss: 7.1190
Epoch 9/20 - loss: 6.9696
Epoch 10/20 - loss: 6.8278
Epoch 11/20 - loss: 6.6907
Epoch 12/20 - loss: 6.5571
Epoch 13/20 - loss: 6.4267
Epoch 14/20 - loss: 6.2992
Epoch 15/20 - loss: 6.1746
Epoch 16/20 - loss: 6.0526
Epoch 17/20 - loss: 5.9334
Epoch 18/20 - loss: 5.8168
Epoch 19/20 - loss: 5.7027
Epoch 20/20 - loss: 5.5911
<__main__.Perceptron at 0x7f8ec9b813a0>
Der Fehler wird offenbar geringer.
3.5.3 Visualisierung
Wir zeichnen den Verlauf der Kostenfunktion über die Epochen. Das erste Netz mit Lernrate 0.01 hat steigende Kosten, d.h. das Netz wird schlechter. Das zweite Netz mit Lernrate 0.0001 zeigt dagegen sinkende Kosten.
Wir verwenden eine logarithmische Darstellung der Werte (log10), damit der Verlauf darstellbar bleibt.
= plt.subplots(nrows=1, ncols=2, figsize=(10,4))
fig, ax
0].plot(range(1, len(model1.loss)+1), np.log10(model1.loss), marker='o')
ax[0].set_xlabel('Epochen')
ax[0].set_ylabel('log(SSE)')
ax[0].set_title('Perceptron, alpha=0.01')
ax[
1].plot(range(1, len(model2.loss)+1), np.log10(model2.loss), marker='o')
ax[1].set_xlabel('Epochen')
ax[1].set_ylabel('log(SSE)')
ax[1].set_title('Perceptron, alpha=0.0001')
ax[
plt.show()
Probleme
In den obigen Graphen sehen wir zwei Probleme:
- Bei einer Lernrate von \(0.01\) scheint das Netz ständig über das Minimum hinauszuschießen (Overshoot) und entfernt sich sogar davon, so dass der Gesamtfehler sich im Training sogar immer weiter erhöht.
- Bei einer kleineren Lernrate von \(0.0001\) reduziert sich der Gesamtfehler, aber aufgrund der kleinen Lernrate könnte es viele Epochen dauern, bevor das echte Minimum erreicht ist.
3.6 Training verbessern
3.6.1 Feature-Scaling: Normalisieren und Standardisieren
Eine Maßnahme, die Abhilfe schafft, ist das Skalieren der Features. Es kann nämlich ein Problem sein, wenn ein Feature sich im Bereich 1000-2000 bewegt und ein anderes Feature im Bereich 0.001 bis 0.0015. Das erschwert den Gradientenabstieg, wie Abbildung 3.4 illustriert. Man sieht die Gewichte als 2D-Darstellung. Der Fehler wird hier mit “Höhenlinien” dargestellt. Der Gradientenabstieg vollzieht sich mit sehr langgestreckten Vektoren, was es erschwert, das Verfahren über eine einheitliche Lernrate zu kontrollieren.
Dazu schaut man sich eine einfache Konstellation an:
Nehmen wir an, die Wertebereiche wären \(x_1 \in [0,1]\) und \(x_2 \in [0,1000]\).
- berechne die Änderungen \(\Delta w_i := \alpha \, (y^k - \hat{y}^k ) x_i^k\,\) für \(\,i = 1,\ldots, n\)
- berechne die Änderung für das Bias-Neuron \(\Delta w_0 := \alpha \, (y^k - \hat{y}^k )\)
- führe für alle Gewichte ein Update durch mit \(w_i := w_i + \Delta w_i\)
Für die Änderung gilt also:
\[w_i := w_i + \alpha \, (y^k - \hat{y}^k ) x_i^k\]
Es gibt zwei Arten der Skalierung: Normalisieren und Standadisieren.
Allgemein nennt man das Vorverarbeiten der Features auch Feature Engineering.
Normalisieren
Beim Normalisieren skaliert man alle Features auf den Bereich \([0, 1]\). Bei Featurevektoren \(x^k\) berechnet man die normalisierten Vektoren \(\bar{x}^k\) für jedes einzelne Feature \(i\) einzeln:
\[ \bar{x}_i^k = \frac{x_i^k - min_i}{max_i - min_i} \]
Hier stehen \(min_i\) und \(max_i\) für das Minimum/Maximum eines Features \(i\) über alle Vektoren \(x^k\).
Wenn die ursprünglichen Werte bei 0 beginnen (d.h. \(min_i = 0\)), was gar nicht so selten der Fall ist, ist diese Umformung natürlich extrem leicht:
\[ \bar{x}_i^k = \frac{x_i^k}{max_i} \]
Typisches Beispiel aus der Bildverarbeitung: Bei einem Schwarzweiß-Bild liegen die Pixel in der Regel als Grauwert im Bereich \(\{0, \ldots, 255\}\) vor. Bei einem Farbbild sind es drei Werte, einen pro Farbkanal (Rot, Gelb, Blau).
Bleiben wir beim Schwarzweiß-Bild. Wenn wir einen NumPy-Array haben:
= np.array([13, 120, 5, 211])
data data
array([ 13, 120, 5, 211])
Können wir ausnutzen, dass NumPy die komponentenweise Anwendung von mathematischen Operationen mit dieser eleganten Formulierung erlaubt:
= data/255
data data
array([0.05098039, 0.47058824, 0.01960784, 0.82745098])
Achten Sie darauf, dass ein “data/255” allein (ohne das “data =”) nicht ausreicht, weil in dem Fall das Array nicht verändert wird.
Standardisieren
Beim Standardisieren will man erreichen, dass die Featurewerte sich gemäß einer Normalverteilung verhalten, mit Mittelwert \(0\) und einer Standardabweichung von \(1\). Dazu muss man für ein Feature \(i\) den Mittelwert \(\mu_i\) und die Standardabweichung \(\sigma_i\) berechnen. Dann verschiebt und skaliert man jedes Feature wie folgt:
\[ \bar{x}_i^k = \frac{x_i^k - \mu_i}{\sigma_i} \]
Auch hier ein Praxisbeispiel: In NumPy lässt sich beides leicht realisieren, da es Funktionen wie mean (Mittelwert) und std (Standardabweichung) gibt, die man einfach auf NumPy-Arrays anwenden kann.
= np.copy(iris_x)
iris_x_st 3] iris_x_st[:
array([[5.1, 1.4],
[4.9, 1.4],
[4.7, 1.3]])
Wir müssen darauf achten, dass wir jedes der beiden Feature getrennt behandeln. Das Feature wird über den zweiten Index gesteuert.
10,0] iris_x[:
array([5.1, 4.9, 4.7, 4.6, 5. , 5.4, 4.6, 5. , 4.4, 4.9])
Wir standardisieren das erste Feature:
0] = (iris_x[:,0] - iris_x[:,0].mean()) / iris_x[:,0].std()
iris_x_st[:, 3] iris_x_st[:
array([[-0.5810659 , 1.4 ],
[-0.89430898, 1.4 ],
[-1.20755205, 1.3 ]])
Und jetzt das zweite Feature:
1] = (iris_x[:,1] - iris_x[:,1].mean()) / iris_x[:,1].std()
iris_x_st[:, 3] iris_x_st[:
array([[-0.5810659 , -1.01297765],
[-0.89430898, -1.01297765],
[-1.20755205, -1.08231219]])
3.6.2 Trainieren mit standardisierten Daten
Wir erstellen ein drittes Netz und trainieren es mit den standardisierten Daten und Lernrate 0.01.
= Perceptron(alpha=0.01)
model3 =EPOCHS) model3.fit(iris_x_st, iris_y, epochs
Train on 100 samples
Epoch 1/20 - loss: 24.4906
Epoch 2/20 - loss: 8.2845
Epoch 3/20 - loss: 5.6750
Epoch 4/20 - loss: 3.9525
Epoch 5/20 - loss: 2.8155
Epoch 6/20 - loss: 2.0650
Epoch 7/20 - loss: 1.5696
Epoch 8/20 - loss: 1.2426
Epoch 9/20 - loss: 1.0267
Epoch 10/20 - loss: 0.8842
Epoch 11/20 - loss: 0.7902
Epoch 12/20 - loss: 0.7281
Epoch 13/20 - loss: 0.6871
Epoch 14/20 - loss: 0.6601
Epoch 15/20 - loss: 0.6422
Epoch 16/20 - loss: 0.6304
Epoch 17/20 - loss: 0.6227
Epoch 18/20 - loss: 0.6175
Epoch 19/20 - loss: 0.6141
Epoch 20/20 - loss: 0.6119
<__main__.Perceptron at 0x7f8ea917ebb0>
Wir sehen uns die Kostenentwicklung im direkten Vergleich mit dem zweiten Netz an. Wir können jetzt den Logarithmus weglassen, weil keine extrem hohen Werte vorkommen.
= plt.subplots(nrows=1, ncols=2, figsize=(10,4))
fig, ax
0].plot(range(1, len(model2.loss)+1), model2.loss, marker='o')
ax[0].set_xlabel('Epochen')
ax[0].set_ylabel('SSE')
ax[0].grid()
ax[0].set_ylim([0, 25]) # gleiche y-Skala anlegen
ax[0].set_title('Perceptron, alpha=0.0001')
ax[
1].plot(range(1, len(model3.loss)+1), model3.loss, marker='o')
ax[1].set_xlabel('Epochen')
ax[1].set_ylabel('SSE')
ax[1].grid()
ax[0].set_ylim([0, 25]) # gleiche y-Skala anlegen
ax[1].set_title('Perceptron standardisiert, alpha=0.01')
ax[
plt.show()
Man sieht klar, dass das Netz mit den standardisierten Daten (rechts) schneller lernt, auch weil die Lernrate mit \(0.01\) deutlich höher ist.
Zur Erinnerung: Auf den nicht-standardisierten Daten hat eine Lernrate von \(0.01\) dazu geführt, dass der Fehler sich ständig vergrößert (siehe oben).
3.6.3 Trainingsmethoden: SGD und Minibatch
Oben sind wir davon ausgegangen, dass wir erst alle Trainingsbeispiele abarbeiten, die Deltas für jedes Einzelgewicht aufsummieren, und am Ende die Gewichte anpassen. Das kann dazu führen, dass das System sehr langsam lernt, da sich wichtige Gewichtsänderungen am Ende einer Epoche “rausmitteln”. Man nennt diese Vorgehensweise manchmal Batch-Learning, aber dieser Begriff ist missverständlich, weil man auch manchmal Minibatch-Learning damit meint. In der Praxis kommt es kaum noch vor, dass alle Trainingsbeispiele vor dem Update der Gewichte durchlaufen werden.
Beim Perzeptron haben wir die Gewichte nach jedem einzelnen Sample angepasst, das ist im Grunde eine Form von SGD.
Stochastic Gradient Descent (SGD)
SGD bedeutet, dass man die Gewichte nach jedem einzelnen Trainingsbeispiel anpasst. Das nennt man manchmal auch Online-Learning. Der Begriff “Online” bezieht sich auf Szenarien, wo sich eine System “live” im Einsatz befindet und auf jede neue Einzelinformation (z.B. aus der Umwelt) eine Anpassung vornimmt. Da die Samples bei dieser Methode zufällig ausgewählt werden (anstatt in immer dergleichen Reihenfolge), spricht man von Stochastic Gradient Descent.
Der Nachteil dieser Methode ist, dass mehr Rechenleistung zum Einsatz kommt. Bei 60000 Trainingsbeispielen wird pro Epoche nicht ein einziges Update durchgeführt, sondern es werden 60000 Updates durchgeführt. Technisch führt dies dazu, dass man nicht mehrere Trainingsbeispiele zu einer Matrix \(X\) zusammenfassen kann, wie oben skizziert, und die damit einhergehenden Performancevorteile nicht zum Zuge kommen.
Darüber hinaus schanken die Gewichte stark, da jedes Trainingsbeispiel (auch z.B. “schlechte” Beispiele) direkt die Gewichte beeinflussen. Schlechte Einflüsse werden also nicht “herausgemittelt”. Also muss man eher eine sehr niedrige Lernrate wählen, was wiederum das Training verlangsamt.
Minibatch
Anstatt die Gewichte entweder nach jedem Beispiel zu updaten oder erst nach Durchlaufen aller Beispiele, kann man eine Teilmenge der Trainingsbeispiele durchlaufen, bevor ein Update durchgeführt wird. Für große Datensätze ist das die Methode der Wahl. Diese Teilmengen nennt man auch Batches und bei Bibliotheken gibt man häufig die Größe dieser Batches an (batch size), z.B. 32 oder 64.
Die Trainingsdaten werden also in Batches aufgeteilt. In einer Epoche werden alle Batches durchlaufen.
Wir werden Minibatch im nächsten Kapitel beim Thema “Backpropagation” im Aktion sehen.
Siehe auch die Videos von Andrew Ng: Mini Batch Gradient Descent und Understanding Mini-Batch Gradient Descent
SGD in Keras = Minibatch
SGD nennt man in Keras auch Optimierungsmethode, da es noch einige Varianten gibt (z.B. Adam oder RMSprop). In Keras wird im Grunde immer Minibatch durchgeführt, es sei denn, man zwingt Keras dazu, nach jedem einzelnen Trainingsbeispiel ein Update durchzuführen.
Sie sehen dies daran, dass die Methode fit
einen Parameter batch_size
anbieten, der per Default auf 32 eingestellt ist. Dies ist genau die Batchgröße in Minibatch.
3.6.4 Mehrere Klassen: One-vs-All
Bei der obigen Herleitung handelt es sich um binäre Klassifikation mit einem Ouput \(\in [0, 1]\). Wie bekommen wir eine Klassifizierung für mehrere Klassen?
Im nächsten Kapitel werden wir neuronale Netze betrachten, die auf “ganz natürliche Weise” einen Mehrklassen-Output haben, aber hier möchte ich kurz eine generelle Denkweise vorstellen für den Fall, dass wir nur binäre Klassifikatoren zur Verfügung haben (unabhängig davon, ob es ein neuronales Netz ist oder z.B. Logistische Regression).
Die Grundidee ist, dass man bei mehreren (\(N\)) Klassen \(i\in{1,\ldots,N}\) für jede Klasse \(i\) einen eigenen binären Klassifikator \(h_w^{(i)}\) erstellt. Bei 4 Klassen hätten wir also vier Klassifikatoren \(h_w^{(1)}, h_w^{(2)},h_w^{(3)},h_w^{(4)}\). Die Trainingsdaten werden für jeden binären Klassifikator entsprechend präpariert: Für Klassifikator \(h_w^{(1)}\) nimmt man alle Trainingsbeispiele der Klasse 1 als Positivbeispiele (\(y=1\)) und alle anderen als Negativbeispiele (\(y=0\)). Entsprechend für die anderen drei Klassifikatoren. Im nächsten Abschnitt sehen Sie das anhand eines praktischen Beispiels.
Um eine Vorhersage auf Eingabedaten \(x\) zu erhalten, steckt man \(x\) in alle Klassifikatoren \(h_w^{(i)}\) und wählt die Klasse des Klassifikators mit dem höchsten Ausgabewert.
\[ \underset{i}{\arg \max} \, h_w^{(i)}(x) \]
Diese Methode nennt man auch One-vs-All (OvA) oder One-vs-Rest.
Die Funktion arg max funktioniert wie folgt. Sie bekommt eine Funktion mit einem Parameter und gibt den Parameterwert zurück, mit dem die Funktion maximal wird. Beispiel: Einen Vektor \(v = (v_1, \ldots, v_4)\) kann man auch als Funktion über seine Indizes betrachten. In diesem Fall gibt arg max den Index des Vektorelements mit dem höchsten Wert zurück. Als Beispiel sei Vektor \(v = (5, 2, 18, -3)\) gegeben; dann ist \(\underset{i}{\arg \max} \, v_i = 3\).
3.7 Keras 1: Einführung in die Praxis
Im praktischen Teil werden wir hauptsächlich mit Keras - innerhalb von TensorFlow - arbeiten. Wir sehen uns hier ganz simple Netze innerhalb von Keras/TensorFlow an.
3.7.1 Hintergrund
TensorFlow
TensorFlow ist eine Open-Source-Bibliothek für Python (mit Bestandteilen in C++) für den Themenbereich des Maschinellen Lernens, inklusive Neuronaler Netze. TensorFlow wurde ursprünglich vom Google Brain Team für Google-eigenen Projekte entwickelt, wurde aber 2015 unter einer Open-Source-Lizenz (Apache License 2.0) für die Allgemeinheit frei gegeben. Mittlerweile dürfte TensorFlow das mit Abstand das wichtigste Framework im Bereich Maschinelles Lernen sein.
Noch ein paar Worte zum Entwicklungsteam hinter TensorFlow: Google Brain begann 2011 als ein sogenanntes “Google X”-Projekt als Kooperation zwischen Google-Fellow Jeff Dean und Stanford-Professor Andrew Ng. Seit 2013 ist Deep-Learning-Pioneer Geoffrey Hinton als leitender Wissenschaftler im Team. Weitere bekannte Wissenschaftler bei Google Brain sind Alex Krizhevsky und Ilya Sutskever (Autoren von AlexNet), Christopher Olah (bekannt durch seinen Blog) und Chris Lattner (Erfinder von Apple’s Programmiersprache Swift).
Keras
Keras ist ebenfalls eine Open-Source-Bibliothek für Python von François Chollet, die erstmals 2015 als Python-API für verschiedene “Backends” (u.a. TensorFlow) veröffentlicht wurde. Es ist ein objektorientiertes Framework für das Erstellen, Trainieren und Evaluieren Neuronaler Netze. Das Buch Deep Learning with Python (Chollet 2021) ist vom Erfinder von Keras und daher von besonderer Relevanz (einschränkend muss man sagen, dass die Theorie in dem Buch ein wenig zu kurz kommt). Eine Idee von Keras ist, dass es auch als Interface für verschiedene Backends in anderen Kontexten (z.B. Robotik oder mobile) genutzt werden kann. Daher liegt ein Fokus von Keras auf einer intuitiven, modularen und erweiterbaren Systematik.
Keras wurde unabhängig von TensorFlow entwickelt, wurde dann aber 2017 in TensorFlow 1.4 als Teil der TensorFlow Core API aufgenommen, d.h. alle Konzepte und Daten von Keras stehen in TensorFlow zur Verfügung. François Chollet arbeitet seit 2015 für Google und hat dort auch die Einbindung in TensorFlow unterstützt.
3.7.2 Daten
Wir wollen Keras nutzen, um ein Perzeptron-Netz zu bauen. Dazu stellen wir zunächst die Datensätze für die Operatoren AND und OR her. Wie wir schon weiter oben gesehen haben, ist der Raum der Daten für AND und OR sehr klein.
Für AND haben wir folgenden Datensatz:
x1 | x2 | y |
---|---|---|
0 | 0 | 0 |
0 | 1 | 0 |
1 | 0 | 0 |
1 | 1 | 1 |
Für OR den folgenden:
x1 | x2 | y |
---|---|---|
0 | 0 | 0 |
0 | 1 | 1 |
1 | 0 | 1 |
1 | 1 | 1 |
Die x-Werte sind also für beide Operatoren gleich:
= np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
x_data x_data
array([[0, 0],
[0, 1],
[1, 0],
[1, 1]])
Die Operatoren haben lediglich unterschiedliche y-Werte (Label):
= np.array([0, 0, 0, 1])
y_and = np.array([0, 1, 1, 1])
y_or print("y-Werte AND: ", y_and)
print("y-Werte OR: ", y_or)
y-Werte AND: [0 0 0 1]
y-Werte OR: [0 1 1 1]
3.7.3 Neuronales Netz für AND
In Keras heißt die Basisklasse für Neuronale Netze Sequential
. Der Name weist darauf hin, dass ein Sequential-Objekt eine geordnete Reihe (Sequenz) von Schichten enthält.
Wir erstellen zunächst ein Neuronales Netz für AND. Also stellen wir eine Instanz von Sequential
her, dies wäre unser Modell.
from tensorflow.keras.models import Sequential
= Sequential() model_and
Siehe: Getting started with the Keras Sequential model
Schichten hinzufügen
Jetzt kann man Schichten als Objekte dem Modell hinzufügen. Es gibt verschiedene Arten von Schichten. Die wichtigste ist die Klasse Dense
. Eine Schicht vom Typ Dense
verbindet sich mit allen Neuronen aus der “Vorgängerschicht”. In anderen Kontexten nennt man solche Schicchten auch fully connected layer oder FC layer.
Wenn die Schicht, wie hier, die erste Schicht ist, dann verbindet sich die Schicht mit der Eingabeschicht, die nicht explizit hinzugefügt wird, sondern automatisch vorhanden ist. Mit input_dim
gibt man an, wieviele Neuronen die Inputschicht hat. In unserem Fall sind es zwei Eingabeneuronen.
Zusätzlich geben wir hier die Initialisierung von Gewichten mit kernel_initializer
und des Bias-Gewichts mit bias_initializer
an. In unserem Fall setzen wir alle Gewichte auf Null.
Mit activation
kann man die Aktivierungsfunktion spezifizieren. Wir wählen die Funktion linear, also die einfache Identität \(g(z) = z\).
Unsere Dense-Schicht ist gleichzeitig auch die Ausgabeschicht.
from tensorflow.keras.layers import Dense
1, input_dim=2,
model_and.add(Dense(='zeros',
kernel_initializer='zeros',
bias_initializer='linear')) activation
Unser Netz sieht derzeit so aus:
Mit der Methode summary
geben wir uns eine Zusammenfassung unserer Netzwerk-Architektur aus, was später bei komplexeren Netzen hilfreich ist.
model_and.summary()
Model: "sequential"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
dense (Dense) (None, 1) 3
=================================================================
Total params: 3
Trainable params: 3
Non-trainable params: 0
_________________________________________________________________
Parameter
Man kann das Netzwerkobjekt auch programmatisch untersuchen. Die Eigenschaft layers
enthält alle Schichtenobjekte in iterierbarer Form. Hier wenden wir die Methode get_config
auf jede Schicht an, die uns ein Dictionary zurückgibt, das wir hier ausgeben.
for l in model_and.layers:
for key in l.get_config():
print(f'{key}: {l.get_config()[key]}')
name: dense
trainable: True
batch_input_shape: (None, 2)
dtype: float32
units: 1
activation: linear
use_bias: True
kernel_initializer: {'class_name': 'Zeros', 'config': {}}
bias_initializer: {'class_name': 'Zeros', 'config': {}}
kernel_regularizer: None
bias_regularizer: None
activity_regularizer: None
kernel_constraint: None
bias_constraint: None
Besonders interessant sind natürlich die Gewichte, zumindest bei kleineren Netzen (später werden die Gewichte nicht mehr handhabbar).
Mit der Methode get_weights
kann man sich die Gewichte und Bias-Gewichte aller Schichten ausgeben lassen.
= model_and.get_weights()
w, b print(f'WEIGHTS {w[0]} {w[1]} BIAS {b}')
WEIGHTS [0.] [0.] BIAS [0.]
Die Methode gibt es auch für jede Schicht:
for l in model_and.layers:
= l.get_weights()
w, b print(f'WEIGHTS {w[0]} {w[1]} BIAS {b}')
WEIGHTS [0.] [0.] BIAS [0.]
Training
Damit man ein Keras-Netzwerk trainieren kann, muss es zunächst mit der Methode compile
konfiguriert werden.
Konfiguration mit Compile
Mit compile
legen wir Hyperparameter wie die Lernrate und die Optimierungsmethode fest, aber auch Metriken wie die Art der Zielfunktion und die Evaluationsmaße.
Wir verwenden hier stochastic gradient descent (SGD) als Optimierungsmethode, mit einer Lernrate von 0.1. Als Zielfunktion wählen wir MSE (gemittelte Fehlerquadrate) und als Evaluationsmaß Accuracy. Die “Sum of squared errors” (SSE), die wir verwendet haben, wird in Keras nicht angeboten, aber MSE ist ja sehr ähnlich.
from tensorflow.keras.optimizers import SGD
= SGD(learning_rate=0.1)
sgd
compile(optimizer=sgd,
model_and.='mean_squared_error',
loss=['acc'])
metrics
# Alternative:
# Hier benötigt man das Objekt in sgd nicht, kann allerdings auch
# nicht die Lernrate einstellen
#
# model_and.compile(optimizer='sgd', loss='mean_squared_error', metrics=['acc'])
Wie wir später noch sehen, muss man nicht unbedingt ein Objekt für den Optimizer herstellen, sondern kann auch einfach in der Methode compile optimizer=‘sgd’ angeben. Das Vorgehen hier benötigt man nur, wenn man den Optimizer konfigurieren will (wie hier mit der Lernrate).
Trainieren mit Fit
Das eigentliche Training, also die Updates der Paramter mit Hilfe der Trainingsdaten, findet in der Methode fit
statt.
for _ in range(10):
=1, batch_size=4)
model_and.fit(x_data, y_and, epochs= model_and.get_weights()
w, b print(f'WEIGHTS {w[0]} {w[1]} BIAS {b}')
1/1 [==============================] - 0s 143ms/step - loss: 0.2500 - acc: 0.7500
WEIGHTS [0.05] [0.05] BIAS [0.05]
1/1 [==============================] - 0s 2ms/step - loss: 0.1863 - acc: 0.7500
WEIGHTS [0.08750001] [0.08750001] BIAS [0.08]
1/1 [==============================] - 0s 2ms/step - loss: 0.1544 - acc: 0.7500
WEIGHTS [0.11637501] [0.11637501] BIAS [0.09649999]
1/1 [==============================] - 0s 2ms/step - loss: 0.1375 - acc: 0.7500
WEIGHTS [0.13926876] [0.13926876] BIAS [0.103925]
1/1 [==============================] - 0s 2ms/step - loss: 0.1276 - acc: 0.7500
WEIGHTS [0.15798594] [0.15798594] BIAS [0.10528625]
1/1 [==============================] - 0s 2ms/step - loss: 0.1212 - acc: 0.7500
WEIGHTS [0.17375943] [0.17375943] BIAS [0.10263181]
1/1 [==============================] - 0s 2ms/step - loss: 0.1164 - acc: 0.7500
WEIGHTS [0.18743233] [0.18743233] BIAS [0.09735357]
1/1 [==============================] - 0s 2ms/step - loss: 0.1126 - acc: 0.7500
WEIGHTS [0.19958213] [0.19958213] BIAS [0.09039639]
1/1 [==============================] - 0s 2ms/step - loss: 0.1092 - acc: 0.7500
WEIGHTS [0.21060517] [0.21060517] BIAS [0.08240069]
1/1 [==============================] - 0s 2ms/step - loss: 0.1062 - acc: 1.0000
WEIGHTS [0.22077432] [0.22077432] BIAS [0.07379951]
Vorhersage
Jetzt kann man mit predict
Vorhersagen mit dem trainierten Netz berechnen.
model_and.predict(x_data)
1/1 [==============================] - 0s 42ms/step
array([[0.07379951],
[0.29457384],
[0.29457384],
[0.51534814]], dtype=float32)
Da wir kontinuierliche Werte bekommen, legen wir eine Schwellwertfunktion an, um die Ausgabe auf die Werte {0, 1} zu zwingen.
= model_and.predict(x_data)
pred_and = [(1 if x>=0.5 else 0) for x in pred_and]
pred_and pred_and
1/1 [==============================] - 0s 14ms/step
[0, 0, 0, 1]
3.7.4 Neuronales Netz für OR
Wir wiederholen alle Schritte, um ein Neuronales Netz für OR zu entwickeln.
= Sequential()
model_or 1, input_dim=2,
model_or.add(Dense(='zeros',
kernel_initializer='zeros',
bias_initializer='linear')) activation
model_or.summary()
Model: "sequential_1"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
dense_1 (Dense) (None, 1) 3
=================================================================
Total params: 3
Trainable params: 3
Non-trainable params: 0
_________________________________________________________________
= model_or.get_weights()
w, b print(f'WEIGHTS {w[0]} {w[1]} BIAS {b}')
WEIGHTS [0.] [0.] BIAS [0.]
from tensorflow.keras.optimizers import SGD
= SGD(learning_rate=0.1)
sgd
compile(optimizer=sgd,
model_or.='mean_squared_error',
loss=['acc']) metrics
for _ in range(5):
=1, batch_size=4)
model_or.fit(x_data, y_or, epochs= model_or.get_weights()
w, b print(f'WEIGHTS {w[0]} {w[1]} BIAS {b}')
1/1 [==============================] - 0s 112ms/step - loss: 0.7500 - acc: 0.2500
WEIGHTS [0.1] [0.1] BIAS [0.15]
1/1 [==============================] - 0s 2ms/step - loss: 0.3925 - acc: 0.2500
WEIGHTS [0.17] [0.17] BIAS [0.25]
1/1 [==============================] - 0s 2ms/step - loss: 0.2258 - acc: 0.5000
WEIGHTS [0.2195] [0.2195] BIAS [0.31599998]
1/1 [==============================] - 0s 2ms/step - loss: 0.1479 - acc: 1.0000
WEIGHTS [0.25497502] [0.25497502] BIAS [0.35889998]
1/1 [==============================] - 0s 2ms/step - loss: 0.1110 - acc: 1.0000
WEIGHTS [0.28083876] [0.28083876] BIAS [0.38612497]
= model_or.predict(x_data)
pred_raw pred_raw
1/1 [==============================] - 0s 25ms/step
array([[0.38612497],
[0.6669637 ],
[0.6669637 ],
[0.9478025 ]], dtype=float32)
= model_or.predict(x_data)
pred_or = [(1 if x>=0.5 else 0) for x in pred_or]
pred_or pred_or
1/1 [==============================] - 0s 13ms/step
[0, 1, 1, 1]
Sanity check: Wir prüfen, ob der Vorhersagewert übereinstimmt mit den selbst berechneten Wert:
= model_or.get_weights()
w, b print(f'WEIGHTS {w[0]} {w[1]} BIAS {b}')
WEIGHTS [0.28083876] [0.28083876] BIAS [0.38612497]
b
array([0.38612497], dtype=float32)
print(f"Ausgabewert für x=(1, 1) selbst berechnet: {w[0] + w[1] + b}")
print(f"Ausgabewert für x=(1, 1) vom Modell: {pred_raw[3][0]}")
Ausgabewert für x=(1, 1) selbst berechnet: [0.9478025]
Ausgabewert für x=(1, 1) vom Modell: 0.9478024840354919
3.7.5 Neuronales Netz für den Iris-Datensatz
Wir möchten jetzt noch das Experiment mit den Iris-Daten mit einem Keras-Netz wiederholen.
Wir zeigen nochmal die Daten als 2D-Plot:
0][:50], features[1][:50], color='red', marker='o', label='setosa')
plt.scatter(features[0][50:], features[1][50:], color='blue', marker='x', label='versicolor')
plt.scatter(features['sepal length')
plt.xlabel('petal length')
plt.ylabel(='upper left')
plt.legend(loc plt.show()
3] iris_x[:
array([[5.1, 1.4],
[4.9, 1.4],
[4.7, 1.3]])
NN mit Lernrate 0.1
Wir erstellen unser Modell. Wir haben zwei Eingabeneuronen und ein Ausgabeneuron. Die Aktivierungsfunktion ist die Identität.
= Sequential()
model_iris 1, input_dim=2,
model_iris.add(Dense(='zeros',
kernel_initializer='zeros',
bias_initializer='linear')) activation
Wir wählen eine relativ hohe Lernrate von 0.1.
= SGD(learning_rate=0.1)
sgd
compile(optimizer=sgd,
model_iris.='mean_squared_error',
loss=['acc']) metrics
= model_iris.fit(iris_x,
history
iris_y, =EPOCHS) epochs
Epoch 1/20
4/4 [==============================] - 0s 903us/step - loss: 865.8167 - acc: 0.5200
Epoch 2/20
4/4 [==============================] - 0s 771us/step - loss: 4188001024.0000 - acc: 0.4800
Epoch 3/20
4/4 [==============================] - 0s 793us/step - loss: 21012481103101952.0000 - acc: 0.6200
Epoch 4/20
4/4 [==============================] - 0s 796us/step - loss: 89839012331486789500928.0000 - acc: 0.5400
Epoch 5/20
4/4 [==============================] - 0s 786us/step - loss: 587794278385399943249400954880.0000 - acc: 0.4200
Epoch 6/20
4/4 [==============================] - 0s 802us/step - loss: 3242514596769586388482606652108832768.0000 - acc: 0.4400
Epoch 7/20
4/4 [==============================] - 0s 711us/step - loss: inf - acc: 0.5800
Epoch 8/20
4/4 [==============================] - 0s 818us/step - loss: inf - acc: 0.5400
Epoch 9/20
4/4 [==============================] - 0s 824us/step - loss: inf - acc: 0.4400
Epoch 10/20
4/4 [==============================] - 0s 1ms/step - loss: inf - acc: 0.4600
Epoch 11/20
4/4 [==============================] - 0s 795us/step - loss: inf - acc: 0.4800
Epoch 12/20
4/4 [==============================] - 0s 728us/step - loss: nan - acc: 0.5200
Epoch 13/20
4/4 [==============================] - 0s 779us/step - loss: nan - acc: 0.5000
Epoch 14/20
4/4 [==============================] - 0s 699us/step - loss: nan - acc: 0.5000
Epoch 15/20
4/4 [==============================] - 0s 780us/step - loss: nan - acc: 0.5000
Epoch 16/20
4/4 [==============================] - 0s 726us/step - loss: nan - acc: 0.5000
Epoch 17/20
4/4 [==============================] - 0s 806us/step - loss: nan - acc: 0.5000
Epoch 18/20
4/4 [==============================] - 0s 704us/step - loss: nan - acc: 0.5000
Epoch 19/20
4/4 [==============================] - 0s 796us/step - loss: nan - acc: 0.5000
Epoch 20/20
4/4 [==============================] - 0s 690us/step - loss: nan - acc: 0.5000
Ergebnis
Wir sehen uns die Entwicklung von Loss und Accuracy an.
Dazu definieren wir eine Hilfsfunktion:
def plot_loss_acc(history):
= range(len(history.history['acc']))
epochs = plt.subplots(nrows=1, ncols=2, figsize=(10,4))
fig, ax 0].plot(epochs, history.history['loss'], 'r', label='Loss')
ax[0].set_xlabel('Epochen')
ax[0].set_ylabel('Loss')
ax[0].set_title('Loss')
ax[1].plot(epochs, history.history['acc'], 'b', label='Accuracy')
ax[1].set_xlabel('Epochen')
ax[1].set_ylabel('Accuracy')
ax[1].set_title('Accuracy')
ax[ plt.show()
plot_loss_acc(history)
Man sieht hier, dass das Netz mit Lernrate 0.1 offenbar nicht zum Ziel kommt. Stattdessen “oszilliert” es und die Zielfunktion geht gegen Unendlich. Das heißt, die Lernrate ist zu hoch.
NN mit Lernrate 0.01
Wir versuchen das ganze nochmal mit niedrigerer Lernrate 0.01.
= Sequential()
model_iris 1, input_dim=2,
model_iris.add(Dense(='zeros',
kernel_initializer='zeros',
bias_initializer='linear')) activation
= SGD(learning_rate=0.01)
sgd
compile(optimizer=sgd,
model_iris.='mean_squared_error',
loss=['acc']) metrics
= model_iris.fit(iris_x,
history
iris_y, =EPOCHS) epochs
Epoch 1/20
4/4 [==============================] - 0s 923us/step - loss: 0.2701 - acc: 0.8200
Epoch 2/20
4/4 [==============================] - 0s 918us/step - loss: 0.1386 - acc: 0.9700
Epoch 3/20
4/4 [==============================] - 0s 808us/step - loss: 0.1169 - acc: 1.0000
Epoch 4/20
4/4 [==============================] - 0s 857us/step - loss: 0.1031 - acc: 1.0000
Epoch 5/20
4/4 [==============================] - 0s 835us/step - loss: 0.1119 - acc: 0.8800
Epoch 6/20
4/4 [==============================] - 0s 892us/step - loss: 0.0723 - acc: 1.0000
Epoch 7/20
4/4 [==============================] - 0s 801us/step - loss: 0.0646 - acc: 1.0000
Epoch 8/20
4/4 [==============================] - 0s 748us/step - loss: 0.0574 - acc: 0.9900
Epoch 9/20
4/4 [==============================] - 0s 777us/step - loss: 0.0489 - acc: 1.0000
Epoch 10/20
4/4 [==============================] - 0s 749us/step - loss: 0.0429 - acc: 1.0000
Epoch 11/20
4/4 [==============================] - 0s 791us/step - loss: 0.0397 - acc: 1.0000
Epoch 12/20
4/4 [==============================] - 0s 796us/step - loss: 0.0330 - acc: 1.0000
Epoch 13/20
4/4 [==============================] - 0s 728us/step - loss: 0.0296 - acc: 1.0000
Epoch 14/20
4/4 [==============================] - 0s 749us/step - loss: 0.0281 - acc: 1.0000
Epoch 15/20
4/4 [==============================] - 0s 852us/step - loss: 0.0317 - acc: 1.0000
Epoch 16/20
4/4 [==============================] - 0s 972us/step - loss: 0.0236 - acc: 1.0000
Epoch 17/20
4/4 [==============================] - 0s 795us/step - loss: 0.0214 - acc: 1.0000
Epoch 18/20
4/4 [==============================] - 0s 776us/step - loss: 0.0224 - acc: 1.0000
Epoch 19/20
4/4 [==============================] - 0s 720us/step - loss: 0.0188 - acc: 1.0000
Epoch 20/20
4/4 [==============================] - 0s 729us/step - loss: 0.0187 - acc: 1.0000
= model_iris.get_weights()
w, b print(f'WEIGHTS {w[0]} {w[1]} BIAS {b}')
WEIGHTS [-0.06556347] [0.313473] BIAS [-0.03396798]
Ergebnis
plot_loss_acc(history)
Mit Lernrate 0.01 stabilisiert sich die Netz-Performance bereits in der 4. Epoche und erreicht 100% Accuracy.
Im Kapitel über Feedforward-Netze geht es in Abschnitt 5.3 weiter mit Keras.