Updates dieser Seite:
Nachdem wir im letzten Kapitel Neuronale Netze mit nur Eingabe- und Ausgabeschicht kennen gelernt haben, die nur ein einziges Ausgabeneuron für binäre Klassifikation genutzt haben, geht es in diesem Kapitel um Netze mit mehreren Schichten, sogenannte Feedforward-Neuronale-Netze (FNN). Ihre Ausgabeschicht besteht in der Regel aus mehreren Neuronen. Zum Lernen der Parameter stellen wir das Verfahren Backpropagation vor, vielleicht das wichtigste Verfahren im Bereich neuronaler Netze. Wir lernen auch, wie man in Keras FNN erstellt, trainiert und evaluiert.
Nutzen Sie die Lernziele, um Ihr Wissen zu überprüfen:
Name | Daten | Anz. Klassen | Klassen | Trainings-/Testdaten | Ort |
---|---|---|---|---|---|
MNIST | s/w-Bilder (28x28) | 10 | handgeschriebene Ziffern 0...9 | 60000/10000 | Abschnitt 3 |
In diesem Kapitel werden wir folgende Notation verwenden.
Symbol | Bedeutung |
---|---|
$n$ | Anzahl der Eingabeneuronen |
$m$ | Anzahl der Ausgabeneuronen |
$L$ | Anzahl der Schichten |
$n_l$ | Anzahl der Neuronen in Schicht $l$ |
$N$ | Anzahl der Trainingsbeispiele |
$(x^k, y^k)$ | $k$-tes Trainingsbeispiel mit Featurevektor $x^k$ und korrektem Output $y^k$ (auch: Label, Klasse) |
$X$ | Matrix der Featurevektoren aller Trainingsbeispiele |
$W^{(l)}$ | Gewichtsmatrix von Schicht $l-1$ zu Schicht $l$ |
$b^{(l)}$ | Vektor der Bias-Gewichte von Schicht $l-1$ zu Schicht $l$ |
$z^{(l)}$ | Rohinput (Vektor) der Neuronen in Schicht $l$ |
$g$ | Aktivierungsfunktion |
$a^{(l)}$ | Aktivierung (Vektor) der Neuronen in Schicht $l$ |
$\hat{y}$ | Berechnete Ausgabe (Vorhersage) des Netzwerks bei einer Eingabe $x$ mit den aktuellen Gewichten |
$J$ | Zielfunktion in Form einer Fehler- oder Kostenfunktion (loss function), die es zu minimieren gilt |
$\lambda$ | Regularisierungsparameter (kommt in $J$ zum Einsatz) |
$\delta^{(l)}$ | Fehlerwerte (Vektor) an den Neuronen in Schicht $l$ |
$\Delta w^{(l)}_{i,j}$ | Änderungswert von Gewicht $w_{i,j}$ in Schicht $l$ und Einzelwert der Matrix $\Delta W^{(l)}$ |
$\Delta b^{l}_i$ | Änderungswert von Bias-Gewicht $b_i$ in Schicht $l$ und Einzelwert des Vektors $\Delta b^{(l)}$ |
$\alpha$ | Lernrate $\in [0, 1]$ |
$\beta$ | Momentum-Parameter $\in [0, 1]$ |
import tensorflow
import math
from math import exp
import matplotlib.pyplot as plt
import numpy as np
import time
2023-05-05 14:11:31.137764: I tensorflow/core/platform/cpu_feature_guard.cc:193] This TensorFlow binary is optimized with oneAPI Deep Neural Network Library (oneDNN) to use the following CPU instructions in performance-critical operations: SSE4.1 SSE4.2 To enable them in other operations, rebuild TensorFlow with the appropriate compiler flags.
Wir gehen über zu Netzwerken, die im Gegensatz zum Perzeptron zusätzliche Schichten zwischen Input- und Outputschicht haben. Diese werden auch versteckte Schichten (engl. hidden layers) genannt. Man kann solche Netze als Feedforward Neural Nets (FNNs) oder als Multilayer Perceptrons (MLP) bezeichnen. Wir werden hier den Begriff FNN verwenden.
Feedforward-Netze bestehen aus Neuronen und gerichteten Verbindungen. Sie sind also gerichtete azyklische Graphen (DAG), dürfen also keine Zyklen enthalten. Keine Zyklen bedeutet, dass es kein Neuron gibt, von dem aus man über Verbindungen wieder zum selben Neuron gelangen kann.
Zusätzlich sind die Neuronen in geordneten Schichten organisiert. Das bedeutet, dass jede Schicht eine Indexzahl $l$ besitzt und jedes Neuron in Schicht $l$ nur Verbindungen zu Neuronen in Schicht $l+1$ haben darf.
Als durchgängiges Beispiel betrachten wir ein Netzwerk wie unten abgebildet. Es hat:
Da wir also nur die parametrisierten Schichten zählen, haben wir es hier mit einem 2-Schicht-Netzwerk zu tun. Diese Zähleweise ist motiviert durch Frameworks wie Keras und PyTorch.
Unsere Notation definieren wir unabhängig von der Anzahl der Schichten. Daher hier noch eine allgemeine Darstellung eines FNN mit $L$ Schichten:
Die Anzahl der Schichten nennen wir $L$. Wir zählen nur parametrisierte Schichten, also nicht die Eingabeschicht. Bei dem Beispielnetz wäre $L = 2$. Wir verwenden Index $l$, um eine bestimmte Schicht anzuzeigen. Damit wir auch die Eingabeschicht "mitzählen" können, bekommt die Eingabeschicht den Index $l = 0$. Also ist $l \in \{0, \ldots, L\}$.
Die Anzahl der Neuronen in Schicht $l$ wird mit $n_l$ bezeichnet. Im obigen Beispielnetz (abgebildet in blau) wären $n_1 = 3$ und $n_2 = 2$.
Die Eingabe wird durch einen Vektor der Länge $n$ repräsentiert:
$$x = (x_1, \ldots, x_n)$$Die Ausgabe des Netzes wird durch einen Vektor der Länge $m$ repräsentiert:
$$y = (y_1, \ldots, y_m)$$Jedes $y_i$ repräsentiert eine Kategorie bzw. ein Label. Wo die Unterscheidung notwendig ist, bezeichnen wir mit $y$ die korrekte Ausgabe (eines Trainingsbeispiels) und mit $\hat{y}$ die berechnete Ausgabe eine Netzwerks mit den aktuellen Gewichten.
Die Roheingabe (net input) des $i$-ten Neurons in Schicht $l$ wird durch $z_i^{(l)}$ dargestellt.
Die Aktivierung des $i$-ten Neurons in Schicht $l$ wird durch $a_i^{(l)}$ dargestellt. Den Eingabevektor setzen wir auch mit der Aktivierung von "Schicht 0" gleich:
$$ (x_1, \ldots, x_n) = (a_1^{(0)}, \ldots, a_n^{(0)}) $$Die Aktivierung der letzten Schicht $L$ entspricht der berechneten Ausgabe, also
$$ (\hat{y}_1, \ldots, \hat{y}_m) = (a_1^{(L)}, \ldots, a_m^{(L)}) $$Die Gewichte von Schicht $l-1$ zu Schicht $l$ werden als Matrix $W^{(l)}$ dargestellt. Ein Einzelgewicht von Quellneuron $i$ in Schicht $l$ zu Zielneuron $j$ in Schicht $l$ wird durch $w_{j,i}^{(l)}$ repräsentiert. Dazu später mehr.
Bislang haben wir nur binäre Klassifikation behandelt. Jetzt möchten wir Klassifizierer betrachten, wo es mehr als zwei Klassen gibt. Die naive Herangehensweise wäre, jede Klasse über eine Zahl (1, 2, 3) zu repräsentieren und mit einem einzigen Ausgabeneuron diese Zahlen zu lernen. Dies ist aber problematisch, weil die Zahlen 1, 2, 3 Zusammenhänge aufweisen, die inhaltlich nicht mit den Klassenzusammenhängen übereinstimmen. Konkret sind die Zahlen 1 und 2 "dichter" beieinander als die Zahlen 1 und 3. Diese Nähe spiegelt sich aber in der Regel nicht in den Kategorien (z.B. Bilderkennung mit 1=Auto 2=Mensch 3=Verkehrsschild). In der Praxis zeigt sich, dass dies den Lernprozess erschwert.
Die Lösung ist, die Klassen derart zu repräsentieren, dass numerisch keine Zusammenhänge abgebildet sind. Bei drei Klassen verwendet man etwas einen Vektor der Länge drei und jede Klasse bekommt eine Komponente, so dass sich drei Zielvektoren für drei Klassen ergeben:
$$ \left( \begin{array}{c} 1 \\ 0\\0 \end{array} \right)\quad \left( \begin{array}{c} 0 \\ 1\\0 \end{array} \right)\quad \left( \begin{array}{c} 0 \\ 0\\1 \end{array} \right) $$Mathematisch betrachtet handelt es sich um drei Vektoren, die orthogonal zueinander stehen und somit linear unabhängig sind. Man nennt diese Art der Kodierung auch One-Hot Encoding. Es handelt sich um die Standard-Vorgehensweise bei mehreren Klassen (also bei mehr als 2 Klassen).
Man kann jeden Vektor auch als Wahrscheinlichkeitsverteilung sehen, wo die korrekte Klasse mit 1 angegeben ist. Diese Sicht passt zur Softmax-Funktion (s.u.).
Bei einer konkreten Eingabe $x$ findet eine Vorwärtsverarbeitung statt, um Ausgabe $\hat{y}$ zu berechnen. Das nennt man auch Forward Propagation und funktioniert wie beim Perzeptron. Dabei geht man schrittweise durch alle Schichten und berechnet pro Schicht erst Roheingabe und dann Aktivierung der Neuronen. Die Ausgabe $\hat{y}$ ist die Aktivierung der letzten Schicht $L$.
Die Roheingabe für das i-te Neuron in Schicht $l$ berechnet sich aus der gewichteten Summe der Aktivierungen der vorgeschalteten Schicht $l-1$.
$$ z_i^{(l)} = \sum_{j=1}^{n_{l-1}} w^{(l)}_{i,j} \; a_j^{(l-1)} $$wobei $l \in \{1, \ldots, L\}$ und $i \in \{1, \ldots, n_l\}$.
Betrachtet man $z$ als Funktion über die Aktivierungen $a$ der Vorgängerschicht, handelt es sich um eine lineare Funktion (die Gewichte übernehmen dabei die Rolle von konstanten Koeffizienten).
Hier examplarisch der Rohinput für das erste Neuron der zweiten Schicht (Ausgabeschicht) im Beispielnetz (blau):
$$ \begin{align*} z_1^{(2)} & = \sum_{j=1}^{3} w^{(2)}_{1,j} \; a_j^{(1)} \\[2mm] & = w^{(2)}_{1,1} \; a_1^{(1)} + w^{(2)}_{1,2} \; a_2^{(1)} + w^{(2)}_{1,3} \; a_3^{(1)} \end{align*} $$Die Roheingabe $z$ wird jetzt in die Aktivierungsfunktion $g$ gegeben, um die Aktivierung des Neurons zu berechnen.
Für ein einzelnes Neuron $i$ in Schicht $l$ ist das:
$$ a_i^{(l)} = g(z_i^{(l)}) $$Als Aktivierungsfunktion für $g$ wählen wir die Sigmoidfunktion (auch logistische Funktion genannt). Man beachte, dass es sich um eine nicht-lineare Funktion handelt.
$$ g(z) = \frac{1}{1 + e^{-z}} $$(Es sind aber auch andere Aktivierungsfunktionen möglich, siehe unten.)
In der Vektordarstellung werden die Roheingaben für Schicht $l$ als Vektor $z^{(l)}$ und die Aktivierungen als Vektor $a^{(l)}$ dargestellt. Dann können wir die Anwendung der Aktivierungsfunktion wie folgt schreiben:
$$ a^{(l)} = g(z^{(l)}) = \left( \begin{array}{c} g(z_1^{(l)}) \\ \vdots \\ g(z_{n_l}^{(l)}) \end{array} \right)$$Die Funktion $g$ wird hier also komponentenweise angewendet.
Die Aktivierung der letzten Schicht $L$ entspricht dem Gesamtoutput $\hat{y}$ des FNN.
Für ein einzelnes Ausgabeneuron $i$ in Schicht $L$ also:
$$ \hat{y}_i = a_i^{(L)} = g(z_i^{(L)}) $$In Vektorschreibweise:
$$ \hat{y} = g(z^{(L)}) $$Wenn wir die Aktivierungen als Vektoren und die Gewichte als Matrix darstellen, können wir die Formeln wesentlich kompakter darstellen. Wir versuchen, den Übergang möglichst intuitiv zu erklären.
Die Gewichte von Neuronen der Schicht $l-1$ zu Neuronen der Schicht $l$ werden als Matrix $W^{(l)}$ repräsentiert.
Das Einzelgewicht $w_{j, i}^{(l)}$ ist das Gewicht zwischen Quellneuron $i$ in Schicht $l-1$ und dem Zielneuron $j$ in Schicht $l$.
Hinweis: Man beachte die Reihenfolge der Indizes, die man vielleicht umgekehrt erwarten würde. Je nach Fachbuch wird auch die umgekehrte Reihenfolge gewählt, dann ändern sich die Gleichungen.
Für das Beispielnetz sieht die Gewichtsmatrix $W^{(1)}$ von der Eingabeschicht (Schicht 0) zur versteckten Schicht (Schicht 1) so aus:
$$ \begin{pmatrix} w_{1,1}^{(1)} & w_{1,2}^{(1)} \\ w_{2,1}^{(1)} & w_{2,2}^{(1)} \\ w_{3,1}^{(1)} & w_{3,2}^{(1)} \end{pmatrix} $$Es handelt sich also um eine 3x2-Matrix. Die Anzahl der Zeilen steht für die Anzahl der Zielneuronen, hier für 3 Neuronen in Schicht 1. Die Anzahl der Spalten steht für die Anzahl der Quellneuronen, hier für 2 Neuronen in Schicht 0 (Eingabe).
Hier sehen wir nochmal den relevanten Teil des Beispielnetzwerks. Vergleichen Sie die Gewichte mit der Matrix oben.
Wenden wir uns der Verarbeitung zu. Dazu betrachten wir $a^{(0)}$ und $z^{(1)}$ als Vektoren. Wir können den Rohinput der ersten Schicht $z^{(1)}$ als Multiplikation der Gewichtsmatrix $W^{(1)}$ mit dem Aktivierungsvektor $a^{(0)}$ der Eingabe (Schicht 0) darstellen.
Im Beispiel wäre das die folgende Rechnung:
$$\left( \begin{array}{c} z_1^{(1)} \\ z_2^{(1)} \\ z_3^{(1)} \end{array} \right) = \begin{pmatrix} w_{1,1}^{(1)} & w_{1,2}^{(1)} \\ w_{2,1}^{(1)} & w_{2,2}^{(1)} \\ w_{3,1}^{(1)} & w_{3,2}^{(1)} \end{pmatrix} \left( \begin{array}{c} a_1^{(0)} \\ a_2^{(0)} \end{array} \right) = \left( \begin{array}{c} w_{1,1}^{(1)} \; a_1^{(0)} + w_{1,2}^{(1)} \; a_2^{(0)} \\ w_{2,1}^{(1)} \; a_1^{(0)} + w_{2,2}^{(1)} \; a_2^{(0)} \\ w_{3,1}^{(1)} \; a_1^{(0)} + w_{3,2}^{(1)} \; a_2^{(0)} \end{array} \right) $$Wir können das in Vektorschreibweise sehr kompakt schreiben:
$$ z^{(1)} = W^{(1)} \; a^{(0)} $$Allgemein gilt für eine beliebige Schicht $l$:
$$ z^{(l)} = W^{(l)} \; a^{(l-1)} $$wobei $l \in {1,\ldots,L}$.
Hinweis: Wie oben schon erwähnt, wird in einigen Fachbüchern auch die folgende Form gewählt $ z^{(l)} = (a^{(l-1)})^T \; W^{(l)} $. In dieser Formulierung drehen sich die Indices von $W$ um.
Jetzt fügen wir sogenannte Bias-Neuronen hinzu (engl. bias = Tendenz/Neigung/Verzerrung). Jede Schicht - außer der Ausgabeschicht - hat genau ein Bias-Neuron mit dem konstanten Aktivierungswert $+1$. Die Einflussstärke des Bias-Neurons auf das Zielneuron $i$ der nächsten Schicht $l$ wird dann über das Gewicht $b_i^{(l)}$ gesteuert.
Innerhalb einer Schicht können wir die Gewichte des Bias-Neurons als Vektor schreiben. Man beachte, dass die Länge des Vektors der Anzahl der Zielneuronen entspricht:
$$b^{(l)} = \left( \begin{array}{c} b_1^{(l)} \\ \vdots \\ b_{n_{l}}^{(l)} \end{array} \right) $$Hinweis: Im Gegensatz zum Perzeptron packen wir das Gewicht des Bias-Neurons nicht in die Gewichtsmatrix. Dies ist der üblichere Weg in der Literatur, vielleicht weil man dann in den Gleichungen nicht ständig das $x$ mit einer "1" erweitern und die Bias-Gewichte in der Matrix gesondert betrachten muss. Beim Implementieren ist dann wieder die Lösung mit der Gewichtsmatrix potentiell interessant.
Die Roheingabe wird jetzt durch den Input des Bias-Neurons erweitert:
$$ \tag{Z} z_i^{(l)} = \sum_{j=1}^{n_{l-1}} w^{(l)}_{i,j} \; a_j^{(l-1)} + b_i^{(l)} $$In Vektorform addieren wir einfach den Bias-Vektor zum Ergebnis der Matrizenmultiplikation. Das ist unsere erste Berechnungsformel für Forward Propagation:
$$ \tag{ZV} z^{(l)} = W^{(l)} \; a^{(l-1)} + b^{(l)} $$Warum gibt es überhaupt diese Bias-Neuronen, die unsere schöne Formel oben noch ein bisschen komplexer machen? Kann man die nicht weglassen? Wenn Sie sich die Abbildung oben anschauen, sehen Sie, dass das Bias-Neuron im Gegensatz zu den anderen Neuronen derselben Schicht nicht von der Eingabe $x$ abhängt. Das heißt, über das Bias-Gewicht kann ich den Wert eines Neurons "global" nach oben oder unten schieben, egal, wie der Input aussieht. Das entspricht tatsächlich in der klassischen Geradengleichung $f(x) = ax + b$ dem $b$ als y-Achsenabschnitt (wobei die Tatsache, dass das hier auch $b$ heißt, Zufall ist). Auch dort schiebt das $b$ den Wert nach oben oder nach unten, unabhängig von Eingabe $x$.
Wir überlegen uns kurz, wie viele Parameter unser Netz hat, denn die Komplexität des Netzwerks und damit sowohl Dauer als auch Speicherbedarf im Training hängen entscheidend davon ab. Parameter sind alle Größen, die im Training angepasst werden. Beim FNN sind das Gewichte in Gewichtsmatrizen und Bias-Vektoren.
Wir sehen uns nochmal das Netz von oben an:
Zwischen Schicht 0 und 1 haben wir eine 3x2-Gewichtsmatrix und einen Bias-Vektor der Länge 3. Daraus ergeben sich 3x2 + 3 = 9 Parameter. Zwischen Schicht 1 und 2 haben wir eine 2x3 Gewichtsmatrix und einen Bias-Vektor der Länge 2, also 2x3 + 2 = 8 Parameter. Insgesamt hat das Beispielnetz also 17 Parameter. Zum Vergleich: heutige CNNs haben mehrere Millionen Parameter.
Allgemein kann man die Anzahl der Parameter für eine Schicht $l$ ($l > 0 $) mit folgender Formel angeben:
$$ P_l = n_{l-1} n_l + n_l = n_l (n_{l-1} + 1) $$Die Gesamtzahl der Parameter $P$ ist also:
$$ P = \sum_{l=1}^L P_l = n_l (n_{l-1} + 1) $$Das "+1" steht dort wegen der Bias-Gewichte.
In unserem Beispielnetz mit zwei Schichten läuft Forward Propagation wie folgt:
$$ x = a^{(0)} \, \overset{W^{(1)}}{\longrightarrow} \, z^{(1)} \, \overset{g}{\longrightarrow} \, a^{(1)} \, \overset{W^{(2)}}{\longrightarrow} \, z^{(2)} \, \overset{g}{\longrightarrow} \, a^{(2)} = \hat{y} $$In Schicht $0$ gilt:
$$ a^{(0)} = x $$In Schicht $1$ rechnen wir:
$$ \begin{align*} z^{(1)} & = W^{(1)} \; a^{(0)} + b^{(1)} \\[2mm] & = W^{(1)} \; x + b^{(1)} \\[2mm] a^{(1)} & = g(z^{(1)}) \end{align*} $$Dann berechnen wir Schicht $2$ und Ausgabe $\hat{y}$:
$$ \begin{align*} z^{(2)} & = W^{(2)} \; a^{(1)} + b^{(2)} \\[2mm] a^{(2)} & = g(z^{(2)}) \\[2mm] \hat{y} & = a^{(2)} \end{align*} $$Die Aktivierungsfunktion $g$ ist von entscheidender Bedeutung für den Erfolg Neuronaler Netze, denn diese Funktion führt eine Nicht-Linearität in die Gesamtrechnung ein. Man mache sich klar, dass die Weiterleitung der Aktivierung über die Gewichte zur sogenannten Roheingabe eine lineare Abbildung ist:
$$ z_i^{(l)} = \sum_{j=1}^{n_{l-1}} \Big[ w^{(l)}_{i,j} \; a_j^{(l-1)} \Big] + b_i^{(l)} $$Erst wenn die Aktivierungsfunktion $g$ zum Zug kommt, wird eine (in der Regel) nicht-lineare Funktion eingeführt. Diese Berechnung ist die zweite wichtige Formel bei Forward Propagation:
$$ \tag{A} a_i^{(l)} = g(z_i^{(l)}) $$Oben haben wir eine sigmoide Funktion für unser FNN gewählt. Man beachte, dass für die Ausgabe oft eine andere Aktivierungsfunktion gewählt wird als für die Zwischenschichten. Wir betrachten hier die Zwischenschichten.
Eine Sigmoidfunktion - wie die logistische Funktion - ist eine oft gewählte Aktivierungsfunktion für Zwischenschichten. Die Funktion ist so definiert:
$$ g(z) = \frac{1}{1 + e^{-z}} $$Die logistische Funktion bildet den Input auf das Interval [0, 1] ab.
Alternativ kann man auch eine Funktion wählen, die auf das Interval [-1, 1] abbildet, das leistet z.B. der Tangens hyperbolicus (tanh).
Streng genommen nennt man beide Funktionen (logistisch und tanh) sigmoide Funktionen. Oft wird die logistische Funktion gleichgesetzt mit dem Begriff "Sigmoidfunktion" (z.B. auch in Keras). Der tanh kommt deutlich seltener zum Einsatz.
In der folgenden Abbildung sehen wir beide Funktionen im Vergleich:
def sigmoid(z):
return 1 / (1+np.exp(-z))
x = np.arange(-10, 10, .1)
y_sig = sigmoid(x)
y_tanh = np.tanh(x)
plt.plot(x, y_sig, 'b', label='logistisch')
plt.plot(x, y_tanh, 'r', label='tanh')
plt.xlabel('x')
plt.ylabel('y')
plt.title('Logistische und tanh-Funktion')
plt.xticks([-10,-5,0,5,10])
plt.yticks([-1,-.5,0,.5, 1])
plt.grid()
plt.legend()
plt.show()
Eine in den letzten Jahren wohl am meisten genutzte Funktion für Zwischenschichten ist das Rectified Linear Unit (ReLU). Diese ist definiert als
$$ g(z) := \max(0, z) $$Für manche ist diese Darstellung vielleicht intuitiver:
$$ g(z) := \begin{cases} z &\mbox{wenn}\quad z > 0\\ 0 &\mbox{wenn}\quad z \leq 0 \end{cases} $$Zu beachten ist, dass die Funktion nicht differenzierbar in 0 ist, dass dies aber in der Praxis kein Problem ist. Im Vergleich zur Heaviside-Funktion ist es aber so, dass die Steigung im positiven x-Raum natürlich beim Gradientenabstieg wichtig ist. Eine zweite Beobachtung ist, dass die Funktion zwar abschnittsweise linear ist, aber insgesamt wieder nicht-linear.
x = np.arange(-2, 2, .1)
y_relu = [max(0, i) for i in x]
plt.plot(x, y_relu, 'r', label='ReLU')
plt.xlabel('x')
plt.ylabel('y')
plt.title('Rectified Linear Unit (ReLU)')
plt.xticks([-2,0,2])
plt.yticks([-.5,0,.5, 1])
plt.grid()
plt.legend()
plt.show()
Für die Ausgabe $\hat{y}$ möchte man in einem Neuronalen Netz oft eine besondere Form haben. Jedes Ausgabeneuron repräsentiert ja eine bestimmte Klasse (z.B. Katze, Hund, Mensch bei einem Bildklassifikator). Daher wäre es wünschenswert, die entsprechenden Werte $\hat{y}_1, \ldots, \hat{y}_m$ als Wahrscheinlichkeiten zu verstehen. Bedenken Sie, dass die Rohinputs ganz beliebige Werte annehmen können (z.B. deutlisch größer als 1 oder negative Werte). Damit die Werte in $\hat{y}$ wie Wahrscheinlichkeiten "wirken", möchten wir zwei Eigenschaften haben:
Das ist tatsächlich gar nicht so schwer.
Wenn Sie eine Reihe von Werten $z_1, \ldots, z_m$ haben (die zunächst mal alle positiv sind, also $z_i \geq 0$), dann "normalisieren" Sie einfach jeden Wert, indem Sie ihn durch die Summe aller $z_i$ teilen:
$$ \tilde{z}_i = \frac{z_i}{\sum_{j=1}^m z_j} $$Man sieht direkt, dass jeder Wert zwischen 0 und 1 liegen muss und dass die Summe aller $z_i$ eins ist.
Ähnlich funktioniert die Softmax-Funktion, die wir hier vorstellen. Bei Softmax wird der Wert $z_i$ noch durch die Exponentfunktion "geschickt", d.h. wir nehmen $e^{z_i}$ statt $z_i$. Schaut man sich die Funktion an (siehe Python-Ausgabe unten), sieht man zwei Eigenschaften:
x = np.arange(-3, 3, .1)
y = np.exp(x)
plt.plot(x, y, 'r')
plt.xlabel('z')
plt.ylabel('f')
plt.title('Funktion e^z im Bereich [-3, 3]')
plt.xticks(np.arange(-3,3,1))
plt.yticks(np.arange(0, 20, 5))
plt.grid()
plt.show()
Die Softmax-Funktion ist jetzt wie folgt definiert ($i \in \{1,\ldots,m\}$):
$$ g_{sm}(z_i) := \frac{e^{z_i}}{\sum_{j=1}^m e^{z_j}} $$Diese Funktion erfüllt beide Eigenschaften (wie Sie sich selbst leicht überzeugen können):
Sehen wir uns vier Beispielwerte an, die als Rohinput $z_1, z_2, z_3, z_4$ bei vier Ausgabeneuronen ankommen könnten, zusammen mit der jeweiligen Softmax-Umrechnung (siehe auch den Code unten):
z | softmax |
---|---|
-10.36 | 1.07 e-12 (= 0) |
0.71 | 6.87 e-08 (= 0) |
15.41 | 0.17 |
17.02 | 0.83 |
Sie sehen, dass die Werte im Interval [0,1] liegen und die Unterschiede akzentuiert werden.
Hinweis: Im Unterschied zu den bisherigen Aktivierungsfunktionen (z.B. logistische Funktion oder ReLU) wird bei Softmax die gesamte Werteverteilung in die Rechnung mit einbezogen (durch die Summe im Nenner), d.h. die Werte sind immer relativ zu allen anderen Werten.
Wir sehen uns die Funktion in Python an und geben gleich ein paar Beispiele für Rohinput-Verteilungen aus.
# Als Eingabe wird ein NumPy-Array erwartet
def softmax(n):
expnum = np.exp(n) # exp auf alle Arraywerte (np.exp für Broadcasting)
s = expnum.sum() # Summe aller Werte
return expnum/s # Broadcasting der Division
# Funktion für die Ausgabe
def print_softmax(num):
sm = softmax(num)
for i in range(len(num)):
print(f"Rohinput: {num[i]:7.2f} => Softmax: {sm[i]:.5f}")
# Beispiele:
n1 = np.array([0, 1, 0, 0])
print_softmax(n1)
n2 = np.array([-10, 100, -10, -90])
print('\n')
print_softmax(n2)
n3 = np.array([-3, -2, -1, 0, 1, 2, 3])
print('\n')
print_softmax(n3)
n4 = np.array([-10.36, 0.71, 15.41, 17.02])
print('\n')
print_softmax(n4)
Rohinput: 0.00 => Softmax: 0.17488 Rohinput: 1.00 => Softmax: 0.47537 Rohinput: 0.00 => Softmax: 0.17488 Rohinput: 0.00 => Softmax: 0.17488 Rohinput: -10.00 => Softmax: 0.00000 Rohinput: 100.00 => Softmax: 1.00000 Rohinput: -10.00 => Softmax: 0.00000 Rohinput: -90.00 => Softmax: 0.00000 Rohinput: -3.00 => Softmax: 0.00157 Rohinput: -2.00 => Softmax: 0.00426 Rohinput: -1.00 => Softmax: 0.01159 Rohinput: 0.00 => Softmax: 0.03150 Rohinput: 1.00 => Softmax: 0.08563 Rohinput: 2.00 => Softmax: 0.23276 Rohinput: 3.00 => Softmax: 0.63270 Rohinput: -10.36 => Softmax: 0.00000 Rohinput: 0.71 => Softmax: 0.00000 Rohinput: 15.41 => Softmax: 0.16659 Rohinput: 17.02 => Softmax: 0.83341
Wir schauen uns nochmal alle Formeln, die wir für die Vorwärtsverarbeitung (Forward Propagation) benötigen, im Überblick an. Dabei bezeichnet $L$ die Anzahl der Schichten, $n_l$ die Zahl der Neuronen in Schicht $l$ und $m$ die Anzahl der Ausgabeneuronen.
Zunächst schreiben wir die Formeln so, dass nur Skalare erscheinen (also keine Vektoren oder Matrizen). Index $i$ bezieht sich auf ein Neuron der Schicht $l$.
$$ \begin{align} a^{(0)}_i &:= x_i \tag{Eingabeschicht}\\[3mm] z^{(l)}_i &:= \sum_{j=1}^{n_{l-1}}\Big[ w_{i, j}^{(l)} \; a_j^{(l-1)}\Big] + b_i^{(l)} \tag{Roheingabe}\\[3mm] a^{(l)}_i &:= g(z^{(l)}_i) \tag{Aktivierung}\\[3mm] \hat{y}_i &:= a^{(L)}_i\tag{Ausgabe} \end{align} $$Jetzt sehen wir uns die Schreibweise mit Vektoren und Matrizen an.
$$ \begin{align} a^{(0)} &:= x \tag{Eingabeschicht}\\[3mm] z^{(l)} &:= W^{(l)}\, a^{(l-1)} + b^{(l)}\tag{Roheingabe}\\[3mm] a^{(l)} &:= g(z^{(l)}) \tag{Aktivierung}\\[3mm] \hat{y} &:= a^{(L)}\tag{Ausgabe} \end{align} $$Für die Aktivierungsfunktion $g$ wählen wir zwei unterschiedliche Funktionen. Wir definieren beide in Komponentenschreibweise.
Wir wählen die Sigmoidfunktion für die Zwischenschichten $l = 1, \ldots, L-1$:
$$ g(z_i) := \frac{1}{1 + e^{-z_i}} $$Wir wählen die Softmax-Funktion für die Ausgabeschicht $L$:
$$ g_{sm}(z_i) := \frac{e^{z_i}}{\sum_{j=1}^m e^{z_j}} $$Backpropagation ist eine Methode, um die Gewichte schrittweise so anzupassen, dass sie sich einer optimalen Lösung annähern. Bei Backpropagation wird wieder das Verfahren des Gradientenabstiegs eingesetzt, um die optimalen Gewichte zu finden. Wie bei jedem Optimierungsproblem müssen wir auch hier eine Zielfunktion definieren, auch Kosten-, Fehler- oder Verlustfunktion genannt. Diese Zielfunktion gilt es zu minimieren.
Die Verfahren hinter Backpropagation wurden bereits in den 60er Jahren im Bereich Kontrolltheorie/Regelungstheorie entwickelt, aber erst in den 1974 für die Anwendung in Neuronalen Netzen durch Paul Werbos (1974, 1982) "wiederentdeckt". Wirkliche Verbreitung findet die Methode in den 80ern dann durch Rumelhart, Hinton und Willams (1986, 1988) , die auch den Begriff Backpropagation prägten. Siehe auch Schmidhuber (2015) für einen Überblick zur Geschichte des Verfahrens.
Wenn Sie etwas zur Entstehungsgeschichte von Backprop um die Person David Rumelhart in den 80er Jahren hören möchten, horchen Sie in den Lex-Fridman-Podcast mit den Gast Jay McClelland hinein (etwa ab 01:12:35). McClelland gehört ebenfalls zu den Pionieren der Arbeit an Neuronalen Netzen, die damals unter den Begriff Konnektionismus und in die größere Disziplin der Kognitionswissenschaften fielen. Rumelhart ist leider 2011 im Alter von 68 verstorben.
Geoffrey E. Hinton ist wissenschaftlich auch heute noch sehr aktiv und gilt als einer der Pioniere des Deep Learning. Er erhielt 2018 den renommierten Turing Award der ACM, zusammen mit Yann LeCun und Yoshua Bengio.
Da wir es wieder mit einem Optimierungsproblem zu tun haben, benötigen wir eine Zielfunktion (engl. objective function), die wir minimieren oder maximieren.
Wir entscheiden uns für das Minimieren und suchen also eine Kostenfunktion oder Verlustfunktion, engl. loss function. Wir betrachten zunächst den Fall der binären Klassifikation und gehen dann zur Mehrklassen-Klassifikation mit $m$ Klassen über.
Zunächst definieren wir die Kostenfunktion $J$ für binäre Klassifikation, d.h. es gibt nur ein einziges Ausgabeneuron mit Ausgabewert $\hat{y}$. Wir betrachten ferner nur ein einziges Trainingsbeispiel $(x^k, y^k)$. Wir definieren jetzt die sogenannte Binary Cross-Entropy Funktion als Verlustfunktion wie folgt
$$ J_k := - \Big( y \; log(\hat{y}) + (1 - y) \; log(1 - \hat{y}) \Big) $$Wir haben den Index $k$ bei $y$ und $\hat{y}$ der Lesbarkeit halber weggelassen.
Diese Formel ist nicht so kryptisch ist, wie sie zunächst scheint. Der Zielwert $y$ ist immer entweder genau 0 oder genau 1, also $y \in \{0,1\}$, d.h. es wird immer einer der beiden Summanden "ausgewählt" und der andere "gelöscht", also
$$ J_k = \begin{cases} - y \; log(\hat{y}) & \quad \text{falls } y = 1 \\[3mm] - (1 - y) \; log(1 - \hat{y}) & \quad \text{falls } y = 0 \end{cases} $$Außerdem ist wichtig, dass $\hat{y}$ ist eine Dezimalzahl zwischen 0 und 1 ist aufgrund der Softmax-Funktion, also $\hat{y} \in [0,1]$. Schauen wir uns den Fall $y=1$ an. Die negative Log-Funktion $-log(x)$ wird im Interval $[0, 1]$ nahe 0 sehr groß (hoher Fehler) und nahe 1 - also nahe dem gewünschten Zielwert - sehr klein bzw. gleich 0, wenn wir genau 1 erreichen. Das ist also sinnvoll.
Wir sehen uns jetzt für den Fall $y = 1$ eine Wertetabelle an:
Zielwert $y$ | Vorhersage $\hat{y}$ | log($\hat{y}$) | Fehler -log($\hat{y}$) |
---|---|---|---|
1 | 1 | 0 | 0 |
1 | 0.9 | -0.11 | 0.11 |
1 | 0.5 | -0.69 | 0.69 |
1 | 0.1 | -2.30 | 2.30 |
1 | 0.00001 | -11.51 | 11.51 |
Auch hier sehen wir: Wenn die Vorhersage $\hat{y}$ richtig liegt, dann ist der Fehler genau 0. Je weiter der vorhergesagte Wert vom Zielwert abweicht, umso größer der Fehlerbeitrag. Der Fehler steigt sogar exponentiell nahe 0. Man bedenke, dass $log(0)$ nicht definiert ist (minus unendlich), deshalb haben wir in der Tabelle auch nur einen sehr kleinen Wert genommen. Die Null müsste man auch in einer Implementierung entsprechend abfangen.
Für den Fall $y = 0$ gilt natürlich das gleiche wie oben, die Werte werden durch $1-\hat{y}$ entsprechend "umgedreht".
In Keras konfiguriert man die loss function, wenn man die Methode compile
auf dem Modell aufruft:
model.compile(optimizer='sgd',
loss='binary_crossentropy',
metrics=['acc'])
Wir sehen uns im nächsten Abschnitt den Fall an, dass mehrere Klassen vorliegen.
In den meisten Fällen haben wir es mit Mehrklassen-Klassifikation zu tun und da verwendet man die sogenannte Categorical Cross-Entropy. Im Gegensatz zur binären Klassifikation haben wir hier mehrere Output-Neuronen vorliegen (pro Kategorie/Klasse eines). Bei einer Mehrklassen-Klassifikation mit $m$ Kategorien und One-Hot-Encoding ist die Fehlerfunktion für ein Trainingsbeispiel $k$ wie folgt definiert:
$$ \tag{J} J_k = - \sum_{i=1}^m y_i\, log(\hat{y}_i) $$Die Idee ist hier, dass alle Komponenten, die nicht der Zielklasse entsprechen, ignoriert werden. Zum Beispiel: Die Daten enthalten 5 Kategorien ($m=5$) und bei einem Trainingsbeispiel $k=3$ ist die korrekte Ausgabe wäre die 2. Kategorie, also
$$ y^3 = \begin{pmatrix} 0\\1\\0\\0\\0 \end{pmatrix} $$Dann ist der Fehlerbeitrag für dieses Trainingsbeispiel 3:
$$J_3 = - log(\hat{y}_2) $$Das heißt, man schaut sich in diesem Fall nur den Output von Ausgabeneuron Nr. 2 an und nimmt dort noch den Logarithmus. Die Begründung für den negativen Logarithmus haben wir oben bei Binary Cross-Entropy besprochen.
Wir haben oben nur den Fehler bei einem einzigen Trainingsbeispiel definiert (J). Nehmen wir an, wir haben $N$ Trainingsbeispiele $(x^k, y^k)$. Jetzt addieren wir die Kosten aller Trainingsbeispiele auf und mitteln sie:
$$ \tag{J2} J = - \frac{1}{N} \sum_{k=1}^N \sum_{i=1}^m y_i\, log(\hat{y}_i) $$Das entspricht der Summe der Fehlerfunktionen für einzelne Trainingsbeispiele:
$$ J = \frac{1}{N} \sum_{k=1}^N J_k $$Da $J$ von den aktuellen Gewichten $W$ abhängt, schreibt man auch $J(W)$ oder $J_W$. Wir lassen diesen Hinweis aber i.d.R. weg.
In Keras gibt man categorical_crossentropy für die loss function in der Methode compile
des Modells an:
model.compile(optimizer='sgd',
loss='categorical_crossentropy',
metrics=['acc'])
Man fügt den folgenden Term zur Kostenfunktion dazu, um zu verhindern, dass die individuelle Gewichte zu dominant werden. Man kann zeigen, dass dies den Effekt des Overfitting verringert. Man kann sich das so vorstellen, dass ein zu schnelles Abdriften in eine extreme Gewichtsverteilung (ein paar Gewichte werden sehr groß) dadurch verhindert wird, dass dieser "Strafterm" zu den Kosten hinzuaddiert wird. Effektiv wird dadurch die absolute Größe der Gewichte verringert, daher spricht man auch von weight decay (siehe auch unten unter "Effekt").
Wir addieren die Beträge aller Gewichtsmatrizen von allen Schichten:
$$ \frac{\lambda}{2} \: \sum_{l=1}^{L} \left\Vert W^{(l)} \right\Vert^2 $$wobei die L2-Norm einer Matrix $W$ definiert ist als Wurzel der quadrierten Komponenten:
$$ \tag{N} \left\Vert W \right\Vert = \left( \sum_i \sum_j (w_{i,j})^2 \right)^{\frac{1}{2}}$$Dies nennt man auch L2-Regularisierung, da hier die L2-Norm angewandt wird. Die L1-Regularisierung würde die Beträge der Gewichte verwenden (= L1-Norm). Der Faktor $\lambda$ (griechischer Buchstabe Lambda) steuert die Stärke der Regularisierung.
Wir addieren den L2-Regularisierungsterm zu unserer Kostenfunktion (J) und lösen dann den Regularisierungsterm mit Hilfe von (N) auf.
$$ \begin{align*} J_\text{reg} &= J + \frac{\lambda}{2} \sum_{l=1}^{L} \left\Vert W^{(l)} \right\Vert^2 \\ &= J + \frac{\lambda}{2} \: \sum_{l=1}^{L} \sum_{j=1}^{n_{l-1}} \sum_{h=1}^{n_l} (w^{(l)}_{h, j})^2 \\ &= - \frac{1}{N} \sum_{k=1}^N \sum_{i=1}^m y_i\, log(\hat{y}_i) \, + \frac{\lambda}{2} \: \sum_{l=1}^{L} \sum_{j=1}^{n_{l-1}} \sum_{h=1}^{n_l} (w^{(l)}_{h, j})^2 \end{align*} $$Dies wäre also unsere vollständige Zielfunktion.
Wir möchten überlegen, wie genau die Regularisierung wirkt. Dazu unterscheiden wir die Kostenfunktion ohne Regularisierung $J$ und die Kostenfunktion mit Regularisierung $J_\text{reg}$. Der Zusammenhang ist:
$$\tag{R} J_\text{reg} = J + \frac{\lambda}{2} \sum_{l=1}^{L} \left\Vert W^{(l)} \right\Vert^2 $$Jetzt schauen wir uns den Update-Schritt der Gewichte an:
$$ w_{i,j} := w_{i,j} + \alpha \Delta w_{i,j} \tag{*}\label{update}$$Das Delta berechnet sich wie folgt:
$$ \Delta w_{i,j} = - \frac{\partial}{\partial w_{i,j}} J_\text{reg} $$Wir setzen (R) ein:
$$ \begin{align*} \Delta w_{i,j} &= - \frac{\partial}{\partial w_{i,j}} \left[ J + \frac{\lambda}{2} \: \sum_{l=1}^{L} \left\Vert W^{(l)} \right\Vert^2 \right] \\ &= - \left[ \frac{\partial}{\partial w_{i,j}} \frac{\lambda}{2} \: \sum_{l=1}^{L} \left\Vert W^{(l)} \right\Vert^2 + \frac{\partial}{\partial w_{i,j}} J \right] \end{align*} \tag{**}\label{delta} $$Jetzt gilt Folgendes aufgrund von (N):
$$ \frac{\partial}{\partial w_{i,j}} \frac{\lambda}{2} \: \sum_{l=1}^{L} \left\Vert W^{(l)} \right\Vert^2 = \lambda\,w_{i,j} $$(Das $w_{i,j}$ gehört zu einer bestimmten Schicht $h$, was wir hier nicht explizit hingeschrieben haben. Daher fallen alle Terme anderer Schichten weg.)
Wir setzen dies in (\ref{delta}) ein:
$$ \Delta w_{i,j} = - \left[ \lambda\,w_{i,j} + \frac{\partial}{\partial w_{i,j}} J \right] $$Das setzen wir wieder in (\ref{update}) ein und formen um:
$$ \begin{align*} w_{i,j} &:= w_{i,j} - \alpha \left[ \lambda\,w_{i,j} + \frac{\partial}{\partial w_{i,j}} J \right]\\ &= \left(1 - \alpha \lambda \right) w_{i,j} - \alpha \frac{\partial}{\partial w_{i,j}} J \end{align*} $$Wir haben jetzt wieder ungefähr die Formel in (\ref{update}). Im Unterschied zur ursprünglichen Formel sieht man aber, dass das Gewicht $w_{i,j}$ in jedem Schritt ein wenig reduziert wird, denn $(1- \alpha \lambda)$ ist ja ein Faktor, der echt kleiner als eins ist. Deshalb wird auch von weight decay gesprochen.
Jetzt berechnen wir die "Fehler" an jedem Neuron. Diese Fehler verwenden wir später, um die Gewichte anzupassen, wie wir das schon bei Perzeptron und Adaline getan haben.
Den Fehler in der Output-Schicht zu definieren, ist relativ offensichtlich, aber weniger klar bei den versteckten Neuronen. Alle Fehler werden hier mit $\delta$ (griechisches kleines Delta) bezeichnet, da "Delta" in der Mathematik immer auf eine Differenz hindeutet.
In der folgenden Abbildung sehen wir in unserem Beispielnetz, wo die Fehler $\delta^{(1)}$ und $\delta^{(2)}$ berechnet werden. In der Eingabeschicht gibt es keinen Fehler, weil dort ja das Trainingsbeispiel anliegt. Die Biasneuronen ignorieren wir hier.
Grob gesehen haben wir die folgende Verarbeitungskette in unserem 3-Schicht-Netzwerk:
Wie man sieht, läuft die Berechnung rückwärts durch die Schichten, von Schicht 2 zu Schicht 1.
Allgemein definieren wir zunächst den Fehler $\delta$ für die Ausgabeschicht $L$. Hier zunächst mal für jedes Neuron $i$:
$$ \delta_i^{(L)} = \hat{y}_i - y_i $$In der Vektordarstellung können wir den Index weglassen:
$$ \delta^{(L)} = \hat{y} - y $$Die Herleitung finden Sie im nächsten Kapitel unter (D1).
Für eine beliebige Schicht $l$, außer Aus- oder Eingabeschicht, d.h. $l \in \{1, \ldots, L-1\}$, berechnet sich $ \delta^{(l)}$ wie folgt:
$$ \delta^{(l)} = (W^{(l+1)})^T \; \delta^{(l+1)} \odot g'(z^{(l)}) $$wobei der Operator $\odot$ die elementweise Multiplikation von Vektoren ausdrückt, auch bekannt als Hadamard product). Ein Beispiel:
$$\left( \begin{array}{c} a_1 \\ a_2 \\ a_3 \end{array} \right) \odot \left( \begin{array}{c} b_1 \\ b_2 \\ b_3 \end{array} \right) = \left( \begin{array}{c} a_1 b_1\\ a_2 b_2\\ a_3 b_3\end{array} \right) $$Die Herleitung der obigen Formel für $\delta^{(l)}$ finden Sie im nächsten Kapitel unter (D2).
Etwas kompakter schreiben wir:
$$ \label{backprop1}\tag{D} \delta^{(l)} = \begin{cases} \hat{y} - y & \mbox{wenn}\quad l = L\\[3mm] (W^{(l+1)})^T \; \delta^{(l+1)} \odot g'(z^{(l)}) & \mbox{wenn}\quad l \in \{1, \ldots, L-1\} \end{cases} $$Wenn wir uns das schematisch ansehen, sehen wir, dass jedes Neuron seinen eigenen "Fehlerwert" $\delta$ hat. Ausgenommen sind die Eingabeneuronen, was sinnvoll ist, denn die Eingabedaten sind ja die Features eines Trainingsbeispiels und können daher nicht "falsch" sein.
In einem Übungsblatt haben wir gezeigt, dass die Ableitung $g'$ für die logistische Funktion wie folgt aussieht:
$$g'(z^{(l)}) = a^{(l)} \odot (1 - a^{(l)})$$so dass wir schreiben können:
$$ \tag{D'}\delta^{(l)} = \begin{cases} \hat{y} - y & \mbox{wenn}\quad l = L\\[3mm] (W^{(l+1)})^T \; \delta^{(l+1)} \odot a^{(l)} \odot (1 - a^{(l)})& \mbox{wenn}\quad l \in \{1, \ldots, L-1\} \end{cases} $$Man sieht, dass man zur Berechnung von $\delta^{(l)}$ lediglich den Fehler der direkt nachgelagerten Schicht $l+1$, die Aktivierung der eigenen Schicht $l$ und der Gewichte zwischen den Schichten $l$ und $l+1$ benötigt. Mit dieser Formel ist somit es möglich, ein $\delta$ für jedes Neuron zu berechnen, indem man schichtweise rückwärts von Outputschicht zu Inputschicht vorgeht. Daher der Name Backpropagation (Rückwärtsverarbeitung).
Hinweis: Formel (D) ist allgemeiner, da verschiedene Aktivierungsfunktionen $g$ zum Einsatz kommen können. Für die Herleitung betrachten wir aber das speziellere (D').
Wenn wir uns eine einzelne Komponente $i$ eines $\delta$ ansehen, erkennen wir, welche Werte in die Rechnung eingehen:
$$ \delta_i^{(l)} = a_i^{(l)} \: (1 - a_i^{(l)}) \: \sum_j w_{j,i}^{(l+1)} \: \delta_j^{(l+1)}$$Schauen wir uns ein konkretes $\delta_2^{(1)}$ an (siehe Abb. unten). Dies soll den Einfluss des Neurons 2 in Schicht 1 auf die Fehler in der Ausgabeschicht 2 erfassen.
$$ \delta_2^{(1)} = a_2^{(1)} \: (1 - a_2^{(1)}) \: \sum_{j=1}^2 w_{j,2}^{(2)} \: \delta_j^{(2)}$$Schematisch sehen wir, welche Daten für die Berechnung von $\delta_2^{(1)}$ benötigt werden, nämlich insbesondere alle Fehlerwerte der nachfolgenden Schicht, aber auch die Gewichte dorthin und die Aktivierung des Neurons.
Zunächst scheint die Summe plausibel: man gewichtet die zwei Komponenten im Fehler $\delta^{(2)}$ je nachdem, wie hoch das Gewicht - also der Einfluss des Neurons - auf die jeweilige Fehlerkomponente war.
Der Term $a\: (1 - a)$ entspricht der Ableitung der Aktivierungsfunktion, also $g'(z)$. Dies ist also die "Richtung" der Änderung und ist als Teil des Gradienten dieser Schicht für den Gradientenabstieg wesentlich.
Jetzt wollen wir unsere Fehlerwerte nutzen, um die Gewichte anzupassen.
Anpassung des Gewichts $w_{i,j}^{(l)}$ bedeutet, ein Delta zu addieren. Wie gewohnt steuern wir den Grad der Änderung mit einem Faktor $\alpha \in [0, 1]$, der Lernrate.
$$ w_{i,j}^{(l)} := w_{i,j}^{(l)} + \alpha \; \Delta w_{i,j}^{(l)} $$Wir müssen $ \Delta w_{i,j}^{(l)} $ für jedes Gewicht berechnen, wie die folgende Abbildung illustriert.
Wir benutzen auch hier wieder Gradientenabstieg, d.h. wir fragen uns, in welche Richtung und wie stark wir $w_{i,j}$ ändern müssen, indem wir die partielle Ableitung der Zielfunktion hinsichtlich $w_{i,j}$ bilden.
Wir werden später noch zeigen, dass gilt:
$$ \frac{\partial J}{\partial w_{i,j}^{(l)}} = a_j^{(l-1)} \: \delta_i^{(l)} $$Wir müssten hier noch einen Term für die L2-Regularisierung addieren, lassen ihn aber um der Lesbarkeit willen weg. Die Formel oben gilt also für den Fall $\lambda = 0$.
Diese Ableitung ist genau das, was wir für unser Delta benötigen, denn das Delta soll ja in Richtung negative Richtung des Gradienten zeigen.
$$ \Delta w_{i,j}^{(l)} = - \frac{\partial J}{\partial w_{i,j}^{(l)}} = - a_j^{(l-1)} \: \delta_i^{(l)} $$Wir können uns schematisch ansehen, wie für eine Gewichtsänderung $\Delta w^{(1)}_{2,1}$ sowohl der Fehlerwert des Zielneurons $\delta^{(1)}_2$ als auch die Aktivierung des Quellneurons $a^{(0)}_1$ hinzugezogen wird. Die Herleitung zur obigen Formel finden Sie im nächsten Kapitel unter (W).
Für die Bias-Gewichte $b^{(l)}$ gilt analog:
$$ \Delta b_i^{(l)} = - \frac{\partial J}{\partial b_i^{(l)}} = - \delta_i^{(l)} $$Man kann sich vorstellen, dass die "Aktivierung" $a$ hier ja immer gleich eins ist. Die Herleitung zu dieser Formel finden Sie im nächsten Kapitel unter (B).
Wenn wir die Berechnungen mit Matrizen durchführen möchten, erstellen wir pro Schicht eine Matrix $\Delta W^{(l)}$, wobei $l \in \{1,\ldots,L\}$. Diese Matrizen beinhalten alle Änderungen aller Gewichte. Zusätzlich benötigen wir Vektoren $\Delta b^{(l)}$ für die Biasneuronen.
Die Matrix kann elegant berechnet werden mit
$$ \label{backprop2}\tag{DelW} \Delta W^{(l)} = - \delta^{(l)} \: (a^{(l-1)})^T $$Die Vektoren für die Biasgewichte sind ganz einfach:
$$ \label{backprop3}\tag{DelB} \Delta b^{(l)} = - \delta^{(l)} $$Wir möchten (DelW) mit Hilfe unseres Beispielnetzwerks prüfen: Zwischen Schicht 2 und 3 haben die 2x3-Gewichtsmatrix $W^{(2)}$ und die entsprechende 2x3-Deltamatrix $\Delta W^{(2)}$. Jetzt multiplizieren wir den Fehlervektor der Ausgabeschicht (2x1-Matrix) mit dem transponierten Aktivierungsvektor der 3 versteckten Neuronen (1x3-Matrix):
$$ \Delta W^{(2)} = - \left( \begin{array}{c} \delta_1^{(2)} \\ \delta_2^{(2)} \end{array} \right) \; ( a_1^{(1)} \; a_2^{(1)} \; a_3^{(1)} ) = - \begin{pmatrix} \delta_1^{(2)} \: a_1^{(1)} & \delta_1^{(2)} \: a_2^{(1)} & \delta_1^{(2)} \: a_3^{(1)} \\ \delta_2^{(2)} \: a_1^{(1)} & \delta_2^{(2)} \: a_2^{(1)} & \delta_2^{(2)} \: a_3^{(1)} \end{pmatrix}$$Die Ergebnismatrix hat also die Form 2x3, genau wir es für $\Delta W^{(2)}$ benötigen.
Die obige Formel gilt für ein Trainingsbeispiel, wenn man sich die Herleitung ansieht.
Wenn wir den Fehler für Trainingsbeispiel $k$ mit $J_k$ bezeichnen, gilt:
$$ \Delta W^{(l), k} = - \frac{\partial J_k}{\partial W^{(l)}} $$Nun gilt für unsere Fehlerfunktion $J$, dass der Gesamtfehler die gemittelte Summe der Fehler auf den einzelnen Beispielen ist:
$$ J = \frac{1}{N} \sum_{k=1}^N J_k $$Dann möchten wir die Änderung für alle Trainingsbeispiele mit Matrix $D^{(l)}$ ausdrücken:
$$ D^{(l)} := - \frac{\partial J}{\partial W^{(l)}} $$Jetzt können wir einsetzen und auflösen:
$$ \begin{align*} D^{(l)} &= - \frac{\partial J}{\partial W^{(l)}} \\[2mm] &= - \frac{\partial}{\partial W^{(l)}} \frac{1}{N} \sum_{k=1}^N J_k\\[2mm] &= \frac{1}{N} \sum_{k=1}^N - \frac{\partial J_k}{\partial W^{(l)}} \\[2mm] &= \frac{1}{N} \sum_{k=1}^{N} \Delta W^{(l), k} \end{align*} $$In der Praxis durchlaufen wir alle Trainingsbeispiele $1,\ldots , N$, addieren wir alle Deltamatrizen auf und teilen das Ergebnis durch $N$:
$$ D^{(l)} = \frac{1}{N} \sum_{k=1}^{N} \Delta W^{(l), k} $$Dann verwenden wir $D$, um die Gewichte anzupassen:
$$ W^{(l)} := W^{(l)} + \alpha \: D^{(l)} $$Hier nochmal alle relevanten Formeln in Vektorschreibweise.
Für die Vorwärtsverarbeitung berechnen wir schichtweise den Rohinput $z$ und die Aktivierung $a$.
$$ \begin{align} \label{fp1}\tag{Z} z^{(l)} &= W^{(l)} \; a^{(l-1)} + b^{(l)} \\[3mm] \label{fp2}\tag{A} a^{(l)} &= g(z^{(l)}) \end{align} $$Für die Rückwärtsverarbeitung berechnen wir rückwärts-schichtweise die Fehlerterme $\delta$ und die Updates für Gewichtsmatrizen $\Delta W$ und die Updates für die Bias-Vektoren $\Delta b$.
$$ \begin{align} \label{bp1}\tag{D'}\delta^{(l)} & = \begin{cases} \hat{y} - y & \mbox{wenn}\quad l = L\\[3mm] (W^{(l+1)})^T \; \delta^{l+1} \odot a^{(l)} \odot (1 - a^{(l)})& \mbox{wenn}\quad l \in \{1, \ldots, L-1\} \end{cases}\\[5mm] \label{bp2}\tag{DelW} \Delta W^{(l)} & = - \delta^{(l)} \: (a^{(l-1)})^T \\[3mm] \label{bp3}\tag{DelB} \Delta b^{(l)} & = - \delta^{(l)} \end{align} $$Nochmal zusammengefasst der Algorithmus für $N$ Trainingsbeispiele $(x^k, y^k)$:
Für jede Epoche:
Hinweis: Bei einer Implementation würde man natürlich nicht pro Trainingsbeispiel eine Sammelmatrix $\Delta W^{(l)}$ verwalten, sondern die Werte direkt in $D^{(l)}$ speichern und später mitteln.
Die obige Variante ist klassische Batch-Training. Bei Mini-Batch würde man in Schritt 2 nicht alle $N$ Beispiele durchlaufen, sondern nur einen Teil davon, und dann Schritte 3+4 (Updates) durchführen. Die Epoche ist dann aber noch nicht zu Ende. Erst würde man alle Mini-Batches durchlaufen (und jeweils die Gewichte anpassen).
Wir definieren Mini-Batch als Algorithmus. Die Trainingsdaten $T$ seien aufgeteilt auf $M$ Teilmengen $(T_1, \ldots, T_M)$:
Für jede Epoche:
Siehe auch die Videos von Andrew Ng: Mini Batch Gradient Descent und Understanding Mini-Batch Gradient Descent
In der Regel stellt man sich die Verarbeitung so vor, dass man zunächst das erste Trainingsbeispiel verwendet, um eine komplette Forward Propagation zu berechnen und dann das nächste Beispiel usw. Es stellt sich aber heraus, dass es deutlich effizienter ist, erst für alle Trainingsbeispiele die Ausgabe der ersten Schicht zu berechnen, dann für alle Beispiele die zweite Schicht usw.
(Wenn wir hier von "alle Trainingsbeispiele" die Rede ist, dann gilt das analog auch für eine Teilmenge, also einen "Batch".)
Seien $(x^k, y^k)$ die Trainingsbeispiele, wobei $k \in \{1,\ldots, N\}$.
Schauen wir uns das Beispielnetz an:
Betrachten wir den ersten Verarbeitungsschritt von von der Eingabe zu Schicht $1$ (ohne Bias):
$$ z^{(1)} = W^{(1)} \; x $$$ W^{(1)} $ ist eine $3\times 2$ Matrix und $x$ ist eine $2\times 1$ Matrix, so dass das Ergebnis $z^{(1)}$ eine $3\times 1$ Matrix ist. Wir lassen hier den Index für das Trainingsbeispiel weg:
$$ \label{ein_sample}\tag{a} \begin{pmatrix} w_{1,1}^{(1)} & w_{1,2}^{(1)} \\ w_{2,1}^{(1)} & w_{2,2}^{(1)} \\ w_{3,1}^{(1)} & w_{3,2}^{(1)} \end{pmatrix} \left( \begin{array}{c} x_1 \\ x_2 \end{array} \right) = \left( \begin{array}{c} z_1^{(1)} \\ z_2^{(1)} \\ z_3^{(1)} \end{array} \right) $$Es ist jetzt egal, ob wir für dieses Trainingsbeispiel die nächste Schicht berechnen (was auch der Netzoutput wäre) oder ob wir für das zweite Trainingsbeispiel ebenfalls die erste Schicht berechnen. Wir müssen natürlich sicherstellen, dass die Ergebnisse der zweiten Schicht $z^{(1)}$ separat gespeichert werden.
Ähnlich wie beim Adaline-Netz in Kapitel 3 bilden wir aus den Eingabevektoren aller $N$ Trainingsbespiele eine $2\times N$ Matrix $X$. Das heißt, jedes Trainingsbeispiel bekommt eine Spalte in der Matrix $X$.
Wir müssen hier einen Index für die Trainingsbeispiele (von $1$ bis $N$) hinzufügen. Zur Erinnerung: der obere Index ohne Klammer gibt die Nummer des Trainingsbeispiels an. Sie sehen gut, dass die Trainingsbeispiele in Spalten nebeneinander stehen:
$$ X = \begin{pmatrix} x_1^{1} & \ldots & x_1^{N} \\ x_2^{1} & \ldots & x_2^{N} \end{pmatrix} $$Wir schauen uns die Rechnung von oben an, wobei wir das einzelne Trainingsbeispiel $x$ durch die Matrix $X$ mit allen Trainingsbeispielen ersetzen (zur Erinnerung: der hochgestellte Index mit Klammern gibt die Schicht an):
$$ W^{(1)} \; X = \begin{pmatrix} w_{1,1}^{(1)} & w_{1,2}^{(1)} \\ w_{2,1}^{(1)} & w_{2,2}^{(1)} \\ w_{3,1}^{(1)} & w_{3,2}^{(1)} \end{pmatrix} \begin{pmatrix} x_1^{1} & \ldots & x_1^{N} \\ x_2^{1} & \ldots & x_2^{N} \end{pmatrix} = \begin{pmatrix} z_1^{(1), 1} & \ldots & z_1^{(1), N} \\ z_2^{(1), 1} & \ldots & z_2^{(1), N} \\ z_3^{(1), 1} & \ldots & z_3^{(1), N} \end{pmatrix} = Z^{(1)} $$Vergleichen Sie diese Rechnung mit der Rechung (\ref{ein_sample}). Was Sie hier sehen ist, dass wir eine Matrix $Z^{(1)}$ erhalten, die die Roheingaben aller $N$ Trainingsbeispiele für Schicht $1$ erhält, die sich aus der "gleichzeitigen" Verarbeitung aller Trainingsvektoren $x^1, \ldots , x^N$ ergibt. Und das alles mit einer einzigen Matrixmultiplikation. Es ist, als hätten wir mit $N$ Netzen gleichzeitig die $N$ Trainingseingaben verrechnet. Man beachte aber, dass dies nur zulässig ist, wenn bei allen Berechnungen die gleichen Gewichte gelten.
In gleicher Weise kann man die weiteren Schichten verarbeiten (nicht vergessen, die Aktivierungsfunktion $f$ noch auf alle Elemente von $Z^{(1)}$ anzuwenden, dann weiter zur Schicht $2$), um schließlich die Gesamtausgabe in einer Matrix $\hat{Y}$ zu erhalten. Die Spalten von $\hat{Y}$ entsprechen den Ausgabevektoren $\hat{y}^1,\ldots , \hat{y}^N$ für alle $N$ Trainingsbeispiele.
Wir haben oben den Bias-Gewichtsvektor ignoriert. Bei einem Trainingsbeispiel addieren wir eigentlich noch Vektor $b$:
$$ \begin{pmatrix} w_{1,1}^{(1)} & w_{1,2}^{(1)} \\ w_{2,1}^{(1)} & w_{2,2}^{(1)} \\ w_{3,1}^{(1)} & w_{3,2}^{(1)} \end{pmatrix} \left( \begin{array}{c} x_1 \\ x_2 \end{array} \right) + \left( \begin{array}{c} b_1^1 \\ b_2^1 \\ b_3^1 \end{array} \right) = \left( \begin{array}{c} z_1^{(1)} \\ z_2^{(1)} \\ z_3^{(1)} \end{array} \right) $$Wenn wir die Eingabevektoren zur Matrix $X$ zusammenfassen, haben wir folgende Rechnung ohne Bias:
$$ \begin{pmatrix} w_{1,1}^{(1)} & w_{1,2}^{(1)} \\ w_{2,1}^{(1)} & w_{2,2}^{(1)} \\ w_{3,1}^{(1)} & w_{3,2}^{(1)} \end{pmatrix} \begin{pmatrix} x_1^{1} & \ldots & x_1^{N} \\ x_2^{1} & \ldots & x_2^{N} \end{pmatrix} = \begin{pmatrix} z_1^{(1), 1} & \ldots & z_1^{(1), N} \\ z_2^{(1), 1} & \ldots & z_2^{(1), N} \\ z_3^{(1), 1} & \ldots & z_3^{(1), N} \end{pmatrix} $$Die Ergebnismatrix hat $N$ Spalten, eben eine Spalte für jedes Trainingsbeispiel. Man kann hier keinen Vektor addieren. Um den Bias hinzuzufügen, müsste man eigentlich eine Matrix $B$ addieren, wo der Bias-Gewichtsvektor $N$-mal kopiert in $N$ Spalten vorliegt:
$$ \begin{pmatrix} w_{1,1}^{(1)} & w_{1,2}^{(1)} \\ w_{2,1}^{(1)} & w_{2,2}^{(1)} \\ w_{3,1}^{(1)} & w_{3,2}^{(1)} \end{pmatrix} \begin{pmatrix} x_1^{1} & \ldots & x_1^{N} \\ x_2^{1} & \ldots & x_2^{N} \end{pmatrix} + \begin{pmatrix} b_1^1 & \ldots & b_1^1 \\ b_2^1 & \ldots & b_2^1 \\ b_3^1 & \ldots & b_3^1 \end{pmatrix} = \begin{pmatrix} z_1^{(1), 1} & \ldots & z_1^{(1), N} \\ z_2^{(1), 1} & \ldots & z_2^{(1), N} \\ z_3^{(1), 1} & \ldots & z_3^{(1), N} \end{pmatrix} $$Technisch gesehen gibt es zwei Möglichkeiten, das zu tun, ohne tatsächlich die Matrix $B$ zu konstruieren (was evtl. wertvolle Rechenzeit kostet).
Option 1: Man erweitert die Matrizen $W$ und $X$ wie folgt:
$$ \begin{pmatrix} w_{1,1}^{(1)} & w_{1,2}^{(1)} & b_1 \\ w_{2,1}^{(1)} & w_{2,2}^{(1)} & b_2 \\ w_{3,1}^{(1)} & w_{3,2}^{(1)} & b_3 \end{pmatrix} \begin{pmatrix} x_1^{1} & \ldots & x_1^{N} \\ x_2^{1} & \ldots & x_2^{N} \\ 1 & \ldots & 1 & \end{pmatrix} = \begin{pmatrix} z_1^{(1), 1} & \ldots & z_1^{(1), N} \\ z_2^{(1), 1} & \ldots & z_2^{(1), N} \\ z_3^{(1), 1} & \ldots & z_3^{(1), N} \end{pmatrix} $$Option 2: Man nutzt das Broadcasting in NumPy, so dass Vektor $b$ auf korrekte Weise $N$-mal kopiert wird bei der Addition:
$$ \begin{pmatrix} w_{1,1}^{(1)} & w_{1,2}^{(1)} \\ w_{2,1}^{(1)} & w_{2,2}^{(1)} \\ w_{3,1}^{(1)} & w_{3,2}^{(1)} \end{pmatrix} \begin{pmatrix} x_1^{1} & \ldots & x_1^{N} \\ x_2^{1} & \ldots & x_2^{N} \end{pmatrix} \oplus \left( \begin{array}{c} b_1 \\ b_2 \\ b_3 \end{array} \right) = \begin{pmatrix} z_1^{(1), 1} & \ldots & z_1^{(1), N} \\ z_2^{(1), 1} & \ldots & z_2^{(1), N} \\ z_3^{(1), 1} & \ldots & z_3^{(1), N} \end{pmatrix} $$Wir haben das Broadcasting mit dem Symbol $\oplus$ angedeutet. Die Bedeutung ist, dass der Vektor $b$ auf jede Spalte der links stehenden Matrix addiert wird.
Im letzten Kapitel haben wir Keras kennen gelernt und sehr einfach Netze erstellt, die lediglich Eingabe- und Ausgabeschicht hatten. Jetzt sehen wir uns an, wie man FNN mit Zwischenschichten erstellt und trainiert.
Bevor man ein NN mit fit trainiert, legt man mit compile die Loss-Funktion fest, z.B.
model.compile(loss='categorical_crossenropy', optimizer='adam')
Die Loss-Funktion ist in der Regel eine von diesen (die Begiffe sind mit der Keras-Dokumentation verlinkt):
Hier nochmal eine kurze Erklärung, wann Sie welche nehmen:
Sie verwenden binary_crossentropy für binäre Klassifikation, wo Ihre Labels als Liste mit Nullen und Einsen vorliegen.
Auf categorical_crossentropy greifen Sie bei der Klassifikation mit multiplen Klassen zurück (Beispiel MNIST mit Klassen 0 bis 9). Hier müssen die Labels in One-Hot-Encoding vorliegen.
Die sparse_categorical_crossentropy verwenden Sie ebenfalls bei der Klassifikation mit multiplen Klassen, aber hier liegen die Labels als Integer-Werte vor, also z.B. [1, 5, 3, 0] etc. Achten Sie darauf, dass die Indizes der Klassen bei 0 beginnen.
Wir ziehen den Datensatz MNIST hinzu, bei dem es um die Erkennung von handgeschriebenen Ziffern (0..9) geht. Wir haben diese Daten bereits in Kapitel 2 genutzt. Der Datensatz wird sowohl mit der Scikit-learn-Bibliothek als auch mit der Keras-Bibliothek ausgeliefert. Wir verwenden hier die Keras-Variante.
Siehe: https://keras.io/datasets/#mnist-database-of-handwritten-digits
Eine Einführung in die MNIST-Daten in Keras finden Sie im folgenden Video.
Die Daten kommen bereits separiert in Trainings- und Testdaten.
from tensorflow.keras.datasets import mnist
(x_train, y_train), (x_test, y_test) = mnist.load_data()
Wir sehen uns die Struktur an. Die Bilder sind als 28x28-Matrizen hinterlegt.
print('x_train: ', x_train.shape)
print('y_train: ', y_train.shape)
print('x_test: ', x_test.shape)
print('y_test: ', y_test.shape)
x_train: (60000, 28, 28) y_train: (60000,) x_test: (10000, 28, 28) y_test: (10000,)
Die Funktion imshow (image show) erlaubt uns das schnelle Betrachten der Bilder. Wir wählen als color map (cmap) Graustufen.
Erstes Trainingsbild:
plt.imshow(x_train[0], cmap='gray')
<matplotlib.image.AxesImage at 0x7fec5801f4f0>
Zehntes Trainingsbild:
plt.imshow(x_train[9], cmap='gray')
<matplotlib.image.AxesImage at 0x7fecab3bf1c0>
Hier geben wir von jeder Kategorie das erste Trainingsbeispiel aus:
fig, ax = plt.subplots(nrows=2, ncols=5, sharex=True, sharey=True,)
ax = ax.flatten()
for i in range(10):
img = x_train[y_train == i][0]
ax[i].imshow(img, cmap='gray')
ax[0].set_xticks([]) # keine Achsenmarkierungen
ax[0].set_yticks([])
plt.tight_layout()
plt.show()
Für unsere Zwecke möchten wir die Bildmatrizen linearisieren zu Vektoren der Länge 784 (= 28*28). Dazu verwenden wir reshape.
Außerdem möchten wir die Pixelwerte (0..255) auf das Interval [0, 1] normalisieren. Das erreichen wir, indem wir alle Werte durch 255.0 teilen.
x_train = x_train.reshape(60000, 784)/255.0
x_test = x_test.reshape(10000, 784)/255.0
Kleiner Check, ob die Linearisierung geklappt hat:
print('x_train: ', x_train.shape)
print('x_test: ', x_test.shape)
x_train: (60000, 784) x_test: (10000, 784)
Um die zehn Ziffern 0, \ldots, 9 in der Ausgabe zu kodieren, benutzen wir One-Hot Encoding, d.h. jede Ziffer wird durch einen Vektor der Länge 10 repräsentiert:
$$ \left( \begin{array}{c} 1 \\0 \\ \vdots \\ 0\end{array} \right) \quad \left( \begin{array}{c} 0 \\1 \\ \vdots \\ 0\end{array} \right) \quad \ldots\quad \left( \begin{array}{c} 0 \\0 \\ \vdots \\ 1\end{array} \right) $$In Keras gibt es dafür die Funktion to_categorical
. Wir schreiben das Ergebnis in neue Variablen.
from tensorflow.keras.utils import to_categorical
y_train_1hot = to_categorical(y_train, 10)
y_test_1hot = to_categorical(y_test, 10)
Wir prüfen, ob die Label-Daten die richtige Form haben.
print("y_train: ", y_train_1hot.shape)
print("y_test: ", y_test_1hot.shape)
y_train: (60000, 10) y_test: (10000, 10)
Das passt: Es sind Vektoren der Länge 10.
Wir bauen jetzt unser Modell mit einer versteckten Schicht mit 60 Neuronen und wenden beim Lernen sogenanntes Batchtraining an, d.h. wir verarbeiten alle Trainingsbeispiele, bevor wir ein Update durchführen.
Das Netz hat eine Eingabeschicht mit 784 Neuronen und dann zwei parametrisierte Schichten:
Schematisch sieht das so aus (ohne Bias-Neuronen bzw. -Vektor):
In Keras erstellen wir das Netz als Instanz der Klasse Sequential
.
Wir fügen dem Objekt zwei Schichten vom Typ Dense
(fully connected) hinzu. Die Eingabeschicht ist implizit und wird über den Parameter input_shape
quasi mitgeneriert. Wir wählen die Sigmoid-Funktion für die Aktivierung der versteckten Schicht. Bei der Ausgabeschicht geben wir an, dass wir die Softmax-Funktion anwenden möchten.
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense
model = Sequential()
model.add(Dense(60,
input_shape=(784,),
activation='sigmoid'))
model.add(Dense(10,
activation='softmax'))
2023-05-05 14:11:34.866287: I tensorflow/core/platform/cpu_feature_guard.cc:193] This TensorFlow binary is optimized with oneAPI Deep Neural Network Library (oneDNN) to use the following CPU instructions in performance-critical operations: SSE4.1 SSE4.2 To enable them in other operations, rebuild TensorFlow with the appropriate compiler flags.
Bei input_shape
muss ein Tupel übergeben werden, daher die Klammern und das Komma in der Klammer. Wenn der Input ein einfacher Vektor ist (und keine Matrix oder Tensor), dann kann man auch input_dim = 784
schreiben.
Eine weitere Alternative ist es, in einer ersten Zeile folgendes zu schreiben:
model = Sequential()
model.add(Input(shape=(784,))
model.add(Dense(20,
activation='sigmoid'))
model.add(Dense(10,
activation='softmax'))
In der obigen Variante wird die Eingabe quasi als virtuelle Schicht behandelt.
Wie viele Parameter hat das Netz? Die Gewichtsmatrix $W^{(1)}$ von Input- zu versteckter Schicht hat 784x20 Einträge:
784*20
15680
Es kommen 20 Gewichte für das Bias-Neuron dazu. Insgesamt also 15700 Parameter. Die Gewichtsmatrix $W^{(2)}$ von versteckter zu Ausgabeschicht enthält 10x20 = 200 Einträge plus den 10 Gewichten für das Bias-Neuron zur Ausgabe, macht 210 Parameter. Alles in allem also 15910 Parameter.
Wir schauen uns das Netz in der Kurzübersicht mit Hilfe der Methode summary
an. Dort steht auch nochmal die Anzahl der Parameter:
model.summary()
Model: "sequential" _________________________________________________________________ Layer (type) Output Shape Param # ================================================================= dense (Dense) (None, 60) 47100 dense_1 (Dense) (None, 10) 610 ================================================================= Total params: 47,710 Trainable params: 47,710 Non-trainable params: 0 _________________________________________________________________
Einen etwas technischeren Blick auf Ihr Modell mit allen Schichten erhalten Sie mit get_config
. Hier sehen Sie zum Beispiel verschiedene Einstellungen in den Schichten, z.B. zur Initialisierung der Gewichte.
model.get_config()
{'name': 'sequential', 'layers': [{'class_name': 'InputLayer', 'config': {'batch_input_shape': (None, 784), 'dtype': 'float32', 'sparse': False, 'ragged': False, 'name': 'dense_input'}}, {'class_name': 'Dense', 'config': {'name': 'dense', 'trainable': True, 'batch_input_shape': (None, 784), 'dtype': 'float32', 'units': 60, 'activation': 'sigmoid', 'use_bias': True, 'kernel_initializer': {'class_name': 'GlorotUniform', 'config': {'seed': None}}, 'bias_initializer': {'class_name': 'Zeros', 'config': {}}, 'kernel_regularizer': None, 'bias_regularizer': None, 'activity_regularizer': None, 'kernel_constraint': None, 'bias_constraint': None}}, {'class_name': 'Dense', 'config': {'name': 'dense_1', 'trainable': True, 'dtype': 'float32', 'units': 10, 'activation': 'softmax', 'use_bias': True, 'kernel_initializer': {'class_name': 'GlorotUniform', 'config': {'seed': None}}, 'bias_initializer': {'class_name': 'Zeros', 'config': {}}, 'kernel_regularizer': None, 'bias_regularizer': None, 'activity_regularizer': None, 'kernel_constraint': None, 'bias_constraint': None}}]}
Mit get_weights
können wir auf die Parameter des Netzwerks zugreifen. Für jede Schicht gibt es jeweils
Wir lassen uns hier in einer Schleife jeweils die Dimensionen der Gewichtsmatrix und des Bias-Vektor für unsere zwei Schichten (Hidden + Output) ausgeben:
weights = model.get_weights()
for w in weights:
print(w.shape)
(784, 60) (60,) (60, 10) (10,)
Wir sehen hier, dass die Gewichtsmatrix $W^{(1)}$ von der Eingabeschicht zur versteckten Schicht die Dimensionen 784x20 hat. In unserer theoretischen Behandlung haben wir eine 20x784-Matrix verwendet, also die transponierte Version. Beide Varianten sind möglich und üblich.
Der Bias-Vektor von Input zu Hidden hat eine Länge von 20. Auch das ist plausibel, wenn Sie sich in der Abbildung oben ein Bias-Neuron in der Inputschicht vorstellen, dass mit allen 20 versteckten Neuronen verbunden ist. Dies muss offensichtlich 20 Gewichte haben.
Beim Training wollen wir die ursprüngliche Variante des Backpropagation verwenden, wo alle Trainingsbeispiele vor einem Update durchlaufen werden. Wir nennen diese Variante Batchtraining (nicht zu verwechseln mit "Minibatch", siehe unten).
In Keras spezifizieren wir dazu Stochastic Gradient Descent, kurz SGD, als Optimierungsmethode und geben als Batchgröße die Anzahl der Trainingsdaten an. Als Zielfunktion wählen wir wie im Theorieteil die Cross-Entropy-Funktion.
Damit wir die Lernrate einstellen können, erstellen wir für die Optimierungsmethode ein eigenes SGD-Objekt (stochastic gradient descent). Wir sehen uns mit get_config
die Voreinstellungen an.
from tensorflow.keras.optimizers import SGD
opt = SGD()
opt.get_config()
{'name': 'SGD', 'learning_rate': 0.01, 'decay': 0.0, 'momentum': 0.0, 'nesterov': False}
Die Standardeinstellung für die Lernrate ist also 0.01. Wir erstellen ein neues Objekt mit unserer gewünschten Lernrate:
opt = SGD(learning_rate = 0.1)
opt.get_config()
{'name': 'SGD', 'learning_rate': 0.1, 'decay': 0.0, 'momentum': 0.0, 'nesterov': False}
Mit compile
konfigurieren wir das Training zunächst nur und übergeben zum Beispiel die Optimiermethode als Objekt. Alternativ kann man als Parameter für optimizer
den String sgd
übergeben, dann werden für Lernrate etc. die Standardeinstellungen gewählt. Als Metrik für unsere Messungen wählen wir Accuracy, kurz acc
.
model.compile(optimizer=opt,
loss='categorical_crossentropy',
metrics=['acc'])
Das eigentliche Training wird mit fit
durchgeführt. Wichtig ist, dass fit
den Verlauf der Metriken pro Epoche in einem History-Objekt zurückgibt. Will man die Fehler- und Performance-Entwicklung später visualisieren, muss man dieses Objekt speichern.
Die Methode hat außer den Daten und Epochen noch ein paar Parameter:
Um Batchtraining zu realisieren, wählen wir eine Batchgröße von 60000, und bilden damit das originale Backpropagation nach, wo in einer Epoche alle Trainingsbeispiele verarbeitet werden, bevor ein Update der Gewichte durchgeführt wird.
Wir unterdrücken die Ausgabe (verbose=0), messen aber die Trainingsdauer mit Hilfe der Funktion time aus dem gleichnamigen Paket (gibt die Sekunden seit 1.1.1970 0:00 Uhr zurück).
start_time = time.time() # Wir merken uns die Startzeit
history1 = model.fit(x_train, y_train_1hot,
epochs=20,
batch_size=60000,
verbose=1,
validation_data = (x_test, y_test_1hot))
duration = time.time() - start_time # Wir berechnen die Dauer in Sek.
print(f'TRAININGSDAUER: {duration:.2f} Sek.')
Epoch 1/20 1/1 [==============================] - 1s 614ms/step - loss: 2.4342 - acc: 0.0987 - val_loss: 2.3790 - val_acc: 0.0949 Epoch 2/20 1/1 [==============================] - 0s 97ms/step - loss: 2.3776 - acc: 0.0990 - val_loss: 2.3417 - val_acc: 0.0958 Epoch 3/20 1/1 [==============================] - 0s 100ms/step - loss: 2.3406 - acc: 0.1012 - val_loss: 2.3147 - val_acc: 0.1092 Epoch 4/20 1/1 [==============================] - 0s 97ms/step - loss: 2.3137 - acc: 0.1126 - val_loss: 2.2937 - val_acc: 0.1443 Epoch 5/20 1/1 [==============================] - 0s 98ms/step - loss: 2.2929 - acc: 0.1472 - val_loss: 2.2764 - val_acc: 0.1681 Epoch 6/20 1/1 [==============================] - 0s 109ms/step - loss: 2.2758 - acc: 0.1683 - val_loss: 2.2615 - val_acc: 0.1838 Epoch 7/20 1/1 [==============================] - 0s 95ms/step - loss: 2.2610 - acc: 0.1867 - val_loss: 2.2481 - val_acc: 0.2072 Epoch 8/20 1/1 [==============================] - 0s 94ms/step - loss: 2.2477 - acc: 0.2101 - val_loss: 2.2357 - val_acc: 0.2307 Epoch 9/20 1/1 [==============================] - 0s 91ms/step - loss: 2.2355 - acc: 0.2340 - val_loss: 2.2240 - val_acc: 0.2607 Epoch 10/20 1/1 [==============================] - 0s 97ms/step - loss: 2.2240 - acc: 0.2587 - val_loss: 2.2127 - val_acc: 0.2866 Epoch 11/20 1/1 [==============================] - 0s 119ms/step - loss: 2.2129 - acc: 0.2831 - val_loss: 2.2018 - val_acc: 0.3136 Epoch 12/20 1/1 [==============================] - 0s 91ms/step - loss: 2.2021 - acc: 0.3090 - val_loss: 2.1910 - val_acc: 0.3384 Epoch 13/20 1/1 [==============================] - 0s 96ms/step - loss: 2.1915 - acc: 0.3336 - val_loss: 2.1804 - val_acc: 0.3603 Epoch 14/20 1/1 [==============================] - 0s 91ms/step - loss: 2.1811 - acc: 0.3543 - val_loss: 2.1700 - val_acc: 0.3803 Epoch 15/20 1/1 [==============================] - 0s 91ms/step - loss: 2.1708 - acc: 0.3747 - val_loss: 2.1596 - val_acc: 0.3975 Epoch 16/20 1/1 [==============================] - 0s 91ms/step - loss: 2.1606 - acc: 0.3934 - val_loss: 2.1493 - val_acc: 0.4160 Epoch 17/20 1/1 [==============================] - 0s 97ms/step - loss: 2.1505 - acc: 0.4106 - val_loss: 2.1391 - val_acc: 0.4310 Epoch 18/20 1/1 [==============================] - 0s 90ms/step - loss: 2.1405 - acc: 0.4264 - val_loss: 2.1289 - val_acc: 0.4462 Epoch 19/20 1/1 [==============================] - 0s 101ms/step - loss: 2.1305 - acc: 0.4406 - val_loss: 2.1188 - val_acc: 0.4582 Epoch 20/20 1/1 [==============================] - 0s 94ms/step - loss: 2.1206 - acc: 0.4536 - val_loss: 2.1087 - val_acc: 0.4702 TRAININGSDAUER: 2.59 Sek.
Die Trainingsdauer im Batchtraining ist mit 3 Sekunden für 20 Epochen extrem kurz.
Wir sehen uns die Entwicklung von Zielfunktion (Loss) und Accuracy an.
Dazu definieren wir vorab eine Hilfsfunktion.
def set_subplot(ax, y_label, traindata, testdata, ylim):
e_range = range(1, len(traindata) + 1)
ax.plot(e_range, traindata, 'b', label='Training')
ax.plot(e_range, testdata, 'g', label='Test')
ax.set_xlabel('Epochen')
ax.set_ylabel(y_label)
ax.legend()
ax.grid()
ax.set_ylim(ylim)
ax.set_title(y_label)
Achten Sie bei den Plots immer auf die Grenzen der y-Achse (im Code set_ylim). Diese werden immer so gewählt, dass man die Kurve möglichst gut sieht. Hier haben wir das Interval 0 bis 0.6 für die Accuracy. Weiter unten haben wir ein anderes Interval. Dies muss man beim Vergleich der Kurven natürlich beachten.
fig, ax = plt.subplots(nrows=1, ncols=2, figsize=(15,4))
set_subplot(ax[0], 'Loss', history1.history['loss'],
history1.history['val_loss'], [0, 3])
set_subplot(ax[1], 'Accuracy', history1.history['acc'],
history1.history['val_acc'], [0, 0.6])
plt.show()
Mit evaluate können wir die Performanz unseres Modells auf den Testdaten messen. Wir bekommen ein Tupel mit Fehler und Accuracy zurück.
loss, acc = model.evaluate(x_test, y_test_1hot)
print(f"Evaluation auf den Testdaten:\n\nLoss = {loss:.3f}\nAccuracy = {acc:.3f}")
313/313 [==============================] - 0s 615us/step - loss: 2.1087 - acc: 0.4702 Evaluation auf den Testdaten: Loss = 2.109 Accuracy = 0.470
Mit Batchtraining erhalten wir nach 20 Epochen Training eine enttäuschende Accuracy von 47% auf den Testdaten.
Mit Batchtraining (Batchgröße = 60000) haben wir ein enttäuschendes Ergebnis erzielt. Wir sehen uns jetzt das reine SGD und eine Variante von Minibatch-Training an.
Wenn wir die Batchgröße auf 1 setzen, erreichen wir "reines" Stochastic Gradient Descent, wo die Gewichte nach jedem Trainingsbeispiel angepasst werden.
Wir erzeugen eine neue Instanz des Netzwerks mit den gleichen Eigenschaften (20 versteckte Neuronen, gleiche Aktivierungsfunktionen). Anschließend trainieren wir es unter gleichen Bedingungen. Der einzige Unterschied ist die Batchgröße, die wir hier auf 1 setzen.
model = Sequential()
model.add(Dense(60,
input_shape=(784,),
activation='sigmoid'))
model.add(Dense(10, activation='softmax'))
opt = SGD(learning_rate = 0.1)
model.compile(optimizer=opt,
loss='categorical_crossentropy',
metrics=['acc'])
start_time = time.time()
history2 = model.fit(x_train, y_train_1hot,
epochs=20,
batch_size=1,
verbose=1,
validation_data = (x_test, y_test_1hot))
duration = time.time() - start_time
print(f'TRAININGSDAUER: {duration:.2f} Sek.')
Epoch 1/20 60000/60000 [==============================] - 36s 602us/step - loss: 0.2395 - acc: 0.9269 - val_loss: 0.1508 - val_acc: 0.9524 Epoch 2/20 60000/60000 [==============================] - 35s 581us/step - loss: 0.1318 - acc: 0.9598 - val_loss: 0.1363 - val_acc: 0.9567 Epoch 3/20 60000/60000 [==============================] - 34s 570us/step - loss: 0.1099 - acc: 0.9666 - val_loss: 0.1232 - val_acc: 0.9634 Epoch 4/20 60000/60000 [==============================] - 34s 566us/step - loss: 0.0919 - acc: 0.9717 - val_loss: 0.1173 - val_acc: 0.9633 Epoch 5/20 60000/60000 [==============================] - 35s 582us/step - loss: 0.0812 - acc: 0.9736 - val_loss: 0.1179 - val_acc: 0.9650 Epoch 6/20 60000/60000 [==============================] - 35s 579us/step - loss: 0.0740 - acc: 0.9761 - val_loss: 0.1185 - val_acc: 0.9660 Epoch 7/20 60000/60000 [==============================] - 35s 577us/step - loss: 0.0683 - acc: 0.9776 - val_loss: 0.1151 - val_acc: 0.9654 Epoch 8/20 60000/60000 [==============================] - 35s 575us/step - loss: 0.0616 - acc: 0.9796 - val_loss: 0.1141 - val_acc: 0.9660 Epoch 9/20 60000/60000 [==============================] - 34s 564us/step - loss: 0.0543 - acc: 0.9826 - val_loss: 0.1167 - val_acc: 0.9692 Epoch 10/20 60000/60000 [==============================] - 34s 569us/step - loss: 0.0483 - acc: 0.9845 - val_loss: 0.1213 - val_acc: 0.9684 Epoch 11/20 60000/60000 [==============================] - 34s 569us/step - loss: 0.0500 - acc: 0.9833 - val_loss: 0.1059 - val_acc: 0.9723 Epoch 12/20 60000/60000 [==============================] - 34s 563us/step - loss: 0.0437 - acc: 0.9858 - val_loss: 0.1126 - val_acc: 0.9714 Epoch 13/20 60000/60000 [==============================] - 35s 576us/step - loss: 0.0383 - acc: 0.9866 - val_loss: 0.1089 - val_acc: 0.9714 Epoch 14/20 60000/60000 [==============================] - 34s 564us/step - loss: 0.0359 - acc: 0.9884 - val_loss: 0.1194 - val_acc: 0.9720 Epoch 15/20 60000/60000 [==============================] - 34s 562us/step - loss: 0.0301 - acc: 0.9900 - val_loss: 0.1197 - val_acc: 0.9708 Epoch 16/20 60000/60000 [==============================] - 35s 590us/step - loss: 0.0337 - acc: 0.9887 - val_loss: 0.1091 - val_acc: 0.9724 Epoch 17/20 60000/60000 [==============================] - 33s 556us/step - loss: 0.0287 - acc: 0.9903 - val_loss: 0.1216 - val_acc: 0.9719 Epoch 18/20 60000/60000 [==============================] - 33s 555us/step - loss: 0.0246 - acc: 0.9922 - val_loss: 0.1297 - val_acc: 0.9688 Epoch 19/20 60000/60000 [==============================] - 33s 556us/step - loss: 0.0214 - acc: 0.9928 - val_loss: 0.1203 - val_acc: 0.9732 Epoch 20/20 60000/60000 [==============================] - 34s 559us/step - loss: 0.0218 - acc: 0.9927 - val_loss: 0.1232 - val_acc: 0.9717 TRAININGSDAUER: 685.25 Sek.
Wir sehen, dass das Training mit reinem SGD mit ca. 11 Minuten deutlich länger dauert als beim Batchtraining mit seinen 2 Sekunden (etwa um Faktor 300) - in beiden Fällen für 20 Epochen.
Das liegt daran, dass hier in jeder Epoche 60000 Mal sämtliche Gewichte angepasst werden, wohingegen vorher nur ein einziges Update pro Epoche stattfand.
Jetzt schauen wir uns die Performance an. Achten Sie auf den Wertebereich, der bei der Accuracy-Kurve zwischhen 0.9 und 1.0 liegt.
fig, ax = plt.subplots(nrows=1, ncols=2, figsize=(15,4))
set_subplot(ax[0], 'Loss', history2.history['loss'],
history2.history['val_loss'], [0, .5])
set_subplot(ax[1], 'Accuracy', history2.history['acc'],
history2.history['val_acc'], [0.9, 1])
plt.show()
Wir sehen einen typischen Verlauf der Accuracy: Auf den Trainingsdaten steigt die Kurve immer weiter an, aber auf den Testdaten bleibt die Kurve in einem bestimmten Bereich. Sobald die Kurve auf den Testdaten sich einpendelt, kann man das Training beenden, weil das Training anschließend Overfitting betreibt.
Wir schauen uns jetzt noch einmal den finalen Accuracy-Wert auf den Testdaten an:
loss, acc = model.evaluate(x_test, y_test_1hot)
print(f"Evaluation auf den Testdaten:\n\nLoss = {loss:.3f}\nAccuracy = {acc:.3f}")
313/313 [==============================] - 0s 597us/step - loss: 0.1232 - acc: 0.9717 Evaluation auf den Testdaten: Loss = 0.123 Accuracy = 0.972
Mit 97% Accuracy auf den Testdaten erreicht das mit reinem SGD trainierte Netz einen deutlich besseren Wert als das vorigen Netz, dass wir mit Batchtraining trainiert haben.
Nachdem wir Batchtraining (alle Trainingsbeispiele pro Update) und reines SGD (Batchgröße = 1) ausprobiert haben, setzen wir jetzt die eigentliche Idee von Minibatch um, dass eine bestimmte Anzahl von (zufällig ausgewählten) Trainingsdaten durchlaufen wird, bevor dann ein Update durchgeführt wird. Es handelt sich also um einen Kompromiss zwischen Batchtraining und reinem SGD.
Im Keras ist bei der Methode SGD eine Batchgröße von 32 standardmäßig eingestellt. Diese probieren wir jetzt aus. Den Parameter batch_size lassen wir weg, damit die Standardgröße von 32 genommen wird. Alles andere bleibt wie in den beiden anderen Versuchen gleich.
model = Sequential()
model.add(Dense(60,
input_shape=(784,),
activation='sigmoid'))
model.add(Dense(10, activation='softmax'))
opt = SGD(learning_rate = 0.1)
model.compile(optimizer=opt,
loss='categorical_crossentropy',
metrics=['acc'])
start_time = time.time()
history3 = model.fit(x_train, y_train_1hot,
epochs=20,
verbose=1,
validation_data = (x_test, y_test_1hot))
duration = time.time() - start_time
print(f'TRAININGSDAUER: {duration:.2f} Sek.')
Epoch 1/20 1875/1875 [==============================] - 2s 806us/step - loss: 0.5589 - acc: 0.8612 - val_loss: 0.3169 - val_acc: 0.9119 Epoch 2/20 1875/1875 [==============================] - 1s 786us/step - loss: 0.2952 - acc: 0.9157 - val_loss: 0.2594 - val_acc: 0.9255 Epoch 3/20 1875/1875 [==============================] - 1s 777us/step - loss: 0.2491 - acc: 0.9290 - val_loss: 0.2282 - val_acc: 0.9337 Epoch 4/20 1875/1875 [==============================] - 1s 775us/step - loss: 0.2176 - acc: 0.9385 - val_loss: 0.2005 - val_acc: 0.9427 Epoch 5/20 1875/1875 [==============================] - 1s 780us/step - loss: 0.1943 - acc: 0.9441 - val_loss: 0.1836 - val_acc: 0.9446 Epoch 6/20 1875/1875 [==============================] - 1s 763us/step - loss: 0.1763 - acc: 0.9496 - val_loss: 0.1682 - val_acc: 0.9502 Epoch 7/20 1875/1875 [==============================] - 1s 785us/step - loss: 0.1620 - acc: 0.9538 - val_loss: 0.1594 - val_acc: 0.9531 Epoch 8/20 1875/1875 [==============================] - 2s 843us/step - loss: 0.1500 - acc: 0.9574 - val_loss: 0.1481 - val_acc: 0.9562 Epoch 9/20 1875/1875 [==============================] - 1s 760us/step - loss: 0.1398 - acc: 0.9599 - val_loss: 0.1404 - val_acc: 0.9568 Epoch 10/20 1875/1875 [==============================] - 1s 755us/step - loss: 0.1310 - acc: 0.9632 - val_loss: 0.1353 - val_acc: 0.9580 Epoch 11/20 1875/1875 [==============================] - 1s 752us/step - loss: 0.1236 - acc: 0.9651 - val_loss: 0.1292 - val_acc: 0.9599 Epoch 12/20 1875/1875 [==============================] - 1s 755us/step - loss: 0.1168 - acc: 0.9669 - val_loss: 0.1230 - val_acc: 0.9614 Epoch 13/20 1875/1875 [==============================] - 1s 751us/step - loss: 0.1107 - acc: 0.9688 - val_loss: 0.1188 - val_acc: 0.9631 Epoch 14/20 1875/1875 [==============================] - 1s 749us/step - loss: 0.1055 - acc: 0.9703 - val_loss: 0.1153 - val_acc: 0.9648 Epoch 15/20 1875/1875 [==============================] - 1s 758us/step - loss: 0.1007 - acc: 0.9722 - val_loss: 0.1128 - val_acc: 0.9648 Epoch 16/20 1875/1875 [==============================] - 1s 761us/step - loss: 0.0961 - acc: 0.9735 - val_loss: 0.1097 - val_acc: 0.9660 Epoch 17/20 1875/1875 [==============================] - 1s 753us/step - loss: 0.0923 - acc: 0.9741 - val_loss: 0.1092 - val_acc: 0.9666 Epoch 18/20 1875/1875 [==============================] - 1s 763us/step - loss: 0.0884 - acc: 0.9757 - val_loss: 0.1057 - val_acc: 0.9666 Epoch 19/20 1875/1875 [==============================] - 2s 827us/step - loss: 0.0848 - acc: 0.9767 - val_loss: 0.1037 - val_acc: 0.9671 Epoch 20/20 1875/1875 [==============================] - 1s 755us/step - loss: 0.0817 - acc: 0.9776 - val_loss: 0.1045 - val_acc: 0.9668 TRAININGSDAUER: 29.20 Sek.
Minibatch liegt mit einer Dauer von ca. 29 Sekunden zwischen Batch-Training (3 Sek.) und reinem SGD (11 Min.). Interessant ist, dass es doch erheblich schneller ist als reines SGD.
Wir sehen uns wieder die Performance an:
fig, ax = plt.subplots(nrows=1, ncols=2, figsize=(15,4))
set_subplot(ax[0], 'Loss', history3.history['loss'],
history3.history['val_loss'], [0, 1])
set_subplot(ax[1], 'Accuracy', history3.history['acc'],
history3.history['val_acc'], [0.8, 1])
plt.show()
loss, acc = model.evaluate(x_test, y_test_1hot)
print(f"Evaluation auf den Testdaten:\n\nLoss = {loss:.3f}\nAccuracy = {acc:.3f}")
313/313 [==============================] - 0s 587us/step - loss: 0.1045 - acc: 0.9668 Evaluation auf den Testdaten: Loss = 0.105 Accuracy = 0.967
Die Performance für unser Minibatch-Training mit Batchgröße 32 ist mit einer 97% Accuracy auf den Testdaten praktisch gleich zur Performance mit reinem SGD.
Schauen wir uns die Resultate nochmal im Vergleich an (unten sieht man auch die jeweilige Accuracy als Kurve über die Epochen).
Trainingsdauer | Accuracy (Test) | |
---|---|---|
Batchtraining | 3 Sek | 27% |
Reines SGD |