Die Kreuzvalidierung ist eine weit verbreitete Technik zur Bewertung der Generalisierbarkeit eines Machine-Learning-Modells. Hier bei STATWORX diskutieren wir oft über Evaluationsmetriken und darüber, wie wir sie effizient in unseren Data-Science-Workflow einbauen können. In diesem Blog-Beitrag stelle ich die Grundlagen der Kreuzvalidierung vor, gebe Hinweise zur Optimierung ihrer Parameter und zeige, wie man sie auf effiziente Weise von Grund auf selbst erstellen kann.
Grundlagen der Modellbewertung und Kreuzvalidierung
Die Kreuzvalidierung ist eine Technik zur Evaluation von Machine Learning-Modellen. Die zentrale Intuition hinter dieser Modellevaluierung besteht darin, herauszufinden, ob das trainierte Modell verallgemeinerbar ist, d. h., ob die Vorhersagekraft, die wir beim Training beobachten, auch bei ungesehenen Daten zu erwarten ist. Wir könnten das Modell direkt mit den Daten füttern, für die es entwickelt wurde, d. h. die es vorhersagen soll. Aber auch dann gibt es für uns keine Möglichkeit, zu wissen oder zu überprüfen, ob die Vorhersagen zutreffend sind.
Natürlich möchten wir eine Art Benchmark der Generalisierbarkeit unseres Modells haben, bevor wir es in die Produktion überführen. Daher besteht die Idee darin, die vorhandenen Trainingsdaten in einen tatsächlichen Trainingsdatensatz und einen Holdout-Testdatensatz aufzuteilen, die nicht zum Training verwendet wird und als “ungesehene” Daten dient. Da dieser Testdatensatz Teil der ursprünglichen Trainingsdaten ist, haben wir eine ganze Reihe von “richtigen” Ergebnissen, die wir überprüfen können. Zur Bewertung der Modellleistung kann dann eine geeignete Fehlermetrik wie der Root Mean Squared Error (RMSE) oder der Mean Absolute Percentage Error (MAPE) verwendet werden. Bei der Wahl der geeigneten Bewertungsmetrik ist jedoch Vorsicht geboten, da es Tücken gibt (wie in diesem Blogbeitrag meines Kollegen Jan beschrieben).
Bei vielen Algorithmen für Machine Learning können Hyperparameter angegeben werden, z. B. die Anzahl der Bäume in einem Random Forest. Die Kreuzvalidierung kann auch zum Tuning der Hyperparameter eines Modells genutzt werden, indem der Generalisierungsfehler verschiedener Modellspezifikationen verglichen wird.
Allgemeine Ansätze zur Modellevaluation
Es gibt Dutzende Techniken zur Modellevaluierung, die immer einen Kompromiss zwischen Varianz, Verzerrung und Rechenzeit darstellen. Bei der Bewertung eines Modells ist es wichtig, diese Kompromisse zu kennen, da die Wahl der geeigneten Technik in hohem Maße von der Problemstellung und den beobachteten Daten abhängt. Ich werde dieses Thema behandeln, nachdem ich zwei der gebräuchlichsten Techniken zur Modellevaluierung vorgestellt habe: den Train-Test-Split und die k-fache Kreuzvalidierung.
Bei der ersten Methode werden die Trainingsdaten nach dem Zufallsprinzip in eine Trainings- und eine Testeinheit aufgeteilt (Abbildung 1), wobei in der Regel ein großer Teil der Daten als Trainingsset beibehalten wird. In der Literatur werden am häufigsten Verhältnisse von 70/30 oder 80/20 verwendet, wobei das genaue Verhältnis von der Größe der Daten abhängt.
Der Nachteil dieses Ansatzes ist, dass diese einmalige zufällige Aufteilung dazu führen kann, dass die Daten in zwei sehr unausgewogene Teile aufgeteilt werden, was zu verzerrten Schätzungen des Generalisierungsfehlers führt. Dies ist besonders kritisch, wenn man nur über begrenzte Daten verfügt, da einige Merkmale oder Muster vollständig im Testteil landen könnten. In einem solchen Fall hat das Modell keine Chance, sie zu lernen, und seine Leistung wird möglicherweise unterschätzt.
Eine robustere Alternative ist die sogenannte k-fache Kreuzvalidierung (Abbildung 2). Hier werden die Daten gemischt und dann nach dem Zufallsprinzip in Teilmengen aufgeteilt. Der Hauptvorteil gegenüber dem Train-Test-Split besteht darin, dass jede der Partitionen iterativ als Testdatensatz (d. h. Validierungsmenge) verwendet wird, wobei die verbleibenden -Teile in dieser Iteration als Trainingsdaten dienen. Dieser Vorgang wird Mal wiederholt, sodass jede Beobachtung sowohl in den Trainings- als auch in den Testdaten enthalten ist. Die entsprechende Fehlermetrik wird dann einfach als Mittelwert aus allen Iterationen berechnet und ergibt den Kreuzvalidierungsfehler.
Dabei handelt es sich eher um eine Erweiterung des Train-Test-Splits als um eine völlig neue Methode: Das heißt, das Train-Test-Verfahren wird Mal wiederholt. Wenn z.B. so niedrig wie gewählt wird, erhält man zwar nur zwei Sets. Aber auch dieser Ansatz ist dem Train-Test-Split immer noch überlegen, da beide Teile iterativ für das Training ausgewählt werden, sodass das Modell die Chance hat, aus allen Daten zu lernen und nicht nur eine zufällige Teilmenge davon. Daher führt dieser Ansatz in der Regel zu robusteren Leistungsschätzungen.
Wenn man die beiden Abbildungen oben vergleichtt, sieht man, dass eine Aufteilung von Training und Test mit einem Verhältnis von 80/20 einer Iteration einer 5-fachen (d.h. ) Kreuzvalidierung entspricht, bei der 4/5 der Daten für das Training und 1/5 für die Validierung zurückbehalten werden. Der entscheidende Unterschied ist, dass bei der k-fachen Kreuzvalidierung die Validierungsmenge in jeder der Iterationen verschoben wird. Beachte, dass eine k-fache Kreuzvalidierung robuster ist als die einfache Wiederholung der Aufteilung von Training und Test Mal: Bei der k-fachen Kreuzvalidierung wird die Aufteilung einmal vorgenommen und dann durch die Partitionen iteriert, wohingegen bei der wiederholten Aufteilung von Training und Test die Daten -mal neu aufgeteilt werden, wodurch möglicherweise einige Daten beim Training ausgelassen werden.
Wiederholte CV und LOOCV
Es gibt viele Varianten der k-fachen Kreuzvalidierung. Man kann zum Beispiel auch eine “wiederholte Kreuzvalidierung” durchführen. Die Idee dahinter ist, dass sobald die Daten einmal in Partitionen aufgeteilt wurden, diese Aufteilung für das gesamte Verfahren gilt. Auf diese Weise besteht nicht die Gefahr, dass wir zufällig einige Teile ausschließen. Bei der Repeated CV wiederholst du diesen Prozess des Mischens und der zufälligen Aufteilung der Daten in Teilmengen eine gewisse Anzahl von Malen. Anschließend kannst du den Durchschnitt über die resultierenden Kreuzvalidierungsfehler der einzelnen Durchgänge bilden, um eine globale Leistungsschätzung zu erhalten.
Ein weiterer Spezialfall der k-fachen Kreuzvalidierung ist die “Leave One Out Cross-Validation” (LOOCV), bei der du setzt. Das heißt, du verwendest in jeder Iteration eine einzige Beobachtung aus deinen Daten als Validierungsteil und die restlichen Beobachtungen als Trainingsmenge. Das klingt zwar nach einer sehr robusten Version der Kreuzvalidierung, aber von dieser Methode wird im Allgemeinen aus zwei Gründen abgeraten:
- Erstens ist sie in der Regel sehr rechenintensiv. Für die meisten Datensätze, die beim angewandten Machine Learning verwendet werden, ist es weder erstrebenswert noch umsetzbar, das Modell Mal zu trainieren (obwohl es für sehr kleine Datensätze nützlich sein kann).
- Zweitens: Selbst wenn man die Rechenleistung (und Zeit) hätte, um diesen Prozess durchzuführen, ist ein weiteres Argument, das von Kritiker:innen von LOOCV aus statistischer Sicht vorgebracht wird, dass der resultierende Kreuzvalidierungsfehler eine hohe Varianz aufweisen kann. Das liegt daran, dass das “Validierungsset” nur aus einer einzelnen Beobachtung besteht, und je nach Verteilung deiner Daten (und möglichen Ausreißern) kann diese erheblich variieren.
Generell sollte man beachten, dass die Leistung von LOOCV sowohl in der wissenschaftlichen Literatur als auch in der breiteren Machine Learning Community ein etwas kontroverses Thema ist. Daher empfehle ich, sich über diese Debatte zu informieren, wenn man in Erwägung zieht, LOOCV zur Schätzung der Generalisierbarkeit eines Modells zu verwenden (siehe z. B. hier) und ähnliche Beiträge auf StackExchange). Wie so oft könnte die Antwort lauten: “Es kommt darauf an”. Bedenke auf jeden Fall den Rechenaufwand von LOOCV, der kaum von der Hand zu weisen ist (außer du hast einen winzigen Datensatz).
Der Wert von und der Kompromiss zwischen Verzerrung und Varianz
Wenn nicht (unbedingt) die beste Wahl ist, wie findet man dann einen geeigneten Wert für ? Es stellt sich heraus, dass die Antwort auf diese Frage auf den berüchtigten Bias-Variance Trade-Off hinausläuft. Warum ist das so?
Der Wert für bestimmt, in wie viele Partitionen die Daten unterteilt werden und damit auch die Größe (d.h. die Anzahl der Beobachtungen, die in jedem Teil enthalten sind). Wir wollen so wählen, dass ein ausreichend großer Teil unserer Daten in der Trainingsmenge verbleibt – schließlich wollen wir nicht zu viele Beobachtungen verschenken, die zum Trainieren unseres Modells verwendet werden könnten. Je höher der Wert von ist, desto mehr Beobachtungen werden bei jeder Iteration in die Trainingsmenge aufgenommen.
Nehmen wir zum Beispiel an, wir haben 1.200 Beobachtungen in unserem Datensatz, dann würde unser Trainingssatz bei aus Beobachtungen bestehen, aber mit würde es 1.050 Beobachtungen umfassen. Je mehr Beobachtungen für das Training verwendet werden, desto eher approximiert man die tatsächliche Leistung des Modells (so als ob es auf dem gesamten Datensatz trainiert worden wäre) und desto geringer ist die Verzerrung – oder Bias – der Fehlerschätzung im Vergleich zu einem kleineren Teil der Daten. Doch mit zunehmendem nimmt die Größe der Validierungspartition ab, und die Fehlerschätzung reagiert in jeder Iteration empfindlicher auf diese wenigen Datenpunkte, wodurch die Gesamtvarianz steigen kann. Im Grunde genommen geht es darum, zwischen den “Extremen” des Train-Test-Splits einerseits und LOOCV andererseits zu wählen. Die folgende Abbildung veranschaulicht schematisch (!) die Bias-Variance Performance und den Rechenaufwand der verschiedenen Kreuzvalidierungsmethoden.
Als Faustregel gilt: Mit höheren Werten für k nimmt die Verzerrung ab und die Varianz zu. Konventionell gelten Werte wie oder als guter Kompromiss und sind daher in den meisten Fällen des Machine Learning zum Quasi-Standard geworden.
“These values have been shown empirically to yield test error rate estimates that suffer neither from excessively high bias nor from very high variance.”
James et al. 2013: 184
Wenn du dich nicht besonders für den Prozess der Kreuzvalidierung interessierst, sondern ihn einfach in deinen Data Science Workflow integrieren willst (was ich dir sehr empfehle!), solltest du einen dieser Werte für wählen und es dabei belassen.
Implementierung der Kreuzvalidierung in
Apropos Integration der Kreuzvalidierung in deinen täglichen Arbeitsablauf – welche Möglichkeiten gibt es da? Glücklicherweise ist die Kreuzvalidierung ein Standardwerkzeug in beliebten Paketen für Machine Learning, wie z. B. dem Paket caret
in R. Hier kannst du die Methode mit der Funktion trainControl
festlegen. Im folgenden Skript trainieren wir einen Random Forest mit 10-facher Kreuzvalidierung auf dem iris
Datensatz.
library(caret)
set.seed(12345)
inTrain <- createDataPartition(y = iris[["Species"]], p = .7, list = FALSE)
iris.train <- iris[inTrain, ]
iris.test <- iris[- inTrain, ]
fit.control <- caret::trainControl(method = "cv", number = 10)
rf.fit <- caret::train(Species ~ .,
data = iris.train,
method = "rf",
trControl = fit.control)
Wir definieren unsere gewünschte Kreuzvalidierungsmethode in der Funktion trainControl
, speichern die Ausgabe im Objekt fit.control
und übergeben dieses Objekt dann an das Argument trControl
der Funktion train
. Du kannst die anderen in diesem Beitrag vorgestellten Methoden auf ähnliche Weise definieren:
# Leave-One-Out Cross-validation:
fit.control <- caret::trainControl(method = "LOOCV", number = 10)
# Repeated CV (remember to specify the number of repeats!)
fit.control <- caret::trainControl(method = "repeatedcv", number = 10, repeats = 5)
Die altmodische Art: Die -fold Kreuzvalidierung von Hand implementieren
Data Science-Projekte können jedoch schnell so komplex werden, dass die vorgefertigten Funktionen in Machine Learning-Paketen nicht mehr geeignet sind. In solchen Fällen musst du den Algorithmus – einschließlich der Kreuzvalidierungstechniken – von Hand implementieren, zugeschnitten auf die spezifischen Projektanforderungen. Ich möchte dir ein provisorisches Skript zeigen, mit dem du eine einfache k-fache Kreuzvalidierung in R von Hand implementieren kannst (wir gehen das Skript hier Schritt für Schritt an; den gesamten Code findest du auf GitHub).
Daten simulieren, Fehlermetrik definieren und festlegen
# devtools::install_github("andrebleier/Xy")
library(tidyverse)
library(Xy)
sim <- Xy(n = 1000,
numvars = c(2,2),
catvars = 0,
cor = c(-0.5, 0.9),
noisevars = 0)
sim_data <- sim[["data"]]
RMSE <- function(f, o){
sqrt(mean((f - o)^2))
}
k <- 5
Wir beginnen damit, die benötigten Pakete zu laden und einige Simulationsdaten mit 1.000 Beobachtungen mit dem Paket Xy()
zu simulieren, das mein Kollege André entwickelt hat (siehe seinen Blogbeitrag über Regressionsdaten mit Xy simulieren). Da wir eine Art Fehlermaß zur Bewertung der Modellleistung benötigen, definieren wir unsere RMSE-Funktion, die ziemlich einfach ist: Der RMSE ist der Root Mean Squared Error, also die Wurzel aus dem Mittelwert des quadrierten Fehlers, wobei der Fehler die Differenz zwischen den gefitteten (fitted; f) und den beobachteten (observed; o) Werten ist – man kann die Funktion so ziemlich von links nach rechts lesen. Zuletzt geben wir unser k an, das im Beispiel auf den Wert 5 gesetzt ist und als einfacher Integer gespeichert wird.
Aufteilung der Daten
set.seed(12345)
sim_data <- mutate(sim_data,
my.folds = sample(1:k,
size = nrow(sim_data),
replace = TRUE))
Als nächstes unterteilen wir unsere Daten in folds. Hierfür fügen wir den Daten eine neue Spalte hinzu,my.folds
: Wir ziehen eine Stichprobe (mit Zurücklegen) von 1 bis zum Wert von , in unserem Fall also 1 bis 5, und fügen jeder Zeile (Beobachtung) in den Daten zufällig eine dieser fünf Zahlen hinzu. Bei 1.000 Beobachtungen sollte jede Zahl etwa 200 Mal zugewiesen werden.
Training und Validierung des Modells
cv.fun <- function(this.fold, data){
train <- filter(data, my.folds != this.fold)
validate <- filter(data, my.folds == this.fold)
model <- lm(y ~ NLIN_1 + NLIN_2 + LIN_1 + LIN_2,
data = train)
pred <- predict(model, newdata = validate) %>% as.vector()
this.rmse <- RMSE(f = pred, o = validate$y)
return(this.rmse)
}
Anschließend definieren wir “cv.fun”, das Herzstück unseres Kreuzvalidierungsverfahrens. Diese Funktion benötigt zwei Argumente: this.fold
und data
. Auf die Bedeutung von this.fold
werde ich gleich zurückkommen, für den Moment setzen wir es einfach auf 1. Innerhalb der Funktion teilen wir die Daten in eine Trainings- und eine Validierungspartition auf, indem wir sie nach den Werten von my.folds
und this.fold
unterteilen: Jede Beobachtung mit einem zufällig zugewiesenen my.folds
-Wert abweichend von 1 (also etwa 4/5 der Daten) geht in das Training. Alle Beobachtungen mit einem my.folds
-Wert gleich 1 (die restlichen 1/5) bilden die Validierungsmenge. Zur Veranschaulichung passen wir dann ein einfaches lineares Modell mit dem simulierten Ergebnis und vier Prädiktoren an. Beachte, dass wir dieses Modell nur auf die train
-Daten anwenden! Dann verwenden wir dieses Modell, um unsere Validierungsdaten vorherzusagen. Da wir für diese Teilmenge der ursprünglichen Trainingsdaten wahre Ergebnisse haben (darum geht es ja!), können wir unseren RMSE berechnen und ihn zurückgeben.
Iterieren durch die Foldings und Berechnen des CV-Fehlers
cv.error <- sapply(seq_len(k),
FUN = cv.fun,
data = sim_data) %>%
mean()
cv.error
Zum Schluss verpacken wir den Funktionsaufruf von cv.fun
in eine sapply()
-Schleife – hier passiert die ganze Magie: Wir iterieren über den Bereich von k, so dass seq_len(k)
uns in diesem Fall den Vektor [1] 1 2 3 4 5
liefert. Wir wenden jedes Element dieses Vektors auf cv.fun
an. In der *apply()
-Familie wird der Iterationsvektor immer als erstes Argument der aufgerufenen Funktion übergeben, so dass in unserem Fall jedes Element dieses Vektors zu einem Zeitpunkt an this.fold
übergeben wird. Außerdem übermitteln wir unsere simulierten sim_data
als Argument data
.
Fassen wir kurz zusammen, was das bedeutet: In der ersten Iteration ist “this.fold” gleich 1. Das bedeutet, dass unsere Trainingsmenge aus allen Beobachtungen besteht, bei denen my.folds
nicht 1 ist, und die Beobachtungen mit dem Wert 1 bilden die Validierungsmenge (genau wie im Beispiel oben). In der nächsten Iteration der Schleife ist this.fold
gleich 2. Folglich bilden die Beobachtungen mit den Werten 1, 3, 4 und 5 die Trainingsmenge und die Beobachtungen mit dem Wert 2 gehen in die Validierungsgruppe, und so weiter. Indem wir über alle Werte von k iterieren, erhalten wir das in Abbildung 2 gezeigte diagonale Muster, bei dem jede Datenpartition einmal als Validierungsmenge verwendet wird.
Zum Schluss berechnen wir den Mittelwert: Das ist der Mittelwert unserer k einzelnen RMSE-Werte und ergibt unseren Kreuzvalidierungsfehler. Und das war’s: Wir haben gerade unsere eigene Kreuzvalidierungsfunktion definiert!
Dies ist lediglich eine Vorlage: Du kannst jedes beliebige Modell und jede beliebige Fehlermetrik einfügen. Wenn du bis hierher durchgehalten hast, kannst du gerne selbst versuchen, eine wiederholte CV zu implementieren oder mit verschiedenen Werten für k herumzuspielen.
Fazit
Wie du siehst, ist es gar nicht so schwer, die Kreuzvalidierung selbst zu implementieren. Sie bietet dir eine große Flexibilität, um projektspezifische Anforderungen zu berücksichtigen, wie z. B. benutzerdefinierte Fehlermetriken. Wenn du nicht so viel Flexibilität brauchst, ist die Implementierung der Kreuzvalidierung in gängigen Machine Learning-Paketen ein Kinderspiel.
Ich hoffe, dass ich dir einen ausreichenden Überblick über die Kreuzvalidierung geben konnte und wie du sie sowohl in vordefinierten Funktionen als auch von Hand implementieren kannst. Wenn du Fragen, Kommentare oder Ideen hast, kannst du mir gerne eine E-Mail schicken.
Quellen