#KI-Werkstatt

Hier möchte ich zeigen, wie man einfach und strukturiert in die Welt der neuronalen Netze einsteigt. Im Laufe dieses Blogs werde ich die typischen Schritte von der Datenbeschaffung, deren Bereinigung und Vorbereitung bis hin zum Trainieren und Finalisieren eines DNN (Deep Neuron Network) durchschreiten.
Dabei möchte ich versuchen, das ganze Themengebiet zu entzaubern und den Marketing – Sternen – Staub weg zu blasen. Wenn Du den Blogeintrag durchgelesen hast, solltest Du eine eigene Werkbank haben, auf der Du die verschiedenen Netztypen und Konfigurationen einspannen und testen kannst.

Was DNN nicht sind

Ich will hier mit einem ganz großen Gerücht aufräumen: Tiefe Neuronale Netze haben nichts mit einem Gehirn zu tun. Sie sind definitiv kein Nachbau eines Nervengeflechts. Der Name Neuronales Netz ist einfach nur irreführend. Den wissenschaftlich korrekten Namen kann sich aber auf der anderen Seite keiner merken: „Tiefe differenzierbare verkettete geometrische Transformationen“. Die einzige Analogie zwischen dem Entscheidungsprozess eines Gehirns und eines DNN, die ich gelten lassen möchte, ist die, dass man bei einer Entscheidung viele verschiedene abgespeicherte „Bilder“ gewichtet.
Wenn Du Dir zum Beispiel überlegst, ob Du nun aufstehen sollst um einen Apfel zu essen, hast Du vielleicht das frische, saftige Knacken beim ersten Biss ganz stark im Vordergrund Deiner Überlegungen. Dass Schneewittchen ein Problem mit einem vergifteten Apfel hatte, weißt Du zwar, dennoch spielt diese „Bild“ für Deine Entscheidung bestimmt keine Rolle. Ein gut trainiertes DNN macht das sehr ähnlich. Es gewichtet ebenfalls diese Bilder und zieht für seine Entscheidung nur die Bilder mit den ganz starken Gewichten heran.

Was DNN sind

Neuronale Netze und Deep Learning sind im Kern einfach nur extrem viele geometrische Gleichungen, die einen Raum zerlegen. Zusammengeschaltet ergeben sich daraus unglaublich viele Matrizenmanipulationen, die es zu lösen gilt. Die Matrizen sind hierbei vieldimensional. Sie haben also nicht nur ein Raster aus Zeilen und Spalten, wie zum Beispiel eine simple Tabelle, sondern in jeder Zelle wieder neue Matrizen. Der Oberbegriff für diese Ordnungsmuster ist Tensor:

  • Ein Tensor nullter Dimension ist eine einfache Zahl.
  • Ein Tensor erster Dimension ist ein Vektor
  • Ein Tensor zweiter Dimension ist eine Matrix
  • Ein Tensor +n-ter Dimension ist ganz allgemein ein Tensor n-ter Dimension

Nötige Vorbereitungen

Schon ein sehr kleines und übersichtliches Neuronales Netz braucht viele tausend Tensor – Multiplikationen um ordentlich trainiert zu sein. Deshalb muss man sich um zwei Dinge vorab kümmern:

GPU Unterstützung

Man braucht eine Umgebung, die diese Tensor Manipulationen schnell ausführen kann. Das gelingt am besten, wenn man seine Grafikkarten also die GPUs und nicht die CPUs seines Rechners verwendet, da Grafikkarten auf Tensor Operationen optimiert sind. Falls Du übrigens wissen möchtest, wie man unter Windows 10 die GPU Unterstützung installiert, kannst Du diesen Blog Beitrag lesen https://www.mt-itsolutions.com/2018/10/31/gpu-unterstuetzung-fuer-tensorflow-auf-windows-10/.

Computersprachen mit einer Tensor DNA

Man braucht eine Programmiersprache und Frameworks, die es einem leicht machen all diese Tensor Operationen übersichtlich zu programmieren. Wenn sie erst alle Tensoren über Schleifen auflösen müssen, um zwei Tensoren miteinander zu multiplizieren, sollten sie gar nicht erst mit Neuronalen Netzen anfangen.
Genau hier kommt Python mit TensorFlow und Keras ins Spiel:

  1. Python an und für sich hat schon eine phantastische Unterstützung für die Arbeit mit Listen.
  2. Über das Python Framework [NumPy] {http://www.numpy.org/} können sie extrem einfach Tensor Operationen ausdrücken. So ist np.dot(A, B) zum Beispiel die Matrizenmultiplikation von A und B.
  3. Das Framework [TensorFlow] {https://www.tensorflow.org/} aus dem Hause Google bietet die Möglichkeit, Tensoren auf der GPU hochgradig parallel manipulieren zu lassen und hat viele elegante Helfer, die es einem ermöglichen, die Tensoren von einer Berechnung in die nächste fließen zu lassen.
  4. Allerdings ist TensorFlow etwas sperrig und kompliziert, wenn man sich mit neuronalen Netzen befassen will. Hier kommt der API Layer [keras] {https://keras.io/} ebenfalls aus dem Hause Google ins Spiel. Keras bietet einem die Möglichkeit tiefe Neuronale Netze quasi zusammen zu stecken.

Also: Hol Dir eine Tasse Kaffee und lass uns anfangen:)

Der klassische Weg durchs Projekt

Wir werden hier mit zweitbeliebtesten Machine Learning – „Hallo Welt“ in die DNN einsteigen: Dem Erkennen von handschriftlichen Zahlen. Das beliebteste Hallo Welt ist wohl das Erkennen von Katzen und Hunden. Beim Ziffernerkennen, kann man aber schon mit extrem simplen Netzen sehr schöne Ergebnisse erzielen, was bei den Katzen und Hunden eher schwierig ist.

Boiler Plate

Zuerst importiere ich alle Module, die man für das Beispiel braucht:

#tensorflow
import tensorflow as tf
from tensorflow.python.client import device_lib
#keras
import keras
from keras.backend.tensorflow_backend import set_session
from keras.datasets import mnist
from keras import models
from keras import layers
from keras import regularizers
from keras.utils import to_categorical
#numpy
import numpy as np
#matplotlib
%matplotlib inline
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
#time
import time

Using TensorFlow backend.
Leider muss man das korrekte Zusammenspiel von Keras und der GPU auf Windows Maschinen eigens konfigurieren. Macht man das nicht, bekommt man von der GPU keinen „dynamischen“ Speicher zugewiesen. Zudem lass ich mir immer noch die Versionsnummern von TensorFlow und Keras und alle verfügbaren Devices (CPUs und GPUs) ausdrucken. Du solltest darauf achten, dass Du sowas ähnliches wie name: "/device:GPU:0 in Deinem Output findest.

# to force keras to use the CPU uncomment this and restart
#import os
#os.environ["CUDA_VISIBLE_DEVICES"] = '-1' #works on windows 10 to force it to use CPU
config = tf.ConfigProto()
config.gpu_options.allow_growth = True # dynamically grow the memory used on the GPU
config.log_device_placement = True # to log device placement (on which device the operation ran)
# (nothing gets printed in Jupyter, only if you run it standalone)
sess = tf.Session(config=config)
set_session(sess) # set this TensorFlow session as the default session for Keras
print("TensorFlow Version:\t{}".format(tf.__version__))
print("Keras Version:\t\t{}".format(keras.__version__))
print("Found GPU & CPU devices:\n{}".format(device_lib.list_local_devices()))
TensorFlow Version: 1.10.0
Keras Version: 2.2.4
Found GPU & CPU devices:
[name: "/device:CPU:0"
device_type: "CPU"
memory_limit: 268435456
locality {
}
incarnation: 7564581665635198288
, name: "/device:GPU:0"
device_type: "GPU"
memory_limit: 4960052838
locality {
bus_id: 1
links {
}
}
incarnation: 656239875270493923
physical_device_desc: "device: 0, name: GeForce GTX 1060, pci bus id: 0000:02:00.0, compute capability: 6.1"
]

Daten besorgen

DNN brauchen zum Lernen immer sehr viele Trainingsdaten. Wir brauchen für unseren Fall also super viele handgeschriebene Ziffern und deren korrekte Zuordnung. Zum Glück gibt es eine öffentliche Datenbank mit dem Namen MNIST, die da schon mal was vorbereitet hat. Keras hat MNIST Daten bereits eingebunden und man kann alle Daten sehr bequem herunterladen. Dabei werden einem Trainings und Test Daten zur Verfügung gestellt. Es ist wichtig, dass man sich die Testdaten bis zum Schluss aufhebt und sie auf gar keinen Fall in das Training des Modelles mit einsickern zu lassen, da man sonst nicht beweisen kann, ob sein Modell auch ungesehenes vorhersagen kann.

#keras has already the full MNIST dataset
(train_images, train_labels), (test_images, test_labels) = mnist.load_data()

Daten kennen lernen

Es gibt einige Fachbegriffe im Machine Learning, die man kennen sollte. Hier kommen einige, die sich um die Daten ranken:

  1. SAMPLE: Einen „Datensatz“ oder ein Beispiel nennt man ein Sample. In unserem Fall ist das eine handschriftlich geschriebene Ziffer.
  2. Feature: Eine einzelne Eigenschaft oder ein einzelner Attributwert eines Samples. In unserem Fall ist das der Grauwert eines einzelnen Bildpunktes. Unsere Bilder (Samples) sind aus 28 x 28 Bildpunkten zusammengesetzt und jeder Bildpunkt hat einen Grauwert von 0 für Weiß bis 255 für Schwarz. Im Deutschen passt auch das Wort Kriterium recht gut: „Was sind denn die Kriterien, die dein Beispiel unterlegen?“
  3. Label: Das erwartete Ergebnis, das ein gut trainiertes DNN liefern soll. In unserem Fall schicken wir beispielsweise das Bild einer „3“ durch das Netz und erwarten, dass das Netz auch eine Drei und nicht eine Neun erkennt.

Im Augenblick habe ich aber nur vier attribute mit Daten belegt train_images, train_labels, test_images, test_labels von denen ich eigentlich nichts weiß.
Somit schaue ich mir die Daten ein wenig an, um ein Gefühl für meine Samples, Features und Labels zu bekommen.
Zuerst möchte ich ein wenig mehr über die Samples train_images wissen:

# lets look a bit inside
print("train_images:")
print("type: {}".format(type(train_images)))
print("shape: {}".format(train_images.shape))
print("types: {}".format(train_images.dtype))
#get ramdom features out of the first sample and the seven'th column.
print("Some random features: {}".format(train_images[0][6][6:10]))
print("Biggest gray scale value: {}".format(np.max(train_images, axis = 0).max(axis = 0).max(axis = 0)))
print("Smalest gray scale value: {}".format(np.min(train_images, axis = 0).min(axis = 0).min(axis = 0)))
# draw some examples in a l x b grid
l = 5
b = 10
for i in range(1, l*b + 1):
plt.subplot(l,b,i)
plt.title(train_labels[i])
plt.axis('off')
plt.imshow(train_images[i])
plt.show()
print("Abb 1: Einige Beispiele der Trainigsdaten")
print("")
# do the same for the test data
print('--------------------------')
print("test_images: ")
print("type: {}".format(type(test_images)))
print("shape: {}".format(test_images.shape))
print("types: {}".format(test_images.dtype))
# draw some examples in the same grid
for i in range(1, l*b + 1):
plt.subplot(l,b,i)
plt.title(test_labels[i])
plt.axis('off')
plt.imshow(test_images[i])
plt.show()
print("Abb 2: Einige Beispiele der Testdaten")
print("")
train_images:
type: <class 'numpy.ndarray'>
shape: (60000, 28, 28)
types: uint8
Some random features: [ 0 0 30 36]
Biggest gray scale value: 255
Smalest gray scale value: 0


Abb 1: Einige Beispiele der Trainigsdaten

test_images:
type: <class 'numpy.ndarray'>
shape: (10000, 28, 28)
types: uint8


Abb 2: Einige Beispiele der Testdaten
Hier konnte ich schon Einiges in Erfahrung bringen:

  1. Die Samples kommen in einem 3-D Numpy Array
  2. Die erste Dimension enthält die Samples erstes, zweites, … n-tes
  3. Ich habe 60.000 Samples fürs Training und 10.000 für den Test
  4. Die zweite Dimension in meinem Array enthält je eine Bildzeile. Jedes Bild hat 28 Zeilen
  5. Die Dritte Dimension enthält für jeden Bildpunkt einer Zeile den entsprechenden Grauwert. Es gibt 28 Punkte je Zeile und Grauwerte von 0 bis 255
  6. Zum Sample an der n-ten Stelle bekomme ich die erwartete Antwort (das Label) an der n-ten Stelle
  7. Es gibt anscheinend keine verborgenen Hinweise auf die Trainingsdaten, die das Ergebnis verraten würden. Prominent ist da ein Fall, wo ein DNN Panzer erkennen sollte und sich im Training auch extrem gutartig verhalten hat, im Betrieb aber kläglich versagte. Bei der Ursachenanalyse ist dann aufgefallen, dass alle Trainingsbilder die Panzer immer vor strahlend blauem Himmel darstellten.

Feature Engeneering

Hat man ein Gefühl für die Daten entwickelt, muss man sie für das Training vorbereiten. Sprich, man muss die Samples so aufbereiten, dass man sie auf der einen Seite in das Netzt hineinbekommt und die Labels so verformen, dass man sie mit der Antwort des Netzes vergleichen kann.
Dazu sollte man folgendes Wissen: Ein neuronales Netzt verfügt über eine starre Anzahl von Gewichten, die es zu trainieren gilt. Das heißt, dass jedes dedizierte Feature eines Samples immer genau die gleiche Gegend von Gewichten auf die gleiche Art massieren muss wie alle seine „Kollegen“ in den anderen Samples.
Das klingt jetzt vielleicht ein wenig kompliziert ist es aber nicht. Ganz praktisch gesprochen:
In der ersten Näherung ist ein DNN eine schwarze Schachtel, in die man auf der einen Seite einen Vektor I der Länge n reinsteckt und auf der anderen Seite einen Vektor O der Länge m herausbekommt.

  1. Das bedeutet, wenn der Vektor I des ersten Samples 784 lang ist, müssen auch alle anderen Sample Vektoren 784 lang sein.
  2. Das bedeutet auch, wenn an der ersten Stelle des Vektors I des ersten Samples die linke obere Ecke eines Bildes beschrieben wird, muss das auch für alle anderen Vektoren so sein.
  3. Zu guter Letzt ist der Inhalt der schwarzen Schachtel leider eine numerische, nicht lineare Maschine und leider keine analytische. Somit sollte auch jede Stelle im Vektor I über den gleichen Wertebereich verfügen. Wenn also die Erste Stelle einen Wertebereich von 0 bis 255 hat, so sollten auch alle anderen Werte in diesem Bereich liegen. (Numerische Algorithmen haben unter anderen mit Instabilitäten zu kämpfen, die das Ergebnis verfälschen. Wenn Du tiefer in die Mathematik dahinter einsteigen möchtest, kannst Du zum Beispiel folgende Abhandlung lesen Untersuchungen zur Stabilität von geometrisch nichtlinearen Berechnungen

In unserem Fall gilt das für die Features zum Glück schon. Alle Samples lassen sich über einen Vektor der Länge 28 x 28 = 784 beschreiben und die Werte von allen Stellen liegen zwischen 0 und 255. Sehr schön!
Ein wenig Pech haben wir aber mit den Ergebnissen also den Labels. Man könnte zwar ganz naiv sagen: Naja ich stecke auf der einen Seite einen Vektor I mit der Länge 784 rein und bekomme auf der anderen Seite einen Vektor O mit der Länge 1 und dem Wertebereich von 0 bis 9 heraus. Wenn ich also das Bild einer 3 links reinstecke, sollte das Netz eine drei auf der anderen Seite ausspucken. Leider funktioniert das so aber nicht. Ein Netz kann nur eine Wahrscheinlichkeit vorhersagen. Es kann also sagen: Mit einer Wahrscheinlichkeit von 80% handelt es sich um eine drei aber mit einer Wahrscheinlichkeit von 20% kann es auch eine Neun sein.
Also modellieren wir den Vektor O mit der Länge 10 und einem Wertebereich zwischen 0 und 1. Jede Stelle in dem Vektor steht für eine Zahl und der Wert für die Wahrscheinlichkeit, dass es sich um genau diese Zahl handelt (1 = 100%).
Wir müssen also nun alle Label in solche Vektoren umwandeln. Die Transformation nennt sich eine Binärtransformation und so einen Vektor nennt man in der IT einen One Hot Vector. Glücklicher Weise gibt es dafür bei Keras schon ein Util Funktion to_categorical:

#convert labels into one hot representation
train_labels_one_hot = to_categorical(train_labels)
test_labels_one_hot = to_categorical(test_labels)
print("The original label of the first sample\n{}".format(train_labels[0]))
print("The one hot label of the first sample\n{}".format(train_labels_one_hot[0]))

The original label of the first sample
5
The one hot label of the first sample[0. 0. 0. 0. 0. 1. 0. 0. 0. 0.] Ich möchte hier die Chance nutzen mir noch einen Überblick über die Balance der Samples zu schaffen. Stell Dir vor, wir hätten unter allen 60.000 Samples eine 0, eine 1, eine 2, eine 3, eine 4, eine 5, eine 6, eine 7, eine 8 und 59.991 mal eine handschriftliche 9. Das DNN wäre echt dumm, wenn es jemals etwas anderes vorhersagen würde als eine 9. Somit sollte man immer ein Auge darauf werfen, ob die Samples ausgewogen sind.
Ein Blick auf das Histogramm sagt mir, dass ich auf der sicheren Seite liege und erst mal keine überflüssigen Beispiele herausfiltern werde.
Wer übrigens noch einmal sein Wissen über Standardabweichungen und Histogramme auffrischen will, der kann sich dieses witzig gemachte Video anschauen Varianz und Standardabweichung – Stochastik

#check if the samnples are balanced
balance = np.sum(train_labels_one_hot, axis = 0)
print("Samples per digit:\n{}".format(balance))
mean = balance - balance.mean()
print("Distance to the mean:\n{}".format(mean))
plt.hist(np.arange(10), weights=balance, density=True, facecolor='g', alpha=0.75)
plt.xlabel('Drawn Digit')
plt.ylabel('Deviation of Samples')
plt.title('Histogram Drawn Digit Samples')
plt.axis([0, 9, 0, 0.15])
plt.grid(True)
plt.show()
print("Abb 3: Die Verteilung der Trainingsdaten")
print("")

Samples per digit:[5923. 6742. 5958. 6131. 5842. 5421. 5918. 6265. 5851. 5949.] Distance to the mean:[ -77. 742. -42. 131. -158. -579. -82. 265. -149. -51.]
Abb 3: Die Verteilung der Trainingsdaten

Das Model Trainieren

Ich werde hier mit dem aller einfachsten Typ von DNN beginnen. Dem Fully Connected Neural Network. Neuronale Netze sind in Schichten sogenannte Layer aufgebaut. Ein Layer verfügt im Kern immer über eine Menge von Gewichten, die in der Trainingsphase eingestellt werden müssen. Dazu schickt man einige wenige Samples durch das Netz, vergleicht das Ergebnis mit dem Sollwert, propagiert den Fehler in die Layer zurück und passt die Gewichte entsprechend an. Die Gewichte sind also die Stellschrauben, mit denen man justiert, wie wichtig eine Information ist oder nicht.
Ich fange hier mit einem ganz kleinen Set von Layern an:
1. Über models.Sequential()holt man sich von keras ein frisches DNN
2. Der Reshape Layer nimmt die 28 x 28 Graustufenmatrix der Samples entgegen und transformiert sie in einen Vektor der Länge 784. Die Features werden also nur anders arrangiert aber nicht verändert. Somit ist das nur ein kleines Util, dass mir die lästige Arbeit abnimmt
3. Die Dense Layer sind eben jene fully connected layers. Sie multiplizieren einfach nur jedes Feature mit jedem Gewicht. Dabei wird der Eingabevektor I Schritt für Schritt bis hin zum Zielvektor O verkleinert. Hier von 784 (28 x 28) über 324 und 36 bis hin zu 10. 10 ist genau die Binärausgabe, auf die wir unsere Label transformiert haben.
4. Die Aktivierungsfunktion activation ist hier mit der sigmoid für die Hidden Layer ,also die inneren Schichten und der softmax sinnvoll vorbelegt. Die Aktivierungsfunktion ist der nicht lineare Anteil eines DNN und steuert, ab welchem Schwellwert ein Ergebnis von einem Layer zum anderen weitergegeben wird. Dadurch lässt sich ein Rauschen im Netz unterdrücken. Bildlich gesprochen ist das eine Art Türwächter, der die Tür vor einem Knoten eines Layers erst aufmacht, wenn sehr laut an die Tür geklopft wird. Somit können sich zum Beispiel viele leise Töne bemerkbar machen, wenn sie zusammen auftreten, wohingegen ein einzelner leiser Ton einfach ignoriert wird. Das Gewicht wiederum stellt sicher, dass wichtige aber nur sehr leise Töne entsprechend verstärkt werden, damit sie auch alleine durch die Tür der Aktivierungsfunktion kommen. Eine Liste der Bereits in Keras verbauten Aktivierungsfunktionen findest Du hier Keras Activations. Die richtige oder beste Aktivierungsfunktion zu erkennen, erfordert allerdings ein wenig Erfahrung und Verständnis, wie die Mathematik in den Layern funktioniert.
5. Nachdem ich alle meine Layer zusammengesteckt habe, rufe ich das Kompilieren des Modells auf. Dazu übergibt man die Meta Parameters der optimizerund die metrics kann man bei sehr vielen DNN einfach so lassen. Der optimizer ist der Algorithmus, der über das Gradientenverfahren die perfekten Gewichte finden soll.
6. über summary kann man sich das Modell dann noch einmal anzeigen lassen.

network = models.Sequential()
network.add(layers.Reshape((28 * 28,), input_shape=(28,28)))
network.add(layers.Dense(324, activation='sigmoid'))
network.add(layers.Dense(36, activation='sigmoid'))
network.add(layers.Dense(10 , activation='softmax'))
network.compile(optimizer='rmsprop', loss='categorical_crossentropy', metrics=['accuracy'])
network.summary()

Layer (type) Output Shape Param #

reshape_1 (Reshape) (None, 784) 0


dense_1 (Dense) (None, 324) 254340


dense_2 (Dense) (None, 36) 11700


dense_3 (Dense) (None, 10) 370

Total params: 266,410
Trainable params: 266,410
Non-trainable params: 0


Nun kann man anfangen, sich ins perfekte Modell reinzudrehen. Über die Funktion fit startet die Trainingsphase des Netzes. Man übergibt ihr:
1. den Eingabevektor I train_imagesund
2. den erwarteten Ausgabevektor O train_labels_one_hot
3. Die Anzahl der Epochen epochs, die durchlaufen werden sollen. Das ist die Zahl, wie oft man alle Trainingsdaten hintereinander durch das Netz schieben soll um es zu trainieren. Es ist klar, dass sich das Ergebnis irgendwann nicht mehr verbessert. Dann hat das Netz quasi alles aus den Trainingsdaten herausgeholt, was es konnte und es bleiben nur noch die „Schalen“ übrig.
4. Die Größe eines Trainingsblocks die batch_size. Alle Trainingsdaten werden in einzelne Segmente unterteilt. Die Größe eines dieser Segmente wird über die batch_size geregelt. Dabei werden erst alle Samples eines Batches durch das Netz gedrückt. Der Fehler wird mit der loss Funktion aufsummiert, um nachher auf alle Gewichte verteilt (Back Propagation) zu werden, damit man dann mit der optimizer Funktion die Gewichte ein Stück anpassen kann. Ist die batch_size also 1 wird der Optimizer nach jedem Durchgang angeworfen und ist die batch_size gleich der Menge der Samples wird der Optimizer nur einmal per Epoche verwendet. Die Zahl sollte immer ein Wert aus der Reihe 2 hoch x sein also 2,4,8,16,32,64,182,256,512,.. um von der GPU effektiv berechnet werden zu können. Wer nun denkt, eine batch_size von 1 ist damit das Beste, irrt zumeist, da sich das Netz ja dann nur auf ein bestimmtes Sample hin optimiert. In unserem Fall wäre das dann eine bestimmte Ziffer. Man sollte dem Netz schon die Gelegenheit geben sich ein wenig breiter aufzustellen.
5. Der validation_split gibt an, wie viele Trainingsdaten das Netz nach jeder Epoche zum Validieren verwenden soll. Es werden also alle Trainingsdaten durch das Netz gepumpt, der Optimizer versucht die Gewichte anzupassen und dann wird über die validation Daten gemessen, wie gut das Netz nun schon funktioniert. Damit erkennt man bereits während des Trainings, wann die Testdaten ausgereizt sind und ein Overfitting eintritt. Dabei passt sich das Netz nur noch auf die Trainingsdaten an und wird zunehmend neuen Daten gegenüber intolerant.
6. Der shuffle Parameter gibt an, ob die Daten vor jeder neuen Epoche gemischt werden sollen. Das vermeidet, ein „Einbrennen“ von festen Abfolgen. Manchmal ist die Sequenz der Sample aber wichtig, dann muss man shuffle natürlich auf False setzen.

start = time.time()
history = network.fit(
train_images,
train_labels_one_hot,
epochs=20,
batch_size=512,
validation_split=0.2,
shuffle=True)
print("It took {} sec".format(time.time() - start))

Train on 48000 samples, validate on 12000 samples
Epoch 1/20
48000/48000 [==============================] – 2s 42us/step – loss: 1.0228 – acc: 0.8218 – val_loss: 0.5680 – val_acc: 0.9044
Epoch 2/20
48000/48000 [==============================] – 1s 17us/step – loss: 0.4490 – acc: 0.9084 – val_loss: 0.3345 – val_acc: 0.9224
Epoch 3/20
48000/48000 [==============================] – 1s 17us/step – loss: 0.3002 – acc: 0.9252 – val_loss: 0.2568 – val_acc: 0.9335
Epoch 4/20
48000/48000 [==============================] – 1s 17us/step – loss: 0.2397 – acc: 0.9344 – val_loss: 0.2174 – val_acc: 0.9405
Epoch 5/20
48000/48000 [==============================] – 1s 17us/step – loss: 0.2059 – acc: 0.9423 – val_loss: 0.1958 – val_acc: 0.9443
Epoch 6/20
48000/48000 [==============================] – 1s 17us/step – loss: 0.1831 – acc: 0.9467 – val_loss: 0.1826 – val_acc: 0.9491
Epoch 7/20
48000/48000 [==============================] – 1s 17us/step – loss: 0.1663 – acc: 0.9516 – val_loss: 0.1637 – val_acc: 0.9523
Epoch 8/20
48000/48000 [==============================] – 1s 19us/step – loss: 0.1527 – acc: 0.9549 – val_loss: 0.1570 – val_acc: 0.9523
Epoch 9/20
48000/48000 [==============================] – 1s 20us/step – loss: 0.1436 – acc: 0.9580 – val_loss: 0.1547 – val_acc: 0.9538
Epoch 10/20
48000/48000 [==============================] – 1s 23us/step – loss: 0.1337 – acc: 0.9599 – val_loss: 0.1408 – val_acc: 0.9576
Epoch 11/20
48000/48000 [==============================] – 1s 18us/step – loss: 0.1257 – acc: 0.9629 – val_loss: 0.1365 – val_acc: 0.9602
Epoch 12/20
48000/48000 [==============================] – 1s 21us/step – loss: 0.1202 – acc: 0.9647 – val_loss: 0.1396 – val_acc: 0.9582
Epoch 13/20
48000/48000 [==============================] – 1s 21us/step – loss: 0.1134 – acc: 0.9667 – val_loss: 0.1336 – val_acc: 0.9595
Epoch 14/20
48000/48000 [==============================] – 1s 17us/step – loss: 0.1114 – acc: 0.9662 – val_loss: 0.1286 – val_acc: 0.9619
Epoch 15/20
48000/48000 [==============================] – 1s 17us/step – loss: 0.1038 – acc: 0.9695 – val_loss: 0.1255 – val_acc: 0.9638
Epoch 16/20
48000/48000 [==============================] – 1s 17us/step – loss: 0.1007 – acc: 0.9706 – val_loss: 0.1244 – val_acc: 0.9634
Epoch 17/20
48000/48000 [==============================] – 1s 17us/step – loss: 0.0964 – acc: 0.9712 – val_loss: 0.1224 – val_acc: 0.9647
Epoch 18/20
48000/48000 [==============================] – 1s 18us/step – loss: 0.0935 – acc: 0.9715 – val_loss: 0.1202 – val_acc: 0.9641
Epoch 19/20
48000/48000 [==============================] – 1s 18us/step – loss: 0.0872 – acc: 0.9742 – val_loss: 0.1160 – val_acc: 0.9689
Epoch 20/20
48000/48000 [==============================] – 1s 18us/step – loss: 0.0844 – acc: 0.9748 – val_loss: 0.1163 – val_acc: 0.9675
It took 18.941316604614258 sec
Man kann sich den Trainingsverlauf auch visualisieren.
1. Dabei werden pro Epoche die Genauigkeit der Trainingsdaten mit der Genauigkeit der Validierungsdaten ins Verhältnis gesetzt.
2. Zudem kann man sich auch das Verhältnis der Verlustfunktionen anzeigen lassen.
Nun spielt man an den Meta Parametern, der Anzahl an Layern und an der „Höhe“ der Layer solange herum, bis man ein Optimum erreicht hat. Der Wert der Vorhersage sollte dabei möglichst hoch sein und die beiden Linien sollten sich möglichst nicht voneinander trennen. Irgendwann flacht die Kurve der Validierungsdaten deutlich ab. Daran erkenn man die maximale Anzahl der Epochen. Der Bereich rechts davon wird als Overfitting bezeichnet.
Es gibt eine Faustregel bei den DNN: Fange immer mit dem kleinsten aller möglichen Netze an. Das bedeutet also; verwende so wenige Layer wie möglich und mach sie so klein wie möglich. Das liegt am folgenden einfachen Sachverhalt: Die Anzahl der Layer mal der Größe des Eingabevektors ergibt die Dimensionen des Hypothesen Raumes Das ist wirklich ein geometrischer hochdimensionaler Raum. In unserem Beispiel spielen wir in einem Raum mit bis zu 784 x 3 Dimensionen. Durch diesen Raum spinnt sich die Fläche der möglichen Lösungen. Ein Tiefpunkt in dieser Fläche stellt eine mögliche Lösung dar. Je mehr Dimensionen man aber hat, umso mehr mögliche Tiefpunkte besitzt die Lösungsfläche. Jeder Tiefpunkt ist dabei eine mathematisch plausible Lösung, hat mit der Realität aber vielleicht nichts zu tun. Das ist so ähnlich wie mit den Pyramiden in Ägypten. Für die einen, die in einem niedrig – dimensionalen Raum leben, handelt es sich dabei einfach um sehr große und sehr alte Gräber von Pharaonen. Für die Anderen handelt es sich dabei um Landerampen von Außerirdischen Raumschiffen. Das mag irgendwie plausibel sein aber wahrscheinlich ist dann doch eher die einfachere Lösung. Man bezeichnet das als Ockhams Rasiermesser.

acc = history.history['acc']
val_acc = history.history['val_acc']
loss = history.history['loss']
val_loss = history.history['val_loss']
epochs = range(1, len(acc) + 1)
plt.plot(epochs, acc, 'bo', label='Training acc')
plt.plot(epochs, val_acc, 'b', label='Validation acc')
plt.title('Training and validation accuracy')
plt.legend()
plt.show()
print("Abb 4: Anstieg der Genauigkeit über die Epochen")
print("")
plt.plot(epochs, loss, 'bo', label='Training loss')
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.legend()
plt.show()
print("Abb 5: Abnahme des Fehlers über die Epochen")
print("")


Abb 4: Anstieg der Genauigkeit über die Epochen

Abb 5: Abnahme des Fehlers über die Epochen
Mach Dir den Spaß und versuche eine optimale Konfiguration des Models zu finden. Wenn du über 97% Genauigkeit kommen solltest, schreib mir eine Nachricht. Übrigens kannst Du natürlich auf andere Aktivierungsfunktionen verwenden. Eine ganz gute wäre noch die ‚relu‘.
Du kannst auch versuchen die Grauwerte der Samples im Wertebereich von 0 bis 1 zu skalieren. Da verhalten sich einige Aktivierungsfunktionen besser als mit den großen Zahlen.

Testen

Wenn Du Dich in ein gutes Model gedreht hast, ist es Zeit, mit deinen frischen und unberührten Testdaten den finalen Test anzutreten. Für diese sehr einfache und für die Bilderkennung auch eher ungeeignete Netzarchitektur sind 97% ein wirklich gutes Ergebnis.

(test_loss, test_acc) = network.evaluate(test_images, test_labels_one_hot)
print("Loss: ", test_loss)
print("Accuracy: ", test_acc)

10000/10000 [==============================] – 1s 57us/step
Loss: 0.11395154709517956
Accuracy: 0.9652

Speichern

Normalerweise dauert es einige Zeit bis ein Model trainiert ist. Da können schon mal einige Wochen ins Land gehen. Das heißt, man sollte seine Arbeit speichern, um sein Model später produktiv nutzen zu können. Wie man ein Model hinter REST Services bereitstellt, wird übrigens einer der nächsten Blogs werden.
Das Model wird in zwei verschiedenen Dateien gespeichert.
1. Die Konfiguration des Models
2. Die Trainierten Gewichte

# serialize model to JSON
model_json = network.to_json()
with open("persist_model/simple_MNIST_model.json", "w") as json_file:
json_file.write(model_json)
# serialize weights to HDF5
network.save_weights("persist_model/simple_MNIST_model.h5")
print("Saved model to disk")

Saved model to disk

Für Neugierige

Eigentlich ist der klassische Arbeitsauftrag ein perfekt trainiertes Model zu bauen nun zu Ende. Ich möchte aber noch zwei Punkte beleuchten, die meine persönliche Neugier geweckt haben:

Wie „sieht“ ein DNN die Welt?

Vielleicht ist Dir ja aufgefallen, dass ich für die Anzahl der Knoten meiner Layer immer Quadratzahlen verwendet habe. Das habe ich gemacht, damit die Grundkonfiguration der Bilder erhalten bleibt und das Bild quasi mehr und mehr zusammengedrückt wird. Nun habe ich nämlich die einfache Möglichkeit mir die Gewichte visualisieren zu lassen. Je heller die Farbe desto größer ist das Gewicht. Das heißt, dort werden auch leise Werte deutlich gemacht, wohingegen dunkle Farben symbolisieren, dass dort auch kräftige Werte unterdrückt werden müssen. Ich denke, man kann daraus nicht allzu viel ableiten, zumal der geometrische Ort der geschriebenen Ziffern durch das Ausmultiplizieren der Gewichte ja verloren gegangen ist. Interessant wäre es aber im Rahmen einer Fehlersuche darauf zu achten, ob man extreme Ausreißer hat. Zudem ist in diesen Bildern die komplette Information gespeichert, wie man handgeschriebene Zahlen erkennt. Das hat für mich eine gewisse Schönheit.

weights = network.get_weights()
def show_weights(pic):
im = plt.imshow(pic)
values = np.unique(np.linspace(pic.min(), pic.max(), 10).ravel())
colors = [ im.cmap(im.norm(value)) for value in values]
patches = [ mpatches.Patch(color=colors[i], label="Level {l}".format(l=values[i]) ) for i in range(len(values)) ]
plt.legend(handles=patches, bbox_to_anchor=(1.05, 1), loc=2, borderaxespad=0. )
plt.show()
show_weights(weights[1].reshape(18,18))
print("Abb 6: Die Gewichtsmatrix des ersten Layers mit 18x18 Knoten")
print("")
show_weights(weights[3].reshape(6,6))
print("Abb 7: Die Gewichtsmatrix des zweiten Layers mit 6x6 Knoten")
print("")
show_weights(weights[5].reshape(10,1))
print("Abb 8: Die Gewichtsmatrix des letzten Layers mit 10 Knoten")
print("")


Abb 6: Die Gewichtsmatrix des ersten Layers mit 18×18 Knoten

Abb 7: Die Gewichtsmatrix des zweiten Layers mit 6×6 Knoten

Abb 8: Die Gewichtsmatrix des letzten Layers mit 10 Knoten

Visualisieren der Fehler

Um die 97% unserer Zahlen werden nun richtig erkannt, aber welche Zahlen erkennt unser Modell denn nicht und warum?
Ich habe mir dazu einige richtig erkannte Zahlen und einige falsch erkannt Zahlen anzeigen lassen.
Man erkennt hier recht deutlich, warum das Netz Probleme mit den falsch erkannten hatte: Sie sind mehr oder weniger hingeschliert. Auf der anderen Seite sind die Zahlen für das menschliche Auge immer noch richtig gut zu erkennen. Es handelt sich hierbei also nicht um Grenzfälle, wo man auch als Mensch unsicher ist, ob das nun eine 3 oder eine 9 ist.
Um diese Schwäche des Models auszumerzen braucht man aber eine andere Architektur, nämlich eine, die alle Merkmale einer Ziffer sammelt und zur Bestimmung heranzieht. Darüber werde ich in meinem nächsten Blog schreiben (cliff hanger ;-)).

# let's play a bit with the test data
results = network.predict(test_images)
#lets see how the result looks like
print("On example label vektor:\n{}".format(results[0]))
print("The max value out of it:\n{}".format(results[0].max()))
print("The position of the max value in the vector:\n{}".format(results[0].argmax()))
print("show some true ones:")
l = 5
b = 10
count = 0
for index, result in enumerate(results):
predict = result.argmax()
expect = test_labels[index]
if predict == expect:
count += 1
plt.subplot(l,b,count)
plt.title(str(predict) + "=" + str(expect))
plt.axis('off')
plt.imshow(test_images[index])
if count >= l * b:
break
plt.show()
print("Abb 9: Einige richtig erkannte Test - Ziffern")
print("")
print("show some wrong ones:")
count = 0
for index, result in enumerate(results):
predict = result.argmax()
expect = test_labels[index]
if predict != expect:
count += 1
plt.subplot(l,b,count)
plt.title(str(predict) + "≠" + str(expect))
plt.axis('off')
plt.imshow(test_images[index])
if count >= l * b:
break
plt.show()
print("Abb 10: Einige falsch erkannte Test - Ziffern")
print("")

On example label vektor:[1.1954846e-05 6.8768364e-05 1.6366718e-04 8.3673972e-04 1.1831850e-05
6.2164967e-05 3.2683665e-06 9.9870729e-01 1.8144203e-05 1.1619164e-04] The max value out of it:
0.9987072944641113
The position of the max value in the vector:
7
show some true ones:

Abb 9: Einige richtig erkannte Test – Ziffern
show some wrong ones:

Abb 10: Einige falsch erkannte Test – Ziffern

Noch zwei Schmankerl

zum Schluß möchte ich noch die zwei extremsten Fälle herausfiltern.
1. Wie sieht die Ziffer aus, die gerade noch als richtig erkannt wurde? Bei der sollte der Maximalwert in ihrem Ergebnisvektor ja am kleinsten von allen Testergebnissen sein.
2. Wie sieht die Ziffer aus, die am „falschesten“ erkannt wurde? Bei der sollte der Maximalwert in ihrem Ergebnisvektor ebenfalls am kleinsten von allen Testergebnissen sein.

print("Find the biggest corner case:")
# index in the result vector ==> position in the vector ==> smallest maximum
min_v = (0, 0, 1)
for index, result in enumerate(results):
pos = result.argmax()
max_v = result.max()
if min(min_v[2], max_v) is max_v:
min_v = (index, pos, max_v)
print("The result with the smalles max value has index {}. The position of the value is {} and the max value itself is {}".format(min_v[0],min_v[1],min_v[2]))
print("This is the whole result vector:\n{}".format(results[min_v[0]]))
predict = min_v[1]
expect = test_labels[min_v[0]]
plt.title("{}={}".format(predict, expect))
plt.axis('off')
plt.imshow(test_images[min_v[0]])
plt.show()
print("Abb 11: Der härteste Grenzfall, der noch richtig erkannt wurde. Die Vorhersagegenauigkeit war hier nur {0:2.2f}%".format(min_v[2]*100))
print("")

Find the biggest corner case:
The result with the smalles max value has index 7921. The position of the value is 8 and the max value itself is 0.20387263596057892
This is the whole result vector:[0.01079895 0.14439031 0.08587827 0.0804871 0.03792836 0.171636
0.1203277 0.02258283 0.20387264 0.12209784]
Abb 11: Der härteste Grenzfall, der noch richtig erkannt wurde. Die Vorhersagegenauigkeit war hier nur 20.39%

print("Find the biggest corner case:")
# index in the result vector ==> position in the vector ==> smallest maximum
min_v = (0, 0, 1)
for index, result in enumerate(results):
pos = result.argmax()
if pos == test_labels[index]:
continue
max_v = result.max()
if min(min_v[2], max_v) is max_v:
min_v = (index, pos, max_v)
print("The result with the smalles max value has index {}. The position of the value is {} and the max value itself is {}".format(min_v[0],min_v[1],min_v[2]))
print("This is the whole result vector:\n{}".format(results[min_v[0]]))
predict = min_v[1]
expect = test_labels[min_v[0]]
plt.title("{}≠{}".format(predict, expect))
plt.axis('off')
plt.imshow(test_images[min_v[0]])
plt.show()
print("Abb 12: Der härteste Grenzfall, der falsch erkannt wurde. Hier wurde zu {0:2.2f}% die {1} gewählt. Das richtige Ergebnis nämlich die {2} wurde zu {3:2.2f}% erkannt. Knapp daneben ist auch vorbei.".format(min_v[2]*100, predict, expect, results[min_v[0]][expect]*100))
print("")

Find the biggest corner case:
The result with the smalles max value has index 3853. The position of the value is 5 and the max value itself is 0.27323389053344727
This is the whole result vector:[0.18453261 0.02215308 0.13388705 0.00051565 0.00919781 0.2732339
0.26515973 0.00232244 0.10765679 0.00134091]
Abb 12: Der härteste Grenzfall, der falsch erkannt wurde. Hier wurde zu 27.32% die 5 gewählt. Das richtige Ergebnis, nämlich die 6 wurde zu 26.52% erkannt. Knapp daneben ist auch vorbei.

Jetzt teilen auf:

4 Comments

  1. Valerius 16. September 2019 at 15:37 - Reply

    Guten Tag,
    ich finde ihren Bericht wirklich sehr interessant!
    Können Sie vielleicht noch ihre Quellen (Bücher,Online) angeben?
    Ich wäre ihnen sehr Dankbar. :D
    Mit freundlichen Grüßen

  2. Johannes Höhne 17. September 2019 at 14:31 - Reply

    Hallo.
    Freut mich, dass Sie meinen Blog mögen :)
    Mir hat folgendes Buch wirklich gut geholfen:
    Deep Learning mit Python und Keras: Das Praxis-Handbuch vom Entwickler der Keras-Bibliothek (mitp Professional)
    Viel Spaß beim Lesen
    Gruß
    Johannes

  3. Tobias 30. September 2020 at 9:26 - Reply

    Hallo Herr Höhne,
    auch meinerseits noch ein großes Lob zu diesem Artikel. :)
    Ich hätte nur die Frage, wie genau dann eine Zahl aus einem neuen Bild vorhergesagt werden kann.
    Also ich trainiere und speichere das Modell wie in Ihrem Artikel beschrieben, dann lade ich dieses Modell in eine neue Anwendung. Doch wie bekomme ich nun eine Vorhersage welche Zahl in einem von mir extra hochgeladenen Array zu sehen ist?
    Wie genau kann ich mir die Umsetzung dort vorstellen?
    Schon mal vielen Dank im Voraus.

    • Johannes 5. Oktober 2020 at 8:19 - Reply

      Hallo
      Vielen Dank fürs Lesen meines Blogs.
      Ich habe ebenfalls ein Ende zu Ende Beispiel erstellt:
      https://www.mt-itsolutions.com/blog/ki-werkstatt/so-entwirft-man-ein-top-cnn/
      Dort ist beschrieben, wie man ein Model abspeichert und nachher wieder in den Speicher lädt.
      Wenn man das fertige Model verwenden möchte, ruft man die Funktion {mein geladenes model}.predict({der Bildevektor}) => Ergebnisvektor auf.
      Als Code ist das also:
      # Generate predictions for samples
      predictions = model.predict(samples_to_predict)
      print(predictions)
      Gruß
      Johannes

Jetzt kommentieren