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])]) # .@@@.


AI schlägt Mensch in Videospielen

Ich persönlich war ja immer ein leidenschaftlicher Gamer. Das hat mit fünf Jahren mit Super Mario Land auf dem Gameboy begonnen. Im Spiel Dota 2 habe ich über 1450 Stunden verbracht.

Die Menschen waren schon immer fasziniert davon, wenn sie ein Spiel nicht selber spielen mussten. Ja, ich schaue in eure Richtung, teufliche Idle Games wie Cookie Clicker!

Sie kennen Cookie Clicker nicht? Okay, es ist ganz einfach: Nehmen Sie sich 1-2 Stunden Zeit und gehen sie auf die Seite Cookie Clicker Link und klicken Sie links auf den Cookie. Und nach 30-60 Minuten schauen Sie an, was für eine erbärmliche Gestalt Sie geworden sind, dass Sie sich von einem so doofen Spiel gefangen nehmen lassen!

Aber keine Angst, an diesem Punkt waren wir alle einmal. Idle Games sind Spiele, die automatisch ablaufen und von uns nur wenig Interaktion benötigen – Welche aber genau auf das Belohnungszentrum des Gehirns abzielen. Wie schön ist es doch, sich vom ganzen Stress dieser komplizierten Welt zurückzulehnen und ohne grosse Anstrengung erfolgreich zu sein. Hach!

Wie etwa meine Freundin: Da habe ich ihr Cookie Clicker gezeigt und gesagt, sie soll doch mal ein wenig spielen. Nach 2 Minuten meinte sie: „Was für ein Scheissspiel, da kann man ja gar nichts machen! Ist das ein Scherz?“. Das war etwa um 8 Uhr Abends.

Um 11 Uhr sagte ich dann zu Ihr: „Ich gehe jetzt übrigens Zähneputzen und dann ins Bett, kommst Du auch?“ und sie meinte nur: „Jaja ich komm gleich, ich muss nur noch kurz…“

Aber ja, eigentlich wollte ich gar nicht über Idle Games schreiben, sondern über die neuesten Bots im KI Bereich, welche die professionellen Spieler schlagen!

Bots schlagen Menschen mit Hilfe von Reinforcement Learning

Die grösste Erfolgsgeschichte des Jahres war wahrscheinlich DeepMind’s AlphaGo, ein Programm, welches den weltbesten Go Spieler geschlagen hat.

Go ist so ziemlich eines der kompliziertesten Brettspiele der Welt, und lange war es nicht möglich, Menschen darin zu schlagen, da es bei jedem Zug unglaublich viele Kombinationen gibt.

go.png

Das Spezielle an AlphaGo ist es, dass es nicht mit Daten aus den Vergangenheit trainiert wurde. Es hat nicht tausende von Zügen von professionellen Spielern analysiert. Stattdessen hat man dem Programm gesagt: „Schau, das ist Go und das sind die Regeln, jetzt spiel mal“ und das Programm hat aussschliesslich anhand des Resultats sein eigenes Verhalten verbessert.

Unglaublich, nicht?

Die neueren Versionen von AlphaGo haben dann jeweils auch die alten Versionen geschlagen. Gegen Ende des Jahres kam dann noch eine neuere Version mit dem Namen AlphaGo Zero heraus, welche zusätzlich auch noch Schach und Shogi spielen kann (was auch immer Shogi ist – Was zum Essen?).

Die Programme haben dabei Züge gemacht, welche selbst die professionellen Spieler überrascht haben. Mittlerweile schauen sich die Profis sogar Züge von den Programmen ab.

Poker

Go war nicht das einzige Spiel mit erheblichen Fortschritten. Libratus, ein Programm entwickelt vom Carnegie Mellon University, hat die besten Poker Spieler in einem Texas Hold’em Turnier geschlagen. Dabei muss man aber sagen, dass die Bots eine spezielle Variante des Pokers namens Heads-up Poker gespielt haben, bei dem nur zwei Spieler gegeneinander spielen. Das Spielen gegen mehrere Spieler ist doch noch ein komplizierteres Problem.

poker.png

Dotoooooo

Go ist schon beeindruckend… für mich persönlich zählt aber natürlich die Leistung des OpenAi Bots, der Dota 2 gespielt hat, am Meisten.

dota.png

Dota 2 ist ein unglaublich komplexes Spiel. Dabei spielen 5 Spieler aus aller Welt gegen 5 andere Spieler. Ein Spiel dauert 30-60 Minuten. Zu Beginn wählt jeder Spieler einen von unzähligen Helden, wobei jeder Held eigene Fähigkeiten, Stärken und Schwächen hat. Dann gibt es im Spiel hunderte von Objekten, die man kaufen muss, um die eigene Figur zu stärken.

Immer wenn man einen Gegner tötet, kriegt man Gold, mit dem man Ausrüstung kaufen kann, und dann gibt es noch die Deny-Technik, und Mobs und Kombinationen von Fähigkeiten und Roshan und und…

Man könnte stundenlang über Dota 2 sprechen, so komplex ist es. Ach ja, das Ziel für jedes Team ist es, die gegnerische Basis zu zerstören. Das geht aber eine gewisse Zeit, da man zuerst verschiedene Türme auf der Karte zerstören muss, bevor man das Herz der Basis angreifen kann.

Wenn Sie es mal ausprobieren möchten, es ist kostenlos in Steam erhältlich: Dota 2 im Steam Store
Anfängertipp: Probieren Sie am Anfang lieber, nicht zu sterben, als einen Gegner zu töten. Sie helfen Ihrem Team so viel mehr.

Auf jeden Fall hat OpenAi einen Bot geschaffen, welcher einen professionellen Spieler schlagen kann. Wie beim Poker handelt es sich aber um eine spezielle Variante, bei der 1 gegen 1 Spieler spielen. Das normale Spiel, bei dem 5 Spieler gegen 5 andere aufeinandertreten, ist eine ganz andere Liga.

Der Bot hat dabei ganz bewusst Taktiken eingesetzt, um den Gegner ranzulocken. Zum Beispiel hatte der Bot einmal ganz wenig Lebenspunkte und der menschliche Spieler dachte, er hätte leichtes Spiel. Der Bot hat ihn aber dann getötet, ohne selber zu sterben.

Das OpenAi Team hat nun das Ziel, einen Bot zu programmieren, welcher 5 gegen 5 Matches spielen kann.

Ich kann mir echt nicht vorstellen, wie ein neuronales Netzwerk eines solchen Bots aussehen muss, um so ein kompliziertes Spiel spielen zu können.

Starcraft 2 noch zu kompliziert

Was den KI Programmierern noch nicht gelungen ist, ist ein Bot zu erstellen, welcher einen Starcraft 2 Spieler schlagen kann.

sc2.png

Starcraft 1 und 2 sind besonders in Südkorea extrem beliebt – So beliebt, dass es unzählige Fernsehsender gibt, welche Spiele übertragen. Die Spiele sind dort ein Nationalsport, wie bei uns Fussball. Wenn zwei professionelle Spieler gegeneinander antreten, wird das in riesigen Arenen gemacht und natürlich Live übertragen.

Starcraft unterscheidet sich von Dota 2 insofern, dass ein Spieler nicht nur eine Figur steuern muss, sondern eine ganze Armee. Es gibt drei grundverschiedene Völker mit unzähligen Einheiten, welche wiederum alle eigene Fähigkeiten haben. Dazu muss auch noch eine Basis gebaut werden, wo diese Einheiten produziert werden können. Ein klassisches Strategiespiel eben.

Wenn Sie sich ein Bild machen wollen, schauen Sie sich doch mal ein Spiel zwischen zwei Profis an (englisch, aber vom weltbesten Starcraft 2 Caster Husky):
EPIC – Life vs ForGG – Dreamhack – TvZ – Catallena – StarCraft 2

Starcraft 2 ist übrigens auch kostenlos auf Blizzards Seite erhältlich, für Einsteiger (und Profis haha) würde ich aber Dota 2 empfehlen, da man dort nur eine Einheit steuern muss und so nicht völlig überfordert ist.

Wie sieht die Zukunft aus?

Ich stelle mir die Zukunft so vor: Wir sitzen zu Hause auf dem Sofa und schauen im Fernsehen (oder mit der Virtual Reality Brille) zu, wie sich zwei Bots gegenseitig in Starcraft 2 bekriegen. Was für eine Unterhaltung!

Wobei, es gibt ja schon Leute, welche das machen: Ich kenne zum Beispiel FIFA Spieler, welche ganze Turniere veranstalten und bei jedem Spiel aber nicht selber mitspielen, sondern den Computer gegen Computer antreten lassen und dann am Ende das Resultat notieren. So tragen sie das Turnier aus, bis am Ende ein Fussballteam gewonnen hat.

Aber ja, bei einem Spiel wie FIFA will man wohl auch lieber nicht selber spielen *duck*

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.