22  Arrow

22.1 Introdução

Arquivos CSV são feitos para serem lidos facilmente por seres humanos. Eles são um bom formato de intercâmbio porque são muito simples e podem ser lidos por qualquer ferramenta existente. Porém, arquivos CSV não são muito eficientes: você tem muito trabalho para importar os dados para o R. Neste capítulo, você aprenderá uma alternativa poderosa: o formato parquet, um formato baseado em um padrão aberto amplamente utilizado em sistemas de grande volume de dados (big data).

Nós iremos juntar arquivos parquet com o Apache Arrow, um conjunto de ferramentas multi-linguagens projetado para análise eficiente e transporte de grandes conjunto de dados. Usaremos Apache Arrow via o pacote arrow, o qual fornece um backend dplyr, permitindo que você analise conjuntos de dados maiores que a quantidade de memória de seu computador, usando a familiar sintaxe dplyr. Como benefício adicional, o arrow é extremamente rápido: você verá alguns exemplos a seguir neste capítulo.

Tanto o pacote arrow quanto o pacote dbplyr rodam por trás do dplyr (dplyr backends), assim, você deve estar se perguntando quando usar um ou outro. Em muitos casos, a escolha já foi feita para você, pois os dados já estão em bancos de dados ou em arquivos parquet e você trabalhará com eles da forma como já estão. Mas se você está começando com seus próprios dados (talvez arquivos CSV), você pode carregá-los em um banco de dados ou convertê-los para parquet. Geralmente, é dificil saber qual funcionará melhor, então para fases iniciais da tua análise, vamos te encorajar a tentar usar ambos e escolher aquele que funcione melhor para você.

(Um grande obrigado à Danielle Navarro que contribuiu com a versão inicial deste capítulo.)

22.1.1 Pré-requisitos

Neste capítulo, continuaremos a usar o tidyverse, particularmente o dplyr, mas iremos combiná-lo com o pacote arrow, que foi projetado especificamente para trabalhar com grandes conjuntos de dados.

Posteriormente neste capítulo, veremos também algumas conexões entre o arrow e o duckdb, portanto também precisaremos do dbplyr e duckdb.

library(dbplyr, warn.conflicts = FALSE)
library(duckdb)
#> Loading required package: DBI

22.2 Obtendo os dados

Começamos obtendo um conjunto de dados que precise destas ferramentas: um conjunto de dados de items retirados das bibliotecas públicas de Seattle, disponível online em data.seattle.gov/Community/Checkouts-by-Title/tmmm-ytt6. Este conjunto de dados possui 41.389.465 linhas que informam quantas vezes cada livro foi retirado em cada mês, desde Abril de 2005 até Outubro de 2022.

O código abaixo faz download de uma cópia em cache desses dados. O dado é um arquivo CSV de 9 GB, portanto leva um certo tempo para baixar. Eu recomendo fortemente usar curl::multi_download() para baixar grandes arquivos já que foi projetado exatamente para este propósito: fornece uma barra de progresso e pode retomar o download se for interrompido.

dir.create("data", showWarnings = FALSE)

curl::multi_download(
  "https://r4ds.s3.us-west-2.amazonaws.com/seattle-library-checkouts.csv",
  "data/seattle-library-checkouts.csv",
  resume = TRUE
)
#> # A tibble: 1 × 10
#>   success status_code resumefrom url                    destfile        error
#>   <lgl>         <int>      <dbl> <chr>                  <chr>           <chr>
#> 1 TRUE            200          0 https://r4ds.s3.us-we… data/seattle-l… <NA> 
#> # ℹ 4 more variables: type <chr>, modified <dttm>, time <dbl>,
#> #   headers <list>

22.3 Abrindo o conjunto de dados

Vamos começar dando uma olhada nos dados. Com 9 GB, este arquivo é tão grande que provavelmente não queremos carregá-lo por inteiro na memória do computador. Como regra geral, dizemos que você vai querer ter no mínimo duas vezes mais memória que o tamanho dos dados e muitos notebooks chegam até 16 GB. Isto significa que queremos evitar read_csv() e, ao invés disso, usar arrow::open_dataset():

seattle_csv <- open_dataset(
  sources = "data/seattle-library-checkouts.csv", 
  col_types = schema(ISBN = string()),
  format = "csv"
)

O que acontece quando este código é executado? open_dataset() irá inspecionar (scan) alguns milhares de linhas para entender a estrutura do conjunto de dados. A coluna ISBN contém valores em branco nas primeiras 80.000 linhas, portanto devemos especificar o tipo da coluna para ajudar o arrow a trabalhar com a estrutura de dados. Uma vez que os dados foram varridos pela open_dataset(), ele registra o que encontrou e para; ele lerá mais linhas apenas quando você explicitamente solicitar. Este metadado é o que vemos quando imprimimos seattle_csv:

seattle_csv
#> FileSystemDataset with 1 csv file
#> UsageClass: string
#> CheckoutType: string
#> MaterialType: string
#> CheckoutYear: int64
#> CheckoutMonth: int64
#> Checkouts: int64
#> Title: string
#> ISBN: string
#> Creator: string
#> Subjects: string
#> Publisher: string
#> PublicationYear: string

A primeira linha na saída te diz que seattle_csv está armazenado localmente em disco como um único arquivo CSV; ele será carregado em memória apenas quando necessário. O restante da saída informa o tipo atribuído pelo arrow para cada coluna.

Podemos ver o que realmente temos no conjunto de dados com glimpse(). Isto revela que há ~41 milhões de linhas e 12 colunas, e nos mostra alguns valores.

seattle_csv |> glimpse()
#> FileSystemDataset with 1 csv file
#> 41,389,465 rows x 12 columns
#> $ UsageClass      <string> "Physical", "Physical", "Digital", "Physical", "Ph…
#> $ CheckoutType    <string> "Horizon", "Horizon", "OverDrive", "Horizon", "Hor…
#> $ MaterialType    <string> "BOOK", "BOOK", "EBOOK", "BOOK", "SOUNDDISC", "BOO…
#> $ CheckoutYear     <int64> 2016, 2016, 2016, 2016, 2016, 2016, 2016, 2016, 20…
#> $ CheckoutMonth    <int64> 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,…
#> $ Checkouts        <int64> 1, 1, 1, 1, 1, 1, 1, 1, 4, 1, 1, 2, 3, 2, 1, 3, 2,…
#> $ Title           <string> "Super rich : a guide to having it all / Russell S…
#> $ ISBN            <string> "", "", "", "", "", "", "", "", "", "", "", "", ""…
#> $ Creator         <string> "Simmons, Russell", "Barclay, James, 1965-", "Tim …
#> $ Subjects        <string> "Self realization, Conduct of life, Attitude Psych…
#> $ Publisher       <string> "Gotham Books,", "Pyr,", "Random House, Inc.", "Di…
#> $ PublicationYear <string> "c2011.", "2010.", "2015", "2005.", "c2004.", "c20…

Podemos começar a usar este conjunto de dados com verbos dplyr usando collect() para forçar o arrow a executar a computação e retornar algum dado. Por exemplo, este código nos mostra o número total de retiradas (checkouts) por ano (year):

seattle_csv |> 
  group_by(CheckoutYear) |> 
  summarise(Checkouts = sum(Checkouts)) |> 
  arrange(CheckoutYear) |> 
  collect()
#> # A tibble: 18 × 2
#>   CheckoutYear Checkouts
#>          <int>     <int>
#> 1         2005   3798685
#> 2         2006   6599318
#> 3         2007   7126627
#> 4         2008   8438486
#> 5         2009   9135167
#> 6         2010   8608966
#> # ℹ 12 more rows

Graças ao arrow, este código funcionará independente do tamanho do conjunto de dados que tivermos. Porém, ainda é um pouco lento: no computador do Hadley demorou ~10 segundos para concluir. Não é tão terrível considerando a quantidade de dados que temos, mas podemos deixá-lo mais rápido mudando para um formato melhor.

22.4 O formato parquet

Para tornar este conjunto de dados mais fácil de trabalhar, vamos trocá-lo para o formato de arquivo parquet e dividí-lo em múltiplos arquivos. As seções seguintes irão primeiramente introduzir você ao parquet e particionamento (partitioning) e então aplicar o que aprendemos nos dados das bibliotecas de Seattle.

22.4.1 Vantagens do parquet

Assim como CSV, parquet é usado para dados retangulares, mas ao invés de ser um formato texto que você pode ler com qualquer editor de arquivo, é um formato binário personalizado projetado especificamente para as necessidades de grandes volumes de dados (big data). Isto significa que:

  • Arquivos parquet são normalmente menores que seus arquivos equivalentes CSV. Parquet é baseado em codificação eficiente para manter o tamanho do arquivo menor, e permite a compressão do arquivo. Isto ajuda a tornar os arquivos parquet rápidos, pois há menos dados para serem movidos do disco para a memória.

  • Arquivos parquet possuem um rico sistema de tipagem. Como falamos na Seção 7.3, um arquivo CSV não fornece nenhuma informação sobre os tipos das colunas. Por exemplo, um leitor de CSV precisa adivinhar se "08-10-2022" deve ser lido como texto ou data. Diferentemente, arquivos parquet armazenam dados de maneira a registrar o tipo juntamente com os dados.

  • Arquivos parquet são “orientados-a-colunas” (column-oriented). Isto significa que eles são organizados coluna por coluna, muito parecido com os data frames do R. Isto geramente leva a um melhor desempenho para tarefas de análise de dados quando comparado aos arquivos CSV, que são organizados linha a linha.

  • Arquivos parquet são “fragmentados” (chunked), o que torna possível trabalhar em diferentes partes do mesmo arquivo ao mesmo tempo e, se você tiver sorte, pular alguns fragmentos completamente.

Há uma desvantagem primária nos arquivos parquet: eles não podem mais ser “lidos por humanos”, ou seja, se você olhar para um arquivo parquet usando readr::read_file(), você verá apenas um monte de caracteres sem sentido.

22.4.2 Particionamento

Conforme os conjuntos de dados vão ficando cada vez maiores, armazenar todos os dados em um único arquivo fica cada vez mais problemático, e geralmente é mais útil dividi-los em vários arquivos. Quando esta estrutura é feita de forma inteligente, esta estratégia pode levar a melhoras significativas de desempenho, pois muitas análises precisam apenas de um subconjunto dos arquivos.

Não existem regras absolutas sobre como particionar seu conjunto de dados: os resultados dependerão de seus dados, padrões de acesso e os sistemas que lêem os dados. Você provavelmente precisará fazer alguns experimentos antes de encontrar o particionamento ideal para sua situação. Como uma recomendação aproximada, arrow sugere que você evite arquivos menores que 20MB e maiores que 2GB e evite particionamentos que produzam mais que 10.000 arquivos. Você deve também tentar particionar por variáveis usadas em filtros; como você verá em breve, isto permite que o arrow evite trabalho desnecessário e leia apenas os arquivos relevantes.

22.4.3 Reescrevendo os dados das bibliotecas de Seattle

Vamos aplicar estas ideias nos dados das bibliotecas de Seattle e ver como se saem na prática. Vamos particionar os dados por CheckoutYear (ano da retirada), já que é provável que algumas análises queiram apenas olhar para dados recentes e particionar por ano gera 18 fragmentos de tamanho razoável.

Para reescrever os dados, definimos o particionamento usando dplyr::group_by() e então salvamos as partições em um diretório com arrow::write_dataset(). write_dataset() tem dois argumentos importantes: um diretório onde criaremos os arquivos e o formato que usaremos.

pq_path <- "data/seattle-library-checkouts"
seattle_csv |>
  group_by(CheckoutYear) |>
  write_dataset(path = pq_path, format = "parquet")

Isto leva aproximadamente um minuto para executar; como veremos em breve, este investimento de tempo inicial será compensado, pois tornará as operações futuras muito mais rápidas.

Vamos ver o que acabamos de produzir:

tibble(
  arquivos = list.files(pq_path, recursive = TRUE),
  tamanho_MB = file.size(file.path(pq_path, arquivos)) / 1024^2
)
#> # A tibble: 18 × 2
#>   arquivos                         tamanho_MB
#>   <chr>                                 <dbl>
#> 1 CheckoutYear=2005/part-0.parquet       109.
#> 2 CheckoutYear=2006/part-0.parquet       164.
#> 3 CheckoutYear=2007/part-0.parquet       178.
#> 4 CheckoutYear=2008/part-0.parquet       195.
#> 5 CheckoutYear=2009/part-0.parquet       214.
#> 6 CheckoutYear=2010/part-0.parquet       222.
#> # ℹ 12 more rows

O arquivo CSV único de 9GB foi reescrito como 18 arquivos parquet. Os nomes dos arquivos usam uma convenção de “auto-descrição” usada pelo projeto Apache Hive. Partições no estilo Hive nomeiam as pastas com uma convenção “chave=valor” e como você pode imaginar, o diretório CheckoutYear=2005 contém todos os dados onde o ano da retirada (CheckoutYear) é 2005. Cada arquivo tem entre 100 e 300 MB e o tamanho total agora é aproximandamente 4 GB, um pouco mais que a metade do arquivo CSV original. Isto é o que esperamos, já que parquet é um formato muito mais eficiente.

22.5 Usando dplyr com arrow

Agora que criamos estes arquivos parquet, precisamos lê-los novamente. Nós usamos open_dataset() novamente, mas dessa vez informando um diretório:

seattle_pq <- open_dataset(pq_path)

Agora podemos escrever nosso pipeline dplyr. Por exemplo, poderíamos contar o número total de livros retirados em cada mês dos últimos cinco anos:

query <- seattle_pq |> 
  filter(CheckoutYear >= 2018, MaterialType == "BOOK") |>
  group_by(CheckoutYear, CheckoutMonth) |>
  summarize(TotalCheckouts = sum(Checkouts)) |>
  arrange(CheckoutYear, CheckoutMonth)

Escrever código dplyr para arrow é conceitualmente similar ao dbplyr, Capítulo 21: você escreve o código dplyr, que é automaticamente transformado em uma consulta compreensível para a biblioteca C++ do Apache Arrow e que será executada quando você chamar collect(). Se imprimirmos o objeto query podemos ver algumas informações sobre o que esperamos que o Arrow nos retorne quando a execução ocorrer:

query
#> FileSystemDataset (query)
#> CheckoutYear: int32
#> CheckoutMonth: int64
#> TotalCheckouts: int64
#> 
#> * Grouped by CheckoutYear
#> * Sorted by CheckoutYear [asc], CheckoutMonth [asc]
#> See $.data for the source Arrow object

E podemos obter os resultados chamando collect():

query |> collect()
#> # A tibble: 58 × 3
#> # Groups:   CheckoutYear [5]
#>   CheckoutYear CheckoutMonth TotalCheckouts
#>          <int>         <int>          <int>
#> 1         2018             1         355101
#> 2         2018             2         309813
#> 3         2018             3         344487
#> 4         2018             4         330988
#> 5         2018             5         318049
#> 6         2018             6         341825
#> # ℹ 52 more rows

Assim como dbplyr, arrow entende apenas algumas expressões do R, portanto você pode não conseguir escrever exatamente o mesmo código que escreveria normalmente. Entretanto, a lista de operações e funções suportadas é bastante extensa e continua aumentando; veja a lista completa de funções atualmente suportadas digitando ?acero.

22.5.1 Desempenho

Vamos dar uma olhada rápida em como a mudança de CSV para parquet impactou o desempenho. Primeiro, vamos medir quanto tempo demora para calcular o número de livros retirados em cada mês de 2021 quando os dados estão armazenados em um único grande arquivo csv:

seattle_csv |> 
  filter(CheckoutYear == 2021, MaterialType == "BOOK") |>
  group_by(CheckoutMonth) |>
  summarize(TotalCheckouts = sum(Checkouts)) |>
  arrange(desc(CheckoutMonth)) |>
  collect() |> 
  system.time()
#>    user  system elapsed 
#>  15.691   1.408  14.932

Agora, vamos usar nossa nova versão do conjunto de dados na qual os dados de retiradas das bibliotecas de Seattle foram particionadas em 18 arquivos parquet menores:

seattle_pq |> 
  filter(CheckoutYear == 2021, MaterialType == "BOOK") |>
  group_by(CheckoutMonth) |>
  summarize(TotalCheckouts = sum(Checkouts)) |>
  arrange(desc(CheckoutMonth)) |>
  collect() |> 
  system.time()
#>    user  system elapsed 
#>   0.339   0.054   0.081

A melhora de desempenho de ~100x é atribuída a dois fatores: O particionamento de vários arquivos e o formato de cada arquivo:

  • O particionamento melhora o desempenho pois esta consulta usa CheckoutYear == 2021 para filtrar os dados, e arrow é inteligente o suficiente para reconhecer que precisa ler apenas 1 dos 18 arquivos parquet.
  • O formato parquet melhora o desempenho por armazenar dados em formato binário que pode ser carregado mais diretamente para a memória. O formato colunar (column-wise) e os metadados ricos fazem com que o arrow precise ler apenas as quatro colunas usadas na consulta (CheckoutYear, MaterialType, CheckoutMonth e Checkouts).

Esta diferença de desempenho massiva é o motivo pelo qual vale a pena converter grandes arquivos CSV para parquet!

22.5.2 Usando duckdb com arrow

Tem uma última vantagem de usar parquet e arrow — é muito fácil transformar um conjunto de dados arrow em um banco de dados DuckDB (Capítulo 21) chamando arrow::to_duckdb():

seattle_pq |> 
  to_duckdb() |>
  filter(CheckoutYear >= 2018, MaterialType == "BOOK") |>
  group_by(CheckoutYear) |>
  summarize(TotalCheckouts = sum(Checkouts)) |>
  arrange(desc(CheckoutYear)) |>
  collect()
#> Warning: Missing values are always removed in SQL aggregation functions.
#> Use `na.rm = TRUE` to silence this warning
#> This warning is displayed once every 8 hours.
#> # A tibble: 5 × 2
#>   CheckoutYear TotalCheckouts
#>          <int>          <dbl>
#> 1         2022        2431502
#> 2         2021        2266438
#> 3         2020        1241999
#> 4         2019        3931688
#> 5         2018        3987569

O legal da função to_duckdb() é que a transferência não envolve nenhuma cópia de memória e atende aos objetivos do ecossistema arrow: permitir transições transparentes de um ambiente computacional para outro.

22.5.3 Exercícios

  1. Encontre os livros mais populares para cada ano.
  2. Qual autor tem mais livros no sistema de bibliotecas de Seattle?
  3. Como as retiradas de livros vs livros eletrônicos (ebooks) mudaram ao longo dos últimos 10 anos?

22.6 Resumo

Neste capítulo, você experimentou o pacote arrow, que fornece um backend dplyr para trabalhar com grandes conjuntos de dados em disco. Ele pode funcionar com arquivos CSV e é muito mais rápido se você converter seus dados para parquet. Parquet é um formato de dados binários projetado especificamente para análise de dados em computadores modernos. Um número muito menor de ferramentas podem funcionar com arquivos parquet em comparação com CSV, mas sua estrutura particionada, compactada e colunar torna sua análise muito mais eficiente.

A seguir, você aprenderá sobre sua primeira fonte de dados não retangular, que você manipulará usando ferramentas fornecidas pelo pacote tidyr. Vamos nos concentrar nos dados provenientes de arquivos JSON, mas os princípios gerais se aplicam a dados com estrutura baseada em árvore (tree-like), independentemente de sua origem.