Content from Introduction


Last updated on 2024-07-09 | Edit this page

Estimated time: 12 minutes

Overview

Questions

  • Why should we care about reproducibility?
  • How can targets help us achieve reproducibility?

Objectives

  • Explain why reproducibility is important for science
  • Describe the features of targets that enhance reproducibility

Episode summary: Introduce the idea of reproducibility and why / who would want to use targets

What is reproducibility?


Reproducibility is the ability for others (including your future self) to reproduce your analysis.

We can only have confidence in the results of scientific analyses if they can be reproduced.

However, reproducibility is not a binary concept (not reproducible vs. reproducible); rather, there is a scale from less reproducible to more reproducible.

targets goes a long ways towards making your analyses more reproducible.

Other practices you can use to further enhance reproducibility include controlling your computing environment with tools like Docker, conda, or renv, but we don’t have time to cover those in this workshop.

What is targets?


targets is a workflow management package for the R programming language developed and maintained by Will Landau.

The major features of targets include:

  • Automation of workflow
  • Caching of workflow steps
  • Batch creation of workflow steps
  • Parallelization at the level of the workflow

This allows you to do the following:

  • return to a project after working on something else and immediately pick up where you left off without confusion or trying to remember what you were doing
  • change the workflow, then only re-run the parts that that are affected by the change
  • massively scale up the workflow without changing individual functions

… and of course, it will help others reproduce your analysis.

Who should use targets?


targets is by no means the only workflow management software. There is a large number of similar tools, each with varying features and use-cases. For example, snakemake is a popular workflow tool for python, and make is a tool that has been around for a very long time for automating bash scripts. targets is designed to work specifically with R, so it makes the most sense to use it if you primarily use R, or intend to. If you mostly code with other tools, you may want to consider an alternative.

The goal of this workshop is to learn how to use targets to reproducible data analysis in R.

Where to get more information


targets is a sophisticated package and there is a lot more to learn that we can cover in this workshop.

Here are some recommended resources for continuing on your targets journey:

About the example dataset


For this workshop, we will analyze an example dataset of measurements taken on adult foraging Adélie, Chinstrap, and Gentoo penguins observed on islands in the Palmer Archipelago, Antarctica.

The data are available from the palmerpenguins R package. You can get more information about the data by running ?palmerpenguins.

The three species of penguins in the palmerpenguins dataset. Artwork by @allison_horst.

The goal of the analysis is to determine the relationship between bill length and depth by using linear models.

We will gradually build up the analysis through this lesson, but you can see the final version at https://github.com/joelnitta/penguins-targets.

Key Points

  • We can only have confidence in the results of scientific analyses if they can be reproduced by others (including your future self)
  • targets helps achieve reproducibility by automating workflow
  • targets is designed for use with the R programming language
  • The example dataset for this workshop includes measurements taken on penguins in Antarctica

Content from First targets Workflow


Last updated on 2024-07-09 | Edit this page

Estimated time: 12 minutes

Overview

Questions

  • What are best practices for organizing analyses?
  • What is a _targets.R file for?
  • What is the content of the _targets.R file?
  • How do you run a workflow?

Objectives

  • Create a project in RStudio
  • Explain the purpose of the _targets.R file
  • Write a basic _targets.R file
  • Use a _targets.R file to run a workflow

Episode summary: First chance to get hands dirty by writing a very simple workflow

Create a project


About projects

targets uses the “project” concept for organizing analyses: all of the files needed for a given project are put in a single folder, the project folder. The project folder has additional subfolders for organization, such as folders for data, code, and results.

By using projects, it makes it straightforward to re-orient yourself if you return to an analysis after time spent elsewhere. This wouldn’t be a problem if we only ever work on one thing at a time until completion, but that is almost never the case. It is hard to remember what you were doing when you come back to a project after working on something else (a phenomenon called “context switching”). By using a standardized organization system, you will reduce confusion and lost time… in other words, you are increasing reproducibility!

This workshop will use RStudio, since it also works well with the project organization concept.

Create a project in RStudio

Let’s start a new project using RStudio.

Click “File”, then select “New Project”.

This will open the New Project Wizard, a set of menus to help you set up the project.

Screenshot of RStudio New Project Wizard menu
The New Project Wizard

In the Wizard, click the first option, “New Directory”, since we are making a brand-new project from scratch. Click “New Project” in the next menu. In “Directory name”, enter a name that helps you remember the purpose of the project, such as “targets-demo” (follow best practices for naming files and folders). Under “Create project as a subdirectory of…”, click the “Browse” button to select a directory to put the project. We recommend putting it on your Desktop so you can easily find it.

You can leave “Create a git repository” and “Use renv with this project” unchecked, but these are both excellent tools to improve reproducibility, and you should consider learning them and using them in the future, if you don’t already. They can be enabled at any later time, so you don’t need to worry about trying to use them immediately.

Once you work through these steps, your RStudio session should look like this:

Screenshot of RStudio with a newly created project called 'targets-demo' open containing a single file, 'targets-demo.Rproj'
Your newly created project

Our project now contains a single file, created by RStudio: targets-demo.Rproj. You should not edit this file by hand. Its purpose is to tell RStudio that this is a project folder and to store some RStudio settings (if you use version-control software, it is OK to commit this file). Also, you can open the project by double clicking on the .Rproj file in your file explorer (try it by quitting RStudio then navigating in your file browser to your Desktop, opening the “targets-demo” folder, and double clicking targets-demo.Rproj).

OK, now that our project is set up, we are ready to start using targets!

Create a _targets.R file


Every targets project must include a special file, called _targets.R in the main project folder (the “project root”). The _targets.R file includes the specification of the workflow: directions for R to run your analysis, kind of like a recipe. By using the _targets.R file, you won’t have to remember to run specific scripts in a certain order. Instead, R will do it for you (more reproducibility points)!

Anatomy of a _targets.R file

We will now start to write a _targets.R file. Fortunately, targets comes with a function to help us do this.

In the R console, first load the targets package with library(targets), then run the command tar_script().

R

library(targets)
tar_script()

Nothing will happen in the console, but in the file viewer, you should see a new file, _targets.R appear. Open it using the File menu or by clicking on it.

We can see this default _targets.R file includes three main parts:

  • Loading packages with library()
  • Defining a custom function with function()
  • Defining a list with list().

The last part, the list, is the most important part of the _targets.R file. It defines the steps in the workflow. The _targets.R file must always end with this list.

Furthermore, each item in the list is a call of the tar_target() function. The first argument of tar_target() is name of the target to build, and the second argument is the command used to build it. Note that the name of the target is unquoted, that is, it is written without any surrounding quotation marks.

Set up _targets.R file to run example analysis


Background: non-targets version

We will use this template to start building our analysis of bill shape in penguins. First though, to get familiar with the functions and packages we’ll use, let’s run the code like you would in a “normal” R script without using targets.

Recall that we are using the palmerpenguins R package to obtain the data. This package actually includes two variations of the dataset: one is an external CSV file with the raw data, and another is the cleaned data loaded into R. In real life you are probably have externally stored raw data, so let’s use the raw penguin data as the starting point for our analysis too.

The path_to_file() function in palmerpenguins provides the path to the raw data CSV file (it is inside the palmerpenguins R package source code that you downloaded to your computer when you installed the package).

R

library(palmerpenguins)

# Get path to CSV file
penguins_csv_file <- path_to_file("penguins_raw.csv")

penguins_csv_file

OUTPUT

[1] "/vast/scratch/users/milton.m/cache/R/renv/cache/v5/R-4.3/x86_64-pc-linux-gnu/palmerpenguins/0.1.1/6c6861efbc13c1d543749e9c7be4a592/palmerpenguins/extdata/penguins_raw.csv"

We will use the tidyverse set of packages for loading and manipulating the data. We don’t have time to cover all the details about using tidyverse now, but if you want to learn more about it, please see the “Manipulating, analyzing and exporting data with tidyverse” lesson.

Let’s load the data with read_csv().

R

library(tidyverse)

# Read CSV file into R
penguins_data_raw <- read_csv(penguins_csv_file)

penguins_data_raw

OUTPUT

Rows: 344 Columns: 17
── Column specification ────────────────────────────────────────────────────────
Delimiter: ","
chr  (9): studyName, Species, Region, Island, Stage, Individual ID, Clutch C...
dbl  (7): Sample Number, Culmen Length (mm), Culmen Depth (mm), Flipper Leng...
date (1): Date Egg

ℹ 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.

OUTPUT

# A tibble: 344 × 17
   studyName `Sample Number` Species         Region Island Stage `Individual ID`
   <chr>               <dbl> <chr>           <chr>  <chr>  <chr> <chr>
 1 PAL0708                 1 Adelie Penguin… Anvers Torge… Adul… N1A1
 2 PAL0708                 2 Adelie Penguin… Anvers Torge… Adul… N1A2
 3 PAL0708                 3 Adelie Penguin… Anvers Torge… Adul… N2A1
 4 PAL0708                 4 Adelie Penguin… Anvers Torge… Adul… N2A2
 5 PAL0708                 5 Adelie Penguin… Anvers Torge… Adul… N3A1
 6 PAL0708                 6 Adelie Penguin… Anvers Torge… Adul… N3A2
 7 PAL0708                 7 Adelie Penguin… Anvers Torge… Adul… N4A1
 8 PAL0708                 8 Adelie Penguin… Anvers Torge… Adul… N4A2
 9 PAL0708                 9 Adelie Penguin… Anvers Torge… Adul… N5A1
10 PAL0708                10 Adelie Penguin… Anvers Torge… Adul… N5A2
# ℹ 334 more rows
# ℹ 10 more variables: `Clutch Completion` <chr>, `Date Egg` <date>,
#   `Culmen Length (mm)` <dbl>, `Culmen Depth (mm)` <dbl>,
#   `Flipper Length (mm)` <dbl>, `Body Mass (g)` <dbl>, Sex <chr>,
#   `Delta 15 N (o/oo)` <dbl>, `Delta 13 C (o/oo)` <dbl>, Comments <chr>

We see the raw data has some awkward column names with spaces (these are hard to type out and can easily lead to mistakes in the code), and far more columns than we need. For the purposes of this analysis, we only need species name, bill length, and bill depth. In the raw data, the rather technical term “culmen” is used to refer to the bill.

Illustration of bill (culmen) length and depth. Artwork by @allison_horst.

Let’s clean up the data to make it easier to use for downstream analyses. We will also remove any rows with missing data, because this could cause errors for some functions later.

R

# Clean up raw data
penguins_data <- penguins_data_raw |>
  # Rename columns for easier typing and
  # subset to only the columns needed for analysis
  select(
    species = Species,
    bill_length_mm = `Culmen Length (mm)`,
    bill_depth_mm = `Culmen Depth (mm)`
  ) |>
  # Delete rows with missing data
  remove_missing(na.rm = TRUE)

penguins_data

OUTPUT

# A tibble: 342 × 3
   species                             bill_length_mm bill_depth_mm
   <chr>                                        <dbl>         <dbl>
 1 Adelie Penguin (Pygoscelis adeliae)           39.1          18.7
 2 Adelie Penguin (Pygoscelis adeliae)           39.5          17.4
 3 Adelie Penguin (Pygoscelis adeliae)           40.3          18
 4 Adelie Penguin (Pygoscelis adeliae)           36.7          19.3
 5 Adelie Penguin (Pygoscelis adeliae)           39.3          20.6
 6 Adelie Penguin (Pygoscelis adeliae)           38.9          17.8
 7 Adelie Penguin (Pygoscelis adeliae)           39.2          19.6
 8 Adelie Penguin (Pygoscelis adeliae)           34.1          18.1
 9 Adelie Penguin (Pygoscelis adeliae)           42            20.2
10 Adelie Penguin (Pygoscelis adeliae)           37.8          17.1
# ℹ 332 more rows

That’s better!

targets version

What does this look like using targets?

The biggest difference is that we need to put each step of the workflow into the list at the end.

We also define a custom function for the data cleaning step. That is because the list of targets at the end should look like a high-level summary of your analysis. You want to avoid lengthy chunks of code when defining the targets; instead, put that code in the custom functions. The other steps (setting the file path and loading the data) are each just one function call so there’s not much point in putting those into their own custom functions.

Finally, each step in the workflow is defined with the tar_target() function.

R

library(targets)
library(tidyverse)
library(palmerpenguins)

clean_penguin_data <- function(penguins_data_raw) {
  penguins_data_raw |>
    select(
      species = Species,
      bill_length_mm = `Culmen Length (mm)`,
      bill_depth_mm = `Culmen Depth (mm)`
    ) |>
    remove_missing(na.rm = TRUE)
}

list(
  tar_target(penguins_csv_file, path_to_file("penguins_raw.csv")),
  tar_target(penguins_data_raw, read_csv(
    penguins_csv_file, show_col_types = FALSE)),
  tar_target(penguins_data, clean_penguin_data(penguins_data_raw))
)

I have set show_col_types = FALSE in read_csv() because we know from the earlier code that the column types were set correctly by default (character for species and numeric for bill length and depth), so we don’t need to see the warning it would otherwise issue.

Run the workflow


Now that we have a workflow, we can run it with the tar_make() function. Try running it, and you should see something like this:

R

tar_make()

OUTPUT

▶ dispatched target penguins_csv_file
● completed target penguins_csv_file [0.003 seconds]
▶ dispatched target penguins_data_raw
● completed target penguins_data_raw [0.287 seconds]
▶ dispatched target penguins_data
● completed target penguins_data [0.009 seconds]
▶ ended pipeline [0.613 seconds]

Congratulations, you’ve run your first workflow with targets!

Key Points

  • Projects help keep our analyses organized so we can easily re-run them later
  • Use the RStudio Project Wizard to create projects
  • The _targets.R file is a special file that must be included in all targets projects, and defines the worklow
  • Use tar_script() to create a default _targets.R file
  • Use tar_make() to run the workflow

Content from Loading Workflow Objects


Last updated on 2024-07-09 | Edit this page

Estimated time: 12 minutes

Overview

Questions

  • Where does the workflow happen?
  • How can we inspect the objects built by the workflow?

Objectives

  • Explain where targets runs the workflow and why
  • Be able to load objects built by the workflow into your R session

Episode summary: Show how to get at the objects that we built

Where does the workflow happen?


So we just finished running our first workflow. Now you probably want to look at its output. But, if we just call the name of the object (for example, penguins_data), we get an error.

R

penguins_data

ERROR

Error in eval(expr, envir, enclos): object 'penguins_data' not found

Where are the results of our workflow?

  • To reinforce the concept of targets running in a separate R session, you may want to pretend trying to run penguins_data, then feigning surprise when it doesn’t work and using it as a teaching moment (errors are pedagogy!).

We don’t see the workflow results because targets runs the workflow in a separate R session that we can’t interact with. This is for reproducibility—the objects built by the workflow should only depend on the code in your project, not any commands you may have interactively given to R.

Fortunately, targets has two functions that can be used to load objects built by the workflow into our current session, tar_load() and tar_read(). Let’s see how these work.

tar_load()


tar_load() loads an object built by the workflow into the current session. Its first argument is the name of the object you want to load. Let’s use this to load penguins_data and get an overview of the data with summary().

R

tar_load(penguins_data)
summary(penguins_data)

OUTPUT

   species          bill_length_mm  bill_depth_mm
 Length:342         Min.   :32.10   Min.   :13.10
 Class :character   1st Qu.:39.23   1st Qu.:15.60
 Mode  :character   Median :44.45   Median :17.30
                    Mean   :43.92   Mean   :17.15
                    3rd Qu.:48.50   3rd Qu.:18.70
                    Max.   :59.60   Max.   :21.50  

Note that tar_load() is used for its side-effect—loading the desired object into the current R session. It doesn’t actually return a value.

tar_read()


tar_read() is similar to tar_load() in that it is used to retrieve objects built by the workflow, but unlike tar_load(), it returns them directly as output.

Let’s try it with penguins_csv_file.

R

tar_read(penguins_csv_file)

OUTPUT

[1] "/vast/scratch/users/milton.m/cache/R/renv/cache/v5/R-4.3/x86_64-pc-linux-gnu/palmerpenguins/0.1.1/6c6861efbc13c1d543749e9c7be4a592/palmerpenguins/extdata/penguins_raw.csv"

We immediately see the contents of penguins_csv_file. But it has not been loaded into the environment. If you try to run penguins_csv_file now, you will get an error:

R

penguins_csv_file

ERROR

Error in eval(expr, envir, enclos): object 'penguins_csv_file' not found

When to use which function


tar_load() tends to be more useful when you want to load objects and do things with them. tar_read() is more useful when you just want to immediately inspect an object.

The targets cache


If you close your R session, then re-start it and use tar_load() or tar_read(), you will notice that it can still load the workflow objects. In other words, the workflow output is saved across R sessions. How is this possible?

You may have noticed a new folder has appeared in your project, called _targets. This is the targets cache. It contains all of the workflow output; that is how we can load the targets built by the workflow even after quitting then restarting R.

You should not edit the contents of the cache by hand (with one exception). Doing so would make your analysis non-reproducible.

The one exception to this rule is a special subfolder called _targets/user. This folder does not exist by default. You can create it if you want, and put whatever you want inside.

Generally, _targets/user is a good place to store files that are not code, like data and output.

Note that if you don’t have anything in _targets/user that you need to keep around, it is possible to “reset” your workflow by simply deleting the entire _targets folder. Of course, this means you will need to run everything over again, so don’t do this lightly!

Key Points

  • targets workflows are run in a separate, non-interactive R session
  • tar_load() loads a workflow object into the current R session
  • tar_read() reads a workflow object and returns its value
  • The _targets folder is the cache and generally should not be edited by hand

Content from The Workflow Lifecycle


Last updated on 2024-07-09 | Edit this page

Estimated time: 12 minutes

Overview

Questions

  • What happens if we re-run a workflow?
  • How does targets know what steps to re-run?
  • How can we inspect the state of the workflow?

Objectives

  • Explain how targets helps increase efficiency
  • Be able to inspect a workflow to see what parts are outdated

Episode summary: Demonstrate typical cycle of running targets: make, inspect, adjust, make…

Re-running the workflow


One of the features of targets is that it maximizes efficiency by only running the parts of the workflow that need to be run.

This is easiest to understand by trying it yourself. Let’s try running the workflow again:

R

tar_make()

OUTPUT

✔ skipped target penguins_csv_file
✔ skipped target penguins_data_raw
✔ skipped target penguins_data
✔ skipped pipeline [0.202 seconds]

Remember how the first time we ran the pipeline, targets printed out a list of each target as it was being built?

This time, it tells us it is skipping those targets; they have already been built, so there’s no need to run that code again.

Remember, the fastest code is the code you don’t have to run!

Re-running the workflow after modification


What happens when we change one part of the workflow then run it again?

Say that we decide the species names should be shorter. Right now they include the common name and the scientific name, but we really only need the first part of the common name to distinguish them.

Edit _targets.R so that the clean_penguin_data() function looks like this:

R

clean_penguin_data <- function(penguins_data_raw) {
  penguins_data_raw |>
    select(
      species = Species,
      bill_length_mm = `Culmen Length (mm)`,
      bill_depth_mm = `Culmen Depth (mm)`
    ) |>
    remove_missing(na.rm = TRUE) |>
    # Split "species" apart on spaces, and only keep the first word
    separate(species, into = "species", extra = "drop")
}

Then run it again.

R

tar_make()

OUTPUT

✔ skipped target penguins_csv_file
✔ skipped target penguins_data_raw
▶ dispatched target penguins_data
● completed target penguins_data [0.129 seconds]
▶ ended pipeline [0.393 seconds]

What happened?

This time, it skipped penguins_csv_file and penguins_data_raw and only ran penguins_data.

Of course, since our example workflow is so short we don’t even notice the amount of time saved. But imagine using this in a series of computationally intensive analysis steps. The ability to automatically skip steps results in a massive increase in efficiency.

Challenge 1: Inspect the output

How can you inspect the contents of penguins_data?

With tar_read(penguins_data) or by running tar_load(penguins_data) followed by penguins_data.

Under the hood


How does targets keep track of which targets are up-to-date vs. outdated?

For each target in the workflow (items in the list at the end of the _targets.R file) and any custom functions used in the workflow, targets calculates a hash value, or unique combination of letters and digits that represents an object in the computer’s memory. You can think of the hash value (or “hash” for short) as a unique fingerprint for a target or function.

The first time your run tar_make(), targets calculates the hashes for each target and function as it runs the code and stores them in the targets cache (the _targets folder). Then, for each subsequent call of tar_make(), it calculates the hashes again and compares them to the stored values. It detects which have changed, and this is how it knows which targets are out of date.

Where the hashes live

If you are curious about what the hashes look like, you can see them in the file _targets/meta/meta, but do not edit this file by hand—that would ruin your workflow!

This information is used in combination with the dependency relationships (in other words, how each target depends on the others) to re-run the workflow in the most efficient way possible: code is only run for targets that need to be re-built, and others are skipped.

Visualizing the workflow


Typically, you will be making edits to various places in your code, adding new targets, and running the workflow periodically. It is good to be able to visualize the state of the workflow.

This can be done with tar_visnetwork()

R

tar_visnetwork()
Visualization of the targets worklow, showing 'penguins_data' connected by lines to 'penguins_data_raw', 'penguins_csv_file' and 'clean_penguin_data'

You should see the network show up in the plot area of RStudio.

It is an HTML widget, so you can zoom in and out (this isn’t important for the current example since it is so small, but is useful for larger, “real-life” workflows).

Here, we see that all of the targets are dark green, indicating that they are up-to-date and would be skipped if we were to run the workflow again.

Installing visNetwork

You may encounter an error message The package "visNetwork" is required.

In this case, install it first with install.packages("visNetwork").

Challenge 2: What else can the visualization tell us?

Modify the workflow in _targets.R, then run tar_visnetwork() again without running tar_make(). What color indicates that a target is out of date?

Light blue indicates the target is out of date.

Depending on how you modified the code, any or all of the targets may now be light blue.

‘Outdated’ does not always mean ‘will be run’

Just because a target appears as light blue (is “outdated”) in the network visualization, this does not guarantee that it will be re-built during the next run. Rather, it means that at least of one the targets that it depends on has changed.

For example, if the workflow state looked like this:

A -> B* -> C -> D

where the * indicates that B has changed compared to the last time the workflow was run, the network visualization will show B, C, and D all as light blue.

But if re-running the workflow results in the exact same value for C as before, D will not be re-run (will be “skipped”).

Most of the time, a single change will cascade to the rest of the downstream targets and cause them to be re-built, but this is not always the case. targets has no way of knowing ahead of time what the actual output will be, so it cannot provide a network visualization that completely predicts the future!

Other ways to check workflow status


The visualization is very useful, but sometimes you may be working on a server that doesn’t provide graphical output, or you just want a quick textual summary of the workflow. There are some other useful functions that can do that.

tar_outdated() lists only the outdated targets; that is, targets that will be built during the next run, or depend on such a target. If everything is up to date, it will return a zero-length character vector (character(0)).

R

tar_outdated()

OUTPUT

character(0)

tar_progress() shows the current status of the workflow as a dataframe. You may find it helpful to further manipulate the dataframe to obtain useful summaries of the workflow, for example using dplyr (such data manipulation is beyond the scope of this lesson but the instructor may demonstrate its use).

R

tar_progress()

OUTPUT

# A tibble: 3 × 2
  name              progress
  <chr>             <chr>
1 penguins_csv_file skipped
2 penguins_data_raw skipped
3 penguins_data     completed

Callout

Targets also offers some dynamic monitoring tools that you can leave running to monitor long pipelines:

  • tar_poll gives a ongoing progress summary in the R terminal
  • tar_watch starts a Shiny app that shows the current output of tar_visnetwork

Granular control of targets


It is possible to only make a particular target instead of running the entire workflow.

To do this, type the name of the target you wish to build after tar_make() (note that any targets required by the one you specify will also be built). For example, tar_make(penguins_data_raw) would only build penguins_data_raw, not penguins_data.

Furthermore, if you want to manually “reset” a target and make it appear out-of-date, you can do so with tar_invalidate(). This means that target (and any that depend on it) will be re-run next time.

Let’s give this a try. Remember that our pipeline is currently up to date, so tar_make() will skip everything:

R

tar_make()

OUTPUT

✔ skipped target penguins_csv_file
✔ skipped target penguins_data_raw
✔ skipped target penguins_data
✔ skipped pipeline [0.635 seconds]

Let’s invalidate penguins_data and run it again:

R

tar_invalidate(penguins_data)
tar_make()

OUTPUT

✔ skipped target penguins_csv_file
✔ skipped target penguins_data_raw
▶ dispatched target penguins_data
● completed target penguins_data [0.017 seconds]
▶ ended pipeline [0.368 seconds]

If you want to reset everything and start fresh, you can use tar_invalidate(everything()) (tar_invalidate() accepts tidyselect expressions to specify target names).

Caution should be exercised when using granular methods like this, though, since you may end up with your workflow in an unexpected state. The surest way to maintain an up-to-date workflow is to run tar_make() frequently.

How this all works in practice


In practice, you will likely be switching between running the workflow with tar_make(), loading the targets you built with tar_load(), and editing your custom functions by running code in an interactive R session. It takes some time to get used to it, but soon you will feel that your code isn’t “real” until it is embedded in a targets workflow.

Key Points

  • targets only runs the steps that have been affected by a change to the code
  • tar_visnetwork() shows the current state of the workflow as a network
  • tar_progress() shows the current state of the workflow as a data frame
  • tar_outdated() lists outdated targets
  • tar_invalidate() can be used to invalidate (re-run) specific targets

Content from Best Practices for targets Project Organization


Last updated on 2024-07-09 | Edit this page

Estimated time: 12 minutes

Overview

Questions

  • What are best practices for organizing targets projects?
  • How does the organization of a targets workflow differ from a script-based analysis?

Objectives

  • Explain how to organize targets projects for maximal reproducibility
  • Understand how to use functions in the context of targets

Episode summary: Demonstrate best-practices for project organization

A simpler way to write workflow plans


The default way to specify targets in the plan is with the tar_target() function. But this way of writing plans can be a bit verbose.

There is an alternative provided by the tarchetypes package, also written by the creator of targets, Will Landau.

Install tarchetypes

If you haven’t done so yet, install tarchetypes with install.packages("tarchetypes").

The purpose of the tarchetypes is to provide various shortcuts that make writing targets pipelines easier. We will introduce just one for now, tar_plan(). This is used in place of list() at the end of the _targets.R script. By using tar_plan(), instead of specifying targets with tar_target(), we can use a syntax like this: target_name = target_command.

Let’s edit the penguins workflow to use the tar_plan() syntax:

R

library(targets)
library(tarchetypes)
library(palmerpenguins)
library(tidyverse)

clean_penguin_data <- function(penguins_data_raw) {
  penguins_data_raw |>
    select(
      species = Species,
      bill_length_mm = `Culmen Length (mm)`,
      bill_depth_mm = `Culmen Depth (mm)`
    ) |>
    remove_missing(na.rm = TRUE) |>
    # Split "species" apart on spaces, and only keep the first word
    separate(species, into = "species", extra = "drop")
}

tar_plan(
  penguins_csv_file = path_to_file("penguins_raw.csv"),
  penguins_data_raw = read_csv(penguins_csv_file, show_col_types = FALSE),
  penguins_data = clean_penguin_data(penguins_data_raw)
)

I think it is easier to read, do you?

Notice that tar_plan() does not mean you have to write all targets this way; you can still use the tar_target() format within tar_plan(). That is because =, while short and easy to read, does not provide all of the customization that targets is capable of. This doesn’t matter so much for now, but it will become important when you start to create more advanced targets workflows.

Organizing files and folders


So far, we have been doing everything with a single _targets.R file. This is OK for a small workflow, but does not work very well when the workflow gets bigger. There are better ways to organize your code.

First, let’s create a directory called R to store R code other than _targets.R (remember, _targets.R must be placed in the overall project directory, not in a subdirectory). Create a new R file in R/ called functions.R. This is where we will put our custom functions. Let’s go ahead and put clean_penguin_data() in there now and save it.

Similarly, let’s put the library() calls in their own script in R/ called packages.R (this isn’t the only way to do it though; see the “Managing Packages” episode for alternative approaches).

We will also need to modify our _targets.R script to call these scripts with source:

R

source("R/packages.R")
source("R/functions.R")

tar_plan(
  penguins_csv_file = path_to_file("penguins_raw.csv"),
  penguins_data_raw = read_csv(penguins_csv_file, show_col_types = FALSE),
  penguins_data = clean_penguin_data(penguins_data_raw)
)

Now _targets.R is much more streamlined: it is focused just on the workflow and immediately tells us what happens in each step.

Finally, let’s make some directories for storing data and output—files that are not code. Create a new directory inside the targets cache called user: _targets/user. Within user, create two more directories, data and results. (If you use version control, you will probably want to ignore the _targets directory).

A word about functions


We mentioned custom functions earlier in the lesson, but this is an important topic that deserves further clarification. If you are used to analyzing data in R with a series of scripts instead of a single workflow like targets, you may not write many functions (using the function() function).

This is a major difference from targets. It would be quite difficult to write an efficient targets pipeline without the use of custom functions, because each target you build has to be the output of a single command.

We don’t have time in this curriculum to cover how to write functions in R, but the Software Carpentry lesson is recommended for reviewing this topic.

Another major difference is that each target must have a unique name. You may be used to writing code that looks like this:

R

# Store a person's height in cm, then convert to inches
height <- 160
height <- height / 2.54

You would get an error if you tried to run the equivalent targets pipeline:

R

tar_plan(
    height = 160,
    height = height / 2.54
)

ERROR

Error:
! Error running targets::tar_make()
Error messages: targets::tar_meta(fields = error, complete_only = TRUE)
Debugging guide: https://books.ropensci.org/targets/debugging.html
How to ask for help: https://books.ropensci.org/targets/help.html
Last error message:
    duplicated target names: height
Last error traceback:
    base::tryCatch(base::withCallingHandlers({ NULL base::saveRDS(base::do.c...
    tryCatchList(expr, classes, parentenv, handlers)
    tryCatchOne(tryCatchList(expr, names[-nh], parentenv, handlers[-nh]), na...
    doTryCatch(return(expr), name, parentenv, handler)
    tryCatchList(expr, names[-nh], parentenv, handlers[-nh])
    tryCatchOne(expr, names, parentenv, handlers[[1L]])
    doTryCatch(return(expr), name, parentenv, handler)
    base::withCallingHandlers({ NULL base::saveRDS(base::do.call(base::do.ca...
    base::saveRDS(base::do.call(base::do.call, base::c(base::readRDS("/vast/...
    base::do.call(base::do.call, base::c(base::readRDS("/vast/scratch/users/...
    (function (what, args, quote = FALSE, envir = parent.frame()) { if (!is....
    (function (targets_function, targets_arguments, options, envir = NULL, s...
    tryCatch(out <- withCallingHandlers(targets::tar_callr_inner_try(targets...
    tryCatchList(expr, classes, parentenv, handlers)
    tryCatchOne(expr, names, parentenv, handlers[[1L]])
    doTryCatch(return(expr), name, parentenv, handler)
    withCallingHandlers(targets::tar_callr_inner_try(targets_function = targ...
    targets::tar_callr_inner_try(targets_function = targets_function, target...
    pipeline_from_list(targets)
    pipeline_from_list.default(targets)
    pipeline_init(out)
    pipeline_targets_init(targets, clone_targets)
    tar_assert_unique_targets(names)
    tar_throw_validate(message)
    tar_error(message = paste0(...), class = c("tar_condition_validate", "ta...
    rlang::abort(message = message, class = class, call = tar_empty_envir)
    signal_abort(cnd, .file)

A major part of working with targets pipelines is writing custom functions that are the right size. They should not be so small that each is just a single line of code; this would make your pipeline difficult to understand and be too difficult to maintain. On the other hand, they should not be so big that each has large numbers of inputs and is thus overly sensitive to changes.

Striking this balance is more of art than science, and only comes with practice. I find a good rule of thumb is no more than three inputs per target.

Key Points

  • Put code in the R/ folder
  • Put functions in R/functions.R
  • Specify packages in R/packages.R
  • Put other miscellaneous files in _targets/user
  • Writing functions is a key skill for targets pipelines

Content from Managing Packages


Last updated on 2024-07-09 | Edit this page

Estimated time: 12 minutes

Overview

Questions

  • How should I manage packages for my targets project?

Objectives

  • Demonstrate best practices for managing packages

Episode summary: Show how to load packages and maintain package versions

Loading packages


Almost every R analysis relies on packages for functions beyond those available in base R.

There are three main ways to load packages in targets workflows.

Method 1: library()

This is the method you are almost certainly more familiar with, and is the method we have been using by default so far.

Like any other R script, include library() calls near the top of the _targets.R script. Alternatively (and as the recommended best practice for project organization), you can put all of the library() calls in a separate script—this is typically called packages.R and stored in the R/ directory of your project.

The potential downside to this approach is that if you have a long list of packages to load, certain functions like tar_visnetwork(), tar_outdated(), etc., may take an unnecessarily long time to run because they have to load all the packages, even though they don’t necessarily use them.

Method 2: tar_option_set()

In this method, use the tar_option_set() function in _targets.R to specify the packages to load when running the workflow.

This will be demonstrated using the pre-cleaned dataset from the palmerpenguins package. Let’s say we want to filter it down to just data for the Adelie penguin.

Save your progress

You can only have one active _targets.R file at a time in a given project.

We are about to create a new _targets.R file, but you probably don’t want to lose your progress in the one we have been working on so far (the penguins bill analysis). You can temporarily rename that one to something like _targets_old.R so that you don’t overwrite it with the new example _targets.R file below. Then, rename them when you are ready to work on it again.

This is what using the tar_option_set() method looks like:

R

library(targets)
library(tarchetypes)

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

tar_plan(
  adelie_data = filter(penguins, species == "Adelie")
)

OUTPUT

▶ dispatched target adelie_data
● completed target adelie_data [0.046 seconds]
▶ ended pipeline [0.199 seconds]

This method gets around the slow-downs that may sometimes be experienced with Method 1.

Method 3: packages argument of tar_target()

The main function for defining targets, tar_target() includes a packages argument that will load the specified packages only for that target.

Here is how we could use this method, modified from the same example as above.

R

library(targets)
library(tarchetypes)

tar_plan(
  tar_target(
    adelie_data,
    filter(penguins, species == "Adelie"),
    packages = c("dplyr", "palmerpenguins")
  )
)

OUTPUT

▶ dispatched target adelie_data
● completed target adelie_data [0.04 seconds]
▶ ended pipeline [0.186 seconds]

This can be more memory efficient in some cases than loading all packages, since not every target is always made during a typical run of the workflow. But, it can be tedious to remember and specify packages needed on a per-target basis.

One more option

Another alternative that does not actually involve loading packages is to specify the package associated with each function by using the :: notation, for example, dplyr::mutate(). This means you can avoid loading packages altogether.

Here is how to write the plan using this method:

R

library(targets)
library(tarchetypes)

tar_plan(
  adelie_data = dplyr::filter(palmerpenguins::penguins, species == "Adelie")
)

OUTPUT

▶ dispatched target adelie_data
● completed target adelie_data [0.027 seconds]
▶ ended pipeline [0.162 seconds]

The benefits of this approach are that the origins of all functions is explicit, so you could browse your code (for example, by looking at its source in GitHub), and immediately know where all the functions come from. The downside is that it is rather verbose because you need to type the package name every time you use one of its functions.

Which is the right way?

There is no “right” answer about how to load packages—it is a matter of what works best for your particular situation.

Often a reasonable approach is to load your most commonly used packages with library() (such as tidyverse) in packages.R, then use :: notation for less frequently used functions whose origins you may otherwise forget.

Maintaining package versions


Tracking of custom functions vs. functions from packages

A critical thing to understand about targets is that it only tracks custom functions and targets, not functions provided by packages.

However, the content of packages can change, and packages typically get updated on a regular basis. The output of your workflow may depend not only on the packages you use, but their versions.

Therefore, it is a good idea to track package versions.

About renv

Fortunately, you don’t have to do this by hand: there are R packages available that can help automate this process. We recommend renv, but there are others available as well (e.g., groundhog). We don’t have the time to cover detailed usage of renv in this lesson. To get started with renv, see the “Introduction to renv” vignette.

You can generally use renv the same way you would for a targets project as any other R project. However, there is one exception: if you load packages using tar_option_set() or the packages argument of tar_target() (Method 2 or Method 3, respectively), renv will not detect them (because it expects packages to be loaded with library(), require(), etc.).

The solution in this case is to use the tar_renv() function. This will write a separate file with library() calls for each package used in the workflow so that renv will properly detect them.

Selective tracking of functions from packages

Because targets doesn’t track functions from packages, if you update a package and the contents of one of its functions changes, targets will not re-build the target that was generated by that function.

However, it is possible to change this behavior on a per-package basis. This is best done only for a small number of packages, since adding too many would add too much computational overhead to targets when it has to calculate dependencies. For example, you may want to do this if you are using your own custom package that you update frequently.

The way to do so is by using tar_option_set(), specifying the same package name in both packages and imports. Here is a modified version of the earlier code that demonstrates this for dplyr and palmerpenguins.

R

library(targets)
library(tarchetypes)

tar_option_set(
  packages = c("dplyr", "palmerpenguins"),
  imports = c("dplyr", "palmerpenguins")
)

tar_plan(
  adelie_data = filter(penguins, species == "Adelie")
)

If we were to re-install either dplyr or palmerpenguins and one of the functions used from those in the pipeline changes (for example, filter()), any target depending on that function will be rebuilt.

Resolving namespace conflicts


There is one final best-practice to mention related to packages: resolving namespace conflicts.

“Namespace” refers to the idea that a certain set of unique names are only unique within a particular context. For example, all the function names of a package have to be unique, but only within that package. Function names could be duplicated across packages.

As you may imagine, this can cause confusion. For example, the filter() function appears in both the stats package and the dplyr package, but does completely different things in each. This is a namespace conflict: how do we know which filter() we are talking about?

The conflicted package can help prevent such confusion by stopping you if you try to use an ambiguous function, and help you be explicit about which package to use. We don’t have time to cover the details here, but you can read more about how to use conflicted at its website.

When you use conflicted, you will typically run a series of commands to explicitly resolve namespace conflicts, like conflicts_prefer(dplyr::filter) (this would tell R that we want to use filter from dplyr, not stats).

To use this in a targets workflow, you should put all calls to conflicts_prefer in a special file called .Rprofile that is located in the main folder of your project. This will ensure that the conflicts are always resolved for each target.

The recommended way to edit your .Rprofile is to use usethis::edit_r_profile("project"). This will open .Rprofile in your editor, where you can edit it and save it.

For example, your .Rprofile could include this:

R

library(conflicted)
conflicts_prefer(dplyr::filter)

Note that you don’t need to run source() to run the code in .Rprofile. It will always get run at the start of each R session automatically.

Key Points

  • There are multiple ways to load packages with targets
  • targets only tracks user-defined functions, not packages
  • Use renv to manage package versions
  • Use the conflicted package to manage namespace conflicts

Content from Working with External Files


Last updated on 2024-07-09 | Edit this page

Estimated time: 12 minutes

Overview

Questions

  • How can we load external data?

Objectives

  • Be able to load external data into a workflow
  • Configure the workflow to rerun if the contents of the external data change

Episode summary: Show how to read and write external files

Treating external files as a dependency


Almost all workflows will start by importing data, which is typically stored as an external file.

As a simple example, let’s create an external data file in RStudio with the “New File” menu option. Enter a single line of text, “Hello World” and save it as “hello.txt” text file in _targets/user/data/.

We will read in the contents of this file and store it as some_data in the workflow by writing the following plan and running tar_make():

Save your progress

You can only have one active _targets.R file at a time in a given project.

We are about to create a new _targets.R file, but you probably don’t want to lose your progress in the one we have been working on so far (the penguins bill analysis). You can temporarily rename that one to something like _targets_old.R so that you don’t overwrite it with the new example _targets.R file below. Then, rename them when you are ready to work on it again.

R

library(targets)
library(tarchetypes)

tar_plan(
  some_data = readLines("_targets/user/data/hello.txt")
)

OUTPUT

▶ dispatched target some_data
● completed target some_data [0.005 seconds]
▶ ended pipeline [0.156 seconds]

If we inspect the contents of some_data with tar_read(some_data), it will contain the string "Hello World" as expected.

Now say we edit “hello.txt”, perhaps add some text: “Hello World. How are you?”. Edit this in the RStudio text editor and save it. Now run the pipeline again.

R

library(targets)
library(tarchetypes)

tar_plan(
  some_data = readLines("_targets/user/data/hello.txt")
)

OUTPUT

✔ skipped target some_data
✔ skipped pipeline [0.113 seconds]

The target some_data was skipped, even though the contents of the file changed.

That is because right now, targets is only tracking the name of the file, not its contents. We need to use a special function for that, tar_file() from the tarchetypes package. tar_file() will calculate the “hash” of a file—a unique digital signature that is determined by the file’s contents. If the contents change, the hash will change, and this will be detected by targets.

R

library(targets)
library(tarchetypes)

tar_plan(
  tar_file(data_file, "_targets/user/data/hello.txt"),
  some_data = readLines(data_file)
)

OUTPUT

▶ dispatched target data_file
● completed target data_file [0.001 seconds]
▶ dispatched target some_data
● completed target some_data [0.001 seconds]
▶ ended pipeline [0.16 seconds]

This time we see that targets does successfully re-build some_data as expected.

A shortcut (or, About target factories)


However, also notice that this means we need to write two targets instead of one: one target to track the contents of the file (data_file), and one target to store what we load from the file (some_data).

It turns out that this is a common pattern in targets workflows, so tarchetypes provides a shortcut to express this more concisely, tar_file_read().

R

library(targets)
library(tarchetypes)

tar_plan(
  tar_file_read(
    hello,
    "_targets/user/data/hello.txt",
    readLines(!!.x)
  )
)

Let’s inspect this pipeline with tar_manifest():

R

tar_manifest()

OUTPUT

# A tibble: 2 × 2
  name       command
  <chr>      <chr>
1 hello_file "\"_targets/user/data/hello.txt\""
2 hello      "readLines(hello_file)"           

Notice that even though we only specified one target in the pipeline (hello, with tar_file_read()), the pipeline actually includes two targets, hello_file and hello.

That is because tar_file_read() is a special function called a target factory, so-called because it makes multiple targets at once. One of the main purposes of the tarchetypes package is to provide target factories to make writing pipelines easier and less error-prone.

Non-standard evaluation


What is the deal with the !!.x? That may look unfamiliar even if you are used to using R. It is known as “non-standard evaluation,” and gets used in some special contexts. We don’t have time to go into the details now, but just remember that you will need to use this special notation with tar_file_read(). If you forget how to write it (this happens frequently!) look at the examples in the help file by running ?tar_file_read.

Other data loading functions


Although we used readLines() as an example here, you can use the same pattern for other functions that load data from external files, such as readr::read_csv(), xlsx::read_excel(), and others (for example, read_csv(!!.x), read_excel(!!.x), etc.).

This is generally recommended so that your pipeline stays up to date with your input data.

Challenge: Use tar_file_read() with the penguins example

We didn’t know about tar_file_read() yet when we started on the penguins bill analysis.

How can you use tar_file_read() to load the CSV file while tracking its contents?

R

source("R/packages.R")
source("R/functions.R")

tar_plan(
  tar_file_read(
    penguins_data_raw,
    path_to_file("penguins_raw.csv"),
    read_csv(!!.x, show_col_types = FALSE)
  ),
  penguins_data = clean_penguin_data(penguins_data_raw)
)

OUTPUT

▶ dispatched target penguins_data_raw_file
● completed target penguins_data_raw_file [0.005 seconds]
▶ dispatched target penguins_data_raw
● completed target penguins_data_raw [0.286 seconds]
▶ dispatched target penguins_data
● completed target penguins_data [0.019 seconds]
▶ ended pipeline [0.66 seconds]

Writing out data


Writing to files is similar to loading in files: we will use the tar_file() function. There is one important caveat: in this case, the second argument of tar_file() (the command to build the target) must return the path to the file. Not all functions that write files do this (some return nothing; these treat the output file is a side-effect of running the function), so you may need to define a custom function that writes out the file and then returns its path.

Let’s do this for writeLines(), the R function that writes character data to a file. Normally, its output would be NULL (nothing), as we can see here:

R

x <- writeLines("some text", "test.txt")
x

OUTPUT

NULL

Here is our modified function that writes character data to a file and returns the name of the file (the ... means “pass the rest of these arguments to writeLines()”):

R

write_lines_file <- function(text, file, ...) {
  writeLines(text = text, con = file, ...)
  file
}

Let’s try it out:

R

x <- write_lines_file("some text", "test.txt")
x

OUTPUT

[1] "test.txt"

We can now use this in a pipeline. For example let’s change the text to upper case then write it out again:

R

library(targets)
library(tarchetypes)

source("R/functions.R")

tar_plan(
  tar_file_read(
    hello,
    "_targets/user/data/hello.txt",
    readLines(!!.x)
  ),
  hello_caps = toupper(hello),
  tar_file(
    hello_caps_out,
    write_lines_file(hello_caps, "_targets/user/results/hello_caps.txt")
  )
)

OUTPUT

▶ dispatched target hello_file
● completed target hello_file [0.001 seconds]
▶ dispatched target hello
● completed target hello [0.001 seconds]
▶ dispatched target hello_caps
● completed target hello_caps [0 seconds]
▶ dispatched target hello_caps_out
● completed target hello_caps_out [0.005 seconds]
▶ ended pipeline [0.233 seconds]

Take a look at hello_caps.txt in the results folder and verify it is as you expect.

Challenge: What happens to file output if its modified?

Delete or change the contents of hello_caps.txt in the results folder. What do you think will happen when you run tar_make() again? Try it and see.

targets detects that hello_caps_out has changed (is “invalidated”), and re-runs the code to make it, thus writing out hello_caps.txt to results again.

So this way of writing out results makes your pipeline more robust: we have a guarantee that the contents of the file in results are generated solely by the code in your plan.

Key Points

  • tarchetypes::tar_file() tracks the contents of a file
  • Use tarchetypes::tar_file_read() in combination with data loading functions like read_csv() to keep the pipeline in sync with your input data
  • Use tarchetypes::tar_file() in combination with a function that writes to a file and returns its path to write out data

Content from Branching


Last updated on 2024-07-09 | Edit this page

Estimated time: 12 minutes

Overview

Questions

  • How can we specify many targets without typing everything out?

Objectives

  • Be able to specify targets using branching

Episode summary: Show how to use branching

Why branching?


One of the major strengths of targets is the ability to define many targets from a single line of code (“branching”). This not only saves you typing, it also reduces the risk of errors since there is less chance of making a typo.

Types of branching


There are two types of branching, dynamic branching and static branching. “Branching” refers to the idea that you can provide a single specification for how to make targets (the “pattern”), and targets generates multiple targets from it (“branches”). “Dynamic” means that the branches that result from the pattern do not have to be defined ahead of time—they are a dynamic result of the code.

In this workshop, we will only cover dynamic branching since it is generally easier to write (static branching requires use of meta-programming, an advanced topic). For more information about each and when you might want to use one or the other (or some combination of the two), see the targets package manual.

Example without branching


To see how this works, let’s continue our analysis of the palmerpenguins dataset.

Our hypothesis is that bill depth decreases with bill length. We will test this hypothesis with a linear model.

For example, this is a model of bill depth dependent on bill length:

R

lm(bill_depth_mm ~ bill_length_mm, data = penguins_data)

We can add this to our pipeline. We will call it the combined_model because it combines all the species together without distinction:

R

source("R/packages.R")
source("R/functions.R")

tar_plan(
  # Load raw data
  tar_file_read(
    penguins_data_raw,
    path_to_file("penguins_raw.csv"),
    read_csv(!!.x, show_col_types = FALSE)
  ),
  # Clean data
  penguins_data = clean_penguin_data(penguins_data_raw),
  # Build model
  combined_model = lm(
    bill_depth_mm ~ bill_length_mm,
    data = penguins_data
  )
)

OUTPUT

✔ skipped target penguins_data_raw_file
✔ skipped target penguins_data_raw
✔ skipped target penguins_data
▶ dispatched target combined_model
● completed target combined_model [0.005 seconds]
▶ ended pipeline [0.36 seconds]

Let’s have a look at the model. We will use the glance() function from the broom package. Unlike base R summary(), this function returns output as a tibble (the tidyverse equivalent of a dataframe), which as we will see later is quite useful for downstream analyses.

R

library(broom)
tar_load(combined_model)
glance(combined_model)

OUTPUT

# A tibble: 1 × 12
  r.squared adj.r.squared sigma statistic   p.value    df logLik   AIC   BIC deviance df.residual  nobs
      <dbl>         <dbl> <dbl>     <dbl>     <dbl> <dbl>  <dbl> <dbl> <dbl>    <dbl>       <int> <int>
1    0.0552        0.0525  1.92      19.9 0.0000112     1  -708. 1422. 1433.    1256.         340   342

Notice the small P-value. This seems to indicate that the model is highly significant.

But wait a moment… is this really an appropriate model? Recall that there are three species of penguins in the dataset. It is possible that the relationship between bill depth and length varies by species.

We should probably test some alternative models. These could include models that add a parameter for species, or add an interaction effect between species and bill length.

Now our workflow is getting more complicated. This is what a workflow for such an analysis might look like without branching (make sure to add library(broom) to packages.R):

R

source("R/packages.R")
source("R/functions.R")

tar_plan(
  # Load raw data
  tar_file_read(
    penguins_data_raw,
    path_to_file("penguins_raw.csv"),
    read_csv(!!.x, show_col_types = FALSE)
  ),
  # Clean data
  penguins_data = clean_penguin_data(penguins_data_raw),
  # Build models
  combined_model = lm(
    bill_depth_mm ~ bill_length_mm,
    data = penguins_data
  ),
  species_model = lm(
    bill_depth_mm ~ bill_length_mm + species,
    data = penguins_data
  ),
  interaction_model = lm(
    bill_depth_mm ~ bill_length_mm * species,
    data = penguins_data
  ),
  # Get model summaries
  combined_summary = glance(combined_model),
  species_summary = glance(species_model),
  interaction_summary = glance(interaction_model)
)

OUTPUT

✔ skipped target penguins_data_raw_file
✔ skipped target penguins_data_raw
✔ skipped target penguins_data
✔ skipped target combined_model
▶ dispatched target interaction_model
● completed target interaction_model [0.004 seconds]
▶ dispatched target species_model
● completed target species_model [0.001 seconds]
▶ dispatched target combined_summary
● completed target combined_summary [0.007 seconds]
▶ dispatched target interaction_summary
● completed target interaction_summary [0.003 seconds]
▶ dispatched target species_summary
● completed target species_summary [0.003 seconds]
▶ ended pipeline [0.475 seconds]

Let’s look at the summary of one of the models:

R

tar_read(species_summary)

OUTPUT

# A tibble: 1 × 12
  r.squared adj.r.squared sigma statistic   p.value    df logLik   AIC   BIC deviance df.residual  nobs
      <dbl>         <dbl> <dbl>     <dbl>     <dbl> <dbl>  <dbl> <dbl> <dbl>    <dbl>       <int> <int>
1     0.769         0.767 0.953      375. 3.65e-107     3  -467.  944.  963.     307.         338   342

So this way of writing the pipeline works, but is repetitive: we have to call glance() each time we want to obtain summary statistics for each model. Furthermore, each summary target (combined_summary, etc.) is explicitly named and typed out manually. It would be fairly easy to make a typo and end up with the wrong model being summarized.

Example with branching


First attempt

Let’s see how to write the same plan using dynamic branching:

R

source("R/packages.R")
source("R/functions.R")

tar_plan(
  # Load raw data
  tar_file_read(
    penguins_data_raw,
    path_to_file("penguins_raw.csv"),
    read_csv(!!.x, show_col_types = FALSE)
  ),
  # Clean data
  penguins_data = clean_penguin_data(penguins_data_raw),
  # Build models
  models = list(
    combined_model = lm(
      bill_depth_mm ~ bill_length_mm, data = penguins_data),
    species_model = lm(
      bill_depth_mm ~ bill_length_mm + species, data = penguins_data),
    interaction_model = lm(
      bill_depth_mm ~ bill_length_mm * species, data = penguins_data)
  ),
  # Get model summaries
  tar_target(
    model_summaries,
    glance(models[[1]]),
    pattern = map(models)
  )
)

What is going on here?

First, let’s look at the messages provided by tar_make().

OUTPUT

✔ skipped target penguins_data_raw_file
✔ skipped target penguins_data_raw
✔ skipped target penguins_data
▶ dispatched target models
● completed target models [0.005 seconds]
▶ dispatched branch model_summaries_812e3af782bee03f
● completed branch model_summaries_812e3af782bee03f [0.007 seconds]
▶ dispatched branch model_summaries_2b8108839427c135
● completed branch model_summaries_2b8108839427c135 [0.003 seconds]
▶ dispatched branch model_summaries_533cd9a636c3e05b
● completed branch model_summaries_533cd9a636c3e05b [0.003 seconds]
● completed pattern model_summaries
▶ ended pipeline [0.462 seconds]

There is a series of smaller targets (branches) that are each named like model_summaries_812e3af782bee03f, then one overall model_summaries target. That is the result of specifying targets using branching: each of the smaller targets are the “branches” that comprise the overall target. Since targets has no way of knowing ahead of time how many branches there will be or what they represent, it names each one using this series of numbers and letters (the “hash”). targets builds each branch one at a time, then combines them into the overall target.

Next, let’s look in more detail about how the workflow is set up, starting with how we defined the models:

R

  # Build models
  models = list(
    combined_model = lm(
      bill_depth_mm ~ bill_length_mm, data = penguins_data),
    species_model = lm(
      bill_depth_mm ~ bill_length_mm + species, data = penguins_data),
    interaction_model = lm(
      bill_depth_mm ~ bill_length_mm * species, data = penguins_data)
  ),

Unlike the non-branching version, we defined the models in a list (instead of one target per model). This is because dynamic branching is similar to the base::apply() or purrrr::map() method of looping: it applies a function to each element of a list. So we need to prepare the input for looping as a list.

Next, take a look at the command to build the target model_summaries.

R

  # Get model summaries
  tar_target(
    model_summaries,
    glance(models[[1]]),
    pattern = map(models)
  )

As before, the first argument is the name of the target to build, and the second is the command to build it.

Here, we apply the glance() function to each element of models (the [[1]] is necessary because when the function gets applied, each element is actually a nested list, and we need to remove one layer of nesting).

Finally, there is an argument we haven’t seen before, pattern, which indicates that this target should be built using dynamic branching. map means to apply the command to each element of the input list (models) sequentially.

Now that we understand how the branching workflow is constructed, let’s inspect the output:

R

tar_read(model_summaries)

OUTPUT

# A tibble: 3 × 12
  r.squared adj.r.squared sigma statistic   p.value    df logLik   AIC   BIC deviance df.residual  nobs
      <dbl>         <dbl> <dbl>     <dbl>     <dbl> <dbl>  <dbl> <dbl> <dbl>    <dbl>       <int> <int>
1    0.0552        0.0525 1.92       19.9 1.12e-  5     1  -708. 1422. 1433.    1256.         340   342
2    0.769         0.767  0.953     375.  3.65e-107     3  -467.  944.  963.     307.         338   342
3    0.770         0.766  0.955     225.  8.52e-105     5  -466.  947.  974.     306.         336   342

The model summary statistics are all included in a single dataframe.

But there’s one problem: we can’t tell which row came from which model! It would be unwise to assume that they are in the same order as the list of models.

This is due to the way dynamic branching works: by default, there is no information about the provenance of each target preserved in the output.

How can we fix this?

Second attempt

The key to obtaining useful output from branching pipelines is to include the necessary information in the output of each individual branch. Here, we want to know the kind of model that corresponds to each row of the model summaries. To do that, we need to write a custom function. You will need to write custom functions frequently when using targets, so it’s good to get used to it!

Here is the function. Save this in R/functions.R:

R

glance_with_mod_name <- function(model_in_list) {
  model_name <- names(model_in_list)
  model <- model_in_list[[1]]
  glance(model) |>
    mutate(model_name = model_name)
}

Our new pipeline looks almost the same as before, but this time we use the custom function instead of glance().

R

source("R/functions.R")
source("R/packages.R")

tar_plan(
  # Load raw data
  tar_file_read(
    penguins_data_raw,
    path_to_file("penguins_raw.csv"),
    read_csv(!!.x, show_col_types = FALSE)
  ),
  # Clean data
  penguins_data = clean_penguin_data(penguins_data_raw),
  # Build models
  models = list(
    combined_model = lm(
      bill_depth_mm ~ bill_length_mm, data = penguins_data),
    species_model = lm(
      bill_depth_mm ~ bill_length_mm + species, data = penguins_data),
    interaction_model = lm(
      bill_depth_mm ~ bill_length_mm * species, data = penguins_data)
  ),
  # Get model summaries
  tar_target(
    model_summaries,
    glance_with_mod_name(models),
    pattern = map(models)
  )
)

OUTPUT

✔ skipped target penguins_data_raw_file
✔ skipped target penguins_data_raw
✔ skipped target penguins_data
✔ skipped target models
▶ dispatched branch model_summaries_812e3af782bee03f
● completed branch model_summaries_812e3af782bee03f [0.018 seconds]
▶ dispatched branch model_summaries_2b8108839427c135
● completed branch model_summaries_2b8108839427c135 [0.007 seconds]
▶ dispatched branch model_summaries_533cd9a636c3e05b
● completed branch model_summaries_533cd9a636c3e05b [0.005 seconds]
● completed pattern model_summaries
▶ ended pipeline [0.58 seconds]

And this time, when we load the model_summaries, we can tell which model corresponds to which row (you may need to scroll to the right to see it).

R

tar_read(model_summaries)

OUTPUT

# A tibble: 3 × 13
  r.squared adj.r.squared sigma statistic   p.value    df logLik   AIC   BIC deviance df.residual  nobs model_name
      <dbl>         <dbl> <dbl>     <dbl>     <dbl> <dbl>  <dbl> <dbl> <dbl>    <dbl>       <int> <int> <chr>
1    0.0552        0.0525 1.92       19.9 1.12e-  5     1  -708. 1422. 1433.    1256.         340   342 combined_model
2    0.769         0.767  0.953     375.  3.65e-107     3  -467.  944.  963.     307.         338   342 species_model
3    0.770         0.766  0.955     225.  8.52e-105     5  -466.  947.  974.     306.         336   342 interaction_model

Next we will add one more target, a prediction of bill depth based on each model. These will be needed for plotting the models in the report. Such a prediction can be obtained with the augment() function of the broom package.

R

tar_load(models)
augment(models[[1]])

OUTPUT

# A tibble: 342 × 8
   bill_depth_mm bill_length_mm .fitted .resid    .hat .sigma   .cooksd .std.resid
           <dbl>          <dbl>   <dbl>  <dbl>   <dbl>  <dbl>     <dbl>      <dbl>
 1          18.7           39.1    17.6  1.14  0.00521   1.92 0.000924      0.594
 2          17.4           39.5    17.5 -0.127 0.00485   1.93 0.0000107    -0.0663
 3          18             40.3    17.5  0.541 0.00421   1.92 0.000168      0.282
 4          19.3           36.7    17.8  1.53  0.00806   1.92 0.00261       0.802
 5          20.6           39.3    17.5  3.06  0.00503   1.92 0.00641       1.59
 6          17.8           38.9    17.6  0.222 0.00541   1.93 0.0000364     0.116
 7          19.6           39.2    17.6  2.05  0.00512   1.92 0.00293       1.07
 8          18.1           34.1    18.0  0.114 0.0124    1.93 0.0000223     0.0595
 9          20.2           42      17.3  2.89  0.00329   1.92 0.00373       1.50
10          17.1           37.8    17.7 -0.572 0.00661   1.92 0.000296     -0.298
# ℹ 332 more rows

Challenge: Add model predictions to the workflow

Can you add the model predictions using augment()? You will need to define a custom function just like we did for glance().

Define the new function as augment_with_mod_name(). It is the same as glance_with_mod_name(), but use augment() instead of glance():

R

augment_with_mod_name <- function(model_in_list) {
  model_name <- names(model_in_list)
  model <- model_in_list[[1]]
  augment(model) |>
    mutate(model_name = model_name)
}

Add the step to the workflow:

R

source("R/functions.R")
source("R/packages.R")

tar_plan(
  # Load raw data
  tar_file_read(
    penguins_data_raw,
    path_to_file("penguins_raw.csv"),
    read_csv(!!.x, show_col_types = FALSE)
  ),
  # Clean data
  penguins_data = clean_penguin_data(penguins_data_raw),
  # Build models
  models = list(
    combined_model = lm(
      bill_depth_mm ~ bill_length_mm, data = penguins_data),
    species_model = lm(
      bill_depth_mm ~ bill_length_mm + species, data = penguins_data),
    interaction_model = lm(
      bill_depth_mm ~ bill_length_mm * species, data = penguins_data)
  ),
  # Get model summaries
  tar_target(
    model_summaries,
    glance_with_mod_name(models),
    pattern = map(models)
  ),
  # Get model predictions
  tar_target(
    model_predictions,
    augment_with_mod_name(models),
    pattern = map(models)
  )
)

Best practices for branching

Dynamic branching is designed to work well with dataframes (tibbles).

So if possible, write your custom functions to accept dataframes as input and return them as output, and always include any necessary metadata as a column or columns.

Challenge: What other kinds of patterns are there?

So far, we have only used a single function in conjunction with the pattern argument, map(), which applies the function to each element of its input in sequence.

Can you think of any other ways you might want to apply a branching pattern?

Some other ways of applying branching patterns include:

  • crossing: one branch per combination of elements (cross() function)
  • slicing: one branch for each of a manually selected set of elements (slice() function)
  • sampling: one branch for each of a randomly selected set of elements (sample() function)

You can find out more about different branching patterns in the targets manual.

Key Points

  • Dynamic branching creates multiple targets with a single command
  • You usually need to write custom functions so that the output of the branches includes necessary metadata

Content from Parallel Processing


Last updated on 2024-07-09 | Edit this page

Estimated time: 12 minutes

Overview

Questions

  • How can we build targets in parallel?

Objectives

  • Be able to build targets in parallel

Episode summary: Show how to use parallel processing

Once a pipeline starts to include many targets, you may want to think about parallel processing. This takes advantage of multiple processors in your computer to build multiple targets at the same time.

When to use parallel processing

Parallel processing should only be used if your workflow has independent tasks—if your workflow only consists of a linear sequence of targets, then there is nothing to parallelize. Most workflows that use branching can benefit from parallelism.

targets includes support for high-performance computing, cloud computing, and various parallel backends. Here, we assume you are running this analysis on a laptop and so will use a relatively simple backend. If you are interested in high-performance computing, see the targets manual.

Install R packages for parallel computing

For this demo, we will use the new crew backend.

Install required packages

You will need to install several packages to use the crew backend:

R

install.packages("nanonext", repos = "https://shikokuchuo.r-universe.dev")
install.packages("mirai", repos = "https://shikokuchuo.r-universe.dev")
install.packages("crew", type = "source")

Set up workflow

To enable parallel processing with crew you only need to load the crew package, then tell targets to use it using tar_option_set. Specifically, the following lines enable crew, and tells it to use 2 parallel workers. You can increase this number on more powerful machines:

R

library(crew)
tar_option_set(
  controller = crew_controller_local(workers = 2)
)

Make these changes to the penguins analysis. It should now look like this:

R

source("R/functions.R")
source("R/packages.R")

# Set up parallelization
library(crew)
tar_option_set(
  controller = crew_controller_local(workers = 2)
)

tar_plan(
  # Load raw data
  tar_file_read(
    penguins_data_raw,
    path_to_file("penguins_raw.csv"),
    read_csv(!!.x, show_col_types = FALSE)
  ),
  # Clean data
  penguins_data = clean_penguin_data(penguins_data_raw),
  # Build models
  models = list(
    combined_model = lm(
      bill_depth_mm ~ bill_length_mm, data = penguins_data),
    species_model = lm(
      bill_depth_mm ~ bill_length_mm + species, data = penguins_data),
    interaction_model = lm(
      bill_depth_mm ~ bill_length_mm * species, data = penguins_data)
  ),
  # Get model summaries
  tar_target(
    model_summaries,
    glance_with_mod_name(models),
    pattern = map(models)
  ),
  # Get model predictions
  tar_target(
    model_predictions,
    augment_with_mod_name(models),
    pattern = map(models)
  )
)

There is still one more thing we need to modify only for the purposes of this demo: if we ran the analysis in parallel now, you wouldn’t notice any difference in compute time because the functions are so fast.

So let’s make “slow” versions of glance_with_mod_name() and augment_with_mod_name() using the Sys.sleep() function, which just tells the computer to wait some number of seconds. This will simulate a long-running computation and enable us to see the difference between running sequentially and in parallel.

Add these functions to functions.R (you can copy-paste the original ones, then modify them):

R

glance_with_mod_name_slow <- function(model_in_list) {
  Sys.sleep(4)
  model_name <- names(model_in_list)
  model <- model_in_list[[1]]
  broom::glance(model) |>
    mutate(model_name = model_name)
}
augment_with_mod_name_slow <- function(model_in_list) {
  Sys.sleep(4)
  model_name <- names(model_in_list)
  model <- model_in_list[[1]]
  broom::augment(model) |>
    mutate(model_name = model_name)
}

Then, change the plan to use the “slow” version of the functions:

R

source("R/functions.R")
source("R/packages.R")

# Set up parallelization
library(crew)
tar_option_set(
  controller = crew_controller_local(workers = 2)
)

tar_plan(
  # Load raw data
  tar_file_read(
    penguins_data_raw,
    path_to_file("penguins_raw.csv"),
    read_csv(!!.x, show_col_types = FALSE)
  ),
  # Clean data
  penguins_data = clean_penguin_data(penguins_data_raw),
  # Build models
  models = list(
    combined_model = lm(
      bill_depth_mm ~ bill_length_mm, data = penguins_data),
    species_model = lm(
      bill_depth_mm ~ bill_length_mm + species, data = penguins_data),
    interaction_model = lm(
      bill_depth_mm ~ bill_length_mm * species, data = penguins_data)
  ),
  # Get model summaries
  tar_target(
    model_summaries,
    glance_with_mod_name_slow(models),
    pattern = map(models)
  ),
  # Get model predictions
  tar_target(
    model_predictions,
    augment_with_mod_name_slow(models),
    pattern = map(models)
  )
)

Finally, run the pipeline with tar_make() as normal.

OUTPUT

✔ skip target penguins_data_raw_file
✔ skip target penguins_data_raw
✔ skip target penguins_data
✔ skip target models
• start branch model_predictions_5ad4cec5
• start branch model_predictions_c73912d5
• start branch model_predictions_91696941
• start branch model_summaries_5ad4cec5
• start branch model_summaries_c73912d5
• start branch model_summaries_91696941
• built branch model_predictions_5ad4cec5 [4.884 seconds]
• built branch model_predictions_c73912d5 [4.896 seconds]
• built branch model_predictions_91696941 [4.006 seconds]
• built pattern model_predictions
• built branch model_summaries_5ad4cec5 [4.011 seconds]
• built branch model_summaries_c73912d5 [4.011 seconds]
• built branch model_summaries_91696941 [4.011 seconds]
• built pattern model_summaries
• end pipeline [15.153 seconds]

Notice that although the time required to build each individual target is about 4 seconds, the total time to run the entire workflow is less than the sum of the individual target times! That is proof that processes are running in parallel and saving you time.

The unique and powerful thing about targets is that we did not need to change our custom function to run it in parallel. We only adjusted the workflow. This means it is relatively easy to refactor (modify) a workflow for running sequentially locally or running in parallel in a high-performance context.

Now that we have demonstrated how this works, you can change your analysis plan back to the original versions of the functions you wrote.

Key Points

  • Dynamic branching creates multiple targets with a single command
  • You usually need to write custom functions so that the output of the branches includes necessary metadata
  • Parallel computing works at the level of the workflow, not the function

Content from Reproducible Reports with Quarto


Last updated on 2024-07-09 | Edit this page

Estimated time: 12 minutes

Overview

Questions

  • How can we create reproducible reports?

Objectives

  • Be able to generate a report using targets

Episode summary: Show how to write reports with Quarto

Copy-paste vs. dynamic documents


Typically, you will want to communicate the results of a data analysis to a broader audience.

You may have done this before by copying and pasting statistics, plots, and other results into a text document or presentation. This may be fine if you only ever do the analysis once. But that is rarely the case—it is much more likely that you will tweak parts of the analysis or add new data and re-run your pipeline. With the copy-paste method, you’d have to remember what results changed and manually make sure everything is up-to-date. This is a perilous exercise!

Fortunately, targets provides functions for keeping a document in sync with pipeline results, so you can avoid such pitfalls. The main tool we will use to generate documents is Quarto. Quarto can be used separately from targets (and is a large topic on its own), but it also happens to be an excellent way to dynamically generate reports with targets.

Quarto allows you to insert the results of R code directly into your documents so that there is no danger of copy-and-paste mistakes. Furthermore, it can generate output from the same underlying script in multiple formats including PDF, HTML, and Microsoft Word.

Install Quarto

If you haven’t done so already, you will need to install Quarto, which is separate from R.

You will also need to install the quarto R package with install.packages("quarto").

About Quarto files


.qmd or .Qmd is the extension for Quarto files, and stands for “Quarto markdown”. Quarto files invert the normal way of writing code and comments: in a typical R script, all text is assumed to be R code, unless you preface it with a # to show that it is a comment. In Quarto, all text is assumed to be prose, and you use special notation to indicate which lines are R code to be evaluated. Once the code is evaluated, the results get inserted into a final, rendered document, which could be one of various formats.

Quarto workflow

We don’t have the time to go into the details of Quarto during this lesson, but recommend the “Introduction to Reproducible Publications with RStudio” incubator (in-development) lesson for more on this topic.


Dynamic documents like Quarto (or Rmarkdown, the predecessor to Quarto) can actually be used to manage data analysis pipelines. But that is not recommended because it doesn’t scale well and lacks the sophisticated dependency tracking offered by targets.

Our suggested approach is to conduct the vast majority of data analysis (in other words, the “heavy lifting”) in the targets pipeline, then use the Quarto document to summarize and plot the results.

Report on bill size in penguins


Continuing our penguin bill size analysis, let’s write a report evaluating each model.

To save time, the report is already available at https://github.com/joelnitta/penguins-targets.

Copy the raw code from here and save it as a new file penguin_report.qmd in your project folder (you may also be able to right click in your browser and select “Save As”).

Then, add one more target to the pipeline using the tar_quarto() function like this:

R

source("R/functions.R")
source("R/packages.R")

tar_plan(
  # Load raw data
  tar_file_read(
    penguins_data_raw,
    path_to_file("penguins_raw.csv"),
    read_csv(!!.x, show_col_types = FALSE)
  ),
  # Clean data
  penguins_data = clean_penguin_data(penguins_data_raw),
  # Build models
  models = list(
    combined_model = lm(
      bill_depth_mm ~ bill_length_mm, data = penguins_data),
    species_model = lm(
      bill_depth_mm ~ bill_length_mm + species, data = penguins_data),
    interaction_model = lm(
      bill_depth_mm ~ bill_length_mm * species, data = penguins_data)
  ),
  # Get model summaries
  tar_target(
    model_summaries,
    glance_with_mod_name(models),
    pattern = map(models)
  ),
  # Get model predictions
  tar_target(
    model_predictions,
    augment_with_mod_name(models),
    pattern = map(models)
  ),
  # Generate report
  tar_quarto(
    penguin_report,
    path = "penguin_report.qmd",
    quiet = FALSE,
    packages = c("targets", "tidyverse")
  )
)

The function to generate the report is tar_quarto(), from the tarchetypes package.

As you can see, the “heavy” analysis of running the models is done in the workflow, then there is a single call to render the report at the end with tar_quarto().

How does targets know when to render the report?


It is not immediately apparent just from this how targets knows to generate the report at the end of the workflow (recall that build order is not determined by the order of how targets are written in the workflow, but rather by their dependencies). penguin_report does not appear to depend on any of the other targets, since they do not show up in the tar_quarto() call.

How does this work?

The answer lies inside the penguin_report.qmd file. Let’s look at the start of the file:

MARKDOWN

---
title: "Simpson's Paradox in Palmer Penguins"
format:
  html:
    toc: true
execute: 
  echo: false
---

```{r}
#| label: load
#| message: false
targets::tar_load(penguin_models_augmented)
targets::tar_load(penguin_models_summary)

library(tidyverse)
```

This is an example analysis of penguins on the Palmer Archipelago in Antarctica.

The lines in between --- and --- at the very beginning are called the “YAML header”, and contain directions about how to render the document.

The R code to be executed is specified by the lines between ```{r} and ```. This is called a “code chunk”, since it is a portion of code interspersed within prose text.

Take a closer look at the R code chunk. Notice the two calls to targets::tar_load(). Do you remember what that function does? It loads the targets built during the workflow.

Now things should make a bit more sense: targets knows that the report depends on the targets built during the workflow, penguin_models_augmented and penguin_models_summary, because they are loaded in the report with tar_load().

Generating dynamic content


The call to tar_load() at the start of penguin_report.qmd is really the key to generating an up-to-date report—once those are loaded from the workflow, we know that they are in sync with the data, and can use them to produce “polished” text and plots.

Challenge: Spot the dynamic contents

Read through penguin_report.qmd and try to find instances where the targets built during the workflow (penguin_models_augmented and penguin_models_summary) are used to dynamically produce text and plots.

  • In the code chunk labeled results-stats, statistics from the models like P-value and adjusted R squared are extracted, then inserted into the text with in-line code like `r mod_stats$combined$r.squared`.

  • There are two figures, one for the combined model and one for the separate model (code chunks labeled fig-combined-plot and fig-separate-plot, respectively). These are built using the points predicted from the model in penguin_models_augmented.

You should also interactively run the code in penguin_report.qmd to better understand what is going on, starting with tar_load(). In fact, that is how this report was written: the code was run in an interactive session, and saved to the report as it was gradually tweaked to obtain the desired results.

The best way to learn this approach to generating reports is to try it yourself.

So your final Challenge is to construct a targets workflow using your own data and generate a report. Good luck!

Key Points

  • tarchetypes::tar_quarto() is used to render Quarto documents
  • You should load targets within the Quarto document using tar_load() and tar_read()
  • It is recommended to do heavy computations in the main targets workflow, and lighter formatting and plot generation in the Quarto document

Content from Deploying Targets on HPC


Last updated on 2024-07-09 | Edit this page

Estimated time: 12 minutes

Overview

Questions

  • Why would we use HPC to run Targets workflows?
  • How can we run Targets workflows on Slurm?

Objectives

  • Be able to run a Targets workflow on Slurm
  • Understand how workers relate to targets
  • Know how to configure Slurm jobs within targets
  • Be able to create a pipeline with heterogeneous workers

Advantages of HPC


If your analysis involves computationally intensive or long-running tasks such as training machine learning models or processing very large amounts of data, it will quickly become infeasible to use a single machine to run this. If you have access to a High Performance Computing (HPC) cluster, you can leverage the numerous machines with Targets to scale up your analysis. This differs from the exucution we have learned so far, which spawns extra R processes on the same machine to speed up execution.

Configuring Targets for Slurm


Install required packages

You will need to install crew.cluster to enable the HPC integration:

R

install.packages("crew.cluster")

To adapt Targets to use the Slurm HPC scheduler, we change the controller. In this section we will assume that our HPC uses Slurm as its job scheduler, but you can use other schedulers such as PBS/TORQUE, Sun Grid Engine (SGE) or LSF.

In the Parallel Processing section, we used the following configuration:

R

tar_option_set(
  controller = crew_controller_local(workers = 2)
)

ERROR

Error in crew_controller_local(workers = 2): could not find function "crew_controller_local"

To configure this for Slurm, we swap out the controller with crew_controller_slurm() a new one from the crew.cluster package:

R

tar_option_set(
  controller = crew_controller_slurm(
    workers = 3,
    script_lines = "module load R",
    slurm_memory_gigabytes_per_cpu = 1
  )
)

Callout

If you were using a scheduler other than Slurm, you would instead select crew_controller_lsf(), crew_controller_pbs() or crew_controller_sge() instead of crew_controller_slurm().

These functions have their own unique arguments which are associated with the scheduler.

There are a number of options you can pass to crew_controller_slurm() to fine-tune the Slurm execution, which you can find here. Here we are only using three:

  • workers sets the number of jobs that are submitted to Slurm to process targets.
  • script_lines adds some lines to the Slurm submit script used by Targets. This is useful for loading Environment Modules as we have done here.
  • slurm_memory_gigabytes_per_cpu specifies the amount of memory we need.

Let’s run the modified workflow:

R

library(crew.cluster)
library(targets)
library(tarchetypes)
library(palmerpenguins)
library(broom)
suppressPackageStartupMessages(library(tidyverse))

source("R/packages.R")
source("R/functions.R")

tar_option_set(
  controller = crew_controller_slurm(
    workers = 3,
    script_lines = "module load R",
    slurm_memory_gigabytes_per_cpu = 1
  )
)

tar_plan(
  # Load raw data
  tar_file_read(
    penguins_data_raw,
    path_to_file("penguins_raw.csv"),
    read_csv(!!.x, show_col_types = FALSE)
  ),
  # Clean data
  penguins_data = clean_penguin_data(penguins_data_raw),
  # Build models
  models = list(
    combined_model = lm(
      bill_depth_mm ~ bill_length_mm, data = penguins_data),
    species_model = lm(
      bill_depth_mm ~ bill_length_mm + species, data = penguins_data),
    interaction_model = lm(
      bill_depth_mm ~ bill_length_mm * species, data = penguins_data)
  ),
  # Get model summaries
  tar_target(
    model_summaries,
    glance_with_mod_name_slow(models),
    pattern = map(models)
  ),
  # Get model predictions
  tar_target(
    model_predictions,
    augment_with_mod_name_slow(models),
    pattern = map(models)
  )
)

OUTPUT

▶ dispatched target penguins_data_raw_file
● completed target penguins_data_raw_file [1.926 seconds]
▶ dispatched target penguins_data_raw
● completed target penguins_data_raw [0.409 seconds]
▶ dispatched target penguins_data
● completed target penguins_data [0.023 seconds]
▶ dispatched target models
● completed target models [0.008 seconds]
▶ dispatched branch model_predictions_812e3af782bee03f
▶ dispatched branch model_predictions_2b8108839427c135
▶ dispatched branch model_predictions_533cd9a636c3e05b
● completed branch model_predictions_812e3af782bee03f [4.027 seconds]
▶ dispatched branch model_summaries_812e3af782bee03f
● completed branch model_predictions_533cd9a636c3e05b [4.011 seconds]
▶ dispatched branch model_summaries_2b8108839427c135
● completed branch model_summaries_812e3af782bee03f [5.44 seconds]
▶ dispatched branch model_summaries_533cd9a636c3e05b
● completed branch model_predictions_2b8108839427c135 [5.478 seconds]
● completed pattern model_predictions
● completed branch model_summaries_2b8108839427c135 [4.012 seconds]
● completed branch model_summaries_533cd9a636c3e05b [4.009 seconds]
● completed pattern model_summaries
▶ ended pipeline [27.918 seconds]

We’ve successfully transferred our analysis onto a Slurm cluster!

Increasing Resources

Q: How would you modify your _targets.R if your functions needed 2 CPUs?

Check the arguments for crew_controller_slurm.

R

tar_option_set(
  controller = crew_controller_slurm(
    workers = 3,
    script_lines = "module load R",
    slurm_memory_gigabytes_per_cpu = 1,
    # Added this
    slurm_cpus_per_task = 2
  )
)

SBATCH Options


The script_lines argument shown above can also be used to add #SBATCH flags, to configure your worker job. Each entry in the vector will be treated as a new line to be added to the sbatch script that is generated. However, you have to be careful to put all of your #SBATCH lines before any other bash commands. sbatch flags are listed here in the Slurm documentation. For instance, to request that the worker has a GPU available, you could do the following:

R

tar_option_set(
  controller = crew_controller_slurm(
    workers = 3,
    script_lines = c(
      "#SBATCH --gres=gpu:1",
      "module load R"
    ),
    slurm_memory_gigabytes_per_cpu = 1
  ) 
)

In general, it’s better to use a dedicated crew_controller_slurm argument than to use script_lines, if one exists. For example, prefer slurm_cpus_per_task=2 to script_lines="--cpus-per-task=2" and set name="my_name" rather than using script_lines="--job-name=my_name".

HPC Workers


crew uses a persistent worker strategy. This means that crew does not submit one Slurm job for each target. Instead, you define a pool of workers when configuring the workflow. In our example above we specified a maximum of 3 workers. For each worker, crew submits a single Slurm job, and these workers will process multiple targets over their lifetime.

We can verify that this has happened using sacct, which we can use to query information about our past jobs. All the Slurm jobs with the same hash (the part after crew-) belong to the same Slurm controller:

BASH

sacct --starttime now-5minutes --allocations

OUTPUT

JobID           JobName  Partition    Account  AllocCPUS      State ExitCode
------------ ---------- ---------- ---------- ---------- ---------- --------
17244966            zsh    regular       wehi          4    RUNNING      0:0
17247538     DiskBacked    regular       wehi          8    RUNNING      0:0
17247562     sys/dashb+    regular       wehi          2    RUNNING      0:0
17247674     crew-7909+    regular       wehi          2     FAILED     15:0
17247676     crew-7909+    regular       wehi          2     FAILED     15:0
17247677     crew-7909+    regular       wehi          2     FAILED     15:0 

The upside of this approach is that we don’t have to know how long each target takes to build, or what resources it needs. It also means that we don’t submit a lot of jobs, making our Slurm usage easy to monitor.

The downside of this mechanism is that the resources of the worker have to be sufficient to build all of your targets. In other words, you need to work out the maximum RAM and CPUs used across all of your targets, and specify those maximum resources in the crew_controller_slurm() function.

Choosing a Worker

Q: Say we have two targets. One uses 100 GB of RAM and 1 CPU, and the other needs 10 GB of RAM and 8 CPUs to run a multi-threaded function. What worker configuration do we use?

We need to choose the maximum of all resources if we have a single worker. It will need 100 GB of RAM and 8 CPUs. To do this we might use a controller a bit like this:

R

crew_controller_slurm(
  name = "cpu_worker",
  workers = 3,
  script_lines = "module load R",
  slurm_cpus_per_task = 8,
  slurm_memory_gigabytes_per_cpu = 100 / 8
)

ERROR

Error in crew_controller_slurm(name = "cpu_worker", workers = 3, script_lines = "module load R", : could not find function "crew_controller_slurm"

Heterogeneous Workers


In some cases we may prefer to use more than one different Slurm job processing our targets, especially if some of our targets need different hardware from others, such as a GPU. When we do this, we say we have “heterogeneous workers”, meaning that not all worker jobs are the same as each other. To do this, we firstly define each worker configuration by adding the name argument to crew_controller_slurm:

R

small_memory <- crew_controller_slurm(
  name = "small_memory",
  script_lines = "module load R",
  slurm_memory_gigabytes_per_cpu = 10
)
big_memory <- crew_controller_slurm(
  name = "big_memory",
  script_lines = "module load R",
  slurm_memory_gigabytes_per_cpu = 20
)

Next, we tell Targets about these controllers using tar_option_set as before, with one difference: we have to combine them in a controller group:

R

tar_option_set(
  controller = crew_controller_group(small_memory, big_memory)
)

Then we specify each controller by name in each target definition:

R

list(
  tar_target(
    name = big_memory_task,
    command = Sys.getenv("SLURM_MEM_PER_CPU"),
    resources = tar_resources(
      crew = tar_resources_crew(controller = "big_memory")
    )
  ),
  tar_target(
    name = small_memory_task,
    command = Sys.getenv("SLURM_MEM_PER_CPU"),
    resources = tar_resources(
      crew = tar_resources_crew(controller = "small_memory")
    )
  )
)

When we run the pipeline, we can see the differing results:

R

tar_make()
tar_read(big_memory_task)
tar_read(small_memory_task)

OUTPUT

── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
✔ dplyr     1.1.4     ✔ readr     2.1.5
✔ forcats   1.0.0     ✔ stringr   1.5.1
✔ ggplot2   3.5.1     ✔ tibble    3.2.1
✔ lubridate 1.9.3     ✔ tidyr     1.3.1
✔ purrr     1.0.2
── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
✖ dplyr::filter() masks stats::filter()
✖ dplyr::lag()    masks stats::lag()
ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
▶ dispatched target small_memory_task
▶ dispatched target big_memory_task
● completed target big_memory_task [1.198 seconds]
● completed target small_memory_task [1.561 seconds]
▶ ended pipeline [8.424 seconds]
[1] "20480"
[1] "10240"

Mixing GPU and CPU targets

Q: Say we have the following targets workflow. How would we modify it so that gpu_task is only run in a GPU Slurm job?

R

graphics_devices <- function(){
  system2("lshw", c("-class", "display"), stdout=TRUE, stderr=FALSE)
}

tar_plan(
  tar_target(
    cpu_hardware,
    graphics_devices()
  ),
  tar_target(
    gpu_hardware,
    graphics_devices()
  )
)

You will need to define two different crew controllers. Also, you will need to request a GPU from Slurm. You can find an example of this above.

R

tar_option_set(
  controller = crew_controller_group(
    crew_controller_slurm(
      name = "cpu_worker",
      workers = 1,
      script_lines = "module load R",
      slurm_memory_gigabytes_per_cpu = 1,
      slurm_cpus_per_task = 1
    ),

    crew_controller_slurm(
      name = "gpu_worker",
      workers = 1,
      script_lines = c(
        "#SBATCH --partition=gpuq",
        "#SBATCH --gres=gpu:1",
        "module load R"
      ),
      slurm_memory_gigabytes_per_cpu = 1,
      slurm_cpus_per_task = 1
    )
  )
)

OUTPUT

── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
✔ dplyr     1.1.4     ✔ readr     2.1.5
✔ forcats   1.0.0     ✔ stringr   1.5.1
✔ ggplot2   3.5.1     ✔ tibble    3.2.1
✔ lubridate 1.9.3     ✔ tidyr     1.3.1
✔ purrr     1.0.2
── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
✖ dplyr::filter() masks stats::filter()
✖ dplyr::lag()    masks stats::lag()
ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
▶ dispatched target cpu_hardware
▶ dispatched target gpu_hardware
● completed target cpu_hardware [2.551 seconds]
● completed target gpu_hardware [2.998 seconds]
▶ ended pipeline [12.848 seconds]

R

tar_read("cpu_hardware")

OUTPUT

 [1] "  *-display"
 [2] "       description: VGA compatible controller"
 [3] "       product: MGA G200e [Pilot] ServerEngines (SEP1)"
 [4] "       vendor: Matrox Electronics Systems Ltd."
 [5] "       physical id: 0"
 [6] "       bus info: pci@0000:02:00.0"
 [7] "       version: 42"
 [8] "       width: 32 bits"
 [9] "       clock: 33MHz"
[10] "       capabilities: vga_controller bus_master cap_list rom"
[11] "       configuration: driver=mgag200 latency=0"
[12] "       resources: irq:16 memory:d2000000-d2ffffff memory:d3a10000-d3a13fff memory:d3000000-d37fffff memory:d3a00000-d3a0ffff"

R

tar_read("gpu_hardware")

OUTPUT

 [1] "  *-display"
 [2] "       description: VGA compatible controller"
 [3] "       product: Integrated Matrox G200eW3 Graphics Controller"
 [4] "       vendor: Matrox Electronics Systems Ltd."
 [5] "       physical id: 0"
 [6] "       bus info: pci@0000:03:00.0"
 [7] "       version: 04"
 [8] "       width: 32 bits"
 [9] "       clock: 66MHz"
[10] "       capabilities: vga_controller bus_master cap_list rom"
[11] "       configuration: driver=mgag200 latency=64 maxlatency=32 mingnt=16"
[12] "       resources: irq:16 memory:91000000-91ffffff memory:92808000-9280bfff memory:92000000-927fffff"
[13] "  *-display"
[14] "       description: 3D controller"
[15] "       product: NVIDIA Corporation"
[16] "       vendor: NVIDIA Corporation"
[17] "       physical id: 0"
[18] "       bus info: pci@0000:17:00.0"
[19] "       version: a1"
[20] "       width: 64 bits"
[21] "       clock: 33MHz"
[22] "       capabilities: bus_master cap_list"
[23] "       configuration: driver=nvidia latency=0"
[24] "       resources: iomemory:21f00-21eff iomemory:21f80-21f7f irq:18 memory:9c000000-9cffffff memory:21f000000000-21f7ffffffff memory:21f800000000-21f801ffffff"
[25] "  *-display"
[26] "       description: 3D controller"
[27] "       product: NVIDIA Corporation"
[28] "       vendor: NVIDIA Corporation"
[29] "       physical id: 0"
[30] "       bus info: pci@0000:65:00.0"
[31] "       version: a1"
[32] "       width: 64 bits"
[33] "       clock: 33MHz"
[34] "       capabilities: bus_master cap_list"
[35] "       configuration: driver=nvidia latency=0"
[36] "       resources: iomemory:24f00-24eff iomemory:24f80-24f7f irq:18 memory:bc000000-bcffffff memory:24f000000000-24f7ffffffff memory:24f800000000-24f801ffffff"
[37] "  *-display"
[38] "       description: 3D controller"
[39] "       product: NVIDIA Corporation"
[40] "       vendor: NVIDIA Corporation"
[41] "       physical id: 0"
[42] "       bus info: pci@0000:ca:00.0"
[43] "       version: a1"
[44] "       width: 64 bits"
[45] "       clock: 33MHz"
[46] "       capabilities: bus_master cap_list"
[47] "       configuration: driver=nvidia latency=0"
[48] "       resources: iomemory:28f00-28eff iomemory:28f80-28f7f irq:18 memory:e7000000-e7ffffff memory:28f000000000-28f7ffffffff memory:28f800000000-28f801ffffff"
[49] "  *-display"
[50] "       description: 3D controller"
[51] "       product: NVIDIA Corporation"
[52] "       vendor: NVIDIA Corporation"
[53] "       physical id: 0"
[54] "       bus info: pci@0000:e3:00.0"
[55] "       version: a1"
[56] "       width: 64 bits"
[57] "       clock: 33MHz"
[58] "       capabilities: bus_master cap_list"
[59] "       configuration: driver=nvidia latency=0"
[60] "       resources: iomemory:29f00-29eff iomemory:29f80-29f7f irq:18 memory:f2000000-f2ffffff memory:29f000000000-29f7ffffffff memory:29f800000000-29f801ffffff"

Key Points

  • crew.cluster::crew_controller_slurm() is used to configure a workflow to use Slurm
  • Crew uses persistent workers on HPC, and you need to choose your resources accordingly
  • You can create heterogeneous workers by using multiple calls to crew_controller_slurm(name=)