Problemas com memória ou tempo de execução no dplyr? Use o data.table

O objetivo deste post é apresentar como utilizar o pacote data.table por meio de uma “tradução” das operações realizadas com o dplyr. Para um entendimento completo deste post, é importante que o leitor tenha uma boa noção de como o dplyr funciona.

Diego Castro
2020-01-29

Muitas vezes, ao trabalhar com dados muito pesados usando dplyr, tive problemas relacionados à memória do computador (o uso da memória era tão grande que o computador travava) e à velocidade de execução das operações. A alternativa que encontrei foi aprender o pacote data.table.

Você deve estar se perguntado: quais as vantagens e desvantagens desse pacote? Em geral, ele é mais eficiente em uso de memória e mais rápido do que o dplyr. Por outro lado, a sintaxe do dplyr é mais amigável e ele possui funções pra realização de operações mais complexas, como o pivot_longer() e o pivot_wider() por exemplo.

Vale comentar que existe um pacote recentemente lançado no CRAN chamado dtplyr. A ideia desse pacote é usar a sintaxe do dplyr para realizar operações com o data.table. No entanto, eu prefiro usar diretamente o data.table por já estar acostumado com a sintaxe e por não precisar de um intermediário, o que pode diminuir a eficiência do pacote.

Vamos então ao que interessa…

Os dados

Vamos usar os dados de exportações brasileiras no ano de 2019. Para baixar, basta acessar este link e baixar os dados de exportações de 2019. Uma alternativa é fazer isso via R da seguinte forma:


download.file(
  url = "http://www.mdic.gov.br/balanca/bd/comexstat-bd/ncm/EXP_2019.csv",
  destfile = "../../dados/EXP_2019.csv"
)

Após o download dos dados, vamos carregá-los. Para isso, iremos usar a nossa primeira função do pacote data.table chamada fread(). Essa função é bastante eficiente na leitura dos dados e os converte para duas classes: data.table e data.frame (que são as classes que precisamos para usar o data.table e dplyr respectivamente).


# Carregar pacotes

library(dplyr)
library(data.table)

# Carregar dados

dados <- fread(
  file = "../../dados/EXP_2019.csv",
  colClasses = c(rep("character", 8), rep("integer", 3))
)

# Verificar a classe do objeto dados

class(dados)

[1] "data.table" "data.frame"

Com os dados carregados, vamos entender um pouco de sua estrutura. Existem 11 variáveis, sendo 8 delas códigos ou siglas que representam, por exemplo, o produto exportado, Unidade da Federação (UF) exportadora, país de destino, e as outras 3 são a quantidade exportada, peso exportado e valor de exportação. Além disso, os dados possuem mais de 1 milhão e 400 mil linhas. Para observamos sua estrutura, basta usar o código abaixo.


glimpse(dados)

Observations: 1,423,026
Variables: 11
$ CO_ANO     <chr> "2019", "2019", "2019", "2019", "2019", "2019"...
$ CO_MES     <chr> "12", "08", "07", "07", "02", "11", "09", "09"...
$ CO_NCM     <chr> "19021900", "84149020", "08061000", "90328983"...
$ CO_UNID    <chr> "10", "10", "10", "11", "11", "10", "10", "10"...
$ CO_PAIS    <chr> "580", "756", "607", "586", "589", "158", "063...
$ SG_UF_NCM  <chr> "ES", "SP", "ES", "SP", "SP", "SP", "SP", "PR"...
$ CO_VIA     <chr> "00", "01", "00", "07", "04", "01", "07", "07"...
$ CO_URF     <chr> "0727600", "0817800", "0727600", "0917500", "0...
$ QT_ESTAT   <int64> 43, 4, 18, 88, 27, 54700, 158059, 2551, 433,...
$ KG_LIQUIDO <int64> 43, 4, 18, 299, 142, 54649, 158059, 2551, 89...
$ VL_FOB     <int> 180, 154, 68, 30805, 5695, 109073, 321274, 139...

Estrutura da sintaxe do data.table

\[data\_table[i, j, \text{by}]\]

Como apresentado acima, para usarmos o data.table precisamos primeiro identificar os dados em formato “data.table” que vamos manipular e em seguida abrir colchetes. Dentro dos colchetes, existem 3 partes. A primeira, antes da primeira vírgula (na estrutura acima representada pela letra i), apresenta operações realizadas nas linhas. A segunda parte, representada pela letra j, são operações nas colunas. Por fim, a última parte identifica eventuais agrupamentos nos dados para realizar operações em i ou j. Como vamos ver mais a frente, nem sempre todas as partes precisam estar explícitas em nossos códigos. Se vamos fazer apenas operações nas linhas, por exemplo, as partes j e by não precisar ser incluídas.

No data.table, muitas vezes a mesma operação poder ser feita usando notações diferentes. Como será mostrado a diante, as seleção de colunas, por exemplo, pode ser feita usando o símbolo .() ou diretamente com um vetor de nomes de colunas. Do mesmo modo, a criação de nova coluna pode ser realizada assim ‘:=’ (col = valores) ou diretamente assim col := valores. Neste post, irei apresentar apenas uma das formas para não tornar a leitura muito longa. De todo mundo, inserindo os termos “data.table” e “cheat sheet” no google você encontrará diversos resumos de como utilizar esse pacote.

Vamos à tradução do dplyr para o data.table.

Filter()

Vamos usar nossos dados para aprender filtrar informações usando o data.table. Imagine, então, que queiramos observar apenas as operações realizadas em agosto de 2019. No dplyr, fazemos o seguinte:


dados_dplyr <- dados %>% 
  filter(CO_MES == "08") 

head(dados_dplyr)

  CO_ANO CO_MES   CO_NCM CO_UNID CO_PAIS SG_UF_NCM CO_VIA  CO_URF
1   2019     08 84149020      10     756        SP     01 0817800
2   2019     08 44092900      16     607        PA     01 0217800
3   2019     08 84831019      11     097        SP     04 0817600
4   2019     08 71031000      10     386        RS     04 0817700
5   2019     08 76069200      10     232        SP     00 0817800
6   2019     08 72104910      10     741        SP     00 0817800
  QT_ESTAT KG_LIQUIDO VL_FOB
1        4          4    154
2      151     178854 182820
3        1          8     39
4        0          0    119
5        1          1     84
6       40         40    420

Em data.table, temos que realizar operações na parte i da estrutura apresentada acima. Basta fazermos o seguinte:


dados_dt <- dados[CO_MES == "08"]

head(dados_dt)

   CO_ANO CO_MES   CO_NCM CO_UNID CO_PAIS SG_UF_NCM CO_VIA  CO_URF
1:   2019     08 84149020      10     756        SP     01 0817800
2:   2019     08 44092900      16     607        PA     01 0217800
3:   2019     08 84831019      11     097        SP     04 0817600
4:   2019     08 71031000      10     386        RS     04 0817700
5:   2019     08 76069200      10     232        SP     00 0817800
6:   2019     08 72104910      10     741        SP     00 0817800
   QT_ESTAT KG_LIQUIDO VL_FOB
1:        4          4    154
2:      151     178854 182820
3:        1          8     39
4:        0          0    119
5:        1          1     84
6:       40         40    420

Repare que neste caso não há nenhuma vírgula dentro dos colchetes, ou seja, não incluímos operações nas colunas e não agrupamos os dados.

select()

Para selecionar colunas no dplyr, basta usar a função select(). Vamos selecionar, então, a coluna de produtos, país e UF, além das colunas de valores.


dados_dplyr <- dados_dplyr %>% 
  select(CO_NCM, CO_PAIS, SG_UF_NCM, QT_ESTAT, KG_LIQUIDO, VL_FOB)  

head(dados_dplyr)

    CO_NCM CO_PAIS SG_UF_NCM QT_ESTAT KG_LIQUIDO VL_FOB
1 84149020     756        SP        4          4    154
2 44092900     607        PA      151     178854 182820
3 84831019     097        SP        1          8     39
4 71031000     386        RS        0          0    119
5 76069200     232        SP        1          1     84
6 72104910     741        SP       40         40    420

A mesma operação no data.table é feita usando a parte j da estrutura apresentada. Repare, que neste caso, precisamos incluir uma vírgula antes da seleção de colunas para sinalizar que estamos realizando operações na parte j, mesmo deixando a parte i em branco. A parte do agrupamento (by) não é necessária neste caso. No código abaixo, usamos a notação do data.table .() para não precisarmos escrever os nomes das colunas em um vetor, o que faria com que tivéssemos que incluir aspas em todos os nomes. Se preferir, também é possível usar um vetor para selecionar as colunas.


dados_dt <- dados_dt[, .(CO_NCM, CO_PAIS, SG_UF_NCM, QT_ESTAT, KG_LIQUIDO, VL_FOB)]

head(dados_dt)

     CO_NCM CO_PAIS SG_UF_NCM QT_ESTAT KG_LIQUIDO VL_FOB
1: 84149020     756        SP        4          4    154
2: 44092900     607        PA      151     178854 182820
3: 84831019     097        SP        1          8     39
4: 71031000     386        RS        0          0    119
5: 76069200     232        SP        1          1     84
6: 72104910     741        SP       40         40    420

mutate()

A função mutate() do dplyr é usada para alterar colunas ou criar novas colunas. Vamos, em nosso exemplo, criar duas colunas, uma que transforme o valor exportado para milhões e uma coluna que receba um dos valores do vetor c("0", "1", NA) (essa coluna é criada apenas para fins didáticos, sem significado específico). No dplyr, isso é feito da seguinte forma:


set.seed(123)

dados_dplyr <- dados_dplyr %>% 
  mutate(
    VL_FOB_MILHAO = VL_FOB/1e6, 
    LABEL = sample(c("0", "1", NA), nrow(dados_dplyr), replace = TRUE)
  )

head(dados_dplyr)

    CO_NCM CO_PAIS SG_UF_NCM QT_ESTAT KG_LIQUIDO VL_FOB VL_FOB_MILHAO
1 84149020     756        SP        4          4    154      0.000154
2 44092900     607        PA      151     178854 182820      0.182820
3 84831019     097        SP        1          8     39      0.000039
4 71031000     386        RS        0          0    119      0.000119
5 76069200     232        SP        1          1     84      0.000084
6 72104910     741        SP       40         40    420      0.000420
  LABEL
1  <NA>
2  <NA>
3  <NA>
4     1
5  <NA>
6     1

No data.table faremos uso de uma outra notação especial: ':='. Esse símbolo é usado justamente para fazer operações de criação e alterações de colunas. Como no caso anterior, a parte i é deixada em branco e a última parte não é incluída. Estamos, portanto, utilizando a parte j para a operação.


set.seed(123)

dados_dt <- dados_dt[
  , 
  ':=' (VL_FOB_MILHAO = VL_FOB/1e6, 
        LABEL = sample(c("0", "1", NA), nrow(dados_dplyr), replace = TRUE))
]

head(dados_dt)

     CO_NCM CO_PAIS SG_UF_NCM QT_ESTAT KG_LIQUIDO VL_FOB
1: 84149020     756        SP        4          4    154
2: 44092900     607        PA      151     178854 182820
3: 84831019     097        SP        1          8     39
4: 71031000     386        RS        0          0    119
5: 76069200     232        SP        1          1     84
6: 72104910     741        SP       40         40    420
   VL_FOB_MILHAO LABEL
1:      0.000154  <NA>
2:      0.182820  <NA>
3:      0.000039  <NA>
4:      0.000119     1
5:      0.000084  <NA>
6:      0.000420     1

Note que novamente não utilizamos a última parte da estrutura por não estarmos fazendo nenhum tipo de agrupamento nos dados.

Subtituir NAs de uma coluna específica

Existem algumas maneiras de substituir NAs de colunas específicas usando o dplyr. Uma delas é usar a função mutate() em conjunto com a função ifelse(). Neste caso, alteraremos uma coluna a partir de uma condição em que, caso o elemento seja NA, nós o substituiremos por “2”.


dados_dplyr <- dados_dplyr %>% 
  mutate(LABEL = ifelse(is.na(LABEL), "2", LABEL))

head(dados_dplyr)

    CO_NCM CO_PAIS SG_UF_NCM QT_ESTAT KG_LIQUIDO VL_FOB VL_FOB_MILHAO
1 84149020     756        SP        4          4    154      0.000154
2 44092900     607        PA      151     178854 182820      0.182820
3 84831019     097        SP        1          8     39      0.000039
4 71031000     386        RS        0          0    119      0.000119
5 76069200     232        SP        1          1     84      0.000084
6 72104910     741        SP       40         40    420      0.000420
  LABEL
1     2
2     2
3     2
4     1
5     2
6     1

No data.table, usando o que aprendemos anteriormente, poderíamos fazer a substituição dos NAs usando a mesma condição acima na operação análoga ao mutate(). No entanto, gostaria de mostrar uma outra forma de fazer tal operação que utiliza operações em linhas e em colunas ao mesmo tempo. Veja abaixo.


dados_dt <- dados_dt[is.na(LABEL), LABEL := "2"]

head(dados_dt)

     CO_NCM CO_PAIS SG_UF_NCM QT_ESTAT KG_LIQUIDO VL_FOB
1: 84149020     756        SP        4          4    154
2: 44092900     607        PA      151     178854 182820
3: 84831019     097        SP        1          8     39
4: 71031000     386        RS        0          0    119
5: 76069200     232        SP        1          1     84
6: 72104910     741        SP       40         40    420
   VL_FOB_MILHAO LABEL
1:      0.000154     2
2:      0.182820     2
3:      0.000039     2
4:      0.000119     1
5:      0.000084     2
6:      0.000420     1

Neste caso, estamos dizendo ao data.table para “filtrar” as linhas nas quais a coluna LABEL é NA e inserir o caractere “2” em seu lugar. Escrevi “filtrar” entre aspas porque aqui há apenas um filtro temporário. Ao final da operação, nossos dados continuam com o mesmo número de linhas, diferentemente do filtro que apresentamos anteriormente.

Exluir colunas

No dplyr, para “dropar” colunas, basta usar a função select() e incluir o sinal negativo antes do nome das colunas, como abaixo.


dados_dplyr <- dados_dplyr %>% 
  select(-VL_FOB_MILHAO, -LABEL)

head(dados_dplyr)

    CO_NCM CO_PAIS SG_UF_NCM QT_ESTAT KG_LIQUIDO VL_FOB
1 84149020     756        SP        4          4    154
2 44092900     607        PA      151     178854 182820
3 84831019     097        SP        1          8     39
4 71031000     386        RS        0          0    119
5 76069200     232        SP        1          1     84
6 72104910     741        SP       40         40    420

No data.table temos que utilizar a notação análoga ao mutate() e atribuir NULL aos elementos das colunas que queremos excluir.


dados_dt <- dados_dt[, ':=' (VL_FOB_MILHAO = NULL, LABEL = NULL)]

head(dados_dt)

     CO_NCM CO_PAIS SG_UF_NCM QT_ESTAT KG_LIQUIDO VL_FOB
1: 84149020     756        SP        4          4    154
2: 44092900     607        PA      151     178854 182820
3: 84831019     097        SP        1          8     39
4: 71031000     386        RS        0          0    119
5: 76069200     232        SP        1          1     84
6: 72104910     741        SP       40         40    420

group_by() + mutate()

Imagine que queiramos criar uma coluna que conte quantas vezes cada país aparece nos nossos dados, mas sem perder nenhuma informação ao final da operação, ou seja, mantendo o número de linhas de antes da contagem. Neste caso, podemos usar no dplyr um group_by() em conjunto com o mutate(), como a seguir.


dados_dplyr <- dados_dplyr %>% 
  group_by(CO_PAIS) %>% 
  mutate(N_pais = n()) %>% 
  ungroup()

head(dados_dplyr)

# A tibble: 6 x 7
  CO_NCM   CO_PAIS SG_UF_NCM QT_ESTAT KG_LIQUIDO VL_FOB N_pais
  <chr>    <chr>   <chr>     <int64>  <int64>     <int>  <int>
1 84149020 756     SP          4           4        154   1568
2 44092900 607     PA        151      178854     182820   1728
3 84831019 097     SP          1           8         39   3885
4 71031000 386     RS          0           0        119   1741
5 76069200 232     SP          1           1         84    789
6 72104910 741     SP         40          40        420   2823

Finalmente iremos utilizar a terceira parte da nossa estrutura do data.table. Com esse pacote, vamos criar uma coluna como aprendemos anteriormente usando a notação especial .N para a contagem de linhas em conjunto com a parte by da estrutura do data.table.


dados_dt <- dados_dt[, ':=' (N_pais = .N), by = .(CO_PAIS)]

head(dados_dt)

     CO_NCM CO_PAIS SG_UF_NCM QT_ESTAT KG_LIQUIDO VL_FOB N_pais
1: 84149020     756        SP        4          4    154   1568
2: 44092900     607        PA      151     178854 182820   1728
3: 84831019     097        SP        1          8     39   3885
4: 71031000     386        RS        0          0    119   1741
5: 76069200     232        SP        1          1     84    789
6: 72104910     741        SP       40         40    420   2823

Se não quiséssemos definir o nome da nova coluna, bastaria escrever .N na parte j da estrutura e uma coluna chamada N seria criada com a contagem de linhas de acordo com o agrupamento definido.

group_by() + summarise()

Vamos agora descobrir qual valor o Brasil exportou para cada país por UF. Para isso, precisamos agrupar os dados por país e UF e agregar o valor exportado somando todas as linhas que apresentam o mesmo CO_PAIS e SG_UF_NCM. Fazemos isso no dplyr da seguinte maneira.


dados_dplyr <- dados_dplyr %>% 
  group_by(CO_PAIS, SG_UF_NCM) %>% 
  summarise(VL_FOB = sum(VL_FOB)) %>% 
  ungroup()

head(dados_dplyr)

# A tibble: 6 x 3
  CO_PAIS SG_UF_NCM VL_FOB
  <chr>   <chr>      <int>
1 013     DF         70211
2 013     ES         60894
3 013     GO         35372
4 013     MG         21172
5 013     MS        101644
6 013     MT          5861

No data.table novamente vamos utilizar a última parte da sintaxe, mas desta vez em conjunto com o símbolo .(). Segue o código.


dados_dt <- dados_dt[, .(VL_FOB = sum(VL_FOB)), by = .(CO_PAIS, SG_UF_NCM)]

head(dados_dt)

   CO_PAIS SG_UF_NCM   VL_FOB
1:     756        SP 48940232
2:     607        PA  1229280
3:     097        SP 52563137
4:     386        RS 26841890
5:     232        SP  2929105
6:     741        SP 38035742

Repare que a ordem dos nossos dados não é a mesma da apresentada pelo dplyr. Vamos reordená-los para verificar que nossas tabelas continuam iguais.


dados_dt <- dados_dt[order(CO_PAIS, SG_UF_NCM)]

head(dados_dt)

   CO_PAIS SG_UF_NCM VL_FOB
1:     013        DF  70211
2:     013        ES  60894
3:     013        GO  35372
4:     013        MG  21172
5:     013        MS 101644
6:     013        MT   5861

Tudo igual, não é mesmo?

left_join()

Para finalizar, vamos realizar uma operação de join. Como sabemos, existem alguns tipos diferentes de joins. Vamos aqui como exemplo realizar um left join, ou seja, juntaremos dois conjuntos de dados por meio de chaves mantendo todas as linhas dos dados da esquerda. Primeiramente vamos criar um data frame com as siglas de alguns estados e seus respectivos nomes.


estados <- data.frame(
  SG_UF_NCM = c("DF", "ES", "GO", "MG"),
  NO_UF_NCM = c("Distrito Federal", "Espirito Santo", "Goias", "Minas Gerais")
)

No dplyr realizamos tal operação usando a função left_join(). Observe que no data frame estados criado acima existem apenas 4 estados. Portanto, ao realizarmos o left join, as linhas de todos os estados que não estão no data frame criado receberão NA na coluna NO_UF_NCM.


dados_dplyr <- dados_dplyr %>% 
  left_join(
    x = ., 
    y = estados, 
    by = "SG_UF_NCM"
  )

head(dados_dplyr)

# A tibble: 6 x 4
  CO_PAIS SG_UF_NCM VL_FOB NO_UF_NCM       
  <chr>   <chr>      <int> <fct>           
1 013     DF         70211 Distrito Federal
2 013     ES         60894 Espirito Santo  
3 013     GO         35372 Goias           
4 013     MG         21172 Minas Gerais    
5 013     MS        101644 <NA>            
6 013     MT          5861 <NA>            

Como dito anteriormente, no data.table a mesma operações podem ser realizadas de diferentes formas. Aqui, o left join será feito usando a função merge.data.table(). Antes de fazer isso, precisamos lembrar de transformar o data frame como os nomes dos estados na classe data table. Para fazer isso, basta:


estados <- as.data.table(estados)

Agora podemos realizar o left join.


dados_dt <- merge.data.table(
  x = dados_dt,
  y = estados,
  by = "SG_UF_NCM",
  all.x = TRUE
)

dados_dt <- dados_dt[order(CO_PAIS, SG_UF_NCM)]

head(dados_dt)

   SG_UF_NCM CO_PAIS VL_FOB        NO_UF_NCM
1:        DF     013  70211 Distrito Federal
2:        ES     013  60894   Espirito Santo
3:        GO     013  35372            Goias
4:        MG     013  21172     Minas Gerais
5:        MS     013 101644             <NA>
6:        MT     013   5861             <NA>

Um detalhe importante da função merge.data.table() é saber com usar os argumentos all, all.x e all.y. Definindo all = TRUE estamos realizando um full join, ou seja, todas as linhas de ambos os dados serão trazidas para o dado final. Se all.x = TRUE, realizamos o left joint. Analogamente, all.y = TRUE refere-se ao right join. Finalmente, se definirmos all = FALSE estamos realizando um inner join, ou seja, apenas as linhas em que houve match nas chaves definidas permanecerão nos dados finais.

Conclusões

Vimos que as operações básicas do dplyr podem ser realizadas de maneira fácil com o data.table e na maioria das vezes utilizando menos linhas de código. Além disso, o data.table possui a vantagem de ser mais rápido e eficiente em relação ao uso de memória (não foi o objetivo deste post mostrar isso, mas você poderá encontrar facilmente comparações na internet). No entanto, pessoalmente eu prefiro na maior parte das vezes usar o dplyr por ser bastante prático e intuitivo. Costumo usar o data.table quando tenho que fazer operações pesadas como joins usando dados com milhões de linhas. De qualquer modo, vale a pena conhecer e aprender o data.table.

Citation

For attribution, please cite this work as

Castro (2020, Jan. 29). Fulljoin: Problemas com memória ou tempo de execução no dplyr? Use o data.table. Retrieved from https://www.fulljoin.com.br/posts/2020-01-25-dplyr-para-datatable/

BibTeX citation

@misc{castro2020problemas,
  author = {Castro, Diego},
  title = {Fulljoin: Problemas com memória ou tempo de execução no dplyr? Use o data.table},
  url = {https://www.fulljoin.com.br/posts/2020-01-25-dplyr-para-datatable/},
  year = {2020}
}