Building an analysis pipeline with targets

R
reproducibility
ecology tutorial
ggplot2
Build a dependency graph in base R for a stream monitoring analysis, then measure what one stale intermediate does to the reported ecological interval.
Author

Tidy Ecology

Published

2026-07-24

A reviewer asks one question about your figure: is the confidence interval in the caption computed from the same data as the point estimate beside it. The analysis is a stream monitoring study, the figure has been redrawn perhaps forty times since March, and in that time the effort threshold moved once, a September batch of reaches arrived, and the phosphorus values for two reaches were recalibrated after a laboratory check. Every one of those reruns was a decision about what to rerun. Nobody wrote the decisions down, and nothing in the folder records which parts of the output were recomputed after which change.

This is what a pipeline tool is for, and calling it a convenience misses the point. The useful thing a pipeline gives you is not automation. It is an answer to the question “what is now out of date”, computed rather than remembered. The rest is bookkeeping.

The post builds that answer by hand. First a small dependency graph in base R, with a cost attached to each step, then the propagator that turns a change into the set of steps that are now stale. Then a fortnight of edits is run three times over the same analysis: rebuilding everything each time, rebuilding what one careful analyst believes is affected, and rebuilding what the graph says. The comparison is not mainly about time. It is about what reaches the manuscript when a stale intermediate survives a change.

library(ggplot2)

te_pal <- list(forest = "#275139", green = "#2f8f63", sage = "#93a87f",
               clay = "#b5534e", gold = "#cda23f", line = "#dad9ca",
               ink = "#16241d", paper = "#f5f4ee")

theme_te <- function() {
  theme_minimal(base_size = 12) +
    theme(panel.grid.minor = element_blank(),
          panel.grid.major = element_line(colour = "#e7e6dc"),
          plot.background = element_rect(fill = "#f5f4ee", colour = NA),
          panel.background = element_rect(fill = "#f5f4ee", colour = NA),
          plot.title = element_text(face = "bold", colour = te_pal$ink),
          axis.title = element_text(colour = "#2c3a31"))
}

The analysis as a graph

The study is a nutrient gradient survey. Thirty stream reaches, three kick samples each, taxa identified to a working list, total phosphorus measured once per reach. The quantity the report exists to state is the number of EPT taxa (mayflies, stoneflies, caddisflies) lost per doubling of phosphorus, with a bootstrap interval and a permutation p-value beside it.

Written as a script that is one long file, the analysis is a sequence of steps that each consume what an earlier step produced. Written as a graph, it is a set of named targets and the edges between them. The edge list is the whole content of the thing: it says that the model is a function of the reach table, and that the reach table is a function of the richness summary and the chemistry, and nothing else.

Each target also carries a cost. The costs here are declared, not timed: they stand for how long the step takes when it runs, and they are written into the table rather than measured because a second measured on the machine that renders this post is not a second on yours. That is the same portability argument as in speeding up your analysis code, and it is why every comparison below is reported as a ratio.

tg <- data.frame(
  target = c("raw_samples", "taxon_lookup", "chem", "clean_samples", "qc_flags",
             "reach_ept", "reach_table", "model", "boot", "perm", "rarefy",
             "figure", "report"),
  cost = c(3, 1, 1, 6, 9, 10, 2, 2, 40, 38, 30, 10, 1),
  stringsAsFactors = FALSE)

ed <- data.frame(rbind(
  c("raw_samples",   "clean_samples"),
  c("taxon_lookup",  "clean_samples"),
  c("clean_samples", "qc_flags"),
  c("clean_samples", "reach_ept"),
  c("reach_ept",     "reach_table"),
  c("chem",          "reach_table"),
  c("reach_table",   "model"),
  c("reach_table",   "boot"),
  c("reach_table",   "perm"),
  c("reach_table",   "rarefy"),
  c("model",         "figure"),
  c("rarefy",        "figure"),
  c("model",         "report"),
  c("boot",          "report"),
  c("perm",          "report")), stringsAsFactors = FALSE)
names(ed) <- c("from", "to")

parents_of  <- function(edges, node) edges$from[edges$to == node]
children_of <- function(edges, node) edges$to[edges$from == node]
cost_of     <- function(nodes) sum(tg$cost[match(nodes, tg$target)])

topo_order <- function(nodes, edges) {
  left <- nodes
  done <- character(0)
  while (length(left) > 0) {
    ready <- left[sapply(left, function(n) all(parents_of(edges, n) %in% done))]
    if (length(ready) == 0) stop("the graph has a cycle: ",
                                 paste(left, collapse = ", "))
    done <- c(done, ready)
    left <- setdiff(left, ready)
  }
  done
}

downstream <- function(edges, changed) {
  front <- changed
  hit <- changed
  while (length(front) > 0) {
    nxt <- setdiff(unique(edges$to[edges$from %in% front]), hit)
    hit <- c(hit, nxt)
    front <- nxt
  }
  hit
}

ord <- topo_order(tg$target, ed)
total_cost <- sum(tg$cost)
round(c(targets = nrow(tg), edges = nrow(ed), total_cost_seconds = total_cost,
        leaves = sum(!tg$target %in% ed$from),
        roots = sum(!tg$target %in% ed$to)), 4)
           targets              edges total_cost_seconds             leaves 
                13                 15                153                  3 
             roots 
                 3 
print(ord)
 [1] "raw_samples"   "taxon_lookup"  "chem"          "clean_samples"
 [5] "qc_flags"      "reach_ept"     "reach_table"   "model"        
 [9] "boot"          "perm"          "rarefy"        "figure"       
[13] "report"       

The topological sort is the only ordering rule a pipeline needs: a target may run once every one of its parents has run. It is also the cheapest possible test that the graph is a graph and not a tangle, because the sort has nowhere to go the moment a cycle exists. Adding one edge that closes a loop makes the failure immediate rather than mysterious.

bad_edges <- rbind(ed, data.frame(from = "report", to = "clean_samples"))
cat(tryCatch({ topo_order(tg$target, bad_edges); "sorted" },
             error = function(e) conditionMessage(e)), "\n")
the graph has a cycle: clean_samples, qc_flags, reach_ept, reach_table, model, boot, perm, rarefy, figure, report 

The message names the ten targets that could not be scheduled, which is the loop plus everything trapped behind it. A script cannot fail this way, because a script has an order whether or not the order makes sense: it runs top to bottom, and if line 40 needs something line 80 produces, you get the previous session’s value or an error, depending on what happened to be lying around.

lay <- tg
lay$y <- c(1.1, 0, -1.5, 0.6, -0.9, 0.8, 0, 1.6, 0.55, -0.55, -1.6, -1.1, 1.1)
depth <- setNames(rep(1, nrow(tg)), tg$target)
for (n in ord) {
  p <- parents_of(ed, n)
  if (length(p) > 0) depth[n] <- max(depth[p]) + 1
}
lay$x <- depth[lay$target]
lay$share <- sapply(lay$target, function(n)
  100 * cost_of(downstream(ed, n)) / total_cost)

seg <- data.frame(x = lay$x[match(ed$from, lay$target)],
                  y = lay$y[match(ed$from, lay$target)],
                  xe = lay$x[match(ed$to, lay$target)],
                  ye = lay$y[match(ed$to, lay$target)])
gap <- 0.1
len <- sqrt((seg$xe - seg$x)^2 + ((seg$ye - seg$y) / 3)^2)
seg$x1 <- seg$x + (seg$xe - seg$x) * gap / len
seg$y1 <- seg$y + (seg$ye - seg$y) * gap / len
seg$x2 <- seg$xe - (seg$xe - seg$x) * gap / len
seg$y2 <- seg$ye - (seg$ye - seg$y) * gap / len

ggplot(lay, aes(x, y)) +
  geom_segment(data = seg, aes(x = x1, y = y1, xend = x2, yend = y2),
               inherit.aes = FALSE, colour = "#9aa295", linewidth = 0.5,
               arrow = arrow(length = unit(0.08, "inches"), type = "closed")) +
  geom_point(aes(fill = share), shape = 21, size = 5.6, colour = te_pal$ink,
             stroke = 0.7) +
  geom_text(aes(label = paste0(target, "\n", cost, " s")), vjust = -0.75,
            lineheight = 0.9, size = 2.8, colour = te_pal$ink) +
  scale_fill_gradient(low = te_pal$sage, high = te_pal$clay,
                      name = "Cost rerun if this target changes (per cent)") +
  scale_x_continuous(breaks = 1:6, limits = c(0.6, 6.4)) +
  scale_y_continuous(limits = c(-2.2, 2.5)) +
  labs(x = "Depth in the graph", y = NULL,
       title = "Changing the first target reruns 98.7 per cent of the cost") +
  theme_te() +
  theme(legend.position = "top", axis.text.y = element_blank(),
        panel.grid.major.y = element_blank())
A layered network diagram with six columns of circles joined by arrows running left to right. The three circles in the first column, the raw samples, the taxon lookup and the chemistry, are dark red, meaning nearly the whole pipeline is rerun when they change. Colour fades through olive to pale green towards the right, where the figure and report circles are palest. Four circles in the fifth column, the model, the bootstrap, the permutation test and the rarefaction, all fan out from a single reach table circle in the fourth.
Figure 1: The thirteen targets, placed by depth in the graph, with the declared cost of each step beneath its name. Colour is the share of the pipeline’s total cost that has to be rerun when that target changes, so the three inputs on the left are the expensive things to touch and the outputs on the right are nearly free.

What one change makes stale

The propagator is the ten-line downstream function in the first chunk, the one that coloured the figure above. Given the set of targets you changed, walk forward along the edges until nothing new is reached. Everything reached is stale. Everything else is untouched and can keep its stored value.

That is the entire idea, and writing it out makes clear what it does not do. It does not know whether a target’s value would actually change; it knows that the target’s inputs changed, so its stored value is no longer a value anyone can vouch for. A pipeline is conservative in exactly this way, and the conservatism is the point: it never tells you that something is current when it is not.

stale_of <- function(changed) {
  d <- downstream(ed, changed)
  c(stale = length(d), cost = cost_of(d), percent = 100 * cost_of(d) / total_cost)
}

inval <- t(sapply(tg$target, function(n) stale_of(n)))
inval <- data.frame(target = rownames(inval), inval, row.names = NULL)
print(inval[order(-inval$percent), ])
          target stale cost    percent
1    raw_samples    11  151 98.6928105
2   taxon_lookup    11  149 97.3856209
4  clean_samples    10  148 96.7320261
6      reach_ept     8  133 86.9281046
3           chem     8  124 81.0457516
7    reach_table     7  123 80.3921569
9           boot     2   41 26.7973856
11        rarefy     2   40 26.1437908
10          perm     2   39 25.4901961
8          model     3   13  8.4967320
12        figure     1   10  6.5359477
5       qc_flags     1    9  5.8823529
13        report     1    1  0.6535948
round(c(cheapest_change_percent = min(inval$percent),
        dearest_change_percent = max(inval$percent),
        median_change_percent = median(inval$percent),
        targets_that_invalidate_over_half = sum(inval$percent > 50),
        targets_that_invalidate_only_themselves = sum(inval$stale == 1)), 4)
                cheapest_change_percent                  dearest_change_percent 
                                 0.6536                                 98.6928 
                  median_change_percent       targets_that_invalidate_over_half 
                                26.7974                                  6.0000 
targets_that_invalidate_only_themselves 
                                 3.0000 

The spread is the finding. Editing the taxon lookup, a small text table that maps recorded names to EPT membership, invalidates 97.3856 per cent of the pipeline’s cost. Editing the report, the last step, invalidates 0.6536 per cent: itself and nothing else. The median target invalidates 26.7974 per cent. There is no useful average here, because the values pile up at the two ends, with the inputs and the cleaning steps near the top and the outputs near the bottom. Which end your edit falls at is decided by the shape of the graph rather than by how big the edit felt.

Three of the thirteen targets invalidate only themselves. Those are the ones you can edit freely. The other ten cost something, and the dearest three to touch are the raw samples, the taxon lookup and the cleaning rule, which are the three a field season changes most often.

sl <- data.frame(
  target = factor(rep(inval$target, 2), levels = inval$target[order(inval$percent)]),
  value = c(inval$stale, inval$percent),
  lab = c(sprintf("%.0f", inval$stale), sprintf("%.1f", inval$percent)),
  share = rep(inval$percent, 2),
  panel = factor(rep(c("Targets made stale (count)",
                       "Share of total pipeline cost (per cent)"),
                     each = nrow(inval)),
                 levels = c("Targets made stale (count)",
                            "Share of total pipeline cost (per cent)")))

ggplot(sl, aes(value, target, fill = share)) +
  geom_col(width = 0.72) +
  geom_text(aes(label = lab), hjust = -0.15, size = 2.9, colour = te_pal$ink) +
  facet_wrap(~panel, scales = "free_x") +
  scale_fill_gradient(low = te_pal$sage, high = te_pal$clay,
                      name = "Cost rerun if this target changes (per cent)") +
  scale_x_continuous(expand = expansion(mult = c(0, 0.18)),
                     breaks = function(lims)
                       if (lims[2] < 20) seq(0, 10, by = 5) else seq(0, 90, by = 30)) +
  labs(x = NULL, y = NULL,
       title = "Six of the thirteen steps put over half the pipeline out of date") +
  theme_te() +
  theme(legend.position = "top",
        strip.text = element_text(colour = te_pal$ink, face = "bold", size = 9),
        panel.spacing.x = unit(1.6, "lines"),
        panel.grid.major.y = element_blank())
Two panels of horizontal bars sharing the same list of thirteen target names on the vertical axis, ordered from the raw samples at the top to the report at the bottom. The left panel counts targets made stale and runs from eleven down to one. The right panel is the share of total cost and runs from just under one hundred per cent down to nearly zero, with a clear break between the six long bars above and the seven short bars below. Every bar in both panels is filled by that cost share, dark red at the top fading to pale green at the bottom, and the colour bar above the panels is the key to it.
Figure 2: For each target, what a change to it makes stale: the number of targets on the left and the share of the pipeline’s declared cost on the right. Targets are ordered by cost share, and the same colour scale as the graph figure is used, so the dark red rows are the expensive things to edit.

The analysis the targets actually run

Up to here the graph has been an abstraction with costs attached. To measure what a stale intermediate does to an ecological result, the targets have to compute something real. The data are simulated so that the pipeline can be rerun as many times as the measurement needs, and so that the correct answer at every stage is available for comparison.

Thirty reaches, forty taxa in the working list, three kick samples per reach. A taxon occurs at a reach with a probability that depends on how tolerant it is and how enriched the reach is, and the sensitive taxa are the ones on the EPT list, so EPT richness falls as phosphorus rises. Detection depends on how long the sample took, and nine samples were cut short.

set.seed(20260815)
n_pool <- 30
n_taxa <- 40
n_rep  <- 3

tp_pool <- round(exp(rnorm(n_pool, log(45), 0.85)), 1)
enrich  <- (log2(tp_pool) - min(log2(tp_pool))) /
  (max(log2(tp_pool)) - min(log2(tp_pool)))
tol <- c(sort(runif(22, 0, 0.62)), runif(18, 0.3, 1))
occ_p <- outer(enrich, tol, function(e, t) plogis(2.6 + 6.5 * (t - e)))
occ <- matrix(runif(n_pool * n_taxa) < occ_p, n_pool, n_taxa)

meta <- data.frame(
  reach  = rep(seq_len(n_pool), each = n_rep),
  rep    = rep(seq_len(n_rep), n_pool),
  effort = round(runif(n_pool * n_rep, 7, 12), 1))
short <- sample(nrow(meta), 9)
meta$effort[short] <- round(runif(9, 2, 4.6), 1)
det_p <- plogis(-1.4 + 0.30 * meta$effort)
obs <- matrix(FALSE, nrow(meta), n_taxa)
for (i in seq_len(nrow(meta)))
  obs[i, ] <- occ[meta$reach[i], ] & (runif(n_taxa) < det_p[i])

ept_v1 <- seq_len(n_taxa) <= 22
ept_v2 <- ept_v1
ept_v2[c(23, 24)] <- TRUE
ept_v2[22] <- FALSE

round(c(reaches_in_pool = n_pool, taxa = n_taxa, samples = nrow(meta),
        ept_taxa_lookup_v1 = sum(ept_v1), ept_taxa_lookup_v2 = sum(ept_v2),
        median_effort_minutes = median(meta$effort),
        shortened_samples = length(short),
        samples_in_the_first_build = sum(meta$reach <= 24),
        phosphorus_lowest = min(tp_pool), phosphorus_highest = max(tp_pool)), 4)
           reaches_in_pool                       taxa 
                      30.0                       40.0 
                   samples         ept_taxa_lookup_v1 
                      90.0                       22.0 
        ept_taxa_lookup_v2      median_effort_minutes 
                      23.0                        9.3 
         shortened_samples samples_in_the_first_build 
                       9.0                       72.0 
         phosphorus_lowest         phosphorus_highest 
                       9.0                      141.9 

Each target is a function of the values its parents produced and of a list of settings. The settings are what the edits change: an effort threshold, which version of the taxon lookup is in force, whether the September batch is included, how richness is standardised, how many bootstrap replicates. Keeping them in one list is not tidiness for its own sake; it is what lets an edit be stated as a change to a named thing, which is what the propagator needs.

rec <- list()

rec$raw_samples <- function(v, p) {
  keep <- meta$reach <= p$n_reach & !(meta$reach %in% p$drop_reach)
  list(meta = meta[keep, ], obs = obs[keep, , drop = FALSE])
}
rec$taxon_lookup <- function(v, p) if (p$lookup == 1) ept_v1 else ept_v2
rec$chem <- function(v, p) {
  tp <- tp_pool[seq_len(p$n_reach)]
  if (p$chem_fix) tp[c(7, 19)] <- tp[c(7, 19)] * c(0.42, 0.55)
  data.frame(reach = seq_len(p$n_reach), tp = tp)
}
rec$clean_samples <- function(v, p) {
  r <- v$raw_samples
  keep <- r$meta$effort >= p$min_effort
  list(meta = r$meta[keep, ], obs = r$obs[keep, v$taxon_lookup, drop = FALSE])
}
rec$qc_flags <- function(v, p) {
  c(kept = nrow(v$clean_samples$meta),
    dropped = nrow(v$raw_samples$meta) - nrow(v$clean_samples$meta))
}
rec$reach_ept <- function(v, p) {
  cs <- v$clean_samples
  rs <- sort(unique(cs$meta$reach))
  val <- sapply(rs, function(r) {
    ix <- which(cs$meta$reach == r)
    if (p$standardise == "per_sample") mean(rowSums(cs$obs[ix, , drop = FALSE]))
    else sum(colSums(cs$obs[ix, , drop = FALSE]) > 0)
  })
  data.frame(reach = rs, ept = val)
}
rec$reach_table <- function(v, p) {
  m <- merge(v$reach_ept, v$chem, by = "reach")
  m$lp <- log2(m$tp)
  m
}
fit_it <- function(d) lm(ept ~ lp, data = d)
rec$model <- function(v, p) fit_it(v$reach_table)
rec$boot <- function(v, p) {
  d <- v$reach_table
  set.seed(p$boot_seed)
  s <- replicate(p$boot_reps, {
    ix <- sample(nrow(d), nrow(d), replace = TRUE)
    coef(fit_it(d[ix, ]))["lp"]
  })
  unname(quantile(s, c(0.025, 0.975)))
}
rec$perm <- function(v, p) {
  d <- v$reach_table
  set.seed(p$perm_seed)
  obs_slope <- coef(fit_it(d))["lp"]
  nul <- replicate(p$perm_reps, {
    dd <- d
    dd$lp <- sample(dd$lp)
    coef(fit_it(dd))["lp"]
  })
  unname((1 + sum(abs(nul) >= abs(obs_slope))) / (p$perm_reps + 1))
}
rec$rarefy <- function(v, p) round(mean(v$reach_table$ept), 4)
rec$figure <- function(v, p) c(points = nrow(v$reach_table), theme = p$fig_theme)
rec$report <- function(v, p)
  c(slope = unname(coef(v$model)["lp"]), lo = v$boot[1], hi = v$boot[2],
    p = v$perm)

settings <- list(n_reach = 24, drop_reach = integer(0), min_effort = 5,
                 lookup = 1, chem_fix = FALSE, standardise = "per_sample",
                 boot_reps = 400, boot_seed = 11, perm_reps = 400,
                 perm_seed = 12, fig_theme = 1)

build_all <- function(p) {
  v <- list()
  for (n in ord) v[[n]] <- rec[[n]](v, p)
  v
}
v0 <- build_all(settings)
round(v0$report, 4)
  slope      lo      hi       p 
-4.4069 -4.9973 -3.8153  0.0025 
print(v0$qc_flags)
   kept dropped 
     65       7 

Two details in there are worth pausing on. The bootstrap sets its own seed from the settings rather than taking whatever state the session happens to be in, so its value depends only on its inputs and not on what ran before it; a target whose value depends on call order cannot be cached honestly. Checking an analysis script measures what happens when that rule is broken. And the report target does nothing except assemble four numbers other targets computed. That is deliberate: the thing the manuscript quotes should be a target, so that the question “is this current” has an answer.

The starting analysis reports 4.4069 EPT taxa lost per doubling of total phosphorus, with a bootstrap interval running from 3.8153 to 4.9973 taxa and a permutation p-value of 0.0025 against 400 shuffles. Seven of the 72 samples are dropped by the five-minute effort rule.

Three ways through the same fortnight of edits

Twelve edits, in the order they happened. Some are cosmetic, some change the data, one changes the definition of the response. Each edit is stated as a change to the settings plus the target or targets whose code or input it touches, which is the only thing the propagator needs to know.

edits <- list(
  list(lab = "figure axis relabelled",           tgt = "figure",
       set = list(fig_theme = 2)),
  list(lab = "effort threshold 5 to 8 minutes",  tgt = "clean_samples",
       set = list(min_effort = 8)),
  list(lab = "bootstrap 400 to 1200 draws",      tgt = "boot",
       set = list(boot_reps = 1200)),
  list(lab = "taxon lookup revised",             tgt = "taxon_lookup",
       set = list(lookup = 2)),
  list(lab = "September batch arrives",          tgt = c("raw_samples", "chem"),
       set = list(n_reach = 30)),
  list(lab = "phosphorus recalibrated",          tgt = "chem",
       set = list(chem_fix = TRUE)),
  list(lab = "richness pooled over replicates",  tgt = "reach_ept",
       set = list(standardise = "pooled")),
  list(lab = "permutation 400 to 1000 shuffles", tgt = "perm",
       set = list(perm_reps = 1000)),
  list(lab = "one reach excluded",               tgt = "raw_samples",
       set = list(drop_reach = 9L)),
  list(lab = "effort threshold back to 7",       tgt = "clean_samples",
       set = list(min_effort = 7)),
  list(lab = "second reach excluded",            tgt = "raw_samples",
       set = list(drop_reach = c(9L, 27L))),
  list(lab = "figure theme changed",             tgt = "figure",
       set = list(fig_theme = 3)))

mental <- ed[!(ed$from == "reach_table" & ed$to == "boot"), ]
mental <- rbind(mental, data.frame(from = "chem", to = "clean_samples"))

edit_cost <- t(sapply(edits, function(e)
  c(graph = cost_of(downstream(ed, e$tgt)),
    memory = cost_of(downstream(mental, e$tgt)),
    everything = total_cost)))
print(data.frame(edit = sapply(edits, function(e) e$lab), edit_cost))
                               edit graph memory everything
1            figure axis relabelled    10     10        153
2   effort threshold 5 to 8 minutes   148    108        153
3       bootstrap 400 to 1200 draws    41     41        153
4              taxon lookup revised   149    109        153
5           September batch arrives   152    112        153
6           phosphorus recalibrated   124    109        153
7   richness pooled over replicates   133     93        153
8  permutation 400 to 1000 shuffles    39     39        153
9                one reach excluded   151    111        153
10       effort threshold back to 7   148    108        153
11            second reach excluded   151    111        153
12             figure theme changed    10     10        153
round(c(mean_cost_invalidated_per_edit_percent =
          mean(100 * edit_cost[, "graph"] / total_cost),
        edits_that_invalidate_over_half = sum(edit_cost[, "graph"] > total_cost / 2)), 4)
mean_cost_invalidated_per_edit_percent        edits_that_invalidate_over_half 
                               68.4096                                 8.0000 

The three strategies differ only in which targets they rebuild after each edit.

Rebuilding everything is the strategy that cannot be wrong. It runs all thirteen targets after every edit, including the thirty-second rarefaction that nothing upstream touched.

Letting the graph decide rebuilds the changed targets and everything downstream of them, in topological order.

The middle strategy is the one almost everyone actually uses: rebuild what you believe is affected. The analyst here is not careless. They have a mental model of the graph, and it is wrong in exactly two places. They believe the bootstrap reads the saved reach table from disk each time it runs, so they never think of it as needing a rebuild when the table changes; and they believe the cleaning step depends on the chemistry, so they rerun cleaning whenever a phosphorus value changes. One missing edge and one edge that does not exist.

run_sequence <- function(mode) {
  p <- settings
  v <- build_all(p)
  cost <- total_cost
  out <- data.frame(step = 0, cost = cost, rebuilt = length(ord),
                    slope = v$report[["slope"]], lo = v$report[["lo"]],
                    hi = v$report[["hi"]], pval = v$report[["p"]])
  for (k in seq_along(edits)) {
    e <- edits[[k]]
    for (nm in names(e$set)) p[[nm]] <- e$set[[nm]]
    hit <- switch(mode,
                  everything = tg$target,
                  graph = downstream(ed, e$tgt),
                  memory = downstream(mental, e$tgt))
    hit <- ord[ord %in% hit]
    for (n in hit) v[[n]] <- rec[[n]](v, p)
    cost <- cost + cost_of(hit)
    out <- rbind(out, data.frame(step = k, cost = cost, rebuilt = length(hit),
                                 slope = v$report[["slope"]],
                                 lo = v$report[["lo"]], hi = v$report[["hi"]],
                                 pval = v$report[["p"]]))
  }
  rownames(out) <- NULL
  out
}

truth_sequence <- function() {
  p <- settings
  v <- build_all(p)
  out <- data.frame(step = 0, slope = v$report[["slope"]], lo = v$report[["lo"]],
                    hi = v$report[["hi"]], pval = v$report[["p"]])
  for (k in seq_along(edits)) {
    for (nm in names(edits[[k]]$set)) p[[nm]] <- edits[[k]]$set[[nm]]
    v <- build_all(p)
    out <- rbind(out, data.frame(step = k, slope = v$report[["slope"]],
                                 lo = v$report[["lo"]], hi = v$report[["hi"]],
                                 pval = v$report[["p"]]))
  }
  rownames(out) <- NULL
  out
}

A <- run_sequence("everything")
G <- run_sequence("graph")
M <- run_sequence("memory")
TR <- truth_sequence()

agrees <- function(x) all(abs(x$slope - TR$slope) < 1e-9 &
                            abs(x$lo - TR$lo) < 1e-9 & abs(x$hi - TR$hi) < 1e-9)
round(c(cost_rebuild_everything = max(A$cost),
        cost_from_memory = max(M$cost),
        cost_let_the_graph_decide = max(G$cost),
        ratio_everything_over_graph = max(A$cost) / max(G$cost),
        ratio_memory_over_graph = max(M$cost) / max(G$cost),
        graph_saves_percent = 100 * (1 - max(G$cost) / max(A$cost)),
        memory_does_less_work_than_graph_percent =
          100 * (1 - max(M$cost) / max(G$cost)),
        target_rebuilds_everything = sum(A$rebuilt[-1]),
        target_rebuilds_graph = sum(G$rebuilt[-1]),
        target_rebuilds_memory = sum(M$rebuilt[-1])), 4)
                 cost_rebuild_everything 
                               1989.0000 
                        cost_from_memory 
                               1114.0000 
               cost_let_the_graph_decide 
                               1409.0000 
             ratio_everything_over_graph 
                                  1.4116 
                 ratio_memory_over_graph 
                                  0.7906 
                     graph_saves_percent 
                                 29.1604 
memory_does_less_work_than_graph_percent 
                                 20.9368 
              target_rebuilds_everything 
                                156.0000 
                   target_rebuilds_graph 
                                 87.0000 
                  target_rebuilds_memory 
                                 82.0000 
c(rebuild_everything_matches_truth = agrees(A),
  graph_matches_truth = agrees(G),
  memory_matches_truth = agrees(M))
rebuild_everything_matches_truth              graph_matches_truth 
                            TRUE                             TRUE 
            memory_matches_truth 
                           FALSE 

Rebuilding everything costs 1989 declared seconds over the fortnight; letting the graph decide costs 1409, a saving of 29.1604 per cent. That is a real saving and it is smaller than the advertising suggests, because most of these edits are near the root of the graph and a root edit invalidates nearly everything. The average edit here invalidates 68.4096 per cent of the pipeline’s cost. A pipeline is not a way of making a badly shaped analysis cheap; it is a way of not paying for the parts that genuinely did not change.

The middle strategy costs 1114, which is 20.9368 per cent less work than the graph did. It is the cheapest of the three. It is also the only one whose reported numbers do not match a full rebuild, and that is the whole point of the comparison: the work it skipped was work that mattered.

wrong_ci <- abs(M$lo - TR$lo) > 1e-9 | abs(M$hi - TR$hi) > 1e-9
visible <- M$slope < M$lo | M$slope > M$hi
boot_due <- sapply(edits, function(e) "boot" %in% downstream(ed, e$tgt))
boot_run <- sapply(edits, function(e) "boot" %in% downstream(mental, e$tgt))

k <- nrow(M)
round(c(edits_after_which_the_estimate_was_wrong = sum(abs(M$slope - TR$slope) > 1e-9),
        edits_after_which_the_p_value_was_wrong = sum(abs(M$pval - TR$pval) > 1e-12),
        edits_after_which_the_interval_was_wrong = sum(wrong_ci),
        bootstrap_rebuilds_the_graph_would_have_run = sum(boot_due),
        bootstrap_rebuilds_the_analyst_ran = sum(boot_run),
        states_where_the_estimate_falls_outside_its_own_interval = sum(visible),
        wrong_states_with_no_visible_symptom = sum(wrong_ci) - sum(visible)), 4)
                edits_after_which_the_estimate_was_wrong 
                                                       0 
                 edits_after_which_the_p_value_was_wrong 
                                                       0 
                edits_after_which_the_interval_was_wrong 
                                                      10 
             bootstrap_rebuilds_the_graph_would_have_run 
                                                       9 
                      bootstrap_rebuilds_the_analyst_ran 
                                                       1 
states_where_the_estimate_falls_outside_its_own_interval 
                                                       6 
                    wrong_states_with_no_visible_symptom 
                                                       4 
round(c(final_taxa_lost_per_doubling = -TR$slope[k],
        correct_interval_low = -TR$hi[k], correct_interval_high = -TR$lo[k],
        reported_interval_low = -M$hi[k], reported_interval_high = -M$lo[k],
        correct_width = TR$hi[k] - TR$lo[k],
        reported_width = M$hi[k] - M$lo[k],
        low_end_understated_percent = 100 * (M$hi[k] - TR$hi[k]) / -TR$hi[k],
        high_end_understated_percent = 100 * (M$lo[k] - TR$lo[k]) / -TR$lo[k],
        overlap_of_the_two_intervals =
          min(TR$hi[k], M$hi[k]) - max(TR$lo[k], M$lo[k]),
        biggest_move_of_the_correct_interval_in_one_edit =
          max(abs(diff(TR$lo)))), 4)
                    final_taxa_lost_per_doubling 
                                          5.2095 
                            correct_interval_low 
                                          4.5260 
                           correct_interval_high 
                                          5.9688 
                           reported_interval_low 
                                          3.7686 
                          reported_interval_high 
                                          5.0756 
                                   correct_width 
                                          1.4428 
                                  reported_width 
                                          1.3071 
                     low_end_understated_percent 
                                         16.7354 
                    high_end_understated_percent 
                                         14.9645 
                    overlap_of_the_two_intervals 
                                          0.5496 
biggest_move_of_the_correct_interval_in_one_edit 
                                          1.0032 

The estimate the analyst reports is right at every step, and so is the permutation p-value. Both of those targets sit in the part of the mental model that was correct, so they were rebuilt whenever they needed to be. The interval is wrong after 10 of the 12 edits.

The final numbers say it plainly. The correct interval for the loss of EPT taxa per doubling of phosphorus runs from 4.5260 to 5.9688 taxa, around an estimate of 5.2095. The reported interval runs from 3.7686 to 5.0756. Its low end understates the loss by 16.7354 per cent, its high end by 14.9645 per cent, and the two intervals share only 0.5496 taxa of width out of a correct width of 1.4428. The interval is not noisier than it should be, or slightly shifted. It is the interval from a data set that stopped existing nine edits ago, computed before the September reaches arrived, before the phosphorus recalibration, and before richness was redefined from a per-sample mean to a pooled count.

The graph would have rebuilt the bootstrap after 9 of the 12 edits. The analyst rebuilt it after 1, the one where they edited the bootstrap itself. Eight missed rebuilds, from a single wrong belief about one edge.

There is one visible symptom, and it is worth knowing about because it costs nothing to check. In 6 of the 13 states the reported estimate falls outside its own reported interval, which is arithmetically impossible for a bootstrap of that estimate and is therefore proof that the two came from different runs. In the other 4 wrong states the estimate still sits inside the stale interval and nothing at all looks amiss. Comparing an estimate against its own interval is one line of code and it is the only warning the output itself can give you here.

lv <- c("Rebuild everything", "From memory", "Let the graph decide")
cost_df <- data.frame(
  step = rep(A$step, 3),
  value = c(A$cost, M$cost, G$cost),
  strategy = factor(rep(lv, each = nrow(A)), levels = lv),
  panel = "Cumulative rebuild cost (declared seconds)")
band_df <- data.frame(
  step = rep(A$step, 3),
  lo = -c(A$hi, M$hi, G$hi), hi = -c(A$lo, M$lo, G$lo),
  strategy = factor(rep(lv, each = nrow(A)), levels = lv),
  panel = "Reported EPT taxa lost per doubling of phosphorus")
est_df <- data.frame(step = A$step, value = -A$slope,
                     panel = "Reported EPT taxa lost per doubling of phosphorus")
pal3 <- c(te_pal$gold, te_pal$clay, te_pal$forest)

# the everything and graph bands hold identical values at every step, so the
# first is traced as an outline: three fills would paint over one another
band_fill <- band_df[band_df$strategy != "Rebuild everything", ]
band_edge <- band_df[band_df$strategy == "Rebuild everything", ]

ggplot(cost_df, aes(step, value)) +
  geom_ribbon(data = band_fill, aes(y = NULL, ymin = lo, ymax = hi, fill = strategy),
              alpha = 0.5) +
  geom_ribbon(data = band_edge, aes(y = NULL, ymin = lo, ymax = hi,
                                    colour = strategy),
              fill = NA, linetype = 2, linewidth = 0.6, show.legend = FALSE) +
  geom_line(data = est_df, aes(step, value), colour = te_pal$ink,
            linewidth = 0.9) +
  geom_point(data = est_df, aes(step, value), colour = te_pal$ink, size = 1.7) +
  geom_line(aes(colour = strategy), linewidth = 0.9) +
  geom_segment(data = data.frame(
    step = 5, value = 3.62,
    panel = "Reported EPT taxa lost per doubling of phosphorus"),
    aes(x = step, xend = step, y = value, yend = 4.2), colour = te_pal$ink,
    linewidth = 0.4) +
  geom_text(data = data.frame(
    step = 5, value = 3.48,
    panel = "Reported EPT taxa lost per doubling of phosphorus"),
    label = "reported estimate", size = 3.1, colour = te_pal$ink) +
  facet_wrap(~panel, ncol = 1, scales = "free_y") +
  scale_colour_manual(values = pal3, name = NULL, drop = FALSE) +
  scale_fill_manual(values = pal3, name = NULL, drop = FALSE, guide = "none") +
  scale_x_continuous(breaks = 0:12) +
  labs(x = "Edits made, in order",
       y = NULL,
       title = "The strategy that did the least work reported a nine-edit-old interval") +
  theme_te() +
  theme(legend.position = "top",
        strip.text = element_text(colour = te_pal$ink, face = "bold", size = 9))
Two stacked panels sharing an edit number axis from zero to twelve. The upper panel has three rising lines: rebuild everything climbs steeply and highest, letting the graph decide climbs less steeply, and working from memory is the lowest of the three. The lower panel shows two shaded bands and one dashed outline: the green band for the graph runs with its lower edge near 3.7 taxa and its upper edge near 4.9 for the first seven edits, then both edges step up together at the seventh edit, the lower to about 4.53 and the upper to about 5.97, so the band shifts upward as a whole. The dashed gold outline for rebuilding everything traces that green band exactly, the red memory band stays flat from the third edit onward at the lower level, and the dark estimate line rises out of the top of the memory band.
Figure 3: The fortnight of edits under the three strategies. The upper panel is cumulative declared cost, the lower panel the reported interval for the loss of EPT taxa per doubling of phosphorus, with the point estimate as the dark line. Rebuilding everything is drawn as a dashed gold outline rather than a filled band: its interval is identical to the graph’s at every step, so a third fill would only hide the two beneath it. The band for the memory strategy stops moving after the third edit and ends up below its own point estimate.

The lower panel is the shape worth remembering. The correct interval moves whenever the data or the definition move, in one case by 1.0032 taxa in a single edit, when richness changes from a per-sample mean to a count pooled across replicates: a shift of about four fifths of the width of the interval itself. The reported interval moves once, early, and then holds still through seven substantive changes to the analysis. Nothing about the way it is displayed distinguishes the flat stretch from the moving one. It is a number in a caption, and it looks exactly like a number that was computed this morning.

Topological order and the parallel ceiling

The topological sort gives one valid order out of many. What it does not give is the shortest schedule, because targets with no path between them can run at the same time. The bound on that is the critical path: the longest chain of dependent targets, measured in cost. No amount of hardware gets you below it.

longest <- setNames(rep(NA_real_, nrow(tg)), tg$target)
for (n in ord) {
  p <- parents_of(ed, n)
  longest[n] <- tg$cost[tg$target == n] + if (length(p)) max(longest[p]) else 0
}
crit_len <- max(longest)
crit_path <- names(which.max(longest))
while (length(parents_of(ed, crit_path[1])) > 0) {
  p <- parents_of(ed, crit_path[1])
  crit_path <- c(p[which.max(longest[p])], crit_path)
}

makespan <- function(workers) {
  finish <- setNames(rep(NA_real_, nrow(tg)), tg$target)
  free <- rep(0, workers)
  left <- ord
  while (length(left) > 0) {
    ready <- left[sapply(left, function(n) all(!is.na(finish[parents_of(ed, n)])))]
    ready <- ready[order(-tg$cost[match(ready, tg$target)])]
    n <- ready[1]
    dep <- if (length(parents_of(ed, n))) max(finish[parents_of(ed, n)]) else 0
    w <- which.min(free)
    finish[n] <- max(free[w], dep) + tg$cost[tg$target == n]
    free[w] <- finish[n]
    left <- setdiff(left, n)
  }
  max(finish)
}

sched <- data.frame(workers = 1:6)
sched$makespan <- sapply(sched$workers, makespan)
sched$speedup <- total_cost / sched$makespan
print(round(sched, 4))
  workers makespan speedup
1       1      153  1.0000
2       2       99  1.5455
3       3       63  2.4286
4       4       62  2.4677
5       5       62  2.4677
6       6       62  2.4677
print(crit_path)
[1] "raw_samples"   "clean_samples" "reach_ept"     "reach_table"  
[5] "boot"          "report"       
round(c(total_cost_seconds = total_cost,
        critical_path_seconds = crit_len,
        critical_path_targets = length(crit_path),
        theoretical_ceiling = total_cost / crit_len,
        speedup_at_three_workers = sched$speedup[3],
        gain_from_a_fourth_worker = sched$speedup[4] - sched$speedup[3],
        share_of_cost_on_the_critical_path = 100 * cost_of(crit_path) / total_cost,
        ceiling_if_the_bootstrap_were_twice_as_fast =
          (total_cost - 20) / (crit_len - 20)), 4)
                         total_cost_seconds 
                                   153.0000 
                      critical_path_seconds 
                                    62.0000 
                      critical_path_targets 
                                     6.0000 
                        theoretical_ceiling 
                                     2.4677 
                   speedup_at_three_workers 
                                     2.4286 
                  gain_from_a_fourth_worker 
                                     0.0392 
         share_of_cost_on_the_critical_path 
                                    40.5229 
ceiling_if_the_bootstrap_were_twice_as_fast 
                                     3.1667 

The critical path runs from the raw samples through cleaning, the richness summary, the reach table and the bootstrap to the report: 6 targets and 62 of the 153 declared seconds. The ceiling is therefore 2.4677, and the schedule reaches 2.4286 of that with three workers. A fourth worker adds 0.0392 and a fifth adds nothing at all.

ceiling_val <- total_cost / crit_len

ggplot(sched, aes(workers, speedup)) +
  geom_hline(yintercept = ceiling_val, linetype = 2, colour = te_pal$clay,
             linewidth = 0.8) +
  geom_line(colour = te_pal$forest, linewidth = 1) +
  geom_point(colour = te_pal$forest, size = 3.4) +
  geom_text(aes(label = sprintf("%.2f", speedup)), vjust = 1.9, size = 3.2,
            colour = te_pal$ink) +
  annotate("text", x = 3.5, y = ceiling_val + 0.11,
           label = sprintf("ceiling set by the critical path: %.4f", ceiling_val),
           size = 3.2, colour = te_pal$clay) +
  scale_x_continuous(breaks = 1:6) +
  scale_y_continuous(limits = c(0.75, 2.75)) +
  labs(x = "Workers running targets at the same time",
       y = "Speed-up over one worker",
       title = "A third worker is worth having and a fifth is worth nothing") +
  theme_te()
A line with points rising from a speed-up of one at one worker to about one and a half at two workers and about two and a half at three, then flat across four, five and six workers. A horizontal dashed line just above the flat part marks the ceiling of 2.4677 set by the critical path, and is labelled.
Figure 4: Speed-up against the number of workers, from a greedy schedule of the same graph. The dashed line is the ceiling set by the critical path, which no number of workers can pass. Three workers reach almost all of it and the curve is flat after four.

That ceiling is the useful thing to compute before buying anything, and it is a property of the graph rather than of the machine. A fourth core is worth 0.0392 here. Halving the bootstrap, which is on the critical path, raises the ceiling itself from 2.4677 to 3.1667, and it shortens the run on a single core as well, which no number of cores ever does. Working out which of your steps sit on the critical path is a loop over the topological order, and it tells you where an hour of programming pays and where it does not.

Two honest caveats about that ceiling. It assumes a worker is free the moment a target finishes and that moving a value between workers is free, neither of which is true; and it assumes the costs are right, which for a real pipeline means reading them off the log of a previous run rather than guessing.

What the targets package does with all this

Everything above is what the targets package does for you, with three differences that matter.

It works out the edges itself. You write the targets as function calls and it inspects the code to find which target names appear inside which expressions, so the edge list is derived from the analysis rather than maintained beside it. An edge list you maintain by hand goes stale in the same way a comment does.

It decides staleness by hashing rather than by timestamps. A target is out of date when the hash of its value’s inputs, the body of the function that made it, or its declared settings differ from what was recorded. That is stricter than the file modification times that make uses: touching a file without changing it does not trigger a rebuild, and changing a function body does, even though no file the target reads has changed. It is also the same idea as the lockfile in pinning package versions with renv: a recorded claim about the state something was built from.

And it stores the values, so tar_read(model) gives you the fitted model in a fresh session without running anything.

The pipeline lives in a file called _targets.R at the top of the project, and it is the same thirteen targets written in the package’s notation. It is not run here because this post has to render with nothing installed beyond ggplot2:

# _targets.R, read by the targets package
library(targets)
tar_option_set(packages = c("stats", "ggplot2"))
source("R/functions.R")

list(
  tar_target(raw_samples,   read_samples("data/kick_samples.csv")),
  tar_target(taxon_lookup,  read_lookup("data/ept_lookup.csv")),
  tar_target(chem,          read_chem("data/phosphorus.csv")),
  tar_target(clean_samples, clean_it(raw_samples, taxon_lookup, min_effort = 7)),
  tar_target(qc_flags,      count_dropped(raw_samples, clean_samples)),
  tar_target(reach_ept,     summarise_ept(clean_samples)),
  tar_target(reach_table,   join_reach(reach_ept, chem)),
  tar_target(model,         fit_it(reach_table)),
  tar_target(boot,          boot_ci(reach_table, reps = 1200, seed = 11)),
  tar_target(perm,          perm_test(reach_table, reps = 1000, seed = 12)),
  tar_target(rarefy,        rarefaction(reach_table)),
  tar_target(figure,        draw_it(reach_table, model, rarefy)),
  tar_target(report,        assemble(model, boot, perm))
)

Then four commands do the work of this entire post:

tar_outdated()     # which targets are stale, without running anything
tar_visnetwork()   # the graph, with the stale targets highlighted
tar_make()         # run exactly those, in topological order
tar_read(report)   # the value, without rerunning the pipeline

tar_outdated() is the one to run before you believe a number. It is the propagator from the second section, applied to the real analysis, and it answers the reviewer’s question in one line.

The idea is older than R. make has worked this way since 1976, Snakemake and Nextflow are the same graph with a different notation and a scheduler attached, and every one of them exists because the alternative is remembering.

The blog you are reading is an example

Quarto has this same machinery, narrowed to one file. Setting freeze: auto records the rendered output of each post and reruns a post’s R code only when that post’s source has changed:

execute:
  freeze: auto

This site runs on it, which is why editing one sentence in the About page does not rerun the simulations in 550 posts.

round(c(posts_on_this_site = 550,
        share_of_post_renders_a_one_post_edit_invalidates = 100 / 550,
        site_level_pages_downstream_of_every_post = 3), 4)
                               posts_on_this_site 
                                         550.0000 
share_of_post_renders_a_one_post_edit_invalidates 
                                           0.1818 
        site_level_pages_downstream_of_every_post 
                                           3.0000 

The arithmetic is the same as the second section of this post: editing one post invalidates 0.1818 per cent of the post renders. It is not the whole story, because the listing page, the sitemap and the feed are downstream of every post and are rebuilt each time, which is exactly the fan-in that the report target has in the graph above. The granularity of the freeze is the file, so a post with one cheap plot and a post with an hour of simulation in it are invalidated the same way, and moving that simulation out of the post and into a target of its own is what would make the granularity finer.

The honest limit

The graph only knows what you declared. That sentence is the limit, and the measurement above is a demonstration of it rather than an argument against it: the memory strategy failed because one edge was missing from the analyst’s model, and a pipeline tool fails in the same way when a dependency is invisible to it. A function that reads a file the pipeline does not know about, a global option set in a startup file, a package whose default changed under you, a value taken from the session’s random state: none of these are edges the tool can see, and each of them can change a result without making anything stale. That is the same failure the lockfile post is about, one level up.

Staleness is not correctness. Everything in this post measures whether the stored values are consistent with the current inputs and code. A pipeline that is entirely up to date can still be computing the wrong thing: the wrong model, the wrong exclusion rule, a units error of the kind testing your analysis code measures. The graph will faithfully rebuild a wrong answer and mark it current.

The three strategies were compared on one edit sequence, and the ratios depend on it. A fortnight in which the edits had all been cosmetic would show the graph saving far more than 29.1604 per cent, and a fortnight of nothing but data corrections would show it saving almost nothing. What does not depend on the sequence is the asymmetry: rebuilding too much wastes time, and rebuilding too little publishes numbers that came from different versions of the data. Only one of those two errors is visible in the output, and only 6 of the 10 wrong states here were visible even in principle.

The declared costs are stipulated, so every timing statement in the post is a ratio and none of it is a claim about wall clock seconds on any particular machine. And a pipeline has a cost of its own: the graph is code that has to be kept in step with the analysis, the stored values take disk space, and for a script that runs in four seconds and will never run again, all of this is overhead with no return. The threshold is roughly where the total rerun cost over the life of the project exceeds the cost of writing the graph down, which for an analysis rerun a dozen times with a bootstrap in it is crossed almost immediately, and for a one-afternoon calculation is never crossed at all.

Where to go next

The pipeline pins what is derived from what, and it says nothing about what the code was run with. That gap is the subject of pinning package versions with renv, where a changed default in a dependency moves the answer with no error and no warning, and of a reproducible statistical workflow in R, which is the wider argument this post is one part of.

If the ceiling calculation in the parallel section interested you, the thing to do before adding workers is to find out where the time is, which is what speeding up your analysis code measures, because the target on the critical path is usually not the one you expected. And once the graph exists, the functions inside the targets are ordinary functions with ordinary arguments, which makes them straightforward to test one at a time: testing your analysis code scores which kinds of test are worth writing.

References

Landau WM 2021 Journal of Open Source Software 6(57):2959 (10.21105/joss.02959)

Koster J, Rahmann S 2012 Bioinformatics 28(19):2520-2522 (10.1093/bioinformatics/bts480)

Di Tommaso P, Chatzou M, Floden EW, Prieto Barja P, Palumbo E, Notredame C 2017 Nature Biotechnology 35(4):316-319 (10.1038/nbt.3820)

Sandve GK, Nekrutenko A, Taylor J, Hovig E 2013 PLoS Computational Biology 9(10):e1003285 (10.1371/journal.pcbi.1003285)

Peng RD 2011 Science 334(6060):1226-1227 (10.1126/science.1213847)

Wilson G, Bryan J, Cranston K, Kitzes J, Nederbragt L, Teal TK 2017 PLoS Computational Biology 13(6):e1005510 (10.1371/journal.pcbi.1005510)

Newsletter

Get new tutorials by email

New R and QGIS tutorials for ecologists, straight to your inbox. No spam; unsubscribe anytime.

By subscribing you agree to receive these emails and confirm your address once. See the privacy policy.