Optimér en model med en opskrift i et workflow

I dette kapitel skal vi bruge tidymodels til at skabe et komplet arbejdsflow, der omfatter valg af prædiktionsmodel, optimering af hyperparametre med krydsvalidering samt udvælgelse og evaluering af den bedste model. Vi begynder med at aktivere tre pakker med følgende kommandoer:

## Aktivér tidymodels til prædiktionsmodellering
library(tidymodels)

## Aktivér tidyverse til håndtering af data
library(tidyverse)

## Aktivér vip for at udtrække variable importance
library(vip)

Data til denne øvelse kommer fra pakken survival, hvor der findes data om personer med coloncancer. Vi aktiverer survival og indlæser alle datasæt med cancerstudier. Derefter inspicerer vi colon, som er de data, vi skal arbejde med.

## Aktivér survival-pakken
library(survival)

## Aktivér alle datasæt med cancerstudier
data(cancer)

## Inspicér de første 10 rækker i studiet om coloncancer
head(colon, 10)
   id study      rx sex age obstruct perfor adhere nodes status differ extent surg node4 time etype
1   1     1 Lev+5FU   1  43        0      0      0     5      1      2      3    0     1 1521     2
2   1     1 Lev+5FU   1  43        0      0      0     5      1      2      3    0     1  968     1
3   2     1 Lev+5FU   1  63        0      0      0     1      0      2      3    0     0 3087     2
4   2     1 Lev+5FU   1  63        0      0      0     1      0      2      3    0     0 3087     1
5   3     1     Obs   0  71        0      0      1     7      1      2      2    0     1  963     2
6   3     1     Obs   0  71        0      0      1     7      1      2      2    0     1  542     1
7   4     1 Lev+5FU   0  66        1      0      0     6      1      2      3    1     1  293     2
8   4     1 Lev+5FU   0  66        1      0      0     6      1      2      3    1     1  245     1
9   5     1     Obs   1  69        0      0      0    22      1      2      3    1     1  659     2
10  5     1     Obs   1  69        0      0      0    22      1      2      3    1     1  523     1

I colon findes kolonnen status, som er studiets udfaldsmål. Værdien 1 angiver, at patienten er død, og værdien 0 angiver, at patienten har overlevet. Vi vil modificere colon-data med følgende trin:

  1. Vi omkoder status 1 til Yes og 0 til No og gør variablen til en faktor med funktionen recode_factor().
  2. Vi omkoder sex, så der står Kvinna og Man i stedet for henholdsvis 0 og 1.
  3. Vi fjerner kolonnerne time, id og study, da vi ikke skal bruge dem i vores model.
  4. Variabler med 0/1 omkodes til No/Yes på en snedig måde med mutate(across()). Bemærk, at vi med across() kan vælge flere kolonner, som vi vil anvende funktionen recode_factor() på, og derefter repræsenteres kolonnen af .x i funktionen. Se linje 5 nedenfor.
colon2 <- colon |>
  mutate(status = recode_factor(status, "1"="Yes", "0"="No"),
         sex = recode_factor(sex, "0"="Kvinna", "1"="Man")) |>
  select(-time, -id, -study) |>
  mutate(across(c(obstruct, adhere, perfor, surg, node4), ~recode_factor(.x, "0"="No", "1"="Yes")))

## Inspicér data
head(colon2, 10)
        rx    sex age obstruct perfor adhere nodes status differ extent surg node4 etype
1  Lev+5FU    Man  43       No     No     No     5    Yes      2      3   No   Yes     2
2  Lev+5FU    Man  43       No     No     No     5    Yes      2      3   No   Yes     1
3  Lev+5FU    Man  63       No     No     No     1     No      2      3   No    No     2
4  Lev+5FU    Man  63       No     No     No     1     No      2      3   No    No     1
5      Obs Kvinna  71       No     No    Yes     7    Yes      2      2   No   Yes     2
6      Obs Kvinna  71       No     No    Yes     7    Yes      2      2   No   Yes     1
7  Lev+5FU Kvinna  66      Yes     No     No     6    Yes      2      3  Yes   Yes     2
8  Lev+5FU Kvinna  66      Yes     No     No     6    Yes      2      3  Yes   Yes     1
9      Obs    Man  69       No     No     No    22    Yes      2      3  Yes   Yes     2
10     Obs    Man  69       No     No     No    22    Yes      2      3  Yes   Yes     1

Bemærk, at vi herefter bruger colon2.

Opret træningsdata, testdata og resampling (krydsvalidering)

Vi deler nu colon2 i træningsdata (70% af data) og testdata (30%). Da studiet har til formål at undersøge hændelsen død, vælger vi at stratificere opdelingen, så andelen, der dør, er lige stor i træningsdata og testdata. Det er ønskværdigt, for at modellen kan testes og trænes under realistiske forhold. Hvis der er mange observationer, bliver andelen, der dør, med stor sandsynlighed lige stor i træningsdata og testdata (fordi træningsdata og testdata dannes helt tilfældigt). Denne funktion er derfor særligt nyttig, hvis antallet af observationer er lille, idet funktionen sikrer, at den tilfældige udvælgelse har lige store andele dødsfald i træningsdata og testdata.

## Opretter træningsdata og testdata med set.seed() først
set.seed(1)
colon_split <- initial_split(colon2, strata=status, prop=0.7)
colon_train <- training(colon_split)
colon_test  <- testing(colon_split)

## Opretter krydsvalidering af træningsdata
my_cross_validation <- vfold_cv(colon_train,
                                v = 5,
                                repeats=10,
                                strata = status)

Bemærk også, at vi har angivet set.seed(1), hvilket på dansk kaldes at så et frø. Når man sår et frø i denne sammenhæng, fjerner man tilfældigheden i den funktion, der køres efter set.seed(). I dette tilfælde betyder det blot, at vi kan få præcis de samme observationer i træningsdata og testdata, hver gang vi gentager opdelingen. Det er ønskværdigt for at opnå reproducerbare resultater. Du kan bruge et hvilket som helst tal i set.seed().

Resampling betyder, at den samme datamængde genbruges til at træne og evaluere en model. I dette tilfælde bruges en type resampling, der kaldes krydsvalidering (se Krydsvalidering). Krydsvalideringen foretages i træningsdata ved hjælp af funktionen vfold_cv(), som tilfældigt inddeler træningsdata i 5 lige store dele (folds), og hver model vil blive trænet i alle folds undtagen én, som bruges til at teste modellen. Dette gentages 10 gange (repeats=10) for hver model, hvilket medfører, at alle modeller trænes og evalueres i tilfældigt valgte dele af træningsdata. Den model, der klarer sig bedst under krydsvalideringen, er med største sandsynlighed også bedst på testdata (og dermed den bedste model).

Specificér en model og metode

I næste trin specificerer vi en model, nemlig extreme gradient boosting (XGBoost). Det gør vi ved at bruge funktionen boost_tree(), hvor samtlige hyperparametre sættes til tune(), hvilket betyder, at de skal optimeres via resampling. Vi specificerer metoden med set_engine() og typen af prædiktion med set_mode(). Derefter udskriver vi modelobjektet (xgb_spec).

xgb_spec <-
  boost_tree(mtry = tune(),
             tree_depth = tune(),
             trees = tune(),
             learn_rate = tune(),
             min_n = tune(),
             loss_reduction = tune(),
             sample_size = tune(),
             stop_iter = tune()) |>
  set_engine('xgboost') |>
  set_mode('classification')

xgb_spec
Boosted Tree Model Specification (classification)

Main Arguments:
  trees = tune()
  min_n = tune()
  tree_depth = tune()
  learn_rate = tune()
  loss_reduction = tune()
  sample_size = tune()
  stop_iter = tune()

Computational engine: xgboost

Hyperparametrene i xgboost er som følger

  • mtry: The number of predictors that will be randomly sampled at each split when creating the tree models.
  • trees: The number of trees contained in the ensemble. Default: 15.
  • min_n: The minimum number of data points in a node that are required for the node to be split further. Default: 1.
  • tree_depth: The maximum depth of the tree (i.e. number of splits). Default: 6.
  • learn_rate: The rate at which the boosting algorithm adapts from iteration-to-iteration. Default: 0.3.
  • loss_reduction: The reduction in the loss function required to split further. Type: double, default: 0.0.
  • sample_size: The amount of data exposed to the fitting routine. Type: double, default: 1.0.
  • stop_iter: The number of iterations without improvement before stopping. Type: integer, default: Inf.

I næste trin skal vi definere en matrix med kombinationer af hyperparametre. Disse matricer kaldes grid. Krydsvalideringens formål er at gennemsøge matricen efter den bedste model, hvilket kaldes grid search. Vi kan oprette et grid med flere forskellige funktioner, hvoraf én er grid_latin_hyperube(). Med denne funktion dannes kombinationer, der, baseret på det antal kombinationer vi tillader, dækker et så "stort område som muligt".

xgb_grid <- grid_latin_hypercube(
  trees(),
  min_n(),
  learn_rate(),
  loss_reduction(),
  tree_depth(),
  sample_prop(),
  stop_iter(),
  finalize(mtry(), colon_train),
  size = 20)

Vores grid har i alt 50 forskellige kombinationer af hyperparametre (size=50), hvilket betyder, at vi tester 50 forskellige modeller. Bemærk, at parameteren mtry (antal variabler) specificeres i funktionen finalize(), hvilket skyldes, at mtry ikke kan antage en hvilken som helst værdi (antallet af kolonner kan være for lille).

xgb_grid
   trees min_n learn_rate loss_reduction tree_depth sample_size stop_iter  mtry
   <int> <int>      <dbl>          <dbl>      <int>       <dbl>     <int> <int>
 1    55    24   4.15e- 2       2.28e- 2          6       0.358        14    10
 2   413    32   1.35e- 5       5.05e- 6         14       0.887        17     7
 3  1843    33   7.88e- 3       2.08e- 8          2       0.491        15     7
 4  1214    27   9.13e- 8       1.57e- 3          4       0.227        10     5
 5  1495    26   3.26e-10       5.35e- 7          4       0.596        15     6
 6   476    20   5.08e-10       1.33e- 8         13       0.828        11     2
 7  1117     8   5.41e- 3       2.92e- 7          8       0.731        17    12
 8   351     4   3.27e- 4       2.31e- 9         10       0.864        12    12
 9   310    35   9.31e- 9       1.98e+ 1         13       0.809        12     4
10  1450    40   1.34e- 6       2.03e-10          9       0.245         4    13
11  1269    35   1.13e- 8       4.29e- 1          4       0.615         4     7
12   990    22   8.24e- 2       2.13e- 5         11       0.532         8     2
13   128    23   5.87e-10       3.18e- 9         10       0.786        18     9
14  1347    13   5.02e- 9       8.81e- 2         12       0.722         7     9
15    94    38   6.35e- 7       9.03e- 5          7       0.168         5     4
16  1745    21   9.00e- 3       3.48e- 4          3       0.385        14     5
17  1791     6   1.93e- 4       1.23e- 6         10       0.413         3    12
18  1399    15   2.79e- 7       3.69e- 5          5       0.675         9    10
19   672    12   6.34e- 5       9.73e- 1          6       0.777        14     1
20  1531    12   2.25e-10       4.90e+ 0          3       0.266        15    11
21   893    19   1.54e- 6       1.15e+ 1          9       0.539         6    10
22   264    36   5.98e- 8       3.36e+ 0         14       0.208        10     8
23   233    38   1.25e- 9       2.41e- 3          9       0.998         6     3
24  1657    10   1.40e- 3       7.56e- 8          3       0.315        19     5
25   928     6   2.55e- 9       7.20e- 1          6       0.338        13     8
26  1418    33   2.85e- 4       1.40e- 7          1       0.970        11     6
27  1059     7   1.71e- 8       2.38e- 6          7       0.709         8     1
28   835    30   3.09e- 6       5.97e-10          8       0.131         3     8
29   162     9   2.75e- 8       1.66e-10         13       0.759        16     6
30   519     2   9.92e- 5       4.33e- 3          6       0.427        11    11
31  1153    23   3.08e- 5       1.07e- 4         13       0.922         7     2
32  1966     5   7.63e- 6       1.85e+ 0          2       0.398         8    10
33  1171    18   3.14e- 9       3.13e-10         12       0.652         9     1
34  1312    28   9.25e-10       2.25e- 4         12       0.576        13     3
35   590    20   5.51e- 5       1.34e- 9         15       0.189        18     6
36   699    25   3.85e- 8       1.04e- 2          8       0.289        13    11
37   389    14   5.52e- 2       7.44e- 6          7       0.474        16    11
38  1632     3   6.07e- 6       1.62e- 6          2       0.893        10    12
39   614    27   8.39e- 4       3.97e- 2         11       0.115        12    13
40  1011    16   4.35e- 7       1.07e+ 1         11       0.505        19     6
41   530    16   4.86e- 4       6.27e- 9          5       0.211         7     3
42   869    39   2.56e- 3       2.60e- 7         14       0.557        19     8
43  1821    16   2.37e- 7       2.43e- 1          9       0.678        20     4
44   799    29   4.12e- 6       1.63e- 2          5       0.935         6     3
45  1916    31   2.44e- 5       9.56e- 2          4       0.136        20     4
46    32    11   2.04e- 2       4.02e- 8         12       0.964         5     7
47  1713     8   1.74e- 7       6.99e- 9         15       0.838         9     3
48  1945    36   1.66e- 2       1.35e- 5          3       0.640         5     9
49  1567    18   1.50e-10       4.94e- 4          2       0.457        17     8
50   740    29   1.93e- 3       1.16e- 3          8       0.318        18    11

Opret en opskrift (recipe)

I næste trin opretter vi en opskrift, hvor vi definerer, hvad der er studiets udfald, samt hvilke prædiktorer vi vil bruge. Vi definerer også, hvilke pre-processing-trin vi vil foretage. Se kommentarerne med forklaringer.

xgb_recipe <- recipe(status ~ ., data=colon_train) |>
  # step_normalize normaliserer alle kontinuerlige prædiktorer
  step_normalize(all_numeric()) |>
  # step_dummy opretter dummy-variabler af alle kategoriske variabler. Dette kræves af xgboost
  step_dummy(all_nominal_predictors()) |>
  # step_novel er en metode, der er nødvendig for at håndtere den situation, som kan opstå, hvis
  # testdata indeholder niveauer af kategoriske variabler, som ikke fandtes i træningsdata
  step_novel(all_nominal_predictors())

Opret et arbejdsflow

Derefter opretter vi et arbejdsflow med opskriften og modellen:

xgb_wf <- workflow() |>
  add_recipe(xgb_recipe) |>
  add_model(xgb_spec)

xgb_wf
══ Workflow ═══════════════════════════════════════════════════════════════════════════════════════════
Preprocessor: Recipe
Model: boost_tree()

── Preprocessor ───────────────────────────────────────────────────────────────────────────────────────
3 Recipe Steps

• step_normalize()
• step_dummy()
• step_novel()

── Model ──────────────────────────────────────────────────────────────────────────────────────────────
Boosted Tree Model Specification (classification)

Main Arguments:
  mtry = tune()
  trees = tune()
  min_n = tune()
  tree_depth = tune()
  learn_rate = tune()
  loss_reduction = tune()
  sample_size = tune()
  stop_iter = tune()

Computational engine: xgboost

Træn modellerne

Derefter gennemføres træningen (dvs. modellerne oprettes). Bemærk argumentet metrics = yardstick::metric_set(roc_auc), som betyder, at vi fra pakken yardstick henter funktionen metric_set og sætter den til roc_auc (arealet under ROC-kurven). Det bliver således arealet under ROC-kurven, der bruges til at sammenligne modellerne.

xgb_res <- tune_grid(
  xgb_wf,
  resamples = my_cross_validation,
  grid      = xgb_grid,
  metrics   = yardstick::metric_set(roc_auc),
  control   = control_grid(save_pred = TRUE,
                           parallel_over = "everything",
                           save_workflow = TRUE))

Parallelisér træningen

Hvis du vil fremskynde ovenstående trin, kan du gøre det ved at parallelisere kørslen på flere af computerens kerner. Det gøres med pakkerne parallell og doParallell. For at måle den tid, det tager at foretage kørslen, bruger vi pakken tictoc. Koden til en paralleliseret beregning på 6 kerner er som følger:


## Aktivér pakkerne (glem ikke at installere pakkerne)
library(tictoc)
library(parallel)
library(doParallel)

## Opret en klynge i R med 6 kerner, så kørslen foretages på 6 kerner
mitt_kluster <- makePSOCKcluster(6)

## Aktivér din klynge
registerDoParallel(mitt_kluster)

## Start uret
tic()

## Foretag kørslen
xgb_res <- tune_grid(
  xgb_wf,
  resamples = my_cross_validation,
  grid      = xgb_grid,
  metrics   = yardstick::metric_set(roc_auc),
  control   = control_grid(save_pred = TRUE,
                           parallel_over = "everything",
                           save_workflow = TRUE))

## Stop klyngen
stopCluster(mitt_kluster)

## Stop uret
toc()

Evaluér modellerne

Resultaterne inspiceres derefter med funktionen collect_metrics(). Den opretter en tabel med information om hver models hyperparametre samt modellens resultat under krydsvalideringen. Kolonnen mean viser gennemsnitlig ROC AUC under krydsvalideringen. Vi sorterer alle modellerne efter faldende ROC AUC (den bedste model havner øverst):

collect_metrics(xgb_res) %>% arrange(desc(mean))
    mtry trees min_n tree_depth  learn_rate loss_reduct…¹ sampl…² stop_…³ .metric .esti…⁴  mean     n std_err
 1    12   985     8          6 0.0719           9.45e- 7   0.695       4 roc_auc binary  0.743    50 0.00337
 2     6  1713     2          2 0.0432           2.83e-10   0.517       7 roc_auc binary  0.733    50 0.00323
 3     2   794     5          3 0.0567           4.73e- 7   0.904      14 roc_auc binary  0.732    50 0.00334
 4     6   716     5          9 0.000956         1.62e- 2   0.880       9 roc_auc binary  0.727    50 0.00399
 5     9   159     6          5 0.00270          2.68e- 6   0.796      16 roc_auc binary  0.712    50 0.00411
 6     4  1394     9         13 0.000000417      1.68e- 9   0.862      13 roc_auc binary  0.707    50 0.00425
 7     1   447     4         15 0.0000679        2.17e+ 0   0.986       7 roc_auc binary  0.705    50 0.00383
 8     6   857    12          7 0.00000888       1.19e-10   0.745      12 roc_auc binary  0.704    50 0.00430
 9     9  1207    12          4 0.000126         3.87e- 2   0.946       6 roc_auc binary  0.698    50 0.00415
10    10  1280    14         12 0.00000152       1.17e- 8   0.666       6 roc_auc binary  0.695    50 0.00452
## … with 40 more rows, and abbreviated variable names ¹loss_reduction, ²sample_size, ³stop_iter, ⁴.estimator
## ℹ Use `print(n = ...)` to see more rows

For at undersøge, hvordan forskellige hyperparametre påvirkede ROC-AUC, skriver vi følgende, temmelig indviklede, kode:

xgb_res_plot <-
  xgb_res |>
  collect_metrics() |>
  filter(.metric == "roc_auc") |>
  select(mean, mtry:sample_size) |>
  pivot_longer(mtry:sample_size,
               values_to = "value",
               names_to = "parameter") |>
  ggplot(aes(value, mean, color = parameter)) +
  geom_point(alpha = 0.8, show.legend = FALSE) +
  facet_wrap(~parameter, scales = "free_x") +
  labs(x = NULL, y = "AUC")

xgb_res_plot
Punktdiagram i otte paneler, der viser ROC-AUC mod værdien af hver XGBoost-hyperparameter, fx mtry, trees og learn_rate

For at se de 5 bedste modeller i faldende rækkefølge skriver vi show_best():

show_best(xgb_res, "roc_auc")
   mtry trees min_n tree_depth learn_rate loss_reduction sample_size stop_…¹ .metric .esti…²  mean     n
1    12   985     8          6   0.0719         9.45e- 7       0.695       4 roc_auc binary  0.743    50
2     6  1713     2          2   0.0432         2.83e-10       0.517       7 roc_auc binary  0.733    50
3     2   794     5          3   0.0567         4.73e- 7       0.904      14 roc_auc binary  0.732    50
4     6   716     5          9   0.000956       1.62e- 2       0.880       9 roc_auc binary  0.727    50
5     9   159     6          5   0.00270        2.68e- 6       0.796      16 roc_auc binary  0.712    50
## … with abbreviated variable names ¹stop_iter, ².estimator

For at udtrække den bedste model skriver vi select_best():

best_model <- select_best(xgb_res, "roc_auc")
best_model
   mtry trees min_n tree_depth learn_rate loss_reduction sample_size stop_iter .config
1    12   985     8          6     0.0719    0.000000945       0.695         4 Preprocessor1_Model09

Til sidst kører vi den bedste model igen på hele træningsdatasættet (uden krydsvalidering) og evaluerer modellen på testdata. Det kan gøres med funktionen last_fit(), hvor vi angiver colon_split, som indeholder både træningsdata og testdata.

final_model <-
  xgb_res %>%
  extract_workflow("xgb_wf") %>%
  finalize_workflow(best_model) %>%
  last_fit(split = colon_split,
           metrics = yardstick::metric_set(roc_auc))

Denne models evne til at prædiktere på testdata kan nu evalueres:

collect_metrics(final_model)
  .metric .estimator .estimate .config
  roc_auc binary         0.707 Preprocessor1_Model1

ROC AUC var 0,707 for den bedste model.

Vi kan udtrække prædiktionerne på testdata med følgende kode:

collect_predictions(final_model)
   id               .pred_Yes .pred_No  .row status .config
 1 train/test split     0.720   0.280      2 Yes    Preprocessor1_Model1
 2 train/test split     0.120   0.880      4 No     Preprocessor1_Model1
 3 train/test split     0.475   0.525      8 Yes    Preprocessor1_Model1
 4 train/test split     0.940   0.0603    13 Yes    Preprocessor1_Model1
 5 train/test split     0.905   0.0946    14 Yes    Preprocessor1_Model1
 6 train/test split     0.503   0.497     20 No     Preprocessor1_Model1
 7 train/test split     0.436   0.564     21 No     Preprocessor1_Model1
 8 train/test split     0.612   0.388     22 No     Preprocessor1_Model1
 9 train/test split     0.562   0.438     30 No     Preprocessor1_Model1
10 train/test split     0.831   0.169     32 Yes    Preprocessor1_Model1
## … with 548 more rows

En ROC-kurve kan let oprettes med følgende kode:

collect_predictions(final_model) %>%
  roc_curve(status, .pred_Yes) %>%
  ggplot(aes(x = 1 - specificity, y = sensitivity)) +
  geom_path() +
  geom_abline(lty = 3) +
  coord_equal() +
  theme_bw()
ROC-kurve for den endelige model med sensitivitet mod 1 minus specificitet og en stiplet diagonal referencelinje

Variable importance

Vi kan udtrække variablernes betydning (relative variable importance) med pakken vip på følgende måde:

library(vip)

final_model %>%
  extract_fit_parsnip() %>%
  vip(geom = "point")
Punktdiagram over variablernes relative betydning i modellen, hvor age har størst betydning efterfulgt af nodes og sex_Man

Referencer

Hvitfeldt E, Frick H (2022). censored: 'parsnip' Engines for Survival Models. R package version 0.1.1, https://github.com/tidymodels/censored.

Tidymodels

Kuhn M, Vaughan D (2022). parsnip: A Common API to Modeling and Analysis Functions. https://github.com/tidymodels/parsnip, https://parsnip.tidymodels.org/.

Vaughan D, Couch S (2022). workflows: Modeling Workflows. https://github.com/tidymodels/workflows, https://workflows.tidymodels.org.

XGBoost: A Scalable Tree Boosting System