Cientista de dados, aquele ser que pode ser imaginado como um super-geek por trás dos modelos solucionadores de mistérios. Bom isso acontece, mas o que pouca gente conta é que um bom pedaço do tempo desse profissional é investido em dataPrep, nada cool, costuma ser demorado e delicado, essa última parte quer dizer que “não, não da para automatizar 100% do dataPrep, o ideal é sempre ter um humano se certificando de que tudo está bem”. É inevitável analisar alguns histogramas, estudar dados faltantes, fazer filtros, agrupamentos e tudo o mais.

É aí que entra o data.table. Ah, data.table! Essa belezinha simplesmente é o jeito mais legal de virar sua base de dados ao avesso de forma “braçal”, é uma biblioteca que permite ao usuário adotar uma ‘linguagem’ de manipulção de base que é curta e, na grande maioria das vezes, bem mais rápida que a concorrência.

Ok, ok, isso é fala de fã, admito. O que vou fazer aqui então é dar uma pequena demonstração de como um comando pode ser passado do aclamado dplyr para datatable de forma simples, uma vez que se entende a estrutura, e como isso faz diferença no desempenho do script. Para isso vamos começar construindo uma base de 20 milhões de linhas e 10 colunas.

library(data.table)
library(dplyr)
library(purrr) # For map() function
library(microbenchmark) # For benchmarking
library(tidyverse)

dfs <- rep(2e7,10)

set.seed(123456)
myList <- map(dfs,   #amostra de 20kk de valores no range de 1:1e5, 10 vezes
              base::sample,
              x = 1:1e5,
              replace = TRUE) %>%
  map(matrix,  # em cada posicao da lista, quebra o vetor em 10 e joga cada parte como coluna de uma matriz
      ncol = 10) %>%
  map(data.frame)  # transforma todas matrizes da lista em data.frames
  
  df3 <- data.table::rbindlist(myList) # une as bases na lista

Antes de prosseguir, vamos dar uma olhada na base.

  df3
             X1    X2    X3    X4    X5    X6    X7    X8    X9   X10
       1: 87921 55793 81282 39316 43389 54449 60047 18947 21528 46857
       2: 78535 91446 36566 17892  6147 33818 67607 22926 26595 30526
       3:  6326 76229 60797 92675  1069 23344 51051 30793 47039 52544
       4: 76518 66161 48164 35810 32729 87865 21972 73380 88603 11411
       5: 57728  2418 17606 59877 72380 39148 85925  4679   824 28162
      ---                                                            
19999996: 61381 43589 70131 41942 32774 19965  4749 52238 50814 50884
19999997: 45927  4094 38977 76729 90735 21795  9222 63427 70221 26999
19999998: 99080 70908  9367 14237 39944 77173 36196 95234 31328 47128
19999999: 60724 37080  4661 49043 70521 87968 38904 10466 16827 46864
20000000:  4670 63739 27229 73348 72747 21393 29047 91010 44935 96431

Agora suponha que estamos na fase de dataPrep desta base, tentando descobrir coisas sobre ela como : “ Qual o valor minimo de X3 agrupado por X1?”. Vamos escrever isso em dplyr e datatable para entender como é simples traduzir de um para o outro.

 # escrevendo em datatable 
teste1 <- df3[,.(min_X3 = min(X3)),.(X1)][order(X1)]

# escrevendo em dplyr
teste2 <- df3 %>% 
          group_by(X1) %>%
          summarize(min_X3 = min(X3))
          
# conferindo se as saídas são iguais          
all.equal(teste1,as.data.table(teste2))
[1] TRUE

Detalhando o que acontece no código acima, partindo do entendimento de dplyr

 output <- data %>%  # primeiro passo: Fornecer a base a ser manipulada
           group_by(var) %>%  # segundo passo: informar variável de agrupamento
           summarize( var_label = func(var2)) # terceiro passo: aplicar função na variável de interesse, dando resultado por grupo.

# A tradução para datatable pode ser confusa no princípio porque apesar de ter uma ordem cronológica, no datatable talvez essa ordem não seja tão intuitiva.

# estrutura básica para operar com datatable

data[filtro, .(summarize), .(group_by)] 

# para entender ideia de ordem no datatable temos que:

data[ filtro passo 1, .(summarize passo 1), .(group_by passo 1)]

# para ir ao passo 2 teríamos

data2 <- data[ filtro passo 1, .(summarize passo 1), .(group_by passo 1)]
data2[ filtro passo 2, .(summarize passo 2), .(group_by passo 2)]

# mas a beleza do pacote é fazer com que você precise enderessar objetos o mínimo possível então da para escrever assim

output <- data[ filtro passo 1, .(summarize passo 1), .(group_by passo 1)][ filtro passo 2, .(summarize passo 2), .(group_by passo 2)] ... [ filtro passo n, .(summarize passo n), .(group_by passo n)]

#vale lembrar que é por conta da estrutura básica que quando declaramos teste1, precisamos começar com vírgula, para indicar que não queremos filtros no nosso cálculo.
#Fora isso basta copiar o que você colocaria no 'summarize' para o espaço após a primeira virgula e copiar o que você colocaria no 'group_by' após a segunda vírgula.

Agora que já sabemos como gerar a mesma operação por grupo nos dois pacotes, vamos comparar o desempenho com microbenchmark, esta função executa uma expressão um dado número de vezes e conta o tempo gasto em cada iteração. Para esse caso, foram feitas 100 execuções de cada expressão.

 time_champs <- microbenchmark({
 df3[,.(min_X3 = min(X3)),.(X1)][order(X1)]
 }, times = 100L)

time_nonchamps <- microbenchmark({
    df3 %>% 
    group_by(X1) %>%
    summarize(min_X3 = min(X3))
    }, times = 100L)

hist1 hist2

1 segundo com datatable e uma variação muito pequena desse tempo quando repetimos a ação várias vezes, já o dplyr leva de 2 à 4 segundos. A coisa fica pior para o dplyr se tivermos interesse num grupo maior e em mais resultados no summarize.

 time_champs2 <- microbenchmark({
                df3[,.(min_X3 = min(X3),
                       max_X3 = max(X3)),.(X1,X2)][order(X1,X2)]
}, times = 10L)

time_nonchamps2 <- microbenchmark({
                   df3 %>% 
                       group_by(X1,X2) %>%
                       summarize(min_X3 = min(X3),
                                 max_X3 = max(X3))
}, times = 10L)

hist3 hist4

Aqui o dplyr levou de 28 à 230 segundos, contra 5.5 à 5.7 segundos do datatable.

Supondo que isso não seja suficiente para te convencer a dar uma chance ao datatable, vamos à um caso de join que tem uma solução muito eficaz e interessante em datatable.

d1<-data.table(v1=c("a","b","c","d","d","b","a","c","a","d","b","a"),
               v2=(seq(1:12)),V3=rep(1:4,times=3))

head(d1)
   v1 v2 V3
1:  a  1  1
2:  b  2  2
3:  c  3  3
4:  d  4  4
5:  d  5  1
6:  b  6  2

d2<-data.table(v1=c("a","b","c","d"),v3=c(3,2,1,4),v4=c("y","x","t","e"))

head(d2)
   v1 v3 v4
1:  a  3  y
2:  b  2  x
3:  c  1  t
4:  d  4  e

O objetivo é colocar a informação da coluna v4 na base d1 para os casos onde os valores de v1 e v3 são equivalentes nas duas bases. No dplyr para resolver esse problema seria necessário usar um join, que copiaria d1 e ameaça explodir sua memória se d1 for grande. Com datatable, duas linhas resolvem o problema.

setkey(d1, v1, V3) 
d1[d2, v4 := v4][]

# checando o resultado
    v1 v2 V3   v4
 1:  a  1  1 <NA>
 2:  a  9  1 <NA>
 3:  a  7  3    y
 4:  a 12  4 <NA>
 5:  b  2  2    x
 6:  b  6  2    x
 7:  b 11  3 <NA>
 8:  c  3  3 <NA>
 9:  c  8  4 <NA>
10:  d  5  1 <NA>
11:  d 10  2 <NA>
12:  d  4  4    e

Mágico, hein? Mas o que aconteceu?

  • Primeiro indicamos as variáveis de d1 que serão consideradas chave.
  • Em seguida temos d1[d2] que escrito dessa forma equivale a um right_join entre as bases utilizando as chaves definidas, mas ainda não é esse o objetivo, porque se o match não existir o que queremos ver em v4 é NA.
  • := equivale ao mutate e vai adicionar a coluna de acordo com a referência, sendo assim as linhas sem match serão NA.
  • Por fim, o [] no final serve para printar o resultado.

Fato curioso sobre este exemplo, é que ele é um caso real, que gerou ‘pergunta no stackoverflow’ uns anos atrás. A pessoa tinha uma base grande em mãos e já havia tentado loop, que se mostrou impraticável, depois partiu para merge e sqldf que não deram conta do problema.

Vale ressaltar que o data.table é tudo isso sim, mas ele não é para big data, ele é um last resort, se ficar impraticável com data.table é porque tem que partir uma ferramenta específica para manipular big data.

Últimas considerações

  • A inspiração principal para esse post veio quando eu comecei a perceber a frequência com que problemas do meu dia a dia, envolvendo manipulações pesadas, podiam ser resolvidos com datatable. Como inspiração secundária os créditos vão para ‘este post’ onde eu descobri que o jeito mais eficaz de agregar bases numa lista é com data.table::rbindlist, além de pegar emprestado seu método de gerar aquela base enorme que uso no início, então créditos duplos a ele.

  • Quer dar uma chance ao data.table, mas tá magoado com a sintaxe dele e/ou receoso de sair do dplyr que é tão organizado e intuitivo? ‘Aqui’ você encontra um de-para muito bem feito de ações importantes nas duas bibliotecas.

  • Para interessados, o datatable está sendo implementado para Python, isso deixa bem bacana a transição de uma linguagem para outra pois sair de dplyr para pandas não é trivial, mas se você usa data.table, vai manipular bases nas duas linguagens sem estresse. Mais detalhes ‘aqui

  • Por último, porém talvez o mais importante para quem foi seduzido pelo datatable, ‘neste post do stackoverflow’ um camarada muito solícito faz uma análise detalhada da luta data.table vs dplyr, levando em consideração os pilares velocidade, uso de memória, sintaxe e features.

E aí, partiu usar data.table?!