Debugging and defensive R code

R
reproducibility
ecology tutorial
ggplot2
The expensive R error is the one that runs and returns a wrong number. Measured on a beetle survey: what stays silent, what it costs, and what catches it.
Author

Tidy Ecology

Published

2026-07-24

An analysis of pitfall trap data stops with a message about contrasts. You have never written the word contrasts in your life. You read the message three times, work out which line it came from, and find that the line is fine: it fits a linear model to twelve site means, which is what it is supposed to do. The fault is seven stages upstream, in a lookup that returned nothing because a column was called code and the script asked for site.

That error cost an afternoon and it is the cheap one, because it stopped. In the same script two other things went wrong. One of them warned, which meant it announced itself in a way that is easy to scroll past. The third neither stopped nor warned. It produced a table of the right shape with plausible numbers in it, and the grazing effect it reported was 62 per cent larger than the truth.

This post measures the three of them on the same analysis. It then takes the silent one apart, because vector recycling is the mechanism behind most of the silent arithmetic errors in R, and it does its worst damage in exactly the balanced designs that good field practice produces. After that comes the other silent error that nobody warns ecologists about: a numeric covariate that arrives as a factor, where as.numeric returns the level index rather than the value. The last section prices the defence. Twelve clauses of precondition checking at the top of one function, scored against ten faults, against what R says by itself and against what tryCatch adds.

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 survey and the number it reports

Twelve grassland sites, six grazed and six ungrazed, each visited five times with pitfall traps. Every record carries the site, the visit, the number of trap nights the trap was open and the beetle catch. A separate table holds one detection correction weight per site, the kind of adjustment that comes from a vegetation height model or a paired mark-recapture calibration. The analysis standardises each catch to a rate per hundred trap nights, multiplies by the site weight, averages up to the site, and reports the percentage by which the grazed sites exceed the ungrazed ones.

set.seed(20260811)

n_site  <- 12
n_visit <- 5
site  <- sprintf("S%02d", 1:n_site)
treat <- rep(c("grazed", "ungrazed"), each = 6)

d <- data.frame(site = rep(site, each = n_visit),
                visit = rep(1:n_visit, times = n_site),
                stringsAsFactors = FALSE)
d$treat  <- treat[match(d$site, site)]
d$nights <- sample(4:10, nrow(d), replace = TRUE)
site_rate <- ifelse(treat == "grazed", 3.6, 2.4) * exp(rnorm(n_site, 0, 0.18))
d$catch  <- rpois(nrow(d), site_rate[match(d$site, site)] * d$nights)

wt <- data.frame(code = site, weight = round(runif(n_site, 0.75, 1.45), 3),
                 stringsAsFactors = FALSE)
w <- wt$weight
names(w) <- wt$code

analyse <- function(recs, wvec) {
  rate <- 100 * recs$catch / recs$nights
  corr <- rate * wvec
  sm <- tapply(corr, recs$site, mean)
  tb <- data.frame(site = names(sm), treat = treat[match(names(sm), site)],
                   mean_rate = as.numeric(sm), stringsAsFactors = FALSE)
  fit <- lm(mean_rate ~ treat, data = tb)
  100 * -coef(fit)[[2]] / mean(tb$mean_rate[tb$treat == "ungrazed"])
}

base_recs <- d[, c("site", "visit", "treat", "nights", "catch")]
base_w <- as.numeric(w[d$site])
site_mean <- tapply(100 * d$catch / d$nights * base_w, d$site, mean)
true_effect <- analyse(base_recs, base_w)

print(head(d, 4))
  site visit  treat nights catch
1  S01     1 grazed      4    12
2  S01     2 grazed      4    15
3  S01     3 grazed      9    32
4  S01     4 grazed      8    25
print(wt)
   code weight
1   S01  0.858
2   S02  0.801
3   S03  1.087
4   S04  0.935
5   S05  1.244
6   S06  0.917
7   S07  1.206
8   S08  1.229
9   S09  0.762
10  S10  1.284
11  S11  1.081
12  S12  0.868
round(c(records = nrow(d), sites = n_site, visits_per_site = n_visit,
        analysis_lines = length(deparse(analyse)),
        reported_grazing_effect_percent = true_effect), 4)
                        records                           sites 
                        60.0000                         12.0000 
                visits_per_site                  analysis_lines 
                         5.0000                         10.0000 
reported_grazing_effect_percent 
                        22.5741 

The number the paper would carry is 22.5741 per cent. It is correct, because the data were built so that the answer is known, and everything that follows is a comparison against it. The unit for all the damage measurements below is a percentage point of that reported effect, which keeps the comparison honest: a fault that moves the answer by a tenth of a point and a fault that moves it by twenty are not the same problem, even if both are wrong.

Three errors, and what each one costs to find

Write the analysis out as eight named stages, each one taking the previous result and producing the next. That is what the script is anyway; naming the stages just makes the intermediate values addressable, which is the first thing you need when something goes wrong.

d$nights_txt <- as.character(d$nights)
bad_rows <- c(9, 28, 47)

stage_names <- c("weights", "nights", "usable", "rate",
                 "corrected", "site means", "site table", "contrast")

run_pipe <- function(fault = "none", guarded = FALSE) {
  dd <- d
  if (fault == "text nights") dd$nights_txt[bad_rows] <- "not set"
  vals <- vector("list", 8)
  names(vals) <- stage_names
  log <- data.frame(stage = integer(0), kind = character(0), text = character(0),
                    stringsAsFactors = FALSE)
  stopped <- FALSE
  step <- function(i, expr) {
    if (stopped) return(NULL)
    v <- withCallingHandlers(
      tryCatch(expr, error = function(e) {
        log <<- rbind(log, data.frame(stage = i, kind = "error",
                                      text = conditionMessage(e),
                                      stringsAsFactors = FALSE))
        stopped <<- TRUE
        NULL
      }),
      warning = function(w) {
        log <<- rbind(log, data.frame(stage = i, kind = "warning",
                                      text = conditionMessage(w),
                                      stringsAsFactors = FALSE))
        invokeRestart("muffleWarning")
      })
    vals[[i]] <<- v
    v
  }

  wv <- step(1, {
    v <- if (fault == "missing column") wt$weight[match(dd$site, wt$site)]
         else wt$weight[match(dd$site, wt$code)]
    if (guarded) stopifnot(length(v) == nrow(dd), all(is.finite(v)))
    v })
  ni <- step(2, {
    v <- as.numeric(dd$nights_txt)
    if (guarded) stopifnot(all(is.finite(v)), all(v > 0))
    v })
  us <- step(3, { dd$nights <- ni; dd$w <- wv; dd[ni > 0, ] })
  ra <- step(4, 100 * us$catch / us$nights)
  co <- step(5, {
    wvec <- if (fault == "recycled") wt$weight else us$w
    if (guarded) stopifnot(length(wvec) == nrow(us))
    ra * wvec })
  sm <- step(6, tapply(co, us$site, mean))
  tb <- step(7, data.frame(site = names(sm), treat = treat[match(names(sm), site)],
                           mean_rate = as.numeric(sm), stringsAsFactors = FALSE))
  ef <- step(8, {
    fit <- lm(mean_rate ~ treat, data = tb)
    100 * -coef(fit)[[2]] / mean(tb$mean_rate[tb$treat == "ungrazed"])
  })
  list(vals = vals, log = log, effect = if (is.null(ef)) NA_real_ else ef)
}

clean <- run_pipe("none")
round(c(stages = length(stage_names),
        messages_on_the_clean_run = nrow(clean$log),
        effect_on_the_clean_run = clean$effect), 4)
                   stages messages_on_the_clean_run   effect_on_the_clean_run 
                   8.0000                    0.0000                   22.5741 

Three faults go in, one at a time. The first is the column name: wt$site instead of wt$code. There is no such column, so $ returns NULL, match against NULL returns missing for every record, and the weight vector comes back as sixty missing values. R says nothing at the time. The second is a trap nights column that arrived as text, with three cells reading not set; as.numeric turns those into missing values and warns while doing it. The third is the recycled weight vector, the subject of the next section, which produces no message of any kind.

locate <- function(x) {
  if (is.null(x) || length(x) == 0) return("nothing there")
  num <- if (is.data.frame(x)) unlist(x[vapply(x, is.numeric, logical(1))]) else
         if (is.numeric(x)) x else NULL
  if (!is.null(num) && length(num) > 0) {
    if (anyNA(num)) return("missing values")
    if (any(!is.finite(num))) return("not finite")
    if (any(num < 0)) return("negative")
  }
  NA_character_
}

fault_names <- c("missing column", "text nights", "recycled")
fault_stage <- c(1, 2, 5)

scan_one <- function(f) {
  r <- run_pipe(f)
  g <- run_pipe(f, guarded = TRUE)
  flags <- vapply(r$vals[1:7], locate, character(1))
  hit <- which(!is.na(flags))
  data.frame(fault = f,
             r_kind = if (nrow(r$log)) r$log$kind[1] else "silence",
             r_stage = if (nrow(r$log)) r$log$stage[1] else NA_integer_,
             fault_stage = fault_stage[match(f, fault_names)],
             inspected = if (length(hit)) hit[1] else 7L,
             found = length(hit) > 0,
             guard_stage = if (nrow(g$log)) g$log$stage[1] else NA_integer_,
             effect = r$effect, stringsAsFactors = FALSE)
}
scan <- do.call(rbind, lapply(fault_names, scan_one))
scan$gap <- scan$r_stage - scan$fault_stage
scan$shift <- scan$effect - true_effect
print(scan, right = FALSE)
        fault          r_kind  r_stage fault_stage inspected found guard_stage
weights missing column error    8      1           1          TRUE 1          
nights  text nights    warning  2      2           2          TRUE 2          
1       recycled       silence NA      5           7         FALSE 5          
        effect   gap shift    
weights       NA  7         NA
nights  26.49815  0   3.924041
1       36.67264 NA  14.098534
for (f in fault_names) {
  lg <- run_pipe(f)$log
  cat(f, ":", if (nrow(lg)) paste0(lg$kind[1], " at stage ", lg$stage[1], ", ",
                                   lg$text[1]) else "no message at all", "\n")
}
missing column : error at stage 8, contrasts can be applied only to factors with 2 or more levels 
text nights : warning at stage 2, NAs introduced by coercion 
recycled : no message at all 
round(c(stages_between_message_and_fault_missing_column = scan$gap[1],
        stages_between_message_and_fault_text_nights = scan$gap[2],
        intermediates_inspected_missing_column = scan$inspected[1],
        intermediates_inspected_text_nights = scan$inspected[2],
        intermediates_inspected_recycled = scan$inspected[3],
        faults_a_scan_of_the_intermediates_finds = sum(scan$found),
        reported_effect_text_nights_percent = scan$effect[2],
        reported_effect_recycled_percent = scan$effect[3],
        shift_text_nights_points = scan$shift[2],
        shift_recycled_points = scan$shift[3]), 4)
stages_between_message_and_fault_missing_column 
                                         7.0000 
   stages_between_message_and_fault_text_nights 
                                         0.0000 
         intermediates_inspected_missing_column 
                                         1.0000 
            intermediates_inspected_text_nights 
                                         2.0000 
               intermediates_inspected_recycled 
                                         7.0000 
       faults_a_scan_of_the_intermediates_finds 
                                         2.0000 
            reported_effect_text_nights_percent 
                                        26.4981 
               reported_effect_recycled_percent 
                                        36.6726 
                       shift_text_nights_points 
                                         3.9240 
                          shift_recycled_points 
                                        14.0985 
fl <- c("Missing column name", "Trap nights as text", "Recycled weights")
row_of <- c(3, 2, 1)
marker <- c("Where the fault is", "Where R's message points",
            "Where a precondition stops the run")
off <- c(0.19, 0, -0.19)
map_df <- data.frame(
  row = rep(row_of, each = 3) + rep(off, 3),
  what = factor(rep(marker, 3), levels = marker),
  stage = c(scan$fault_stage[1], scan$r_stage[1], scan$guard_stage[1],
            scan$fault_stage[2], scan$r_stage[2], scan$guard_stage[2],
            scan$fault_stage[3], NA, scan$guard_stage[3]))
map_df <- map_df[!is.na(map_df$stage), ]
gap_df <- data.frame(x = scan$fault_stage[1], y = row_of[1] + off[1],
                     xend = scan$r_stage[1], yend = row_of[1] + off[2])
note_df <- data.frame(stage = 6.2, row = row_of[3],
                      lab = "R says nothing at all")

ggplot(map_df, aes(stage, row)) +
  geom_segment(data = gap_df, aes(x = x, xend = xend, y = y, yend = yend),
               inherit.aes = FALSE, colour = te_pal$sage, linewidth = 1.4,
               arrow = arrow(length = unit(8, "pt"), type = "closed")) +
  geom_point(aes(shape = what, colour = what), size = 4.4, stroke = 1.3) +
  geom_text(data = note_df, aes(stage, row, label = lab), inherit.aes = FALSE,
            colour = te_pal$clay, size = 3.5, hjust = 0, fontface = "italic") +
  scale_shape_manual(values = c(16, 17, 0), name = NULL) +
  scale_colour_manual(values = c(te_pal$forest, te_pal$clay, te_pal$gold),
                      name = NULL) +
  scale_x_continuous(breaks = 1:8, labels = stage_names, limits = c(0.7, 8.7)) +
  scale_y_continuous(breaks = row_of, labels = fl, limits = c(0.5, 3.5)) +
  labs(x = "Analysis stage", y = NULL,
       title = "Only one of the three faults is reported where it happens") +
  theme_te() +
  theme(legend.position = "top",
        panel.grid.major.y = element_blank(),
        axis.text.x = element_text(size = 8.5),
        axis.text.y = element_text(size = 10, colour = te_pal$ink),
        legend.text = element_text(size = 9))
A marker plot with the eight pipeline stages on the horizontal axis and three faults on the vertical axis. The missing column row has a long arrow running from a fault marker at stage one to R's message marker at stage eight, with a precondition marker back at stage one. The unparseable trap nights row has all three markers stacked at stage two. The recycled weights row has a fault marker and a precondition marker at stage five, and instead of a message marker a note reading R says nothing at all.
Figure 1: Where each of the three faults lives in the eight stage analysis, where R’s own message points, and where a one line precondition stops the run. The arrow runs from the fault to the message, so its length is the distance by which the message misdirects you. The recycled weight row has no message marker because there is no message.

The missing column name is the loudest fault and the least helpful message. R stops at stage 8 with contrasts can be applied only to factors with 2 or more levels, which is a statement about model matrices, and the fault is at stage 1, 7 stages away. Nothing on the way there complains, because a vector of missing values is a perfectly legal thing to divide, multiply and average.

The trap nights column is the polite one. The warning comes from stage 2, which is where the fault is, so the distance is 0. It costs 2 inspections of intermediate values to confirm. The analysis then finishes and reports 26.4981 per cent, which is 3.9240 points above the truth, because three records dropped out and they were not a random three.

The recycled weights get no message, and that is the finding. A scan of the intermediates does not help either. The rule used here is the one a human eye applies when it stares at an object in the console: is anything missing, is anything not finite, is anything negative where it cannot be. That rule flags the first fault at the first intermediate and the second at the second. On the recycled run it flags nothing at all, across every one of the 7 intermediate values, because every one of them is the right length and full of positive finite numbers. Only 2 of the 3 faults are findable by looking. The answer that comes out the far end is 36.6726 per cent against a true 22.5741.

The precondition column is the answer to that, and it is worth noticing what it does to the first fault as well as the third. With a stopifnot at the head of each stage the missing column stops at stage 1 instead of stage 8, and the recycled weights stop at stage 5 instead of never. In all three cases the message arrives where the fault is. The next three sections work out what those checks are worth and what they cost.

The silent one: R recycles a short vector without asking

R’s recycling rule is that an arithmetic operation between vectors of different lengths repeats the shorter one until it is as long as the longer one. It is a genuinely useful rule: counts / 2 and counts - mean(counts) both depend on it. It is also the reason rate * w runs when rate has one element per record and w has one element per site.

The survey has 60 records and 12 sites. Sixty is a multiple of twelve, so R has no complaint to make. The weights simply march through the record vector in a twelve step cycle while the records march through in five step blocks, and the two cycles agree only where they happen to coincide.

rate <- 100 * d$catch / d$nights
correct <- rate * base_w
recycled <- rate * wt$weight

due <- match(d$site, site)
applied <- ((seq_len(nrow(d)) - 1) %% n_site) + 1
align <- data.frame(record = 1:14, site = d$site[1:14],
                    weight_due = wt$weight[due[1:14]],
                    weight_applied = wt$weight[applied[1:14]],
                    right = due[1:14] == applied[1:14])
print(align, right = FALSE)
   record site weight_due weight_applied right
1   1     S01  0.858      0.858           TRUE
2   2     S01  0.858      0.801          FALSE
3   3     S01  0.858      1.087          FALSE
4   4     S01  0.858      0.935          FALSE
5   5     S01  0.858      1.244          FALSE
6   6     S02  0.801      0.917          FALSE
7   7     S02  0.801      1.206          FALSE
8   8     S02  0.801      1.229          FALSE
9   9     S02  0.801      0.762          FALSE
10 10     S02  0.801      1.284          FALSE
11 11     S03  1.087      1.081          FALSE
12 12     S03  1.087      0.868          FALSE
13 13     S03  1.087      0.858          FALSE
14 14     S03  1.087      0.801          FALSE
sm_rec <- tapply(recycled, d$site, mean)
site_err <- 100 * (sm_rec / site_mean - 1)
eff_rec <- analyse(base_recs, wt$weight)

pair_flip <- function(a, b) {
  ij <- which(upper.tri(matrix(0, length(a), length(a))), arr.ind = TRUE)
  100 * mean(sign(a[ij[, 1]] - a[ij[, 2]]) * sign(b[ij[, 1]] - b[ij[, 2]]) < 0)
}

round(c(records = nrow(d),
        records_given_the_right_weight = sum(due == applied),
        sites_moved_more_than_ten_percent = sum(abs(site_err) > 10),
        worst_site_error_percent = max(abs(site_err)),
        grand_mean_error_percent = 100 * (mean(sm_rec) / mean(site_mean) - 1),
        site_pairs = choose(n_site, 2),
        pairs_reversed_percent = pair_flip(site_mean, sm_rec),
        true_effect_percent = true_effect,
        recycled_effect_percent = eff_rec,
        shift_in_points = eff_rec - true_effect,
        relative_inflation_percent = 100 * (eff_rec / true_effect - 1)), 4)
                          records    records_given_the_right_weight 
                          60.0000                            8.0000 
sites_moved_more_than_ten_percent          worst_site_error_percent 
                           9.0000                           45.1890 
         grand_mean_error_percent                        site_pairs 
                           2.8883                           66.0000 
           pairs_reversed_percent               true_effect_percent 
                          33.3333                           22.5741 
          recycled_effect_percent                   shift_in_points 
                          36.6726                           14.0985 
       relative_inflation_percent 
                          62.4545 
print(round(site_err, 2))
   S01    S02    S03    S04    S05    S06    S07    S08    S09    S10    S11 
 16.76  38.94 -11.60  18.45 -21.74   7.56  -7.50 -24.99  45.19 -23.33  -0.74 
   S12 
 17.67 

Of the 60 records, 8 get the weight that belongs to their own site. The other 52 get a weight belonging to some other site, and nothing anywhere in the run says so.

The consequence is not spread evenly and that is what makes it dangerous. The grand mean of the twelve site means is out by 2.8883 per cent, which is inside the noise of anything you would notice by looking at the total. The worst individual site is out by 45.1890 per cent, 9 of the 12 sites move by more than 10 per cent, and 33.3333 per cent of the 66 site pairs come out ranked the wrong way round. The aggregate looks fine because the weights are conserved: every weight is used five times, just against the wrong records, so the sum is nearly preserved while the allocation is destroyed.

The reported grazing effect goes from 22.5741 per cent to 36.6726 per cent, a shift of 14.0985 points and a relative inflation of 62.4545 per cent. The direction survives, which is the worst possible outcome, because a result that changes sign gets questioned and a result that stays in the same direction and grows gets written up.

rec_df <- data.frame(
  site = factor(rep(names(site_mean), 2), levels = rev(names(site_mean))),
  treat = rep(treat, 2),
  value = c(as.numeric(site_mean), as.numeric(sm_rec)),
  version = factor(rep(c("Each site's own weight", "Weights recycled"),
                       each = n_site),
                   levels = c("Each site's own weight", "Weights recycled")))
seg_df <- data.frame(site = factor(names(site_mean),
                                   levels = rev(names(site_mean))),
                     treat = treat, lo = pmin(site_mean, sm_rec),
                     hi = pmax(site_mean, sm_rec))

ggplot(rec_df, aes(value, site)) +
  geom_segment(data = seg_df, aes(x = lo, xend = hi, y = site, yend = site),
               inherit.aes = FALSE, colour = te_pal$line, linewidth = 2.4) +
  geom_point(aes(colour = version, shape = version), size = 3.2, stroke = 1.2) +
  facet_grid(treat ~ ., scales = "free_y", space = "free_y") +
  scale_colour_manual(values = c(te_pal$forest, te_pal$clay), name = NULL) +
  scale_shape_manual(values = c(16, 1), name = NULL) +
  labs(x = "Mean corrected catch per hundred trap nights", y = NULL,
       title = "Recycled weights move nine of the twelve site means by over ten per cent") +
  theme_te() +
  theme(legend.position = "top",
        strip.text = element_text(colour = te_pal$ink, face = "bold", size = 9))
A dot plot with mean corrected catch rate per hundred trap nights on the horizontal axis and twelve site codes on the vertical axis, split into a grazed panel and an ungrazed panel. Each site has two points joined by a grey bar: a dark green point for the correct weight and a red point for the recycled weight. Most bars are long, and the red points scatter to both sides of the green ones without a consistent direction.
Figure 2: Mean corrected catch rate for each of the twelve sites, computed with each site’s own detection weight and with the weight vector recycled across the records. The two panels separate the grazed from the ungrazed sites; the grey bar between the two points is the error at that site.

Now the near miss. Lose one trap, so that 59 records are left, and 59 is not a multiple of 12. R then warns: longer object length is not a multiple of shorter object length. The identical mistake, made against slightly messier data, announces itself.

ragged <- base_recs[-7, ]
caught <- character(0)
rag_rec <- withCallingHandlers(
  analyse(ragged, wt$weight),
  warning = function(w) {
    caught <<- c(caught, conditionMessage(w))
    invokeRestart("muffleWarning")
  })
rag_true <- analyse(ragged, as.numeric(w[ragged$site]))
rag_due <- match(ragged$site, site)
rag_applied <- ((seq_len(nrow(ragged)) - 1) %% n_site) + 1

cat("warning:", caught, "\n")
warning: longer object length is not a multiple of shorter object length 
round(c(records_after_the_loss = nrow(ragged),
        is_a_multiple_of_twelve = nrow(d) %% n_site,
        remainder_after_the_loss = nrow(ragged) %% n_site,
        records_given_the_right_weight = sum(rag_due == rag_applied),
        true_effect_on_these_records = rag_true,
        recycled_effect_on_these_records = rag_rec,
        shift_in_points = rag_rec - rag_true,
        warnings_raised = length(caught)), 4)
          records_after_the_loss          is_a_multiple_of_twelve 
                         59.0000                           0.0000 
        remainder_after_the_loss   records_given_the_right_weight 
                         11.0000                           4.0000 
    true_effect_on_these_records recycled_effect_on_these_records 
                         22.3777                          35.1969 
                 shift_in_points                  warnings_raised 
                         12.8193                           1.0000 

The ragged version does the same kind of damage: 4 of the 59 records get the right weight and the reported effect moves from 22.3777 to 35.1969, a shift of 12.8193 points. It also raises exactly 1 warning, and the balanced version raises none.

That is worth stating plainly, because it inverts the usual advice. The warning is a statement about arithmetic, not about correctness. It appears when the lengths do not divide, and a balanced design with an equal number of visits at every site is precisely the case where they do. Run the same bug on a complete, tidy, well-organised data set and R is silent. Run it on a data set with a flooded trap in it and R speaks up. The better your fieldwork, the quieter the mistake.

The fix is to stop relying on position and carry the key. w[d$site] is a lookup by site code, and it fails loudly with a subscript error if a code is missing rather than quietly reusing whatever is next in line. merge on the code column does the same thing at the data frame level. When you have written the lookup, one line confirms it worked:

wv <- w[d$site]
round(c(length_matches_records = as.numeric(length(wv) == nrow(d)),
        every_record_has_a_weight = as.numeric(!anyNA(wv)),
        every_record_has_its_own_sites_weight =
          as.numeric(all(wv == w[d$site])),
        weights_that_differ_from_the_recycled_version =
          sum(as.numeric(wv) != wt$weight[applied])), 4)
                       length_matches_records 
                                            1 
                    every_record_has_a_weight 
                                            1 
        every_record_has_its_own_sites_weight 
                                            1 
weights_that_differ_from_the_recycled_version 
                                           52 

A numeric column that arrived as a factor

Thirty sites on an elevation gradient from the valley floor to the ridge, with species richness falling as you climb. The elevation column came off a spreadsheet as text rather than as numbers, and somewhere in the import it became a factor. The analyst writes as.numeric(elevation) and fits the regression.

as.numeric on a factor does not return the values. It returns the integer codes: the position of each value in the level table. The level table is sorted, and it is sorted as text, so the order it produces is the dictionary order of the printed numbers rather than their numeric order.

set.seed(20260811)
n_elev <- 30
elev <- round(runif(n_elev, 60, 1450))
rich <- round(26 - 0.011 * elev + rnorm(n_elev, 0, 1.8))
sheet <- as.character(elev)
f <- factor(sheet)
idx <- as.numeric(f)

cat("first eight levels in level order:", levels(f)[1:8], "\n")
first eight levels in level order: 1032 1048 1071 1073 1088 1115 1130 1135 
cat("the same eight elevations sorted numerically:",
    sort(elev)[1:8], "\n")
the same eight elevations sorted numerically: 69 119 295 329 342 390 463 580 
fit_stats <- function(fit) {
  cf <- summary(fit)$coefficients
  c(slope = cf[2, 1], p_value = cf[2, 4], r_squared = summary(fit)$r.squared)
}
fit_true <- lm(rich ~ elev)
fit_idx  <- lm(rich ~ idx)

round(c(sites = n_elev, levels = nlevels(f),
        lowest_site_m = min(elev), highest_site_m = max(elev),
        gradient_span_m = diff(range(elev)),
        spearman_index_against_elevation = cor(idx, elev, method = "spearman"),
        index_runs_from = min(idx), index_runs_to = max(idx)), 4)
                           sites                           levels 
                         30.0000                          28.0000 
                   lowest_site_m                   highest_site_m 
                         69.0000                        1438.0000 
                 gradient_span_m spearman_index_against_elevation 
                       1369.0000                          -0.4796 
                 index_runs_from                    index_runs_to 
                          1.0000                          28.0000 
round(c(true_slope_species_per_metre = fit_stats(fit_true)[1],
        true_p_value = fit_stats(fit_true)[2],
        true_r_squared = fit_stats(fit_true)[3],
        index_slope_species_per_level = fit_stats(fit_idx)[1],
        index_p_value = fit_stats(fit_idx)[2],
        index_r_squared = fit_stats(fit_idx)[3]), 6)
 true_slope_species_per_metre.slope                true_p_value.p_value 
                          -0.010679                            0.000000 
           true_r_squared.r_squared index_slope_species_per_level.slope 
                           0.846339                            0.265723 
              index_p_value.p_value           index_r_squared.r_squared 
                           0.009291                            0.218003 
round(c(true_richness_change_over_the_gradient =
          coef(fit_true)[[2]] * diff(range(elev)),
        index_richness_change_over_the_gradient =
          coef(fit_idx)[[2]] * (nlevels(f) - 1),
        recovered_by_as_numeric_as_character =
          max(abs(as.numeric(as.character(f)) - elev))), 4)
 true_richness_change_over_the_gradient index_richness_change_over_the_gradient 
                               -14.6197                                  7.1745 
   recovered_by_as_numeric_as_character 
                                 0.0000 

The level order is the first surprise. The eight lowest levels are all four digit elevations starting with a one, because "1032" sorts before "69" for the same reason "apple" sorts before "zebra". The rank correlation between the level index and the actual elevation is -0.4796, so the covariate is not merely rescaled, it is reversed and shuffled.

The regression says what you would expect from that. The true fit gives a slope of -0.010679 species per metre with a p value that rounds to 0 at six decimal places, which over the 1369 metres between the lowest and the highest site is a loss of 14.6197 species. The fit on the level index gives a slope of +0.265723 species per level with a p value of 0.009291, which is significant at any conventional threshold and has the wrong sign. Read as a gradient it claims a gain of 7.1745 species from the valley to the ridge. The elevation effect in these data is real, large and downhill, and the broken analysis reports a significant uphill increase.

Now the version that should worry you more. Restrict the analysis to the sites between 100 and 999 metres. Every elevation is now three characters wide, so the dictionary order of the strings is the numeric order of the values, and the level index becomes the rank of the elevation. Nothing is shuffled any more.

k <- elev >= 100 & elev <= 999
f2 <- factor(as.character(elev[k]))
idx2 <- as.numeric(f2)
fit_t2 <- lm(rich[k] ~ elev[k])
fit_i2 <- lm(rich[k] ~ idx2)

round(c(sites_in_the_window = sum(k),
        spearman_index_against_elevation = cor(idx2, elev[k], method = "spearman"),
        lowest_m = min(elev[k]), highest_m = max(elev[k])), 4)
             sites_in_the_window spearman_index_against_elevation 
                              14                                1 
                        lowest_m                        highest_m 
                             119                              979 
round(c(true_slope_species_per_metre = fit_stats(fit_t2)[1],
        true_p_value = fit_stats(fit_t2)[2],
        true_r_squared = fit_stats(fit_t2)[3],
        index_slope_species_per_level = fit_stats(fit_i2)[1],
        index_p_value = fit_stats(fit_i2)[2],
        index_r_squared = fit_stats(fit_i2)[3]), 6)
 true_slope_species_per_metre.slope                true_p_value.p_value 
                          -0.009781                            0.000039 
           true_r_squared.r_squared index_slope_species_per_level.slope 
                           0.768425                           -0.595604 
              index_p_value.p_value           index_r_squared.r_squared 
                           0.000025                            0.784082 
round(c(true_richness_change = coef(fit_t2)[[2]] * diff(range(elev[k])),
        index_richness_change = coef(fit_i2)[[2]] * (nlevels(f2) - 1),
        error_in_the_reported_gradient_percent =
          100 * (coef(fit_i2)[[2]] * (nlevels(f2) - 1) /
                   (coef(fit_t2)[[2]] * diff(range(elev[k]))) - 1)), 4)
                  true_richness_change                  index_richness_change 
                               -8.4117                                -7.7429 
error_in_the_reported_gradient_percent 
                               -7.9511 
pn <- c("Elevation in metres\n(the column parsed)",
        "Level index, all sites\n(mixed digit widths)",
        "Level index, 100 to 999 m\n(equal digit widths)")
fac_df <- rbind(
  data.frame(x = elev, y = rich, panel = pn[1]),
  data.frame(x = idx, y = rich, panel = pn[2]),
  data.frame(x = idx2, y = rich[k], panel = pn[3]))
fac_df$panel <- factor(fac_df$panel, levels = pn)
fac_fit <- data.frame(
  intercept = c(coef(fit_true)[[1]], coef(fit_idx)[[1]], coef(fit_i2)[[1]]),
  slope = c(coef(fit_true)[[2]], coef(fit_idx)[[2]], coef(fit_i2)[[2]]),
  panel = factor(pn, levels = pn))

inner_breaks <- function(lims) {
  pad <- 0.08 * diff(lims)
  b <- pretty(lims, n = 5)
  b[b > lims[1] + pad & b < lims[2] - pad]
}

ggplot(fac_df, aes(x, y)) +
  geom_abline(data = fac_fit, aes(intercept = intercept, slope = slope),
              colour = te_pal$clay, linewidth = 1) +
  geom_point(colour = te_pal$forest, size = 2.4, alpha = 0.9) +
  facet_wrap(~panel, scales = "free_x") +
  scale_x_continuous(breaks = inner_breaks, expand = expansion(mult = 0.07)) +
  labs(x = "Covariate as the model received it", y = "Species richness",
       title = "The level index reverses the gradient, then hides inside a tidy window") +
  theme_te() +
  theme(panel.spacing.x = unit(1.4, "lines"),
        panel.spacing.y = unit(16, "pt"),
        strip.text = element_text(colour = te_pal$ink, face = "bold", size = 9))
Three scatter panels of species richness against a covariate, each with a fitted straight line. The left panel, elevation in metres, has points falling clearly from left to right. The middle panel, the level index over all sites, has a scattered cloud with a line rising gently from left to right. The right panel, the level index over the equal width window, falls from left to right and looks as tidy as the left panel.
Figure 3: Species richness against three codings of the same elevation column. On the left the column has been parsed to metres. In the middle it is the factor level index over all thirty sites, where the dictionary order of the printed numbers reverses the gradient. On the right it is the level index over the fourteen sites between one hundred and nine hundred and ninety nine metres, where the level order and the numeric order agree.

In that window the rank correlation between the index and the elevation is exactly 1. The true fit over the 14 sites gives -0.009781 species per metre with an r squared of 0.768425, and the index fit gives -0.595604 species per level with an r squared of 0.784082 and a smaller p value than the correct model. Every diagnostic you would look at says the broken model is the better one. The reported gradient, expressed as the richness change across the sampled range, is 7.7429 species against a true 8.4117, which is out by 7.9511 per cent.

That is the shape of the whole problem in one number. When the factor coding scrambles the covariate the analysis falls apart visibly and someone notices. When it does not scramble it, the analysis looks better than the correct one and reports a gradient that is wrong by eight per cent in units nobody can interpret, and there is nothing in the output to look at. The cure is one character class: as.numeric(as.character(f)) recovers the elevations exactly, with a maximum error of 0. The check is one line, stopifnot(is.numeric(elev)), placed where the data enter the analysis rather than where the model is fitted.

What preconditions cost, and what tryCatch does not do

Ten faults, all of them things that happen to real trap data or to real scripts. Three have already appeared above. Each one is presented to the same analyse function, once bare and once behind a block of preconditions, and the questions are the same each time: does R say anything, does the guard catch it, and what does the reported grazing effect become.

check_inputs <- function(recs, wvec) {
  stopifnot(nrow(recs) > 0,
            is.numeric(recs$nights), is.numeric(recs$catch),
            all(is.finite(recs$nights)), all(is.finite(recs$catch)),
            all(recs$nights > 0), all(recs$catch >= 0),
            is.numeric(wvec), length(wvec) == nrow(recs),
            all(is.finite(wvec)), all(wvec > 0.4 & wvec < 2.5),
            anyDuplicated(recs[, c("site", "visit")]) == 0)
}
guarded <- function(recs, wvec) {
  check_inputs(recs, wvec)
  analyse(recs, wvec)
}
watched <- function(recs, wvec) {
  withCallingHandlers(
    tryCatch(analyse(recs, wvec),
             error = function(e) NA_real_),
    warning = function(w) invokeRestart("muffleWarning"))
}
round(c(guard_lines = length(deparse(check_inputs)),
        guard_conditions = 12,
        analysis_lines = length(deparse(analyse)),
        trycatch_lines = length(deparse(watched))), 4)
     guard_lines guard_conditions   analysis_lines   trycatch_lines 
               9               12               10                5 

The faults. Missing weights and both recycling cases come from the sections above. The second one is what the trap nights column looks like after the parse: as.numeric has already turned the three not set cells into missing values and already raised its warning, upstream, in the reading step. What reaches analyse is a numeric column with three holes in it, and a hole is not a condition, so the run below is silent even though the earlier section showed that same column warning at stage 2. The warning belongs to the parse, not to the analysis, and the two are usually in different files. The rest are a factor where a number was expected, a trap that was never set so the night count is zero, a catch typed with a minus sign, a visit entered twice when two field books were merged, a detection weight recorded as zero, and the last one, which is the subject of the final section: a weight vector of exactly the right length whose entries belong to the wrong sites.

mk <- function(name, recs = base_recs, wvec = base_w)
  list(name = name, recs = recs, wvec = wvec)

r2 <- base_recs; r2$nights[bad_rows] <- NA
r5 <- base_recs; r5$nights <- factor(r5$nights)
r6 <- base_recs; r6$nights[c(3, 22, 41, 58)] <- 0
r7 <- base_recs; r7$catch[c(12, 39)] <- -r7$catch[c(12, 39)]
r8 <- rbind(base_recs, base_recs[c(5, 31), ])
w9 <- base_w; w9[d$site == "S05"] <- 0
wt10 <- wt[order(wt$weight), ]

faults <- list(
  mk("weight lookup returns nothing", wvec = rep(NA_real_, nrow(d))),
  mk("trap nights already missing", r2),
  mk("weights recycled, balanced", wvec = wt$weight),
  mk("weights recycled, one record lost", base_recs[-7, ], wt$weight),
  mk("trap nights arrive as a factor", r5),
  mk("trap never set, zero nights", r6),
  mk("a catch typed with a minus sign", r7),
  mk("one visit entered twice", r8, as.numeric(w[r8$site])),
  mk("a detection weight recorded as zero", wvec = w9),
  mk("weights in the wrong site order", wvec = rep(wt10$weight, each = n_visit)))

observe <- function(expr) {
  said <- character(0)
  val <- withCallingHandlers(
    tryCatch(expr, error = function(e) {
      said <<- c(said, paste("error:", conditionMessage(e)))
      NA_real_
    }),
    warning = function(w) {
      said <<- c(said, paste("warning:", conditionMessage(w)))
      invokeRestart("muffleWarning")
    })
  list(value = val, said = said)
}

res <- do.call(rbind, lapply(faults, function(f) {
  bare <- observe(analyse(f$recs, f$wvec))
  keep <- observe(guarded(f$recs, f$wvec))
  data.frame(fault = f$name,
             r_speaks = length(bare$said) > 0,
             answer = bare$value,
             guard = length(keep$said) > 0,
             message = if (length(bare$said)) substr(bare$said[1], 1, 46) else "silence",
             stringsAsFactors = FALSE)
}))
res$shift <- res$answer - true_effect
res$trycatch <- res$r_speaks
print(res[, c("fault", "r_speaks", "guard", "answer", "shift")], right = FALSE)
   fault                               r_speaks guard answer     shift      
1  weight lookup returns nothing        TRUE     TRUE         NA          NA
2  trap nights already missing         FALSE     TRUE         NA          NA
3  weights recycled, balanced          FALSE     TRUE 36.6726381  14.0985338
4  weights recycled, one record lost    TRUE     TRUE 35.1969365  12.6228322
5  trap nights arrive as a factor       TRUE     TRUE         NA          NA
6  trap never set, zero nights          TRUE     TRUE         NA          NA
7  a catch typed with a minus sign     FALSE     TRUE 28.2667902   5.6926859
8  one visit entered twice             FALSE     TRUE 22.6918116   0.1177074
9  a detection weight recorded as zero FALSE     TRUE  2.8776891 -19.6964152
10 weights in the wrong site order     FALSE    FALSE -0.7839586 -23.3580629
cat("\n")
for (i in seq_len(nrow(res))) cat(sprintf("%2d %s\n", i, res$message[i]))
 1 error: contrasts can be applied only to factor
 2 silence
 3 silence
 4 warning: longer object length is not a multipl
 5 warning: '/' not meaningful for factors
 6 error: NA/NaN/Inf in 'y'
 7 silence
 8 silence
 9 silence
10 silence
silent <- !res$r_speaks
round(c(faults = nrow(res),
        r_speaks_on = sum(res$r_speaks),
        trycatch_reports = sum(res$trycatch),
        preconditions_catch = sum(res$guard),
        silent_faults = sum(silent),
        silent_and_still_returning_a_number =
          sum(silent & is.finite(res$answer)),
        worst_silent_shift_points = max(abs(res$shift[silent]), na.rm = TRUE),
        faults_nothing_catches = sum(!res$r_speaks & !res$guard)), 4)
                             faults                         r_speaks_on 
                            10.0000                              4.0000 
                   trycatch_reports                 preconditions_catch 
                             4.0000                              9.0000 
                      silent_faults silent_and_still_returning_a_number 
                             6.0000                              5.0000 
          worst_silent_shift_points              faults_nothing_catches 
                            23.3581                              1.0000 
ord <- order(ifelse(is.na(res$shift), -1, abs(res$shift)))
def_df <- data.frame(
  fault = factor(rep(res$fault, 3), levels = res$fault[ord]),
  defence = factor(rep(c("R itself", "tryCatch\nwrapper",
                         "12 precondition\nclauses"), each = nrow(res)),
                   levels = c("R itself", "tryCatch\nwrapper",
                              "12 precondition\nclauses")),
  caught = factor(ifelse(c(res$r_speaks, res$trycatch, res$guard),
                         "Caught", "Not caught"),
                  levels = c("Caught", "Not caught")))

ggplot(def_df, aes(defence, fault, fill = caught)) +
  geom_tile(colour = te_pal$paper, linewidth = 1.6) +
  scale_fill_manual(values = c(te_pal$forest, te_pal$line), name = NULL) +
  labs(x = NULL, y = NULL,
       title = "Preconditions catch nine of ten; one fault passes every check") +
  theme_te() +
  theme(legend.position = "top",
        panel.grid.major = element_blank(),
        axis.text.y = element_text(size = 9),
        axis.text.x = element_text(size = 9, face = "bold"))
A grid of coloured squares with ten faults on the vertical axis and three defences on the horizontal axis. The R itself and tryCatch columns are identical, with four dark squares and six pale ones. The preconditions column is dark for nine of the ten rows. The single pale square in that column is the top row, weights in the wrong site order.
Figure 4: Ten faults against three defences. R by itself notices the four that raise a condition; the tryCatch wrapper reports exactly the same four, because a handler can only handle something that was signalled; the twelve precondition clauses catch nine. The faults are ordered by how far they move the reported grazing effect, with the ones that return no usable number at all at the bottom.

R speaks on 4 of the 10 faults. The other 6 pass in silence, and 5 of those 6 return a number that gets written down. The worst of the silent ones moves the reported effect by 23.3581 points, which is more than the effect itself.

The tryCatch column is identical to the R column, and that is the point of putting it in the figure. A condition handler handles conditions. If nothing is signalled there is nothing to handle, so tryCatch around the whole analysis converts 4 errors into 4 recorded messages and does not see the other 6 at all. It is the right tool for a different job: carrying on through a loop over a hundred sites when one of them fails, or attaching a better message to a failure you can predict. It is not a way of finding out whether the answer is right.

The 12 precondition clauses catch 9 of the 10, and the tryCatch wrapper costs 5 lines to duplicate what R already did. The guard is not free: check_inputs deparses to 9 lines against 10 for the analysis it protects, so the checking is very nearly as long as the thing checked. It is still cheap, because it is written once and then runs on every data set the function ever sees, and because every clause in it is a sentence an ecologist can read. Trap nights are a positive number. Catches are not negative. There is one weight per record. No site was visited twice on the same visit number. None of that is about programming, and all of it is a claim about how a pitfall trap survey works.

Two habits go with the guards. The first is to put them at the top of a function rather than in a script, so they travel with the code and run on every data set it ever sees; a check inside a script runs once, on the data that were in memory that afternoon. The second is that a guard is cheap only if it is specific: stopifnot(all(recs$nights > 0)) names the thing that failed, whereas a general assertion that the data are valid tells you nothing you did not already know.

For the moment when a guard does fire, R’s interactive tools are what you want next, and they are in this block rather than in a running chunk because they need a session with a person sitting at it:

# not run here: every one of these needs an interactive session
traceback()                 # the call stack of the error that just happened
options(error = recover)    # drop into a frame browser when anything errors
debug(analyse)              # step through the next call to analyse
undebug(analyse)
browser()                   # paste this into a function to stop at that line
options(warn = 2)           # turn every warning into an error, with a traceback

options(warn = 2) is the one worth knowing about for the recycling case, and it is also the one that explains why the recycling case is so bad. Turning warnings into errors would have caught the ragged version of the bug at once and would still have done nothing at all about the balanced one.

The honest limit

A precondition catches what it states. Nothing else. The tenth fault is the demonstration, and it is not an artificial one: the weight table is sorted by weight rather than by site code, and the analyst attaches it with rep(weight, each = 5) instead of matching on the code. That is a reasonable thing to type when the two tables started out in the same order.

bad_w <- rep(wt10$weight, each = n_visit)
bad_out <- observe(guarded(base_recs, bad_w))
bad_effect <- analyse(base_recs, bad_w)
bad_sm <- tapply(rate * bad_w, d$site, mean)

clause <- c(rows = nrow(base_recs) > 0,
            nights_numeric = is.numeric(base_recs$nights),
            catch_numeric = is.numeric(base_recs$catch),
            nights_finite = all(is.finite(base_recs$nights)),
            catch_finite = all(is.finite(base_recs$catch)),
            nights_positive = all(base_recs$nights > 0),
            catch_not_negative = all(base_recs$catch >= 0),
            weights_numeric = is.numeric(bad_w),
            one_weight_per_record = length(bad_w) == nrow(base_recs),
            weights_finite = all(is.finite(bad_w)),
            weights_in_range = all(bad_w > 0.4 & bad_w < 2.5),
            no_duplicate_visits = anyDuplicated(base_recs[, c("site", "visit")]) == 0)
print(clause)
                 rows        nights_numeric         catch_numeric 
                 TRUE                  TRUE                  TRUE 
        nights_finite          catch_finite       nights_positive 
                 TRUE                  TRUE                  TRUE 
   catch_not_negative       weights_numeric one_weight_per_record 
                 TRUE                  TRUE                  TRUE 
       weights_finite      weights_in_range   no_duplicate_visits 
                 TRUE                  TRUE                  TRUE 
round(c(precondition_clauses = length(clause),
        clauses_that_pass = sum(clause),
        messages_from_the_guarded_run = length(bad_out$said),
        weights_used = length(unique(bad_w)),
        records_given_the_right_weight = sum(bad_w == base_w),
        true_effect_percent = true_effect,
        reported_effect_percent = bad_effect,
        shift_in_points = bad_effect - true_effect,
        pairs_reversed_percent = pair_flip(site_mean, bad_sm),
        the_check_that_would_have_caught_it =
          as.numeric(all(bad_w == as.numeric(w[base_recs$site])))), 4)
               precondition_clauses                   clauses_that_pass 
                            12.0000                             12.0000 
      messages_from_the_guarded_run                        weights_used 
                             0.0000                             12.0000 
     records_given_the_right_weight                 true_effect_percent 
                             5.0000                             22.5741 
            reported_effect_percent                     shift_in_points 
                            -0.7840                            -23.3581 
             pairs_reversed_percent the_check_that_would_have_caught_it 
                            37.8788                              0.0000 

All 12 clauses pass. The guarded run produces 0 messages. The weight vector has one entry per record, all 12 distinct weights are present, every value is finite and inside the plausible range, and 5 of the 60 records happen to receive the weight they should. The reported grazing effect comes out at -0.7840 per cent against a true 22.5741, a shift of 23.3581 points, and this time the sign does go. An analysis that should report a 22.6 per cent grazing effect reports none.

The check that would have caught it is in the last line of that chunk, and it is not a check about types or lengths. It compares the weight actually used against the weight the site code says it should be, which means it has to know what the join key is. That is the general shape of the limit: shape checks are cheap and catch a lot, and the errors they cannot see are the ones where the shape is right and the correspondence is wrong. Alignment between two tables is the commonest of those in ecology, because ecological data always arrive as several tables that have to be put next to each other, and the standard advice about it is worth repeating: never line tables up by position when a key exists, and check the result of the join rather than trusting it.

Two smaller limits belong here too. The precondition set was written after the ten faults were known, so 9 out of 10 is an optimistic figure in the same way that a test written after the bug is an optimistic test; the same argument appears in testing your analysis code, where the honest way to score a suite is to break the code on purpose. And every measurement in this post is one simulated survey with one seed. The mechanisms are exact and would repeat: as.numeric on a factor always returns the level index, and 60 always divides by 12. The sizes of the shifts are one draw from a distribution and would move if the survey were resampled.

Where to go next

The cheapest habit from this post is the smallest one: when you attach a vector to a table, check its length against the number of rows on the very next line, and when you read a column that should be a number, check that it is one. Reading field data into R is where those two checks belong, at the boundary between the spreadsheet and the analysis, and joining ecological tables without losing zeros is the post for the alignment problem that the last section could not solve.

Once the guards are in place they want to become assertions that run on their own. Testing your analysis code turns a precondition into a test with inputs chosen to break it, and checking an analysis script does the same thing one level up, asking whether the whole file runs from a clean session and reports the numbers it actually computed.

References

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

Wilson G, Aruliah DA, Brown CT, Chue Hong NP, Davis M, Guy RT, Haddock SHD, Huff KD, Mitchell IM, Plumbley MD, Waugh B, White EP, Wilson P 2014 PLoS Biology 12(1):e1001745 (10.1371/journal.pbio.1001745)

Wickham H 2014 Journal of Statistical Software 59(10):1-23 (10.18637/jss.v059.i10)

Zuur AF, Ieno EN, Elphick CS 2010 Methods in Ecology and Evolution 1(1):3-14 (10.1111/j.2041-210X.2009.00001.x)

Ziemann M, Eren Y, El-Osta A 2016 Genome Biology 17:177 (10.1186/s13059-016-1044-7)

Chambers JM 2008 Software for Data Analysis: Programming with R, Springer (ISBN 978-0-387-75935-7)

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.