Partie 21 Organiser un projet avec targets

targets est une extension développée par Will Landau qui permet d’organiser un projet sous la forme d’un pipeline de traitements, composé de différentes étapes, et gérant automatiquement les dépendances entre celles-ci39. Cette organisation a plusieurs avantages :

  • elle permet une description de toutes les étapes du pipeline dans un fichier dédié, et force à séparer ces différentes étapes dans des fonctions à part, ce qui facilite la lisibilité et la maintenance du projet
  • elle facilite la reproductibilité des traitements, car elle garantit que toutes les étapes ont bien été effectuées dans le bon ordre et dans un nouvel environnement
  • elle optimise les temps de calcul, car en cas de modification seules les étapes qui le nécessitent sont relancées

L’utilisation de targets dans des petits projets peut être vue comme une complexité supplémentaire pas toujours très utile, mais elle peut être très bénéfique pour des projets plus complexes ou comportant des temps de calculs importants à certaines étapes.

targets fait partie de l’initiative rOpenSci.

21.1 Définition du pipeline

21.1.1 Projet d’exemple

On part d’un projet très simple : à partir du fichier national des prénoms donnés à la naissance, diffusé par l’INSEE, on souhaite produire un document indiquant les prénoms ayant les évolutions les plus fortes (à la hausse ou à la baisse) entre 2019 et 2020.

Le dossier de notre projet s’organise de la manière suivante :

data/
└── nat2020.csv
R/
├── fonctions_recode.R
└── fonctions_calculs.R
_targets.R

À noter que targets n’impose aucune structure de projet particulière en-dehors de la présence du fichier _targets.R. On aurait donc pu avoir une organisation tout à fait différente.

Le fichier data/nat2020.csv contient les données brutes téléchargées depuis le site de l’INSEE.

Le fichier R/fonctions_recode.R contient deux fonctions de traitement et de remise en forme des données.

# On conserve uniquement 2019 et 2020 et on
# filtre les lignes des prénoms rares regroupés
filter_data <- function(df) {
    df %>%
        filter(annais %in% c("2019", "2020")) %>%
        filter(preusuel != "_PRENOMS_RARES")
}

# Passage d'un format avec les années en ligne à un
# format avec les années en colonnes
pivot_2019_2020 <- function(df) {
    df %>%
        tidyr::pivot_wider(names_from = annais, values_from = nombre) %>%
        relocate(`2020`, .after = `2019`)
}

Le fichier R/fonctions_calculs.R contient une seule fonction qui calcule les variables d’évolution 2019-2020.

# Calcul des indicateurs d'évolution en effectifs et pourcentages
# pour les prénoms dont la fréquence est > à min_n
calcule_evo <- function(df, min_n = 200) {
    df %>%
        filter(`2020` > min_n | `2019` > min_n) %>%
        mutate(
            evo = (`2020` - `2019`),
            `evo%` = round(evo / `2019` * 100, 2)
        ) %>%
        drop_na(evo)
}

21.1.2 _targets.R

C’est dans le fichier _targets.R, situé à la racine du dossier, qu’on va définir le pipeline constitué de toutes les étapes de notre traitement : chargement et manipulation des données, calculs, génération de rapports, etc. Ces étapes sont également appelées cibles (targets).

La syntaxe présentée ici est celle proposée par l’extension tarchetypes, qui est un peu plus facile à prendre en main et plus lisible que la syntaxe native de targets.

Le fichier _targets.R commence par charger à la fois targets et tarchetypes.

# Packages nécessaires pour ce script
library(targets)
library(tarchetypes)

On va ensuite utiliser source() pour charger le contenu des deux fichiers R/fonctions_recode.R et R/fonctions_calculs.R, et pouvoir utiliser par la suite les fonctions qu’ils définissent.

# Chargement des fonctions
source("R/fonctions_recode.R")
source("R/fonctions_calculs.R")

On définit ensuite des options globales pour le pipeline. L’option packages de tar_option_set(), permet de spécifier une liste d’extensions à charger systématiquement avant le lancement de chaque étape. Ici on s’assure que l’extension tidyverse est bien chargée et disponible, et on positionne l’option tidyverse.quiet à TRUE pour supprimer le message qu’elle affiche systématiquement au chargement.

# Options pour les différentes étapes
options(tidyverse.quiet = TRUE)
tar_option_set(packages = "tidyverse")

Vient enfin la définition du pipeline proprement dit. Celle-ci se fait via la fonction tar_plan() de tarchetypes.

tar_plan(

)

La première opération que l’on souhaite effectuer est de charger les données contenues dans data/nat2020.csv. Pour cela on va d’abord créer une première étape qui consiste à référencer notre fichier CSV à l’aide de la fonction tar_file().

tar_plan(
    # Chargement du fichier CSV
    tar_file(csv_file, "data/nat2020.csv")
)

Cette première étape définit une cible (target), nommée csv_file, qui pointe vers notre fichier CSV.

On ajoute une seconde étape qui charge les données à l’aide de read_csv2().

tar_plan(
    # Chargement du fichier CSV
    tar_file(csv_file, "data/nat2020.csv"),
    donnees_brutes = read_csv2(csv_file),
)

Cette nouvelle étape définit une deuxième cible nommée donnees_brutes. Cette cible correspond au nom d’une étape, mais aussi à un objet : dans ce qui suit, donnees_brutes correspond au tableau de données résultat du read_csv2().

On va utiliser cet objet donnees_brutes dans une troisième étape nommée donnees qui lui applique les deux fonctions de filtrage et transformation définies dans R/fonctions_recode.R.

tar_plan(
    # Chargement du fichier CSV
    tar_file(csv_file, "data/nat2020.csv"),
    donnees_brutes = read_csv2(csv_file),

    # Mise en forme des données
    donnees = donnees_brutes %>%
        filter_data() %>%
        pivot_2019_2020()
)

Ici aussi, donnees est à la fois le nom d’une cible et un objet contenant nos données retravaillées. On utilise cet objet dans une étape supplémentaire qui utilise la fonction de R/fonctions_calculs.R pour calculer les variables d’évolution.

tar_plan(
    # Chargement du fichier CSV
    tar_file(csv_file, "data/nat2020.csv"),
    donnees_brutes = read_csv2(csv_file),

    # Mise en forme des données
    donnees = donnees_brutes %>%
        filter_data() %>%
        pivot_2019_2020(),

    # Calcul indicateurs
    donnees_evo = donnees %>%
        calcule_evo(min_n = 1000)
)

On notera que les cibles doivent toutes avoir des noms différents. Si on exécute plusieurs étapes de transformation ou de calcul sur un tableau de données, on devra donner un nom distinct à ces cibles et aux objets qui correspondent.

Au final, notre fichier _targets.R est donc le suivant :

# Packages nécessaires pour ce script
library(targets)
library(tarchetypes)

# Chargement des fonctions
source("R/fonctions_recode.R")
source("R/fonctions_calculs.R")

# Options pour les différentes étapes
options(tidyverse.quiet = TRUE)
tar_option_set(packages = "tidyverse")

# Définition du pipeline
tar_plan(
    # Chargement du fichier CSV
    tar_file(csv_file, "data/nat2020.csv"),
    donnees_brutes = read_csv2(csv_file),

    # Mise en forme des données
    donnees = donnees_brutes %>%
        filter_data() %>%
        pivot_2019_2020(),

    # Calcul indicateurs
    donnees_evo = donnees %>%
        calcule_evo(min_n = 1000)

)

targets offre aussi la possibilité de définir notre pipeline directement dans un fichier RMarkdown, ce qui peut permettre notamment de mieux le documenter. Pour plus d’information on pourra se référer au chapitre Target Markdown du manuel en ligne.

21.2 Exécution du pipeline

Une fois notre pipeline défini, targets fournit des outils permettant de visualiser sa structure et son état, notamment la fonction tar_visnetwork().

tar_visnetwork()

Les différentes cibles apparaissent sous forme de cercles, et les fonctions qui leur sont appliquées sous forme de triangles. Les flèches indiquent que targets a automatiquement créé un réseau de dépendances entre cibles et fonctions : ainsi la cible donnees dépend des fonctions filter_data, pivot_2019_2020 et de la cible donnees_brutes, qui elle-même dépend de la cible csv_file.

La couleur des différents éléments montrent que ceux-ci sont à l’état outdated : ils ne sont pas à jour.

On va donc exécuter notre pipeline, en utilisant la fonction tar_make().

tar_make()
#> • start target csv_file
#> • built target csv_file
#> • start target donnees_brutes
#> ℹ Using "','" as decimal and "'.'" as grouping mark. Use `read_delim()` for more control.
#> Rows: 667364 Columns: 4
#> ── Column specification ────────────────────────────────────────────────────────
#> Delimiter: ";"
#> chr (2): preusuel, annais
#> dbl (2): sexe, nombre
#> 
#> ℹ 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.
#> • built target donnees_brutes
#> • start target donnees
#> • built target donnees
#> • start target donnees_evo
#> • built target donnees_evo
#> • end pipeline: 2.35 seconds

Lorsqu’on utilise tar_make(), targets lance une nouvelle session R (pour éviter tout problème ou conflit lié à l’état de notre session actuelle), charge les extensions définies via tar_option_set(), et exécute les cibles définies avec tar_plan().

On visualise le nouvel état de notre pipeline, et on voit que toutes les cibles sont passées à l’état up to date.

tar_visnetwork()

À chaque étape, targets crée et stocke dans un cache chacun des objets correspondants aux différentes cibles (donnees_brutes, donnees, etc.). On peut charger à tout moment ces objets dans notre session avec la fonction tar_load()40.

tar_load(donnees_evo)
donnees_evo
#> # A tibble: 128 × 6
#>     sexe preusuel  `2019` `2020`   evo `evo%`
#>    <dbl> <chr>      <dbl>  <dbl> <dbl>  <dbl>
#>  1     1 AARON       2443   2312  -131  -5.36
#>  2     1 ADAM        3670   3386  -284  -7.74
#>  3     1 ALEXANDRE   1154   1039  -115  -9.97
#>  4     1 AMIR        1588   1360  -228 -14.4 
#>  5     1 ANTOINE     1786   1455  -331 -18.5 
#>  6     1 ARTHUR      4008   3800  -208  -5.19
#>  7     1 AUGUSTIN    1546   1379  -167 -10.8 
#>  8     1 AXEL        1809   1683  -126  -6.97
#>  9     1 AYDEN       1524   1786   262  17.2 
#> 10     1 BAPTISTE    1403   1202  -201 -14.3 
#> # … with 118 more rows

On peut aussi utiliser tar_read(), qui lit et retourne les résultats d’une des cibles, permettant de les stocker dans un nouvel objet.

evo <- tar_read(donnees_evo)

21.3 Modification du pipeline

Essayons de lancer à nouveau notre pipeline :

tar_make()
#> ✔ skip target csv_file
#> ✔ skip target donnees_brutes
#> ✔ skip target donnees
#> ✔ skip target donnees_evo
#> ✔ skip pipeline: 0.063 seconds

On voit que toutes les cibles ont été “skippées” : quand on lance tar_make(), seules les cibles qui sont à l’état outdated sont recalculées. Les résultats des autres sont conservés tels quels.

On va maintenant modifier légèrement notre fichier R/fonctions_calculs.R : plutôt que d’arrondir les évolutions en pourcentages à deux décimale, on n’en conserve plus qu’une.

# Calcul des indicateurs d'évolution en effectifs et pourcentages
# pour les prénoms dont la fréquence est > à min_n
calcule_evo <- function(df, min_n = 200) {
    df %>%
        filter(`2020` > min_n | `2019` > min_n) %>%
        mutate(
            evo = (`2020` - `2019`),
            `evo%` = round(evo / `2019` * 100, 1)
        ) %>%
        drop_na(evo)
}

On visualise à nouveau l’état de notre pipeline :

tar_visnetwork()

Grâce à sa gestion interne des dépendances entre les cibles, targets a vu que la fonction calcule_evo a été modifiée (elle est passée en statut outdated), et comme la cible donnees_evo dépend de cette fonction, celle-ci a également été placée en outdated. On peut obtenir directement une liste des cibles qui ne sont plus à jour à l’aide de la fonction tar_outdated() :

tar_outdated()
#> [1] "donnees_evo"

On relance notre pipeline :

tar_make()
#> ✔ skip target csv_file
#> ✔ skip target donnees_brutes
#> ✔ skip target donnees
#> • start target donnees_evo
#> • built target donnees_evo
#> • end pipeline: 0.906 seconds

On voit que les cibles csv_file, donnees_brutes et donnees ont été “skippées” : targets est allé prendre directement leurs valeurs déjà stockées en cache. Par contre donnees_evo a bien été recalculée.

On peut vérifier que notre pipeline est désormais entièrement à jour :

tar_visnetwork()

À noter que targets gère aussi les modifications des fichiers externes. Ainsi, si on modifie le contenu de nat2020.csv, la cible csv_file passerait en outdated, tout comme l’ensemble des autres cibles puisqu’elles dépendent directement ou indirectement de celle-ci. Dans ce cas, un tar_make() aurait pour effet de recalculer l’intégralité du pipeline.

21.4 RMarkdown

Imaginons maintenant qu’on souhaite générer un rapport à partir d’un document RMarkdown en utilisant les données d’évolution calculées par notre pipeline. On crée donc un nouveau fichier evolution.Rmd dans un dossier reports.

data/
└── nat2020.csv
R/
├── fonctions_recode.R
└── fonctions_calculs.R
reports/
└── evolution.Rmd
_targets.R

Quand on utilise un document RMarkdown dans un pipeline, on doit accéder aux données en utilisant les fonctions tar_read() ou tar_load() : ceci permet de s’assurer qu’on récupère les données “à jour”, et cela permet aussi à targets de déterminer un lien de dépendance entre le document et les données.

Comme on souhaite utiliser les données de donnees_evo, on devra donc utiliser quelque chose comme :

d <- tar_read(donnees_evo)

Au final, le contenu de notre fichier RMarkdown est le suivant :

---
title: "Évolutions des prénoms 2019-2020"
date: "`r Sys.Date()`"
output: 
    html_document:
        df_print: paged
---

```{r setup, include = FALSE}
knitr::opts_chunk$set(echo = FALSE)
d <- tar_read(donnees_evo)
```


## Plus fortes hausses

```{r}
d %>%
    arrange(desc(`evo%`)) %>%
    head(10)
```

## Plus fortes baisses

```{r}
d %>%
    arrange(`evo%`) %>%
    head(10)
```

Pour ajouter ce rapport à notre pipeline, on crée une nouvelle cible dans le tar_plan() de _targets.R. Comme il s’agit d’un document RMarkdown, on utilise la fonction tar_render().

# Packages nécessaires pour ce script
library(targets)
library(tarchetypes)

# Chargement des fonctions
source("R/fonctions_recode.R")
source("R/fonctions_calculs.R")

# Options pour les différentes étapes
options(tidyverse.quiet = TRUE)
tar_option_set(packages = "tidyverse")

# Définition du pipeline
tar_plan(
    # Chargement du fichier CSV
    tar_file(csv_file, "data/nat2020.csv"),
    donnees_brutes = read_csv2(csv_file),

    # Mise en forme des données
    donnees = donnees_brutes %>%
        filter_data() %>%
        pivot_2019_2020(),

    # Calcul indicateurs
    donnees_evo = donnees %>%
        calcule_evo(min_n = 1000),

    # Génération rapport
    tar_render(report_evo, "reports/evolution.Rmd")
)

Visualisons notre pipeline modifié :

tar_visnetwork()

On voit que notre nouvelle cible report_evo a bien été prise en compte, qu’elle dépend bien de donnees_evo et qu’elle est à l’état outdated.

Si on exécute notre pipeline :

tar_make()
#> ✔ skip target csv_file
#> ✔ skip target donnees_brutes
#> ✔ skip target donnees
#> ✔ skip target donnees_evo
#> • start target report_evo
#> • built target report_evo
#> • end pipeline: 1.231 seconds

La cible report_evo a bien été calculée, et on devrait retrouver notre rapport compilé au format HTML dans le dossier reports.

21.5 Gestion des données en cache

targets garde une copie des objets correspondant aux cibles du pipeline dans un cache, en fait sous forme de fichiers placés dans un sous-dossier _targets.

On a vu qu’on peut récupérer ces objets dans notre session via les fonctions tar_read() et tar_load(). targets propose également plusieurs fonctions pour gérer les données et métadonnées en cache :

  • tar_destroy() supprime la totalite du répertoire _targets. Elle permet donc de “repartir de zéro”, sans aucun cache et avec toutes les cibles à recalculer.
  • tar_delete(donnees) supprime l’objet donnees du cache et place l’état de la cible correspondante à outdated. Elle permet de forcer le recalcul d’une cible et de celles qui en dépendent. À noter qu’on peut sélectionner plusieurs cibles en utilisant la syntaxe de la tidy selection.
  • tar_prune() permet de supprimer les cibles qui ne sont plus présentes dans le pipeline. Elle permet donc de “faire le ménage” quand on a supprimé des étapes dans _targets.R.

21.6 Avantages et limites

21.6.1 Avantages

On peut voir dans cette introduction rapide que l’utilisation de targets présente de nombreux avantages :

  1. le fichier _targets.R fournit une description détaillée des étapes du projet. Cela facilite les choses quand on revient dessus après un certain temps et qu’on n’a plus tous les détails en tête, ou si on le partage avec quelqu’un.
  2. chaque cible du pipeline est définie via des fonctions, ce qui garantit une séparation et une encapsulation des différentes étapes.
  3. l’utilisation de tar_make() garantit que toutes les cibles du pipeline sont recalculées dans le bon ordre : pas de risque de lancer un script sur des données qui ne seraient pas complètement à jour parce qu’on a oublié de relancer certains recodages par exemple.
  4. tar_make() s’exécute toujours dans un environnement vide, ce qui élimine les problèmes liés à l’état de notre session en cours et garantit la reproductibilité des résultats.
  5. comme targets conserve une copie des résultats des cibles en cache, pas besoin de tout recalculer quand on relance le projet, on peut récupérer directement les résultats et savoir si ils sont à jour.
  6. tar_make() ne recalcule que les cibles qui le nécessitent, les temps de calcul et d’exécution sont optimisés.

Parmi les inconvénients liés à l’utilisation de targets, on notera que le débuggage est un peu plus complexe, même si l’extension fournit plusieurs outils pour faciliter le travail.

21.6.2 Interactivité et développement du pipeline

Une des limitations de targets est que le pipeline ne permet pas l’utilisation de fonctions “interactives”. Par exemple, on pourrait ajouter une étape affichant un graphique dans tar_plan() :

graphique = ggplot(donnees_evo) + geom_histogram(aes(x = evo))

Ceci fonctionne, mais ne provoque pas l’affichage du graphique. Il faut faire un tar_read(graphique) pour pouvoir le visualiser.

De la même manière, on ne peut pas utiliser d’interfaces interactives comme celles vues pour faciliter les recodages de variables (par exemple section 9.3.2.1). Il est donc souvent pratique de commencer à développer des transformations, calculs ou analyses de façon “interactive”, via un script classique dans lequel on importe les données nécessaires via tar_read(). Une fois qu’on obtient le résultat souhaité, on transforme ce code en une ou plusieurs fonctions et on les intègre au pipeline de targets.

On notera que les documents RMarkdown s’utilisent très bien avec targets : du moment qu’on charge les données avec tar_read() ou tar_load(), ils permettent à la fois une utilisation “interactive” pendant leur écriture, et une intégration directe dans un pipeline avec tar_render() sans avoir besoin de les modifier.

21.7 Ressources

Nous n’avons vu ici qu’un petit aperçu des fonctionnalités de targets, qui est une extension extrêmement riche et qui propose de nombreuses autres possibilités, comme la parallélisation des calculs, la gestion des versions de paquets via renv, la création programmatique de cibles…

Le package bénéficie d’une excellente documentation en anglais. On pourra donc se référer aux sites officiels de targets et tarchetypes, mais surtout à l’ouvrage en ligne The targets R Package User Manual, très clair et très complet.

Le groupe des utilisateurs de R de Lille a accueilli une intervention (toujours en anglais) de Will Landau, l’auteur de targets. Celle-ci est disponible en vidéo sur YouTube.


  1. Pour les personnes habituées au développement, il s’agit d’un équivalent à GNU Make pour R.↩︎

  2. Ces données sont stockées dans le répertoire _targets à la racine du projet.↩︎