C. Lente

A Magia de Purrr

Você já ouviu falar sobre um pacote de R chamado purrr? Ele é descrito como um toolkit de programação funcional para a linguagem R e permite que façamos coisas honestamente incríveis. Se você nunca ouviu falar sobre o purrr ou mesmo se já ouviu falar mas não sabe de todo o seu potencial, esse post é feito para você.

Perd√£o pela honestidade

Se voc√™ quisesse tornar o R mais conciso, o que voc√™ mudaria nele? Uma boa primeira tentativa talvez envolvesse simplificar a “composi√ß√£o de fun√ß√Ķes” (o ato de aplicar uma fun√ß√£o ao resultado de outra). D√™ uma olhada nesse exemplo horr√≠vel:

car_data <- 
  transform(aggregate(. ~ cyl, 
                      data = subset(mtcars, hp > 100), 
                      FUN = function(x) round(mean(x, 2))), 
            kpl = mpg*0.4251)

Se n√£o quisermos salvar resultados intermedi√°rios, compor diversas fun√ß√Ķes passa a ser super importante. Mas a estrat√©gia mostrada acima faz com que seja muito dif√≠cil entender o que est√° acontecendo e em que ordem (para os nerds lendo isso, √© o equivalente de escrever $g(f(x))$ ao inv√©s de $f \circ g(x)$). Em R, a solu√ß√£o para esse problema vem na forma do “pipe", um operador que nos permite colocar a primeira fun√ß√£o antes da segunda e n√£o dentro dela:

car_data <- 
  mtcars %>%
  subset(hp > 100) %>%
  aggregate(. ~ cyl, data = ., FUN = . %>% mean %>% round(2)) %>%
  transform(kpl = mpg %>% multiply_by(0.4251))

Mas o que mais voc√™ mudaria no R? Bem, o pr√≥ximo lugar que evidentemente precisa de uma melhoria s√£o s√≥ la√ßos…

Listas de listas

Antes de começarmos a demonstração, você vai precisar de alguns pacotes. Instale-os rodando o código abaixo:

install.packages(c("devtools", "purrrr"))
devtools::install_github("jennybc/repurrrsive")
library(purrr)

Agora que temos tudo pronto, vamos conhecer melhor a estrela desse tutorial! gh_repos é uma lista multi-nível gigantesca que pode assustar até os programadores de R mais experientes. Eu vou renomear gh_repos para ghr por simplicidade:

ghr <- repurrrsive::gh_repos

Felizmente a sua estrutura √© simples o suficiente para que possamos us√°-la para prop√≥sitos educacionais. O primeiro n√≠vel de ghr √© composto por 6 listas, cada uma representando um usu√°rio do GitHub. Cada uma destas listas √© feita de mais ou menos 30 listas menores representando os reposit√≥rios daquele usu√°rio. Cada reposit√≥rio tem mais de 60 campos com informa√ß√Ķes sobre o repo; um destes campos tamb√©m √© uma lista e cont√©m dados de login pertencentes ao dono do repo.

Eu sei que isso não parece muito fácil de entender, mas vamos revisar a estrutura algumas vezes ainda. Antes de tudo vamos só refrescar as nossas habilidades com listas e descobrir quantos repositórios o primeiro usuário em ghr tem:

length(ghr[[1]])
# [1] 30

Nesse pequeno comando estamos selecionando o primeiro elemento do primeiro n√≠vel da lista (ghr[[1]]) ou, em outras palavras, estamos escolhendo o primeiro usu√°rio. Ao aplicar length() neste usu√°rio, podemos ver quantos elementos ele(a) tem, resultando no n√ļmero de reposit√≥rios pertencentes a ele(a). De forma geral, se quis√©ssemos ver quantos campos de informa√ß√£o tem o terceiro repo deste usu√°rio (ou o comprimento do terceiro elemento do segundo n√≠vel associado ao primeiro elemento do primeiro n√≠vel), poder√≠amos rodar length(ghr[[1]][[3]]).

Laços de uma linha

Agora que você se lembra de como listas funcionam em R, um óbvio incremento de dificuldade é descobrir quantos repositórios cada usuário tem. Isso pode ser resolvido com o bom e velho laço for, aplicando length() a cada elemento do primeiro nível de ghr:

lengths <- c()
for (i in seq_along(ghr)) {
  lengths <- c(lengths, length(ghr[[i]]))
}
lengths
# [1] 30 30 30 26 30 30

Mas vamos com calma, tem que existir um jeito mais f√°cil! S√≥ estamos iterando em uma lista, por que precisamos de i, c(), seq_along(), ou mesmo lengths? √Č aqui que o map() entra em jogo, o carro chefe do pacote purrr. map() √© uma abstra√ß√£o de la√ßos, permitindo que iteremos nos elementos de uma lista e n√£o em alguma vari√°vel auxiliar. Em outras palavras ele aplica uma fun√ß√£o em todo elemento de uma lista.

map(ghr, length)
# [[1]]
# [1] 30
# 
# [[2]]
# [1] 30
# 
# [[3]]
# [1] 30
# 
# [[4]]
# [1] 26
# 
# [[5]]
# [1] 30
# 
# [[6]]
# [1] 30

Bem, usamos menos linhas de c√≥digo, mas o que est√° acontecendo com essa sa√≠da? map() √© uma fun√ß√£o muito gen√©rica, ent√£o ela sempre retorna listas (assim ela n√£o precisa se preocupar com o tipo da sa√≠da). Mas map() tem v√°rias fun√ß√Ķes irm√£s, map_xxx() (map_dbl(), map_chr(), map_lgl(), …), que s√£o capazes de “nivelar” a sa√≠da se voc√™ j√° souber que tipo ela ter√°. No nosso caso queremos um vetor de doubles, ent√£o usamos map_dbl():

map_dbl(ghr, length)
# [1] 30 30 30 26 30 30

Você viu isso?! São apenas 21 caracteres e eles fizeram a mesma coisa que aquele laço horrível lá em cima!

F√≥rmulas e fun√ß√Ķes

Agora que voc√™ j√° conheceu os princ√≠pios fundamentais do purrr, eu vou lhe apresentar √†s fun√ß√Ķes an√īnimas, outra funcionalidade interessant√≠ssima do pacote. Elas s√£o fun√ß√Ķes que podemos definir dentro de um map() sem ter que nome√°-las, aparecendo em duas formas: f√≥rmulas e fun√ß√Ķes.

F√≥rmulas s√£o antecedidas por um til e voc√™ n√£o pode controlar o nome de seus argumentos. Fun√ß√Ķes por outro lado s√£o, bem, fun√ß√Ķes normais do R. Primeiramente vamos ver como f√≥rmulas funcionam:

map_dbl(ghr, ~length(.x))
# [1] 30 30 30 26 30 30

Fórmulas nos permitem passar argumentos para a função sendo mapeada. Lembre-se de como estamos tirando o comprimento de cada sub-lista de ghr? Se usarmos a notação-til podemos explicitamente acessar aquele elemento e colocá-lo onde quisermos dentro da chamada da função, mas o seu nome será .x independentemente de qualquer outra coisa.

map(1:3, ~runif(2, max = .x))
# [[1]]
# [1] 0.2512402 0.4499058
# 
# [[2]]
# [1] 1.767479 1.600513
# 
# [[3]]
# [1] 2.367293 1.263795

No exemplo acima temos que usar a notação-til porque, se não tivéssemos, o vetor 1:3 acabaria sendo usado como o primeiro argumento de runif(). E falando em argumentos, map() convenientemente permite que você envie qualquer outro argumento fixo no final da chamada (note como desta vez 1:3 é usado automaticamente como o primeiro argumento).

map(1:3, runif, min = 3, max = 6)
# [[1]]
# [1] 3.902211
# 
# [[2]]
# [1] 4.511896 4.405196
# 
# [[3]]
# [1] 5.498137 3.940454 5.413348

E por √ļltimo mas n√£o menos importante, fun√ß√Ķes. Elas s√£o muito parecidas com f√≥rmulas, entretanto aqui voc√™ pode nomear os argumentos como quiser (a desvantagem √© que voc√™ tem que definir a fun√ß√£o de forma bastante prolixa):

map(1:3, function(n) { runif(n, min = 3, max = 6) })
# [[1]]
# [1] 4.54061
# 
# [[2]]
# [1] 3.557612 4.022569
# 
# [[3]]
# [1] 3.369300 4.109919 4.095583

Maps e maps

Como voc√™ j√° deve ter percebido, tamb√©m √© poss√≠vel chamar uma map() dentro da outra! Isso √© muito √ļtil quando queremos acessar n√≠veis mais profundos de uma lista (como quando falamos sobre length(ghr[[1]][[3]])). Vamos ver quantos campos de informa√ß√£o tem cada repo de cada usu√°rio:

map(ghr, ~map(.x, length))
# [[1]]
# [[1]][[1]]
# [1] 68
# 
# [[1]][[2]]
# [1] 68
# 
# [[1]][[3]]
# [1] 68
# 
# [[1]][[4]]
# [1] 68
#
# ...

map(ghr, ~map_dbl(.x, length))
# [[1]]
# [1] 68 68 68 68 68 68 68 68 68 68 68 68 68 68 68 68 68 68 68
# [20] 68 68 68 68 68 68 68 68 68 68 68
# 
# [[2]]
# [1] 68 68 68 68 68 68 68 68 68 68 68 68 68 68 68 68 68 68 68
# [20] 68 68 68 68 68 68 68 68 68 68 68
# 
# ...

O primeiro comando acima devolve uma lista de listas muito longas, mas isso se deve somente ao fato de que o map() mais interior retorna uma lista para cada repo e depois o map() mais de fora embrulha tudo aquilo em outra lista. Para uma sa√≠da mais inteligente, usar map_dbl() na chamada mais interna nos permite devolver um √ļnico vetor para cada usu√°rio.

No entanto, esse campos cont√©m outras informa√ß√Ķes preciosas. At√© agora nossa lista permaneceu completamente sem nomes, o que significa que cada lista de usu√°rio e cada lista de repo n√£o est√£o marcadas com os nomes dos usu√°rios e repos. Vamos ver se podemos encontrar os nomes dos usu√°rios no campo login da lista $owner de cada repo (note o uso de map_chr(); esse √© o equivalente de map_dbl() para caracteres):

map(ghr, function(user) {
  map_chr(user, ~.x$owner$login)
})
# [[1]]
# [1] "gaborcsardi" "gaborcsardi" "gaborcsardi" "gaborcsardi"
# [5] "gaborcsardi" "gaborcsardi" "gaborcsardi" "gaborcsardi"
# ...
# 
# [[2]]
# [1] "jennybc" "jennybc" "jennybc" "jennybc" "jennybc"
# [6] "jennybc" "jennybc" "jennybc" "jennybc" "jennybc"
# ...
# 
# ...

map(ghr, function(user) {
  user %>% map_chr(~.x$owner$login)
})
# ...

map(ghr, ~map_chr(.x, ~.x$owner$login))
# ...

Todos os 3 comandos devolvem exatamente a mesma coisa, mas o primeiro é o mais fácil de entender. Para cada autor, iteramos em seus repos e acessamos o elemento $owner$login. O segundo nos mostra que é possível mapear um pipe. O terceiro por sua vez condensa tudo ao máximo (note como usamos .x duas vezes; a primeira vez vem do map() e representa cada usuário, enquanto a segunda vem do map_chr() e representa cada repo).

No entanto, todos todos os comandos sofrem de repetição na saída dado que estamos fazendo a mesma coisa para cada repo disponível. Já que só precisamos dessa informação uma vez para cada usuário, podemos usar o bom e velho [1] para pegar apenas o primeiro elemento do vetor retornado por map_chr() e depois usar outro map_chr() para que não precisemos lidar com listas estranhas:

map_chr(ghr, ~map_chr(.x, ~.x$owner$login)[1])
# [1] "gaborcsardi" "jennybc"     "jtleek"      "juliasilge" 
# [5] "leeper"      "masalmon" 

Pipes e maps

Na se√ß√£o acima usamos map()s com pipes, e agora vamos usar pipes com map()s. Isso deveria ser bastante l√≥gico dado o √ļltimo trecho de c√≥digo, mas vamos usar o map() para pegar o login dos usu√°rios, usar set_names() para dar nomes aos usu√°rios de acordo com seus logins e por fim usar pluck() para selecionar a lista de reposit√≥rios de “jennybc” (note o ponto em set_names(); ele representa o resultado vindo da linha cima, estamos usando ele como o segundo argumento da fun√ß√£o):

ghr %>%
  map_chr(~map_chr(.x, ~.x$owner$login)[1]) %>%
  set_names(ghr, .) %>%
  pluck("jennybc")
# ...

A sa√≠da desse comando foi omitida, mas usando pluck() selecionamos apenas o elemento de ghr chamado “jennybc” (essa fun√ß√£o trabalha exatamente como [[]], ent√£o poder√≠amos ter usado 2 j√° que a lista de Jenny √© a segunda do primeiro n√≠vel).

E assim por diante…

Agora que sabemos nomear o primeiro nível da estrutura, que tal fazermos o mesmo para os repos? Para isso precisamos ir um nível mais fundo e colocar nomes lá também:

ghr %>%
  map(function(user) {
    user %>%
      set_names(map(., ~.x$name))
  }) %>%
  pluck("jennybc", "eigencoder")
# ...

ghr %>%
  map(~set_names(.x, map(.x, ~.x$name))) %>%
  pluck("jennybc", "eigencoder")
# ...

As duas sequencias devolvem a mesma coisa (omitida), mas a segunda é muito mais concisa. Aqui estamos iterando nos usuários, nomeando cada repo de acordo com o elemento $name de cada um e por fim selecionando o repositório eigencoder de Jenny (que seria equivalente a [[2]][[30]]).

Dois coelhos

O legal de programar é tentar escrever a mesma coisa no mínimo de caracteres possível (tarefa apelidada de code golf). Antes de nomearmos tanto usuários e repos, vamos deixar o processo de nomear usuários seja um pouco mais enxuto:

set_names(ghr, map_chr(ghr, ~map_chr(.x, ~.x$owner$login)[1]))
set_names(ghr, map(ghr, ~map(.x, ~.x$owner$login)[[1]]))
set_names(ghr, map(ghr, ~.x[[1]]$owner$login))

Todos os 3 comandos fazem a mesma coisa, sendo que já vimos o primeiro antes. O segundo comando aproveita-se do fato de que set_names() não precisa receber um vetor como argumento, uma lista também funciona. O terceiro inverte a ideia de pegar o login de todos os repos e depois selecionar o primeiro pegando o login apenas do primeiro repo.

Agora que temos a forma mais curta possível de nomear os elementos principais de ghr, aqui está o que eu chamo de dois coelhos em uma cajadada:

ghr <- ghr %>%
  set_names(map(., ~.x[[1]]$owner$login)) %>%
  map(~set_names(.x, map(.x, ~.x$name)))
  
> names(ghr)
# [1] "gaborcsardi" "jennybc"     "jtleek"      "juliasilge" 
# [5] "leeper"      "masalmon"  

> names(ghr$jennybc)
# [1] "2013-11_sfu"                         
# [2] "2014-01-27-miami"                    
# [3] "2014-05-12-ubc"
# ...

E falando em “dois”…

Para finalizar esse tutorial, vou criar uma fun√ß√£o simples que retorna o n√ļmero de estrelas que cada usu√°rio tem. Nessa tarefa temos que iterar em dois objetos: ghr e os nomes de seus usu√°rios.

about <- function(user, name) {
  stars <- map_dbl(user, ~.x$stargazers_count) %>% sum()
  message(name, " has ", stars, " stars!")
}

map2(ghr, names(ghr), about)
# gaborcsardi has 289 stars!
# jennybc has 190 stars!
# jtleek has 4910 stars!
# juliasilge has 308 stars!
# leeper has 66 stars!
# masalmon has 47 stars!

Em about() pegamos a soma da contagem de star gazers de cara repo de um usuário (usando map_dbl(), claro) e depois soltamos a mensagem com o nome. Para fazer isso para cada usuário de ghr, usamos a prima mais próxima de map(): map2().

Essa função é análoga a map(), mas itera em duas listas ao invés de somente usa (note que para fórmulas usamos .x no lugar dos elementos da primeira lista e .y no lugar dos elementos da segunda). E agora que você já entende os membros mais importantes da família map(), aqui está uma lista de todos os outros que você já pode começar a usar:

  • map2_xxx() (an√°loga a map_xxx())
  • pmap() (com a qual voc√™ pode iterar em quantos elementos forem necess√°rios)
  • lmap() (para mapear com fun√ß√Ķes que recebem e retornam listas)
  • imap() (para iterar em uma lista e seus nomes, assim como acabamos de fazer)
  • map_at()/map_if() (fun√ß√Ķes que permitem com que voc√™ filtre quais elementos ser√£o mapeados)

Palavras finais

Esse não foi um post pequeno, mas eu sinto que não poderia ter feito ele em menos palavras. Mapeamento é um conceito complicado e demorou muito tempo para que eu entendesse o pouco que eu sei.

O pacote purrr √© realmente uma ferramenta incr√≠vel (na minha opini√£o, a mais conveniente e bonita da linguagem R) e √© justo dizer que a map() √© grande parte do motivo… Mas ela n√£o √© a √ļnica fam√≠lia de fun√ß√Ķes no pacote!

No pr√≥ximo post falaremos sobre algumas outras fun√ß√Ķes do purrr: reduce(), flatten(), invoke(), modify(), possibly() e keep(). Enquanto isso, d√™ uma olhada no meu github, no de Jennifer Bryan (autora do repurrrsive) e no de Hadley Wickham (autor do purrr e outros pacotes incr√≠veis de R).