5  Feedforward-Netze

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.

Konzepte in diesem Kapitel

Forward Propagation, Gewichte und Gewichtsmatrix, Schichten, One-Hot-Encoding, Aktivierungsfunktion, Softmax, Bias-Neuronen, Regularisierung, Binary Cross-Entropy, Categorical Cross-Entropy, Backpropagation

  • Sie verstehen die grundlegenden Konzepte von Feedforward-Netzen (FNN)
  • Sie kennen die Notation für FNN und können den Informationsfluss im Netzwerk (Forward Propagation) anhand der Formeln in Komponenten- und Vektor/Matrixform erklären
  • Sie kennen verschiedene Aktivierungsfunktionen (Sigmoid, ReLU, Softmax)
  • Sie können Backpropagation und Gradientenabstieg erklären und verstehen relevante Konzepte wie die Zielfunktion, Regularisierung und Gradienten; insbesondere verstehen Sie die Gewichtsanpassung und können Sie mit konkreten Werten berechnen
  • Sie verstehen bei Backpropagation die in parallele Verarbeitung mehrerer Trainingsbeispiele als Matrix und können dieses Vorgehen begründen
  • Sie können FNNs in Python mit Hilfe der Keras-Bibliothek erstellen und mit Daten aus Keras trainieren; Sie sind sich der verschiedenen Hyperparameter-Optionen (Lernrate, Batchgröße etc.) und ihrer Bedeutung aus praktischer Erfahrung bewusst
  • 26.04.2024: Viele kleine Anpassungen, meist Formulierungen oder Abbildungsgrößen

Datensatz

Name Daten Anz. Klassen Klassen Trainings-/Testdaten Ort
MNIST s/w-Bilder (28x28) 10 handgeschriebene Ziffern 0…9 60000/10000 5.3.2

Notation

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]\)

Importe

import tensorflow
import math
from math import exp
import matplotlib.pyplot as plt
import numpy as np
import time

5.1 Was sind Feedforward-Netze?

Wir gehen über zu Netzwerken, die im Gegensatz zum Perzeptron zusätzliche Schichten zwischen Input- und Outputschicht haben. Diese werden auch versteckte Schichten oder hidden layers genannt. Man kann solche Netze als Feedforward Neural Nets (FNNs) oder als Multilayer Perceptrons (MLP) bezeichnen. Wir werden hier das Kürzel 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.

5.1.1 Notation für FNN

Beispielnetz

Als durchgängiges Beispiel betrachten wir das Netzwerk in Abbildung 5.1.

Dieses Netz hat:

  1. eine Eingabe: obwohl man dies als Schicht auffassen könnte, wird die Eingabe nicht als Schicht mitgezählt, da hier noch keine Gewichte/Parameter zum Einsatz kommen
  2. eine versteckte Schicht (engl. hidden layer): dies ist eine Schicht mit Parametern
  3. die Ausgabeschicht: auch diese Schicht hat Parameter, die Aktivierung ist gleichzeitig die finale Ausgabe des Netzwerks
Nur parametrisierte Schichten zählen

Ein FNN besteht aus Schichten von Neuronen. Wir unterscheiden zwischen nicht-parametrisierten Schichten (die Eingabeschicht) und parametrisierten Schichten (alle anderen Schichten). Wenn wir von “Schichten” sprechen meinen wir aber die parametrisierten Schichten. Also haben wir es hier mit einem 2-Schicht-Netzwerk zu tun. Diese Zähleweise ist motiviert durch Frameworks wie Keras und PyTorch.

Beispiel eines Feedforward-Netzes mit zwei Schichten

Figure 5.1: Beispiel eines Feedforward-Netzes mit zwei Schichten

Allgemeine Darstellung

Nachdem wir ein Beispielnetz gesehen haben, betrachten wir in Abbildung 5.2 eine allgemeine Darstellung eines FNN mit \(L\) Schichten.

Allgemeine Darstellung eines Feedforward-Netzes

Figure 5.2: Allgemeine Darstellung eines Feedforward-Netzes

Die Anzahl der (parametrisierten) Schichten nennen wir \(L\). Bei dem Beispielnetz 5.1 wäre \(L = 2\). Wir verwenden Index \(l\), um eine bestimmte Schicht anzuzeigen. Damit wir auch die Neuronen der Eingabeschicht erfassen 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 Beispielnetz 5.1 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-1\) zu Zielneuron \(j\) in Schicht \(l\) wird durch \(w_{j,i}^{(l)}\) repräsentiert. Dazu später mehr.

Ausgabe (One-Hot Encoding)

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 (siehe Abschnitt 5.1.4).

5.1.2 Forward Propagation

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\).

Roheingabe

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*} \]

Aktivierung

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.

Output

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)}) \]

Matrixdarstellung der Gewichte

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\).

Indizes der Gewichtsmatrix

Man beachte die Reihenfolge der Indizes bei der Gewichtsmatrix \(W\): Der erste Index ist das Ziel, zweiter Index die Quelle. Man würde das vielleicht umgekehrt erwarten. Je nach Fachbuch wird auch die umgekehrte Reihenfolge gewählt, dann ändert sich die Multiplikationsreihefolge mit \(W\) in den Gleichungen (siehe auch Hinweis unten).

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.

Gewichte im Beispielnetz

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}\).

Multiplikationsreihenfolge W und a

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 Indizes von \(W\) um.

Bias-Neuronen

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\), wie man in Abbildung 5.3 sieht. Die Einflussstärke des Bias-Neurons auf das Zielneuron \(i\) der nächsten Schicht \(l\) wird dann über das Gewicht \(b_i^{(l)}\) gesteuert.

Bias-Neuronen

Figure 5.3: Bias-Neuronen haben eine konstante Aktivierung von +1 und einen Gewichtsvektor b.

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) \]

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 Abbildung 5.3 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\).

Das Video erläutert die Vorwärtsverarbeitung des Feedforward-Netzes.

Anzahl der Parameter (Gewichte)

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 in Abbildung 5.3 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 Konvolutionsnetze haben mehrere Millionen Parameter, GPT-3 hat 175 Milliarden 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.

Forward Propagation im Beispielnetz

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*} \]

5.1.3 Aktivierungsfunktion

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.

Sigmoide Funktionen

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()

Rectified Linear Unit (ReLU)

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()

5.1.4 Softmax für die Ausgabe

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. deutlich größer als 1 oder negativ). Wenn die Werte in \(\hat{y}\) eine Wahrscheinlichkeitsverteilung bilden sollen, müssen sie zwei Eigenschaften aufweisen:

  1. alle Werte liegen zwischen 0 und 1
  2. die Summe aller Werte ergibt 1

Das ist tatsächlich gar nicht so schwer.

Einfaches Normalisieren

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 Werte 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.

Softmax

Ä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:

  • alle Eingangswerte \(z\) werden positiv
  • negative Werte werden auf Werte kleiner 1 gemappt
  • bei positiven Werten werden die Unterschiede stark “vergrößert”
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):

  1. alle \(\hat{y}_i\) liegen im im Interval [0, 1]
  2. die Summe aller Werte \(\hat{y}_1, \ldots, \hat{y}_m\) ist \(1\), d.h. \(\sum_i \hat{y}_i = 1\)

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.

Softmax im Vergleich zu anderen Aktivierungsfunktionen

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.

Softmax in Python und Beispiele

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

5.1.5 Formeln im Überblick

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.

Komponentenschreibweise

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} \]

Vektorschreibweise

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} \]

Aktivierungsfunktionen

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}} \]

5.2 Lernen mit Backpropagation

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, and Williams (1986), 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; oder Sie schauen sich einen kurzen Clip an, wo McClelland über Geoffrey Hinton und seine Rolle bei Backpropagation spricht). 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 Jahren 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. Wenn Sie diesen “grandfather of deep learning” sprechen hören wollen, gibt es viele Vorträge im Internet, z.B. Will digital intelligence replace biological intelligence? vom 02.02.2024.

Lesenswert

5.2.1 Zielfunktion

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.

Kosten für ein Trainingsbeispiel: Binary Cross-Entropy

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.

Negativer Logarithmus

Negativer Logarithmus

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.

Categorical Cross-Entropy

In den meisten Fällen haben wir es mit Mehrklassen-Klassifikation zu tun. Im Gegensatz zur binären Klassifikation haben wir hier mehrere Output-Neuronen vorliegen (pro Klasse ein Neuron, also eine One-Hot-Encoding). Als Aktivierung in der Ausgabeschicht wird dann die Softmax-Funktion verwendet, so dass der berechnete Output immer als Wahrscheinlichkeitsverteilung über alle Klassen vorliegt.

In diesem Fall verwendet man als Verlustfunktion die sogenannte Categorical Cross-Entropy. Bei einer Mehrklassen-Klassifikation mit \(m\) Klassen 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.

Anmerkung

Man könnte sich fragen, ob es ein Problem ist, dass alle Aktivierungen der nicht-korrekten Klasse ignoriert werden. Würde ein Modell dann nicht einfach lernen, bei allen Klassen eine 1 zu produzieren? Hier ist die Softmax-Funktion wesentlich, die dafür sorgt, dass die nicht-korrekten Klassen indirekt addressiert werden, da sich alle Aktivierungen zu 1 aufsummieren.

Wir haben oben nur den Fehler \(J_k\) bei einem einzigen Trainingsbeispiel \(k\) definiert. 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'])

5.2.2 Regularisierung

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.

Finale Zielfunktion (mit Categorical Cross-Entropy)

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.

Effekt der Regularisierung (Weight Decay)

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.

5.2.3 Fehlerberechnung (Delta)

Jetzt berechnen wir die “Fehler” an jedem Neuron. Diese Fehler verwenden wir später, um die Gewichte anzupassen, wie wir das schon beim Perzeptron 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.

Beispielnetz mit Fehler delta

Berechnung

Grob gesehen haben wir die folgende Verarbeitungskette in unserem Beispiel-Netz:

  1. berechne \(\delta^{(2)}\) unter Verwendung der errechneten Ausgabe \(\hat{y}\)
  2. berechne \(\delta^{(1)}\) unter Verwendung des errechneten \(\delta^{(2)}\)

Wie man sieht, läuft die Berechnung rückwärts durch die Schichten, von Schicht 2 zu Schicht 1.

Fehler in der Ausgabeschicht

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 6 unter (D1).

Fehler in beliebiger Schicht

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 6 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’).

Intuition

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.

Beispielnetz mit Fehler delta

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.

5.2.4 Gewichtsanpassung

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.

Beispielnetz mit Fehler delta

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 6 unter (W).

Beispielnetz mit Fehler delta

Für die Bias-Gewichte \(b^{(l)}\) gilt analog:

\[ \Delta b_i^{(l)} = - \frac{\partial J}{\partial b_i^{(l)}} = - \delta_i^{(l)} \]

Wenn man diese Formel mit der obigen vergleicht, kann man sich vorstellen, dass die Aktivierung \(a^{(l-1})\) hier ja immer gleich eins ist. Die Herleitung zu dieser Formel finden Sie im nächsten Kapitel 6 unter (B).

Vektorform

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)} \]

Sanity Check: Wir möchten (DelW) mit Hilfe unseres Beispielnetzwerks prüfen. Zwischen der mittleren Schicht 1 und der Ausgabeschicht (Schicht 2) haben wir 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 Neuronen der mittleren Schicht (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.

Update mit allen Trainingsbeispielen

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)} \]

5.2.5 Formeln im Überblick

Hier nochmal alle relevanten Formeln in Vektorschreibweise.

Forward Propagation

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} \]

Backpropagation

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} \]

5.2.6 Backpropagation-Algorithmus

Standardalgorithmus

Nochmal zusammengefasst der Algorithmus für \(N\) Trainingsbeispiele \((x^k, y^k)\):

Für jede Epoche:

  1. Initialisiere für alle Trainingsbeispiele Sammelmatrizen \(\Delta W^{(l), k} := 0\) und Sammelvektoren \(\Delta b^{(l),k} := 0 \quad\) (jeweils für \(l = 1,\ldots, L-1\) und \(k= 1, \ldots, N\))
  2. Für alle Trainingsbeispiele \(k = 1, \ldots, N\):
    • Forward Propagation:
      • Setze \(a^{(0), k} := x^k\quad\) (Wir lassen im weiteren den Index \(k\) weg)
      • Berechne \(a^{(1)}, \ldots, a^{(L)} = \hat{y} \quad\) (Z & A)
    • Backpropagation:
      • Berechne Fehler \(\delta^{(L)}, \ldots, \delta^{(2)}, \delta^{(1)} \quad\) (D)
      • Erhöhe alle \(\Delta W^{(l),k}\) und \(\Delta b^{(l),k} \quad\) (DelW & DelB)
  3. Passe für alle \(l\) die Gewichtsmatrizen an mit

\[ \begin{align*} D^{(l)} &:= \frac{1}{N} \sum_{k=1}^{N} \Delta W^{(l), k} \\[3mm] W^{(l)} &:= W^{(l)} + \alpha \: D^{(l)} \end{align*} \]

  1. Passe für alle \(l\) die Biasvektoren an mit

\[ \begin{align*} B^{(l)} &:= \frac{1}{N} \sum_{k=1}^{N} \Delta b^{(l), k} \\[3mm] b^{(l)} &:= b^{(l)} + \alpha \: B^{(l)} \end{align*} \]

Implementations-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.

Mini-Batch

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:

  • Für jede Teilmenge \(T_j \quad (j = 1, \ldots, M)\)
    1. Initialisiere Sammelmatrizen \(\Delta W^{(l)} := 0\) und Sammelvektoren \(\Delta b^{(l)} := 0\)
    2. Für alle Trainingsbeispiele \((x, y) \in T_j\):
    • Setze \(a^{(0)} = x\)
    • Berechne Aktivierungen \(a^{(1)}, \ldots, a^{(L)} = \hat{y}\) (Forward Propagation)
    • Berechne Fehler \(\delta^{(L)}, \ldots, \delta^{(2)}, \delta^{(1)}\) (Backward Propagation)
    • Erhöhe alle \(\Delta W^{(l)}\) und \(\Delta b^{(l)}\)
    • Passe für alle \(l\) die Gewichtsmatrizen \(W^{(l)}\) und die Biasvektoren \(b^{(l)}\) an

Siehe auch die Videos von Andrew Ng: Mini Batch Gradient Descent und Understanding Mini-Batch Gradient Descent

Batch und Mini-Batch

In unserem Kontext bedeutet Batch-Training, dass alle Traningsbeispiele in einem Rutsch durchlaufen werden (und dann ein Update durchführt) und Mini-Batch-Training, dass man die Trainingsbeispiele in kleinere Einheiten unterteilt und für jede dieser kleineren Einheiten ein Update durchführt. Diese kleineren Einheiten nennt man auch Batches und die Anzahl der Trainingsbeispiele in einem Batch nennt man batch size. Heutzutage ist praktisch jedes Training vom Typ Mini-Batch, aber man muss etwas vorsichtig sein mit den Begriffen.

5.2.7 Parallele Verarbeitung mehrerer Trainingsbeispiele

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.

Seien \((x^k, y^k)\) die Trainingsbeispiele, wobei \(k \in \{1,\ldots, N\}\).

Vorwärtsverarbeitung für ein Trainingsbeispiel

Sehen Sie sich noch einmal das Beispielnetz in Abbildung 5.1 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) \]

Wir könnten jetzt die Aktivierung \(a^{(1)}\) berechnen und dann mit \(W^{(2)}\) die nächste Schicht berechnen. Aber bei mehreren Trainingsbeispielen geht man anders vor.

Vorwärtsverarbeitung für mehrere Trainingsbeispiele

Wir könnten also für das Bespiel oben (a) die nächste Schicht für das aktuelle Trainingsbeispiel berechnen oder (b) wir könnten für das nächste Trainingsbeispiel auch erstmal die erste Schicht berechnen. Beides wäre möglich, da wir davon ausgehen, dass sich die Gewichte nicht ändern. Wir können also im Extremfall für alle Trainingsbeispiele erst einmal die Ergebnisse der ersten Schicht berechenen. Wir müssen natürlich alle diese Ergebnisse speichern, damit wir im nächsten Schritt für alle Trainingsbeispiele die Ergebnisse an der zweiten Schicht berechnen können.

Ähnlich wie beim Perzeptron in Abschnitt 3.5 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.

Berücksichtigung des Bias-Gewichtsvektors

Wir haben oben den Bias-Gewichtsvektor ignoriert. Bei einem Trainingsbeispiel addieren wir eigentlich noch Vektor \(b^1\):

\[ \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 eventuell 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.

5.3 FNN in Keras und MNIST-Datensatz

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.

5.3.1 Loss-Funktion in Keras

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.

5.3.2 MNIST-Datensatz

MNIST kurz gefasst

Im MNIST-Datensatz geht es Erkennung von handgeschriebenen Ziffern von 0 bis 9. Es gibt zehn Klassen, die es vorherzusagen gilt: 0, 1, 2, … 9. Die Bilder haben die Auflösung 28x28 und liegen in Graustufen vor. Der Datensatz enthält 60000 Trainingsbeispiele und 10000 Testbeispiele.

Wir ziehen den Datensatz MNIST hinzu. MNIST steht für Modified National Institute of Standards and Technology (dataset). Siehe auch den Wikipedia-Artikel zu MNIST und die Keras-Doku zu MNIST.

Eine Einführung in die MNIST-Daten in Keras finden Sie im folgenden Video.

Daten einlesen

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,)

Daten visualisieren

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 0x7fbb309bdac0>

Zehntes Trainingsbild:

plt.imshow(x_train[9], cmap='gray')
<matplotlib.image.AxesImage at 0x7fbb1078b340>

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()

Daten vorverarbeiten

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)

One-Hot Encoding

Um die zehn Ziffern 0, , 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.

5.3.3 FNN mit zwei Schichten und Batchtraining

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:

  • Versteckte Schicht mit 60 Neuronen
  • Ausgabeschicht mit 10 Neuronen

Schematisch sieht das so aus (ohne Bias-Neuronen bzw. -Vektor):

Netz mit 20 versteckten Neuronen in Keras

Modell

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'))
2024-03-01 11:52:37.664647: 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.

Anzahl der Parameter

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}}]}

Gewichtsmatrizen und Biasvektoren

Mit get_weights können wir auf die Parameter des Netzwerks zugreifen. Für jede Schicht gibt es jeweils

  • eine Gewichtsmatrix und
  • einen Bias-Vektor.

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.

Training

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.

Lernrate

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}

Compile

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'])

Fit

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:

  • mit batch_size können Sie die Größe der Minibatches angeben (Standardmäßig 32)
  • mit validation_data übergibt man Daten, auf denen nach jeder Epoche das Modell evaluiert wird; wir benutzen hier die echten Testdaten und keine speziellen Validierungsdaten
  • mit verbose kann man die Ausgabe steuern (z.B. mit verbose=0 ausschalten).

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 965ms/step - loss: 2.4124 - acc: 0.1206 - val_loss: 2.3657 - val_acc: 0.1352
Epoch 2/20
1/1 [==============================] - 0s 215ms/step - loss: 2.3649 - acc: 0.1315 - val_loss: 2.3311 - val_acc: 0.1481
Epoch 3/20
1/1 [==============================] - 0s 160ms/step - loss: 2.3305 - acc: 0.1465 - val_loss: 2.3044 - val_acc: 0.1717
Epoch 4/20
1/1 [==============================] - 0s 167ms/step - loss: 2.3039 - acc: 0.1662 - val_loss: 2.2826 - val_acc: 0.1952
Epoch 5/20
1/1 [==============================] - 0s 330ms/step - loss: 2.2824 - acc: 0.1908 - val_loss: 2.2642 - val_acc: 0.2202
Epoch 6/20
1/1 [==============================] - 0s 181ms/step - loss: 2.2641 - acc: 0.2180 - val_loss: 2.2479 - val_acc: 0.2500
Epoch 7/20
1/1 [==============================] - 0s 168ms/step - loss: 2.2480 - acc: 0.2441 - val_loss: 2.2331 - val_acc: 0.2767
Epoch 8/20
1/1 [==============================] - 0s 190ms/step - loss: 2.2334 - acc: 0.2724 - val_loss: 2.2193 - val_acc: 0.3036
Epoch 9/20
1/1 [==============================] - 0s 165ms/step - loss: 2.2198 - acc: 0.3000 - val_loss: 2.2062 - val_acc: 0.3268
Epoch 10/20
1/1 [==============================] - 0s 188ms/step - loss: 2.2069 - acc: 0.3259 - val_loss: 2.1935 - val_acc: 0.3545
Epoch 11/20
1/1 [==============================] - 0s 134ms/step - loss: 2.1944 - acc: 0.3515 - val_loss: 2.1813 - val_acc: 0.3760
Epoch 12/20
1/1 [==============================] - 0s 136ms/step - loss: 2.1824 - acc: 0.3752 - val_loss: 2.1693 - val_acc: 0.3999
Epoch 13/20
1/1 [==============================] - 0s 132ms/step - loss: 2.1706 - acc: 0.3970 - val_loss: 2.1575 - val_acc: 0.4189
Epoch 14/20
1/1 [==============================] - 0s 137ms/step - loss: 2.1590 - acc: 0.4184 - val_loss: 2.1459 - val_acc: 0.4416
Epoch 15/20
1/1 [==============================] - 0s 136ms/step - loss: 2.1476 - acc: 0.4368 - val_loss: 2.1344 - val_acc: 0.4572
Epoch 16/20
1/1 [==============================] - 0s 131ms/step - loss: 2.1363 - acc: 0.4531 - val_loss: 2.1230 - val_acc: 0.4706
Epoch 17/20
1/1 [==============================] - 0s 143ms/step - loss: 2.1251 - acc: 0.4677 - val_loss: 2.1117 - val_acc: 0.4844
Epoch 18/20
1/1 [==============================] - 0s 116ms/step - loss: 2.1140 - acc: 0.4824 - val_loss: 2.1005 - val_acc: 0.4997
Epoch 19/20
1/1 [==============================] - 0s 120ms/step - loss: 2.1030 - acc: 0.4956 - val_loss: 2.0893 - val_acc: 0.5102
Epoch 20/20
1/1 [==============================] - 0s 100ms/step - loss: 2.0921 - acc: 0.5073 - val_loss: 2.0782 - val_acc: 0.5222
TRAININGSDAUER: 4.26 Sek.

Die Trainingsdauer im Batchtraining ist mit 3 Sekunden für 20 Epochen extrem kurz.

Evaluation

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 482us/step - loss: 2.0782 - acc: 0.5222
Evaluation auf den Testdaten:

Loss = 2.078
Accuracy = 0.522

Mit Batchtraining erhalten wir nach 20 Epochen Training eine enttäuschende Accuracy von 47% auf den Testdaten.

5.3.4 Einfluss der Batchgröße

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.

Reines SGD (Batchgröße = 1)

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 [==============================] - 27s 445us/step - loss: 0.2389 - acc: 0.9267 - val_loss: 0.1521 - val_acc: 0.9548
Epoch 2/20
60000/60000 [==============================] - 27s 445us/step - loss: 0.1303 - acc: 0.9599 - val_loss: 0.1366 - val_acc: 0.9571
Epoch 3/20
60000/60000 [==============================] - 27s 450us/step - loss: 0.1037 - acc: 0.9681 - val_loss: 0.1135 - val_acc: 0.9653
Epoch 4/20
60000/60000 [==============================] - 27s 457us/step - loss: 0.0888 - acc: 0.9715 - val_loss: 0.1149 - val_acc: 0.9652
Epoch 5/20
60000/60000 [==============================] - 27s 448us/step - loss: 0.0807 - acc: 0.9743 - val_loss: 0.1160 - val_acc: 0.9656
Epoch 6/20
60000/60000 [==============================] - 28s 459us/step - loss: 0.0715 - acc: 0.9778 - val_loss: 0.1128 - val_acc: 0.9677
Epoch 7/20
60000/60000 [==============================] - 27s 456us/step - loss: 0.0618 - acc: 0.9798 - val_loss: 0.1087 - val_acc: 0.9696
Epoch 8/20
60000/60000 [==============================] - 29s 483us/step - loss: 0.0574 - acc: 0.9810 - val_loss: 0.1178 - val_acc: 0.9683
Epoch 9/20
60000/60000 [==============================] - 28s 464us/step - loss: 0.0513 - acc: 0.9832 - val_loss: 0.1123 - val_acc: 0.9697
Epoch 10/20
60000/60000 [==============================] - 28s 466us/step - loss: 0.0474 - acc: 0.9843 - val_loss: 0.1107 - val_acc: 0.9699
Epoch 11/20
60000/60000 [==============================] - 28s 468us/step - loss: 0.0421 - acc: 0.9863 - val_loss: 0.1124 - val_acc: 0.9691
Epoch 12/20
60000/60000 [==============================] - 28s 463us/step - loss: 0.0392 - acc: 0.9869 - val_loss: 0.1087 - val_acc: 0.9691
Epoch 13/20
60000/60000 [==============================] - 28s 463us/step - loss: 0.0365 - acc: 0.9881 - val_loss: 0.1175 - val_acc: 0.9695
Epoch 14/20
60000/60000 [==============================] - 28s 466us/step - loss: 0.0335 - acc: 0.9886 - val_loss: 0.1134 - val_acc: 0.9696
Epoch 15/20
60000/60000 [==============================] - 28s 464us/step - loss: 0.0291 - acc: 0.9904 - val_loss: 0.1199 - val_acc: 0.9708
Epoch 16/20
60000/60000 [==============================] - 28s 467us/step - loss: 0.0279 - acc: 0.9911 - val_loss: 0.1271 - val_acc: 0.9687
Epoch 17/20
60000/60000 [==============================] - 28s 469us/step - loss: 0.0288 - acc: 0.9904 - val_loss: 0.1177 - val_acc: 0.9702
Epoch 18/20
60000/60000 [==============================] - 28s 463us/step - loss: 0.0255 - acc: 0.9916 - val_loss: 0.1075 - val_acc: 0.9748
Epoch 19/20
60000/60000 [==============================] - 28s 463us/step - loss: 0.0210 - acc: 0.9931 - val_loss: 0.1242 - val_acc: 0.9704
Epoch 20/20
60000/60000 [==============================] - 28s 469us/step - loss: 0.0213 - acc: 0.9929 - val_loss: 0.1223 - val_acc: 0.9689
TRAININGSDAUER: 553.90 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 495us/step - loss: 0.1223 - acc: 0.9689
Evaluation auf den Testdaten:

Loss = 0.122
Accuracy = 0.969

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.

Minibatch (Batchgröße = 32)

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 [==============================] - 1s 688us/step - loss: 0.5664 - acc: 0.8549 - val_loss: 0.3221 - val_acc: 0.9064
Epoch 2/20
1875/1875 [==============================] - 1s 648us/step - loss: 0.2941 - acc: 0.9156 - val_loss: 0.2587 - val_acc: 0.9246
Epoch 3/20
1875/1875 [==============================] - 1s 672us/step - loss: 0.2479 - acc: 0.9288 - val_loss: 0.2245 - val_acc: 0.9334
Epoch 4/20
1875/1875 [==============================] - 1s 660us/step - loss: 0.2166 - acc: 0.9377 - val_loss: 0.2032 - val_acc: 0.9407
Epoch 5/20
1875/1875 [==============================] - 1s 673us/step - loss: 0.1933 - acc: 0.9445 - val_loss: 0.1855 - val_acc: 0.9446
Epoch 6/20
1875/1875 [==============================] - 1s 692us/step - loss: 0.1753 - acc: 0.9495 - val_loss: 0.1696 - val_acc: 0.9506
Epoch 7/20
1875/1875 [==============================] - 1s 669us/step - loss: 0.1607 - acc: 0.9542 - val_loss: 0.1608 - val_acc: 0.9536
Epoch 8/20
1875/1875 [==============================] - 1s 691us/step - loss: 0.1483 - acc: 0.9586 - val_loss: 0.1505 - val_acc: 0.9565
Epoch 9/20
1875/1875 [==============================] - 1s 661us/step - loss: 0.1375 - acc: 0.9607 - val_loss: 0.1393 - val_acc: 0.9578
Epoch 10/20
1875/1875 [==============================] - 1s 661us/step - loss: 0.1289 - acc: 0.9629 - val_loss: 0.1328 - val_acc: 0.9613
Epoch 11/20
1875/1875 [==============================] - 1s 690us/step - loss: 0.1210 - acc: 0.9663 - val_loss: 0.1272 - val_acc: 0.9612
Epoch 12/20
1875/1875 [==============================] - 1s 654us/step - loss: 0.1141 - acc: 0.9689 - val_loss: 0.1245 - val_acc: 0.9631
Epoch 13/20
1875/1875 [==============================] - 1s 706us/step - loss: 0.1077 - acc: 0.9703 - val_loss: 0.1185 - val_acc: 0.9640
Epoch 14/20
1875/1875 [==============================] - 1s 645us/step - loss: 0.1024 - acc: 0.9718 - val_loss: 0.1128 - val_acc: 0.9658
Epoch 15/20
1875/1875 [==============================] - 1s 660us/step - loss: 0.0972 - acc: 0.9734 - val_loss: 0.1102 - val_acc: 0.9657
Epoch 16/20
1875/1875 [==============================] - 1s 660us/step - loss: 0.0929 - acc: 0.9744 - val_loss: 0.1078 - val_acc: 0.9679
Epoch 17/20
1875/1875 [==============================] - 1s 669us/step - loss: 0.0885 - acc: 0.9756 - val_loss: 0.1033 - val_acc: 0.9689
Epoch 18/20
1875/1875 [==============================] - 1s 657us/step - loss: 0.0850 - acc: 0.9769 - val_loss: 0.1009 - val_acc: 0.9692
Epoch 19/20
1875/1875 [==============================] - 1s 683us/step - loss: 0.0817 - acc: 0.9781 - val_loss: 0.1005 - val_acc: 0.9687
Epoch 20/20
1875/1875 [==============================] - 1s 678us/step - loss: 0.0783 - acc: 0.9786 - val_loss: 0.0970 - val_acc: 0.9709
TRAININGSDAUER: 25.60 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 544us/step - loss: 0.0970 - acc: 0.9709
Evaluation auf den Testdaten:

Loss = 0.097
Accuracy = 0.971

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.

Konklusion

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 11 Min 97%
Minibatch (32) 29 Sek 97%

Es scheint - zumindest bei diesen Daten - so zu sein, dass man mit Minibatch-Training mit der voreingestellten Batchgröße am besten fährt, da man eine hohe Accuracy bei tolerierbarer Trainingsdauer erzielt.

Wichtig ist immer, sich die Rahmenbedingungen des obigen “Experiments” vor Augen zu führen. Wir haben die Optimierungsmethode Stochastic Gradient Descent mit einer Lernrate von 0.1 verwendet und für 20 Epochen trainiert. Wir hatten ein FNN mit einer versteckten Schicht mit 60 Neuronen und haben auf den MNIST-Daten gearbeitet. Sollte sich einer dieser Umstände ändern, kann das die Ergebnisse verändern.

Zudem müsste man jede der drei Variante mehrfach testen (z.B. 10x) und dann den Mittelwert von Dauer und Accuracy nehmen, um statistische Glaubwürdigkeit zu erreichen. In unserem Beispiel sind die Unterschiede allerdings so deutlich, dass wir uns das ersparen.

Wir sehen uns nochmal die Kurven der drei Ansätze nebeneinander an. Man beachte den Unterschied in der Skalierung des linken Graphen.

fig, ax = plt.subplots(nrows=1, ncols=3, figsize=(15,4))

set_subplot(ax[0], 'Batch 60000 / Accuracy', history1.history['acc'], history1.history['val_acc'], [0, 1])
set_subplot(ax[1], 'Batch 1 / Accuracy', history2.history['acc'], history2.history['val_acc'], [0.85, 1])
set_subplot(ax[2], 'Batch 32 / Accuracy', history3.history['acc'], history3.history['val_acc'], [0.85, 1])

plt.show()