Tutorial - Das Perzeptron


Neuronales Netz selbst entwickeln mit Python

Die Geschichte geht zurück auf das Perzeptron

Im Grunde fing der Hype um Deep Learning schon in der Mitte des letzten Jahrhunderts an - nur, dass dieser Hype von den Verfechtern einer anderen Form von KI wegpolemisiert wurde. Und zugegeben, damals konnten die Maschinen auch noch nicht so schnell rechnen wie heute.

Das Perzeptron von Frank Rosenblatt (1956) gehört zu den liebgewonnenen Klassikern der KI, und die hieran zeigbaren Ideen und Bausteine des maschinellen Lernens über die Erfindung des Künstlichen Neurons funktionieren im Kleinen wie im Großen (auch im sehr kleinen, denn gerade wird versucht, das Perzeptron auf Quantencomputern nachzubilden.)

Zentraler Gedanke dabei ist ein Neuron, das von anderen Neuronen gefüttert wird und je nach Menge der Eingaben selbst wieder aktiv wird und andere Neuronen aktiviert. Die Eingaben werden also kurzerhand im Neuron aufsummiert, als würden sich da Moleküle anreichern, und wenn ein bestimmter Schwellwert mit dieser gebildeten Summe erreicht wird, schaltet das Neuron selbst wieder auf 1, sonst auf 0, weil die Eingabe nicht gereicht hat. Im Grunde ist also dieser Zustand am Ende - das Neuron leuchtet auf (=1) oder bleibt dunkel (=0) - ein binärer Kippschalter. Zumindest in unserem einfachen Modell für den Anfang.

Schema eines Neurons - Creative Commons Attribution Share Alike 3.0 - Quelle: https://en.wikipedia.org/wiki/File:Neuron-no_labels2.png

In der Sprache des maschinellen Lernens würde man von einem binären Klassifikator sprechen: die Vorgänger des einen Ausgabe-Neurons sind die Eingabe-Neuronen. Am Ende steht das gewünschte Ergebnis, bspw. ob die Eingaben zu einer bestimmten Klasse gehören oder eben nicht. Man denke bspw. an viele Eingaben für die Pixel eines Bildes, und am Ende soll aufleuchten, ob auf dem Bild eine Katze zu erkennen ist. Soweit gehen wir in diesem kleinen Tutorial noch nicht, da wir ja eine ganze Menge neue Begriffe lernen müssen.

Wie arbeitet ein Perzeptron?

Das Perzeptron gehört zu den Methoden des Supervised Learning (Lernen mit Lehrer), es benötigt also zum Zweck des Lernens gute Beispiele, die dem Perzeptron sagen, ob es richtig oder falsch gearbeitet hat. Diese Trainingsbeispiele müssen also sorgfältig ausgewählt werden.

Das ganze Geheimnis des Lernens steckt in den Verbindungen, die zwischen Eingabe- und Ausgabeschicht stecken. Jedes Eingabeneuron geht eine Verbindung mit dem Ausgabeneuron ein. Diese Verbindungen erhalten sog. Gewichte (engl. weights), mit der die Stärke der Verbindung in Zahlenwerte übertragen wird.

Einfaches Beispiel

Im Rahmen unseres Tutorials machen wir es uns zunächst einmal etwas einfacher. Wir erlauben nur, dass die Eingabeneuronen binär aktiviert sein dürfen, sprich: es werden nur Aktivierungen von 1 oder 0 zugelassen. Damit wir uns das besser vorstellen können, wollen wir einen Klassifikator trainieren, der uns sagt, ob heute ein “Schöner Tag” ist. Dazu fragen wir uns, ob die Sonne scheint und / oder die Freunde Zeit haben.

Sonne scheint Freunde haben Zeit Schöner Tag
1 1 1
0 1 1
1 0 1
0 0 0

Wir nennen hierbei die beiden Spalten “Sonne scheint” und “Freunde haben Zeit” unsere Merkmale (engl. features), die wir als Eingabe verwenden, und “Schöner Tag” ist unser gesuchter Wert oder die gewünschte Ausgabe.

So gehen wir also einfach mal davon aus, dass wir nicht genau wissen, was ein schöner Tag ist: wir haben nur Beispieldaten mit zwei Merkmalen (Sonne und Freunde) erhalten. Für diese Beispiele wissen wir auch im Training, was das erwartete bzw. richtige Ergebnis ist. Später wollen wir dann aber nur noch mit den Eingabe-Merkmalen zum Ziel kommen, ohne dass wir die Antwort dann schon im Voraus kennen.

Wenn man sich diese Tabelle so anschaut, steckt da auch zunächst keine große Magie dahinter: im Grunde ist das die logische ODER-Funktion, sprich, ein Schöner Tag ist immer dann gegeben, wenn mindestens eines der beiden Eingangsmerkmale erfüllt ist. So weit, so trivial. Aber wir wollen ja nun keine ODER-Funktion fest einprogrammieren, sondern diese von einem Algorithmus lernen lassen. Bei größerer Datenmenge erkennen wir Schlaumeier nämlich nicht mit einem Blick auf die Datentabelle, welche Funktion sich da im Hintergrund zwischen Merkmalen und Ergebnis verbergen könnte.

Zum Schluss darf dann der Supervisor sagen, ob das Perzeptron alles richtig gelernt hat: falls ja, ist alles ok und nichts wird geändert. Aber wehe, es ist falsch: dann gibt es auf die virtuellen Finger und die Gewichte des Systems müssen geändert werden.

Die Bausteine

Nun haben wir schon einige Konzepte gehört:

  • Jedes Eingabeneuron hat eine Verbindung mit dem Ausgabeneuron. Diese werden einfach durchgezählt und als Gewichte des Netzes bezeichnet (engl. weights). Vom englischen Wort weights leitet sich dann auch das Formelzeichen $w_n$ ab, wobei n eben das n-te Gewicht ist. Keine Panik, im Kontext unten sehen wir gleich, wie das benutzt wird.

  • Im Ausgabeneuron wird alles, was da rein kommt, aufsummiert, wobei die Eingaben über eine Gewichtung stärker oder schwächer auf die Summe einzahlen soll. Wir lassen dabei zu, dass die Gewichte Fließkommazahlen sein können, wie bspw. 0.4 oder -0.2

  • das Ausgabeneuron des Perzeptrons feuert 1, wenn die Eingaben einen Schwellwert überschritten haben, ansonsten bleibt es dunkel und auf 0. Das nennt man auch Aktivierung des Ausgabeneurons.

Das Ausrechnen mit +, * und >0

Um diese Konzepte am Computer umzusetzen, greifen wir zu zwei Operatoren der Algebra: Die Summe lässt sich mit einem Plus (+) realisieren, die Stärke der Verbindung lässt sich prima mit einer Multiplikation (*) abbilden.

Unsere Eingabeneuronen schreibe ich mal ein bisschen kürzer: Für “Sonne scheint” schreibe ich kurz S, und für “Freunde haben Zeit” schreibe ich kurz F.

Dann ist die Ausgabe ob ein schöner Tag ist wie folgt zu lesen:

Diese Zeile muss man sich kurz auf der Zunge zergehen lassen: im Grunde genommen ist das die ganze Magie hinter der Aktivierung des Ausgabeneurons! Aus den Eingaben S und F, die jeweils mit ihren ihnen eigenen Verbindungsgewichten multipliziert werden, entsteht eine neue Zahl, die wir mit dem Schwellwert vergleichen können. Natürlich wird die Formel etwas unübersichtlicher, wenn wir mehr Eingaben und vor allen Dingen auch mehr mögliche Ausgaben haben.

Da die Neuronen der hinteren Ebenen immer von den Neuronen der vorherigen Ebenen gefüttert werden, nennt man diese Netze auch vollständig verbundene Feedforward-Netze 1

Ihr fragt Euch vielleicht, wo denn die Verbindungen zwischen den Neuronen an sich geblieben sind - da wir fast immer davon ausgehen, dass die Neuronen der einen Schicht (bspw. Eingabe-Schicht) mit allen Neuronen der anderen Schicht verbunden sind, müssen wir uns nur die Gewichte in der richtigen Anordnung merken und wissen immer ganz genau, was zwischen Neuron 2 der einen Seite und Neuron 1 der anderen Seite los ist.

Angenommen, wir wissen, dass $w_1$ und $w_2$ jeweils 0.4 sind - und der Schwellwert wäre 0.3 - und wenn wir dann $S=1$ setzen und $F=0$, dann würden wir rechnen und vergleichen:

Das wäre wahr für diesen und für die anderen Fälle der Tabelle und somit würden diese Gewichte im Grunde genommen die ODER-Funktion abbilden - das kann man glauben oder selbst nachrechnen. Aber das ist ja gecheatet - denn die Gewichte wollen wir ja am Anfang gar nicht kennen! Das ist ja das, was wir später solange verändern wollen, bis meistens das richtige Ergebnis rauskommt! Gehen wir also nochmal zurück und setzen die Gewichte wieder auf 0 zurück. Dann ist auch für alle Beispiele die Ausgabe 0 und wir werden niemals ein wahres Resultat haben. Glaubt ihr nicht? Rechnet es nach!

Wir basteln uns ein Neuron dazu

S und F kann ich nicht verändern, weil diese ja als Eingaben kommen. Aber ich kann $w_1$ und $w_2$ sowie den $Schwellwert$ verändern - allerdings ist das ja immer noch ein bisschen krude, lieber möchte ich doch den Schwellwert auf die andere Seite der Ungleichung bringen, damit ich den Schwellwert nicht als Extrawurst behandeln muss. Das mache ich wie folgt:

Habt ihr es gemerkt? Am Anfang steht nun 1 mal $w_0$ und am Ende wird verglichen auf größer 0 - Falls der Schwellwert bspw. vorher 0.5 gewesen wäre, müsste ich also nun für $w_0$ den Wert -0.5 setzen, und ich komme zum gleichen Ergebnis. Diese 1 am Anfang ist also eine zusätzliche Eingabe in das System und wird auch Bias-Neuron genannt. Das Bias-Neuron ist immer aktiviert, und immer auch mit 1 aktiv. In Schaubildern sieht man daher einen Kreis mit einer 1 drin. Auf diese Art und Weise brauche ich nur noch ein Trainingsverfahren für die Verbindungsgewichte zwischen Eingabe und Ausgabe und muss mich um den Schwellwert nicht mehr extra kümmern. Schau Dir das Schaubild oben nochmal an, dort ist das Bias-Neuron schon mit einem Kringel und einer 1 eingezeichnet, und der Schwellwert ist damit aus der Welt geschaffen.

Mathematisch formulieren

Statt S und F für unsere Merkmale gewöhnen wir uns jedoch gleich an, die Variablen so wie die Gewichte auch durchzunummerieren. Dann sieht das so aus:

In der Mathematik ist dieses Muster sehr weit verbreitet - im Grunde habe ich eine Liste2 mit Werten $i_0, i_2, …, i_n$ für die Eingaben und eine Liste mit Werten für die Gewichte $w_0, w_2, …, w_n$. Jedes Element der einen Liste wird mit jedem Element der anderen Liste multipliziert und zum Schluss werden die Werte aufsummiert. Das nennt man auch Skalarprodukt (engl. dot product, zur Vertiefung siehe dort). Das können wir mit Python einfach selbst implementieren (im nächsten Tutorial arbeiten wir dann wie echte Profis auch noch mit numpy).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def skalarprodukt(liste1, liste2):
'''
Berechnet das Skalarprodukt über zwei Wertelisten.
Listen müssen gleich lang sein.
'''
if len(liste1) != len(liste2):
raise Exception("Listen müssen gleich lang sein")

ergebnis = 0

for i in range(len(liste1)):
ergebnis = ergebnis + (liste1[i] * liste2[i])

return ergebnis

Aktivierungsfunktion

Normalsterbliche Programmierer wie Du und ich würden jetzt sagen: ja, da haben wir jetzt eine wunderbare Größer-Gleich-Null-Klausel im Code, aber da Mathematiker nicht wie herkömmliche Programmierer denken, nennen Sie das was wir da machen wollen Heaviside-Funktion. Das meint aber im Grunde das gleiche: alles, was kleiner 0 ist, ist 0. Alles, was größer oder gleich 0 ist, ist 1.

1
2
3
4
5
def heaviside(x):
if x < 0:
return 0
else:
return 1

Und übrigens: Ja, es heisst wirklich Heaviside und nicht Heavyside. Zufälligerweise hatte der Typ, der eine Funktion in die Welt gesetzt hat, die schon irgendwie wie eine Heavyside-Funktion aussieht, ganz einfach Heaviside gehießen. Wer sich das nicht merken kann, darf aber auch Treppenfunktion oder Stufenfunktion sagen. Das meint das gleiche.

Die wunderschöne Heavyside- aeeehhh... Heaviside-Funktion. Ach, nennen wir sie einfach Treppenfunktion

In Schaubildern neuronaler Netze sieht man dann eben ein Neuron, wo der Anfang einer Treppe eingezeichnet ist (deswegen nennt man die Funktion auch Treppenfunktion). Scroll nochmal hoch zum Schaubild, dort siehst Du das Netz mit der 1 für das Bias-Neuron und der Treppe für unser Ausgabeneuron. Und Sonne und Freunde.

Wir haben nun auch schon ein bisschen das historische Rosenblattsche Perzeptron verlassen. Das macht aber nix, wir wollen ja auch nicht in der Vergangenheit stehen bleiben.

Jetzt haben wir aber fast alles zusammen. Wir brauchen nun ein paar Gewichte und Eingaben und können die Ausgabe des Perzeptrons schon berechnen:

Die Berechnung im Code

1
2
3
4
5
6
7
8
9
weights = [-0.2, 0.3, 0.3]

# die erste Position mit der 1.0 ist das Bias-Neuron
# (in der Literatur wird das Bias-Neuron oft als letztes genommen,
# aber das spielt ja im Moment keine Rolle)
# die zweite Position mit 0.0 steht für "heute scheint keine Sonne"
# die dritte Position mit 1.0 steht für "Freunde haben Zeit"

inputs = [1.0, 0.0, 1.0]

Jetzt können wir alles ineinander stecken. Wir berechnen das Skalarprodukt aus der inputs und der weights Liste und stecken das Ergebnis wieder als Parameter in die heaviside Funktion, und wir erhalten das richtige Ergebnis

1
heaviside(skalarprodukt(inputs, weights))
1
1

Herzlichen Glückwunsch! Bis hierin wurde im Grunde genommen ein einlagiges neuronales Netz mit einem Ausgabeneuron gebaut, das über die komplizierte Heaviside-Funktion (nagut, kompliziert ist hier nur der Name) aktiviert wird. Höchste Zeit, sich eine kleine Pause zu nehmen und sich zu stärken.


Wie lernt ein Perzeptron?

Puh, harter Tobak. Begriffe, die die Welt nicht braucht. Aber auf Dauer lohnt es sich doch, den Jargon zu lernen. Bislang gab es auch gar keine wirklich schwere Mathematik, oder? Viel eher drückt bis hier hin die Last der unterschiedlichen Konzepte, die wir uns erarbeitet haben. Zum Glück haben wir das schwerste schon hinter uns und stürzen uns jetzt darauf, wie das Perzeptron lernen kann. Denn die richtigen Gewichte, also die Werte für $w_0$, $w_1$ und $w_2$, fallen uns ja nicht in den Schoß (in der Regel).

Oben haben wir ja schon gelesen, dass wir die Gewichte verändern dürfen. Denken wir einmal kurz darüber nach. Auf welche Weise könnte man die Gewichte denn verändern?

Man könnte einfach zufällige Gewichte durchprobieren. Wir lassen dann alle Trainingsbeispiele einmal durchlaufen, und merken uns immer die Gewichte, die zu den bislang meisten Erkennungserfolgen geführt haben.

Ja, kann man so machen. Das dauert aber eine Ewigkeit. Also, in unserem Beispiel jetzt noch nicht, aber wenn wir nur ein paar mehr Merkmale hinzufügen, wird es doch ein Sisyphos-Spiel. Zudem hat dieses Verfahren gar kein Gedächtnis, von einer Intuition ganz zu schweigen: Wenn wir schon einmal nah dran waren an einem guten Ergebnis, dann probieren wir ja in der nächsten Runde wieder ein komplett zufälliges Ergebnis. Das können wir besser!

Bevor man etwas tut, sollte man vielleicht auch klären, warum man etwas tut, und den Anlass für das Ändern der Gewichte beschreiben:

Man könnte die Gewichte so lassen, wie sie sind, wenn das Einsetzen von Werten für S und F in die Perzeptron-Formel den richtigen Wert für den Schönen Tag produziert hat, und nur dann verändern, wenn die Formel zu einem falschen Ergebnis gekommen ist.

Bingo! Das klingt doch schon mal nach einem guten Ansatz.

Was wir bisher haben

Schauen wir uns die Formel nochmal genauer an. Zum Schluss liefert unsere Perzeptron-Aktivierungs-Formel - dank der magischen Heaviside-Funktion - entweder eine Eins oder eine Null.

Zuviel des Guten - da kann man noch was von wegnehmen

Wenn eine 1 das Ergebnis war, aber 0 richtig gewesen wäre, dann kann das nur bedeuten: die gewichtete Summe vor dem Größer-Gleich-Zeichen war zu groß. Das wiederum kann nur bedeuten, dass die Gewichte falsch in dem Sinne gewählt sind, dass auch die relevanten Gewichte zu groß sind.

Darf's ein bisschen mehr sein? Hier fehlt noch was zum Glück

Wenn umgekehrt eine 0 das Ergebnis war, aber 1 richtig gewesen wäre, dann kann das folgerichtig nur bedeuten: die gewichtete Summe vor dem Größer-Gleich-Zeichen war zu klein, die relevanten Gewichte müssten also größer gewählt werden.

Damit sind wir schon einen großen Schritt weiter, denn wir kennen nun schon die Richtung, in die wir Gewichte verändern müssen. Da wir uns ja langsam daran gewöhnen, wie ein Mathematiker zu denken, können wir doch bestimmt auch dafür wieder eine schöne Formel finden.

Die Richtung der Gewichtsänderung hängt offenbar ab von dem erwarteten Ergebnis und dem gelieferten Ergebnis. Und wir haben zwei Fälle zu beachten:

Erwartet wurde Ergebnis war Was müssen wir tun
0 1 Gewichte verkleinern
1 0 Gewichte vergrößern

Kann man die beiden Fälle nicht einfach mit IF-Statements abfrühstücken? Muss man denn wirklich diesen Mathekram bis zum Ende durchhalten?

Das geht vielleicht, solange wir nur Einsen und Nullen und nur ein Ausgabeneuron haben. Wenn wir mehrere Ausgaben wollen (bspw. “Schöner Tag”, “Mittelguter Tag”, “Montag”), oder wenn wir statt Einsen und Nullen auch noch “die Werte dazwischen” zulassen wollen, und erst recht wenn wir damit anfangen wollen, tiefere Modelle zu bauen, die auch noch Zwischenschichten zwischen Ein- und Ausgabe haben, dann sind wir mit unserem Programmiererlatein dann doch schnell am Ende. Dann lieber noch mal mathematisch denken, denn das können wir später auch mathematisch weiter ausbauen.

Den Fehler des Perzeptrons messen

Und was machen wir nun mit diesen beiden Fällen, wenn wir als Mathematiker denken? Wir rechnen einfach mit den Werten weiter. Der Unterschied zwischen dem gewollten und dem tatsächlichen Output lässt sich doch als Differenz darstellen:

In Schlau ist eine Differenz nicht einfach nur eine Differenz, sondern ein Delta, und der griechische Buchstabe dafür ist $\delta$. Statt Wert-erwartet schreiben wir auch $t_1$ (für engl. t=target, Zielwert) und den Wert-geliefert schreiben wir als $o_1$ (für engl. o=output). Und wir haben uns ja schon angewöhnt, die Formelzeichen durchzunummerieren - falls wir mal mehr als ein Ausgabeneuron haben wollen, wird uns diese Gewohnheit nützlich zufallen.

Kurz und knackig, nicht wahr?

Wenn wir unsere Tabelle oben anschauen, bekommen wir also für den Fehler -1, wenn eine 0 erwartet wurde, aber 1 geliefert wurde. Und wir bekommen eine +1, wenn eine 1 erwartet, aber eine 0 geliefert wurde. Und wenn beide Werte übereinstimmen, also bspw. eine 1 erwartet und eine 1 geliefert wurde, dann ist der Fehler 0!

Das klingt doch nach einer Arbeitsgrundlage.

Denn das Vorzeichen liefert uns ja auch gleich die Richtung der Korrektur! Entweder die Ausgabe war zu groß, dann bekommen wir mit -1 den Ratschlag, die Größe unserer Gewichte in Richtung kleinere Gewichte zu verschieben, oder die Ausgabe war zu klein und wir dürfen mit +1 annehmen, dass wir auf die Gewichte nochmal etwas dazupacken dürfen, oder eine 0, die uns ganz klar sagt, dass es für uns gerade Null und Nichts zu tun gibt.

Wir merken uns, dass uns die Differenz die Richtung gibt. Später, wenn wir mit tieferen und komplexeren neuronalen Netzen arbeiten, werden wir über das Differenzieren eine Richtung erhalten.

Aus dem Fehler lernen (ein bißchen)

Jetzt haben wir es auch schon fast geschafft. Wir wissen, in welche Richtung unser Fehler korrigiert werden muss, aber wie sollten wir nun vorgehen, um die Gewichte zu aktualisieren?

Bislang können wir davon ausgehen, dass wir mit dem $\delta$ schon mal wissen, ob das Gewicht größer oder kleiner sein soll, die erste Ideenskizze könnte also vielleicht so aussehen:

Wenn $\delta_1$ den Wert +1 hat, dann wird das Gewicht vergrößert, ist es -1, dann wird das Gewicht verringert. Wenn es gar keinen Fehler gab, dann ist $\delta_1$ gleich Null und es ändert sich gar nix am Gewicht. So weit konsistent mit unserer Intuition.

Generell sollten wir aber nicht gleich solche großen Sprünge machen, und wir führen daher noch eine Lernrate ein, die wir mangels Phantasie mit alpha ($\alpha$) bezeichnen wollen und für die wir für den Anfang einen Wert von 0.1 wählen. Dann wird das Gewicht nicht mehr so krass geändert:

Wenn wir uns langsamer unserem Ziel nähern, vermeiden wir, über das Ziel hinaus zu schießen. Die genauere Untersuchung des Lernverhaltens ist aber Stoff für einen anderen Tag.

So ganz am Ende sind wir auch noch nicht, und wir sollten nochmal untersuchen, welche Gewichte wir eigentlich verändern sollten. Dazu holen wir uns die Beispiele unserer Trainingsdaten zu Hilfe.

Die relevanten Gewichte finden

Zunächst mal übertragen wir unsere Lerntabelle in eine Liste von Listen.3 Dabei führen wir das daueraktivierte Bias-Neuron am Anfang und die beiden Merkmale S und F an Position 2 und 3 der Liste. Das gewünschte Ergebnis halten wir uns in einer einfachen Liste parat, die wir targets nennen. Die drei Gewichte belegen wir mit zufälligen Werten.

1
2
3
4
5
6
7
8
9
10
11
12
from random import random

training = [ [1.0, 1.0, 1.0 ], # Sonne scheint, Freunde haben Zeit
[1.0, 0.0, 1.0,], # Freunde haben Zeit
[1.0, 1.0, 0.0 ], # Sonne scheint
[1.0, 0.0, 0.0 ]] # Gar nix von alledem

targets = [ 1.0, 1.0, 1.0, 0.0 ] # Das letzte war kein Schöner Tag

weights = [ random() for i in range(0,3)]

print(weights)

Nun können wir uns ein Trainingsbeispiel, sagen wir mal das 4. (also im Array-Index 3), holen, den output o berechnen und mit dem target t verrechnen:

1
2
3
4
5
6
7
8
9
10
# Wir untersuchen genau ein Trainingsbeispiel
inputs = training[3]
t = targets[3]

# Output o berechnen
o = heaviside(skalarprodukt(inputs, weights))

# Und das delta ermitteln zwischen Wunsch und Vorhersage des Perzeptron
delta = t - o
print(delta)

Da random() nur positive Werte zwischen 0.0 und 1.0 liefert und wir daher einen Block drüber nur positive Gewichte erzeugt haben, wird für o ganz sicher eine 1.0 herausgekommen sein, und unser t wäre wünschenswerterweise eine 0.0 gewesen, denn das war gar kein Schöner Tag, so dass wir ein delta von -1 haben sollten.

Wenn wir uns dieses Trainingsbeispiel nochmal anschauen, sehen wir, dass außer dem Bias-Neuron kein anderes Neuron gefeuert hat. Im Grunde genommen haben wir mit dem Skalarprodukt also folgendes ausgerechnet:

Die Gewichte $w_1$ und $w_2$ waren also gar nicht am aktuellen Fehler beteiligt, weil die entsprechenden Eingabeneuronen gar nicht aktiv waren! Da die Gewichte nicht am Fehler beteiligt waren, sollten wir auch aus Sicht unseres Programmes für das Trainieren der Gewichte gar keine Aussage darüber wagen, ob die nicht beteiligten Gewichte nun gut oder schlecht waren. Wenn wir einen Fehler feststellen, können wir zunächst nur mit den Gewichten weiterarbeiten, die auch etwas zum Fehler beigetragen haben.

Endlich: Die Perzeptron-Lernregel

Mensch, jetzt haben wir es bald geschafft und sind kurz vor der finalen Lernregel, die ihren Ursprung in der Perzeptron-Lernregel von Rosenblatt hat und von Widrow und Hoff zur sogenannten Delta-Regel weiterentwickelt wurde.

Generell wissen wir ja nun, dass ein Gewicht nur etwas zum Fehler beigetragen haben kann, wenn das entsprechende Input-Neuron vor diesem Gewicht auch aktiv war.

Wir können also die Veränderung eines Gewichtes verhindern, in dem wir mit dem zugehörigen Input-Neuron multiplizieren. Denn war das Input-Neuron 0.0, dann wird auch die Änderung 0.0 sein! Tada!

Alter Schwede - delta, alpha und i in einer Multiplikation. Wir ändern ein Gewicht also proprotional zur Stärke der Aktivierung im Input-Neuron, proportional zur Lernrate alpha und - ganz besonders wichtig - auch in die Richtung der Differenz zwischen Soll und Ist, die wir delta genannt haben. Das ist wirklich eine komprimierte Darstellung, über die man eine Weile meditieren darf. Und der Pfeil nach links bedeutet, dass wir das Ergebnis von rechts wieder der Variablen $w_n$ zuweisen (ihr wisst ja, für Mathematiker ist das =-Zeichen schon anderweitig belegt).

Die Trainingsepochen können starten

Wir haben Trainingsdaten. Wir wissen, wie wir unsere n Gewichte aktualisieren können. Jetzt brauchen wir nur noch eine Strategie, damit das Training auch gelingt.

Bislang haben wir uns immer nur ein Trainingsbeispiel rausgepickt und untersucht, aber das Training soll ja für alle Beispiele laufen, damit auch alle Ausgaben später so sind, wie gewünscht. Mindestens müssen wir also einmal für alle Trainingsbeispiele die Gewichtsaktualisierung durchführen. Die Präsentation aller Beispiele und das Ausrechnen von Fehler und Gewichtsaktualisierung wird auch Epoche genannt.

Es wäre schön, wenn eine Epoche reichen würde: in der Regel müssen jedoch mehrere Epochen durchlaufen werden, damit die Gewichte sich nach und nach gemütlich anpassen können und der Trainingsfehler immer kleiner wird.

Jedoch gibt es noch eine kleine Komplikation zu beachten: wenn wir die Trainingsbeispiele immer in der gleichen Reihenfolge präsentieren, laufen wir in Gefahr, dass eine immer gleiche Folge von Gewichtsanpassungen dazu führt, dass Änderungen, die am Anfang der Beispiele gemacht wurden, durch Rückwärtsänderungen am Ende der Beispiele wieder rückgängig gemacht werden. Sprich, wenn wir erst in zwei Schritten ein Gewicht erhöhen, dann wieder in zwei Schritten ein Gewicht verringern, dann geht das ganze Spiel wieder von vorne los und wir erreichen niemals einen stabilen Zustand: das Netz oszilliert.

Um diese Oszillation zu vermeiden greifen wir zu einem Trick und präsentieren die Trainingsbeispiele in einer zufälligen Reihenfolge. Hier können wir einige Funktionalitäten vom Random Modul aus Python 3 verwenden: wir erzeugen eine Index-Liste, die wir dann immer wieder durchmischen mit shuffle, und diese gemischten Indexe verwenden wir dann, um die Reihenfolge zu variieren.

1
2
3
4
5
6
7
from random import shuffle

# Eine Liste mit Zahlen von 1
# bis Anzahl Trainingsdatensätze
indexes = [i for i in range(0, len(training))]

print(indexes)
1
2
3
4
5
# Jetzt einmal Durschmischen
shuffle(indexes)

for i in indexes:
print(i)

Statt shuffle kann man sich auch im random-Modul die Funktion sample anschauen.

Jetzt können wir endlich alle Ideen in Form von Code begutachten:

  • Anzahl Epochen festlegen
  • Lernrate alpha festlegen
  • Gewichte zufällig initialisieren
  • Indexe für das Shufflen initialisieren

Und dann:

  • Einmal über mehrere Epochen laufen
  • Die Trainingsbeispiele in einer zufälligen Reihenfolge präsentieren
  • Das delta ausrechnen und die Gewichte aktualisieren
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
anzahl_epochen = 20
alpha = 0.1

weights = [random() for i in range(0,3)]
indexes = [i for i in range(0, len(training))]

for e in range(0, anzahl_epochen):
print ("Starte Epoche ", e)
shuffle(indexes)
for i in indexes:
inputs = training[i]
target = targets[i]

output = heaviside(skalarprodukt(inputs, weights))
delta = target - output

if (delta != 0):
print("... Fehler erkannt bei Trainingsbeispiel {}".format(i))

# Magic - die Delta-Regel für alle Gewichte
for n in range(0,len(weights)):
weights[n] = weights[n] + delta * alpha * inputs[n]

So oder so ähnlich sollte das Training ablaufen - selbstverständlich wird man in jedem Lauf abweichende Ausgaben haben, da der Zufall hier mit reinspielt. Am Ende sollten jedoch meistens die richtigen Gewichte gefunden sein:

    Starte Epoche  0
    ... Fehler erkannt bei Trainingsbeispiel 3
    Starte Epoche  1
    ... Fehler erkannt bei Trainingsbeispiel 3
    Starte Epoche  2
    ... Fehler erkannt bei Trainingsbeispiel 3
    Starte Epoche  3
    ... Fehler erkannt bei Trainingsbeispiel 3
    Starte Epoche  4
    ... Fehler erkannt bei Trainingsbeispiel 3
    Starte Epoche  5
    ... Fehler erkannt bei Trainingsbeispiel 3
    Starte Epoche  6
    ... Fehler erkannt bei Trainingsbeispiel 3
    Starte Epoche  7
    ... Fehler erkannt bei Trainingsbeispiel 3
    Starte Epoche  8
    ... Fehler erkannt bei Trainingsbeispiel 3
    Starte Epoche  9
    Starte Epoche  10
    Starte Epoche  11
    Starte Epoche  12
    Starte Epoche  13
    Starte Epoche  14
    Starte Epoche  15
    Starte Epoche  16
    Starte Epoche  17
    Starte Epoche  18
    Starte Epoche  19

Zum Schluss kann man sich noch die Gewichte anschauen: im Grunde genommen sind die Gewichte alles, was man braucht, um das fertig trainierte Modell auch zu speichern:

1
print(weights)

Eigene Experimente wagen

Herzlichen Glückwunsch! Nun hast Du es geschafft, zu sehen, was beim Lernen eines Perzeptrons alles beachtet und implementiert werden muss. Im Grunde ist das gar nicht viel Code um die Weltherrschaft an sich zu reissen.

  • Versuche mal andere Trainingsdaten aufzubauen und statt der ODER-Funktion die UND-Funktion zu modellieren. Dazu musst Du die Trainingsdaten oben anpassen.

  • Glaube nicht, dass das einfache Perzeptron mit der Lernregel alles lernen kann. Es gibt eine Funktion, die XOR heisst, die man mit einem einlagigen Perzeptron nicht lernen kann. Während des Trainings werden dann abwechselnd Trainingsbeispiele fehlschlagen, und das Netz oszilliert auf Dauer.

Fortsetzung

Hier geht zum nächsten Teil des Tutorials:
Das Perzeptron - Teil 2

Wer lieber gleich in die Herleitung der Delta-Regel einsteigen möchte, kann den nächsten Teil erst mal skippen und direkt beim dritten Teil des Tutorials weiterlesen.

Downloads

Das gesamte Tutorial gibt es als Jupyter Notebook in einem Github-Repository zum sofort loslegen: https://github.com/dannybusch/neuromant.de-Tutorials/blob/master/notebooks/Tutorial_Das-Perzeptron-Teil-1.ipynb

Python und Jupyter Notebook können bspw. von dort bezogen werden: Anaconda Python - wir arbeiten mit Python 3.x

Eine ausführlichere Anleitung zur Installation von Python und Jupyter findet sich hier.

Schlusswort

Ich freue mich über Feedback zu diesem Tutorial. Entweder direkt unter dem Blog-Eintrag, via Twitter an @Danny_Busch_HB oder auch ganz profan via E-Mail. Die Adresse findet man im Impressum.

Fußnoten


  1. 1.die zugrundeliegende mathematische Struktur ist ein vollständiger bipartiter Graph https://programmingwiki.de/Grundbegriffe_der_Graphentheorie_2
  2. 2.statt Liste von Werten kann man auch einfach Vektor sagen.
  3. 3.eine Liste von gleichen Listen, wie wir sie haben, kann man auch Matrix nennen.