Atelier pour découvrir la récupération de données via avec le format Parquet

Le format Parquet est un format de données connaissant une popularité importante du fait de ses caractéristiques techniques (orientation colonne, compression, interopérabilité…), de sa nature open source et du riche écosystème associé dont les frameworks les plus proéminents sont Arrow et DuckDB. A ces nombreux avantages s’ajoutent une intégration native aux infrastructures cloud basées sur S3, des extensions nombreuses pour traiter des données complexes comme les données géographiques ou, plus récemment, le portage en WASM de DuckDB permettant de construire des applications réactives impliquant des transformations de données directement depuis le navigateur.

Auteur·rice

Lino Galiana

Date de publication

9 avril 2025

Onyxia Onyxia
Onyxia Onyxia

Afficher les slides associées

Cliquer ici pour les afficher en plein écran.

Regarder le replay de la session live du 09 Avril 2025:

1 Introduction

Tout au long de ce tutoriel guidé, nous allons voir comment utiliser le format Parquet de manière la plus efficiente.

Afin de comparer les différents formats et méthodes d’utilisation, nous allons généralement comparer le temps d’exécution et l’usage mémoire d’une requête standard.

1.1 Etapes préliminaires

Au cours de cet atelier, nous aurons besoin des packages suivants:

#| output: false
library(duckdb)
library(glue)
library(DBI)
library(dplyr)
library(dbplyr)
library(mapview)

Ce tutoriel s’appuie sur des données ouvertes diffusées au format Parquet. Pour les récupérer, vous pouvez exécuter le script suivant:

Récupérer les données
library(arrow)
library(dplyr)
library(readr)
library(fs)

# Chemin du fichier d'entrée
filename_table_individu <- "data/RPindividus.parquet"

# Lire le fichier Parquet
df <- read_parquet(filename_table_individu)

# Filtrer les données pour REGION == "24"
df_filtered <- df %>% filter(REGION == "24")

# Sauvegarder en CSV
write_csv(df_filtered, "data/RPindividus_24.csv")

# Sauvegarder en Parquet
write_parquet(df_filtered, "data/RPindividus_24.parquet")

# Créer le dossier si nécessaire
dir_create("data/RPindividus")

# Sauvegarder en Parquet partitionné par REGION et DEPT
write_dataset(
  df,
  path = "data/RPindividus",
  format = "parquet",
  partitioning = c("REGION", "DEPT")
)

2 Passer de CSV à Parquet

Commençons par comparer les formats CSV et Parquet afin de comprendre les gains qu’apporte déjà ce format.

Le prochain chapitre propose d’utiliser le package bench pour les comparatifs. Il est plus simple d’encapsuler dans ces benchmarks des fonctions: vous pouvez développer le code puis l’intégrer dans une fonction ad hoc.

Pour ce premier exercice, nous proposons d’utiliser Arrow pour la lecture des fichiers. Nous verrons ultérieurement comment faire la même chose avec DuckDB.

Exercice 1 : Du CSV au Parquet
  • La requête suivante permet de calculer les données pour construire une pyramide des âges sur un département donné, à partir du fichier CSV du recensement. Après l’avoir testée, encapsuler celle-ci dans une fonction req_csv (sans argument).
res <- readr::read_csv("data/RPindividus_24.csv") |>
    filter(DEPT == "36") |>
    group_by(AGED, DEPT) |>
    summarise(n_indiv = sum(IPONDI))
  • Sur le même modèle, construire une fonction req_read_parquet basée cette fois sur le fichier data/RPindividus_24.parquet chargé avec la fonction read_parquet d’Arrow

  • Comparer les performances (temps d’exécution et allocation mémoire) de ces deux méthodes grâce à la fonction bench::mark, à laquelle on passera les paramètres iterations = 1 (comparaison à partir d’une seule itération) et check = FALSE (autorise les outputs des deux fonctions à être différents).

  • Arranger les données pour avoir un tableau de résultats

Proposition de correction
library(dplyr)
library(ggplot2)
library(bench)
library(stringr)

path_data <- "./data"
path_parquet <- glue("{path_data}/RPindividus.parquet")
path_parquet_subset <- glue("{path_data}/RPindividus_24.parquet")
path_csv_subset <- glue("{path_data}/RPindividus_24.csv")


req_csv <- function(path="data/RPindividus_24.csv") {
  res <- readr::read_csv(path) |>
    filter(DEPT == "36") |>
    group_by(AGED, DEPT) |>
    summarise(n_indiv = sum(IPONDI))

  return(res)
}

req_read_parquet <- function(path="data/RPindividus_24.parquet") {
  res <- arrow::read_parquet(path) |>
    filter(DEPT == "36") |>
    group_by(AGED, DEPT) |>
    summarise(n_indiv = sum(IPONDI))

  return(res)
}

req_open_dataset <- function(path="data/RPindividus_24.parquet") {
  res <- arrow::open_dataset(path) |>
    filter(DEPT == "36") |>
    group_by(AGED, DEPT) |>
    summarise(n_indiv = sum(IPONDI)) |>
    collect()

  return(res)
}

req_open_dataset_full <- function(path="data/RPindividus.parquet") {
  res <- arrow::open_dataset(path) |>
    filter(DEPT == "36") |>
    group_by(AGED, DEPT) |>
    summarise(n_indiv = sum(IPONDI)) |>
    collect()

  return(res)
}

req_open_dataset_part <- function(path="data/RPindividus_partitionne.parquet") {
  res <- arrow::open_dataset(path, hive_style = TRUE) |>
    filter(DEPT == "36") |>
    group_by(AGED, DEPT) |>
    summarise(n_indiv = sum(IPONDI)) |>
    collect()

  return(res)
}


mesurer_taille <- function(path){
  poids <- file.size(path)
  taille <- convertir_taille(poids)
  return(taille)
}

# Fonction pour convertir les octets en Mo ou Go
convertir_taille <- function(octets) {
  if (octets >= 1024^3) {
    return(paste(round(octets / 1024^3, 2), "Go"))
  } else if (octets >= 1024^2) {
    return(paste(round(octets / 1024^2, 2), "Mo"))
  } else {
    return(paste(octets, "octets"))
  }
}




library(glue)
library(stringr)
library(dplyr)
library(ggplot2)
library(bench)
library(gt)

source("R/benchmark_functions.R")

path_data <- "./data"
path_parquet <- glue("{path_data}/RPindividus.parquet")
path_parquet_subset <- glue("{path_data}/RPindividus_24.parquet")
path_parquet_csv <- glue("{path_data}/RPindividus_24.csv")


benchmark1 <- bench::mark(
    req_csv = req_csv(path_csv_subset),
    req_read_parquet = req_read_parquet(path_parquet_subset),
    iterations = 1,
    check = FALSE
)

poids_csv <- mesurer_taille(path_csv_subset)
poids_parquet <- mesurer_taille(path_parquet_subset)



benchmark1_table <- benchmark1 |>
    mutate(
        value = as.numeric(str_extract(mem_alloc, "\\d+(\\.\\d+)?")),
        unit = str_extract(mem_alloc, "[KMGTP]B"),
        mem_alloc_kb = case_when(
        unit == "KB" ~ value,
        unit == "MB" ~ value * 1024,
        unit == "GB" ~ value * 1024^2,
        unit == "TB" ~ value * 1024^3,
        unit == "PB" ~ value * 1024^4,
        TRUE ~ NA_real_  # Par défaut, si l'unité n'est pas reconnue
        )
    ) |>
    mutate(
        median = as.numeric(median, units="seconds"),
        poids = as.numeric(
            gsub(" Mo", "", c(poids_csv, poids_parquet))
        ),
        method = c("CSV avec `read_csv` (`readr`)", "Parquet `read_parquet` (`arrow`)"),
    ) |>
    mutate(
        median_bar = as.numeric(median, units="seconds"),
        mem_alloc = paste0(value, " ", unit),
        poids_bar = poids
        ) |>
    select(
        method, poids, poids_bar, median, median_bar, mem_alloc, mem_alloc_bar=mem_alloc_kb
        )


benchmark1 <- gt(benchmark1_table) |>
    gtExtras::gt_plt_bar(column=median_bar) |>
    gtExtras::gt_plt_bar(column=mem_alloc_bar) |>
    gtExtras::gt_plt_bar(column=poids_bar) |>
    cols_move(median_bar, after=median) |>
    cols_move(mem_alloc_bar, after=mem_alloc_bar) |>
    tab_spanner(md("**Temps d'exécution**<br> _(sec.)_"), starts_with("median")) |>
    tab_spanner(md("**Mémoire allouée**<br> _(MB)_"), starts_with("mem_alloc")) |>
    tab_spanner(md("**Poids sur disque**<br> _(Mo)_"), starts_with("poids")) |>
    cols_label(everything() ~ '') |>
    fmt_number(median, decimals = 2) |>
    fmt_number(poids, decimals = 0) |>
    fmt_markdown(method)


dir.create("./bench")
gtsave(benchmark1, "./bench/mark1.html")

3 Comprendre l’intérêt de la lazy evaluation

La partie précédente a montré un gain de temps considérable du passage de CSV à Parquet. Néanmoins, l’utilisation mémoire était encore très élevée alors qu’on utilise de fait qu’une infime partie du fichier.

Dans cette partie, on va voir comment utiliser la lazy evaluation et les optimisations du plan d’exécution effectuées par Arrow pour exploiter pleinement la puissance du format Parquet.

3.1 La lazy evaluation en pratique

Exercice 2 : Exploiter la lazy evaluation et les optimisations d’Arrow
  • Utiliser la fonction arrow::open_dataset pour ouvrir le fichier data/RPindividus_24.parquet. Regarder la classe de l’objet obtenu.

  • Afficher les 5 premières lignes de la table avec la fonction head(). Observer l’objet obtenu (sortie en console, classe).

  • Faire la même chose avec duckdb (par le biais de l’API tidyverse et en SQL direct)

  • Prenez ce code:

arrow::open_dataset(path_parquet_subset) |>
    filter(DEPT == "36") |>
    group_by(AGED, DEPT) |>
    summarise(n_indiv = sum(IPONDI))

et exécutez le. Que se passe-t-il à votre avis ?

  • Ajouter une étape collect() à la fin de cette chaîne. Comprenez-vous la différence ?

  • Construire une fonction req_open_dataset sur le modèle de celles de la partie précédente, qui importe cette fois les données avec la fonction arrow::open_dataset

  • Comparer les performances (temps d’exécution et allocation mémoire) de la méthode read_parquet et de la méthode open_dataset grâce à la fonction bench::mark

3.2 Comprendre l’optimisation permise par Parquet et DuckDB

Pour réduire la volumétrie des données importées, il est possible de mettre en oeuvre deux stratégies:

  • N’importer qu’un nombre limité de colonnes
  • N’importer qu’un nombre limité de lignes

Comme cela a été évoqué dans les slides, le format Parquet est particulièrement optimisé pour le premier besoin. C’est donc généralement la première optimisation mise en oeuvre. Pour s’en convaincre on peut regarder la taille des données importées dans deux cas:

  • On utilise beaucoup de lignes mais peu de colonnes
  • On utilise beaucoup de colonnes mais peu de lignes

Pour cela, nous utilisons la fonction SQL EXPLAIN ANALYZE disponible dans duckdb. Elle décompose le plan d’exécution de duckdb, ce qui nous permettra de comprendre la stratégie d’optimisation. Elle permet aussi de connaître le volume de données importées lorsqu’on récupère un fichier d’internet. En effet, duckdb est malin: plutôt que de télécharger un fichier entier pour n’en lire qu’une partie, la librairie est capable de n’importer que les blocs du fichier qui l’intéresse.

Ceci nécessite l’utilisation de l’extension httpfs (un peu l’équivalent des library de R en duckdb). Elle s’installe et s’utilise de la manière suivante

#| output: false
library(duckdb)

# url_bpe <- "https://www.insee.fr/fr/statistiques/fichier/8217525/BPE23.parquet"
url_bpe <- "https://minio.lab.sspcloud.fr/lgaliana/diffusion/BPE23.parquet"
con <- dbConnect(duckdb())

dbExecute(
  con,
  glue(
    "INSTALL httpfs;",
    "LOAD httpfs;"
  )
)

Demandons à DuckDB d’exécuter la requête “beaucoup de colonnes, pas beaucoup de lignes” et regardons le plan d’exécution et les informations données par DuckDB:

Voir le plan : “beaucoup de colonnes, pas beaucoup de lignes”
glue(
    'EXPLAIN ANALYZE ',
    'SELECT * FROM read_parquet("{url_bpe}") LIMIT 5'
  )
plan <- dbGetQuery(
  con,
  glue(
    'EXPLAIN ANALYZE ',
    'SELECT * FROM read_parquet("{url_bpe}") LIMIT 5'
  )
)
print(plan)
Voir le plan : “peu de colonnes, beaucoup de lignes”
plan <- dbGetQuery(
  con,
  glue(
    'EXPLAIN ANALYZE ',
    'SELECT TYPEQU, LONGITUDE, LATITUDE FROM read_parquet("{url_bpe}") LIMIT 10000'
  )
)
print(plan)

La comparaison de ces plans d’exécution montre l’intérêt de faire un filtre sur les colonnes : les besoins computationnels sont drastiquement diminués. Le filtre sur les lignes n’arrive que dans un second temps, une fois les colonnes sélectionnées.

Pourquoi seulement un rapport de 1 à 4 entre le poids des deux fichiers ? C’est parce que nos requêtes comportent toute deux la variable IPONDI (les poids à utiliser pour extrapoler l’échantillon à la population) qui est à haute précision là où beaucoup d’autres colonnes comportent un nombre réduit de modalités et sont donc peu volumineuses.

4 Le partitionnement

La lazy evaluation et les optimisations d’Arrow apportent des gain de performance considérables. Mais on peut encore faire mieux ! Lorsqu’on sait qu’on va être amené à filter régulièrement les données selon une variable d’intérêt, on a tout intérêt à partitionner le fichier Parquet selon cette variable.

Partie 3 : Le Parquet partitionné
  • Parcourir la documentation de la fonction arrow::write_dataset pour comprendre comment spécifier la clé de partitionnement d’un fichier Parquet. Plusieurs méthodes sont possibles !
  • Importer la table individus complète du recensement data/RPindividus.parquet avec la fonction arrow::open_dataset et l’exporter en une table data/RPindividus_partitionne.parquet partitionnée par la région (REGION) et le département (DEPT)
  • Observer l’arborescence de fichiers de la table exportée
  • Modifier la fonction req_open_dataset de la partie précédente pour partir de la table complète (non-partitionnée) data/RPindividus.parquet au lieu de l’échantillon
  • Construire une fonction req_open_dataset_partitionne sur le modèle de req_open_dataset, qui importe cette fois les données partitionnées data/RPindividus_partitionne.parquet. Ne pas oublier de spécifier le paramètre hive_style = TRUE.
  • Comparer les performances (temps d’exécution et allocation mémoire) des deux méthodes grâce à la fonction bench::mark

5 DuckDB ou Arrow ?

La réponse dépend des préférences de chacun. Les deux écosystèmes sont très bien. DuckDB est pensé pour sa simplicité d’usage et son universalité: en principe, qu’on fasse du , , ou de la ligne de commande, on pourra utiliser le même code SQL, seule la définition de la connection changera. Arrow propose une syntaxe un peu moins familière mais l’intégration au tidyverse rend ce framework beaucoup plus facile d’usage que si on devait directement utiliser Arrow.

Il existe aussi des clients plus hauts niveau pour duckdb: c’est notamment le cas de la connection entre duckdb et tidyverse permise en . Ceux-ci permettent des opérations de manipulation de données plus complexes: si le SQL de DuckDB est déjà plus simple d’usage que celui de PostGre, on reste limité pour des opérations complexes de (dé)structuration de données: le client tidyverse apporte les avantages de cet écosystème en plus de ceux de DuckDB.

6 (Geo)Parquet et DuckDB pour les données spatiales

Nous proposons, pour illustrer l’un des atouts de DuckDB, à savoir sa simplicité d’usage sur des problèmes complexes, de faire du traitement de données spatiales. Pour cela, nous allons faire une tâche lourde: restreindre des données spatiales à partir de leur appartenance à une zone géographique donnée. Cette dernière sera définie géométriquement comme un triangle dont deux des coins correspondent aux anciens bâtiments de l’Insee à Malakoff.

L’import des contours dont nous aurons besoin en se fait assez naturellement grâce à sf.

6.1 Récupération des données

#| output: false
dir.create("data")

download.file("https://minio.lab.sspcloud.fr/projet-formation/nouvelles-sources/data/triangle.geojson", "data/triangle.geojson")
download.file("https://minio.lab.sspcloud.fr/projet-formation/nouvelles-sources/data/malakoff.geojson", "data/malakoff.geojson")
download.file("https://minio.lab.sspcloud.fr/projet-formation/nouvelles-sources/data/montrouge.geojson", "data/montrouge.geojson")
download.file("https://minio.lab.sspcloud.fr/projet-formation/nouvelles-sources/data/geoparquet/dvf.parquet", "data/dvf.parquet")
download.file("https://minio.lab.sspcloud.fr/projet-formation/nouvelles-sources/data/geoparquet/carreaux.parquet", "data/carreaux.parquet")


triangle <- sf::st_read("data/triangle.geojson", quiet=TRUE)
malakoff <- sf::st_read("data/malakoff.geojson", quiet=TRUE)
montrouge <- sf::st_read("data/montrouge.geojson", quiet=TRUE)

On peut visualiser la ville de Malakoff et notre zone d’intérêt (qu’on nommera, par abus de langage, le triangle d’or de Malakoff):

mapview(malakoff) + mapview(triangle, col.regions = "#ffff00")

Nous utiliserons aussi les contours de Montrouge pour cette partie:

mapview(montrouge)

En principe, duckdb fonctionne à la manière d’une base de données. Autrement dit, on définit une base de données et effectue des requêtes (SQL ou verbes tidyverse) dessus. Pour créer une base de données, il suffit de faire un read_parquet avec le chemin du fichier.

Comme il n’est pas possible de distinguer cette zone par requêtes attributaires, nous proposons de :

  1. Via DuckDB, extraire les transactions de l’ensemble de la commune de Malakoff tout en conservant leur caractère spatial (chaque transaction correspond à un point géographique, avec ses coordonnées xy).
  2. Utiliser localement le package sf pour distinguer spatialement les transactions effectuées à l’intérieur ou à l’extérieur du Triangle d’Or (dont nous fournissons les contours).
  3. Calculer la médiane des prix dans les deux sous-zones.

On extrait les transactions de Malakoff. Pour information, dans le fichier dvf.parquet, les coordonnées spatiales sont stockées dans un format binaire spécifique (Well-Known Binary - WKB). Ce format est efficace pour le stockage et les calculs, mais n’est pas directement lisible ou interprétable par les humains.

En transformant ces géométries en une représentation texte lisible (Well-Known Text - WKT) avec ST_AsText, on rend les données spatiales faciles à afficher, interpréter ou manipuler dans des contextes qui ne supportent pas directement les formats binaires géospatiaux.

6.2 Spatial join avec DuckDB

Pour cet exercice, nous allons utiliser les variables suivantes:

cog_malakoff <- "92046"
cog_montrouge <- "92049"

et le geoparquet pourra être interprété par duckdb selon ce modèle:

FROM read_parquet('data/dvf.parquet')
SELECT
  XXXX,
  ST_AsText(geometry) AS geom_text
WHERE XXXX

La base de données se crée tout simplement de la manière suivante :

#| output: false
#| echo: true

con <- dbConnect(duckdb::duckdb())
dbExecute(con, "INSTALL spatial;")
dbExecute(con, "LOAD spatial;")
Exercice 3
  1. En vous inspirant du template ci-dessus, créer un dataframe transactions_malakoff qui recense les transactions dans cette charmante bourgade.

  2. A ce niveau, les transactions extraites sont maintenant chargées en mémoire et on les transforme dans un format qui facilite leur manipulation en R via le package sf.

transactions_malakoff <-
  sf::st_as_sf(transactions_malakoff, wkt = "geom_text", crs = 2154) |>
  rename(geometry=geom_text)
  1. Nous allons créer un masque pour reconnaître les transactions qui sont situées ou non dans le triangle d’or. Utiliser la structure suivante pour créer ce masque :
bool_mask <- transactions_malakoff |>
  # ... |>
  sf::st_intersects(triangle, sparse = FALSE)

⚠️ il faut tenir compte des projections géographiques avant de faire l’opération d’intersection. Ce code est donc à amender à la marge pour pouvoir faire l’intersection.

Cela donne un vecteur de booléen, on peut donc identifier les transactions dans le triangle d’or ou en dehors à partir de celui-ci.

Ci-dessous le dataframe brut extrait via Duckdb (réponse 1).

#| echo: true
query2 <- glue("
    FROM read_parquet('data/dvf.parquet')
    SELECT
        code_commune,
        valeur_fonciere,
        ST_AsText(geometry) AS geom_text
    WHERE code_commune = '{cog_malakoff}'
")

transactions_malakoff <- dbGetQuery(con, query2)

head(transactions_malakoff, 3)

Ci-dessous, le dataframe transformé en objet sf et prêt pour les opérations spatiales (réponse 2) :

#| echo: true
transactions_malakoff <-
  sf::st_as_sf(transactions_malakoff, wkt = "geom_text", crs = 2154) |>
  rename(geometry=geom_text)

head(transactions_malakoff, 3)

Une fois les données prêtes, on intersecte les points avec le triangle représentant le centre-ville de Malakoff (question 3)

#| echo: true
bool_mask <- transactions_malakoff |>
  sf::st_transform(4326) |>
  sf::st_intersects(triangle, sparse = FALSE)

head(bool_mask)

On peut ensuite facilement créer nos deux espaces de Malakoff :

#| echo: true
in_triangle <- transactions_malakoff[bool_mask,]
out_triangle <- transactions_malakoff[!bool_mask,]

Une fois que chaque transaction est identifiée comme étant à l’intérieur ou à l’extérieur du Triangle, le calcul de la médiane des prix est immédiat.

median_in <- median(in_triangle$valeur_fonciere)
median_out <- median(out_triangle$valeur_fonciere)

print(glue("Médiane des prix dans le Triangle d'Or de Malakoff : ", median_in))
print(glue("Médiane des prix dans le reste de Malakoff : ", median_out))

La médiane des prix est un peu plus élevée dans le Triangle qu’en dehors. On peut aller au-delà et étudier la distribution des transactions. Bien que la taille d’échantillon soit réduite, on a ainsi une idée de la diversité des prix dans cette bucolique commune de Malakoff.

#| code-fold: true
#| code-summary: "Produire la figure sur la distribution du prix des biens"
library(ggplot2)
library(scales)

malakoff_identified <- transactions_malakoff %>%
  mutate(
    region = if_else(as.logical(bool_mask), "Triangle d'or", "Hors triangle d'or")
  )

ggplot(
  malakoff_identified,
  aes(y = valeur_fonciere, x = region, fill = region)
) +
  geom_violin() +
  scale_y_continuous(
    trans = "log10",
    labels = comma_format(),
    breaks = scales::trans_breaks("log10", function(x) 10^x)
  ) +
  geom_jitter(height = 0, width = 0.1) +
  labs(y = "Valeur de vente (€)") +
  theme_minimal()

Tout ceci ne nous dit rien de la différence entre les biens dans le triangle et en dehors de celui-ci. Nous n’avons fait aucun contrôle sur les caractéristiques des biens. Nous laissons les curieux explorer la mine d’or qu’est cette base.

7 Accéder directement à des données sur S3

En premier lieu, la bonne pratique est de définir la connexion à S3 par le biais de secrets DuckDB:

import os
import duckdb 

con = duckdb.connect(database=":memory:")

con.execute(
    f"""
CREATE SECRET secret_ls3 (
    TYPE S3,
    KEY_ID '{os.environ["AWS_ACCESS_KEY_ID"]}',
    SECRET '{os.environ["AWS_SECRET_ACCESS_KEY"]}',
    ENDPOINT '{os.environ["AWS_S3_ENDPOINT"]}',
    SESSION_TOKEN '{os.environ["AWS_SESSION_TOKEN"]}',
    REGION 'us-east-1',
    URL_STYLE 'path'
);
"""
)

Il suffit ensuite de faire un read_parquet en préfixant par s3://. La lecture se passera comme si on était en local:

query = (
    "FROM read_parquet('s3://projet-formation/bonnes-pratiques/data/RPindividus.parquet') "
    "SELECT IPONDI AS poids, COLUMNS('.*AGE.*')"
)
con.sql(query)

On peut bien sûr faire des opérations plus complexes, par exemple calculer une pyramide des âges par département par le biais d’une requête SQL:

pyramide = con.sql("""
    FROM read_parquet('s3://projet-formation/bonnes-pratiques/data/RPindividus.parquet')
    SELECT 
        CAST(FLOOR(AGED / 10) * 10 AS INT) AS tranche_age,
        CAST(SUM(IPONDI) AS INT) AS poids,
        DEPT
    GROUP BY tranche_age, DEPT
    ORDER BY tranche_age, DEPT
""").to_df()

pyramide

Représentons celle-ci sur une carte. Récupérons le fond de carte des départements avec cartiflette:

from cartiflette import carti_download

shp_communes = carti_download(
    values = ["France"],
    crs = 4326,
    borders = "DEPARTEMENT",
    vectorfile_format="topojson",
    simplification=50,
    filter_by="FRANCE_ENTIERE_DROM_RAPPROCHES",
    source="EXPRESS-COG-CARTO-TERRITOIRE",
    year=2022
)

shp_communes.head(2)

Calculons la part de chaque classe d’âge pour neutraliser l’effet taille:

donnees_carte = (
    shp_communes
    .merge(pyramide, left_on="INSEE_DEP", right_on="DEPT")
)
donnees_carte["proportion"] = donnees_carte["poids"] / donnees_carte["POPULATION"]

Et voici les cartes, obtenues simplement:

from plotnine import *
map = (
    ggplot(donnees_carte.loc[donnees_carte["tranche_age"] == 30]) +
    geom_map(aes(fill = "proportion")) +
    theme_light() +
    labs(title = "Part des trentenaires dans la population")
)
map
from plotnine import *
map = (
    ggplot(donnees_carte.loc[donnees_carte["tranche_age"] == 60]) +
    geom_map(aes(fill = "proportion")) +
    theme_light() +
    labs(title = "Part des soixantenaires dans la population")
)
map

Données partitionnées

On peut faire la même chose avec des données partitionnées:

con.sql("SELECT * FROM read_parquet('s3://projet-formation/bonnes-pratiques/data/RPindividus/**/*.parquet', hive_partitioning = true) WHERE DEPT IN (11, 31, 34)")

8 DuckDB WASM

DuckDB permet aussi de faire des traitements analytiques directement dans le navigateur grâce à son implémentation WASM. C’est particulièrement pratique pour créer des applications réactives comme celle-ci :

Que ce soit avec Quarto (comme ici) ou par le biais d’Observable (comme ), il est donc possible de construire des applications interactives reposant sur des sites statiques, sans avoir besoin d’un serveur ou .

Références supplémentaires

Du contenu pédagogique supplémentaire sur le sujet, produit par l’Insee:

D’autres références utiles, à consommer sans modération, sur le sujet:

Une vidéo sur la philosophie derrière la naissance de DuckDB et sa relation avec MotherDuck: