Machine Learning in der Google Cloud 2
Prinzipiell ist gegen Random Forests nichts einzuwenden, wenn man auf Retro-Modelle aus den 90ern steht. Manche sagen auch “bewährte Technik”. Allerdings muss man es sich auch erst einmal leisten können, die entsprechende Hardware anzuschaffen. Denn es ist das Eine, die Modelle zum Laufen zu bringen, aber das Andere, dass sie zu Lebzeiten auch fertig werden. Man freut sich ja, wenn der übergequollene Arbeitsspeicher das Tuning nicht im Keim erstickt, weiß aber den schnellen Tod zu schätzen, wenn man gespannt auf Ergebnisse wartet, die nie geliefert werden.
Wogegen aber schon etwas einzuwenden ist, ist die sehr niedrige Meme Dichte im letzten Blog-Beitrag! Die Dichte verkam eher zum Vakuum. Viele Menschen haben mich auf der Straße angesprochen, ob der überbordenden Seriosität, für die ich mich an dieser Stelle entschuldigen möchte. Dieser Beitrag soll sich als Fels in der Brandung erheben, der sich der stroke
-Betroffenheitswelle entgegenstemmt, die danach trachtete, jeglichen humoristischen Ansatz im Keim zu ersticken.
Also, worum wird es im zweiten Teil gehen? Wir schauen uns mlr3pipelines etwas detaillierter an. Mit Pipelines kann man wunderbare Dinge machen, wie verschiedene Modelle parallel laufen zu lassen bzw. sie hintereinanderzuschalten (stacking). Abbildung 1 zeigt uns gleich mal eine etwas komplexere Pipeline, wie sie auf unser stroke
Klassifikationsproblem angewendet wurde. Schön bunt. Also was passiert hier?
- Im ersten Branch können wir uns nicht entscheiden, ob wir die Kategorie
Unknown
in der Variablesmoking status
imputieren sollen (nach dem Motto: irgendeinen wahrensmoking status
müssen die Leute ja haben) oder ob wir einfach die Kategorie so lassen (nach dem Motto: Leute von denen man es nicht weiß, sind qualitativ eventuell anders als Leute, von denen man den Status weiß). Diese Frage eignet sich hervorragen, um an Teilnehmer:innen in einem Statistik gerichtet zu werden. Nach 20 Minuten Diskussion, lernen alle, dass es nicht immer eine richtige Antwort gibt. - Wir können uns nicht für ein Modell entscheiden. Daher trainieren wir mal mehrere Modelle parallel und leiten die Ergebnisse dann an unseren Super Learner (
xgboost
) weiter. Der soll dann das beste draus machen. Und Achtung:xgboost
ist nicht nur Super, sondern per Definition schon extrem.
Ich wollte eigentlich recht ausführlich über die Ergebnisse berichten und dieses kompliziertere Modell mit dem einfachen Random Forest vergleichen. Aber gut, wayne interessierts? Also stürzen wir uns auf die Pipe!
Warum Pipelines?
Jedem Anfang wohnt ein gewisser Zauber inne, sagen die einen. Die anderen sagen, dass man lieber die Finger von was Neuem lassen sollte, denn es kostet Zeit, Nerven und am Ende bleibt man, mit einer hohen Wahrscheinlichkeit, doch bei dem, was man vorher schon gekannt hat. Der bekannte Horror wird bevorzugt. Mir geht es mit neuen Packages meistens so, dass der Zauber ihnen nur so lange innewohnt, solange ich mich oberflächlich mit dem Thema beschäftigt habe, Stichwort: Vorträge und YouTube-Videos. mlr3
wirkt wie die Lösung aller Machine-Learning Probleme, bis zu dem Zeitpunkt, bei dem es sich dann selbst in die Schlange dieser Probleme einreiht. Nach dem Motto: Bevor ich mlr3
probiert habe, hatte ich ein Problem beim Tunen meines Modells, jetzt habe ich zwei. Tatsächlich (auch nachhaltig) positiv in diesem ganzen Prozess sind mir die mlr3pipelines
aufgefallen. Mit einer Pipeline kann man quasi ein Rezept erstellen, das beschreibt, was mit meinen Daten alles passieren soll, ohne dass man selbst kochen muss.
Die Aufarbeitungsschritte werden nicht sofort umgesetzt, wie z. B. Variablen imputieren oder standardisieren, sondern die Daten werden erst im Laufe des Tuning Prozesses, immer wieder, aufbereitet. Manchmal ist die Aufbereitung (wie bei uns) auch Teil des Modells. Insbesondere verhindert man, z. B. im Falle einer Crossvalidation (CV), dass Infos von aus dem Trainingsdatensatz in den Test Datensatz “leaken”. Auch hilfreich ist, dass man bestimmte Entscheidungen vielleicht nicht durch “Expertenwissen” treffen will1, sondern man kann auch einfach schauen was den besseren Fit ergibt. Probieren geht über studieren. Außerdem, kann man dann coole Grafiken machen.
Pipeline mit dem stroke
Datensatz
Hier ist der Code, der die Pipeline beinhaltet die in Abbildung 1 dargestellt ist.
Code: Stacking mit mlr3 (Achtung: Viel Code!)
library(data.table)
library(magrittr)
library(mlr3)
library(mlr3tuning)
library(mlr3extralearners)
library(mlr3learners)
library(mlr3pipelines)
library(paradox)
library(bbotk)
library(mlr3mbo)
library(forcats)
library(future)
library(xgboost)
plan(multisession)
## Argumente uebernehmen
<- commandArgs(trailingOnly = TRUE)
args
# defaults
<- 10
default_evals <- 1 * 60 * 60
default_duration
<- ifelse(length(args) >= 1, as.integer(args[1]), default_evals)
n_evals <- ifelse(length(args) >= 2, as.integer(args[2]), default_duration)
duration
cat("\n -------------------------------------------- \n")
cat("Tuning Stoppt nach:\n")
cat(" Evals:", n_evals, "\n")
cat(" Zeit in Sekunden:", duration)
cat("\n -------------------------------------------- \n\n")
set.seed(42)
# einlesen und modden
= fread("healthcare-dataset-stroke-data.csv") %>%
d := as.numeric(ifelse(bmi == "N/A", NA_character_, bmi))] %>%
.[, bmi := as.character(id)] %>%
.[, id != "Other",]
.[gender
# id muss ein character sein, sonst bekomme ich unten bei der rollenzuteilung einen error
# denn ein "name" spalte muss character oder factor sein, aber nie integer - whyever
#### TASK ######################################################################
# es ist etwas kompliziert eine variable den feature status zu entziehen.
= as_task_classif(d, target = "stroke")
task_stroke $col_roles$feature <- setdiff(task_stroke$col_roles$feature, "id")
task_stroke$col_roles$name <- "id"
task_stroke$set_col_roles("stroke", c("target","stratum"))
task_stroke
= partition(task_stroke, ratio = 0.8)
split ### learner --------------------------------------------------------------------
= lrn("classif.ranger",
learner_rf predict_type = "prob",
respect.unordered.factors = "partition",
num.trees = 3000,
id = "rf") # mit id = "rf" kann ich bei ps dann die präambel kuerzer schreiben
= lrn("classif.naive_bayes",
learner_nb predict_type = "prob",
id = "nb")
= lrn("classif.kknn",
learner_knn predict_type = "prob",
id = "knn")
# rf muss davor geschrieben werden, weil es sonst bei der pipe unklarheiten
# bezueglich parameternamen gibt!
= ps(
param_set gabelung.selection = p_fct(levels = c("unknown_as_category", "impute_unknown")),
cvnb.laplace = p_dbl(lower = 0, upper = 2),
cvnb.eps = p_dbl(lower = 1e-6, upper = 1e-2),
cvrf.mtry.ratio = p_dbl(0.4, 1),
cvrf.min.node.size = p_int(50, 1000),
super.eta = p_dbl(lower = 0.01, upper = 0.4), # Lernrate von XGBoost
super.max_depth = p_int(lower = 3, upper = 11), # Maximale Tiefe
super.nrounds = p_int(lower = 50, upper = 1000), # Anzahl der Boosting-Runden
cvknn.k = p_int(lower = 3, upper = 60)
)
### resampling -----------------------------------------------------------------
= rsmp("cv", folds = 5)
resampling_CV5 = msr("classif.auc")
measure_AUC
### tuner ----------------------------------------------------------------------
= tnr("mbo")
tuner_bayes
### terminator -----------------------------------------------------------------
= trm("combo",
terminator2 list(
trm("evals", n_evals = n_evals),
trm("run_time", secs = duration)
)
)
################################################################################
### pipeline -------------------------------------------------------------------
################################################################################
= po("branch",
gabelung options = c("unknown_as_category", "impute_unknown"), id = "gabelung")
= pipeline_robustify(task = task_stroke,
robustifiy learner = learner_rf,
character_action = "factor!",
impute_missings = FALSE)
#### keep unknown ==============================================================
<- po("imputelearner", learner = lrn("classif.rpart"),
po_impute_f_uk param_vals = list(
affect_columns = selector_type("factor")
id = "imp_f_uk") # id notwendig weil sonst probleme bei robustify
),
<- po("imputelearner", learner = lrn("regr.rpart"),
po_impute_d_uk param_vals = list(
affect_columns = selector_type("numeric")
id = "imp_d_uk")
),
= po("colapply",
graph_impute_unknown param_vals = list(
applicator = function(x) {
# x ist hier ein Vektor der Spalte
<- data.table::copy(x)
x1 == "Unknown"] <- NA_integer_
x1[x = forcats::fct_drop(x1)
res return(res)
},affect_columns = selector_grep("smoking_status")
id = "unknown_as_na"
), %>>%
) %>>%
po_impute_f_uk
po_impute_d_uk
#### impute unknown ============================================================
<- po("imputelearner", learner = lrn("regr.rpart"),
po_impute_d_imp param_vals = list(
affect_columns = selector_type("numeric")
id = "imp_d")
),
#############
= lrn("classif.rpart", predict_type = "prob", id = "super")
super_learner
### stacking
=
base_learners gunion(list(
po("nop", id = "nop_original"),
po("learner_cv", learner = learner_rf, id = "cvrf"),
po("learner_cv", learner = learner_nb, id = "cvnb"),
po("learner_cv", learner = learner_knn, id = "cvknn")
%>>% po("featureunion", id = "base_learners")
))
#### COMBINED GRAPH ============================================================
= robustifiy %>>%
combined_graph %>>%
gabelung gunion(list(po_impute_d_imp, graph_impute_unknown)) %>>%
po("unbranch", id = "unbranch_main") %>>%
%>>%
base_learners po("encode", method = "one-hot", id = "encode_super") %>>%
po("learner", learner = lrn("classif.xgboost", predict_type = "prob", id = "super"))
################################################################################
### autotune -------------------------------------------------------------------
= AutoTuner$new(
at learner = combined_graph,
resampling = resampling_CV5,
measure = measure_AUC,
search_space = param_set,
terminator = terminator2,
tuner = tuner_bayes
)
$train(task_stroke, row_ids = split$train)
at
= at$predict(task_stroke, row_ids = split$test)
pred_res = measure_AUC$score(pred_res)
auc_test
= list(at = at,
full_res pred_res = pred_res,
auc_test = auc_test,
split = split)
saveRDS(full_res, "output/at_stack2_splitted.rds")
Was können Pipelines?
Die Einzelteile einer Pipeline sind wie Mini-Modelle. Also, man kann sie auch trainieren und wir bekommen ein etwas unübersichtliches, aber komplexes Objekt zurück. Wir nehmen jetzt mal den gekürzten Anfang unseres gestackten Modells, um uns dann mit den Pipes herumspielen zu können2.
Robustify
Wenn Modelle anfangen, Errors zu droppen, zieht es mich plötzlich ganz weit weg vom Computer.
Diese vorgefertigte Pipeline, sorgt dafür, dass es zu keinen bösen Überraschungen kommt! Aus meiner Sicht: Uneingeschränkte Empfehlung. Man erspart sich viele Fehlermeldungen! Diesen Text zu lesen ist btw. auch sehr empfehlenswert! Es werden die Daten nur gerade so viel verändert, wie es für den learner
unbedingt notwendig ist. Minimalistisch und zielführend.
- “factor!” erzwingt, dass
character
Spalten zufactor
umgewandelt werden. Die Pipeline würde das nicht machen, wenn es für denlearner
nicht notwendig wäre. - Es werden keine Missings imputiert. Das will ich dann später selbst machen.
Wir sehen hier einen Vorher-/Nachher-Vergleich. Zuerst sind noch einige character
-Spalten enthalten, nachher, keine einzige mehr! Wir sehen auch, dass die Spalten umsortiert wurden. Nun sind alle factor
-Spalten in einem Block.
# Vorher:
sapply(task_stroke$data(), class)
stroke Residence_type age avg_glucose_level
"factor" "character" "numeric" "numeric"
bmi ever_married gender heart_disease
"numeric" "character" "character" "integer"
hypertension smoking_status work_type
"integer" "character" "character"
# Anwenden der Robustify Pipe auf den Task
= robustifiy$train(task_stroke)
res_rob
# Nachher:
sapply(res_rob[[1]]$data(), class)
stroke Residence_type ever_married gender
"factor" "factor" "factor" "factor"
smoking_status work_type age avg_glucose_level
"factor" "factor" "numeric" "numeric"
bmi heart_disease hypertension
"numeric" "integer" "integer"
Mit der plot
Methode lässt sich die Pipeline samt ihrer Elemente sogar interaktiv darstellen wie in Abbildung 2 zu sehen ist!
Robustify - Mini Beispiel
Konstanten machen Probleme, wenn man sie einfach so einer statistischen Methode zuführt. Die Methode erwartet Variablen. Eine Konstante hat diesen Namen nicht verdient, denn sie variiert ja per Definition nicht. Typischerweise will man Konstanten loswerden, da sie nichts – aber auch gar nichts – Konstruktives beitragen, um die Varianz von y zu erklären. Jetzt wäre es ein leichtes, alle Konstanten zu eliminieren. Allerdings kann eine Variable, die im Gesamtdatensatz noch ausreichend Variation zeigt, durch die Gruppenbildung der Kreuzvalidierung zu einer Konstanten werden. Die Alarmglocken schrillen – die Pipe kommt zur Hilfe. Schauen wir also mal, was passiert, wenn man eine Konstante im Datensatz hat.
Hier wird aber gleich mehrerlei ausprobiert, in dem kleinen Datensatz dat_mini
. Es wird gleich zweimal pipeline_robustify
mit unterschiedlichen Einstellungen ausgeführt.
- Hier wird erwähnt, dass explizit nicht imputiert werden soll
- Es soll imputiert werden und es sollen alle
character
Spaltenfactor
werden, egal, ob das notwendig ist oder nicht.
# Simulation eines Mini Datensatzes
set.seed(1653)
<- data.frame(
dat_mini target = factor(sample(c("A", "B", "C"), 20, replace = TRUE)),
feature1 = rnorm(20),
feature2 = sample(letters, 20, replace = TRUE),
constant_feature = 1 # <- konstante Spalte
%>%
)
data.table
4:5, feature1 := NA_real_] # Missings erzeugen
dat_mini[
# Task erstellen
= as_task_classif(dat_mini, target = "target")
task_mini
# Robustify
= pipeline_robustify(task = task_mini,
robbi_mini1 learner = learner_rf,
impute_missings = FALSE
)
= pipeline_robustify(task = task_mini,
robbi_mini2 learner = learner_rf,
character_action = "factor!",
impute_missings = TRUE)
# Graph trainieren
= robbi_mini1$train(task_mini)
robbi_res1 = robbi_mini2$train(task_mini) robbi_res2
Das wirkt sich direkt auf die Daten aus.
- Hier sind noch die 2 Missings enthalten und
feature2
ist nochcharacter
aber die Konstante ist weg! - Hier ist ebenso die Konstante weg, Missings sind weg,
character
geändert und eine neue Spalte hier, die anzeigt, wo ursprünglich Missings waren!
target feature1 feature2
<fctr> <num> <char>
1: B -0.7315033 b
2: B 1.9773381 h
3: C 0.3296053 k
4: A NA j
5: B NA s
6: A -0.9334918 q
target feature2 feature1 missing_feature1
<fctr> <fctr> <num> <fctr>
1: B b -0.7315033 present
2: B h 1.9773381 present
3: C k 0.3296053 present
4: A j 1.5194536 missing
5: B s -1.1473332 missing
6: A q -0.9334918 present
Imputation
Will man sich mit fehlenden Werten nicht zufriedengeben oder verwendet man obskure statistische Methoden, die mit fehlenden Werten nicht umgehen können, ist es Zeit, sich etwas auszudenken. Der erste Impuls ist vielleicht, sofort zu imputieren, denn die fehlenden Werte sind wie die von Datenkaries gefressenen Löcher in unserem, hoffentlich ansonsten gesunden, Datenzahn. Die sofortige Imputation ist aber laut Beipackzettel nicht empfohlen und wird in Fachkreisen auch “Imputatio Praecox” genannt. Denn wer es sich leisten kann, und das sollte wirklich jeder, imputiert tunlichst innerhalb der CV Samples, mit dem Ziel, keinerlei Informationen vom CV-Trainingssample an das CV-Testsample zu liefern. Wer Style hat, imputiert also so spät wie möglich.
Der Pipeline Operator po
wird wie ein Modell trainiert. Interessant ist auch, dass in dem Fall für alle numerischen Variablen ein Modell gemacht wird, egal ob es wirklich benötigt wird oder nicht. In unserem Fall gibt es also Modelle für age
, avg_glucose_level
und bmi
. Wirklich angewendet würde es in der pipe nur für bmi
. Dort dann automatisch. Wir müssen die predict
Methode verwenden, um die Werte wirklich zu imputieren.
<- po("imputelearner", learner = lrn("regr.rpart"),
po_impute_d_imp param_vals = list(
affect_columns = selector_type("numeric")
id = "imp_d")
),
$train(res_rob)
po_impute_d_imp= po_impute_d_imp$predict(res_rob) tsk_imputed
= po_impute_d_imp$state
state_imputer = state_imputer$model
trained_learners
names(trained_learners)
[1] "age" "avg_glucose_level" "bmi"
Wir holen uns das Modell aus dem Objekt raus und können den Regression Tree, der als Methode gewählt wurde, plotten. Abbildung 3 gibt Gelegenheit dazu, die Splits, die der Baum gewählt hat, zu inspizieren. Leider dienen hier nur gerundete Werte der numerischen Einordnung. Die Autor:innen dieser schönen Funktion haben sich sehr viel Mühe gemacht, Argumente zu ersinnen, die eine mögliche Änderung des Rundungsverhaltens insinnuieren, den Zahlendruck in der Grafik aber unangetastet lassen. Mithilfe dieser Argumente lassen sich Rundungen der verschiedensten Boxen feinabstimmen, um das, dem Eckigen abgeneigte, Auge nicht zu kränken. Ob dieser irreführenden Sackgassen, sackte ich in Resignation zusammen3.
= trained_learners[["bmi"]]$model
model_bmi
prp(model_bmi)

bmi
Wir können auch nachvollziehen, ob und was imputiert wurde. Statt vieler Missings, sind nun Werte zu sehen, die wir dank der Rundungen in der vorigen Abbildung nicht wiedererkennen.
= task_stroke$data()[,which(is.na(bmi))]
index_bmi_na
cbind(tsk_imputed$output$data(rows = index_bmi_na, cols = "bmi") %>% head,
$data(rows = index_bmi_na, cols = "bmi") %>% head) task_stroke
bmi bmi
<num> <num>
1: 33.81869 NA
2: 30.34060 NA
3: 33.81869 NA
4: 33.81869 NA
5: 33.81869 NA
6: 33.81869 NA
Ok, Pipelines können noch viel mehr und es gäbe noch einiges herzuzeigen, aber das kommt vielleicht in einem dritten Teil. Vielleicht auch nicht.