Neuronales Netz – Erklärung des Beispielcodes

Edit: Sorry, der Link für das Beispiel von joelgrus war kaputt, ich habe ihn wieder aktualisiert. 

Neuronale Netze sind wirklich nicht einfach zu verstehen. Bereits die Theorie ist schwierig und vielfältig, und dann soll man das Ganze noch in der Python Implementation verstehen? Ich selber habe schon stundenlang den Code studiert und bin doch immer wieder verwirrt.

In diesem Beitrag werde ich das Beispiel aus dem Buch Einführung in Data Science erklären. Ich werde den Code auseinandernehmen, alles Unwichtige löschen, deutsche Kommentare hinzufügen und mich auf das Wesentliche beschränken.

So hoffe ich, kann ich den Code herunterbrechen, als dass auch Nicht-Albert-Einsteins eine Chance haben, diesen zu verstehen.

Das Beispiel ist öffentlich verfügbar unter dieser Adresse:
https://github.com/joelgrus/data-science-from-scratch/blob/master/first-edition/code-python3/neural_networks.py

Mein angepasster Code ist ganz unten in diesem Beitrag zu finden und kann direkt etwa in PyCharm laufen gelassen werden.

Was für ein neuronales Netzwerk baut das Beispiel?

Der Beispielcode baut ein neuronales Netzwerk, um die Zahlen zwischen 0 und 9 zu erkennen – Im Buch ist quasi die Idee, bei einem Captcha die Zahlen herauszufinden.

Wie sehen die Zahlen 0-9 als Eingabe aus?

Konkret hat man jede der 10 Zahlen in einem Vektor mit jeweils 25 Zeichen. Na, das kann man sich ja jetzt super vorstellen.

Eine Null sieht etwa so aus:

"""11111
   1...1
   1...1
   1...1
   11111""",

Und eine Vier so:

"""1...1
   1...1
   11111
   ....1
   ....1"""

Und so weiter. Wenn man das neuronale Netz trainiert, werden die Punkte in Nullen umgewandelt und das Ganze ist dann ein langer Vektor.

Für das Training des Netzwerkes wird aus der Null also dann sowas:

[1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1]

Das Ziel ist, dass man am Ende dem Netz einen Vektor geben kann, und wenn dieser ähnlich wie dieser Nullvektor aussieht (er muss nicht genau übereinstimmen), sagt das Netz korrekt eine Null vorher.

Wie sieht denn die Ausgabe aus?

Die Eingabe ist also ein 25-stelliger Vektor für jede Zahl 0 bis 9. Die Ausgabe ist ein 10-stelliger Vektor mit lauter Nullen ausser jeweils bei der Zahl, die er voraussagen will.

Eine Null hat also diesen Eintrag:

[1, 0, 0, 0, 0, 0, 0, 0, 0, 0]

Und eine 4 hat diesen Eintrag:

[0, 0, 0, 0, 4, 0, 0, 0, 0, 0]

Wie üblich beginnt ein Vektor mit der Position 0, nicht bei 1.

Wie wird die Ausgabe eines Neurons berechnet?

Wie in meinem Beitrag Neuronale Netze Tutorial – Übersicht der Konzepte oder ML: Was ist der Perzeptron Lernalgorithmus? beschrieben, wird auch hier für jedes Neuron zuerst die Nettoeingabe berechnet (= Skalarprodukt) und dann alles in die Sigmoid Funktion gehauen, was einen Wert zwischen 0 und 1 für die folgende Schicht ergibt.

Kurze Repetition Skalarprodukt: Jedes Neuron hat ja etliche, sagen wir beispielsweise 26 Verknüpfungen, welche von der vorherigen Schicht in dieses Neuron hineinführen – das sind die x-Werte. Und jede Verknüpfung hat ein Gewicht, was ja eben die Gewichte sind, die wir mit dem Netz trainieren wollen – Das sind die w-Werte.

Und das Skalarprodukt ist = x1 * w1 + x2 * w2 + x3 * w3 etc.

Das ergibt also eine Zahl, die man in die Sigmoid-Funktion gibt und etwas zwischen 0 und 1 herausbekommt.

Im Code werden die Gewichte weights genannt und die x-Werte einfach inputs.

def dot(v,w):
 """ Skalarprodukt zweier Vektoren = Summe der Produkte 
 der Komponenten
 v_1 * w_1 + v_2 * w_2 + ... + v_n * w_n """
 return sum(v_i * w_i
 for v_i, w_i in zip(v,w))

def sigmoid(t):
 return 1 / (1 + math.exp(-t))

def neuron_output(weights, inputs):
 """Hier passiert das Multiplizieren der Gewichte 
 w * Inputs i und das Anwenden der Sigmoid Funktion.
 Die Gewichte sind um 1 länger als die Inputs
 weil das Bias noch am Ende angehängt wird"""
 return sigmoid(dot(weights, inputs))

Moment mal, wieviele Schichten und Neuronen haben wir?

Bei der Initialisierungphase des Codes sehen wir, wie das neuronale Netz aufgebaut wird:

random.seed(0) # Setze seed damit jeder Durchlauf gleich ist
 input_size = 25 # Jede Eingave ist ein Vektor mit Länge 25
 num_hidden = 5 # Wir haben 5 Neuronen in der versteckten Schicht
 output_size = 10 # Wir haben 10 Neuronen in der Ausgabeschicht

Wie man hier sieht, haben wir drei Schichten:

  • Die Eingabeschicht sind die oben erwähnten 25-langen Vektoren.

Also moment mal. Das bedeutet: EIN 25-langer Vektor ist auch nur EINE Zahl, die man „gleichzeitig“ trainiert. Anders gesagt: Man trainiert zuerst die 0, dann die 1, bis zur 9 und fängt dann wieder bei 0 an.

  • Die versteckte Schicht hat 5 Neuronen.

Warum genau 5? Hmm keine Ahnung – Aber auf jeden Fall kann man diesen Wert später auch ändern. Grundsätzlich sollte man so programmieren, dass man immer dynamisch auf den verwendeten Wert reagiert. Egal ob es jetzt 5 oder 10 Neuronen in dieser Schicht sind, der Code sollte deswegen nicht abstürzen.

  • Die Ausgabeschicht hat wie bereits erwähnt 10 Ausgabeneuronen, nämlich genau die 10 Zahlen, die wir voraussagen wollen.

Wie wird der Eingabe- und Ausgabevektor initialisiert?

Für die Eingabewerte, also diese komischen 25-stelligen Vektoren, wird die Python Funktion map verwendet. Diese führt für jede der raw_digits die Funktion make_digit aus, und die wiederum ändert jeden Punkt zu einer 0.

# map function: Fuehre die Funktion make_digit mit jedem
 # raw_digits Teil aus und gib es zurück
 # Auf Deutsch: Mach aus jedem Punkt der raw_digits eine 0
 inputs = list(map(make_digit, raw_digits))

Der Ausgabevektor beziehungsweise Zielvektor braucht die bereits erwähnten 10 Einträge der Zahlen 0-9 in dieser speziellen Vektorform, bei der die jeweilige Zahl eine 1 ist und alle andern eine 0.

# Die richtigen Ausgaben = 10 Zeilen
 # vier ist etwa
 # 0 1 2 3 4 5 6 7 8 9
 # [0, 0, 0, 0, 1, 0, 0, 0, 0, 0]
 targets = [[1 if i == j else 0 for i in range(10)]
 for j in range(10)]

inputs und targets können dann für das Trainieren des Netzes verwendet werden.

Wie wird das neuronale Netz gebaut und initialisiert?

Eine einzige Variable namens network vereint alle Informationen zum Netzwerk. Als oberste Stufe ist es eine Liste mit zwei Einträgen, nämlich den 0-er Index für die versteckte Schicht und den 1-er Index für die Ausgabeschicht.

network

Im Screenshot sieht man schön die zwei obersten Einträge für die Schichten, dann jeweils 5 beziehungsweise 10 Einträge für die Neuronen.

Ich bin unzählige Male durcheinandergekommen, welcher Eintrag jetzt warum wieviele Untereinträge hat. Darum hier ein Screenshot vom Netz der versteckten Schicht. Man sieht, dass jedes Neuron 25 Gewichte trägt.

network-input-weights

Und hier ein Screenshot von der Ausgabeschicht. Jedes Neuron trägt die Gewichte der 5 vorherigen Neuronen der versteckten Schicht.

network-output-weights

Für die Gewichte werden übrigens zufällige Werte genommen:

# initialisiere hidden und output weights mit random Werten
 # ---------------------------------------------------------
 # jedes versteckte Neuron hat ein Gewicht pro Eingabe plus 
 # ein Bias Gewicht
 hidden_layer = [[random.random() for __ in range(input_size + 1)]
 for __ in range(num_hidden)]

# jedes Ausgabe-Neuron hat ein Gewicht pro Eingabe plus 
# ein Bias Gewicht
 output_layer = [[random.random() for __ in range(num_hidden + 1)]
 for __ in range(output_size)]

# das Netzwerk startet mit zufälligen Gewichten
 network = [hidden_layer, output_layer]

Bei beiden Schichten wird noch ein Bias hinzugefügt, damit die Werte nicht durch den Nullpunkt gehen (wenn man diesem https://www.youtube.com/watch?v=biLLUSkohiY Video glauben mag. Das muss ich später noch einmal untersuchen).

Zwischen der Eingabe- und der versteckten Schicht haben wir dann also

26 * 5 = 130 Gewichte

Zwischen der versteckten und der Ausgabe-Schicht haben wir

6 * 10 = 60 Gewichte

So, der einfache Teil ist somit gemacht. Jetzt kommen nur noch zwei schwere Teile und das neuronale Netz ist fertig.

Wie wird das Netz trainiert und später verwendet?

Das Trainieren wird mit der Funktion backpropagate gemacht. Dieser übergibt man das Netzwerk, den Eingabe- und Ausgabe-Vektor.

# Trainiere das neuronale Netz
 # 10,000 Iterationen sind genug um zu konvergieren
 for __ in range(10000):
   for input_vector, target_vector in zip(inputs, targets):
     backpropagate(network, input_vector, target_vector)

Hier sieht man auch den Code, den ich schon erwähnt hatte: Für jede Zahl im Eingabevektor (also die Zahlen von 0-9) wird die Funktion backpropagate aufgerufen – aber stets einzeln, da eine Zahl ja ein 25-stelliger Vektor von 0en und 1en ist. Und das ganze 10’000 Mal, um die Gewichte zu trainieren.

Das Vorhersagen oder Bestimmen von Zahlen wird mit der Funktion predict gemacht. Dieser übergibt man einen 25-stelligen Vektor und erhält dann die Antwort, um welche Zahl es sich gemäss Netz handelt.

def predict(input):
 # Das Resultat der Ausgabe ist im letzten Index, also im -1
 return feed_forward(network, input)[-1]

Total easy oder! Haha… Ja, dieser Code ist noch easy, der komplizierte Teil ist aber in diesen beiden Funktionen, und die schauen wir uns jetzt an.

Die Funktion feed_forward

Wir beginnen mit dieser Funktion, da sie dann von der Funktion backpropagate verwendet wird (und auch einfacher zu verstehen ist, die schwerste Funktion kommt quasi als „Endboss“ am Schluss).

Ich habe dem Code tonnenweise Kommentare hinzugefügt. Das mache ich normalerweise natürlich nicht, sondern nur, wenn ich etwas nicht verstehe.
(Also mache ich es eigentlich fast immer… nein nein kleiner Scherz 😉 )

Jetzt muss ich nochmals einen wichtiger Punkt erwähnen: Die Variable neural_network in dieser Funktion beinhaltet die gesamte Struktur des Netzes – und das sind drei Ebenen: Zuerst die zwei Schichten (versteckte und Ausgabeschicht), dann für jede Schicht die entsprechenden Neuronen und dann noch für jedes Neuron die Gewichte aller Verknüpfungen, die in dieses Neuron führen.

3 Ebenen: Schichten -> Neuronen -> Gewichte

Darum wird in der Funktion feed_forward zuerst über die zwei Schichten iteriert. Dann wird mit der Super-Sigmoid-Funktion neuron_output der Ausgabewert jedes Neurons berechnet, wie am Anfang des Posts erklärt.

Und wie schon gesagt bedeutet das, für jedes Neuron werden seine Gewichte mit den Eingabewerten multipliziert und addiert (Skalarprodukt) und das Resultat in die Sigmoid-Funktion geprügelt.

Die Gewichte haben ja bereits das Bias erhalten, deshalb muss jetzt der Eingabevektor auch ein Bias erhalten, damit die Vektoren die gleiche Länge haben und skalarproduktet werden können.

def feed_forward(neural_network, input_vector):
 """Nimmt ein Neuronales Netzwerk entgegen - Darin sind 
 die Gewichte gespeichert!
 Es ist eine list (Schichten) of lists (Neuronen) of lists (weights)
 Gehe durch jeden Layer und dann durch jeden Neuron.
 Für jeden Neuron, berechne das Skalarprodukt und 
 dann den Sigmoid Wert"""

  outputs = []

# gehe durch die beiden Layers Hidden + Output
 for layer in neural_network:

# Fuege wieder das Bias hinzu
   input_with_bias = input_vector + [1]

 # Gehe durch jedes Neuron und aktualisiere die Gewichte 
 # mit Skalarprodukt
 # dann rechne noch für jedes Neuron den Sigmoid Wert aus
 # Für den Hidden Layer gibt das nur noch 5 Werte, weil 
 # es 5 Neuronen sind
 # (Einmal die Sigmoid Funktion pro Neuron)
 # Für den Output Layer gibt es 10 Werte
   output = [neuron_output(neuron, input_with_bias)
            for neuron in layer]

 # Speichere die zwei Output-Resultate (eines pro Layer)
   outputs.append(output)

 # Der Input zum nächsten Layer ist der Output von diesem Layer
 # So werden die Ausgaben vom Hidden Layer gleich an 
 # den Output Layer weitergereicht
   input_vector = output

return outputs

Ein ganz ganz wichtiger Punkt, den man hier im Code sieht, ist in der zweitletzten Zeile. Für die Sigmoidifizierung der versteckten-Schicht-Neuronen werden nämlich die Zahlen aus dem 25-stelligen Eingabevektor verwendet (was einer Zahl, etwa der 0, entspricht). Die Ausgabe der Neuronen der versteckten Schicht wird dann als Eingabe für die Berechnung der Sigmoid-Werte der Ausgabeschicht verwendet.

Wir erinnern uns: Jedes Neuron der Ausgabeschicht hat 6 Verknüpfungen von der vorherigen Schicht (5 Neuronen + Bias) und benötigt daher auch 6 x-Werte und Gewichte für die Sigmoid-Berechnung.

Und wie sieht outputs am Ende der Funktion aus? Nun wir haben die 5 berechneten Werte für die Neuronen der versteckten Schicht und 10 Werte für die Neuronen der Ausgabeschicht.

berechnete_outputs

Man beachte, dass die Funktion feed_forward den Target-Vektor nicht benötigt. Stattdessen nimmt es einen Eingabevektor, rechnet das Netz durch alle Schichten gemäss den Gewichten und am Ende erhält man in outputs[-1] die Vorhersage der Zahl 0 bis 9 (vorausgesetzt natürlich, das Netz wurde trainiert).

Die Funktion backpropagate

Bei dieser Funktion hatte ich am meisten Mühe, sie zu verstehen. Darum habe ich sie auch regelrecht mit Kommentaren vollgemüllt.

Ich werde versuchen, den Code Schritt für Schritt zu erklären – Ich bin nämlich fest überzeugt, dass die Schritte „theoretisch“ nicht sooo kompliziert sind.

Was macht die Funktion grundsätzlich? Es rechnet einmal durch das gesamte Netz, indem es die Funktion feed_forward aufruft. Die berechneten Werte verwendet sie dann, um die Fehler oder Deltas der aktuellen Gewichte herauszufinden und diese zu korrigieren.

Okay, ganz langsam… tief Luft holen…

Die Inputparameter der Funktion kennen wir immer noch: Es sind das neuronale Netz, dann der aktuelle Zahl (etwa der 25-stellige Vektor der Zahl 0) und den 10-stelligen Zielvektor der aktuellen Zahl.

Wenn also der Eingabevektor die 25 Zahlen für die 0 sind, dann steht in target folgendes:

input_vector = [1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1]
target = [1, 0, 0, 0, 0, 0, 0, 0, 0, 0]

Zuerst wird also die Funktion feed_forward aufgerufen und die berechneten Ausgabewerte der beiden Schichten entnommen.

Dann werden die Gewichte für die Ausgabe- und die versteckte Schicht aktualisiert.

Für die versteckte Schicht und die Ausgabeschicht sind es jeweils die gleichen zwei Schritte, die gemacht werden:
1. Zuerst wird der Fehler berechnet, den die Gewichte momentan noch haben.
2. Dann aktualisiere jedes Gewicht um den berechneten Fehler

Die Gewichte der Ausgabeschicht

Das Berechnen der Fehler, oder Deltas, wie sie im Code genannt werden, geschieht mit den von der feed_forward berechneten Ausgabewerte und dem Zielwert.

Einfach gesagt macht man folgendes:

Vorhandener Fehler = Berechneter Wert minus Zielwert

Wäre der berechnete Wert nämlich bereits der gewünschte Wert, würde die Rechnung Null ergeben und somit wäre auch der Fehler Null.

Was jetzt hier noch dazukommt, ist die Ableitung der Sigmoidfunktion. Das hat mit dem Gradientenverfahren zu tun. Das habe ich in meinem Artikel Neuronale Netze Tutorial – Übersicht der Konzepte unter dem Punkt 9. Oh Shit – der schwere Teil erkärt. Wer es aber wirklich im Detail wissen will, dem sei mein Lieblingsbuch Neuronale Netze selbst programmieren empfohlen. In diesem Buch Einführung in Data Science, von dem ich das Beispiel habe, wird einfach nur gezeigt, wie man es macht, aber nicht im Detail erklärt.

# Berechne nun die Fehler der berechneten Werte
# Wegen dem Gradientenverfahren wird aber nicht 
# einfach "output - target"
# gemacht sondern die Ableitung der Sigmoid Funktion mit einbezogen

# Die Formel "output * (1 - output)" ergibt sich aus der Ableitung
# der Sigmoid Funktion

# output_deltas sind dann die 10 Zahlen, die den Fehler 
# repräsentieren
 output_deltas = [output * (1 - output) * (output - target[i])
                  for i, output in enumerate(outputs)]

Man sieht im Code auch schön, wie durch die 10 Zahlen des Outputs sowie auch durch das target iteriert wird – Jede Zahl wird also einzeln auf einen Fehler durchgerechnet.

Die output_deltas sehen nach dem ersten Durchlauf so aus:

output-deltas

Hat man die Fehlerdeltas berechnet, kann man die Gewichte anhand derer aktualisieren:

# Aktualisiere die Gewichte vom Output Layer (network[-1])
# Gehe durch jedes Output Neuron (und dessen 5 Gewichte)
# Für jedes Output Neuron gehe durch die 
# berechneten Hidden Outputs
 for i, output_neuron in enumerate(network[-1]):
   for j, hidden_output in enumerate(hidden_outputs + [1]):
# Aktuelles Gewicht = Aktuelles Gewicht - berechneten Fehler 
# dieser Ausgabe
# * (mal) Berechnete Sigmoid Ausgabe dieses Neurons 
# (für die Gewichtung, wenn
# die Sigmoid Ausgabe klein war wird dieses Neuron weniger gewichtet)
     output_neuron[j] -= output_deltas[i] * hidden_output

Man geht durch jedes Neuron, und für jedes Neuron geht man durch die Gewichte, die in dieses Neuron führen. So wird jedes Gewicht gemäss dem Fehler nach oben oder unten korrigiert.

PS: output_neuron steht für ein einzelnes Neuron, welches 6 Gewichte hat (5 + 1 Bias). Man verwendet extra enumerate, damit man einen Index j hat, mit dem man durch die Gewichte iterieren kann.

output-neuron

Man beachte das kleine Minuszeichen vor -= welches bedeutet, dass man den rechten Term von output_neuron[j] abzieht.

Das sieht also eigentlich so aus:

output_neuron[j] = output_neuron[j] – output_deltas[i] * hidden_output

Debuggt man mit PyCharm durch den Teil sieht man schön die einzelnen Werte vor und nach der Berechnung:

output-neuron-debug

Die Gewichte der versteckten Schicht

Nun werden noch einmal diesselben zwei Schritte für die versteckte Schicht gemacht mit kleinen Unterschieden.

Die versteckte Schicht hat bekannterweise 5 Neuronen und erhält daher auch 5 Error-Werte.

# Die Formel für die Hidden deltas ist:
# error_hidden = error_output * gewichte_von_hidden_zu_output_layer

# Das erste Neuron ist ja mit der Ausgabeschicht durch 
# 10 Gewichte verbunden
# Der Fehler e für den ersten Hidden Knoten etwa berechnet sich so:
# e_hidden_1 = e_output_1 * Gewicht_zu_diesem_Knoten_1
# + e_output_2 * Gewicht_zu_diesem_Knoten_2 etc.
# darum kommt hier das dot(ausgabefehler, ausgabegewichte)
 hidden_deltas = [hidden_output * (1 - hidden_output) *
                dot(output_deltas, [n[i] for n in network[-1]])
                for i, hidden_output in enumerate(hidden_outputs)]

Um die Fehler herauszufinden, macht man jetzt quasi eine umgekehrte Nettoeingabe beziehungsweise Skalarprodukt, daher braucht man hier auch die dot Funktion. Statt den Eingabewerten und Gewichten verwendet man die Output-Werte der Ausgabeschicht und die… hmm ja gut die Gewichte werden auch verwendet. Also rechnet man quasi einfach das Skalarprodukt, aber statt von vorne nach hinten durch das Netz zu gehen kommt man vom Ende und rechnet sich nach vorne zur versteckten Schicht. Ich denke, so ist es am Einfachsten, sich das vorzustellen.

Das effektive Aktualisieren der Gewichte geschieht ähnlich wie vorhin.

# Aktualisiere die Gewichte der versteckten Schicht ( = network[0])
 for i, hidden_neuron in enumerate(network[0]):
# jeder Hidden Neuron hat 26 Gewichte, die von den 
# Eingabesignalen zu ihm führen
# darum kann er schön mit dem Eingabevektor von 26 Werten 
# multipliziert werden
   for j, input in enumerate(input_vector + [1]):
# Aktuelles Gewicht = Aktuelles Gewicht - Berechneten Fehler 
# * Neuron_Sigmoid_Gewichtung
# in diesem Fall nimmt man den Eingabevektor, also für die 0 
# etwa diesen:
# [1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 
#     1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1]
# und 1 sagt dann "dieser Wert soll korrigiert werden"
# und 0 "dieser Wert soll nicht korrigiert werden"
     hidden_neuron[j] -= hidden_deltas[i] * input

Zusammenfassung der Backpropagation

Wie ich sagte, ist die Fehlerrückführung theoretisch gar nicht so kompliziert – Wenn man es aber aus dem Code herauslesen muss, können einen die vielen Vektoren und Formeln schon verwirren. Ich werde demnächst noch einen Beitrag schreiben, der das Ganze einfach erklärt.

Bis dahin seien hier nochmals die vier Formeln zusammengefasst. Wie beschrieben, sind es zwei mal die gleichen zwei Schritte:

1. Das Herausfinden des Fehlers der aktuellen Schicht
2. Das Aktualisieren der Gewichte der aktuellen Schicht

Fehler der Ausgabeschicht
error_output = Soll-Wert minus Ist-Wert

Aktualisieren der Gewichte zwischen versteckter und Ausgabeschicht
output_weights = output_weights – error_output * hidden_output

Fehler der versteckten Schicht
error_hidden = error_output und output_weights

Aktualisieren der Gewichte zwischen Eingabe- und versteckter Schicht
hidden_weights = hidden_weights – error_hidden * input

Eine Zahl zwischen 0 und 9 vorhersagen

Hat man nun das Netz trainiert (in 10’000 Iterationen), sind die Gewichte entsprechend angepasst worden. Dann kann mit der Funktion predict beziehungsweise feed_forward ein Wert vorausgesagt werden. Als Input braucht es wieder einen 25-stelligen Vektor.

Im Codebeispiel ganz unten werden etwa die 3 und die 8 vorhergesagt.

# round Funktion = Runde die Ausgabe auf zwei Nachkommastellen
# Ergibt so etwas: 
# 0 [0.96, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.02, 0.03, 0.0]
 print([round(x, 2) for x in
 predict( [0,1,1,1,0,    # .@@@.
           0,0,0,1,1,    # ...@@
           0,0,1,1,0,    # ..@@.
           0,0,0,0,0,    # ...@@
           0,1,1,1,0])]) # .@@@.

Dies ist nicht genau die trainierte 3, die sieht nämlich so aus:

"""11111
   ....1
   11111
   ....1
   11111""",

Und dennoch kann das Netz mit 90% Sicherheit sagen, dass es eine 3 ist.

 0      1     2     3
[0.0, 0.0, 0.0, 0.9, 0.0, 0.0, 0.0, 0.01, 0.0, 0.19]

Ich hoffe, dieser Beitrag hilft, die Funktionsweise eines neuronales Netzwerk besser zu verstehen, so dass wir alle ruhiger schlafen können.

Der Code in seiner ganzen Pracht

Hmm gibt es in WordPress eine anständige Code-Funktion?

Wem es lieber ist, der möge den Code von meinem Github-Account Downloaden: https://github.com/CorDharel/machinelearning/blob/master/18NeuronaleNetzwerkeMinimal.py

import math, random

def dot(v,w):
    """ Skalarprodukt zweier Vektoren = Summe der Produkte der Komponenten
    v_1 * w_1 + v_2 * w_2 + ... + v_n * w_n """
    return sum(v_i * w_i
              for v_i, w_i in zip(v,w))

def sigmoid(t):
    return 1 / (1 + math.exp(-t))

def neuron_output(weights, inputs):
    """Hier passiert das Multiplizieren der Gewichte w * Inputs i und das
    Anwenden der Sigmoid Funktion.
    Die Gewichte sind um 1 länger als die Inputs
    weil das Bias noch am Ende angehängt wird"""
    return sigmoid(dot(weights, inputs))


def feed_forward(neural_network, input_vector):
    """Nimmt ein Neuronales Netzwerk entgegen - Darin sind die Gewichte gespeichert!
    Es ist eine list (Schichten) of lists (Neuronen) of lists (weights)

    Gehe durch jeden Layer und dann durch jeden Neuron.
    Für jeden Neuron, berechne das Skalarprodukt und dann den Sigmoid Wert"""

    outputs = []

    # gehe durch die beiden Layers Hidden + Output
    for layer in neural_network:

        # Fuege wieder das Bias hinzu
        input_with_bias = input_vector + [1]

        # Gehe durch jedes Neuron und aktualisiere die Gewichte mit Skalarprodukt
        # dann rechne noch für jedes Neuron den Sigmoid Wert aus
        # Für den Hidden Layer gibt das nur noch 5 Werte, weil es 5 Neuronen sind
        # (Einmal die Sigmoid Funktion pro Neuron)
        # Für den Output Layer gibt es 10 Werte
        output = [neuron_output(neuron, input_with_bias)
                  for neuron in layer]

        # Speichere die zwei Output-Resultate (eines pro Layer)
        outputs.append(output)

        # Der Input zum nächsten Layer ist der Output von diesem Layer
        # So werden die Ausgaben vom Hidden Layer gleich an den Output Layer weitergereicht
        input_vector = output

    return outputs


def backpropagate(network, input_vector, target):

    # network hat nur zwei Einträge:
    # 0 = Gewichte vom Hidden Layer, 1 = Gewichte vom Output Layer
    # Werden so im Code genannt: output_neuron, hidden_neuron

    hidden_outputs, outputs = feed_forward(network, input_vector)

    # hidden_outputs sind die 5 berechnete Ausgaben des Hidden Layers
    # outputs sind die 10 berechneten Ausgaben des Output Layers

    # Berechne nun die Fehler der berechneten Werte
    # Wegen dem Gradientenverfahren wird aber nicht einfach "output - target"
    # gemacht sondern die Ableitung der Sigmoid Funktion mit einbezogen

    # Die Formel "output * (1 - output)" ergibt sich aus der Ableitung
    # der Sigmoid Funktion

    # output_deltas sind dann die 10 Zahlen, die den Fehler repräsentieren
    output_deltas = [output * (1 - output) * (output - target[i])
                     for i, output in enumerate(outputs)]

    # Aktualisiere die Gewichte vom Output Layer (network[-1])
    # Gehe durch jedes Output Neuron (und dessen 5 Gewichte)
    # Für jedes Output Neuron gehe durch die berechneten Hidden Outputs
    for i, output_neuron in enumerate(network[-1]):
        for j, hidden_output in enumerate(hidden_outputs + [1]):
            # Aktuelles Gewicht = Aktuelles Gewicht - berechneten Fehler dieser Ausgabe
            # * (mal) Berechnete Sigmoid Ausgabe dieses Neurons (für die Gewichtung, wenn
            # die Sigmoid Ausgabe klein war wird dieses Neuron weniger gewichtet)
            output_neuron[j] -= output_deltas[i] * hidden_output


    # back-propagate die Fehler zum Hidden Layer
    # gibt 5 Fehler- bzw. Delta-Werte für die Hidden-Nodes

    # Die Formel für die Hidden deltas ist:
    # error_hidden = error_output * gewichte_von_hidden_zu_output_layer

    # Das erste Neuron ist ja mit der Ausgabeschicht durch 10 Gewichte verbunden
    # Der Fehler e für den ersten Hidden Knoten etwa berechnet sich so:
    # e_hidden_1 = e_output_1 * Gewicht_zu_diesem_Knoten_1
    #            + e_output_2 * Gewicht_zu_diesem_Knoten_2 etc.
    # darum kommt hier das dot(ausgabefehler, ausgabegewichte)
    hidden_deltas = [hidden_output * (1 - hidden_output) *
                      dot(output_deltas, [n[i] for n in network[-1]])
                     for i, hidden_output in enumerate(hidden_outputs)]

    # Aktualisiere die Gewichte der versteckten Schicht ( = network[0])
    for i, hidden_neuron in enumerate(network[0]):
        # jeder Hidden Neuron hat 26 Gewichte, die von den Eingabesignalen zu ihm führen
        # darum kann er schön mit dem Eingabevektor von 26 Werten multipliziert werden
        for j, input in enumerate(input_vector + [1]):
            # Aktuelles Gewicht = Aktuelles Gewicht - Berechneten Fehler * Neuron_Sigmoid_Gewichtung
            # in diesem Fall nimmt man den Eingabevektor, also für die 0 etwa diesen:
            # [1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1]
            # und 1 sagt dann "dieser Wert soll korrigiert werden"
            # und 0 "dieser Wert soll nicht korrigiert werden"
            hidden_neuron[j] -= hidden_deltas[i] * input


# Lass das Programm nur laufen, wenn es direkt aufgerufen und
# nicht importiert wurde
if __name__ == "__main__":

    raw_digits = [
          """11111
             1...1
             1...1
             1...1
             11111""",

          """..1..
             ..1..
             ..1..
             ..1..
             ..1..""",

          """11111
             ....1
             11111
             1....
             11111""",

          """11111
             ....1
             11111
             ....1
             11111""",

          """1...1
             1...1
             11111
             ....1
             ....1""",

          """11111
             1....
             11111
             ....1
             11111""",

          """11111
             1....
             11111
             1...1
             11111""",

          """11111
             ....1
             ....1
             ....1
             ....1""",

          """11111
             1...1
             11111
             1...1
             11111""",

          """11111
             1...1
             11111
             ....1
             11111"""]

    # ersetze die Punkte in den Zahlen durch 0en
    def make_digit(raw_digit):
        return [1 if c == '1' else 0
                for row in raw_digit.split("\n")
                for c in row.strip()]

    # map function: Fuehre die Funktion make_digit mit jedem
    # raw_digits Teil aus und gib es zurück
    # Auf Deutsch: Mach aus jedem Punkt der raw_digits eine 0
    inputs = list(map(make_digit, raw_digits))

    # Die richtigen Ausgaben = 10 Zeilen
    # vier ist etwa
    #  0  1  2  3  4  5  6  7  8  9
    # [0, 0, 0, 0, 1, 0, 0, 0, 0, 0]
    targets = [[1 if i == j else 0 for i in range(10)]
               for j in range(10)]

    random.seed(0)   # Setze seed damit jeder Durchlauf gleich ist
    input_size = 25  # Jede Eingave ist ein Vektor mit Länge 25
    num_hidden = 5   # Wir haben 5 Neuronen in der versteckten Schicht
    output_size = 10 # Wir haben 10 Neuronen in der Ausgabeschicht

    # initialisiere hidden und output weights mit random Werten
    # ---------------------------------------------------------
    # jedes versteckte Neuron hat ein Gewicht pro Eingabe plus ein Bias Gewicht
    hidden_layer = [[random.random() for __ in range(input_size + 1)]
                    for __ in range(num_hidden)]

    # jedes Ausgabe-Neuron hat ein Gewicht pro Eingabe plus ein Bias Gewicht
    output_layer = [[random.random() for __ in range(num_hidden + 1)]
                    for __ in range(output_size)]

    # Das Netz startet mit zufälligen Werten
    network = [hidden_layer, output_layer]

    # Trainiere das neuronale Netz
    # 10,000 Iterationen sind genug um zu konvergieren
    for __ in range(10000):
        for input_vector, target_vector in zip(inputs, targets):
            backpropagate(network, input_vector, target_vector)

    def predict(input):
        # Das Resultat der Ausgabe ist im letzten Index, also im -1
        return feed_forward(network, input)[-1]

    # drucke eine Liste der Vorhersagen von 0 - 9
    for i, input in enumerate(inputs):
        outputs = predict(input)
        print(i, [round(p,2) for p in outputs])

    print()
    print(""".@@@.
...@@
..@@.
...@@
.@@@.""")
    # round Funktion = Runde die Ausgabe auf zwei Nachkommastellen
    # Ergibt so etwas: 0 [0.96, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.02, 0.03, 0.0]
    print([round(x, 2) for x in
          predict(  [0,1,1,1,0,    # .@@@.
                     0,0,0,1,1,    # ...@@
                     0,0,1,1,0,    # ..@@.
                     0,0,0,0,0,    # ...@@
                     0,1,1,1,0])]) # .@@@.
    print()

    print(""".@@@.
@..@@
.@@@.
@..@@
.@@@.""")
    print([round(x, 2) for x in
          predict(  [0,1,1,1,0,    # .@@@.
                     1,0,0,1,1,    # @..@@
                     0,1,1,1,0,    # .@@@.
                     1,0,0,1,1,    # @..@@
                     0,1,1,1,0])]) # .@@@.


Buchempfehlung: Einführung in Data Science

Wieder habe ich eine Buchempfehlung für interessierte Data Scientisten: Es heisst Einführung in DataScience.

einführung ds

Titel: Einführung in Data Science
Untertitel: Grundprinzipien der Datenanalyse mit Python
Autor: Joel Grus
Übersetzung: Kristian Rother
Verlag: O’REILLY
ISBN: 978-3-96009-021-2
Erscheinungsdatum: 01.03.2016
Seitenanzahl: 348

Das Buch unterscheidet sich von den unzähligen anderen Büchern über Data Science in dem Punkt, dass wirklich alle Algorithmen und Funktionen selber in Python implementiert werden.

Das hat seine positiven und negativen Seiten:

Positive Punkte

Unglaublich positiv ist sicherlich, dass man im Detail sieht, wie alles funktioniert. Man verwendet nicht einfach irgendeinen KNNClassifier einer bestehenden Python Library, sondern programmiert alles selber. Als ob man bei einem Algorithmus nicht nur eine Blackbox sieht, sondern hineingehen und sich umschauen kann. Echt toll!

Weiterhin ist auch sehr positiv, dass der Code aus dem Buch frei im Internet verfügbar ist – Wobei das eigentlich Standard bei den O’REILLY Büchern ist – dennoch. Im Buch sind oftmals die wichtigen Codestücke dargestellt, da hilft es ungemein, wenn man die Files noch in voller Pracht hat, um die Zusammenhänge zu sehen.

Hier ist der Link zum Code: https://github.com/joelgrus/data-science-from-scratch

Positiv ist auch, dass sehr viele Themen abgehandelt werden. Nicht nur die üblichen Verdächtigen wie Maschinelles Lernen, k-Nächste-Nachbarn, Naive-Bayes-Klassifikation, Lineare/Multiple/Logistische Regression, Entscheidungsbäume und Neuronale Netzwerke sondern auch Themen, die zur Einführung in Python und Statistik gehören: Lineare Algebra, Statistik, Wahrscheinlichkeit oder Gradientenmethode. Und wie bereits beschrieben, wird in jedem Kapitel alles selber programmiert.

Mir persönlich gefällt das Kapitel über Neuronale Netze am Besten, obwohl es relativ kurz ist – wie eigentlich fast jedes Kapitel. Das kann jetzt positiv oder negativ sein. Weiter gibt es Kapitel zu Clustering, Linguistische Datenverarbeitung, Graphenanalyse, Empfehlungssysteme, Datenbanken und SQL und Mapreduce. Also wirklich ein sehr breites Spektrum.

Negative Punkte

Ein negativer Punkt, den ich ab und an hatte, ist, dass es so viele Themen sind, dass einige Dinge nicht im Detail erklärt werden – oder nur kurz. Besonders bei den Statistik-Themen werden einige Dinge als gegeben angesehen und nicht im Detail beschrieben. Da hilft es, wenn man entweder schon ein Vorwissen in Statistik hat oder bereit ist, sich über die Themen zu informieren.

Wer übrigens einen sehr guten Youtube Kanal wissen will, der mir bei der Repetition der Basics geholfen hat, dem sei folgender Empfohlen: Kurzes Tutorim Statistik

Speziell für Einsteiger ist das Buch Neuronale Netze selbst programmieren daher mehr empfohlen als Einführung in Data Science – Aber hey, von jenem Buch bin ich sowieso Fan, das würde ich sogar meiner Grossmutter als Nachtlektüre empfehlen!

Was ich auch empfehlen kann, ist, für das Herumspielen mit den Codebeispielen nicht iPython Notebooks zu nehmen sondern gleich eine echte IDE wie PyCharm.

Fazit

Das Buch hat eine sehr hohe Qualität und ein breites Themenspektrum. Die Algorithmen und Methoden werden alle selber programmiert, statt einfach Bibliotheken zu nehmen, was das Buch von der Konkurrenz hervorhebt. Man sollte aber willig sein, ein wenig Zeit mit den Kapiteln zu verbringen und sich ein wenig hineinzufuchsen.

 

 

Neuronale Netze Tutorial – Übersicht der Konzepte

Da hab ich also nun bereits zum zweiten Mal das Buch Neuronale Netze selbst programmieren gelesen und habe aber immer noch Mühe, das ganze in ein Big Picture zu setzen. Daher möchte ich in diesem Beitrag alle Konzepte zusammentragen, die für ein neuronales Netz wichtig sind, ohne hier auf die Details einzugehen.

Ich probiere, das Thema so lange herunterzubrechen, bis man das Wesen der neuronalen Netze auf einen Blick erkennen kann. Von diesem Punkt an kann man sich dann wieder darauf konzentrieren, einzelne Themen ins Detail zu studieren.

Übersicht der Konzepte

Folgende Themen fassen den Umgang mit neuronalen Netzen zusammen:

1. Neuronen und Sigmoidfunktion
2. Verschiedene Schichten
3. Gewichte
4. Matrizen
5. Eingangs- und Ausgangssignale von Knoten
6. Knotenwert = Gewichte * Eingabesignale
7. Fehlerrückführung
8. Gradientenabstiegsverfahren
9. Steigung mittels Differenzialrechnung ermitteln
10. Eingabedaten und Ausgabedaten skalieren
11. Geeignete Anfangsgewichte wählen

1. Neuronen und Aktivierungfunktion (Sigmoidfunktion)

Neuronen feuern, wenn sie einen gewissen Schwellwert erreicht haben. In der Mathematik spricht man dabei von einer Aktivierungsfunktion. Die meisten Bücher fangen mit einer Sprungfunktion an, welche bei einem gewissen Wert (etwa x = 3) von 0 auf 1 springt und so die Ausgabe des Neurons aktiviert. Beim nächsten Beispiel geht es dann meistens weiter mit der Sigmoidfunktion, weil diese für eine Aktivierungsfunktion besser geeignet ist – Denn die Natur macht keine Sprünge.

So sieht die Sigmoidfunktion übrigens aus. Man sieht schön, dass bei x = 0 der y-Wert 0.5 beträgt.

sigmoid.gif

Ein Neuron kann dabei viele Eingänge haben. Die Werte dieser Eingänge werden summiert und eben an die Sigmoidfunktion übergeben. Wird der Schwellwert überschritten, feuert das Neuron seine tödlicher Salve auf den Gegner… ach ne, was steht hier? „Das Neuron feuert ein Signal entlang des Axons zu den Terminalen, um es an die Dendriten der nächsten Neuronen weiterzugeben.“ … Hmm… Na gut, das klingt aber bei Weitem nicht so dramatisch.

2. Schichten oder Layers

In einem neuronalen Netz verwendet man mehrere Schichten, und jede Schicht enthält eine bestimmte Anzahl Knoten. Im einfachsten Fall mit drei Schichten hat man eine Eingangsschicht, eine versteckte Schicht und eine Ausgabeschicht.

neural_net.jpeg

3. Gewichte – Where the magic happens

Gewichte sind das absolut zentrale eines neuronalen Netzes (oder auch anderen Machine Learning Algorithmen wie dem Perzeptron). Dort passiert nämlich das, was man als Lernen bezeichnet.

Anmerkung: Im Bild oben von Punkt 2. repräsentiert jeder Pfeil von einem Knoten zum nächsten ein Gewicht.

Jeder Knoten in einer Schicht ist mit jedem Knoten der letzten und der folgenden Schicht verbunden. Wenn man das Netz nun trainiert, wird jedes Gewicht entweder verstärkt oder abgeschwächt, je nachdem, ob es für die aktuelle Aufgabe wichtig oder irrelevant ist.

Ein Gewicht ist dabei einfach eine Zahl. Und dabei ist es nur natürlich, dass ein Gewicht von etwa 15 wichtiger ist als ein Gewicht von 2. Diese Zahlen beziehungsweise Gewichte wurden ja schon bei Punkt 1. genannt als Eingabe bei der Sigmoidfunktion. Sie werden also zusammenaddiert und jeder Knoten hat dann quasi eine Gewichtssumme.

Bezeichnet werden die Gewichte üblicherweise mit w und dem entsprechenden Index des jeweiligen Gewichts. „W1,2“ etwa steht für das Gewicht vom ersten Knoten zum zweiten Knoten der Folgeschicht.

Wichtige Verständnisfrage: Wenn es drei Schichten hat und jeder Knoten der einen Schicht ist mit jedem Knoten der Folgeschicht verbunden – Sind es dann zwischen Schicht 1 und 2 diesselben Gewichte wie zwischen Schicht 2 und 3?
Antwort: Es sind nicht dieselben Gewichte. Jede Schicht hat eigene Gewichte zur Folgeschicht, je grösser also das Netz, desto mehr Gewichte hat man. Hat man beispielsweise ein Netz mit 3 Schichten à 3 Knoten, sind das insgesamt 18 Gewichte
(Alle 3 Knoten der ersten Schicht verbunden mit allen 3 Knoten der zweiten
Schicht = 9 Gewichte. Dann nochmals 9 Gewichte für die Knoten der zweiten zur dritten Schicht = 18 Gewichte total)

Das Lernen eines neuronalen Netzes ist also tatsächlich die Anpassung dieser Gewichte.

4. Matrizen und deren Multiplikation

Nun ja es wird halt alles in diesen ganz praktischen mathematischen Behältern namens Matrizen gespeichert. Und die werden halt miteinander multipliziert. Das ist aber keine Hexerei.

Einfach nicht vergessen: Entgegen dem natürlichen menschlichen Empfinden (wie etwa beim Lesen eines Buches) kommen zuerst die Reihen und dann die Spalten. Eine 5 * 2 Matrix halt also 5 Zeilen und 2 Spalten.

Die Matrixmultiplikation verwendet man etwa für das Multiplizieren der Eingangssignale und den Gewichten. Wie, Eingangssignale kamen in diesem Blogpost noch gar nicht vor? Verdammt! Das muss ich sofort ändern.

5. Eingangs- und Ausgangssignale der Knoten

Die Eingangssignale sind der Input in das neuronale Netz – Also die Daten, die wir analysieren müssen. Die Ausgangssignale sind die targets, also die Zielwerte, die das neuronale Netz vorhersagen soll.

Hat man etwa als Input Bilder von handschriftlich gemalten Zahlen, die als 28*28 Pixel-Bilder gespeichert sind, kann man für jeden Pixel den Grauwert (eine Zahl zwischen 0 und 255) nehmen und erhält so total 784 Eingabeknoten.

Will man mit diesen Eingabeknoten die Zahlen von 0 bis 9 vorhersagen, benötigt man 10 Ausgabeknoten ganz am Ende des neuronalen Netzes. Dabei darf man aber nicht vergessen, dass jeder Knoten in den mittleren versteckten Schichten auch Ausgabeknoten haben.

Der Ausgabewert dieser Knoten ist dann all das, was hineingegangen ist – und was dann noch in die Aktivierungs-/Sigmoidfunktion gequetscht wurde.

Und jeder Ausgabewert ist dann halt wiederum der Eingabewert der folgenden Schicht.

6. Knotenwert = Gewichte * Eingabesignale

Okay okay, ich wiederhole mich hier. Jetzt geht es nochmals um Gewichte und Eingabesignale? Wo ist hier die Struktur dieses Posts? Nun, die Struktur sagt: Ein Punkt wird abgeschlossen, ein neuer kriegt auch eine neue Nummer.

Ich habe geschrieben, dass wir aus unseren Daten die Eingangssignale bestimmen. Und der Knoten in der darauffolgenden Schicht macht dann irgendwas mit diesen Daten und auch noch mit den Gewichten, denn jeder Knoten der ersten Schicht ist mit jedem Knoten der zweiten Schicht verbunden.

Aber was wird gemacht? Ganz einfach:

x = (Input_1 * Gewicht_1) + (Input_2 * Gewicht_2) + ...

x ist dabei einfach der Wert, den wir zusammenrechnen, um zu sehen ob ein Neuron feuert. Man nennt diesen Knotenwert auch Nettoeingabe.

knotenwerte.png

Ich wiederhole mich bewusst immer wieder, denn nur mit etlichen Links auf die vorherigen Punkte kann ich das Gesamtbild zusammenfügen. Denken Sie nur daran: Sobald Sie bei einem Satz nicht verwirrt sind und stattdessen „Bah alter Käse das weiss ich schon lange“ sagen, haben sie das Prinzip dahinter verstanden.

7. Durch Fehler lernen

Hier hört man auch immer den Satz: Man will die Gewichte moderieren.

Schmeisst man alle Inputs in ein neuronales Netz kriegt man für einen Knoten am Ende einen berechneten Wert, etwa 5. Tatsächlich sollte aber 8 herausgekommen sein. Schön doof, oder?

Keineswegs! Die Differenz dieser beiden Werte 8 – 5 = 3 ist der Ausgabefehler und wird vom Ausgang des neuronalen Netzes wieder rückwärts in das Netz reingeworfen und auf die Knoten verteilt, so dass diese die Gewichte moderieren – also anpassen können.

Moderieren heisst dabei einfach, dass man nicht den vollen Wert 3 auf die Knoten in der vorherigen Schicht verteilt, sondern vielleicht nur die Hälfte. Das Ziel ist eines neuronalen Netzes ist es nämlich, sich langsam aber stetig den richtigen Werten anzupassen.

Hat ein Knoten etwa zwei Eingabesignale wird der Wert 3 nicht gleichmässig auf diese Knoten verteilt. Stattdessen wird das Gewicht der beiden Knoten noch berücksichtigt. Hat Knoten 1 ein Gewicht von 7 und Knoten 2 ein Gewicht von 14 kriegt Knoten 2 auch brav ein grösseres Stück vom Kuchen, da es anscheinend auch einen grösseren Hunger hat.

Die ganze Berechne das Netz und gehe dann wieder Rückwärts, um die Fehler auf die Knoten zu verteilen nennt man übrigens Fehlerrückführung (englisch Backpropagation).

8. Die Beziehung zwischen Gewichten und dem Fehler

Das GradientenmannistdiesesWortlangVerfahren habe ich Ihnen schon mal erklärt (Siehe Was ist das Gradientenabstiegsverfahren?). Es ist aber wirklich wichtig, dass Sie verstehen, dass man ein neuronales Netz nicht auf einen Schlag lösen kann. Ich meine, das wird Ihnen schon klar sein, immerhin reden wir von trainieren und Fehlerrückführung und so weiter. Es gibt aber (Mathe-) Probleme, die kann man auf einen Schlag lösen, und das geht bei neuronalen Netzen eben nicht. Stattdessen nähern wir uns immer mehr der optimalen Einstellung der Gewichte.

Und jetzt kommt eben das Gradientenverfahren ins Spiel. Sie können jetzt gerne überall im Netz die Theorie davon lesen. Wirklich wichtig ist nun folgendes: Wir machen das Verfahren auf einer Funktion, bei der der x-Wert alle Gewichte sind und der y-Wert der Fehler des neuronalen Netzes!

Anders gesagt: Der Fehler ist die Abhängige Variable y der Gewichte x und je nachdem, welche Werte all diese verschiedenen x’es haben, verändert sich der Fehler y zum Guten oder zum Schlechten – Wobei „gut“ ein tiefer Wert ist und „schlecht“ ein hoher Wert ist. Und mit „tief“ meine ich das absolute Minimum und das finden wir heraus mit dem Grassalbitursteinschlagverfahren ähm Gradientenabstiegsverfahren!

Sehen Sie diesen wichtigen Zusammenhang? Die ganze mathematische Theorie nützt einem nichts, wenn man sich diesem Zusammenhang zwischen Fehler und Gewichten bewusst ist.

Jedes Gewicht ist dabei eine Dimension, wenn man das tatsächlich streng mathematisch anschauen. Das ist aber dann schlussenlich nicht so relevant, weil wir uns etwa eine 10’000er Dimension eh nicht mehr vorstellen können.

9. Oh Shit – der schwere Teil

Dieser Punkt heisst korrekt: „Das Gradientenabstiegsverfahren und die Differenzialrechnung – Kann es Liebe sein?

Dieser Punkt ist mit Abstand der Schwierigste, weshalb ich dessen kurze Erklärung einfach noch ein wenig hinauszögern möchte. Habe ich Ihnen schon einmal erzählt, dass der Erfinder des Wortes Jogging genau beim Joggen an einem plötzlichen Herztod gestorben ist?

Nun gut. Tief durchatmen.

Ich beginne ganz einfach: Hat man eine Funktion, die wie ein U geformt ist, und man fängt links oben an einem Punkt an und will zum Minimum unten in der Mitte, dann läuft man nach unten. Die Steigung ist dabei negativ, denn sie zeigt nach rechts unten. Ist die Steigung negativ, laufen wir also nach rechts. Ist die Steigung positiv, laufen wir nach links.

Ah ja, und da haben wir es schon: Punkt 8. erzählt ja die hinreissende Geschichte zwischen Gewichten und dem Fehler, und wie findet man heraus, wie sich ein Ding y ändert je nachdem wie sich Dinge x ändern? Da macht man mit der Differenzialrechnung – Und das ergibt dann die gewünschte Steigung, die uns sagt, ob wir nach links oder rechts laufen müssen.

Wobei… ja gut, ich muss ehrlich sein… am Anfang hat mich das schon ein wenig verschreckt. Aber eigentlich ist es nicht sooo wild. Man macht die Ableitungen der Fehlerfunktion und diejenige der Sigmoidfunktion, verwendet dafür die Kettenregel… das ist wirklich interessant, wenn man das nachvollzieht, aber schlussendlich könnte man wohl auch überleben, wenn man einfach die vollendete Formel dafür verwendet, wie es tagtäglich tausende Studenten weltweit tun (ich nicht).

Ich finde es darum viel wichtiger, dass man überhaupt versteht, warum man die Steigung braucht. Und was der Link zwischen dem Fehler, den Gewichten und der Differentialrechnung ist.

Darum fasse ich das noch einmal kurz zusammen:

  • Die Steigung brauchen wir, um bei der Fehlerfunktion den Fehler zu minimieren. Die Steigung sagt uns, in welche Richtung man gehen muss, um zum Minimum = zum kleineren Fehler zu kommen.
  • Der Fehler ist die Abhängige Variable y und diese ist abhängig von allen Gewichten w (also in der Mathe jeweils die x-Achse). Die Differentialrechnung, also die Ableitung oder eben die Steigung, sagt uns: Wie verändert sich der Fehler wenn sich die Gewichte ändern?

Und wie genau verwenden wir die Steigung schlussendlich? Nun, wir haben eine Formel dafür und setzen dann in Python einfach die benötigten Werte ein. Ja ja ich weiss, diese Erklärung klingt jetzt wieder mal total bescheuert, aber es ist echt schwer, das Ganze in Worte zu fassen. Wir haben eine Formel und die verwenden wir, um die Gewichte anzupassen. Denn das ist ja das absolute Ziel des neuronalen Netzes, am Ende sinnvolle Gewichte zu haben.

10. Eingabedaten und Ausgabedaten skalieren

Die zur Verfügung stehenden Daten müssen zuerst entsprechend bearbeitet werden, bevor sie in das neuronale Netz eingespiesen werden. Fakt ist: Bei uns in der Vorlesung war einmal ein Mitarbeiter von Google, der auf diesem Gebiet gearbeitet hatte. Er hat uns dann auch klar gesagt, dass ein grosser Teil der Arbeit eines Data Scientists in der Vorbereitung der Daten liegt. Und das kann gut mehrere Wochen dauern pro Auftrag, bei dem man die Daten bearbeiten muss.

Kurze Repetition: Mit Eingabedaten meine ich die Werte, die in die Knoten reingehen (Die Eingaben in die Aktivierungsfunktion). Ausgabedaten sind die Daten, die dann aus den Knoten rauskommen, also die Ausgabe der Sigmoidfunktion.

Man muss immer daran denken, dass die Eingabedaten schlussendlich in den Knoten landen, wo sie dann in die Aktivierungsfunktion – oftmals in die Sigmoidfunktion gepresst werden. Grosse x-Werte streben bei der Sigmoidfunktion jeweils nach y = 1, während kleine x-Werte nach y = 0 streben (die Sigmoidfunktion gibt nur den Bereich von 0 bis 1 aus). Grosse und auch allzu kleine x-Werte sollte man dabei verhindern, da es sonst zu einer Sättigung des Netzes kommen kann und es nicht mehr optimal lernt.

Oftmals skaliert man die Eingabewerte auf den Bereich von 0.01 bis 1.01. Nullwerte sollte man ebenfalls verhindern, da dies die Lernfähigkeit für diesen Eingabewert völlig zerstört, da dann eine Multiplikation mit 0 geschieht.

11. Geeignete Anfangsgewichte wählen

Es wäre fatal, wenn man zu Beginn alle Gewichte mit Null oder mit dem gleichen Wert initialisieren würde, weil dann das Netz nicht gut lernen kann. Für die Knoten der zweiten Schicht multipliziert man ja jeden Eingabewert der ersten Schicht mit dem dazugehörigen Gewicht. Da das Gewicht aber immer gleich wäre, würde jeder Knoten der zweiten Schicht den gleichen Wert erhalten. Dann würde natürlich die Fehlerrückführung auch nicht funktionieren.

Die Werte sollten zufällig in einem bestimmten Bereich gewählt werden – Aber in welchem Bereich? Mathematiker haben die folgende Faustregel aufgestellt: Man nimmt die Anzahl Verknüpfungen, bildet davon die Wurzel und dann davon wiederum den Kehrwert ( = 1 durch x). Wenn zu einem Knoten also beispielsweise 3 Verknüpfungen führen, wäre der Bereich für die Gewichtsinitialisierungen von -1/Wurzel(3) bis +1/Wurzel(3).

Puh. Alles klar?