Updates dieser Seite:

  • 20.03.2022: v1 neues Semester

Überblick

In diesem Kapitel gehen wir zur Klassifikation über. Wir sehen uns das Verfahren der logistischen Regression an, sowohl direkt in Python als auch als implementierte Methode in Scikit-learn. Außerdem beschäftigen wir uns mit der Evaluation von Modellen und entsprechenden Metriken. Abschließend geht es noch um die Frage, wie man Trainingsdaten für das Training auftrennen muss.

Konzepte

Logistische Regression, binäre und Mehrklassen-Klassifikation, One-vs-All, Konfusionsmatrix, Recall, Precision, Accuracy, Trainings-/Test- und Validierungsdaten, Cross-Validierung

Datensätze

Iris, MNIST (Scikit-learn)

In [1]:
import warnings
warnings.filterwarnings('ignore')

import numpy as np
import pandas as pd
import matplotlib
import matplotlib.pyplot as plt
import seaborn as sns
Bad key "text.kerning_factor" on line 4 in
/Users/kipp/anaconda3/envs/nndl/lib/python3.7/site-packages/matplotlib/mpl-data/stylelib/_classic_test_patch.mplstyle.
You probably need to get an updated matplotlibrc file from
https://github.com/matplotlib/matplotlib/blob/v3.1.3/matplotlibrc.template
or from the matplotlib source distribution

1 Klassifizierung mit logistischer Regression

Nachdem wir gesehen haben, wie wir mit Regression einen Wert vorhersagen können, kommen wir jetzt zur Frage, wie man eine Klasse, Kategorie oder Label vorhersagt. Genauer gesagt: Wir suchen zu einem Feature-Vektor die dazugehörige Klasse oder Kategorie. Ein klassisches Beispiel ist die Klassifikation von e-Mails in eine der zwei Klassen SPAM oder nicht-SPAM.

Die Klassifikationsmethode, die wir uns ansehen, heißt logistischen Regression. Das Wort "Regression" ist hier möglicherweise verwirrend, weil wir es ja mit Klassifikation und nicht mit Regression zu tun haben. Der Grund ist, dass der zugrunde liegende Mechanismus ähnlich zur linearen Regression ist, wie wir gleich sehen werden.

1.1 Problemstellung

Wir haben einen Datensatz von $N$ gelabelten Beispielen $(x^{k}, y^{k})$ mit $k = 1, \ldots, N$.

Das $x \in \mathbb{R}^D$ ist wieder ein $D$-dimensionaler Feature-Vektor, das $y \in \{0, 1\}$ ist jetzt ein binärer (boolescher) Wert.

Zwei klassische Beispiele sind

  • Klassfikation von Bildern: der Feature-Vektor $x$ enthält alle Pixelwerte eines Bildes in Form eines Arrays und Kategorie $y$ gibt z.B. an, ob es sich um das Bild einer Katze handelt ($y=1$) oder nicht ($y=0$).
  • Klassfikation von e-Mails: der Feature-Vektor $x$ repräsentiert die "Eigenschaften" einer e-Mail. Man nehme etwa 2000 ausgewählte, alphabetisch sortierte Wörter, die also einen eindeutigen Wortindex haben. Vektor $x \in \mathbb{R}^{2000}$ hat für jedes Wort, das in der entsprechenden Mail enthalten ist, an dem entsprechenden Wortindex eine 1 stehen (überall sonst eine 0). Kategorie $y$ gibt z.B. an, ob es sich um SPAM ($y=1$) handelt oder nicht ($y=0$).

1.2 Modell

Wie in der linearen Regression suchen wir ein Modell $h_w(x)$ als Linearkombination der Komponenten des Vektors $x$

$$ x = \left( \begin{array}{c} x_1 \\ \vdots \\ x_D \end{array} \right) $$

Zur Vereinfachung definieren wir $x$ als $(D+1)-$dimensionalen Vektor, wo immer $x_0 = 1$ ist. Auch hier geht es um ein konstantes Gewicht (siehe nächsten Abschnitt).

$$ x = \left( \begin{array}{c} 1 \\ x_1 \\ \vdots \\ x_D \end{array} \right) $$

Unser Modell $h$ ist jetzt eine Abbildung der Form

$$ h: \mathbb{R}^{D+1} \rightarrow \mathbb{R} $$

Der Zielwert ist ein Wert $\in \mathbb{R}$ im Interval $[0, 1]$, den man als Wahrscheinlichkeit oder genauer als Plausibilität/Likelihood interpretiert.

Ist das Modell ein Katzendetektor mit Input $x$ (Bilddaten), so bedeutet ein Output $h(x)=0.8$, dass das Bild mit 80%iger Wahrscheinlichkeit ein Katzenbild ist.

Parameter

Die Parameter von $h$ lassen sich als Vektor $w$ von Dimension $D+1$ darstellen.

$$ w = \left( \begin{array}{c} w_0 \\ w_1 \\ \vdots \\ w_D \end{array} \right) $$

Die Linearkombination der x-Werte mit den Gewichten $w$ kann man als Skalarprodukt darstellen. Hier muss man $w$ transponieren, da man sonst nicht Matrixmultiplikation anwenden darf:

$$ \begin{align} w^T x & = w_0 x_0 + w_1 x_1 + \ldots + w_D x_D \\[2mm] & = \sum_{i=0}^D w_i x_i \end{align} $$

Dies sieht tatsächlich wie lineare Regression aus, aber bei der logistischen Regression wollen wir aus Ausgabe keine Zahl als Output und der linearen Regression können Werte, die negativ sind, oder Werte, die weit über 1 liegen, vorkommen.

Stattdessen wollen wir (im einfachsten Fall) zwischen zwei Klassen unterscheiden, indem wir eine 0 oder eine 1 ausgeben.

Logistische Funktion

Um sicherzustellen, dass sich die Ausgabe möglichst nah an der 1 oder der 0 bewegt, wenden wir die logistische Funktion auf die Linearkomination $w^T x$ an. Die logistische Funktion $g$ ist wie folgt definiert:

$$ g(z) = \frac{1}{1+e^{-z}} $$

Hier sehen Sie den Verlauf der Funktion:

In [2]:
def sigmoid(z):
    return 1/(1+np.exp(-z))

x = np.arange(-10, 10, .1) # stellt eine Reihe von Werten von -10 bis 10 mit Schrittweite 0.1 als Numpy-Array her
y = sigmoid(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('Logistische Funktion')
plt.xticks([-10,-5,0,5,10])
plt.yticks([0,.5, 1])
plt.grid()
plt.show()

Modell

Die logistische Funktion "zwingt" also einen beliebigen Eingabewert in den Bereich [0, 1]. Außerdem ist die Funktion differenzierbar. Jetzt definieren wir unser Modell für die logistische Regression so:

$$ \begin{align} h_w (x) & = g(w^Tx) \\[2mm] & = \frac{1}{1+e^{-w^T x}} \end{align} $$

Anmerkung: Die logistische Funktion ist ein Beispiel für eine Sigmoidfunktion. In der Praxis werden die beiden Begriffe aber oft synonym benutzt.

1.3 Intuition mit einem Beispiel

Wie genau funktioniert jetzt die Klassifikation, z.B. von einem Bild? Wir bleiben bei der binären Klassifikation, d.h. es geht um die Unterscheidung zwischen zwei Klassen $K_0$ und $K_1$.

Sehen wir uns ein Beispiel an, wo die Eingabe $x$ aus zwei Features $x = (x_1, x_2)$ besteht. Diese Features könnten zum Beispiel Pixelwerte sein. Die Ausgabe $y$ ist entweder 0, dann gehört das Beispiel zu Klasse $K_0$, oder 1, dann ist es von Klasse $K_1$.

Mit Hilfe der roten Geraden können wir die Klassen trennen. Man sagt auch, dass die Beispiele linear separierbar sind. Die rote Gerade wird durch die folgende Geradengleichung definiert:

$$ x_2 = 1 + \frac{1}{2} x_1 $$

Für alle Datenpunkte der Klasse $K_0$ (grün) gilt

$$ x_2 > 1 + \frac{1}{2} x_1 $$

Und für Punkte der Klasse $K_1$ (blau) gilt

$$ x_2 < 1 + \frac{1}{2} x_1 $$

Die Ungleichungen können wir umformen:

$$ 0 > 1 + \frac{1}{2} x_1 - x_2 \quad (K_0)$$$$ 0 < 1 + \frac{1}{2} x_1 - x_2 \quad (K_1)$$

Wir wenden jetzt den bekannten Trick an, dass wir $x$ um eine Komponente $x_0$ ergänzen (die immer $=1$ ist):

$$ x := \left( \begin{array}{c} x_0 \\ x_1 \\ x_2 \end{array} \right) = \left( \begin{array}{c} 1 \\ x_1 \\ x_2 \end{array} \right) $$

Wenn wir die Gewichte wie folgt setzen

$$ w := \left( \begin{array}{c} w_0 \\ w_1 \\ w_2 \end{array} \right) = \left( \begin{array}{c} 1 \\ 0.5 \\ -1 \end{array} \right) $$

können wir schreiben:

$$ 0 > w^T x \quad (K_0)$$$$ 0 < w^T x \quad (K_1)$$

Theoretisch könnten wir die Klasse wie folgt bestimmen, wenn wir "hart" zwischen 0 und 1 entscheiden wollten:

$$ \tilde{h}_w(x) := \begin{cases} 0 &\mbox{wenn}\quad w^T x < 0\\ 1 &\mbox{wenn}\quad w^T x > 0 \end{cases} $$

Dieses $\tilde{h}$ wäre eine Heaviside-Funktion (Stufenfunktion), die nicht differenzierbar ist. Stattdessen wählen wir einen "weichen" Übergang um den Nullpunkt, indem wir eine sigmoide Funktion $g$ wählen (z.B. die logistische Funktion):

$$ h_w(x) = g(w^T x) $$

wobei die sigmoide Funktion $g$ bei $x < 0$ schnell gegen 0 und bei $x > 0$ schnell gegen 1 geht. Unser Modell $h$ gibt also so etwas wie eine Wahrscheinlichkeit zurück, dass es sich um Klasse $K_1$ handelt. Bei einem Wert nahe 0 handelt es sich um Klasse $K_0$. Wie bereits angemerkt spricht man hier von Plausibilität oder engl. Likelihood.

1.4 Maximum Likelihood und Cross-Entropy

Das Optimierungskriterium wird hier mit Hilfe von Plausibilität/Likelihood hergestellt. Für jedes einzelne Trainingsbeispiel gibt uns das aktuelle Modell eine Plausibilität $\in [0, 1]$. Um für alle Trainingsbeispiele die Plausibilität zu errechnen, dass wir immer richtig liegen, multiplizieren wir die Plausibilitäten aller Trainingsbeispiele.

Zielfunktion

Unsere Zielfunktion definieren wir mit Pi-Notation und nennen sie vorläufig $\tilde{L}$, da wir später auf eine andere Version umsteigen:

$$ \tilde{L}_w := \prod_{k=1}^N h_w (x^k)^{y^k}\,\left(1 - h_w (x^k)\right)^{(1-y^k)} $$

Hinweis: Das hochgestellte $k$ ist ein Index fürs jeweilige Trainingsbeispiel, die hochgestellten $y$ bzw. $(1-y)$ sind dagegen echte Potenzen.

Wichtig ist, dass diese Likelihood-Funktion $\tilde{L}$ maximiert werden soll (man spricht deshalb auch vom Maximum-Liklihood-Verfahren); im Gegensatz zu der Loss-Funktion bei der linearen Regression, die minimiert werden sollte. Dass heißt, die Plausibilität, dass die richtige Kategorie vom Modell genannt wird, soll möglichst hoch sein. Da jedes $y^k$ entweder 0 oder 1 ist, wird immer einer der Faktoren "neutralisiert".

Ist also das Trainingbeispiel in Kategorie 1 ($y^k = 1$), wird diese Likelihood genommen:

$$ h_w (x^k)^1 \, \left(1 - h_w (x^k)\right)^0 = h_w (x^k) $$

Ist die Kategorie 0 ($y^k = 0$), dann wird die inverse Likelihood genommen:

$$ h_w (x^k)^0 \, \left(1 - h_w (x^k)\right)^1 = 1 - h_w (x^k) $$

Zwei Beispiele:

  • Die echte Klasse für $x^k$ ist 1 und unser Modell $h_w(x^k)$ sagt $0.9$ vorher. Dann geht genau dieser Wert $h_w (x^k) = 0.9$ in unsere Zielfunktion $\tilde{L}$ ein.
  • Die echte Klasse für $x^k$ ist 0, aber unser Modell $h_w(x^k)$ sagt $0.8$ vorher. Dann wird unsere Zielfunktion $\tilde{L}$ mit dem inversen Wert $1 - h_w(x^k) = 0.2$ bedacht.

Cross-Entropy-Funktion

Das Problem beim Aufmultiplizieren ist, dass das Ergebnis schnell sehr klein wird und vom Rechner auf 0 gerundet wird. Daher verwendet man einen Trick: Man wenden den Logarithmus auf die Likelihood-Funktion an und verwandelt so die Multiplikation in eine Addition.

Warum ist das zulässig? Da wir die Zielfunktion verwenden, um zu beurteilen, ob ein Modell besser oder schlechter ist als das andere (d.h. wir nutzen sie nur zum Vergleich), reicht es, wenn unsere Änderung diese Größer-kleiner-Beziehung erhält. Dies ist aber der Fall, da die Logarithmus-Funktion streng monoton wächst.

Wir betrachten also die sogenannte Log-Likelihood-Variante von $\tilde{L}$

$$ \log(\tilde{L}_w) = \sum_{i=k}^N \Big[\, y^k log(h_w(x^k)) + (1-y^k)log(1-h_w(x^k)) \,\Big] $$

Mit $log$ ist der natürliche Logarithmus - mit Basis $e$ - gemeint; in der Schule oft mit $ln$ bezeichnet. Obige Formel nutzt die zwei Logarithmus-Regeln:

$$ \begin{align} log(a \cdot b) & = log(a) + log(b) \\[3mm] log(a^b) & = b \, log(a) \end{align} $$

Wir definieren unsere sogenannte Cross-Entropy Loss Function $L$. Wir möchten sie als Fehler- oder Verlustfunktion definieren, die wir minimieren müssen. Also stellen wir ein Minuszeichen voran. Zusätzlich bilden wir den Durchschnitt über alle Trainingsbeispiele. Dies ist erlaubt, weil auch hier die Größer-kleiner-Beziehung erhalten bleibt:

$$ \label{cross_entropy}\tag{a} L_w := - \frac{1}{N} \sum_{k=1}^N \Big[\, y^k log(h_w(x^k)) + (1-y^k)log(1-h_w(x^k)) \,\Big] $$

Die Gleichung (\ref{cross_entropy}) kann man wieder so deuten, dass man bei jedem Summanden (jedes $k$) eine Fallunterscheidung vornimmt, da $y^i$ entweder 0 oder 1 ist:

$$ L_w := - \frac{1}{N} \sum_{k=1}^N \begin{cases} log(h_w(x^k)) &\mbox{wenn } y^k = 1\\[3mm] log(1-h_w(x^k)) &\mbox{wenn } y^k = 0 \end{cases} $$

Gradient

Auch hier wenden wir Gradientenabstieg an, um das Optimum der Gewichte $w$ für unsere Funktion $h$ zu finden. Die Funktion $h$ berechnet für eine Eingabe $x$ die Wahrscheinlichkeit, dass $x$ von der fraglichen Klasse ist (wir sind noch bei der binären Klassifikation).

$$ \begin{align} h_w (x) & = g(w^Tx) \\[2mm] & = \frac{1}{1+e^{-w^T x}} \end{align} $$

Für die Optimierung müssen wir den Gradienten finden, damit wir unsere Gewichte zyklisch anpassen können.

$$\nabla L = \left( \frac{\partial L}{\partial w_0}, \ldots, \frac{\partial L}{\partial w_D} \right)$$

Diese partiellen Ableitungen leiten wir im nächsten Abschnitt her. Hier schonmal das Resultat: für $j=0, \ldots, D$

$$ \label{ableitung_result}\tag{a} \frac{\partial L}{\partial w_j} = - \frac{1}{N} \sum_{k=1}^N \left(h(x^k) - y^k\right)\, x_j^k $$

Wie schon bei der linearen Regression werden hier alle Trainingsbeispiele durchlaufen (Summenzeichen), um eine Ableitung zu berechnen.

Herleitung des Gradienten

Wir suchen die partiellen Ableitungen:

$$ \label{ableitung_l_gesamt}\tag{b} \frac{\partial L}{\partial w_j} = - \frac{1}{N} \frac{\partial}{\partial w_j} \sum_{k=1}^N \Big[ \, y^k log(h_w(x^k)) + (1-y^k) log(1-h_w(x^k)) \, \Big] $$

Dabei wird das $x$ erst mit den Gewichten multipliziert und dann durch die logistische Funktion $g$ geschickt:

$$ \begin{align} h(x) & = g(w^T x) \\[3mm] g(z) & = \frac{1}{1 + e^{-z}} \end{align} $$

Ableitung der logistischen Funktion

Um (\ref{ableitung_l_gesamt}) zu lösen, benötigen wir später die Ableitung der logistischen Funktion $g$. Wir nehmen das Ergebnis vorweg und rechnen es dann nach:

$$ \label{ableitung_log_f}\tag{c} \frac{d}{dz} g(z) = g(z)\, \left(1- g(z)\right) $$

Um diese Formel herzuleiten, formen wir $g$ leicht um:

$$ g(z) = \frac{1}{1 + e^{-z}} = \frac{e^z}{1 + e^z} $$

Jetzt die Ableitung:

$$ \label{ableitung_log_f_1}\tag{d} \frac{d}{dz} g(z) = \frac{e^z (1 + e^z) - e^z e^z}{(1 + e^z)^2} = \frac{e^z + e^z e^z - e^z e^z}{(1 + e^z)^2} = \frac{1}{1 + e^z} \frac{e^z}{1 + e^z} = \frac{1}{1 + e^z} g(z) $$

Den linken Faktor können wir wie folgt umformen:

$$ \label{ableitung_log_f_2}\tag{e} \frac{1}{1 + e^z} = \frac{1 + e^z - e^z}{1 + e^z} = 1 - \frac{e^z}{1 + e^z} = 1 - g(z) $$

Wir setzen (\ref{ableitung_log_f_2}) in (\ref{ableitung_log_f_1}) ein und kommen auf die Formel (\ref{ableitung_log_f}).

Hauptrechnung

Zurück zur Hauptrechnung: Wir ziehen den Operator für die partielle Ableitung in die Summe hinein:

$$ \begin{align} \frac{\partial L}{\partial w_j} & = - \frac{1}{N} \frac{\partial}{\partial w_j} \sum_{k=1}^N \Big[\, y^k log(h(x^k)) + (1-y^k)log(1-h(x^k)) \, \Big] \\ & = - \frac{1}{N} \sum_{k=1}^N \frac{\partial}{\partial w_j} \Big[\, y^k log(h(x^k)) + (1-y^k)log(1-h(x^k)) \, \Big] \end{align} $$

So können wir den Faktor, die Summe und das $k$ erstmal ignorieren:

$$ \label{ableitung_l}\tag{f} \frac{\partial}{\partial w_j}\Big[\, y\, log(h(x)) + (1-y)\, log(1-h(x)) \,\Big] $$

Wir schauen uns den linken Summanden in (\ref{ableitung_l}) an:

$$ \begin{align} \frac{\partial}{\partial w_j} y\, log(h(x)) & = y \frac{\partial}{\partial w_j} \, log(g(w^T x))\\[2mm] & = y \, \frac{1}{g(w^T x)} \frac{\partial}{\partial w_j} g(w^T x)\\[2mm] & = y \, \frac{1}{g(w^T x)} g(w^T x) (1 - g(w^T x)) \frac{\partial}{\partial w_j} w^T x\\[2mm] & = y \, (1 - g(w^T x))\, x_j\\[2mm] & = y \, (1 - h(x))\, x_j \\[2mm] & = y x_j - h(x) y x_j \end{align} $$

Wir schauen uns den rechten Summanden in (\ref{ableitung_l}) an:

$$ \begin{align} \frac{\partial}{\partial w_j} (1-y)\, log(1-h(x)) & = (1-y)\, \frac{\partial}{\partial w_j} log(1-g(w^T x))\\[2mm] & = (1-y) \, \frac{1}{1-g(w^T x)} \frac{\partial}{\partial w_j} (1 - g(w^T x))\\[2mm] & = (1-y) \, \frac{1}{1-g(w^T x)} (- g(w^T x) (1 - g(w^T x))) \frac{\partial}{\partial w_j} w^T x\\[2mm] & = (y-1) \, g(w^T x)\, x_j\\[2mm] & = (y-1) \, h(x) \, x_j \\[2mm] & = h(x) y x_j - h(x) x_j \end{align} $$

Wir setzen die Summanden in (\ref{ableitung_l}) ein:

$$ \label{ableitung_2}\tag{f} \begin{align} \frac{\partial}{\partial w_j}\Big[\, y\, log(h(x)) + (1-y)\, log(1-h(x)) \,\Big] & = y x_j - h(x) y x_j + h(x) y x_j - h(x) x_j\\[2mm] & = y x_j - h(x) x_j\\[2mm] & = (y - h(x))\, x_j \end{align} $$

Jetzt sind wir fast fertig. Wir setzen noch (\ref{ableitung_2}) in (\ref{ableitung_l_gesamt}) ein und erhalten die partielle Ableitung von $L$:

$$ \begin{align} \frac{\partial L}{\partial w_j} & = - \frac{1}{N} \sum_{k=1}^N \frac{\partial}{\partial w_j} \Big[\, y^k log(h(x^k)) + (1-y^k)log(1-h(x^k)) \,\Big] \\[3mm] &= \frac{1}{N} \sum_{k=1}^N\, \left(h(x^k) - y^k\right)\, x_j^k \end{align} $$

Womit das Resultat (\ref{ableitung_result}) gezeigt wäre.

1.5 Mehrere Klassen: One-vs-All-Methode

Bei der obigen Herleitung handelt es sich um binäre Klassifikation mit einem Ouput $\in [0, 1]$. Wie bekommen wir eine Klassifizierung für mehrere Klassen?

Die Grundidee ist, dass man für jede Klasse $i$ einen eigenen binären Klassifikator $h_w^{(i)}$ erstellt (bei $N$ Klassen $i\in{1,\ldots,N}$). Bei 4 Klassen hätten wir also vier Klassifikatoren $h_w^{(1)}, h_w^{(2)},h_w^{(3)},h_w^{(4)}$. Die Trainingsdaten werden für jeden binären Klassifikator entsprechend präpariert: Für Klassifikator $h_w^{(1)}$ nimmt man alle Trainingsbeispiele der Klasse 1 als Positivbeispiele ($y=1$) und alle anderen als Negativbeispiele ($y=0$). Entsprechend für die anderen drei Klassifikatoren. Im nächsten Abschnitt sehen Sie das anhand eines praktischen Beispiels.

Um eine Vorhersage auf Eingabedaten $x$ zu erhalten, steckt man $x$ in alle Klassifikatoren $h_w^{(i)}$ und wählt die Klasse des Klassifikators mit dem höchsten Ausgabewert.

$$ \underset{i}{\arg \max} \, h_w^{(i)}(x) $$

Diese Methode nennt man auch One-vs-All (OvA) oder One-vs-Rest.

Hinweis: Die Funktion arg max funktioniert wie folgt. Sie bekommt eine Funktion mit einem Parameter und gibt den Parameterwert zurück, mit dem die Funktion maximal wird. Beispiel: Einen Vektor $v = (v_1, \ldots, v_4)$ kann man auch als Funktion über seine Indizes betrachten. In diesem Fall gibt arg max den Index des Vektorelements mit dem höchsten Wert zurück. Als Beispiel sei Vektor $v = (5, 2, 18, -3)$ gegeben; dann ist $\underset{i}{\arg \max} \, v_i = 3$.

1.6 Nichtlineare Entscheidungsgrenzen

In dem obigen Beispiel konnten wir die Trainingsbeispiele durch eine einfache Gerade trennen. Das ist natürlich nicht immer - oder sogar selten - der Fall. Vielleicht ist eine Kurve notwendig oder sogar kreisförmige Grenzen.

Mathematisch können wir die Funktion unseres Modells in zwei Richtungen erweiteren:

$$ h(x) = g(w_0 + w_1 x_1 + w_2 x_2 + w_3 x_1^2 + w_4 x_2^2) $$

Oder:

$$ h(x) = g(w_0 + w_1 x_1 + w_2 x_2 + w_3 x_1^2 + w_4 x_1^2 x_2 + \ldots) $$

Man beachte, dass man diese Art von Funktionen ganz einfach mit der oben gezeigten Methodik verwenden kann. Lediglich die Zahl der Parameter $w_i$ steigt. Die Ableitungen, die ja nach den Gewichten ableiten (nicht nach $x$), bleiben genau so wie oben berechnet.

2 Logistische Regression implementieren

Wie bei der linearen Regression betrachten wir eine Umsetzung "von Scratch" in Python und anschließend die Umsetzung mit Hilfe einer Bibliothek.

2.1 Umsetzung in Python (Iris-Daten) [optional]

Für unsere Python-Implementierung nehmen wir einen Datensatz aus der Scikit-learn-Bibliothek. Wir nutzen den beliebten, sehr kleinen Iris-Datensatz, der aus dem Jahr 1936 stammt. Bei diesem Datensatz geht es um Blumen, genauer gesagt um die Gattung der "Schwertlilien", deren lateinischer Name Iris lautet.

Siehe auch: https://en.wikipedia.org/wiki/Iris_flower_data_set

Datensatz "Iris"

In den Daten geht es um die Unterscheidung von drei Klassen (Pflanzenarten) von Schwertlilien:

  • iris setosa (Klasse 0)
  • iris virginica (Klasse 1)
  • iris versicolor (Klasse 2)

Wir nutzen nur die ersten zwei Klassen und haben somit automatisch ein binäres Klassifikationsproblem (Klassen 0 und 1).

Als Features wurden vier Eigenschaften der Blüte gemessen:

  • sepal length (Feature 0)
  • sepal width (Feature 1)
  • petal length (Feature 2)
  • petal width (Feature 3)

Wir nutzen zur Vereinfachung nur zwei Features (Features 0 und 1).

Dabei ist "sepal" das Kelchblatt und "petal" das Kronblatt. Siehe auch: https://de.wikipedia.org/wiki/Kelchblatt

Daten laden und vorbereiten

In der Sammlung sind 150 Dateneinträge, jeweils 50 pro Klasse. Jeder Eintrag besteht aus einem Inputvektor der Länge 4 und einer Zahl 0, 1, 2 für die Klasse.

Zunächst beziehen wir die Daten:

In [3]:
from sklearn import datasets

iris = datasets.load_iris() # Dictionary

Siehe auch https://scikit-learn.org/stable/auto_examples/datasets/plot_iris_dataset.html

Im Feld data stehen die 150 Feature-Vektoren für die 150 Einträge:

In [4]:
iris.data[:3] # zeige die ersten drei Einträge
Out[4]:
array([[5.1, 3.5, 1.4, 0.2],
       [4.9, 3. , 1.4, 0.2],
       [4.7, 3.2, 1.3, 0.2]])

Im Feld target stehen die korrekten Klassen der 150 Trainingsbeispiele:

In [5]:
iris.target
Out[5]:
array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
       1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
       1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
       2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
       2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2])

Zunächst stellen wir unsere x-Werte her (Featurevektoren). Wir verwenden lediglich zwei Features, nämlich Feature 0 (sepal length) und Feature 1 (sepal width). Außerdem nehmen wir nur die ersten 100 Beispiele (d.h. nur die Klassen 0 und 1).

In [6]:
iris_x = iris.data[:, :2]
iris_x = iris_x[:100] # nur die ersten 100 Elemente
iris_x[:3] 
Out[6]:
array([[5.1, 3.5],
       [4.9, 3. ],
       [4.7, 3.2]])

Jetzt bauen wir unsere y-Werte (Zielwerte). Wir nehmen nur die ersten 100 Trainingsbeispiele, so dass nur die Klassen 0 und 1 auftreten.

In [7]:
iris_y = iris.target[:100]
iris_y
Out[7]:
array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
       1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
       1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1])

Visualisierung

Wir sehen uns die Daten als Scatterplot an. Da wir nur zwei Features betrachten, können wir das als 2D-Plot betrachten.

In [8]:
features = iris_x.T

plt.scatter(features[0][:50], features[1][:50], color='red', marker='o', label='setosa')
plt.scatter(features[0][50:], features[1][50:], color='blue', marker='x', label='versicolor')
plt.xlabel('sepal length')
plt.ylabel('sepal width')
plt.legend(loc='upper left')
plt.show()

Vorüberlegungen zur Implementierung

Im Folgenden nehmen wir uns die unterschiedlichen Konzepte vor (Zielfunktion, Fehlerfunktion, Gradient) und probieren diese mit ganz wenigen Testdaten aus, um sicher zu stellen, dass die Datenformate und Berechnungen korrekt sind.

Die Anzahl der Features $D$ ist $2$.

Zielfunktion

Schauen wir uns zunächst unsere Zielfunktion $h$ an:

$$ \begin{align} h_w (x) & = g(w^Tx) \\[2mm] & = g(w_0 + w_1 x_1 + w_2 x_2) \end{align} $$

Die Funktion $g$ ist die logistische Funktion. Um die kümmern wir uns später. Jetzt bilden wir $w^T x$ in Python ab. Denken Sie bei der obigen Darstellung daran, dass wir bei dem $x$ eine $1$ in die erste Komponente packen müssen. Das heißt, die Featurevektoren $x$ und die Gewichtsvektoren $w$ müssen die Länge $D+1 = 3$ haben.

Wir erzeugen ein paar Testdaten (Variablen beginnen alle mit "t"). Hier haben wir drei Trainingsbeispiele mit je zwei Features ($D=2$). Diese Vektoren sind das $x$ oben in der Gleichung.

In [9]:
tx = np.array([[.3, .24], [.5, .22], [.1, .1]])
print(tx.shape)
tx
(3, 2)
Out[9]:
array([[0.3 , 0.24],
       [0.5 , 0.22],
       [0.1 , 0.1 ]])

Wir möchten die drei Featurevektoren um eine Komponente erweitern ($D+1$). Jeder Vektor soll eine 1 an erster Stelle bekommen. Also generieren wir so viele Einsen wie wir Trainingsbeispiele haben (= 3).

In [10]:
ones = np.ones((tx.shape[0], 1))
print(ones.shape)
ones
(3, 1)
Out[10]:
array([[1.],
       [1.],
       [1.]])

Jetzt erweitern wir alle Trainingsbeispiele auf einmal mit Hilfe von concatenate und erhalten eine 3x3-Matrix.

In [11]:
tx = np.concatenate((ones, tx), axis=1)
print(tx.shape)
tx
(3, 3)
Out[11]:
array([[1.  , 0.3 , 0.24],
       [1.  , 0.5 , 0.22],
       [1.  , 0.1 , 0.1 ]])

Kommen wir zu den Gewichten $w$. Wir benötigen drei Gewichte (zwei für die zwei Features und eines für die Konstante). Wir wählen als Beispiel beliebige Werte:

In [12]:
tw = np.array([0.02, 0, 1.2])
print(tw.shape)
tw
(3,)
Out[12]:
array([0.02, 0.  , 1.2 ])

Kommen wir zur Multiplikation mit den Trainingsbeispielen $w^T x$. Wir nennen das den Rohoutput $z = w^T x$. Der finale Output $h$ wird dann mit der logistischen Funktion berechnet: $h = g(z)$.

In NumPy können wir alle drei Trainingsbeispiele mit einer einzigen Multiplikation verarbeiten. Der Output dieser Operation ist der Rohoutput $z$ für jedes Trainingsbeispiel, also ein Vektor der Länge 3.

In [13]:
print(tx.shape, tw.shape)

tz = np.dot(tx, tw)
tz
(3, 3) (3,)
Out[13]:
array([0.308, 0.284, 0.14 ])

Für den finalen Ouput $h$ benötigen wir die logistische Funktion. Dank NumPy kann man die Funktion auf einen Vektor anwenden, dann wird sie auf jeder Komponente ausgeführt und gibt einen Vektor der gleichen Länge zurück.

Wir führen die logistische Funktion auf allen (drei) Rohoutputs der Trainingsdaten aus.

In [14]:
def sigmoid(z):
    return 1 / (1 + np.exp(-z))

th = sigmoid(tz)
th
Out[14]:
array([0.57639701, 0.5705266 , 0.53494295])

Diese Werte sind die berechneten Werte der Funktion $h$ mit den aktuellen Gewichten in $w$ für die drei Trainingsbeispiele.

Fehlerfunktion

Unsere Fehlerfunktion (loss function) ist die Log-Liklihood-Funktion:

$$ L_w := - \frac{1}{N} \sum_{k=1}^N \Big[\, y^k log(h_w(x^k)) + (1-y^k)log(1-h_w(x^k)) \,\Big] $$

Wir definieren für unsere drei Trainigsbeispiele den jeweils korrekten Zielwert (Klasse/Label).

In [15]:
ty = np.array([1, 1, 0])

Jetzt definieren wir unsere Loss-Funktion. Durch das mean werden alle Komponenten summiert und durch $N$ geteilt.

In [16]:
def loss(h, y):
    return -(y * np.log(h) + (1 - y) * np.log(1 - h)).mean()

loss(th, ty)
Out[16]:
0.6259164219595338

Gradient

Der Gradient ist ein Vektor der Länge $D+1$. Er besteht aus den einzelnen partiellen Ableitungen, die wie folgt aussehen:

$$ \frac{\partial L}{\partial w_j} = \frac{1}{N} \sum_{k=1}^N \left(h(x^k) - y^k\right)\, x_j^k $$

Wir berechnen die Differenz zwischen $h$ (alle berechneten Outputs) und $y$ (alle Zielwerte) und bilden dann das Skalarprodukt mit $x$, welches auch die Summierung durchführt. Alle Ableitungen werden dann noch durch $N$ geteilt (wird in NumPy komponentenweise angewendet):

In [17]:
gradient = np.dot(tx, (th - ty)) / ty.size
gradient
Out[17]:
array([-0.1413529 , -0.17355075, -0.13768535])

Komplette Klasse

Ein Verfahren wie logistische Regression wird häufig in einer Klasse gekapselt. Dort werden Hyperparameter wie Lernrate (alpha) und Anzahl der Epochen fürs Training bereits beim Instanziieren hinterlegt.

In [18]:
class LogisticRegression:
    def __init__(self, alpha=0.01, epochs=1000):
        self.alpha = alpha
        self.epochs = epochs        
    
    def sigmoid(self, z):
        return 1 / (1 + np.exp(-z))
    
    def loss(self, h, y):
        return -(y * np.log(h) + (1 - y) * np.log(1 - h)).mean()
    
    def fit(self, x, y):
        # Einsen an die x-Vektoren hängen
        ones = np.ones((x.shape[0], 1))
        x = np.concatenate((ones, x), axis=1)
        
        # Gewichte erstellen (=0)
        self.w = np.zeros(x.shape[1])
        
        # Training
        for i in range(self.epochs):
            z = np.dot(x, self.w)
            h = self.sigmoid(z)
            gradient = np.dot(x.T, (h - y)) / y.size
            self.w -= self.alpha * gradient # Update
            
            # Output
            if(i % 500 == 0):
                z = np.dot(x, self.w)
                h = self.sigmoid(z)
                print(f'Epoch {i}: Loss = {self.loss(h, y)} \t')
    
    def p(self, x):
        ones = np.ones((x.shape[0], 1))
        x = np.concatenate((ones, x), axis=1)
        return self.sigmoid(np.dot(x, self.w))
    
    def predict(self, x, threshold=0.5):
        return self.p(x) >= threshold

Wir erzeugen eine Instanz und trainieren das Objekt auf den vorbereiten Iris-Daten.

In [19]:
model = LogisticRegression(alpha=0.1, epochs=20000)
model.fit(iris_x, iris_y)
Epoch 0: Loss = 0.685802727822415 	
Epoch 500: Loss = 0.14350772307926513 	
Epoch 1000: Loss = 0.09989369821855715 	
Epoch 1500: Loss = 0.08292331668333791 	
Epoch 2000: Loss = 0.07365438313023151 	
Epoch 2500: Loss = 0.06772283851752295 	
Epoch 3000: Loss = 0.06355494554767765 	
Epoch 3500: Loss = 0.06043782562823827 	
Epoch 4000: Loss = 0.0579994766792833 	
Epoch 4500: Loss = 0.05602596346192948 	
Epoch 5000: Loss = 0.05438518040242759 	
Epoch 5500: Loss = 0.05299098651395044 	
Epoch 6000: Loss = 0.05178474071458257 	
Epoch 6500: Loss = 0.05072510345575388 	
Epoch 7000: Loss = 0.04978207702927587 	
Epoch 7500: Loss = 0.04893335758745598 	
Epoch 8000: Loss = 0.048162013817947755 	
Epoch 8500: Loss = 0.04745496027650863 	
Epoch 9000: Loss = 0.04680192442815358 	
Epoch 9500: Loss = 0.04619473025595247 	
Epoch 10000: Loss = 0.0456267905229231 	
Epoch 10500: Loss = 0.04509273993624603 	
Epoch 11000: Loss = 0.044588165533035934 	
Epoch 11500: Loss = 0.04410940545172221 	
Epoch 12000: Loss = 0.04365339664553678 	
Epoch 12500: Loss = 0.04321755817522391 	
Epoch 13000: Loss = 0.04279970073701032 	
Epoch 13500: Loss = 0.04239795578852334 	
Epoch 14000: Loss = 0.04201071948969199 	
Epoch 14500: Loss = 0.041636607966167916 	
Epoch 15000: Loss = 0.041274421313932434 	
Epoch 15500: Loss = 0.04092311441564364 	
Epoch 16000: Loss = 0.04058177311144981 	
Epoch 16500: Loss = 0.04024959461291656 	
Epoch 17000: Loss = 0.03992587130484911 	
Epoch 17500: Loss = 0.03960997727132699 	
Epoch 18000: Loss = 0.03930135702682916 	
Epoch 18500: Loss = 0.03899951604338602 	
Epoch 19000: Loss = 0.03870401274916858 	
Epoch 19500: Loss = 0.03841445173926072 	

Mit der Funktion predict können wir uns diskrete Ausgabewerte zurückgeben lassen und diese mit den korrekten Klassen vergleichen.

In [20]:
preds = model.predict(iris_x)

(preds == iris_y).mean()
Out[20]:
0.99

Die Parameter sind über das Objekt zugreifbar.

In [21]:
model.w
Out[21]:
array([ -5.68144513,   6.99489916, -10.40887421])

Visualisierung

Wie können wir die Regressionsgerade visualisieren? In unserem Scatterplot war Feature $x_1$ auf der x-Achse und Feature $x_2$ auf der y-Achse. Das entscheidende Kriterium ist

$$ 0 = w_0 + w_1 x_1 + w_2 x_2 $$

Jetzt formen wir das so um, dass wir eine Geradengleichung haben:

$$ x_2 = - \frac{w_0}{w_2} - \frac{w_1}{w_2} x_1 $$

Wir bauen uns für diese Funktion eine Python-Funktion und berechnen den linken und rechten Randwert der Geraden (für $x=4$ und $x=7$).

In [22]:
def reg(x):
    return - model.w[0]/model.w[2] - model.w[1]/model.w[2] * x

print(reg(4), reg(7))
2.142225092995891 4.158264201898953

Jetzt sehen wir auch den einen Ausreißer, der für die 99% (statt 100%) verantwortlich ist.

In [23]:
plt.scatter(features[0][:50], features[1][:50], color='red', marker='o', label='setosa')
plt.scatter(features[0][50:], features[1][50:], color='blue', marker='x', label='versicolor')
plt.plot([4, 7], [reg(4), reg(7)], 'r-') 
plt.xlabel('sepal length')
plt.ylabel('petal length')
plt.legend(loc='upper left')
plt.show()

2.2 Umsetzung mit Scikit-learn (MNIST-Daten)

Jetzt betrachten wir die Umsetzung mit Hilfe der Bibliothek Scikit-learn.

MNIST-Daten

Bei dem Datensatz MNIST geht es um die Klassifikation von handgeschriebenen Ziffern. Das heißt, anhand eines Bildes einer Ziffer soll ein Klassifizierer eine der Klassen 0, 1, ... , 9 ausgeben.

In Scikit-learn ist dieser Datensatz enthalten und kann ganz einfach geladen werden:

In [24]:
from sklearn.datasets import fetch_openml

mnist = fetch_openml('mnist_784', version=1)
mnist.keys()
Out[24]:
dict_keys(['data', 'target', 'frame', 'feature_names', 'target_names', 'DESCR', 'details', 'categories', 'url'])

Die Bilder haben eine Auflösung von 28x28, sind aber "flach" (engl. flattened) repräsentiert:

In [25]:
28 * 28
Out[25]:
784

Es sind 70000 Bilder mit je 784 Pixeln.

In [26]:
x = mnist["data"]
x.shape
Out[26]:
(70000, 784)

Wir schauen uns ein paar Pixel an (erstes Sample, Pixel 400-500) und sehen, dass wir uns im Bereich 0...255 bewegen.

In [27]:
x[0][400:500]
Out[27]:
array([  0.,   0.,   0.,   0.,   0.,  81., 240., 253., 253., 119.,  25.,
         0.,   0.,   0.,   0.,   0.,   0.,   0.,   0.,   0.,   0.,   0.,
         0.,   0.,   0.,   0.,   0.,   0.,   0.,   0.,   0.,   0.,   0.,
         0.,  45., 186., 253., 253., 150.,  27.,   0.,   0.,   0.,   0.,
         0.,   0.,   0.,   0.,   0.,   0.,   0.,   0.,   0.,   0.,   0.,
         0.,   0.,   0.,   0.,   0.,   0.,   0.,   0.,  16.,  93., 252.,
       253., 187.,   0.,   0.,   0.,   0.,   0.,   0.,   0.,   0.,   0.,
         0.,   0.,   0.,   0.,   0.,   0.,   0.,   0.,   0.,   0.,   0.,
         0.,   0.,   0.,   0.,   0., 249., 253., 249.,  64.,   0.,   0.,
         0.])

Und die jeweils korrekte Ausgabe (= Ziffer).

In [28]:
y = mnist["target"]
y.shape
Out[28]:
(70000,)

Wir plotten das erste Sample:

In [29]:
%matplotlib inline

digit = x[0]
image = digit.reshape(28, 28)
plt.imshow(image, cmap=matplotlib.cm.binary)
plt.axis("off")

plt.show()

Die dazugehörige korrekte Ausgabe. Wir sehen, dass die Werte Characters sind.

In [30]:
y[0]
Out[30]:
'5'

Wir wollen die Zielwerte als Integer-Zahlen haben:

In [31]:
y = y.astype(np.uint8)
y[:20]
Out[31]:
array([5, 0, 4, 1, 9, 2, 1, 3, 1, 4, 3, 5, 3, 6, 1, 7, 2, 8, 6, 9],
      dtype=uint8)

Wir skalieren die Pixelwerte auf [0, 1] und kontrollieren das:

In [32]:
x = x/255
x[0][400:500]
Out[32]:
array([0.        , 0.        , 0.        , 0.        , 0.        ,
       0.31764706, 0.94117647, 0.99215686, 0.99215686, 0.46666667,
       0.09803922, 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.17647059,
       0.72941176, 0.99215686, 0.99215686, 0.58823529, 0.10588235,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.0627451 , 0.36470588,
       0.98823529, 0.99215686, 0.73333333, 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.        , 0.        ,
       0.        , 0.        , 0.        , 0.97647059, 0.99215686,
       0.97647059, 0.25098039, 0.        , 0.        , 0.        ])

Wir nehmen 60000 Samples für das Training und 10000 für den Test.

In [33]:
x_train, x_test, y_train, y_test = x[:60000], x[60000:], y[:60000], y[60000:]

Binäre Klassifikation

Wir nehmen uns die Ziffer '5' heraus, um einen binären Klassifizierer zu bauen. Dieser unterscheidet also zwischen '5' und 'nicht 5'.

Dazu müssen wir unseren y-Array ändern. Dort möchten wir nur True (= ist 5) und False (= nicht 5) haben. Wir können das in Python sehr schön durch Boolesche Maskierung erreichen:

In [34]:
y_train5 = (y_train == 5)
y_test5 = (y_test == 5)

y_train5[:20] # Testausgabe
Out[34]:
array([ True, False, False, False, False, False, False, False, False,
       False, False,  True, False, False, False, False, False, False,
       False, False])

Modell

Wir erstellen als Modell ein Objekt vom Typ LogisticRegression.

In [35]:
from sklearn.linear_model import LogisticRegression

modelLR = LogisticRegression()

Training

Das Trainieren von Modellen wird oft mit dem Verb fit (engl. für "anpassen") beschrieben. Deshalb heißt die Methode so:

In [36]:
modelLR.fit(x_train, y_train5)
Out[36]:
LogisticRegression(C=1.0, class_weight=None, dual=False, fit_intercept=True,
                   intercept_scaling=1, l1_ratio=None, max_iter=100,
                   multi_class='auto', n_jobs=None, penalty='l2',
                   random_state=None, solver='lbfgs', tol=0.0001, verbose=0,
                   warm_start=False)

Vorhersagen

Wir lassen uns jetzt für unsere gesamten Testdaten Vorhersagen für die spätere Evaluation errechnen.

In [37]:
y_pred5 = modelLR.predict(x_test)
y_pred5
Out[37]:
array([False, False, False, ..., False,  True, False])

Mehrklassen-Klassifikation

Jetzt möchten wir einen Klassifizierer erstellen, der für alle 10 Klassen Vorhersagen treffen kann.

Modell

Zunächst erzeugen wir eine neue Instanz von LogisticRegression:

In [38]:
modelMLR = LogisticRegression()

Der einzige Unterschied zu vorher ist, dass wir die originalen y-Daten für die korrekten Klassen benutzen, also Zahlen von 0, ..., 9 anstatt der binären Werte {True, False}.

Training

Jetzt trainieren wir das Modell:

In [39]:
modelMLR.fit(x_train, y_train)
Out[39]:
LogisticRegression(C=1.0, class_weight=None, dual=False, fit_intercept=True,
                   intercept_scaling=1, l1_ratio=None, max_iter=100,
                   multi_class='auto', n_jobs=None, penalty='l2',
                   random_state=None, solver='lbfgs', tol=0.0001, verbose=0,
                   warm_start=False)

Vorhersagen

Für die spätere Evaluation treffen wir wieder Vorhersagen auf allen Testbeispielen:

In [40]:
y_pred = modelMLR.predict(x_test)
y_pred
Out[40]:
array([7, 2, 1, ..., 4, 5, 6], dtype=uint8)

3 Evaluation von Modellen

Wie bewerten wir die Güte eines trainierten Modells? Bei der linearen Regression haben wir zum Beispiel den mittleren quadratischen Fehler (MSE) als Maß. Bei Klassifikationsproblemen schauen wir uns an, wie oft eine Klasse richtig errechnet wird.

3.1 Binäre Klassfikation

Bei der binären Klassifikation erstellen wir ein Modell, dass eine Klasse K errechnen können soll. Das heißt, die Ausgabe ist K oder nicht K. Eine solche Klasse kann zum Beispiel "Katzenbild" sein und entsprechend "kein Katzenbild". Ein aktuelles Beispiel wäre Corona, wo wir unterscheiden zwischen "hat Corona" und "hat kein Corona".

Schöner Artikel dazu auf Wikipedia: https://de.wikipedia.org/wiki/Beurteilung_eines_bin%C3%A4ren_Klassifikators

Zufälligerweise hat das ZDF auch Video mit dem Beispiel "Corona-Schnelltest" produziert: Wie zuverlässig sind Corona-Schnelltests?. Hier könnte man mal überlegen, mit welchen der hier vorgestellten Maße die im Video gezeigten Maße übereinstimmen.

Positives und Negatives

Alle Trainingsbeispiele, die von Typ K sind, nennen wir Positives, alle anderen Negatives. Im Corona-Beispiel teilen wir unsere Population in Positives und Negatives ein:

Wenn wir alle Trainingsbeispiele durch unser Modell schicken, erhalten wir jedes Mal eine errechnete Klasse (engl. predicted class) und kennen natürlich auch die echte Klasse (engl. actual class).

Im Corona-Beispiel können wir uns einen Corona-Schnelltest als Modell vorstellen. Die Population besteht jetzt aus allen per Schnelltest getesteten Personen (zum Beispiel für eine Teststation in einem bestimmten Zeitraum mit einem Test pro Person).

Der Schnelltest definiert eine Treffermenge (Kreis). Alle Personen in der Treffermenge sind gemäß des Tests positiv.

(Die grafische Darstellung ist von den Wikipedia-Eintrag inspiriert: https://en.wikipedia.org/wiki/Precision_and_recall)

True Positives und False Positives

Jetzt unterscheiden wir vier Fälle. Zunächst schauen wir uns die Treffermenge an (die, die positiv getestet wurden). Hier unterscheiden wir zwischen Tests, die korrekterweise positiv sind, da die Personen Corona haben (True Positives) und solchen Tests, die fälschlicherweise behaupten, die Person sei positiv, obwohl sie kein Corona hat (False Positives).

  • True Positives (TP): Anzahl der Samples, die korrekterweise K zugeordnet wurden
  • False Positives (FP): Anzahl der Samples, die als K erkannt wurden, obwohl sie nicht zu K gehören

Die False Positives sind also "falscher Alarm" und führen beim Beispiel Corona dazu, dass (unnötigerweise) Quarantäneregelungen greifen.

True Negatives und False Negatives

Jetzt schauen wir uns die Tests an, die außerhalb der Treffermenge sind. Auch hier haben wir Tests, die korrekterweise außerhalb der Treffermenge sind, weil die Personen kein Corona haben (True Negatives), und solche, die fälschlicherweise negativ sind, da die Personen doch Corona haben (False Negatives).

  • True Negatives (TN): Anzahl der Samples, die korrekterweise nicht K zugeordnet wurden
  • False Negatives (FN): Anzahl der Samples, die als nicht K erkannt wurden, obwohl sie zu K gehören

Die False Negatives sind also diejenigen, die wir eigentlich suchen, aber die uns "durch die Lappen" gegangen sind. Im Fall von Corona heißt das: Diese Personen denken, sie seien nicht ansteckend, sind es aber doch!

Konfusionsmatrix

In einer Konfusionsmatrix (engl. confusion matrix) zählen wir bei jedem Trainingsbeispiel mit, von welcher Klasse (K oder nicht K) es ist und welche Klasse (K oder nicht K) das Modell errechnet hat. Jede Zelle der Konfusionsmatrix entspricht einem der vier oben genannten Fälle:

Die obere Zeile (d.h. Summe der zwei Zellen) entspricht allen Negatives, die untere Zeile allen Positives.

Auf der Diagonalen stehen alle korrekten Vorhersagen (TP + TN). Die anderen beiden Zellen sind die Fehlklassifikationen (FN + FP). Die Summe aller Zellen ist die Anzahl aller Trainingsbeispiele (TP + TN + FP + FN).

Hier ein Beispiel mit echten Zahlen. Jetzt nehmen wir ein Beispiel, wo wir Mails in SPAM und nicht-SPAM klassifizieren.

nicht SPAM (predicted) SPAM (predicted)
nicht SPAM (actual) 556 12
SPAM (actual) 4 28

Es handelt sich um 600 Trainingsbeispiele (Summe aller Zellen). Davon wurden 584 korrekt klassifiziert (Diagonale). Es gab 32 Samples der Klasse SPAM (untere Zeile). Das Modell hat aber 40 Samples als SPAM klassifiziert (rechte Spalte).

Recall

Die Metrik Recall misst, wie viele von den möglichen Treffern wir "erwischt" haben. Andere Bezeichnungen für Recall sind: Sensitivität, True-Positive-Rate oder Trefferquote/Hit rate.

In anderen Worten: Wie viele Positives wir korrekt vorhergesagt haben (TP), relativ zu allen tatsächlichen Positives (TP + FN).

Rechnerisch heißt das:

$$ \mbox{recall} = \frac{TP}{TP + FN} $$

Im Beispiel kommen wir auf einen Recall von 87.5%:

$$ \mbox{recall} = \frac{28}{28 + 4} = 0.875$$

Für das Corona-Beispiel heißt das: Recall misst, wie viele wir von den tatsächlich mit Corona infizierten wir auch gefunden haben.

Precision

Die Metrik Precision misst, wie viele von den Vorhersagen, die wir getroffen haben (TP + FP), wirklich korrekt waren (TP). Ein anderer Begriff dafür ist Genauigkeit.

$$ \mbox{precision} = \frac{TP}{TP + FP} $$

Im Beispiel kommen wir auf eine Precision von 70%:

$$ \mbox{precision} = \frac{28}{28 + 12} = 0.7$$

Für das Corona-Beispiel heißt das: Precision gibt die Wahrscheinlichkeit wider, dass ein positiver Schnelltest auch wirklich stimmt.

Siehe auch: https://developers.google.com/machine-learning/crash-course/classification/precision-and-recall

Bedeutung von Recall und Precision

Sie sollten sich anhand von Beispielszenarien klar machen, dass Recall und Precision sehr unterschiedliche Wichtigkeiten haben.

Hoher Recall: Beim Beispiel Corona möchte man natürlich alle Infizierten auch positiv testen, also einen hohen Recall erzielen. Wie bekommen Sie einen hohen Recall? Ganz einfach: Ihr Schnelltest (Modell) schlägt immer positiv aus. Das ergäbe 100% Recall. Aber natürlich auch extrem viele False Positives. Ein fälschlich positiver Coronatest hat unangenehme Folgen (14 Tage Quarantäne oder ein PCR-Test mit Quarantäne, bis das Ergebnis da ist). Hier ist es sinnvoll, mit der Precision zu messen, wie zuverlässig ein Test ist.

Hohe Precision: Wenn Sie Ihren Schnelltest sehr vorsichtig gestalten und sehr selten positiv ausschlagen, wenn Sie absolut sicher sind, dass der Test positiv ist, dann erzielen Sie vielleicht eine hohe Precision. Aber oft entgehen Ihnen dann sehr viele der tatsächlichen Positives und der Recall ist niedrig.

Überlegen Sie sich für andere Szenarien (defekte Bauteile in der Produktion detektieren, gefährliche Situationen per Überwachungskameras identifizieren etc.), welche Bedeutung Recall und Precision haben.

Accuracy

Die Metrik Accuracy betrachtet die Gesamtmenge der richtigen Vorhersagen (Diagonale der Konfusionsmatrix) relativ zu der Gesamtzahl der Samples (Summe aller Felder der Matrix). Andere Begriffe sind: Korrektklassifikationsrate oder Treffergenauigkeit.

Rechnerisch heißt das:

$$ \mbox{accuracy} = \frac{\mbox{korrekte Vorhersagen}}{\mbox{Anzahl aller Beispiele}} $$

Wenn wir das mit Hilfe unserer Begrifflichkeiten ausdrücken wollen, ist das:

$$ \mbox{accuracy} = \frac{TP + TN}{TP + TN + FP + FN} $$

Im Beispiel kommen wir auf eine Accuracy von 97.3%:

$$ \mbox{accuracy} = \frac{28 + 556}{28 + 556 + 12 + 4} = 0.973$$

Eine Besonderheit von Accuracy ist, dass auch Negatives stark in den Wert eingehen. Umgekehrt ist das nicht immer erwünscht, denn in der Regel gibt es deutlich mehr Negatives als Positives, so dass das Ergebnis von Accuracy weniger aussagekräftig ist. Daher ist bei binärer Klassifikation immer zu überlegen, ob nicht Precision und Recall geeignetere Maße sind.

Ein Vorteil von Accuracy ist, dass sie bei der Mehrklassen-Klassifikation relativ leicht umsetzbar ist (im Gegensatz zu Precision und Recall), wie wir später noch sehen werden.

Siehe auch: https://developers.google.com/machine-learning/crash-course/classification/accuracy

Evaluation des binären MNIST-Klassifizierers

Wir greifen unseren binären Klassifizierer modelLR (5 versus nicht-5) von oben auf. Wir hatten auf den Testdaten die Vorhersagen berechnet und lassen uns jetzt die Konfusionsmatrix berechnen. Dazu übergeben wir die echten y-Werte und die errechneten y-Werte.

Siehe: https://scikit-learn.org/stable/modules/generated/sklearn.metrics.confusion_matrix.html

In [41]:
from sklearn import metrics

cm = metrics.confusion_matrix(y_test5, y_pred5)
cm
Out[41]:
array([[9040,   68],
       [ 151,  741]])

Wir visualisieren die Konfusionsmatrix mit Hilfe der Library Seaborn.

Siehe: https://seaborn.pydata.org/generated/seaborn.heatmap.html

In [42]:
sns.heatmap(pd.DataFrame(cm), annot=True, cmap="YlGnBu", fmt='d')
plt.tight_layout()
plt.title('Verwechslungsmatrix', y=1.1)
plt.ylabel('Korrektes Label')
plt.xlabel('Vorhergesagtes Label')
Out[42]:
Text(0.5, 15.0, 'Vorhergesagtes Label')

Precision, Recall und Accuracy

Anhand der Werte in der Matrix setzen wir TN, TP, FP und FN.

In [43]:
TN = 9040.0
TP = 741.0
FP = 68.0
FN = 151.0

Mit diesen Werten können wir Precision, Recall und Accuracy anhand der Formeln berechnen.

In [44]:
print("Precision: ", TP / (TP + FP))
Precision:  0.9159456118665018
In [45]:
print("Recall: ", TP / (TP + FN))
Recall:  0.8307174887892377
In [46]:
print("Accuracy: ", (TP+TN)/(TP+TN+FP+FN))
Accuracy:  0.9781
In [47]:
print("Precision:", metrics.precision_score(y_test5, y_pred5))
print("Recall:", metrics.recall_score(y_test5, y_pred5))
print("Accuracy:", metrics.accuracy_score(y_test5, y_pred5))
Precision: 0.9159456118665018
Recall: 0.8307174887892377
Accuracy: 0.9781

ROC und AUC [optional]

Modelle kann man oft mit Hyperparametern sensibler oder laxer einstellen. In der logistischen Regression benutzt man z.B. den Schwellwert $0,5$, um zu entscheiden, ab welchem Outputwert die Klasse 1 gewählt wird (sonst: Klasse 0). Setzt man diesen auf $0,9$, wird man weniger Treffer haben, aber vielleicht eine höhere Präzision. Nun kann man sich zwei Metriken (TPR, FPR) für alle möglichen Schwellwerte ansehen.

Recall nennt man auch die True Positive Rate (TPR). TPR setzt die korrekte Treffermenge ins Verhältnis zu allen Positives:

$$ \mbox{TPR} = \frac{TP}{TP + FN} $$

Analog kann man die False Positive Rate (FPR) definieren. FPR setzt die falsch klassifizierten Treffer (False Positives), die also eigentlich Negatives sind, ins Verhältnis zu allen Negatives:

$$ \mbox{FPR} = \frac{FP}{FP + TN} $$

Wenn wir unser Modell mit vielen unterschiedlichen Schwellwerten testen und für jeden Test FPR auf der x-Achse abtragen und TPR auf der y-Achse, erhalten wir die ROC-Kurve (receiver operating characteristic).

Je "bauchiger" die Kurve, umso besser die Performance des Modells, wohingegen eine Diagonale auf einen Zufallsprozess hindeutet. Dies wiederum messen wir mit dem Flächeninhalt AOC (area under the ROC curve).

Beides lässt sich in Scikit-learn berechnen.

In [48]:
y_pred5_prob = modelLR.predict_proba(x_test)[::,1]
fpr, tpr, _ = metrics.roc_curve(y_test5,  y_pred5_prob)
auc = metrics.roc_auc_score(y_test5, y_pred5_prob)

plt.plot(fpr, tpr, label=f"auc = {auc:.3f}")
plt.ylabel("True Positive Rate (TPR)")
plt.xlabel("False Positive Rate (FPR)")
plt.legend(loc=4)
plt.show()

3.2 Mehrklassen-Klassifikation

Bei mehreren Klassen können wir auch eine Konfusionsmatrix aufstellen.

Hier ein Beispiel mit drei Klassen A, B, C:

Auch hier finden wir auf der Diagonalen alle korrekten Vorhersagen. In den anderen Zellen können wir genau ablesen, welche Klasse mit welcher verwechselt wurde.

Accuracy

Bei der Mehrklassen-Klassifikation kann man nicht mehr von True Positives, False Positives etc. sprechen, denn es gibt nicht mehr die binäre Unterteilung in zwei Klassen (Positives, Negatives). Für die Berechnung der Accuracy ist das aber nicht weiter schlimm. Mit Hilfe der Konfusionsmatrix kann Accuracy direkt berechnet werden:

$$ \mbox{accuracy} = \frac{\mbox{Summe aller Diagonalzellen}}{\mbox{Summe aller Zellen}} $$

Denn das entspricht der Definition

$$ \mbox{accuracy} = \frac{\mbox{korrekte Vorhersagen}}{\mbox{Anzahl aller Beispiele}} $$

Natürlich benötigt man nicht unbedingt die Konfusionsmatrix zur Berechnung. Man kann einfach den entsprechenden Datensatz durchlaufen, die korrekten Vorhersagen zählen und dies am Schluss durch die Größe des Datensatzes teilen.

Da es im Machine Learning oft um Mehrklassen-Klassifikation geht, trifft man dort Accuracy am häufigsten als Maß an, da es relativ leicht zu berechnen ist.

Recall und Precision

Die Metriken Precision und Recall lassen sich nicht ohne weiteres von binärer Klassifikation auf mehrere Klassen übertragen. Stattdessen berechnet man für jede Klasse das entsprechende Maß, als wäre es ein binäres Problem (Klasse vs. Rest) und bildet anschließend den Mittelwert über alle Klassen.

Zum Beispiel kann man für Kategorie A die Precision $P_A$ für Klasse A versus nicht-A berechnen. Das kann man sich anhand der Konfusionsmatrix verdeutlichen, wo wir die Zellen für B und C zu "nicht A" zusammenfassen. Dann haben wir wieder die Werte für TP, FP, TN und FN und können Precision aus der Sicht von Klasse A berechnen.

Dasgleiche können wir für B und C tun, um $P_B$ und $P_C$ zu berechnen. Die gesamte Precision ist dann der Mittelwert:

$$P = \frac{P_A + P_B + P_C}{3}$$

Jetzt kann man auf die Idee kommen, dass eine Klasse, die häufiger vertreten ist, auch mit mehr Gewicht in das Gesamtmaß eingebracht werden soll. Nehmen wir an die Klassen A, B, C sind mit je 50%, 30%, 20% in den Samples vertreten.

Dann wäre ein gewichtetes Mittel wie folgt:

$$P_{weighted} = 0.5 \,P_A + 0.3\,P_B + 0.2\,P_C$$

Evaluation des MNIST-Mehrklassen-Klassifizierers

Wir greifen unser Mehrklassen-Modell von oben wieder auf. Wir hatten bereits die errechneten Vorhersagen in einer Variablen abgelegt.

Zunächst berechnen wir die Konfusionsmatrix:

In [49]:
cm2 = metrics.confusion_matrix(y_test, y_pred)
cm2
Out[49]:
array([[ 959,    0,    0,    3,    1,    7,    5,    4,    1,    0],
       [   0, 1111,    4,    2,    0,    2,    3,    2,   11,    0],
       [   6,    9,  926,   16,    9,    4,   13,    6,   39,    4],
       [   4,    1,   18,  917,    1,   22,    4,   11,   25,    7],
       [   1,    1,    7,    3,  914,    0,   10,    4,   10,   32],
       [  10,    2,    3,   34,    7,  783,   14,    6,   29,    4],
       [   9,    3,    8,    2,    7,   14,  912,    2,    1,    0],
       [   1,    8,   24,    5,    7,    1,    0,  950,    3,   29],
       [   9,   11,    8,   23,    7,   25,   12,    7,  861,   11],
       [   9,    8,    0,   11,   24,    6,    0,   19,    7,  925]])

Wir visualisieren die Matrix mit Seaborn.

In [50]:
sns.heatmap(pd.DataFrame(cm2), annot=True, cmap="YlGnBu", fmt='d')
plt.tight_layout()
plt.title('Verwechslungsmatrix', y=1.1)
plt.ylabel('Korrektes Label')
plt.xlabel('Vorhergesagtes Label')
Out[50]:
Text(0.5, 15.0, 'Vorhergesagtes Label')

Jetzt berechnen wir die Accuracy über alle Klassen. In der einfachsten Variante wird der Mittelwert über alle Accuracywerte der Klassen gebildet.

Siehe: https://scikit-learn.org/stable/modules/generated/sklearn.metrics.accuracy_score.html#sklearn.metrics.accuracy_score

In [51]:
print("Accuracy: {:.4f}".format(metrics.accuracy_score(y_test, y_pred)))
Accuracy: 0.9258

Precision und Recall werden hier für jede Kategorie einzeln berechnet (z.B. 4 vs nicht-4). Anschließend werden die 10 Werte (in unserem Beispiel) gemittelt.

In [52]:
print("Precision: {:.4f}".format(metrics.precision_score(y_test, y_pred, average='macro')))
print("Recall: {:.4f}".format(metrics.recall_score(y_test, y_pred, average='macro')))
Precision: 0.9250
Recall: 0.9248

Mit der Option average='weighted' wird der Mittelwert gewichtet, je nachdem wie hoch der Anteil der jeweiligen Kategorie ist (s.o.):

In [53]:
print("Precision: {:.4f}".format(metrics.precision_score(y_test, y_pred, average='weighted')))
print("Recall: {:.4f}".format(metrics.recall_score(y_test, y_pred, average='weighted')))
Precision: 0.9257
Recall: 0.9258

Bei den MNIST-Daten ist jede Klasse gleich oft vertreten, daher sind Mittelwert und gewichteter Mittelwert gleich (Rundungsabweichungen können auftreten). Wenn man eine sehr ungleiche Verteilung hat, sollte man aber das gewichtete Mittel in Betracht ziehen.

4 Trainings- vs. Testdaten

Beim überwachten Lernen hat man Trainingsdaten $(x_k, y_k)$ mit der jeweils korrekten Ausgabe. Hat man ein Modell trainiert, darf man die Performance des Modells offensichtlich nicht auf den gleichen Daten testen, mit denen man das Modell trainiert hat. Daher teilt man die Daten vor dem Training in zwei getrennte und disjunkte Datensätze (i.d.R. nachdem man die Daten vorher durchgemischt hat):

  • Trainingsdaten
  • Testdaten

Testdaten nennt man auch Holdout-Daten, da man sie dem Modell im Training vorenthält.

Die Aufteilung ist z.B. 80% Trainingsdaten und 20% Testdaten. Natürlich möchte man soviele Trainingsdaten wie möglich für ein möglichst optimales Modell verwenden. Umgekehrt muss man ausreichend Daten für einen aussagekräftigen Test haben. Bei sehr großen Datensätzen mit Millionen von Datenpunkten, kann auch eine Aufteilung 95% zu 5% sinnvoll sein.

Die Testdaten sind immer die Daten, mit denen man letztendlich die Performanz des Modells misst und publiziert.

4.1 Validierungsdaten

In vielen Kontexten wird oft ein dritter Datensatz hinzugenommen, die Validierungsdaten. Diese werden benutzt, wenn man Hyperparameter wie die Lernrate oder die Trainingsdauer optimieren möchte.

Statt die Daten in Trainings- und Testdaten zu teilen, nehmen wir eine Dreiteilung vor:

  • Trainingsdaten (70%)
  • Validierungsdaten (10%)
  • Testdaten (20%)

Die Prozentangaben sind nur Beispiele.

Jetzt nehmen wir an, wir möchten die Trainingsdauer festlegen. Wir wissen, dass ein zu langes Trainings (d.h. zu viele Epochen) zu Overfitting führen kann. Wie finden wir also die optimale Trainingsdauer? Beim Training, wo wir nur die Trainingsdaten für die Updates der Parameter (Gewichte) verwenden, messen wir nach jeder Epoche die Accuracy auf den Validierungsdaten (nicht auf den Testdaten). Das könnte so aussehen:

Sobald die Accuracy auf den Validierungsdaten stagniert oder sinkt, stoppen wir das Training, da wir annehmen, dass zu diesem Zeitpunkt die Generalisierungsfähigkeit des Netzes abnimmt. Man nennt diese Technik auch Early Stopping. In der Abbildung ist das Zeitpunkt $t_{stop}$. Es kann durchaus sein, dass bei einer anderen Wahl von Validierungsdaten dieser Zeitpunkt etwas anders ausfallen würde. Und genau das ist der Grund, warum wir $t_{stop}$ nicht anhand der "echten" Testdaten ermitteln dürfen. Denn letztlich ist auch $t_{stop}$ ein Parameter - genauer gesagt, ein Hyperparameter - und Daten, die man zum Tunen von Parametern und Hyperparametern verwendet, gelten als vom Modell "gesehen".

Ist das Modell also derart trainiert mit der vermeindlich optimalen Trainingsdauer $t_{stop}$, so kann man die Performance auf den tatsächlich "ungesehenen" Testdaten durchführen.

Der Unterschied zwischen Validierungs- und Testdaten ist also, dass die Validierungsdaten bereits während des Trainings (i.d.R. in jeder Epoche) zum Einsatz kommen, um die Performanz des Modell auf "ungesehenen" Daten zu testen, wohingegen die Testdaten erst nach Abschluss des Trainings für einen finalen Test des trainierten und ausgewählten Modells verwendet werden.

4.2 Cross-Valididation

Holdout-Daten (egal ob Validierungsdaten oder Testdaten) sind natürlich für das Training "verloren". Man könnte überlegen, dass man genau die "falschen" Daten für das Testen zurückhält, oder dass man zu viele oder zu wenige nimmt. Schließlich möchte man für das Training möglichst viele und möglichst "gute" Daten verwenden.

Ein Verfahren, um die obigen Bedenken abzuschwächen, ist Cross-Validation (im Deutschen auch "Kreuzvalidierung", aber der englische Begriffe ist üblicher). Die Grundidee ist, dass man in mehreren Runden immer andere Validierungsdaten benutzt, und am Ende die jeweils gemessene Performanz mittelt. Wir nehmen hier als Performanzmaß die Accuracy, es könnte aber auch der Fehler (Loss), Precision oder Recall sein.

Wir demonstrieren das hier mit 5-fold cross validation. Man teilt die Trainingsdaten zufällig in fünf Teile (auch "folds" genannt) $D_1, \ldots, D_5$ mit je 20% der Daten. Natürlich sind die fünf Teilmengen nicht-überlappend (man sagt auch disjunkt).

Die Grundidee ist jetzt, dass man in fünf Durchläufen immer einen andere Teilmenge als Testdaten nimmt. Zunächst wählt man $D_5$ als Testdatensatz und trainiert auf $D_1, \ldots, D_4$. Man misst die Accuracy entsprechend auf $D_5$. In der nächsten Runde setzt man das Netz komplett zurück und training neu.

Wir schauen uns alle fünf Durchläufe mit der entsprechenden Aufteilung und Messung der Accuracy an:

  1. Training auf $D_1, D_2, D_3, D_4$, Test auf $D_5$ => Accuracy $Acc_1$
  2. Training auf $D_1, D_2, D_3, D_5$, Test auf $D_4$ => Accuracy $Acc_2$
  3. Training auf $D_1, D_2, D_4, D_5$, Test auf $D_3$ => Accuracy $Acc_3$
  4. Training auf $D_1, D_3, D_4, D_5$, Test auf $D_2$ => Accuracy $Acc_4$
  5. Training auf $D_2, D_3, D_4, D_5$, Test auf $D_1$ => Accuracy $Acc_5$

Die Gesamt-Accuracy ist dann der Durchschnitt $\frac{1}{5} \sum_i Acc_i$.

Ein Extremfall der Cross-Validierung ist die Leave-One-Out-Methode. Hier wird in jeder Runde nur ein Validierungsbeispiel aus dem Trainingsdatensatz herausgehalten (leave one out). Bei 100 Trainingsbeispielen wird also 1 Beispiel herausgehalten und auf den restlichen 99 Beispielen trainiert. Anschließend wird auf dem einen Validierungsbeispiel gemessen. In den nächsten 99 Runden wird das gleiche mit jeweils anderen Beispielen wiederholt und am Ende werden die 100 Werte gemittelt. Bei $N$ Trainingsbeispielen entspricht Leave-One-Out also einer N-fold cross validation.

Aktuell wird im Bereich des Deep Learning allerdings eher selten Cross-Validation angewendet, weil das Verfahren mit einem Mehraufwand verbunden ist: Bei K-fold cross validation vervielfacht sich die Trainingsdauer um Faktor K. Umgekehrt ist das Problem, dass Validierungsdaten "verloren" gehen, gerade bei großen Datenmengen eher marginal.

5 Weiterführende Themen [optional]

Wir verlassen den allgemeinen Bereich des Maschinellen Lernens (ML) und fokussieren uns ab dem nächsten Kapitel auf Neuronale Netze. Aber natürlich sind Neuronale Netze nur ein kleiner Teil des Gesamtgebiets. Daher gebe ich Ihnen hier ein paar Pointer, wenn Sie sich weiter im Bereich ML umsehen möchten.

Ein paar Methoden, die im Bereich ML heutzutage oft verwendet werden, sind (Links führen zu den entsprechenden Wikipedia-Artikeln, die einen guten ersten Eindruck vermitteln):

Wenn Sie sich im Maschinellen Lernen weiterbilden möchten, kann ich folgende Bücher empfehlen.

Dieses Buch ist offensichtlich sehr kompakt, aber dennoch ganz gut verstehbar:

  • Burkov, Andriy (2019) The Hundred-Page Machine Learning Book (auch online lesbar).

Diese zwei Bücher sind Praxis-Bücher mit Python und Jupyter:

  • Géron, Aurélien (2019) Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow: Concepts, Tools, and Techniques to Build Intelligent Systems, 2nd Edition, O'Reilly

  • Albon, Chris (2018) Python Machine Learning Cookbook: Practical solutions from preprocessing to deep learning, O'Reilly

Das akademische Standardwerk zum Thema ist eventuell etwas anstrengend zu lesen:

  • Bishop, Christopher M. (2007) Pattern Recognition and Machine Learning, 2nd Edition, Springer.

Das folgende Buch ist eine persönliche Empfehlung, weil es viel in die Praxis schaut und auch ein Gefühl dafür vermittelt, wo die Grenze zwischen Data Science und Machine Learning verläuft:

  • Skiena, Steven S. (2017) The Data Science Design Manual, Springer.