22  Arrow

22.1 Introducción

Los archivos CSV están diseñados para que los humanos los lean fácilmente. Son un buen formato de intercambio porque son muy simples y pueden ser leídos por todas las herramientas bajo el sol. Pero los archivos CSV no son muy eficientes: hay que trabajar mucho para leer los datos en R. En este capítulo, aprenderá sobre una poderosa alternativa: el formato parquet, un formato basado en estándares abiertos ampliamente utilizado por los sistemas de big data.

Combinaremos los archivos de parquet con Apache Arrow, una caja de herramientas multilingüe diseñada para el análisis y el transporte eficientes de grandes conjuntos de datos. Usaremos Apache Arrow a través del paquete arrow, que proporciona un backend de dplyr que le permite analizar conjuntos de datos más grandes que la memoria usando la sintaxis familiar de dplyr. Como beneficio adicional, arrow es extremadamente rápida: verá algunos ejemplos más adelante en el capítulo.

Tanto arrow como dbplyr proporcionan backends de dplyr, por lo que es posible que se pregunte cuándo usar cada uno. En muchos casos, la elección está hecha por usted, ya que los datos ya están en una base de datos o en archivos de parquet, y deseará trabajar con ellos tal como están. Pero si está comenzando con sus propios datos (quizás archivos CSV), puede cargarlos en una base de datos o convertirlos en parquet. En general, es difícil saber qué funcionará mejor, por lo que en las primeras etapas de su análisis lo alentamos a que pruebe ambos y elija el que funcione mejor para usted.

(Muchas gracias a Danielle Navarro que contribuyó con la versión inicial de este capítulo.)

22.1.1 Requisitos previos

En este capítulo, continuaremos usando tidyverse, particularmente dplyr, pero lo emparejaremos con el paquete arrow, que está diseñado específicamente para trabajar con grandes datos.

Más adelante en el capítulo, también veremos algunas conexiones entre arrow y duckdb, por lo que también necesitaremos dbplyr y duckdb.

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

22.2 Obtener los datos

Comenzamos obteniendo un conjunto de datos digno de estas herramientas: un conjunto de datos de préstamo de artículos de las bibliotecas públicas de Seattle, disponible en línea en data.seattle.gov/Community/Checkouts-by-Title/tmmm-ytt6. Este conjunto de datos contiene 41 389 465 filas que le indican cuántas veces cada libro fue prestado cada mes desde abril de 2005 hasta octubre de 2022.

El siguiente código le dará una copia en caché de los datos. Los datos son un archivo CSV de 9 GB, por lo que llevará algún tiempo descargarlos. Recomiendo encarecidamente usar curl::multidownload() para obtener archivos muy grandes, ya que está diseñado exactamente para este propósito: le brinda una barra de progreso y puede reanudar la descarga si se interrumpe.

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 Abrir un conjunto de datos

Comencemos echando un vistazo a los datos. Con 9 GB, este archivo es lo suficientemente grande como para que probablemente no queramos cargarlo todo en la memoria. Una buena regla general es que, por lo general, desea al menos el doble de memoria que el tamaño de los datos, y muchas computadoras portátiles no superan los 16 Gb. Esto significa que queremos evitar read_csv() y en su lugar usar arrow::open_dataset():

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

¿Qué sucede cuando se ejecuta este código? open_dataset() escaneará unas pocas miles de filas para descubrir la estructura del conjunto de datos. La columna ISBN contiene valores en blanco para las primeras 80 000 filas, por lo que debemos especificar el tipo de columna para ayudar a arrow a calcular la estructura de datos. Una vez que los datos han sido escaneados por open_dataset(), registra lo que se encuentra y se detiene; solo leerá filas adicionales a medida que las solicite específicamente. Estos metadatos son los que vemos si 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

La primera línea de la salida le dice que seattle_csv se almacena localmente en el disco como un único archivo CSV; solo se cargará en la memoria según sea necesario. El resto de la salida le indica el tipo de columna que arrow ha imputado para cada columna.

Podemos ver qué hay realmente con glimpse(). Esto revela que hay ~41 millones de filas y 12 columnas, y nos muestra algunos 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 comenzar a usar este conjunto de datos con verbos dplyr, usando collect() para forzar a arrow a realizar el cálculo y devolver algunos datos. Por ejemplo, este código nos dice el número total de pagos por año:

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

Gracias a arrow, este código funcionará independientemente del tamaño del conjunto de datos subyacente. Pero actualmente es bastante lento: en la computadora de Hadley, tardó ~ 10 segundos en ejecutarse. Eso no es terrible dada la cantidad de datos que tenemos, pero podemos hacerlo mucho más rápido si cambiamos a un mejor formato.

22.4 El formato parquet

Para facilitar el trabajo con estos datos, cambiemos al formato de archivo parquet y dividámoslo en varios archivos. Las siguientes secciones le presentarán primero el parquet y las particiones, y luego aplicarán lo que aprendimos a los datos de la biblioteca de Seattle.

22.4.1 Ventajas del parquet

Al igual que CSV, el parquet se usa para datos rectangulares, pero en lugar de ser un formato de texto que puede leer con cualquier editor de archivos, es un formato binario personalizado diseñado específicamente para las necesidades de big data. Esto significa que:

  • Los archivos de parquet suelen ser más pequeños que el archivo CSV equivalente. Parquet se basa en codificaciones eficientes para reducir el tamaño del archivo y admite la compresión de archivos. Esto ayuda a que los archivos de parquet sean más rápidos porque hay menos datos para mover del disco a la memoria.

  • Los archivos de parquet tienen un sistema de tipo rico. Como comentamos en Sección 7.3, un archivo CSV no proporciona ninguna información sobre los tipos de columna. Por ejemplo, un lector de CSV tiene que adivinar si "08-10-2022" debe analizarse como una cadena o una fecha. Por el contrario, los archivos de parquet almacenan datos de una manera que registra el tipo junto con los datos.

  • Los archivos de parquet están “orientados a columnas”. Esto significa que están organizados columna por columna, como el marco de datos de R. Esto generalmente conduce a un mejor rendimiento para las tareas de análisis de datos en comparación con los archivos CSV, que se organizan fila por fila.

  • Los archivos de parquet están “fragmentados”, lo que hace posible trabajar en diferentes partes del archivo al mismo tiempo y, si tiene suerte, saltarse algunos fragmentos por completo.

Hay una desventaja principal en los archivos de parquet: ya no son “legibles por humanos”, es decir, si miras un archivo de parquet usando readr::read_file(), solo verás un montón de galimatías.

22.4.2 Fraccionamiento

A medida que los conjuntos de datos se hacen cada vez más grandes, almacenar todos los datos en un solo archivo se vuelve cada vez más complicado y, a menudo, es útil dividir grandes conjuntos de datos en varios archivos. Cuando esta estructuración se realiza de manera inteligente, esta estrategia puede conducir a mejoras significativas en el rendimiento porque muchos análisis solo requerirán un subconjunto de los archivos.

No existen reglas estrictas sobre cómo particionar su conjunto de datos: los resultados dependerán de sus datos, patrones de acceso y los sistemas que leen los datos. Es probable que necesite experimentar un poco antes de encontrar la partición ideal para su situación. Como guía aproximada, arrow sugiere que evite los archivos de menos de 20 MB y más de 2 GB y evite las particiones que producen más de 10,000 archivos. También debe intentar particionar por variables por las que filtra; como verá en breve, eso permite que arrow se salte una gran cantidad de trabajo al leer solo los archivos relevantes.

22.4.3 Reescribiendo los datos de la biblioteca de Seattle

Apliquemos estas ideas a los datos de la biblioteca de Seattle para ver cómo se desarrollan en la práctica. Vamos a particionar por CheckoutYear, ya que es probable que algunos análisis solo quieran ver datos recientes y la partición por año produce 18 fragmentos de un tamaño razonable.

Para reescribir los datos definimos la partición usando dplyr::group_by() y luego guardamos las particiones en un directorio con arrow::write_dataset(). write_dataset() tiene dos argumentos importantes: un directorio donde crearemos los archivos y el formato que usaremos.

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

Esto tarda aproximadamente un minuto en ejecutarse; como veremos en breve, esta es una inversión inicial que vale la pena al hacer que las operaciones futuras sean mucho más rápidas.

Echemos un vistazo a lo que acabamos de producir:

tibble(
  files = list.files(pq_path, recursive = TRUE),
  size_MB = file.size(file.path(pq_path, files)) / 1024^2
)
#> # A tibble: 18 × 2
#>   files                            size_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

Nuestro único archivo CSV de 9 GB se ha reescrito en 18 archivos de parquet. Los nombres de archivo utilizan una convención de “autodescripción” utilizada por el proyecto Apache Hive. Las particiones de estilo Hive nombran carpetas con una convención “clave=valor”, por lo que, como puede suponer, el directorio CheckoutYear=2005 contiene todos los datos donde CheckoutYear es 2005. Cada archivo tiene entre 100 y 300 MB y el tamaño total ahora es de alrededor de 4 GB, un poco más de la mitad del tamaño del archivo CSV original. Esto es lo que esperamos ya que el parquet es un formato mucho más eficiente.

22.5 Usando dplyr con arrow

Ahora que hemos creado estos archivos de parquet, necesitaremos volver a leerlos. Usamos open_dataset() nuevamente, pero esta vez le damos un directorio:

seattle_pq <- open_dataset(pq_path)

Ahora podemos escribir nuestra canalización dplyr. Por ejemplo, podríamos contar el número total de libros prestados cada mes durante los últimos cinco años:

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

Escribir código dplyr para datos de arrow es conceptualmente similar a dbplyr, Capítulo 21: usted escribe código dplyr, que se transforma automáticamente en una consulta que entiende la biblioteca Apache Arrow C++, que luego se ejecuta cuando llama a collect(). Si imprimimos el objeto query, podemos ver un poco de información sobre lo que esperamos que devuelva Arrow cuando tenga lugar la ejecución:

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

Y podemos obtener los resultados llamando 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

Al igual que dbplyr, arrow solo comprende algunas expresiones R, por lo que es posible que no pueda escribir exactamente el mismo código que normalmente haría. Sin embargo, la lista de operaciones y funciones admitidas es bastante extensa y sigue creciendo; encuentra una lista completa de las funciones soportadas actualmente en ?acero.

22.5.1 Rendimiento

Echemos un vistazo rápido al impacto en el rendimiento de cambiar de CSV a parquet. Primero, cronometremos cuánto tiempo lleva calcular la cantidad de libros prestados en cada mes de 2021, cuando los datos se almacenan como un solo archivo csv grande:

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

Ahora usemos nuestra nueva versión del conjunto de datos en el que los datos de préstamo de la biblioteca de Seattle se han dividido en 18 archivos de parquet más pequeños:

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

La aceleración de ~100x en el rendimiento se atribuye a dos factores: la partición de varios archivos y el formato de los archivos individuales:

  • La partición mejora el rendimiento porque esta consulta usa CheckoutYear == 2021 para filtrar los datos, y arrow es lo suficientemente inteligente como para reconocer que solo necesita leer 1 de los 18 archivos de parquet.
  • El formato parquet mejora el rendimiento al almacenar datos en un formato binario que se puede leer más directamente en la memoria. El formato por columnas y los metadatos enriquecidos significan que arrow solo necesita leer las cuatro columnas realmente utilizadas en la consulta (CheckoutYear, MaterialType, CheckoutMonth y Checkouts).

¡Esta gran diferencia en el rendimiento es la razón por la que vale la pena convertir grandes CSV en parquet!

22.5.2 Usando duckdb con arrow

Hay una última ventaja de parquet y arrow: es muy fácil convertir un conjunto de datos de arrow en una base de datos DuckDB (Capítulo 21) llamando a 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

Lo bueno de to_duckdb() es que la transferencia no implica ninguna copia de memoria y habla de los objetivos del ecosistema de arrow: permitir transiciones sin problemas de un entorno informático a otro.

22.5.3 Exercises

  1. Averigua cuál es el libro más popular de cada año.
  2. ¿Qué autor tiene la mayor cantidad de libros en el sistema de bibliotecas de Seattle?
  3. ¿Cómo ha cambiado el pago de libros frente a los libros electrónicos en los últimos 10 años?

22.6 Resumen

En este capítulo, se le ha dado una idea del paquete arrow, que proporciona un backend dplyr para trabajar con grandes conjuntos de datos en disco. Puede funcionar con archivos CSV, y es mucho más rápido si convierte sus datos a parquet. Parquet es un formato de datos binarios que está diseñado específicamente para el análisis de datos en computadoras modernas. Muchas menos herramientas pueden trabajar con archivos de parquet en comparación con CSV, pero su estructura dividida, comprimida y en columnas hace que sea mucho más eficiente de analizar.

A continuación, aprenderá sobre su primera fuente de datos no rectangular, que manejará con las herramientas proporcionadas por el paquete tidyr. Nos centraremos en los datos que provienen de archivos JSON, pero los principios generales se aplican a los datos en forma de árbol, independientemente de su origen.