Programando com dplyr (tidy eval)

E aí, pessoal! Depois de conhecer o dplyr (se ainda não conhece, veja aqui nosso post de introdução), começamos a escrever nossos códigos usando as funções desse pacote, e o costume de usar o dplyr acaba nos fazendo pensar em escrever nossas próprias funções usando alguns comandos do dplyr. Você já tentou escrever e teve algum problema? Nós também, e por isso estudamos um pouco sobre tidy evaluation. Com isso vamos conseguir escrever nossas primeiras funções usando select, mutate, ou outras funções do dplyr, aproveitando a facilidade e rapidez que o pacote nos oferece.

Pra ilustrar esse primeiro exemplo, vamos usar o famoso dataset iris, mas vamos antes transformá-lo num tibble, apenas para facilitar na visualização:

require(dplyr)

iris_tibble <- as_tibble(iris)

Começamos fazendo uma função muito simples, que apenas seleciona uma coluna passada como argumento. Vamos usar essa nossa função para selecionar a coluna Species, mas iremos passar o nome dessa coluna entre aspas.

seleciona <- function(dados, coluna){
  dados %>% 
    select(coluna)
}

seleciona(iris_tibble, "Species")
## # A tibble: 150 x 1
##    Species
##    <fct>  
##  1 setosa 
##  2 setosa 
##  3 setosa 
##  4 setosa 
##  5 setosa 
##  6 setosa 
##  7 setosa 
##  8 setosa 
##  9 setosa 
## 10 setosa 
## # ... with 140 more rows

Da mesma forma funciona com o pull, ao invés do select. Faça o teste!

Mas e se quisermos passar o nome da coluna Species sem aspas, como acontece em todos os verbos do dplyr? Vamos tentar passar a coluna sem aspas pra nossa função:

seleciona(iris, Species)
## Error in .f(.x[[i]], ...): objeto 'Species' não encontrado

Veja que o erro nos diz que o objeto Species não foi encontrado. Isso acontece porque não criamos nenhum objeto com esse nome, e o R retorna um erro quando executamos o nome de um objeto que ainda não existe.

Quosures

Iremos então capturar uma expressão ao invés de calculá-la. Para isso usaremos a função quo(). Essa função fará a captura da expressão e de seu environment. Dessa maneira, o R saberá de onde tirar o valor daquela expressão, por exemplo, do ambiente do nosso script principal e não do ambiente da nossa função. Vejamos um exemplo:

# O R fará a conta
1 + 1
## [1] 2
# O R retornará a expressão sem fazer a conta
quo(1 + 1)
## <quosure>
## expr: ^1 + 1
## env:  global

Assim nós temos um objeto do tipo quosure. Então, aplicando no nosso problema, iremos precisar passar um quosure como argumento para a função, mas também teremos que dizer quando substituir a expressão da quosure por seu valor. Para isto, utilizaremos !!:

seleciona <- function(dados, coluna){
  dados %>% 
    select(!!coluna)
}

seleciona(iris_tibble, quo(Species))
## # A tibble: 150 x 1
##    Species
##    <fct>  
##  1 setosa 
##  2 setosa 
##  3 setosa 
##  4 setosa 
##  5 setosa 
##  6 setosa 
##  7 setosa 
##  8 setosa 
##  9 setosa 
## 10 setosa 
## # ... with 140 more rows

Dessa maneira podemos passar Species com ou sem aspas. Testa aí!

Mas ficar usando quo ao passar os argumentos pra função pode ser um pouco chato. Podemos pensar em usar esse comando dentro da nossa função:

seleciona <- function(dados, coluna){
  
  coluna <- quo(coluna)
  
  print(coluna)
  
  dados %>% 
    select(!!coluna)
}

seleciona(iris_tibble, Species)
## <quosure>
## expr: ^coluna
## env:  0000000018E72638
## Error: `coluna` must evaluate to column positions or names, not a formula

Esse print foi colocado de propósito pra entendermos o que está acontecendo. A função quo() é muito literal, ou seja, ela armazemou coluna, não esperando ela receber o valor Species na chamada da função. Precisamos então de uma função que espere o argumento ser dado para capturar seu nome.

Enquo

Para fazer o que desejamos precisamos usar a função enquo(). Esta função irá primeiro tomar o valor de coluna e depois transformar esse valor num quosure.

Observação: Esta função serve apenas para utilizarmos dentro de uma função. Se quisermos testar a função rodando linha a linha, devemos substituir enquo() por quo().

seleciona <- function(dados, coluna){
  
  coluna <- enquo(coluna)
  
  print(coluna)
  
  dados %>% 
    select(!!coluna)
}

seleciona(iris_tibble, Species)
## <quosure>
## expr: ^Species
## env:  global
## # A tibble: 150 x 1
##    Species
##    <fct>  
##  1 setosa 
##  2 setosa 
##  3 setosa 
##  4 setosa 
##  5 setosa 
##  6 setosa 
##  7 setosa 
##  8 setosa 
##  9 setosa 
## 10 setosa 
## # ... with 140 more rows

Neste caso também podemos passar Species com aspas, sem problemas. A partir de agora vamos construir um outro tibble, que nos ajudará a continuar entendendo tidy eval.

(df <- tibble(
  g1 = c(1, 1, 2, 2, 2),
  g2 = c(1, 2, 1, 2, 1),
  a = sample(5), 
  b = sample(5)
))
## # A tibble: 5 x 4
##      g1    g2     a     b
##   <dbl> <dbl> <int> <int>
## 1     1     1     5     1
## 2     1     2     3     4
## 3     2     1     1     3
## 4     2     2     4     5
## 5     2     1     2     2

Iremos construir uma função que recebe o tibble, a coluna a ser usada para agrupamento e a coluna para calcular a média:

my_summarise <- function(df, var_grupo, var_summarise){
  
  var_grupo <- enquo(var_grupo)
  var_summarise <- enquo(var_summarise)
  
  df %>%
    group_by(!!var_grupo) %>%
    summarise(media = mean(!!var_summarise))
}

Agora vamos usar a função utilizando como argumentos df, g1 e a, ou seja, queremos calcular a média de a, agrupando pelos valores de g1.

my_summarise(df, g1, a)
## # A tibble: 2 x 2
##      g1 media
##   <dbl> <dbl>
## 1     1  4   
## 2     2  2.33

Assim como estávamos fazendo quando o quosure era passado para o select, vamos tentar passar os nomes de colunas com aspas:

my_summarise(df, "g1", "a")
## Warning in mean.default(~"a"): argumento não é numérico nem lógico:
## retornando NA
## # A tibble: 1 x 2
##   `"g1"` media
##   <chr>  <dbl>
## 1 g1        NA

Temos dois problemas aqui. O primeiro é que o group_by na verdade criou uma variável "g1", com aspas, com um único valor: "g1". Além disso, sabemos que não é possível calcular a média de "a", então temos um NA na coluna media. Mais a frente vamos tentar solucionar essa questão.

Mudar nomes de variáveis

Suponha que queiramos retornar a média das colunas a e b e, para diferenciar os nomes, as colunas terão os nomes media_a e media_b. Para isso iremos utilizar quo_name(), que converte o quosure num string, e o operador :=

my_summarise2 <- function(df, var1, var2) {
  
  var1 <- enquo(var1)
  var2 <- enquo(var2)
  
  mean_name1 <- paste0("media_", quo_name(var1))
  mean_name2 <- paste0("media_", quo_name(var2))
  
  summarise(df, 
    !!mean_name1 := mean(!!var1), 
    !!mean_name2 := mean(!!var2)
  )
}

my_summarise2(df, a, b)
## # A tibble: 1 x 2
##   media_a media_b
##     <dbl>   <dbl>
## 1       3       3

Da mesma maneira, podemos receber o nome da coluna que vai ser criada no summarise() como argumento da função.

my_summarise3 <- function(df, coluna, nome){
  
  coluna <- enquo(coluna)
  nome <- enquo(nome)
  
  df %>%
  summarise(
    !!quo_name(nome) := mean(!!coluna)
  )
}

my_summarise3(df, a, media)
## # A tibble: 1 x 1
##   media
##   <dbl>
## 1     3

Observação: Estas duas formas também funcionam dentro do mutate().

Argumentos em group_by, arrange, mutate e summarise

Ao passarmos o nome de uma coluna com aspas para ser usado em group_by, o enquo() (como vimos acima) não irá retornar o resultado desejado. Uma maneira de resolver esta questão é utilizar a função sym(). Vamos observar que o funcionamento dela é bem similar a função já mencionada enquo().

my_summarise_string <- function(df, var_grupo, var_summarise) {
  
  var_grupo <- sym(var_grupo)
  var_summarise <- sym(var_summarise)
  
  df %>%
    group_by(!!var_grupo) %>%
    summarise(media = mean(!!var_summarise))
}

my_summarise_string(df, "g1", "a")
## # A tibble: 2 x 2
##      g1 media
##   <dbl> <dbl>
## 1     1  4   
## 2     2  2.33

Da mesma maneira fazemos se os parâmetros forem usados em arrange(), summarise() e mutate(). O ponto é que desta maneira a função só irá conseguir fazer os cálculos se passarmos var_grupo e var_summarise com aspas. Se quisermos uma função que aceite os parâmetros com ou sem aspas, uma alternativa será utilizar try() e, com isso, utilizar if(). Veja na função a seguir:

summarise_by <- function(df, var_grupo, var_soma){
  
  teste_1 <- try(is.character(var_grupo), silent = T)
  teste_2 <- try(is.character(var_soma), silent = T)

  if(teste_1 == T & teste_2 == T){
    summ_sym <- sym(var_soma)
    group_syms <- sym(var_grupo)

    return(df %>%
      group_by(!!group_syms) %>%
      summarise(soma = sum(!!summ_sym)) %>%
      arrange(!!group_syms))

  }else if(teste_1 != T & teste_2 != T){

    summ_sym <- enquo(var_soma)
    group_syms <- enquo(var_grupo)
    
    return(df %>%
      group_by(!!group_syms) %>%
      summarise(soma = sum(!!summ_sym)) %>% 
      arrange(!!group_syms))
   }
}

summarise_by(df, g1, a)
## # A tibble: 2 x 2
##      g1  soma
##   <dbl> <int>
## 1     1     8
## 2     2     7
# Ou
summarise_by(df, "g1", "a")
## # A tibble: 2 x 2
##      g1  soma
##   <dbl> <int>
## 1     1     8
## 2     2     7

Capturando expressão para filtrar

Se quisermos capturar uma expressão a ser utilizada no filter, o procedimento é bem parecido:

meu_filtro <- function(df, filtro){
  
  filtro <- enquo(filtro)
  
  df %>% 
    filter(!!filtro)
}

meu_filtro(df, g1 == 1)
## # A tibble: 2 x 4
##      g1    g2     a     b
##   <dbl> <dbl> <int> <int>
## 1     1     1     5     1
## 2     1     2     3     4

Joins

Ao fazer algum dos joins do dplyr, existe a necessidade de explicitar a(s) variável(is) que será(ão) utilizada(s) ao juntar as duas bases. Porém sabemos que o argumento by requer um vetor de caractere nomeado, ou seja, c("c1" = "c2"), mas também já sabemos que um enquo não é um caractere, e sim uma fórmula. Suponha as seguintes bases:

base1 <- tibble(id = sample(1:5, 3), val = sample(1:10, 3))
base1
## # A tibble: 3 x 2
##      id   val
##   <int> <int>
## 1     3    10
## 2     4     1
## 3     2     7
base2 <- tibble(id = sample(1:5, 3), val = sample(1:10, 3))
base2
## # A tibble: 3 x 2
##      id   val
##   <int> <int>
## 1     1     5
## 2     3     6
## 3     2     4

Suponha uma função que vai simplesmente fazer um full_join entre as bases. Para fazer isso, precisaremos das funções set_names e quo_name. (vamos ativar o pacote rlang para utilizar set_names):

require(rlang)

meu_full_join <- function(b1, b2, id1, id2){
  
  id1 <- enquo(id1)
  id2 <- enquo(id2)
  
  by <- set_names(quo_name(id2), quo_name(id1))
  
  b1 %>% 
    full_join(b2, by = by)
}

meu_full_join(base1, base2, id, id)
## # A tibble: 4 x 3
##      id val.x val.y
##   <int> <int> <int>
## 1     3    10     6
## 2     4     1    NA
## 3     2     7     4
## 4     1    NA     5

Note que a função set_names funciona da seguinte forma, ela cria um vetor nomeado utilizando o primeiro argumento como valor e o segundo argumento como nome deste nosso novo vetor.

set_names(1:4, letters[1:4])
## a b c d 
## 1 2 3 4

que é o mesmo de criar vetores nomeados utilizando a função base c, como vamos ver abaixo:

c("a" = 1, "b" = 2 , "c" = 3, "d" = 4 )
## a b c d 
## 1 2 3 4

voltando ao nosso objetivo principal, por isso que é colocado o quo_name da segunda base primeiro e depois o quo_name da primeira base.

Função com múltiplas variáveis

Vamos criar uma função que conta o número de casos para todas as combinações de valores dos grupos que serão passados como argumentos, mas não vamos fixar o número de variáveis, vamos aceitar quantas variáveis forem passadas. Precisamos fazer três mudanças:

  • Usar ... na definição da função;
  • Usar quos() ao invés de enquo();
  • Usar !!! ao invés de !!.
conta_casos <- function(df, ...) {
  
  var_grupo <- quos(...)

  df %>%
    group_by(!!!var_grupo) %>%
    summarise(n = n())
}

conta_casos(df, g1, g2)
## # A tibble: 4 x 3
## # Groups:   g1 [?]
##      g1    g2     n
##   <dbl> <dbl> <int>
## 1     1     1     1
## 2     1     2     1
## 3     2     1     2
## 4     2     2     1

Se quisermos passar essas variáveis para ... com aspas, basta utilizar a função syms() ao invés de quos na função. A diferença maior fica na chamada da função. Veja:

conta_casos_string <- function(df, ...) {
  
  var_grupo <- syms(...)

  df %>%
    group_by(!!!var_grupo) %>%
    summarise(n = n())
}

conta_casos_string(df, list("g1", "g2"))
## # A tibble: 4 x 3
## # Groups:   g1 [?]
##      g1    g2     n
##   <dbl> <dbl> <int>
## 1     1     1     1
## 2     1     2     1
## 3     2     1     2
## 4     2     2     1

A diferença é que na hora de chamar a função precisamos passar os argumentos como uma lista.

Checando as múltiplas variáveis

Podemos querer verificar se os múltiplos argumentos em ... foram ou não passados pra função. Vamos fazer essa checagem com a função conta_casos, criada acima, utilizando apenas a função length.

conta_casos <- function(df, ...) {
  
  var_grupo <- quos(...)
  
  if(length(var_grupo) > 0){
    df %>%
      group_by(!!!var_grupo) %>%
      summarise(n = n())
  }else{
    warning("Colunas para agrupar não encontradas")
    df %>% 
      summarise(n = n())
  }
}  

conta_casos(df)
## Warning in conta_casos(df): Colunas para agrupar não encontradas
## # A tibble: 1 x 1
##       n
##   <int>
## 1     5