Mon top 7 des évolutions récentes de R-Tidyverse

En quelques années à peine, Tidyverse a pris une place considérable au sein de l’écosystème R. Pour certains ce fut leur point d’entrée dans l’apprentissage de R, pour beaucoup une passionnante mise en perspective de leurs acquis, pour d’autres enfin une agaçante remise en cause d’un « base R » qui fonctionnait très bien sans cette surcouche…

A l’origine de Tidyverse, il y a l’idée d’un homme, Hadley Wickham, directeur scientifique chez RStudio concepteur de l’incontournable librairie graphique ggplot2, statisticien visionnaire et charismatique.

Tidyverse s’est d’abord appelé « hadleyverse », l’univers d’Hadley, avant que celui-ci ne le renomme stratégiquement et opportunément en 2016. Il offre un ensemble cohérent de librairies, partageant les mêmes principes de conception, les mêmes API, conçues pour interagir de façon harmonieuse. Tidyverse vise à simplifier considérablement l’apprentissage de R pour un data-scientist, tout en donnant à R une nouvelle jeunesse par des fondations rénovées (rlang et vctrs). Il propose une grammaire élégante et accessible, et des principes de codages modernes (programmation fonctionnelle et fonctions « pures »).

En posant début 2014 les premières pierres de l’édifice (dplyr et tidyr), Wickham avait d’emblée affirmé cette ambition, à l’évidence longuement mûrie. Il a constitué l’équipe pour la mener à bien, d’abord autour de lui chez RStudio, et avec des dizaines de contributeurs externes. Fin 2019 la version 1.0 de tidyr est apparue, et mi 2020 c’est au tour de dplyr d’atteindre ce jalon symbolique.

C’est donc le bon moment, quelques mois après, pour revenir, avec le recul de la pratique, sur les principales avancées de l’année écoulée. Ce top 7 est subjectif et retient ce qui m’a le plus servi, sans préjuger de l’intérêt de ce que je n’ai pas encore eu l’occasion d’exploiter. Tidyverse recouvre aujourd’hui un grand nombre de librairies. Mon analyse concerne plus particulièrement dplyr, tidyr et rlang. Le commentateur et l’utilisateur que je suis observe une œuvre en train de s’épanouir, avec aussi ses imperfections, ses faux-départs, ses emballements et ses nécessaires erreurs comme le reconnait Wickham[7]. Nous pouvons tous contribuer à ce fascinant déploiement par nos retours et nos suggestions.

Je vais prendre comme d’habitude quelques jeux de données concrets comme base de travail, l’un sur les populations légales communales, l’autre sur les élections municipales 2020.

1 – Sélectionner des colonnes

Commençons par charger sur le site de l’Insee le fichier des populations légales des communes au 1er janvier 2020 :
library(tidyverse) ; library(janitor) ; library(curl) ; library(readxl)
urlpopleg <- "https://insee.fr/fr/statistiques/fichier/4265429/ensemble.xls"
curl_download(urlpopleg, xlsfile <- tempfile())  

tb_com <- read_excel(xlsfile, sheet = 5, skip = 7) %>% 
          janitor::clean_names()     # normalise les noms de colonne
Examinons le contenu de cette table. Elle comprend quelques colonnes de codes géographiques, deux colonnes de libellé et trois colonnes numériques de population :
glimpse(tb_com)

Rows: 34,995
Columns: 10
$ code_region               <chr> "84", "84", "84", "84", "84", "84", "84", "84", "84...
$ nom_de_la_region          <chr> "Auvergne-Rhône-Alpes", "Auvergne-Rhône-Alpes", "Au...
$ code_departement          <chr> "01", "01", "01", "01", "01", "01", "01", "01", "01...
$ code_arrondissement       <chr> "2", "1", "1", "2", "1", "1", "1", "1", "1", "4", "...
$ code_canton               <chr> "08", "01", "01", "22", "04", "01", "01", "04", "10...
$ code_commune              <chr> "001", "002", "004", "005", "006", "007", "008", "0...
$ nom_de_la_commune         <chr> "L' Abergement-Clémenciat", "L' Abergement-de-Varey...
$ population_municipale     <dbl> 776, 248, 14035, 1689, 111, 2726, 752, 330, 1115, 3...
$ population_comptee_a_part <dbl> 18, 1, 393, 34, 6, 115, 15, 9, 17, 11, 4, 4, 92, 5,...
$ population_totale         <dbl> 794, 249, 14428, 1723, 117, 2841, 767, 339, 1132, 3...
Je veux garder seulement un code commune officiel (qui sera la concaténation à 5 caractères de code_departement et code_commune), le libellé des communes et les 3 variables numériques. Voici plusieurs façons de préparer le terrain, avec la syntaxe étendue tidy-select :
# on liste les colonnes désirées nommément ou par un début de chaine
tb_com_2 <- tb_com %>% 
            select(code_departement, code_commune, nom_de_la_commune, starts_with("pop")) 

# version raccourcie avec un test de présence de chaine
tb_com_2 <- tb_com %>% 
            select(code_departement, contains("commune"), starts_with("pop")) 

# encore plus compact avec des indices de colonne 
# ici, on ne cite aucun nom de colonne, intéressant quand ceux-ci sont compliqués à écrire
tb_com_2 <- tb_com %>% select(3, 6, 7, starts_with("pop"))
# | signifie "ou", il est équivalent à une juxtaposition délimitée par des ,
# & est un autre opérateur qui va intersecter des conditions, présente moins d'intérêt ici

# voici une version plus ouverte, avec une référence au typage numérique des col. à garder
tb_com_2 <- tb_com %>% select(3, 6:7, where(is.numeric))
# is.numeric est une fonction, qui peut devenir quelconque (donc personnalisée) 
# where permet d'élargir le jeu de fonctions standards "starts_with", "contains", etc.

2 – Renommer des colonnes

La nouvelle fonction rename_with() simplifie en les unifiant les possibilités des rename_if, _at, _all. Elle amène plus de souplesse et reste intuitive à manier :
# la puissance de rename_with : appliquer une transformation à toutes les colonnes
tb_com_3 <- tb_com_2 %>% 
            rename_with(toupper)
# => tout en majuscules

# la fonction peut être personnalisée, et s'appliquer à un jeu de colonnes ciblé
tb_com_3 <- tb_com_2 %>% 
            rename_with(~ str_replace(., 'population', 'pop'), where(is.numeric))
# ici on veut raccourcir le nom des colonnes numériques

# rappel : on va parfois plus vite en renommant simplement d'après les indices de colonnes
tb_com_3 <- tb_com_2 %>% 
            rename(pop_mun = 4, pop_a_part = 5, pop_tot = 6)

# et ici une écriture qui permet de distinguer la définition du renommage de son application 
varnums  <- list(libgeo = 3, pop_mun = 4, pop_a_part = 5, pop_tot = 6)
tb_com_3 <- tb_com_2 %>% 
            rename(!!!varnums) # le !!! "étale" la liste varnums sous forme de paramètres
# => rend une chaine dplyr plus lisible quand nb. d'opérations s'enchaînent

# A tibble: 34,995 x 6
   code_departement code_commune libgeo                   pop_mun pop_a_part pop_tot
 1 01               001          L' Abergement-Clémenciat     776         18     794
 2 01               002          L' Abergement-de-Varey       248          1     249
 3 01               004          Ambérieu-en-Bugey          14035        393   14428

3 – Déplacer des colonnes

Dplyr 1.0 améliore la gestion du positionnement d’une nouvelle colonne, ou des colonnes à supprimer après application de la formule de création. Cela se traduit par l’apparition de nouveaux paramètres dans mutate(), et aussi la nouvelle fonction relocate().
# cette écriture ajoute par défaut ma nouvelle colonne tout à la droite de la table
tb_com_4 <- tb_com_3 %>% 
            mutate(codgeo = str_c(str_sub(code_departement,1,2), code_commune))   

# A tibble: 34,995 x 7
   code_departement code_commune libgeo                   pop_mun pop_a_part pop_tot codgeo
 1 01               001          L' Abergement-Clémenciat     776         18     794 01001 
 2 01               002          L' Abergement-de-Varey       248          1     249 01002 

# je voudrais qu'elle apparaisse comme la nouvelle 1ère colonne
tb_com_4 <- tb_com_3 %>% 
            mutate(codgeo = str_c(str_sub(code_departement,1,2), code_commune), 
                   .before = 1)  
# .before cible la colonne avant laquelle l'insérer (nom ou indice)

# cet autre paramètre .keep élimine les variables utilisées dans la concaténation
tb_com_4 <- tb_com_3 %>% 
            mutate(codgeo = str_c(str_sub(code_departement,1,2), code_commune), 
                   .before = 1, .keep = "unused") 
# je ne conserve ainsi que le résultat de mon calcul

# A tibble: 34,995 x 5
   codgeo libgeo                   pop_mun pop_a_part pop_tot
 1 01001  L' Abergement-Clémenciat     776         18     794
 2 01002  L' Abergement-de-Varey       248          1     249

# relocate peut faire bouger tout un ensemble de colonnes
tb_com4 <- tb_com_4 %>% 
           relocate(where(is.numeric), .after = last_col())
# ne change rien ici car les col. numériques étaient déjà toutes à droite

4 – Compléter une table avec une autre

Nous avons donc une table de 34 995 lignes, et cet effectif m’interpelle car j’ai en tête un nombre de communes de France en 2020 se terminant par 8. Je vais vérifier ce qu’il en est en récupérant une table que je sais exacte, décrivant pour chaque commune son appartenance au zonage d’attraction des villes. Et en effet, le bon effectif est 34 968. Ma table initiale a donc trop de lignes. Voyons comment analyser ce qu’il y a de différent.
urlzaav <- "https://www.data.gouv.fr/fr/datasets/r/a9221cc4-089f-4cff-b557-d1b71f7be443"
# zonage pour les communes 2020 en aires d'attraction des villes
xlsfile <- curl_download(urlzaav, tempfile(), quiet = F) 
tb_zaav <- read_excel(xlsfile, sheet = 1, skip = 2) %>% 
           select(codgeo = 1, libgeo = 2) 

# A tibble: 34,968 x 2
   codgeo libgeo                 
 1 01001  L'Abergement-Clémenciat
 2 01002  L'Abergement-de-Varey  
 3 01004  Ambérieu-en-Bugey 

# anti_join me donne ici les lignes de tb_com_4 qui ne sont pas dans tb_zaav
tb_com_4 %>% anti_join(tb_zaav, by = "codgeo")
# je précise la clé de jointure, ici sur codgeo et uniquement sur codgeo

# A tibble: 48 x 5
   codgeo libgeo                       pop_mun pop_a_part pop_tot
 1 13201  Marseille 1er Arrondissement   39786        181   39967
 2 13202  Marseille 2e Arrondissement    24810         63   24873

# je regarde si à l'inverse il ne manquerait pas des lignes dans tb_com_4
tb_zaav %>% anti_join(tb_com_4, by = "codgeo")

# A tibble: 21 x 2
   codgeo libgeo     
 1 13055  Marseille  
 2 14666  Sannerville
 3 69123  Lyon       
 4 75056  Paris      
 5 97601  Acoua      
 6 97602  Bandraboua 

# voici une autre manière de considérer les différences, centrée sur les libellés géo.
setdiff(tb_com_4$libgeo, tb_zaav$libgeo)
# noter ces blancs peu esthétiques

  [1] "L' Abergement-Clémenciat"      "L' Abergement-de-Varey"       
  [3] "L' Épine-aux-Bois"             "L' Escale"                    
  [5] "L' Hospitalet"                 "L' Argentière-la-Bessée" 
Les enseignements que je tire de cette analyse sont les suivants :
  • ma table initiale comprend des lignes pour chaque arrondissement de Paris, Lyon et Marseille ;
  • a contrario, elle ne décrit pas le total pour Paris, Lyon et Marseille ;
  • les communes de Mayotte sont absentes ;
  • Sannerville (14666) manque. Après exploration, c’est une commune rétablie, après scission, début 2020 ;
  • enfin il y a des petites variations de libellés, avec un espace disgracieux après L’ dans ma table initiale.
Je vais utiliser cette nouvelle table de référence (tb_zaav) pour redresser ma table initiale (tb_com_4) :
  • en ajoutant les communes manquantes ;
  • en corrigeant les libellés de commune initiaux.
La nouvelle fonction rows_upsert() fait tout le travail ! Pour qu’elle fonctionne, il faut que la table qui corrige aie des colonnes qui soient présentes, sous le même nom, dans la table à corriger. C’est bien le cas ici (codgeo, libgeo) :
tb_com_5 <- tb_com_4 %>% rows_upsert(tb_zaav, by = "codgeo") 

# A tibble: 35,016 x 5
   codgeo libgeo                  pop_mun pop_a_part pop_tot
 1 01001  L'Abergement-Clémenciat     776         18     794
 2 01002  L'Abergement-de-Varey       248          1     249
 3 01004  Ambérieu-en-Bugey         14035        393   14428

# je vérifie ce qui a été ajouté
tb_com_5 %>% filter(is.na(pop_mun))

# A tibble: 21 x 5
   codgeo libgeo      pop_mun pop_a_part pop_tot
 1 13055  Marseille        NA         NA      NA
 2 14666  Sannerville      NA         NA      NA
 3 69123  Lyon             NA         NA      NA
 4 75056  Paris            NA         NA      NA
 5 97601  Acoua            NA         NA      NA

Il me manque naturellement les données de population pour les nouvelles communes insérées. Mais s’agissant de Paris, Lyon et Marseille, je vais pouvoir les calculer en sommant les données par arrondissement. Ce que nous allons aborder dans la rubrique suivante.

5 – Paramétrer une composition dplyr

Cette rubrique est un peu plus technique et intéressera surtout celles ou ceux qui aiment écrire des fonctions. Si ce n’est pas votre cas, vous pouvez passez directement à la 6 !

Reprenons notre table avant redressement, tb_com_4, avec dans l’idée de calculer des totaux pour Paris, Lyon et Marseille (PLM), et de le faire via une seule et unique fonction, spécialement affinée. Parmi les variables à considérer :

  • la plage de codes arrondissements pour chaque ville PLM ;
  • 3 nouveaux codes commune à définir pour Paris, Lyon et Marseille
  • et pour corser l’exposé, nous allons mettre le nom de colonne « codgeo » dans une variable, notre nouvelle fonction n’en sera que plus générique !
Dans la syntaxe dplyr, on écrit directement des noms de colonne (ex : code_departement ou codgeo) sans quotes, ils sont bien reconnus, par défaut, comme des noms de colonne de la table en cours de traitement, et non comme d’éventuelles variables d’environnement. C’est ce qu’on appelle le data-masking. Du coup, comment les distinguer de véritables variables ? C’est là que {{…}} intervient (embrace pour les initiés), et c’est une nouveauté de la librairie rlang, incluse dans tidyverse, chargée par défaut par dplyr. Examinons pas-à-pas l’introduction d’une variable dans la syntaxe dplyr :
varcodgeo <- 'codgeo' 
tb_com_4 %>% select(varcodgeo)
# ici varcodgeo est bien décodé, car il est isolé dans le select

# A tibble: 34,995 x 1
   codgeo
 1 01001 
 2 01002 

# cela passe encore ici 
tb_com_4 %>% select(varcodgeo, pop_mun)

# A tibble: 34,995 x 2
   codgeo pop_mun
 1 01001      776
 2 01002      248
 3 01004    14035

# mais erreur ici car mélange de variable et de nom de colonne 
tb_com_4 %>% select(varcodgeo | pop_mun)
#Erreur : Can't subset columns that don't exist.
x Column `varcodgeo` doesn't exist.

# voici une écriture qui fonctionne + génériquement
tb_com_4 %>% select({{varcodgeo}} | pop_mun)

# et une variante qui fonctionne aussi, .data désignant le flux de données courant
tb_com_4 %>% select(.data[[varcodgeo]] | pop_mun)
{{varcodgeo}} et .data[[varcodgeo]] sont ici deux façons (non totalement équivalentes[3]) de rendre évaluable une variable dans une chaîne dplyr. La première écriture est plus agréable. C’est celle que nous allons privilégier.
Voici maintenant comment aborder le ciblage de chaque grande ville PLM et la sommation des populations de leurs arrondissements. Nous allons prendre l’exemple de Marseille pour asseoir la logique du raisonnement. Il s’agit de filtrer sur une plage de codes arrondissement, d’agréger cet extrait, et d’injecter le code et le libellé de la cité phocéenne en entier :
# les arrondissements de Marseille sont codés de 13201 à 13216
tb_marseille <- tb_com_4 %>% 
                filter(codgeo >="13201" & codgeo <= "13216") %>%
                summarise_if(is.numeric, sum, na.rm = TRUE) %>%
                mutate(codgeo = "13055", libgeo = "Marseille", .before = 1)

# A tibble: 1 x 5
  codgeo libgeo    pop_mun pop_a_part pop_tot
1 13055  Marseille  863310       6505  869815

# variante du filter isolant codgeo pour faciliter son remplacement
tb_marseille <- tb_com_4 %>% 
                filter(across(codgeo,  ~ . >= "13201" & . <= "13216")) %>%
                summarise_if(is.numeric, sum, na.rm = TRUE) %>%
                mutate(codgeo = "13055", libgeo = "Marseille", .before = 1)
# ~ . >= "13201" & . <= "13216") est une forme raccourcie, dite "anonyme" pour
# function(c) {return (c >= "13201" & c <= "13216")}

# insertion de la variable varcodgeo avec {{varcodgeo}}
tb_marseille <- tb_com_4 %>% 
                filter(across({{varcodgeo}},  ~ . >= "13201" & . <= "13216")) %>%
                summarise_if(is.numeric, sum, na.rm = TRUE) %>%
                mutate({{varcodgeo}} := "13055", libgeo = "Marseille", .before = 1)
# noter le := dans mutate({{varcodgeo}} := "13055"

# insertion des autres variables définissant Marseille
codarm1     <- "13201"
codarm2     <- "13216"
codgeocible <- "13055"
libgeocible <- "Marseille"
varlibgeo   <- "libgeo"

tb_marseille <- tb_com_4 %>% 
                filter(across({{varcodgeo}},  ~ . >= codarm1 & . <= codarm2)) %>%
                summarise_if(is.numeric, sum, na.rm = TRUE) %>%
                mutate({{varcodgeo}} := codgeocible, 
                       {{varlibgeo}} := libgeocible, .before = 1)

# A tibble: 1 x 5
  codgeo libgeo    pop_mun pop_a_part pop_tot
1 13055  Marseille  863310       6505  869815
Nous sommes maintenant en mesure de poser une fonction pour ajouter les totaux PLM à tb_com_4 :
# fonction qui somme les variables numériques choisies d'un jeu d'arrondissements 
insertTotArm <- function(tb, varcodgeo, codgeoCible, codarm1, codarm2, 
                         varlibgeo, libgeocible) {
      tb %>% 
        filter(across(varcodgeo,  ~ .>= codarm1 & .<= codarm2)) %>%
        summarise_if(is.numeric, sum, na.rm = TRUE)  %>%   
        mutate({{varcodgeo}} := codgeoCible, 
               {{varlibgeo}} := libgeocible, .before = 1) %>% 
        bind_rows(tb) %>% 
        arrange({{varcodgeo}})  
      # ajoute la ligne ainsi créée à la table tb passée en paramètre
      # et renvoie la table tb complétée et triée
}

# application
tb_com_5 <- tb_com_4 %>% 
            insertTotArm(codgeo, "13055", "13201", "13216", libgeo, "Marseille") %>% 
            insertTotArm(codgeo, "69123", "69381", "69389", libgeo, "Lyon") %>% 
            insertTotArm(codgeo, "75056", "75101", "75120", libgeo, "Paris") 

# vérification
tb_com_5 %>% filter(codgeo %in% c("13055","69123","75056"))

# A tibble: 3 x 5
  codgeo libgeo    pop_mun pop_a_part pop_tot
1 13055  Marseille  863310       6505  869815
2 69123  Lyon       516092       6587  522679
3 75056  Paris     2187526      17247 2204773
Notons bien la façon dont la fonction d’insertion est appelée
tb_com_5 <- tb_com_4 %>% 
            insertTotArm(codgeo, "13055", "13201", "13216", libgeo, "Marseille")
Les variables codgeo et libgeo s’écrivent sans quotes, ce qui peut surprendre quand on a l’habitude d’autres langages de programmation, mais est conforme à la grammaire dplyr, où les noms de colonne sont utilisés sans quotation dans les verbes d’action (select, arrange…) L’écriture en est d’autant plus esthétique (un peu comme avec SQL). Mais si je veux appeler cette fonction en dehors d’une chaine avec le pipe, comme ceci, cela génèrera une erreur :
insertTotArm(tb_com_4, codgeo, "13055", "13201", "13216", libgeo, "Marseille")
# erreur objet 'codgeo' introuvable
Il faudrait que je l’écrive ainsi, en ajoutant les quotes :
insertTotArm(tb_com_4, "codgeo", "13055", "13201", "13216", "libgeo", "Marseille")
Mais cela ne fonctionne pas parfaitement, le résultat n’est pas trié comme attendu : Je dois modifier l’instruction arrange dans la fonction, remplaçant {{varcodgeo}} par .data[[varcodgeo]] :
insertTotArmQuotes <- function(tb, varcodgeo, codgeoCible, codarm1, codarm2, 
                               varlibgeo, libgeocible) {
       tb %>% 
          filter(across({{varcodgeo}},  ~ .>= codarm1 & .<= codarm2)) %>%
          summarise_if(is.numeric, sum, na.rm = TRUE)  %>%   
          mutate({{varcodgeo}} := codgeoCible, 
                 {{varlibgeo}} := libgeocible, .before = 1) %>% 
          bind_rows(tb) %>% 
          arrange(.data[[varcodgeo]])  
}

insertTotArmQuotes(tb_com_4, "codgeo", "13055", "13201", "13216", "libgeo", "Marseille")
il y a donc un petit piège ici qui demande de la vigilance. Selon que l’on passe les variables sous forme de chaine ou telles quelles, sans quotes, la substitution de variables par leur valeur ne se traite pas – encore – de la même manière. Cette différence est sans doute temporaire, la librairie rlang qui gère cela devrait évoluer pour rendre {{}} absolument tout terrain.
Pour élargir la compréhension de ces substitutions de variables, voici deux exemples canoniques, qui montrent comment, selon les verbes dplyr utilisés, les règles d’interprétation diffèrent :
v <- "pop_mun"
tb_com_5 %>% arrange(3)           # KO, le tri ne se fait pas
tb_com_5 %>% arrange(v)           # KO
tb_com_5 %>% arrange({{v}})       # KO
tb_com_5 %>% arrange(.data[[v]])        # OK
tb_com_5 %>% arrange(desc(.data[[v]]))  # OK (tri décroissant)
# arrange() utilise le data-masking, cf. ?arrange
# dans ce contexte seul .data[[...]] fonctionne avec une variable passée comme string

varcodgeo <- "codgeo"
tb_com_5 %>% separate(1, c('d2','d3'), 2)                   # OK 
tb_com_5 %>% separate(varcodgeo, c('d2','d3'), 2)           # OK
tb_com_5 %>% separate({{varcodgeo}}, c('d2','d3'), 2)       # OK
tb_com_5 %>% separate(.data[[varcodgeo]], c('d2','d3'), 2)  # OK
# separate() est tidy-select compatible, cf. ?separate
# on peut utiliser indices, voire variables seules ou {{...}} dans tous les cas

# A tibble: 34,998 x 6
   d2    d3    libgeo                   pop_mun pop_a_part pop_tot
 1 01    001   L' Abergement-Clémenciat     776         18     794
 2 01    002   L' Abergement-de-Varey       248          1     249
Ainsi, pour tenter l’énoncé de deux règles plus générales :
  • arrange(), count(), filter(), group_by(), mutate(), summarise() utilisent le “data masking”. Les variables à convertir en noms de colonnes (existantes) s’utilisent au sein d’un .data[[…]] si elles sont passées en tant que « string » ;
  • across(), relocate(), rename(), select(), separate(), unite(), pull() utilisent la “tidy selection”, on peut donc valoriser le ciblage intelligent par indice, position d’une chaine, typage… Dans ce contexte une variable peut être évaluée soit directement si elle est isolée en tant que paramètre, soit via {{…}}.
Il est probable (en tout cas souhaitable) que cette différence de comportement disparaisse, au profit de la seconde formule.
Ce mécanisme {{}} qui rend enfin possible l’écriture de fonctions personnalisés dans dplyr est fondamental et représente l’aboutissement de plusieurs années de recherches et de tâtonnements. C’est l’aventure de la « tidy-evaluation« , qui s’est aussi appelée « évaluation paresseuse », ou différée, ou quasi-quotation, qui relève de problèmes conceptuels redoutables touchant aux soubassements de R. C’est ce à quoi s’emploie Lionel Henry (collègue de Wickham) avec la librairie rlang[3].

6 – Traiter différemment des ensembles de colonnes avec across()

Annoncée en fanfare, nouveauté emblématique de dplyr 1.0, across() est une façon de cibler un ensemble de colonnes (avec la souplesse de tidy-select) et de leur appliquer un même traitement. Sa « découverte » (cf. « Why did it take so long to discover across() ?« ) répond à deux objectifs :
  • simplifier la syntaxe dplyr dont l’éventail fonctionnel a beaucoup augmenté avec les déclinaisons en _at, _if, _all de nombre de verbes de base (cf. la petite pique de Matt Dowle, le créateur de data.table) ;
  • appliquer des traitements différenciés à des groupes de colonnes via plusieurs across().
Une exploitation intéressante d’across() consiste à regrouper une table en appliquant des traitements statistiques différenciés à des ensembles de colonnes selon leur type (colonnes caractères, additives, de type ratios…) En voici un exemple.
La table de la décomposition communale du zonage en aires d’attraction des villes présente des codes géographiques, des variables de typologie (caractères) et des colonnes numériques (population).
urlzaav &lt;- "https://www.data.gouv.fr/fr/datasets/r/a9221cc4-089f-4cff-b557-d1b71f7be443"
xlsfile &lt;- curl_download(urlzaav, tempfile(), quiet = F)
tb_zaav &lt;- read_excel(xlsfile, sheet = 1, skip = 2) %&gt;%
select(!starts_with("lib_"))
glimpse(tb_zaav)
 
Rows: 34,968
Columns: 10
$ com2020               <chr> "01001", "01002", "01004", "01005", "01006", "01007", "0...
$ aav2020               <chr> "524", "000", "243", "002", "286", "243", "243", "286", ...
$ typ_zaav2020          <chr> "20", "30", "11", "20", "20", "20", "20", "20", "30", "2...
$ typ2_zaav2020         <chr> "21", "30", "11", "24", "21", "21", "21", "21", "30", "2...
$ dept                  <chr> "01", "01", "01", "01", "01", "01", "01", "01", "01", "0...
$ reg                   <chr> "84", "84", "84", "84", "84", "84", "84", "84", "84", "8...
$ pop2017_com           <dbl> 776, 248, 14035, 1689, 111, 2726, 752, 330, 1115, 376, 3...
$ pop2017_aav2020       <dbl> 8043, NA, 30697, 2238656, 24284, 30697, 30697, 24284, NA...
$ typ_densite2018       <chr> "3", "4", "2", "3", "4", "3", "2", "3", "3", "3", "4", "...
$ typ_urbain_rural_2017 <chr> "H", "H", "C", "H", "H", "H", "B", "H", "H", "H", "H", "...
J’aimerais regrouper cette table par région, en calculant des statistiques différentes pour les caractères et les numériques. across() va me permettre de moduler les traitements, tout en ciblant des groupes de variables. Je vais m’intéresser pour les codes et les typologies à leurs modalités distinctes (nombre et liste), et pour les numériques au minimum, maximum et à la somme.
# jeu de fonctions pour colonnes numériques
min_max <- list(
  min = ~ min(.x, na.rm = TRUE),
  max = ~ max(.x, na.rm = TRUE)
)

# jeu de fonctions pour colonnes caractères
# nb et liste délimitée de modalités distinctes
nbmods_list <- list(
  nb  = ~ length(unique(.)),
  mod = ~ str_c(sort(unique(.)), collapse=",")
)

s <- tb_zaav %>% group_by(reg) %>%
  summarise(across(where(is.character), nbmods_list, .names = "{fn}_{col}"),
            across(starts_with("pop"),  min_max,     .names = "{fn}_{col}"),
            sum_pop2017 = sum(pop2017_com, na.rm = TRUE)) 

s %>% select(reg, starts_with("nb_"))
# donne par région le nb. de communes, d'aires d'attraction, de départements...

# A tibble: 18 x 8
   reg   nb_com2020 nb_aav2020 nb_typ_zaav2020 nb_typ2_zaav2020 nb_dept nb_typ_densite2~
 1 01            32          4               4                6       1                2
 2 02            34          4               4                5       1                3
 3 03            22          4               3                5       1                4
 4 04            24          8               4                6       1                3
 5 06            17          1               2                2       1                3
 6 11          1268          1               4                2       8                4

# examen des statistiques sur les colonnes de type numérique
s %>% select(reg, starts_with("min_"), starts_with("max_"), starts_with("sum_"))
# donne par région les min/max communaux et somme de population

# A tibble: 18 x 6
   reg   min_pop2017_com max_pop2017_com min_pop2017_aav2020 max_pop2017_aav20~ sum_pop2017
 1 01               1046           53491                7024             317425      390253
 2 02                721           80041                6695             357717      372594
 3 03                152           61268               28604             138920      268700
 4 04               5260          147931                7312             307374      853659
 5 06               5192           71437              256518             256518      256518
 6 11                 26         2187526            13024518           13024518    12174880

Il y a toutefois, pour l’heure, deux petits soucis avec across(), qui peuvent inciter à l’expérimenter sans non plus se précipiter :

  • une écriture plus lourde et moins intuitive quand il s’agit de remplacer les fonctions en _if,
  • une perte de performance dans certains contextes.
Applicateur zélé de la consigne initiale de mettre à jour mes programmes avec across(), j’ai eu la surprise de constater parfois des temps d’exécution sensiblement plus longs. En voici un exemple :
library(vroom)
mun2014 <- vroom(str_c("https://data.regardscitoyens.org/elections/2014_municipales/",
                       "MN14_Bvot_T1_01-49.txt"), 
                 col_select = -c('X4','X9','X10','X11'), col_names = FALSE, 
                 locale = locale(encoding = "WINDOWS-1252")) 
# 275000 obs, 9 variables

# test de sommation de variables numériques, syntaxe dplyr 0.8
system.time(dataag1 <- mun2014 %>%
            group_by_if(is.character) %>%
            summarise_if(is_numeric, sum) 
) # 1.2 s

# test de sommation de variables numériques, syntaxe across/dplyr 1
# écriture plus complexe et 10 fois plus longue à l'exécution
system.time(dataag2 <- mun2014 %>%
            group_by(across(where(is.character))) %>%
            summarise(across(where(is_numeric), sum)) 
) # 11 s avec la version dplyr 1.0.2

# vérification de l'identité des deux calculs
all.equal(dataag1,dataag2) # true

2 éclairages intéressants nous viennent des développeurs dplyr, en réponse à cet exemple, soumis ici sur Github :

  • Hadley Wickham : il n’y a pas de problème à continuer d’utiliser ces fonctions en if_, qui sont encore là pour plusieurs années au minimum ;
  • Romain François : nous sommes conscients des performances réduites d’across(), et nous y travaillons, mais cela va venir dans un second temps, car dplyr 1.0 représente une grosse réécriture sur un nouveau socle, vctrs[2].

Un autre progrès consisterait à autoriser l’écriture directe de fonctions élémentaires comme is.numeric(), is.character(), qui sont de même niveau que starts_with() ou contains(), sans devoir les encapsuler dans un where(), que je réserverais à des fonctions plus personnalisées.

7 – Calculer une somme en ligne

Summarise() opère sur des vecteurs/colonnes par défaut, (comme SQL), mais parfois, ce sont les variables numériques d’un même enregistrement que l’on veut sommer. On rencontre ce cas de figure avec les élections municipales, dont le nombre de listes ou de candidats est variable, ce qui se traduit par un grand nombre de colonnes dans les fichiers mis à disposition par le ministère de l’Intérieur :
library(tidyverse); library(janitor) 
urlmun <- str_c("https://static.data.gouv.fr/resources/",
                "elections-municipales-2020-resultats/",
                "20200317-201224/2020-03-16-resultats-communes-de-1-000-et-plus.csv")

tb_mun <- read_csv(urlmun) %>% clean_names() %>% select(!contains("libelle")) 

# cette table comprend 200 colonnes ! Dont 16 dont le nom commence par sieges_elu_
# en voici un aperçu
tb_mun %>% select(1:2, contains("sieges_elu_"))

# A tibble: 9,978 x 17
   code_du_departe~ code_de_la_comm~ sieges_elu_1 sieges_elu_2 sieges_elu_3 sieges_elu_4
 1                1 4                           3            0            4           NA
 2                1 5                          NA           NA           NA           NA

# réduisons cette table avec sélection des 16 colonnes à sommer en ligne
# et constitution d'un code commune officiel, codgeo, à 5 caractères
tb_mun <- tb_mun %>% select(1:2, starts_with("sieges_elu")) %>%
          rename(d2 = 1, c3 = 2) %>%  
          mutate(codgeo = str_c(str_pad(d2, 2, "left", "0"), 
                                str_pad(c3, 3, "left", "0")), 
                 .before = 1, .keep = "unused") 

# A tibble: 9,978 x 17
   codgeo sieges_elu sieges_elu_1 sieges_elu_2 sieges_elu_3 sieges_elu_4 sieges_elu_5
 1 01004          26            3            0            4           NA           NA
 2 01005          19           NA           NA           NA           NA           NA
 3 01007          18            5           NA           NA           NA           NA
A partir de cette structure simplifiée, rowwise() nous donne la possibilité de cibler tout un groupe de colonnes pour appliquer, dans le sens horizontal, une somme, et un max :
# sommation de toutes les variables commençant par sieges_elu_
tbl2 <- tbl %>% 
        rowwise() %>% 
        mutate(nbelus_t1 = sum(c_across(starts_with("sieges_elu_")), na.rm = TRUE),
               .keep = "unused")

# sommation de toutes les variables commençant par sieges_elu_ + calcul du max
tbl2 <- tbl %>% 
        rowwise() %>% 
        mutate(nbelus_t1 = sum(c_across(starts_with("sieges_elu_")), na.rm = TRUE), 
               max_nbelus_t1 = na_if(max(c_across(starts_with("sieges_elu_")), 
                                         na.rm = TRUE),-Inf),
               .keep = "unused")
        # system.time => 6 s

# A tibble: 9,978 x 4
# Rowwise: 
   codgeo sieges_elu nbelus_t1 max_nbelus_t1
 1 01004          26         7             4
 2 01005          19         0            NA
 3 01007          18         5             5
 4 01010          15         0            NA
 5 01014          19         4             4
sieges_elus était le nombre de sièges à pourvoir, nbelus_t1 nous donne le nombre d’élus dès le 1er tour. Noter à nouveau ici le grand intérêt de .keep = « unused » pour produire un résultat bien nettoyé !
Je constate toutefois que l’exécution prend près de 6 secondes, ce qui est anormalement lent. La vignette rowwise confirme en effet que le processus est complexe et coûteux (calcul pour chaque ligne séparément puis reconstitution de la table résultante). Quand une alternative optimisée existe, comme rowSums ou rowMeans, on pourra la privilégier. Mais dans notre exemple, on calcule aussi un max, rowwise() offre alors le maximum de flexibilité. On peut aussi préférer en passer par une fonction de pivot, ce qui se révèle bien plus performant ! Ces fonctions de tidyr ont superbement évolué cette dernière année, et j’aurais pu en faire un 8ème point si je ne les avais pas déjà abordées ici.
# variante rowSums
tbl2 <- tbl %>% 
        mutate(nbelus_t1 = rowSums(across(starts_with("sieges_elu_")), na.rm = TRUE), 
               .keep = "unused")

# variante pivot_longer
tbl %>% pivot_longer(starts_with("sieges_elu_")) %>% 
        group_by(across(1:2)) %>% 
        summarise(nbelus_t1 = sum(value, na.rm = TRUE), 
                  max_nbelus_t1 = na_if(max(value, na.rm = TRUE),-Inf))
        # system.time => 0.4 s
Enfin, cette nouvelle écriture c_across() en mode rowwise() me parait une complication inutile, si l’on pouvait utiliser un across() générique, ce serait plus élégant, même si défi de programmation polymorphe il y a !

Pour aller plus loin

Pour comprendre vers où va tidyverse, on ne peut guère se référer qu’aux conférences d’Hadley Wickham. Il n’y pas pas à ma connaissance de « roadmap » officielle. HW lui même dit désormais se méfier des effets d’annonces[7], après l’accouchement difficile de la « tidy-evaluation » (ou programmation avec dplyr), ou le démarrage suspendu de ggvis.

En réalité, il y a encore beaucoup à exploiter et consolider, et je l’ai peu abordé ici, autour de la possibilité de générer via dplyr, sans recourir à purr, autre chose que des dataframes : modèles, graphiques, rapports… Les évolutions de summarise (démultiplication de lignes et de colonnes), l’introduction de rowwise qui permet d’activer via dplyr un éventail nouveau de fonctions auparavant non compatibles car incapables de travailler sur des colonnes (fonctions non vectorisées) sont riches de perspectives séduisantes. Le fait que Romain François travaille à mi-temps sur Apache Arrow[2] est aussi le signe qu’il y a du côté de ces nouveaux formats de données volumineuses des enjeux stratégiques.

Je crois à la nécessité de prendre plus en considération encore la performance de calculSouvent sollicité sur ce thème, HW a coutume de répondre qu’entre l’élégance et la rapidité d’exécution, il choisit l’élégance, que le principal goulet d’étranglement, c’est le cerveau humain, et non l’ordinateur. 

Mais c’est oublier qu’aujourd’hui la concurrence est rude. Si pour un même traitement (chargement de données, agrégation, écriture dans une base, affichage d’une carte), une autre technologie opère 5 fois plus vite, et parfois même dans un simple navigateur web (cf. D3/Observable pour la visualisation), il y a des questions à se poser.

Elégance, accessibilité et performance doivent pouvoir se rejoindre ! Je ne doute pas que tidyverse suivra ce chemin et nous réserve encore de passionnantes expériences.

 

13 commentaires sur “Mon top 7 des évolutions récentes de R-Tidyverse”

  1. Merci pour cette revue détaillée des nouvelles trouvailles sur R. Il n’est pas toujours aisé de se tenir au courant des dernières nouveautés, c’est donc bien utile d’avoir ce genre d’articles explicatifs. Je m’en vais de ce pas essayer la fonction embrace {{}} qui pourrait être une solution plus simple dans le cas de l’évaluation non standard.

    1. Et merci, cet embrace semble en effet le point de maturité, avec une syntaxe plus épurée, d’une réflexion qui a pris quelques années (et qui se poursuit)

  2. Salut Eric,
    Merci pour cet article très intéressant. J’ai beaucoup appris sur l’utilisation d’accross et rowwise.
    Juste un petit détail. Je crois qu’à la place de « zip_url » vous vouliez mettre « urlzaav » lors du téléchargement de la table de la décomposition communale du zonage en aires d’attraction des villes.

  3. un grand merci, vraiment très intéressant et très clair, comme par exemple les fonctions utilisant le data masking ou la tidy evaluation, c’est beaucoup plus clair quand on le sait …
    j’espère qu’il y aura une suite
    bonne journée

    1. Merci pour cet intérêt pour ces notions subtiles de tidy evaluation. Il est vrai qu’il est difficile de trouver un exposé complet sur le sujet

  4. Merci beaucoup pour cet article et les autres, qui vont devenir de vraies références au sein de mon SSM. Petite question sur les embrace {{ }} à laquelle je ne trouve pas réponse : comment introduire plusieurs variables au sein du même embrase ? je souhaite l’utiliser dans un group_by() enchainé d’un complete(). Merci d’avance !

      1. Merci pour votre réponse. Le across fonctionne bel et bien dans les verbes fonctionnant en data-masking. En revanche, dans mon cas où j’enchaine un group_by() et un complete(), le across n’est pas accepté dans complete(). Un message d’erreur m’indique d’ailleurs que seuls les verbes en data-masking comme mutate(), group_by() ou filter() acceptent le across. J’ai tenté le all_of() avec des quotes dans le complete mais rien à faire. Je vais m’orienter vers la solution du un embrace = une variable. Merci !

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *