library(tidyverse)
library(questionr)
data(hdv2003)
data(rp2018)
18 Itérer avec purrr
purrr
est une extension du tidyverse qui fournit des outils pour travailler avec les vecteurs et les fonctions, et notamment pour itérer sur les éléments de vecteurs ou de listes en leur appliquant une fonction.
Dans cette section on aura besoin des extensions du tidyverse (dont purrr
fait partie), que nous chargeons donc immédiatement, de même que les jeux de données rp2018
et hdv2003
de questionr
.
18.1 Exemple d’application
Pour mieux appréhender de quoi il s’agit, on part du vecteur suivant, qui contient des extraits (fictifs ?) de discours politiques.
<- c(
discours "nous privilégierons une intergouvernementalisation sans agir anticonstitutionnellement",
"le souffle de la nation est le vent qui agite les drapeaux de nos libertés",
"nous devons faire preuve de plus de pédagogie pour cette réforme",
"mon compte twitter a été piraté"
)
On souhaite calculer la longueur de chaque extrait, en nombre de mots.
On commence par découper grossièrement chaque extrait en mots en utilisant la fonction str_split()
de stringr
1.
<- str_split(discours, " ")
mots str(mots)
#> List of 4
#> $ : chr [1:7] "nous" "privilégierons" "une" "intergouvernementalisation" ...
#> $ : chr [1:15] "le" "souffle" "de" "la" ...
#> $ : chr [1:11] "nous" "devons" "faire" "preuve" ...
#> $ : chr [1:6] "mon" "compte" "twitter" "a" ...
L’objet mots
est une liste de vecteurs de chaînes de caractères qui contiennent les mots des différents extraits.
Calculer le nombre de mots de chaque extrait revient à calculer la longueur de chaque élément de mots
. Pour cela on pourrait vouloir utiliser la fonction length()
directement :
length(mots)
#> [1] 4
Ceci ne fonctionne pas, car length()
nous retourne le nombre d’éléments de mots
, pas celui de chacun de ses éléments : ce qu’on veut, ça n’est pas length(mots)
mais length(mots[[1]])
, length(mots[[2]])
, etc.
On a vu Section 17.3.4 qu’on peut pour cela utiliser une boucle for
, par exemple de la manière suivante.
<- list()
resultat for (item in mots) {
<- c(resultat, length(item))
resultat
}
resultat#> [[1]]
#> [1] 7
#>
#> [[2]]
#> [1] 15
#>
#> [[3]]
#> [1] 11
#>
#> [[4]]
#> [1] 6
Ça fonctionne, mais la syntaxe est un peu “lourde”.
La fonction map()
de purrr
propose exactement cette fonctionnalité. Elle prend deux arguments principaux :
- un vecteur ou une liste
- une fonction
et elle retourne une liste contenant le résultat de la fonction appliquée à chaque élément du vecteur ou de la liste.
En utilisant map()
on peut remplacer notre boucle for
par un simple :
map(mots, length)
#> [[1]]
#> [1] 7
#>
#> [[2]]
#> [1] 15
#>
#> [[3]]
#> [1] 11
#>
#> [[4]]
#> [1] 6
map
va itérer sur les éléments de mots
, leur appliquer tour à tour la fonction length
passée en argument, et regrouper les résultats dans une liste.
À noter qu’on peut évidemment utiliser le pipe.
%>% map(length)
mots #> [[1]]
#> [1] 7
#>
#> [[2]]
#> [1] 15
#>
#> [[3]]
#> [1] 11
#>
#> [[4]]
#> [1] 6
Ici notre résultat est une liste. Or il pourrait être simplifié sous forme de vecteur atomique, puisque tous ses éléments sont des nombres.
Si on souhaitait obtenir un vecteur numérique avec une boucle for
, il faut soit convertir le résultat de la boucle précédente en vecteur atomique (avec unlist()
ou purrr::flatten_int()
), soit modifier cette boucle pour qu’elle génère plutôt un vecteur numérique :
<- numeric(length(discours))
resultat for (i in seq_along(mots)) {
<- length(mots[[i]])
resultat[i]
}
resultat#> [1] 7 15 11 6
Mais purrr
propose des variantes de la fonction map
qui permettent justement de s’assurer du type de résultat obtenu. Ainsi, map_dbl()
renverra toujours un vecteur de nombres flottants, et map_int()
un vecteur de nombres entiers. En remplacement de la boucle for
ci-dessus, on peut donc utiliser :
%>% map_int(length)
mots #> [1] 7 15 11 6
18.2 map
et ses variantes
18.2.1 Modes d’appel de map
L’objectif de map
est donc d’appliquer une fonction à l’ensemble des éléments d’un vecteur ou d’une liste.
On a vu qu’on pouvait l’utiliser pour appliquer la fonction length
à chacun des vecteurs contenus par la liste mots
. En utilisant map_int
on s’assure de récupérer un simple vecteur numérique, plus facile à utiliser par la suite si on souhaite par exemple calculer une moyenne.
%>% map_int(length)
mots #> [1] 7 15 11 6
Si on souhaitait plutôt extraire le dernier mot de chaque vecteur, on pourrait créer une fonction spécifique et l’appliquer avec map()
.
<- function(v) {
dernier_mot tail(v, 1)
}
%>% map(dernier_mot)
mots #> [[1]]
#> [1] "anticonstitutionnellement"
#>
#> [[2]]
#> [1] "libertés"
#>
#> [[3]]
#> [1] "réforme"
#>
#> [[4]]
#> [1] "piraté"
Comme notre résultat est une liste de chaînes de caractères simples, on peut forcer le résultat à être plutôt un vecteur de type character en utilisant map_chr()
:
%>% map_chr(dernier_mot)
mots #> [1] "anticonstitutionnellement" "libertés"
#> [3] "réforme" "piraté"
Comme notre fonction est très courte, on peut aussi préférer utiliser une fonction anonyme, introduites Section 14.4.2.
%>% map_chr(function(v) {
mots tail(v, 1)
})#> [1] "anticonstitutionnellement" "libertés"
#> [3] "réforme" "piraté"
On peut aussi utiliser la notation abrégée sous forme de formule, propre aux fonctions du tidyverse, présentée Section 15.3.
%>% map_chr(~ tail(.x, 1))
mots #> [1] "anticonstitutionnellement" "libertés"
#> [3] "réforme" "piraté"
On peut également utiliser la notation compacte pour les fonctions anonymes disponible sous R à partir de la version 4.1.
%>% map_chr(\(v) tail(v, 1))
mots #> [1] "anticonstitutionnellement" "libertés"
#> [3] "réforme" "piraté"
Enfin, si on fournit des arguments supplémentaires à map
, ils sont passés comme argument à la fonction qu’il applique, on peut donc également utiliser la notation suivante :
%>% map_chr(tail, 1)
mots #> [1] "anticonstitutionnellement" "libertés"
#> [3] "réforme" "piraté"
Dans ce qui suit on utilisera de préférence la notation “formule”, mais toutes les versions ci-dessus sont équivalentes et donnent le même résultat.
Petite astuce à noter, si on transmet à map()
autre chose qu’une fonction, elle utilisera cette information pour extraire des éléments. Ainsi, v %>% map(1)
extraiera le premier élément de chaque élément du vecteur v
, v %>% map("foo")
extraiera les éléments nommés “foo”, etc.
18.2.2 Variantes de map
On a vu que map
propose plusieurs variantes qui permettent de contrôler le type de résultat qu’elle retourne :
map()
retourne une listemap_int()
retourne un vecteur atomique d’entiersmap_dbl()
retourne un vecteur atomique de nombres flottantsmap_chr()
retourne un vecteur atomique de chaînes de caractèresmap_lgl()
retourne un vecteur atomique deTRUE
/FALSE
Attention, ces variantes sont très strictes : si la fonction appelée retourne un résultat qui n’est pas compatible avec le résultat attendu, elle génère une erreur. C’est le cas si dans le code précédent on essaie de récupérer chaque dernier mot sous forme d’un vecteur de nombres :
%>% map_dbl(tail, 1)
mots #> Error in `map_dbl()`:
#> ℹ In index: 1.
#> Caused by error:
#> ! Can't coerce from a string to a double vector.
Pour pouvoir utiliser ces variantes et obtenir un vecteur atomique, chaque résultat retourné par la fonction appliquée doit être de longueur 1. Ainsi, si on souhaitait extraire plutôt la liste des mots contenant un “f”, certains résultats ne contiennent aucun élément, et d’autres en contiennent deux :
%>% map(~ str_subset(.x, "f"))
mots #> [[1]]
#> character(0)
#>
#> [[2]]
#> [1] "souffle"
#>
#> [[3]]
#> [1] "faire" "réforme"
#>
#> [[4]]
#> character(0)
Dans ce cas on ne peut pas utiliser map_chr()
: si on essaie on obtient un message d’erreur nous indiquant que certains résultats de str_subset()
ne sont pas au bon format.
%>% map_chr(~ str_subset(.x, "f"))
mots #> Error in `map_chr()`:
#> ℹ In index: 1.
#> Caused by error:
#> ! Result must be length 1, not 0.
Dans ce cas, on doit donc utiliser map()
et conserver le résultat sous forme de liste, qui elle peut contenir des éléments de longueurs différentes.
%>%
mots map(~ str_subset(.x, "f"))
#> [[1]]
#> character(0)
#>
#> [[2]]
#> [1] "souffle"
#>
#> [[3]]
#> [1] "faire" "réforme"
#>
#> [[4]]
#> character(0)
On notera qu’on peut tout à fait enchaîner les map()
si on veut effectuer des opérations supplémentaires.
%>%
mots map(~ str_subset(.x, "f")) %>%
map_int(length)
#> [1] 0 1 2 0
18.2.3 map
et tableaux de données
La page suivante contient les données du jeu de données rp2018
sous la forme de fichiers CSV, avec un fichier par département :
https://github.com/juba/tidyverse/tree/main/resources/data/rp2018
À partir de cette page, on peut télécharger les fichiers CSV en utilisant des adresses de la forme :
https://raw.githubusercontent.com/juba/tidyverse/main/resources/data/rp2018/rp2018_01.csv
En remplaçant “01” par le code du département souhaité.
On peut créer une fonction genere_url()
qui, à partir d’une liste de codes de départements, retourne les adresses des fichiers correspondant.
<- function(codes) {
genere_url paste0(
"https://raw.githubusercontent.com/juba/tidyverse/main/resources/data/rp2018/rp2018_",
codes,".csv"
)
}
genere_url(c("42", "69"))
#> [1] "https://raw.githubusercontent.com/juba/tidyverse/main/resources/data/rp2018/rp2018_42.csv"
#> [2] "https://raw.githubusercontent.com/juba/tidyverse/main/resources/data/rp2018/rp2018_69.csv"
Grâce à la fonction read_csv()
, on peut charger directement dans notre session R un fichier en indiquant son URL.
<- read_csv(genere_url("69")) data69
Comment faire si l’on souhaite charger les fichiers de plusieurs départements ? La fonction read_csv()
n’accepte qu’une seule URL à la fois, elle n’est pas vectorisée.
Dans ce cas on peut utiliser map()
pour l’appliquer tour à tour à plusieurs URL.
<- c("38", "42", "69")
departements <- genere_url(departements)
urls
<- urls %>% map(read_csv) dfs
Le résultat dfs
est une liste de trois tableaux de données. Chacun de ces éléments est un tibble : d’abord celui du fichier CSV des données de l’Isère, puis celui de la Loire, et enfin celui du Rhône.
On peut itérer sur cette liste dfs
pour appliquer une fonction à chacun de ces tableaux.
# Affichage des dimensions de chaque tableau
%>% map(dim)
dfs #> [[1]]
#> [1] 146 37
#>
#> [[2]]
#> [1] 56 37
#>
#> [[3]]
#> [1] 132 37
# Calcul de la moyenne de la variable dipl_aucun
%>% map_dbl(~ mean(.x$dipl_aucun))
dfs #> [1] 19.24298 23.57400 17.74882
# Calcul du coefficient associé à la variable dipl_sup dans
# la régression linéaire de cadres en fonction de dipl_sup
%>% map_dbl(~ {
dfs <- lm(cadres ~ dipl_sup, data = .x)
reg $coefficients["dipl_sup"]
reg
})#> [1] 1.132317 1.264469 1.027263
Si on souhaite réunir ces trois tibbles en un seul, on peut utiliser la fonction bind_rows()
de dplyr
.
<- c("38", "42", "69")
departements <- genere_url(departements)
urls
<- urls %>%
df map(read_csv) %>%
bind_rows()
df
Les quelques lignes de code ci-dessus partent donc d’une liste d’identifiants de départements, génèrent les URL des fichiers CSV correspondant, les importent dans R et assemblent le résultat en un seul tableau. Plutôt efficace !
bind_rows()
est une fonction de dplyr
. Si on souhaite utiliser uniquement des fonctions de purrr
, on peut utiliser list_rbind()
, qui donne le même résultat.
Une fonction utile en complément de map
est la fonction list.files()
, qui peut lister les fichiers ayant une certaine extension dans un dossier spécifique. Par exemple, l’instruction suivante liste tous les fichiers se terminant par .csv
du sous-dossier data
.
<- list.files("data", "*.csv", full.names = TRUE) fichiers
On peut dès lors utiliser map()
, read_csv()
et bind_rows()
, pour lire tous ces fichiers en une seule fois et les concaténer en un seul tableau de données.
<- fichiers %>%
d map(read_csv) %>%
bind_rows()
On peut aussi utiliser bind_cols()
ou list_cbind()
quand les résultats d’un map
sont des colonnes d’un tableau de données qu’on souhaite rassembler en les concaténant.
18.3 Itérer sur les colonnes d’un tableau de données
On a vu Section 16.3 que les tableaux de données (data frame ou tibble) sont en fait des listes dont les éléments sont les colonnes du tableau. Si on applique map()
à un tableau, celle-ci itérera donc sur ses colonnes.
Par exemple, on peut appliquer n_distinct
au jeu de données starwars
et obtenir le nombre de valeurs distinctes de chacune de ses colonnes.
%>% map_int(n_distinct)
starwars #> name height mass hair_color skin_color eye_color birth_year
#> 87 46 39 13 31 15 37
#> sex gender homeworld species films vehicles starships
#> 5 3 49 38 24 11 17
Le résultat est équivalent à celui qu’on obtient en faisant un summarise()
sur l’ensemble des colonnes, comme vu Section 15.2, sauf que map_int()
retourne un vecteur numérique tandis que summarise()
renvoie un tibble à une ligne.
%>%
starwars summarise(
across(everything(), n_distinct)
)#> # A tibble: 1 × 14
#> name height mass hair_color skin_color eye_color birth_year sex gender
#> <int> <int> <int> <int> <int> <int> <int> <int> <int>
#> 1 87 46 39 13 31 15 37 5 3
#> # ℹ 5 more variables: homeworld <int>, species <int>, films <int>,
#> # vehicles <int>, starships <int>
De la même manière, si on veut connaître le nombre de valeurs manquantes pour chaque variable :
%>% map_int(~ sum(is.na(.x)))
starwars #> name height mass hair_color skin_color eye_color birth_year
#> 0 6 28 5 0 0 44
#> sex gender homeworld species films vehicles starships
#> 4 4 10 4 0 0 0
Contrairement à across()
, on ne peut pas spécifier directement une sélection de colonnes à map()
. On peut par contre utiliser des fonctions comme keep()
ou discard()
qui “filtrent” les éléments d’une liste via une fonction qui renvoie TRUE
ou FALSE
.
On peut par exemple utiliser discard()
après map()
pour ne conserver que les colonnes ayant au moins une valeur NA
.
%>%
starwars map_int(~ sum(is.na(.x))) %>%
discard(~ .x == 0)
#> height mass hair_color birth_year sex gender homeworld
#> 6 28 5 44 4 4 10
#> species
#> 4
Ou bien utiliser keep()
pour n’appliquer mean()
qu’aux variables numériques.
%>%
starwars keep(is.numeric) %>%
map_dbl(mean, na.rm = TRUE)
#> height mass birth_year
#> 174.35802 97.31186 87.56512
18.4 modify
modify()
est une variante de map()
qui a pour particularité de renvoyer un résultat du même type que la liste ou le vecteur donné en entrée.
Ainsi, si on l’applique à un vecteur de chaînes de caractères, le résultat sera aussi un vecteur de chaînes de caractères.
<- c("brouette", "moto", "igloo")
v %>% modify(~ paste("incroyable", .x))
v #> [1] "incroyable brouette" "incroyable moto" "incroyable igloo"
Si on l’applique à une liste, le résultat sera aussi une liste.
<- list("brouette", "moto", "igloo")
v %>% modify(~ paste("incroyable ", .x))
v #> [[1]]
#> [1] "incroyable brouette"
#>
#> [[2]]
#> [1] "incroyable moto"
#>
#> [[3]]
#> [1] "incroyable igloo"
L’objectif de modify()
est de permettre de “modifier” une liste ou un vecteur en lui appliquant une fonction tout en étant sûr qu’on ne va pas modifier son type.
L’intérêt principal de modify()
est qu’elle propose deux variantes, modify_if()
et modify_at()
, qui sélectionnent les éléments respectivement via une fonction et via leur nom ou leur position, et qui n’appliquent la fonction de transformation qu’aux éléments sélectionnés.
Cela peut être particulièrement utile quand on l’applique à un tableau de données. Par exemple le code suivant utilise modify_if()
pour transformer uniquement les colonnes de type factor
de hdv2003
en character
, et laisser les autres inchangées.
%>% modify_if(is.factor, as.character) hdv2003
On notera qu’on obtient le même résultat avec le code suivant qui utilise across()
de dplyr
.
%>%
hdv2003 mutate(
across(
where(is.factor),
as.character
) )
modify_at
permet d’appliquer une fonction à certaines variables à partir de leurs noms.
%>%
hdv2003 modify_at(c("sexe", "qualif"), as.character)
En utilisant vars()
, on peut sélectionner les variables avec toutes les possibilités offertes par la tidy selection.
%>%
hdv2003 modify_at(vars(hard.rock:sport), as.character)
#> Warning: Using `vars()` in .at was deprecated in purrr 1.0.0.
Là aussi, on peut obtenir le même résultat en utilisant across()
.
%>%
hdv2003 mutate(
across(
:sport,
hard.rock
as.character
) )
18.5 imap
Imaginons que nous avons récupéré les données suivantes, qui représentent des évaluations obtenues par quatre restaurants, sous la forme d’une liste.
<- list(
restos "La bonne fourchette" = c(3, 3, 2, 5, 2, 3, 2, 4, 1, 3),
"La choucroute de l'amer" = c(4, 1, 2, 4, 2, 5, 2),
"L'Hair de rien" = c(1, 5, 5, 1, 5, 3, 1, 5, 2),
"La blanquette de Vaulx" = c(4, 1, 3, 1, 3, 3, 1, 4, 2, 5)
)
À partir de cette liste, on souhaite créer un tableau de données comportant la moyenne et l’écart-type des notes de chaque restaurant. Comme on l’a vu précédemment, cela peut se faire avec l’aide de map()
et bind_rows()
.
%>%
restos map(~ tibble(moyenne = mean(.x), ecart_type = sd(.x))) %>%
bind_rows()
#> # A tibble: 4 × 2
#> moyenne ecart_type
#> <dbl> <dbl>
#> 1 2.8 1.14
#> 2 2.86 1.46
#> 3 3.11 1.90
#> 4 2.7 1.42
On obtient le tableau souhaité, mais il manque une information : le nom du restaurant correspondant à chaque ligne. Cette information est incluse dans les noms des éléments de la liste restos
, or la fonction passée à map
n’y a pas accès, elle n’a accès qu’à leurs valeurs.
C’est pour ce type de cas de figure que purrr
propose la famille de fonctions imap()
. Celle-ci fonctionne de la même manière que map()
, sauf que la fonction appliquée prend deux arguments : d’abord la valeur de l’élément courant, puis son nom.
Dans l’exemple suivant, on applique imap()
à une liste simple et on affiche un message avec le nom et la valeur de chaque élément.
<- list(nom1 = 1, nom2 = 3)
l
<- l %>% imap(function(valeur, nom) {
l2 message("La valeur de ", nom, " est ", valeur)
})#> La valeur de nom1 est 1
#> La valeur de nom2 est 3
On peut évidemment utiliser la notation “formule” de purrr
, il faut juste se souvenir que dans ce cas .x
correspond à la valeur, et .y
au nom.
<- l %>% imap(~ {
l2 message("La valeur de ", .y, " est ", .x)
})#> La valeur de nom1 est 1
#> La valeur de nom2 est 3
Tout comme map()
proposait les variantes map_int()
, map_chr()
ou map_lgl()
, on peut également utiliser imap_dbl()
ou imap_chr()
pour forcer le type de résultat retourné.
Pour reprendre notre exemple de départ, on peut donc, en utilisant imap
, récupérer le nom de l’élément courant de la liste restos
et l’utiliser pour rajouter le nom du restaurant dans notre tibble de résultats.
On peut le faire avec une fonction anonyme “classique” :
%>%
restos imap(function(notes, nom) {
tibble(resto = nom, moyenne = mean(notes), ecart_type = sd(notes))
%>%
}) bind_rows()
#> # A tibble: 4 × 3
#> resto moyenne ecart_type
#> <chr> <dbl> <dbl>
#> 1 La bonne fourchette 2.8 1.14
#> 2 La choucroute de l'amer 2.86 1.46
#> 3 L'Hair de rien 3.11 1.90
#> 4 La blanquette de Vaulx 2.7 1.42
On peut aussi utiliser la notation compacte de type formule, en se souvenant à nouveau que .x
correspond à la valeur de l’élément courant, et .y
à son nom.
%>%
restos imap(~ {
tibble(resto = .y, moyenne = mean(.x), ecart_type = sd(.x))
%>%
}) bind_rows()
#> # A tibble: 4 × 3
#> resto moyenne ecart_type
#> <chr> <dbl> <dbl>
#> 1 La bonne fourchette 2.8 1.14
#> 2 La choucroute de l'amer 2.86 1.46
#> 3 L'Hair de rien 3.11 1.90
#> 4 La blanquette de Vaulx 2.7 1.42
Si on utilise imap()
sur une liste ou un vecteur qui n’a pas de noms, le deuxième argument passé à la fonction appliquée sera l’indice de l’élément courant : 1 pour le premier, 2 pour le deuxième, etc.
18.6 walk
walk()
est une variante de map()
qui a pour particularité de ne pas retourner de résultat. On l’utilise lorsqu’on souhaite parcourir un vecteur ou une liste et appliquer à ses éléments une fonction dont on ne souhaite conserver que les “effets de bord” : afficher un message, générer un graphique, enregistrer un fichier…
Par exemple, le code suivant génère quatre diagrammes en barre indiquant la répartition des notes des différents restaurants vus dans la section précédente.
walk(restos, ~ barplot(table(.x)))
Comme pour map()
, la variante iwalk()
permet d’itérer à la fois sur les valeurs et sur les noms des éléments du vecteur ou de la liste. Ceci permet par exemple d’afficher le nom du restaurant comme titre de chaque graphique.
iwalk(restos, ~ barplot(table(.x), main = .y))
Au final, on notera que l’utilisation de walk()
, comme elle ne retourne pas de résultats, est très proche de celle d’une boucle for
.
18.7 map2
et pmap
: itérer sur plusieurs vecteurs en parallèle
Supposons qu’un.e collègue, qui travaille avec nous sur le jeu de données rp2018
, nous a envoyé une liste de variables dont elle voudrait connaître les corrélations. Cette liste a été saisie dans un tableur sur deux colonnes, chaque ligne indiquant deux variables pour lesquelles elle souhaite qu’on effectue ce calcul.
Après importation dans R on obtient le tableau de données suivant.
<- tribble(
correlations ~var1, ~var2,
"dipl_sup", "dipl_aucun",
"dipl_sup", "cadres",
"hlm", "cadres",
"hlm", "ouvr",
"proprio", "hlm"
)
correlations#> # A tibble: 5 × 2
#> var1 var2
#> <chr> <chr>
#> 1 dipl_sup dipl_aucun
#> 2 dipl_sup cadres
#> 3 hlm cadres
#> 4 hlm ouvr
#> 5 proprio hlm
Pour pouvoir calculer les corrélations souhaitées, on doit itérer sur les deux vecteurs var1
et var2
en parallèle, et calculer la corrélation entre la colonne de rp2018
correspondant à la valeur courante de var1
et celle correspondant à la valeur courante de var2
.
C’est précisément ce que fait la fonction map2()
. Celle-ci prend trois arguments en entrée :
- deux listes ou vecteurs qui seront itérés en parallèle
- une fonction qui accepte deux arguments : ceux-ci prendront tour à tour les deux valeurs courantes des deux listes ou vecteurs itérés
On peut donc utiliser map2
pour itérer parallèlement sur les deux colonnes var1
et var2
de notre tableau correlations
, et calculer la corrélation des deux colonnes correspondantes de rp2018
.
map2(
$var1,
correlations$var2,
correlations~ cor(rp2018[[.x]], rp2018[[.y]])
)#> [[1]]
#> [1] -0.6146729
#>
#> [[2]]
#> [1] 0.9291504
#>
#> [[3]]
#> [1] -0.1067832
#>
#> [[4]]
#> [1] 0.2126366
#>
#> [[5]]
#> [1] -0.7786399
map2()
propose les mêmes variantes map2_int()
, mapr2_chr()
, etc. que map()
. On peut donc utiliser map2_dbl()
pour récupérer un vecteur numérique plutôt qu’une liste, et l’utiliser par exemple pour rajouter une colonne à notre tableau de départ.
$corr <- map2_dbl(
correlations$var1,
correlations$var2,
correlations~ cor(rp2018[[.x]], rp2018[[.y]])
)
correlations#> # A tibble: 5 × 3
#> var1 var2 corr
#> <chr> <chr> <dbl>
#> 1 dipl_sup dipl_aucun -0.615
#> 2 dipl_sup cadres 0.929
#> 3 hlm cadres -0.107
#> 4 hlm ouvr 0.213
#> 5 proprio hlm -0.779
Si on souhaite uniquement capturer les effets de bord sans récupérer les résultats de la fonctions appliquée, on peut aussi utiliser la variante walk2()
.
Supposons maintenant que notre collègue nous a envoyé, toujours sous la même forme, une liste de variables dont elle souhaite obtenir un nuage de points, mais en fournissant également un titre à ajouter au graphique. On obtient le tableau suivant :
<- tribble(
nuages ~var1, ~var2, ~titre,
"dipl_sup", "dipl_aucun", "Diplômés du supérieur x sans diplôme",
"dipl_sup", "cadres", "Pourcentage de cadres x diplômés du supérieur",
"hlm", "cadres", "Pas facile de trouver un titre",
"proprio", "cadres", "Oui non vraiment c'est pas simple"
)
nuages#> # A tibble: 4 × 3
#> var1 var2 titre
#> <chr> <chr> <chr>
#> 1 dipl_sup dipl_aucun Diplômés du supérieur x sans diplôme
#> 2 dipl_sup cadres Pourcentage de cadres x diplômés du supérieur
#> 3 hlm cadres Pas facile de trouver un titre
#> 4 proprio cadres Oui non vraiment c'est pas simple
On est dans une situation similaire à la précédente, sauf que cette fois on doit itérer sur trois vecteurs en parallèle. On va donc utiliser la fonction pmap()
qui permet d’itérer sur autant de listes ou vecteurs que l’on souhaite. Plus précisément, comme on souhaite générer des graphiques on va utiliser la variante pwalk()
qui ne retourne pas de résultat.
pmap()
et pwalk()
prennent deux arguments principaux :
- les vecteurs et listes sur lesquels itérer, eux-mêmes regroupés dans une liste
- une fonction acceptant autant d’arguments que de vecteur ou listes sur lesquels on itère
Dans notre exemple on aurait donc un appel de la forme suivante.
pwalk(
list(nuages$var1, nuages$var2, nuages$titre),
function(var1, var2, titre) {
plot(
rp2018[[var1]], rp2018[[var2]],xlab = var1, ylab = var2, main = titre
)
} )
Petite précision, si la liste est nommée, il faut que les noms des arguments de la fonction correspondent aux noms de la liste.
pwalk(
list(v1 = nuages$var1, v2 = nuages$var2, titre = nuages$titre),
function(v1, v2, titre) {
plot(
rp2018[[v1]], rp2018[[v2]],xlab = v1, ylab = v2, main = titre
)
} )
À noter que comme nuages
est un tableau de données, donc une liste dont les éléments sont ses colonnes, on obtient le même résultat avec :
pwalk(
nuages,function(var1, var2, titre) {
plot(
rp2018[[var1]], rp2018[[var2]],xlab = var1, ylab = var2, main = titre
)
} )
On peut utiliser la syntaxe “formule” pour la fonction anonyme, dans ce cas les arguments sont accessibles avec la notation ..1
, ..2
, etc. On notera que dans ce cas la syntaxe “formule” est sans doute moins lisible que la syntaxe classique avec function()
qui permet de nommer les paramètres.
pwalk(
nuages,~ {
plot(
1]], rp2018[[..2]],
rp2018[[..xlab = ..1, ylab = ..2, main = ..3
)
} )
Comme pour map()
et map2()
, pmap()
propose aussi les variantes pmap_int()
, pmap_chr()
, etc.
18.8 Répéter une opération
Les fonctions de purrr
peuvent être utilisées quand on souhaite juste répéter une opération un certain nombre de fois, à la place d’une boucle for
.
Par exemple si on souhaite générer 10 vecteurs de 100 nombre aléatoires, on pourra remplacer la boucle suivante :
<- list()
res for (i in 1:10) {
<- c(res, rnorm(100))
res }
Par un appel à map()
:
<- map(1:10, ~ rnorm(100)) res
Ce qui donne un code un peu plus compact et plus lisible.
De la même manière, si on s’intéresse juste aux effets de bord, on pourra éventuellement remplacer une boucle for
par un appel à walk()
.
18.9 Quand (ne pas) utiliser map
Une fois qu’on a compris la logique de map()
et de ses variantes, on peut être tenté.es de l’appliquer un peu systématiquement. Il faut cependant garder en tête que son usage n’est pas toujours conseillé notamment dans les cas où il existe déjà une fonction vectorisée qui permet d’obtenir le même résultat.
Ainsi cela n’aurait pas de sens de faire :
<- 1:5
v map_dbl(v, ~ .x + 10)
#> [1] 11 12 13 14 15
Quand on peut simplement faire :
+ 10
v #> [1] 11 12 13 14 15
Pour prendre un exemple un peu moins caricatural, de nombreuses fonctions de stringr
sont vectorisées, il n’est donc pas utile de faire :
<- c("fantastique", "effectivement", "igloo")
textes map_int(textes, ~ str_count(.x, "f"))
#> [1] 1 2 0
Quand on peut faire simplement :
str_count(textes, "f")
#> [1] 1 2 0
Par contre map()
est utile quand on souhaite appliquer une fonction qui n’est pas vectorisée à plusieurs valeurs, comme c’est le cas par exemple avec read_csv()
, qui ne permet pas de charger plusieurs fichiers d’un coup :
<- c("fichier1.csv", "fichier2.csv")
fichiers <- fichiers %>% map(read_csv) l
Ou quand on veut itérer sur un argument non vectorisé, par exemple ici sur l’argument pattern
de str_count()
:
<- c(a = "a", e = "e", i = "i")
voyelle <- c("brouette", "moto", "igloo")
textes %>% map(~ str_count(textes, pattern = .x))
voyelle #> $a
#> [1] 0 0 0
#>
#> $e
#> [1] 2 0 0
#>
#> $i
#> [1] 0 0 1
On l’utilise également quand on veut appliquer une fonction non pas à une liste, mais aux éléments qu’elle contient :
<- list(1:3, c(2, 5))
l %>% map_int(length)
l #> [1] 3 2
À noter qu’en termes de performance, map()
n’est pas forcément plus rapide qu’une boucle for
, puisque dans les deux cas on itère sur un ensemble de valeurs. Par contre une fonction vectorisée existante sera toujours (beaucoup) plus rapide.
18.10 purrr
vs *apply
Les fonctions de purrr
ont des équivalents dans R “de base”, ce sont notamment les fonctions de la famille apply
: lapply
, sapply
, mapply
…
L’avantage de map()
et des autres fonctions fournies par purrr
et qu’elles sont plus explicites : on a des fonctions différentes selon qu’on veut seulement appliquer une fonction (map()
), générer des effets de bord (walk
), modifier une liste sans changer son type (modify()
), etc. purrr
propose également de nombreuses fonctions utiles qui facilite le travail avec les vecteurs et listes.
Mais un des avantages principaux des fonctions de la famille map()
est qu’elles sont consistantes et cohérentes dans le type de résultat qu’elles retournent : on est certain que map()
ou imap()
retourneront une liste, que map_chr()
ou map2_chr()
retourneront un vecteur de chaînes de caractères, etc.
Là encore, il n’est pas question de dire qu’il ne faut pas utiliser les fonctions *apply
. Si vous en avez l’habitude et qu’elles fonctionnent pour vous, il n’y a pas spécialement de raison de changer. Mais si vous n’avez pas l’habitude de ce type d’opérations ou si vous préférez une syntaxe plus cohérente et plus facile à retenir, les fonctions de purrr
peuvent être intéressantes.
Si vous souhaitez en savoir plus, l’ouvrage en ligne R for data science contient une comparaison plus détaillée des deux familles de fonctions.
18.11 Ressources
Au-delà de celles présentées ici, purrr
propose de nombreuses autres fonctions facilitant la manipulation et les itérations sur les listes et les vecteurs. On peut en trouver la liste complète et la documentation associée (en anglais) sur le site de l’extension.
La section Iteration de l’ouvrage en ligne R for data science (en anglais) propose une présentation de plusieurs fonctions de purrr.
RStudio propose une antisèche (en anglais, format PDF) qui résume les différentes fonctions de purrr
.
Sur le blog en français de Lise Vaudor, on trouvera un billet Itérer des fonctions avec purrr et une suite practice makes purrr-fect.
18.12 Exercices
18.12.1 map
et ses variantes
Exercice 1.1
La liste suivante rassemble les notes obtenues par un élève dans différentes matières.
<- list(
notes maths = c(12, 15, 8, 10),
anglais = c(18, 11, 9),
sport = c(5, 13),
musique = 14
)
En utilisant map()
, calculer une liste indiquant la moyenne dans chaque matière.
%>% map(mean) notes
En utilisant une variante de map()
, simplifier le résultat pour obtenir un vecteur numérique.
%>% map_dbl(mean) notes
On a rajouté à la liste les notes obtenues en technologie, parmi lesquelles une note est manquante.
<- list(
notes maths = c(12, 15, 8, 10),
anglais = c(18, 11, 9),
sport = c(5, 13),
musique = 14,
techno = c(12, NA)
)
Calculer à nouveau un vecteur numérique des moyennes par matière, mais sans tenir compte de la valeur manquante.
%>% map_dbl(~ mean(.x, na.rm = TRUE))
notes # Ou bien
%>% map_dbl(mean, na.rm = TRUE) notes
Calculer une liste qui contient pour chaque matière la moyenne, la note minimale et la note maximale.
# On peut aussi utiliser list() plutôt que c()
%>%
notes map(~ c(
moyenne = mean(.x, na.rm = TRUE),
min = min(.x, na.rm = TRUE),
max = max(.x, na.rm = TRUE)
))
Exercice 1.2
La liste suivante comporte les parcours biographiques de 5 personnes sous la forme de vecteurs indiquant leurs communes de résidence successives.
<- list(
parcours c("Lyon", "F1ixevi11e", "Saint-Dié-en-Poui11y"),
c("Sainte-Gabelle-sur-Sarthe"),
c("Décines", "Meyzieu", "Demptezieu"),
c("Meyzieu", "Lyon", "Paris", "F1ixevi11e", "Lyon"),
c("La Bâtie-Divisin", "Versai11es")
)
À l’aide de map()
, calculer une nouvelle liste comportant le nombre de villes de résidence pour chaque parcours.
%>% map(length) parcours
Utiliser une variante de map()
pour simplifier le résultat et obtenir un vecteur numérique plutôt qu’une liste.
%>% map_int(length) parcours
Déterminer pour chaque parcours le nombre de fois où la personne a résidé à Lyon.
%>% map_int(~ sum(.x == "Lyon")) parcours
On vient de repérer un problème dans les données : des caractères “l” ont été remplacés par des “1”. Utiliser map()
pour corriger l’objet parcours
en remplaçant tous les “1” par des “l”.
<- parcours %>%
parcours map(~ str_replace_all(.x, "1", "l"))
Exercice 1.3
Le vecteur suivant contient les adresses de deux fichiers CSV contenant les données de rp2018
pour les départements de l’Ain et du Rhône :
<- c(
urls "https://raw.githubusercontent.com/juba/tidyverse/main/resources/data/rp2018/rp2018_01.csv",
"https://raw.githubusercontent.com/juba/tidyverse/main/resources/data/rp2018/rp2018_69.csv"
)
Utiliser map()
pour charger ces deux tableaux de données dans une liste nommée dfs
.
<- urls %>% map(read_csv) dfs
Utiliser map()
et bind_rows()
pour charger ces deux tableaux de données et les regrouper en une seule table d
. Que constatez-vous ?
# Génère une erreur !
<- urls %>%
d map(read_csv) %>%
bind_rows()
À l’aide de map()
, afficher la variable code_insee
des deux tableaux de dfs
. D’où vient le problème ?
# Une variable a été importée en character, l'autre en numérique
%>% map(~ .x$code_insee) dfs
Trouver une solution pour corriger le problème.
# En convertissant manuellement code_insee en character
<- urls %>%
d map(~ {
<- read_csv(.x)
tab $code_insee <- as.character(tab$code_insee)
tab
tab%>%
}) bind_rows()
# Ou bien en utilisant l'option col_types de read_csv
<- urls %>%
d map(read_csv, col_types = c("code_insee" = "character")) %>%
bind_rows()
18.12.2 modify
et itération sur les colonnes d’un tableau
Exercice 2.1
Soit le tableau de données d
suivant :
<- tribble(
d ~prenom, ~nom, ~age, ~taille,
"pierre-edmond", "multinivo", 19, 151,
"YVONNE-HENRI", "QUIDEU", 73, 182,
"jean-adélaïde", "hacépé", 27, NA
)
Utliser typeof()
et map()
pour afficher le type de toutes les colonnes de d
.
%>% map(typeof) d
En utilisant keep()
et map()
, calculer la moyenne de toutes les variables numériques de d
.
%>%
d keep(is.numeric) %>%
map(mean, na.rm = TRUE)
En utilisant modify_if()
, appliquer str_to_title()
à toutes les colonnes de type character
pour corriger la capitalisation des noms et prénoms.
<- d %>% modify_if(is.character, str_to_title) d
À l’aide de la fonction discard()
, supprimer de d
les colonnes qui comportent des NA
.
%>% discard(~ sum(is.na(.x)) > 0)
d # Ou bien
%>% discard(~ any(is.na(.x))) d
18.12.3 imap
et walk
Exercice 3.1
On reprend la liste de notes vue dans un exercice précédent.
<- list(
notes maths = c(12, 15, 8, 10),
anglais = c(18, 11, 9),
sport = c(5, 13),
musique = 14,
techno = c(12, NA)
)
Sans utiliser de boucle for
, afficher avec la fonction message()
les valeurs de chaque moyenne tour à tour de manière à obtenir le résultat suivant :
#> 11.25
#> 12.6666666666667
#> 9
#> 14
#> 12
%>% walk(~ message(mean(.x, na.rm = TRUE))) notes
De la même manière afficher pour chaque matière les messages suivants :
#> Votre moyenne en maths est 11.25
#> Votre moyenne en anglais est 12.6666666666667
#> Votre moyenne en sport est 9
#> Votre moyenne en musique est 14
#> Votre moyenne en techno est 12
%>% iwalk(function(notes, matiere) {
notes message("Votre moyenne en ", matiere, " est ", mean(notes, na.rm = TRUE))
})
Construire un tableau de données avec une matière par ligne et deux colonnes contenant le nom de la matière et la valeur de la moyenne.
#> # A tibble: 5 × 2
#> matiere moyenne
#> <chr> <dbl>
#> 1 maths 11.2
#> 2 anglais 12.7
#> 3 sport 9
#> 4 musique 14
#> 5 techno 12
%>%
notes imap(function(notes, matiere) {
tibble(
matiere = matiere,
moyenne = mean(notes, na.rm = TRUE)
)%>%
}) bind_rows()
Exercice 3.2
Lors de la présentation des boucles for
, on a vu la fonction suivante qui affiche les dimensions de chaque tableau de données contenu dans une liste.
<- function(dfs) {
affiche_dimensions for (i in seq_along(dfs)) {
<- names(dfs)[[i]]
name <- dfs[[i]]
df message("Dimensions de ", name, " : ", nrow(df), "x", ncol(df))
}
}
<- list(
l hdv = hdv2003,
rp = rp2018
)
affiche_dimensions(l)
#> Dimensions de hdv : 2000x20
#> Dimensions de rp : 5417x62
Réécrire affiche_dimensions()
en utilisant iwalk()
.
<- function(dfs) {
affiche_dimensions iwalk(dfs, function(df, name) {
message("Dimensions de ", name, " : ", nrow(df), "x", ncol(df))
}) }
Exercice 3.3
Lors de la présentation des boucles, on a vu la fonction summaries
suivante, qui prend en paramètre un tableau de données et une liste de noms de colonnes, et applique la fonction summary()
à ces colonnes.
<- function(d, vars) {
summaries for (var in vars) {
message("--- ", var, " ---")
print(summary(d[, var]))
}
}
summaries(hdv2003, c("sexe", "age"))
Réécrire la fonction sans utiliser de boucle for
.
<- function(d, vars) {
summaries %>%
vars walk(~ {
message("--- ", .x, " ---")
print(summary(d[, .x]))
}) }
Modifier cette nouvelle fonction en utilisant keep()
ou discard()
, pour qu’elle fonctionne sans erreur même si l’une des variables passées en arguments n’existe pas.
summaries(hdv2003, c("sexe", "igloo", "age"))
#> --- sexe ---
#> sexe
#> Homme: 899
#> Femme:1101
#> --- age ---
#> age
#> Min. :18.00
#> 1st Qu.:35.00
#> Median :48.00
#> Mean :48.16
#> 3rd Qu.:60.00
#> Max. :97.00
<- function(d, vars) {
summaries %>%
vars keep(~ .x %in% names(d)) %>%
walk(~ {
message("--- ", .x, " ---")
print(summary(d[, .x]))
}) }
18.12.4 map2
et pmap
Exercice 4.1
Soit le tableau de données suivant, qui indique des couples de variables du jeu de données hdv2003
.
<- tribble(
croisements ~v1, ~v2,
"qualif", "clso",
"qualif", "cinema",
"sexe", "clso",
"sexe", "cinema"
)
croisements#> # A tibble: 4 × 2
#> v1 v2
#> <chr> <chr>
#> 1 qualif clso
#> 2 qualif cinema
#> 3 sexe clso
#> 4 sexe cinema
À l’aide de map2()
, calculer une liste qui contient les tableaux croisés des quatre couples de variables de croisements
.
map2(
$v1, croisements$v2,
croisements~ table(hdv2003[[.x]], hdv2003[[.y]])
)
Modifier le code précédent pour obtenir une liste comportant, pour chaque couple de variables :
- les noms des deux variables croisées
- le résultat du test du \(\chi^2\) appliqué aux deux variables
<- map2(
khi2 $v1, croisements$v2,
croisements~ list(
variables = paste(.x, .y),
chisq = chisq.test(hdv2003[[.x]], hdv2003[[.y]])
) )
Facultatif : filtrer la liste obtenue à l’étape précédente pour ne conserver que les éléments dont la p-value du test du \(\chi^2\) est inférieure à 0.001.
<- map2(
khi2 $v1, croisements$v2,
croisements~ list(
variables = paste(.x, .y),
chisq = chisq.test(hdv2003[[.x]], hdv2003[[.y]])
)%>%
) keep(~ .x$chisq$p.value < 0.001)
Exercice 4.2
Pour rappel, on peut simuler un tirage au sort de type “pile ou face” avec la fonction sample()
de la manière suivante :
sample(
c("pile", "face"),
size = 10,
replace = TRUE,
prob = c(0.5, 0.5)
)#> [1] "face" "pile" "face" "pile" "face" "pile" "face" "pile" "pile" "face"
Le tableau de données suivant définit trois types de tirages aléatoires différents. elements
contient les éléments parmi lesquels on tire, probas
les probabilités de chaque élément d’être tiré, et n
le nombre de tirages.
<- tribble(
tirages ~elements, ~probas, ~n,
c("pile", "face"), c(0.5, 0.5), 1000,
c("rouge", "noire"), c(0.1, 0.9), 500,
1:6, rep(1 / 6, 6), 800
)
À l’aide de pmap()
et de sample()
, créer une liste contenant les résultats des simumations de ces trois séries de tirages.
<- tirages %>%
resultats pmap(
function(elements, probas, n) {
sample(elements, size = n, replace = TRUE, prob = probas)
} )
Représenter avec barplot()
les résultats obtenus.
%>% walk(~ barplot(table(.x))) resultats
18.12.5 Répéter une opération
Exercice 5.1
La fonction suivante simule le lancer de n
dés à 6 faces.
<- function(n) {
lancer_des sample(1:6, size = n, replace = TRUE)
}
lancer_des(3)
#> [1] 6 1 2
On souhaite utiliser cette fonction pour générer 10 lancers de 4 dés. On essaie avec le code suivant :
map(1:10, lancer_des, 4)
Est-ce que cela fonctionne ? Pourquoi ?
Ça ne fonctionne pas car lancer_des()
est appelée à chaque itération avec deux arguments : la valeur courante de l’itérateur, et 4. Ainsi dans sa première itération, map()
exécute lancer_des(1, 4)
, ce qui génère un message d’erreur puisque lancer_des()
n’accepte qu’un argument.
Trouver une solution en corrigeant le code.
map(1:10, ~ lancer_des(4))
Exercice 5.2
Comme vu précédemment, la fonction suivante permet de simuler 20 lancers de pièces et de stocker le résultat dans un vecteur de chaînes de caractères.
sample(c("pile", "face"), size = 20, replace = TRUE)
#> [1] "pile" "pile" "face" "face" "face" "pile" "pile" "face" "face" "face"
#> [11] "pile" "pile" "pile" "pile" "face" "face" "pile" "pile" "face" "pile"
À l’aide de map()
et de ce code, créer une liste nommée sims
contenant le résultat de 100 simulations de 20 lancers.
<- map(
sims 1:100,
~ sample(c("pile", "face"), size = 20, replace = TRUE)
)
Toujours à l’aide de map()
, calculer le nombre de “pile” obtenus pour chaque simulation de sims
.
<- sims %>% map_int(~ sum(.x == "pile")) n_piles
Déterminer le nombre minimal et le nombre maximal de “pile” obtenus parmi toutes les simulations de sims
.
range(n_piles)
Facultatif : à l’aide de la fonction head_while(), déterminer pour chaque simulation de sims
le nombre de “pile” consécutifs obtenus avant le premier “face”.
%>% map_int(
sims ~ length(head_while(.x, ~ .x == "pile"))
)
Facultatif : la fonction rle()
permet de calculer les run length encoding d’un vecteur.
rle(c("pile", "face", "face", "face", "pile", "pile"))
#> Run Length Encoding
#> lengths: int [1:3] 1 3 2
#> values : chr [1:3] "pile" "face" "pile"
À l’aide de cette fonction et de map_int()
, calculer pour chaque simulation la longueur de la plus grande série consécutive de “pile”.
%>% map_int(~ {
sims <- rle(.x)
r <- r$lengths[r$values == "pile"]
longueurs_pile max(longueurs_pile)
})
Pour une application sérieuse on utilisera des fonctions spécifiques de “tokenization” d’extensions dédiées à l’analyse textuelle, comme
quanteda
.↩︎