Introdução e aplicações da família map

O objetivo deste post é apresentar aplicações relevantes das funções da família mapdo pacote purrr. Essas funções são implementadas sob o paradigma de programação funcional. Saber utilizar esse tipo de programação facilita muito o processamento de dados e modelagem. É recomendável fazer o download da cheatsheet a seguir e consultar a documentação em https://purrr.tidyverse.org/.

Além da documentação, para quem prefere acompanhar por vídeo, o Caio Lente gravou um vídeo bem detalhado sobre o assunto (para assistir, clique aqui).

Neste post serão utilizados os seguintes pacotes (o purrr já é carregado com o tidyverse):

library(tidyverse)
library(ranger)
library(rsample)
library(ISLR)

Introdução à família map

Uma grande facilidade que se encontra no R é o fato de termos operações vetorizadas de forma nativa. Considere o exemplo a seguir, em que temos um vetor numérico e queremos saber a raíz quadrada de cada elemento. Poderíamos executar essa tarefa diretamente com sqrt.

(x <- c(1, 4, 9, 16, 25))
## [1]  1  4  9 16 25
sqrt(x)
## [1] 1 2 3 4 5

Note que essa função está fazendo a seguinte operação:

\[ \begin{align} f(x) & = c(f(1), \quad f(4), \quad f(9), \quad f(16), \quad f(25)) \\ & = c(\sqrt{1}, \quad \sqrt{4}, \quad \sqrt{9}, \quad \sqrt{16}, \quad \sqrt{25}) \end{align} \]

ou seja, aplicando a função f (raiz quadarada) a cada elemento do vetor x. De forma alternativa, podemos utilizar a função map.

map(x, sqrt)
## [[1]]
## [1] 1
## 
## [[2]]
## [1] 2
## 
## [[3]]
## [1] 3
## 
## [[4]]
## [1] 4
## 
## [[5]]
## [1] 5

No entanto, note que agora temos um objeto do tipo lista como saída e isso é importante porque a lista no R pode conter diferentes tipos de objetos. Por exemplo, não é possível ter um vetor contendo elementos inteiros, booleanos e string, mas isso é possível quando trabalhamos com uma lista. Veja no exemplo abaixo.

c(1, TRUE, "teste")
## [1] "1"     "TRUE"  "teste"
list(1, TRUE, "teste")
## [[1]]
## [1] 1
## 
## [[2]]
## [1] TRUE
## 
## [[3]]
## [1] "teste"

Em muitas situações queremos que a saída já esteja de acordo com um tipo determinado de objeto. Por exemplo, podemos ter interesse em aplicar a função sqrt a cada elemento do objeto x com a função map, mas queremos que a saída seja diretamente um vetor do tipo double. Assim, podemos utilizar a função map_dbl.

map_dbl(x, sqrt)
## [1] 1 2 3 4 5

Para obter os resultados em tipos específicos, podemos utilizar os seguintes pósfixos:

  • _dbl: vetor com elementos de números com casas decimais
  • _int: vetor com elementos de números inteiros
  • _chr: vetor com elementos de texto
  • _lgl: vetor com elementos booleanos (TRUE ou FALSE)
  • _dfc: data frame criado ao unir colunas
  • _dfr: data frame criado ao unir linhas

Pensando em um contexto de análise de dados, em que temos uma estrutura do tipo data frame, podemos aplicar alguma função diretamente a cada elemento do data frame. Perceba a diferença dos objetos retornados com map e map_dbl.

tibble(x = c(1, 4, 9, 16, 25)) %>% 
  mutate(raiz = map(x, sqrt))
## # A tibble: 5 x 2
##       x raiz     
##   <dbl> <list>   
## 1     1 <dbl [1]>
## 2     4 <dbl [1]>
## 3     9 <dbl [1]>
## 4    16 <dbl [1]>
## 5    25 <dbl [1]>
tibble(x = c(1, 4, 9, 16, 25)) %>% 
  mutate(raiz = map_dbl(x, sqrt))
## # A tibble: 5 x 2
##       x  raiz
##   <dbl> <dbl>
## 1     1     1
## 2     4     2
## 3     9     3
## 4    16     4
## 5    25     5

Anteriormente foram apresentados funções recebendo apenas um argumento. Isso porque as funções da família map aceitam apenas um argumento como entrada. Para utilizar dois argumentos, considere a família map2.

map2 (funções que recebem dois argumentos)

Em diversos cenários precisamos trabalhar com funções com mais de um argumento. Considere um exemplo em que queremos calcular a área de diferentes retângulos a partir de um vetor com as bases e outro com as alturas. Poderíamos utilizar a função map2 da seguinte forma:

base <- c(1, 2, 3)
altura <- c(4, 5, 6)

map2(base, altura, prod)
## [[1]]
## [1] 4
## 
## [[2]]
## [1] 10
## 
## [[3]]
## [1] 18

A partir de uma estrutura de banco de dados, considere que queremos obter a soma das variáveis num1 e num2 para cada elemento. Aqui consideraremos a função map2 e map2_int.

tibble(num1 = 1:5, 
       num2 = -15:-11) %>% 
  mutate(soma = map2(num1, num2, sum))
## # A tibble: 5 x 3
##    num1  num2 soma     
##   <int> <int> <list>   
## 1     1   -15 <int [1]>
## 2     2   -14 <int [1]>
## 3     3   -13 <int [1]>
## 4     4   -12 <int [1]>
## 5     5   -11 <int [1]>
tibble(num1 = 1:5, 
       num2 = -15:-11) %>% 
  mutate(soma = map2_int(num1, num2, sum))
## # A tibble: 5 x 3
##    num1  num2  soma
##   <int> <int> <int>
## 1     1   -15   -14
## 2     2   -14   -12
## 3     3   -13   -10
## 4     4   -12    -8
## 5     5   -11    -6

Note que podemos fazer isso de forma diferente, mas o objetivo aqui é facilitar a compreensão e aplicação das funções da família map.

Quando a função precisa receber 3 ou mais argumentos, podemos utilizar a função pmap.

pmap (funções que recebem 3 ou mais argumentos)

Considere o cenário em que queremos obter, para cada elemento do banco de dados, a soma dos valores de três colunas (num1, num2 e num3). Podemos utilizar as funções pmap e pmap_dbl.

tibble(num1 = 1:5, 
       num2 = -15:-11, 
       num3 = seq(10, 50, 10)) %>% 
  mutate(soma = pmap(list(num1, num2, num3), sum))
## # A tibble: 5 x 4
##    num1  num2  num3 soma     
##   <int> <int> <dbl> <list>   
## 1     1   -15    10 <dbl [1]>
## 2     2   -14    20 <dbl [1]>
## 3     3   -13    30 <dbl [1]>
## 4     4   -12    40 <dbl [1]>
## 5     5   -11    50 <dbl [1]>
tibble(num1 = 1:5, 
       num2 = -15:-11, 
       num3 = seq(10, 50, 10)) %>% 
  mutate(soma = pmap_dbl(list(num1, num2, num3), sum))
## # A tibble: 5 x 4
##    num1  num2  num3  soma
##   <int> <int> <dbl> <dbl>
## 1     1   -15    10    -4
## 2     2   -14    20     8
## 3     3   -13    30    20
## 4     4   -12    40    32
## 5     5   -11    50    44

Em muitas situações, criaremos uma função para um único processamento. Para evitar a criação de um objeto desnecessário, utilizaremos a estrutura de fórmula que será convertida em uma função diretamente dentro do map. Nesse caso, temos três formas diferentes de nos referir aos argumentos:

Argumento único com . - f(x) = x + 2

tibble(numero = c(1, 4, 9, 16, 25)) %>% 
  mutate(raiz = map_dbl(numero, ~ .x + 2))
## # A tibble: 5 x 2
##   numero  raiz
##    <dbl> <dbl>
## 1      1     3
## 2      4     6
## 3      9    11
## 4     16    18
## 5     25    27

Dois argumentos com .x e .y - f(x, y) = x + y

tibble(numero = c(1, 4, 9, 16, 25), 
       valor = c(10, 3, 4, 5, 8)) %>% 
  mutate(raiz = map2_dbl(numero, valor,  ~ .x + .y))
## # A tibble: 5 x 3
##   numero valor  raiz
##    <dbl> <dbl> <dbl>
## 1      1    10    11
## 2      4     3     7
## 3      9     4    13
## 4     16     5    21
## 5     25     8    33

Três ou mais argumentos com ..1, ..2, ..3 etc - f(x, y, z) = z (x + y)

tibble(numero = c(1, 4, 9, 16, 25), 
       valor = c(10, 3, 4, 5, 8), 
       fator = c(.1, .5, .9, .7, .2)) %>% 
  mutate(raiz = pmap_dbl(list(numero, valor, fator),  ~ ..3 * (..1 + ..2)))
## # A tibble: 5 x 4
##   numero valor fator  raiz
##    <dbl> <dbl> <dbl> <dbl>
## 1      1    10   0.1   1.1
## 2      4     3   0.5   3.5
## 3      9     4   0.9  11.7
## 4     16     5   0.7  14.7
## 5     25     8   0.2   6.6

Aplicações

Nessa seção apresentaremos duas aplicações importantes da família map no contexto de machine learning e de simulação estatística. A aplicação em machine learning nos permite fazer o ajuste de hiperparâmetros para os casos em que não exista alguma implementação pronta.

Ajuste de hiperparâmetro por validação cruzada

Nessa aplicação, como base, utilizaremos o conjunto de dados Credit do pacote ISLR. O objetivo desse banco de dados é predizer os valores da variável Balance.

dados <- ISLR::Credit 

head(dados)
ID Income Limit Rating Cards Age Education Gender Student Married Ethnicity Balance
1 14.891 3606 283 2 34 11 Male No Yes Caucasian 333
2 106.025 6645 483 3 82 15 Female Yes Yes Asian 903
3 104.593 7075 514 4 71 11 Male No No Asian 580
4 148.924 9504 681 3 36 11 Female No No Asian 964
5 55.882 4897 357 2 68 16 Male No Yes Caucasian 331
6 80.180 8047 569 4 77 10 Male No No Caucasian 1151

Suponha que será utilizado um modelo de Floresta Aleatória e deseja-se definir o hiperparâmetro mtry (define o número de variáveis que serão sorteadas para cada divisão dos nós). Queremos testar os valores 2, 4, 8 e 10 para essa quantidade e, consequentemente, estimar qual desses valores leva a um menor erro de previsão. Para isso, utilizaremos a validação cruzada. A função vold_cv nos auxiliará nesse processo (veja o post Descartes e rsample para mais detalhes sobre a função vfold_cv).

set.seed(15)

vfold_cv(dados, v = 10) # v indica o número de lotes
## #  10-fold cross-validation 
## # A tibble: 10 x 2
##    splits           id    
##    <list>           <chr> 
##  1 <split [360/40]> Fold01
##  2 <split [360/40]> Fold02
##  3 <split [360/40]> Fold03
##  4 <split [360/40]> Fold04
##  5 <split [360/40]> Fold05
##  6 <split [360/40]> Fold06
##  7 <split [360/40]> Fold07
##  8 <split [360/40]> Fold08
##  9 <split [360/40]> Fold09
## 10 <split [360/40]> Fold10

Para cada uma das dez linhas são definidos conjuntos de treinamento (com 360 observações) e conjuntos de teste (40 observações). O lote é identificado pela coluna id. Faremos a combinação desses lotes com os valores experimentais de mtry.

vfold_cv(dados, v = 10) %>% 
  crossing(mtry = c(2, 4, 8, 10))
## # A tibble: 40 x 3
##    splits           id      mtry
##    <list>           <chr>  <dbl>
##  1 <split [360/40]> Fold01     2
##  2 <split [360/40]> Fold01     4
##  3 <split [360/40]> Fold01     8
##  4 <split [360/40]> Fold01    10
##  5 <split [360/40]> Fold02     2
##  6 <split [360/40]> Fold02     4
##  7 <split [360/40]> Fold02     8
##  8 <split [360/40]> Fold02    10
##  9 <split [360/40]> Fold03     2
## 10 <split [360/40]> Fold03     4
## # ... with 30 more rows

Assim, para cada linha do tibble acima, ajustaremos a Floresta Aleatória com o conjunto de treino do split considerando o mtry da respectiva linha e avaliaremos o erro no conjunto de teste. Isso será feito com a aplicação de uma função para cada linha do tibble com map_dbl.

Primeiro, precisaremos definir a função para calcular o erro quadrático médio nessa situação.

ajuste <- function(splits, variaveis_mtry) {
  
  treino <- training(splits)
  teste <- testing(splits)
  
  rf <- ranger(Balance ~ . - ID, mtry = variaveis_mtry, data = treino)
  
  eqm <- mean((teste$Balance - predict(rf, teste)$predictions)^2)
  
  return(eqm)
  
}

Pronto! Agora, para obter o erro estimado em cada cenário, aplicaremos a função ajustecom auxílio da função map (execute o comando a seguir linha por linha e verifique as saídas).

set.seed(321)

vfold_cv(dados, v = 10) %>% 
  crossing(mtry = c(2, 4, 8, 10)) %>% 
  mutate(eqm = map2_dbl(splits, mtry, ajuste)) %>% 
  group_by(mtry) %>% 
  summarise(eqm_medio = mean(eqm), 
            desv_pad = sd(eqm)) %>% 
  arrange(eqm_medio)
## # A tibble: 4 x 3
##    mtry eqm_medio desv_pad
##   <dbl>     <dbl>    <dbl>
## 1    10     9950.    3959.
## 2     8    10318.    4039.
## 3     4    14760.    5630.
## 4     2    27114.    8353.

Portanto, o valor de mtry que levou a menor estimativa pontual do erro quadrático médio foi de 10. Dessa forma, utilizaremos 10 variáveis em cada split das árvores da Floresta Aleatória.

E se tivéssemos como objetivo avaliar o ajuste do hiperparâmetro mtry e min.node.size? Poderíamos utilizar a função pmap_dlb da seguinte forma:

ajustes2 <- function(splits, variaveis_mtry, node_size) {
  
  treino <- training(splits)
  teste <- testing(splits)
  
  rf <- ranger(Balance ~ . - ID, mtry = variaveis_mtry, min.node.size = node_size, data = treino)
  
  eqm <- mean((teste$Balance - predict(rf, teste)$predictions)^2)
  
  return(eqm)
  
}

set.seed(321)

vfold_cv(dados, v = 10) %>% 
  crossing(mtry = c(2, 4, 8, 10), 
           node_size = c(5, 10, 15)) %>% 
  mutate(eqm = pmap_dbl(list(splits, mtry, node_size), ajustes2)) %>% 
  group_by(mtry, node_size) %>% 
  summarise(eqm_medio = mean(eqm), 
            desv_pad = sd(eqm)) %>% 
  arrange(eqm_medio)
## # A tibble: 12 x 4
## # Groups:   mtry [4]
##     mtry node_size eqm_medio desv_pad
##    <dbl>     <dbl>     <dbl>    <dbl>
##  1    10         5     9876.    3804.
##  2     8         5    10158.    3967.
##  3    10        10    10490.    3926.
##  4     8        10    11347.    4598.
##  5    10        15    11838.    4680.
##  6     8        15    12312.    5127.
##  7     4         5    14523.    5623.
##  8     4        10    15948.    6101.
##  9     4        15    17730.    6611.
## 10     2         5    26583.    8001.
## 11     2        10    29425.    9239.
## 12     2        15    32271.    9782.

Logo, nesse cenário, o ideal seria utilizar mtry = 10 e node_size = 5.

Note como podemos facilitar diversas tarefas apenas com funções do pacote rsample e com a definição de funções simples.

A seguir veremos algumas aplicações para entender um pouco mais sobre a influência de certas quantidades em conceitos estatísticos.

Intervalo de Confiança

Nesta simulação trabalharemos com intervalos de confiança para a média com dados de uma distribuição normal com variância conhecida. Lembre-se que o intervalo de confiança para esse caso é dado por:

\[ IC(\mu, \gamma) = \bigg[\overline{X} \pm z_{(1 + \gamma)/2} \frac{\sigma}{\sqrt{n}}\bigg], \]

em que \(\sigma\) é o desvio padrão, \(n\) é o tamanho da amostra e \(\gamma\) é o nível de confiança.

Inicialmente consideraremos uma amostra (n_amostra) de tamanho 100 de uma população com média (media_pop) igual a 50, desvio padrão (dp_pop) igual a 3 e um nível de confiança (confianca) de 95%. Vamos criar a função simulacao para retornar a média amostral e os limites inferior e superior do intervalo.

media_pop <- 50
dp_pop <- 3
n_amostra <- 100
confianca <- .95


simulacao <- function(n_amostra, media_pop, dp_pop, confianca = .95) {
  
  x <- rnorm(n_amostra, media_pop, dp_pop)
  
  tibble(media_amostral = mean(x), 
         lim_inf = mean(x) - qnorm((1 + confianca)/2) * dp_pop / sqrt(n_amostra),
         lim_sup = mean(x) + qnorm((1 + confianca)/2) * dp_pop / sqrt(n_amostra))
  
}

Agora, aplicaremos a função simulacao 100 vezes. Veja a saída.

tibble(b = 1:10^2) %>% 
  mutate(resultado = map(b, ~simulacao(n_amostra, media_pop, dp_pop, confianca))) %>% 
  unnest(cols = resultado) 
## # A tibble: 100 x 4
##        b media_amostral lim_inf lim_sup
##    <int>          <dbl>   <dbl>   <dbl>
##  1     1           50.4    49.8    51.0
##  2     2           50.2    49.7    50.8
##  3     3           50.2    49.6    50.8
##  4     4           50.1    49.5    50.7
##  5     5           50.1    49.5    50.6
##  6     6           49.9    49.3    50.5
##  7     7           49.5    48.9    50.0
##  8     8           49.6    49.0    50.2
##  9     9           50.5    49.9    51.0
## 10    10           50.1    49.5    50.7
## # ... with 90 more rows

Identificaremos com uma cor vermelha os intervalos que não contenham a média populacional identificada pela linha azul tracejada. Qual a porcentagem de intervalos contendo a média populacional você esperaria?

tibble(b = 1:10^2) %>% 
  mutate(resultado = map(b, ~simulacao(n_amostra, media_pop, dp_pop, confianca))) %>% 
  unnest(cols = resultado) %>% 
  mutate(contem = case_when(media_pop >= lim_inf & media_pop <= lim_sup ~ "contém", 
                            TRUE ~ "não contém")) %>% 
  ggplot(aes(b, media_amostral, color = contem)) + 
    geom_hline(yintercept = media_pop, color = "blue", lty = 2) +
    geom_errorbar(aes(ymin = lim_inf, ymax = lim_sup)) +
    labs(x = "simulação", y = NULL, color = NULL) +
    scale_color_manual(values = c("contém" = "black", "não contém" = "red")) +
    theme_bw() + 
    theme(legend.position = "none")

Intervalo de confiança - variando o nível de confiança

O que aconteceria com os intervalos se considerássemos diferentes níveis de confiança? Um nível de confiança maior levaria a um intervalo de amplitude maior? Para verificar essas questões, faremos uma simulação variando essa quantidade com os conceitos apresentados.

crossing(b = 1:10^2, 
         confianca = c(.25, .50, .75, .99)) %>% 
  mutate(resultado = map(confianca, ~simulacao(n_amostra, media_pop, dp_pop, .x))) %>% 
  unnest(cols = resultado) %>% 
  mutate(contem = case_when(media_pop >= lim_inf & media_pop <= lim_sup ~ "contém", 
                            TRUE ~ "não contém")) %>% 
  ggplot(aes(b, media_amostral, color = contem)) + 
    geom_hline(yintercept = media_pop, color = "blue", lty = 2) +
    geom_errorbar(aes(ymin = lim_inf, ymax = lim_sup)) +
    labs(x = "simulação", y = NULL, color = NULL) +
    facet_wrap(~ confianca, ncol = 2) +
    scale_color_manual(values = c("contém" = "black", "não contém" = "red")) +
    theme_bw() + 
    theme(legend.position = "none")

Note que baixos valores do nível de confiança estão associados a intervalos menores e um número reduzido de intervalos contendo a média populacional. Veja como os intervalos com 99% de confiança apresentam uma grande amplitude.

Intervalo de confiança - variando o tamanho amostral

E o que aconteceria com a amplitude dos intervalos ao variar o tamanho amostral fixando-se o nível de confiança?

crossing(b = 1:10^2, 
         n = c(10, 100, 500, 1000)) %>% 
  mutate(resultado = map(n, ~simulacao(.x, media_pop, dp_pop, confianca))) %>% 
  unnest(cols = resultado) %>% 
  mutate(contem = case_when(media_pop >= lim_inf & media_pop <= lim_sup ~ "contém", 
                            TRUE ~ "não contém")) %>% 
  ggplot(aes(b, media_amostral, color = contem)) + 
    geom_hline(yintercept = media_pop, color = "blue", lty = 2) +
    geom_errorbar(aes(ymin = lim_inf, ymax = lim_sup)) +
    labs(x = "simulação", y = NULL, color = NULL) +
    facet_wrap(~ n, ncol = 2) +
    scale_color_manual(values = c("contém" = "black", "não contém" = "red")) +
    theme_bw() + 
    theme(legend.position = "none")

Intervalo de confiança - variando o tamanho amostral e o nível de confiança

E como se relacionam as variações do tamanho amostral e nível de confiança? A seguir apresentamos uma simulação testando diferentes combinações.

crossing(b = 1:10^2, 
         n = c(100, 500, 1000), 
         confianca = c(.33, .66, .99)) %>% 
  mutate(resultado = map2(n, confianca, ~simulacao(.x, media_pop, dp_pop, .y))) %>% 
  unnest(cols = resultado) %>% 
  mutate(contem = case_when(media_pop >= lim_inf & media_pop <= lim_sup ~ "contém", 
                            TRUE ~ "não contém")) %>% 
  ggplot(aes(b, media_amostral, color = contem)) + 
    geom_hline(yintercept = media_pop, color = "blue", lty = 2) +
    geom_errorbar(aes(ymin = lim_inf, ymax = lim_sup)) +
    labs(x = "simulação", y = NULL, color = NULL) +
    facet_wrap(n ~ confianca, ncol = 3) +
    scale_color_manual(values = c("contém" = "black", "não contém" = "red")) +
    theme_bw() + 
    theme(legend.position = "none")

Conclusão

Nesse post aprendemos a utilizar as funções da família map com algum detalhe. Verificamos como fazer o ajuste de hiperparâmetros de forma muito rápida com auxílio do pacote rsample e como realizar diferentes simulações com poucas linhas de código. Esse último contexto de aplicação é muito útil para quem está começando a estudar estatística.

Caso tenha alguma crítica, sugestão ou comentário, me envie uma mensagem!

Avatar
Tiago Mendonça

My interests include data science, machine learning, programming, photography, traveling and music.

comments powered by Disqus

Related