5.4 Remaniement des données

Dans le module 4, vous avez réalisé vos premiers remaniements de données dans le cadre des graphiques en barres. Nous ne nous sommes pas étendu sur les fonctions utilisées à cette occasion. Le remaniement des données est une étape cruciale en analyse des données et il faut en maîtriser au moins les principaux outils. Heureusement, il est déjà possible d’aller loin en combinant une petite dizaine d’outils simples. Les cinq principaux (les plus utilisés) dans l’approche Tidyverse utilisée ici sont :

  • sélectionner des colonnes au sein d’un jeu de données avec select()/sselect()

  • filtrer des lignes dans un jeu de données avec filter()/sfilter()

  • calculer de nouvelles variables dans un jeu de données avec mutate()/smutate()

  • regrouper les données au sein d’un tableau avec group_by()/sgroup_by()

  • résumer les variables d’un jeu de données avec summarise()/ssummarise()

Ces outils provenant du package {dplyr} et de {collapse} et {svBase} pour les versions dont le nom commence par un “s” supplémentaire sont décrits en détails dans le chapitre 4 de “R for Data Science (2e)”. Nous allons nous familiariser avec eux via une approche pratique sur base d’exemples concrets.

urchin <- read("urchin_bio", package = "data.io", lang = "fr")
rmarkdown::paged_table(urchin)

5.4.1 select()/sselect()

Lors de l’utilisation de vos jeux de données, vous serez amené à réduire vos données en sous-tableau ne reprenant qu’un sous-ensemble des variables initiales. select() et sselect() effectuent cette opération18 :

La fonction select() est une fonction du “tidyverse” (nous l’appellerons désormais une fonction tidy) qui a un comportement particulier dans R. Une fonction relativement équivalente, mais “non-tidy” est sselect(). Le préfixe “s” est là pour indiquer que c’est une fonction “speedy”. Ces fonctions sont similaire à leur équivalents tidy, mais généralement plus rapides. Les fonctions tidy, ont cependant d’autres qualités. Notamment, elles fonctionneront aussi en grande partie sur des bases de données. Ces deux fonctions sont souvent interchangeables, mais pas toujours. Dans SciViews::R, il est très important de faire la distinction entre les deux types de fonctions et leurs particularités.

5.4.1.1 Fonctions “speedy”

Pour lister les fonctions speedy disponibles, vous pouvez faire :

list_speedy_functions()
#  [1] "sadd_count"         "sadd_tally"         "sarrange"          
#  [4] "sbind_cols"         "sbind_rows"         "scount"            
#  [7] "sdistinct"          "sdrop_na"           "sextract"          
# [10] "sfill"              "sfilter"            "sfilter_ungroup"   
# [13] "sfull_join"         "sgroup_by"          "sinner_join"       
# [16] "sleft_join"         "smutate"            "smutate_ungroup"   
# [19] "spivot_longer"      "spivot_wider"       "spull"             
# [22] "srename"            "srename_with"       "sreplace_na"       
# [25] "sright_join"        "sselect"            "sseparate"         
# [28] "sseparate_rows"     "ssummarise"         "stally"            
# [31] "stransmute"         "stransmute_ungroup" "suncount"          
# [34] "sungroup"           "sunite"

Vous pouvez constater que le nom de toutes ces fonctions est préfixé d’un “s”. En fait, elles sont l’équivalent de fonctions du même nom, mais sans le préfixe “s” qui sont définies dans le tidyverse, voir les fonctions “tidy” ci-dessous. Ces fonctions speedy sont rendues disponibles dans SciViews::R afin d’accélérer le calcul sur de gros jeux de données. Vous nez devez pas prendre de précautions particulières pour les utiliser, si ce n’est de vous rappeler de ne pas mélanger les fonctions speedy et tidy dans une même instruction R.

Par exemple, si vous voulez utiliser sselect pour extraire du tableau urchin un sous-tableau qui ne reprend que les variables (= colonnes) origin, height et skeleton, vous utiliserez :

urchin2 <- sselect(urchin, origin, height, skeleton)
urchin2[1:6, ] # Récupérer les 6 premières lignes de urchin2 et l'imprime = head()
# # A data.table: 6 x 3
# # Language:     fr
#   origin  height skeleton
#   <fct>    <dbl>    <dbl>
# 1 Fishery    5      0.179
# 2 Fishery    5.7    0.188
# 3 Fishery    5.2    0.235
# 4 Fishery    4.6    0.063
# 5 Fishery    4.8   NA    
# 6 Fishery    5     NA

Vous voyez que vous obtenez ici un data.table qui ne contient plus que deux colonnes. Vous pouvez aussi le voir avec class() (cet objet est un data.table qui lui-même est également un data.frame, on dit qu’il hérite de la classe data.frame) :

class(urchin2)
# [1] "data.table" "data.frame"

5.4.1.2 Fonctions “tidy”

Pour lister les fonctions tidy disponibles, vous pouvez faire :

list_tidy_functions()
#  [1] "add_count"         "add_tally"         "arrange"          
#  [4] "bind_cols"         "bind_rows"         "count"            
#  [7] "distinct"          "drop_na"           "extract"          
# [10] "fill"              "filter"            "filter_ungroup"   
# [13] "full_join"         "group_by"          "inner_join"       
# [16] "left_join"         "mutate"            "mutate_ungroup"   
# [19] "pivot_longer"      "pivot_wider"       "pull"             
# [22] "rename"            "rename_with"       "replace_na"       
# [25] "right_join"        "select"            "separate"         
# [28] "separate_rows"     "summarise"         "tally"            
# [31] "transmute"         "transmute_ungroup" "uncount"          
# [34] "ungroup"           "unite"

Ces fonctions ont toutes un comportement commun et sont conçues pour travailler sur un tableau de données. Dans R, un tel tableau de données peut être encodé dans trois types d’objets :

  • le data.frame est l’objet de R de base,
  • le tibble est un data.frame particulier implémenté dans le tidyverse, et
  • le data.table est implémenté dans le package {data.table} et permet de réaliser des remaniement de tableaux plus rapidement que les deux précédents.

Dans SciViews::R, l’objet data.table est utilisé par défaut. Les fonctions speedy sont écrites pour utiliser cet objet de manière native comme nous venons de le découvrir plus haut. Il existe une surcouche des fonctions tidy dans le package {dtplyr} qui fonctionne aussi sur des objets de classe data.table. Cependant, à des fins d’optimisation des calculs, les fonctions tidy appliquées à des data.tables via {dtplyr} ne renvoient pas directement le résultat. En fait, elles créent un objet particulier qui contient la ou les suites d’opérations à effectuer. Par exemple, la sélection des colonnes origin et weight du tableau urchin à l’aide de select() donne ceci :

urchin2 <- select(urchin, origin, height, skeleton)
class(urchin2)
# [1] "dtplyr_step_subset" "dtplyr_step"

Cette fois-ci, l’objet obtenu est un dtplyr_step_subset qui hérite d’un dtplyr_step (dans RStudio, cet objet apparaîtra comme une list). Il n’est pas possible de faire grand chose en dehors de l’utilisation de fonctions tidy sur cet objet. Ainsi, vous obtenez une erreur avec ceci :

urchin2[1:6, ]
# Error in urchin2[1:6, ]: nombre de dimensions incorrect

En fait, avec les fonctions tidy, il faut prendre soin de collecter les résultats à la fin si on travaille avec des objets de classe data.table. Il y a plusieurs façons d’y arriver.

  • Utiliser l’opérateur d’assignation alternative %<-% au lieu de <- qui collecte automatiquement le résultat.
urchin2 %<-% select(urchin, origin, height, skeleton)
class(urchin2)
# [1] "data.table" "data.frame"
  • Utiliser collect_dtx() de manière explicite.
urchin2 <- collect_dtx(select(urchin, origin, height, skeleton))
class(urchin2)
# [1] "data.table" "data.frame"

Notez que vous pouvez utiliser l’assignation alternative à peu près n’importe quand, et même aussi avec les fonctions speedy.

Les fonctions select() et sselect() permettent aussi de sélectionner des colonnes par leur position dans le tableau :

urchin2 <- sselect(urchin, c(1, 4, 14))
head(urchin2)
# # A data.table: 6 x 3
# # Language:     fr
#   origin  height skeleton
#   <fct>    <dbl>    <dbl>
# 1 Fishery    5      0.179
# 2 Fishery    5.7    0.188
# 3 Fishery    5.2    0.235
# 4 Fishery    4.6    0.063
# 5 Fishery    4.8   NA    
# 6 Fishery    5     NA

Attention : la fonction sselect() n’est pas toujours utilisable pour remplacer la fonction select(). Par exemple, l’instruction contains() est utile pour sélectionner les variables dont le nom contient une chaîne de caractères. Mais cette forme n’est pas comprise par sselect().

urchin3 %<-% select(urchin, origin, contains("weight"))
head(urchin3)
# # A data.table: 6 x 3
#   origin  buoyant_weight weight
#   <fct>            <dbl>  <dbl>
# 1 Fishery             NA  0.522
# 2 Fishery             NA  0.642
# 3 Fishery             NA  0.734
# 4 Fishery             NA  0.370
# 5 Fishery             NA  0.610
# 6 Fishery             NA  0.610

Idem pour ends_with() et d’autres opérateurs du genre rassemblés dans la page d’aide ?select_helpers :

urchin4 %<-% select(urchin, ends_with("ht"))
head(urchin4)
# # A data.table: 6 x 3
#   height buoyant_weight weight
#    <dbl>          <dbl>  <dbl>
# 1    5               NA  0.522
# 2    5.7             NA  0.642
# 3    5.2             NA  0.734
# 4    4.6             NA  0.370
# 5    4.8             NA  0.610
# 6    5               NA  0.610

5.4.2 filter()/sfilter()

De même que toutes les colonnes d’un tableau ne sont pas forcément utiles, il est souvent nécessaire de sélectionner les lignes en fonction de critères particuliers pour restreindre l’analyse à une sous-population données, ou pour éliminer les cas qui ne correspondent pas à ce que vous voulez. filter() est une fonction tidy qui effectue ce travail. L’équivalent speedy est sfilter(). Repartons du jeu de données urchin simplifié à trois variables (urchin2).

rmarkdown::paged_table(urchin2)

Si vous voulez sélectionner uniquement un niveau "lvl" d’une variable facteur fact, vous pouvez utiliser un test de condition “égal à” (==) : fact == "lvl". Notez bien le double signe égal ici, et n’oubliez pas d’indiquer le niveau entre guillemets. De même, vous pouvez sélectionner tout sauf ce niveau avec l’opérateur “différent de” (!=). Les opérateurs “plus petit que” (<) ou “plus grand que” (>) fonctionnent sur les chaines de caractères selon une logique d’ordre alphabétique, donc, "a" < "b"19.

Comparaison Opérateur Exemple
Égal à == fact == "lvl"
Différent de != fact != "lvl"
Plus grand que > fact > "lvl"
Plus grand ou égal à >= fact >= "lvl"
Plus petit que < fact < "lvl"
Plus petit ou égale à <= fact <= "lvl"

En version tidy avec %<-% :

# Tous les oursins sauf ceux issus de la pêche
urchin_sub1 %<-% filter(urchin2, origin != "Fishery")
rmarkdown::paged_table(urchin_sub1)

… et le même en version speedy, l’assignation normale ne pose pas de problème ici (mais l’assignation alternative %<-% fonctionnera aussi, elle n’est juste pas nécessaire) :

# Tous les oursins sauf ceux issus de la pêche
urchin_sub1 <- sfilter(urchin2, origin != "Fishery")
rmarkdown::paged_table(urchin_sub1)

Vous pouvez aussi utiliser une variable numérique pour filtrer les données. Les comparaisons précédentes sont toujours applicables, sauf que cette fois vous faites porter la comparaison par rapport à une constante (ou par rapport à une autre variable numérique).

# Oursins plus hauts que 20mm
urchin_sub2 <- sfilter(urchin2, height > 20)
rmarkdown::paged_table(urchin_sub2)

Vous pouvez combiner différentes comparaisons avec les opérateurs “et” (&) et “ou” (|) :

# Oursins plus hauts que 20 mm ET issus d'élevage ("Farm")
urchin_sub3 <- sfilter(urchin2, height > 20 & origin == "Farm") 
rmarkdown::paged_table(urchin_sub3)

Avec des variables facteurs composées de nombreux niveaux comme on peut en retrouver dans le jeu de données zooplankton du package {data.io}, vous pouvez être amené à sélectionner plusieurs niveaux au sein de cette variable. L’opérateur %in% permet d’indiquer que nous souhaitons garder tous les niveaux qui sont dans une liste. Il n’existe pas d’opérateur %not_in%, mais il suffit d’inverser le résultat en précédent l’instruction de ! pour obtenir cet effet. Par exemple, !letters %in% c("a", "d", "f") conserve toutes les lettres sauf a, d et f. L’opérateur ! est d’ailleurs utilisable avec toutes les comparaisons pour en inverser les effets. Ainsi, !x == 1 est équivalent à x != 1.

zooplankton <- read("zooplankton", package = "data.io", lang  = "FR")
# Garde uniquement les copépodes (correspondant à 4 groupes distincts)
copepoda <- sfilter(zooplankton,
  class %in% c("Calanoïde", "Cyclopoïde",  "Harpacticoïde", "Poecilostomatoïde"))
rmarkdown::paged_table(sselect(copepoda, ecd:perimeter, class))

Enfin, la détection et l’élimination de lignes contenant des valeurs manquantes (encodées comme NA) est spéciale. En effet, vous ne pouvez pas écrire quelque chose comme x == NA car ceci se lit comme “x est égale à … je ne sais pas quoi”, ce qui renvoie à son tour NA pour toutes les comparaisons quelles qu’elles soient. Vous pouvez utiliser la fonction spécialement prévue pour ce test is.na(). Ainsi, is.na(x) effectue en réalité ce que vous voulez et peut être utilisée à l’intérieur de filter() ou sfilter(). Cependant, il existe une fonction spécialement prévue pour débarrasser les tableaux des lignes contenant des valeurs manquantes : drop_na() ou sdrop_na(). Si vous spécifier des noms de colonnes (facultatifs), la fonction ira rechercher les valeurs manquantes uniquement dans ces colonnes-là, sinon, elle scrutera tout le tableau (mais faites très attention à ne pas utiliser inconsidérément cette fonction sans spécifier les colonnes : éliminer des individus sur base de valeurs manquantes dans des colonnes que vous n’utilisez pas ensuite est idiot).

urchin_sub4 <- sdrop_na(urchin)
rmarkdown::paged_table(urchin_sub4)
À vous de jouer !
h5p

5.4.3 mutate()/smutate()

La fonction tidy mutate() permet de calculer de nouvelles variables (si le nom fourni n’existe pas encore dans le jeu de donnée) ou écrase les variables existantes de même nom. La fonction speedy équivalente est smutate(). Repartons du jeu de données urchin. Pour calculer de nouvelles variables, vous pouvez employer :

  • les opérateurs arithmétiques :
    • addition : +
    • soustraction : -
    • multiplication : *
    • division : /
    • exposant : ^
    • modulo (reste lors d’une division entière) : %%
    • division entière : %/%
urchin <- smutate(urchin, 
  sum_skel  =  lantern + spines + test, 
  ratio     = sum_skel / skeleton,
  skeleton2 = skeleton^2)
rmarkdown::paged_table(sselect(urchin, skeleton:spines, sum_skel:skeleton2))
  • les fonctions mathématiques :
    • ln() ou log() (logarithme népérien), lg() ou log10() (logarithme en base 10)
    • ln1p() ou log1p() (logarithme népérien de x + 1), ou lg1p() (logarithme en base 10 de x + 1)
    • exp() (exponentielle, ex) et expm1() (ex - 1)
    • sqrt() (racine carrée)
    • sin(), cos(), tan()
urchin <- smutate(urchin,
  skeleton_log  = log(skeleton), 
  skeleton_sqrt = sqrt(skeleton))
rmarkdown::paged_table(sselect(urchin, skeleton, skeleton_log, skeleton_sqrt))

La fonction tidy transmute() ou stransmute() effectue la même opération, mais en plus, elle laisse tomber les variables d’origine pour ne garder que les nouvelles variables calculées.

À vous de jouer !
h5p

5.4.4 group_by()/sgroup_by()

La fonction tidy group_by() ou la fonction speedy sgroup_by() ne change rien dans le tableau lui-même, mais ajoute une annotation qui indique que les calculs ultérieurs devront être effectués sur des sous-ensembles du tableau en parallèle. Ceci est surtout utile avec summarise()/ssummarise() (voir ci-dessous), mais aussi avec mutate()/smutate() pour faire des transformations par groupes. Pour annuler le regroupement, il suffit d’utiliser ungroup()/sungroup().

À noter que toutes les fonctions qui collectent les résultats, à savoir as_dtx(), collect_dtx() et les assignations alternatives %<-% et %->% dégroupent également automatiquement les données. Ceci est voulu afin d’éviter que l’on oublie plus tard que l’objet a un regroupement enregistré et que vous ne réalisiez par la suite des calculs non voulus (par groupes alors que vous pensez travailler individu). Il faut toujours préciser les groupes aussi proche que possible de leur utilisation, et dégrouper éventuellement à la fin lorsqu’on n’en a plus besoin.

urchin_by_orig <- group_by(urchin, origin)
head(urchin_by_orig)
# Source: local data table [6 x 24]
# Groups: origin
# Call:   head(`_DT8`, n = 6L)
# 
#   origin  diameter1 diameter2 height buoyant_weight weight solid_parts
#   <fct>       <dbl>     <dbl>  <dbl>          <dbl>  <dbl>       <dbl>
# 1 Fishery       9.9      10.2    5               NA  0.522       0.478
# 2 Fishery      10.5      10.6    5.7             NA  0.642       0.589
# 3 Fishery      10.8      10.8    5.2             NA  0.734       0.677
# 4 Fishery       9.6       9.3    4.6             NA  0.370       0.344
# 5 Fishery      10.4      10.7    4.8             NA  0.610       0.559
# 6 Fishery      10.5      11.1    5               NA  0.610       0.551
# # … with 17 more variables: integuments <dbl>, dry_integuments <dbl>,
# #   digestive_tract <dbl>, dry_digestive_tract <dbl>, gonads <dbl>,
# #   dry_gonads <dbl>, skeleton <dbl>, lantern <dbl>, test <dbl>, spines <dbl>,
# #   maturity <int>, sex <fct>, sum_skel <dbl>, ratio <dbl>, skeleton2 <dbl>,
# #   skeleton_log <dbl>, skeleton_sqrt <dbl>
# 
# # Use as.data.table()/as.data.frame()/as_tibble() to access results

Noter la seconde ligne ci-dessus : Groups: origin qui indique quels regroupements sont actifs dans le tableau.

# collect_dtx() ou %<-% / %->% dégroupent automatiquement
urchin_collected %<-% urchin_by_orig
urchin_collected
# # A data.table: 421 x 24
#    origin  diameter1 diameter2 height buoyant_weight weight solid_parts
#    <fct>       <dbl>     <dbl>  <dbl>          <dbl>  <dbl>       <dbl>
#  1 Fishery       9.9      10.2    5               NA  0.522       0.478
#  2 Fishery      10.5      10.6    5.7             NA  0.642       0.589
#  3 Fishery      10.8      10.8    5.2             NA  0.734       0.677
#  4 Fishery       9.6       9.3    4.6             NA  0.370       0.344
#  5 Fishery      10.4      10.7    4.8             NA  0.610       0.559
#  6 Fishery      10.5      11.1    5               NA  0.610       0.551
#  7 Fishery      11        11      5.2             NA  0.672       0.605
#  8 Fishery      11.1      11.2    5.7             NA  0.703       0.628
#  9 Fishery       9.4       9.2    4.6             NA  0.413       0.375
# 10 Fishery      10.1       9.5    4.7             NA  0.449       0.398
# # … with 411 more rows, and 17 more variables: integuments <dbl>,
# #   dry_integuments <dbl>, digestive_tract <dbl>, dry_digestive_tract <dbl>,
# #   gonads <dbl>, dry_gonads <dbl>, skeleton <dbl>, lantern <dbl>, test <dbl>,
# #   spines <dbl>, maturity <int>, sex <fct>, sum_skel <dbl>, ratio <dbl>,
# #   skeleton2 <dbl>, skeleton_log <dbl>, skeleton_sqrt <dbl>
# Excepté pour les commentaires, ces deux objets sont identiques
comment(urchin_collected) <- comment(urchin)
identical(urchin_collected, urchin)
# [1] TRUE

5.4.5 summarise()/ssummarise()

Si vous voulez résumer vos données (calcul de la moyenne, médiane, etc.), vous pouvez réaliser ceci sur une variable en particulier avec les fonctions dédiées. Par exemple mean(urchin$skeleton) renvoie la masse moyenne de squelette pour tous les oursins (ce calcul donne NA dès qu’il y a des valeurs manquantes, mais l’argument na.rm = TRUE permet d’obtenir un résultat en ne tenant pas compte de ces données manquantes : mean(urchin$skeleton, na.rm = TRUE)). Cela devient vite laborieux s’il faut réitérer ce genre de calcul sur plusieurs variables du jeu de données, et assembler ensuite les résultats dans un petit tableau synthétique. D’autant plus, s’il faut séparer d’abord le jeu de données en sous-groupes pour faire ces calculs. La fonction tidy summarise(), ou son équivalent speedy ssummarise() reporte automatiquement ces calculs, en tenant compte des regroupements proposés via group_by()/sgroup_by().

tooth <- read("ToothGrowth", package = "datasets", lang = "fr")
tooth_summary <- ssummarise(tooth,
  "moyenne" = mean(len), 
  "minimum" = min(len), 
  "médiane" = median(len), 
  "maximum" = max(len))
knitr::kable(tooth_summary, digits = 2,
  caption = "Allongement des dents chez des cochons d'Inde recevant de l'acide ascorbique.")
Tableau 5.6: Allongement des dents chez des cochons d’Inde recevant de l’acide ascorbique.
moyenne minimum médiane maximum
18.81 4.2 19.25 33.9

Voici les mêmes calculs, mais effectués séparément pour les deux types de supplémentations alimentaires. Pour se faire, nous allons combiner deux étapes en une : un (s)group_by() suivi d’un (s)summarise() (en fait, imbriqué dans …).

tooth_summary2 <- ssummarise(sgroup_by(tooth, supp),
  "moyenne" = mean(len), 
  "minimum" = min(len), 
  "médiane" = median(len), 
  "maximum" = max(len))
knitr::kable(tooth_summary2, digits = 2,
  caption = "Allongement des dents chez des cochons d'Inde en fonction du supplément jus d'orange (OJ) ou vitamine C (VC).")
Tableau 5.7: Allongement des dents chez des cochons d’Inde en fonction du supplément jus d’orange (OJ) ou vitamine C (VC).
supp moyenne minimum médiane maximum
OJ 20.66 8.2 22.7 30.9
VC 16.96 4.2 16.5 33.9
Pièges et astuces
  • Tout comme lors de réalisation d’une boite de dispersion, vous devez être particulièrement vigilant au nombre d’observation par sous-groupe. Pensez toujours à ajoutez à chaque tableau de résumé des données, le nombre d’observations par sous-groupe grâce à la fonction fn().
tooth_summary2 <- ssummarise(sgroup_by(tooth, supp),
  "moyenne" = mean(len), 
  "minimum" = min(len), 
  "médiane" = median(len), 
  "maximum" = max(len),
  "n"       = fn(len))
knitr::kable(tooth_summary2, digits = 2,
  caption = "Allongement des dents chez des cochons d'Inde en fonction du supplément jus d'orange (OJ) ou vitamine C (VC).")
Tableau 5.8: Allongement des dents chez des cochons d’Inde en fonction du supplément jus d’orange (OJ) ou vitamine C (VC).
supp moyenne minimum médiane maximum n
OJ 20.66 8.2 22.7 30.9 30
VC 16.96 4.2 16.5 33.9 30

Si vous souhaitez connaitre le nombre d’observations non manquantes dans votre jeu de données, utilisez fnobs() au lieu de fn(). Vous pouvez naturellement utiliser les deux simultanément.

urchin_skeleton_summary <- ssummarise(sgroup_by(urchin, origin),
  "moyenne" = mean(skeleton, na.rm = TRUE),
  "n"       = fn(skeleton),    # Nombre total de cas
  "n obs"   = fnobs(skeleton)) # Nombre de cas hors valeurs manquantes
knitr::kable(urchin_skeleton_summary, digits = 2,
  caption = "Moyenne, nombre de cas et nombre d'observations hors valeurs manquantes pour la masse du squelette (en g) d'oursins d'élevage et de pêcheries.")
Tableau 5.9: Moyenne, nombre de cas et nombre d’observations hors valeurs manquantes pour la masse du squelette (en g) d’oursins d’élevage et de pêcheries.
origin moyenne n n obs
Farm 6.88 218 122
Fishery 7.21 203 136

Dans le cas présent, le nombre d’observations utilisable est nettement inférieur au nombre total de cas dans le tableau. Cette distinction entre fn() et fnobs() est donc très importante. Ne la perdez pas de vue. De plus, si vous n’utilisez pas na.rm = TRUE dans les fonctions comme mean(), median(), … vous aurez des NA partout dans votre tableau résumé. D’un autre côté, soyez bien conscient qu’avec na.rm = TRUE, c’est le nombre d’observations hors valeurs manquantes qui est utilisé pour ces calculs !

Les fonctions fn() et fnobs() ne sont pas définies dans le tidyverse. Ce sont des fonctions d’une autre famille appelée dans SciViews::R des fonctions “fstat”. Dans tidyverse, on vous fera utiliser la fonction n() sans argument qui est l’équivalent de fn(). Il n’y a pas d’équivalent direct de fnobs() et il faut ruser avec du code comme sum(!is.na(skeleton)) pour obtenir ce résultat, ce qui n’est pas pratique. La fonction n() n”est pas utilisable avec les fonctions speedy, et d’une manière générale, préférez-lui fn() qui est plus logique et qui fonctionne partout.

  • summarise() calcule ses variables dans le même environnement que le tableau de départ. Donc, si vous utiliser des noms de colonnes qui existent déjà, elles seraient écrasées par le résultat du calcul. Avec un data.table, ceci n’est pas permis (le code fonctionnera pourtant dans un autre contexte avec un data.frame, c’est pourquoi nous convertissons d’abord tooth avec as_dtf()). Voici un exemple concret :
tooth_summary3 %<-% summarise(as_dtf(tooth),
  "len" = mean(len), # Notez le même nom à gauche et à droite du = (len)
  "len_sd" = sd(len))
knitr::kable(tooth_summary3, digits = 2,
  caption = "Exemple de résumé des données erroné à cause de l'écrasement de la variable `len`.")
Tableau 5.10: Exemple de résumé des données erroné à cause de l’écrasement de la variable len.
len len_sd
18.81 NA

L’écart type est… NA ??? Pour comprendre ce qui s’est passé, il faut lire la transformation réalisée par summarise() ligne après ligne :

  • la moyenne de la variable len est placée dans … len. Donc ici, nous écrasons la variable initiale de 60 observations par un nombre unique : la moyenne,
  • l’écart type de len est ensuite calculé. Attendez une minute, de quel len s’agit-il ici ? Et bien la dernière calculée, soit celle qui contient une seule valeur, la moyenne. Or, sd() nécessite au moins deux valeurs pour que l’écart type puisse être calculé, sinon, NA est renvoyé. C’est encore heureux ici, car nous aurions pu faire un calcul qui renvoie un résultat, … mais qui n’est pas celui qu’on croit !

Conclusion : ne nommez jamais vos variables créées avec summarise() exactement comme les variables de votre tableau en entrée.

Toutefois, ssummarise() fonctionne différemment et n’a pas de problèmes dans ce cas-là :

tooth_summary4 <- ssummarise(tooth,
  "len" = mean(len), # Notez le même nom à gauche et à droite du = (len)
  "len_sd" = sd(len))
knitr::kable(tooth_summary4, digits = 2,
  caption = "Exemple de résumé des données correct avec `fsummarise()`.")
Tableau 5.11: Exemple de résumé des données correct avec fsummarise().
len len_sd
18.81 7.65

Faites bien attention : les couples de fonctions avec et sans “s” comme select()/sselect(), mutate()/smutate() ou summarise()/ssummarise() se ressemblent très fort au niveau de leur usage. On pourrait donc être tenté de croire qu’elles sont parfaitement interchangeables. La plupart du temps, c’est le cas. Cependant, elles fonctionnement radicalement différemment en interne et des différences existent, sont connues, et ne sont pas des bugs. Vérifiez toujours votre code !

5.4.5.1 Résumé avec les fonctions “fstat”

R propose de base une série de fonctions qui calculent des descripteurs statistiques classiques tels que la moyenne mean(), la médiane median(), la variance var(), etc. Toutes ces fonctions ont un argument na.rm qui prend la valeur FALSE par défaut. S’il y a une valeur manquante ou plus, le calcul renverra alors NA.

vec <- c(5, 3, 8, NA, 4)
mean(vec)
# [1] NA

Vous devez indiquer na.rm = TRUE si vous voulez quand même calculer la moyenne sur ce vecteur vec en utilisant uniquement les observations (non manquantes) :

mean(vec, na.rm = TRUE)
# [1] 5

Ces fonctions ne sont pas prévues pour travailler sur des tableaux entiers.

mean(iris)
# Warning in mean.default(iris): l'argument n'est ni numérique, ni logique :
# renvoi de NA
# [1] NA

La fonction mean() (et les autres fonctions équivalentes) ne sont pas prévues pour travailler sur ce genre d’objet qui contient plusieurs colonnes.

head(iris)
# # A data.table: 6 x 5
# # Language:     fr
#   sepal_length sepal_width petal_length petal_width species
#          <dbl>       <dbl>        <dbl>       <dbl> <fct>  
# 1          5.1         3.5          1.4         0.2 setosa 
# 2          4.9         3            1.4         0.2 setosa 
# 3          4.7         3.2          1.3         0.2 setosa 
# 4          4.6         3.1          1.5         0.2 setosa 
# 5          5           3.6          1.4         0.2 setosa 
# 6          5.4         3.9          1.7         0.4 setosa

C’est pour cela que vous devez utiliser une construction plus complexe avec summarise() ou ssummarise(). Si vous voulez résumer les quatre variables numériques par la moyenne en fonction de species, vous devez faire (rappelez-vous que vous devez aussi utiliser d’autres noms que les noms de variables dans le tableau de départ) :

ssummarise(sgroup_by(iris, species),
  mean_sepal_length = mean(sepal_length, na.rm = TRUE),
  mean_sepal_width  = mean(sepal_width, na.rm = TRUE),
  mean_petal_length = mean(petal_length, na.rm = TRUE),
  mean_petal_width  = mean(petal_width, na.rm = TRUE))
# # A data.table: 3 x 5
# # Language:     fr
#   species    mean_sepal_leng… mean_sepal_width mean_petal_leng… mean_petal_width
#   <fct>                 <dbl>            <dbl>            <dbl>            <dbl>
# 1 setosa                 5.01             3.43             1.46            0.246
# 2 versicolor             5.94             2.77             4.26            1.33 
# 3 virginica              6.59             2.97             5.55            2.03

Tidyverse offre une astuce pour éviter de se répéter. Si vous devez appliquer la même fonction sur plusieurs variables, vous pouvez l’indiquer avec across() (aussi avec les fonctions pseedy) comme ceci :

ssummarise(sgroup_by(iris, species),
  across(sepal_length:petal_width, mean, na.rm = TRUE))
# # A data.table: 3 x 5
# # Language:     fr
#   species    sepal_length sepal_width petal_length petal_width
#   <fct>             <dbl>       <dbl>        <dbl>       <dbl>
# 1 setosa             5.01        3.43         1.46       0.246
# 2 versicolor         5.94        2.77         4.26       1.33 
# 3 virginica          6.59        2.97         5.55       2.03

C’est déjà mieux, même si c’est moins lisible quant à ce que l’on veut obtenir comme tableau final. Dans le package {collapse}, et dans SciViews::R qui utilise ce package, il y a des fonctions de remplacement de mean(), median(), … qui offrent plusieurs avantages. Nous les appellerons des fonctions “fstat” car elles calculent des descripteurs statistiques et leur nom est préfixé à l’aide d’un “f”. Ainsi, l’analogue de mean() en fonction fstat est fmean(). Souvent, vous pourrez utiliser fmean() à la place de mean(), mais son comportement et ses possibilités sont très différentes :

  • elle est plus rapide (appréciable pour les gros jeux de données), en fait “f”, c’est pour fast !
  • la valeur par défaut pour son argument na.rm est TRUE. Vous ne devez donc pas préciser na.rm = TRUE à tout bout de champ (mais restez bien attentif aux valeurs manquantes dans vos données !)
  • elle fonctionne aussi sur des tableaux de données ne contenant que des variables numériques
  • elle peut utiliser les regroupements effectués à l’aide de sgroup_by() (mais pas group_by()) et en tient compte à condition que toutes les autres variables non concernées par le regroupement soient numériques

Illustrons tout ceci :

fmean(vec) # Pas besoin de préciser na.rm = TRUE, c'est la valeur pas défaut
# [1] 5

Application directe sur un tableau, même avec regroupement :

fmean(sgroup_by(iris, species))
# # A data.table: 3 x 5
# # Language:     fr
#   species    sepal_length sepal_width petal_length petal_width
#   <fct>             <dbl>       <dbl>        <dbl>       <dbl>
# 1 setosa             5.01        3.43         1.46       0.246
# 2 versicolor         5.94        2.77         4.26       1.33 
# 3 virginica          6.59        2.97         5.55       2.03

Le tableau obtenu est très similaire à celui que nous avions calculé à l’aide de ssummarise() plus haut (à part que le nom des variables est conservé) et identique à celui obtenu en utilisant across(). Le calcul est toutefois bien plus rapide (vous ne devez pas comprendre le code ci-dessous, juste considérer que l’on détermine les performances de trois versions du même calcul : “tidy”, “speedy” et “fstat”) :

iris2 <- datasets::iris
names(iris2) <- c("sepal_length", "sepal_width", "petal_length", "petal_width", "species")
bench::mark(
  tidy   = collect_dtf(summarise(group_by(iris2, species),
    across(sepal_length:petal_width, mean, na.rm = TRUE))),
  speedy = ssummarise(sgroup_by(iris2, species),
    across(sepal_length:petal_width, mean, na.rm = TRUE)),
  fstat  = fmean(sgroup_by(iris2, species)))
# # A tibble: 3 × 6
#   expression      min   median `itr/sec` mem_alloc `gc/sec`
#   <bch:expr> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
# 1 tidy         5.66ms   7.06ms      141.   71.92KB     4.80
# 2 speedy     175.07µs 199.29µs     4657.   23.77KB     6.44
# 3 fstat       52.17µs  59.63µs    15002.    3.76KB     6.28

Examinez les colonnes median qui est le temps median et mem_alloc qui est la mémoire vive nécessaires pour faire ces calculs.

  • Il a fallu 160 fois moins de temps à fmean() qu’à la version tidy avec summarise() et quatre fois moins de temps que la version speedy avec ssummarise(). Vous comprenez maintenant pourquoi ces fonctions sont appelées “speedy” et “fast”.

  • Concernant l’utilisation de la mémoire vive, il a fallu treize fois moins de mémoire vive à fmean() qu’à summarise et six fois moins qu’à ssummarise().

Par rapport aux petits jeux de données que nous utilisons dans le cadre de ce cours, la différence n’est pas très visible. Mais si vous travaillez plus tard avec des bien plus gros jeux de données, pensez à utiliser les fonctions speedy, ou mieux les fonctions fstat directement si vous le pouvez.

À noter que, à la fois dans les constructions tidy et speedy, vous pouvez utiliser aussi les fonctions fstat. Ainsi, vous pouvez substituer fmean() à mean() dans ssummarise() (avec un gain de temps appréciable pour les calculs avec sgroup_by()). Par contre, ne mélangez jamais les fonctions de R de base avec les fonctions fstat dans la même instruction mutate() ou (s)summarise(). Sinon, le calcul qui est réalisé risque de ne pas être celui que vous espériez.

ssummarise(sgroup_by(iris, species),
  across(sepal_length:petal_width, fmean)) # Utilisation de fmean() dans ssummarise()
# # A data.table: 3 x 5
# # Language:     fr
#   species    sepal_length sepal_width petal_length petal_width
#   <fct>             <dbl>       <dbl>        <dbl>       <dbl>
# 1 setosa             5.01        3.43         1.46       0.246
# 2 versicolor         5.94        2.77         4.26       1.33 
# 3 virginica          6.59        2.97         5.55       2.03

Enfin, vous pouvez lister toutes les fonctions fstat disponibles comme ceci :

list_fstat_functions()
#  [1] "ffirst"     "flast"      "fmax"       "fmean"      "fmedian"   
#  [6] "fmin"       "fmode"      "fndistinct" "fnobs"      "fn"        
# [11] "fna"        "fnth"       "fprod"      "fsd"        "fsum"      
# [16] "fvar"

Notez bien aussi les fonction fn() et fnobs() que nous avons vues pour énumérer les cas et les observations effectivement réalisées hors valeurs manquantes. fna compte les valeurs manquantes et est donc complémentaire à fnobs() (fna(x) + fnobs(x) == fn(x)) fndistinct() peut aussi être utile pour énumérer le nombre de valeurs différentes rencontrées (par exemple pour décider si cela est raisonnable de convertir directement une variable numeric ou character en factor). Vous devriez pouvoir déduire le rôle des autres fonctions via leur nom. fvar() calcule la variance alors que fsd() calcule l’écart type (standard deviation en anglais, d’où l’abréviation “sd”) qui est la racine carrée de la variance.

Pour en savoir plus
  • Le chapitre consacré à la transformation des données de R for Data Science seconde edition présente le remaniement d’un tableau de données à l’aide des fonctions tidy différemment et propose des exemples et exercices complémentaires très utiles.

  • La meilleure façon de se familiariser avec les “verbes” du tidyverse est de réaliser des transformations de données par soi-même. En cas de blocage, le site https://stackoverflow.com permet de chercher des solutions. Pour une recherche ciblée sur le langage R, précédez vos mots clés par “[R]” (R entre crochets). Par exemple, pour explorer diverses utilisations de la fonction mutate(), vous entrerez le texte de recherche suivant: “[R] mutate”. Ne cherchez pas les fonctions speedy ou fstat, vous ne trouverez pas grand chose par contre. Adaptez les exemples trouvés avec les fonctions tidy, si nécessaire.

  • N’oubliez pas les aide-mémoires de {dplyr} et de {tidyr} qui forment aussi une source d’inspiration utile pour vous guider vers les fonction (les “verbes”) adéquats. Ensuite, allez voir l’aide en ligne de la fonction avec ?ma_fonction.


  1. Voyez ?select_helpers pour une panoplie de fonctions supplémentaires qui permettent une sélection “intelligente” des variables utilisables avec select().↩︎

  2. L’ordre alphabétique qui fait également intervenir les caractères accentués diffère en fonction de la configuration du système (langue). L’état du système tel que vu par R pour le tri alphabétique est obtenu par Sys.getlocale("LC_COLLATE"). Dans la SciViews Box, ceci est toujours "en_US.UTF-8", ceci afin de rendre le traitement reproductible d’un PC à l’autre, qu’il soit en anglais, français, espagnol, chinois, ou n’importe quelle autre langue.↩︎