Astuces d'optimisation d'un script R

On regroupe ici quelques astuces pour optimiser le temps d’exécution d’un code R. On en propose pour l’instant quatre, mais le post pourra être actualisé par la suite. L’idée est de regrouper des situations auxquelles chacun pourrait être confronté. Les points explorés dans cette note sont les suivants :

Définition d’une fonction apply sur les colonnes d’un dataframe

Imaginons que vous souhaitiez appliquer une fonction à un ensemble de variables d’un data.frame, définies dans une liste (par exemple pour faire une fonction qui appliquerait des statistiques sur un ensemble de variables choisies par l’utilisateur). On définit donc une telle liste de variables, dans la table flights du package nycflights13:

var <- c("dep_delay", "arr_delay", "air_time")

On sort ensuite les moyennes de ces trois variables avec sapply. En base R, cela peut s’écrire ainsi :

Option 1

sapply(var, function(x) sum(flights[[x]], na.rm = TRUE))
## dep_delay arr_delay  air_time 
##   4152200   2257174  49326610

Mais aussi ainsi :

Option 2

sapply(flights[var], function(x) sum(x, na.rm = TRUE))
## dep_delay arr_delay  air_time 
##   4152200   2257174  49326610

Cette dernière option pouvant se simplifier, puisqu’on n’a pas vraiment besoin de définir notre fonction à la volée dans ce cas :

Option 2 bis

sapply(flights[var], sum, na.rm = TRUE)
## dep_delay arr_delay  air_time 
##   4152200   2257174  49326610

Ainsi, l’option 2 peut sembler à juste titre plus intuitive (ne serait-ce que parce qu’elle se simplifie avec l’option 2bis), pourtant l’option 1 est significativement plus rapide, comme le montre la fonction microbenchmark :

## Unit: milliseconds
##          expr      min       lq     mean   median       uq      max neval
##      Option 1 1.568429 1.578101 1.664026 1.580945 1.595168 2.863791    50
##      Option 2 1.917159 1.926831 1.991547 1.935932 1.980875 2.935472    50
##  Option 2 bis 1.910902 1.920004 1.956117 1.934794 1.962102 2.326759    50

C’est bon à savoir, mais pour ce genre de traitement ça vaut le coup de s’intéresser aux méthodes dplyr et data.table qui offrent des solutions faciles et efficaces.

group_by par une variable caractère

Quelque chose de très simple à faire pour optimiser ses codes en dplyr : ne pas faire de group_by sur des variables caractères mais sur des factors. On montre ici un exemple très simple sur la même base flights. Tout d’abord, faisons une moyenne des retards à l’arrivée groupée par le lieu d’origine :

flightstib %>% group_by(origin) %>% 
  summarize(mean_delay = mean(arr_delay, na.rm = TRUE))

On compare la rapidité de cette instruction, à celle-ci, qui fait la même chose sur une variable factor :

flightstib$originfac <- as.factor(flightstib$origin)
flightstib %>% group_by(originfac) %>% 
  summarize(mean_delay = mean(arr_delay, na.rm = TRUE))

Le résultat de la fonction microbenchmark appliquée à ces deux instructions donne :

## Unit: milliseconds
##                expr      min       lq     mean   median       uq      max
##  group by character 14.53741 14.74278 15.99699 14.82441 15.64134 25.96469
##     group by factor 11.86932 12.05932 13.04685 12.13243 12.29911 18.96564
##  neval
##    100
##    100

La différence est de l’ordre de 20% et peut peser encore plus sur des tables plus volumineuses. Elle est attendue car le type factor est plus léger et convient parfaitement pour des statistiques groupées. Mais cela semble jouer particulièrement pour les instructions sur dplyr, comme le suggère cette discussion sur le github de dplyr. À noter qu’on ne compte pas dans la comparaison le temps de transposition d’une variable caractère en factor, puisque celui-ci peut être appliqué une seule fois pour de nombreuses instructions ou être appliqué au moment de l’import des bases.

Ralentissement de la vitesse d’exécution pour une création de variable directement à l’intérieur de summarise()

Si on reprend l’exemple donné dans le précédent post, vous avez pu remarquer que :

# Rappel : df <- data.frame(id1 = c(1:100), idgpe = sample(50), replace = TRUE)
df %>% as_tibble() %>% mutate(var = rnorm(100)) %>% 
group_by(idgpe) %>% summarise(var_mean = mean(var)) -> output_tibble

pouvait se réécrire de manière plus directe (comme le fait d’ailleurs la partie sur data.table) ainsi :

df %>% as_tibble() %>%  group_by(idgpe) %>% 
  summarise(var_mean = mean(rnorm(100))) -> output_tibble

C’est-à-dire en se passant du mutate pour remplacer var par sa valeur dans summarise.
Hé bien, cette instruction n’est pas seulement présentée ainsi pour le plaisir de vous montrer la fonction mutate, mais aussi parce que la première option est bien plus rapide que la seconde, comme le montre la fonction microbenchmark :

microbenchmark::microbenchmark(times=100L, dplyr1 = {
  df %>% as_tibble() %>% mutate(var = rnorm(100)) %>% 
    group_by(idgpe) %>% summarise(var_mean = mean(var)) 
}, dplyr2 = {
  df %>% as_tibble() %>% group_by(idgpe) %>% 
    summarise(var_mean = mean(rnorm(100))) 
})
## Unit: milliseconds
##    expr      min       lq     mean   median       uq       max neval
##  dplyr1 1.510403 1.553923 1.598069 1.574118 1.609106  1.888715   100
##  dplyr2 2.557160 2.628840 2.894916 2.655293 2.713036 12.905264   100

Ca peut sembler secondaire pour cet exemple, mais sur des grosses tables la différence va vraiment peser. Regardons par exemple les différences de performance de deux instructions dplyr agrégeant par heure une variable égale au pourcentage de retard à l’arrivée par rapport à la durée du vol en utilisant les données de nycflights13:

microbenchmark::microbenchmark(times=10L, dplyr_mutate = {
flightstib %>% mutate(propor_delay = arr_delay / air_time) %>% 
group_by(time_hour) %>% 
summarise(propor_delay = mean(propor_delay)) -> output_dplyr 
}, dplyr_sans_mutate = {
flightstib %>% group_by(time_hour) %>% 
summarise(propor_delay = mean(arr_delay / air_time)) -> output_dplyr 
})
## Unit: milliseconds
##               expr      min        lq      mean    median        uq
##       dplyr_mutate  24.1488  24.64203  25.23982  24.67844  25.79005
##  dplyr_sans_mutate 203.7348 205.64452 211.34958 208.19116 213.98445
##        max neval
##   28.70845    10
##  238.19298    10

Les performances changent du tout au tout. Il semblerait donc que cela soit une très mauvais pratique d’essayer de “sauter” l’étape du mutate(), sans doute parce que le group_by peine à traiter le regroupement d’une opération de variables pas encore regroupées. C’est une propriété de summarise qu’il est important d’avoir à l’esprit.

Ralentissement de la vitesse d’exécution pour une statistique sur une variable booléenne

Imaginons maintenant que l’on crée une variable booléenne indiquant si le pourcentage de retard à l’arrivée est supérieur à 20%.

flightstib %>% mutate(bool_delay = (arr_delay / air_time > 0.20)) 

On peut vouloir savoir combien de vols connaisent un retard supérieur à 20% à chaque heure, en agrégeant cette première variable et en appliquant un sum() sur celle-ci.

flightstib %>% mutate(bool_delay = (arr_delay / air_time > 0.20)) %>% 
  group_by(time_hour) %>% summarise(delay_over_20p = sum(bool_delay)) -> stats

Cette instruction tourne sans problèmes, mais lentement du fait de la difficulté de dplyr à traiter une somme sur un booléen. Il vaut mieux alors définir dans mutate la variable bool_delay comme une variable numérique avec as.numeric(arr_delay / air_time > 0.20) pour optimiser la rapidité du code. Le tableau suivant donne le résultat du microbenchmark de ces deux options :

## Unit: milliseconds
##              expr       min       lq      mean    median        uq
##              bool 150.76373 154.0661 171.61047 157.65526 160.14871
##  as.numeric(bool)  25.38386  25.6666  27.45456  26.93977  28.94454
##        max neval
##  301.75275    10
##   31.40669    10

Les gains en temps d’exécution sont particulièrement importants. On ne constate pas une telle différence avec data.table par exemple.

Sur le même sujet