Restart_6: Programmation fonctionnelle

Lise Vaudor

31/03/2022

Quésako

La programmation doit vous permettre d’automatiser un certain nombre de traitements avec R, i.e. de décrire un certain nombre d’opérations similaires et les faire exécuter en lot par la machine.

La programmation fonctionnelle est une manière de réaliser cette automatisation des traitements, en raisonnant principalement sur l’écriture et l’utilisation des fonctions comme “unités opérationnelles”.

Fonctions: Pourquoi en écrire

L’utilisateur de R peut créer lui-même ses fonctions, par exemple s’il pense répéter plusieurs fois un même type de traitement.

Imaginons par exemple que je veux réaliser la même régression linéaire, mais portant sur 3 jeux de données distincts data1, data2, data3.

Voilà comment je vais m’y prendre (en gros, je copie-colle deux fois mes deux premières lignes de code en remplaçant à chaque endroit “variant” ce qui doit l’être (ici lmX, dataX, sX):

lm1=lm(y~x, data=data1)
s1=summary(lm1)
lm2=lm(y~x, data=data2)
s2=summary(lm2)
lm3=lm(y~x, data=data2)
s3=summary(lm3)

D’ailleurs, ouhlala, oups, je me suis trompée en mettant data2 au lieu de data3… Eh oui parce que les copier-coller + modifs c’est non seulement pénible à faire mais en plus on risque des oublis ou des coquilles

A la place, je peux définir une fonction:

f_lm=function(my_data){
  my_lm=lm(y~x, data=my_data)
  my_s=summary(my_lm)
  return(my_s)
}
f_lm(data1)
f_lm(data2)
f_lm(data3)

Fonctions: Pourquoi en écrire

Ainsi, pour schématiser ce principe, il s’agit en fait d’identifier ce qui dans le code est commun au différentes itérations (ici en gris) et les éléments qui varient d’une fois à l’autre (en jaune bleu rose à gauche) et qui vont correspondre à l’argument (ou aux différents arguments) de la fonction à droite (en orange).

Typiquement, on recommande de créer une fonction (au lieu d’écrire plusieurs fois des lignes de code identiques à un ou deux détails près) dès que l’on souhaite réaliser au moins 3 opérations similaires.

Fonctions: Comment en écrire

Une fonction s’écrit de la manière suivante:

mafonction <-function(argument1,argument2){
  ...
  ...
  resultat <- ...
  return(resultat)
}

On définit ainsi plusieurs choses:

  • le nom de la fonction (ici mafonction)
  • les arguments ou inputs (ici argument1, argument2)
  • les opérations réalisées ou corps de la fonction (ici symbolisé par les …)
  • la sortie ou output qui est retournée via return()

Par exemple, la fonction

Tconversion  <-function(x){
    reponse=(x-32)/1.8
    return(reponse)
}

convertit les températures en degrés Fahrenheit, en températures en degrés Celsius. On peut par exemple la tester de cette manière:

Tconversion(451)
[1] 232.7778

Fonctions: valeurs par défauts des arguments

Tconversion <- function(x, type="FtoC"){
  if(type=="FtoC"){resultat <- (x-32)/1.8}
  if(type=="CtoF"){resultat <- 1.8*x+32}
  return(resultat)
}

Testons cette fonction:

Tconversion(451)
[1] 232.7778
Tconversion(451, type="FtoC")
[1] 232.7778
Tconversion(232, type="CtoF")
[1] 449.6
Tconversion(232, type="kekek!")
Error in Tconversion(232, type = "kekek!"): objet 'resultat' introuvable

Fonctions: inputs, output, side effects

Voilà comment on peut voir une fonction…

Ici je considère une fonction .f() qui a un input principal x et des inputs secondaires (...).

Classiquement, on va produire l’output en appelant la fonction avec pour arguments l’input x et les inputs secondaires: output=.f(x,...).

Par exemple:

x=c(33,NA,2,15,7,4,5)
moyenne=mean(x,na.rm=TRUE)
Ici, j’ai produit l’output moyenne en appelant la fonction mean(), avec pour argument principal x et pour argument secondaire na.rm=TRUE.

Structures conditionnelles dans du code ordinaire

Les instructions conditionnelles (if ou if else) permettent d’exécuter (ou non) certaines commandes en fonction de conditions spécifiées par l’utilisateur.

Voici la structure d’une instruction conditionnelle if. Le principe est que les lignes de codes (symbolisées par ...) sont exécutées si et seulement si la condition condition est évaluée comme TRUE.

if(condition){
  ...
  ...
  ...
}

La structure if else permet de préciser ce qui se passe dans le cas contraire (quand la condition n’est pas vérifiée):

if(condition){
  ...
}else{
  ...
}

Exemples:

Temperature <- 21
if(Temperature<15){
  print("il fait frisquet aujourd'hui!")
}

Il ne se passe rien, car la condition n’étant pas remplie la commande print(... n’a pas été exécutée

Temperature <- 12
if(Temperature<15){
  print("il fait frisquet aujourd'hui!")
}
[1] "il fait frisquet aujourd'hui!"

Ici au contraire, la condition (Temperature <15) était remplie et la commande print(... a bien été exécutée

Structure conditionnelle if dans une fonction: erreurs et warnings

Tconversion <- function(x, type="FtoC"){
  if(type=="FtoC"){resultat <- (x-32)/1.8}
  if(type=="CtoF"){resultat <- 1.8*x+32}
  if(type!="FtoC" & type!="CtoF"){
    warning("Type de conversion inconnu")
    resultat=NA
  }
  return(resultat)
}

Testons cette fonction:

Tconversion(232, type="kekek!")
Warning in Tconversion(232, type = "kekek!"): Type
de conversion inconnu
[1] NA
Tconversion <- function(x, type="FtoC"){
  if(type=="FtoC"){resultat <- (x-32)/1.8}
  if(type=="CtoF"){resultat <- 1.8*x+32}
  if(type!="FtoC" & type!="CtoF"){
    stop("Type de conversion inconnu")
  }
  return(resultat)
}

Testons cette fonction:

Tconversion(232, type="kekek!")
Error in Tconversion(232, type = "kekek!"): Type de conversion inconnu

Boucles implicites et vectorisation

Considérons à nouveau la fonction Tconversion():

Tconversion <- function(x, type="FtoC"){
  if(type=="FtoC"){resultat <- (x-32)/1.8}
  if(type=="CtoF"){resultat <- 1.8*x+32}
  return(resultat)
}

Cette fonction renvoie une valeur quand l’argument x est de longueur 1:

Tconversion(461)
[1] 238.3333

Mais elle peut également renvoyer un vecteur de valeurs quand l’argument x est un vecteur de longueur n>1.

Tconversion(c(460,465,470))
[1] 237.7778 240.5556 243.3333

On dit que la fonction Tconversion() est vectorisée (par opposition à une fonction qui ne fonctionnerait que pour une seule valeur en entrée.).

Elle est de plus vectorisée sans qu’on ait eu à fournir d’effort particulier en ce sens, mais simplement du fait que les différents opérateurs arithmétiques que l’on utilise dans le corps de la fonction sont eux-mêmes vectorisés.

On bénéficie ainsi d’une sorte de “boucle cachée” qui permet d’automatiser les calculs pour l’ensemble des éléments du vecteur.

Boucles for

On peut également réaliser des opérations en boucle à l’aide d’une structure de contrôle for.

Les boucles for permettent d’exécuter des instructions de manière itérative (ou répétée).

Voici la structure d’une instruction instruction for:

for(compteur in sequence){
  ...
  ...
  ...
}

Par exemple:

for(i in 1:5){
  print(paste("On en est à",i,"!"))
}
[1] "On en est à 1 !"
[1] "On en est à 2 !"
[1] "On en est à 3 !"
[1] "On en est à 4 !"
[1] "On en est à 5 !"

On fait varier le compteur (ici appelé i) dans une séquence et on utilise cet élément variant pour répéter un ensemble d’instructions un certain nombre de fois avec juste un élément qui change

C’est un principe qui vous dit quelque chose?…

Eh oui, c’est quelque chose qu’on pourrait faire en transformant l’ensemble d’instructions en fonction, et en faisant en sorte d’appliquer cette fonction à l’ensemble des éléments variants.

D’ailleurs, dans le cadre de la programmation fonctionnelle, on n’utilise pas trop de boucles for, on utilise plutôt les fonctions du package purrr!

purrr: map() au lieu d’une boucle for

Imaginons maintenant que je souhaite appeler la fonction mean() de manière répétée sur plusieurs éléments d’une liste:

myX=list(c(1,6),
         c(33,NA,2,15,7,4,5),
         c(3))

Je pourrais choisir de le faire via une boucle for:

moyennes=vector("list",length=3)
for (i in 1:length(myX)){
  moyennes[i]=mean(myX[[i]],na.rm=TRUE)
}
print(moyennes)
[[1]]
[1] 3.5

[[2]]
[1] 11

[[3]]
[1] 3

OU BIEN je peux choisir de le faire avec la fonction map() du package purrr

library(purrr)
moyennes=map(myX,mean, na.rm=TRUE)
print(moyennes)
[[1]]
[1] 3.5

[[2]]
[1] 11

[[3]]
[1] 3

purrr: map() en un dessin

Ainsi, en utilisant map(), j’ai en quelque sorte transformé ma petite fonction/usine mean() (ci-dessus) en lui adjoignant une “rampe d’approvisionnement” (à droite):

Mon argument principal, x, devient ainsi une liste d’éléments utilisés comme input pour la fonction mean(). Mon argument secondaire, na.rm=TRUE, est en revanche le même pour toutes les itérations.

L’output moyennes est par défaut également une liste.

purrr: type d’output

Notez que l’on aurait pu ici demander explicitement à ce que le résultat nous soit renvoyé non pas comme une liste, mais comme un vecteur de valeurs numériques de type “double”:

moyennes=map_dbl(myX,mean,na.rm=TRUE)
print(moyennes)
[1]  3.5 11.0  3.0

Selon le type d’output renvoyé par la fonction, il peut ainsi être assez pratique d’utiliser les fonctions

  • map_dbl() (double)
  • map_lgl (logique)
  • map_int() (entier)
  • etc.

purrr: walk() ou map()

Considérons maintenant les effets secondaires, en prenant pour exemple une fonction dont l’utilité première n’est pas de renvoyer un output, mais plutôt d’afficher quelque chose dans la console:

print_moyenne=function(x){
  print(paste("la moyenne est de",
              mean(x,na.rm=TRUE)))
  return(NULL)
}

Reprenons notre exemple myX:

myX=list(c(1,6),
         c(33,NA,2,15,7,4,5),
         c(3))

La fonction “walk()” permet d’itérer les “effets secondaires” d’une fonction… ici 3 messages/nuages de fumée différents:

walk(myX,print_moyenne)
[1] "la moyenne est de 3.5"
[1] "la moyenne est de 11"
[1] "la moyenne est de 3"

purrr: boucle sur 2 arguments principaux avec map2()

Considérons maintenant une fonction à laquelle on voudrait adjoindre “deux rampes d’approvisionnement”. On va prendre pour exemple la fonction cor() qui calcule un coefficient de corrélation linéaire entre deux vecteurs:

cor(c(1,5,6,9),c(0.3,0.8,0.9,1.2))
[1] 0.9976344
myX=list(c(2,5,6,7,1,0,1,1),
       c(5,1,6,NA,2),
       c(2,5,8,6))
myY=list(c(5,8,9,7,22,1,9,9),
       c(2,8,9,5,4),
       c(8,9,8,7))

On veut itérer la fonction cor() en considérant chaque élément de x ET de y (le i-ième élément de x correspondant au i-ième élément de y…). On peut faire cela en considérant la fonction map2().

map2(myX,myY,cor)
[[1]]
[1] -0.07264618

[[2]]
[1] NA

[[3]]
[1] -0.1632993

purrr: p arguments principaux avec pmap()

Enfin, on peut généraliser ce principe à \(p>2\) arguments principaux:

Dans ce cas, les p listes d’arguments sont fournis comme une liste, i.e. on passe à pmap() un argument .l qui est une liste de p éléments qui sont eux-mêmes des listes

l <- list(a = list(1, 1, 1),
          b = list(10, 20, 30),
          c = list(100, 200, 300))
f=function(a, b, c){
  return((a + b) * c)
}
pmap(l,f)
[[1]]
[1] 1100

[[2]]
[1] 4200

[[3]]
[1] 9300

purrr: fonction ou formule

Avec purrr, il est très simple d’itérer une fonction f(arg1,arg2,arg3, ...) sur son premier argument.

C’est ce qu’on fait avec:

map(.x=liste_arg1,
    .f=f)

Mais que faire quand on souhaite itérer une fonction sur un argument autre que le premier?

On va pour ce faire utiliser une formule (~):

map(.x=liste_arg2,
    .f=~f(arg1=___,arg2=.x, arg3=___,...))

ou dans un style plus implicite (en omettant le nom des arguments):

map(liste_arg2,
    ~f(___,.x,___,...))

Imaginons par exemple que l’on souhaite utiliser la fonction str_detect(string,pattern) et l’itérer sur son argument pattern.

string="Le Dalaï Lama a la dalle à Lima et casse la dalle à Dallas."
patterns=c("[Ll]a","[Mm]a", "[Dd]a")

map(patterns, ~str_extract_all(string,.x))
[[1]]
[[1]][[1]]
[1] "la" "La" "la" "la" "la"


[[2]]
[[2]][[1]]
[1] "ma" "ma"


[[3]]
[[3]][[1]]
[1] "Da" "da" "da" "Da"

purrr: Echapper aux erreurs avec safely()

Jusqu’ici, tout va bien: j’ai choisi pour commencer des exemples d’application simples, où tout se déroule comme sur des roulettes.

Mais avec les fonctions de purrr comme avec une boucle for, il est particulièrement problématique qu’une des itérations génère une erreur, car même si cette erreur ne concerne qu’un élément parmi peut-être beaucoup d’autres, son occurrence stoppe l’exécution de toutes les itérations.

Considérons ainsi l’exemple suivant:

myX=list(c(2,5,6,7,1,0,1,1),
         c())
myY=list(c(5,8,9,7,22,1,9,9),
         c())
map2(myX,myY,cor)
Error in .f(.x[[i]], .y[[i]], ...): supply both 'x' and 'y' or a matrix-like 'x'

Aïe… La fonction cor() n’accepte pas que ses arguments x ou y soient vides et génère ainsi une erreur sur la deuxième itération. On n’obtient donc de résultat ni pour cette itération, ni pour les autres…

purrr:: Echapper aux erreurs: safely()

Pour remédier à cela, outre modifier la fonction cor() pour qu’elle puisse s’adapter à ce cas particulier, il est possible d’utiliser la fonction safely():

map2(myX,myY,safely(cor))
[[1]]
[[1]]$result
[1] -0.07264618

[[1]]$error
NULL


[[2]]
[[2]]$result
NULL

[[2]]$error
<simpleError in .f(...): supply both 'x' and 'y' or a matrix-like 'x'>

Dans ce cas, pour chaque élément d’input, on obtient un élément d’output qui contient deux choses:

  • un élément result (qui correspond au résultat souhaité et est dans ce cas vide pour le troisième élément, problématique)
  • un élément error (qui correspond à l’éventuel message d’erreur généré et est dans ce cas vide pour les deux premiers éléments, qui ne génèrent pas d’erreur)

purrr et dplyr

Comment intégrer l’usage de purrr dans la manipulation de tableaux avec purrr? Principalement en combinant dplyr::mutate() et purrr::map().

Voici un exemple, où l’on utilise la fonction emo::ji() pour transformer des mots en emojis (=des caractères spéciaux).

tib
# A tibble: 3 × 1
  mot   
  <chr> 
1 monkey
2 heart 
3 banana

Voici comment procéder:

result=tib %>% 
  mutate(emoji=map(mot,emo::ji))
result
# A tibble: 3 × 2
  mot    emoji      
  <chr>  <list>     
1 monkey <emoji [1]>
2 heart  <emoji [1]>
3 banana <emoji [1]>

On a ici produit une table contenant une “colonne liste” (ou list-column). Voyez plutôt:

result$emoji
[[1]]
🐒 

[[2]]
❤️ 

[[3]]
🍌 

purrr et dplyr: éviter les list-columns

Dans le cas illustré dans la diapositive précédente, le fait que la nouvelle colonne soit une liste plutôt qu’un vecteur ordinaire est plus une gêne qu’autre chose…

De fait, nous aurions mieux fait d’utiliser map_chr() (un emoji étant un caractère spécial, un vecteur d’emoji est bien un vecteur de type “chr”).

result=tib %>% 
  mutate(emoji=map_chr(mot,emo::ji))
result
# A tibble: 3 × 2
  mot    emoji
  <chr>  <chr>
1 monkey 🐒   
2 heart  ❤️    
3 banana 🍌   

purrr et dplyr: gérer les list-columns

Dans d’autres cas, la fonction qui nous intéresse a pour sortie autre chose qu’une valeur unitaire et dans ce cas il est nécessaire de “gérer” des listes et des list-columns pour essayer de revenir à des formats les plus simples et “plats” possibles.

Ici par exemple, on a utilisé safely(emo::ji)) pour éviter que l’erreur générée par l’absence d’un emoji représentant une loutre ne bloque totalement l’exécution du programme:

res1=tibble::tibble(mot=c("monkey","lion","otter","bear")) %>%
  mutate(emoji=map(mot,safely(emo::ji)))
res1
# A tibble: 4 × 2
  mot    emoji           
  <chr>  <list>          
1 monkey <named list [2]>
2 lion   <named list [2]>
3 otter  <named list [2]>
4 bear   <named list [2]>

On peut commencer à “réaplatir” la table en ne sélectionnant que la partie “result” de res1:

res2=res1 %>% 
  mutate(emoji=map(emoji,"result")) 
res3=res2%>% 
  mutate(emoji=map_chr(emoji,~ifelse(is.null(.x),NA,.x)))
res3
# A tibble: 4 × 2
  mot    emoji
  <chr>  <chr>
1 monkey 🐒   
2 lion   🦁   
3 otter  <NA> 
4 bear   🐻   

purrr et dplyr: récupérer l’ensemble des sous-éléments selon leur nom

Attention, notez bien la différence de syntaxe:

map(x,"pouetpouet")

cherche à extraire un élément appelé “pouetpouet” des différents éléments de la liste x.

En revanche

map(x,pouetpouet)

cherche à appliquer une fonction appelée pouetpouet aux différents éléments de la liste x.

Listes et nested tibbles

Considérons la fonction it_likes_what(), qui à un nom d’animal (et un seul) associe une phrase-emoji représentant le ou les aliment(s) qu’il aime:

it_likes_what("bear")
# A tibble: 3 × 1
  emojis
  <chr> 
1 🐻❤️🍯 
2 🐻❤️🍓 
3 🐻❤️🐟 
ntib=tibble::tibble(who=c("tiger","monkey","bear")) %>%
  mutate(emojis=map(.x=who,it_likes_what))
ntib
# A tibble: 3 × 2
  who    emojis          
  <chr>  <list>          
1 tiger  <tibble [2 × 1]>
2 monkey <tibble [1 × 1]>
3 bear   <tibble [3 × 1]>

Pour aplatir la table, il ne suffit pas d’utiliser map_df() (qui ne fonctionne pas ici car la table en sortie peut comprendre n>1 lignes).

Pour aplatir cette table, on peut en fait faire appel à la fonction tidyr::unnest():

tib=ntib %>% 
  unnest(cols=emojis)
tib
# A tibble: 6 × 2
  who    emojis
  <chr>  <chr> 
1 tiger  🐯❤️🥩 
2 tiger  🐯❤️🐟 
3 monkey 🐒❤️🍌 
4 bear   🐻❤️🍯 
5 bear   🐻❤️🍓 
6 bear   🐻❤️🐟 

Listes et nested tibbles

En utilisant mutate + map + une fonction qui produisait un jeu de données de taille variable en sortie, on avait produit une nested tibble (qu’on avait ré-aplati à l’aide de tidyr::unnest()).

On peut aussi être amené à vouloir produire une nested tibble pour itérer une fonction via map, si la fonction qu’on veut appliquer doit avoir pour entrée principale un jeu de données.

Dans ce cas on peut utiliser la fonction tidyr::nest()

birds %>%
  group_by(color) %>% nest(family=adults) %>%
  mutate(family=map(family,lay_eggs)) %>%
  mutate(family=map(family,hatch_eggs)) %>%
  unnest(cols=family)

On récapitule!

On a parlé de quoi, déjà?
  • Ecriture de fonctions
  • Structures conditionnelles (if) et messages/warnings
  • Vectorisation et boucles implicites
  • Itération de fonctions avec purrr
    • itération par map(), map2(), map_dbl(), etc.
    • pas d’arrêt lié aux erreurs: safely()
    • usage de formules si itération sur n-ième argument
  • Listes et nested tibbles
    • extraction d’éléments de listes via leur position ou leur nom
    • nesting/unnesting de tibbles via les fonctions de tidyr