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 colonneExaminons 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...
# 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"
- 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.
- en ajoutant les communes manquantes ;
- en corrigeant les libellés de commune initiaux.
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 !
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)
# 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
# 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
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' introuvableIl 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.
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
- 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 {{…}}.
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().
urlzaav <- "https://www.data.gouv.fr/fr/datasets/r/a9221cc4-089f-4cff-b557-d1b71f7be443" xlsfile <- curl_download(urlzaav, tempfile(), quiet = F) tb_zaav <- read_excel(xlsfile, sheet = 1, skip = 2) %>% 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", "...
# 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.
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
# 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 4sieges_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é !
# 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 sEnfin, 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 calcul. Souvent 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.
- [1] dplyr 1.0.0 available now!, 2020!
- [2] Les nouveautés de {dplyr} au Meetup Raddicts Paris avec Romain Francois, 2020
- [3] Interactivity and Programming in the tidyverse, Lionel Henry
- [4] Programming with dplyr
- [5] The Seven Key Things You Need To Know About dplyr 1.0.0
- [6] 10-must-know-tidyverse-features, R-bloggers, 2020
- [7] Tidyverse: the greatest mistakes, Hadley Wickham, 2019
- [8] The tidy tools manifesto, Hadley Wickham, 2020
- [9] Towards-a-grammar-of-interactive-graphics, Hadley Wickham, 2016
Bravo pour cet article très intéressant parce qu’il décortique en prenant du recul et de manière positivement critique les avancées de tidyverse!
Bonjour, et merci pour ce sentiment qui correspond bien à l’approche que j’ai voulu adopter.
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.
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)
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.
Bonjour Venel,
et merci pour votre lecture attentive ! C’est ajusté.
Bel exemple et beau partage 😉
Merci
Merci pour cette impression de lecture, que je devine approfondie !
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
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
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 !
merci pour votre avis !
Pour un group_by sur deux variables v1 et v2, je vous suggère d’essayer :
tb %>% group_by({{v1}}, {{v2}})
tb %>% group_by(across(all_of(c(v1,v2))))
Vous pourrez trouver d’autres exemples aussi dans : https://www.icem7.fr/r/across-plus-puissant-flexible-quil-ny-parait/
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 !