Tutorial - Das Perzeptron - Teil 3


Gradientenabstieg und Herleitung der Delta-Regel - die Mathematik hinter den Neuronalen Netzen

Die allgemeinere Mechanik des maschinellen Lernens

In den letzten beiden Tutorials haben wir uns eine Menge erarbeitet, aber natürlich wollen wir uns noch lange nicht damit zufrieden geben. Wir wollen noch mehr!

Einige Dinge haben wir auch einfach so akzeptiert, ohne, dass wir diese schon hinterfragt hätten. Warum brauche ich eine Lernrate? Wie groß soll meine Lernrate sein? Und warum funktioniert das Lernen überhaupt, und gibt es auch Fälle, in denen es nicht funktioniert? Können wir die Ausgabeneuronen immer nur binär aktivieren und nur 1/0-Entscheidungen treffen?

In dieser dritten und wirklich umfangreichen Ausgabe des Tutorials schließen wir daher noch ein paar konzeptionelle Lücken rund um das Perzeptron, und eines gleich vorweg: es wird ein paar Nüsse zu knacken geben.

Je nach Vorkenntnissen wird es passieren, dass man das Tutorial nicht an einem Nachmittag schafft, und man also über mehrere Tage verteilt den Text durchackert. Aber das ist vollkommen in Ordnung, und es lohnt sich auch tierisch!

Falls sich jemand, der schon Ableitungen rauf und runter gelernt hat, jemals gefragt hat, wofür man dieses Wissen benötigt: hier können wir es einsetzen. Aber keine Angst, wir stehen das gemeinsam durch. Legt schon mal ein Lineal und ein Energy-Drink Eurer Wahl bereit. Denn beides werden wir brauchen, um uns den Gradientenabstieg (engl. gradient descent) zu erarbeiten. Dazu machen wir uns die Welt auch erstmal wieder einfach.

Wir fangen ganz einfach an, mit einem Strich durch eine Datenwolke. Der Strich ist das Modell, das wir aus den Datenpunkten erlernen wollen. Das könnten bspw. Daten einer Messstation für CO2-Werte sein:

Die Linie, das Lineare und das Lineal

Bislang haben wir unser Ausgabeneuron des Perzeptrons in zwei Stufen aktiviert:

  • die gewichtete Summe aller Eingaben, also das Skalarprodukt zwischen Eingabevektor i und Gewichtsvektor w
  • eine Aktivierungsfunktion, in unserem Falle hatten wir die Heaviside-Funktion gewählt, die immer dann 1 wurde, wenn die gewichtete Summe aller Eingaben größer oder gleich 0 war, sonst blieb das Ausgabeneuron dunkel auf 0

Was wäre, wenn wir den Schritt mit der Aktivierungsfunktion weglassen würden? Wenn wir unser Ausgabeneuron also nicht mit der Heaviside-Funktion aktivieren, sondern einfach das ausgeben, was auch reingekommen ist?

Die Schönheit gerader Linien

Aus der Schule dürfte noch bekannt sein, dass eine Gerade im flachen, kartesischen Raum (sprich, auf dem Karopapier, erkennbar an den vielen rechten Winkeln) mit einer einfachen Formel beschrieben werden kann:

Lineare Aktivierung des letzten Neurons

Wenn wir das auf unser Neuronenmodell übertragen, dann ist das b unser Gewicht für das Bias-Neuron, und m ist das Gewicht für das eine Eingabeneuron x. In Schaubildern sieht man dann für das lineare Ausgabeneuron einen Kreis, in dem eine durchgezogene schräge Linie eingetragen ist - da wir die Eingabe einfach 1:1 als Ausgabe durchreichen (für den Moment - wir zaubern da bald noch deutlich kompliziertere, aber auch mächtigere Aktivierungsfunktionen hin).

Es bleibt weiterhin ein einfaches Skalarprodukt, wenn wir wieder eine 1 für den Bias einschleusen, und es sieht aus, wie unser erster Schritt der Perzeptronaktivierung, wenn die Formel für die Gerade ein bisschen umbenannt und umsortiert ist:

Wer bis hier hin nur Bahnhof verstanden hat, sollte noch mal im ersten und zweiten Teil der Tutorials vorbeischauen.

Das Schöne ist, dass wir nun auch mal ganz unverfälscht in die Ausgabe unseres Neurons reinschauen können, da ja am Ende eine gerade Linie auf dem Karopapier bei rauskommt (rauskommen sollte).

Für den einfachen Fall, dass m = 1 ist und b = 0, reduziert sich das Ganze auf $y = x$ bzw. in Funktionsschreibweise $f(x)=x$, das ist also eine Gerade, die durch den 0-Punkt geht und ziemlich schräg verläuft.

Wenn ich auf der x-Achse einen Schritt nach rechts gehe, dann kann ich auf der y-Achse auch einen Schritt nach oben gehen und bin wieder auf der roten Linie. Wenn m = 2 wäre, würde ich für jeden Schritt, den ich auf der x-Achse mache, zwei Schritte auf der y-Achse nach oben gehen müssen. Die Linie wäre viel steiler, daher nennt man m auch die “Steigung” der Linie.

Und das b verschiebt den ganzen Graphen einfach nur nach oben oder nach unten. Hier unten sieht man die Funktion $f(x) = 0.5 * x + 3$. Um auf der y-Achse einen Schritt höher zu kommen, muss ich also auf der x-Achse ganze zwei Schritte nach rechts vollziehen. Und die Gerade schneidet die y-Achse just bei der 3.

OMG, es ist eine Datenwolke!

Was wir nun versuchen wollen, ist aus Beispieldaten eine Linie zu trainieren, die die Daten möglichst gut wiedergibt. Dabei müssen wir versuchen, möglichst allen Punkten gut gerecht zu werden, damit die Linie auch ein gutes Modell für die Punkte wird.

Dazu erzeugen wir uns einmal einen Datensatz mit Punkten, die zwar einen Zusammenhang mit der x-Achse haben, allerdings ein bisschen zufällig nach oben oder unten zerstreut sind (falls numpy noch nicht installiert wurde, einfach mit pip install numpy nachholen - das gilt immer dann, wenn gerade ein Paket fehlen sollte).

1
2
3
4
5
6
7
8
import numpy as np

# 100 Werte zwischen -10 und +10 für unser x
x = np.linspace(-10,10,100)

# Nun Werte für y erzeugen.
# Im Grunde ist dann y = 15x + 25 + Zufall
y = 15 * x + 25.0 + np.random.randn(100) * 25

Nun schauen wir uns mal an, was wir da Tolles erzeugt haben:

1
2
3
4
5
6
import matplotlib.pyplot as plt
%matplotlib inline

plt.figure(figsize=(8,6))
plt.scatter(x,y, color='r')
plt.grid(True)

So ähnlich sollte das Bild dann aussehen - selbstverständlich nicht ganz exakt genau so, da wir eine Zufallskomponente haben:

Was für eine herrliche Datenwolke

Was wäre nun die ideale Linie durch diese Datenwolke, die die Datenwolke möglichst gut beschreibt? Eigentlich wissen wir das schon. Wir haben die Daten ja selbst erzeugt. Da oben, im Code, steht ja die Vorschrift, die erklärt, wie die Y-Werte entstanden sind. Wir haben 15 mal x genommen, und darauf 25 addiert. m wäre also 15, und b wäre 25.

1
2
3
4
plt.figure(figsize=(8,6))
plt.scatter(x,y, color='r')
plt.plot(x, [15 * xx + 25 for xx in x], color='b')
plt.grid(True)

So, die Linie ist da, wir sind fertig. Oder?

Die Werte ehrlich erlernen

So einfach kommen wir aber nicht ungeschoren davon. Wir wollen ja wieder so tun, als wüssten wir die Antwort noch nicht, sondern müssen die Gewichte in einem algorithmischen Lernprozess ermitteln. Außerdem: da die y-Daten ja auch eine Zufallskomponente haben, ist die Linie vielleicht auch gar nicht sooo ideal, je nach dem, wie stark uns der Zufall einen Strich spielt - aeh, einen Streich spielt, natürlich.

Deswegen setzen wir jetzt unsere Gewichte auf 0 und erzeugen eine ordentliche Startbedingung. Wir erweitern unser kleines x zu einem großen X, indem wir wieder unser Bias-Neuron einschleusen - dieses brauchen wir, um die Verschiebung der Linie nach oben oder unten zu modellieren, also das $b$ aus der Formel $mx+b$. Scroll nochmal nach oben zu dem Netz und der Umstellung der Formel für eine Gerade. Da sieht man die eingekringelte 1 für das Bias-Neuron, das wir beim letzten Mal auch schon verwendet haben.

Großbuchstaben deuten im mathematischen Text häufig an, dass es mehrere Werte gibt, die in einer Tabelle zusammengefasst sind. Wir erinnern uns, dass wir dazu auch Matrix sagen dürfen.

Also machen wir ein Groß-X und schleusen ein Bias-Neuron ein:

1
X = np.insert(x.reshape(100,1), 0, 1, axis=1)

Dann machen wir einen Gewichtsvektor weights und initialisieren diesen mit 0 Werten.

1
weights = np.zeros(2) # Ein Gewicht für Bias, eins für das Eingabeneuron x

Jetzt können wir auch unser aktuelles Modell (mit den Gewichten) plotten gegen die Datenwolke:

1
2
plt.scatter(x,y, color='r') # die echten Daten
plt.plot(x, X.dot(weights)) # Lang lebe das dotproduct

Man darf ruhig eine Weile darüber meditieren, was da passiert. Da alle Gewichte 0 sind, wird automatisch auch das Skalarprodukt aus X und den Gewichten überall 0.

Auf jeden Fall sieht die Linie nun gar nicht so aus, wie wir sie brauchen, um damit an die Öffentlichkeit gehen zu können - es sei denn, wir verkaufen das Modell an die BILD-Zeitung (“Klimalüge! Anerkannte KI-Forscher beweisen: CO2-Werte nehmen gar nicht zu!”). Es soll ja Fälle gegeben haben, in denen so etwas Ähnliches auf der Welt schon passiert ist. Nutzt uns aber ja auch nix, wenn uns dann trotzdem auf Dauer die Füße nass werden.

Augenscheinlich wird schnell ersichtlich, dass die Punkte einfach ganz oft einen großen Abstand zur Linie haben, und die momentane Linie ganz sicher kein gutes Modell für die Daten ist. Die Linie steigt nicht so an, wie die Punkte das eigentlich vorgeben. Offenbar wäre die Linie besser, wenn die Linie versuchen würde, sich in die Mitte der Daten zu mogeln.

Abstand zwischen Modell und Daten

Wir müssen wieder mit irgendwas rechnen. Wir müssen uns überlegen, wie wir den Fehler zwischen unserem Modell und den Daten quantifizieren können, damit wir am Computer damit etwas tun können. Noch können wir ja nicht einfach unsere Augen an den Computer anschließen.

Schauen wir uns mal an, wie die Abstände der Modelllinie zu unseren Datenpunkten uns einen Hinweis auf die Güte des Modells geben:

Nein, das ist kein bizarrer Blumengarten
Kleiner ist echt besser.
Im ersten Bild ergeben alle Stäbe zusammenaddiert einen recht großen Fehler.
Im zweiten Bild ergeben alle Stäbe zusammenaddiert einen relativ kleinen Fehler.

Donnerkeil. Das scheint tatsächlich ein guter Ansatz zu sein, um zu ermitteln, wie gut sich die Linie in die Daten einschmiegt. Der Abstand zwischen den Target-Punkten und den vom Modell berechneten Output kommt uns ja auch schon sicherlich irgendwie bekannt vor.

Wir müssen nur derb aufpassen jetzt: wenn wir für den Fehler einfach nur $target - output$ annehmen, dann treten die Punkte aus der Datenwolke, die unterhalb der Linie sind, als negativer Fehler in Erscheinung. Wenn wir alle einzelnen Fehler jedes Datenpunktes aufsummieren würden, könnte es sein, dass der gesamte Fehler des Modells 0 wird - weil sich die negativen Fehler und die positiven Fehler gegeneinander aufheben!

Ein Fehler von 0 könnte aber auf verschiedene Arten entstehen: eine gerade Linie, bei der die Hälfte der Punkte unterhalb und die andere Hälfte der Punkte oberhalb ist, hätte ebenso den Fehler 0 wie eine schräge Linie, bei der ebenso die Hälfte der Punkte ober- bzw. unterhalb der Linie sind. Fatal! Welches ist denn dann die bessere Linie?

Fehler oben, Fehler unten
Auch Fehler oben, Fehler unten.
Wenn wir die rosanen Stäbe abziehen, und die grünen Stäbe addieren, ergibt sich für beide Modelle der Fehler von 0.

Wir sollten also dringend vereinbaren, dass Fehler (auch die Teilfehler) immer positive Werte sind: dann können wir alle Fehler aufaddieren und miteinander vergleichen - so wie oben, als wir die Stäbe zum ersten Mal in blau eingezeichnet haben. Dann ist die schräge Linie tatsächlich auch rechnerisch besser als die völlig deplatzierte Linie. Für unser Lernprogramm wird das die einzige Möglichkeit sein, die Güte des Modells zu erfassen.

In diesem Zusammenhang wird statt von Fehler auch von einer Verlustfunktion (engl. loss function) gesprochen. Der Verlust wird größer, je mehr und je stärkere Fehler vorliegen. Wir suchen also nach dem sparsamsten Modell, wollen also den Verlust möglichst minimieren. In mathematischen Texten wird man für die loss-Funktion oft ein stilisiertes L finden:

Im Fundus des mathematischen Werkzeugkoffers gibt es mindestens zwei naheliegende Kandidaten, um aus den einzelnen Abständen einen positiven Fehler zu machen:

  • man könnte einfach den absoluten Betrag des Abstandes nehmen - in numpy gibt es dafür wie in vielen anderen Bibliotheken die Funktion abs
1
2
def verlust_absolut(target, output):
return np.abs(target-output) # ist immer positiv!
  • man könnte den Abstand quadrieren, denn - mal - ergibt +
1
2
3
4
def verlust_quadrat(target, output):
# der Doppel-* bedeutet "hoch" in Python,
# also ** 2 ist "hoch 2" oder "zum Quadrat"
return (target - output) ** 2

Es wird höchste Zeit, dass wir uns einmal anschauen, wie sich die Fehler des Modells denn verändern, wenn wir die Gewichte verändern.

Fehler minimieren, nach Augenmaß

Dieses Mal haben wir nämlich das große Glück, dass wir nur zwei Gewichte haben - eines für die Steigung der Geraden und eines für den Schnittpunkt mit der y-Achse.

Wir können einfach alle möglichen Gewichte in kleineren Schritten einmal als Grundlage für unser Modell nehmen und den jeweiligen Fehler des Modells ausrechnen. Das tun wir einfach mal, und wir bauen uns dazu einige Hilfsfunktionen. Denn das Ganze wollen wir ja in 3D plotten!

3D brauchen wir, da der Fehler ja abhängig ist von zwei Koordinaten: wenn ich das Gewicht $w_0$ verändere, dann muss ich auch wissen, welchen Wert $w_1$ hat, um den für diese beiden Gewichte geltenden Fehler zu ermitteln.

Im folgenden kommen eine Menge Zeilen Pyhon-Code, die aber für das Verständnis des Themas nicht so bedeutend sind - da sie nur der Visualisierung dienen. Ihr könnt das also schnell überfliegen.

Zuerst schreibe ich eine kleine Funktion, die alle Trainingsbeispiele einmal präsentiert und den gesamten Fehler über alle Beispiele aufaddiert zurückgibt - als Parameter lasse ich jedoch offen, für welche Gewichte ich den Fehler berechnen möchte - und auch lasse ich offen, welche Funktion den Fehler berechnen wird:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def verlust_fuer_gewichte(verlustfunktion, w0, w1):
fehler = 0.0

# Einmal über alle Trainingsbeispiele
for i in range(len(X)):

# Ausgabe berechnen mit dem Parametern w0 und w1
output = X[i].dot(np.array([w0,w1]))

# Was wäre korrekt gewesen?
target = y[i]

# Aufrufen der loss function und addieren zum
# Gesamtfehler
fehler += verlustfunktion(target, output)

return fehler

Jetzt muss ich nur noch, auch um der Dreidimensionalität zu genügen, für jedes Paar an Gewichten, die wir uns anschauen wollen, einmal den Fehler ausrechnen. Als Parameter lasse ich wieder die Kostenfunktion frei, sowie die Range für die W0s und die W1s - das sind also die Listen mit den Gewichten, und durch die Doppelschleife wird jede Kombination aus den beiden Listen einmal berechnet.

1
2
3
4
5
6
7
8
9
10
11
def verlust_fuer_grid(verlustfunktion, W0s, W1s):
grid = []

# Doppelschleife. Das kennt ihr doch?
for w0 in W0s:
ingrid = []
for w1 in W1s:
err = verlust_fuer_gewichte(verlustfunktion, w0,w1)
ingrid.append(err)
grid.append(ingrid)
return np.array(grid)

Keine Angst, gleich ist es geschafft. Wirklich eine Menge Code, nur um mal eben so einen Verlust zu plotten - dagegen ist der Lernalgorithmus ja schon fast sparsam. Zu Gunsten der Lesbarkeit habe ich jedoch auch nicht von allen Möglichkeiten Pythons Gebrauch gemacht, um die eine oder andere Zeile Code zu sparen.

Als nächstes erzeugen wir für alle möglichen Gewichte die Wertelisten und berechnen damit alle möglichen Fehler für die Kombinationen aus den Gewichten:

1
2
3
4
5
6
def berechne_verlustfunktion(verlustfunktion):
w0 = np.linspace(-10, 40, 40) # unser bias zwischen -10 und 40
w1 = np.linspace(-10, 40, 40) # die steigung auch
Z = verlust_fuer_grid(verlustfunktion, w0,w1)
W0, W1 = np.meshgrid(w0, w1) # macht nur koordinaten
return W0, W1, Z

Jetzt kommt noch die Funktion, die das ganze plotten soll. Haltet Euch nicht zu lange damit auf, das ist nur matplotlib-Geballer, damit das ganze einigermaßen anmutig aussieht, daher kommentiere ich den Code auch nicht weiter.

1
2
3
4
5
6
7
8
9
10
11
def plotte_verlustfunktion(W0s, W1s, Z):
from mpl_toolkits.mplot3d import Axes3D
fig = plt.figure(figsize=(11,11))
ax = fig.add_subplot(111, projection='3d')
ax.plot_surface(W0s, W1s, Z, color='red', alpha=.8)
ax.plot_wireframe(W0s, W1s, Z, color='y', alpha=0.2)
ax.view_init(20, 35)
ax.set_ylabel('$w_0$', fontsize=16)
ax.set_xlabel('$w_1$', fontsize=16)
ax.grid(True)
ax.set_title('Verlust / Fehler', fontsize=16)

Endlich! Jetzt haben wir alles am Start um uns (was wollten wir eigentlich noch mal gerade tun?), aehm, na klar: um uns jeden möglichen Fehler für eine ganze Reihe von möglichen Kombinationen aus Gewichten anzuschauen:

1
2
W0s, W1s, Z = berechne_verlustfunktion(verlust_absolut)
plotte_verlustfunktion(W0s, W1s, Z)

Na, wenn das mal keine schöne Wanne ist, dann weiß ich es auch nicht. Was sehen wir denn da? Auf jeden Fall scheinen die Gewichte $w_0 = 0$ und $w_1 = 30$ nicht die optimalen Gewichte zu sein, da der Fehler an dieser Stelle nicht minimal ist. Auch scheint sich eine Veränderung von $w_0$ nicht so krass auf den Fehler auszuwirken als eine Änderung am Gewicht $w_1$ - dieses erzeugt ja überhaupt erst diese wunderbare Wanne.

Wir sehen aber auch, dass wir, egal, wo wir an der Wanne starten, immer irgendwie nach unten schauen können, bis wir irgendwann das Minimum der Verlustfunktion erreicht haben. Da, wo auch dieses Minimum ist, müssen also die idealen Gewichte sein!

Einen haben wir ja noch: wir haben ja schon eine weitere Loss-Funktion implementiert, die Fehler in das Quadrat gehoben hat. Das wollen wir uns jetzt nach all der Mühe nicht entgehen lassen:

1
2
W0s, W1s, Z = berechne_verlustfunktion(verlust_quadrat)
plotte_verlustfunktion(W0s, W1s, Z)

Wunderschön, nicht wahr? Viel glatter und runder als unsere absoluten Fehler, schön parabolisch in jederlei Hinsicht, ein echter Hingucker, ach was, eine Sensation, wie sie im Blog steht!

Herzlichen Glückwunsch bis zu dieser Stelle - da hat sich wirklich jemand durchgebissen und ist nun in der Lage, sich den Zusammenhang zwischen der Wahl der Gewichte und einer möglichen Repräsentation der Fehler vorzustellen. Jetzt wird es höchte Zeit, entweder eine kurze Pause zu machen oder den Energy-Drink zu öffnen.

Gradientenabstieg - Runter kommen sie alle

Wer in Analysis aufgepasst hat (also, wer in seinen besten Jahren der 11. Klasse aufwärts nicht gerade außerhalb des Schulgebäudes mit interessanteren Dingen beschäftigt war), und sich das Gebilde da oben anschaut, wird sagen können:

Dann nehmen wir doch einfach die erste Ableitung davon, setzen diese gleich 0 und lösen die Gewichte direkt auf. Das Ding hat ja nur ein Minimum!

Und das wäre gar nicht schlecht, Herr Specht.

Der olle Gauß, Senior Data Scientist, mit Backenbart. Damals gab es noch keine Tinktur für Haarwuchs, aber Mützen. Auch heute gibt es noch keine Tinktur für Haarwuchs, und es gibt immer noch Mützen. Zufall?

In der Tat hat der gute alte Gauß die sog. Methode der Kleinsten Quadrate schon 1795 beschrieben und damit Beobachtungen von Planeten auf eine Ellipsenbahn gemapped, sprich, eine Linie durch eine Datenwolke gezogen.

In unserem einfachsten Fall, dass wir eine lineare Aktivierung haben und sonst kein Hokuspokus dazwischen, kann man tatsächlich eine eindeutige Lösung für die Gewichte finden, einfach nur, in dem man diese mit Mitteln der Analysis ausrechnet. Der Oberbegriff dafür wäre auch Lineare Regression (nicht zu Verwechseln mit Autoaggression).

Von diesem guten Fall wollen wir aber nicht ausgehen. Wenn wir endlich unsere einfachen Netze verlassen wollen, Richtung mehrschichtige Neuronale Netze mit allerlei unterschiedlichen Aktivierungsfunktionen und Schnickschnack, wird das Navigieren deutlich schwieriger. Dann sieht die Oberfläche der Loss-Funktion auch gar nicht mehr so aus wie in unserem Bild da oben, sondern mehr so wie eine aufgepeitschte Nordsee bei Sturmflut zu Vollmond. Wer sich dann noch in einen Werwolf verwandelt und mit Wolfstatzen versuchen muss, unterscheidbare Tasten auf der Tastatur zu treffen, der trainiert wohl gerade ein tiefes Neuronales Netz.

"Deichbruch an der Elbe (1825)" - Friedrich Thöming (1802-1873) - Museum Eckernförde - Gemeinfrei - Was machen wir mit so einer undurchschaubaren Oberfläche?

Wir wollen uns der Lösung des Problems, die richtigen Gewichte für ein Netz zu finden, daher lieber gleich viel allgemeiner nähern. Und zwar näherungsweise. Ohne Witz! Wir lösen heute also nicht nur das Rätsel einer lieblosen Linie durch eine Datenwolke, sondern das gesamte Rätsel, wie neuronale Netze lernen.

Wir wollen uns nochmal anschauen, was so eine Ableitung eigentlich ist.

Differenzieren zur Orientierung

Nehmen wir mal einen einfachen Fall, den jeder kennen dürfte: die Normalparabel.

Das Ding wollen wir uns sogleich auch mal vor Augen führen.

1
2
3
4
parabel_x = np.linspace(-8, 8, 100)
parabel_y = parabel_x ** 2
plt.plot(parabel_x, parabel_y)
plt.grid(True)

So. Mal genommen, Sie befänden sich dort:

Ok, ich gebe es zu, ich bin ein xkcd-Fan. Ich hoffe, mir nimmt den Stiltransfer niemand übel. Aber zurück zum Thema: Angenommen, wir würden uns dort befinden. Wie kommen wir von dort zum Minimum, wenn wir noch gar nicht wissen, in welcher Richtung sich das Minimum befindet? Wir sind ein kurzsichtiges Strichmännchen und können nur die Stelle untersuchen, auf der wir uns gerade befinden.

Was wir aber auch als kurzsichtiges Strichmännchen merken müssten, ist, dass es an der Stelle, an der wir uns befinden, ziemlich steil geht. Mit andereren Worten: die Steigung an der Stelle ist ziemlich übel, und es geht eindeutig nach rechts bergab. Wenn wir zum Minimum wollen, sollten wir also nicht nach links gehen. Sondern nach rechts.

Der eine oder andere wird es sofort parat haben: die Steigung der Normalparabel $f(x) = x^2$ an der Stelle $x$ ist eben genau $2x$. Wenn wir uns an der Stelle $x=-6$ befinden, ist die Steigung also negativ, und eine negative Steigung bedeutet, wir müssen weiter nach rechts gehen. Wären wir auf der gegenüberliegenden Seite an der Stelle $x=6$ wäre die Steigung positiv und wir müssten nach links gehen.

Hier kann uns die Analysis also helfen, uns zu orientieren, in dem wir uns die Ableitung (engl. Derivative) an der Stelle, an der wir uns gerade befinden anschauen. Der Standort ist dabei erstmal egal.

Ihr erinnert Euch sicherlich noch an die Perzeptron-Lernregel, die wir in den letzten beiden Tutorials eingeführt und angewendet haben. Dort hatten wir durch die Differenz aus geliefertem und gewünschtem Wert die Richtung für die Korrektur bekommen. Jetzt fangen wir an mit dem Differenzieren, um uns eine Richtung für die Orientierung zu erarbeiten.

Die Bausteine aus der Analysis

Wer alle Bausteine der Analysis frisch parat hat, weil er dies gerade im Mathe LK hatte oder im Studium sowieso übermorgen die Prüfung schreibt und bis zur partiellen Ableitung (engl. partial derivative) und Kettenregel (engl. chain rule) runterbeten kann, kann diesen Block hier bis zur nächsten Sektion überspringen.

Für alle anderen kommt jetzt nochmal eine kurze Auffrischung, und wir ziehen uns auch nur das aus der ewigen Mathematik heraus, was wir wirklich für die Neuronalen Netze brauchen. Es ist wirklich okay, wenn man den kommenden Text nicht nur einmal, sondern nochmal und wieder liest, zwischendurch frische Luft schnappt oder Gummibärchen beim Lesen verspeist, nachdem man ihnen Namen gegeben hat. Und wenn man einen gespitzten Bleistift parat hält, um nachzurechnen, ob alles in diesem Tutorial mit rechten Dingen zugeht.

Auch, wer Neuronale Netze “nur anwenden” möchte, ist gut darin beraten, zumindetens einmal verstanden zu haben, was die grundlegende Mechanik dahinter ist. Das hilft, die Technologie besser einzuschätzen und auch produktiver einzusetzen.

Ihr müsst bedenken, von dem Rosenblattschen Perzeptron bis hin zu dem Punkt, an den wir bald kommen, sind viele Jahrzehnte der Wissenschaft vergangen. Einige der klügsten Köpfe ihrer Zeit haben nicht das gesehen, was uns im Nachhinein wie selbstverständlich erscheinen wird. Also, packen wir es an!

Wie steil geht f mit x?

Im Grunde genommen ist eine Ableitung eine Anwendung von Differenzen (und Quotienten): wir betrachten die lokale Steigung des Graphen, in dem wir ein minimal kleines Steigungsdreieck bilden. Die grundlegende Frage bei der einfachen Ableitung ist: wie verändert sich mein $f(x)$, wenn ich $x$ verändere? Daher bezeichnet man die Ableitung auch als lokale Änderungsrate.

Fangen wir also nochmal an, uns das ins Gedächtnis zu rufen:

Hier sieht man: damit $f(x)$ einen Schritt nach oben geht, muss man in $x$ zwei Schritte nach rechts machen. Die Steigung ist also der senkrechte Stab (der Abstand zwischen den Funktionswerten an den jeweiligen Stellen) geteilt durch den waagrechten Stab (der Abstand zwischen den jeweiligen Stellen):

Setzen wir passend zur Illustration hier oben $x_0 = 2$ und $x_1 = 4$ und lesen die Funktionswerte vom Graphen ab, dann können wir ausrechnen:

Das gilt in der Form für gerade Linien, aber dank der Idee vom “unendlichen Kleinen” können wir das Ausrechnen der an einem Punkt geltenden Steigung für allerlei andere wohlfeile Funktionen übertragen. Bei Kurven nähern wir uns mit einem Grenzwert an ein beliebig kleines Steigungsdreieck an, das die Steigung am jeweils betrachteten Standort repräsentiert.

Das hat man dann mit dem ollen Limes untersucht (Limes hieß auch die Grenze zwischen dem römischen und germanischen Reich, verläuft u. a. auch quer durch den verschlafenen Teil von Hessen), das heisst, wir gucken in einem beliebig kleinen Bereich um die Stelle $x_0$ herum, ob der Quotient auf einen Punkt hin zuläuft oder undefinierbar bleibt.

Wenn der Grenzwert für alle möglichen Werte von $x_0$ existiert, dann ist diese Funktion differenzierbar. Eine umfassendere Einführung in die Analysis findet man auf wikibooks.org.

In der Schule hat man vermutlich die Bezeichnung “F-Strich”, also $f’(x)$, für die 1. Ableitung einer Funktion $f(x)$ kennengelernt.

Kurzes Rechenbeispiel

Für unsere Parabel kann man das sogar gänzlich ohne Limes ausrechnen, da sich der Differenzquotient nur durch algebraische Umformung in Wohlgefallen auflöst.

Dazu setzen wir die Parameter $x_0 + h$ und $x_0 - h$ ein die Vorschrift $f(x) = x^2$

$$ \begin{eqnarray} f'(x^2) &=& \frac{ (x_0 + h)^2 - (x_0 - h)^2 }{ (x_0 + h) - (x_0 -h) } \nonumber \\ &=& \frac { \overbrace{\textstyle (x_0^2 + 2hx_0 + h^2)}^{\text {binomische Formel } (a+b)^2} - \overbrace{ (x_0^2 - 2hx_0 + h^2) }^{\text{binomische Formel } (a-b)^2} } { x_0 - x_0 + 2h } \nonumber \\ &=& \frac { 4 hx_0 } { 2h } \nonumber \\ &=& 2x_0 \end{eqnarray} $$

Die Hauptarbeit ist also das Zusammenfassen der beiden ausgerechneten Binome von Zeile 2 zu Zeile 3 unter Beachtung aller Vorzeichen. Der Rest ist das Durchstreichen von unnötigem Kram, der sowohl im Zähler als auch im Nenner vorkommt. Nicht immer zeigen sich Funktionen beim Ableiten so wenig zickig wie die Parabel.

In der Praxis schaut man hierzu aber auch einfach in eine Formelsammlung, statt selbst den Differenzquotient auszurechnen.

Einfacher Gradientenabstieg

Nun können wir schon, da wir nun ja endlich wissen, was die Ableitung von $x^2$, einmal Strichmännchen spielen und einen ersten Gradientenabstieg wagen. Das ist nur jeweils ein kleiner Schritt für das Strichmännchen, aber ein großer Schritt für uns.

Wir versuchen also, zum Minimum der Parabel zu kommen, und zwar nur mit dem Wissen über die Steigung an der Stelle, an der wir uns gerade befinden (wir erinnern uns: Strichmännchen, die eine Rolle beim Gradientenabstieg spielen wollen, müssen kurzsichtig sein).

Wie oben in der Illustration wollen wir etwa bei $x = -6$ starten. Dann rechnen wir die Steigung an der Stelle aus und unterscheiden:

  • wenn die Steigung negativ ist, kann sich das Minimum wohl eher auf der rechten Seite befinden, wir erhöhen also den Wert von $x$ mit einer Lernrate. Wir kommen von “negative Steigung” auf “positive Änderung”, in dem wir bspw. mit -1 multiplizieren.

  • wenn die Steigung positiv ist, kann sich das Minimum wohl eher auf der linken Seite befinden, wir vermindern also den Wert von $x$ mit einer Lernrate. Wir kommen von positiver Steigung auf negative Änderung ebenso, wenn wir bspw. mit -1 multiplizieren

  • durch die Multiplikation mit -1 sind also beide Fälle in einem Rutsch abgefrühstückt und wir sparen ein doofes If-Statement :-D

  • wir merken uns den Pfad, den wollen wir nämlich auch noch plotten.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def gradientenabstieg(f, f_1, start_x, alpha, max_steps):
"""
f ist die Funktion, die wir absteigen wollen
f_1 ist die Ableitung dieser Funktion
alpha ist unsere Lernrate
max_steps ist die maximale Anzahl an Schritten
"""
# Wir starten bei start_x
pfad = [start_x]
for step in range(0, max_steps):

# Wie ist die Steigung an der Stelle start_x?
steigung = f_1(start_x)

# Wir machen eine Änderung entlang der Steigung
aenderung_x = -1 * alpha * steigung

# Und addieren die Änderung auf start_x
# Wir haben dann einen neuen Startpunkt,
# und wir können wieder von vorne beginnen
start_x = start_x + aenderung_x

# Wir merken uns die Reise von Punkt zu Punkt
# damit wir diese später plotten können
pfad.append(start_x)

# Die Reise zurückgeben
return pfad

Als Parameter können wir zwei Funktionen reinstecken: einmal die Funktion, die wir absteigen wollen. Da wir ja die ganze Zeit über Ableitungen palavern, stecken wir auch gleich noch die passende erste Ableitung mit rein. Die anderen Parameter sind alpha für die Schrittweite und max_steps für die Schrittmenge (das sehen wir gleich im Bild).

Dazu brauchen wir also zwei Funktionen, eine, die wir bewandern, und eine, damit wir wandern können:

1
2
3
4
5
def f_quadrat(x):
return x ** 2

def f_quadrat_ableitung(x):
return 2 * x

Jetzt kommt wieder ein bisschen matplotlib-Schmuck-Code, den wir einfach nur ausführen, aber nicht weiter beachten:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from matplotlib.patches import FancyArrowPatch

def plotte_gradientenabstieg(f, pfad):
x = np.linspace(-6.5,6.5,100)
plt.figure(figsize=(6,8))

# Zunaechst die Funktion einzeichnen
plt.plot(x, f(x))
ax = plt.gca()

# Dann Pfeile einzeichnen für den Gradientenabstieg
# OH DIESE PFEILE IN MATPLOTLIB!
for i in range(0,len(pfad)-1):
posA = (pfad[i], f(pfad[i]))
posB = (pfad[i+1], f(pfad[i]))
p = FancyArrowPatch(posA=posA, posB=posB,
arrowstyle='fancy, head_width=6, head_length=4',
color='red')
ax.add_artist(p)
posA = posB
posB = (pfad[i+1], f(pfad[i+1]))
p = FancyArrowPatch(posA=posA, posB=posB,
arrowstyle='fancy, head_width=6, head_length=4',
color='grey')
ax.add_artist(p)

Damit können wir uns jetzt anschauen, wie so ein einfacher Gradientenabstieg aussehen würde. Damit wir besonders fancy sind, aktivieren wir auch den xkcd-Look.

1
2
3
pfad = gradientenabstieg(f_quadrat, f_quadrat_ableitung, -6, 0.2, 15)
with plt.xkcd():
plotte_gradientenabstieg(f_quadrat, pfad)

Und dann haben wir dieses wunderbare Bild:

In immer kleiner werdenden Schritten steigen wir die Parabel hinab. Die Schritte werden deshalb kleiner, weil die Parabel nach unten hin immer flacher wird. Die Steigung sinkt also, damit auch die Schrittweite der Änderung, die wir oben in der Funktion gradientenabstieg ausrechnen.

Wir starten einfach mal auf der gegenüberliegenden Seite, setzen also start_x auf 6, und bringen eine wesentlich größere Lernrate alpha ins Spiel:

1
2
3
pfad = gradientenabstieg(f_quadrat, f_quadrat_ableitung, 6, 0.86, 10)
with plt.xkcd():
plotte_gradientenabstieg(f_quadrat, pfad)

Man sieht also, die Lernrate alpha beeinflusst tatsächlich enorm das Verhalten des Gradientenabstieges. Im letzten Bild hüpfen die Werte über die Schlucht hin und her. Probiert doch selbst einmal ein paar unterschiedliche Werte aus!

Die Aktualisierung unseres Wertes für das x lief im Code also in folgender Vorschrift ab:

Das war ein großer Schritt aus vielen kleinen Schritten - und wir nähern uns auch langsam aber sicher unserem Ziel.

Wozu wir uns das angeschaut haben

Es sollte aber auf jeden Fall ganz gut klar geworden sein, dass so ein Gradientenabstieg eine nützliche Sache sein kann. Später wollen wir ja wieder die Gewichte unseres Neuronalen Netzes verändern, um auf der Oberfläche der Verlustfunktion in Richtung des Tals zu navigieren. Als eine mögliche Verlustfunktion haben wir ja schon das Quadrieren der Fehler kennengelernt.

Was wir aber auch gesehen haben, als wir oben den 3D-Plot bemüht haben: wir müssen nicht nur in eine Richtung ableiten, sondern in mehrere Richtungen schauen (und wie das geht, lernen wir gleich).

Um nun also die Gewichte in Bezug auf den Fehler ändern zu können, brauchen wir noch ein paar Griffe in den Werkzeugkasten der Mathematik.

Puh, das wird doch langsam alles ganz schön viel Mathezeug! Ich will doch kein Formelfritze werden, sondern einfach nur tolle KI coden können!

Keine Panik: der mathematische Teil ist in diesem Kapitel des Tutorials in der Tat besonders intensiv, ja, um nicht zu sagen, penetrant. Viel brauchen wir nicht mehr, und schon bald können wir uns zurücklehnen, die Früchte unseres Erfolges ernten und genießen. Und das Zimmer einmal durchlüften.

Herzlichen Glückwunsch! - wer bis hier hin die Grundlagen eingeatmet hat, der will es wirklich wissen. Und wenn man diese eine magische Zeile Code da oben mit der Aktualisierung der start_x-Variablen verstanden hat, dann ist man nicht mehr weit davon entfernt, mit Code zaubern und große Probleme der Menschheit lösen zu können.

Unsere kleine Formelsammlung

Der lockige Leibniz. Gab uns nicht nur die schönste aller Schreibweisen für Ableitungen, sondern hat auch das Rechnen mit 1en und 0en - das binäre Zahlensystem - erfunden. Vielleicht der erste Informatiker überhaupt.

Die kommenden Dinge aus einer Formelsammlung sollten wir frisch ins Gedächtnis holen, damit wir den Kraftakt zur Herleitung der Delta-Regel auch wirklich bewältigen können. Der eine oder andere kennt das evtl. ja auch alles schon, vielleicht sind aber auch ein paar Dinge dabei, die man heute zum ersten Mal sieht.

Eine bessere Notation für $f’$

Falls man das noch nie gesehen hat, es gibt eine viel schönere Schreibweise als $f’$ für die Ableitung, die ich viel einleuchtender finde.

$\large{\frac{df}{dx}}$

Das spricht man “df nach dx” und meint damit “die Funktion f wird nach x abgeleitet” (mein alter Mathelehrer war ein Witzbold und sprach immer von “nach x abgelitten”). Man darf dies keinesfalls als “geteilt durch” lesen, sondern man sollte $\frac{d}{dx}$ wie einen ziemlich raffinierten und mit Finessen gespickten Operator betrachten, der eine Funktion ableitet.

Das ist die Notation nach Leibniz (nicht der mit den 52 Zähnen, sondern dieser adelige Lockenkopf da oben im Bild, modisch noch im späten Mittelalter, geistig schon in der frühen Aufklärung). Auch, wenn man zum Kontrollieren von Ableitungen bspw. WolframAlpha einsetzt, wird man diese in der Wissenschaft gängige Notation wieder entdecken können, wie zum Beispiel auch für die Parabel (ein Stück nach unten scrollen und man bekommt als Derivative die Ableitung in Leibnizscher Notation gezeigt).

Man kann das auch $\frac{d}{dx}(f)$ schreiben oder wenn man f gleich ausschreiben will auch bspw. $\frac{d}{dx}(x^2)$ für die Ableitung der Parabel.

Diese Notation wird uns gleich nützlich zufallen.

Die Ableitung einer konstanten Funktion

Das ist nun wirklich easy. Eine Konstante hat keine Steigung.

Wenn unsere Hand nicht so zittern würde, sähe man auch, dass da nix steigt

Sprich, die Ableitung von $f(x) = 1$ oder $f(x) = 3,141592653 $ ist immer 0, weil sich da - ganz gleich, wie man den Wert von $x$ verändert - am Funktionswert einfach nix ändern will.

Funktionen mit + verbunden

Das ist eine sehr schöne Eigenschaft, in der Formelsammlung zu finden unter dem Schlagwort Linearität der Ableitung. Zwei mit + verbundene Funktionen bzw. Terme kann man einzeln ableiten und dann wieder nach dem Ableiten addieren.

Wenn wir also bspw. soetwas haben:

dann können wir das Ding am Plus getrennt einzeln ableiten und wieder addieren:

In unseren Kontext ist das wichtig, weil die Eingabe in ein Ausgabeneuron ja am Anfang aus einer Addition besteht, die sich im Skalarprodukt versteckt. Alle Eingabeneuronen und Gewichte zum Ausgabeneuron stecken da drin, und für die Gewichte interessieren wir uns ja beim Lernen in besonderem Maße.

Die famose Kettenregel

Bald gehören auch wir zur weltweiten KI-Verschwörung

Bei der Kettenregel (engl. chain rule) geht es darum, wie man ableitet, wenn mehrere Funktionen ineinander gestöpselt sind. Es wird aber auch gemunkelt, dass es eine weltweite Verschwörung von Mathelehrern gibt, die nichts anderes zum Ziel hat, unser aller Gehirne in akrobatischste Kettenformationen zu zwängen, ohne uns den tatsächlichen Nutzen davon aufzuzeigen.

Tatsächlich haben wir Freunde der Neuronalen Netzen allen Grund dazu, uns an einer Verkettung zu erfreuen - schliesslich sind unsere Neuronen ja auch miteinander verkettet. Und, ob ihr es glaubt oder nicht, der zweite große Durchbruch für die neuronalen Netze Ende der 80er Jahre mit dem heute allerorts eingesetzten Lernverfahren Backpropagation wäre ohne die Kettenregel völlig undenkbar! Grund genug, uns dieser heiligen Regel ein wenig ausführlicher zu widmen.

So schlimm ist es auch eigentlich gar nicht.

Beispielsweise, wenn ich so etwas habe wie $f(x) = sin(cos(x))$. Der Kosinus ist hier direkt von x abhängig, aber der Sinus ist nur indirekt von x abhängig, direkt aber von dem Wert, den der innere Kosinus geliefert hat. Die Kettenregel erfordert sicherlich ein geschultes Auge, um zu sehen, wann man wirklich eine Verschachtelung hat, die im Sinne der Kettenregel gemeint ist. Da hilft leider tatsächlich nur ein Blick auf viele Beispiele, um das einzuüben.

Aber selbst eine so simple Funktion wie $f(x) = (x+1)^2$ besteht aus einer Verkettung: Die äußere Funktion ist “zum Quadratnehmen”, und die innere Funktion ist eine einfache Addition. Um das Ding auseinander zu nehmen ist es ratsam, wenn man sich die identifizierten Teile von innen nach außen separat auflistet und diesen Teilfunktionen einen jeweils eigenen Namen gibt (man kann natürlich auch einfach erstmal ein paar anonyme Gummibärchen essen). Das ist, wenn man so will, wie ein bisschen Refactoring eines mathematischen Codes.

Die innere Funktion haben wir als $x + 1$ identifiziert. Diese Funktion nennen wir jetzt mal mangels Phantasie einfach $g$. Das wird ein Teil der Kette und wir merken uns: $g(x) = x + 1$

Die äußere Funktion war ja anfangs das ganze Ding, $f(x) = (x + 1)^2$. Da wir oben aber schon längst in weiser Voraussicht den inneren Ausdruck $x + 1$ extrahiert und umgetauft haben, können wir unser $f$ nun auch so schreiben: $f(x) = g ^ 2$. Nichts und niemand kann uns daran hindern, und wir können auch jederzeit wieder überall dort, wo jetzt $g$ steht, den ursprünglichen Ausdruck $x+1$ wieder einsetzen. Wir tricksen einfach alle aus, am Ende stimmt wieder alles, und uns werden vom staunenden Publikum magische Kräfte zugesprochen - statt Kaninchen zaubern wir einfach ein gespeichertes $g$ aus dem Hut.

Jetzt werden aber auch die Verhältnisse deutlich klarer: $f$ ist jetzt von $g$ abhängig, und $g$ selbst ist wiederum von $x$ abhängig (langsam wird auch klar, warum das Kettenregel heisst… das könnte man unendlich fortsetzen). Alle diese Teile der Kette kann man dann einzeln ableiten, man setzt im Anschluss an die einzelnen Ableitungen wieder die benamsten Teilstücke zurück an ihren anfänglichen Platz, und man verbindet alle Teile mit einer Multiplikation.

Die Kettenregel arbeitet also nach dem Grundsatz: Äußere Ableitung mal innere Ableitung.

Unser Beispiel soll das ganze einmal durchspielen.

Wir machen erstmal $\color{green}{g = x + 1}$.

Dann ist g eine Funktion von x.

Dann machen wir unser $\color{blue}{f = g^2}$ und dadurch wird $f$ eine Funktion, die von $g$ abhängig ist.

Um nun unser $f$ wieder nach $x$ abzuleiten, müssen wir also $f$ nach $g$ und $g$ nach $x$ ableiten, weil das die neuen Abhängigkeiten sind, und in eine multiplikative Kette einfädeln:

Junge Junge. Da haben wir gerade erst die Notation von Leibniz gelernt und dann gleich so ein Biest. Wir gucken mal, wie man damit weiterarbeiten kann. Wir setzen als erstes mal für $f$ und $g$ die jeweiligen Vorschriften wieder in die Rechnung:

Der erste Teil kann schon mal abgeleitet werden:

Der zweite Teil nach dem Malpunkt kann nun auch einfach abgelitten werden:

In diesem Schritt konnten wir sogar zwei Regeln von vorhin anwenden, das Verketten von Termen mit + und die Ableitung einer Konstanten.

Nun können wir die einzeln abgeleiteten Funktionen wieder in der Kette zusammenführen:

Jetzt dürfen wir unser $g$-Kaninchen wieder aus dem Hut zaubern und setzen $g=x+1$ wieder zurück und erhalten als finales Ergebnis:

Puuuuuh! Was muss man sich doch hier wirklich in mathe-artistische Gefilde begeben, um diese ganze Kette auf die Reihe zu bekommen. Das Tolle jedoch ist, dass wir jetzt quasi unbesiegbar sind. Wir haben vom dem Zaubertrank getrunken und erhalten genau die Kräfte, die wir brauchen. Niemand kann uns aufhalten, auch noch längere Ketten zu bauen, auch an so einen Anblick kann man sich gewöhnen:

Man beachte, wie das vorherige Glied der Kette immer nach dem nächsten Glied der Kette abgeleitet wird. Vor dem Gleichheitszeichen steht das, wo wir hin wollen, und dahinter steht eine Kette, die uns den Weg dahin ermöglicht.

Jetzt sind wir auch hoffentlich froh, dass wir die Notation nach Leibniz kennengelernt haben! Wie soll denn die Kettenregel bitteschön in schulmathematischer Form aussehen, wenn wir f(g(h(i(j(x))))) haben? In dieser Leibnizschen Form kann man das doch wirklich sehr gut handhaben, da wir in jedem Schritt wissen, wonach wir ableiten.

Sind wir jetzt endlich durch? Leider noch nicht ganz, wir brauchen aber nun wirklich nur noch eines. Dann haben wir’s mit dem mathematischen Werkzeugkasten für heute. Und wir brauchen auch im nächsten Tutorial keine neuen Konzepte einzuführen. Wir haben dann gleich alles, was wir für lange Zeit brauchen.

Partielle Ableitung - wie Rosinen picken

Partielle Ableitung klingt schlimmer, als es ist. Zumal wir schon ganz schön viel Vorarbeit geleistet haben, zerfliesst die partielle Ableitung quasi in unseren mathemagischen Händen.

Bislang haben wir uns nur Funktionen angeschaut, in denen nur ein ödes x drin vorkommt. Wie wäre es denn, wenn da unterschiedliche Dinge in einer Funktion drin sind? Bspw.

Da ist die Funktion $o_1$ ja schon gleich mit vier unterschiedlichen benamsten und nummerierten Eingabemöglichkeiten gespickt. Und das erinnert uns ja auch an unser Skalarprodukt zwischen den inputs i und den Gewichten w, hier nochmal, weil es so schön ist: $i \bullet w$.

Die Strategie, hier mit mehreren Parametern zu jonglieren, ist aber denkbar einfach: wir picken uns einen raus, den wir gerade besonders lecker finden, und tun so, als wären alle anderen konstant!

Oben haben wir ja schon, gleich zu Beginn, gelernt, dass die Ableitung von konstanten Termen NIX ergibt - wir können uns also wirklich auf pikante Details einer Funktion mit mehreren Parametern stürzen und die anderen einfach rausfallen lassen. Woooosh! - es ist, als wäre unsere Welt gerade ein bisschen kleiner und einfacher geworden.

Wir bauen uns wieder ein sehr einfaches Beispiel:

Und dieses Beispiel kann im gegenwärtigen Zustand eigentlich nur folgende Reaktion auslösen:

WTF macht dieses $\pi$ da und was soll der Mist mit dem $\frac{1}{1+e^{\theta\beta y}}$? Aber keine Panik!. Wenn man soetwas sieht, tief durchatmen, und erstmal weiterlesen, was eigentlich tatsächlich gerade verlangt ist.

Wir wollen das Ding da oben partiell nach x ableiten. Da partielle Ableitung ja schon etwas besonderes ist, nutzen wir dafür auch eine besondere Notation:

Das ist bitte nicht zu verwechseln mit unserem $\frac{df}{dx}$. Das stilisierte kursive Zeichen $\partial$ ist auch kein griechischer Buchstabe, sondern einfach nur ein entfernt an ein kleines d erinnernder Kringel, den sich ein Mathematiker ausgedacht hat, weil es für partielle Ableitungen noch keine Notation gab (und vermutlich gab es eine OutOfGreekLettersException).
Der d-Kringel hat sogar einen eigenen Wikipedia-Artikel.

Man liest das dann auch als “f partiell nach x” (und nur nach x, nach nix anderem).

Jetzt müssen wir nur noch alle unsere Regeln einmal anwenden:

  • Eine Summe kann zerlegt werden und die Teile können einzeln abgeleitet werden
  • Eine Konstante hat die Ableitung 0
  • In der partiellen Ableitung sind alle Terme, die nicht von dem gerade untersuchten Parameter abhängen, konstant gehalten.

Das heisst also für uns:

Alles klar? Der Bruch fliegt raus, da ist kein x drin. Was $\pi$ ist, wurde nicht genauer erklärt, daher gehen wir davon aus, dass das sowieso eine Konstante ist, ein x kommt auch nicht drin vor. Diese Teile der Summe einzeln partiell nach x abgeleitet werden spätestens in diesem Zuge zu einer Konstanten und werden zur 0. Bleibt also nur noch das $x^2$ übrig, und das können wir nun wirklich mittlerweile rauf und runter ableiten. Nochmal Glück gehabt, was?

Dadurch, dass wir nur nach einem Parameter partiell ableiten, ist es so, als schauen wir nur entlang einer Achse. In unserem Kontext wollen wir die Fehleroberfläche in Bezug auf die Änderung eines Gewichtes untersuchen, sprich, wir machen einen Gradientenabstieg hin zum minimalen Fehler, in dem wir die Gewichte aktualisieren.

Der helle Wahnsinn! Wir sind endlich am Ende von dem Block, der alle grundlegenden mathematischen Werkzeuge bereitgelegt hat. Mehr müssen wir nicht mehr in unseren Kopf reinballern, um neuronale Netze vollständig zu implementieren. Wir müssen es jetzt “nur” noch anwenden.

Dazu machen wir aber jetzt nochmal eine kleine Verschnaufpause und sehen uns gleich wieder. Gleich wird auch klar werden, wozu wir alle diese Dinge aus der Formelsammlung ausgegraben haben.

Die Macht ist mit uns

Wir sind jetzt gerüstet für den letzten Teil. Mit der Herleitung einer verallgemeinerten Delta-Regel schließen wir das Tutorial dann auch ab, und was wir mit diesem Tutorial an Wissen angereichert haben, werden wir bis zum Deep Learning beibehalten.

Vom Fehler zum Gewicht durchkämpfen

Fassen wir noch einmal kurz zusammen, was wir bisher gelernt haben:

  • wir können den Fehler des Neuronalen Netzes messen, dazu haben wir einen 3D-Plot der Verlustfunktion dargestellt für alle möglichen Gewichtspaarungen, da wir in unserem Problem mit der geraden sowieso nur zwei Gewichte haben, war das so möglich. Und was möglich ist, wird ja auch früher oder später einfach gemacht.

  • wir wollen die Gewichte mit Gradientenabstieg anpassen: dazu müssen wir wissen, wie der Fehler sich verändert, wenn wir die Gewichte ändern: eine Änderungsrate schreit ja förmlich nach eine Kette von Ableitungen

Wenn wir also unsere Gewichte anpassen möchten, so dass der Fehler kleiner wird, müssen wir in Erfahrung bringen, in welche Richtung wir unser Gewicht anpassen müssen.

Wir brauchen dazu die partielle Ableitung des Fehlers (unsere loss-Funktion $\mathcal{L}$) nach dem jeweiligen Gewicht ($w_n$).

Dann können wir die Lernregel für die Aktualisierung eines Gewichtes wie folgt notieren:

Wir müssen uns also durch alle Erinnerungsbruchstücke kämpfen, um einen Weg vom einzelnen Gewicht zum Fehler des Netzes zu finden. Und das tun wir jetzt.

Herleitung der erweiterten Delta-Regel

Unser Loss war ja bspw. definiert als: “gewünschte Ausgabe minus tatsächliche Ausgabe zum Quadrat”. Da wir eine quadratische Form ja mittlerweile wie ein Profi ableiten können, nehmen wir auch diese Definition.

Wenn wir uns dieses Ding mal so anschauen, ist da noch gar kein $w$ drin, nach dem man ableiten könnte. $t_0$ ist das Wunschkonzert aus den Trainingsdaten, und offensichtlich ist die loss-Funktion abhängig vom output $o$. Wir können daher $\mathcal{L}$ erstmal nur nach o ableiten und müssen dann von dort aus den Weg zum Gewicht finden.

Unser output $o$ des Ausgabeneurons wiederum haben wir für unser Problem mit der geraden Linie so definiert, dass das Ausgabeneuron einfach nur das ausgibt, was als Netzeingabe auch reingekommen ist. Denn das ist ja genau eine gerade Linie, wie wir mit der Analogie zum berühmten $mx + b$ gezeigt haben.

Unsere Aktivierungsfunktion, die wir mal wie schlaue Mathematiker einfach mit $\varphi$ (griech. phi) bezeichnen (Mathematiker sind eben faul - von eindeutigen Bezeichnungen für Variablen und Code-Smell haben die einfach noch nix gehört. Mir als Informatiker tränen auch immer die Augen, wenn ich Mathematikern bei der Arbeit zusehen muss), ist daher im Moment wenig spektakulär - wir nehmen sie aber trotzdem mal mit rein, denn später wollen wir ja auch andere Aktivierungsfunktionen nutzen, und dazu sollten wir die gesamte Kette einmal gesehen haben:

Der output ist dann eine Verkettung (na, klingelt es langsam?) der Aktivierungsfunktion mit der Netzeingabe, die wir mal mit $net$ bezeichnen wollen.

Wenn wir das so schön aufgeschrieben haben, sehen wir auch in $o_0$ erstmal noch gar kein einziges Gewicht, dafür eine Aktivierungsfunktion und eine Netzeingabe. Wir können an dieser Stelle also immer noch nicht nach dem Gewicht ableiten, sondern müssen uns wiederum auf die Suche nach dem ersten $w$ machen.

Und da haben wir es auch schon, denn die Netzeingabe $net$ ist ja unser Skalarprodukt aus Eingabeneuronen und Gewichten:

Wir haben das $w$ endlich gefunden, wenn auch erst in einer langen Kette. Wir schreiben die nochmal zusammen auf, damit wir das Gebilde in der ganzen Pracht bewundern können (im Mittelalter hätte man uns spätestens jetzt ob der seltsamen Symbole als Hexe/r aus der Stadt verbannt):

Wichtig ist nur, dass wir jetzt einen Weg vom Fehler zum Gewicht gefunden haben. Denn jetzt können wir das Ding nach allen Regeln der Kunst mit unserer Formelsammlung ableiten:

Den Loss können wir nur nach o ableiten, o können wir nur nach net, und net schließlich partiell nach dem einen Gewicht w_n.

Zunächst beheben wir mal den Code-Smell und setzen für diese kryptischen Abkürzungen wieder die Formeln in die eigentliche Rechnung, die wir später auch am Computer implementieren wollen (ein Glück, dass wir diesen Leibniz kennengelernt haben!) - so machen wir bspw. an der richtigen Stelle aus dem o auch gleich wieder unsere Aktivierungsfunktion, seht selbst:

Jetzt müssen wir wirklich nur noch jedes Ding einmal kurz einzeln ableiten.

Die Ableitung von $(t_0 - o_0)^2$ nach $o_0$ ist schon gleich ein bisschen tricky. Erinnert Euch das auch an das Beispiel, was wir in der Kettenregel vorgeführt bekommen haben? Auch hier haben wir ja schon wieder eine innere Funktion, die $t_0 - o_0$, und eine äußere Funktion, das zum Quadratnehmen.

Wir machen also eine kleine Nebenrechnung auf:

Die innere Funktion nennen wir einfach g:

Die äußere nennen wir h:

$h$ hängt dann nicht mehr von $o$ ab und kann also nur nach $g$ abgelitten werden. $g$ jedoch können wir immer noch nach $o$ ableiten. Dann können wir ein kleine Kette aufmachen und ableiten:

Habt ihr es gemerkt? Das Auflösen der -1 hat die Reihenfolge von $t_0$ und $o_0$ vertauscht. Die 2 am Anfang ist eigentlich völlig Wurscht. In Lehrbüchern wird die quadratische Loss-Funktion auch immer schon unkommentiert mit einem Vorfaktor von $\frac{1}{2}$ eingeführt, so dass sich die 2 einfach wegkürzt. Die 2 ist deshalb egal, weil wir sowieso gleich noch eine Lernrate $\alpha$ benutzen werden. Spätestens, wenn wir $\alpha$ einfach halbieren, ist die 2 völlig egal, und niemand macht uns irgendwelche Vorschriften, wie wir $\alpha$ zu setzen haben.

Im nächsten Schritt lassen wir die 2 einfach verschwinden, wir tun mal so, als hätten wir die Loss-Funktion gleich so definiert, dass das passiert wäre. Also bitte jetzt nicht mehr nach der 2 suchen.

Als nächstes kommt die Identitätsfunktion $\varphi$ dran. Was soll man da schon groß zu sagen? Die Ableitung einer Funktion die aussieht wie $f(x)=x$ ist einfach 1. Auch wenn da $\varphi(x)=x$ steht, ist die Ableitung 1, es sieht aber sehr viel beeindruckender aus!

So, nun müssen wir noch mal in das Skalarprodukt reinzoomen. Die verkürzte Schreibweise mit dem $\bullet$ ist gut zu merken und sparsam in der wertvollen schwarzen Tinte, man muss sich allerdings jetzt daran erinnern, dass es sich um eine Summe von Multiplikationen handelt.

Wir erinnern uns an drei der vier Regeln:

  • in der Summe darf ich die Summanden einzeln ableiten und nach diesem Vorgang wieder zusammenaddieren
  • in der partiellen Ableitung halte ich alle Terme, die nicht von meinem Zielparameter abhängen, konstant
  • und Konstanten fliegen einfach raus - fertig!

Wenn wir nach dem einen Gewicht $w_n$ ableiten, bleibt also nur noch ein einziger Term übrig, den wir überhaupt noch explizit ableiten können:

$i_n * w_n$

Alle anderen Terme sind durch die beiden Ableitungsregeln wie von Zauberhand verschwunden! Pure Mathemagie! Das Publikum tobt und applaudiert mit einem frenetischen Jubel!

Und da $w_n$ der Parameter ist, nach dem wir ableiten, interessieren wir uns für die Steigung des Parameters. Das ist, was sollte es auch anderes sein, der Vorfaktor $i_n$. Die Ableitung dieser scheusslich langen Summe löst sich also dank aller Regeln auf in ein $i_n$ - wenn dass nicht sensationell ist, womit kann man denn dann noch Eindruck schinden?

Geschafft! Jetzt haben wir die Loss-Funktion $\mathcal{L}$ partiell nach $w_n$ abgeleitet und wissen also nun, wenn wir entlang der Achse von $w_n$ schauen, wie die Steigung unter unseren Füßen ist. Die Steigung gibt uns dann an, in welche Richtung wir unser Gewicht verändern müssen.

Wir haben vor einer Weile gesehen, dass wir beim Gradientenabstieg in die umgekehrte Richtung der Steigung laufen mussten (negative Steigung -> nach rechts gehen, positive Steigung -> nach links gehen)! Setzen wir jetzt alles in die Funktion für die Gewichtsaktualisierung ein, wir bedenken den Richtungswechsel mit einer Multiplikation mit -1 und vereinfachen so weit wie möglich:

Noch ein bisschen zusammenfassen (wir tauschen wieder einfach die Richtung der Subtraktion zwischen $o_0$ und $t_0$):

Das, meine Damen und Herren, das ist wieder die Delta-Regel, die wir schon in Tutorial 1 kennen gelernt haben! Schaut doch nach, wenn ihr es nicht glaubt! Wir hatten lediglich noch den Term $(t_0 - o_0)$ als Delta bezeichnet und dafür $\delta$ geschrieben. Wir haben auch mit dem Eingabeneuron $i_n$ multipliziert, damit wir die Gewichte proportional dazu ändern, wie stark das Neuron am anderen Ende der Leitung aktiviert war. Auch das ist hier erhalten geblieben!

Waaas? Das ganze Geraffel war also nur dazu da, um die Delta-Regel wieder aus der Mottenkiste zu holen??!?!

Ja, witzigerweise läuft es wohl darauf hinaus, dass wir die Delta-Regel des Perzeptrons auch durch die graduelle Verkleinerung des quadrierten Fehlers herleiten können.

Aber: Wir haben ja auch die blaue 1 unterschlagen! Hier ist nur deshalb die einfache Delta-Regel wieder entdeckt worden, weil wir für die Aktivierungsfunktion $\varphi$ die Identität, also $f(x)=x$, angenommen haben. Das gilt also nur in dem Fall, dass wir eine Lineare Ausgabeaktivierung haben!

Wenn wir andere Aktivierungsfunktionen benutzen wollen, dann können wir alles gleich lassen, und müssen nur den Teil der Kette austauschen, der sich um die Aktivierungsfunktion gekümmert hat. Allgemein können wir die erweiterte Delta-Regel also wie folgt auffassen:

Wenn wir andere Loss-Funktionen verwenden wollen, müssen wir auch den Teil der Kette austauschen, der sich um diesen Aspekt des Gradienten gekümmert hat. Hier helfen uns dann später Bibliotheken wie Keras oder PyTorch, damit wir nicht alles mühsam von Hand ausrechnen müssen.

Herzlichen Glückwunsch! Wer bis hier hin gekommen ist, der hat die fundamentale Mathematik der Neuronalen Netze vollständig aufgesogen. Was später noch kommt, sind Schmuck am Nachthemd und ein paar schmutzige Tricks. Wir ändern vielleicht mal eine Aktivierungsfunktion. Wir tweaken hier und tweaken dort noch ein bisschen. Vor allen Dingen aber wollen wir auch noch mehrere Ausgabeneuronen modellieren und Backpropagation einführen, das wir brauchen, wenn wir tiefe Modelle mit versteckten Zwischenschichten (engl. hidden layer) bauen wollen. Aber Backpropagation ist wirklich nicht viel komplizierter als das, was wir uns bis hier mit der erweiterten Delta-Regel alles angeeignet haben.

Endlich: das gesamte Trainingsprogramm

Ja, jetzt dürfen wir uns nochmal anschauen, wie wir unser eingangs formuliertes Problem lösen - wie wir also die lineare Regression mittels Gradientenabstiegsverfahren lösen und so eine ideale Linie durch die Datenwolke ziehen können. Mensch, da kommt man sich doch fast schon echt klug vor, wenn man so einen komplizierten Satz aussprechen kann.

Das ist ja jetzt einfach, nicht wahr? Wir haben einen Loss, der quadratisch ist, wir haben eine lineare Aktivierung, herausgepurzelt ist die olle Delta-Regel, also funktioniert auch unser alter Code aus den vorherigen Tutorials eigentlich immer noch (obwohl das Problem von außen ganz anders aussieht).

Für die Jupyter Notebook Nutzer, erstmal schauen wir, ob alles noch da ist:

1
X.shape

Wenn diese Zelle ausgeführt wurde, sollte (100,2) im Jupyter-Notebook erschienen sein. Falls alles gut, dann hier weitermachen, ansonsten nochmal alle Zellen wieder ausführen.

Wir importieren uns wieder unseren tollen Fortschrittsbalken von tqdm, setzen die Lernparameter und initialisieren die Gewichte:

1
from tqdm import tqdm
1
2
3
epochen = 250 # Anzahl der Epochen, jede Epoche = alle Daten zeigen
alpha = 0.0001 # Unsere Lernrate alpha
errors = [] # Nur, damit wir uns die Fehler über die Zeit merken
1
weights = np.zeros(2) # ein Gewicht für Bias, eines für die Eingabe

Jetzt kann es auch schon losgehen mit dem Training. Zwischendurch plotten wir nochmal, damit wir den Verlauf des Trainings besser sehen können:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59

# Wir plotten den Urzustand der Linie als schwarze
# gestrichelte Linie erstmal raus
plt.figure(figsize=(12,9))
plt.scatter(X[:,1],y, color='r')
plt.plot(X[:,1], X.dot(weights), color='black', linestyle='--', alpha=.9)

# Wir zippen die X und y Daten in eine Liste zusammen.
# Auf diese Art und Weise können wir einfach data shufflen
# um unsere Daten immer in zufälliger Reihenfolge dem Netz
# zu präsentieren
data = list(zip(X,y))

# Für alle Epochen...
for epoche in tqdm(range(0, epochen)):

error = 0 # Um die Fehler für jede Epoche zu zählen

# Einmal durchschütteln
np.random.shuffle(data)

# Für alle Datenpunkte...
for i in range(0, len(X)):

# Einmal das Netz aktivieren.
inputs = data[i][0]
output = inputs.dot(weights) # ouput = mx + b

# Was wäre unser Zieldatenpunkt in der Wolke gewesen?
target = data[i][1]

# Der Fehler ist unser quadratische Fehler
error += verlust_quadrat(target, output)

# Unsere magischer gewordene Delta-Regel
# Da lineare Aktivierung, keine besondere Ableitung
# einer Aktivierungsfunktion dazwischen
weights += alpha * (target-output) * inputs


# Jetzt haben wir für diese Epoche alle Datenpunkte präsentiert
# und die Gewichte aktualisiert.

# Wir merken uns den aufsummierten Fehler der Epoche
errors.append(error)

# Wir plotten die aktualisierte Linie in einem Blauton.
# scale ist eine Hilfsvariable, um eine Skala auf den
# Blautönen zu finden.
scale = 1. - ((epochen - epoche) / epochen) / 2.
plt.plot(X[:,1], X.dot(weights), color=plt.cm.Blues(scale), alpha=0.8)

# Alle Epochen sind durch, wir plotten noch das finale Ergebnis
# in einem satten Blau
plt.plot(X[:,1], X.dot(weights), color='b')
plt.grid(True)

# Wir setzen unser gefundenes mx+b in den Titel
_ = plt.title(str(weights[1]) + "x + " + str(weights[0]))

Nach dem ersten Lauf dieser 250 Epochen sollte in etwa so ein Bild entstanden sein:

Im Jupyter-Notebook kann man nun die obige Code-Zelle einfach nochmal ausführen, dann sollte ein neues Bild entstehen, bei dem sich die Linie schon bedeutend weniger stark ändert. Hier sind ein paar Beispielbilder aus meinen Läufen:

Hier nochmal der Hinweis, dass abgesehen von den Plots dazwischen die gleiche Gewichtsaktualisierung vollzogen wurde wie im letzten Tutorial mit unseren Sonardaten. Was ja schon die Vielseitigkeit auch unseres einfachen Modelles zeigt.

Fazit

Puh! Das war wirklich harter Tobak, anstrengend und teilweise auch echt übelriechendes Zeug. Das war sicherlich keine einfache Reise, schon gar nicht, wenn man diese zum ersten Mal in dieser Form gemacht hat, aber es wird von nun an wieder leichter werden.

Wer sowieso gerade schon viel Analysis betreibt, wird es sicherlich deutlich einfacher gehabt haben als jemand, der sich nach vielen Jahren wieder für das Thema interessiert. Es ist sicherlich schwer, beim Schreiben beiden Gruppen gleichermaßen gerecht zu werden. Ich hoffe dennoch, dass jeder auch noch etwas neues hier mitgenommen hat.

Die Kettenregel hat uns gezeigt, dass man sich den Fehler auch als eine Kette vorstellen kann, die letztlich vom einzelnen Gewicht abhängt, und mit der partiellen Ableitung konnten wir den Einfluss eines einzelnen Gewichtes isolieren. Wir haben es auch schon geschafft, die allgemeine Form der Lernregel darzustellen, wir können diese im Sinne eines Gradientenabstiegsverfahrens einbinden und nutzen das alles auch gleich im nächsten Tutorial für andere tolle Dinge.

Downloads

Das ganze Tutorial gibt es wie immer auch wieder als Jupyter Notebook, so dass man den Python Code gleich zu Hause auf dem lokalen Rechner ausführen kann - quasi mitten im Begleittext.

Die interaktiven Tutorials befinden sich in einem Github-Repository: https://github.com/dannybusch/neuromant.de-Tutorials/

Direktlink zur Datei: https://github.com/dannybusch/neuromant.de-Tutorials/blob/master/notebooks/Tutorial_Das-Perzeptron-Teil-3.ipynb