Comparaisons base R - dplyr - data.table

La richesse de R, alimentée par une communauté de développeurs très active, rend le choix d’une méthode adaptée à une problématique donnée difficile, et c’est tant mieux. Vous trouverez ici une modeste participation au débat qui oppose les deux packages d’analyse des données les plus en vue dans la communauté R : data.table et dplyr. L’article se présente en deux parties :

Rappels sur dplyr et data.table

Si vous connaissez déjà la syntaxe de ces packages, vous pouvez directement aller à la partie Comparaisons sur une étude de cas simple. On rappelle ici les principales caractéristiques de ces packages mais pour se former à leur utilisation on peut se référer au cours de perfectionnement de Martin Chevalier. Pour une exploration de ce qu’englobe le tidyverse et notamment une présentation des commandes de dplyr, vous pouvez jeter un oeil à l’introduction à R et au tidyverse de J. Barnier. Enfin pour data.table, on trouve des informations utiles sur le cours Manipulations avancée avec data.table de J. Larmarange.

dplyr et le tidyverse

Le tidyverse (contraction de “tidy” et “universe”) est un concept initié par Hadley Wickham, chef statisticien de RStudio. Il regroupe un ensemble de packages utiles au traitement statistique et au nettoyage de bases de données. On va s’intéresser ici presque seulement au package dplyr (dont les instructions seront appliquées aux tibbles, un format de data.frame issu du tidyverse), mais vous pouvez parcourir les packages proposés dans le tidyverse sur le site officiel.
dplyr propose un ensemble d’opérations de traitement de données sous une syntaxe différente de celle utilisée dans les fonctions de base de R. Ce langage présente le double avantage d’être à la fois lisible pour quelqu’un habitué aux langages tels que SAS ou SQL et de proposer des fonctions optimisées qui présentent de bonnes performances en termes de temps d’exécution. La grammaire dplyr s’appuie en effet sur des fonctions au nom explicite :

  • mutate(data, newvar1=fonction(var1,var2...)) et transmute(data, newvar1=fonction(var1,var2...)) créent de nouvelles variables
  • filter(data, condition) sélectionne au sein d’une table certaines observations, à la manière de where dans SAS.
  • arrange(data, var1, descending var2,...) trie une base selon une ou plusieurs variables (l’équivalent d’une proc sort).
  • select(data, var1 : varX) sélectionne certaines variables dans une base, à la manière de keep dans SAS.
  • summarise(data, newvar1=mean(var1), newvar2=sum(var2)) réalise toute sorte d’opérations statistiques sur une table.
  • group_by(data, var) regroupe une table par une variable
  • et bien d’autres…

Un aspect pratique de ce langage est que toutes ces opérations peuvent être chaînées à l’aide de l’opérateur %>% (“pipe” en anglais) dont la syntaxe est la suivante : data %>% fonction(...) est équivalent à fonction(data, ...). Cette syntaxe permet de chaîner un grand nombre d’opérations sur une base commune, en limitant le nombre de fois où l’on écrit des tables intermédiaires tout en conservant une grande lisibilité du code. Ce petit exemple vous en convaincra peut-être :

library(tidyverse) # On aurait aussi pu charger seulement le package dplyr
# on crée un data frame avec 100 lignes, chaque individu appartenant à un des 50 groupes
df <- data.frame(id1 = c(1:100), idgpe = sample(50), replace = TRUE)

# on y applique les instructions de dplyr
df %>% as_tibble() %>% mutate(var = rnorm(100)) %>% 
  group_by(idgpe) %>% summarise(var_mean = mean(var)) -> output_tibble

Un regard peu habitué contesterait peut-être l’aspect très lisible de l’instruction, mais ça l’est réellement. Le déroulé est le suivant :

  1. on transforme notre data.frame en tibble (pour rappel : format optimisé de data.frame pour dplyr) avec as_tibble
  2. on crée une variable var avec mutate
  3. on agrège par idgpe avec group_by
  4. on calcule la moyenne de var avec summarise, que l’on stocke dans var_mean. Comme cette instruction suit un group_by, elle est réalisée à l’intérieur de chaque groupe (défini par idgpe), sinon elle aurait été réalisé sur l’ensemble de la table.

Tout cela est stocké dans une table output_tibble, qui est ainsi un tibble agrégé par idgpe et qui a donc 50 lignes. L’intérêt de ce chaînage est qu’il permet une économie de code et d’écriture d’éventuelles tables intermédiaires.

Data.table

Le package data.table ne prétend pas, contrairement au tidyverse, proposer une syntaxe concurrente à base R mais enrichir celle-ci. Il est axé autour d’un nouveau format d’objet, le data.table, qui est un type de data.frame qui permet une utilisation optimisée de l’opérateur [.
Tout data.frame peut être converti en data.table grâce à la fonction as.data.table, ou, de manière plus optimale pour l’utilisation de la mémoire, grâce à la fonction setDT qui permet de directement transformer la nature de l’objet sans avoir à en écrire un autre. Il est important d’avoir en tête qu’un data.frame converti en data.table conserve les caractéristiques d’un data.frame. Cependant, l’opérateur [ appliqué au data.table change de signification et devient :

DT[i,j,by]

Avec i qui permet de sélectionner des observations (sans avoir besoin de répéter le nom de la base dans laquelle on se trouve), j qui permet de créer ou sélectionner des variables et by de regrouper les traitement selon les modalités d’une variable définie. Comme dans dplyr, il est possible de chaîner les opérations réalisées comme le montre l’exemple suivant, qui reprend le même cas de figure que celui illustrant le package dplyr :

library(data.table) 
# on convertit notre data frame précédemment créé en data.table
dt <- as.data.table(df)

# on y applique les même instructions
dt[, list(var_mean = mean(rnorm(100))), by = list(idgpe = idgpe)] -> output_dt

Le fait de renseigner mes variables au sein de list() me permet d’avoir une table en sortie au niveau de idgpe (donc 50 observations), sans cela ma variable est bien moyennée par groupe mais la table en sortie est toujours au niveau id1 (100 observations).

Vitesses d’exécution

Voilà donc pour les présentations! Allez, on montre le résultat d’un petit microbenchmark des deux juste pour voir :

## Unit: milliseconds
##        expr    min      lq      mean  median       uq     max neval
##       dplyr 6.6939 9.39175 12.218420 12.0326 14.41980 21.2397   100
##  data.table 1.9088 2.50940  2.939709  2.9378  3.28745  6.3173   100

Sur cet exemple, on voit un avantage clair à data.table! Mais on est sur une toute petite table en entrée. On va essayer de se rapprocher de cas plus concrets en s’intéressant à un exemple sur des bases plus importantes.

Comparaisons sur une étude de cas simple

Les avantages et inconvénients de ces deux packages sont à l’origine de nombreux débats. Vous pouvez vous en convaincre en suivant cette discussion sur stackoverflow. On peut quand même dégager deux compromis :

  • Le choix de l’un ou l’autre des packages dépend beaucoup de ce que l’on va en faire (types d’analyses, taille des données, profils des utilisateurs du code…).
  • Les deux packages sont plus intéressants que base R pour l’analyse de données, que ce soit en termes de facilité d’écriture ou de performances.

Pour ce deuxième point, on va essayer de s’en convaincre ensemble avec ce petit exemple.

Notre étude de cas

Pour cet exemple, on utilise les données du package de Hadley Wickham que l’on trouve dans nycflights13. En particulier, la base flights donne toutes les heures de départ et d’arrivée selon les aéroports de départ et d’arrivée ainsi que les retards au départ et à l’arrivée. La base weather donne elle des indications météo, heure par heure, dans chaque aéroport. Tout bon statisticien qui se respecte devrait commencer à se dire qu’il y a quelque chose à faire pour tenter d’expliquer les retards des avions (spoiler alert : on ne va pas le faire).
Commençons par charger nos packages (n’oubliez pas de faire install.packages("nom_pck") avant si vous ne l’avez jamais fait) et nos données :

# Les packages nécessaires
library(tidyverse) # Regroupe différents packages, voir https://www.tidyverse.org/ 
library(data.table)
library(microbenchmark) # Pour les calculs de vitesse d'exécution
library(nycflights13) # Pour les données

# data.table pour tests avec data.table
flightsdt <- as.data.table(flights)
weatherdt <- as.data.table(weather)

Notez que l’on n’est pas obligés de faire du dplyr sur des tibbles plutôt que des data frame, mais on suit ici les recommandations d’Hadley Wickham.

Moyenne des retards et fusion des tables

Un rapide examen des bases vous montre que la première étape avant toute analyse est comme souvent de regrouper les éléments de flights par heure et aéroport de départ pour pouvoir les fusionner avec la table weather, qui donnent les indications météo minute par minute. On écrit cette instruction de 3 manières différentes :

En base R

flights_time_hour <- aggregate.data.frame(list(arr_delay = flights$arr_delay, 
                                    dep_delay = flights$dep_delay), 
                                      list(time_hour = flights$time_hour, origin = flights$origin), 
                                      mean)
output_base <- merge(weather, flights_time_hour, by = c("time_hour", "origin"), sort = FALSE)

(J’ai utilisé aggregate.data.frame et pas tapply pour avoir directement un data.frame en sortie)

En dplyr

flights %>% group_by(time_hour, origin) %>% 
  summarise(arr_delay = mean(arr_delay),
            dep_delay = mean(dep_delay)) %>% 
  inner_join(weather, by = c("time_hour", "origin")) -> output_dplyr 
## `summarise()` regrouping output by 'time_hour' (override with `.groups` argument)

En data.table

output_DT <- merge(flightsdt[, list(arr_perc_delay = mean(arr_delay),
                       dep_perc_delay = mean(dep_delay)), by = c("time_hour", "origin")],
      weatherdt, by = c("time_hour", "origin"))

J’ai utilisé la fonction merge plutôt que DT1[DT2, on = c("time_hour", "origin"), nomatch = 0] car j’ai constaté qu’elle était plus rapide, conformément à ce que montre bien cet article du Jozef’s Rblog.

Comparaisons des vitesses d’exécution

Je vous laisse juger de la lisibilité de chacune de ces instructions, qui font toutes la même chose, car c’est finalement assez subjectif. On donne ici les résultats d’un microbenchmark de ces instructions :

## Unit: milliseconds
##   expr       min        lq       mean    median        uq       max neval
##   Base 2984.1129 3194.0318 3367.71999 3290.3493 3593.8568 3810.2907    10
##  DplyR  343.0911  361.0002  487.68594  468.3438  578.0777  768.4978    10
##     DT   45.8798   47.9191   57.85761   54.5310   68.4981   80.8654    10

Les résultats sont très nettement en faveur des packages dplyr et data.table, ce dernier ayant l’avantage. Sans doute existe-t-il des moyens de plus optimiser l’instruction en base R, mais là n’est pas vraiment la question. On voit qu’avec une syntaxe simple et lisible, dplyr et data.table font beaucoup mieux que l’instruction qui viendrait à l’esprit d’un statisticien qui n’utiliserait que les premières fonctions venues de base R.