Wichtige Updates seit Semesterbeginn:
Wir steigen in das Thema des Maschinellen Lernens ein, indem wir die Typen des Maschinellen Lernens kennenlernen. Da wir uns ausschließlich mit Supervised Learning (Überwachtes Lernen) beschäftigen, betrachten wir ein Regressionsverfahren als Beispiel. Wir implementieren das Verfahren der linearen Regression direkt in Python und lernen anschließend die Anwendung des bereits implementierten linearen Regressors in der Bibliothek Scikit-learn kennen.
Nutzen Sie die Lernziele, um Ihr Wissen zu überprüfen:
California housing dataset
Diese Webseiten basieren auf Jupyter-Notebooks, daher benötigen wir einige Importe für Python. In diesem Kapitel importieren Libraries fürs Datenhandling (NumPy und Pandas) und zum Visualisieren (Matplotlib).
import numpy as np
import pandas as pd
import matplotlib
import matplotlib.pyplot as plt
In diesem Kapitel wollen wir uns einen Überblick über einfache Methoden des Maschinellen Lernens (ML) verschaffen. Beim ML geht es immer darum, ein Modell durch Daten und Lernmechanismen zu optimieren.
Wir unterscheiden fünf Typen des maschinellen Lernens:
Beim überwachten Lernen gibt es gelabelte Trainingsdaten, d.h. man hat Paare $(x^k, y^k)$ von Eingabefeatures $x^k$ und korrekter Ausgabe $y^k$. Die Ausgabe $y^k$ ist das jeweilige "Label" (oder die Klasse oder die Kategorie) des $k$-ten Trainingsbeispiels. Man benutzt die Trainingsdaten, um das Modell anzupassen und zu optimieren. Optimierung heißt, dass die Differenz (Fehler) zwischen Ausgabe des Modells und korrekter Ausgabe möglichst gering ist.
Hinweis: Wir verwenden den hochgestellten Index (wie in $w^k$), um ein einzelnes Trainingsbeispiel zu kennzeichnen. Es handelt sich nicht um Potenzierung.
In dieser Vorlesung geht es hauptsächlich um überwachtes Lernen.
Beim unüberwachten Lernen sind die Trainingsdaten ungelabelt, d.h. man hat nur Features $(x^k)$. Das Lernverfahren bildet aufgrund der Daten eigene Kategorien (Cluster) bzw. erlaubt es, die Features auf eine kleinere Menge von Features zu reduzieren, die besonders gut zur Trennung der Trainingsdaten geeignet sind.
Methodisch sind die Bereiche des Supervised Learning und des Unsupervised Learning sehr verschieden. Das Skript enthält ein (optionales) Kapitel, wo ganz kurz auf Unsupervised Learning mit Neuronalen Netzen eingegangen wird, um diesen Unterschied zu illustrieren.
Beim semi-überwachten Lernen ist sowohl eine eher geringe Menge gelabelter Daten gegeben sowie eine große Menge ungelabelter Daten. Mit Hilfe von Verfahren des unüberwachten Lernens versucht man, die Kategorien der gelabelten Daten auf die ungelabelten Beispiele zu übertragen.
Self-supervised Learning ist besonders in der Sprachverarbeitung (NLP) und in diesem Zusammenhang natürlich mit aktuellen Technologien wie Transformer, GPT und ChatGPT relevant.
Beim selbstüberwachten Lernen möchte auf ungelabelten Daten die Methoden des überwachten Lernens anwenden. Wie funktioniert das? Man nimmt eine Menge an ungelabelten Daten und "versteckt" einen bestimmten Aspekt. Diesen Aspekt nimmt man als Label.
Nehmen wir an, wir möchten für ein beliebiges Schwarzweiß-Foto die Farben automatisch "vorhersagen". Als Daten liegt eine Menge an Farbfotos ohne Labels vor.
Jetzt überführt man die Farbfotos in Schwarzweißbilder (Inputdaten). Die Farbinformation wird zum Label erklärt. Man kann ein Modell trainieren, das für Schwarzweiß-Fotos die Farbinformation vorhersagt (Zhang et al. 2016). Mit einem solchen Modell kann man dann alte Schwarzweiß-Bilder kolorieren.
Ein anderes Beispiel: Man nimmt ein Foto und generiert drei neue Fotos, indem man das Foto um 45°, 90° und 180° dreht. Die Aufgabe des Modells ist es, die Originalausrichtung zu erraten. Da man selbst die Änderung vorgenommen hat, kann man die Fotos entsprechende labeln (das Originalfoto mit "korrekt" und die gedrehten mit "falsch"). Ein trainiertes Modell kann dann für ein beliebiges Foto vorhersagen, ob die Orientierung korrekt oder falsch ist.
Ein typisches Beispiel aus der Sprachverarbeitung ist folgendes Szenario: Man nimmt einen Satz (ohne Label), zum Beispiel
Ich war gestern früh beim Bäcker.
Aus diesem Satz kann man viele Traningsbeispiele generieren, um zum Beispiel immer das nächste Wort vorherzusagen:
Diese Aufgabe klingt für sich genommen vielleicht nicht besonders spannend. Man benutzt Aufgaben wie diese aber häufig nicht, um wirklich eine Vorhersagemaschine zu bauen, die das nächste Wort vorhersagt, sondern um die Parameter des so trainiertes Netzwerks als Repräsentationen für Wörter (bzw. Tokens) zu verwenden. Diese Repräsentationen nennt man word embeddings.
Im Vergleich zum semi-überwachten Lernen hat man beim self-supervised Learning also keine Teilmenge an gelabelten Daten, sondern man nimmt die Labels aus den Daten selbst.
Obwohl die Ausgangslage (ungelabelte Daten) das selbstüberwachte Lernen in die Nähe des Unsupervised Learning rückt, sind die Methoden natürlich exakt die gleichen wie beim Supervised Learning.
Wen die Methode interessiert, kann ich folgenden Überblicksartikel von Lilian Weng empfehlen - auch mit Blick auf aktuelle Forschung: Self-Supervised Representation Learning. Außerdem gibt es ein ca. 1-stündiges Video Self-supervised Learning, ein Tutorial von Licheng Yu, Yen-Chun Chen und Linjie Li auf der CVPR2020-Konferenz.
Beim bestärkenden Lernen werden Techniken des überwachten Lernens genutzt, um in einer Umwelt (repräsentiert durch einen Zustand) mit entsprechenden Umweltreizen (Belohnung/Bestrafung, engl. Reward) das Verhalten zu optimieren. Hier spricht man von einem Agenten. Das Modell bekommt als Input den aktuellen Zustand und gibt als Ausgabe die nächste Handlung (Aktion) aus. Die Belohnung/Bestrafung wird für das Lernen verwendet und kann nach jeder einzelnen Aktion oder nach einer Reihe von Aktionen erfolgen.
Reinforcement Learning wurde durch spektakuläre Erfolge im Bereich Computerspiele bekannt. AlphaGo Zero und AlphaZero können Spiele wie Schach lernen, in dem sie im Self-Play gegen sich selbst spielen (Silver et al. 2017). Eine Variante wurde im Self-Play trainiert, um alte Atari-Spiele wie Breakout zu lernen (Mnih et al. 2013). Weitere Erfolge gab es in der Robotik, z.B. beim Erlernen von komplexen und hochgradig kontext-abhängigen Bewegungsmustern (z.B. Lee et al. 2020 oder Gao et al. 2020).
In der Sprachverarbeitung rund um GPT und ChatGPT wurde ein Verfahren namens Reinforcement Learning from Human Feedback (RLHF) eingesetzt, um ein Fine-Tuning der Sprachmodelle zu erreichen. Mehr dazu finden Sie in einem Blogartikel Illustrating Reinforcement Learning from Human Feedback auf HuggingFace.
Reinforcement Learning werden wir in dieser Vorlesung leider nicht behandeln können. Wenn Sie etwas dazu lesen wollen, kann ich das Standardwerk von Sutton und Barto (2018) empfehlen. Außerdem gibt es einen guten Online-Kurs: Reinforcement Learning - Goal Oriented Intelligence von DeepLizard. Wer gleich praktisch einsteigen möchte, sei die Umgebung Gymnasium empfohlen (ehemals OpenAI Gym), wo man Sofwareagenten in Python in Umgebungen testen kann, die RL benötigen.
Wir gehen hier auf zwei einfache Verfahren des überwachtes Lernens ein:
Hier sehen wir schematisch ein Modell für Regression (oben) und eines für Klassifikation (unten):
Wir beginnen mit Regression.
Bei der linearen Regression trainieren wir ein Modell, das es erlaubt, anhand von Eingabedaten eine Zahl vorherzusagen.
Wir haben einen Datensatz von $N$ gelabelten Trainingsbeispielen $(x^{k}, y^{k})$ mit $k = 1, \ldots, N$. Der Index $k$ kennzeichnet ein Trainingsbeispiel.
Jedes $x \in \mathbb{R}^D$ ist ein $D$-dimensionaler Feature-Vektor und $y \in \mathbb{R}$ der entsprechende Zielwert. Da das $y$ ein echter, kontinuierlicher Wert ist, ist die Bezeichnung "gelabelte Beispiele" etwas irreführend, da es kein "Label" im Sinne einer Kategorie ist. "Gelabelt" bedeutet hier, dass der Zielwert bekannt ist.
Ein typisches Beispiel ist der Wert von Häusern. Der Vektor $x = (x_1, x_2, \ldots, x_D)$ repräsentiert verschiedene Haus-Eigenschaften wie Wohnfläche in qm, Anzahl der Zimmer und Größe des Grundstücks in qm. Der Wert $y$ repräsentiert den Kaufpreis, zum Beispiel in Tausend Euro (z.B. "321" für 321000 Euro).
In unserem späteren Beispiel ist $x^k$ aber eine einfache Zahl, also ein Skalar und kein Vektor, d.h. wir haben nur ein einziges Feature, aufgrund dessen wir den Wert $y$ vorhersagen möchten. Man nennt dies dann auch einfache lineare Regression.
Hinweis: Der tiefgestellte Index bezeichnet eine Komponente (einen Koeffizienten) eines Vektors oder einer Matrix (dort entsprechend zwei Indizes für Zeile und Spalte).
Die Grundannahme ist jetzt, dass es eine ideale Funktion $h^*$ gibt, die für jeden möglichen Eigenschaftsvektor $x$ (Feature-Vektor) eines Hauses den "wahren" Wert des Hauses zurückgibt. Der Buchstabe "h" kommt von "Hypothese" bzw. hypothetische Funktion.
$h^*$ ist also eine Abbildung der Art
$$ h^*: \mathbb{R}^D \rightarrow \mathbb{R} $$Gesucht ist jetzt ein sogenanntes Modell, das sich dieser idealen Funktion $h^*$ möglichst gut annähert. Wir nennen diese Modell $h$, das ist natürlich ebenfalls eine Funktion $h: \mathbb{R}^D \rightarrow \mathbb{R}$. Das $h$ steht für engl. hypothesis (Hypothese) und soll zeigen, dass die Funktion eine Vermutung über die Wirklichkeit darstellt.
Die Funktion $h$ beinhaltet eine Reihe von Parametern $w = (w_0, w_1, \ldots, w_D) \in\mathbb{R}^{D+1}$. Da $h$ von diesen Parametern abhängt, nennen wir die Funktion $h_w$. Wir lassen das kleine $w$ aber häufig aus Gründen der Lesbarkeit weg.
Hinweis: Das $w$ kommt von engl. weight (Gewicht) und deutet auf die spätere Verwendung bei Neuronalen Netzen hin. In der Literatur wird auch oft der griechische Buchstabe $\theta$ ("Theta") benutzt, um Parameter zu repräsentieren.
Im einfachsten Fall kann man $h_w(x)$ als Linearkombination der Komponenten von $x$ plus einer Konstanten $w_0$ auffassen:
$$ h_w (x) = w_0 + w_1 x_1 + \ldots + w_D x_D $$Wenn $x$ kein Vektor, sondern ein Skalar ist, wird daraus eine einfache Gerade (lineare Funktion):
$$ h_w (x) = w_0 + w_1 x $$wobei $w_0$ den y-Achsenabschnitt darstellt und $w_1$ die Steigung.
Wir suchen die optimalen Werte $w$. Was optimal genau bedeutet, legen wir über eine Zielfunktion fest, engl. objective function. Diese spiegelt entweder Fehler oder Kosten wider und muss dann minimiert werden, dann spricht man von einer Verlustfunktion, engl. loss function. Alternativ kann sie auch Nutzen oder den Gewinn angeben, dann muss sie maximiert werden. Eine solche Funktion nennt man Nutzenfunktion, engl. utility function. Beide Spielarten sind klassische Probleme der mathematischen Optimierung.
Wir messen den Fehler als Differenz zwischen unserem Modell $h_w$ und dem tatsächlichen Wert $y_k$ aus den Trainingsdaten. Zur Erinnerung: Unsere Trainingsdaten bestehen aus $N$ Tupeln der Form $(x^k, y^k)$. Genauer gesagt nehmen wir die Summe der quadratischen Fehler, teilen diese durch die Anzahl der Trainingsbeispiele und erhalten so den mittleren quadratischen Fehler, engl. mean squared error oder MSE:
$$ \frac{1}{N} \sum_{k=1}^N \left( y^k - h_w (x^k) \right)^2 $$Warum wird das Quadrat genommen und nicht der Betrag? Weil die Quadratsfunktion differenzierbar ist, d.h. man kann die Ableitung bilden, um ein Verfahren namens Gradientenabstieg, engl. gradient descent, einzusetzen.
Die Betragsfunktion ist hingegen nicht differenzierbar, da sie nicht-stetig am Nullpunkt ist (auch gut erklärt unter https://de.wikipedia.org/wiki/Differenzierbarkeit).
Wir zeigen hier nochmal die beiden Funktionen um den Nullpunkt herum.
x = np.arange(-10, 10, .1) # stellt eine Reihe von Werten von -10 bis 10 mit Schrittweite 0.1 als Numpy-Array her
y = abs(x) # wendet die Funktion elementweise auf die Werte an und produziert einen neuen Numpy-Array
plt.plot(x,y)
plt.xlabel('x')
plt.ylabel('y')
plt.title('Betragsfunktion')
plt.xticks([-10,-5,0,5,10])
plt.yticks([0,5, 10])
plt.show()
Sie sehen, dass die Betragsfunktion am Nullpunkt nicht differenzierbar ist. Was heißt differenzierbar überhaupt? Damit eine Funktion differenzierbar ist, muss man eine Tangente an jedem Punkt bestimmen können. Wenn Sie eine "Ecke" sehen, ist das nicht möglich.
x = np.arange(-10, 10, .1) # stellt eine Reihe von Werten von -10 bis 10 mit Schrittweite 0.1 als Numpy-Array her
y = x**2 # Schreibweise für Potenz
plt.plot(x,y)
plt.xlabel('x')
plt.ylabel('y')
plt.title('Quadratfunktion')
plt.xticks([-10,-5,0,5,10])
plt.yticks([0,50, 100])
plt.show()
Bei der Quadratfunktion gibt es eine Tangente am Nullpunkt, diese hätte die Steigung 0. Es lässt sich also zu $f(x) = x^2$ die Ableitung bilden, nämlich $f'(x) = 2x$.
Hinweis: Dass eine Funktion nicht differenzierbar ist, ist kein "Knock-out-Kriterium", wenn es um Gradientenabstieg geht. Es erleichtert aber die mathematischen Herleitungen, da man sonst Fallunterscheidungen einführen müsste. Im Bereich Neuronaler Netze, wo auch Gradientenabstieg verwendet wird, wird z.B. aktuell häufig die sogenannte ReLU-Funktion benutzt, die nicht-differenzierbar bei $x=0$ ist.
Als nächstes sehen wir uns ein Verfahren an, mit dem wir Parameter für ein Modell finden, das die Zielfunktion optimiert.
Eine Umsetzung von Optimierung kann durch ein Verfahren namens Gradientenabstieg (engl. gradient descent) geleistet werden. Wir illustrieren einfache lineare Regression mit einem konkreten Beispiel in Python.
Für unser Python-Beispiel schauen wir hier den Datensatz California housing dataset. In diesem Datensatz ist der Kaufpreis von Häusern in Kalifornien erfasst, zusammen mit verschiedenen Features, z.B. dem Alter des Hauses, die Anzahl der Zimmer oder auch das Durchschnittseinkommen der Gegend, in der das Haus steht. Die Idee ist, den Hauspreis für ein "neues" Haus vorherzusagen, wenn man die entsprechenden Features kennt. Wir werden uns das Feature Durchschnittseinkommen herauspicken, so dass wir einfache Datenpaare $(x^k, y^k)$, wo das $x^k$ ein solcher Einkommenswert ist und das $y^k$ der Kaufpreis eines Hauses.
Dieser Datensatz ist in der Bibliothek Scikit-learn enthalten, d.h. wir können ihn mit einer Anweisung in den Speicher laden.
Siehe auch: https://inria.github.io/scikit-learn-mooc/python_scripts/datasets_california_housing.html
Wir packen die Daten in einen Pandas-Dataframe (daher df). Pandas ist eine Python-Bibliothek. Ein Pandas-Dataframe ist einfach eine Tabelle. Eine Tabelle ist eine Matrix, die zusätzlich Zeilen- und Spaltenbeschriftungen hat:
# Für dieses Beispiel wird das Paket scikit-learn benötigt
from sklearn.datasets import fetch_california_housing
housing = fetch_california_housing()
df = pd.DataFrame(data=housing.data, columns=housing.feature_names)
Werfen wir einen Blick in die Tabelle. Die erste Spalte - MedInc - gibt den Median aller Einkommen der Gegend an, wo das Haus steht. Es erscheint plausibel, dass das ein guter Wert ist, um den Wert des Hauses einzuschätzen. Die Vermutung wäre: Je höher das Einkommen, desto höher die Hauspreise. Wir überprüfen diese Hypothese im Folgenden.
df.head(5) # zeigt die ersten 5 Zeilen der Tabelle
MedInc | HouseAge | AveRooms | AveBedrms | Population | AveOccup | Latitude | Longitude | |
---|---|---|---|---|---|---|---|---|
0 | 8.3252 | 41.0 | 6.984127 | 1.023810 | 322.0 | 2.555556 | 37.88 | -122.23 |
1 | 8.3014 | 21.0 | 6.238137 | 0.971880 | 2401.0 | 2.109842 | 37.86 | -122.22 |
2 | 7.2574 | 52.0 | 8.288136 | 1.073446 | 496.0 | 2.802260 | 37.85 | -122.24 |
3 | 5.6431 | 52.0 | 5.817352 | 1.073059 | 558.0 | 2.547945 | 37.85 | -122.25 |
4 | 3.8462 | 52.0 | 6.281853 | 1.081081 | 565.0 | 2.181467 | 37.85 | -122.25 |
Wir speichern uns die erste Spalte mit dem Median der Einkommen:
incomes = df['MedInc'].array
incomes
<PandasArray> [8.3252, 8.3014, 7.2574, 5.6431, 3.8462, 4.0368, 3.6591, 3.12, 2.0804, 3.6912, ... 3.5673, 3.5179, 3.125, 2.5495, 3.7125, 1.5603, 2.5568, 1.7, 1.8672, 2.3886] Length: 20640, dtype: float64
Unsere Zielwerte - die Preise der Häuser - sind nicht in der Tabelle, sondern stecken noch in der Datenstruktur housing:
prices = housing.target
prices
array([4.526, 3.585, 3.521, ..., 0.923, 0.847, 0.894])
Die Hauspreise sind in 100000 USD angegeben. Das erste Haus kostet also etwa 450000 USD.
Kurzer "Sanity check": Wie lang sind die Arrays und sind sie gleich lang?
print(len(prices), len(incomes))
20640 20640
Da das doch sehr viele Werte sind, beschränken wir uns auf die ersten 200 Werte. Sonst werden die Plots unten zu unübersichtlich.
prices = prices[:200]
incomes = incomes[:200]
len(prices)
200
Wir sehen uns die Verteilung der Datenpunkte an: Auf der x-Achse sind die Hauspreise, auf der y-Achse die Einkommen. Diese Plots nennt man auch Scatterplots, weil man dort die Verteilung sehen kann.
Man sieht direkt, dass es eine Korrelation zu geben scheint. Je höher das Einkommen, umso höher der Hauspreis und umgekehrt.
plt.scatter(incomes, prices)
plt.ylabel('Hauspreis')
plt.xlabel('Einkommen')
plt.title('Hauspreise als Funktion des Einkommens')
plt.show()
Als nächstes bilden wir ein Modell und versuchen dieses dann anhand der Daten in der Tabelle zu optimieren.
Das Modell für eine lineare Regression ist eine lineare Funktion $h$. Bei der einfachen linearen Regression hat $h$ die Form:
$$ h(x) = w_0 + w_1 x $$wobei hier $w_0$ und $w_1$ Skalare sind. Die Funktion repräsentiert also eine Gerade mit Steigung $w_1$ und y-Achsenabschnitt $w_0$.
Unsere Beispieldaten schreiben wir in der Form $(x^k, y^k)$, wobei $x^k$ das jeweilige Einkommen der Gegend darstellt und $y^k$ den Hauspreis. Index $k$ repräsentiert die Zeile in unserer Datentabelle. Wenn $N$ die Gesamtzahl der Daten (=Zeilen) bezeichnet, läuft Index $k$ also von $1$ bis $N$. Man schreibt auch $k \in {1, \ldots, N}$.
Wir definieren unsere Zielfunktion $J$ wie oben beschrieben als mittleren quadratischen Fehler (MSE). Jeder Fehler ist die quadrierte Differenz zwischen dem tatsächlichen Wert $y^k$ und dem errechneten Wert unseres Modells $h(x^k)$.
$$ \begin{align*} \tag{ziel} J :&= \frac{1}{N} \sum_{k=1}^N \left( y^k - h(x^k) \right)^2 \\[2mm] &= \frac{1}{N} \sum_{k=1}^N \left( y^k - (w_0 + w_1 x^k) \right)^2 \end{align*} $$Für das Verfahren des Gradientenabstiegs berechnen wir für jeden der zwei Parameter die partielle Ableitung. Hier kommt die Kettenregel zum Einsatz.
$$ \begin{align*} \frac{\partial J}{\partial w_0} & = \frac{1}{N} \sum_{k=1}^N - 2\left( y^k - (w_0 + w_1 x^k) \right) \\[2mm] \frac{\partial J}{\partial w_1} & = \frac{1}{N} \sum_{k=1}^N - 2x^k\left( y^k - (w_0 + w_1 x^k) \right) \end{align*} $$Jetzt setzen wir wieder $h(x^k)$ ein:
$$ \begin{align*}\tag{grad} \frac{\partial J}{\partial w_0} & = \frac{1}{N} \sum_{k=1}^N - 2\left( y^k - h(x^k) \right) \\[2mm] \frac{\partial J}{\partial w_1} & = \frac{1}{N} \sum_{k=1}^N - 2x^k\left( y^k - h(x^k) \right) \end{align*} $$Beachten Sie, dass aufgrund der Summierung alle Trainingsbeispiele zur Berechnung dieser Ableitungen durchlaufen werden müssen.
Das Training führt jetzt die Optimierung durch. Das Ziel ist, durch schrittweise Anpassung - also Updates der Parameter - die optimalen Werte für $w_0$ und $w_1$ zu finden. Zu Beginn initialisieren wir die Parameter wie folgt:
$$w_0 := 0 \\[2mm] w_1 := 0 $$Das Training vollzieht sich in Runden, die wir Epochen nennen. Das Update der Parameter $w_0$ und $w_1$ geschieht mit Hilfe der jeweiligen partiellen Ableitung am Ende einer Epoche:
$$ \begin{align*} w_0 & := w_0 - \alpha \frac{\partial J}{\partial w_0}\\[2mm] w_1 & := w_1 - \alpha \frac{\partial J}{\partial w_1} \end{align*} $$Beachten Sie, dass in jeder Epoche erst alle Trainingsbeispiele durchlaufen werden müssen, um obige Ableitungen zu errechnen; das sieht man am Summenzeichen in Gleichung (grad). Daher kann erst am Ende der Epoche das Update der Parameter durchgeführt werden.
Da die Ableitungen in eine Richtung zeigen, die den Fehler $J$ vergrößern, müssen wir die Ableitungen negieren, daher das Minuszeichen.
Außerdem möchten wir kontrollieren, wie stark wir die Parameter pro Update ändern - manchmal nennt man das die Schrittweite. Das wird über die Lernrate $\alpha$ gesteuert, ein Wert $\in (0, 1)$, der als Faktor die Ableitung modifiziert. Je höher $\alpha$ ist, umso schneller wird gelernt. Ein zu hoher Wert birgt aber die Gefahr, ein Minimum zu überspringen und im schlimmsten Fall um das Minimum herum zu oszillieren.
Für die Wahl der Lernrate gibt es keine allgemeingültige Empfehlung, in der Praxis zeigt sich aber, dass Werte um $0.1$ bis $0.3$ gute Ausgangspunkte sind. Teilweise kann die Lernrate aber auch deutlich niedriger ausfallen; wir verwenden später zum Beispiel $\alpha = 0.001$. Die Lernrate wird im einfachsten Fall empirisch festgestellt.
Hinweis: Empirisch heißt "aus Erfahrung" - man kann auch einfach sagen: Trial and Error.
Die Lernrate ist ein Beispiel für einen Hyperparameter. Ein Hyperparameter ist ein Parameter, der während des Trainings nicht verändert wird, wohingegen unsere "normalen" Parameter $w_0$ und $w_1$ ja während des Trainings fortlaufend angepasst werden.
Ein weiteres Bespiel für einen Hyperparameter ist die Trainingsdauer, also die Anzahl der Epochen, mit der ein Netz trainiert wird.
Hier sehen wir den Trainingsprozess schematisch. Innerhalb einer Epoche werden alle $N$ Trainingsdaten durchlaufen und der jeweilige Summand für die zwei partiellen Ableitungen aufgesammelt. Dies fließt am Ende der Epoche in die Berechnung des Gradienten ein (Summation und Berechnung des Durchschnitts). Der Gradient dient wiederum dazu, die Parameter anzupassen, also ein Update durchzuführen.
In den obigen Versuchen haben wir mit einer einfachen linearen Funktion gearbeitet, die zwei Parameter hat ($w_0$ und $w_1$), die einen einfachen Inputwert $x$ hat und einen einfachen Outputwert $y = h(x)$.
$$ h(x) = w_0 + w_1 x $$Natürlich könnte man noch höhere Potenzen von x hinzunehmen, um so eine Kurve zu erzeugen, die sich den Daten besser anschmiegt. Nehmen wir $x^2$ dazu, spricht man von quadratischer Regression:
$$ h(x) = w_0 + w_1 x + w_2 x^2 $$In diesem Fall erhalten wir keine Regressionsgerade, sondern eine U-förmige Kurve (Parabel). Entsprechend bekommen Sie komplexere Kurven mit steigender Zahl von Potenzen:
$$ \begin{align*} h(x) & = w_0 + w_1 x + w_2 x^2 \\[1mm] h(x) & = w_0 + w_1 x + w_2 x^2 + w_3 x^3 \\[1mm] & \vdots\\[1mm] h(x) & = w_0 + w_1 x + \ldots + w_n x^n \end{align*} $$Beachten Sie aber, dass die Parameter $w_0, w_1, \ldots$ immer noch linear sind (also nicht z.B. zu einer Potenz erhoben werden). Das Verfahren ist tatsächlich immer noch sehr einfach.
In der Regel haben Sie natürlich nicht nur ein Input-Feature wie mittleres Einkommen im Hausbeispiel. Sie haben z.B. noch Anzahl der Zimmer, Größe in qm, Alter in Jahren etc. Jedes Feature wird mit einer weiteren Input-Variablen $x_1$, $x_2$ usw. erfasst, so dass man insgesamt einen Vektor $x = (x_1, x_2, \ldots, x_n)$ hat.
Unsere Formel für das Modell sieht dann z.B. so aus
$$ h(x) = w_0 + w_1 x_1 + w_2 x_2 + \ldots + w_n x_n $$Auch hier ist es relativ leicht, das Verfahren entsprechend anzupassen.
Zunächst sehen wir uns an, wie man lineare Regression von Grund auf in Python implementiert. Oft versteht man so am besten, wie die Theorie in die Praxis übertragen wird.
Wir schreiben eine Funktion update, die die Parameter $w_0$ und $w_1$ einmalig updatet, also für eine Epoche. Dazu müssen wir die zwei partiellen Ableitungen wie oben gezeigt berechnen, diese werden in den Variablen dJw0 und dJw1 gespeichert.
Zur Erinnerung:
$$ \begin{align*} \frac{\partial J}{\partial w_0} & = \frac{1}{N} \sum_{k=1}^N - 2\left( y^k - (w_0 + w_1 x^k) \right) \\[2mm] \frac{\partial J}{\partial w_1} & = \frac{1}{N} \sum_{k=1}^N - 2x^k\left( y^k - (w_0 + w_1 x^k) \right) \end{align*} $$Sie sehen, dass wir in einer Schleife alle Trainingsbeispiele durchlaufen, um die Summe zu berechnen. Erst beim Update wird das $1/N$ durchgeführt. Dies entspricht einer Epoche.
# Eingabe: x-Werte, y-Werte, Parameter und Lernrate
def update(x, y, w1, w0, alpha):
# Schritt 1: Ableitungen berechnen
dJdw1 = 0
dJdw0 = 0
N = len(x)
for i in range(N):
dJdw1 += -2 * x[i] * (y[i] - (w1 * x[i] + w0))
dJdw0 += -2 * (y[i] - (w1 * x[i] + w0))
# Schritt 2: Updates durchführen
w1 = w1 - (1/float(N)) * dJdw1 * alpha
w0 = w0 - (1/float(N)) * dJdw0 * alpha
return w1, w0
Jetzt definieren wir die Funktion train, die das Training in mehreren Epochen durchführt.
Wir speichern in jeder Epoche den Fehlerwert (Loss) und die Parameterwerte in einer Liste, der Historie. Außerdem geben wir alle 100 Epochen den Zwischenstand auf der Konsole aus. Das History-Objekt geben wir am Ende des Funktionscodes zurück.
# Eingabe: x-Werte, y-Werte, Parameter, Lernrate und Anzahl der Epochen
def train(x, y, w1, w0, alpha, epochs):
history = []
for e in range(epochs):
# Historie speichern
l = loss(x, y, w1, w0)
history.append((l, w1, w0))
if e % 100 == 0:
print(f"epoch: {e:4} loss: {l:7.3f} w1={w1:.3f} w0={w0:.3f}")
w1, w0 = update(x, y, w1, w0, alpha)
return history
Es fehlt noch die oben verwendete Funktion loss, die den aktuellen Fehler (MSE) berechnet. Auch hier müssen alle Trainingsbeispiele in einer Schleife durchlaufen werden.
Hinweis: In Python bedeutet der Doppelstern "potenzieren".
# Eingabe: x-Werte, y-Werte und Parameter
def loss(x, y, w1, w0):
N = len(x)
error = 0
# summiere quadratischen Fehler auf
for i in range(N):
error += (y[i] - (w1 * x[i] + w0))**2
# gib Mittelwert zurück
return error / float(N)
Jetzt können wir trainieren. Wir initialisieren unsere Parameter mit 0 und wählen eine sehr niedrige Lernrate von 0.001. Es sollen 3000 Epochen durchlaufen werden. Wir speichern die zurückgegebene Historie.
history = train(incomes, prices, 0, 0, .001, 3000)
epoch: 0 loss: 4.833 w1=0.000 w0=0.000 epoch: 100 loss: 0.481 w1=0.493 w0=0.149 epoch: 200 loss: 0.459 w1=0.510 w0=0.187 epoch: 300 loss: 0.448 w1=0.504 w0=0.219 epoch: 400 loss: 0.439 w1=0.498 w0=0.249 epoch: 500 loss: 0.431 w1=0.491 w0=0.276 epoch: 600 loss: 0.424 w1=0.485 w0=0.303 epoch: 700 loss: 0.417 w1=0.480 w0=0.328 epoch: 800 loss: 0.411 w1=0.474 w0=0.351 epoch: 900 loss: 0.406 w1=0.469 w0=0.373 epoch: 1000 loss: 0.402 w1=0.465 w0=0.394 epoch: 1100 loss: 0.397 w1=0.460 w0=0.414 epoch: 1200 loss: 0.394 w1=0.456 w0=0.433 epoch: 1300 loss: 0.391 w1=0.452 w0=0.450 epoch: 1400 loss: 0.388 w1=0.448 w0=0.467 epoch: 1500 loss: 0.385 w1=0.445 w0=0.483 epoch: 1600 loss: 0.383 w1=0.441 w0=0.497 epoch: 1700 loss: 0.381 w1=0.438 w0=0.511 epoch: 1800 loss: 0.379 w1=0.435 w0=0.525 epoch: 1900 loss: 0.377 w1=0.432 w0=0.537 epoch: 2000 loss: 0.376 w1=0.430 w0=0.549 epoch: 2100 loss: 0.375 w1=0.427 w0=0.560 epoch: 2200 loss: 0.373 w1=0.425 w0=0.570 epoch: 2300 loss: 0.372 w1=0.423 w0=0.580 epoch: 2400 loss: 0.371 w1=0.420 w0=0.590 epoch: 2500 loss: 0.371 w1=0.418 w0=0.598 epoch: 2600 loss: 0.370 w1=0.417 w0=0.607 epoch: 2700 loss: 0.369 w1=0.415 w0=0.615 epoch: 2800 loss: 0.369 w1=0.413 w0=0.622 epoch: 2900 loss: 0.368 w1=0.412 w0=0.629
Sanity check: Wir schauen uns die ersten 5 Einträge der Historie an. Jeder Eintrag sollte ein 3-Tupel mit Loss, w1 und w0 enthalten.
history[:5]
[(4.8325005300005, 0, 0), (4.576862434147349, 0.015626331876340004, 0.003914440100000001), (4.3362345247875265, 0.030784316641758333, 0.00772275025382289), (4.109734998459303, 0.0454879210288684, 0.011428088967518804), (3.896533855539058, 0.05975069530422363, 0.015033520569153993)]
Wir können die Plotfunktion der Matplotlib-Bibliothek nutzen, um die Fehlerentwicklung über die Epochen zu visualisieren.
Dazu benötigen wir eine Liste der Loss-Werte. Wir wenden List Comprehension an.
losses = [x[0] for x in history]
losses[:5] # Sanity check
[4.8325005300005, 4.576862434147349, 4.3362345247875265, 4.109734998459303, 3.896533855539058]
Jetzt der Plot. Die Plotfunktion benötigt für die Werte zwei Listen, eine für die x-Werte, eine für die y-Werte.
Für die x-Achse erzeugen wir eine einfache Liste der Form (1, 2, ..., 30).
plt.plot(range(len(losses)), losses, label='Loss-Entwiclung')
plt.ylabel("Loss")
plt.xlabel("Epochen")
plt.show()
Der letzte Eintrag der Historie enthält hier die optimalen Parameterwerte, die wir zwischenspeichern.
last = history[len(history)-1]
w1opt = last[1]
w0opt = last[2]
print(f'w1 = {w1opt:.3f} w0 = {w0opt:.3f}')
w1 = 0.410 w0 = 0.635
Jetzt kann man die berechneten Parameter $w_0$ und $w_1$ verwenden, um Vorhersagen zu treffen. Wir möchten wissen, welche Verkaufszahl zu erwarten ist, wenn wir einen bestimmten Betrag für Werbung ausgeben.
Dazu definieren wir die Funktion predict, die einfach die Funktion $h$ auf einem beliebigen $x$ mit den angegebenen Parametern anwendet.
def predict(x, w1, w0):
return w1*x + w0
Wir fragen uns, was in einer Gegend mit einem Median-Einkommen von 10 für ein Hauspreis zu erwarten ist. Oben hat unser Algorithmus die optimalen Parameter berechnet, die wir jetzt in unsere Vorhersagefunktion einsetzen:
predict(10, w1opt, w0opt)
4.735790571398752
Schauen Sie oben im Scatterplot bei 10 (auf der x-Achse) nach, ob die Vorsage plausibel ist.
Jetzt schauen wir uns nochmal den Scatterplot von oben an und zeichnen die Gerade ein, die sich aus den berechneten Parametern ergibt. Dazu zeichnen wir eine Linie von x=0 bis x=50 und berechnen die y-Werte mit Hilfe unserer Funktion predict.
Das tun wir in folgender Funktion:
def plot_scatter_regress(w1, w0, title):
plt.scatter(incomes, prices)
plt.plot([0, 12], [predict(0, w1, w0), predict(12, w1, w0)], 'r-') # Regressionsgerade
plt.xlabel('Einkommen')
plt.ylabel('Hauspreis')
plt.title(title)
plt.show()
Jetzt zeichnen wir Scatterplot und Regressionsgerade:
plot_scatter_regress(w1opt, w0opt, 'Hauspreis als Funktion des Einkommens')
Im Rückblick schauen wir uns an, wie sich die Regressionsgerade in den ersten 100 Epochen des Trainings entwickelt (insgesamt 3000 Epochen). Man sieht gut, wie die Gerade zu Beginn große Sprünge macht und sich in späteren Epochen nur noch wenig verändert.
for i in range(0, 100, 10):
w1 = history[i][1]
w0 = history[i][2]
plot_scatter_regress(w1, w0, f'Epoch {i} Loss={history[i][0]:.2f}')