De long en large ou de large en long ? Transposons facile avec R et le “pivot”

Mon 1er coup de cœur pour R, je l’ai eu en découvrant les 2 fonctions de transposition pivot_wider et pivot_longer. Remettre une table au carré (je devrais dire au rectangle) est un besoin tellement fréquent pour qui veut produire un graphique ou une carte à partir d’un jeu de données récupéré sur le web !

Jadis (?) on faisait des tableaux croisés dynamiques avec son tableur, pour bien exposer à l’écran les relations entre deux notions, l’une déployée en ligne, l’autre en colonne. Les statisticiens ont l’habitude aussi des tables de contingence, qui visualisent la dépendance entre deux caractères sous forme de comptages, de sommes et/ou de pourcentages.

Quand on génère un tableau croisé, on passe d’une structure “longue” à une structure “large“. La structure longue a beaucoup de lignes et peu de colonnes. La structure large équilibre mieux les deux dimensions, elle est plus lisible. C’est aussi celle que l’on retrouve dans un graphique, car elle exploite au mieux les deux dimensions du plan.

Mais pour transformer un jeu de données avant sa représentation finale, la structure longue est la plus pratique, car les opérations statistiques portent avant tout sur des colonnes (filtrages, agrégations, calcul de nouveaux indicateurs…) Chaque colonne dans une table statistique bien construite (“tidy” en terminologie R/Wickham) a un contenu homogène, et les colonnes différencient des axes et des indicateurs, en nombre bien maitrisé.

Une série de traitements se conduit mieux avec des structures longues et ne produit du “large” qu’en bout de chaine, quand il s’agit d’éditer un tableau d’analyse ou une datavisualisation. C’est aussi pourquoi les données sont généralement stockées en base sous une forme longue. Enfinles outils de datavisualisation en ligne promeuvent de plus en plus le format long en entrée. 

Les mécanismes de transposition de tables

Mais ce que l’on trouve en OpenData est assez souvent en format “large“, quand le producteur a privilégié la lisibilité de l’exposition à la facilité du remodelage. Ces deux critères sont également respectables. Avec la boîte à outils R, pas de souci, nous sommes vraiment armés pour le tout terrain !

Je vais m’appuyer dans cet article sur des exemples concrets, qui vous parleront je l’espère plus facilement que les exposés habituels sur les jeux iris ou mtcars fournis par défaut dans R. 

Je vais vous montrer, en utilisant les options les plus avancées des fonctions de pivot et exploitant des données OpenData Insee :

  • comment produire du format long à partir du large,
  • et réciproquement du large à partir du long, même si le besoin est moins fréquent,
  • et enfin comment simplement transposer une table, en échangeant 2 critères qualitatifs, sans forcément qu’elle ne devienne radicalement plus longue ou plus large.

1 – Naissances millésimées : isoler l’année dans une nouvelle colonne

Nous reprenons la base Insee historique des naissances domiciliées présentée dans cet article :
library(tidyverse); library(curl); library(readxl); library(janitor)

url_ec  <- "https://www.insee.fr/fr/statistiques/fichier/1893255/base_naissances_2019.zip"
xlsfile <- str_replace(basename(url_ec), ".zip", ".xlsx") # "base_naissances_2019.xlsx"
curl_download(url_ec, tempzip <- tempfile())              # télécharge le zip

xlsfile <- unzip(zipfile = tempzip, files = xlsfile)      # extrait le xlsx

tb_com <- read_excel(xlsfile, sheet = 1, skip = 5) %>% 
          clean_names() %>% select(-(libgeo:dep))      
# clean_names de janitor normalise les colonnes => minuscules sans blancs ni accents
Voici les données chargées dans tb_com (ici les 5 premières lignes) :
Pour passer en structure longue, pivot_longer est la fonction appropriée :
tb_com_tr <- tb_com %>%   
             pivot_longer(cols = starts_with('naisd'), 
                          names_to = "annee", values_to = "naisd") 

# par défaut, la nouvelle variable annee reprend les noms de colonnes numériques
# il nous faut la corriger en remplaçant la racine 'naisd' par '20', ainsi 'naisd12' => '2012'
tb_com_tr <- tb_com_tr %>% 
             mutate(annee = str_replace(annee, "naisd", "20"))

Voilà qui nous amène à une table de 3 colonnes et 350 000 lignes (ici les 20 premières) :

Nous pouvons éviter ce mutate additionnel en tirant partie des nombreuses options avancées de pivot_longer. Voici quelques variantes, avec expression régulière et/ou fonction de recodage :
tb_com_tr <- tb_com %>% 
  pivot_longer(cols = starts_with('naisd'), names_to = "annee", values_to = "naisd",
               names_prefix = "naisd")   # cette chaîne est soustraite, 'naisd12' => '12'

tb_com_tr <- tb_com %>% 
  pivot_longer(cols = starts_with('naisd'), names_to = "annee", values_to = "naisd", 
               names_pattern = "naisd(\\d+)") # extraction du contenu entre ()

# il nous faut préfixer annee par "20", ce qui est possible avec names_transform
tb_com_tr <- tb_com %>%  
  pivot_longer(cols = starts_with('naisd'), names_to = "annee", values_to = "naisd",
               names_pattern = "naisd(\\d+)", names_transform = list(annee = ~str_c('20',.x))) 
# combinaison extraction des chiffres après naisd et fonction de concaténation

tb_com_tr <- tb_com %>% 
  pivot_longer(cols = starts_with('naisd'), names_to = "annee", values_to = "naisd",
               names_transform = list(annee = ~str_c('20', str_sub(.x, 6)))) 
# une seule fonction pour fabriquer la colonne annee

Dans cet exemple, nous avons fait passer une collection de variables (en ligne) par transposition vers 2 nouvelles colonnes : 1 axe qualitatif temporel et 1 indicateur numérique unique. Confier tous les traitements à la seule fonction pivot_longer est un gage de rapidité, car elle sait mettre en œuvre des optimisations de bas niveau.

Le format long facilite la mise en graphique. La conceptualisation même de ggplot2 s’appuie sur la définition de variables visuelles directement connectées à des axes d’analyse, soit des colonnes de la table en entrée :
ggplot(tb_com_tr %>% filter(codgeo %in% c('31555','13055')) ) +  
       aes(x = annee, y = naisd, colour = codgeo, group = codgeo) +
       geom_line(size = 1.2) +
       labs(title = "Évolution des naissances domiciliées à Toulouse et Marseille", 
            color = "Dépts", subtitle="source : Insee", x = "", y = "" ) +
       theme_minimal() +
       expand_limits(y = 0) +
       scale_y_continuous(breaks = seq(0, 14000, 2000)) +
       scale_color_discrete(name = "Communes", labels = c("Marseille", "Toulouse"))

2 – Types de familles : dégager 2 nouveaux axes qualitatifs

Voyons dans ce nouvel exemple comment dégager deux axes qualitatifs au lieu d’un seul. Je m’appuie ici sur un tableau détaillé du recensement, millésimé 2017, accessible à partir de cette page Insee. Il s’agit de données communales par type de famille et nombre d’enfants de moins de 25 ans.

Ce fichier zippé fait un peu plus de 10 mo. Je le charge et supprime la colonne LIBGEO (nom des communes), par souci d’allègement et de lisibilité ultérieure :
zip_url <- "https://www.insee.fr/fr/statistiques/fichier/4515481/BTX_TD_FAM1_2017.zip"
xlsfile <- unzip(curl_download(zip_url, tempfile(), quiet = F))
tb_com  <- read_excel(xlsfile, sheet = 1, skip = 10) %>% select(-LIBGEO)
Voici un aperçu des données chargées dans tb_com, cette table a une soixantaine de colonnes et 35 000 lignes :
# A tibble: 34,951 x 61
   CODGEO NBENFFR0_TF1211 NBENFFR0_TF1212 NBENFFR0_TF1221 NBENFFR0_TF1222 NBENFFR0_TF1231
 1 01001             0                0              0               0               30.6
 2 01002             0                0              0               0               15.3
 3 01004             3.17            15.8           14.5            48.0            484. 
 4 01005             0                0              5.30           10.3             79.4
 5 01006             0                0              0               0               10.1
 6 01007             0                0              0               0              112. 
 7 01008             0                0              0               4.98            34.9
 8 01009             0                0              0               5.09            20.4
 9 01010             0                0              0               5               30  
10 01011             0                0              0               0                0  

La doc du fichier précise que ce tableau présente des comptages issus du croisement de 2 critères qualitatifs :

  • NBENFFR : nb. d’enfants, modalité codée de ‘0’ à ‘4’
  • TF12 : type de famille, modalité codée de ’11’ à ’44’

L’information peut se lire dans les noms de colonnes du fichier.

Ainsi NBENFFR2_TF1244 correspond à la définition NBENFFR = ‘2’ et TF12 = ’44’ (couple sans emploi avec 2 enfants de moins de 25 ans).

Je veux passer en structure longue et réduire drastiquement le nombre de colonnes. La fonction de pivot spécifie deux variables à créer, avec leur nom cible, et la façon de les remplir en analysant les noms de colonne avec une expression régulière (names_pattern) :
tb_com_tr <- tb_com %>%
             pivot_longer(!CODGEO, names_to = c("NBENFFR","TF12"), values_to = "NB",
                          names_pattern = "NBENFFR(\\d+)_TF12(\\d+)")
# !CODGEO cible toutes les variables sauf CODGEO
# \\d cible un chiffre (digit), équivalent à [0-9] ou [:digit:]
# \\d+ cible une répétition quelconque de chiffres
# l'écriture ci-dessus fonctionne mais est paresseuse, 
# le + spécifiant un nb. quelconque de chiffres

# une "regexp" plus stricte s'écrirait :
tb_com_tr <- tb_com %>%
             pivot_longer(!CODGEO, names_to = c("NBENFFR","TF12"), values_to = "NB",
                          names_pattern = "NBENFFR([0-9]{1})_TF12([0-9]{2})")

Voici les données dans la table transposée tb_com_tr (ici les 20 premières lignes) :

 

Comme on le voit une telle structure est bien plus facile à manipuler. Que l’on songe par exemple à la sélection suivante : familles monoparentales de plus de 4 enfants (TF12 <= ’22’ && NBENFFR = ‘4’). Dans la structure large initiale, il aurait fallu identifier et lister méticuleusement les colonnes concernées.

Appliquons ce filtrage, et faisons en sorte de revenir à la structure large d’origine (tableau croisé), par l’emploi de la fonction de pivot symétrique pivot_wider :
tb_com_fl <- tb_com_tr %>% 
  filter(TF12 <= '22' & NBENFFR == '4') %>%
  pivot_wider(names_from = c("NBENFFR","TF12"), values_from = "NB",
              names_glue = "NBENFFR{NBENFFR}_TF12{TF12}")
# names_glue permet des substitutions dynamiques au sein des {}
Extrait des 5 premières lignes :

Pivot_wider présente une syntaxe tout à fait symétrique de celle de pivot_longer. Elle fonctionne d’autant mieux que le jeu à transposer vers un format large dispose d’une clé primaire (ici CODGEO) qui identifie bien chaque enregistrement de façon unique. Si tel n’était pas le cas (vous travaillez par exemple sur un jeu d’observations élémentaires), et qu’un warning apparaissait, ajouter un incrément automatique de type fid = row_number() ou cumsum(…) vous tirera d’affaire.

3 – Chiffres clés depuis 1968 : transposer 2 groupes de variables, naissances et décès

Poursuivons notre exploration de pivot_longer. Jusqu’à présent, nous avons dans le passage à la structure longue réduit les colonnes numériques initiales à une seule, car nous n’avions qu’un indicateur à considérer (naissances ou nombre de familles). Voyons comment gérer le cas de deux indicateurs (ou plus), avec une base de départ qui comprend des informations plus variées sur les naissances, les décès, les logements, la population, etc.

Je vais pour cela télécharger la base communale des chiffres clés extraits des différents recensements depuis 1968. Dans un premier temps, je ne retiens que les variables millésimées relatives aux naissances et décès.
urlcc   <- str_c("https://www.insee.fr/fr/statistiques/fichier/4171585/",
                 "base-cc-serie-histo-2016-xls.zip")
xlsfile <- unzip(curl_download(urlcc, tempfile()))  
tb_cc   <- read_excel(xlsfile, sheet = 1, skip = 5) %>% clean_names()
                         
tb_cc   <- tb_cc %>% select(codgeo, starts_with("nais"), starts_with("dece"))

# A tibble: 34,953 x 15
   codgeo nais1116 nais0611 nais9906 nais9099 nais8290 nais7582 nais6875 dece1116 dece0611
 1 01001        41       48       67       60       47       28       37       25       29
 2 01002        21       13       26       20       10        4        5        7        9
 3 01004      1114     1005     1158     1299     1323     1161     1220      595      503
 4 01005       101      114      116      135       86       69       99       42       43
 5 01006         9        4        6        6        3        2        5        6        5
 6 01007       163      156      199      198      159      151      128       69       67
 7 01008        55       53       44       59       62       23       27       19       16
 8 01009        10       26       18       21       17       11       10       11       16
 9 01010        57       72       70       70       63       67       48       30       35
10 01011        21       24       34       39       36       26       40        9        7
L’information temporelle correspond à chaque période intercensitaire. Elle est codée de façon compacte (0611, 6875…) Transposons avec pivot_longer :
tb_cc_tr <- tb_cc %>% 
            pivot_longer(!codgeo, values_to = "nb", names_to = c("stat","periode"),
                         names_pattern = "([a-z]+)(\\d+)" ) 

Voici la nouvelle table pivotée (10 premières lignes) :

Ce résultat n’est peut-être pas celui que nous espérions, où dece et nais se retrouveraient en colonne, mais il n’est pas sans intérêt. Voyons comment l’exploiter. Je commence par poser une recodification du champ periode pour le rendre plus explicite, et aussi plus naturellement triable selon l’ordre chronologique. Je vais ensuite générer, après la transposition, un double graphique comparatif.
# fonction de recodage de la période '8290' => '1982-1990', '0611' => '2006-2011'
f <- function(periode) {
  p1 = str_sub(periode,1,2)
  p2 = str_sub(periode,3,5)
  p1 = ifelse( p1 > '3', str_c('19', p1), str_c('20', p1) )
  p2 = ifelse( p2 > '3', str_c('19', p2), str_c('20', p2) )
  str_c(p1, "-", p2)
}

# transposition avec recodage de la période
tb_cc_tr <- tb_cc %>% 
  pivot_longer(!codgeo, values_to = "nb", names_to = c("stat","periode"),
               names_transform = list(periode = f), 
               names_pattern = "([a-z]+)(\\d+)" ) 

# mise en graphique en exploitant les 'facet', démultipliant les visualisations
# pour avoir une courbe des décès à côté de la courbe des naissances
ggplot(tb_cc_tr %>% filter(codgeo %in% c('31555','13055')) %>% arrange(periode), 
       aes(x = periode, y = nb, group = codgeo, colour = codgeo)) +
       facet_wrap(~stat) +
       geom_line(size = 1.2) +
       theme_minimal() +
       theme(axis.text.x = element_text(angle = 45, hjust = 1)) +
       labs(title = "Evolution démographique à Toulouse et Marseille", x = "", y = "") +
       scale_color_discrete(name = "Communes", labels = c("Marseille", "Toulouse")) 
2 graphiques produits automatiquement en vis-à-vis (facet_wrap)
Maintenant, si je veux placer nais et dece en colonne, je transpose dans l’autre sens avec pivot_wider :
tb_cc_tr <- tb_cc_tr %>% 
             pivot_wider(names_from = 'stat', values_from = 'nb')

Ce qui me donne :

Avec cette structure, je vais pouvoir calculer plus facilement un solde naturel comme nais-dece.

4 - Utilisation avancée de pivot_longer

Pour arriver au résultat précédent, j’ai donc enchaîné un pivot_longer et un pivot_wider. Mais j’aurais pu utiliser un seul pivot_wider, dont l’écriture est, il faut bien le dire, un peu cryptique, mais étonnante par sa compacité :
tb_cc_tr <- tb_cc %>% 
            pivot_longer(!codgeo, values_to = "nb", names_to = c(".value", "periode"),
            names_pattern = "([a-z]+)(\\d+)")
# le mot clé .value désigne les variables extraites (nais/dece) à ne pas transposer
# c'est-à-dire à conserver comme colonnes

# tb_cc : table d'origine
   codgeo nais1116 nais0611 nais9906 nais9099 nais8290 nais7582 nais6875 dece1116 dece0611
 1 01001        41       48       67       60       47       28       37       25       29
 2 01002        21       13       26       20       10        4        5        7        9
 3 01004      1114     1005     1158     1299     1323     1161     1220      595      503
 4 01005       101      114      116      135       86       69       99       42       43

# tb_cc_tr : table transposée
   codgeo periode nais  dece
 1 01001  1116     41    25
 2 01001  0611     48    29
 3 01001  9906     67    36
 4 01001  9099     60    45
 5 01001  8290     47    30
 6 01001  7582     28    31
 7 01001  6875     37    25
 8 01002  1116     21     7
 9 01002  0611     13     9
10 01002  9906     26    16
Mais il y a mieux avec l’accès au mécanisme interne de la transposition, qui commence par compiler les paramètres reçus en créant une table de “spécifications”. La fonction build_longer_spec expose cette spec :
spec <- tb_cc %>% 
        build_longer_spec(!codgeo, values_to = "nb", names_to = c(".value", "periode"),
                          names_pattern = "([a-z]+)(\\d+)")

# spec : A tibble: 14 x 3
   .name    .value periode
 1 nais1116 nais   1116   
 2 nais0611 nais   0611   
 3 nais9906 nais   9906   
 4 nais9099 nais   9099   
 5 nais8290 nais   8290   
 6 nais7582 nais   7582   
 7 nais6875 nais   6875   
 8 dece1116 dece   1116   
 9 dece0611 dece   0611   
10 dece9906 dece   9906   
11 dece9099 dece   9099   
12 dece8290 dece   8290   
13 dece7582 dece   7582   
14 dece6875 dece   6875
Ensuite, la table de spec est prise en compte pour transposer la table initiale, cette seconde étape est reproductible par l’instruction suivante :
tb_cc_tr &lt;- tb_cc %&gt;% pivot_longer_spec(spec)

# tb_cc_tr : table transposée
   codgeo periode  nais  dece
 1 01001  1116       41    25
 2 01001  0611       48    29
 3 01001  9906       67    36
 4 01001  9099       60    45
 5 01001  8290       47    30
Autrement dit pivot_longer peut se décomposer ainsi :
build_longer_spec => crée une table spec => pivot_longer_spec => crée la table transposée

Du coup, si l’on comprend bien la logique de cette table de spécifications, il est possible de l’aménager à son gré pour définir des transpositions qui ne seraient pas possibles avec les paramètres actuels de pivot_longer (fonction qui est une sorte d’assistant pour définir une spéc). Je vais à titre d’exemple affiner la table spec précédente et la faire exécuter.
# fonction de recodage '06'=>'2006', '75'=>'1975'
f2 <- function(x) str_c(ifelse(str_sub(x,1,1) == '0','20','19'), x)

# modification de la spécification de transposition
# recodage de la période avec f2 et renommage des variables nais et dece
spec2 <- spec %>% 
         mutate(`.value` = recode(`.value`, 'nais' = 'naissances','dece' = 'deces')) %>%
         separate(periode, into = c("a1","a2"), sep = 2) %>%
         mutate(a1 = f2(a1), a2 = f2(a2)) %>% 
         unite("periode", a1:a2, sep = '-')

# spec2 : A tibble: 14 x 3
   .name    .value     periode  
 1 nais1116 naissances 1911-1916
 2 nais0611 naissances 2006-1911
 3 nais9906 naissances 1999-2006
 4 nais9099 naissances 1990-1999
 5 nais8290 naissances 1982-1990
 6 nais7582 naissances 1975-1982
 7 nais6875 naissances 1968-1975
 8 dece1116 deces      1911-1916
 9 dece0611 deces      2006-1911
10 dece9906 deces      1999-2006
11 dece9099 deces      1990-1999
12 dece8290 deces      1982-1990
13 dece7582 deces      1975-1982
14 dece6875 deces      1968-1975

# application de la nouvelle spec2
tb_cc_tr <- tb_cc %>% pivot_longer_spec(spec2)

# tb_cc_tr : A tibble: 244,671 x 4
   codgeo periode   naissances deces
 1 01001  1911-1916         41    25
 2 01001  2006-1911         48    29
 3 01001  1999-2006         67    36
 4 01001  1990-1999         60    45
 5 01001  1982-1990         47    30
 6 01001  1975-1982         28    31
 7 01001  1968-1975         37    25
 8 01002  1911-1916         21     7
 9 01002  2006-1911         13     9
10 01002  1999-2006         26    16

5 – Données par pays de la Banque mondiale

J’ai travaillé récemment sur des indicateurs de santé en Amérique du Sud, tirés de la base de données de la Banque mondiale.

Par simplicité d’exposé, je vais pour une fois mobiliser un jeu de données proposé par R, même s’il est plus ancien, sa structure est bien la même.
tb_wb &lt;- world_bank_pop %&gt;% select(-(`2000`:`2009`))

Comme on le voit, après avoir un peu allégé cette table pour la lisibilité, les années sont présentées en colonnes, mais on a cette fois-ci un axe “indicator” qui décline toute une série d’indicateurs par pays. Cette structure n’est à l’évidence pas très maniable.

Nous allons simplement échanger les axes indicateur et annee en associant un pivot_longer et un pivot_wider :
tb_wb_tr <- tb_wb %>%
  pivot_longer(`2010`:ncol(.), names_to = "annee", values_to = "value") %>% 
  pivot_wider(names_from = "indicator", values_from = "value") %>% 
  clean_names()  # normalise les noms de colonne

Récapitulatif

Les fonctions de pivot de R sont puissantes et rapides, mais demandent à être apprivoisées (se familiariser avec les expressions régulières est un avantage certain). Elles permettent de remettre en forme pratiquement tout jeu de données, afin d’en faciliter le maniement et l’analyse ultérieure. pivot_longer et pivot_wider sont au cœur de la philosophie élégante et fluide de R/Tidyverse. Prenant la suite de gather et spread, elles continuent d’évoluer avec des options introduites pour résoudre des problèmes très pratiques.

2 commentaires sur “De long en large ou de large en long ? Transposons facile avec R et le “pivot””

  1. Merci pour ce tuto. Pour le moment j’utilisais plutôt gather/spread. Je vais me pencher sur les fonctions de pivot qui semblent très puissantes dans ces exemples.
    Une remarque sur l’exemple 2 du TD FAM de l’Insee, ça laisse songeur de savoir que les données sont au départ exactement sous la forme “longue”, qu’elles sont retraitées à l’Insee pour être diffusées en version “large” et finalement remise en version “longue” par les utilisateurs. Peut être faudrait il laisser les données à l’état initial et laisser les utilisateurs décider de la structure qu’ils souhaitent. C’est d’ailleurs exactement ce qui est fait proposé via les API Insee (notamment l’API DDL), mais c’est probablement réservé à un public expert.

    1. Merci Régis, je comprends votre interrogation quant au format idéal de diffusion. L’idéal c’est qu’il y ait les deux, car le format ‘long’ peut dérouter les non-habitués, alors que le large a aussi ses avantages (meilleur exposé des variables, vision immédiate de toutes les données d’une commune, etc.) Le chemin des API complémentaires au mode de diffusion actuel me parait une bonne réponse, dès lors bien sûr que l’API est simple et rapide d’usage.
      pivot_longer et pivot_wider remplacent gather et spread, avec selon leur concepteur H. Wickham le souhait d’une dénomination plus facile à retenir, et des ajouts fonctionnels en partie empruntés à l’univers de l’excellent data.table.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée.