Updates dieser Seite:
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.
Logistische Regression, binäre und Mehrklassen-Klassifikation, One-vs-All, Konfusionsmatrix, Recall, Precision, Accuracy, Trainings-/Test- und Validierungsdaten, Cross-Validierung
Iris, MNIST (Scikit-learn)
import warnings
warnings.filterwarnings('ignore')
import numpy as np
import pandas as pd
import matplotlib
import matplotlib.pyplot as plt
import seaborn as sns
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.
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
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.
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.
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:
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()
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.
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.
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.
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:
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} $$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.
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} $$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}).
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.
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$.
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.
Wie bei der linearen Regression betrachten wir eine Umsetzung "von Scratch" in Python und anschließend die Umsetzung mit Hilfe einer Bibliothek.
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
In den Daten geht es um die Unterscheidung von drei Klassen (Pflanzenarten) von Schwertlilien:
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:
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
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:
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:
iris.data[:3] # zeige die ersten drei Einträge
Im Feld target stehen die korrekten Klassen der 150 Trainingsbeispiele:
iris.target
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).
iris_x = iris.data[:, :2]
iris_x = iris_x[:100] # nur die ersten 100 Elemente
iris_x[:3]
Jetzt bauen wir unsere y-Werte (Zielwerte). Wir nehmen nur die ersten 100 Trainingsbeispiele, so dass nur die Klassen 0 und 1 auftreten.
iris_y = iris.target[:100]
iris_y
Wir sehen uns die Daten als Scatterplot an. Da wir nur zwei Features betrachten, können wir das als 2D-Plot betrachten.
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()
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$.
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.
tx = np.array([[.3, .24], [.5, .22], [.1, .1]])
print(tx.shape)
tx
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).
ones = np.ones((tx.shape[0], 1))
print(ones.shape)
ones
Jetzt erweitern wir alle Trainingsbeispiele auf einmal mit Hilfe von concatenate und erhalten eine 3x3-Matrix.
tx = np.concatenate((ones, tx), axis=1)
print(tx.shape)
tx
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:
tw = np.array([0.02, 0, 1.2])
print(tw.shape)
tw
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.
print(tx.shape, tw.shape)
tz = np.dot(tx, tw)
tz
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.
def sigmoid(z):
return 1 / (1 + np.exp(-z))
th = sigmoid(tz)
th
Diese Werte sind die berechneten Werte der Funktion $h$ mit den aktuellen Gewichten in $w$ für die drei Trainingsbeispiele.
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).
ty = np.array([1, 1, 0])
Jetzt definieren wir unsere Loss-Funktion. Durch das mean werden alle Komponenten summiert und durch $N$ geteilt.
def loss(h, y):
return -(y * np.log(h) + (1 - y) * np.log(1 - h)).mean()
loss(th, ty)
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):
gradient = np.dot(tx, (th - ty)) / ty.size
gradient
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.
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.
model = LogisticRegression(alpha=0.1, epochs=20000)
model.fit(iris_x, iris_y)
Mit der Funktion predict können wir uns diskrete Ausgabewerte zurückgeben lassen und diese mit den korrekten Klassen vergleichen.
preds = model.predict(iris_x)
(preds == iris_y).mean()
Die Parameter sind über das Objekt zugreifbar.
model.w
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$).
def reg(x):
return - model.w[0]/model.w[2] - model.w[1]/model.w[2] * x
print(reg(4), reg(7))
Jetzt sehen wir auch den einen Ausreißer, der für die 99% (statt 100%) verantwortlich ist.
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()
Jetzt betrachten wir die Umsetzung mit Hilfe der Bibliothek Scikit-learn.
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:
from sklearn.datasets import fetch_openml
mnist = fetch_openml('mnist_784', version=1)
mnist.keys()
Die Bilder haben eine Auflösung von 28x28, sind aber "flach" (engl. flattened) repräsentiert:
28 * 28
Es sind 70000 Bilder mit je 784 Pixeln.
x = mnist["data"]
x.shape
Wir schauen uns ein paar Pixel an (erstes Sample, Pixel 400-500) und sehen, dass wir uns im Bereich 0...255 bewegen.
x[0][400:500]
Und die jeweils korrekte Ausgabe (= Ziffer).
y = mnist["target"]
y.shape
Wir plotten das erste Sample:
%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.
y[0]
Wir wollen die Zielwerte als Integer-Zahlen haben:
y = y.astype(np.uint8)
y[:20]
Wir skalieren die Pixelwerte auf [0, 1] und kontrollieren das:
x = x/255
x[0][400:500]
Wir nehmen 60000 Samples für das Training und 10000 für den Test.
x_train, x_test, y_train, y_test = x[:60000], x[60000:], y[:60000], y[60000:]
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:
y_train5 = (y_train == 5)
y_test5 = (y_test == 5)
y_train5[:20] # Testausgabe
Wir erstellen als Modell ein Objekt vom Typ LogisticRegression.
from sklearn.linear_model import LogisticRegression
modelLR = LogisticRegression()
Das Trainieren von Modellen wird oft mit dem Verb fit (engl. für "anpassen") beschrieben. Deshalb heißt die Methode so:
modelLR.fit(x_train, y_train5)
Wir lassen uns jetzt für unsere gesamten Testdaten Vorhersagen für die spätere Evaluation errechnen.
y_pred5 = modelLR.predict(x_test)
y_pred5
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}.
Jetzt trainieren wir das Modell:
modelMLR.fit(x_train, y_train)
Für die spätere Evaluation treffen wir wieder Vorhersagen auf allen Testbeispielen:
y_pred = modelMLR.predict(x_test)
y_pred
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.
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.
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)
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).
Die False Positives sind also "falscher Alarm" und führen beim Beispiel Corona dazu, dass (unnötigerweise) Quarantäneregelungen greifen.
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).
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!
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).
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.
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
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.
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
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
from sklearn import metrics
cm = metrics.confusion_matrix(y_test5, y_pred5)
cm
Wir visualisieren die Konfusionsmatrix mit Hilfe der Library Seaborn.
Siehe: https://seaborn.pydata.org/generated/seaborn.heatmap.html
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')
Anhand der Werte in der Matrix setzen wir TN, TP, FP und FN.
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.
print("Precision: ", TP / (TP + FP))
print("Recall: ", TP / (TP + FN))
print("Accuracy: ", (TP+TN)/(TP+TN+FP+FN))
Diese Funktionen werden auch in Scikit-learn bereitgestellt. Daher benutzen wir fortan die eingebauten Funktionen.
Siehe:
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))
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.
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()
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.
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.
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$$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:
cm2 = metrics.confusion_matrix(y_test, y_pred)
cm2
Wir visualisieren die Matrix mit Seaborn.
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')
Jetzt berechnen wir die Accuracy über alle Klassen. In der einfachsten Variante wird der Mittelwert über alle Accuracywerte der Klassen gebildet.
print("Accuracy: {:.4f}".format(metrics.accuracy_score(y_test, y_pred)))
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.
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')))
Mit der Option average='weighted' wird der Mittelwert gewichtet, je nachdem wie hoch der Anteil der jeweiligen Kategorie ist (s.o.):
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')))
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.
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):
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.
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:
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.
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:
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.
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:
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:
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: