//styles, look here: https://cdnjs.com/libraries/highlight.js/9.12.0

July 28, 2020

1306 palavras 7 mins

Classificando distribuições a partir dos momentos

Surgiu uma curiosidade legítima na minha cabeça ontem e eu queria saber se consigo, a partir dos momentos amostrais, treinar um classificador razoável para a família do processo gerador. Responder isso vai ser divertido porque é um bom playgrond para ferramentas do tidyverse e deixa de exemplo um fluxo mínimo de modelagem com tidymodels.

A primeira coisa a fazer é uma função que recebe um nome de função que possa gerar dados aleatórios seguindo algum processo conhecido - o parâmetro dgp vem de data generating process. Vou parametrizar ela menos do que é possível porque, putz moh trabalho.

process_factory <- function(dgp, n = 100) {
  
  if(dgp == "rnorm") {
    
    to_be_called <- call("rnorm", 
                         n = n, 
                         sd = runif(1, 0, 3), 
                         mean = runif(1, -3, 3))
  
  } else if(dgp == "rt") {
    
    to_be_called <- call("rt", 
                         n = n, 
                         df = sample(1:30, 1), 
                         ncp = runif(1, 0, 10))
    
  } else if(dgp == "runif") {
    
    to_be_called <- call("runif",
                         n = n,
                         min = runif(1, -3, 0),
                         max = runif(1, 0, 3))
    
    } else if(dgp == "rexp") {
   
   to_be_called <- call("rexp", 
                        n = n, 
                        rate = runif(1, 0, 3))
  
    } else if(dgp == "rgamma") {

    to_be_called <- call("rgamma", 
                         n = n, 
                         shape = runif(1, 0, 3),
                         scale = (1/runif(1, 0, 3)) + rnorm(1, sd = .2))
      
    }
  
  eval(to_be_called)
      
}

Agora uma função que recebe um vetor com dados simulados com algum processo gerador dado e retorna os primeiros \(k\) momentos amostrais.

first_kmoments <- function(process, .min_k = 1, .k = 20, .center = TRUE) {
  
  tibble(process = list(process)) %>%
    list() %>%
    rep(times = .k) %>%
    reduce(bind_rows) %>%
    mutate(K = .min_k:.k,
           moment = map2_dbl(
             .x = process, 
             .y = K,
             ~ moment(x = .x, order = .y, center = .center))) %>%
    pivot_wider(values_from = moment, 
                names_from = K, 
                names_prefix = "moment_") %>%
    select(-process)
  
}

(Tem um bug bem fácil de consertar e de reproduzir na função acima, fica como exercício)

Beleza agora podemos de fato simular alguns dados e pedir os momentos das distribuições simuladas.

library(tidyverse)
library(e1071)
library(magrittr)

(tibble(
  dgp = # variável com o nome dos processos, o Y do nosso modelo
   sample(c("rnorm", "runif", "rexp", "rgamma", "rt"), # amostre um de 4 nomes 
          size = 10000, # 100000 vezes
          replace = TRUE), # com substituição
   moments = # variável momentos que irá conter uma lista de dataframes
       map(dgp, # itere sobre o vetor com nomes de dgps
           ~ first_kmoments(process_factory(.x)))) %>% 
  unnest(moments) %>% # desaninha a lista de dataframes
  na.omit() ->
  data)
## # A tibble: 9,981 x 21
##    dgp    moment_1 moment_2 moment_3 moment_4 moment_5 moment_6  moment_7
##    <chr>     <dbl>    <dbl>    <dbl>    <dbl>    <dbl>    <dbl>     <dbl>
##  1 runif  2.22e-18   2.47   -8.95e-3  1.02e+1  3.48e-2 4.91e+ 1  1.49e+ 0
##  2 rt     3.20e-16  12.4     6.30e+1  9.49e+2  1.11e+4 1.57e+ 5  2.23e+ 6
##  3 runif -1.11e-17   0.528  -3.29e-2  5.86e-1 -1.25e-1 8.24e- 1 -3.28e- 1
##  4 rnorm  2.22e-16   0.970   2.19e-1  2.20e+0  1.14e+0 6.63e+ 0  5.35e+ 0
##  5 rt     5.86e-16   2.59    2.10e+0  2.10e+1  4.57e+1 2.86e+ 2  9.57e+ 2
##  6 rt     6.39e-16 826.      1.20e+5  2.07e+7  3.68e+9 6.62e+11  1.20e+14
##  7 runif -2.50e-18   0.0904  8.14e-4  1.45e-2 -8.97e-5 2.81e- 3 -1.03e- 4
##  8 runif  1.61e-17   0.457  -4.20e-2  3.73e-1 -7.06e-2 3.72e- 1 -1.04e- 1
##  9 runif  1.55e-17   0.831  -1.32e-1  1.23e+0 -4.43e-1 2.18e+ 0 -1.20e+ 0
## 10 rt    -2.13e-16   2.47    3.75e+0  2.51e+1  8.53e+1 4.46e+ 2  1.97e+ 3
## # … with 9,971 more rows, and 13 more variables: moment_8 <dbl>,
## #   moment_9 <dbl>, moment_10 <dbl>, moment_11 <dbl>, moment_12 <dbl>,
## #   moment_13 <dbl>, moment_14 <dbl>, moment_15 <dbl>, moment_16 <dbl>,
## #   moment_17 <dbl>, moment_18 <dbl>, moment_19 <dbl>, moment_20 <dbl>

Como são dados 100% simulados eu não vejo a virtude de explorar graficamente, vou pular então. Agora vamos treinar uma grade de modelos Random Forest.

library(tidymodels)

doParallel::registerDoParallel() # executar em paralelo

data_split <- initial_split(data, strata = dgp) # divisão dos dados
data_treino <- training(data_split) # treino
data_teste <- testing(data_split) # teste

data_rec <- recipe(dgp ~ ., data = data_treino) 
data_prep <- prep(data_rec) 

(modelo_tune <- rand_forest( # especificação da grade a ser avaliada
  mtry = tune(),
  trees = 1000,
  min_n = tune()) %>%
  set_mode("classification") %>%
  set_engine("randomForest"))
## Random Forest Model Specification (classification)
## 
## Main Arguments:
##   mtry = tune()
##   trees = 1000
##   min_n = tune()
## 
## Computational engine: randomForest
(modelo_workflow <- workflow() %>%
  add_recipe(data_rec) %>%
  add_model(modelo_tune))
## ══ Workflow ═══════════════════════════════════════════════════════════════════════════════════════════════
## Preprocessor: Recipe
## Model: rand_forest()
## 
## ── Preprocessor ───────────────────────────────────────────────────────────────────────────────────────────
## 0 Recipe Steps
## 
## ── Model ──────────────────────────────────────────────────────────────────────────────────────────────────
## Random Forest Model Specification (classification)
## 
## Main Arguments:
##   mtry = tune()
##   trees = 1000
##   min_n = tune()
## 
## Computational engine: randomForest
(data_folds <- vfold_cv(data_treino, 5))
## #  5-fold cross-validation 
## # A tibble: 5 x 2
##   splits            id   
##   <list>            <chr>
## 1 <split [6K/1.5K]> Fold1
## 2 <split [6K/1.5K]> Fold2
## 3 <split [6K/1.5K]> Fold3
## 4 <split [6K/1.5K]> Fold4
## 5 <split [6K/1.5K]> Fold5

O passo final é “afinar” a grade e estimar todos os modelos seguindo o workflow.

(tune_results <- tune_grid(
  modelo_workflow,
  resamples = data_folds,
  grid = 20
))

Vamos avaliar brevemente a área abaixo da curva ROC dos vários modelos que treinamos.

tune_results %>%
  collect_metrics(summarize = FALSE) %>%
  filter(.metric == "roc_auc") %>%
  ggplot(aes(x = .estimate)) +
  geom_histogram(fill = "light green") +
  labs(title = "Distribuição das AUROC",
       x = "AUROC",
       y = "") +
  scale_x_continuous(labels = scales::percent) +
  theme_minimal()

A primeira vez que rodei tudo me deu AUROCs beeem altas, além dos 95%. Em classificação binária, na maioria dos domínios de aplicação, isso é alto demais para ser verdade, mas problemas multiclasse normalmente vêm com AUROCs altas. Vamos olhar com mais cuidado para o melhor modelo - ainda dentro da amostra de treino.

library(vip)

(melhor_auc <- select_best(tune_results, "roc_auc"))
## # A tibble: 1 x 3
##    mtry min_n .config
##   <int> <int> <chr>  
## 1    14     9 Model01
(modelo_final <- finalize_model(modelo_tune, melhor_auc))
## Random Forest Model Specification (classification)
## 
## Main Arguments:
##   mtry = 14
##   trees = 1000
##   min_n = 9
## 
## Computational engine: randomForest
(avaliacao <- modelo_final %>%
  set_engine("randomForest") %>%
  fit(dgp ~ ., data = juice(data_prep)))
## parsnip model object
## 
## Fit time:  16.4s 
## 
## Call:
##  randomForest(x = as.data.frame(x), y = y, ntree = ~1000, mtry = ~14L,      nodesize = ~9L) 
##                Type of random forest: classification
##                      Number of trees: 1000
## No. of variables tried at each split: 14
## 
##         OOB estimate of  error rate: 21.09%
## Confusion matrix:
##        rexp rgamma rnorm   rt runif class.error
## rexp   1024    387     0   48     0  0.29814942
## rgamma  514    768     8  140     1  0.46331237
## rnorm     0     12  1309  160    37  0.13768116
## rt       40     93   110 1320     6  0.15869981
## runif     0      6    16    1  1488  0.01522171

Interessante que algumas distribuições são particularmente difíceis de acertar, como a gama. A uniforme e a normal, por exemplo, são beeem mais fáceis de acertar.

vip(avaliacao, geom = "col", fill = "light green") +
  labs(title = "Importância de variáveis para o classificador") +
  theme_minimal()

Acho razoável pensar que os momentos mais discriminantes dependem da cesta de distribuições alimentadas. É difícil pensar que assimetria é relevante para distinguir entre uma normal e uma uniforme, por exemplo.

E como ficamos fora da amostra de teste? A função last_fit() pega a receita de dados de treino e aplica na amostra de teste.

workflow_final <- workflow() %>%
  add_recipe(data_rec) %>%
  add_model(modelo_final)

(final_res <- workflow_final %>%
  last_fit(data_split))
## # Resampling results
## # Monte Carlo cross-validation (0.75/0.25) with 1 resamples  
## # A tibble: 1 x 6
##   splits        id          .metrics      .notes      .predictions     .workflow
##   <list>        <chr>       <list>        <list>      <list>           <list>   
## 1 <split [7.5K… train/test… <tibble [2 ×… <tibble [0… <tibble [2,493 … <workflo…
final_res %>%
  collect_metrics()
## # A tibble: 2 x 3
##   .metric  .estimator .estimate
##   <chr>    <chr>          <dbl>
## 1 accuracy multiclass     0.791
## 2 roc_auc  hand_till      0.957

A pergunta era se . Até que dá.