dplyr : pourquoi vos résultats sont faux avec group_by() et summarise() (et comment corriger)

Illustration du piège de group_by() sous dplyr pouvant produire des résultats incohérents après summarise()

Table des matières

Introduction

Cet article fait suite à un retour d’un lecteur concernant un comportement de dplyr souvent mal compris avec les fonctions summarise() et group_by().

Vous exécutez votre code. Tout fonctionne. Les résultats semblent cohérents.

Et pourtant… ils sont faux !

Parce qu’ une partie du traitement n’est en réalité pas maîtrisée.

C’est typiquement le genre de point sur lequel j’insiste dans mes formations : comprendre précisément ce que fait le code pour éviter des erreurs silencieuses dans les analyses.

 

Un exemple concret avec des données cliniques

Pour illustrer ce comportement dans un contexte plus proche de la pratique, nous allons utiliser un jeu de données heart_disease. issu du package funModeling :

# chargement du packahe funModeling
library(funModeling)
# chargement des données
data("heart_disease")

# chargement du package dplyr
library(dplyr)

# aperçu des données
glimpse(heart_disease)

Rows: 303
Columns: 16
$ age                    <int> 63, 67, 67, 37, 41, 56, 62, 57, 63, 53, 57, 56,…
$ gender                 <fct> male, male, male, male, female, male, female, f…
$ chest_pain             <fct> 1, 4, 4, 3, 2, 2, 4, 4, 4, 4, 4, 2, 3, 2, 3, 3,…
$ resting_blood_pressure <int> 145, 160, 120, 130, 130, 120, 140, 120, 130, 14…
$ serum_cholestoral      <int> 233, 286, 229, 250, 204, 236, 268, 354, 254, 20…
$ fasting_blood_sugar    <fct> 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1, 0,…
$ resting_electro        <fct> 2, 2, 2, 0, 2, 0, 2, 0, 2, 2, 0, 2, 2, 0, 0, 0,…
$ max_heart_rate         <int> 150, 108, 129, 187, 172, 178, 160, 163, 147, 15…
$ exer_angina            <int> 0, 1, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0,…
$ oldpeak                <dbl> 2.3, 1.5, 2.6, 3.5, 1.4, 0.8, 3.6, 0.6, 1.4, 3.…
$ slope                  <int> 3, 2, 2, 3, 1, 1, 3, 1, 2, 3, 2, 2, 2, 1, 1, 1,…
$ num_vessels_flour      <int> 0, 3, 2, 0, 0, 0, 2, 0, 1, 0, 0, 0, 1, 0, 0, 0,…
$ thal                   <fct> 6, 3, 7, 3, 3, 3, 3, 3, 7, 7, 6, 3, 6, 7, 7, 3,…
$ heart_disease_severity <int> 0, 2, 1, 0, 0, 0, 3, 0, 2, 1, 0, 0, 2, 0, 0, 0,…
$ exter_angina           <fct> 0, 1, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0,…
$ has_heart_disease      <fct> no, yes, yes, no, no, no, yes, no, yes, yes, no… 

Ce jeu de données contient des informations cliniques classiques, notamment :

  • le sexe (gender)
  • le type de douleur de poitrine ressentie (chest_pain)
  • la pression artérielle au repos (resting_blood_pressure)

 

Afin de faciliter l’interprétation des résultats, les modalités de la variable chest_pain sont recodées avec des libellés explicites :

# chargement du package forcast pour recoder les modalités de la douleur
library(forcats)

heart_disease$chest_pain<- heart_disease$chest_pain %>%
  fct_recode(
    "Typical Angina" = "1",
    "Atypical Angina" = "2",
    "Non-anginal pain" = "3",
    "Asymptomatic" = "4"
  )

# Affichage des 5 premières lignes des données

heart_disease |> 
  slice_head(n=5)

  age gender       chest_pain resting_blood_pressure serum_cholestoral
1  63   male   Typical Angina                    145               233
2  67   male     Asymptomatic                    160               286
3  67   male     Asymptomatic                    120               229
4  37   male Non-anginal pain                    130               250
5  41 female  Atypical Angina                    130               204
  fasting_blood_sugar resting_electro max_heart_rate exer_angina oldpeak slope
1                   1               2            150           0     2.3     3
2                   0               2            108           1     1.5     2
3                   0               2            129           1     2.6     2
4                   0               0            187           0     3.5     3
5                   0               2            172           0     1.4     1
  num_vessels_flour thal heart_disease_severity exter_angina has_heart_disease
1                 0    6                      0            0                no
2                 3    3                      2            1               yes
3                 2    7                      1            1               yes
4                 0    3                      0            0                no
5                 0    3                      0            0                no 

Dans la suite, nous allons nous intéresser à la pression artérielle au repos par sexe et type de douleur

Une analyse très classique

Imaginons que vous souhaitiez comparer la pression artérielle au repos selon le sexe et le type de douleur de poitrine.

On peut alors calculer la moyenne de resting_blood_pressure pour chaque combinaison de genderet de chest_pain , en utilisant les fonctions group_by() et summarise() du package dplyr , comme ceci :

# pour obtenir un affichage des moyennes avec une décimale après la virgule
options(pillar.sigfig = 4)

# calcul des moyennes par combinaison des modalités  gender et chest pain
by_gender_chest_pain <- heart_disease |> 
  group_by(gender, chest_pain) |> 
  summarise(
    mean_resting_blood_pressure = round(mean(resting_blood_pressure, na.rm = TRUE), 1))

# affichage du résultat
by_gender_chest_pain 

# A tibble: 8 × 3
# Groups:   gender [2]
  gender chest_pain       mean_resting_blood_pressure
  <fct>  <fct>                                  <dbl>
1 female Typical Angina                         147.5
2 female Atypical Angina                        128.1
3 female Non-anginal pain                       127.9
4 female Asymptomatic                           139.1
5 male   Typical Angina                         139.5
6 male   Atypical Angina                        128.6
7 male   Non-anginal pain                       131.9
8 male   Asymptomatic                           129.6 

À ce stade, le résultat semble correspondre à ce que l’on attend : une ligne par combinaison de sexe et de type de douleur thoracique.

Autrement dit, dplyr a bien calculé la pression artérielle moyenne au repos pour chaque sous-groupe.

Mais le piège n’est pas dans le tableau obtenu.

Il apparaît juste après, lorsque l’on réutilise ce tableau pour poursuivre l’analyse.

Le piège apparaît après

Jusqu’ici, tout semble correct.

Mais, imaginons maintenant que vous souhaitiez identifier les deux groupes présentant la pression artérielle moyenne au repos la plus élevée.

Vous pourriez écrire :

by_gender_chest_pain |> 
  slice_max(mean_resting_blood_pressure, n = 2)

# A tibble: 4 × 3
# Groups:   gender [2]
  gender chest_pain       mean_resting_blood_pressure
  <fct>  <fct>                                  <dbl>
1 female Typical Angina                         147.5
2 female Asymptomatic                           139.1
3 male   Typical Angina                         139.5
4 male   Non-anginal pain                       131.9 

Intuitivement, vous vous attendez à obtenir 2 lignes.

Mais en réalité, vous en obtenez… 4.

Ce que fait réellement summarise()

Ce comportement s’explique par le fait que le tableau by_gender_chest_pain reste groupé après l’appel à summarise().

Plus précisément :

  • les données ont initialement été groupées selon gender et chest_pain
  • après summarise(), le résultat reste groupé
  • mais uniquement selon la première variable de regroupement, ici gender

Ce comportement n’est pas complètement intuitif…..

De la même façon, si vous aviez utilisé 3 variables dans group_by(), le tableau obtenu après summarise()serait resté groupé selon les 2 premières variables.

Au final, l’instruction placée après summarise() est appliquée à l’intérieur des groupes résiduels, et non sur l’ensemble du tableau.

Dans notre exemple, slice_max()est donc appliqué séparément :

  • chez les femmes
  • puis chez les hommes

 

C’est pour cette raison que vous obtenez 4 lignes au lieu de 2.

En réalité, R vous avertit avec le message suivant affiché dans la console :

Message d’avertissement de dplyr indiquant que le résultat reste groupé après l’utilisation de summarise()

Mais il est fréquent de ne pas voir ce message, ou de ne pas comprendre immédiatement ce qu’il implique pour la suite de l’analyse.

Pourquoi c’est problématique ?

Ce comportement est piégeux parce que R vous alerte effectivement, mais sans interrompre l’exécution du code. Le script continue donc à tourner, le tableau est produit, et si vous ne prêtez pas attention au message dans la console – ou si vous ne comprenez pas exactement ce qu’il signifie – vous pouvez facilement passer à côté du problème.

Ici, l’erreur est visible parce que vous attendiez 2 lignes et que vous en obtenez 4. Mais dans une analyse plus complexe, ce type de décalage peut être beaucoup moins évident.

Et surtout, le problème ne se limite pas au nombre de lignes obtenu.

Imaginons par exemple que, dans la suite de votre code, vous décidiez de conserver uniquement les deux premières lignes du tableau obtenu. Vous pourriez penser récupérer les deux groupes présentant les pressions artérielles moyennes les plus élevées.

Mais en réalité, ce ne serait pas le cas.

Dans notre exemple, le groupe “female – Typical Angina” possède effectivement la valeur la plus élevée. En revanche, la deuxième ligne du tableau ne correspond pas au deuxième groupe ayant la pression artérielle moyenne la plus élevée dans l’ensemble des données. Elle correspond simplement au deuxième groupe sélectionné à l’intérieur du groupe female.

Autrement dit, le regroupement résiduel modifie subtilement la logique de sélection, et peut conduire à retenir des groupes qui ne sont pas ceux que vous pensiez identifier.

C’est précisément ce qui rend ce type de comportement dangereux : le code fonctionne, le résultat semble plausible, mais l’analyse réalisée n’est pas celle que vous aviez en tête.

Comment éviter ce piège ?

Il existe plusieurs manières d’éviter ce comportement. L’idée générale est toujours la même : expliciter ce que vous voulez faire des groupes après le calcul des statistiques descriptives.

Solution 1 : utiliser la fonction ungroup()

La première solution, qui a longtemps été la manière classique de procéder avec dplyr, consiste à supprimer explicitement le regroupement après summarise() , en utilisant une fonction ungroup() 

by_gender_chest_pain <- heart_disease |> 
  group_by(gender, chest_pain) |> 
  summarise(
    mean_resting_blood_pressure = round(mean(resting_blood_pressure, na.rm = TRUE), 1)
  ) |> 
  ungroup() # utilisation de la fonction ungroup() 

Vous pouvez ensuite appliquer slice_max() sur l’ensemble du tableau :

by_gender_chest_pain |> 
  slice_max(mean_resting_blood_pressure, n = 2)

# A tibble: 2 × 3
  gender chest_pain     mean_resting_blood_pressure
  <fct>  <fct>                                <dbl>
1 female Typical Angina                       147.5
2 male   Typical Angina                       139.5 

Cette fois, vous obtenez bien les deux groupes ayant les pressions artérielles moyennes au repos les plus élevées, tous groupes confondus.

Solution 2 : utiliser .groups = “drop”

Une autre solution, apparue ensuite dans dplyr, consiste à indiquer directement dans summarise() que vous ne souhaitez conserver aucun regroupement, en employant l’argument .groups = "drop":

by_gender_chest_pain <- heart_disease |> 
  group_by(gender, chest_pain) |> 
  summarise(
    mean_resting_blood_pressure = round(mean(resting_blood_pressure, na.rm = TRUE), 1),
    .groups = "drop" # ici
  ) 

L’avantage est que le comportement est explicite dès l’étape de résumé. Vous évitez ainsi de transporter un regroupement résiduel dans la suite de votre analyse.

by_gender_chest_pain |> 
  slice_max(mean_resting_blood_pressure, n = 2)

# A tibble: 2 × 3
  gender chest_pain     mean_resting_blood_pressure
  <fct>  <fct>                                <dbl>
1 female Typical Angina                       147.5
2 male   Typical Angina                       139.5 

Solution 3 : utiliser l’argument .by

Plus récemment encore, dplyr a introduit l’argument .by, qui permet d’effectuer le regroupement directement danssummarise(), sans avoir besoin d’utiliser group_by() en amont, comme ceci :

by_gender_chest_pain <- heart_disease |> 
  summarise(
    mean_resting_blood_pressure = round(mean(resting_blood_pressure, na.rm = TRUE), 1),
    .by = c(gender, chest_pain) # ici
  )
  
    gender     chest_pain mean_resting_blood_pressure
1 female Typical Angina                       147.5
2   male Typical Angina                       139.5 

C’est peut être la solution la plus lisible, car elle rend le code plus local : vous voyez directement, dans l’appel à summarise(), les variables utilisées pour calculer les statistiques par groupe.

Personnellement j’utilise la solution 2.

Conclusion

Ce type de comportement peut sembler anecdotique… mais il illustre parfaitement une réalité fréquente en analyse de données : un code qui s’exécute sans erreur n’est pas forcément un code dont le résultat est maîtrisé.

Dans cet exemple, le piège vient du fait que le regroupement est conservé de manière implicite après summarise()mais uniquement sur les n - 1 premières variables utilisées dans group_by()

Sans une bonne compréhension de ce fonctionnement, il est facile d’enchaîner des opérations qui ne produisent pas les résultats attendus.

C’est précisément pour cela qu’il est important de ne pas se contenter de faire “tourner” du code, mais de comprendre finement ce que chaque étape produit.

 

Enfin, si vous avez déjà été confronté à ce type de comportement, ou si vous avez d’autres exemples de situations “piégeuses” avec dplyr, n’hésitez pas à les partager en commentaire

Vous préférez les vidéos ?

Aller plus loin

bien-demarrer-r-rstudio

CAMPUS DELLADATA

Apprendre R à votre rythme

Je propose désormais une formation en ligne pour démarrer avec R et RStudio, pensée pour les profils scientifiques (recherche médicale, biologie, agro, environnement…). D’autres modules arrivent prochainement.

Portrait de Claire Della Vedova, consultante et formatrice en biostatistique et langage R

Je suis Claire Della-Vedova, consultante en biostatistique, méthodologie clinique et expertise R.

J’accompagne les fabricants de dispositifs médicaux et les équipes scientifiques des sciences du vivant dans leurs projets d’évaluation clinique, d’analyse statistique et d’analyse de données sous R.

🎓 Formations professionnelles R et biostatistiques 

🤝 Prestations et accompagnement sur mesure 

📅 Discuter d’un accompagnement ou d’une prestation sur mesure  

Poursuivez votre lecture

Commentaires

4 réponses

  1. Merci. J’utilisais la solution 2 mais sans avoir réellement remarqué le danger. Je le fais intuitivement tant affectueusement peur que le grouping se propage au cours de l’analyse. Je n’avais même pas encore remarqué le message d’attention de R.

  2. Très utile, merci beaucoup, personnellement j’utilse souvent la solution 1, mais je après la lecture de cet article je pense que je vais migrer à la solution 3

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Fonctions statistiques R

Aide mémoire off'R ;)

Enregistrez vous pour recevoir gratuitement mes fiches « aide mémoire » (ou cheat sheets) qui vous permettront de réaliser facilement les principales analyses biostatistiques avec le logiciel R et pour être informés des mises à jour du site.