“Comment je fais … avec une boucle ?” Une question qui revient souvent. Et dans la majorité des cas, la meilleure réponse donnée consiste en une résolution SANS boucle. Parfois, c’est le temps d’exécution qui pose un problème, et là encore, les alternatives dont dispose R comme les fonctions de la famille apply, le package {purrr}
, ou simplement le fait de reconsidérer le problème en prenant en compte que R est un langage dont la logique est vectorielle, vous permet d’avoir un code optimal.
Sommaire
Les boucles dans R
Histoire de ne pas décevoir les personnes qui lisent cet article dans le but d’apprendre à faire des boucles, on va les détailler ici, mais j’ose espérer que vous continuerez votre lecture car dans bien des cas, il est possible de les remplacer par des solutions plus élégantes.
La boucle for
La syntaxe s’écrit comme suit :
for (variable in vector) {
expr
}
C’est très intuitif, on comprend : pour chaque élément variable
du vecteur vector
, exécuter expr
. Par exemple, je crée un vecteur de taille 100
échantillonné dans une loi normale centrée réduite, et je souhaite calculer les valeurs au carré :
# Vecteur échantillon dans une loi normale N(0,1) :
v_norm <- rnorm(n = 100, mean = 0, sd = 1)
# Vecteur qui stockera les valeurs au carré : initialisation
v_normcarre <- rep(x = NA, times = 100)
# Boucle qui fait le job :
for (i in 1:100) {
v_normcarre[i] <- v_norm[i]^2
}
D’ailleurs, pour être plus générique, on préférera l’utilisation de seq_along()
:
# Boucle avec seq_along
for (i in seq_along(v_norm)) {
v_normcarre[i] <- v_norm[i]^2
}
Autre exemple : je souhaite calculer la somme de chaque ligne d’une matrice :
# Ma matrice :
my_mat <- matrix(rnorm(100), nrow = 10, ncol = 10)
# Vecteur qui contiendra la somme de chaque ligne
vec_sum <- rep(NA, times = 10)
# Boucle sur les lignes
for (i in 1:nrow(my_mat)) {
vec_sum[i] <- sum(my_mat[i,])
}
Facile non ? Oui, mais vraiment loin d’être optimal…
La boucle while
(“tant que”)
Pour la syntaxe, on a :
while (condition) {
expr
}
Comme pour la boucle for
, l’utilisation de la boucle while
est assez intuitive. Ici, on pourrait traduire cela par : tant que la condition
est vraie TRUE
, exécuter expr
. Reprenons l’exemple de notre vecteur à mettre au carré :
# Vecteur échantillon dans une loi normale N(0,1) :
v_norm <- rnorm(n = 100, mean = 0, sd = 1)
# Vecteur qui stockera les valeurs au carré : initialisation
v_normcarre <- rep(x = NA, times = 100)
# Boucle qui fait le job :
i <- 1 # initialisation
while (i <= length(v_norm)) {
v_normcarre[i] <- v_norm[i]^2
i <- i + 1 # incrementation du compteur
}
La valeur de i
doit être initialisée dans un premier temps, puis est incrémentée à chaque tour de boucle. Nous l’utilisons comme compteur. Pour l’exemple de la somme de chaque ligne d’une matrice, cela nous donne de manière similaire :
# Ma matrice :
my_mat <- matrix(rnorm(100), nrow = 10, ncol = 10)
# Vecteur qui contiendra la somme de chaque ligne
vec_sum <- rep(NA, times = 10)
# Boucle sur les lignes
i <- 1 # initialisation
while (i <= nrow(my_mat)) {
vec_sum[i] <- sum(my_mat[i,])
i <- i + 1 # incrementation du compteur
}
Voilà grosso-modo comment fonctionnent les boucles for
et while
dans R. Alors oui, je pourrais m’étendre un peu plus sur le sujet et vous parler des boucles repeat
, des sorties de boucles avec break
, du passage à la prochaine itération avec next
, mais ce qui m’intéresse ici c’est plutôt de vous montrer comment éviter de faire des boucles à tout va.
Utiliser la puissance de la vectorisation
On vous l’a beaucoup répété, R est un langage vectoriel. Le vecteur est en effet la structure élémentaire dans R. Mais la vectorisation, c’est quoi ? On pourrait traduire cela par la conversion d’une répétition d’opérations sur des éléments simples en une unique opération sur des vecteurs. Bon nombre de fonctions sont vectorisées, c’est-à-dire faites pour s’appliquer à des vecteurs. On peut citer :
- Les fonctions les plus simples sont les opérateurs arithmétiques de base
+
,-
,*
,/
:
Ajout d’une valeur à un vecteur avec une boucle for – PAS BIEN
|
Ajout d’une valeur à un vecteur avec une opération vectorisée – BIEN
|
---|
A priori, il ne devrait pas vous venir à l’idée de faire l’opération précédente avec une boucle. Alors pourquoi le faire avec plusieurs vecteurs ?
Somme de 2 vecteurs avec une boucle for – PAS BIEN
|
Somme de 2 vecteurs avec une opération vectorisée – BIEN
|
---|
- Autres fonctions vectorisées utiles, les opérateurs logiques (renvoie
TRUE
ouFALSE
) :<
,>
,<=
,>=
,==
,!=
,%in%
Comparaison de 2 vecteurs avec une boucle for – PAS BIEN
|
Comparaison de 2 vecteurs avec une opération vectorisée – BIEN
|
---|
Notez que les opérations vectorisées présentées ici avec les opérateurs arithmétiques et logiques ne sont correctes que parce que les deux vecteurs sont de même taille. Pour éviter les problèmes, faites en sorte que ce soit le cas.
- Et pour n’en citer que quelques unes :
log()
,exp()
,paste()
, … s’appliquent à des vecteurs. Si je souhaite par exemple créer un vecteurc("Obs. 1", "Obs. 2", ..., "Obs. 10")
:
Avec une boucle for – PAS BIEN
|
Avec une opération vectorisée – BIEN
|
---|
- Les calculs matriciels sont également vectorisés, par exemple pour la multiplication terme à terme :
Avec une boucle for – PAS BIEN
|
Avec une opération vectorisée – BIEN
|
---|
(pour info, la multiplication matricielle s’écrit %*%
)
- Autres exemples non exhaustifs de fonctions qui peuvent s’appliquer à des matrices :
rowSums()
,colSums()
,rowMeans()
,colMeans()
. Si on reprend notre exemple précédent dans lequel on calcule la somme des éléments de chaque ligne :
Avec une boucle for – PAS BIEN
|
Avec une opération vectorisée – BIEN
|
---|
- Le package
{dplyr}
dispose également d’un grand nombre de fonctions vectorisées :lag()
,lead()
,cumall()
,cume_dist()
,na_if()
,recode()
… et bien d’autres ! Vous les retrouverez sur la cheatsheet {dplyr} qu’on vous a traduite en français.
Penser “vecteurs” avec la famille apply
Si vous avez déjà pu supprimer un bon nombre de vos boucles grâce aux opérations vectorisées, les fonctions de la famille apply vont vous motiver à continuer la chasse aux boucles for
et while
. L’appel à ces fonctions vous évitera l’utilisation plus fastidieuse et plus propice aux erreurs des boucles for
ou while
et peut vous permettre d’accélérer les calculs.
L’idée est d’appliquer une fonction FUN
aux éléments :
- d’une matrice, avec la fonction
apply()
. On utilisera le paramètreMARGIN = 1
pour appliquer la fonction aux lignes de la matrice,MARGIN = 2
pour les colonnes. Reprenons l’exemple du calcul de la somme sur les lignes :
Avec une boucle for – PAS BIEN
|
avec apply – BIEN
|
---|
- d’une liste ou d’un vecteur (ou même d’un data frame), avec la fonction
lapply()
. Dans certains cas,lapply()
renvoie une liste. Pour renvoyer un vecteur, on utilisera la fonctionsapply()
, ou encorevapply()
. J’ai par exemple une liste de matrices dont je souhaite calculer la somme de tous les éléments :
avec une boucle for – PAS BIEN
|
avec sapply – BIEN
|
---|
- La fonction
mapply()
est la version multi-variables desapply()
etlapply()
. Comme rien ne vaut un exemple pour aider à la compréhension : je souhaite obtenir un vecteur contenant 1 fois la lettre “A”, 2 fois “B”, …, 5 fois “E” en appliquant la fonctionrep()
.
avec une boucle for – PAS BIEN
|
avec mapply – BIEN
|
---|
Note : Il faudra bien faire attention à la sortie de vos fonctions apply : selon les cas, cela pourra être un vecteur ou une liste.
La stratégie “split-apply-combine” (dépassée) de {plyr}
Avant que n’existe les packages {dplyr}
et {purrr}
, les fonctions de {plyr}
étaient une bonne alternative aux boucles. Mais comme il y a mieux maintenant, on ne va pas trop entrer dans le détail. Ce qu’il faut savoir, c’est que {plyr}
dispose d’une douzaine de fonctions permettant d’appliquer une fonction à un ensemble d’éléments. Ces fonctions s’écrivent sous la forme {X}{Y}ply()
où {X}
et {Y}
seront différents selon les formats d’entrée ({X}
) et de sortie ({Y}
) :
- a pour array, ou matrice
- d pour dataframe
- l pour liste
- **_** pour rien – seulement valable pour
Y
, quand la fonction ne renvoie rien
Par exemple, si j’ai une liste en entrée et que je souhaite un dataframe en sortie, j’appellerai la fonction ldply()
.
Maîtriser le format de sortie avec {purrr}
{purrr}
est un package du {tidyverse} que nous avons déjà abordé il y a quelques années dans “Un code qui ronronne avec {purrr}”. C’est un package avec très peu de dépendances. Installation et chargement du package :
install.packages("purrr")
library(purrr)
Comme le disait Colin dans son article :
Toutes les fonctions de {purrr} sont construites en suivant un même schéma :
function(.x, .f)
où :–
.x
est un vecteur–
.f
est une fonction pure (des fonctions opérant en l’absence de “side effects” — qui ne se reposent pas sur des données externes, et qui ne changent pas les données qui existent en dehors)– le résultat est toujours un vecteur du même type que
.x
Ses fonctions map
permettent d’appliquer la même fonction .f
à chaque élément d’une liste ou d’un vecteur .x
. Elles permettent une meilleure maîtrise sur les formats de sortie que les fonctions de la famille apply
. Quatre fonctions principales, selon le contexte :
map(.x, .f, …)
: applique la fonction.f
à tous les éléments du vecteur (ou de la liste).x
map2(.x, .y, .f, …)
: version multivariée demap
,.f
doit admettre 2 paramètres et sera appliquée aux paires d’éléments des vecteurs (ou listes).x
et.y
pmap(.l, .f, …)
: version multivariée demap
,.f
admettant 2 paramètres ou plus sera appliquée aux n-uplets des éléments des vecteurs (ou listes) contenus dans la liste.l
imap(.x, .f, ...)
: applique la fonction.f
à tous les éléments du vecteur (ou de la liste).x
et à son index.
Ces fonctions renvoient toutes des listes, mais si on souhaite un format de sortie différent, on utilisera l’extension :
_chr
: pour renvoyer un vecteur de chaines de caractères_dfc
: pour renvoyer un vecteur numérique_dfc
: pour renvoyer un dataframe (construction par colonnebind_cols()
)_dfr
: pour renvoyer un dataframe (construction par lignebind_rows()
)_int
: pour renvoyer un vecteur d’entiers_lgl
: pour renvoyer un vecteur de booléens
Exemple : pmap_int()
pour appliquer, sur une liste de 3 variables, une fonction a 3 paramètres qui renvoie un nombre entier. La sortie de pmap_int()
sera un vecteur d’entiers.
Appliquer une fonction sur chaque élément d’une liste ou d’un vecteur avec map()
Exemple : Je souhaite importer tous les fichiers “csv” de mon répertoire et stocker les dataframes dans une liste.
# Créer des fichiers csv dans mon répertoire
readr::write_csv(iris, "iris.csv")
readr::write_csv(mtcars, "mtcars.csv")
readr::write_csv(cars, "cars.csv")
# Vecteur contenant les fichiers à importer
files <- list.files(".", pattern = ".csv")
files
## [1] "cars.csv" "iris.csv" "mtcars.csv"
Avec une boucle for – PAS BIEN
|
Avec map – BIEN
Notez comme le paramètre de la fonction est listé dans |
---|
La fonction map()
peut également s’appliquer avec trois autres suffixes, permettant de conditionner l’appel à la fonction concernée :
map_if(.x, .p, .f, .else = NULL)
: applique.f
aux éléments de.x
qui vérifient.p
, ou la fonction.else
si renseignée sinon. Par exemple :
Avec une boucle for – PAS BIEN
|
Avec map_if – BIEN
|
---|
map_at(.x, .at, .f)
: applique.f
aux éléments de.x
uniquement aux positions définies par.at
, le reste est conservé.
Avec une boucle for – PAS BIEN
|
Avec map_at – BIEN
|
---|
map_depth(.x, .depth, .f)
: applique.f
aux éléments de.x
qui sont au niveau.depth
. Par exemple :
# Liste de valeurs
liste <- list(
a = list(
a1 = c(1, 2, 3),
a2 = c(4, 5, 6)
),
b = list(
b1 = c(7, 8, 9)
)
)
liste
## $a
## $a$a1
## [1] 1 2 3
##
## $a$a2
## [1] 4 5 6
##
##
## $b
## $b$b1
## [1] 7 8 9
Avec une boucle for – PAS BIEN
|
avec map_depth – BIEN
|
---|
La fonction map2()
pour appliquer une fonction sur 2 objets de même longueur
Reprenons l’exemple donné pour illustrer la fonction mapply()
: je souhaite obtenir un vecteur contenant 1 fois la lettre “A”, 2 fois “B”, …, 5 fois “E” en appliquant la fonction rep()
Avec une boucle for – PAS BIEN
|
avec map2 – BIEN
|
---|
Bon, en vrai, dans ce cas, la fonction rep()
est elle-même déjà vectorisée. D’ailleurs c’est un peu le sens de cet article : vérifiez d’abord si la fonction que vous souhaitez utiliser n’est pas déjà vectorisée avant de vous lancer dans des opérations compliquées. Ainsi, avec rep()
, on peut faire :
rep(lettres, times = fois)
Appliquer une fonction avec plusieurs paramètres en utilisant pmap()
Exemple : je souhaite appliquer la fonction gsub()
qui permet de remplacer un morceau de chaîne de caractère par un autre. La fonction gsub()
prend 3 paramètres : pattern
remplacé par replacement
dans la chaîne de caractères x
.
Notez qu’une fois encore, nous nous intéressons à des utilisations terme à terme, c’est-à-dire que les vecteurs utilisés ensembles doivent être de même longueur. Assurez-vous d’être dans ces conditions à chaque fois, comme ça vous êtes sûrs de ce que vous êtes en train de manipuler et de ce que vous comparez à quoi. En effet, R utilise ce qu’on appelle le recyclage. Si les objets ne font pas la même taille, il recycle les valeurs et là, vous ne savez plus forcément ce qu’il se passe. Regardez par exemple le résultat de
1:3 + 1:4
, malgré le warning, il y a quand même un résultat utilisable…
Donc, nous construisons trois vecteurs de même taille. Les modifications seront appliquées terme à terme : montagne
avec pattern = n
et replacement = N
.
vec_texte <- c("montagne", "campagne", "littoral")
pattern <- c("n", "a", "t")
replacement <- c("N", "A", "T")
Avec une boucle for – PAS BIEN
|
avec pmap – BIEN
|
---|
Je vous mets ici le lien vers la cheatsheet de {purrr}
(en anglais), c’est toujours utile de l’avoir sous les yeux : https://github.com/rstudio/cheatsheets/blob/master/purrr.pdf
Appliquer une fonction itérativement avec do.call()
do.call(what, args)
est une autre alternative aux boucles qui applique la fonction what
à TOUS les éléments de la liste args
, dans leur ensemble, et 1 par 1 comme pour les fonctions vues précédemment. Par exemple, j’ai une liste de vecteurs dont je souhaite faire une matrice :
ma_liste <- list(
v1 = 1:10,
v2 = 11:20,
v3 = 21:30,
v4 = 31:40
)
Avec une boucle for – PAS BIEN
|
Avec do.call – BIEN
|
---|
Appliquer une fonction sur différents groupes séparément
Supposons que j’ai un dataframe contenant une variable de groupes et que je souhaite faire la même opération sur les différents groupes. Plusieurs solutions s’offrent à moi. Prenons par exemple le jeu de données starwars
du package {dplyr}
qui recense les personnage des films :
library(dplyr)
data("starwars")
starwars
## # A tibble: 87 x 14
## name height mass hair_color skin_color eye_color birth_year sex gender homeworld species films
## <chr> <int> <dbl> <chr> <chr> <chr> <dbl> <chr> <chr> <chr> <chr> <lis>
## 1 Luke~ 172 77 blond fair blue 19 male mascu~ Tatooine Human <chr~
## 2 C-3PO 167 75 <NA> gold yellow 112 none mascu~ Tatooine Droid <chr~
## 3 R2-D2 96 32 <NA> white, bl~ red 33 none mascu~ Naboo Droid <chr~
## 4 Dart~ 202 136 none white yellow 41.9 male mascu~ Tatooine Human <chr~
## 5 Leia~ 150 49 brown light brown 19 fema~ femin~ Alderaan Human <chr~
## 6 Owen~ 178 120 brown, gr~ light blue 52 male mascu~ Tatooine Human <chr~
## 7 Beru~ 165 75 brown light blue 47 fema~ femin~ Tatooine Human <chr~
## 8 R5-D4 97 32 <NA> white, red red NA none mascu~ Tatooine Droid <chr~
## 9 Bigg~ 183 84 black light brown 24 male mascu~ Tatooine Human <chr~
## 10 Obi-~ 182 77 auburn, w~ fair blue-gray 57 male mascu~ Stewjon Human <chr~
## # ... with 77 more rows, and 2 more variables: vehicles <list>, starships <list>
On souhaite connaître la taille min, max, moyenne en fonction du sexe.
- Avec une boucle
for
– PAS BIEN
groupes <- na.omit(unique(starwars$sex))
# Initier un tableau vide de stockage des résultats
taille_by_sex <- tibble()
# Boucle for
for (g in groupes) {
taille_groupe <- starwars %>%
filter(sex == g) %>%
pull(height)
taille_by_sex <- rbind(
taille_by_sex,
tibble(
sex = g,
height.min = min(taille_groupe, na.rm = TRUE),
height.max = max(taille_groupe, na.rm = TRUE),
height.mean = mean(taille_groupe, na.rm = TRUE)
)
)
}
- Avec la fonction
aggregate()
– BIEN
aggregate(height~sex,
data = starwars,
FUN = function(x)
c(
min = min(x, na.rm = TRUE),
max = max(x, na.rm = TRUE),
mean = mean(x, na.rm = TRUE)
)
)
- Avec la fonction
summarise()
du packagedplyr()
– MIEUX
starwars %>%
filter(!is.na(sex)) %>%
group_by(sex) %>%
summarise(
height.min = min(height, na.rm = TRUE),
height.max = max(height, na.rm = TRUE),
height.mean = mean(height, na.rm = TRUE),
.groups = "drop"
)
## # A tibble: 4 x 4
## sex height.min height.max height.mean
## <chr> <int> <int> <dbl>
## 1 female 150 213 169.
## 2 hermaphroditic 175 175 175
## 3 male 66 264 179.
## 4 none 96 200 131.
Les questions / réponses autour des boucles for
sur Internet
Nous avons écumé les questions quant à l’utilisation des boucles for
sur StackOverflow, et il se trouve que même si la solution n’a pas toujours été donnée sans boucle, nous avons une solution sans !
Ci-dessous un top 3 des questions “for-loop” en R, classées par le nombre de votes :
1 : une boucle itérative trop lente
Le code donné dans cet exemple est le suivant : une fonction qui met beaucoup trop de temps à tourner
- Avec une boucle
for
– AVEC MODERATION
dayloop2 <- function(temp) {
for (i in 1:nrow(temp)) {
temp[i, 10] <- i
if (i > 1) {
if ((temp[i, 6] == temp[i - 1, 6]) & (temp[i, 3] == temp[i - 1, 3])) {
temp[i, 10] <- temp[i, 9] + temp[i - 1, 10]
} else {
temp[i, 10] <- temp[i, 9]
}
} else {
temp[i, 10] <- temp[i, 9]
}
}
return(temp)
}
Beaucoup de réponses de qualité ont été données, mais aucune sans boucle for
,
Alors sincèrement je me suis un peu cassé les dents sur cette fonction purement itérative qui mériterait bien sa boucle for
sans l’existence de la fonction accumulate()
du package {purrr}
, je vous propose donc cette solution :
- Avec
accumulate
– MIEUX
dayloop_accumulate <- function(temp) {
temp %>%
as_tibble() %>%
mutate(cond = c(FALSE, (V6 == lag(V6) & V3 == lag(V3))[-1])) %>%
mutate(V10 = V9 %>%
purrr::accumulate2(.y = cond[-1], .f = function(.i_1, .i, .y) {
if (.y) {
.i_1 + .i
} else {
.i
}
}) %>% unlist()) %>%
select(-cond)
}
# Utilisation
n <- 20
X <- as.data.frame(matrix(sample(1:10, n*9, TRUE), n, 9))
dayloop_accumulate(X)
2 : supprimer certaines valeurs d’un vecteur
La question ici est la suivante : on souhaite supprimer les valeurs de v_suppr
d’un vecteur numérique vec
.
Question : avec une boucle for – PAS BIEN
|
Réponse : vectorisation – BIEN
|
---|
3 : une même modification pour certaines colonnes d’un data.table
On souhaite par exemple multiplier par -1
les éléments de certaines colonnes d’un data.table
:
library(data.table)
dt <- data.table(a = 1:3, b = 1:3, d = 1:3)
cols <- c("a", "b")
Question : avec une boucle for – PAS BIEN
|
Réponse : sans boucle for – BIEN
ou :
|
---|
Conclusion
Et voilà pour les boucles ! Je vous laisse faire vos tests, comparer les performances du code et modifier votre vision du fonctionnement de R. J’espère qu’à partir de maintenant vous réfléchirez à deux fois avant de faire une boucle !
Article rédigé par Elena Salette, avec la participation de Sébastien Rochette.