Non-compliance in field experiments

R
causal inference
experimental design
ecology tutorial
Assigned treatment is not received treatment. A simulated fencing trial measures what intention-to-treat, per-protocol and the Wald ratio each estimate.
Author

Tidy Ecology

Published

2026-07-30

A treatment column in a tidy data frame is a promise that something was done to that plot. Most of the time the promise is kept. The post on split-plot designs in ecology could say, without qualification, that once a plot is burnt or not burnt the two-metre squares inside it can be sown with whatever mix you like, because burning a plot is something you either did or did not do and you were standing there when it happened.

Fencing is not like that. A grazing exclosure is a set of posts and wire that has to survive a winter, and some of them do not. The plots on soft waterlogged ground are the ones where the posts loosen and the cattle push through, so by the following summer the exclosure exists in the assignment spreadsheet and not on the ground. The same shape turns up wherever the treatment is a thing that has to keep happening: a prescribed burn that runs out of fuel halfway across a plot, a farmer in a payment scheme who does not cut the margin, a translocated individual that walks off the release site within a week, a herbicide application skipped because the ground was too wet to drive on.

In every one of those cases the data frame has two columns that are not the same column. One records what was assigned. The other records what was received. Analysis code almost always uses the first and the write-up almost always describes the second, and the gap between them has a size that can be worked out rather than worried about.

The nearest neighbour on this blog is instrumental variables and 2SLS, and the boundary is worth stating before anything else. There, the instrument is a continuous nudge and the job is to clean an unmeasured confounder out of a continuous predictor: the first-stage coefficient can be anything, and the diagnostic that matters is whether it is far enough from zero. Here the instrument is the randomised assignment itself, which is binary and confounded by nothing, because you drew it. The denominator of the ratio stops being an abstract first-stage slope and becomes the compliance rate, a number between zero and one that a field notebook can report. What you get back is not the effect on everyone; it is the effect on the plots whose fences held. The fuzzy version of a regression discontinuity design, where crossing the threshold raises the probability of treatment rather than guaranteeing it, is the same machinery with a different instrument.

This post simulates a fencing trial where a known fraction of the exclosures fail, and runs three estimators on it: intention to treat, which uses the assignment column; per-protocol, which uses the received column; and the Wald ratio, which divides one by the other. It writes down what each one converges to, checks that against the simulation, then makes the failures correlate with plot quality and measures how far wrong per-protocol goes. Then it treats compliance as a design quantity and asks what it costs in plots.

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"))
}

The column that says what was assigned and the column that says what happened

The trial has 400 plots in a floodplain meadow. Half are assigned an exclosure by a coin flip, half are left open, and the response is mean sward height in centimetres at the end of the season. Soil moisture varies across the site and is the strongest thing acting on sward height, so it goes in as a standardised covariate with a coefficient of 8 centimetres per standard deviation against a true exclosure effect of 4 centimetres and a residual standard deviation of 5. That ordering is deliberate: in a real meadow the environmental gradient is usually larger than the treatment you have imposed on it.

Compliance enters through a latent indicator for each plot, whether its fence would hold if one were built. The received treatment is the product of the assignment and that indicator, so a plot assigned to control never ends up fenced. This is one-sided non-compliance and it is the common case in field ecology; the two-sided case, where control plots pick up the treatment as well, appears later in the post.

sim_field <- function(n, tau0 = 4, tau1 = 0, beta_q = 8, a_comp = 0.4,
                      g_comp = 0, sigma = 5, base = 30) {
  q <- rnorm(n)                                   # soil moisture, standardised
  z <- sample(rep(0:1, each = n / 2))             # randomised assignment
  b <- rbinom(n, 1, plogis(a_comp - g_comp * q))  # would the fence hold
  d <- z * b                                      # treatment received
  tau_i <- tau0 + tau1 * q
  y <- base + beta_q * q + tau_i * d + rnorm(n, 0, sigma)
  list(q = q, z = z, b = b, d = d, tau_i = tau_i, y = y)
}

three_est <- function(s) {
  itt <- mean(s$y[s$z == 1]) - mean(s$y[s$z == 0])
  pp <- mean(s$y[s$d == 1]) - mean(s$y[s$d == 0])
  comp <- mean(s$d[s$z == 1]) - mean(s$d[s$z == 0])
  c(itt = itt, pp = pp, late = itt / comp, comp = comp)
}

The three estimators are three lines of arithmetic on group means. Intention to treat splits on z and ignores d entirely. Per-protocol, in the as-treated form used here, splits on d and ignores z. The Wald ratio divides the intention-to-treat contrast by the difference in receipt rates between the two assigned groups, which under one-sided non-compliance is just the compliance rate among the assigned-treated plots.

set.seed(20260802)
n_plot <- 400
comp_rate <- 0.6
tau_true <- 4
trial <- sim_field(n_plot, a_comp = qlogis(comp_rate))
est1 <- three_est(trial)

n_arm <- sum(trial$z == 1)
se_itt <- sqrt(var(trial$y[trial$z == 1]) / n_arm +
                 var(trial$y[trial$z == 0]) / n_arm)
se_late <- se_itt / est1[["comp"]]
n_fenced <- sum(trial$d == 1)
n_failed <- sum(trial$z == 1 & trial$d == 0)

print(round(c(plots = n_plot, assigned_fenced = n_arm,
              fences_that_held = n_fenced, fences_that_failed = n_failed,
              compliance_designed = comp_rate,
              compliance_observed = est1[["comp"]]), 4))
              plots     assigned_fenced    fences_that_held  fences_that_failed 
            400.000             200.000             121.000              79.000 
compliance_designed compliance_observed 
              0.600               0.605 
print(round(c(itt = est1[["itt"]], per_protocol = est1[["pp"]],
              wald = est1[["late"]], true_effect = tau_true), 4))
         itt per_protocol         wald  true_effect 
      3.5743       5.2016       5.9080       4.0000 
print(round(c(se_itt = se_itt, se_wald = se_late,
              t_itt = est1[["itt"]] / se_itt,
              ratio_itt_to_wald = est1[["itt"]] / est1[["late"]]), 4))
           se_itt           se_wald             t_itt ratio_itt_to_wald 
           0.9631            1.5919            3.7112            0.6050 

Of 200 plots assigned an exclosure, 121 still had a standing fence at the end of the season and 79 did not, an observed compliance rate of 0.605 against the 0.6 the simulation was built with. The intention-to-treat contrast is 3.5743 centimetres, the per-protocol contrast is 5.2016, and the Wald ratio is 5.908, against a true effect of 4 centimetres in every plot.

The intention-to-treat number is the smallest of the three, and it is smaller by a factor that is not a coincidence: the ratio of the intention-to-treat estimate to the Wald estimate is 0.605, which is the observed compliance rate to every decimal place, because that is how the Wald ratio was constructed. All three are noisy here. The standard error on the intention-to-treat contrast is 0.9631, so a single 400-plot trial places the effect within a couple of centimetres and no better, and the Wald standard error is larger by the same factor of one over the compliance rate: 1.5919.

The connection to the two-stage least squares machinery is exact rather than analogical. Regress the received treatment on the assignment, keep the fitted values, regress the outcome on those, and the slope is the Wald ratio.

first_stage <- lm(trial$d ~ trial$z)
d_hat <- fitted(first_stage)
b_2sls <- unname(coef(lm(trial$y ~ d_hat))[2])
first_coef <- unname(coef(first_stage)[2])

print(round(c(first_stage_slope = first_coef,
              compliance_rate = est1[["comp"]],
              two_stage_slope = b_2sls, wald_ratio = est1[["late"]]), 6))
first_stage_slope   compliance_rate   two_stage_slope        wald_ratio 
         0.605000          0.605000          5.907973          5.907973 
print(signif(c(absolute_difference = abs(b_2sls - est1[["late"]])), 3))
absolute_difference 
           1.87e-14 

The two agree to 1.87e-14, which is floating point. The first-stage slope is 0.605, the compliance rate again. In the earlier instrumental variables post the first-stage F statistic was the thing to watch because a weak instrument makes that denominator wander near zero. Here the denominator is a proportion you measured in the field, and the equivalent worry is not that it is noisy but that it is small.

Intention to treat is attenuated by exactly the compliance rate

Write \(Y_i(0)\) for the sward height plot \(i\) would have without an exclosure, \(\tau_i\) for the effect a working exclosure would have on it, \(Z_i\) for the assignment and \(B_i\) for the latent indicator of whether the fence would hold. The observed outcome is \(Y_i = Y_i(0) + \tau_i D_i\) with \(D_i = Z_i B_i\), and this already carries two assumptions: plot \(i\) responds only to its own \(D_i\) (nothing leaks in from the neighbours) and assignment moves the outcome only by changing \(D_i\) (nobody manages a plot differently because it is on the fencing list). Randomisation makes \(Z_i\) independent of everything on the right, so

\[\mathbb{E}[Y \mid Z = 1] = \mathbb{E}[Y(0)] + \mathbb{E}[\tau B], \qquad \mathbb{E}[Y \mid Z = 0] = \mathbb{E}[Y(0)]\]

and subtracting gives the whole result in one line:

\[\mathbb{E}[\hat\tau_{\text{ITT}}] = \mathbb{E}[\tau_i B_i] = \pi \cdot \mathbb{E}[\tau_i \mid B_i = 1], \qquad \pi = \Pr(B_i = 1).\]

With a constant effect the second factor is just \(\tau\), so the intention-to-treat estimate converges to \(\pi\tau\): the effect multiplied by the compliance rate. Nothing about the plots that failed enters except how many of them there were. Dividing by \(\pi\) undoes it, which is the Wald ratio and is what Bloom (1984) proposed for exactly this situation.

The sweep below holds the design fixed and varies the compliance rate from 0.3 to 1, with failures assigned at random and independent of plot quality, and averages each estimator over many trials.

set.seed(20260804)
c_grid <- seq(0.3, 1, by = 0.1)
n_rep <- 1000

sweep_c <- t(vapply(c_grid, function(cc) {
  m <- replicate(n_rep, three_est(sim_field(n_plot, a_comp = qlogis(cc))))
  c(mean_itt = mean(m["itt", ]), mean_pp = mean(m["pp", ]),
    mean_late = mean(m["late", ]),
    q10_late = unname(quantile(m["late", ], 0.1)),
    q90_late = unname(quantile(m["late", ], 0.9)),
    sd_itt = sd(m["itt", ]), sd_late = sd(m["late", ]))
}, numeric(7)))
sweep_c <- data.frame(compliance = c_grid, sweep_c)
sweep_c$predicted_itt <- tau_true * c_grid
sweep_c$itt_error <- sweep_c$mean_itt - sweep_c$predicted_itt

print(round(sweep_c[, c("compliance", "mean_itt", "predicted_itt",
                        "itt_error", "mean_pp", "mean_late")], 4))
  compliance mean_itt predicted_itt itt_error mean_pp mean_late
1        0.3   1.2561           1.2    0.0561  3.9944    4.1971
2        0.4   1.5591           1.6   -0.0409  4.0425    3.9038
3        0.5   2.0251           2.0    0.0251  4.0399    4.0398
4        0.6   2.4111           2.4    0.0111  4.0030    4.0232
5        0.7   2.7959           2.8   -0.0041  4.0308    4.0010
6        0.8   3.1808           3.2   -0.0192  4.0264    3.9730
7        0.9   3.6001           3.6    0.0001  4.0109    3.9976
8        1.0   3.9906           4.0   -0.0094  3.9906    3.9906
print(round(sweep_c[, c("compliance", "sd_itt", "sd_late",
                        "q10_late", "q90_late")], 4))
  compliance sd_itt sd_late q10_late q90_late
1        0.3 0.9790  3.2964  -0.0954   8.5011
2        0.4 0.9348  2.3372   0.9567   6.7838
3        0.5 0.9453  1.8642   1.5909   6.3169
4        0.6 0.9316  1.5446   2.0379   5.9821
5        0.7 0.9079  1.2880   2.3147   5.6006
6        0.8 0.9995  1.2387   2.3959   5.5484
7        0.9 0.9653  1.0666   2.6316   5.3092
8        1.0 0.9465  0.9465   2.7791   5.1989
mc_se <- mean(sweep_c$sd_itt) / sqrt(n_rep)
worst_itt <- max(abs(sweep_c$itt_error))
print(round(c(worst_itt_error = worst_itt, mc_se = mc_se,
              worst_in_mc_se_units = worst_itt / mc_se,
              mean_abs_itt_error = mean(abs(sweep_c$itt_error)),
              late_bias_at_lowest = sweep_c$mean_late[1] - tau_true,
              sd_ratio_low_to_high =
                sweep_c$sd_late[1] / sweep_c$sd_late[8]), 4))
     worst_itt_error                mc_se worst_in_mc_se_units 
              0.0561               0.0301               1.8648 
  mean_abs_itt_error  late_bias_at_lowest sd_ratio_low_to_high 
              0.0208               0.1971               3.4827 
lab_e <- c("intention to treat", "per-protocol", "Wald ratio")
est_long <- data.frame(
  compliance = rep(c_grid, 3),
  value = c(sweep_c$mean_itt, sweep_c$mean_pp, sweep_c$mean_late),
  estimator = factor(rep(lab_e, each = length(c_grid)), levels = lab_e))

ggplot(est_long, aes(compliance, value, colour = estimator,
                     shape = estimator)) +
  geom_ribbon(data = sweep_c, inherit.aes = FALSE,
              aes(compliance, ymin = q10_late, ymax = q90_late),
              fill = te_pal$sage, alpha = 0.3) +
  geom_hline(yintercept = tau_true, linetype = "dashed",
             colour = te_pal$ink, linewidth = 0.4) +
  geom_line(data = data.frame(compliance = c_grid,
                              value = tau_true * c_grid,
                              estimator = factor(lab_e[1], levels = lab_e)),
            linetype = "dotted", colour = te_pal$ink, linewidth = 0.5) +
  geom_line(linewidth = 0.7) +
  geom_point(size = 2.3) +
  scale_colour_manual(values = c(te_pal$gold, te_pal$clay, te_pal$forest),
                      name = NULL) +
  scale_shape_manual(values = c(15, 17, 16), name = NULL) +
  coord_cartesian(ylim = c(0, 9)) +
  labs(x = "compliance rate", y = "mean estimate (cm)",
       title = "Only intention to treat moves with the compliance rate") +
  theme_te() +
  theme(legend.position = "bottom")
A plot with the compliance rate from 0.3 to 1 on the horizontal axis and the estimated effect in centimetres on the vertical. A gold line of squares rises steadily from just above 1 at the left to 4 at the right and lies on a dotted straight line through the origin. A dark green line and a red line both run flat at 4 across the whole panel along a dashed horizontal line, overlapping so closely that the red one is mostly hidden underneath the green. A pale green band around them opens out to the left, reaching from the bottom axis up to about 8.5 at the left edge and narrowing to a thin ribbon at the right.
Figure 1: Average of each estimator over 1000 simulated trials against the compliance rate, with failures independent of plot quality. The dotted line is the closed form for the intention-to-treat expectation, the effect multiplied by the compliance rate; the dashed line is the true effect of 4 centimetres. The shaded band is the 10th to 90th percentile of the Wald estimate across trials.

The intention-to-treat column tracks the closed form to 0.0561 centimetres at worst and 0.0208 on average, against a Monte Carlo standard error of 0.0301 on each entry. The worst gap is 1.8648 Monte Carlo standard errors, which is what a maximum over eight independent entries looks like when the underlying error is zero. The attenuation is linear in the compliance rate with nothing left over: at a compliance rate of 0.5 the estimate is half the effect. This is the sense in which intention to treat is honest about non-compliance. It is guaranteed to be too small, by a factor you can look up in your own field notes.

Per-protocol and the Wald ratio both sit on the true effect across the whole range, because in this version the fences that failed were a random subset. That is the situation the next section removes.

The Wald estimate is not free of small-sample trouble. At a compliance rate of 0.3 its mean over 1000 trials is 4.1971, which is 0.1971 above the truth: dividing by an estimated proportion makes this a ratio estimator, and a ratio estimator carries a small-sample bias that grows as its denominator shrinks. The spread matters more than the bias here. The standard deviation of the Wald estimate across trials is 3.2964 at a compliance rate of 0.3 and 0.9465 at full compliance, a factor of 3.4827, which is close to the factor of 3.3333 that one over the compliance rate predicts.

Per-protocol can change the sign of the answer

Random failure is the polite case. Fences do not fail at random: they fail where the ground is soft, and soft ground is wet ground, and wet ground grows a taller sward. That single sentence is enough to break the per-protocol comparison, because the set of plots with a standing fence is now a selected set, chosen by the same variable that drives the outcome.

The simulation makes compliance a logistic function of soil moisture, with slope g_comp controlling how strongly the two are tied. To keep the sweep clean, the intercept is solved at each step so that the average compliance rate stays at 0.6 whatever the slope is, which means the only thing changing along the sweep is the correlation between compliance and plot quality.

a_for <- function(g, target = 0.6) {
  f <- function(a) {
    integrate(function(q) plogis(a - g * q) * dnorm(q), -8, 8)$value - target
  }
  uniroot(f, c(-10, 10), tol = 1e-10)$root
}

set.seed(20260803)
g_grid <- seq(0, 3, length.out = 13)
n_rep_g <- 500

sweep_g <- t(vapply(g_grid, function(g) {
  a <- a_for(g)
  rowMeans(replicate(n_rep_g, {
    s <- sim_field(n_plot, a_comp = a, g_comp = g)
    c(three_est(s),
      rho = cor(s$q[s$z == 1], s$b[s$z == 1]),
      q_gap = mean(s$q[s$d == 1]) - mean(s$q[s$d == 0]))
  }))
}, numeric(6)))
sweep_g <- data.frame(slope = g_grid, sweep_g)

print(round(sweep_g[, c("slope", "rho", "comp", "itt", "pp", "late")], 4))
   slope     rho   comp    itt      pp   late
1   0.00  0.0020 0.5982 2.4063  4.0282 4.0259
2   0.25 -0.1156 0.6010 2.3850  2.9128 3.9649
3   0.50 -0.2309 0.6014 2.3965  1.8521 3.9864
4   0.75 -0.3267 0.6010 2.4189  0.9669 4.0530
5   1.00 -0.4082 0.5993 2.4716  0.2076 4.1428
6   1.25 -0.4738 0.5991 2.4105 -0.4396 4.0594
7   1.50 -0.5215 0.6009 2.4139 -0.8345 4.0448
8   1.75 -0.5644 0.5997 2.4270 -1.2299 4.0809
9   2.00 -0.5984 0.6014 2.3809 -1.5825 3.9928
10  2.25 -0.6257 0.6018 2.3668 -1.8489 3.9652
11  2.50 -0.6500 0.5978 2.3741 -2.0786 4.0140
12  2.75 -0.6650 0.6001 2.3983 -2.2426 4.0288
13  3.00 -0.6780 0.6009 2.4634 -2.2043 4.1420
print(round(sweep_g[, c("slope", "q_gap", "pp")], 4))
   slope   q_gap      pp
1   0.00  0.0027  4.0282
2   0.25 -0.1359  2.9128
3   0.50 -0.2685  1.8521
4   0.75 -0.3804  0.9669
5   1.00 -0.4702  0.2076
6   1.25 -0.5580 -0.4396
7   1.50 -0.6052 -0.8345
8   1.75 -0.6549 -1.2299
9   2.00 -0.6982 -1.5825
10  2.25 -0.7323 -1.8489
11  2.50 -0.7619 -2.0786
12  2.75 -0.7785 -2.2426
13  3.00 -0.7808 -2.2043
mono <- sweep_g[sweep_g$slope <= 2, ]
cross_slope <- approx(mono$pp, mono$slope, xout = 0)$y
cross_rho <- approx(mono$pp, mono$rho, xout = 0)$y
pp_worst <- min(sweep_g$pp)
gap_worst <- sweep_g$q_gap[which.min(sweep_g$pp)]
print(round(c(crossing_slope = cross_slope, crossing_rho = cross_rho,
              pp_at_zero_slope = sweep_g$pp[1],
              pp_worst = pp_worst,
              pp_error_worst = pp_worst - tau_true,
              itt_range = diff(range(sweep_g$itt)),
              late_range = diff(range(sweep_g$late))), 4))
  crossing_slope     crossing_rho pp_at_zero_slope         pp_worst 
          1.0802          -0.4292           4.0282          -2.2426 
  pp_error_worst        itt_range       late_range 
         -6.2426           0.1047           0.1779 
print(round(c(moisture_gap_at_worst = gap_worst,
              gap_times_coefficient = 8 * gap_worst,
              per_protocol_error = pp_worst - tau_true), 4))
moisture_gap_at_worst gap_times_coefficient    per_protocol_error 
              -0.7785               -6.2283               -6.2426 
flip_long <- data.frame(
  rho = rep(sweep_g$rho, 3),
  value = c(sweep_g$itt, sweep_g$pp, sweep_g$late),
  estimator = factor(rep(lab_e, each = nrow(sweep_g)), levels = lab_e))

ggplot(flip_long, aes(rho, value, colour = estimator, shape = estimator)) +
  geom_hline(yintercept = 0, linetype = "dashed", colour = te_pal$ink,
             linewidth = 0.4) +
  geom_line(linewidth = 0.7) +
  geom_point(size = 2.3) +
  annotate("point", x = cross_rho, y = 0, size = 4.2, shape = 21,
           colour = te_pal$clay, fill = NA, stroke = 1.1) +
  scale_x_reverse() +
  scale_colour_manual(values = c(te_pal$gold, te_pal$clay, te_pal$forest),
                      name = NULL) +
  scale_shape_manual(values = c(15, 17, 16), name = NULL) +
  labs(x = "correlation between compliance and soil moisture",
       y = "mean estimate (cm)",
       title = "Per-protocol crosses zero while the other two hold still") +
  theme_te() +
  theme(legend.position = "bottom")
A plot whose horizontal axis is the correlation between compliance and soil moisture, running from zero at the left to about minus 0.68 at the right. A gold line near 2.4 and a dark green line near 4 both run flat and level across the whole width. A red line starts at 4 alongside the green one, falls in a nearly straight diagonal, passes through a dashed horizontal line at zero about three fifths of the way across, and reaches minus 2.2 at the right edge. A red open circle marks the point where the red line meets zero.
Figure 2: The three estimators as the correlation between compliance and soil moisture is made stronger, with the average compliance rate held at 0.6 throughout. Intention to treat and the Wald ratio are unmoved. Per-protocol falls from the true effect, crosses zero, and ends up reporting that exclosures shorten the sward.

At the left edge, where compliance is unrelated to moisture, per-protocol returns 4.0282 and is the best of the three. By the right edge it returns -2.2426, an error of -6.2426 centimetres on an effect of 4. The sign changes at a correlation of -0.4292 between compliance and soil moisture, which is a correlation an ecologist would describe as moderate and would not necessarily notice.

That is the substantive point of the post. A moderate association between whether the treatment stuck and how good the plot was is enough to make the per-protocol analysis report that exclosures reduce sward height when in fact every plot in the simulation would gain 4 centimetres from a working fence. The estimator is not merely attenuated; it is pointing the other way, and it does so with a standard error that looks respectable, because the plots really are different from each other.

Meanwhile the intention-to-treat estimate varies over a range of 0.1047 centimetres across the entire sweep and the Wald estimate over 0.1779. Neither notices. This is worth being precise about, because it is stronger than the usual statement. Selective compliance does not bias intention to treat at all. The derivation above never assumed that \(B_i\) was independent of \(Y_i(0)\): it only needed \(Z_i\) to be randomised, and \(Z_i\) was randomised by you. Selection into compliance moves which plots the effect is averaged over, and that is the subject of the next section, but it cannot contaminate the contrast itself.

The mechanism is visible in the covariate. The gap in mean soil moisture between the plots that ended up fenced and the rest runs from 0.0027 standard deviations at the left of the sweep to -0.7785 where per-protocol is at its worst. Multiplied by the moisture coefficient of 8 that gap is -6.2283 centimetres, against a per-protocol error of -6.2426. The estimator is reporting a moisture contrast with a treatment label on it.

The Wald ratio answers a question about the compliers

So far the effect has been the same 4 centimetres in every plot, which is why the Wald ratio came back with the right answer. Constant effects are a modelling convenience. In this system there is an obvious reason for the effect to vary: wetter plots grow faster and carry more grazing pressure, so keeping the cattle out of them does more. Let the effect be \(\tau_i = 4 + 3q_i\), so the average across the meadow is still 4 but a plot one standard deviation wetter gains 7 centimetres from a fence and a plot one standard deviation drier gains 1.

Now the two facts collide. Fences fail on wet plots, and wet plots are where fencing matters most. The plots that comply are the plots with the least to gain.

set.seed(20260806)
g_het <- 2
a_het <- a_for(g_het, 0.6)
tau_slope <- 3
big <- sim_field(2e5, tau0 = tau_true, tau1 = tau_slope,
                 a_comp = a_het, g_comp = g_het)

ate_true <- mean(big$tau_i)
late_true <- mean(big$tau_i[big$b == 1])
nonc_true <- mean(big$tau_i[big$b == 0])
q_comp <- mean(big$q[big$b == 1])
q_nonc <- mean(big$q[big$b == 0])

print(round(c(population_plots = length(big$q),
              compliance_rate = mean(big$b),
              ate = ate_true, late = late_true,
              non_complier_effect = nonc_true), 4))
   population_plots     compliance_rate                 ate                late 
         2.0000e+05          6.0050e-01          4.0028e+00          2.5443e+00 
non_complier_effect 
         6.1954e+00 
print(round(c(late_minus_ate = late_true - ate_true,
              percent_below_ate = 100 * (late_true - ate_true) / ate_true,
              complier_minus_noncomplier = late_true - nonc_true,
              mean_moisture_compliers = q_comp,
              mean_moisture_failures = q_nonc), 4))
            late_minus_ate          percent_below_ate 
                   -1.4585                   -36.4367 
complier_minus_noncomplier    mean_moisture_compliers 
                   -3.6511                    -0.4852 
    mean_moisture_failures 
                    0.7318 
n_het <- 3000
het <- rowMeans(replicate(n_het, three_est(
  sim_field(n_plot, tau0 = tau_true, tau1 = tau_slope,
            a_comp = a_het, g_comp = g_het))))
print(round(c(mean_itt = het[["itt"]], mean_pp = het[["pp"]],
              mean_wald = het[["late"]],
              wald_minus_late = het[["late"]] - late_true,
              itt_minus_pi_times_late =
                het[["itt"]] - mean(big$b) * late_true,
              itt_if_it_tracked_ate = mean(big$b) * ate_true), 4))
               mean_itt                 mean_pp               mean_wald 
                 1.5038                 -3.0474                  2.5382 
        wald_minus_late itt_minus_pi_times_late   itt_if_it_tracked_ate 
                -0.0061                 -0.0241                  2.4038 
idx <- sample(seq_along(big$q), 40000)
dens_of <- function(keep, lab) {
  dd <- density(big$tau_i[idx][keep], n = 512)
  data.frame(x = dd$x, y = dd$y, group = lab)
}
keep_c <- big$b[idx] == 1
dens_dat <- rbind(dens_of(keep_c, "fence would hold (compliers)"),
                  dens_of(!keep_c, "fence would fail"))
dens_dat$group <- factor(dens_dat$group,
                         levels = c("fence would hold (compliers)",
                                    "fence would fail"))

ggplot(dens_dat, aes(x, y, colour = group, fill = group)) +
  geom_area(position = "identity", alpha = 0.35, colour = NA) +
  geom_line(linewidth = 0.8) +
  geom_vline(xintercept = ate_true, colour = te_pal$ink, linewidth = 0.6) +
  geom_vline(xintercept = late_true, colour = te_pal$forest,
             linetype = "dashed", linewidth = 0.6) +
  annotate("text", x = ate_true + 0.3, y = 0.198, hjust = 0, size = 3.3,
           colour = te_pal$ink, label = "average over all plots") +
  annotate("text", x = late_true - 0.3, y = 0.198, hjust = 1, size = 3.3,
           colour = te_pal$forest, label = "complier average") +
  expand_limits(y = 0.21) +
  scale_colour_manual(values = c(te_pal$forest, te_pal$clay), name = NULL) +
  scale_fill_manual(values = c(te_pal$forest, te_pal$clay), name = NULL) +
  labs(x = "true effect of a working exclosure (cm)", y = "density",
       title = "The compliers are the plots with the least to gain") +
  theme_te() +
  theme(legend.position = "bottom")
Two broad overlapping density curves on a horizontal axis of true per-plot effect in centimetres. A dark green filled curve for the plots whose fence would hold peaks near 2.5; a red filled curve for the plots whose fence would fail peaks a little above 5, slightly higher, and trails off to the right. The two overlap heavily in the middle, where the fills darken. A solid vertical line at 4, labelled at the top as the average over all plots, falls between the two peaks, and a dashed vertical line labelled complier average sits on the peak of the green curve.
Figure 3: Distribution of the true per-plot effect across the meadow, split by whether that plot’s fence would have held. The compliers sit to the left because they are the drier plots, and the effect the Wald ratio recovers is the mean of the left-hand distribution, not the mean of the two together.

Averaged over the whole meadow the effect of a working exclosure is 4.0028 centimetres, which is what the simulation was told to do. Averaged over the plots whose fence would hold it is 2.5443. Averaged over the plots whose fence would fail it is 6.1954. The mean soil moisture of the two groups is -0.4852 and 0.7318 standard deviations, which is where the difference comes from.

The Wald ratio over 3000 simulated trials averages 2.5382, missing the complier average by -0.0061 and sitting 1.4585 centimetres below the average over all plots, which is 36.4367 per cent of it. The estimator did nothing wrong. It estimated the local average treatment effect, which is what Imbens and Angrist (1994) named it and what Angrist, Imbens and Rubin (1996) set out the conditions for, and the local part is not decoration. The trial has no information about the plots that lost their fences, because it never observed a working fence on any of them.

The same arithmetic explains the intention-to-treat number. It averages 1.5038, which is the compliance rate times the complier effect, 1.528, and not the compliance rate times the average effect over all plots, 2.4038. Both factors in that product moved.

The reporting consequence is concrete. If the paper says “fencing raised sward height by 2.54 centimetres”, the sentence is true of one subpopulation and false of the meadow, and the subpopulation is defined by something that was not measured directly: whether the fence would have held. Naming it costs one clause. What the trial estimates is the effect among plots where the exclosure survived the season, which here are the drier plots, and the effect on the wetter plots is larger and unmeasured. That clause also tells a reader why a scheme rolled out everywhere might do better than the trial suggested, or worse, depending on which way the selection ran.

There is one more case to note before leaving the ratio. If control plots can also pick up the treatment, the denominator is no longer a single compliance rate but the difference in receipt rates between the arms.

set.seed(20260807)
rate_treated_arm <- 0.7
rate_control_arm <- 0.15

sim_two_sided <- function(n, c1, c0, tau = tau_true) {
  q <- rnorm(n)
  z <- sample(rep(0:1, each = n / 2))
  d <- rbinom(n, 1, ifelse(z == 1, c1, c0))
  y <- 30 + 8 * q + tau * d + rnorm(n, 0, 5)
  itt <- mean(y[z == 1]) - mean(y[z == 0])
  denom <- mean(d[z == 1]) - mean(d[z == 0])
  c(itt = itt, denom = denom, wald = itt / denom)
}
n_ts <- 3000
ts <- rowMeans(replicate(n_ts, sim_two_sided(n_plot, rate_treated_arm,
                                             rate_control_arm)))
print(round(c(receipt_rate_treated_arm = rate_treated_arm,
              receipt_rate_control_arm = rate_control_arm,
              mean_itt = ts[["itt"]], mean_denominator = ts[["denom"]],
              mean_wald = ts[["wald"]], true_effect = tau_true), 4))
receipt_rate_treated_arm receipt_rate_control_arm                 mean_itt 
                  0.7000                   0.1500                   2.1993 
        mean_denominator                mean_wald              true_effect 
                  0.5508                   3.9921                   4.0000 

With a receipt rate of 0.7 in the assigned arm and 0.15 in the control arm, the denominator averages 0.5508 and the Wald ratio averages 3.9921 against a true 4. The estimator still works, and it now needs an assumption it did not need before: no plot is fenced because it was assigned to control and unfenced because it was assigned to treatment. Defiers of that kind are rare in ecology but not unimaginable, since a contrary landowner is a real thing.

Compliance is a sample size problem before it is an estimation problem

A design that expects some non-compliance needs to be sized for it, and the sizing is where the intuition most often goes wrong. The intention-to-treat effect is \(\pi\tau\) and the standard error of a two-group mean difference with \(n\) plots split evenly is \(2\sigma/\sqrt{n}\), so the sample size for power \(1-\beta\) at level \(\alpha\) is

\[n = \frac{4\sigma^{2}(z_{1-\alpha/2} + z_{1-\beta})^{2}}{(\pi\tau)^{2}}\]

and the compliance rate enters squared. Seventy per cent compliance does not cost thirty per cent of the design. It costs a factor of \(1/0.7^2\), which is a little over two.

The sweep below computes that closed form and then ignores it, searching for the sample size that actually delivers 80 per cent power by simulation at each compliance rate.

set.seed(20260808)
pow_at <- function(n, cc, nsim, tau = tau_true, beta_q = 8, sigma = 5) {
  half <- n / 2
  z <- rep(0:1, each = half)
  crit <- qnorm(0.975)
  mean(replicate(nsim, {
    q <- rnorm(n)
    y <- 30 + beta_q * q + tau * z * rbinom(n, 1, cc) + rnorm(n, 0, sigma)
    y1 <- y[z == 1]
    y0 <- y[z == 0]
    abs(mean(y1) - mean(y0)) /
      sqrt(var(y1) / half + var(y0) / half) > crit
  }))
}

z_sum <- qnorm(0.975) + qnorm(0.8)
var_tot <- 8^2 + 5^2
n_closed <- function(cc) 4 * var_tot * z_sum^2 / (cc * tau_true)^2

c_pow <- c(0.4, 0.5, 0.6, 0.7, 0.85, 1)
pw <- t(vapply(c_pow, function(cc) {
  n_a <- n_closed(cc)
  n_try <- 2 * round(n_a * c(0.55, 0.75, 0.9, 1.05, 1.25, 1.5) / 2)
  p_try <- vapply(n_try, pow_at, numeric(1), cc = cc, nsim = 1200)
  c(n_closed = n_a, n_simulated = approx(p_try, n_try, xout = 0.8)$y,
    power_at_closed = pow_at(2 * round(n_a / 2), cc, 2000))
}, numeric(3)))
pw <- data.frame(compliance = c_pow, pw)
pw$ratio <- pw$n_simulated / pw$n_closed
pw$extra_vs_full <- pw$n_simulated / pw$n_simulated[nrow(pw)]

print(round(pw[, c("compliance", "n_closed", "n_simulated",
                   "power_at_closed", "ratio", "extra_vs_full")], 3))
  compliance n_closed n_simulated power_at_closed ratio extra_vs_full
1       0.40 1091.485    1181.592           0.792 1.083         6.709
2       0.50  698.550     731.644           0.797 1.047         4.154
3       0.60  485.104     499.275           0.782 1.029         2.835
4       0.70  356.403     369.846           0.782 1.038         2.100
5       0.85  241.713     247.408           0.808 1.024         1.405
6       1.00  174.638     176.112           0.794 1.008         1.000
slope_pw <- unname(coef(lm(log(n_simulated) ~ log(compliance),
                           data = pw))[2])
print(round(c(fitted_log_log_slope = slope_pw,
              closed_form_slope = -2,
              plots_at_full_compliance = pw$n_simulated[6],
              plots_at_70_percent = pw$n_simulated[4],
              multiplier_at_70 = pw$extra_vs_full[4],
              one_over_c_squared_at_70 = 1 / 0.7^2), 4))
    fitted_log_log_slope        closed_form_slope plots_at_full_compliance 
                 -2.0668                  -2.0000                 176.1124 
     plots_at_70_percent         multiplier_at_70 one_over_c_squared_at_70 
                369.8462                   2.1001                   2.0408 
c_fine <- seq(0.38, 1.02, length.out = 120)
curves <- rbind(
  data.frame(compliance = c_fine, n = n_closed(c_fine),
             rule = "closed form, one over compliance squared"),
  data.frame(compliance = c_fine,
             n = pw$n_closed[6] / c_fine,
             rule = "one over compliance"))

ggplot(curves, aes(compliance, n, colour = rule, linetype = rule)) +
  geom_line(linewidth = 0.8) +
  geom_point(data = pw, inherit.aes = FALSE,
             aes(compliance, n_simulated), colour = te_pal$gold,
             size = 2.8, shape = 15) +
  scale_x_log10(breaks = c(0.4, 0.5, 0.6, 0.7, 0.85, 1)) +
  scale_y_log10(breaks = c(150, 250, 400, 700, 1200)) +
  scale_colour_manual(values = c(te_pal$forest, te_pal$clay), name = NULL) +
  scale_linetype_manual(values = c("solid", "22"), name = NULL) +
  guides(colour = guide_legend(nrow = 2), linetype = guide_legend(nrow = 2)) +
  labs(x = "compliance rate", y = "plots needed for 80 per cent power",
       title = "The cost of non-compliance is quadratic") +
  theme_te() +
  theme(legend.position = "bottom")
Log-log plot with the compliance rate from 0.4 to 1 on the horizontal axis and the number of plots on the vertical. A solid dark green line descends in a straight diagonal from about 1200 at the left edge to about 175 at the right, with gold square points sitting on it or a little above it. A red dashed line starts much lower, near 460, and runs at half the slope to meet the green line at the right-hand edge, so the vertical gap between them widens steadily from right to left.
Figure 4: Plots needed for 80 per cent power against the compliance rate, on log axes. Points are found by simulation; the solid curve is the closed form, falling as one over the compliance rate squared; the dashed curve is what a one over the compliance rate rule would predict, anchored at full compliance.

Fitting a line through the logarithms gives a slope of -2.0668 against the closed form’s exact minus two, so the quadratic rule is what the simulation finds. In plots: 176 at full compliance, 370 at seventy per cent, a multiplier of 2.1001 where one over the compliance rate would have predicted 1.4286 and one over its square predicts 2.0408. At forty per cent compliance the same effect needs 1182 plots, 6.7093 times the full-compliance design.

The closed form is slightly optimistic, and it is worth knowing by how much. The simulated requirement is between 1.0084 and 1.0826 times the formula, and the empirical power at the formula’s sample size comes out at 0.782 to 0.808 rather than 0.8. The reason is that partial compliance adds variance to the assigned-treated arm that full compliance does not: within that arm, some plots got 4 centimetres and some got nothing, which contributes \(\tau^2\pi(1-\pi)\) to its variance. The formula above uses one common \(\sigma^2\) and misses it. The correction is small next to the factor the compliance rate itself contributes, but it means a power calculation done this way should be treated as a floor.

This is the number to put in front of whoever is deciding how many plots to fence. Spending on fence quality, or on a design that puts the exclosures where they can be maintained, buys sample size at a quadratic rate, and it is usually cheaper than the plots.

What you can check, and what you cannot

Everything above depended on the received-treatment column existing. Two diagnostics come with it, and neither is expensive.

The first is the compliance rate itself, with an interval, reported next to the effect. It is the denominator of the Wald ratio and the multiplier on the sample size, and a reader cannot reconstruct either without it.

The second is the balance check the sign flip section was crying out for: within the assigned-treated arm only, compare the plots that complied against the plots that did not, on every covariate you have. Randomisation says nothing about that comparison, because compliance was not randomised, so any difference is real and is exactly the thing that breaks per-protocol.

set.seed(20260809)
n_bal <- 1000
balance_p <- replicate(n_bal, {
  s <- sim_field(n_plot, a_comp = a_for(cross_slope), g_comp = cross_slope)
  arm <- s$z == 1
  t.test(s$q[arm & s$b == 1], s$q[arm & s$b == 0])$p.value
})
det_rate <- mean(balance_p < 0.05)

set.seed(20260810)
cover_naive <- function(n, cc = comp_rate, tau = tau_true) {
  half <- n / 2
  z <- rep(0:1, each = half)
  q <- rnorm(n)
  y <- 30 + 8 * q + tau * z * rbinom(n, 1, cc) + rnorm(n, 0, 5)
  y1 <- y[z == 1]
  y0 <- y[z == 0]
  e <- mean(y1) - mean(y0)
  s <- sqrt(var(y1) / half + var(y0) / half)
  c(covers_tau = abs(e - tau) < qnorm(0.975) * s,
    covers_itt = abs(e - cc * tau) < qnorm(0.975) * s)
}
n_cov <- c(n_plot, 4 * n_plot, 16 * n_plot)
cv <- t(vapply(n_cov, function(nn)
  rowMeans(replicate(1500, cover_naive(nn))), numeric(2)))
cv <- data.frame(plots = n_cov, cv)

set.seed(20260811)
var_hits <- replicate(2000, {
  s <- sim_field(n_plot, a_comp = qlogis(comp_rate))
  var.test(s$y[s$z == 1], s$y[s$z == 0])$p.value < 0.05
})
var_power <- mean(var_hits)

print(round(c(balance_test_slope = cross_slope,
              detection_rate_at_5pct = det_rate), 4))
    balance_test_slope detection_rate_at_5pct 
                1.0802                 1.0000 
print(signif(c(median_p_value = median(balance_p),
               largest_p_value = max(balance_p)), 3))
 median_p_value largest_p_value 
       2.85e-10        6.04e-04 
print(round(cv, 4))
  plots covers_tau covers_itt
1   400     0.6013     0.9507
2  1600     0.0853     0.9413
3  6400     0.0000     0.9453
print(round(c(coverage_drop = cv$covers_tau[1] - cv$covers_tau[3],
              itt_coverage_range = diff(range(cv$covers_itt))), 4))
     coverage_drop itt_coverage_range 
            0.6013             0.0093 
print(round(c(extra_variance_in_treated_arm =
                tau_true^2 * comp_rate * (1 - comp_rate),
              total_variance = var_tot,
              variance_ratio = 1 + tau_true^2 * comp_rate *
                (1 - comp_rate) / var_tot,
              var_test_detection_rate = var_power), 4))
extra_variance_in_treated_arm                total_variance 
                       3.8400                       89.0000 
               variance_ratio       var_test_detection_rate 
                       1.0431                        0.0610 

At the compliance-moisture slope where per-protocol crosses zero, a two-sample test of soil moisture between compliers and failures inside the treated arm has a p-value below 0.05 in 100 per cent of simulated trials, with a median p-value of 2.85e-10 and a largest p-value across 1000 trials of 6.04e-04. The check that would have caught the sign flip is a one-line t.test and it did not miss once.

Now the case where the column does not exist. Suppose nobody walked the fences, or the walk happened and was not written down. The analysis then has z and y and nothing else, and the intention-to-treat estimate is the only thing available. It is a perfectly good estimate of the effect of assignment. The trouble is that it will be read as the effect of fencing, and the second table shows what that costs.

The naive interval covers the intention-to-treat quantity 0.9507 of the time at 400 plots and 0.9453 at 6400, which is nominal at every size. Its coverage of the actual treatment effect is 0.6013 at 400 plots, 0.0853 at 1600, and 0 at 6400. Collecting more data makes it worse, which is the signature of a bias rather than noise: the interval shrinks around a point that is 1.6 centimetres away and eventually excludes the truth every time.

Nothing in the diagnostic output of that analysis complains. The residuals are fine, the randomisation check on the covariates passes because assignment really was random, the model is correctly specified for the data it was given. The only trace non-compliance leaves is the extra variance it puts in the assigned-treated arm, which for these parameters is \(\tau^2\pi(1-\pi) =\) 3.84 against a total variance of 89, a ratio of 1.0431. An F test comparing the two arms’ variances picks that up in 6.1 per cent of trials at 400 plots, against the 5 per cent it would fire at by chance, so it is a trace and not a diagnostic. If receipt is not recorded, non-compliance is not in the data at all, and the estimate is quietly attenuated by a factor nobody can name.

That is the practical instruction and it is a field-notebook instruction rather than a statistical one. Record what actually happened to each plot, at the plot level, at the end of the season. It converts an unknowable attenuation into a known one.

What to take away

Three estimators, one dataset, and they answer different questions. Intention to treat converges to the compliance rate times the effect among compliers, which the sweep confirmed to 0.0561 centimetres across compliance rates from 0.3 to 1. It is attenuated on purpose and by a known factor, and selective compliance does not touch it: over a sweep that dragged per-protocol from 4 to -2.2426, the intention-to-treat mean moved by 0.1047 centimetres in total.

Per-protocol is the one to avoid. When compliance was random it was fine; when compliance correlated with soil moisture at -0.4292 it reported zero effect, and at its worst it reported -2.2426 centimetres, the wrong sign on an effect of 4 present in every plot. The mechanism is that at that point the plots which kept their fences differed from the rest by -0.7785 standard deviations of soil moisture, and eight centimetres per standard deviation of moisture is enough to swamp a four-centimetre treatment.

The Wald ratio is unbiased for something, and the something has a name. With the effect varying across the meadow it returned 2.5382 against a complier average of 2.5443 and an all-plot average of 4.0028: 36.4367 per cent below the number a reader will assume it is. The plots that lost their fences had an average effect of 6.1954, and the trial contains no information about them.

And compliance is a design parameter with a quadratic price. The simulated sample size fell with the compliance rate at a fitted exponent of -2.0668, so seventy per cent compliance needs 2.1001 times the plots and forty per cent needs 6.7093 times.

The honest limit is where the whole apparatus rests. Every number in this post came from knowing which plots received the treatment, and the Wald ratio needs that column even more than the others do, because the column is its denominator. Without it, the analysis is not wrong, it is attenuated by an unknown factor between zero and one, and the coverage table is the demonstration: at 6400 plots the interval covered the true effect 0 of the time while looking exactly like an interval that covers 0.9453 of the time. There is a second limit the simulation cannot see past either. The derivation assumed a plot responds only to its own treatment, and an exclosure that concentrates grazing onto its unfenced neighbours breaks that assumption before any of this arithmetic starts: see interference between experimental plots for what that does.

References

Angrist JD, Imbens GW, Rubin DB 1996 Journal of the American Statistical Association 91(434):444-455 (10.1080/01621459.1996.10476902)

Imbens GW, Angrist JD 1994 Econometrica 62(2):467-475 (10.2307/2951620)

Bloom HS 1984 Evaluation Review 8(2):225-246 (10.1177/0193841X8400800205)

Kimmel K, Dee LE, Avolio ML, Ferraro PJ 2021 Trends in Ecology and Evolution 36(12):1141-1152 (10.1016/j.tree.2021.08.008)

Christie AP, Amano T, Martin PA, Shackelford GE, Simmons BI, Sutherland WJ 2019 Journal of Applied Ecology 56(12):2742-2754 (10.1111/1365-2664.13499)

Baylis K, Honey-Roses J, Borner J, Corbera E, Ezzine-de-Blas D, Ferraro PJ, Lapeyre R, Persson UM, Pfaff A, Wunder S 2016 Conservation Letters 9(1):58-64 (10.1111/conl.12180)

Ferraro PJ, Pattanayak SK 2006 PLoS Biology 4(4):e105 (10.1371/journal.pbio.0040105)

Gerber AS, Green DP 2012 Field Experiments: Design, Analysis, and Interpretation (ISBN 978-0-393-97995-4)

Hernan MA, Robins JM 2020 Causal Inference: What If (ISBN 978-1-4200-7616-5)

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.