Tutorial - Das Perzeptron - Teil 4


Der Lego-Baukasten des Maschinellen Lernens - heute mit sigmoidaler Aktivierungsfunktion

Sigmoide Aktivierung und logistische Regression

Den wenigsten wird nach dem letzten Teil des Tutorials bewusst sein, welche enorme Vielfalt sich über Nacht eröffnet hat und wieviel Macht bereits in den Puzzle-Steinen steckt, die wir uns unter Schweiß und Tränen erarbeitet haben. Um einen Überblick über die ungeahnten Möglichkeiten zu gewinnen, und um das mühsam im letzten Tutorial Erlernte auch gleich for fun and profit anzuwenden und zu vertiefen, schieben wir einen Use-Case mit einem Datensatz über Weine ein!

Folgende wesentlichen Bausteine haben wir uns im letzten Teil angesehen:

  • Gradientenabstieg: Von einem beliebigen Startpunkt aus wandern wir auf einer Funktion entgegengesetzt zur Steigung hin zu einem Minimum;
  • wir sind auf einer Loss-Funktion zum Minimum des Fehlers gewandert; mit der Kettenregel haben wir einen Weg vom Loss zum Gewicht gefunden und konnten dadurch ein einzelnes Gewicht isolieren;
  • wir hatten eine lineare Aktivierung des Ausgabeneurons untersucht, damit eine Lineare Regression durchgeführt, also eine Gerade durch eine Datenwolke gezogen;
  • Und alles in allem, haben wir damit die erweiterte Delta-Regel hergeleitet.

Was wir tun wollen

Zum einen beschäftigen wir uns eingehender mit einer neuen Verlustfunktion oder auch Loss-Funktion. Wer sich bspw. mit Keras und TensorFlow bewegt, wird auch in der API eine ganze Reihe von Loss-Funktionen wiederfinden können, siehe bspw. https://keras.io/losses/. Dort werden einfach knapp die bereits implementierten Loss-Funktionen aufgelistet. Man sollte also vorher schon wissen, was man da warum verwenden möchte.

Zum anderen schauen wir uns eine andere Aktivierungsfunktion an, die sigmoide Schwellwertfunktion (keine Panik, das wird gleich noch schön auseinandergenommen).

Auch in tiefen Netzwerken gibt es die “letzte Meile”, das Ende des Netzes, und die Kenntnis von dem Zusammenspiel zwischen Verlust- und Aktivierungsfunktion ist ganz besonders wichtig.

Dann werden wir einen Datensatz als Grundlage für ein Training nehmen: dieses Mal wollen wir anhand von verschiedenen, laboranalytischen Eigenschaften eines Weines klassifizieren, ob es sich um einen edlen oder um einen schlechten Wein handelt. Wir werden also quasi über Nacht zum Weinkenner, ohne eine einzige Flasche aufzumachen! Was aber niemanden davon abhalten soll, heute Abend zur Belohnung ein wenig guten Weines einzuschenken. Prost!

Neuronale Netze als Baukasten des Machine Learning

Mit einer geschickten Kombination aus Aktivierungs- und Verlustfunktion und strukturellem Aufbau kann man mit Hilfe der neuronalen Netze eine ganze Schar von klassischen Algorithmen des Maschinellen Lernens, wie bspw. die Logistische Regression (engl. logistic regression) oder eine (lineare) Support Vektor Maschine (engl. support vector machine, SVM) wiederentdecken - man muss sich auch nicht doof vorkommen, wenn man diese Begriffe heute zum ersten Mal hört.

Die große Macht der neuronalen Netze heute ist nun auch darin begründet, dass man diese bewährten Verfahren in den neuronalen Netzen aufgehen lassen und durch Schachtelung und Rekombination zu neuen Höhen treiben kann. Die Neuronalen Netze sind also universelle Machine Learning-Algorithmen: das erklärt vielleicht auch, warum sich heute alle darauf stürzen, und warum es so viel Spaß macht, damit zu arbeiten.

Wir haben uns zum Verständnis des Gradientenabstieges im letzten Tutorial an der Linearen Regression lang gehangelt. In Zeiten von großen Datenmengen mit vielen Variablen kann es sogar sinnvoll sein, eine Variante des Gradientenabstieges auch für die Lineare Regression einzusetzen.

In diesem Tutorial erarbeiten wir uns allerdings mithilfe des neuronalen Modells ein Verfahren aus dem Maschinellen Lernen, das als Logistische Regression bekannt ist - und es gibt kaum einen Data Scientisten auf der Welt, der bei einer Klassifikationsaufgabe und einem bislang unbekannten Datensatz nicht als erstes auch eine LogReg, wie sie liebevoll genannt wird, darauf ansetzt.

Auch am Ende eines tiefen Netzwerkes für bspw. die Bilderkennung ist auch heute nichts anderes als eine sog. Logistische Regression! Das letzte Glied in der Kette ist ja oftmals eine Klassifikation, bspw., ob auf einem Bild ein Rüde oder ein Wollpullover zu sehen ist (nicht, dass man das immer sicher nur anhand eines Bildes unterscheiden könnte).

Eine logistische Regression ist dem Wesen nach nichts anderes als ein ganz bestimmtes neuronales Netzwerk mit je einer ganz bestimmten Aktivierungs- und Verlustfunktion. Nur, dass das über viele Jahrzehnte niemand in dieser Klarheit erkannt hat - die einen kamen eher aus der Statistik, die anderen eher aus der biologisch motivierten Neuroinformatik / Kybernetik. Ist ja auch egal, wie wir das Ding nennen - Hauptsache, wir lösen uns langsam mal von den Baby-Aktivierungsfunktionen! Und wir riechen auch so schon, dass neuronale Netze viel flexibler sein werden, als ein starres altbackenes Korsett der klassischen Verfahren.

Wir gehen da jetzt rein in pikanteste Details, also schnallt Euch an und: Legen wir los!

Aktivierung mit der S-Kurve

Was ist falsch an der Heaviside-Funktion?

Die Heaviside-Funktion, die wir als Aktivierungsfunktion in den ersten beiden Tutorials verwendet haben, hatten wir im letzten Tutorial mal eben so über Board geworfen. Nicht, weil die Heaviside-Funktion an und für sich schlecht wäre; einerseits wollten wir aber keine binäre Klassifikation vornehmen, sondern eine Lineare Regression, wo jegliche Aktivierungsfunktion vollkommen unpassend gewesen wäre; andererseits kann man diese Aktivierungsfunktion überhaupt nicht gut ableiten und wir hätten keinen durchgängigen Weg zur erweiterten Delta-Regel gefunden.

Wenn wir uns die alte Art der Aktivierung so ansehen: Die Steigung der Heaviside ist überall 0 (sie nimmt einen konstanten Wert an, entweder 1 oder 0, und Konstanten haben immer die Ableitung 0), und an der Stelle zwischen \lt0 und \ge0 hat die Funktion zu allem Überfluss eine Sprungstelle.

Das führt dazu, dass die Funktion nicht durchgehend differenzierbar ist (es gibt einfach keinen eindeutigen Grenzwert des Differenzquotienten für die Stelle um 0 herum) - da könnte man als Pragmatiker gerade noch so drüber hinwegsehen, wenn nicht sonst die Steigung der Heaviside-Funktion überall 0 wäre. Denn die Ableitung der Aktivierungsfunktion ist ja ein multiplikativer Teil unserer Lernregel geworden, also würde ein System mit einer stückweise abgeleiteten Heaviside-Funktion gar nix lernen! Für einfache Gemüter, für die es nur Schwarz oder Weiß gibt und nichts dazwischen, ändert sich ja auch oft geistig nicht mehr viel. Ich will jetzt aber keine Namen nennen müssen!

Wir sollten also dringend einen Ersatz suchen: eine Aktivierungsfunktion, die wir für Klassifikationsprobleme nutzen können und eine, die auch schön differenzierbar ist.

Am besten gleich eine, die beides in sich vereint.

Die S-Kurve: S wie Schwung

Die S-Kurve suggeriert, dass es nur eine einzige gibt, allerdings gibt es gleich mehrere Kurven, die wie ein S geschwungen sind. Bevor wir weiter das trockene Brot besprechen, schauen wir uns einmal eine S-Kurve an, die im Kontext der Neuronalen Netze häufig verwendet wird - die sog. logistische Funktion.

S-Kurven werden in Schlau auch sigmoidale Kurven genannt, und eine davon ist eben die logistische Funktion. Das Wort logistisch hat dabei nichts mit Logistik oder gar Logik zu tun! Warum die Funktion so genannt wird, soll uns für den Moment egal sein (der Ursprung steckt im sog. Logit-Modell).

Erst mal unsere lebenswichtigen Bibliotheken importieren:

1
2
3
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline

Zunächst implementieren wir kommentarlos die Sigmoidfunktion, die wir verwenden wollen - wir motivieren diese dann noch im Anschluss, wenn wir uns davon überzeugt haben, dass das mit der S-Kurve überhaupt irgend einen Fortschritt bringt:

1
2
def sigmoid(x):
return 1. / (1+np.exp(-x))

Als mathematische Formel sieht das Biest dann so aus:

\frac{1}{1+e^{-x}}

Unten im Nenner steht eine Exponentialfunktion mit der Basis e, das ist die Eulersche Zahl. Hochgestellt ist ein x mit negativem Vorzeichen. In jeder gut gepflegten mathematischen Software-Bibliothek gibt es eine exp-Funktion, die genau das wie bei uns oben im Code ausdrückt: e hoch irgendwas. Da kommen wir gleich drauf zurück, erst wollen wir die Schönheit sehen.

Wir plotten das Ganze noch über eine Heaviside-Funktion, um beide im direkten Vergleich vor Augen zu haben:

1
2
3
4
5
6
7
8
9
10
11
12
13
x = np.linspace(-9,9,1000) # Erzeuge X-Werte zwischen -9 und 9

plt.figure(figsize=(9,6))

# Beide Funktionen plotten:
# heaviside in orange
# sigmoid in rot
plt.plot(x,np.heaviside(x,1.0), label='heaviside', color='orange')
plt.plot(x,sigmoid(x), label='sigmoid', color='r')

# Grid und Legende anzeigen
plt.grid(True)
plt.legend()

Und schon haben wir beide Graphen zusammen in einem Bild:

Man sieht sehr deutlich, dass sich unsere Sigmoid-Kurve an die Heaviside-Funktion anschmiegt, aber im Bereich zwischen -5 und 5 einen langsamen Übergang zwischen 0 und 1 realisiert. An der Stelle x=0 ist der Wert der Sigmoid-Funktion \frac{1}{2} . Man könnte die logistische Funktion aber auch steiler oder weniger steil machen, je nach dem, ob man noch einen Vorfaktor vor das x packt.

Auch, wenn wir eine andere Basis als e für unsere Exponentialfunktion gewählt hätten, wäre hier eine S-Kurve bei rausgekommen! Mal angenommen, wir hätten die Basis 2 gewählt, dann würde die Funktion so aussehen:

\frac{1}{1+2^{-x}}

Wir untersuchen dann mal ein paar Stellen:

  • Wenn x=0 ist, steht da \frac{1}{1+2^0} . Da irgendwas hoch 0 immer 1 ergibt, haben wir also letzten Endes \frac{1}{1+1} und das ist \frac{1}{2} . So konstruierte S-Kurven haben also an der Stelle x_0 = 0 immer den Wert 0.5 . Das ist unabhängig von der Basis der so addierten Exponentialfunktion.

  • Wenn x im negativen Bereich ist, wird das Negative vom Vorzeichen aufgehoben. Nehmen wir mal an, x ist -5 , dann stünde da \frac{1}{1+2^5} , das wäre also \frac{1}{1+32} - je kleiner x wird, desto größer wird die Zahl unterhalb vom Nenner, die Zahl geht gegen 0, erreicht diese aber nie. Deswegen schwingen wir von links unten nach oben.

  • Wenn x umgekehrt positiv ist, wird das Positive vom Vorzeichen aufgehoben und der Exponent wird negativ. Ein negativer Exponent führt im Grunde genommen dazu, dass wir den Kehrwert nehmen, also aus 2^{-5} wird \frac{1}{2^5} . Je größer also unser x wird, desto kleiner wird die Zahl, die zur 1 dazu addiert wird. Am Ende steht da also \frac{1}{1+\text{winzige-Zahl}} , und das wird dann fast 1, erreicht aber niemals die 1.

Als Basis wird für unsere sigmoide Funktion e verwendet, weil e hoch irgendwas die herausragende Eigenschaft hat, dass die Steigung an einer Stelle dieser e-Funktion wieder der Funktionswert an der Stelle ist, sprich: die Ableitung der e-Funktion ist wieder die e-Funktion selbst!

\frac{d}{dx}(e^x) = e^x

Das kann sonst niemand! Nur e kann das. Wir nehmen also e, weil wir mit dieser Basis viel einfacher und schöner ableiten können.

Auf jeden Fall kann man als Eigenschaft der sog. logistischen Sigmoid-Funktion schon mal mitnehmen, dass die Werte niemals kleiner als 0 werden und auch niemals größer als 1 - ganz gleich, welches x wir reinstecken, die Werte werden in den Bereich zwischen Null und Eins “gesquashed” und erreichen diese beiden Werte auch niemals im Laufe der Unendlichkeit. In der englischen Literatur liest man daher auch oft den Begleitnamen “squashingfunction”.

Das ist für eine binäre Klassifikation eine lobenswerte Eigenschaft, da wir Zahlen, die näher an der 0 sind, als negative Klasse, und Zahlen, die näher an der 1 sind, als positive Klasse interpretieren können.

Es gibt vieles in der Natur, das sich mit so einer Kurve auch erklären lässt, bspw. Sättigungsprozesse: Ein Wert steigt langsam an und erreicht dann ein Plateau, ab dem nichts mehr ansteigen kann. So, wie wenn sich Kaninchen an Orten vermehren, wo es keine natürlichen Feinde gibt, wie auf Inseln - mehr Kaninchen brauchen mehr Nahrung, aber irgendwann ist nicht mehr genug Nahrung da; ergo: die Kaninchenpopulation bleibt in einer maximalen Größe gefangen, es sei denn, jemand führt von außen Salatköpfe und Mohrrüben auf die Insel ein.

Aktivieren und Differenzieren

Aktivieren können wir unser Ausgabeneuron dann wie folgt:

o_0 = \text{sigmoid}(i \bullet w)

Dabei ist i die Liste der Eingabeneuronen (von i wie engl. input), und w ist der Gewichtsvektor, unsere trainierbaren Werte. Mit i \bullet w notierten wir, dass wir das Skalarprodukt aus Eingabe- und Gewichtsvektor in die Aktivierungsfunktion einspeisen.

Wir haben ja in erster Linie nach einem Ersatz für die Heaviside-Funktion gesucht, die wir besser differenzieren können. Das ist mit dieser vom Himmel gefallenen Sigmoid-Funktion möglich!

\text{sigmoid}(x) := \frac{1}{1+e^{-x}}

Nachdem wir uns im letzten Tutorial schon den Wolf abgelitten haben, sparen wir das diesmal auf und suchen nach einer fertigen Lösung in den Untiefen des Internets. Diese Lösung scheint sauber zu sein und führt uns zu folgender Ableitung:

\frac{d}{dx}[\text{sigmoid}(x)] = \text{sigmoid}(x) * (1 - \text{sigmoid}(x))

Witzigerweise ist also die Ableitung wieder zusammengesetzt aus Termen mit der Funktion, die wir gerade abgeleitet haben. Das ist höchst erfreulich, da die ursprüngliche Funktion ja just auch unsere Aktivierungsfunktion ist - wir rechnen ja sowieso mindestens einmal die Aktivierung für jedes Neuron aus. Wenn wir also das Ausgabeneuron o_n schon einmal berechnet haben, reduziert sich die Ableitung für den Gradientenabstieg auf die schmale und knackige Formel:

o_n * (1 - o_n)

Recycling in seiner schönsten Form. Langsam macht es doch echt Spaß, mit diesen Ableitungen zu arbeiten. Am Ende kommt irgendwas einfaches bei raus, auch, wenn es am Anfang so aussah, als käme man nie dort an.

Einbauen in die Delta-Regel

Zitieren wir nochmal unsere erweiterte Delta-Regel aus dem letzten Tutorial herbei:

w_n \leftarrow w_n + \alpha * (t_0 - o_0) * \color{blue}{\frac{\partial}{\partial net}(\varphi(net))} \color{black} * i_n

In dieser Form hatten wir offen gelassen, welche Aktivierungsfunktion wir nutzen wollen. Da wir uns heute für eine sigmoidale Aktivierungsfunktion entschieden haben, können wir für \varphi (phi) einfach einsetzen:

w_n \leftarrow w_n + \alpha * (t_0 - o_0) * \color{blue}{\frac{\partial}{\partial net}(\text{sigmoid}(net))} \color{black} * i_n

… und stellen die Ableitung dieser Aktivierungsfunktion zur Verfügung, und - da wir uns bei der Gewichtsänderung ja auf jeden Fall im Code hinter der bereits berechneten Ausgabe o_0 unseres Ausgabeneurons sind, können wir diesen Wert einfach weiterverwenden:

w_n \leftarrow w_n + \alpha * (t_0 - o_0) * \color{blue}{ o_0 * (1 - o_0)} \color{black} * i_n

An der Verlustfunktion haben wir unterdessen nichts geändert: Der Term (t_0 - o_0) nach der Lernrate \alpha ist immer noch der Teil des Gradienten, der von der quadratischen Verlustfunktion herrührte. Wir werden später sehen, dass diese Verlustfunktion für die logistische Aktivierungsfunktion nicht superideal ist. Vielleicht ein bisschen so, wie Rotwein auf Eis mit Strohhalm zu schlürfen.

Aber alles in allem wird das so schon ganz ordentlich funktionieren: Wir müssen nur den Term für die Ableitung der Aktivierungsfunktion in unseren Code einbauen, und der Rest kann so bleiben, wie er ist! Unser Lernprogramm wird immer mächtiger, und wir fügen nur ein paar weitere Multiplikationen ein - ein kleines Wunderwerk der Mathematik.

Also, ich bin ja schon im letzten Tutorial ausgestiegen: diese Mathematik treibt einen doch zur Verzweiflung! Kann man nicht einfach so auch ein Modell bauen?

Niemand muss alles auswendig lernen, um produktive neuronale Modelle bauen zu können. Aber man wird produktiver sein, wenn man sich einmal auf die mathematische Reise eingelassen hat. Die Macht einer API wie keras, pytorch oder TensorFlow entfaltet sich erst, wenn man die Bausteine dort wiedererkannt hat - dabei ist es aber völlig wurst, ob man die Ableitung oder die Formel für die logistische Funktion noch im Kopf hat oder nicht.

Keine Panik, wir springen jetzt einmal schnell rüber zu unseren Daten und bauen dann ein Modell mit ein paar Zeilen Python-Code.

Die Daten laden und sichten

pandas als ultimatives Datenwerkzeug

In den letzten Tutorials hatten wir die CSV-Dateien noch mit numpy-eigenen Prozeduren geöffnet, dieses Mal holen wir uns eine viel elegantere Bibliothek ins Haus: pandas - pandas ist vielgeliebt und auch vielgehasst, weil Anfänger vor einer großen Lernkurve stehen. Wir wollen uns aber in diesem Tutorial nicht ausgiebig mit pandas beschäftigen, sondern nur das tun, was zur Zweckerfüllung hinreichend ist (nämlich später unsere flüssige Belohnung einheimsen).

Falls noch nicht geschehen, können wir interaktiv im Jupyter Notebook die pandas Bibliothek installieren (oder auf der Konsole pip install pandas ausführen):

1
!pip install pandas

Dann importieren wir die Bibliothek, nennen diese wie im Volksmund üblich pd, und wir laden unsere erlesenen Weindaten in den Speicher:

1
2
import pandas as pd
wine = pd.read_csv('./data/wine.csv')

Den vollständigen Datensatz kann man sich auch hier downloaden.

Pandas bietet wirklich eine ganze Menge an Möglichkeiten, um mit Daten unterschiedlichster Struktur hervorragend zu arbeiten. Das Hauptkonzept ist ein sog. DataFrame, und von dem lassen wir uns einmal die ersten Zeilen ausgeben, dafür gibt es auf einem DataFrame die Methode .head():

1
wine.head()

Es erscheint eine Tabelle mit 14 Spalten, hier im Blog allerdings ein wenig anders gestylt:

Alcohol Malic_acid Ash Alcalinity_of_ash Magnesium Total_phenols Flavanoids Nonflavanoid_phenols Proanthocyanins Color_intensity Hue OD280/OD315_of_diluted_wines Proline binaryClass
0 14.23 1.71 2.43 15.6 127 2.80 3.06 0.28 2.29 5.64 1.04 3.92 1065 N
1 13.20 1.78 2.14 11.2 100 2.65 2.76 0.26 1.28 4.38 1.05 3.40 1050 N
2 13.16 2.36 2.67 18.6 101 2.80 3.24 0.30 2.81 5.68 1.03 3.17 1185 N
3 14.37 1.95 2.50 16.8 113 3.85 3.49 0.24 2.18 7.80 0.86 3.45 1480 N
4 13.24 2.59 2.87 21.0 118 2.80 2.69 0.39 1.82 4.32 1.04 2.93 735 N

Wenn wir hier ein bisschen hin- und herscrollen, entdeckt man auch die letzte Spalte, die binaryClass getauft wurde. Mit P werden in dieser Spalte die guten Weine markiert (p wie positiv), und mit N werden die scheußlichen Weine ausgezeichnet. Weinkenner werden sofort verstehen, was die Spalten so bedeuten. Leider sehe ich hier in der Nähe gerade keinen.

Wir überzeugen uns, dass da wirklich nur zwei Klassen vorhanden sind, und schreiben:

1
wine.binaryClass.unique()

Als Ausgabe gab es array(['N', 'P'], dtype=object) zu lesen, es gibt also zwei unterscheidbare Werte in der Spalte binaryClass und deren Werte sind ‘N’ und ‘P’ - genau das, was wir erwartet haben. pandas hat eine ganze Reihe von Funktionen, die man auf so einer Spalte ausführen kann. Mit ‘.min()’ bekommt man bspw. den kleinsten Wert, folgerichtig mit ‘.max()’ den größten.

Wir wollen nun also nur anhand der ersten Merkmale, bspw. Alkoholgehalt, Farbintensität, Magnesiumgehalt usw., erlernen, ob es sich um einen guten oder einen grässlichen Wein handelt. So wird dann die Skizze unseres Modells aussehen:

Mit Schwung in die Ausgabe

Unser Ausgabeneuron wird im Schaubild nun mit einem geschwungenen S gekennzeichnet, also mit einer sigmoidalen Aktivierung. Wieder enthalten ist die eingekringelte 1, das ist wieder ein Bias-Neuron, das wir schon in der ersten Ausgabe des Tutorials motiviert hatten. Die sonstigen Eingabeneuronen sind wie immer nur dafür da, die Daten einmal anzunehmen und weiter zu reichen.

Im letzten Tutorial hatten wir ja eine Linie durch eine Datenwolke gezogen. Das ging auch ganz gut, weil der Zielwert nicht nur ‘P’ oder ‘N’ war, sondern es waren kontinuierliche Werte. Wir führen uns daher mal vor Augen, wie eines unserer Merkmale, der Alkoholgehalt, in Bezug steht zu der Zielklasse.

Die Daten visualisieren

Wir fangen also mal an, so zu tun, als würden wir eine Datenwolke zeichnen, diesmal haben wir aber nur ‘N’ und ‘P’ als mögliche Zielwerte zur Verfügung. Damit wir das Ganze farblich unterscheiden können, holen wir uns aus den Daten zunächst alle Datenpunkte der Klasse ‘P’, dann alle Daten vom Typ ‘N’, und plotten:

1
2
3
4
5
6
7
8
9
10
11
12
13
import matplotlib.pyplot as plt
%matplotlib inline

# mit pandas kann man wunderbar Daten aus einem DataFrame
# selektieren. Wir erzeugen uns also zwei neue, kleinere
# DataFrames. pos enthält nur die Zeilen, die mit der
# Bedingung in der eckigen Klammer übereinstimmen, neg
# entsprechend
pos = wine[wine.binaryClass=='P']
neg = wine[wine.binaryClass=='N']

plt.scatter(neg['Alcohol'], neg.binaryClass, marker='+')
plt.scatter(pos['Alcohol'], pos.binaryClass, marker='+')

Von einer richtigen “Wolke” können wir dann aber nicht mehr sprechen:

Unten auf der x-Achse können wir den Alkoholgehalt ablesen: Werte zwischen Alk 11% vol. und Alk 15% vol. sind bei den gemessenen Weinen ermittelt worden. Die y-Achse ist eigentlich nur noch ein 1 oder 0. Entweder der Wein war gut oder lausig.

Man sieht aber auch, dass - je höher der Alkoholgehalt wird, desto häufiger tauchen Datenpunkte auf der negativen Achse auf. Ich will jetzt gar nicht darüber nachdenken, welch armer Tropf diese vergorenen Fusel probieren musste (und danach noch davon berichten konnte)! Die eher normal gegorenen Weine neigen dahingegen auch eher dazu, auf der positiven Seite aufschlagen. Es gibt aber auch eine twilight zone, das sind einige Weine zwischen 12% vol. und 14% vol.

Wenn wir jetzt eine gerade Linie durch diese Datenwolke zeichnen würden, sähe das ziemlich bescheuert aus, ziemlich natürlich wirkt es jedoch, wenn wir eine sigmoidale Linie da rein pinseln:

Sofern wir also Merkmale in unseren Daten haben, an denen man die beiden Klassen ganz gut trennen kann, tun wir offenbar gut daran, diese Trennung über eine geschwungene Kurve zu modellieren! Es ist ja nicht so, dass wir jetzt eine gerade Linie krumm machen müssten. Unsere sigmoidale Linie ist ja schon von Haus aus gekrümmt - wir müssen “nur noch” einen Wert finden, der unsere Kurve an die richtige Stelle platziert und entsprechend flacher oder steiler macht. Das machen wir nicht von Hand - sondern mit einem Gradientenabstieg! Dass das S andersrum ist, ist ja völlig wurscht, es ist gespiegelt, und das kriegt man mit negativen Werten sicherlich hin.

Schaue Dir doch mal andere Plots von anderen Spalten des Wein-Datensatzes an! Dazu musst du im Jupyter Notebook nur in der Zelle oben andere Spalten auswählen. Wir sehen ja schon, dass der Alkoholgehalt alleine noch nicht wirklich gut separieren kann, ob der Wein nun wirklich gut oder schlecht war - vor allen Dingen über die mittleren alkoholischen Werte. Einige Merkmale tragen offenbar ganz gute Information, um gute von schlechten Weinen zu unterscheiden, andere wiederum wirken gleichmäßig verteilt. Wir lassen später unser Modell entscheiden, welche Daten es wirklich nutzen möchte, und alle Daten in Kombination werden hoffentlich aussagekräftiger sein als ein Wert alleine.

Selbstverständlich kann unser Modell verschiedene Eingangsmerkmale miteinander kombinieren und dank der trainierbaren Gewichte auch entsprechend einfließen lassen. Diese Kombination der trainierbaren Gewichte ist eine lineare Kombination, da wir mit dem Skalarprodukt die Netzeingabe ausrechnen.

Interpretation des Modells

Dadurch, dass wir nun einen sanften Übergang haben zwischen Klasse Negativ und Klasse Positiv, können wir unserer sigmoidalen Kurve gleich zwei Informationen entlocken: Einmal, wie sicher wir uns sind bzgl. der Zuordnung eines Datensatzes zu einer Klasse: Werte, die näher bei der 1 sind, sind sicherer den positiven Weinen zuzuschreiben als Werte, die nur knapp über 0.5 liegen. Wir erinnern uns, die logistische Funktion squashed die Werte zwischen 0 und 1, und bei x_0=0 ist der Wert der Funktion 0.5. Die Grenze zwischen den beiden Klassen ist also nach der sigmoiden Aktivierung just der Übergang von negativer Netzeingabe zur positiven Netzeingabe an der Stelle 0.

Man kann getrost annehmen, dass wir nun auch eine Wahrscheinlichkeit dafür angeben können, dass ein bestimmter Datensatz zu einer bestimmten Klasse gehört. Das konnten wir bisher noch nicht! Wir können nun also auch Aussagen treffen wie: “Wir sind uns zu 93% sicher, dass dieser Wein ziemlich gutes Zeug ist!”

Sind wir uns wirklich sicher? Oder täuschen wir uns? Bildlizenz: Public Domain

Ein weiterer riesiger Vorteil der logistischen Funktion! Wenn wir uns gar nicht sicher genug wären, könnten wir dem User unseres Modells also diese zusätzliche Information mit auf den Weg geben.

Am Ende wollen wir aber auch wieder hart unsere Datensätze klassifizieren, und dafür vereinbaren wir, dass wir - sofern unser Modell Werte größer als 0.5 produziert hat, der Wein zu den guten Weinen gehört, und für alle Werte kleiner als 0.5 eben umgekehrt zu den schlechten Weinen. Die vergorenen Traubensäfte, die um 0.5 herum liegen, sind also in der twilight zone, und eine Vorhersage ist mit einer größeren Unsicherheit behaftet.

Wichtig ist nur, dass wir unterscheiden: die Vorhersage, ob der Wein gut=1 oder schlecht=0 ist, ist unsere Interpretation des Modells. Die Fehler werden aber weiterhin an den Werten dazwischen bemessen, also an der tatsächlichen Ausgabe des sigmoiden Modells!

Herzlichen Glückwunsch bis zu diesem Punkt. Wir können nun verstehen, dass bei einem 2-Klassen-Problem eine gerade Kurve nicht so rockt wie eine geschwungene S-Kurve. Außerdem ist, wenn man so will, die S-Kurve weiterhin eine Annäherung an die Treppenfunktion, allerdings mit sanften Übergängen, die sogar differenzierbar sind. Und: wir können neben 1/0-Vorhersagen auch noch angeben, wie sicher wir uns bei der Voraussage sind.

Das Training mit Quadratischem Fehler

Hui - wer gut aufgepasst hat, der weiß, dass wir zwei Zeilen Code verändern müssen, um von der linearen Aktivierung auf die sigmoidale Aktivierung zu schwenken. Einmal müssen wir selbstredend unsere Netzeingabe in das Ausgabeneuron auch tatsächlich durch die sigmoid-Funktion jagen, zum anderen müssen wir beim Gradientenabstieg mit der Delta-Regel auch die Ableitung der sigmoid-Funktion einbauen. Denn der Gradientenabstieg besteht ja aus der kettengeregelten, partiellen Ableitung vom Fehler über die Aktivierung über die Netzeingabe zum einzelnen Gewicht!

Die quadratische Verlustfunktion

Zunächst begnügen wir uns mit der quadratischen Verlustfunktion, wenngleich wir auch bald noch motivieren wollen, dass es für unterschiedliche Aktivierungsfunktionen auch mitunter noch bessere Verlustfunktionen geben kann:

1
2
def verlust_quadrat(target, output):
return (target-output) ** 2

Für das Training brauchen wir die nicht wirklich. Den Teil für die Ableitung des Gradienten haben wir ja schon in der Delta-Regel. Aber wir wollen beim Gradientenabstieg ja wieder überprüfen, ob die Fehler auch wirklich kleiner werden.

Splitten in Trainings- und Testdaten

Wir nehmen jetzt unser pandas-Dataframe auseinander. Die Merkmale, die den Wein beschreiben, wollen wir Groß-X nennen (Großbuchstaben sind die Tabellen bzw. Matrizen), und in das Groß-X schleusen wir wieder ein Bias Neuron ein; unseren Zielwert wollen wir y nennen - da unser Ursprungswert ja ein Buchstabe ist, konvertieren wir diesen dann auch noch in Zahlen, treffenderweise in 1 und 0.

Natürlich splitten wir unsere Daten wieder in Trainings- und Testdaten: wir wollen vermeiden, die Daten auswendig zu lernen, sondern ein Modell erschaffen, dass auch für neue, nicht im Training vorhandene Daten gut funktioniert. Dazu holen wie Kollege Zufall dazu und mischen unser DataFrame gut durch. In pandas kann man dazu die Methode sample mit einem Parameter frac=1. aufrufen: dann werden 100% der Daten zufällig gesampelt und als neuer DataFrame zurückgegeben:

1
wine = wine.sample(frac=1.)

In pandas kann man mit .iloc Daten genauso selektieren, wie man das auch in numpy getan hat: in eckige Klammern kommen zwei mit Komma getrennte Angaben, vor dem Komma sind die ausgewählten Zeilen und danach die ausgewählten Spalten. Das kann entweder eine einzelne Zeile/Spalte sein oder eine Range, die man mit Doppelpunkt von:bis notieren kann. Mit einem alleinstehenden Doppelpunkt selektiert man “alle Zeilen” oder “alle Spalten”, je nach dem, wo man sich befindet:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Alle Zeilen, aber nur von Spalte 0 bis zur vorletzten Spalte 
# (mit -1 kann man von "hinten" wieder anfangen zu zählen)
# Mit .values greifen wir letztlich auf das darunterliegende numpy-Array zu
X = wine.iloc[:,0:-1].values

# Jetzt noch das Bias-Neuron hinzufügen
X = np.insert(X,0,1,axis=1)

# Auch alle Zeilen, aber nur die letzte Spalte (unser Zielwert)
# Bei der Gelegenheit können wir den auch gleich konvertieren:
# Wir erinnern uns, neuronale Netze können nicht mit Buchstaben
# rechnen.
# Mit .apply() können wir auf alle Werte eine Funktion anwenden
# Mit .values holen wir uns dann wieder das numpy-Array
y = wine.binaryClass.apply(lambda x: 1 if x == 'P' else 0).values

Wir werden ca. 15% der Daten für das Testen unseres Modell zurückhalten. Dazu müssen wir wissen, wieviele Datensätze wir haben:

1
2
3
# Wie viele Datensätze gibt es?
anzahl_instanzen = len(X)
print(anzahl_instanzen, "Beispieldatensätze vorhanden")

Es sind 178. 10% davon wären ca. 18 Instanzen, 5% wären ca. 9 Datensätze. Wenn wir rund 28 für den Test aufbewahren, dann haben wir 150 Datensätze für das Training. Das nehmen wir:

1
2
3
# _train wird bis Zeile 150, _test ab Zeile 150
X_train, X_test = X[0:150,:], X[150:,:]
y_train, y_test = y[0:150], y[150:]

Trainingsprogramm für ein sigmoides Neuron

Nun kann das Training auch schon starten. Wir legen wie immer die Anzahl der Epochen und die Lernrate alpha ( \alpha ) fest, beide Werte wurden vom Autor dieses Tutorials für den Weindatensatz experimentell bestimmt.

1
2
epochen = 2500
alpha = 0.00001

Wir setzen die Gewichte auf 0 zurück.

1
weights = np.zeros(13+1) # 13 Merkmale + 1 Bias Neuron = 14

Dieses Mal schreiben wir uns eine kleine fit-Funktion, die unseren Lernalgorithmus ein stückweit bündelt. Die Gewichte lassen wir jedoch fürs Erste außerhalb der Funktion, so können wir diese im Jupyter Notebook einfacher untersuchen und zwischen mehreren Aufrufen der fit-Funktion auch weiter leben lassen; demnächst bauen wir uns auch eine schöne Python-Klasse dafür, sobald wir verstanden haben, was da so alles rein muss (falls das Paket tqdm fehlt, mit pip install tqdm nachholen - pip install ist immer ein guter Rat, falls ein Paket fehlen sollte).

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
from tqdm import tqdm

def fit(X, y):
"""
fitted die Gewichte passend zu den Trainingsdaten X und y
die Gewichte weights werden außen (global) gehalten
die lokalen Trainingsfehler werden gesammelt und zurückgegeben
"""
global weights

# Liste für die Trainingsfehler zurücksetzen
errors = []

# Wir packen X und y in eine Struktur, damit wir diese gemeinsam
# immer wieder durchschütteln können (s. u.)
data = list(zip(X, y))

# Über alle Epochen ...
for epoche in tqdm(range(0, epochen)):

error = 0 # Der Fehler der Epoche ist anfangs 0

# Die Daten einmal gut durchschütteln!
np.random.shuffle(data)

# Für alle Trainingsbeispiele ...
for inputs, target in data:

# Das Neuron aktivieren, diesmal mit sigmoid-Funktion!
net = inputs.dot(weights)
output = sigmoid(net)

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

# Unser Gradientenabstieg:
# NEU ist der Teil, der die Ableitung der Sigmoidfunktion
# enthält:
# (output) * (1-output)
weights += alpha * (target-output) * (output) * (1-output) * inputs
errors.append(error)
return errors

Geändert hat sich - außer, dass wir nun eine Funktion haben - gegenüber dem letzten Lernprogramm eigentlich nicht viel.

Die sigmoide Aktivierung wurde hinzugefügt, sowie die Ableitung der sigmoiden Funktion als Teil der Kettenregel, die unseren Gradientenabstieg bildet. Wir hätten die Terme auch noch algebraisch zusammen fassen können, so aber bleibt es besser lesbar.

Nun können wir die fit-Funktion mit unseren Trainingsdaten aufrufen.

1
errors = fit(X_train, y_train)

Natürlich interessieren wir uns dafür, ob die Fehler auch wirklich von Epoche zu Epoche kleiner geworden sind:

1
plt.plot(errors)

Tatsächlich! Das Training scheint zu funktionieren und von Epoche zu Epoche werden die Fehler mit etwas Fluktuation immer ein stückweit kleiner. Die ersten Handvoll Epochen wird ein großer Sprung gemacht, der Rest scheint aber tatsächlich harte Arbeit zu sein, um die Gewichte nach und nach in die richtige Richtung zu klopfen.

Nun wollen wir auch sehen, ob unser Modell generalisieren und mit den Daten umgehen kann, die während des Trainings zurückgehalten wurden. Wir schreiben uns dazu eine kleine predict-Funktion. Das besondere an dieser Vorhersage-Funktion ist, dass sie uns zwei Listen zurückgibt: eine für die tatsächliche Vorhersage, also ob der Wein gut oder schlecht ist, uns eine Liste mit den Werten, wie sicher das Modell davon ausgeht, ob der Wein gut oder schlecht ist!

Vorhersage mit unserem Modell

Dazu schreiben wir uns eine predict-Funktion, die uns neben der Vorhersage, ob es sich um einen guten oder schlechten Wein handelt auch die Werte dazwischen zurückgibt, also die eigentliche Aktivierung des sigmoidalen Neurons. Diesen Wert dürfen wir als Wahrscheinlichkeit interpretieren.

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
def predict(X):
"""
Erzeugt für alle Daten in X zwei Arrays:
- ein Array für die eigentliche Vorhersage, y_pred
- ein Array für die Wahrscheinlichkeiten der Vorhesage, y_prob
"""
# Unsere Gewichte haben wir außen im Jupyter Notebook global gehalten
global weights

# Zwei leere Listen für die Rückgabe
y_preds, y_probs = [],[]

## Für alle Datensätze...
for inputs in X:

# Aktiviere das Neuron mit unseren gelernten Gewichten
net = inputs.dot(weights)
output = sigmoid(net)

# Mache eine harte Vorhersage:
# falls wir uns mehr als 50% sicher sind, dass es ein guter
# Wein ist, geben wir 1 zurück, sonst 0
# Das ist eine gängige Interpretation des Modells
y_pred = 1 if output > 0.5 else 0

# Die Wahrscheinlichkeit ist unsere Aktivierung
y_prob = output

# Die Werte sammeln ...
y_preds.append(y_pred)
y_probs.append(y_prob)

# ... und am Ende zurückgeben. Fertig
return np.array(y_preds), np.array(y_probs)

Nun können wir unsere reifen Trauben ernten und die Vorhersage für unsere zurückgehaltenen Test-Datensätze durchführen:

1
y_preds, y_probs = predict(X_test)

Mit einer kleinen Hilfsfunktion können wir schnell durchrechnen, wie gut die Vorhersage auf den bislang ungesehenen Daten funktioniert hat, eine Metrik, die uns die Prozent der richtig klassifizierten Datensätze ausrechnet (engl. accuracy score):

1
2
3
4
def accuracy_score(y_vorhersage, y_echt):
anzahl = len(y_vorhersage)
vergleich = y_vorhersage == y_echt
return vergleich.sum() / anzahl

Wir vergleichen nun also unsere vorhergesagten Werte gegen die eigentlich richtigen Werte, in dem wir beide Listen in unseren Scorer geben:

1
print(accuracy_score(y_preds, y_test))

Et voilà! Je nach dem, wie der Kollege Zufall reingespielt hat, sollte man eine accuracy irgendwas zwischen 0.78 und 0.95 ausgespuckt bekommen haben. Versuche doch mal selbst, den obigen Code vom Splitten der Test- und Trainingsdaten ab öfter laufen zu lassen, und vergleiche die Ergebnisse!

Die sanfte Vorhersage

Wir können nun jeder Vorhersage auch noch eine Stärke zuordnen, wie sicher sich das Modell bzgl. der Vorhersage gewesen ist: die Liste y_probs gibt auf Skalen zwischen 0 und 1 an die Stärke des Glaubens zurück.

1
print(y_probs)

Eine typische Ausgabe sieht so aus:

"[0.49672035 0.06741513 0.20982449 0.18944095 0.72028913 0.19243083 0.69217764 0.15815701 0.18085559 0.25257858 0.12198094 0.8181152  0.14152866 0.55735908 0.25239352 0.26697747 0.89431269 0.75966737  0.74016572 0.07827787 0.5804464  0.16916063 0.81900048 0.03755282  0.00694323 0.58210149 0.01154403 0.54814189]"

Da wir 0.5 als harte Entscheidungsgrenze gewählt haben, um entweder “guter Wein” oder “schlechter Wein” vorherzusagen, schauen wir uns nochmal die Wahrscheinlichkeiten an, bei denen unsere Klassifikation im Test falsch gewesen ist:

1
2
# Selektiere alle y_probs, bei denen die Vorhersage falsch war
print(y_probs[y_preds!=y_test])

Je mehr Werte in diese Liste falsch klassifizierter Datensätze nahe bei 0.5 sind, desto besser ist unser Modell auch in Schuss. Natürlich kann es trotzdem passieren, dass auch relativ sichere Aussagen getroffen werden, die völlig falsch sind. Die Kunst ist es dann, die falsch positiven und falsch negativen Vorhersagen auszutarieren.

Im produktiven Einsatz würde man auch noch eine Regularisierung der Gewichte vornehmen. Das würde dafür sorgen, dass die Gewichte nicht zu groß werden. Wenn die Gewichte sehr groß werden, trifft das Modell nur noch sehr extreme Entscheidungen, und das Modell wird sich zu sicher; zu sicher zu sein ist in unserem Fall überhaupt nicht gut. Das aber ist Stoff für einen anderen Tag und geht auf unter dem Thema Bias-/Varianzdilemma.

Zwischenruf

Herzlichen Glückwunsch bis zu diesem Punkt! Wir haben hier eine ganze Menge auf die Beine gestellt, ein sigmoidales Neuron mit unseren Board-Mitteln gebändigt und haben nebenbei auch noch genug über Wein gelernt, um auf jeder Party mit unserem Wissen über Weinfarbe und malid acid (dtsch.: Apfelsäure) glänzen und unsere Gastgeber verunsichern zu können.

Wir haben allerdings noch nicht untersucht, ob unsere Merkmale selbst miteinander korreliert sind - in dem Sinne, dass ein Wert wie Farbintensität möglicherweise auch erklärbar ist aus anderen Werten des Datensatzes. Das sollte man, bevor man ein Modell baut, durchaus vorab tun. Auch eine Regularisierung, die beiläufig erwähnt wurde, ist nicht zu verachten. Beides führt aber heute vom Thema zu weit weg.

Obwohl die logistische Regression mit der quadratischen Loss-Funktion in der Regel ganz passabel funktioniert, gibt es auch eine spezielle Loss-Funktion, die besser zur logistischen Aktivierungsfunktion passt. Ich kann schon mal verraten, dass sich eine Sensation anbahnt, wenn wir uns das Zusammenspiel anschauen. Seid ihr bereit? Tief durchatmen! Es geht los.

Was ist falsch am quadratischen Fehler?

Der quadratische Fehler war sehr fein, als es darum ging, eine Gerade durch eine Datenwolke zu ziehen. Wenn wir eine sigmoide Kurve einbalancieren, die möglichst gut zwei Klassen voneinander trennen soll, dann wird die Fehlerfunktion eine Kette der folgenden Art - unser target t minus output o zum Quadrat ergibt, wenn man für o unsere dahinterliegende sigmoide Aktivierung einsetzt:

\mathcal{L} = (t - (\frac{1}{1+e^{-i \bullet w}}))^2

Wenn man den Code für das Plotten der Verlustfunktion aus dem letzten Tutorial ein bisschen anpasst auf unsere sigmoide Aktivierung und auf zwei ausgewählte Gewichte anwendet, ergibt sich für den quadratischen Fehler nun folgendes Bild:

Das sieht gar nicht mal übel, aber auch nicht soo gut aus. Irgendwo ganz da unten ist ein Minimum, aber die Fehleroberfläche ist nun gar nicht mehr so rund und wunderbar parabolisch, wie es im letzten Tutorial mit der linearen Aktivierung der Fall gewesen ist. Ursache dafür ist die sigmoidale Aktivierung, ihre durchlauchte Kurvigkeit überträgt sich bis zur Fehleroberfläche und sorgt für einiges an sigmoid-royalem Durcheinander.

Quadratischer Verlust auf sigmoider Aktivierung erzeugt mitunter Taschen und große Schluchten mit lokalen Minima

Es geht zwar auch irgendwie mit dem Fehler nach unten, allerdings gibt es kleinere “Taschen” zu sehen, in die man beim graduellen Abstieg hineinfallen könnte - wir wandern ja entlang der jeweiligen Steigung - und wenn man in so einem lokalen Minimum gefangen ist, wird die lokale Ableitung 0 sein und man bewegt sich fortan nie wieder vom Fleck! In einem Teufelsmoor dagegen hätte man ja wenigstens noch eine faire Chance (laut um Hilfe schreien)! In der Tat war das viele Jahre auch eines der intellektuellen Hauptargumente gegen neuronale Netze, in der Praxis, insbesondere auch mit tiefen neuronalen Netzen, haben sich lokale Minima allerdings selten als wirkliches Problem herausgestellt - wenn man das Training drumherum im Griff hat und nichts dem Zufall überlässt.

Nichtsdestotrotz (ein tolles Wort - habt ihr es je benutzt?) würde man sich wünschen, es gäbe einen Weg, den Effekt der sigmoiden Aktivierung in Bezug auf den Fehler irgendwie zu neutralisieren, so dass wir wieder eine schöne Wanne bekommen, in der man gemütlich zu einem globalen Minimum schnorcheln kann.

Wenn Du denkst, es geht nicht mehr …

Con los Logaritmos - besser als jeder Harlem Shake! CC0-Lizenz, Quelle: Wikipedia

…dann kommt irgendwo ein Logarithmus daher. Der Logarithmus gehört definitiv zur Voodoo-Sektion der Mathematik. Zum einen lässt sich der Log-Wert nur sehr selten einfach von Hand berechnen und man ist auf einen Taschenrechner (oder eine altmodische Logarithmentafel) angewiesen. Zum anderen taucht der Logarithmus immer dann auf, wenn man ihn am wenigsten erwartet, und zwar aus unterschiedlichsten Gründen quer über alle Forschungsfelder, ob Chemie, Biologie, Physik oder eben auch in der Informatik.

Und im Grunde beantwortet er nur eine eigentlich einfach aussehende Frage: Wenn ich eine exponentielle Formel habe wie 2^x = 32 , welchen Wert müsste dann x haben? Da die Basis 2 ist, würde man auf beiden Seiten den Logarithmus zur Basis 2 nehmen, und dort stünde log_2(2^x) = log_2(32) . Falls man auf dem Taschenrechner keinen logarithmus digitalis (ld, also der Logarithmus zur Basis 2) hat, kann man auch einfach jeden beliebigen anderen Log nehmen und den Wert ausrechnen mit \frac{log(32)}{log(2)} :

1
print(np.log(32) / np.log(2))

Bei den meisten mathematischen Software-Bibliotheken ist die schlicht mit log bezeichnete Funktion in der Regel der ln, der logarithmus naturalis (dtsch. natürlicher Logarithmus / engl. natural logarithm), so auch in numpy. Der ln ist der Logarithmus zur Basis e, also gerade die Umkehrfunktion zu der e-Funktion, die wir oben im Einsatz hatten.

Trotz seiner geheimnisumwitterten Art ist der Logarithmus beliebt. Neben den äußerst nützlichen Rechenregeln des Logarithmus, die es erlauben, aus einer Multiplikation eine Addition zu zaubern, verhält sich der Log auch in Hinblick auf die Ableitung günstig für uns. Damit wir nun aber nicht vom Hölzchen aufs Stöckchen kommen, vereinbaren wir, dass wir bis auf weiteres den Logarithmus als einen funktionellen Freund betrachten, der uns aus einer misslichen Lage befreien wird.

Wanted: eine Verlustfunktion

Wir müssen im Auge behalten, was so eine Verlust- bzw. Kostenfunktion eigentlich ausmacht:

  • je weiter unsere Vorhersage vom tatsächlich gewünschten Output weg ist, desto größer soll auch der Verlust sein, es soll keinen negativen Verlust geben

  • nach Möglichkeit sollte die Loss-Funktion im mathematischen Sinne konvex sein; salopp gesagt sollte es also - egal wo man startet - nur ein einziges globales Minimum geben. Zeichnete man eine Linie zwischen zwei zufällig ausgewählte Punkte auf dem Graphen, so müssten alle Y-Werte der so gezeichneten Linie größer als die Werte des darunterliegenden Graphen sein.

Und, in unserem speziellen Fall:

  • bei einer binären Klassifikation gibt es zwei mögliche Klassen, und damit zwei mögliche Richtungen, aus denen ein Fehler erwachsen kann.

Von der Modell-Interpretation zum Modell-Verlust

Interpretiert hatten wir unser Modell wie folgt: Aktivierungen, die näher an der 0 sind, sollen die negative Klasse darstellen, bis zum Wert von 0.5 (auf der Y-Achse). Siehe oben im Schaubild die gestrichelte Linie auf y=0.5, dort ist unsere Entscheidungsgrenze (engl. decision boundary). Die Aktivierungen größer 0.5, die dann also auch näher an der 1 als an der 0 sind, wurden einer positiven Klasse zugeschrieben.

Dadurch kann man, wenn man so will, eine praktische Abkürzung nehmen und schon vor der Aktivierung durch die sigmoidale Funktion entscheiden, zu welcher Klasse ein Datensatz gehört: eine negative Netzeingabe wird immer Werte kleiner als 0.5 erzeugen, und eine positive Netzeingabe wird immer eine Aktivierung größer 0.5 erzeugen!

Mal angenommen, wir haben einen Datensatz im Training, der einen schlechten Wein repräsentiert hat, und die Aktivierung des Neurons war 0.46 - dann ist zwar die Vorhersage richtig (<0.5 ist ein schlechter Wein), aber die Voraussage wäre noch besser gewesen, wenn die Aktivierung noch näher an der 0 gewesen wäre. Der Abstand von der gewünschten Ausgabe 0 zu unserer Aktivierung wäre also gerade der Wert der Aktivierung.

Wenn wir umgekehrt einen Datensatz haben, der einen guten Wein repräsentiert haben sollte, und die Aktivierung unseres Neurons war bspw. 0.2, dann ist der Abstand von der gewünschten Ausgabe 1 eben die Differenz 1 - 0.2, wir schauen also “von der anderen Seite”.

Der Log Loss

Auf jeden Fall sollte also wieder ein Abstand zwischen gewünschter Ausgabe und tatsächlicher Ausgabe ein Bestandteil unserer Loss-Funktion sein. Nun haben wir aber noch das Problem, dass die Kurvigkeit der sigmoidalen S-Kurve sich nicht auf die Fehleroberfläche übertragen soll. Das kriegen wir aber mit einem Logarithmus glattgebügelt, und das führt uns zu der loss-Funktion, die in der Literatur auch als log-loss (andere Schreibweisen Log Loss, logloss, …) bekannt ist.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def verlust_logloss(t, o):
"""
berechnet den log-loss
für ein target t
und eine sigmoide Aktivierung o
"""

if (t==1):
# Falls die gewünschte Ausgabe die positive Klasse t=1 war,
# messen wir den Abstand von der 0 zum o und logarithmieren
# Durch das Minus wird der Wert immer kleiner, je näher wir
# an die 1 kommen
return -np.log(o + 1e-15)
else:
# Falls ansonsten die gewünschte Ausgabe die negative Klasse
# t=0 war, dann messen wir den Abstand von der 1 aus gesehen
# "rückwärts", logarithmieren und machen ein Minus davor, so
# dass die Werte Richtung 0 immer kleiner werden.
return -np.log(1-o + 1e-15)

Wir nehmen den negativen Logarithmus, weil sonst die Verlustwerte unserer loss-Funktion mit kleinerem Fehler immer größer werden würden. Stünde dort also np.log(o) für den Fall der gewünschten Klasse t=1, dann würde der zurückgegebene Wert mit größerem Fehler immer kleiner werden, weil sich der Logarithmus zwischen Werten >0 und 1 genau so verhält. Dann müssten wir statt nach einem Minimum nach einem Maximum suchen (also einen Gradientenaufstieg statt eines -abstiegs durchführen). Das wollen wir vermeiden, mit dem Vorzeichenwechsel können wir wieder nach einem Minimum suchen (denn die meisten Optimierer und Lernalgorithmen arbeiten eben auf ein Minimum eines Verlusts hin).

Die 1e+15 ist eine ziemlich kleine Zahl, die wir dazu addieren, damit wir nicht aus Versehen den Logarithmus von 0 ziehen - denn an der Stelle 0 ist der Logarithmus nicht definiert und wir würden eine fiese Exception fangen müssen! Dieser Hack ist üblich und findet sich ebenso in renommierten Bibliotheken, und sorgt dafür, dass numerisch alles im grünen Bereich bleibt.

Wir können den Code aber noch ein bisschen vereinfachen und kommen dann zu der Darstellung, die dann auch tatsächlich üblich ist: statt einer Fallunterscheidung bauen wir zwei Multiplikationen mit ein.

1
2
3
4
5
6
7
8
def verlust_logloss(t, o):
"""Kürzere Formel, um Deine Freunde zu verwirren"""

# Falls die gewünschte Ausgabe 0 ist, dann fliegt alles vor
# dem Plus-Zeichen weg und alles nach dem Plus-Zeichen bleibt
# erhalten. Falls t=1 ist, bleibt der vordere Term erhalten und
# der letzte wird durch (1-t) ausgelöscht.
return t * -np.log(o + 1e-15) + (1-t) * -np.log(1 - o + 1e-15)

In mathematischen Zeichen wollen wir das dann auch noch bewundern:

\mathcal{L}(t,o) := t * -ln(o + \epsilon) + (1-t) * -ln(1-o + \epsilon)

Unser \epsilon (epsilon) ist ein beliebig kleiner Wert, knapp über der 0 (der kürzeste Mathematiker-Witz ist übrigens: “Sei Epsilon kleiner 0!” Muahahahhahh… böse!)

Auswirkung auf die Fehleroberfläche

Und so schön glattgebügelt sieht dann eine exemplarische Fehleroberfläche auf unserem Weindatensatz aus, wenn wir zwei Gewichte variieren, diese mit allen Eingaben skalarmultiplizieren, dieses Produkt wiederum durch die logistische Sigmoid-Funktion jagen und den Fehler mit dem Log Loss ausrechnen (alter Schwede - hoffentlich kommt da nicht noch mehr dazu - alleine wie lange die Sätze in diesem Tutorial werden müssen, ist schon belastend für Autor und LeserInnen gleichermaßen).

Log Loss bügelt den Fehler glatt - besser als herkömmliche Loss-Funktionen (für sigmoide Aktivierungen)

Vergleicht das noch mal mit der schrumpeligen Fehleroberfläche von oben, die wir mit dem quadratischen Fehler erzeugt hatten - hier gibt es keine Wellen und Taschen mit lokalen Minimas, nur einen glatten Weg zum Glück. Hurra!

Auswirkungen auf den Gradientenabstieg


So, Leute. Jetzt schnallt Euch wirklich an. Jetzt kommt es! Ihr werdet in wenigen Minuten Augenzeuge sein, wie ein kleines mathematisches Wunder geschieht. Danach werdet ihr den Wunsch haben, den aktuellen Platz zu verlassen, Euch auf die Couch zu legen, zur Decke zu starren und Logikwölkchen über dem Kopf kreisen zu lassen. Aber das ist genau richtig so. Vielleicht wirst Du sogar Lust auf ein Glas guten Rotweins bekommen, um die Welt und die menschliche Leistungsfähigkeit, inkl. Deiner eigenen, gebührend zu feiern.

Schritt 1: Partielle Ableitung!

Wir müssen nun unsere neue Fehlerfunktion ja noch ableiten! Wir haben zwar oben schon die Ableitung der sigmoiden Aktivierung in der Kettenregel eingesetzt (das war o*(1-o) ), aber wir hatten immer noch den Teil (t-o) , der von dem quadratischen Fehler beim Ableiten erzeugt wurde.

Von der Kettenregel wissen wir, dass wir auch wirklich nur noch genau den einen Teil ableiten, der sich um die Loss-Funktion daselbst kümmert. Die gesamte Kette vom Loss zum Gewicht sah so aus:

\begin{eqnarray} \frac{\partial \mathcal{L}}{\partial w_n} &=& \color{red}{\frac{\partial \mathcal{L}}{\partial o}} \color{black}{*} \color{blue}{\frac{\partial o}{\partial net}} \color{black}{*} \color{green}{\frac{\partial net}{\partial w_n}} \end{eqnarray}

Das grüne Ding kennen wir schon, das war das einzelne Gewicht, und dort bleibt nur der Vorfaktor i_n beim partiellen Ableiten übrig - die Stärke des Inputs spielte ja deshalb auch intuitiv eine Rolle, weil nur stark aktivierte Strecken von unserem Algorithmus auch stark geändert werden sollen, weil wir über schwach aktivierte Strecken gar keine Aussagen wagen wollen:

\begin{eqnarray} \frac{\partial \mathcal{L}}{\partial w_n} &=& \color{red}{\frac{\partial \mathcal{L}}{\partial o}} \color{black}{*} \color{blue}{\frac{\partial o}{\partial net}} \color{black}{*} \color{green}{i_n} \end{eqnarray}

Den blauen Teil haben wir in diesem Teil 4 schon weiter oben bestaunt, das Ergebnis der Ableitung war denkbar einfach, auch wenn wir uns den mit fiesen Tricks gespickten Weg zum Ergebnis von woanders geholt haben. Aus der sigmoiden Aktivierung wurde abgeleitet einfach o*(1-o) :

\begin{eqnarray} \frac{\partial \mathcal{L}}{\partial w_n} &=& \color{red}{\frac{\partial \mathcal{L}}{\partial o}} \color{black}{*} \color{blue}{o * (1-o)} \color{black}{*} \color{green}{i_n} \end{eqnarray}

Bleibt also noch der Anfang der Kette, und den können wir losgelöst von Raum und Zeit einmal gemütlich ableiten - wir lassen hier das \epsilon (epsilon) aus der Formel von oben mal weg, das ist nur bei der Implementierung relevant, beim Ableiten hängt es nur nutzlos in der Luft herum (und der Wert geht sowieso gegen 0) - wir setzen also unsere Loss-Funktion ein:

\begin{eqnarray} \color{red}{\frac{\partial \mathcal{L}}{\partial o}} &=& \frac{\partial}{\partial o}( t * -ln(o) + (1-t) * -ln(1-o) ) \end{eqnarray}

ZOMG! Wir brauchen wohl noch zwei kleinere Hints aus der Formelsammlung. Das eine hatten wir schon im letzten Tutorial, die Linearität der Ableitung. Dabei hatten wir uns aber nur den additiven Teil angeschaut, also: wir können eine komplizierte Formel am Plus trennen, die Bestandteile einzeln ableiten und wieder mit Plus zusammenfügen. Es gibt aber auch die Linearität eines konstanten Vorfaktors, den man vor die Ableitung ziehen kann. Da unser target t ja von außen kommt und zwar beliebig, aber fest ist, können wir diesen als konstanten Vorfaktor betrachten - insbesondere auch, weil dieser Term nicht von o oder von den Gewichten abhängt.

Das alles machen wir mal eben in zwei Schritten:

\begin{eqnarray} \color{red}{\frac{\partial \mathcal{L}}{\partial o}} &=& \frac{\partial}{\partial o}( t * -ln(o) ) \overbrace{+}^{\text{plus}} \frac{\partial}{\partial o} ( (1-t) * -ln(1-o) ) \\ \\ &=& \overbrace{t}^{\text{konstant}} * \frac{\partial}{\partial o}( -ln(o) ) + \overbrace{(1-t)}^{\text{konstant}} * \frac{\partial}{\partial o} ( -ln(1-o) ) \end{eqnarray}

Bleiben also nur noch die lns übrig. Ein Blick in die Formelsammlung für die Ableitung des logarithmus naturalis offenbart folgendes:

\frac{d}{dx} ln(x) = \frac{1}{x}

Beinahe zu einfach, nicht wahr? Wenden wir es gleich an - aber bitte, bitte beachtet, dass der zweite ln mit -ln(1-o) wieder eine Verkettung ist! Die innere Ableitung ist dann -1 vom negativen o , und deshalb wechselt das Vorzeichen und aus Minus wird Plus!

\begin{eqnarray} \color{red}{\frac{\partial \mathcal{L}}{\partial o}} &=& t * \frac{\partial}{\partial o}( -ln(o) ) + (1-t) * \frac{\partial}{\partial o} ( -ln(1-o) ) \\ \\ &=& t * \overbrace{- \frac{1}{o}}^{\text{ln abgeleitet}} + (1-t) * \overbrace{ + \frac{1}{1-o}}^{\text{ln abgeleitet mit Kettenregel}} \\ \\ &=& \overbrace{- \frac{t}{o} + \frac{1-t}{1-o}}^{\text{Terme ausmultipliziert}} \\ \\ &=& \overbrace{ \frac{1-t}{1-o} - \frac{t}{o} }^{\text{Terme umsortiert}} \end{eqnarray}

Schritt 2: Einsetzen und vereinfachen!

Na, das war doch recht harmlos, nun können wir den ollen quadratischen Fehler ersetzen durch unsere frisch abgelittene Log Loss-Funktion! Wir setzen also den roten Teil ein:

\begin{eqnarray} \frac{\partial \mathcal{L}}{\partial w_n} &=& \color{red}{\frac{\partial \mathcal{L}}{\partial o}} \color{black}{*} \color{blue}{o * (1-o)} \color{black}{*} \color{green}{i_n} \\ \\ &=& \color{red}{ ( \frac{1-t}{1-o} - \frac{t}{o} ) } \color{black}{*} \color{blue}{o * (1-o)} \color{black}{*} \color{green}{i_n} \end{eqnarray}

Da könnten wir nun aufhören, aber da sind wirklich dieses Mal viele Terme drin, die von o und von t abhängen. Das können wir noch ausmultiplizieren!

Wir holen uns also nach und nach den Faktor o und den Faktor (1-o) in unsere Klammer vorne rein und Kürzen und Vereinfachen, wo immer möglich:

\begin{eqnarray} \frac{\partial \mathcal{L}}{\partial w_n} &=& \color{red}{ ( \frac{1-t}{1-o} - \frac{t}{o} ) } \color{black}{*} \color{blue}{o * (1-o)} \color{black}{*} \color{green}{i_n} \\ \\ &=& \color{red}{ ( \frac{ \color{blue}{o} (1-t)}{1-o} - \frac{\color{blue}{o}t}{o} ) } \color{black}{*} \color{blue}{(1-o)} \color{black}{*} \color{green}{i_n} \\ \\ &=& \color{red}{ ( \frac{ \color{blue}{o} - \color{blue}{o}t}{1-o} - \overbrace{\frac{\color{blue}{o}t}{o}}^{\color{black}{\text{o kürzbar}}} ) } \color{black}{*} \color{blue}{(1-o)} \color{black}{*} \color{green}{i_n} \\ \\ &=& \color{red}{ ( \frac{ \color{blue}{o} - \color{blue}{o}t}{1-o} - t ) } \color{black}{*} \color{blue}{(1-o)} \color{black}{*} \color{green}{i_n} \\ \\ &=& \color{red}{ ( \overbrace{\frac{ \color{blue}{(1-o)} * (\color{blue}{o} - \color{blue}{o}t)}{1-o}}^{\color{black}{\text{(1-o) kürzbar}}} - \overbrace{\color{blue}{(1-o)}t}^{\color{black}{\text{ausmultiplizieren}}} ) } \color{black}{*} \color{green}{i_n} \\ \\ &=& \color{red}{ ( \overbrace{(\color{blue}{o} - \color{blue}{o}t) - (t-\color{blue}{o}t) )}^{\color{black}{\text{Klammern auflösen}}}} \color{black}{*} \color{green}{i_n} \\ \\ &=& \color{red}{ ( \color{blue}{o} - \color{blue}{o}t - t + \color{blue}{o}t ) } \color{black}{*} \color{green}{i_n} \\ \\ &=& \color{red}{ ( \color{blue}{o} - t) } \color{black}{*} \color{green}{i_n} \end{eqnarray}

Schritt 3: Bauklötze staunen!

Ist das nicht zum Mäusemelken? Nun haben wir die Ableitung von Log Loss und die Ableitung der sigmoidalen Aktivierung zusammengerafft, und was kommt dabei heraus:

Schon wieder unsere olle Delta-Regel!

w_n \leftarrow w_n + \alpha * -1 * \color{red}{ ( \color{blue}{o} - t) } \color{black}{*} \color{green}{i_n}

Noch die -1 wieder nutzen (die kommt von der Vorschrift, den negativen Gradienten beim Absteigen zu verwenden), um t und o in andere Reihung zu bringen:

w_n \leftarrow w_n + \alpha * \color{red}{ ( t - \color{blue}{o} ) } \color{black}{*} \color{green}{i_n}

Und das, werte Leserschaft, ist sie schon wieder, die Delta-Regel. Glaubt ihr nicht? Schaut und rechnet es selbst nach! Der Log Loss war also nicht nur ein Neutralisator für lokale Minima auf der Fehleroberfläche, sondern hat auch die Ableitung der sigmoiden Aktivierung neutralisiert, so dass nur noch das \delta = t-o aus der Delta-Regel übrig bleibt!

Sprich: wir hätten unseren Gradientenabstieg im Code gar nicht ändern müssen - ein einfaches Hinzufügen der sigmoiden Aktivierung hätte gereicht, und wir hätten implizit aus einem quadratischen Fehler einen Log Loss gemacht! Was für ein übler Zaubertrick!

Was ist denn DA bitteschön passiert?

Puh! Ich weiß nicht, wie es Euch geht, aber ich muss jetzt erstmal tief ein- und ausatmen. Wie kann es sein, dass eine einzige Delta-Regel für verschiedene Aktivierungs- und Loss-Kombinationen funktioniert?

Tatsächlich sind unterschiedlichste Verfahren aus dem maschinellen Lernen wie einlagige Neuronale Netze mit Heaviside und der Widrow-Hoff-Regel, der Least-Mean-Squares-Algorithmus für die kleinsten Quadrate, die Logistische Regression, die Lineare Regression und viele andere miteinander verwandt. Die klügsten Mathematiker ihrer Zeit hatten lange nicht die Verbindung gesehen, wie wir sie heutzutage herleiten können.

Es sind alles lineare Modelle (engl. linear models), und alle arbeiten implizit mit einer Form von sog. Maximum Likelihood Schätzer (engl. maximum likelihood estimator oder kurz: MLE). Wenn man zu jeder Aktivierung ein passendes Loss-Gegenstück findet, das die Ableitung der Aktivierung auf die Delta-Regel reduziert, dann sieht das wie ein fauler Zauber aus, aber es ist mathematisch alles wohl begründbar. Das alles heute noch, hier und jetzt, herzuleiten, würde aber den Rahmen dieses Tutorials bei weitem sprengen. Wer jedoch Blut geleckt hat, kann mit den Stichworten hier im Text sicherlich neue Quellen auftun.

Wichtig ist, dass es einen großen Unterschied macht, welche Kombination aus Aktivierungs- und Loss-Funktion man wählt: denn sigmoide Aktivierung mit quadratischem Fehler führte bei uns da oben nicht nur zu einer mühsamer zu berechnenden Gradientenabstiegsformel, auch die Fehleroberfläche war alles andere als ideal. Dabei ist es egal, ob man einfach nur selbst ein neuronales Netz implementieren möchte oder ob man fertige Software-Bibliotheken anwendet.

Das finale Training

So, girls, boys und diverse, nun wollen wir aber auch schnell zum Ende kommen, die Nacht ist kurz: Wir kopieren uns nochmal die fit-Funktion von oben als Zelle hier nach unten, ändern den Gradientenabstieg wieder zurück auf die Delta-Regel und lassen das Training laufen. Da wir diesmal den Log Loss optimieren, sollten wir auch diesen als Fehler der Epochen zurückgeben. Sonst bleibt alles beim alten Lernprogramm.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def fit(X, y):
global weights
errors = []
data = list(zip(X, y))
for epoche in tqdm(range(0, epochen)):
error = 0

# Die Daten einmal gut durchschütteln
np.random.shuffle(data)
for inputs, target in data:

# Das Neuron sigmoid aktivieren
net = inputs.dot(weights)
output = sigmoid(net)

# Der Fehler ist unser Log Loss
error += verlust_logloss(target, output)

# Unser Gradientenabstieg:
# Sigmoide Aktivierung + Log Loss = Delta Regel
# WITCHCRAFT!!1elf
weights += alpha * (target-output) * inputs
errors.append(error)
return errors

Wir setzen nochmal unsere Gewichte zurück auf 0, legen Epochen und alpha neu fest und starten das Training:

1
2
3
epochen = 2500
alpha = 0.00001
weights = np.zeros(14) # 13 Merkmale, 1 Bias

Training starten:

1
errors = fit(X_train, y_train)

Fehler plotten:

1
plt.plot(errors)

Geschafft! Auch jetzt sind wir misstrauisch und überprüfen nochmal, was unser Modell gelernt hat:

1
2
y_pred, y_probs = predict(X_test)
print(accuracy_score(y_pred, y_test))

Fazit

Herzlichen Glückwunsch und Respekt! Sich hier durchzubeissen ist sicher nicht immer unproblematisch, da kann ein Text noch so lustig geschrieben sein, zwischendrin stößt man immer mal an eine unsichtbare Wand. Aber das gehört eben dazu! Falls es Fragen gibt, nutze die Möglichkeiten des Diskussionsforums oder suche die E-Mail Adresse auf dieser Seite heraus.

Es ist gut, wenn man APIs wie keras, tensorflow und PyTorch mit Sachverstand einsetzen kann. Es tut auch gut, wenn man einmal nachvollzogen hat, warum man sich Gedanken um Aktivierung- und Loss-Kombi machen sollte. Das mühsame Ableiten machen ja später auch die Software-Bibliotheken für uns. Aber Denken müssen wir immer noch selbst.

Es ist noch viel cooler, wenn man auch selbst Code schreiben kann, der auf magische Art und Weise Dinge aus Daten erlernen kann. Und jetzt sind wir hier schon echt weit gekommen!

Im nächsten Tutorial schauen wir uns dann aber endlich auch das mehrlagige neuronale Netz an (engl. Multi-Layer Perceptron, MLP).

Eigene Experimente

  • Alles, was hier passiert, hängt vom Zufall ab. Versuche, mehrere Läufe durchzuführen und beobachte, was dann passiert!

  • War die Accuracy noch nicht hoch genug? Versuche doch, das Training nochmal zu starten, in dem Du nochmal die fit-Funktion mit den Trainingsdaten aufrufst!

  • Es gibt noch jede Menge andere Aktivierungs- und Loss-Funktionen. Versuche doch mal, eine tanh-Sigmoidkurve (tanh steht für tangens hyperbolicus) in die Lernregel mit einem Loss Deiner Wahl einzubauen! Dann kannst Du mit \tanh(i \bullet w) Dein Ausgabeneuron aktivieren und mit der neuen Formel trainieren.

Downloads

Alle bisherigen Tutorials finden sich in folgendem Github-Repository: https://github.com/dannybusch/neuromant.de-Tutorials

Direkt zur Datei für dieses Tutorial geht es hier: https://github.com/dannybusch/neuromant.de-Tutorials/blob/master/notebooks/Tutorial_Das-Perzeptron-Teil-4.ipynb

Viel Spaß beim Ausprobieren!

Schlusswort

Trotz aufwändiger Kontrolle kann es passieren, dass sich Fehler einschleichen. Falls Ihr einen Fehler gefunden habt, oder Fragen zu den Inhalten bestehen, oder ihr einfach nur mal sagen wollt, wie ihr das Tutorial empfunden habt, dann schreibt Bitte einen Kommentar unten auf die Seite oder erreicht den Autoren via E-Mail an tutorials@neuromant.de

Vielen Dank!

Errata / Updates zum Artikel
  1. In einer früheren Version wurde über die Interpretation der Gewichte gesprochen - dies wird jedoch ausgelagert, da es in diesem Kontext nicht richtig eingewoben werden konnte.