Fonctionnement de across() dans dplyr

Vous avez dû voir passer cette information : une mise à jour majeure de dplyr (version 1.0.0) est sortie il y a quelques mois! L’occasion de faire une nouvelle petite note sur un élément très important de cette nouvelle version : across(), un nouveau verbe pour réaliser des opérations sur plusieurs colonnes. On va le présenter rapidement et regarder ensuite ses performances en termes de vitesse d’exécution par rapport aux anciennes méthodes. On utilise la version 1.0.2 de dplyr, celle sur le CRAN à ce jour, et qui a justement été optimisée par rapport à la version 1.0.0. Cette note est organisée en deux parties :
- across(), ça marche comment?, où l’on présente les bases de across().
- across(), ça tourne comment?, où l’on évalue la vitesse d’exécution par rapport aux anciennes méthodes.

Edit 2021

Cet article a été mis à jour sur le blog de Statoscop avec la dernière version de dplyr qui corrige fortement les problèmes évoqués ci-dessous.

Si vous voulez balayer plus largement les différents éléments de la mise à jour de dplyr, vous pouvez vous rendre sur le site du tidyverse (en anglais) ou sur cet article du blog de ThinkR (en français) qui en présentent les changements majeurs.

across(), ça marche comment?

Syntaxe de base

Le verbe across() vise à remplacer toutes les fonctions suffixées par _if, _at et _all. Il regroupe ces méthodes dans une seule et permet ainsi de les associer, ce qui n’était pas possible avant. Il s’utilise dans mutate et summarise. La syntaxe associée à ce verbe est la suivante :

across(.cols, .fns)

Dans laquelle :
- Les colonnes .cols peuvent être sélectionnées en utilisant la même syntaxe que pour la méthode vars() (nom des variables, starts_with, end_with, contains,…), mais aussi avec des conditions rentrées dans where() qui sélectionneront de la même manière que le faisaient les fonctions suffixées par _if.
- La fonction .fns est définie comme auparavant (le nom de la fonction ou sa définition “à la volée” avec ~ my_fun(.)).

Quelques exemples

Pour changer, on utilise pour ces petits exemples la table penguins promue par Allison Horst pour remplacer l’usage de la table iris. Vous pouvez l’obtenir depuis le package palmerpenguins sur le CRAN. À partir de cette table, l’instruction visant à sortir la moyenne de toutes les variables numériques s’écrivait auparavant :

penguins %>% summarise_if(is.numeric, mean, na.rm = TRUE)
## # A tibble: 1 x 5
##   bill_length_mm bill_depth_mm flipper_length_mm body_mass_g  year
##            <dbl>         <dbl>             <dbl>       <dbl> <dbl>
## 1           43.9          17.2              201.       4202. 2008.

Elle se réécrit avec across() en utilisant where() :

penguins %>% summarise(across(where(is.numeric), mean, na.rm = TRUE))
## # A tibble: 1 x 5
##   bill_length_mm bill_depth_mm flipper_length_mm body_mass_g  year
##            <dbl>         <dbl>             <dbl>       <dbl> <dbl>
## 1           43.9          17.2              201.       4202. 2008.

Si l’on souhaite sélectionner à partir du nom des variables, la nouvelle syntaxe est la suivante :

# Ancienne version
penguins %>% summarise_at(vars(matches("bill*|flipper*")), mean, na.rm = TRUE)
## # A tibble: 1 x 3
##   bill_length_mm bill_depth_mm flipper_length_mm
##            <dbl>         <dbl>             <dbl>
## 1           43.9          17.2              201.

# Avec across()
penguins %>% summarise(across(matches("bill*|flipper*"), mean, na.rm = TRUE))
## # A tibble: 1 x 3
##   bill_length_mm bill_depth_mm flipper_length_mm
##            <dbl>         <dbl>             <dbl>
## 1           43.9          17.2              201.

On note également qu’on peut combiner dorénavant les sélections sur les types des colonnes et sur leur nom dans une seule instruction across(), ce qui n’était pas possible avant. Pour enlever les années des moyennes numériques, on peut par exemple écrire :

penguins %>% summarise(across(where(is.numeric) & -contains("year"), mean, na.rm = TRUE))
## # A tibble: 1 x 4
##   bill_length_mm bill_depth_mm flipper_length_mm body_mass_g
##            <dbl>         <dbl>             <dbl>       <dbl>
## 1           43.9          17.2              201.       4202.

Enfin, le paramètre .names de across() est également très pratique et permet notamment dans une instruction mutate() de créer de nouvelles colonnes nommées à partir des anciennes auxquelles on peut se référer avec .col. Par exemple, si je veux créer deux nouvelles colonnes passant les informations sur le bec en pouces mais en conservant les anciennes colonnes, je peux écrire :

penguins %>% 
  mutate(across(starts_with("bill"), ~ . * 0.04, .names = "pouces_{.col}")) %>% 
  select(contains("bill")) %>% head(5)
## # A tibble: 5 x 4
##   bill_length_mm bill_depth_mm pouces_bill_length_mm pouces_bill_depth_mm
##            <dbl>         <dbl>                 <dbl>                <dbl>
## 1           39.1          18.7                  1.56                0.748
## 2           39.5          17.4                  1.58                0.696
## 3           40.3          18                    1.61                0.72 
## 4           NA            NA                   NA                  NA    
## 5           36.7          19.3                  1.47                0.772

across(), ça tourne comment?

À la sortie de la mise à jour de dplyr, il avait été signalé que la méthode across() impliquerait peut-être de légères pertes en termes de vitesse d’exécution par rapport aux anciennes méthodes _at, _if et _all. Une partie de ce retard a été apparemment rattrapé dans les dernières mises à jour et donc dans la version 1.0.2 que l’on utilise dans cet article. Sur le modèle de ce que l’on a proposé dans un article précédent, on va comparer les instructions _if et _at d’un summarise groupé avec leurs équivalents dans across() pour différentes tailles d’échantillons et de groupes.

Le tibble utilisé a le format suivant, ici pour 100 lignes et deux groupes :

nbrow <- 100
nbgpe <- 2
as_tibble(data.frame(x1 = rnorm(nbrow), x2 =  rnorm(nbrow), 
                     x3 = runif(nbrow), x4 = runif(nbrow),
                     y = as.factor(sample(floor(nbgpe), replace = TRUE))
                               )) %>% 
  arrange(x1)
## # A tibble: 100 x 5
##       x1      x2     x3    x4 y    
##    <dbl>   <dbl>  <dbl> <dbl> <fct>
##  1 -2.60 -1.05   0.0108 0.921 2    
##  2 -2.11  0.0521 0.264  0.331 2    
##  3 -2.10  1.72   0.441  0.366 1    
##  4 -2.09 -1.47   0.762  0.580 2    
##  5 -1.83  0.867  0.420  0.265 1    
##  6 -1.62 -0.503  0.955  0.911 2    
##  7 -1.60  0.362  0.638  0.874 1    
##  8 -1.54  0.121  0.403  0.592 2    
##  9 -1.36 -0.503  0.259  0.959 2    
## 10 -1.34  2.05   0.565  0.230 2    
## # ... with 90 more rows

Les différentes instructions testées sont les suivantes :

# summarise_if  
data %>% group_by(y) %>% summarise_if(is.numeric, mean) 

# across + where()  
data %>% group_by(y) %>% summarise(across(where(is.numeric), mean))  

# summarise_at  
data %>% group_by(y) %>% summarise_at(vars(starts_with("x")), mean) 

# across + starts_with()  
data %>% group_by(y) %>% summarise(across(starts_with("x"), mean))

Les résultats du microbenchmark() pour les différentes combinaisons de nombres de groupes et de lignes sont présentés dans un graphique qui représente la distribution du temps d’exécution des 10 occurences testées pour chaque méthode :

On constate en effet une moins bonne performance en termes de vitesse d’exécution des instructions utilisant le verbe across(). Les différences sont surtout marquées dans le cas où il y a beaucoup de groupes par rapport au nombre de lignes (colonne de droite) et ce quelque soit le nombre de lignes. Elles sont moins importantes dans le cas où il y a peu de groupes par rapport au nombre de lignes (colonne de gauche).

Sur le même sujet