Comparaisons base R, dplyr et data.table
Cet article est une mise à jour de l'article du blog d'Antoine réalisé en 2018. L'idée est de comparer les performances de trois alternatives dans R pour l'analyse de données :
- l'utilisation des seules fonctions de base R
- dplyr
- data.table
Rappels sur dplyr et data.table
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 et on vous conseille l'excellent article a gentle introduction to data.table.
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...))
ettransmute(data, newvar1=fonction(var1,var2...))
créent de nouvelles variablesfilter(data, condition)
sélectionne au sein d'une table certaines observations, à la manière dewhere
dans SAS.arrange(data, var1, descending var2,...)
trie une base selon une ou plusieurs variables (l'équivalent d'uneproc sort
).select(data, var1 : varX)
sélectionne certaines variables dans une base, à la manière dekeep
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, issu du package magrittr
) 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(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 les variables au sein de list()
permet d'avoir une table en sortie au niveau de idgpe
(donc 50 observations), sans cela la 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 :
expr | lq | mean | uq | neval |
---|---|---|---|---|
dplyr | 9.79270 | 13.23297 | 14.367579 | 100 |
data.table | 1.40644 | 2.13729 | 2.546176 | 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.
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)
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)
En dplyr
flights %>% group_by(time_hour, origin) %>%
summarise(arr_delay = mean(arr_delay),
dep_delay = mean(dep_delay)) %>%
ungroup() %>%
inner_join(weather, by = c("time_hour", "origin")) -> output_dplyr
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"))
On utilise la fonction merge
plutôt que DT1[DT2, on = c("time_hour", "origin"), nomatch = 0]
car on a constaté qu'elle était plus rapide, conformément à ce que montre bien cet article du Jozef's Rblog.
Comparaisons des vitesses d'exécution
Chacun jugera 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 :
expr | lq | mean | uq | neval |
---|---|---|---|---|
Base | 1182.33161 | 1396.52780 | 1559.42968 | 10 |
dplyr | 223.45642 | 313.16457 | 360.95388 | 10 |
DT | 22.83487 | 24.68264 | 26.32068 | 10 |
Les résultats sont très nettement en faveur des packages dplyr
et data.table
. Ce dernier est même assez largement le plus rapide, son avantage s'étant accru depuis la première version de cette article. 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 en termes de vitesse d'exécution que les fonctions de base R.