Créer de jolis tableaux croisés avec R et ggplot2

, par Joël Girès

Les tableaux croisés constituent un outil simple mais puissant pour explorer les liens entre variables catégorielles dans un jeu de données. Malheureusement, il peut être laborieux de créer des tableaux de ce genre avec R, en comparaison d’autres logiciels comme SPSS. Je présente ainsi dans cet article une manière de produire de très jolis tableaux croisés dans R à l’aide de ggplot2. Un script directement exécutable est téléchargeable en fin d’article.

1. Tableau croisé en pourcentages par ligne

J’utilise dans la suite les données issues de l’enquête Histoire de vie - Construction des identités menée par l’INSEE. J’utilise ces données mises en formes de la manière décrite par un article précédent de ce site : vous devez donc au préalable suivre cette procédure pour avoir sous la main les données chargées dans un objet, qu’il faut nommer d pour que les opérations présentées ci-après puissent s’exécuter.

Voici le premier type de tableaux que nous allons créer à l’aide de ggplot2, dont la particularité est que les cellules sont remplies d’une couleur d’autant plus intense que le pourcentage est élevé. Il s’agit de la relation entre le diplôme des personnes enquêtées et le diplôme de leurs conjoint-es. Le coloriage des cellules nous fait bien remarquer que les couples se forment plus probablement entre personnes de même niveau de diplôme.

Comment créer ce tableau ? En premier lieu, nous chargeons l’ensemble des packages qui forment le tidyverse, dont fait partie ggplot2, et nous définissons l’encodage des caractères en UTF-8.

library(tidyverse)
options(encoding = "UTF-8") # Je définis l'encodages des caractères en UTF-8

Nous dupliquons les variables que nous désirons utiliser (chacune des dimension X et Y du tableau croisé) dans des colonnes génériques CROSS_X et CROSS_Y. Cela nous permet de définir les variables à utiliser au tout début du script, ce qui nous évitera de changer tout le script par la suite quand nous réaliserons ce même tableau avec d’autres variables.

# Je duplique les variables retenues dans des colonnes génériques, et j'indique leurs noms
d$CROSS_Y <- d$nivetud
y_label <- "Niveau d'étude de l'enquêté"
 
d$CROSS_X <- d$nivetud_cj
x_label <- "Niveau d'étude de son/sa conjoint-e"
 
# J'indique un titre manuellement pour le tableau
cross_table_main_label <- "Tableau croisé"
cross_table_sub_label <- "Niveau d'étude de l'enquêté et de son/sa conjoint-e"

Ensuite, nous calculons les tableaux grâce à la fonction table. Je crée précisément 3 objets différents :

  • Un tableau croisé avec les effectifs (objet table_eff) ;
  • Un tableau croisé avec les pourcentages par ligne (objet table_prop) ;
  • Et je calcule les statistiques khi2 (objet khi2).
table_eff <- table(d$CROSS_Y, d$CROSS_X) # Un tableau croisé avec les effectifs
 
table_prop <- (prop.table(table_eff, 1)*100) # Quelques explications ici : https://www.datacamp.com/community/tutorials/contingency-tables-r
# Pour les pourcentages en colonne, remplacer par : table_prop <- (prop.table(table_eff, 2)*100)
 
khi2 <- chisq.test(table_eff) # Le khi2 est calculé et stocké dans un nouvel objet "khi2"

Cependant, pour créer des tableaux avec ggplot2, nous avons besoin que ceux-ci soient mis en forme dans des dataframes. Nous transformons alors simplement les tableaux avec la fonction de base as.data.frame. Nous désirons que notre tableau possède des marges avec les totaux par ligne : nous ajoutons alors à nos tables d’effectifs et de pourcentages en ligne des totaux avec la fonction addmargins.

df_table_eff <- as.data.frame(addmargins(table_eff,2))
 
df_table_prop <- as.data.frame(addmargins(table_prop,2))

Nous pouvons désormais créer un objet ggplot2. Nous détournons la fonction geom_tile de ggplot2 pour créer les cellules de notre tableau, d’autant plus colorées que le pourcentage par ligne (la variable df_table_prop$Freq) est élevé.

crosstable_prop <- ggplot(df_table_prop, aes(Var2, Var1)) +
  geom_tile(aes(fill = Freq)) +
  scale_fill_distiller(direction = 1) + # Permet d'inverser le sens du gradient de couleur
  #geom_tile(data = as.data.frame(table_prop), colour = "white", fill = NA) + # Cette ligne pour ajouter des bordures blanches entre les cellules
  geom_text(aes(label = paste(round(Freq, digits = 1), "%") ),
            size = 4.5)

Voici le résultat :

Nous nous dirigeons dans la bonne direction, mais il y a encore du travail. Il nous faut précisément solutionner plusieurs points, que nous devons régler en amont de la création de l’objet ggplot2 :

  • Nous ne voulons pas que les cellules des marges avec les totaux par ligne soient colorées : cela n’a pas de sens, puisque le total de la ligne est par définition toujours égal à 100%. Nous créons alors une nouvelle colonne de pourcentages par ligne dans l’objet df_table_prop (Freq2 à partir de Freq), pour laquelle nous définissons que le total en marge ("Sum") est égal à 0 (de cette façon les cellules des totaux ne seront pas colorées) ;
  • Graphiquement, il serait plus lisible que le texte dans la cellule devienne blanc lorsque cette dernière est colorée avec une couleur foncée (lorsque le pourcentage est élevé). Pour ce faire, nous définissons un seuil que nous définissons à 70% du pourcentage maximum affiché dans les cellules - total en marge exclu - dans l’objet white_prop ;
  • Au lieu de laisser ggplot2 décider de la couleur de remplissage des cellules pour nous, nous la définissons nous même dans un ton orangé (#ed6700 en hexadécimal) dans l’objet palette_crosstable_prop.
df_table_prop$Freq2 <- df_table_prop$Freq
df_table_prop$Freq2[df_table_prop$Var2 == "Sum"] <- 0 # bricolage pour le fill des cellules
white_prop <- 0.7*(max(df_table_prop$Freq[df_table_prop$Var2 != "Sum"])) # Seuil de 70% pour la couleur blanche des cellules foncées
palette_crosstable_prop <- "#ed6700"

Nous avons alors tous les éléments en main pour créer un plus joli tableau. Voici les réglages effectués :

  • Nous définissons que la couleur des cellules va du blanc vers la couleur définie dans palette_crosstable_prop plus le pourcentage est élevé avec scale_fill_gradient ;
  • Grâce à un ifelse, nous changeons la couleur du texte des cellules affiché par geom_text en blanc mais ce uniquement lorsque le pourcentage passe le seuil définit dans white_prop ;
  • Il est utile d’afficher les effectifs issus de l’objet df_table_eff mais seulement dans les marges, pour ne pas surcharger le tableau. Nous le faisons donc uniquement lorsque df_table_eff$Var2 == "Sum" grâce à un deuxième geom_text légèrement grisé et décalé vers le bas ;
  • Nous inversons l’ordre de l’axe Y dans scale_y_discrete dans un soucis de cohérence du tableau, puisque nous lisons habituellement du haut vers le bas ;
  • Nous faisons aller à la ligne les labels trop longs de l’axe X à l’aide de la fonction str_wrap pour éviter qu’ils se chevauchent comme dans le tableau précédent ;
  • Nous indiquons les titres grâce aux noms du tableau et des variables déterminés plus haut (x_label, cross_table_main_label...) ;
  • Nous ajoutons également le résultat du khi2 d’indépendance (stocké dans l’objet khi2) en légende (caption) du tableau. Nous utilisons ici le package scale, qui permet d’afficher joliment la p-valeur associée.
library(scale)
crosstable_prop <- ggplot(df_table_prop, aes(Var2, Var1)) +
  geom_tile(aes(fill = Freq2)) +
  scale_fill_gradient(low = "white", high = palette_crosstable_prop) +
  #geom_tile(data = as.data.frame(table_prop), colour = "white", fill = NA) + # Cette ligne pour ajouter des bordures blanches entre les cellules
  geom_text(aes(label = paste(round(Freq, digits = 1), "%") ),
            size = 4.5,
            colour=ifelse(df_table_prop$Freq > white_prop & df_table_prop$Var2 != "Sum", "white", "black")) +
  geom_text(label = ifelse(df_table_eff$Var2 == "Sum", df_table_eff$Freq, ""),
            vjust = 2,
            hjust = 0.5,
            color = "grey",
            size = 4.5) +
  scale_y_discrete(limits=rev) + # pour inverser l'ordre de l'axe y !
  scale_x_discrete(labels = function(x) str_wrap(x, width = 10), position = "bottom") +
  labs(fill = "Proportions") +
  ggtitle (cross_table_main_label) +
  labs(caption = paste0("Khi2 = ",
                        round(khi2$statistic, digits=1),
                        " (",
                        scales::pvalue(khi2$p.value, add_p = TRUE),
                        ")")
       ) +
  labs(subtitle = print(cross_table_sub_label)
       ) +
  xlab(x_label) +
  ylab(y_label)
 
crosstable_prop

Voici le résultat de l’opération :

On se rapproche du résultat voulu, mais ce n’est pas encore tout à fait ça : il nous faut notamment supprimer le panneau gris que l’on voit dépasser des cellules, ainsi que les graduations des axes (nous avons détourné l’usage de geom_tile, il ne s’agit donc pas réellement d’axes dans notre cas). On peut également en profiter pour améliorer le rendu graphique. Je charge ainsi le package hrbrthemes qui propose de jolis thèmes pour ggplot2, notamment theme_ipsum(), auquel j’ajoute quelques réglages personnalisés [1]. Je charge également la police ’Arial Narrow’, utilisée par theme_ipsum(), grâce au package showtext.

library(hrbrthemes) # Des thèmes pour ggplot2
library(showtext)
font_add(family = "Arial Narrow",
         regular = "jo_scripts_arial narrow plain.ttf",
         bold = "jo_scripts_arial narrow bold.ttf",
         italic = "jo_scripts_arial narrow italic.ttf",
         bolditalic = "jo_scripts_arial narrow bold italic.ttf")
showtext_auto()
 
crosstable_prop +
  theme_ipsum() +
  theme(axis.text.x = element_text(),
        axis.ticks.y = element_blank(),
        axis.ticks.x = element_blank(),
        panel.grid.major.y = element_blank(),
        panel.grid.major.x = element_blank(),
        panel.background = element_blank(),
        axis.text.y = element_text(margin = margin(r = 0)),
        legend.key = element_rect(colour="black")
  )

Nous obtenons alors le tableau croisé qui a été montré au début de l’article. Il vous suffit maintenant de changer les variables, leur nom et le titre du tableau au tout début du script et de relancer ce dernier pour créer ce type de tableau pour n’importe quelle variable.

2. Tableau croisé avec indication des résidus standardisés

Le calcul des résidus standardisés constitue un outil qui enrichit fortement la lecture d’un tableau croisé. Les résidus permettent d’indiquer les cellules d’un tableau qui "attirent" ou "repoussent" significativement (dans un sens statistique) par rapport à une situation de référence, généralement l’indépendance entre lignes et colonnes (en considérant donc uniquement les effets des marges). Je ne développe pas ici l’explication de cette statistique. Pour cela je renvoie le lecteur à l’excellent et pédagogique manuel de Julien Barnier : Tout ce que vous n’avez jamais voulu savoir sur le χ2 sans jamais avoir eu envie de le demander.

Notre objectif sera ici de voir comment produire le même tableau que celui vu précédemment, mais en colorant cette fois les cellules en fonction de l’importance des résidus standardisés, et non pas du pourcentage par ligne. La première chose a faire est donc de calculer les résidus pour chacune des cellules du tableau dans un dataframe df_khi2. Nous classons ensuite les résidus dans des catégories discrètes, en fonction du niveau de significativité : non significatif (p < 0.95), significatif avec un niveau de confiance de 0.95, significatif avec un niveau de confiance à 0.99.

df_khi2 <- as.data.frame(addmargins(khi2$residuals,2))
df_khi2$Freq[df_khi2$Var2 == "Sum"] <- 0 # On définit les totaux des résidus par ligne à 0, puisque ça n'a pas de sens de les sommer.
 
# Je fais des catégories discrètes des résidus en fonction de la significativité (0.95 et 0.99)
df_khi2$residuals[df_khi2$Freq < 1.96 & df_khi2$Freq > -1.96] <- "0" # Tous les résidus non significatifs sont définis à 0 pour les éliminer des couleurs des graphiques GGPLOT2
df_khi2$residuals[df_khi2$Freq >= 1.96] <- "1.96 (0.95)"
df_khi2$residuals[df_khi2$Freq <= -1.96] <- "-1.96 (0.95)"
df_khi2$residuals[df_khi2$Freq >= 2.58] <- "2.58 (0.99)"
df_khi2$residuals[df_khi2$Freq <= -2.58] <- "-2.58 (0.99)"
df_khi2$Freq[df_khi2$Freq < 1.96 & df_khi2$Freq > -1.96] <- 0 # Tous les résidus non significatifs sont définis à 0 pour les éliminer des couleurs des graphiques GGPLOT2
 
# J'ordonne les résidus
df_khi2$residuals <- factor(df_khi2$residuals, levels = c("2.58 (0.99)", "1.96 (0.95)","0","-1.96 (0.95)","-2.58 (0.99)"))

Nous possédons dès lors un dataframe comprenant les résidus pour chacune des cellules (df_khi2) correspondant terme à terme à celui reprenant les pourcentages par ligne(df_table_prop). Nous pouvons dès lors colorer les cellules avec geom_tile sur base de df_khi2, et afficher les pourcentages grâce à geom_text sur base de df_table_prop.

Le code ci-dessous détaille comment le graphique ggplot2 est construit. La logique est la même que pour le graphique précédent des pourcentages par ligne, mis à part quelques spécificités :

  • Nous définissons des couleurs discrètes dans l’objet palette_crosstable, qui lie couleurs et niveaux de significativité des résidus ;
  • Nous créons des bordures noires pour les délimiter les cellules du tableau. Il y a ici une astuce pour dessiner des bordures en excluant les cellules comprenant les totaux par ligne (ce n’est pas joli) : nous créons un deuxième geom_tile pour créer cette bordure, avec as.data.frame(table_prop) comme données (ne possédant pas de marges), et non pas df_table_prop (pour lequel des marges ont été calculées précédemment avec addmargins).
palette_crosstable <- c("2.58 (0.99)" = "#83BBE6","1.96 (0.95)" = "#BED9ED", "0" = "white", "-1.96 (0.95)" = "#FCC7D8", "-2.58 (0.99)" = "#FF82AE")
 
crosstable_square <- ggplot(df_khi2, aes(Var2, Var1)) +
  geom_tile(aes(fill = fct_rev(residuals))) +
  geom_tile(data = as.data.frame(table_prop), colour = "black", fill = NA) +
  scale_y_discrete(limits=rev) + # pour inverser l'ordre de l'axe y !
  scale_x_discrete(labels = function(x) str_wrap(x, width = 10), position = "bottom") +
  geom_text(aes(label = paste(round(df_table_prop$Freq, digits = 1), "%") ),
            size = 4.5) +
  geom_text(label = ifelse(df_table_eff$Var2 == "Sum", df_table_eff$Freq, ""),
            vjust = 2,
            hjust = 0.5,
            color = "grey",
            size = 4.5) +
  theme_ipsum() +
  theme(axis.text.x = element_text(),
        axis.ticks.y = element_blank(),
        axis.ticks.x = element_blank(),
        panel.grid.major.y = element_blank(),
        panel.grid.major.x = element_blank(),
        panel.background = element_blank(),
        axis.text.y = element_text(margin = margin(r = 0)),
        legend.key = element_rect(colour="black")
  ) +
  scale_fill_manual(values = palette_crosstable) + 
  labs(fill = "Résidus standardisés") +
  ggtitle (cross_table_main_label) +
  labs(caption = paste0("Khi2 = ",
                        round(khi2$statistic, digits=1),
                        " (",
                        scales::pvalue(khi2$p.value, add_p = TRUE),
                        ")")
  ) +
  labs(subtitle = print(cross_table_sub_label)
  ) +
  xlab(x_label) +
  ylab(y_label)
 
crosstable_square

Voici le résultat. Les cellules roses signalent un écart négatif significatif (les cellules "repoussent") par rapport à une situation d’indépendance, tandis que les cellules bleues indiquent un écart positif significatif (les cellules "attirent"). Les résidus des cellules non colorées ne sont pas significatifs (toujours d’un point de vue statistique).

Ce type de visualisation est très puissant lorsque les marges X et Y sont distribuées de manière inégale (ce qui n’est pas vraiment le cas ici), afin d’apercevoir en un coup d’œil les sur et sous-représentations par rapport à une situation où les 2 variables ne seraient pas liées. Le tableau avec les résidus colorés permet ainsi de conclure dans ce cas à la présence d’une forme d’homogamie sociale fort probablement présente dans la population d’où est issu l’échantillon.

Notes

[1certains réglages sont redondants avec theme_ipsum(), mais ils permettent de créer malgré tout un joli tableau si vous n’utilisez pas ce thème.