Testando múltiplos modelos supervisionados & tunados!

By [map[name:Lucas Moraes.] map[url:https://lucasmoraes.org]] in machine learning tidymodels

March 11, 2022

Neste post, vou usar novamente o {tidymodels}, mas com a intenção de explorar algumas funcionalidades ao invés de implementar e interpretar os modelos em si.

Vou montar um fluxo de trabalho, onde comparo a performance de três modelos supervisionados de classificação (regressão logística, random forest e XGBoost), tunando diferentes hiperparâmetros em cada usando uma busca em grid. Além disso, também vou comparar como diferentes combinações de preditores e etapas de pré-processamento podem influenciar na performance dos mesmos.

Meu objetivo aqui não é realizar um projeto de modelagem do início ao fim. Não irei realizar uma análise exploratória dos dados, nem interpretar os resultados dos modelos. Quero apenas mostrar como a performance de diferentes modelos pode ser analisada usando o framework do {tidymodels}, aumentando a escala de projetos e análises em potencial.

Para esta análise irei usar dados referentes aos atributos de Pokemons. Não se deixe enganar, este dataset é uma ótima ferramenta de testes, pois possui variáveis de todos os tipos, categorias e distribuições.

Existe um tipo especial e raro de pokemon, chamado lendário. Esta será minha variável de resposta, sendo ela binária.

Meus dados crus consistem no dataset pokemon.csv. Este possui uma quantidade enorme de informações, mas vou selecionar apenas algumas, para simplificar o processo.

Novamente, minha intenção aqui é mostrar como este tipo de pipeline pode auxiliar no processo de escolha do modelo a utilizar para um problema. O {tidymodels} tem se mostrado uma ferramenta muito poderosa nesse sentido, permitindo executar essas análises de maneira intuitiva e com sintaxe simples.

Lendo os dados


Primeiramente vou ler a tabela que encontra-se nesta pasta do dropbox e registrar os processadores para agilizar a velocidade das análises:

# chamando pacotes
library(tidyverse)
library(janitor)
library(tidymodels)
library(xgboost)
# puxando tabela
df_pokemon <- 
  read_csv("https://www.dropbox.com/s/loim4redam6feoy/pokemon.csv?dl=1") %>% 
  clean_names()
# registrando cpu's para o processamento paralelo
library(doParallel)
all_cores <- parallel::detectCores(logical = FALSE)
registerDoParallel(cores = all_cores)

Em seguida vou extrair apenas os dados que preciso e formatá-los:

df_model <- 
df_pokemon %>% 
  select(is_legendary,sp_attack,sp_defense, # extraindo apenas essas 5 variáveis
         speed,attack,defense,type1) %>% 
  mutate_if(is.character,as.factor) %>% # convertendo strings para fatores
  mutate(is_legendary=as.factor(is_legendary)) %>%  # convertendo y para fator
  relocate(is_legendary) %>%  # perfumaria - trazendo var pra frente
  na.omit() # Omitindo os NA (não recomendado!)

Desse modo tenho minha variável de resposta is_legendary e algumas outras variáveis (ataque e ataque especial, defesa e defesa especial, velocidade e tipo primário) que escolhi como explicativas. Todas variáveis explicativas são numéricas com a exceção do tipo primário, que assume categorias distintas (e.g.: fogo, água, etc…).

Abaixo, uma visão geral da tabela:

df_model %>% glimpse()
## Rows: 801
## Columns: 7
## $ is_legendary <fct> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, …
## $ sp_attack    <dbl> 65, 80, 122, 60, 80, 159, 50, 65, 135, 20, 25, 90, 20, 25…
## $ sp_defense   <dbl> 65, 80, 120, 50, 65, 115, 64, 80, 115, 20, 25, 80, 20, 25…
## $ speed        <dbl> 45, 60, 80, 65, 80, 100, 43, 58, 78, 45, 30, 70, 50, 35, …
## $ attack       <dbl> 49, 62, 100, 52, 64, 104, 48, 63, 103, 30, 20, 45, 35, 25…
## $ defense      <dbl> 49, 63, 123, 43, 58, 78, 65, 80, 120, 35, 55, 50, 30, 50,…
## $ type1        <fct> grass, grass, grass, fire, fire, fire, water, water, wate…

Particionando a amostra e criando receitas


Em seguida vou dividir minha amostra em partições de treino, teste e replicatas de validação cruzada:

set.seed(123)
df_split <- 
  initial_split(df_model,strata=is_legendary) # dividindo as partições
df_train <- training(df_split) # Extraindo df de treino
df_test <- testing(df_split) # Extraindo df de teste
df_folds <- vfold_cv(df_train) # Criando replicatas por cv

Agora posso criar diferentes “receitas” que serão testadas. Estas consistem nas fórmulas que quero testar (quais variáveis incluir, no caso) e algumas transformações que podem ser feitas nos dados (normalização de variáveis contínuas, por exemplo).

Abaixo um exemplo. Nesta receita, estou usando apenas o ataque e a defesa como variáveis explicativas. Além disso, estou normalizando ambas variáveis, que são numéricas. Também estou corrigindo o desbalanço de classes por downsampling.

receita1 <- 
  recipe(is_legendary ~ attack + defense,data=df_train) %>% # formula
  themis::step_downsample(is_legendary) %>% # downsampling
  step_normalize(all_numeric_predictors()) # normalização

Em seguida vou criar mais duas receitas, uma igual a acima, adicionando o ataque e defesa especiais dos pokemon. Na terceira, vou incluir todos dados:

receita2 <- 
  recipe(is_legendary ~ sp_attack + sp_defense,data=df_train) %>% # diferente aqui
  themis::step_downsample(is_legendary) %>% # downsampling
  step_normalize(all_numeric_predictors()) # normalização

receita3 <- 
  recipe(is_legendary ~ .,data=df_train) %>% # todos dados
  themis::step_downsample(is_legendary) %>% # downsampling
  step_normalize(all_numeric_predictors()) %>%  # normalização
  step_dummy(type1) # encoding da var categórica para o xgboost

Tunagem de hiperparâmetros


Como havia mencionado, quero testar como três modelos diferentes se ajustam aos meus dados: a regressão logística, o random forest e o XGBoost. Além disso, quero testar a performance dos modelos com diferentes valores para seus parâmetros, através da busca em grid.

Faço isso configurando cada modelo, deixando como tune() os argumentos que correspondem aos parâmetros que quero incluir no grid:

log_reg <- # Regressão logística
  logistic_reg(penalty = tune()) %>% # Tunar penalidade
  set_engine("glmnet")

rf_spec <- # Random Forest
  rand_forest(mtry = tune(), # Tunar total de features em cada nó
              min_n = tune(), # Tunar quantidade de dados para um split
              trees = 1000) %>% 
  set_engine("ranger") %>% 
  set_mode("classification")



xgb_spec <- # XGBoost
  xgboost_model <- 
  parsnip::boost_tree(
    mode = "classification",
    trees = 1000,
    min_n = tune(),# Tunar quantidade de dados para um split
    tree_depth = tune(), # Tunar complexidade da árvore
    learn_rate = tune(), # Tunar taxa de aprendizado
    loss_reduction = tune() # Tunar função de perda
  ) %>%
  set_engine("xgboost")

Executando o grid


Tenho 3 receitas, 3 modelos e uma série de hiperparâmetros em cada modelo para testar. Com o {tidymodels}, posso juntar toda essa informação em uma única variável, usando listas nomeadas e a função workflow_set, que aceita as receitas e as especificações dos modelos como input:

modelos_e_receitas <- 
workflow_set(
  preproc = list(receita1 = receita1, # Incluindo receitas
                 receita2 = receita2,
                 receita3 = receita3),
  models = list(xgb_spec = xgb_spec, # Incluindo especificações dos modelos
                rf_spec = rf_spec,
                log_reg=log_reg))

A partir desse objeto, posso executar um map estilo purrr, usando a função workflow_map que vai combinar todas possibilidades nas amostras da validação cruzada. Isso significa que cada combinação (de receita, modelo e hiperparâmetros) será testada em 10 replicatas dos dados de treino e a média das métricas de performance resultantes será extraída. Propositadamente, defini o parâmetro verbose = TRUE para que conforme os ajustes andam, eu saiba o que está acontecendo:

controle <-
   control_grid(
      save_pred = FALSE,
      parallel_over = "everything",
      save_workflow = TRUE,
      extract = extract_model
   )

grid_output <-
  modelos_e_receitas %>%
  workflow_map(
    seed = 1503, # Deixando reprodutível
    resamples = df_folds, # usando replicatas
    grid = 25, # Limitando tamanho do grid
    verbose = TRUE, # Print do andamento
    control = controle # Atributos de controle definidos acima
  )
## i 1 of 9 tuning:     receita1_xgb_spec
## ✔ 1 of 9 tuning:     receita1_xgb_spec (1m 34.7s)
## i 2 of 9 tuning:     receita1_rf_spec
## i Creating pre-processing data to finalize unknown parameter: mtry
## ✔ 2 of 9 tuning:     receita1_rf_spec (28s)
## i 3 of 9 tuning:     receita1_log_reg
## ✔ 3 of 9 tuning:     receita1_log_reg (2.2s)
## i 4 of 9 tuning:     receita2_xgb_spec
## ✔ 4 of 9 tuning:     receita2_xgb_spec (1m 36.5s)
## i 5 of 9 tuning:     receita2_rf_spec
## i Creating pre-processing data to finalize unknown parameter: mtry
## ✔ 5 of 9 tuning:     receita2_rf_spec (27.6s)
## i 6 of 9 tuning:     receita2_log_reg
## ✔ 6 of 9 tuning:     receita2_log_reg (2.3s)
## i 7 of 9 tuning:     receita3_xgb_spec
## Warning in mclapply(argsList, FUN, mc.preschedule = preschedule, mc.set.seed
## = set.seed, : scheduled core 2 did not deliver a result, all values of the job
## will be affected
## ✔ 7 of 9 tuning:     receita3_xgb_spec (1m 39.7s)
## i 8 of 9 tuning:     receita3_rf_spec
## i Creating pre-processing data to finalize unknown parameter: mtry
## Warning in mclapply(argsList, FUN, mc.preschedule = preschedule, mc.set.seed =
## set.seed, : scheduled cores 3, 6 did not deliver results, all values of the jobs
## will be affected
## ✔ 8 of 9 tuning:     receita3_rf_spec (36.1s)
## i 9 of 9 tuning:     receita3_log_reg
## ✔ 9 of 9 tuning:     receita3_log_reg (2.6s)

Explorando os resultados


A primeira coisa que consideraria interessante, antes de selecionar os melhores resultados, seria comparar a performance dos modelos. Isso pode ser feito usando a função autoplot (que resulta em um objeto de classe ggplot), a partir dos resultados do grid:

autoplot(
   grid_output,
   rank_metric = "roc_auc", 
   metric = "roc_auc",       
   select_best = TRUE     
) +
  theme_bw() + # Perfumaria em cima do plot :)
  theme(axis.title.x=element_blank(),
        axis.text.x=element_blank(),
        axis.ticks.x=element_blank())

Cada ponto representa um modelo e uma receita, tendo seus intervalos de confiança medidos com base na tunagem dos hiperparâmetros.

Também é possível rankear os resultados da tabela. Abaixo estou selecionando os 10 melhores modelos em relação aos valores médios de AUC, encontrados nos testes de validação cruzada:

knitr::kable( 
grid_output %>% 
    rank_results() %>% 
    filter(.metric == 'roc_auc') %>% 
    arrange(desc(mean)) %>% 
    select(wflow_id,.metric,mean,model) %>% 
    head()
)
wflow_id .metric mean model
receita3_rf_spec roc_auc 0.9539294 rand_forest
receita3_rf_spec roc_auc 0.9515351 rand_forest
receita3_rf_spec roc_auc 0.9486467 rand_forest
receita3_rf_spec roc_auc 0.9458058 rand_forest
receita3_rf_spec roc_auc 0.9414859 rand_forest
receita3_rf_spec roc_auc 0.9368465 rand_forest

Nesse caso, o melhor modelo foi o receita3_rf_spec, que teve maior AUC média. Lembrando, este é um modelo do tipo random forest que leva em consideração todos os dados que selecionei da tabela, com normalização das variáveis contínuas e downsampling da variável de resposta.

Além disso diversos hiperparâmetros foram testados para todos modelos. Eu poderia inspecionar a variável grid_output de diversas formas, para entender os valores específicos de performance para cada combinação de hiperparâmetro em cada replicata da validação cruzada. Isso pode ser trabalhoso (embora as vezes necessário) e, ao invés de seguir por esse caminho, posso ranquear novamente cada caso, dentro da receita escolhida:

knitr::kable(
grid_output %>% 
    extract_workflow_set_result("receita3_rf_spec") %>%
  collect_metrics() %>%
  filter(.metric=="roc_auc") %>%
  arrange(desc(mean))
)
mtry min_n .metric .estimator mean n std_err .config
7 2 roc_auc binary 0.9539294 7 0.0098178 Preprocessor1_Model19
7 22 roc_auc binary 0.9515351 8 0.0121038 Preprocessor1_Model16
19 25 roc_auc binary 0.9486467 8 0.0138041 Preprocessor1_Model08
19 9 roc_auc binary 0.9458058 7 0.0122021 Preprocessor1_Model11
16 26 roc_auc binary 0.9414859 7 0.0131308 Preprocessor1_Model03
12 39 roc_auc binary 0.9368465 8 0.0165164 Preprocessor1_Model24
17 19 roc_auc binary 0.9325774 8 0.0302957 Preprocessor1_Model07
20 17 roc_auc binary 0.9310903 8 0.0303220 Preprocessor1_Model15
21 12 roc_auc binary 0.9291851 8 0.0301762 Preprocessor1_Model20
1 15 roc_auc binary 0.9204132 8 0.0409642 Preprocessor1_Model23
13 28 roc_auc binary 0.9172246 8 0.0401992 Preprocessor1_Model04
11 38 roc_auc binary 0.9152635 8 0.0446689 Preprocessor1_Model12
4 5 roc_auc binary 0.9116951 7 0.0429818 Preprocessor1_Model10
14 16 roc_auc binary 0.9089189 7 0.0408479 Preprocessor1_Model21
11 33 roc_auc binary 0.9082606 8 0.0401483 Preprocessor1_Model09
17 35 roc_auc binary 0.9078784 8 0.0384864 Preprocessor1_Model25
8 30 roc_auc binary 0.9078225 8 0.0420805 Preprocessor1_Model17
5 5 roc_auc binary 0.9077732 7 0.0452007 Preprocessor1_Model02
21 37 roc_auc binary 0.9070498 8 0.0368612 Preprocessor1_Model01
9 11 roc_auc binary 0.9059694 7 0.0453481 Preprocessor1_Model05
2 14 roc_auc binary 0.9013566 7 0.0470592 Preprocessor1_Model13
10 7 roc_auc binary 0.9006260 7 0.0437577 Preprocessor1_Model22
6 32 roc_auc binary 0.9003536 7 0.0495620 Preprocessor1_Model18
15 21 roc_auc binary 0.9000487 7 0.0418261 Preprocessor1_Model14
3 24 roc_auc binary 0.8996576 7 0.0519144 Preprocessor1_Model06

Alternativamente poderia simplesmente selecionar o melhor conjunto de hiperparâmetros usando a função select_best:

knitr::kable(
grid_output %>% 
   extract_workflow_set_result("receita3_rf_spec") %>% 
   select_best(metric = "roc_auc")
)
mtry min_n .config
7 2 Preprocessor1_Model19

Mas nem sempre faz sentido usar a configuração mais performática no treino, pois ela pode estar performando bem por overfitting!

Implementando na partição de teste


Neste post estou ignorando coisas que não devem ser ignoradas, pois meu objetivo é mostrar o pipeline!

Sendo assim, vou então selecionar meu melhor modelo e os melhores hiperparâmetros, para fazer o ajuste final de meus dados.

Vou repetir o último passo, mas armazenando os resultados em uma variável:

best_results <- 
   grid_output %>% 
   extract_workflow_set_result("receita3_rf_spec") %>% 
   select_best(metric = "roc_auc")

Uso ela para fazer o ajuste na partição de treino inteira (lembrando que até agora ajustei os dados nas replicatas). A função last_fit além de fazer isso, já aplica o modelo na partição de teste:

boosting_test_results <- 
   grid_output %>% 
   extract_workflow("receita3_rf_spec") %>% 
   finalize_workflow(best_results) %>% 
   last_fit(split = df_split)

Com isso, posso ver como o modelo final performa na partição de teste:

collect_metrics(boosting_test_results)
## # A tibble: 2 × 4
##   .metric  .estimator .estimate .config             
##   <chr>    <chr>          <dbl> <chr>               
## 1 accuracy binary         0.891 Preprocessor1_Model1
## 2 roc_auc  binary         0.941 Preprocessor1_Model1

Considerações finais


Existe, muito, mas muito mais que pode/poderia ser feito nesse pipeline. Ainda daria para verificar como as demais métricas de performance funcionam, extrair a matriz de confusão de cada uma delas, usar outras formas de tunagem/avaliação de performance.

Mas a ideia era dar uma pincelada no que é possível fazer com esse framework. Também não poderia deixar de citar o livro Tidy Modeling with R, que explica como usar o framework e de onde tudo desse post foi extraído. Ele conta com uma leitura muito fluida, simples e com ótimos exemplos reprodutíveis!

Posted on:
March 11, 2022
Length:
11 minute read, 2135 words
Categories:
machine learning tidymodels
See Also: