Splicing a monitoring series

R
monitoring
time series
trend
ecology tutorial
A method change part-way through a monitoring series manufactures a trend. Measuring the bias, the exchange rate per decade, and why no diagnostic fires.
Author

Tidy Ecology

Published

2026-07-31

A moth recording network runs twenty-four light traps, one per site, emptied every morning through the flight season, with the season’s catch of a common noctuid summed into one annual figure per trap. The series is twenty-eight years long. In its fifteenth year the mercury vapour bulbs were replaced with low-wattage actinic tubes across the whole network in a single winter, because the old bulbs had stopped being manufactured. Nothing else changed: the same sites, the same operators, the same season, the same species. The catches went on being written into the same column of the same spreadsheet.

A light trap does not sample a fixed volume of air. Merckx and Slade (2014) released marked moths at known distances from an actinic trap and recovered attraction radii differing by more than a factor of two between noctuids and the larger erebids, so what a trap draws in depends on the lamp as much as on the moths. Replacing the lamp was a change of instrument in the middle of a measurement series, and it left a step in the numbers. More than a decade later the network is asked whether the species is declining, and the analysis fits one line through twenty-eight annual values. Methodological change of exactly this kind is one of the reasons Didham et al (2020) give for treating published insect decline rates with care, and the wider problem of separating a signal of change from a change in how the recording was done runs through Isaac et al (2014).

The result of that fit is the subject of this post. A step at the seam reads as trend, the interval around the trend knows nothing about the seam, and none of the checks an analyst would ordinarily run has any purchase on it. The bias is not noise: the residuals are fine, the interval is narrow, the model is confident, and the answer is wrong by a quantity that can be worked out exactly from the size of the step and the length of the series.

This sits next to three posts and repeats none of them. Checking a monitoring design has a check on observer drift, but that drift is smooth: detection slipping by a fixed percentage every year, which adds a slope to a slope. A step is a different object and it behaves differently. That post also names the remedy in a single clause, that a subset of sites can be surveyed by a second method for calibration, and then carries none of it out: no code, no numbers, no idea what the calibration is worth. This post starts from that gap. Errors-in-variables and Deming regression supplies the calibration tool, but on a cross-sectional scatter of one method against another, with no time axis, no trend to protect and no question about how long the two methods have to run side by side. And segmented regression fits a break in a response along a covariate and treats the location of the break as the finding; here the break is in time, its year is written in the network’s protocol file, and the question is not where it is but whether anything in the data can see it at all.

Five measurements follow, all on synthetic counts with the generating trend known, so every error is a distance from a value that was set rather than an argument about what should have happened.

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"),
          axis.text = element_text(colour = "#2c3a31"),
          legend.position = "bottom")
}

A network that changed its traps

The generating model is deliberately plain, because the point is what a plain analysis does to it. Each trap has its own catchability, every year has a weather effect shared across the network, each trap-year has its own departure from that, and the catch is Poisson around the resulting expectation. The population itself does not change: the true log-scale trend is set to zero. Then the new lamp multiplies every subsequent catch by a fixed factor.

The annual network index is the log of the mean catch per trap. Collapsing the twenty-four traps to one figure a year before fitting the trend is the standard move, and it is also the honest one: the weather effect is shared across traps, so treating trap-years as independent replicates for a trend test would put the error term at the wrong scale.

n_year <- 28
yr <- 1994:2021
t_c <- yr - mean(yr)
n_trap <- 24
sw_year <- 2008
new_era <- as.integer(yr >= sw_year)
k_old <- sum(new_era == 0)
beta_true <- 0
sd_trap <- 0.45
sd_year <- 0.22
sd_ty <- 0.35
mu_log <- log(60)
f_meth <- 0.72
step_log <- log(f_meth)

sim_series <- function(seed, step_sz = step_log, beta = beta_true) {
  set.seed(seed)
  a_i <- rnorm(n_trap, mu_log, sd_trap)
  u_t <- rnorm(n_year, 0, sd_year)
  e_it <- matrix(rnorm(n_trap * n_year, 0, sd_ty), n_trap, n_year)
  eta <- outer(a_i, rep(1, n_year)) +
    outer(rep(1, n_trap), beta * t_c + u_t) + e_it
  cnt_cl <- matrix(rpois(n_trap * n_year, exp(eta)), n_trap, n_year)
  cnt_sp <- matrix(rpois(n_trap * n_year,
                         exp(eta + rep(step_sz * new_era, each = n_trap))),
                   n_trap, n_year)
  list(clean = log(colMeans(cnt_cl)), spliced = log(colMeans(cnt_sp)))
}

print(c(years = n_year, traps = n_trap, changeover = sw_year,
        years_on_old_lamp = k_old, years_on_new_lamp = n_year - k_old,
        true_trend = beta_true, method_factor = f_meth))
            years             traps        changeover years_on_old_lamp 
            28.00             24.00           2008.00             14.00 
years_on_new_lamp        true_trend     method_factor 
            14.00              0.00              0.72 
print(round(c(step_log_scale = step_log,
              step_as_pct = 100 * (f_meth - 1),
              mean_catch_old_lamp = exp(mu_log)), 4))
     step_log_scale         step_as_pct mean_catch_old_lamp 
            -0.3285            -28.0000             60.0000 

The new lamp catches 28 per cent fewer moths than the old one, which on the log scale is a step of -0.3285. That factor is not extreme for a lamp change; it is the sort of difference a side-by-side trial would report and then nobody would apply.

d1 <- sim_series(20260731)

fit_tr <- function(idx) {
  m <- lm(idx ~ t_c)
  ci <- confint(m)
  c(slope = unname(coef(m)[2]),
    se = unname(summary(m)$coefficients[2, 2]),
    lo = ci[2, 1], hi = ci[2, 2],
    p = unname(summary(m)$coefficients[2, 4]),
    resid_sd = summary(m)$sigma)
}
one <- rbind(as_recorded = fit_tr(d1$clean), spliced = fit_tr(d1$spliced))
print(round(one, 5))
               slope      se       lo       hi       p resid_sd
as_recorded -0.00873 0.00495 -0.01891  0.00146 0.08993  0.21179
spliced     -0.02685 0.00583 -0.03883 -0.01488 0.00009  0.24910
print(round(cbind(per_decade = 10 * one[, "slope"],
                  lo_decade = 10 * one[, "lo"],
                  hi_decade = 10 * one[, "hi"],
                  pct_decade = 100 * (exp(10 * one[, "slope"]) - 1)), 4))
            per_decade lo_decade hi_decade pct_decade
as_recorded    -0.0873   -0.1891    0.0146    -8.3576
spliced        -0.2685   -0.3883   -0.1488   -23.5509

On the series as recorded by one lamp throughout, the fitted trend is -0.0873 per decade on the log scale, interval -0.1891 to 0.0146, which contains zero, as it should. Splice the lamp change into the same underlying population and the fitted trend is -0.2685 per decade, interval -0.3883 to -0.1488. In the units a report would use, that is a decline of 23.551 per cent per decade with a p value of 0.000095, for a population that did not move.

A line chart over twenty-eight years. Round dots joined by a thin grey line scatter around four and a fifth on the vertical axis for the first half of the panel, then drop at a dashed vertical line just before 2008 and scatter around three and seven tenths for the rest. A straight red line drawn through all of the dots slopes down steadily from left to right, passing above the early dots and below the late ones. Two dark green segments, one over each half, are horizontal and each sits in the middle of its own cloud of dots, roughly half a unit apart.
Figure 1: One simulated network index over twenty-eight years. The population is stable throughout; the dashed vertical line marks the winter the lamps were replaced, after which every catch is multiplied by a fixed factor. The single fitted line through all twenty-eight points slopes down. The two-segment fit, which allows a level difference between the eras, is flat in both halves.

The bias is exact, and the interval does not notice it

One series proves nothing, so run two thousand. Each replicate redraws the trap effects, the weather, the trap-year departures and the Poisson counts, and fits the same one-line model to the series with the lamp change and to the series without it. Alongside the estimate, the loop stores the standard error, the residual standard deviation, the lag-one residual autocorrelation and a normality test, because those are the things an analyst would look at.

diag_of <- function(idx) {
  m <- lm(idx ~ t_c)
  r <- residuals(m)
  ci <- confint(m)
  c(slope = unname(coef(m)[2]),
    se = unname(summary(m)$coefficients[2, 2]),
    excl0 = as.numeric(ci[2, 1] * ci[2, 2] > 0),
    resid_sd = summary(m)$sigma,
    acf1 = sum(r[-1] * r[-length(r)]) / sum(r^2),
    dw = sum(diff(r)^2) / sum(r^2),
    shapiro_p = unname(shapiro.test(r)$p.value))
}

n_rep <- 2000
acc <- matrix(NA_real_, n_rep, 14)
for (b in seq_len(n_rep)) {
  dd <- sim_series(50000 + b)
  acc[b, ] <- c(diag_of(dd$clean), diag_of(dd$spliced))
}
nm_d <- c("slope", "se", "excl0", "resid_sd", "acf1", "dw", "shapiro_p")
colnames(acc) <- c(paste0("c_", nm_d), paste0("s_", nm_d))
acc <- as.data.frame(acc)

bias_pred <- step_log * 6 * k_old * (n_year - k_old) /
  (n_year * (n_year^2 - 1))
print(round(c(mean_slope_clean_dec = 10 * mean(acc$c_slope),
              mean_slope_spliced_dec = 10 * mean(acc$s_slope),
              predicted_bias_dec = 10 * bias_pred,
              predicted_pct_dec = 100 * (exp(10 * bias_pred) - 1)), 5))
  mean_slope_clean_dec mean_slope_spliced_dec     predicted_bias_dec 
               0.00216               -0.17408               -0.17621 
     predicted_pct_dec 
             -16.15573 
print(round(c(mean_se_clean_dec = 10 * mean(acc$c_se),
              mean_se_spliced_dec = 10 * mean(acc$s_se),
              se_ratio = mean(acc$s_se) / mean(acc$c_se),
              sd_slope_clean_dec = 10 * sd(acc$c_slope),
              sd_slope_spliced_dec = 10 * sd(acc$s_slope),
              sd_ratio = sd(acc$s_slope) / sd(acc$c_slope)), 5))
   mean_se_clean_dec  mean_se_spliced_dec             se_ratio 
             0.05479              0.05833              1.06451 
  sd_slope_clean_dec sd_slope_spliced_dec             sd_ratio 
             0.05588              0.05593              1.00074 
print(round(c(excl_zero_clean = mean(acc$c_excl0),
              excl_zero_spliced = mean(acc$s_excl0),
              bias_in_se_units = (mean(acc$s_slope) - beta_true) /
                mean(acc$s_se)), 4))
  excl_zero_clean excl_zero_spliced  bias_in_se_units 
           0.0495            0.8195           -2.9845 

Over 2000 replicates the clean series averages 0.00216 per decade and the spliced one averages -0.17408, against a truth of 0. The predicted value is -0.17621, and the prediction is not a guess. For an ordinary least squares fit of the index on time, the coefficient on a step of size \(s\) entering at year \(k\) is

\[\text{bias} \;=\; s \cdot \frac{\operatorname{Cov}(\mathbb{1}[t > k],\, t)}{\operatorname{Var}(t)} \;=\; \frac{6\,s\,k\,(n-k)}{n\,(n^2-1)}\]

which depends on nothing except the step, the seam position and the series length. The population, the noise and the number of traps do not enter. In percentage terms the manufactured decline is 16.156 per cent per decade.

The second set of numbers is why this survives review. The mean standard error moves from 0.05479 per decade to 0.05833, a ratio of 1.0645: the reported precision worsens by 6.451 per cent while the estimate moves by 2.985 standard errors. The spread of the estimates across replicates barely changes either, 0.05588 per decade clean against 0.05593 spliced, a ratio of 1.0007. The whole sampling distribution slides sideways and keeps its shape, which is the definition of bias and the one thing a standard error cannot report.

The interval excludes zero in 4.95 per cent of clean replicates, which is the nominal rate, and in 81.95 per cent of spliced ones. Four networks in five would publish a significant decline in a population that never changed.

What a step is worth, in trend per decade

Since the bias depends only on three quantities, it can be quoted as an exchange rate: so much step buys so much trend. Sweeping the step over a range and measuring the mean fitted trend confirms the algebra and puts a number on the rate.

exch_rate <- function(nn, kk) 10 * 6 * kk * (nn - kk) / (nn * (nn^2 - 1))

step_grid <- seq(-0.45, 0.45, by = 0.075)
n_rep_x <- 400
ex_tab <- t(vapply(step_grid, function(s) {
  v <- numeric(n_rep_x)
  for (b in seq_len(n_rep_x)) {
    v[b] <- coef(lm(sim_series(80000 + b, step_sz = s)$spliced ~ t_c))[2]
  }
  c(step_log = s, pct_step = 100 * (exp(s) - 1), trend_dec = 10 * mean(v))
}, numeric(3)))
print(round(ex_tab, 5))
      step_log  pct_step trend_dec
 [1,]   -0.450 -36.23718  -0.23754
 [2,]   -0.375 -31.27107  -0.19730
 [3,]   -0.300 -25.91818  -0.15699
 [4,]   -0.225 -20.14838  -0.11689
 [5,]   -0.150 -13.92920  -0.07636
 [6,]   -0.075  -7.22565  -0.03643
 [7,]    0.000   0.00000   0.00380
 [8,]    0.075   7.78842   0.04429
 [9,]    0.150  16.18342   0.08432
[10,]    0.225  25.23227   0.12464
[11,]    0.300  34.98588   0.16486
[12,]    0.375  45.49914   0.20507
[13,]    0.450  56.83122   0.24524
xr_fit <- unname(coef(lm(ex_tab[, "trend_dec"] ~ ex_tab[, "step_log"] - 1))[1])
xr_theory <- exch_rate(n_year, k_old)
print(round(c(measured_exchange_rate = xr_fit,
              closed_form_exchange_rate = xr_theory,
              pct_per_decade_from_10pct_step =
                100 * (exp(xr_theory * log(0.9)) - 1)), 5))
        measured_exchange_rate      closed_form_exchange_rate 
                       0.53645                        0.53640 
pct_per_decade_from_10pct_step 
                      -5.49479 
len_grid <- c(15, 20, 28, 40, 60)
len_tab <- t(vapply(len_grid, function(nn) {
  rr <- exch_rate(nn, nn %/% 2)
  c(series_years = nn, exchange_rate = rr,
    pct_dec_from_10pct_step = 100 * (exp(rr * log(0.9)) - 1),
    fifteen_over_n = 15 / nn)
}, numeric(4)))
print(round(len_tab, 4))
     series_years exchange_rate pct_dec_from_10pct_step fifteen_over_n
[1,]           15        1.0000                -10.0000         1.0000
[2,]           20        0.7519                 -7.6162         0.7500
[3,]           28        0.5364                 -5.4948         0.5357
[4,]           40        0.3752                 -3.8764         0.3750
[5,]           60        0.2501                 -2.6003         0.2500

The measured exchange rate is 0.53645 against a closed form of 0.5364, agreeing to four decimal places. Read it in the direction a reader needs: a step of ten per cent in the index, placed in the middle of a twenty-eight year series, is worth 5.495 per cent per decade of apparent trend. That is a number to carry around. A protocol change nobody thought worth a footnote, a change small enough that a side-by-side trial might well have called it non-significant, buys most of the decline rate that gets a species onto a red list.

The rate falls as the series lengthens, and it falls roughly as fifteen over the number of years: 0.7519 at twenty years, 0.3752 at forty. A long series dilutes a fixed step, which is the one piece of good news here, and it is not much: doubling the length of a scheme halves the damage from a seam, and long schemes are the ones that have accumulated the most seams.

Three straight lines fanning out from the origin on a panel whose horizontal axis runs from a method step of minus four tenths to plus four tenths on the log scale and whose vertical axis runs from about minus three tenths to plus three tenths of trend per decade. The steepest, in red, is labelled twenty year series; a dark green middle line is labelled twenty-eight years and a pale green shallow line forty years. Round dots lie on the middle line at thirteen evenly spaced positions. A full height dashed vertical line stands at the lamp change step, left of the origin, labelled at the top.
Figure 2: Apparent trend produced by a method step, against the size of the step, for a mid-series seam. The points are simulated means over four hundred replicates at a twenty-eight year series; the lines are the closed-form exchange rate at three series lengths. The relationship is exactly linear in the step on the log scale, and the slope of the line is the exchange rate.

An era indicator buys back the trend and gives up the level

The repair is one term. Put an indicator for the lamp era into the model,

\[Y_t \;=\; \alpha + \beta\, t + \delta\, \mathbb{1}[t \geq \text{changeover}] + \varepsilon_t\]

and the step is absorbed by \(\delta\) while \(\beta\) carries the trend. Nothing is calibrated: no side-by-side trial, no conversion factor, no overlap period. The model is told only that the two halves are on different scales, not what the scales are.

To see that the trend really is recovered rather than merely shifted, the population now declines. The true log-scale trend is set to a real value and the same lamp step is applied on top of it.

beta_real <- -0.01
fit_pair <- function(idx) {
  m0 <- lm(idx ~ t_c)
  m1 <- lm(idx ~ t_c + new_era)
  c(naive = unname(coef(m0)[2]),
    naive_se = unname(summary(m0)$coefficients[2, 2]),
    era = unname(coef(m1)[2]),
    era_se = unname(summary(m1)$coefficients[2, 2]),
    delta = unname(coef(m1)[3]),
    delta_se = unname(summary(m1)$coefficients[3, 2]))
}
n_rep_e <- 2000
acc_e <- matrix(NA_real_, n_rep_e, 6)
for (b in seq_len(n_rep_e)) {
  acc_e[b, ] <- fit_pair(sim_series(70000 + b, beta = beta_real)$spliced)
}
colnames(acc_e) <- c("naive", "naive_se", "era", "era_se", "delta", "delta_se")
acc_e <- as.data.frame(acc_e)

t2 <- qt(0.975, n_year - 2)
t3 <- qt(0.975, n_year - 3)
print(round(c(true_trend_dec = 10 * beta_real,
              naive_mean_dec = 10 * mean(acc_e$naive),
              era_mean_dec = 10 * mean(acc_e$era),
              naive_bias_dec = 10 * (mean(acc_e$naive) - beta_real),
              era_bias_dec = 10 * (mean(acc_e$era) - beta_real)), 5))
true_trend_dec naive_mean_dec   era_mean_dec naive_bias_dec   era_bias_dec 
      -0.10000       -0.27773       -0.10188       -0.17773       -0.00188 
print(round(c(naive_cover = mean(abs(acc_e$naive - beta_real) <
                                   t2 * acc_e$naive_se),
              era_cover = mean(abs(acc_e$era - beta_real) <
                                 t3 * acc_e$era_se),
              naive_se_dec = 10 * mean(acc_e$naive_se),
              era_se_dec = 10 * mean(acc_e$era_se),
              se_inflation = mean(acc_e$era_se) / mean(acc_e$naive_se),
              sd_inflation = sd(acc_e$era) / sd(acc_e$naive)), 4))
 naive_cover    era_cover naive_se_dec   era_se_dec se_inflation sd_inflation 
      0.1460       0.9540       0.0579       0.1090       1.8839       2.0003 
print(round(c(delta_mean = mean(acc_e$delta), true_step = step_log,
              delta_as_pct = 100 * (exp(mean(acc_e$delta)) - 1),
              delta_detected = mean(abs(acc_e$delta / acc_e$delta_se) > t3)), 4))
    delta_mean      true_step   delta_as_pct delta_detected 
       -0.3278        -0.3285       -27.9513         0.4090 

The naive fit returns -0.2777 per decade against a truth of -0.1, and its nominal interval covers the truth 14.6 per cent of the time. With the era indicator the estimate is -0.1019 per decade, a bias of -0.00188, and coverage is 95.4 per cent. That is the practical lesson of the post, stated flatly: for a trend question the calibration is not needed. One dummy variable, costing one degree of freedom and no fieldwork, returns the trend unbiased.

What it costs is precision. The standard error goes from 0.0579 per decade to 0.109, and the honest measure of the cost is the spread of the estimates, which rises by a factor of 2.0003. That factor is not accidental. The era indicator and time are strongly collinear on a series split near its middle, and the variance inflation is \(1/(1 - R^2)\) where \(R^2 = 3k(n-k)/(n^2-1)\) is the fit of the step to a straight line in time. At a mid-series seam that gives a variance inflation of four and a spread multiplier of two, which is what came out.

What the model cannot do is put the two eras on one scale. The estimated step is -0.3278 on the log scale against a true -0.3285, so \(\delta\) is doing its job, but \(\delta\) is also only distinguishable from zero in 40.9 per cent of replicates. A question of the form “how many moths per trap now compared with the year the scheme started” has no answer from this model, because the answer would be the trend plus \(\delta\), and \(\delta\) is exactly the quantity nobody measured. Trend questions and level questions are not the same question, and only one of them survives a seam.

There is no safe place to put a seam

The bias formula and the variance inflation both depend on where in the series the changeover falls, and they pull in the same direction: the seam position that manufactures the most trend is also the position at which fixing it costs the most precision.

k_grid <- 4:(n_year - 4)
seam_tab <- data.frame(
  last_old_year = yr[k_grid],
  k = k_grid,
  bias_dec = step_log * exch_rate(n_year, k_grid),
  se_mult = 1 / sqrt(1 - 3 * k_grid * (n_year - k_grid) / (n_year^2 - 1)))
print(round(seam_tab[seq(1, nrow(seam_tab), by = 4), ], 4))
   last_old_year  k bias_dec se_mult
1           1997  4  -0.0863  1.2577
5           2001  8  -0.1438  1.6075
9           2005 12  -0.1726  1.9449
13          2009 16  -0.1726  1.9449
17          2013 20  -0.1438  1.6075
21          2017 24  -0.0863  1.2577
check_k <- c(6, 14, 22)
n_rep_k <- 600
seam_chk <- t(vapply(check_k, function(kk) {
  era_k <- as.integer(seq_len(n_year) > kk)
  nb <- eb <- rr <- numeric(n_rep_k)
  for (b in seq_len(n_rep_k)) {
    set.seed(400000 + b)
    a_i <- rnorm(n_trap, mu_log, sd_trap)
    u_t <- rnorm(n_year, 0, sd_year)
    e_it <- matrix(rnorm(n_trap * n_year, 0, sd_ty), n_trap, n_year)
    eta <- outer(a_i, rep(1, n_year)) + outer(rep(1, n_trap), u_t) + e_it
    cnt <- matrix(rpois(n_trap * n_year,
                        exp(eta + rep(step_log * era_k, each = n_trap))),
                  n_trap, n_year)
    idx <- log(colMeans(cnt))
    m0 <- lm(idx ~ t_c)
    m1 <- lm(idx ~ t_c + era_k)
    nb[b] <- 10 * coef(m0)[2]
    eb[b] <- 10 * coef(m1)[2]
    rr[b] <- summary(m1)$coefficients[2, 2] /
      summary(m0)$coefficients[2, 2]
  }
  c(k = kk, sim_bias_dec = mean(nb),
    theory_bias_dec = step_log * exch_rate(n_year, kk),
    sim_era_dec = mean(eb), sim_sd_mult = sd(eb) / sd(nb),
    sim_se_ratio = mean(rr),
    theory_mult = 1 / sqrt(1 - 3 * kk * (n_year - kk) / (n_year^2 - 1)))
}, numeric(7)))
print(round(seam_chk, 4))
      k sim_bias_dec theory_bias_dec sim_era_dec sim_sd_mult sim_se_ratio
[1,]  6      -0.1213         -0.1187     -0.0003      1.4320       1.3114
[2,] 14      -0.1791         -0.1762     -0.0021      2.0018       1.8872
[3,] 22      -0.1212         -0.1187     -0.0007      1.4021       1.3127
     theory_mult
[1,]      1.4224
[2,]      2.0038
[3,]      1.4224
worst <- seam_tab[which.max(abs(seam_tab$bias_dec)), ]
mild <- seam_tab[seam_tab$k == min(k_grid), ]
share <- abs(seam_tab$bias_dec) / max(abs(seam_tab$bias_dec))
print(round(c(worst_bias_dec = worst$bias_dec,
              worst_se_mult = worst$se_mult,
              earliest_seam_bias_dec = mild$bias_dec,
              earliest_over_worst = mild$bias_dec / worst$bias_dec,
              positions_above_80pct_of_worst = sum(share >= 0.8),
              positions_tested = length(share)), 4))
                worst_bias_dec                  worst_se_mult 
                       -0.1762                         2.0038 
        earliest_seam_bias_dec            earliest_over_worst 
                       -0.0863                         0.4898 
positions_above_80pct_of_worst               positions_tested 
                       13.0000                        21.0000 

At the worst position the manufactured trend is -0.1762 per decade and the era indicator multiplies the spread of the trend estimate by 2.0038. Move the changeover so that only four years sit on the old lamp, as early as this sweep goes, and the manufactured trend is still -0.0863 per decade, 48.98 per cent of the worst case. Across the 21 positions tested, 13 of them produce at least eighty per cent of the maximum bias.

That flatness is the finding. There is a folk intuition that a break near one end of a series is less of a problem, on the grounds that it affects only a few years. It affects only a few years and it moves the trend nearly as much, because what matters is the covariance between the step and time, and that covariance is broad and blunt rather than sharply peaked. The three simulated positions agree with the algebra to within Monte Carlo error, and the era indicator returns essentially zero bias at all three.

One number in that table is smaller than the design algebra says it should be. At the mid-series seam the spread of the era-model estimates is 2.0018 times the spread of the naive ones, matching the predicted 2.0038, but the ratio of the reported standard errors is only 1.8872. The gap is the naive fit flattering itself: its residual standard deviation is inflated by the step it is failing to model, so part of the era indicator’s apparent cost has already been paid, invisibly, by the model that ignores the seam.

Two stacked panels sharing a horizontal axis of changeover year running from 1997 to 2017. The upper panel shows a shallow valley of apparent trend per decade, lowest in the middle at about minus eighteen hundredths and rising only to about minus nine hundredths at each end, with three red dots close to the curve. The lower panel shows a symmetric arch of the spread multiplier peaking at two in the middle and falling to about one and a quarter at each end, again with three red dots on it.
Figure 3: Apparent trend from a fixed method step, and the multiplier on the spread of the trend estimate paid to remove it with an era indicator, against the year of the changeover. Both curves are closed-form; the red dots are simulated at three positions. The bias curve is broad and flat over the middle of the series, so moving a seam towards either end buys much less than it appears to.

Why nothing in the output complains

The diagnostics were collected alongside the estimates in the replicate loop, so they can be read off directly. A step in the middle of a series does leave a residual pattern in principle: high residuals early, low residuals late, with a jump between. The question is whether that pattern is large enough to notice against the year-to-year variation of a real monitoring series.

q_acf <- unname(quantile(acc$c_acf1, 0.95))
q_dw <- unname(quantile(acc$c_dw, 0.05))
print(round(c(mean_acf1_clean = mean(acc$c_acf1),
              mean_acf1_spliced = mean(acc$s_acf1),
              clean_95th_acf1 = q_acf,
              spliced_above_that = mean(acc$s_acf1 > q_acf)), 4))
   mean_acf1_clean  mean_acf1_spliced    clean_95th_acf1 spliced_above_that 
           -0.0766             0.0032             0.2223             0.1195 
print(round(c(mean_dw_clean = mean(acc$c_dw),
              mean_dw_spliced = mean(acc$s_dw),
              clean_5th_dw = q_dw,
              spliced_below_that = mean(acc$s_dw < q_dw)), 4))
     mean_dw_clean    mean_dw_spliced       clean_5th_dw spliced_below_that 
            2.0866             1.9273             1.4947             0.1270 
print(round(c(shapiro_rej_clean = mean(acc$c_shapiro_p < 0.05),
              shapiro_rej_spliced = mean(acc$s_shapiro_p < 0.05),
              median_p_clean = median(acc$c_shapiro_p),
              median_p_spliced = median(acc$s_shapiro_p)), 4))
  shapiro_rej_clean shapiro_rej_spliced      median_p_clean    median_p_spliced 
             0.0535              0.0470              0.5012              0.5122 
print(round(c(resid_sd_clean = mean(acc$c_resid_sd),
              resid_sd_spliced = mean(acc$s_resid_sd),
              ci_width_ratio = mean(acc$s_se) / mean(acc$c_se)), 4))
  resid_sd_clean resid_sd_spliced   ci_width_ratio 
          0.2342           0.2493           1.0645 

Lag-one residual autocorrelation moves from -0.0766 on the clean series to 0.0032 on the spliced one. That is a shift, and it is a shift from mildly negative to zero, which no analyst would look at twice. Judged against the clean distribution’s own upper five per cent point, the spliced series exceeds it 11.95 per cent of the time: a test at the five per cent level would flag about one series in eight. The Durbin-Watson statistic tells the same story, mean 2.0866 clean against 1.9273 spliced.

Normality is completely silent. Shapiro-Wilk rejects at the five per cent level in 5.35 per cent of clean replicates and 4.7 per cent of spliced ones, with median p values of 0.5012 and 0.5122. The residual standard deviation rises from 0.2342 to 0.2493, and that 6.451 per cent is the entire trace the step leaves in the fit’s own summary.

The one procedure that is built to see this is a changepoint scan. Fitting a level shift at a single, pre-specified year is the classical test for a break between two regressions (Chow 1960). When the year is not known in advance the test becomes a scan: fit the shift at every candidate year, take the largest F statistic, and compare it with the distribution of that maximum under no shift, because the break year is a parameter searched over rather than fixed (Andrews 1993). The critical value is simulated here from series generated with no step at all; the strucchange package implements the same family of tests with analytic critical values (Zeileis et al 2002).

cand <- 5:(n_year - 5)
X0 <- cbind(1, t_c)
q0 <- qr(X0)
d_til <- vapply(cand,
                function(kk) qr.resid(q0, as.numeric(seq_len(n_year) > kk)),
                numeric(n_year))
d_ss <- colSums(d_til^2)

sup_f <- function(y) {
  e0 <- qr.resid(q0, y)
  rss0 <- sum(e0^2)
  gain <- (as.vector(crossprod(d_til, e0))^2) / d_ss
  fv <- gain / ((rss0 - gain) / (n_year - 3))
  j <- which.max(fv)
  c(stat = fv[j], k = cand[j])
}

n_null <- 4000
null_f <- numeric(n_null)
for (b in seq_len(n_null)) {
  null_f[b] <- sup_f(sim_series(200000 + b, step_sz = 0)$clean)["stat"]
}
crit_f <- unname(quantile(null_f, 0.95))
print(round(c(median_null_supF = median(null_f), crit_95 = crit_f,
              naive_F_table_crit = qf(0.95, 1, n_year - 3),
              crit_ratio = crit_f / qf(0.95, 1, n_year - 3),
              candidates_searched = length(cand)), 4))
   median_null_supF             crit_95  naive_F_table_crit          crit_ratio 
             3.8741             10.2017              4.2417              2.4051 
candidates_searched 
            19.0000 
det_grid <- sort(c(seq(0, 1, by = 0.1), -step_log))
n_rep_c <- 800
det_tab <- t(vapply(det_grid, function(s) {
  hit <- 0
  near <- 0
  for (b in seq_len(n_rep_c)) {
    fv <- sup_f(sim_series(300000 + b, step_sz = -s)$spliced)
    if (fv["stat"] > crit_f) hit <- hit + 1
    if (abs(fv["k"] - k_old) <= 1) near <- near + 1
  }
  c(step_log = s, pct_step = 100 * (1 - exp(-s)),
    detect = hit / n_rep_c, break_within_one_year = near / n_rep_c,
    trend_dec = -s * exch_rate(n_year, k_old))
}, numeric(5)))
print(round(det_tab, 4))
      step_log pct_step detect break_within_one_year trend_dec
 [1,]   0.0000   0.0000 0.0462                0.2087    0.0000
 [2,]   0.1000   9.5163 0.0575                0.2362   -0.0536
 [3,]   0.2000  18.1269 0.0900                0.3412   -0.1073
 [4,]   0.3000  25.9182 0.1550                0.4888   -0.1609
 [5,]   0.3285  28.0000 0.1875                0.5337   -0.1762
 [6,]   0.4000  32.9680 0.2788                0.6388   -0.2146
 [7,]   0.5000  39.3469 0.4512                0.7725   -0.2682
 [8,]   0.6000  45.1188 0.6562                0.8738   -0.3218
 [9,]   0.7000  50.3415 0.7938                0.9238   -0.3755
[10,]   0.8000  55.0671 0.8862                0.9700   -0.4291
[11,]   0.9000  59.3430 0.9625                0.9912   -0.4828
[12,]   1.0000  63.2121 0.9875                0.9950   -0.5364
mono <- cummax(det_tab[, "detect"])
inc <- which(!duplicated(mono))
need <- approx(mono[inc], det_tab[inc, "step_log"], xout = c(0.5, 0.8))$y
work <- det_tab[which.min(abs(det_tab[, "step_log"] + step_log)), ]
print(round(c(detect_at_lamp_step = unname(work["detect"]),
              step_for_50pct = need[1], step_for_80pct = need[2],
              pct_step_for_50 = 100 * (1 - exp(-need[1])),
              pct_step_for_80 = 100 * (1 - exp(-need[2])),
              trend_dec_at_50 = -need[1] * exch_rate(n_year, k_old),
              trend_dec_at_80 = -need[2] * exch_rate(n_year, k_old)), 4))
detect_at_lamp_step      step_for_50pct      step_for_80pct     pct_step_for_50 
             0.1875              0.5238              0.7068             40.7723 
    pct_step_for_80     trend_dec_at_50     trend_dec_at_80 
            50.6759             -0.2810             -0.3791 

The simulated critical value is 10.202, against 4.242 from the F table that a naive application would use: searching over 19 candidate years multiplies the threshold by 2.405, and ignoring that is how a scan of this kind manufactures its own false positives.

At the lamp step actually in play, the scan detects the break in 18.75 per cent of series. It reaches a coin flip at a step of 0.5238, a drop of 40.77 per cent, and four times in five at 0.7068, a drop of 50.68 per cent. By the time the scan is reliable the step is already manufacturing a trend of -0.3791 per decade, which is 31.55 per cent per decade. The test becomes trustworthy at exactly the sizes where the damage is beyond argument, and it is quiet across the whole range where the damage is merely serious.

Two rising curves on a panel whose horizontal axis is the size of the method step as a percentage drop in the index, from zero to about sixty-three per cent, and whose vertical axis is a probability from zero to one. A dark green curve of round dots labelled break detected starts flat near five hundredths, stays low until about a twenty-five per cent step, then rises steeply and reaches almost one at the right-hand edge. A gold curve of triangles labelled break located within one year runs above it across the whole panel, starting near two tenths and meeting it only at the top right. A faint horizontal dotted line sits at five hundredths, and a dashed vertical line stands at a twenty-eight per cent step, where the green curve is still below two tenths.
Figure 4: Probability that a changepoint scan finds the seam, against the size of the method step, with the critical value simulated under no step. The lower curve is the probability of locating the break within one year of the truth. The vertical marker is the lamp change used throughout the post; at that step the scan is close to its own false-positive rate.

The limit: a step and an abrupt change are the same data

Everything above treated the step as an artefact because the simulation was told it was one. Take that away and the problem stops being statistical. Consider two networks. In the first the population is stable and the lamps were replaced in the changeover year. In the second the lamps were never touched and the population really did drop, in one bad winter, by exactly the factor the lamp change would have produced.

n_tr_big <- 400
n_yr_big <- 80

two_stories <- function(seed, n_tr, n_yv) {
  set.seed(seed)
  tv <- seq_len(n_yv) - mean(seq_len(n_yv))
  era_v <- as.integer(seq_len(n_yv) > n_yv %/% 2)
  a_i <- rnorm(n_tr, mu_log, sd_trap)
  u_t <- rnorm(n_yv, 0, sd_year)
  e_it <- matrix(rnorm(n_tr * n_yv, 0, sd_ty), n_tr, n_yv)
  base_eta <- outer(a_i, rep(1, n_yv)) + outer(rep(1, n_tr), u_t) + e_it

  pop_stable <- rep(0, n_yv)                 # the population never moves
  pop_crashed <- step_log * era_v            # one bad winter, then stable
  lamps_swapped <- step_log * era_v          # the instrument step
  lamps_kept <- rep(0, n_yv)                 # no instrument change

  eta_method <- base_eta + rep(pop_stable + lamps_swapped, each = n_tr)
  eta_ecology <- base_eta + rep(pop_crashed + lamps_kept, each = n_tr)
  cnt <- matrix(rpois(n_tr * n_yv, exp(eta_method)), n_tr, n_yv)
  list(gap = max(abs(eta_method - eta_ecology)),
       same = identical(eta_method, eta_ecology),
       index = log(colMeans(cnt)), tv = tv, era = era_v)
}

small <- two_stories(20260732, n_trap, n_year)
large <- two_stories(20260733, n_tr_big, n_yr_big)
print(c(small_expectations_identical = small$same,
        large_expectations_identical = large$same))
small_expectations_identical large_expectations_identical 
                        TRUE                         TRUE 
print(c(small_max_gap = small$gap, large_max_gap = large$gap,
        large_traps = n_tr_big, large_years = n_yr_big))
small_max_gap large_max_gap   large_traps   large_years 
            0             0           400            80 
n_rep_i <- 1000
acc_i <- matrix(NA_real_, n_rep_i, 2)
for (b in seq_len(n_rep_i)) {
  mi <- lm(sim_series(90000 + b)$spliced ~ t_c + new_era)
  acc_i[b, ] <- c(10 * coef(mi)[2], coef(mi)[3])
}
print(round(c(era_trend_dec = mean(acc_i[, 1]),
              mc_se_trend = sd(acc_i[, 1]) / sqrt(n_rep_i),
              era_step = mean(acc_i[, 2]),
              mc_se_step = sd(acc_i[, 2]) / sqrt(n_rep_i),
              true_step = step_log,
              step_as_pct = 100 * (exp(mean(acc_i[, 2])) - 1),
              naive_trend_dec = 10 * mean(acc$s_slope)), 4))
  era_trend_dec     mc_se_trend        era_step      mc_se_step       true_step 
        -0.0016          0.0036         -0.3244          0.0058         -0.3285 
    step_as_pct naive_trend_dec 
       -27.7056         -0.1741 

The two stories are written down differently and they put the same expectation into every trap-year, so identical() on the two expectation matrices returns TRUE and the largest discrepancy is 0. Raise the network to 400 traps and 80 years and it is still 0. There is no statistic to compute, because there is nothing to compute it on: the two hypotheses are one distribution, and no test has power against a difference that does not exist.

The sting is in what that does to the era model. Averaged over 1000 networks it returns a trend of -0.00157 per decade, Monte Carlo standard error 0.00358, and a step of -0.3244 against a true -0.3285, which is a drop of 27.706 per cent. It is the right model under both stories, and what differs is which coefficient counts as ecology. Under the lamp story, \(\delta\) is an artefact to be discarded and the trend is the finding; under the bad-winter story, \(\delta\) is the finding and the flat trend either side of it is the finding too. Same data, same fit, opposite reports. The one thing that is definitely wrong under both stories is the single straight line, which puts -0.17408 per decade of steady decline into a network that was either stable throughout or stable, dropped once and stable again.

So the distinction is documentary. It lives in the network’s protocol file, in the equipment purchase records, in whatever somebody wrote down in the winter of the changeover, and there is no amount of data that substitutes for it. The practical consequence is small and dull: a column in the database recording which method produced each row, maintained from the first year, costs nothing and is the only thing that makes the era indicator legitimate rather than an assumption.

Observer turnover is the same object under a different name, and the schemes that took it seriously did exactly that. The North American Breeding Bird Survey carries observer identity in its database, which is what allowed between-observer differences in counts to be measured (Sauer, Peterjohn and Link 1994) and a distinct first-year effect for new observers to be separated from them (Kendall, Peterjohn and Sauer 1996). Neither result could have been recovered from the counts alone. A method column is the same idea applied to the instrument rather than the person.

What to take away

A method change part-way through a series is a bias, and bias is the failure mode that monitoring statistics is worst at reporting. The lamp change here moved the fitted trend by -0.17621 per decade, 2.985 standard errors, while widening the reported interval by 6.451 per cent and leaving the spread of the estimate across replicates unchanged at a ratio of 1.0007. Four fifths of such networks would report a significant decline in a stable population.

The exchange rate is the number to remember. At a mid-series seam, apparent trend per decade is 0.5364 times the log step, so a ten per cent step buys 5.495 per cent per decade over twenty-eight years, and the rate scales as roughly fifteen over the number of years. Two things about the seam position went against what I expected. The bias curve is nearly flat across the middle of the series, 13 of 21 tested positions producing at least eighty per cent of the maximum, so there is no end of the series where a seam is cheap. And the position that manufactures the most trend is also the one where the repair costs most: at mid-series the era indicator multiplies the spread of the estimate by 2.0038, the collinearity between a half-and-half step and a straight line being at its worst exactly there.

The repair itself is cheaper than the literature’s usual advice implies. An era indicator recovered a real trend of -0.1 per decade as -0.1019 with 95.4 per cent coverage, using no calibration data at all. It gives up the level, and it gives up 2.0003 times the precision, and for a trend question those are the right things to give up.

The diagnostics deserve one line each because that is all they earn. Normality: silent. Residual standard deviation: 6.451 per cent higher. Lag-one autocorrelation: flags 11.95 per cent of spliced series against a nominal five. A changepoint scan is the only thing that looks for the right object, and at the step in play it found it 18.75 per cent of the time, needing a 50.68 per cent step before it worked four times in five.

The honest limit is that none of this identifies the seam from inside the data. A method step and a genuine abrupt change generate the same distribution, to the last digit at any number of traps and any number of years, and the era model that fits both cannot say which of its two coefficients is the ecology. That is a documentation problem rather than a statistical one. The remaining question is the one this post deliberately did not ask: if the network had run both lamps side by side for a few seasons, how many seasons would it have taken to buy back the precision the era indicator throws away? That is measured in how long a calibration overlap, which treats the seam as a design decision rather than an accident.

References

Chow GC 1960 Econometrica 28(3):591-605 (10.2307/1910133)

Andrews DWK 1993 Econometrica 61(4):821-856 (10.2307/2951764)

Zeileis A, Leisch F, Hornik K, Kleiber C 2002 Journal of Statistical Software 7(2):1-38 (10.18637/jss.v007.i02)

Sauer JR, Peterjohn BG, Link WA 1994 The Auk 111(1):50-62 (10.2307/4088504)

Kendall WL, Peterjohn BG, Sauer JR 1996 The Auk 113(4):823-829 (10.2307/4088860)

Merckx T, Slade EM 2014 Insect Conservation and Diversity 7(5):453-461 (10.1111/icad.12068)

Didham RK, et al 2020 Insect Conservation and Diversity 13(2):103-114 (10.1111/icad.12408)

Isaac NJB, van Strien AJ, August TA, de Zeeuw MP, Roy DB 2014 Methods in Ecology and Evolution 5(10):1052-1060 (10.1111/2041-210X.12254)

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.