8.2 Big data

On entend souvent parler de “big data”, mais qu’est-ce que c’est réellement ? En quoi cela me concerne comme biologiste ? Que dois-je faire si je suis confronté à un gros jeu de données ? Autant de questions auxquelles nous allons nous attaquer maintenant.

Le “big data” va s’intéresser aux jeux de données qui sont trop volumineux pour être traités avec les outils classiques que nous avons étudiés jusqu’ici. Il va donc de pair avec des outils logiciels spécifiques. Mais au fait, à partir de quelle taille de tableau dois-je m’inquiéter ?

8.2.1 Gros jeux de données dans R

Au départ, vos données sont stockées sur le disque, proviennent d’Internet, ou d’un package R. Dans SciViews::R, vous les lisez en mémoire vive avec la fonction read(). Cette fonction importe les données depuis différents formats, mais nous allons considérer deux formats très fréquents en biologie : le CSV et Excel. Microsoft Excel a une limitation maximale de la taille du tableau à un peu plus d’un million de lignes et un peu plus de 16.000 colonnes. Donc, est-ce qu’un million de lignes, c’est à considérer comme du “big data” avec R aussi ? Pour y répondre, il faut déterminer si un tel tableau de données peut tenir en mémoire vive. Admettons que le tableau ne contienne que des nombres réels. Dans R, ces nombres sont représentés par des “doubles” qui occupent chacun 8 octets (les entiers de R, dits “long integers” occupent, eux, 4 octets chacun). Si vous avez 10 variables matérialisées par des “doubles” dans ce tableau d’un million de lignes, vous aurez : 1.000.000 * 10 * 8 octets = 80 millions d’octets ou encore environ 80 mégaoctets (en réalité, un mégaoctet, c’est 1024*1024 octets, mais ne chicanons pas). La mémoire vive d’un PC moyen fait 8 gigaoctets, soit environ 8000 mégaoctets. Aucun problème pour R avec un tableau de cette taille ou même plus gros. En matière de nombre de lignes, Excel déclare forfait vers le million, mais R peut facilement gérer des tableaux bien plus volumineux.

Pour les objets data.table que nous utilisons régulièrement dans SciViews::R, la limite vient du nombre entier le plus grand que R peut gérer (les entiers sont utilisés pour indexer les lignes du tableau en mémoire). Ce nombre est :

.Machine$integer.max
# [1] 2147483647

Comme les entiers sont représentés par 4 octets, cela correspond à 2^31 - 1 = 2.1474836^{9}. On enlève un bit qui sert à stocker le signe de l’entier + ou - et encore un item pour représenter les valeurs manquantes. La limite absolue d’un tableau data.table est donc d’un peu plus de 2 milliards de lignes, soit 2000 fois plus que dans Excel.

Une autre limitation est toutefois à prendre aussi en compte : la taille de la mémoire vive disponible. Vous ne pourrez pas ouvrir et travailler avec un tableau de données plus gros, ou même, qui tienne tout juste en mémoire vive. En effet, il faut garder de la mémoire pour le système d’exploitation et les logiciels, dont R. R doit aussi copier les données partiellement ou totalement selon les fonctions que vous allez utiliser sur votre jeu de données. Donc, pour pouvoir faire ensuite quelque chose d’utile sur ce tableau, il faut compter que le tableau le plus volumineux utilisable correspond à la mémoire vive divisée par un facteur entre 2.5 et 3 (2.5 si vous avez beaucoup de mémoire vive, soit 64Go ou plus). Avec votre machine dans SaturnCloud et ses 16Go, vous pourrez donc traiter un tableau de données de 65 millions de lignes, toujours si vous avez les mêmes 10 colonnes de nombres réels dedans (65.000.000 * 10 * 8 = 5.2Go).

Si vous souhaitez monter un PC qui ne sera limité que par la taille maximale admissible pour un data.table, et jouer avec des tableaux de 2 milliards de lignes (toujours avec 10 colonnes de nombres réels et un facteur multiplicatif de 2.5), il vous faudra installer 400Go de mémoire vive dans ce PC. Naturellement, si vous avez 20 colonnes, il vous en faudra deux fois plus : la taille est relative au nombre de colonnes à raison de 16Go par colonne de nombres réels ou de dates et 8Go par nombres entiers ou variables factor sur 2 milliards de lignes, sans oublier de multiplier tout cela par le facteur de 2.5 à 3.

Mais prenons maintenant un peu de recul : combien de fois aurez-vous à traiter d’aussi gros jeux de données (et surtout à les traiter en une seule fois) ? Il est fort probable plutôt que vous ne rencontrerez déjà que rarement des tableau avec plus de quelques dizaines de millions de lignes. Donc, la quasi-totalité de vos tableaux de données pourront être traitée entièrement en mémoire vive, que ce soit dans votre PC directement, ou dans une machine virtuelle plus grosse dans le cloud. Aujourd’hui, un PC puissant, style station de travail, qui coûte quelques milliers d’euros, peut accueillir très facilement 128Go de mémoire vive. De plus, vous avez aussi accès (avec un compte payant) à des machine sur le cloud qui ont autant, voire plus de mémoire vive. Par exemple, sur SaturnCloud, vous avez les machines 4XLarge avec 16 cœurs et 128Go de RAM, 8XLarge avec 32 cœurs et 256Go RAM, et même 16XLarge avec 64 cœurs et 512Go de RAM !

Avec Excel, vous ne pourrez pas traiter des tableaux plus gros qu’environ 1 million de lignes (voir ici) et aujourd’hui des tableaux de cette taille se rencontrent en biologie, même si ce n’est pas ultra fréquent. Par contre, la limite dans R est :

  • soit de 2^31-1 lignes = plus de 2 milliards de lignes (nombre de lignes maximales indexables dans un data.table),
  • soit la taille de la mémoire vive en Go à raison d’environ (8Go * nombre de colonnes de réels ou dates + 4Go * nombre d’entiers ou facteurs) * nombre de lignes du tableau / 1.000.000.000 * coefficient multiplicatif (2.5 ou 3), approximation d’un Go = 1.000.000.000 octets.
Aujourd’hui, les PC ou machines virtuelles sur le cloud ayant 128Go de mémoire vive sont relativement accessibles. Ils permettent de traiter des tableaux de 600 millions de lignes par 10 colonnes d’entiers… et vous rencontrerez très, très rarement des tableaux aussi volumineux. Par conséquent, la question du “big data” ne se présentera pas pour vous, ou alors, il s’agira de données gérées par une grosse organisation qui a les moyens nécessaires pour engager une équipe de spécialites qui gère ces données à votre place. Vous devez, par contre, maîtriser les techniques qui vous permettent de gérer des tableau moyennement volumineux de plusieurs centaines de milliers de lignes à plusieurs dizaines de millions de lignes.

8.2.2 Tableaux de millions de lignes

Nous allons prendre comme exemple un jeu de données de près de deux millions de lignes et cinq variables (impossible d’ouvrir dans Excel, donc) : babynames, du package du même nom. Il s’agit des prénoms choisis pour des bébés américains entre 1880 et 2017. Nous utiliserons régulièrement la fonction system.time()[["elapsed]] pour calculer le temps qu’il faut pour exécuter une portion de code.

SciViews::R
(time <-system.time(
  babynames <- read("babynames", package = "babynames")
)[["elapsed"]])
# [1] 2.426

Il nous a fallu seulement 2.426 secondes pour charger ce jeu de données en mémoire depuis la machine qui a compilé ce cours en ligne. Essayez dans votre SciViews Box sur SaturnCloud pour voir ce que cela donne…, mais avant cela, vous devez installer le package {babynames} comme ceci :

install.packages("babynames")

Ensuite seulement, vous pourrez lire le jeu de données avec l’instruction read()un peu plus haut. Pour visualiser de manière compacte la structure du tableau, vous pouvez utiliser str() :

str(babynames) # Structure du tableau
# Classes 'data.table' and 'data.frame':    1924665 obs. of  5 variables:
#  $ year: num  1880 1880 1880 1880 1880 1880 1880 1880 1880 1880 ...
#  $ sex : chr  "F" "F" "F" "F" ...
#  $ name: chr  "Mary" "Anna" "Emma" "Elizabeth" ...
#  $ n   : int  7065 2604 2003 1939 1746 1578 1472 1414 1320 1288 ...
#  $ prop: num  0.0724 0.0267 0.0205 0.0199 0.0179 ...
#  - attr(*, "comment")= chr ""
#   ..- attr(*, "lang")= chr "en"
#   ..- attr(*, "lang_encoding")= chr "UTF-8"
#   ..- attr(*, "src")= chr "babynames::babynames"
#  - attr(*, ".internal.selfref")=<externalptr>

Nous avons deux colonnes de nombres réels (l’année year, qui aurait aussi pu être encodée en entiers et les proportions annuelles de chaque prénom dans prop). Il y a aussi une colonne d’entiers, n, le nombre de fois qu’un prénom apparaît chaque année, et deux colonnes textuelles : sex prenant les modalités "F" ou "M" et name, les prénoms.

Pour examiner la place occupée en mémoire vive par un objet R, vous pouvez utiliser lobstr::obj_size().

lobstr::obj_size(babynames)
# 74,917,056 B

Notre tableau occupe près de 75 millions d’octets (Bytes en anglais, d’où l’unité B). Si vous voulez convertir cette valeur en mégaoctets de manière exacte, vous devez savoir qu’un mégaoctet est exactement 1024 x 1024 octets, ou encore 2^20 octets = 1.048.576 octets. Appliquons cela à la sortie de lobstr::obj_size(), tout en déclassant le résultat obtenu pour éviter qu’il n’imprime le B à la fin qui ne serait plus l’unité correcte, puisqu’on sera cette fois-ci en MB. (essayez sans unclass() pour comprendre son rôle ici) :

(lobstr::obj_size(babynames) / 2^20) |> unclass()
# [1] 71.44647

Notre tableau occupe 71,5 mégaoctets en mémoire vive. Pour connaitre la quantité de mémoire vive totale occupée par R, les packages et les objets chargés en mémoire, regarder dans l’onglet Environment de RStudio. Dans la barre d’outils, il y a un petit graphique en parts de tarte qui l’indique avec la valeur en MiB ou GiB. Les MiB, contrairement aux MB correspondent, eux, à 1.000.000 d’octets. Confus ? Oui, ce n’est pas facile, il y a deux façons de calculer la taille en mémoire et sur le disque, avec le “kilo” octets qui vaut soit 1000, soit 1024. Soyez bien attentifs. Par exemple, la taille des disques durs et disques SSD sont généralement renseignés en GiB, des millions d’octets ou en TiB, des milliards d’octets, mais la taille des partitions sur le disque sont renseignées en GB, soit 1024 x 1024 x 1024 octets. Cela explique que vos partitions dépassent de peu les 900 gigaoctets sur votre disque pourtant bien. renseigné comme ayant une capacité de … 1 téraoctet ! Mais revenons à notre onglet Environment. Si vous cliquez sur la petite flèche noire pointant vers le bas à la droite du graphique en parts de tartes et de l’indication, vous avez un menu déroulant qui donne accès à un rapport plus complet de l’utilisation, de la mémoire vive par la session actuelle… C’est très pratique pour voir où on en est, surtout quand on manipule de très gros jeux de données ! Notez à présent la quantité de mémoire utilisée par la session R (le nombre qui apparaît dans la barre d’outils) après avoir chargé le tableau babynames. Nous allons maintenant faire une opération classique sur ce tableau. Nous allons résumer l’utilisation des prénoms toutes années confondues et les trier du plus utilisé au moins utilisé. Ce faisant, nous enregistrerons également le temps nécessaire à ce calcul dans R avec system.time().

(time <- system.time(
  babynames %>.%
    sgroup_by(., name) %>.%
    ssummarise(., total = fsum(n)) %>.%
    sarrange(., desc(total)) %->%
    names
)[["elapsed"]])
# [1] 0.156

Il a fallu moins d’une seconde sur la machine qui compile ce cours pour faire cette opération. Vous voyez donc que, non seulement, nous pouvons lire dans R très facilement des tableaux de plusieurs millions de lignes, mais qu’en plus, les remaniements classiques avec les fonctions “speedy” dont le nom commence par “s” sont également très rapides. Voici les dix prénoms les plus utilisés aux États-Unis :

head(names, n = 10)
# # A data.table: 10 x 2
# # Language:     en
#    name      total
#    <chr>     <int>
#  1 James   5173828
#  2 John    5137142
#  3 Robert  4834915
#  4 Michael 4372536
#  5 Mary    4138360
#  6 William 4118553
#  7 David   3624225
#  8 Joseph  2614083
#  9 Richard 2572613
# 10 Charles 2398453

Il s’agit de prénoms masculins. Cela veut donc dire que les principaux prénoms masculins sont bien plus (ré)utilisés que les principaux prénoms féminins pour lesquels il y a plus de diversité.

Notez à présent la quantité de mémoire utilisée par votre session R… Normalement, si vous êtes parti d’une session vide, vous restez encore largement en dessous du gigaoctet, soit très loin des 16Go disponibles. On a de la marge et aucune précaution particulière ne doit être prise avec des tableaux “aussi petits” pour R. Par contre, faites bien attention aux outils statistiques que vous utilisez dessus. Par exemple, ajuster un modèle linéaire généralisé avec variable aléatoire dans une telle quantité de données prendra soit un temps très, très long, soit fera planter la machine par manque de mémoire (il s’agit d’une technique qui demande énormément de calculs).

Lorsque vous manipulez des gros jeux de données dans R, pensez toujours à surveiller l’utilisation de la mémoire vive par la session en cours et à éliminer les objets qui ne sont plus utiles. Pour cela, vous pouvez utiliser rm(). Par exemple, pour effacer de la mémoire un table nommé df, et un vecteur x, vous entrez rm(df, x).

8.2.3 Format de stockage

Le format de stockage conseillé pour les tableaux cas par variables est le CSV (“coma-separated values”). Il s’agit d’un format qui existe depuis les années 1970 et qui est le plus universel pour l’échange et l’archivage des données21. Malheureusement, le format CSV n’est pas le plus économe en espace disque, car il stocke les données en clair (lisible par un humain) et donc de manière non compressée. Il est toutefois possible de compresser le fichier à l’aide de trois algorithmes différents : gz, bz2 ou xz, dans l’ordre de l’efficacité de compression, mais dans l’ordre inverse de temps de calcul pour compresser et décompresser les données. Ainsi la compression gz (le fichier portera l’extension .csv.gz) est le mode de compression le plus rapide, mais qui génère une compression moyenne. C’est un bon choix pour les tableaux de taille petite à moyenne et pour un usage en local. À l’opposé, le xz (fichiers .csv.xz) permet une bien meilleure compression, au prix d’un temps de calcul bien plus long à la compression (la décompression restant moyennement rapide). C’est un excellent choix pour les fichiers à archiver définitivement ou encore pour un échange via Internet. La compression .bz2 est intermédiaire, et donc utile si vous voulez un compromis entre les deux. Avec le format CSV combiné à la compression, vous pourrez déjà faire beaucoup.

À vous de jouer !
h5p

Nous allons maintenant enregistrer le tableau d’origine babynames sur le disque. Une fois n’est pas coutume, nous sortirons du dépôt et créerons un dossier temporaire nommé babynames_to_delete qui, comme son nom l’indique, est voué à être effacé22. Si vous exécutez ceci dans SaturnCloud assurez-vous d’avoir bien effacé les données, car vous n’avez que 10GiB de disque disponible au total.

testdir <- "../babynames_to_delete"
dir_create(testdir)

Voici le tableau où nous allons stocker les différentes données utiles à la comparaison :

timings <- dtx(
  format = c("csv", "csv.gz", "csv.bz2", "csv.xz"),
  ecriture_s = NA, lecture_s = NA, taille_Mo = NA)

Nous allons maintenant enregistrer notre tableau de données en CSV non compressé et le relire, tout en minutant les opérations d’écriture et de relecture. Le chemin d’accès au fichier est construit à l’aide de path().

csv_file <- path(testdir, "babynames.csv")
(timings$ecriture_s[1] <- system.time(
  write$csv(babynames, csv_file)
)[["elapsed"]])
# [1] 0.388

Et pour la lecture :

(timings$lecture_s[1] <- system.time(
  babynames2 <- read(csv_file)
)[["elapsed"]])
# Rows: 1924665 Columns: 5
# ── Column specification ────────────────────────────────────────────────────────
# Delimiter: ","
# chr (2): sex, name
# dbl (3): year, n, prop
# 
# ℹ Use `spec()` to retrieve the full column specification for this data.
# ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
# [1] 1.38

Nous récupérons également la taille du fichier :

timings$taille_Mo[1] <- file_size(csv_file) / 2^20 # Transformation en Mo

La vitesse d’écriture (et de lecture) dépend naturellement de R, mais aussi de la vitesse du disque utilisé. La machine où ce cours est compile est assez rapide. L’écriture prend moins d’une seconde. Dans SaturnCloud, c’est un peu plus lent, mais toujours très acceptable.

Nous faisons maintenant de même pour les trois formats de compression .gz, .bz2 et .xz. Compression GZ :

csv_gz_file <- path(testdir, "babynames.csv.gz")
(timings$ecriture_s[2] <- system.time(
  write$csv.gz(babynames, csv_gz_file)
)[["elapsed"]])
# [1] 1.429
(timings$lecture_s[2] <- system.time(
  babynames2 <- read(csv_gz_file)
)[["elapsed"]])
# Rows: 1924665 Columns: 5
# ── Column specification ────────────────────────────────────────────────────────
# Delimiter: ","
# chr (2): sex, name
# dbl (3): year, n, prop
# 
# ℹ Use `spec()` to retrieve the full column specification for this data.
# ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
# [1] 1.845
timings$taille_Mo[2] <- file_size(csv_gz_file) / 2^20 # Transformation en Mo

Compression BZ2 :

csv_bz2_file <- path(testdir, "babynames.csv.bz2")
(timings$ecriture_s[3] <- system.time(
  write$csv.bz2(babynames, csv_bz2_file)
)[["elapsed"]])
# [1] 3.192
(timings$lecture_s[3] <- system.time(
  babynames2 <- read(csv_bz2_file)
)[["elapsed"]])
# Rows: 1924665 Columns: 5
# ── Column specification ────────────────────────────────────────────────────────
# Delimiter: ","
# chr (2): sex, name
# dbl (3): year, n, prop
# 
# ℹ Use `spec()` to retrieve the full column specification for this data.
# ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
# [1] 2.271
timings$taille_Mo[3] <- file_size(csv_bz2_file) / 2^20 # Transformation en Mo

Compression XZ :

csv_xz_file <- path(testdir, "babynames.csv.xz")
(timings$ecriture_s[4] <- system.time(
  write$csv.xz(babynames, csv_xz_file)
)[["elapsed"]])
# [1] 19.879
(timings$lecture_s[4] <- system.time(
  babynames2 <- read(csv_xz_file)
)[["elapsed"]])
# Rows: 1924665 Columns: 5
# ── Column specification ────────────────────────────────────────────────────────
# Delimiter: ","
# chr (2): sex, name
# dbl (3): year, n, prop
# 
# ℹ Use `spec()` to retrieve the full column specification for this data.
# ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
# [1] 1.702
timings$taille_Mo[4] <- file_size(csv_xz_file) / 2^20 # Transformation en Mo

Cela donne le résultat suivant :

knitr::kable(timings)
format ecriture_s lecture_s taille_Mo
csv 0.388 1.380 46.528223
csv.gz 1.429 1.845 9.184883
csv.bz2 3.192 2.271 7.711994
csv.xz 19.879 1.702 6.235393

Comme vous pouvez le constater, la compression de ce type de tableau n’est pas négligeable. Déjà avec GZ, on obtient une compression cinq fois et avec XZ, cela monte à 7,5 fois et on passe de 46,5Mo à un peu plus de 6Mo. cependant, le temps de compression (écriture) est alors très long. Le format GZ est le plus rapide et le format BZ2 reste raisonnablement rapide en écriture avec un excellent niveau de compression, mais c’est le plus lent en lecture ensuite. Voici quelques directives basées sur ce résultat :

  • S’il y a beaucoup de place sur le disque et que le disque est rapide, le format non compressé CSV est à privilégier.

  • Si le disque est plus lent, et pour des données qui doivent être lues fréquemment, la compression GZ peut donner de meilleurs résultats en lecture, tout en économisant de la place sur le disque. À privilégier pour SaturnCloud, ou éventuellement, un dépôt GitHub (voir ci-dessous).

  • Pour un meilleur niveau de compression avec des résultats balancés entre écriture et lecture, pensez au format csv.bz2.

  • Pour une compression maximale, utilisez XZ. Ceci se justifie pour des fichiers à stocker sur un serveur sur le net. En effet, le temps de téléchargement sera ici le facteur le plus important et il est directement proportionnel à la taille du fichier.

Vous pouvez naviguer jusque dans votre dossier babynames_to_delete dans l’onglet Files pour voir vos différents fichiers. Enfin, lorsque vous avez fini de travailler avec de gros jeux de données, pensez à nettoyer votre environnement de travail, et éventuellement, vos dossiers des objets et fichiers devenus inutiles (important sur SaturnCloud !).

rm(babynames, babynames2)
fs::dir_delete(testdir)
À vous de jouer !
h5p

8.2.4 Gros jeux de données et git/GitHub

Si nous pouvons travailler confortablement dans R avec des tableaux de plusieurs millions de lignes, le système de gestion git et la plateforme de collaboration GitHub (ou les équivalents GitLab, BitBucket ou autres) n’aiment pas du tout, par contre, les gros fichiers que ces tableaux génèrent… et encore moins lorsque ces fichiers sont amenés à changer d’une version à l’autre ! Pour nos données brutes, nous pouvons raisonnablement penser que l’on peut avoir un fichier qui ne change pas. C’est déjà ça. Mais GitHub ne tolère pas des fichiers plus gros que 25Mo (si téléversés directement via le navigateur web) ou 100Mo via un push. Nous vous conseillons de considérer les techniques discutées ici dès que les fichiers atteignent ou dépassent de 10Mo.

Premièrement, envisagez de stocker vos données (compression csv.xz si possible) ailleurs où elles sont accessibles via un lien URL. Pour cela, il vous faut un serveur de fichiers. Des stockages cloud comme OneDrive, Google Drive, … ne permettent en général pas le téléchargement direct de fichiers via une URL (en tous cas pas sans ruser) : il faut obligatoirement passer par l’interface du système. Par contre, certains dépôts spécialisés dans les données comme Zeonodo vous permettent de créer un compte gratuit et d’ensuite partager des données, y compris de très, très gros fichiers (jusqu’à 50Go, voire plus avec autorisation à demander aux gestionnaires du site). Vous aurez en plus, un DOI, c’est-à-dire, un identifiant unique pour faciliter l’accès (voir module 10).

Deuxièmement, décidez si vous voulez toujours télécharger vos données depuis cette URL, ou si vous voulez utiliser un cache qui vous permettra de ne lire le fichier qu’une seule fois et de le stocker ensuite sur le disque. La fonction read() admet un argument cache= qui indique où stocker une copie du fichier. Le but est de le stocker dans le dépôt, mais dans un dossier qui sera ignoré de git, par exemple dans data/cache. Ainsi, vous éditerez le fichier .gitignore à la racine du dépôt en y ajoutant data/cache/. La barre oblique à la fin est importante pour spécifier qu’il s’agit d’un dossier dont on veut ignorer tout le contenu. À partir de ce moment, le fichier en cache ne sera pas repris dans les commits/pulls/ pushes.

Troisièmement, vous utilisez read() sur l’URL et indiquez le fichier désiré dans cache_file=. Cela donne quelque chose comme ceci (spécifiez toujours bien le format avec $… si vous chargez depuis une URL) :

dir_create("data/cache") # S"assurer que le dossier existe
big_data <- read$csv.xz("https://mysite.org/mybigdata.cs.xz",
  cache_file = "data/cache/mybigdata.csv.xz")

Une fois ceci mis en place, cela fonctionnera comme suit :

  • Vous clonez le dépôt depuis GitHub

  • À la première utilisation de read(), les données sont téléchargées depuis l’URL et l’objet big_data est créé en mémoire vive. Par la même occasion, data/cache/mybigdata.csv.xz est enregistré.

  • Comme le fichier est dans un dossier .gitignore, il n’est pas repris dans les commits.

  • Aux appels suivants de read(), les données ne sont plus rechargées depuis Internet, mais c’est le fichier en cache qui est lu directement.

Ce mécanisme est très efficace pour contourner les limitations de git et GitHub. Cependant dans SaturnCloud, pensez bien à la taille très restreinte du disque virtuel à votre disposition. Posez-vous la question si vous voulez l’encombrer avec le fichier en cache, ou s’il vaut mieux relire les données depuis Internet à chaque fois. Cela est souvent préférable.

À vous de jouer !
h5p

8.2.5 Bases de données à la rescousse

Si les données sont trop volumineuses pour tenir en mémoire, ou s’il n’est pas raisonnable de rapatrier toutes les données (par exemple, depuis Internet) pour au final filtrer ou regrouper les données vers un plus petit jeu de données, il vaut mieux travailler avec des outils spécialisés dans ce type de traitement : les bases de données. Voici un scénario décliné en deux variantes pour vous faire comprendre son intérêt. Vous avez un gros jeu de données (50Go, 9 colonnes par 1 milliard de lignes) sur une machine distante. Vous avez besoin d’une partie de ces données (1%) pour la suite de votre travail, soit 10 millions de lignes après agrégation des données.

Variante #1 : sans base de données

  • Données disponibles sur un serveur distant au format CSV, compressé XZ via une URL (admettons une compression x5 pour fixer les idées, taille du fichier : 10Go).

  • Utilisation de read() sur l’URL. Il faut au moins 128Go de mémoire vive pour traiter un tel tableau. Temps de téléchargement avec une bonne connexion Wifi (120Mbit/s) : 14min.

  • Filtrage et agrégation des données dans SciViews::R à l’aide de sfilter(), sgroup_by() et ssummarise() : < 1 sec.

Variante #2 : avec base de données

  • Données disponibles depuis un serveur distant utilisant une base de données PostgreSQL.

  • Connexion à la base de données depuis le R local : < 1 sec.

  • Utilisation des verbes filter(), group_by() et summarise() dans R avec le package {dbplyr} = code similaire au scénario #1.

  • Exécution d’une requête SQL dans la base de données à l’aide de collect_dtx(). {dbplyr} convertit automatiquement votre code en SQL et l’envoie au serveur PostgreSQL qui effectue le traitement directement sur le serveur : 1-2 sec.

  • Vous récupérez le tableau des données agrégées. Cela représente 1% des données brutes. Toujours avec la même connexion Wifi, cela prend environ 10 sec.

  • Comme vous récupérez un tableau de 10 millions de lignes, il ne nécessite plus que 0,5Go de mémoire vive. Même si vous n’avez pas plus de 8Go de RAM, vous pourrez travailler confortablement avec ce tableau dans R.

  • Vous vous déconnectez de la base de données.

La variante #2 est un peu plus compliquée, car elle implique la connexion à un serveur de base de données, mais comme vous ne devez pas récupérer l’ensemble des données sur votre propre PC, vous pouvez vous contenter de 8Go de RAM au lieu de 128Go. De plus, le traitement sera bien plus rapide (moins de 15 sec, contre près d’1/4h pour la variance #1). En effet, le téléchargement de l’ensemble des données nécessite de récupérer 10Go de données compressées. C’est le goulot d’étranglement dans le processus qui rend la variante #1 très, très lente. Vous l’aurez compris, dans un tel cas, la base de données est largement gagnante (variante #2).

Il existe deux grandes catégories de bases de données : les bases de données relationnelles ou non. Les premières utilisent un langage spécifique pour interroger et manipuler les données : le SQL ou “Structured Query Language”. Dans cette catégorie, nous retrouvons SQLite, PostgreSQL, MySQL/MariaDB, Microsoft Access et SQL Server, Oracle, … Les bases de données non relationnelles, dites “noSQL” utilisent des requêtes en JSON. La plus connue est MongoDB, mais il y en a plusieurs autres. Il existe des packages R permettant d’accéder à toutes ces bases de données et encore à bien d’autres.

À vous de jouer !
h5p

L’exemple présenté plus haut est fictif. Dans un premier temps, vous pouvez vous concentrer sur la façon de vous connecter et de vous déconnecter de la base de données. Ensuite, le package {dbplyr} permet d’utiliser vos verbes {dplyr} familiers tels que filter(), select(), mutate(), group_by() ou summarise(). Pour cela, nous allons toujours utiliser notre jeu de données babynames, mais nous créerons une base de données DuckDB en mémoire dans l’unique but de comprendre la logique de connexion et d’interrogation d’une base de données avec {dbplyr}. Nous approfondirons l’utilisation des bases de données dans le module suivant. Assurons-nous d’abord d’avoir SciViews::R et le jeu de données babynames chargés.

SciViews::R
babynames <- read("babynames", package = "babynames")

Nous créons une base de données DuckDB en mémoire et nous nous y connectons dans la foulée avec DBI::dbConnect(). Naturellement, la plupart du temps, la base de données sera créée sur le disque en indiquant un chemin d’accès à cette base pour l’argument dbdir= à DBI::dbConnect() (voir l’aide en ligne de ?duckdb::duckdb). Ensuite, nous recopions babynames dans la base de données avec copy_to(). En pratique, vous pouvez imaginer une base de données préexistante n’importe où sur Internet et qui possède déjà les données à utiliser.

# Création de la base de données et connexion
con <- DBI::dbConnect(duckdb::duckdb())
copy_to(con, babynames)

À présent, afin d’éviter de confondre la table “babynames” dans la base de données et le jeu de données dans R babynames, nous allons effacer ce dernier. Il ne subsistera donc plus que la version dans DuckBD !

rm(babynames) # Élimination du jeu de données dans la session R

Le package {DBI} vous offre une panoplie de fonctions pour interagir avec la base de données. Par exemple, pour lister les tables présentes dans la base de données :

DBI::dbListTables(con)
# [1] "babynames"

… et pour lister les variables (on parle de champs pour une base de données, ou fields en anglais), vous ferez :

DBI::dbListFields(con, "babynames")
# [1] "year" "sex"  "name" "n"    "prop"

Tout cela concerne bien les données dans DuckDB. Dans l’environnement R, nous n’avons que con la connexion à cette base de données, mais aucune autre donnée dans R lui-même. Nous pouvons créer un objet tbl qui va être lié à une table de notre base de données comme ceci :

db_babynames <- tbl(con, "babynames")
db_babynames
# # Source:   table<babynames> [?? x 5]
# # Database: duckdb_connection
#     year sex   name          n   prop
#    <dbl> <chr> <chr>     <int>  <dbl>
#  1  1880 F     Mary       7065 0.0724
#  2  1880 F     Anna       2604 0.0267
#  3  1880 F     Emma       2003 0.0205
#  4  1880 F     Elizabeth  1939 0.0199
#  5  1880 F     Minnie     1746 0.0179
#  6  1880 F     Margaret   1578 0.0162
#  7  1880 F     Ida        1472 0.0151
#  8  1880 F     Alice      1414 0.0145
#  9  1880 F     Bertha     1320 0.0135
# 10  1880 F     Sarah      1288 0.0132
# # … with more rows

Quand vous imprimez le contenu de db_babynames, vous pouvez avoir l’impression qu’il contient les données, mais il n’en est rien. Une requête sur les 10 premières lignes du tableau est faite en interne à la base de données. Ensuite ces 10 lignes sont affichées, et le résultat est jeté. Aucune donnée n’est réellement stockée dans db_babynames. D’ailleurs, ce n’est pas un data frame, mais une liste.

La particularité de l’objet db_babynames est donc qu’il ne fait qu’un lien avec la table de la base de données. Insistons bien : le contenu de la base n’est pas dans R, il appartient au serveur de la base de données, ici, à DuckDB qui gère sa base en mémoire vive. Cependant, vous pourrez l’utiliser presque comme si c’était un jeu de données habituel dans R. Mais à chaque fois que vous écrirez du code qui utilise une fonction “tidy”, l’action est mise en attente et traduite en langage SQL qui est le langage compris par le serveur de base de données. La fonction show_query() permet de visualiser ce code SQL à tout moment.

À titre d’exemple, reprenons notre petit traitement précédent. Il consiste à sommer les occurrences de tous les prénoms sur toutes les années et puis à les arranger du plus fréquent au moins fréquent. Pour rappel, voici le code que nous avions utilisé avec les fonctions “speedy” pour effectuer l’opération sur le data frame babynames en mémoire vive dans la session R :

babynames %>.%
  sgroup_by(., name) %>.%
  ssummarise(., total = fsum(n)) %>.%
  sarrange(., desc(total)) %->%
  names

Nous allons maintenant réécrire ce code en fonction “tidy” (attention : les fonctions “speedy” commençant par “s” et les fonctions “fast” commençant par “f” ne sont pas utilisables avec les objets tbl !).

db_babynames %>.%
  group_by(., name) %>.%
  summarise(., total = sum(n, na.rm = TRUE)) %>.%
  arrange(., desc(total)) ->
  query

Nous avons éliminé les “s” et “f” devant le nom des fonctions et nous assignons maintenant avec l’opérateur standard -> (très important) à l’objet query. Comme la fonction sum() n’élimine pas les valeurs manquantes par défaut, contrairement à fsum(), nous devons préciser na.rm = TRUE pour avoir le même traitement. À ce stade, rien n’est réalisé. La base de données n’est pas encore interrogée. Utilisons maintenant show_query() pour voir à quoi ressemble le code SQL équivalent, code qui sera exécuté dans la base de données ultérieurement.

show_query(query)
# <SQL>
# SELECT "name", SUM("n") AS "total"
# FROM "babynames"
# GROUP BY "name"
# ORDER BY "total" DESC

Même si vous n’avez jamais rencontré de code SQL auparavant, vous noterez qu’il est relativement compréhensible. Les mots clés utilisés sont proches des noms de fonction de {dplyr}. Ce n’est pas un hasard : {dplyr} s’est inspiré de l’existant, et notamment du langage SQL. À présent, il est temps d’effectuer notre requête dans la base de données et de récupérer le résultat. Pour cela, nous utilisons :collect_dtx()

names <- collect_dtx(query)
names
# # A data.table: 97310 x 2
#    name      total
#    <chr>     <dbl>
#  1 James   5173828
#  2 John    5137142
#  3 Robert  4834915
#  4 Michael 4372536
#  5 Mary    4138360
#  6 William 4118553
#  7 David   3624225
#  8 Joseph  2614083
#  9 Richard 2572613
# 10 Charles 2398453
# # … with 97,300 more rows

Notez bien les points suivants :

  1. Tout le calcul s’est fait dans DuckDB et non dans R.
  2. Le contenu de la table “babynames” (près de 2 millions de lignes par 5 colonnes) est resté dans la base de données.
  3. Seul le résultat a été transféré à R, soit un bien plus petit tableau de 97310 lignes par 2 colonnes. On est donc bien dans le scénario #2 vu plus haut. Ici, ce n’est pas très important, car les données sont de toute façon en mémoire vive, mais si le serveur de base de données était à distance et accessible via Internet, cela ferait gagner un temps considérable !
  4. Ni con, ni db_babynames, ni query ne sont des tableaux contenant des données. Ce sont tous des objets spéciaux créés pour dialoguer avec la base de données.
  5. Par contre, le résultat final obtenu via collect_dtx(), ou avec l’assignation alternative %<-% ou %->% est bien un data frame classique contenant des données (ici dans names).

Lorsque nous avons fini avec cette base de données, nous nous déconnectons avec DBI::dbDisconnect(), en indiquant shutdown = TRUE pour la fermer par la même occasion (dans le cas d’un serveur distant, nous n’avons pas besoin de le fermer ; il restera disponible pour d’autres requêtes ultérieures). Cela libère la mémoire vive occupée dans notre cas.

DBI::dbDisconnect(con, shutdown = TRUE)
Pour en savoir plus
  • Le site de {dbplyr} contient une introduction et plusieurs articles (en anglais) expliquant comment l’utiliser et comment cela fonctionne en interne.
  • R API for DuckDB :utilisation de DuckDB depuis R (en anglais).
  • Une présentation expliquant comment travailler avec de gros jeux de données (en anglais).
  • Une série de blogs intéressants expliquant un outil alternatif très utilisé : Spark, avec le package {sparklyr}, en anglais.
  • Le package {piggyback} propose une alternative intéressante pour stocker des données volumineuses dans un dépôt GitHub sans l’encombrer, via les “releases”, en anglais.
  • Le package {pins} permet de “publier” des données dans un format qui en facilite la réutilisation, y compris la gestion de différentes versions, en anglais.
À vous de jouer !

Effectuez maintenant les exercices du tutoriel B08Lb_bigd (Données volumineuses (big data)).

BioDataScience2::run("B08Lb_bigd")

Réalisez le travail B08Ib_zooscannet.

Travail individuel pour les étudiants inscrits au cours de Science des Données Biologiques II à l’UMONS (Q2 : analyse) à terminer avant le 2023-03-13 23:59:59.

Initiez votre projet GitHub Classroom

Voyez les explications dans le fichier README.md.


  1. Pensez toujours à adjoindre un dictionnaire des données à votre fichier CSV qui renseigne les métadonnées nécessaires pour expliciter chaque colonne du tableau… Nous avons traité cette question dans le module 6 du premier cours.↩︎

  2. Nous pourrions aussi utiliser un dossier temporaire créé comme sous-dossier de tempdir(), mais il ne sera alors pas si facile d’explorer les fichiers créés dans l’onglet Files de RStudio.↩︎