10  Construire une chaîne de traitement reproductible avec targets

10.1 Tâches concernées et recommandations

L’utilisateur souhaite automatiser une chaîne de traitement complexe afin de la rendre reproductible et rapide à exécuter en cas de modification.

Tâche concernée et recommandation

Le package targets permet de construire simplement une chaîne de traitement reproductible.

Les deux éléments suivants sont à prendre en considération :

  • ce package ne sera approprié que si la chaîne de traitement est exclusivement écrite en R ;
  • il est fortement recommandé de savoir créer des fonctions, afin de modulariser le code.

10.2 Pourquoi utiliser targets ?

Le package targets peut être particulièrement intéressant :

  • dans le cadre du développement d’un prototype ayant vocation à devenir une chaîne de production pérenne écrite avec R ;
  • dans le cas d’un projet d’étude qui vise à une forte reproductibilité.

Plus précisément, utiliser targets pour un projet permet de :

  1. Viser la reproductibilité de l’ensemble des étapes de traitement, tout en réduisant au strict nécessaire la répétition de ces étapes, parfois longues ;
  2. Adopter des bonnes pratiques de développement en R par l’usage (modulariser le code, décomposer ses traitements par étapes, assurer la lisibilité des étapes successives du traitement…)
  3. Représenter sous forme de pipeline les étapes de sa chaîne de traitement et leurs dépendances à partir d’une technique de graphiques directionnels asynchrones (appelés DAG pour l’acronyme anglais dans la sphère informatique)
  4. Faciliter la prise en main par une autre personne grâce à une organisation standardisée des codes et à une description complète de l’enchaînement des étapes intégrée dans le code lui-même.
Note

Le guide des bonnes pratiques utilitR devrait prochainement s’enrichir d’éléments concernant la gestion de pipelines de données en R et en Python.

Les premiers éléments du débat sont disponibles sur l’issue #388 dans le dépôt Github d’utilitR.

10.3 Quelles sont les tâches automatisées par targets ?

targets permet de définir et d’exécuter une chaîne de traitement avec :

  • Sauvegarde automatique de résultats intermédiaires, ce qu’on appelle les “targets” (cibles)
  • Traçabilité de ces résultats intermédiaires par targets: lors de la répétition d’une exécution de la chaîne de traitement, ils ne sont mobilisés que si ils sont reproductibles.
  • Si une fonction ou un input nécessaire au calcul d’une “target” est modifié, targets repère automatiquement les étapes à reconduire, et seulement celles-ci.

Ainsi, le lancement du traitement et la vérification de la reproductibilité sont effectués ensemble au cours du développement du projet par l’appel de tar_make().

Vérifier la reproductibilité revient ainsi à ne pas ‘tout relancer’ de 0 ! Ceci représenterait un coût trop élevé. targets automatise le travail d’aller-retour dans les étapes d’une étude ou de prototypage (j’ai modifié l’étape 1, il faut donc que je relance l’étape 2 qui en dépend…), en construisant un graphe des dépendances des différentes étapes du traitement.

Pour la suite de la fiche, prenons l’exemple d’une étude qui se structurerait suivant les étapes suivantes :

  1. Charger les données
  2. Traiter les données
  3. Produire des résultats
  4. Représenter des résultats

10.4 Un projet minimal pour comprendre l’essentiel

10.4.1 Structure du projet

Un projet targets est un projet R en règle générale structuré de la sorte :

  • un fichier _targets.R décrivant les éléments de configuration (par exemple packages utilisés) et l’enchaînement des traitements
  • un dossier R comprenant les scripts définissant les fonctions utilisées par le projet
  • un dossier data pour les données externes (non générées au cours du projet)

L’architecture des dossiers du projet ressemble par conséquent à ceci :

├── _targets.R
├── R/
├───── mesfonctions_pour_faire_ceci.R
├───── mesfonctions_pour_faire_cela.R
├──── ...
├── data/
├───── donnees_entrees.csv
└───── ...
Tip

Organiser ses fichiers de cette façon est très commun, mais pas indispensable pour l’utilisation de targets. La seule obligation est que le fichier _targets.R soit positionné dans le répertoire de travail.

Une manière commode pour un utilisateur souhaitant utiliser targets est donc de créer un projet RStudio à la racine duquel il place ce fichier. En prévision des futures fonctions qu’il va écrire, il crée un dossier R/. Le fichier _targets.R détaille l’enchaînement des traitements. Il doit toujours contenir une instruction chargeant le package targets.

10.4.2 Premier exemple

Partons d’un exemple simple :

  • on lit les données de population depuis un fichier CSV ;
  • on a créé une fonction pour ne garder que les communes de plus de 200 000 habitants ;
  • sur ces communes, on désire connaître la proportion dont le revenu médian est supérieur à 25 000 euros.

La chaîne de traitement est donc ici linéaire. Chaque étape dépend de la précédente et uniquement de celle-ci. Le fichier d’instruction _targets prendra alors la forme suivante:

# fichier _targets.R

library(targets)

tar_option_set(packages = c("dplyr", "readr"))

source("mesfonctions_pour_faire_ceci.R", encoding = "utf-8")

# on crée un fichier à partir d'un des jeux d'exemples
raw_file_path <- "data/donnes_entrees.csv"
dir.create("data")
readr::write_csv(doremifasolData::filosofi_com_2016, raw_file_path)

list(
  
  tar_target(csv_file, raw_file_path, format = "file"),

  tar_target(
    raw_filosofi_epci, readr::read_csv(csv_file),
  ),
  tar_target(
    grandes_villes, garde_grandes_villes(raw_filosofi_epci)
  ),
  tar_target(
    prop_sup_25k, grandes_villes %>% dplyr::summarise(mean(MED16 > 25000)*100)
  )
)

Les fonctions écrites par l’analyste et utilisées dans la chaîne de traitement (en l’occurrence garde_grandes_villes) sont contenues dans les fichiers que l’on “source” au départ, ici depuis un script "mesfonctions_pour_faire_ceci.R.

Les packages utilisés dans les traitements sont définis via la fonction tar_option_set du package targets. Ici, on a besoin des packages dplyr et readr dans notre chaîne de traitement.

La chaîne de traitement est représentée par une liste de tar_target, soit les objets R qui sont les cibles intermédiaires de l’analyse. Ils sont le résultat de l’application à une cible précédente d’une fonction pour obtenir la cible suivante :

  1. Ici la première cible est particulière (format = file) : on spécifie où sont les données d’entrée afin de surveiller si elles changent.
  2. La seconde prend en entrée la première cible data_file et la transforme en appliquant la fonction readr::read_csv en un nouvel objet R, raw_filosofi_epci. Il s’agit ainsi des données brutes après l’import dans R, avant toute modification
  3. La troisième applique cette fois une fonction écrite par l’utilisateur à raw_filosofi_epci pour obtenir grandes_villes, et ainsi de suite…

Ainsi, le fichier _targets.R contient la description de l’ensemble des étapes du traitement. La complexité des traitements est résumée de façon concise par un ensemble minimal de fonctions résumant les grandes étapes. Afin de faire tourner l’analyse, l’utilisateur fait appel au sein du projet à la fonction tar_make(). Il s’agit de la fonction qu’un utilisateur du package targets utilisera le plus fréquemment. L’utilisateur est informé de l’évolution des calculs.

▶ dispatched target csv_file
● completed target csv_file [0.063 seconds]
▶ dispatched target raw_filosofi_epci
Rows: 34932 Columns: 29
── Column specification ────────────────────────────────────────────────────────
Delimiter: ","
chr  (2): CODGEO, LIBGEO
dbl (27): NBMENFISC16, NBPERSMENFISC16, MED16, PIMP16, TP6016, TP60AGE116, T...

ℹ 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.
● completed target raw_filosofi_epci [0.181 seconds]
▶ dispatched target grandes_villes
● completed target grandes_villes [0.085 seconds]
▶ dispatched target prop_sup_25k
● completed target prop_sup_25k [0.003 seconds]
▶ completed pipeline [0.624 seconds]

Lorsque la chaîne de traitement est de taille relativement modeste (comme ici), on peut la visualiser avec la fonction tar_visnetwork:

On obtient bien un diagramme linéaire comme on en avait l’intuition.

Note

Il est tout à fait possible de stocker l’ensemble des cibles intermédiaires dans un emplacement différent du projet. Il s’agit même d’une bonne pratique de séparer le lieu de stockage du code de celui des données.

Il sera nécessaire d’éditer les options de la chaîne dans le fichier _targets.R. Par exemple avec cette ligne de commande, au début du fichier _targets.R (mais après l’appel à library(targets):

tar_config_set(store = "mon_dossier_donnees/projet-toto")

10.4.3 Modification d’une étape intermédiaire

L’utilisateur décide ensuite de modifier la définition des grandes villes considérées. Supposons qu’il ajoute un argument à la fonction garde_grandes_villes pour ne garder que celles dont la population est supérieure à seuil. Dans le fichier _targets.R, il est nécessaire de changer la définition de l’étape de définition de grandes_villes. Cela amènera à une chaîne ayant la structure suivante

# fichier _targets.R

library(targets)

tar_option_set(packages = c("dplyr", "readr"))

source("mesfonctions_pour_faire_ceci.R", encoding = "utf-8")

# on crée un fichier à partir d'un des jeux d'exemples
raw_file_path <- "data/donnes_entrees.csv"
dir.create("data")
readr::write_csv(doremifasolData::filosofi_com_2016, raw_file_path)

list(
  
  tar_target(csv_file, raw_file_path, format = "file"),
  
  tar_target(
    raw_filosofi_epci, readr::read_csv(csv_file),
  ),
  tar_target(
    grandes_villes, garde_grandes_villes(raw_filosofi_epci, seuil = 10000)
  ),
  tar_target(
    prop_sup_25k, grandes_villes %>% dplyr::summarise(mean(MED16 > 25000)*100)
  )
)

Ici, le pipeline est de taille relativement modeste et il est facile d’identifier la source de modification. Néanmoins, la représentation sous forme de diagramme peut aider à mieux s’en rendre compte

La modification de la fonction garde_grandes_villes entraîne la nécessaire mise à jour de grandes_villes et toutes les cibles qui en dépendent, mais pas du début de la chaîne de traitement !

targets va ainsi intelligemment utiliser ceci pour minimiser le temps nécessaire pour mettre à jour l’ensemble de la chaîne de traitement

✔ skipped target csv_file
✔ skipped target raw_filosofi_epci
▶ dispatched target grandes_villes
● completed target grandes_villes [0.007 seconds]
▶ dispatched target prop_sup_25k
● completed target prop_sup_25k [0.003 seconds]
▶ completed pipeline [0.226 seconds]
Warning message:
In dir.create("data") : 'data' already exists

Les cibles définies sont calculées successivement, stockées et mises à jour automatiquement dans un dossier _targets/objects/.

10.4.4 Accéder à des éléments du pipeline dans une session R

On peut facilement accéder à un objet cible, quel que soit son emplacement dans la chaîne de traitement, puisque chaque cible est stockée sous la forme d’un fichier temporaire.

La fonction tar_load permet de charger dans l’environnement R l’objet en question. Par exemple, si on désire tester des choses sur grandes_villes, on pourra utiliser la commande suivante

tar_load(grandes_villes)
head(grandes_villes)
# A tibble: 6 × 29
  CODGEO LIBGEO      NBMENFISC16 NBPERSMENFISC16  MED16 PIMP16 TP6016 TP60AGE116
  <chr>  <chr>             <dbl>           <dbl>  <dbl>  <dbl>  <dbl>      <dbl>
1 01004  Ambérieu-e…        6363          14228  19721      49     17         19
2 01033  Valserhône         6472          15255  21405.     45     16         18
3 01053  Bourg-en-B…       18601          38014. 18249.     46     22         27
4 01173  Gex                4894          11276. 32304.     60     11         NA
5 01283  Oyonnax            9248          22444. 16948.     40     25         31
6 02168  Château-Th…        6805          15070. 17643.     43     24         33
# ℹ 21 more variables: TP60AGE216 <dbl>, TP60AGE316 <dbl>, TP60AGE416 <dbl>,
#   TP60AGE516 <dbl>, TP60AGE616 <dbl>, TP60TOL116 <dbl>, TP60TOL216 <dbl>,
#   PACT16 <dbl>, PTSA16 <dbl>, PCHO16 <dbl>, PBEN16 <dbl>, PPEN16 <dbl>,
#   PPAT16 <dbl>, PPSOC16 <dbl>, PPFAM16 <dbl>, PPMINI16 <dbl>, PPLOGT16 <dbl>,
#   PIMPOT16 <dbl>, D116 <dbl>, D916 <dbl>, RD16 <dbl>

Cela permettra à l’utilisateur de targets de prototyper une nouvelle étape de traitement dans sa session R puis, une fois satisfait, la mettre en production en mettant les fonctions dans le fichier XXXXX.R et en créant l’étape tar_target adéquate.

Tip

Par défaut, les cibles sont stockées au format rds. Ce format présente deux inconvénients :

  • il est spécifique à R et ne permet pas de lire les étapes intermédiaires dans un autre langage (par exemple Python) ;
  • la sérialisation des objets R nécessaire pour écrire sous format rds ou lire un tel fichier est assez lente.

Il est conseillé d’utiliser un autre format de stockage des cibles.

En premier lieu, le format par défaut qui peut être utilisé est le format qs. À l’instar du format rds, celui-ci est spécifique à R mais présente l’avantage d’être beaucoup plus rapide en termes de temps en lecture/écriture. Pour cela, il convient d’ajouter la ligne suivante au début des options du fichier _targets.R :

tar_option_set(format = "fst_dt")

Pour les dataframes, il est possible d’utiliser des formats plus universels ou plus appropriés. Les formats à privilégier sont les suivants:

  • parquet: format qui tend à devenir un standard dans le monde de la science des données. Ce format présente plusieurs avantages, parmi lesquels le fait qu’il est très compressé, très rapide et qu’il conserve les métadonnées du fichier ce qui permet, à la différence des formats type CSV, de conserver l’intégrité des typages des colonnes (voir la fiche Importer des fichiers parquets pour plus de détails) ;
  • fst_tbl (utilisateurs du tidyverse) ou fst_dt (utilisateurs de data.table) : formats spécifiques à R présentant des avantages proches de ceux d’un fichier parquet. Ils préservent la nature d’un data.frame, ce qui permet de repartir d’un tibble ou d’un datatable sans avoir à faire de conversion à chaque étape du pipeline.

Le choix du format de stockage d’un objet se fait directement lors de la déclaration de la cible dans _targets.R:

tar_target(
    grandes_villes, garde_grandes_villes(raw_filosofi_epci),
    format = "parquet"
)

Dans le dossier _targets/object, le fichier sera ainsi stocké au format exigé.

Il n’est pas recommandé d’utiliser les formats parquet, fst_dt ou fst_tbl par défaut car ils ne permettent de stocker que des dataframes. Or, un pipeline peut stocker des objets de nature beaucoup plus diverses (listes, objets ggplot, etc.)

Note

L’utilisation du garbage collector peut parfois s’avérer utile pour nettoyer la mémoire de la session R dans laquelle tourne le pipeline. Ceci est particulièrement utile lorsque les objets manipulés sont volumineux (voir la fiche Superviser sa session R).

Dans targets, cette opération est possible en ajoutant l’argument garbage_collection = TRUE à la définition de la cible :

tar_target(
    grandes_villes, garde_grandes_villes(raw_filosofi_epci),
    garbage_collection = TRUE
)

10.5 Intégrer un rapport en Rmarkdown

L’un des principaux gains à utiliser targets est dans la fiabilisation du processus de production de fichiers markdown à l’issue d’une chaîne de traitement.

Deux philosophies existent pour produire un fichier reproductible dans une chaîne de traitement :

  • Intégrer directement le fichier à la chaîne comme une étape finale du processus de production. Cela revient à produire le RMarkdown via un tar_target particulier ;
  • Exécuter la chaîne de traitement, ou les parties nouvelles de la chaîne de traitement, directement depuis le fichier RMarkdown. Dans ce cas, le fichier .Rmd n’est plus exécuté depuis le _targets.R mais au contraire sert à l’exécuter.

10.5.1 Concevoir un rapport en sortie de chaîne de traitement

Le package tarchetypes est un complément utile. Ce package permet d’intégrer simplement des rapports Rmarkdown dans la pipeline avec tarchetypes::tar_render(). L’essentiel des calculs doit être en amont du rapport markdown, qui doit être rapide à exécuter.

Par exemple, on peut écrire un Rmarkdown report.Rmd considéré comme une des cibles de l’analyse (par exemple, c’est le compte-rendu de l’analyse), et qui dépend d’autres cibles. On souhaite également qu’il soit reproductible, et mis à jour automatiquement en fonction des modifications sur les cibles dont il dépend.

Il suffit d’intégrer ces cibles via tar_read(data) ou tar_load(data) appelé dans un chunk du .Rmd, et de spécifier un _targets.R sur le modèle suivant :

# Fichier _targets.R
# report.Rmd est présent dans le projet.
library(targets)
library(tarchetypes)

list(
  tar_target(data, data.frame(a = seq(2,9), b = seq(2,9))),
  tar_render(report, path = 'report.Rmd')
)

10.5.2 Utiliser des objets issus d’une chaîne de traitement dans un R Markdown

Cette méthode est particulièrement appropriée lorsqu’on désire prototyper un rapport en utilisant un ou plusieurs objets de la chaîne de traitement.

Plus d’éléments sont disponibles dans la documentation officielle

10.6 Les branches

Souvent, les cibles d’une analyse (étapes intermédiaires) sont nombreuses et ont un certain degré de redondance.

Comment créer des cibles automatiquement (sans écrire explicitement dans _targets.R chacune d’entre elles) ? targets propose de décliner les cibles en “branches”.

On distingue :

  • les branches définies dynamiquement : avant l’exécution, le nombre de branches est inconnu ;
  • les branches définies statiquement : le nombre de branche est défini précisément avant l’exécution.

Le premier cas correspond à la répétition d’un grand nombre de tâches homogènes, le second plutôt à un petit nombre de tâches hétérogènes.

Note

Les branches statiques, qui nécessitent l’usage du package tarchetypes, ne sont pas abordées ici.

10.6.1 Les branches dynamiques

Certaines cibles peuvent être le résultat de l’application d’une même fonction à des variantes d’arguments (par exemple, un graphique de restitution pour plusieurs populations d’intérêt).

Pour cela, targets propose les branches dynamiques.

10.6.2 Un exemple

Voici un exemple minimal de pipeline qui va itérer sur N couples d’arguments une même “simulation”, en évitant de créer N cibles distinctes pour les N résultats, et plutôt créer une seule cible résultats qui donnera lieu à autant de branches que de “simulations” :

#_targets.R
library(targets)

simulation <- function(x, y) x * y

list(
  tar_target(x, c(10, 20, 30)),
  tar_target(y, c(1, 2, 3)),
  tar_target(
    resultat,
    data.frame(argument_1 = x, argument_2 = y, res = simulation(x, y)),
    pattern = map(x, y))
)

Ce qui distingue ici la cible resultat de ce qui a été vu précédemment, c’est l’utilisation de l’argument pattern, qui a vocation à itérer sur les vecteurs cibles x et y grâce à map.

Dans la console R, l’utilisateur qui fait appel à tar_make() voit apparaître la déclinaison de resultat en trois branches, exécutées en parallèle.

● run target x
● run target y
● run branch resultat_1851c9ee
● run branch resultat_445bc859
● run branch resultat_1a0263ff
● end pipeline

On obtient le résultat suivant:

tar_read(resultat)
argument_1 argument_2 res
1         10          1  10
2         20          2  40
3         30          3  90

10.6.3 Itérer, croiser les arguments pour créer des branches

Les patterns peuvent être de plusieurs types : map (itérer sur les arguments ligne à ligne), cross (produit cartésien des arguments), head (pour récupérer les premiers arguments), select (pour récupérer certains arguments) …

Par exemple, remplacer map par cross dans la pipeline précédente donne lieu après un tar_make() à

✓ skip target x
✓ skip target y
✓ skip branch resultat_1851c9ee
● run branch resultat_cca1045b
● run branch resultat_3b73d14e
● run branch resultat_fe2f6b6a
✓ skip branch resultat_66951ce8
● run branch resultat_ff612dde
● run branch resultat_d0a65303
● run branch resultat_0a18e8b1
✓ skip branch resultat_7fd56d9a
● end pipeline

Plutôt que d’appliquer la fonction simulation itérativement aux couples d’x et y (3 branches), la fonction est appliquée au produit cartésien de x et y (3 x 3 branches). On remarque d’ailleurs que targets a compris que cela ne changeait pas certains résultats précédents (3 branches strictement identiques, qui ne sont pas recalculées).

tar_read(resultat)
argument_1 argument_2 res
1         10          1  10
2         10          2  20
3         10          3  30
4         20          1  20
5         20          2  40
6         20          3  60
7         30          1  30
8         30          2  60
9         30          3  90

Les pattern peuvent être combinés, avec par exemple pattern = cross(x, map(y, z)).

#_targets.R
library(targets)

simulation <- function(x, y, z) x * y + z

list(
  tar_target(x, c(10, 20, 30)),
  tar_target(y, c(1, 2, 3)),
  tar_target(z, c(2, 4, 6)),
  tar_target(
    resultat,
    data.frame(argument_1 = x, argument_2 = y, argument_3 = z, res = simulation(x, y, z)),
    pattern = cross(x, map(y, z)))
)

qui donne le résultat :

argument_1 argument_2 argument_3 res
1         10          1          2  12
2         10          2          4  24
3         10          3          6  36
4         20          1          2  22
5         20          2          4  44
6         20          3          6  66
7         30          1          2  32
8         30          2          4  64
9         30          3          6  96

Si l’on souhaite itérer sur des listes, plutôt que sur des vecteurs, on peut spécifier à la création de la cible qui sert d’argument aux branches, par exemple une liste de data.frames, que l’on veut itérer sur les éléments "list".

#_targets.R
library(targets)

#' Multiplie la colonne "a" de df par un facteur
#' @param: df: data.frame
#' @param: factor: int
multiply <- function(df, factor){
  df$a <- df$a * factor
  df
}

list(
  tar_target(x, list(data.frame(name = c('Marie','Marwan'), a = c(1, 2)),
                     data.frame(name = c('Bill','Boule'), a = c(2, 4))), iteration = 'list'),
  tar_target(y, c(2, 3)),
  tar_target(
    resultat,
    multiply(x, y),
    pattern = map(x, y))
)

Etc…

10.7 Pour en savoir plus