Variance components in monitoring data

R
survey design
monitoring
ecology tutorial
Split monitoring data into site, year, interaction and residual variance in base R, and see why the largest component is free for trend and fatal for status.
Author

Tidy Ecology

Published

2026-07-25

A monitoring programme visits a set of sites, counts something, and repeats for years. The numbers vary for four different reasons, and a programme that cannot tell them apart cannot say what its own data are good for.

Some sites are simply richer than others, and they stay that way. Some years are good everywhere at once, and some are bad everywhere at once. Some sites are good in a particular year for reasons local to that site. And two visits to the same site in the same season give different counts because counting is noisy.

Those four sources are the variance components. This post estimates them by hand from a balanced design, then shows the point that decides how a programme should be built: the component that dominates one question contributes nothing at all to the other.

A model for monitoring data

Write the log count at site i, year j, visit k as a mean, a linear trend, and four random parts.

# y_ijk = mu + beta*(t_j - tbar) + s_i + a_j + (sa)_ij + e_ijk
#   s_i   persistent differences between sites
#   a_j   a good year or a bad year, shared by every site
#   sa_ij site-by-year interaction: this site, this year
#   e_ijk within-season counting noise

sim_monitoring <- function(n_site, n_year, n_rep, beta,
                           sd_site, sd_year, sd_sy, sd_res, mu = 3) {
  s  <- rnorm(n_site, 0, sd_site)
  a  <- rnorm(n_year, 0, sd_year)
  sa <- matrix(rnorm(n_site * n_year, 0, sd_sy), n_site, n_year)
  t  <- seq_len(n_year)
  tc <- t - mean(t)
  d  <- expand.grid(rep = seq_len(n_rep), site = seq_len(n_site), year = seq_len(n_year))
  d$y <- mu + beta * tc[d$year] + s[d$site] + a[d$year] +
         sa[cbind(d$site, d$year)] + rnorm(nrow(d), 0, sd_res)
  d$site <- factor(d$site); d$yearf <- factor(d$year); d$t <- t[d$year]
  list(d = d, s = s, a = a)
}

sd_site <- 0.55   # among sites
sd_year <- 0.20   # among years, shared
sd_sy   <- 0.30   # site by year
sd_res  <- 0.35   # repeat visits
beta_true <- -0.03            # about 3 per cent per year on the log scale
n_site <- 30; n_year <- 10; n_rep <- 2

v_true <- c(site = sd_site^2, year = sd_year^2, sy = sd_sy^2, res = sd_res^2)
v_tot  <- sum(v_true)
data.frame(component = c("site", "year", "site by year", "repeat visit"),
           variance  = round(unname(v_true), 4),
           per_cent  = round(100 * unname(v_true) / v_tot, 1))
     component variance per_cent
1         site   0.3025     54.5
2         year   0.0400      7.2
3 site by year   0.0900     16.2
4 repeat visit   0.1225     22.1

The site component is the largest by a wide margin, at 54.5 per cent of the total. The year component is the smallest of the structured parts, at 7.2 per cent. Hold on to that ordering, because it reverses later.

Line chart with thirty faint grey site trajectories spread vertically and one dark green annual mean line running through the middle with a mild downward slope.
Figure 1: Ten years of simulated monitoring data. Grey lines are individual sites, which stay in their own lanes; the dark line is the annual mean across all 30 sites. The wobble in the dark line is the year component, and it is the same wobble for every site.

Splitting the variance by hand

The design is balanced, so the four components come straight out of the two-way analysis of variance. Each mean square has a known expectation, and inverting those expectations gives moment estimators. No mixed-model package is needed.

vc_anova <- function(d, n_site, n_year, n_rep, detrended = FALSE) {
  y <- d$y; gm <- mean(y)
  m_si   <- tapply(y, d$site, mean)
  m_yr   <- tapply(y, d$yearf, mean)
  m_cell <- tapply(y, list(d$site, d$yearf), mean)

  SS_site <- n_year * n_rep * sum((m_si - gm)^2)
  SS_year <- n_site * n_rep * sum((m_yr - gm)^2)
  SS_sy   <- n_rep * sum((m_cell - outer(m_si, m_yr, "+") + gm)^2)
  SS_res  <- sum((y - m_cell[cbind(as.integer(d$site), as.integer(d$yearf))])^2)

  # removing a linear trend first costs one year degree of freedom
  df_year <- if (detrended) n_year - 2 else n_year - 1
  MS_site <- SS_site / (n_site - 1)
  MS_year <- SS_year / df_year
  MS_sy   <- SS_sy   / ((n_site - 1) * (n_year - 1))
  MS_res  <- SS_res  / (n_site * n_year * (n_rep - 1))

  c(site = (MS_site - MS_sy) / (n_rep * n_year),
    year = (MS_year - MS_sy) / (n_rep * n_site),
    sy   = (MS_sy - MS_res) / n_rep,
    res  = MS_res)
}

dd$y_detr <- residuals(lm(y ~ t, data = dd))   # take the trend out first
d_detr <- dd; d_detr$y <- dd$y_detr
v_hat <- vc_anova(d_detr, n_site, n_year, n_rep, detrended = TRUE)
round(rbind(estimate = v_hat, truth = v_true), 4)
           site   year     sy    res
estimate 0.2818 0.0577 0.0962 0.1143
truth    0.3025 0.0400 0.0900 0.1225

One data set gives 0.282 for the site component against a true 0.303, and 0.058 for the year component against 0.040. Over repeated data sets the estimator sits on the truth.

set.seed(3671)
B <- 400
vc_reps <- t(replicate(B, {
  z <- sim_monitoring(n_site, n_year, n_rep, beta_true, sd_site, sd_year, sd_sy, sd_res)$d
  z$y <- residuals(lm(y ~ t, data = z))
  vc_anova(z, n_site, n_year, n_rep, detrended = TRUE)
}))
vc_mean <- colMeans(vc_reps)
vc_sd   <- apply(vc_reps, 2, sd)
round(rbind(mean = vc_mean, truth = v_true, sd = vc_sd), 4)
        site   year     sy    res
mean  0.3009 0.0406 0.0905 0.1225
truth 0.3025 0.0400 0.0900 0.1225
sd    0.0834 0.0228 0.0144 0.0103

The detrending step matters. A linear trend lives entirely in the year means, so if it is left in, it is counted as year-to-year variance. Removing it costs one degree of freedom among the years, and the estimator has to be told.

Notice the last row. The site component is estimated with a standard deviation of 0.083 from 30 sites, and the year component with 0.023 from 10 years. Ten years is a small sample of years, and that shows up again in the checking post of this series.

The same variance, two different questions

A monitoring programme is usually asked two questions. What is the state of the region now, and is it changing? The two use the same data and the same components, but not in the same way.

Status. The estimate for one year is the mean over the sampled sites. The year effect for that year is part of what happened, not part of the error, so the error variance is the site, interaction and residual parts divided by the number of sites.

Trend. With the same sites revisited every year, each site’s persistent level is common to all its years, so it shifts the whole trajectory up or down and leaves the slope alone. It drops out completely. The year effect does not drop out: a good year early and a bad year late is a downward slope with no population change behind it.

Sxx <- n_year * (n_year^2 - 1) / 12          # sum of squares of centred year index

var_status <- (v_true["site"] + v_true["sy"] + v_true["res"] / n_rep) / n_site
var_trend  <- (v_true["year"] + (v_true["sy"] + v_true["res"] / n_rep) / n_site) / Sxx

share_status <- 100 * v_true["site"] / (v_true["site"] + v_true["sy"] + v_true["res"] / n_rep)
share_trend  <- 100 * v_true["year"] / (v_true["year"] + (v_true["sy"] + v_true["res"] / n_rep) / n_site)

round(c(se_status = sqrt(unname(var_status)), se_trend = sqrt(unname(var_trend)),
        site_pct_of_status = unname(share_status),
        year_pct_of_trend  = unname(share_trend)), 4)
         se_status           se_trend site_pct_of_status  year_pct_of_trend 
            0.1230             0.0234            66.6667            88.8067 
set.seed(3672)
B <- 1500
mc <- t(replicate(B, {
  z <- sim_monitoring(n_site, n_year, n_rep, beta_true, sd_site, sd_year, sd_sy, sd_res)
  am <- tapply(z$d$y, z$d$yearf, mean)
  truth_y1 <- 3 + beta_true * (1 - mean(seq_len(n_year))) + z$a[1]
  c(status_err = unname(am[1]) - truth_y1,
    slope = unname(coef(lm(am ~ seq_len(n_year)))[2]))
}))
round(c(sd_status_mc = sd(mc[, "status_err"]), sd_status_formula = sqrt(unname(var_status)),
        sd_slope_mc = sd(mc[, "slope"]), sd_slope_formula = sqrt(unname(var_trend)),
        mean_slope  = mean(mc[, "slope"])), 5)
     sd_status_mc sd_status_formula       sd_slope_mc  sd_slope_formula 
          0.12438           0.12298           0.02354           0.02337 
       mean_slope 
         -0.02949 

Simulation agrees with the formulas: 0.1244 against 0.1230 for status, and 0.02354 against 0.02337 for the trend. Now compare the two decompositions.

Two stacked bars side by side. The status bar is dominated by the site component in dark green; the trend bar is dominated by the year component in gold, with no site segment at all.
Figure 2: The same four components, weighted twice. On the left, the share each one contributes to the error of a single-year status estimate. On the right, the share it contributes to the error of the trend. The site component is two thirds of the first bar and exactly zero in the second; the year component is the reverse.

The site component is 66.7 per cent of the status error and nothing at all in the trend. The year component is 7.2 per cent of the raw variance in the data and 88.8 per cent of the trend error. The smallest structured component is the one that decides whether a trend can be seen.

There is a second consequence, and it is the harder one. Adding sites divides the site, interaction and residual parts by a larger number, but it does not touch the year part at all. No number of sites reduces the year component. That sets a floor on trend precision which only more years can lower.

What this does to a naive analysis

The obvious regression fits every observation, absorbs the site differences as fixed effects, and reads off the year coefficient. The point estimate is fine. The standard error is not, because the year effect makes all sites in the same year move together, and a regression that assumes independent residuals cannot know that.

set.seed(3673)
B <- 500
naive <- t(replicate(B, {
  z  <- sim_monitoring(n_site, n_year, n_rep, beta_true, sd_site, sd_year, sd_sy, sd_res)$d
  f  <- lm(y ~ t + site, data = z)
  co <- summary(f)$coefficients["t", ]
  ci <- unname(co[1]) + c(-1, 1) * qt(0.975, f$df.residual) * unname(co[2])
  c(est = unname(co[1]), se = unname(co[2]),
    covered = as.numeric(ci[1] <= beta_true && beta_true <= ci[2]))
}))
naive_se   <- mean(naive[, "se"])
naive_sd   <- sd(naive[, "est"])
naive_cov  <- mean(naive[, "covered"])
round(c(reported_se = naive_se, true_sd = naive_sd,
        ratio = naive_sd / naive_se, coverage = naive_cov), 4)
reported_se     true_sd       ratio    coverage 
     0.0070      0.0231      3.3075      0.4480 

The regression reports a standard error of 0.0070 when the estimate actually varies with a standard deviation of 0.0231, a factor of 3.31. Its nominal 95 per cent interval contains the true trend 45 per cent of the time.

The two-stage estimator used above avoids this. Average within each year first, then regress the 10 annual means on year. The annual mean carries the year effect as a single number, so the regression sees the right amount of noise, and the interval behaves.

set.seed(3674)
B <- 500
cov2 <- mean(replicate(B, {
  z  <- sim_monitoring(n_site, n_year, n_rep, beta_true, sd_site, sd_year, sd_sy, sd_res)$d
  am <- tapply(z$y, z$yearf, mean)
  f  <- lm(am ~ seq_len(n_year))
  ci <- confint(f)[2, ]
  as.numeric(ci[1] <= beta_true && beta_true <= ci[2])
}))
c(two_stage_coverage = cov2, naive_coverage = naive_cov)
two_stage_coverage     naive_coverage 
             0.960              0.448 

Coverage 0.960 against 0.448, from the same data, with the same point estimate. The difference is entirely in which components the analysis believes are there.

The honest limit of this post is that the split assumes the four components are constant over the period, and that the design is balanced. Unbalanced data need a likelihood method rather than mean squares, and a component that itself changes over time (a monitoring scheme that quietly gets better at counting) is not in this model at all. That last one is the third check in the closing post of this series.

Where this leaves the series

The variance components are not a summary statistic. They are the input to every design decision that follows: which sites to revisit, how many years to plan for, and what the programme can honestly promise to detect. The next post spends the same budget four different ways and measures what each one buys.

References

Urquhart, Paulsen and Larsen 1998 Ecological Applications 8:246-257 (bracketed publisher DOI, cited without it)

Larsen, Kincaid, Jacobs and Urquhart 2001 BioScience 51(12):1069-1078 (bracketed publisher DOI, cited without it)

Urquhart and Kincaid 1999 Journal of Agricultural, Biological, and Environmental Statistics 4(4):404-414 (no publisher DOI located)

Searle, Casella and McCulloch 1992 Variance Components, Wiley, New York; ISBN 978-0-471-62162-1 (first edition; the 2006 Wiley-Interscience paperback reissue is ISBN 978-0-470-00959-8)

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.