across() est plus puissant et flexible qu’il n’y parait

La librairie R dplyr permet de manipuler des tables de données par un élégant chainage d’instructions simples : select, group_by, summarise, mutate… à la manière du langage de requête SQL. 

dplyr est le module central de l’univers tidyverse, une collection cohérente de librairies spécialisées et intuitives, ensemble que l’on a souvent présenté comme le symbole du renouveau de R. 

Arrivée à maturité il y a deux ans avec sa version 1.0, dplyr accueillait en fanfare l’intriguant élément “across()”, destiné à remplacer plus d’une dizaine de fonctions préexistantes. across() est ainsi devenu l’emblème de la version toute neuve de la librairie emblématique du “R moderne” !

Je l’ai constaté, across() est encore insuffisamment compris et utilisé, tant il implique une façon de penser différente de nos habitudes d’écriture. Cet article vous présente, au travers de 7 façons de le mettre en œuvre, sa logique assez novatrice. Il s’adresse en priorité à des lecteurs ayant déjà une connaissance de R et dplyr.

across() permet de choisir un groupe de colonnes dans une table, et de leur appliquer un traitement systématique, voici sa syntaxe générique :

Je vais présenter l’usage d’across() avec la base Gaspar, qui décrit les risques naturels et industriels auxquels chaque commune française est exposée. J’en ai préparé un extrait décrivant sept risques pour la métropole. Chaque colonne indicatrice de risque vaut 0 ou 1 (présence).

library(tidyverse)
tb_risques = read_delim(str_c("https://static.data.gouv.fr/resources/base-communale-de-risques/", 
                              "20221027-104756/tb-risques2020.csv"), col_types = c('reg' = 'c')) 
# A tibble: 34,839 x 11
   com   dep   reg   lib_reg              risq_inond risq_seisme risq_nucleaire risq_barrage risq_industriel risq_feux risq_terrain
   <chr> <chr> <chr> <chr>                     <dbl>       <dbl>          <dbl>        <dbl>           <dbl>     <dbl>        <dbl>
 1 01001 01    84    Auvergne-Rhône-Alpes          1           0              0            0               0         0            0
 2 01002 01    84    Auvergne-Rhône-Alpes          0           1              0            0               0         0            1
 3 01004 01    84    Auvergne-Rhône-Alpes          1           1              0            0               0         0            1
 4 01005 01    84    Auvergne-Rhône-Alpes          0           0              0            0               0         0            0
 5 01006 01    84    Auvergne-Rhône-Alpes          0           1              0            0               0         0            0
 6 01007 01    84    Auvergne-Rhône-Alpes          1           1              0            1               0         0            0
 7 01008 01    84    Auvergne-Rhône-Alpes          0           1              0            0               0         0            0
 8 01009 01    84    Auvergne-Rhône-Alpes          1           1              0            0               0         0            0
 9 01010 01    84    Auvergne-Rhône-Alpes          1           1              0            1               1         0            0
10 01011 01    84    Auvergne-Rhône-Alpes          0           1              0            0               0         0            1
# ... with 34,829 more rows

Voici par exemple la traduction cartographique de la colonne risq_barrage (risque de rupture de barrage, en bleu la modalité 1) :

across() cible un ensemble de colonnes avec la même syntaxe que celle utilisée dans un select(). 

Rappelons les mécanismes de la sélection de colonnes dans dplyr  – ils sont nombreux et astucieux – au travers de quelques exemples : 

# "tidy selection" : offre plein de possibilités pour spécifier des colonnes
# à partir de leur nom, de leur type, de leur indice...

tb_risques |> select(codgeo, starts_with('risq_')) 

tb_risques |> select(where(is.character), risq_nucleaire) 

# on peut même intégrer une variable externe
# ces 3 variables constituent chacune une liste de noms de colonnes de risques

c_risques          = tb_risques |> select(starts_with('risq_')) |> names()
c_risques_naturels = str_c("risq_", c('inond','seisme','terrain'))  
c_risques_humains  = str_c("risq_", c('barrage','industriel','feux','nucleaire'))

tb_risques |> select(1:3, all_of(c_risques_humains))

# note : ceci fonctionne, mais plus pour longtemps : avertissement "deprecated" 

tb_risques |> select(codgeo, c_risques_humains) 

# il faut donc entourer toute variable (simple ou liste de colonnes) avec all_of()

across() utilise les mêmes mécanismes de sélection concise (tidy selection) à partir d’indices, de portions de noms ou via les types de colonnes : caractère, numérique, etc. 

La “tidy selection” ne fonctionne pas partout dans dplyr (ou sa copine tidyr) : seuls quelques verbes en tirent parti comme select(), pivot_longer(), unite(), rename_with(), relocate(), fill(), drop_na() et donc across().   

À l’inverse, group_by(), arrange(), mutate(), summarise(), filter() ou count() n’autorisent pas la tidy selection (group_by(1,2) ou arrange(3) ne fonctionnent pas).

Une bonne part de la magie d’across(), on va le voir, consiste à amener la souplesse de la tidy selection au sein de ces verbes qui normalement ne l’implémentent pas (ce “pontage” est aussi dénommé bridge pattern).

1 - rowSums() et across()

Voici un premier exemple avec la fonction de sommation de colonnes rowSums(), qui précisément n’est pas compatible avec la tidy selection. 

Pour sommer des colonnes, on devait auparavant les écrire toutes (dans un mutate()), ou utiliser une obscure syntaxe associant rowSums() avec un select() et un point. 

across() simplifie tout cela :

# on peut sommer des colonnes à la main avec mutate()

tb_risques |> 
  mutate(nb_risques = risq_inond + risq_seisme + risq_feux + risq_barrage 
         + risq_industriel + risq_nucleaire + risq_terrain)

# ou avec rowSums()

tb_risques %>% mutate(nb_risques = rowSums(select(., starts_with('risq_'))))

# note : cette syntaxe complexe, où le . rappelle la table en cours, 
# ne fonctionne qu'avec l'ancien "pipe" %>%

# >>> on peut faire plus simple avec across() 

tb_risques |> mutate(nb_risques = rowSums(across(starts_with('risq_'))))

# ou avec une liste de colonnes stockée dans une variable c_risques_humains

tb_risques |> mutate(nb_risques = rowSums(across(all_of(c_risques_humains))))

# pour mémoire : cette alternative rowwise() est À EVITER ! 
# les performances sont catastrophiques (30 s contre 0,5 s ci-dessus) :

tb_risques |> rowwise() |> 
              mutate(nb_risques = sum(c_across(all_of(c_risques_humains)))) 

Cette première utilisation d’across() est basique (et méconnue), elle ne fait intervenir que le premier paramètre d’across() : une sélection de colonnes

rowSums() (ou sa cousine rowMeans()) conduisent le traitement souhaité (somme ou moyenne) sur ces colonnes.

2 - summarise() et across()

Celles et ceux qui ont déjà joué avec across() l’ont probablement employé dans le contexte d’un summarise(), par exemple pour sommer “vite fait” tout un paquet de colonnes numériques. 

across() invite à spécifier les colonnes visées, puis le traitement à opérer sur chacune d’entre elles. De façon optionnelle, les colonnes produites par le calcul sont renommées pour mieux traduire l’opération conduite, avec par exemple ajout d’un préfixe ou d’un suffixe aux noms de colonne d’origine.  

# série de 5 écritures équivalentes pour compter les communes à risque
# avec across et une fonction toute simple

tb_risques |> summarise(across(starts_with('risq_'), sum))

# across et une fonction "anonyme", avec une option

tb_risques |> summarise(across(where(is.numeric), 
                               \(r) sum(r, na.rm = TRUE))) # R 4.1

# across avec l'écriture "en toutes lettres" de la fonction

tb_risques |> summarise(across(5:last_col(), 
                               function(r) { return( sum(r, na.rm = TRUE) ) }))

# across et une fonction anonyme, écriture ultra concise

tb_risques |> summarise(across(-(1:4), 
                               ~sum(., na.rm = TRUE))) 

# across avec une variable listant les colonnes

tb_risques |> summarise(across(all_of(c_risques), 
                               ~sum(., na.rm = TRUE))) 

# across avec une autre variable et une règle de renommage

tb_risques |> summarise(across(all_of(c_risques_humains), 
                               \(r) sum(r, na.rm = TRUE),
                               .names = "nb_{col}")) 
# A tibble: 1 x 4
nb_risq_barrage nb_risq_industriel nb_risq_feux nb_risq_nucleaire
<dbl>              <dbl>        <dbl>             <dbl>
3731               1819         6565               480

across() opère un renversement de l’ordre naturel, habituel des opérations, tout en les séparant sous forme de paramètres distincts, de nature très différente : liste (de colonnes) et fonctions (de traitement et de renommage). 

Considérez les deux écritures suivantes, la première correspond à nos habitudes de pensée, la seconde, avec across(), introduit une nouvelle façon de modéliser les traitements. Elle n’est pas immédiate à intégrer, elle peut même paraitre abstraite, peu intuitive. Pourtant, l’absorber, franchir ce pas logique, permet de s’ouvrir à de nouvelles dimensions d’analyse et de programmation (dite “fonctionnelle”).

Autre caractéristique fondamentale d’across() : le même traitement est répété indépendamment pour chaque colonne( seule une fonction très particulière comme rowSums() permet de combiner plusieurs colonnes dans une même opération). 

Un bouquet de fonctions (par exemple sum et mean) peut être appelé dans un seul across(), en utilisant une liste.

Enfin, comme on l’a déjà souligné, il est très facile avec across() d’injecter des variables dans une chaine de requête, et donc d’écrire ses propres fonctions pour raccourcir et simplifier ses scripts.

3 - mutate() et across()

mutate() avec across() suit la même logique qu’un summarise(), tout en préservant le niveau de détail (le nombre de lignes) de la table d’origine. across() permet typiquement de recoder ou “normaliser” (convertir en % par exemple) un ensemble de colonnes. 

Les colonnes transformées sont souvent renommées, pour plus de clarté, les colonnes d’origine pouvant être conservées, ou non.

Recodage :

# recoder des colonnes : 1 => 'exposée', 0 => ''

tb_risques |> mutate(across(starts_with('risq_'), 
                            \(r) ifelse(r == 1, 'exposée', '')))

# recoder selon le risque, 1 => 'barrage', 'inond', 'nucleaire'....
# cur_column() indique le nom de la colonne en cours de lecture

tb_risques |> mutate(across(starts_with('risq_'), 
                            \(r) ifelse(r == 1, str_sub(cur_column(), 6), ''))) |> 
              select(com, all_of(c_risques_naturels))

# A tibble: 34,839 x 4
com   risq_inond risq_seisme risq_terrain
<chr> <chr>      <chr>       <chr>       
1 01001 "inond"    ""          ""          
2 01002 ""         "seisme"    "terrain"   
3 01004 "inond"    "seisme"    "terrain"   
4 01005 ""         ""          ""          
5 01006 ""         "seisme"    ""          
# ... with 34,834 more rows

Normalisation :

# conversion en % : 100 * nb de communes exposées / nb total de communes

tb_risques |> summarise(across(starts_with('risq_'), sum), nb_com = n()) |>
              mutate(across(starts_with('risq_'), \(r) 100 * r / nb_com))

# A tibble: 1 x 8
risq_inond risq_seisme risq_nucleaire risq_barrage risq_industriel risq_feux risq_terrain nb_com
<dbl>       <dbl>          <dbl>        <dbl>           <dbl>     <dbl>        <dbl>  <int>
59.2        25.1           1.38         10.7            5.22      18.8         53.8  34839

# conversion en % avec renommage

tb_risques |> summarise(across(starts_with('risq_'), sum), nb_com = n()) |>
              mutate(across(starts_with('risq_'), 
                            \(r) 100 * r / nb_com, 
                            .names = "part_{str_sub(col, 6)}"), 
                     .keep = "unused")

# conversion en % avec une variable pour la liste des colonnes à traiter

tb_risques |> summarise(across(all_of(c_risques_humains), sum), nb_com = n()) |>
              mutate(across(all_of(c_risques_humains), 
                            \(r) 100 * r / nb_com, 
                            .names = "part_{str_sub(col, 6)}"), 
                     .keep = "unused") # éliminer les colonnes d'origine
# A tibble: 1 x 4
part_barrage part_industriel part_feux part_nucleaire
<dbl>           <dbl>     <dbl>          <dbl>
10.7            5.22      18.8           1.38

4 - filter() et 2 variantes d'across() : if_all() et if_any()

Que veut dire filtrer une table en considérant tout un ensemble de colonnes ? 

Je peux vouloir filtrer cette table des risques de deux façons différentes : dégager les communes cumulant tous les risques, ou celles présentant au moins un risque. 

Cette dualité a conduit les concepteurs d’across() à en décliner deux variantes : if_all(), qui reprend la logique d’across() (même condition pour toutes les colonnes), et if_any(), qui ne s’intéresse qu’à la possibilité qu’une colonne au moins remplisse la condition définie par la fonction anonyme.

Par souci de cohérence, across(), étant l’équivalent de if_all(), devient au sein d’un filter() déconseillé (déprécié) au profit de if_all().

# across() est encore utilisable dans filter(), mais plus pour longtemps 
tb_risques |> filter(across(starts_with('risq_'), \(r) r == 1)) 

# un avertissement invite à utiliser plutôt if_all()
tb_risques |> filter(if_all(starts_with('risq_'), \(r) r == 1)) |> 
              select(com)
# A tibble: 4 x 1
com  
<chr>
1 13039
2 13097
3 42056
4 84019
# 4 communes sont exposées aux 7 risques : Fos-sur-Mer, St-Martin-de-Crau,
# Chavanay et Bollène

# if_any pour une condition vérifiée sur une colonne au moins
# parmi celles décrites dans une variable c_risques_humains
tb_risques |> filter(if_any(all_of(c_risques_humains), 
                            \(r) r == 1)) |>
              select(com, all_of(c_risques_humains))

# plus de 10 000 communes exposées à un risque humain
# A tibble: 10,679 x 5
  com   risq_barrage risq_industriel risq_feux risq_nucleaire
  <chr>        <dbl>           <dbl>     <dbl>          <dbl>
1 01007            1               0         0              0
2 01010            1               1         0              0
3 01014            0               1         0              0
4 01024            0               1         0              0
5 01027            1               1         0              0
# ... with 10,674 more rows

5 - mutate() et if_all() ou if_any()

if_all() et if_any() ne sont pas réservés au contexte d’un filter(), il est possible de les utiliser avec un mutate(), matérialisant dans une nouvelle colonne le respect d’une condition. 

Je pourrai ainsi comparer les communes à risque avec les communes sans aucun risque.

tb_risques |> mutate(risque_humain = if_any(all_of(c_risques_humains), 
                                            \(r) r == 1)) |>
              select(com, all_of(c_risques_humains), risque_humain)

# A tibble: 34,839 x 6
  com   risq_barrage risq_industriel risq_feux risq_nucleaire risque_humain
  <chr>        <dbl>           <dbl>     <dbl>          <dbl> <lgl>        
1 01001            0               0         0              0 FALSE        
2 01002            0               0         0              0 FALSE        
3 01004            0               0         0              0 FALSE        
4 01005            0               0         0              0 FALSE        
5 01006            0               0         0              0 FALSE        
# ... with 34,834 more rows

# variante : un comptage simple

tb_risques |> count(if_any(all_of(c_risques_humains), \(r) r == 1)) |>
              select(`exposition risque humain` = 1, nb_com = n)

# A tibble: 2 x 2
`exposition risque humain`  nb_com
  <lgl>                       <int>
1 FALSE                       24160
2 TRUE                        10679

6 - group_by() et across()

group_by() ne permet pas d’utiliser directement la “tidy selection”, across(), dans sa syntaxe la plus simple (sans fonction), lui apporte cette souplesse d’écriture.

# regroupement selon les colonnes d'indice 2 à 4

tb_risques |> 
  group_by(across(2:4)) |> 
  summarise(across(where(is.numeric), sum))

# regroupement selon les colonnes de type caractère, sauf la 1ère

tb_risques |> 
  group_by(across(where(is.character) & -1)) |> 
  summarise(across(where(is.numeric), sum))

tb_risques |> 
  group_by(across(where(is.character) & -1)) |> 
  summarise(across(all_of(c_risques_humains), sum))

# A tibble: 96 x 7
# Groups:   dep, reg [96]
  dep   reg   lib_reg                    risq_barrage risq_industriel risq_feux risq_nucleaire
  <chr> <chr> <chr>                             <dbl>           <dbl>     <dbl>          <dbl>
1 01    84    Auvergne-Rhône-Alpes                 70              56         0             20
2 02    32    Hauts-de-France                       0              48         0              0
3 03    84    Auvergne-Rhône-Alpes                 73               5        31              0
4 04    93    Provence-Alpes-Côte d'Azur           53              14       173              1
5 05    93    Provence-Alpes-Côte d'Azur           17              37       162              0
# ... with 91 more rows

Il devient également possible, avec across(), d’injecter une variable dans un group_by(), comme on va le voir dans la section suivante.

7 - arrange() et across()

Avec across(), le verbe de tri arrange() gagne lui-aussi en souplesse d’écriture.

# cette écriture ne marche pas, arrange n'est pas "tidy select" compatible

tb_risques |> arrange(3)

# mais avec across, ça marche

tb_risques |> arrange(across(3))

# on peut utiliser desc à titre de fonction

tb_risques |> arrange(across(3, desc))

Ce bloc plus riche considère, par département, la part de communes exposée au risque rupture de barrage. Le type de risque devient un paramètre, prélude à l’écriture possible d’une fonction.

# deux variables pour cibler un risque

risq = 'barrage'
col_risq = str_glue("risq_{risq}")

# risque barrage par département

tb_risques |> 
  group_by(dep) |> 
  summarise(across(all_of(col_risq), sum), nb_com = n()) |>
  mutate(across(all_of(col_risq), \(r) 100 * r / nb_com, 
                .names = "part_{.col}"), 
         .keep = 'unused') |> # on ne garde pas les variables d'origine
  arrange(across(str_glue("part_risq_{risq}"), desc)) 

# avec str_glue(), across() peut même décoder une formule !

# A tibble: 96 x 2
dep   part_risq_barrage
<chr>             <dbl>
1 13               46.2
2 46               38.0
3 38               34.0
4 10               32.7
5 19               30.7
# ... with 91 more rows

# Bouches-du-Rhône, Lot, Isère, Aube et Corrèze ont la plus forte 
# part de communes exposées au risque de rupture de barrage

Cette dernière variante utilise across() à tous les étages : group_by(), summarise(), mutate() et arrange() !

# nouvelle variable pour les colonnes de regroupement
# on veut pouvoir regrouper soit par département, soit par région
# (avec le libellé associé)

nivgeo = c("reg","lib_reg")

tb_risques |> 
  group_by(across(all_of(nivgeo))) |> 
  summarise(across(all_of(col_risq), sum), nb_com = n(), 
            .groups = 'drop') |> # raccourci pour ungroup()
  mutate(across(all_of(col_risq), \(r) 100 * r / nb_com,  
                .names = "part_{.col}"), 
         .keep = 'unused') |>
  arrange(across(str_glue("part_risq_{risq}"), desc)) 

# A tibble: 13 x 3
reg   lib_reg                    part_risq_barrage
<chr> <chr>                                  <dbl>
1 84    Auvergne-Rhône-Alpes                  21.6  
2 76    Occitanie                             20.0  
3 93    Provence-Alpes-Côte d'Azur            19.6  
4 75    Nouvelle Aquitaine                    13.1  
5 44    Grand-Est                             10.3  
# ... with 8 more rows

Ces 7 exemples démontrent la puissance et la flexibilité d’across(), qui nous permet d’écrire des programmes plus élégants, plus flexibles.

Ayez le réflexe DRY : Don’t Repeat Yourself. Dès que vous détectez une répétition dans vos scripts, la même formule réécrite pour x colonnes, des blocs de code qui ne diffèrent que par quelques variables, il y a de fortes chances qu’across() vous rende service, vous aide à écrire des scripts plus robustes, lisibles et paramétrables.

across() fait intervenir, le plus souvent, l’écriture d’une petite fonction (dite anonyme), matérialisant l’opération à répéter, qui peut ainsi être optimisée. 

Il vous invite à écrire vos propres fonctions plus globales, sans en passer par la complexité des {{}}, enquos() et autres :=, toutes syntaxes assez vilaines, impossibles à retenir (et à expliquer).

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée.