Formation R Données volumineuses

Support d’animation

29/09/2025

1 Ressources à contrôler : temps d’exécution et mémoire

1.1 Introduction

Ressources à contrôler : temps d’exécution et mémoire

Formation données volumineuses

Les données volumineuses ont un impact sur l’utilisation des ressources. Deux principales ressources doivent être contrôlées lors de la manipulation de données volumineuses :

  • Le temps d’exécution du code

  • La mémoire utilisée

En pratique, comment évaluer les ressources utilisées ?

1.2 Initialisation

Ressources à contrôler : temps d’exécution et mémoire

Formation données volumineuses

# Import packages --------------------------------------------------------------
library(dplyr)
library(doremifasol)
library(arrow)

# Parametrage ------------------------------------------------------------------

PATH_DATA <- "Data"
PATH_INPUT <- paste(PATH_DATA, "Input", sep = "/")
PATH_FINAL <- paste(PATH_DATA, "Final", sep = "/")

# Création des dossiers s'ils n'existent pas
for (path in c(PATH_DATA, PATH_INPUT, PATH_FINAL)) {
  dir.create(path, showWarnings = FALSE)
}

1.3 Temps d’execution

Ressources à contrôler : temps d’exécution et mémoire

Formation données volumineuses

Un temps d’exécution important peut être pénalisant à plusieurs niveaux :

  • Lors de la production du code, il est particulièrement pénible et inefficace de devoir attendre plusieurs minutes avant de pouvoir vérifier le résultat du code produit

  • Même une fois le code produit, il est préférable d’avoir un code s’exécutant rapidement pour être plus efficace dans les tâches dépendant du code en question

1.4 Mesure avec Sys.time

Ressources à contrôler : temps d’exécution et mémoire

Formation données volumineuses

Pour mesurer le temps d’exécution, il est possible d’utiliser Sys.time (fonction native à R) :

# Récupération de l'heure de début
debut <- Sys.time()

# Algorithme à mesurer
invisible(sqrt(1:1e6)) # invisible permet de ne pas afficher le résultat


# Récupération de l'heure de fin
fin <- Sys.time()

# Calcul du temps écoulé
temps_ecoule <- fin - debut
temps_ecoule
Time difference of 0.01022077 secs

1.5 Comparaison de temps d’exécution

Ressources à contrôler : temps d’exécution et mémoire

Formation données volumineuses

On préfèrera utiliser les fonctions de benchmark comme la fonction mark disponible à travers le package bench. La fonction mark permet de comparer précisément une ou plusieurs expressions sur plusieurs itérations avec différents indicateurs. Elle s’utilise comme suit :

expression1 <- quote({
  x <- 1:10
  x * 5
})

expression2 <- quote({
  x <- 0
  for (i in 1:5) x <- x + 1:10
  x
})

bench::mark(
  eval(expression1),
  eval(expression2)
)

1.6 Question 1

Ressources à contrôler : temps d’exécution et mémoire

Formation données volumineuses

Dans l’exercice suivant, on utilisera les données des naissances par code géographique entre 2010 et 2019 :

# Téléchargement des données de naissances
download.file(
  "https://www.insee.fr/fr/statistiques/fichier/1893255/base_naissances_2008-2024_geo2025_csv.zip",
  "naiss.zip"
)

files = unzip("naiss.zip", exdir = "data")
naiss = as_tibble(data.table::fread(files[[2]], ))

naiss |>
  filter(TIME_PERIOD == 2024) |>
  filter(OBS_STATUS == "A") |>
  filter(GEO_OBJECT == "COM") |>
  select(
    code_com = GEO,
    an = TIME_PERIOD,
    naiss = OBS_VALUE
  ) |>
  arrange(code_com, an) -> naissances1019

Ci-dessous se trouve un code qui extrait les deux premiers chiffres du code commune et les place dans une nouvelle colonne appelée code_com_reduit.

code_com_rs <- c()
for (x in naissances1019[["code_com"]]) {
  code_com_r <- substr(x, 1, 2)
  code_com_rs <- c(code_com_rs, code_com_r)
}

naissances1019$code_com_reduit <- code_com_rs
  • Ecrire un code permettant d’améliorer le temps d’exécution de l’algorithme

  • Chiffrer ce gain de temps

1.7 Correction 1

Ressources à contrôler : temps d’exécution et mémoire

Formation données volumineuses

# Définir les deux expressions à comparer
expression_avec_boucle <- quote({
  code_com_rs <- c()

  for (x in naissances1019[["code_com"]]) {
    code_com_r <- substr(x, 1, 2)
    code_com_rs <- c(code_com_rs, code_com_r)
  }
  naissances1019$code_com_reduit <- code_com_rs
})

expression_avec_substr <- quote({
  naissances1019$code_com_reduit <- substr(naissances1019$code_com, 1, 2)
})


# Comparer les performances avec bench::mark
resultats <- bench::mark(
  boucle = eval(expression_avec_boucle),
  substr = eval(expression_avec_substr)
)

# Afficher les noms des expressions dans le tibble de résultats
resultats$expression <- c("boucle", "substr")

# Afficher les résultats
print(resultats)
# A tibble: 2 × 13
  expression      min median `itr/sec` mem_alloc `gc/sec` n_itr  n_gc total_time
  <chr>      <bch:tm> <bch:>     <dbl> <bch:byt>    <dbl> <int> <dbl>   <bch:tm>
1 boucle         4.5s   4.5s     0.222    4.53GB    23.8      1   107       4.5s
2 substr       1.52ms 1.77ms   477.     272.48KB     2.00   239     1    500.5ms
# ℹ 4 more variables: result <list>, memory <list>, time <list>, gc <list>

1.8 Profiling

Ressources à contrôler : temps d’exécution et mémoire

Formation données volumineuses

La méthode dite du profiling permet d’identifier plus facilement les parties du code coûteuses en temps d’exécution.

Pour cela, on peut utiliser la fonction profvis afin d’identifier les parties lentes d’un code. Elle crée un widget qui va recenser chaque appel de fonction, et chaque étape du code. Cette fonction permet également de connaître la quantité de mémoire utilisée. L’analyse permet de décomposer les appels imbriqués lorsqu’une fonction en appelle d’autres ce qui permet d’avoir une vision macro du profiling et également d’aller plus dans le détail.

Afin de profiler un code, il suffit d’évaluer une expression à l’intérieur de profvis (comme pour mark, il est essentiel que l’évaluation se fasse au moment de l’appel de profvis).

On profile du point de vue de R : l’appel de programmes externes type DuckDB apparaîtra comme une boîte noire.

1.9 Question 2

Ressources à contrôler : temps d’exécution et mémoire

Formation données volumineuses

Profiler la fonction filtrer_moins_100 sur naissances1019 :

# Fonction rowSums à laquelle on ajoute une attente de 1 seconde pour allonger artificiellement l'exécution
long_sum <- function(..., na.rm = TRUE) {
  Sys.sleep(0.0001)
  return(sum(..., na.rm = na.rm))
}

# df désigne ici le dataframe contenant les données
# cette fonction retourne le dataframe df privé des lignes pour lesquelles la somme des naissances est inférieure à 100
# et trie le dataframe selon le nombre de naissances
filtrer_moins_100 <- function(df) {
  df_moins_100 <- df %>%
    group_by(code_com, an) %>% 
    summarise(nb_naissances = long_sum(naiss)) %>% 
    filter(nb_naissances < 100) %>%
    arrange(nb_naissances)


  return(df_moins_100)
}

1.10 Correction 2

Ressources à contrôler : temps d’exécution et mémoire

Formation données volumineuses

profvis::profvis({
  naissances_moins_100_profilage <- naissances1019 %>%
    filtrer_moins_100(.)
}) # On constate ici que l'appel de la fonction Sys.sleep correspond à la quasi totalité du temps d'exécution.

1.11 Mémoire 1/3

Ressources à contrôler : temps d’exécution et mémoire

Formation données volumineuses

La mémoire est une ressource essentielle en programmation. Dans ce contexte, la mémoire fait référence à la RAM (Random Access Memory), ou mémoire vive. On la distingue de la mémoire interne (ou disque dur ou disque SSD) dont le stockage est plus pérenne mais l’accès plus lent.

Pour mesurer la mémoire allouée à un objet, il est possible d’utiliser la fonction object.size de la librairie pryr, qui donne le nombre d’octets (de bytes) occupés par cet objet dans la mémoire :

## Espace occupé par une séquence
pryr::object_size(c(1, 3, 4, 5, 6))
96 B
pryr::object_size(c(1, NA, 4 , NA , 3)) # les NA prennent la même place qu'un nombre !!!
96 B
## Espace occupé par une fonction native de R
pryr::object_size(mean)
1.13 kB
## Espace occupé par une base de données
#  Cette base vient avec l'installation de RStudio
pryr::object_size(mtcars)
7.21 kB

1.12 Mémoire 2/3

Ressources à contrôler : temps d’exécution et mémoire

Formation données volumineuses

Cette fonction est plus précise que la fonction native object.size car elle tient compte des éléments partagés au sein d’un objet, entre autres. Autrement dit, la mémoire occupée par plusieurs objets n’est pas forcément égale à la somme de la mémoire occupée par chacun des objets.

Voici une démonstration :

x1 <- rep(1, 1e6)
y1 <- list(x1, x1, x1, x1)

# mémoire occupée estimée par la fonction native `object.size()`
object.size(x1)
8000048 bytes
object.size(y1) # La mémoire occupée estimée est 4 fois celle de x1
32000272 bytes
# mémoire occupée estimée par la fonction `pryr::object_size()`
pryr::object_size(x1)
8.00 MB
pryr::object_size(y1) # L'objet `y` contient 4 fois une référence qui pointe vers le même objet `x`.
8.00 MB

1.13 Mémoire 3/3

Ressources à contrôler : temps d’exécution et mémoire

Formation données volumineuses

Cependant, si nous initialisons des objets de façon indépendante, object_size calculera une allocation de mémoire forcément supérieure :

x2 <- rep(1, 1e6)
y2 <- list(rep(1, 1e6), rep(1, 1e6), rep(1, 1e6), rep(1, 1e6))

pryr::object_size(x2)
8.00 MB
pryr::object_size(y2)
32.00 MB

Des fois le méchanisme de lazy evaluation conduit à des résultats surprenants :

x3 <- 1:1e6
pryr::object_size(x3) # très léger car lazy evaluation 
680 B
y3 <- 2 * x3
pryr::object_size(x3) # faire fois deux force l'évaluation
4.00 MB

2 Méthodes d’optimisation

2.1 Introduction

Méthodes d’optimisation

Formation données volumineuses

La partie précédente nous a permis de comprendre quelles sont les ressources à contrôler lors de la production d’un code (temps d’exécution et mémoire) et comment mesurer l’utilisation de ces ressources. Nous allons dans cette partie présenter plusieurs méthodes permettant d’optimiser l’utilisation de ces ressources : l’utilisation du garbage collector, la parallélisation et le calcul distribué.

2.2 Garbage collection 1/2

Méthodes d’optimisation

Formation données volumineuses

Supposons par exemple que nous ayons créé ces objets :

A <- 0:1000
B <- 0:9999

Après l’exécution de ces lignes de code, les objets A et B deviennent accessibles dans l’environnement global. Cependant, nous observons une diminution de la performance de notre code après la création de ces objets. Cette dégradation est dû à leur utilisation importante de la mémoire.

On pourrait alors utiliser rm(list = ls())

Il se peut que la mémoire soit encore occupée, car la fonction rm a seulement supprimé tous les objets de données de l’espace de travail, mais elle n’a pas libéré la mémoire R.

2.3 Garbage collection 2/2

Méthodes d’optimisation

Formation données volumineuses

R s’occupe automatiquement d’allouer et de libérer la mémoire mais il se peut qu’il ne le fasse pas immédiatement. On peut utiliser la fonction gc (garbage collection) pour demander manuellement la libération de la mémoire inutilisée.

  • La fonction gc peut être appelée à tout moment pour déclencher une collecte des déchets.
  • Cela peut être utile dans des scénarios où vous souhaitez libérer la mémoire à un moment précis de l’exécution du programme.

2.4 Parallélisation 1/2

Méthodes d’optimisation

Formation données volumineuses

Le calcul parallèle est une forme de calcul qui permet d’effectuer plusieurs opérations simultanément en utilisant de nombreux processeurs.

R est, par défaut, monothread : il n’utilise qu’un processeur. Certains paquets lancent du calcul parallèle par eux-mêmes (data.table, duckdb…). On discute ici de comment paralléliser à la main.

Le calcul parallèle consiste en trois étapes :

  • une séparation d’un problème en plusieurs sous-problèmes à résoudre

  • la résolution simultanée des différents sous-problèmes par les processeurs

  • l’agrégation des résultats obtenus par les processeurs

2.5 Parallélisation 2/2

Méthodes d’optimisation

Formation données volumineuses

Cette méthode de calcul a été utilisée avec succès dans de nombreux domaines, tel que la génération de mots de passe. En effet, si l’utilisateur doit rechercher les combinaisons possibles de mots de passe commençant par les lettres A,B ou C, il peut créer trois sous-problèmes, un par lettre, afin de les répartir entre trois processeurs.

Systèmes d’exploitation

Certaines fonctions de ces packages ne sont pas disponibles pour tout les systèmes d’exploitations (notamment Windows). Ainsi, si vous souhaitez éxecuter du code en local puis sur une machine virtuelle du SSPCloud, il est possible que vous n’ayez pas les mêmes erreurs.

NB : les fonctions parallel::mclapply et doParallel::foreach fonctionnent sur les systèmes d’exploitations Linux et Windows, ce qui n’est pas le cas de la fonction parallel::parLapply.

2.6 Les packages future et future.apply

Méthodes d’optimisation

Formation données volumineuses

Le traitement parallèle peut être exécuté à l’aide des librairies future et future.apply. Après l’import des librairies, il suffit de préciser le mode de traitement souhaité à l’aide de la fonction plan :

# install.packages("future")
# install.packages("future.apply")

library(future.apply)

future::plan(sequential) # Traitement séquentiel "classique" des données

future::plan(multisession, .cleanup = TRUE) # Traitement en parallèle dans d'autres sessions R en arrière-plan

future::availableCores() # Pour déterminer le nombre total de sessions R utilisées en arrière-plan.

Un fois le calcul parallèle effectué, il ne faut pas oublier de revenir en traitement séquentiel avec future::plan(sequential).

2.7 Exemple

# Définir la fonction de calcul
calc_sum_of_squares <- function(n) {
  return(sum((1:n)^2))
}

# Créer un vecteur de valeurs n
n_values <- 1:10000

# Approche séquentielle

results_sequential <- quote({
  lapply(n_values, FUN = calc_sum_of_squares)
})


# Approche parallèle avec future.apply

results_parallel <- quote({
  future.apply::future_lapply(n_values, FUN = calc_sum_of_squares)
})


# Comparer les temps d'exécution

resultats <- bench::mark(
  sequentiel = eval(results_sequential),
  parrallel = eval(results_parallel),
  check = FALSE
)

# Afficher les noms des expressions dans le tibble de résultats
resultats$expression <- c("séquentiel", "parallèle")

# Afficher les résultats
print(resultats)
# A tibble: 2 × 13
  expression      min median `itr/sec` mem_alloc `gc/sec` n_itr  n_gc total_time
  <chr>      <bch:tm> <bch:>     <dbl> <bch:byt>    <dbl> <int> <dbl>   <bch:tm>
1 séquentiel    349ms  397ms      2.52     573MB     17.6     2    14      795ms
2 parallèle     273ms  358ms      2.79     589MB     12.6     2     9      717ms
# ℹ 4 more variables: result <list>, memory <list>, time <list>, gc <list>

2.8 Le package parallel

Méthodes d’optimisation

Formation données volumineuses

La fonction mclapply permet simplement de remplacer la fonction lapply par une version parallélisée :

library("parallel")
set.seed(123)
lapply_vectorise <- function(n) {parallel::mclapply(1:10^5, function(i) runif(n))}

temps_lapply_vect <- system.time({vecteur1 <- lapply_vectorise(10000)})

lapply_non_vectorise <- function(n) {lapply(1:10^5, function(i) runif(n))}

temps_lapply <- system.time({vecteur2 <- lapply_non_vectorise(10000)})
temps_lapply[3]
elapsed 
 34.436 
temps_lapply_vect[3]
elapsed 
 46.437 

Cependant, il arrive que les coûts de distribution de calculs sur plusieurs cœurs soient plus importants que le temps de calcul non parallélisé et qu’il ne soit pas pertinent d’y avoir recours

2.9 Le package doParallel 1/2

Le package foreach issu de doParallel propose également des fonctions pour l’implémentation simple de boucles parallélisées.

Notamment, la fonction foreach() a un comportement similaire à une boucle for classique, mais elle est parallélisée et retourne un résultat. On utilise %dopar% pour spécifier que l’on souhaite que la boucle soit parallélisée. Autrement, on peut le remplacer par l’itérateur %do%.

# install.packages("doParallel")
library("doParallel")

foreach(i = 1:4, j = 1:10) %dopar%
  sqrt(i + j)
[[1]]
[1] 1.414214

[[2]]
[1] 2

[[3]]
[1] 2.44949

[[4]]
[1] 2.828427

2.10 Le package doParallel 2/2

Il est également possible de créer un “cluster” pour paramétrer le backend parallèle et choisir le nombre de cœurs sur lequel on veut que la fonction soit parallélisée.

# install.packages("foreach")
library(doParallel)
library(foreach)

# Configurer le backend parallèle
cl <- makeCluster(2) # Utilisation de 2 cœurs pour le traitement parallèle
registerDoParallel(cl)

# Traitement
vecteur_entree <- 1:10

resultat <- foreach(i = vecteur_entree, .combine = c) %dopar% {
  i^2
}

# Arrêter le backend parallèle
stopCluster(cl)

2.11 Question 3

Méthodes d’optimisation

Formation données volumineuses

Transformer les fonctions et boucles suivantes en leur équivalent parallélisé :

# 1 - de deux façons
lapply(1:3, sqrt)

# 2
m <- matrix(rnorm(9), 3, 3)
colMeans(m)

# 3
rnorm(3)

2.12 Correction 3

Méthodes d’optimisation

Formation données volumineuses

# 1 - équivalent de lapply(1:3, sqrt)
foreach(i = 1:3) %dopar%
  sqrt(i)

mclapply(1:3, sqrt)

# 2 - équivalent de colMeans(m)
m <- matrix(rnorm(9), 3, 3)
foreach(i = 1:ncol(m), .combine = c) %dopar%
  mean(m[, i])

# 3 - équivalent de rnorm(3)
times(3) %dopar% rnorm(1)

2.13 Calcul distribué : Principe

Méthodes d’optimisation

Formation données volumineuses

Il est possible que des problèmes complexes ne puissent pas être résolus en utilisant un seul ordinateur, même avec l’aide du calcul parallèle. Il est alors possible d’utiliser plusieurs machines regroupées au sein d’un même système : un cluster. Chaque ordinateur d’un système distribué est appelé nœud.

Les services de cloud computing permettent généralement de créer des clusters.

Enfin, si vous avez accès à plusieurs machines, vous pouvez essayer de mettre en place un cluster pour les utiliser, par exemple avec ce code :

future::plan(cluster, workers = c("n2", "n5", "n6", "n7", "n9")) # On utilise 5 noeuds

2.14 La librairie sparklyr 1/3

Méthodes d’optimisation

Formation données volumineuses

sparklyr est une librairie open-source qui fournit une interface entre R et Apache Spark. L’utilisateur peut tirer parti des capacités de Spark dans un environnement R moderne, grâce à la capacité de Spark à interagir avec des données distribuées avec une faible latence. sparklyr constitue donc un outil efficace pour interagir avec de grands ensembles de données dans un environnement interactif.

sparklyr vs arrow et duckdb

La plupart du temps, il est recommandé d’utiliser arrow et duckdb, qui seront présentés en séquence 2 car plus simples à utiliser. Cependant, dans des cas de données très volumineuses où arrow et duckdb montrent leurs limites, on peut gagner beaucoup de temps à passer par sparklyr

2.15 La librairie sparklyr 2/3

Méthodes d’optimisation

Formation données volumineuses

Pour installer sparklyr sur l’ordinateur et se connecter à Spark, on utilise le code suivant :

# Installation de la librairie`sparkly\` install.packages("sparklyr")

# Import de la librairie
library(sparklyr)

# Installation de spark
# spark_install()

# Connexion à un cluster Spark à un noeud en local
sc <- spark_connect(master = "local")

# Deconnexion de Spark
spark_disconnect(sc)

Une fois la connection établie, une icône Spark apparaîtra dans l’onglet ‘Connections’ de RStudio.

2.16 La librairie sparklyr 3/3

Méthodes d’optimisation

Formation données volumineuses

sparklyr permet d’exécuter du code R à l’échelle de votre cluster Spark grâce à la fonction spark_apply.

spark_apply applique une fonction R à un objet Spark (typiquement, un dataframe Spark). Les objets Spark sont partitionnés afin de pouvoir être distribués sur un cluster.

Note

Partitionner un fichier revient, comme son nom l’indique, à le “découper” selon une clé de partitionnement, qui prend la forme d’une ou de plusieurs variables.

Vous pouvez utiliser spark_apply avec les partitions par défaut ou vous pouvez définir vos propres partitions avec l’argument group_by. Votre fonction R doit retourner un autre Spark dataframe. spark_apply exécutera votre fonction R sur chaque partition et retournera un seul Spark dataframe.

3 Quiz

3.1 Question 1

Quiz

Formation données volumineuses

Qu’est-ce qu’un garbage collection ?

  • A) Le garbage collection est un processus utilisé pour libérer manuellement la mémoire occupée par des objets inutilisés.
  • B) Le garbage collection est un processus utilisé pour collecter automatiquement la mémoire occupée par des objets supprimés.
  • C) Le garbage collection est un processus utilisé pour libérer automatiquement la mémoire occupée par des objets inutilisés.

3.2 Correction 1

Quiz

Formation données volumineuses

La réponse correcte est la réponse :

  • A) Le garbage collection est un processus utilisé pour libérer manuellement la mémoire occupée par des objets inutilisés.

3.3 Question 2

Quiz

Formation données volumineuses

Quelle est la principale différence entre le calcul parallèle et le calcul distribué ?

  • A) Le calcul parallèle implique l’utilisation de plusieurs processeurs sur la même machine, tandis que le calcul distribué implique l’utilisation de plusieurs machines distinctes.

  • B) Le calcul distribué utilise des algorithmes parallèles, mais le calcul parallèle ne peut pas être distribué sur plusieurs machines.

  • C) Le calcul parallèle et le calcul distribué sont des termes interchangeables sans distinction significative.

  • D) Le calcul distribué est spécifique à la programmation orientée objet, tandis que le calcul parallèle concerne la programmation impérative.

3.4 Correction 2

Quiz

Formation données volumineuses

La réponse correcte est la réponse :

  • A) Le calcul parallèle implique l’utilisation de plusieurs processeurs sur la même machine, tandis que le calcul distribué implique l’utilisation de plusieurs machines distinctes.

3.5 Question 3

Quiz

Formation données volumineuses

Comment pouvez-vous paralléliser l’application d’une fonction à l’aide de future.apply ?

  • A) En utilisant la fonction parallel_apply du package future.apply.

  • B) En enveloppant la fonction à appliquer avec future_lapply.

  • C) En spécifiant l’option parallel = TRUE dans la fonction apply.

  • D) En utilisant uniquement les fonctionnalités intégrées de R sans nécessiter de packages supplémentaires.

3.6 Correction 3

Quiz

Formation données volumineuses

La réponse correcte est la réponse :

  • B) En enveloppant la fonction à appliquer avec future_lapply.

4 Exercices complémentaires

4.1 Question 1

Exercices complémentaires

Formation données volumineuses

Comparer l’efficacité des fonctions suivantes sur la base naissances1019. Quel est leur temps médian d’exécution et leur utilisation de la mémoire ?

  • length
  • ncol

4.2 Correction 1

Exercices complémentaires

Formation données volumineuses

On utilise la fonction bench::mark pour calculer le temps d’exécution médian et la mémoire utilisée :

bench::mark(
  a_eval_1 = length(naissances1019),
  a_eval_2 = ncol(naissances1019)
)

4.3 Question 2

Exercices complémentaires

Formation données volumineuses

Créer une fonction R qui effectue une analyse statistique simple et prenant en argument un dataframe et un nom de colonne. Par exemple, vous pouvez calculer la moyenne, l’écart-type ou tout autre indicateur pertinent.

4.4 Correction 2

Exercices complémentaires

Formation données volumineuses

# Créez une fonction pour calculer la moyenne d'une colonne spécifique
calculate_mean <- function(data, column_name) {
  column <- data[[column_name]]
  return(mean(column, na.rm = TRUE))
}

4.5 Question 3

Exercices complémentaires

Formation données volumineuses

Utiliser la parallélisation avec future.apply pour calculer la moyenne (ou autre statistique calculée par la fonction précédente) de différentes colonnes en parallèle. Vous pouvez spécifier plusieurs colonnes à analyser.

4.6 Correction 3

# Spécifiez les noms des colonnes à analyser
columns_to_analyze <- c("an", "naiss")


# Utilisez future_lapply pour appliquer la fonction en parallèle
parallel_means <- future.apply::future_lapply(columns_to_analyze, FUN = calculate_mean, data = naissances1019)

4.7 Question 4

Exercices complémentaires

Formation données volumineuses

Enfin, afficher les résultats des moyennes calculées en parallèle.

4.8 Correction 4

Exercices complémentaires

Formation données volumineuses

# Récupérez les résultats
results <- as.data.frame(parallel_means)
colnames(results) <- columns_to_analyze

# Affichez les moyennes calculées
print(results)
    an    naiss
1 2024 18.91919

4.9 Question 5

Exercices complémentaires

Formation données volumineuses

Les deux codes suivants produisent le même résultat : ils retournent tous deux un data.frame contenant uniquement les lignes pour lesquels la somme des naissances est strictement inférieure à 100.

Selon vous, lequel de ces deux codes a le temps d’exécution le plus rapide ? Vérifier cette hypothèse.

naissances_moins_100_rowSums <- naissances1019 %>%
  group_by(code_com, an) %>% summarise(total = sum(naiss)) %>% 
  filter(total < 100)
filter_code_com_moins_100 <- function(x) {
  new_data <- x

  # Création d'une colonne contenant le nombre de naissances pour chaque code_com/ab
  new_data = new_data %>%  group_by(code_com, an) %>% mutate(total = sum(naiss))
 

  return(filter(new_data, total < 100)[, 1:ncol(x)])
}

naissances_moins_100_function <- naissances1019 %>%
  filter(.$code_com %in% filter_code_com_moins_100(.)$code_com)

4.10 Correction 5

Exercices complémentaires

Formation données volumineuses

Le premier code effectue ce que l’on souhaite. Le second utilise une fonction qui va venir filtrer les données via la création d’une colonne total. On fait ensuite appel à la fonction filter pour sélectionner les lignes ayant un code_com appartenant au data.frame retourné par la fonction. Instinctivement, on pense que la plus rapide est la première. Vérifions-le en comparant les temps d’exécution ainsi que la quantité de ressources consommée. Pour cela, nous utilisons la fonction mark du package bench.

4.11 Correction 5

Exercices complémentaires

Formation données volumineuses

# A tibble: 2 × 13
  expression      min median `itr/sec` mem_alloc `gc/sec` n_itr  n_gc total_time
  <bch:expr>    <bch> <bch:>     <dbl> <bch:byt>    <dbl> <int> <dbl>   <bch:tm>
1 directly      360ms  378ms      2.64    9.11MB    11.9      2     9      756ms
2 with_function 306ms  321ms      3.12    11.7MB     9.35     2     6      642ms
# ℹ 4 more variables: result <list>, memory <list>, time <list>, gc <list>

La fonction bench::mark s’utilise pour comparer entre autres les temps d’exécution (minimum et médian) et l’utilisation des ressources machine pour plusieurs expressions.

Effectivement, le premier code est le plus rapide (il est deux fois plus rapide que le second) et consomme également moins de mémoire. Attention toutefois, ce n’est pas toujours le cas. Lorsqu’on a deux versions d’un même code et que l’on veut conserver la plus adaptée, comparer ces aspects-là est utile. Par exemple, un code qui consommerait moins de ressources mais en étant un peu moins rapide peut tout à fait être le meilleur choix selon le contexte (ressources disponibles, utilisation du code, etc.).

4.12 Question 6

Exercices complémentaires

Formation données volumineuses

Parmi les deux fonctions suivantes, laquelle est la plus rapide ? Pourquoi ? Pouvez-vous le vérifier à l’aide de la fonction bench::mark ?

mean1 <- function(x) mean(x)
mean2 <- function(x) sum(x) / length(x)

4.13 Correction 6

Exercices complémentaires

Formation données volumineuses

x <- runif(1e5)

bench::mark(
  mean1(x),
  mean2(x)
)[c("expression", "min", "median", "itr/sec", "n_gc")]

Bien que le résultat soit surprenant, la fonction mean1 est plus lente que la fonction mean2.

En effet, la fonction mean effectue deux passages sur le vecteur x. Lors du premier passage, elle calcule la somme de tous les éléments de x, et lors du deuxième passage, elle divise cette somme par la longueur totale de x pour obtenir la moyenne. Cette approche a pour objectif d’améliorer la précision numérique.

En revanche, la méthode sum / length effectue le calcul en une seule passe.

5 Approfondissements

5.1 Utilisation de C++ avec R

Approfondissements

Formation données volumineuses

Le but de cette section n’est pas de dispenser un cours détaillé de C++, mais plutôt de :

  • Présenter les principales similarités et différences entre R et C++ ;

  • Reconnaître les cas où il est pertinent de recourir à du code C++ pour optimiser son code R ;

  • Être capable de réemployer des extraits de code RCPP ou C++ et les adapter si besoin.

5.2 Qu’est ce que Rcpp ?

Approfondissements

Formation données volumineuses

Rcpp est un package qui permet l’intégration de code C++ dans R. Il offre notamment une façon simple d’écrire des fonctions C++ qui peuvent être appelées comme des fonctions R.

Zoom : quels sont les différences entre C++ et R ?

C (et donc C++) est un langage compilé alors que R est un langage interprété.

La principale implication de cette différence est que le code C++, étant donné qu’il communique directement avec le CPU (via ce que l’on appelle du code machine), est beaucoup plus rapide que le code R qui passe par plusieurs étapes de vérifications. Cependant, le code C++ est beaucoup plus long à programmer, et il demande d’être beaucoup plus précis. Il est notamment beaucoup plus complexe à débugger. Ainsi, si l’on souhaite intégrer du code C++, il est important de faire très attention à l’objectif de la fonction, aux entrées et sorties.

5.3 Quand avoir recours à Rcpp ? 1/2

Approfondissements

Formation données volumineuses

La plupart des fonctions R base sont codées à partir de fichiers C ou Fortran et sont donc déjà très performantes. En conséquence, le recours à C++ ne doit pas se faire de façon systématique.

On va privilégier son recours uniquement quand on souhaite :

  • créer un package, qui implique une utilisation importante et fréquente des fonctions, et pour qui la performance est importante

  • créer un programme que l’on va avoir besoin de exécuter souvent et / ou sur des bases de données volumineuses

Si l’on se retrouve dans l’un de ces deux cas, il faut ensuite analyser si l’utilisation de C++ est pertinente.

5.4 Quand avoir recours à Rcpp ? 2/2

Approfondissements

Formation données volumineuses

Le code C++ va notamment permettre :

  • une performance plus importante : Le fait d’avoir du code machine permet d’être plus rapide

  • l’accès aux bibliothèques C++ : Rcpp permet d’utiliser des bibliothèques C++ existantes dans le code R

Les problématiques typiques que C++ permet de résoudre sont les suivantes :

  • Les boucles qui ne peuvent pas être facilement vectorisées parce que les itérations sont dépendantes entre elles

  • Les problèmes qui nécessitent des structures de données et des algorithmes avancés que R ne fournit pas :

  • Les fonctions récursives ou les problèmes qui impliquent d’appeler des fonctions un grand nombre de fois.

5.5 La fonction cppFunction

Approfondissements

Formation données volumineuses

La fonction cppFunction permet de coder directement dans le fichier .R une fonction en C++ qui est ensuite utilisable normalement sur R.

Ci-dessous la comparaison entre le calcul de la distance euclidienne, calculée avec R :

pdistR <- function(x, ys) {
  return(sqrt((x - ys)^2))
}

et calculée avec C++ :

cppFunction("NumericVector pdistC(double x, NumericVector ys) {
  int n = ys.size();
  NumericVector out(n);

  for(int i = 0; i < n; ++i) {
    out[i] = sqrt(pow(ys[i] - x, 2.0));
  }
  return out;
}")

5.6 Question 1

Approfondissements

Formation données volumineuses

A quelles fonctions R correspondent ces fonctions C++ ?

# Fonction 1
cppFunction("double f1(NumericVector x) {
  int n = x.size();
  double y = 0;

  for(int i = 0; i < n; ++i) {
    y += x[i] / n;
  }
  return y;
}")


# Fonction 2
cppFunction("NumericVector f2(NumericVector x) {
  int n = x.size();
  NumericVector out(n);

  out[0] = x[0];
  for(int i = 1; i < n; ++i) {
    out[i] = out[i - 1] + x[i];
  }
  return out;
}")

5.7 Correction 1

Approfondissements

Formation données volumineuses

# Fonction 1
cppFunction("double f1(NumericVector x) {
  int n = x.size();
  double y = 0;

  for(int i = 0; i < n; ++i) {
    y += x[i] / n;
  }
  return y;
}")

res_f1C <- f1(c(15, 16, 18, 10, 12, 16, 18, 9))
res_meanR <- mean(c(15, 16, 18, 10, 12, 16, 18, 9))

print("Egalité des résultats de la fonction 1 en C++ et mean de R :")
print(res_f1C == res_meanR)

# Fonction 2
cppFunction("NumericVector f2(NumericVector x) {
  int n = x.size();
  NumericVector out(n);

  out[0] = x[0];
  for(int i = 1; i < n; ++i) {
    out[i] = out[i - 1] + x[i];
  }
  return out;
}")

res_f2C <- f2(c(15, 16, 18, 10, 12, 16, 18, 9))
res_cumsumR <- cumsum(c(15, 16, 18, 10, 12, 16, 18, 9))

print("Egalité des résultats de la fonction 2 en C++ et cumsum R :")
print(res_f2C == res_cumsumR)

5.8 La fonction sourceCpp

Approfondissements

Formation données volumineuses

La fonction sourceCpp permet d’écrire un code C++ dans un fichier .cpp séparé et y faire appel afin d’utiliser les objets créés sur R directement.

Il faut toujours initier le fichier .cpp avec la ligne #include \<Rcpp.h\> using namespace Rcpp; et précéder chaque fonction que l’on veut pouvoir utiliser sur R de la ligne // \[\[Rcpp::export\]\], comme suit :

# include \<Rcpp.h\> using namespace Rcpp;

// \[\[Rcpp::export\]\]
NumericVector f2(NumericVector x) {
  int n = x.size();
  NumericVector out(n);

  out[0] = x[0];
  for(int i = 1; i < n; ++i) {
    out[i] = out[i - 1] + x[i];
  }
  return out;
}

L’appel au fichier se fait ensuite dans un fichier .R classique :

sourceCpp("path/to/file.cpp") # Ajuster le chemin en fonction de l'emplacement du fichier.

5.9 La fonction evalCpp

Approfondissements

Formation données volumineuses

La fonction evalCpp permet d’évaluer le résultat de l’exécution d’une seule expression C++. Cela en fait un bon outil pour l’expérimentation interactive, notamment avant de se lancer dans la programmation d’une fonction en C++.

# Résultats égaux
evalCpp("TRUE + TRUE")
TRUE + TRUE

# Résultats différents
evalCpp("NAN > 1")
NA > 1

6 Bibliographie

6.1 Bibliographie

Il existe des packages C++ pour coder plus rapidement des fonctions mathématiques usuelles. Parmi ceux-ci :

  • Armadillo, avec la possibilité avec possibilité d’utiliser directement RcppArmadillo
    • Ci-après quelques ressources : 1, 2

7 Les outils pour traiter des données volumineuses (2/2)

7.1 Objectifs principaux

Formation données volumineuses

La séquence précédente a permis de présenter les ressources à optimiser lors de la production de code et en particulier de la manipulation de données volumineuses (temps de calcul et mémoire) et des méthodes pour mesurer l’utilisation de ces ressouces. Elle a permis de présenter deux méthodes de calcul (calcul parallélisé et distribué) utiles dans des contextes où l’utilisation de ces ressources peut être importante, notamment lors de la manipulation de données volumineuses.

Cette séquence présente des structures de données répondant plus spécifiquement aux sujets de stockage et de lecture des données volumineuses : arrow et duckDB. Ces formats de données sont particulièrement adaptés aux données volumineuses, permettant ainsi d’enrichir la palette d’outils mobilisables par les stagiaires pour manipuler efficacement des données volumineuses. De plus, ils sont applicables dans différents langages de programmation, comme R mais aussi Python par exemple.

7.2 Mise en place

# Import packages --------------------------------------------------------------
library(dplyr)
library(doremifasol)
library(arrow)

# Parametrage ------------------------------------------------------------------

PATH_DATA <- "Data"
PATH_INPUT <- paste(PATH_DATA, "Input", sep = "/")
PATH_FINAL <- paste(PATH_DATA, "Final", sep = "/")

# Création des dossiers s'ils n'existent pas
for (path in c(PATH_DATA, PATH_INPUT, PATH_FINAL)) {
  dir.create(path, showWarnings = FALSE)
}

8 Le package arrow

8.1 Présentation

Le package arrow

Formation données volumineuses

Le format arrow est un type d’objet manipulable dans R et optimisé pour traiter des données volumineuses. En effet, le format arrow permet de traiter les données hors mémoire : les données ne sont pas chargées dans la mémoire vive, permettant ainsi de résoudre la problématique de mémoire trop limitée face à des données volumineuses.

Le moteur de calcul de arrow a été précurseur mais est aujourd’hui moins complet que celui de duckdb. Cf la fiche UtilitR de comparaison.

8.2 Format Parquet

Le package arrow

Formation données volumineuses

Le format arrow est associé au format de fichier appelé Parquet pour le stockage. Les fichiers Parquet se révèlent très utiles pour stocker des données volumineuses, notamment pour les raisons suivantes :

  • La taille de ces fichiers est optimisée : ils occupent moins d’espace de stockage

  • Les fichiers Parquet sont adaptés au partitionnement, méthode permettant de découper un fichier volumineux en plusieurs fichiers moins volumineux

  • Les types des colonnes sont contenus dans les métadonnées. Ainsi on peut charger un fichier Parquet sans préciser le type de chaque variable.

  • Le format est opensource donc facilement utilisable par les logiciels.

Format parquet

Le format parquet devient la norme à l’INSEE. Il est donc impératif de maîtriser ce format. cf. Note sur Symphonie

8.3 Ecriture de fichiers Parquet

Le package arrow

Formation données volumineuses

L’exemple suivant illustre l’écriture d’un fichier Parquet avec la fonction write_dataset de la librairie arrow (le fichier utilisé est suffisamment léger pour qu’on puisse écrire les données dans un seul fichier Parquet) :

# Téléchargement du fichier zip
download.file(
  "https://www.insee.fr/fr/statistiques/fichier/7633685/dpt2022_csv.zip",
  destfile = file.path(PATH_INPUT, "dpt2022_csv.zip")
)

# Décompression du fichier zip
unzip(file.path(PATH_INPUT, "dpt2022_csv.zip"), exdir = PATH_FINAL)

# Lecture du fichier CSV
dpt2022 <- read.csv(file.path(PATH_FINAL, "dpt2022.csv"), sep = ";")

# Supprime le fichier s'il existe déjà
filename <- file.path(PATH_FINAL, "dpt2022.parquet")
if (file.exists(filename)) unlink(filename)

# Écriture des données en format Parquet
arrow::write_dataset(
  dpt2022,
  path = file.path(PATH_FINAL, "dpt2022.parquet")
)

8.4 Questions

Le package arrow

Formation données volumineuses

  • Comparer dans l’explorateur de fichiers la taille de dpt2022.csv et dpt2022.parquet.
  • Quel autre avantage de ce format remarquez-vous ?

Il existe également le package parquetize qui permet d’écrire et lire des fichiers parquet.

8.5 Partitionnement 1/3

Le package arrow

Formation données volumineuses

Un autre façon d’optimiser les ressources de l’ordinateur s’appelle le partitionnement.

Partitionner un fichier revient, comme son nom l’indique, à le “découper” selon une clé de partitionnement, qui prend la forme d’une ou de plusieurs variables. Comme évoqué dans la fiche UtilitR sur le format Parquet, cela signifie en pratique que l’ensemble des données sera stocké sous forme d’un grand nombre de fichiers Parquet (un fichier par valeur des variables de partitionnement).

Par exemple, il est possible de partitionner un fichier national par département : on obtient alors un fichier Parquet pour chaque département. On peut également partitionner selon plusieurs variables, par exemple le département et le sexe : on obtient alors pour chaque département un fichier par sexe. L’ordre des variables de partitionnement est alors important car le découpage se fait dans l’ordre de ces variables (ici, on a un premier découpage par département et dans chaque partition par département, on partitionne par sexe)

8.6 Partitionnement 2/3

Le package arrow

Formation données volumineuses

Cela permet d’avoir un traitement plus efficace lorsque l’on sait qu’on peut se limiter à une partie des données correspondant à un sous-ensemble des valeurs de la clé de partitionnement, ou bien que l’on peut effectuer les traitements par groupe ce qui permet de distribuer les traitements selon la clé de partitionnement.

Dans d’autres cas, partitionner des données peut faire perdre en performance à cause de la lecture multiple de fichiers que cela implique

Le nom de la variable est ‘dpt’, ce sera donc notre clé de partitionnement. Pour créer des fichiers Parquet partitionnés, on utilise toujours la fonction write_dataset, en précisant cette fois la variable utilisée pour partitionner le fichier initial (dans l’argument partitioning)

8.7 Partitionnement 3/3

Le package arrow

Formation données volumineuses

# Création d'un sous-dossier pour contenir les données
dossier_partitions <- file.path(PATH_FINAL, "dpt2022 partitions")
if (dir.exists(dossier_partitions)) {
  unlink(dossier_partitions, recursive = TRUE) # Suprression du dossier
}
dir.create(dossier_partitions)

arrow::write_dataset(
  dataset = dpt2022,
  path = dossier_partitions,
  partitioning = c("dpt"), # la variable de partitionnement
  format = "parquet"
)

Attention

Si vous enregistrez plusieurs fois dans le même dossier en changeant de partition, de nouvelles partitions seront écrites dans le dossier sans écraser les précédentes et les données se retrouvent alors dupliquées.

NB : Si les clés de partitionnement contiennent des NA, les valeurs manquantes seront considérées comme une valeur de partition en soi comme toute autre valeur

8.8 Requêtes via des fichiers Parquet 1/3

Le package arrow

Formation données volumineuses

Nous allons maintenant lire les fichiers Parquet nouvellement créés et effectuer des requêtes dont le résultat sera converti en dataframe. L’utilisation de cette approche par requêtes permet de traiter les données hors mémoire ce qui représente un avantage considérable face à des données volumineuses :

  • On commence par utiliser la fonction open_dataset utilisée qui permet de se connecter aux données au format Parquet sans les charger

open_dataset vs read_parquet

Il est important de bien différencier les fonctions qui lancent une utilisation des données immédiate et celles qui la diffèrent. Alors que read_parquet charge immédiatement la base entière en RAM, open_dataset se contente de préparer ce chargement sans l’éxécuter.

8.9 Requêtes via des fichiers Parquet 2/3

Le package arrow

Formation données volumineuses

  • On peut ensuite définir une requête sur les données en utilisant dplyr (NB : la requête ne renvoie pas de données)

  • Attention, tous les verbes dplyr ne sont pas reconnus en arrow. Cf. documentation pour la liste des verbes utilisables

  • On peut enfin récupérer les données sous forme d’un dataframe grâce à la fonction collect de dplyr

Comment ça marche ?

Une fonction dplyr comme filter est une fonction générique qui va renvoyer vers une autre fonction selon la classe de l’objet en entrée. Ainsi pour un data.frame, filter va renvoyer vers dplyr:::filter.data.frame alors que pour un objet issu de open_dataset, il renverra vers arrow:::filter.arrow_dplyr_query.

8.10 Requêtes via des fichiers Parquet 2/2

Le package arrow

Formation données volumineuses

# Connexion au fichier Parquet partitionné
donnees_dpt22_part <- open_dataset(
  dossier_partitions,
  partitioning = arrow::schema(dpt = arrow::utf8()) # Il faut spécifier les clés de paritionnement utilisées et leur format (ici utf8 indique l'encodage de la chaîne de caractères)
)

# Requête
requete <- donnees_dpt22_part %>%
  filter(dpt == "75") %>% # Les filtres sur la variable de partition sont particulièrement efficaces puisque les fichiers sont déjà séparés
  select(sexe, preusuel, annais, nombre) %>%
  group_by(sexe, annais) %>%
  summarise(nb_prenoms = sum(nombre))

# Transformation du résultat en dataframe
resultat_dpt2022 <- requete %>%
  collect()

resultat_dpt2022

Le “schéma” décrit la structure des données (métadonnées) ce qui permet un stockage et un traitement efficace cf. UtiltR pour plus d’informations.`

Pour les exercices suivants, on s’appuiera sur les données communales ‘Base du comparateur de territoires’, disponibles à cet url en format xlsx. Ce fichier présente une trentaine d’indicateurs décrivant la population, les logements, les revenus, l’emploi et les établissements au niveau communal :

# install.packages("openxlsx")

download.file("https://www.insee.fr/fr/statistiques/fichier/2521169/base_cc_comparateur_xlsx.zip", destfile = file.path(PATH_INPUT, "base_cc_comparateur_xlsx.zip"))

unzip(file.path(PATH_INPUT, "base_cc_comparateur_xlsx.zip"), exdir = PATH_FINAL)

# On utilise data.table pour l'exemple même si le fichier n'a pas une taille supérieure à 1 Go

filename <- paste(PATH_FINAL, "base_cc_comparateur.xlsx", sep = "/")

indreg <- openxlsx::read.xlsx(filename, startRow = 6)

head(indreg)

8.11 Question 1

Le package arrow

Formation données volumineuses

  • Créer une fonction permettant de partitionner en fichiers Parquet un dataframe ou data.table.

  • Cette fonction devra inclure comme arguments : le dataframe à partitionner, le chemin vers le dossier contenant les fichiers Parquet partitionnés, ainsi que la clé de partitionnement

8.12 Correction 1

Le package arrow

Formation données volumineuses

parquet_partitionner <- function(dataset, chemin_partition, cle_part) {
  dossier_partitions <- file.path(PATH_FINAL, chemin_partition)

  dir.create(dossier_partitions)

  write_dataset(
    dataset = dataset,
    path = dossier_partitions,
    partitioning = c(cle_part), # la variable de partitionnement
    format = "parquet"
  )

  return(dossier_partitions)
}

8.13 Question 2

Le package arrow

Formation données volumineuses

Partitionner le fichier “base_cc_comparateur.xlsx” en fichiers Parquet partitionnés à l’aide de la fonction créée en utilisant comme clé de partitionnement la région (nom de colonne : REG).

8.14 Correction 2

Le package arrow

Formation données volumineuses

parquet_partitionner(indreg, "ind_reg partitions", "REG")
[1] "../Data/Final/ind_reg partitions"

On doit obtenir le partitionnement suivant:

8.15 Question 3

Le package arrow

Formation données volumineuses

Créer une fonction permettant d’avoir le solde naturel (nombre de naissances moins le nombre de décès) au sein d’une région prédéfinie en 2024 à partir du fichier parquet. Les colonnes nécessaires à ce calcul sont : “NAISD24” et “DECES24”.

8.16 Correction 3

Le package arrow

Formation données volumineuses

requeter_collecter_nnat <- function(n_region) {
  donnees_connect <- open_dataset(
    file.path(PATH_FINAL, "ind_reg partitions"),
    partitioning = arrow::schema(REG = arrow::utf8())
  )

  requete <- donnees_connect %>%
    filter(REG == n_region) %>%
    select(REG, NAISD24, DECESD24) %>%
    mutate(solde_naturel = NAISD24 - DECESD24) %>%
    collect() %>%
    group_by(REG) %>%
    summarise(across(where(is.numeric), sum))

  return(requete)
}

8.17 Question 4

Le package arrow

Formation données volumineuses

Utiliser cette fonction pour la région avec le code “75”, puis déterminer la région possédant le solde naturel le plus élevé.

8.18 Correction 4

Le package arrow

Formation données volumineuses

# On utilise la fonction. Ici, on filtre selon la clé de partitionnement, on veut la région avec le code "75"

nnat_idf <- requeter_collecter_nnat("75")

# On détermine la région possédant le solde naturel le plus élevé

regions <- unique(indreg$REG)

soldes_reg <- regions %>%
  lapply(requeter_collecter_nnat) %>%
  bind_rows()

max_solde <- soldes_reg[which.max(soldes_reg$solde_naturel), ]

max_region <- max_solde$REG

cat("la région possédant le solde naturel le plus élevé est ", max_region, "\n")
la région possédant le solde naturel le plus élevé est  06 

9 Utilisation de DuckDB dans R

9.1 Présentation

Utilisation de DuckDB dans R

Formation données volumineuses

DuckDB est un logiciel de gestion de base de données très récent qui a fait une percée spectaculaire dans le monde de la donnée grâce à des performances.

DuckDB s’interface avec R grâce aux paquets DBI (interface générale de R avec les bases de données)et duckdb (permet à DBI de traiter spécifiquement duckdb).

DuckDB attent une syntaxe SQL et le paquet dbplyr permet la traduction des verbes dplyr vers SQL.

La fonction arrow::to_duckdb() transforme un objet arrow par exemple issu d’un open_dataset vers duckdb.

9.2 Le format .duckdb

Utilisation de DuckDB dans R

Formation données volumineuses

DuckDB fournit un format de base de données, .duckdb qui peut contenir plusieurs tables et exécuter toutes les opérations standards de gestion de base de données.

En soi, il peut être vu comme un concurrent du format Parquet mais il est spécifique à DuckDB et est donc moins universel que le format Parquet.

De plus, il rassemble toutes les différentes tables dans le même fichier le rendant moins clair que des Parquet séparés.

Cependant il est plus compressé et plus performant pour le moteur DuckDB

9.3 CREATE TABLE vs CREATE VIEW

Utilisation de DuckDB dans R

Formation données volumineuses

Dans DuckDB, on peut créer des tables dans la base de données .duckdb grâce à la commande SQL CREATE TABLE. Il s’agit d’écrire les données en “dur”. On peut aussi créer des vues grâce à la commande SQL CREATE VIEW. La table ne sera pas intégrée dans la base de données, sa création est différée pour une utilisation ultérieure. Le programme ne garde en mémoire que la requête créant la vue, sans cherchant à l’exécuter pour l’instant.

9.4 Les 3 façons de travailler avec des Parquet

Utilisation de DuckDB dans R

Formation données volumineuses

Il y a donc trois façons de travailler avec duckdb depuis des fichiers Parquet :

  • que des vues : Ne jamais créer de tables mais uniquement des vues (views) jusqu’au collect final qui ramènera le résultat en objet R standard en RAM

  • des tables en RAM : Créer une ou plusieurs tables intermédiaires dans un base de données DuckDB en RAM

  • des tables en dur : Créer une ou plusieurs tables intermédiaires dans un base de données DuckDB sur le disque

9.5 Travail avec DuckDB : que des vues 1/2

Utilisation de DuckDB dans R

Formation données volumineuses

On commence par ouvrir la connexion :

con = DBI::dbConnect(duckdb(), ":memory:")
# ou equivalent con = DBI::dbConnect(duckdb())

Ici on choisit d’écrire la base DuckDB en RAM mais cela n’a pas d’importance car on ne la remplira jamais.

Puis on lit les fichiers parquet :

DBI::dbExecute(con, 
    "
    CREATE VIEW dpt2022 AS
    SELECT *
    FROM read_parquet('../Data/Final/dpt2022.parquet/*.parquet')
    ")
[1] 0

ou si on veut partir des fichiers partitionnés utiliser :

read_parquet('../Data/Final/dpt2022 partitions/**/*.parquet')

9.6 Travail avec DuckDB : que des vues 2/2

Utilisation de DuckDB dans R

Formation données volumineuses

La vue dpt2022 a été créée dans le moteur DuckDB mais n’existe pas encore du point de vue de R.

La fonction tbl va créer un objet R associé :

dpt2022 <- tbl(con, "dpt2022")# On utilise le même nom ici mais ce n'est pas obligatoire.

A partir de là on peut utiliser les fonctions dplyr jusqu’à un collect final qui ramènera l’objet dans R.

Calculons les longueurs de prénom les plus fréquentes pour la génération 1980 :

dpt2022 %>% 
    filter(annais == "1980") %>% 
    mutate(longueur = nchar(preusuel)) %>% 
    group_by(longueur) %>% 
    summarise(n = n()) %>% 
    arrange(desc(n)) %>% 
    collect()
# DBI::dbDisconnect(con)# ne pas oublier de déconnecter à la fin

La base DuckDB n’a jamais été remplie, une requête a été exécutée directement à partir des fichiers Parquet.

Avantages : la RAM n’est jamais encombrée par une base DuckDB et il n’y a pas d’écriture disque.

C’est la méthode qu’on préconise par défaut.

9.7 Travail avec DuckDB : des tables en RAM 1/2

Utilisation de DuckDB dans R

Formation données volumineuses

On ouvre la connexion en précisant bien dans la RAM : con = DBI::dbConnect(duckdb(), ":memory:")

On lit le parquet et on en fait une table dans la base de données DuckDB en RAM :

DBI::dbExecute(con, 
    "
    CREATE TABLE dpt2022 AS
    SELECT *
    FROM read_parquet('../Data/Final/dpt2022.parquet/*.parquet')
    ")

Puis on continue comme précedemment :

dpt2022 <- tbl(con, "dpt2022")
dpt2022 %>% 
    filter(annais == "1980") %>% 
    mutate(longueur = nchar(preusuel)) %>% 
    group_by(longueur) %>% 
    summarise(n = n()) %>% 
    arrange(desc(n)) %>% 
    collect()
# DBI::dbDisconnect(con)# ne pas oublier de déconnecter à la fin

9.8 Travail avec DuckDB : des tables en RAM 2/2

Utilisation de DuckDB dans R

Formation données volumineuses

On aurait pu commencer par une vue puis créer une table plus tard avec compute. Par exemple :

dpt2022 %>% 
    filter(annais == "1980") %>% 
    mutate(longueur = nchar(preusuel)) %>% 
    compute("base_int")

Cela crée une table base_int dans DuckDB qu’on peut, de nouveau, récupérer dans R avec base_int = tbl(con, "base_int").

La base DuckDB prend de la place dans la RAM (mais moins que la table R équivalente car compressée). Néanmoins les calculs à partir de cette table seront très performants.

Cette méthode peut être intéressante, par exemple, si les données ne sont pas énormes et qu’on a des bases intermédiaires qu’on utilise beaucoup.

9.9 Travail avec DuckDB : des tables en dur

Utilisation de DuckDB dans R

Formation données volumineuses

La seule différence avec la méthode précédente est de préciser qu’on veut la base DuckDB en dur :

con = DBI::dbConnect(duckdb(), "baseDuckDB.duckdb")

Alors les CREATE TABLE et compute écriront la base DuckDB sur le disque

Par rapport à la méthode précédente on libère la RAM mais on a un coût d’écriture sur le disque.

Peut être intéressant si on a besoin de grosses bases intermédiaires.

9.10 La traduction vers SQL par dbplyr 1/3

Utilisation de DuckDB dans R

Formation données volumineuses

dbplyr traduit les verbes dplyr en SQL. En cas de dysfonctionnement, placer un show_query() permet de voir si la traduction s’est bien passée.

dpt2022 %>% 
    filter(annais == "1980") %>% 
    mutate(longueur = nchar(preusuel)) %>% 
    group_by(longueur) %>% 
    summarise(n = n()) %>% 
    arrange(desc(n)) %>% 
    show_query()
<SQL>
SELECT longueur, COUNT(*) AS n
FROM (
  SELECT dpt2022.*, LENGTH(preusuel) AS longueur
  FROM dpt2022
  WHERE (annais = '1980')
) q01
GROUP BY longueur
ORDER BY n DESC

Beaucoup d’éléments ont été traduits : filter en WHERE, nchar en LENGTH, n() en COUNT, arrange en ORDER BY etc.

9.11 La traduction vers SQL par dbplyr 2/3

Utilisation de DuckDB dans R

Formation données volumineuses

Quand dbplyr ne connaît pas une fonction, il la laisse telle quelle

dpt2022 %>% 
    filter(annais == "1980") %>% 
    mutate(longueur = nchar(preusuel)) %>% 
    group_by(dpt) %>% 
    summarise(
        moy = mean(longueur),
        var = var(longueur),
        autre = fonction_qui_n_existe_pas(longueur)) %>% 
    show_query()
<SQL>
SELECT
  dpt,
  AVG(longueur) AS moy,
  VARIANCE(longueur) AS var,
  fonction_qui_n_existe_pas(longueur) AS autre
FROM (
  SELECT dpt2022.*, LENGTH(preusuel) AS longueur
  FROM dpt2022
  WHERE (annais = '1980')
) q01
GROUP BY dpt

Cela permet de transmettre des fonctions DuckDB directement comme strptime au lieu de as.Date cf Utilitr

9.12 La traduction vers SQL par dbplyr 2/2

Utilisation de DuckDB dans R

Formation données volumineuses

dbplyr traduit les verbes dplyr en SQL et peut donc ne pas marcher avec des mélanges dplyr/R base :

int <- dpt2022 %>% 
    filter(annais == "1980") 
int$longueur <- nchar(int$preusuel)
Error in `int$preusuel`:
! The `$` method of <tbl_lazy> is for internal use only.
ℹ Use `dplyr::pull()` to get the values in a column.

Il faut essayer de tout écrire en “pur” dplyr :

int <- dpt2022 %>% 
    filter(annais == "1980") %>% 
    mutate(longueur = nchar(preusuel)) %>% 
    show_query()
<SQL>
SELECT dpt2022.*, LENGTH(preusuel) AS longueur
FROM dpt2022
WHERE (annais = '1980')

9.13 Configuration avancée de duckDB

Le package duckdb

Formation données volumineuses

On peut configurer finement le driver DuckDB :

# Configurer le driver duckdb
drv <- duckdb::duckdb(
  dbdir = "fichier.duckdb", # choix d'écriture sur le disque de la BDD DuckDB
  config = list(
    threads = "4", # nombre de thread pour la parallèlisation, par défaut DuckDB les prend tous, on peut vouloir limiter ici 
    memory_limit = "40GB", # Limiter la RAM utilisée.
    temp_directory = "tmp_path/", # endroit où les fichiers temporaires sont conservés
    preserve_insertion_order = "true" # conserver l'ordre des lignes peut prendre du temps, on peut le forcer ici
    )
)

# Initaliser la base de données duckdb avec la configuration
conn_ddb <- DBI::dbConnect(drv = drv)

La règle à connaître est qu’il est recommandé de disposer de 5 à 10Go de mémoire par thread.

9.14 Question 5

Le package duckdb

Formation données volumineuses

On veut obtenir le vecteurs de tous les couples prénoms/département différents, or le R base unique(dept2022 %>% select(dpt, preusuel)) conduit à un résultat non souhaité. Ecrire la même requête en dplyr et essayer.

9.15 Correction 5

Le package duckdb

Formation données volumineuses

dpt2022 %>% distinct(preusuel, dpt) %>% collect()

9.16 Question 6

Le package duckdb

Formation données volumineuses

Déterminer quels départements ont le plus de prénoms différents pour la génération 1980 en partant des parquets partitionnés avec les 3 méthodes : que des vues, une table en RAM, une table en dur.

Essayer d’anticiper les étapes qui seront longues.

Question bonus : Partir des parquets partitionnés est-il judicieux ?

9.17 Correction 6

Le package duckdb

Formation données volumineuses

Que des vues :

con = DBI::dbConnect(duckdb(), ":memory:")

DBI::dbExecute(con, 
               "
    CREATE VIEW dpt2022 AS
    SELECT *
    FROM read_parquet('../Data/Final/dpt2022 partitions/**/*.parquet')
    ")  # quasi immédiat
dpt2022 = tbl(con, "dpt2022")  # quasi immédiat

dpt2022 %>% 
    filter(annais == "1980") %>% 
    distinct(dpt, preusuel) %>% 
    group_by(dpt) %>% 
    summarise(n = n()) %>% 
    arrange(desc(n)) %>% 
    collect()   # lecture disque des parquet + calculs

DBI::dbDisconnect(con)

9.18 Correction 6

Le package duckdb

Formation données volumineuses

Une table en RAM :

con = DBI::dbConnect(duckdb(), ":memory:")

DBI::dbExecute(con, 
               "
    CREATE TABLE dpt2022 AS
    SELECT *
    FROM read_parquet('../Data/Final/dpt2022 partitions/**/*.parquet')
    ") # long car écriture en RAM de la table depuis les parquets
dpt2022 = tbl(con, "dpt2022") # quasi immédiat

dpt2022 %>% 
    filter(annais == "1980") %>% 
    distinct(dpt, preusuel) %>% 
    group_by(dpt) %>% 
    summarise(n = n()) %>% 
    arrange(desc(n)) %>% 
    collect() # plutôt rapide car table DuckDB en RAM

DBI::dbDisconnect(con)

9.19 Correction 6

Le package duckdb

Formation données volumineuses

Une table en dur :

con = DBI::dbConnect(duckdb(), "base.duckdb")

DBI::dbExecute(con, 
    "
    CREATE TABLE dpt2022 AS
    SELECT *
    FROM read_parquet('../Data/Final/dpt2022 partitions/**/*.parquet')
    ") # long, écriture sur disque de la table depuis les parquets
dpt2022 = tbl(con, "dpt2022") # quasi immédiat

dpt2022 %>% 
    filter(annais == "1980") %>% 
    distinct(dpt, preusuel) %>% 
    group_by(dpt) %>% 
    summarise(n = n()) %>% 
    arrange(desc(n)) %>% 
    collect() # rapide car table en base DuckDB mais ralentit par lecture disque

DBI::dbDisconnect(con)

9.20 Correction 6 Bonus

Le package duckdb

Formation données volumineuses

La fonction explain() à utiliser comme show_query fournit le plan d’éxecution de DuckDB. L’ordre des opérations peut différer de l’ordre des instructions grâce à un moteur d’optimisation des requêtes propres à DuckDB. Si au tout début du plan, on part des départements (filtre sur les départements ou group_by) alors le partitionnement est intéressant. Si DuckDB commence par le filtre de la génération 1980, le partionnement perd de l’intérêt.

9.21 Question 7

Le package duckdb

Formation données volumineuses

La requête suivante donnera une erreur, déterminer pour quelle raison en regardant la requête traduite :

library(Hmisc)
 dpt2022 %>%
  filter(sexe == 1) %>%
  mutate(poids = 10 + runif(n())) %>% 
  group_by(annais, dpt) %>%
  summarise(
    somme = sum(nombre),
    sd = sd(nombre),
    mediane = median(nombre),
    mediane_ponderee = wtd.quantiles(nombre, poids, probs=0.5)
  )

9.22 Correction 7

Le package duckdb

Formation données volumineuses

<SQL>
SELECT
  annais,
  dpt,
  SUM(nombre) AS somme,
  STDDEV(nombre) AS sd,
  MEDIAN(nombre) AS mediane,
  wtd.quantiles(nombre, poids, 0.5 AS probs) AS mediane_ponderee
FROM (
  SELECT dpt2022.*, 10.0 + RANDOM() AS poids
  FROM dpt2022
  WHERE (sexe = 1.0)
) q01
GROUP BY annais, dpt

Alors que le runif a bien été traduit en RANDOM, le wtd.quantiles n’a pas été traduit et a été laissé tel quel. Il déclenchera une erreur. Malheureusement la fonction équivalente DuckDB n’existe pas encore.

9.23 Question 8

Le package duckdb

Formation données volumineuses

Créer une requête permettant d’obtenir les années et départements où la proportion du prénom ‘ABEL’ a été la plus élevée parmi les prénoms de sexe masculin.

9.24 Correction 8

Le package duckdb

Formation données volumineuses

query <- dpt2022 %>%
  filter(sexe == 1) %>%
  group_by(annais, dpt) %>%
  summarise(
    nb_prenoms_abel = sum(nombre[preusuel == "ABEL"]), # utilisation de R base <vecteur>[condition] dont la traduction passe bien
    nb_prenoms = sum(nombre)
  ) %>%
  mutate(
    part_abel = nb_prenoms_abel / nb_prenoms
  ) %>%
  arrange(desc(part_abel))
query %>%
  collect()

10 Quiz

10.1 Question 1

Quiz

Formation données volumineuses

Identifiez l’erreur dans le code suivant, qui utilise une fonction de partitionnement de fichier Parquet en langage R :

  • A) La fonction arrow::write_dataset ne prend pas en charge le partitionnement.

  • B) Les noms des colonnes de partitionnement doivent être spécifiés sous forme de symboles.

  • C) Il manque une étape pour créer le répertoire de sortie avant le partitionnement.

  • D) La fonction arrow::write_dataset ne nécessite pas la spécification du répertoire de sortie.

10.2 Correction 1

Quiz

Formation données volumineuses

La réponse correcte est la réponse :

  • C) Il manque une étape pour créer le répertoire de sortie avant le partitionnement.

Explication : Avant de partitionner le fichier Parquet, il est nécessaire de créer le répertoire de sortie s’il n’existe pas déjà. Dans le code fourni, il manque une étape pour créer le répertoire output_parquet avant d’y écrire les partitions du fichier Parquet. La correction pourrait inclure l’ajout de la ligne suivante avant la fonction arrow::write_dataset :

10.3 Question 2

Quiz

Formation données volumineuses

Quelle est la fonction principale du package duckdb ?

  • A) Le package duckdb fournit des fonctionnalités pour créer des canards virtuels dans un environnement R.

  • B) Le package duckdb offre des outils pour la modélisation de données relationnelles dans R.

  • C) Le package duckdb permet la connexion à une base de données DuckDB et l’exécution de requêtes SQL depuis R.

  • D) Le package duckdb est utilisé pour l’analyse statistique avancée des données dans R.

10.4 Correction 2

Quiz

Formation données volumineuses

La réponse correcte est la réponse :

  • C) Le package duckdb permet la connexion à une base de données DuckDB et l’exécution de requêtes SQL depuis R.

10.5 Question 3

Quiz

Formation données volumineuses

Considérez une situation où vous avez des données stockées au format Arrow et vous souhaitez les intégrer à votre base de données DuckDB existante. Quelle fonction de la bibliothèque Arrow serait la plus appropriée pour accomplir cette tâche ?

  • A) arrow::export_to_duckdb()
  • B) arrow::to_duckdb()
  • C) arrow::load_into_duckdb()
  • D) arrow::convert_duckdb()

10.6 Correction 3

Quiz

Formation données volumineuses

La réponse correcte est la réponse :

  • B) arrow::to_duckdb()

11 Bibliographie:

Merci de votre attention

Retrouvez nous sur

  • Projet Sortie de SAS
  • UniSSI
  • DG75-projet-sortie-sas@insee.fr

Formation R Données volumineuses

| | | | | |