Comparing significance is not a test

R
interactions
hypothesis testing
statistical power
ecology tutorial
Significant at one site and not at the other is not evidence that the sites differ. Measuring the fallacy rate in R, and the interaction test that replaces it.
Author

Tidy Ecology

Published

2026-08-04

An open-top chamber experiment, run for three seasons at two sites on the same mountain: one at six hundred metres in the beech belt, one at seventeen hundred metres just above the treeline. At each site thirty seedlings sit inside chambers and thirty outside, and the response is height growth over one season, in millimetres. The design is the same at both sites, the species is the same, the seedlings came from the same nursery batch.

The results section reports two tests. At the low site, warming raised growth and the p value was below five hundredths. At the high site, warming raised growth by a similar amount and the p value was above five hundredths. The discussion then says that the warming response depends on elevation, and offers a mechanism: soil moisture is the limiting factor low down, temperature high up, so the same warming buys different things at different elevations.

Nothing in that sequence tested the claim. Two separate verdicts were compared, and a comparison of verdicts is not a test of a difference. The quantity the discussion is talking about, the amount by which the warming effect at one site exceeds the warming effect at the other, was never estimated and never given an interval. Gelman and Stern (2006) put the point in a title that says the whole thing: the difference between significant and not significant is not itself statistically significant. Nieuwenhuis, Forstmann and Wagenmakers (2011) then counted how often the error is made in practice, going through five years of one literature and finding the comparison of two verdicts used in place of the interaction test in about half the papers where both were available.

This post measures the error rather than describing it. Everything below runs on synthetic data where the true warming effect is set by hand, so every rate is a distance from a known value. Four things get measured: how often two independent tests split their verdicts when the true effect is identical at both sites, what significance level the eyeball rule of non-overlapping error bars actually corresponds to, how often the opposite failure happens (two verdicts that agree while the effects genuinely differ), and how much larger a sample the correct test needs.

It sits next to four posts and repeats none of them. Interaction terms in ecological GLMs teaches how to read an interaction once you have fitted one, through predictions rather than off the coefficient table; this post is about the situation where nobody fitted one. Marginal means and contrasts after a GLM is the machinery for testing a specific difference directly, and the contrast built by hand below is exactly that machinery applied to the narrowest possible case, two slopes and their difference. Effect plots that show the data is the graphical form of the same mistake: two error bars on a page, read as a test they were never able to perform. And term order in unbalanced factorial ANOVA is the other member of the pair, where the test you ran is not the test you meant because the software chose a decomposition you did not.

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

One experiment, two verdicts

The generating model is as plain as it can be. Four cells of thirty seedlings, a residual standard deviation of twelve millimetres, a baseline that differs between sites because the high site is colder, and a warming effect that is set to the same value at both sites. The effect is half a residual standard deviation, which is six millimetres of extra growth: an ordinary size for a warming experiment and, at thirty seedlings a cell, an effect this design finds slightly less often than it misses.

n_cell <- 30
sigma_r <- 12
d_true <- 0.5
base_low <- 46
base_high <- 31

mk_frame <- function(seed, nc, d_low, d_high, sg = sigma_r,
                     b_low = base_low, b_high = base_high) {
  set.seed(seed)
  site <- factor(rep(c("low", "high"), each = 2 * nc),
                 levels = c("low", "high"))
  warming <- factor(rep(rep(c("ambient", "warmed"), each = nc), 2),
                    levels = c("ambient", "warmed"))
  mu <- c(rep(b_low, nc), rep(b_low + d_low * sg, nc),
          rep(b_high, nc), rep(b_high + d_high * sg, nc))
  data.frame(site = site, warming = warming,
             growth = rnorm(4 * nc, mu, sg))
}

print(c(seedlings_per_cell = n_cell, seedlings_total = 4 * n_cell,
        residual_sd_mm = sigma_r, effect_sd_units = d_true,
        effect_mm = d_true * sigma_r,
        true_interaction_mm = 0))
 seedlings_per_cell     seedlings_total      residual_sd_mm     effect_sd_units 
               30.0               120.0                12.0                 0.5 
          effect_mm true_interaction_mm 
                6.0                 0.0 

The warming effect is 6 millimetres at the low site and 6 millimetres at the high site, so the true interaction is exactly 0. Any difference between the two sites in what follows is noise.

Rather than pick a dataset that makes the point, the code below walks forward from a fixed starting seed and stops at the first draw in which the two within-site tests disagree. That takes a stated number of tries, and the number is part of the finding: the split verdict is not a rare event that needs hunting for.

split_ps <- function(dd) {
  c(low = t.test(growth ~ warming, data = dd[dd$site == "low", ],
                 var.equal = TRUE)$p.value,
    high = t.test(growth ~ warming, data = dd[dd$site == "high", ],
                  var.equal = TRUE)$p.value)
}

seed_start <- 20260804
draw_no <- 0
repeat {
  dat <- mk_frame(seed_start + draw_no, n_cell, d_true, d_true)
  pp <- split_ps(dat)
  if (xor(pp[1] < 0.05, pp[2] < 0.05)) break
  draw_no <- draw_no + 1
}
draws_needed <- draw_no + 1

fit_full <- lm(growth ~ warming * site, data = dat)
print(round(summary(fit_full)$coefficients, 4))
                       Estimate Std. Error t value Pr(>|t|)
(Intercept)             50.3600     2.2783 22.1045   0.0000
warmingwarmed            4.1134     3.2220  1.2767   0.2043
sitehigh               -21.1079     3.2220 -6.5512   0.0000
warmingwarmed:sitehigh   5.1957     4.5566  1.1403   0.2565
print(signif(pp, 4))
     low     high 
0.192300 0.006894 
print(c(draws_needed = draws_needed, seed_used = seed_start + draw_no))
draws_needed    seed_used 
           2     20260805 

The loop needed 2 draws to find a split. The site that came out significant was the high one, p 0.0069, and the low site did not, p 0.1923. Written up in the ordinary way that reads as warming mattering above the treeline and not below it, with a ready mechanism about snow cover and season length waiting to be attached to it. The true effect is the same at both sites, and which site wins is a coin toss.

The interaction coefficient in the same fitted model is 5.196 millimetres with a standard error of 4.557 and a p value of 0.257. The test of the thing the discussion wants to claim was available in the same one-line model fit, and it says there is no evidence for the claim.

eff_of <- function(dd, which_site) {
  sub_dat <- dd[dd$site == which_site, ]
  tt <- t.test(growth ~ warming, data = sub_dat, var.equal = TRUE)
  c(est = unname(diff(tt$estimate)), lo = -tt$conf.int[2],
    hi = -tt$conf.int[1], p = tt$p.value)
}
e_low <- eff_of(dat, "low")
e_high <- eff_of(dat, "high")

se_low <- (e_low[["hi"]] - e_low[["lo"]]) / (2 * qt(0.975, 2 * n_cell - 2))
se_high <- (e_high[["hi"]] - e_high[["lo"]]) / (2 * qt(0.975, 2 * n_cell - 2))
se_gap <- sqrt(se_low^2 + se_high^2)
gap_est <- e_low[["est"]] - e_high[["est"]]

print(round(rbind(low = e_low, high = e_high), 4))
        est      lo      hi      p
low  4.1134 -2.1286 10.3553 0.1923
high 9.3090  2.6585 15.9595 0.0069
print(round(c(se_low = se_low, se_high = se_high,
              se_of_difference = se_gap,
              sum_of_the_two_ses = se_low + se_high,
              difference_mm = gap_est,
              difference_in_its_own_se = gap_est / se_gap), 4))
                  se_low                  se_high         se_of_difference 
                  3.1183                   3.3224                   4.5566 
      sum_of_the_two_ses            difference_mm difference_in_its_own_se 
                  6.4407                  -5.1957                  -1.1403 

Three numbers from that block carry the whole argument. The two site effects have standard errors of 3.118 and 3.322 millimetres. The difference between them has a standard error of 4.557, which is neither of those and is not their sum of 6.441 either. And the difference itself is -5.196 millimetres, which is 1.14 of its own standard errors: nowhere near the two it would need. The low site, the one that did not reach significance, in fact has the smaller estimated effect of the two, so the split verdict is at least pointing the way the point estimates point. It is pointing at a gap the data cannot resolve.

Three horizontal error bars on warm off-white paper, stacked, with a dashed vertical line at zero millimetres. The top bar, labelled warming effect at the low site, is centred at about four millimetres and its left end crosses well past zero into negative growth. The middle bar, labelled warming effect at the high site, is centred at about nine millimetres and its left end stops clearly to the right of zero. The bottom bar, labelled difference between the two effects, is centred near minus five millimetres, is drawn in red, and is much longer than the other two, stretching from about minus fourteen to plus four.
Figure 1: The two site-specific warming effects and their difference, each with a 95 per cent interval, from the single dataset above. The high-elevation interval clears zero and the low-elevation one does not, which is what the results section reported. The difference between the two effects, the quantity the discussion actually claims, sits on top of zero with an interval more than twice as wide as either of the others.

How often the verdicts split when nothing differs

One dataset settles nothing, so the whole design gets run four thousand times with the warming effect held identical at both sites. Each replicate draws four cells of thirty seedlings, runs the two within-site tests, and runs the interaction test on the same data. The simulator works on the cell means and the pooled sums of squares directly rather than calling lm four thousand times, which is the same arithmetic at a fraction of the cost.

sim_two_site <- function(seed, nc, d_low, d_high, n_rep, sg = sigma_r) {
  set.seed(seed)
  cell <- function(mu) {
    y <- matrix(rnorm(nc * n_rep, mu, sg), nc, n_rep)
    list(m = colMeans(y), ss = colSums((y - rep(colMeans(y), each = nc))^2))
  }
  a0 <- cell(0); a1 <- cell(d_low * sg)
  b0 <- cell(0); b1 <- cell(d_high * sg)
  dfw <- 2 * nc - 2
  dfi <- 4 * nc - 4
  s_low <- sqrt((a0$ss + a1$ss) / dfw)
  s_high <- sqrt((b0$ss + b1$ss) / dfw)
  s_pool <- sqrt((a0$ss + a1$ss + b0$ss + b1$ss) / dfi)
  e_lo <- a1$m - a0$m
  e_hi <- b1$m - b0$m
  se_lo <- s_low * sqrt(2 / nc)
  se_hi <- s_high * sqrt(2 / nc)
  se_dif <- s_pool * sqrt(4 / nc)
  data.frame(e_lo = e_lo, se_lo = se_lo,
             p_lo = 2 * pt(-abs(e_lo / se_lo), dfw),
             e_hi = e_hi, se_hi = se_hi,
             p_hi = 2 * pt(-abs(e_hi / se_hi), dfw),
             e_dif = e_lo - e_hi, se_dif = se_dif,
             p_dif = 2 * pt(-abs((e_lo - e_hi) / se_dif), dfi))
}

n_rep_main <- 4000
run_null <- sim_two_site(20260804, n_cell, d_true, d_true, n_rep_main)
sig_lo <- run_null$p_lo < 0.05
sig_hi <- run_null$p_hi < 0.05

rate_one <- mean(xor(sig_lo, sig_hi))
rate_both <- mean(sig_lo & sig_hi)
rate_none <- mean(!sig_lo & !sig_hi)
rate_int <- mean(run_null$p_dif < 0.05)
pow_site <- mean(c(sig_lo, sig_hi))

print(round(c(replicates = n_rep_main,
              power_per_site = pow_site,
              significant_at_exactly_one = rate_one,
              significant_at_both = rate_both,
              significant_at_neither = rate_none,
              interaction_test_rejects = rate_int), 5))
                replicates             power_per_site 
                4000.00000                    0.47513 
significant_at_exactly_one        significant_at_both 
                   0.50225                    0.22400 
    significant_at_neither   interaction_test_rejects 
                   0.27375                    0.04700 
print(round(c(two_p_one_minus_p = 2 * pow_site * (1 - pow_site),
              mc_se_of_rate_one = sqrt(rate_one * (1 - rate_one) / n_rep_main),
              mc_se_of_rate_int = sqrt(rate_int * (1 - rate_int) / n_rep_main)),
            5))
two_p_one_minus_p mc_se_of_rate_one mc_se_of_rate_int 
          0.49876           0.00791           0.00335 

With the true effect identical at both sites, the two within-site tests give different verdicts in 50.22 per cent of experiments. That is the headline number. Half of all such studies produce the raw material for a sentence about the response depending on elevation, in a world where it does not depend on elevation at all.

The value is not a coincidence of this design. Each site’s test rejects with probability 0.4751, and the two tests are independent because they use different seedlings, so the chance that exactly one of them rejects is \(2p(1-p) =\) 0.4988, against the measured 0.5022 with a Monte Carlo standard error of 0.0079. The function \(2p(1-p)\) has its maximum at \(p = 1/2\) and its maximum value is one half, so no design can push this above fifty per cent, and any design whose per-site power sits anywhere near one half is close to the worst case.

Meanwhile the interaction test on the same four thousand datasets rejects 4.7 per cent of the time, against a nominal five. It is not that the correct test is unavailable or hard. It is sitting in the same model fit, doing exactly what it is supposed to do, while the comparison of verdicts runs at ten times its error rate.

The rate depends on power, and it peaks where designs live

If the split-verdict rate is \(2p(1-p)\) then it is a function of per-site power alone, which means it can be traced across the whole range of true effect sizes. At a true effect of zero both tests reject at five per cent, so the split rate is about a tenth. At a large true effect both tests reject almost always, so the split rate falls back towards zero. In between there is a maximum, and the maximum is exactly where a well-designed but not lavishly funded ecological experiment tends to sit.

pow_within <- function(d, nc) {
  dfw <- 2 * nc - 2
  tc <- qt(0.975, dfw)
  ncp <- d * sqrt(nc / 2)
  pt(-tc, dfw, ncp) + pt(tc, dfw, ncp, lower.tail = FALSE)
}

d_grid <- seq(0, 1.4, by = 0.1)
n_rep_curve <- 2000
curve_tab <- as.data.frame(t(vapply(seq_along(d_grid), function(j) {
  rr <- sim_two_site(31000 + j, n_cell, d_grid[j], d_grid[j], n_rep_curve)
  a <- rr$p_lo < 0.05
  b <- rr$p_hi < 0.05
  c(d = d_grid[j], power = mean(c(a, b)), one = mean(xor(a, b)),
    both = mean(a & b), inter = mean(rr$p_dif < 0.05))
}, numeric(5))))
curve_tab$power_th <- pow_within(curve_tab$d, n_cell)
curve_tab$one_th <- 2 * curve_tab$power_th * (1 - curve_tab$power_th)
print(round(curve_tab, 4))
     d  power    one   both  inter power_th one_th
1  0.0 0.0435 0.0810 0.0030 0.0415   0.0500 0.0950
2  0.1 0.0638 0.1195 0.0040 0.0425   0.0668 0.1246
3  0.2 0.1227 0.2115 0.0170 0.0500   0.1187 0.2092
4  0.3 0.2108 0.3355 0.0430 0.0490   0.2079 0.3293
5  0.4 0.3242 0.4305 0.1090 0.0495   0.3315 0.4432
6  0.5 0.4790 0.4990 0.2295 0.0540   0.4779 0.4990
7  0.6 0.6320 0.4500 0.4070 0.0435   0.6275 0.4675
8  0.7 0.7652 0.3665 0.5820 0.0505   0.7599 0.3649
9  0.8 0.8640 0.2390 0.7445 0.0500   0.8614 0.2387
10 0.9 0.9295 0.1370 0.8610 0.0455   0.9289 0.1321
11 1.0 0.9695 0.0580 0.9405 0.0485   0.9677 0.0625
12 1.1 0.9855 0.0250 0.9730 0.0445   0.9871 0.0255
13 1.2 0.9955 0.0090 0.9910 0.0445   0.9955 0.0090
14 1.3 0.9992 0.0015 0.9985 0.0545   0.9986 0.0028
15 1.4 1.0000 0.0000 1.0000 0.0455   0.9996 0.0007
d_half <- uniroot(function(x) pow_within(x, n_cell) - 0.5, c(0.05, 1.5))$root
peak_row <- curve_tab[which.max(curve_tab$one), ]
print(round(c(d_where_power_is_half = d_half,
              effect_mm_there = d_half * sigma_r,
              measured_peak_d = peak_row$d,
              measured_peak_rate = peak_row$one,
              interaction_rate_mean = mean(curve_tab$inter),
              interaction_rate_min = min(curve_tab$inter),
              interaction_rate_max = max(curve_tab$inter)), 4))
d_where_power_is_half       effect_mm_there       measured_peak_d 
               0.5146                6.1751                0.5000 
   measured_peak_rate interaction_rate_mean  interaction_rate_min 
               0.4990                0.0476                0.0415 
 interaction_rate_max 
               0.0545 

The measured curve peaks at a true effect of 0.5 standard deviations with a split-verdict rate of 0.499, and the closed form puts per-site power at one half at 0.5146 standard deviations, which for this residual variation is 6.175 millimetres of growth. Either side of that the rate falls, but it falls slowly: anywhere between a quarter and one standard deviation of true effect, the split-verdict rate is above a third.

The interaction test is flat across the whole sweep, averaging 0.0476 and never leaving the band from 0.0415 to 0.0545. It has to be flat, because the true interaction is zero at every point on the curve. That is the contrast worth holding on to: one procedure has a false-positive rate that moves from ten per cent to fifty per cent and back depending on how big the common effect happens to be, and the other has a false-positive rate of five per cent regardless.

A line chart on warm off-white paper. The horizontal axis is the true warming effect in standard deviations from zero to one and two fifths; the vertical axis is a probability from zero to one. A dark green curve with round dots rises from about a tenth at the left edge, arches to a peak of one half at an effect of one half, then falls steadily to nearly zero at the right edge. A pale green curve labelled per site power rises monotonically from five hundredths to one. A flat red line labelled interaction test lies along five hundredths across the whole panel with small dots on it. A dashed horizontal line marks one half and a dashed vertical line stands where per site power reaches one half.
Figure 2: Rate at which two independent within-site tests give different verdicts, against the true warming effect, with the true effect held identical at both sites so the interaction is zero everywhere. Points are two thousand simulated experiments per effect size; the smooth curve is 2p(1-p) with p the exact per-site power. The rate peaks at one half where per-site power is one half, and the interaction test on the same data stays at its nominal five per cent throughout.

What the overlapping-error-bars rule really tests

The graphical version of the fallacy is older and more common than the p value version. Two means with 95 per cent intervals go on a figure, the intervals do not overlap, and the caption or the reader treats that as a significant difference. Schenker and Gentleman (2001) worked out what that rule actually does, and the arithmetic is short enough to redo here.

Two independent estimates \(\hat\theta_1\) and \(\hat\theta_2\) have standard errors \(s_1\) and \(s_2\). Their difference has a standard error of

\[s_{\text{diff}} = \sqrt{s_1^2 + s_2^2}\]

which is the only line of algebra this post needs, and it is the line Altman and Bland (2003) built their note around. The intervals fail to overlap when the gap between the estimates exceeds \(1.96(s_1 + s_2)\). The difference is significant at five per cent when the gap exceeds \(1.96\sqrt{s_1^2 + s_2^2}\). Since a sum of two positive numbers is always at least as large as the square root of their sum of squares, the non-overlap rule is always the stricter of the two, and how much stricter depends only on how the two standard errors compare.

ratio_grid <- c(1, 0.7, 0.5, 0.35, 0.25, 0.17, 0.1, 0.05, 0)
strict_fac <- (1 + ratio_grid) / sqrt(1 + ratio_grid^2)
alpha_impl <- 2 * pnorm(-1.96 * strict_fac)
print(round(rbind(se_ratio = ratio_grid,
                  standard_errors_demanded = 1.96 * strict_fac,
                  implied_alpha = alpha_impl), 5))
                            [,1]    [,2]    [,3]    [,4]    [,5]    [,6]
se_ratio                 1.00000 0.70000 0.50000 0.35000 0.25000 0.17000
standard_errors_demanded 2.77186 2.72968 2.62962 2.49745 2.37685 2.26076
implied_alpha            0.00557 0.00634 0.00855 0.01251 0.01746 0.02377
                            [,7]    [,8] [,9]
se_ratio                 0.10000 0.05000 0.00
standard_errors_demanded 2.14530 2.05543 1.96
implied_alpha            0.03193 0.03984 0.05
alpha_equal <- 2 * pnorm(-1.96 * sqrt(2))
print(round(c(equal_precision_factor = sqrt(2),
              equal_precision_demand = 1.96 * sqrt(2),
              equal_precision_alpha = alpha_equal,
              nominal_alpha = 0.05,
              ratio_of_alphas = 0.05 / alpha_equal), 5))
equal_precision_factor equal_precision_demand  equal_precision_alpha 
               1.41421                2.77186                0.00557 
         nominal_alpha        ratio_of_alphas 
               0.05000                8.97066 

When the two estimates are equally precise, \(s_1 = s_2\), the factor is \(\sqrt{2} =\) 1.4142, so the non-overlap rule demands a gap of 2.772 standard errors where the five per cent test demands 1.96. That is a test at the 0.0056 level, which is 8.97 times stricter than the one the reader thinks is being applied. At the other extreme, when one estimate is far more precise than the other, the factor tends to one and the rule converges on the nominal five per cent from below.

That is worth being clear about, because the direction surprises people who expect an eyeball rule to be too permissive. Non-overlapping intervals are strong evidence of a real difference. The failure is the other way round: intervals that overlap, sometimes by a lot, routinely hide a difference that a proper test would call significant. The rule is conservative as a detector of differences and it is that conservatism, not any looseness, that makes it useless in the split-verdict situation, where the whole question is about a difference the eye has already decided is absent.

sim_pair <- function(seed, n_a, n_b, d_a, d_b, n_rep, sg = sigma_r) {
  set.seed(seed)
  cell <- function(nn, mu) {
    y <- matrix(rnorm(nn * n_rep, mu, sg), nn, n_rep)
    list(m = colMeans(y), ss = colSums((y - rep(colMeans(y), each = nn))^2))
  }
  a0 <- cell(n_a, 0); a1 <- cell(n_a, d_a * sg)
  b0 <- cell(n_b, 0); b1 <- cell(n_b, d_b * sg)
  e_a <- a1$m - a0$m
  e_b <- b1$m - b0$m
  se_a <- sqrt((a0$ss + a1$ss) / (2 * n_a - 2)) * sqrt(2 / n_a)
  se_b <- sqrt((b0$ss + b1$ss) / (2 * n_b - 2)) * sqrt(2 / n_b)
  dfd <- 2 * n_a + 2 * n_b - 4
  s_pool <- sqrt((a0$ss + a1$ss + b0$ss + b1$ss) / dfd)
  se_d <- s_pool * sqrt(2 / n_a + 2 / n_b)
  ta <- qt(0.975, 2 * n_a - 2)
  tb <- qt(0.975, 2 * n_b - 2)
  lo_a <- e_a - ta * se_a; hi_a <- e_a + ta * se_a
  lo_b <- e_b - tb * se_b; hi_b <- e_b + tb * se_b
  data.frame(no_overlap = as.numeric(lo_a > hi_b | lo_b > hi_a),
             diff_rejects = as.numeric(2 * pt(-abs((e_a - e_b) / se_d), dfd) < 0.05),
             se_ratio = se_b / se_a)
}

n_ref <- 20
n_other <- c(20, 45, 80, 180, 320, 720, 1280)
n_rep_ov <- 3000
ov_tab <- as.data.frame(t(vapply(seq_along(n_other), function(j) {
  rr <- sim_pair(52000 + j, n_ref, n_other[j], d_true, d_true, n_rep_ov)
  rat <- mean(rr$se_ratio)
  c(n_second_site = n_other[j], se_ratio = rat,
    non_overlap_rate = mean(rr$no_overlap),
    difference_test_rate = mean(rr$diff_rejects),
    normal_theory = 2 * pnorm(-1.96 * (1 + rat) / sqrt(1 + rat^2)))
}, numeric(5))))
print(round(ov_tab, 5))
  n_second_site se_ratio non_overlap_rate difference_test_rate normal_theory
1            20  1.01469          0.00700              0.05233       0.00557
2            45  0.67718          0.00800              0.04933       0.00649
3            80  0.51108          0.01367              0.05233       0.00836
4           180  0.33981          0.01367              0.05300       0.01290
5           320  0.25527          0.01833              0.04900       0.01713
6           720  0.16988          0.02033              0.04833       0.02379
7          1280  0.12790          0.03533              0.05633       0.02832
print(round(c(non_overlap_at_equal_precision = ov_tab$non_overlap_rate[1],
              non_overlap_most_unequal = ov_tab$non_overlap_rate[nrow(ov_tab)],
              difference_test_mean = mean(ov_tab$difference_test_rate),
              nominal = 0.05), 5))
non_overlap_at_equal_precision       non_overlap_most_unequal 
                       0.00700                        0.03533 
          difference_test_mean                        nominal 
                       0.05152                        0.05000 

The simulation holds the true effect equal at the two sites and varies only how many seedlings the second site has, from 20 per arm up to 1280, which drives the ratio of the two standard errors from 1.015 down to 0.128. At equal precision the non-overlap rule fires in 0.7 per cent of experiments, close to the 0.557 per cent that the normal-theory calculation predicts and a long way below five. At the most unequal precision tested it fires in 3.53 per cent, climbing towards the nominal rate as the arithmetic says it should. The properly constructed test of the difference sits at 5.15 per cent across the whole sweep, which is what a five per cent test is meant to do.

A chart on warm off-white paper with the ratio of the two standard errors on the horizontal axis, from about one tenth to one, and a false-positive rate from zero to seven hundredths on the vertical axis. A red line with round dots labelled non-overlapping intervals starts at about three and a half hundredths on the left and falls steadily to under one hundredth at the right. A dark green line with dots labelled test of the difference runs flat along five hundredths across the whole width. A faint dotted horizontal line marks five hundredths and a second one marks the value just above half a hundredth.
Figure 3: False-positive rate of two rules for declaring that two site effects differ, against the ratio of their standard errors, with the true effects identical. The non-overlap rule is a test whose size depends on the relative precision of the two estimates and is never the five per cent the reader assumes. The test built on the standard error of the difference holds its nominal size throughout.

The failure also runs the other way

Split verdicts manufacture differences that are not there. The mirror image manufactures sameness: two sites whose warming responses genuinely differ, where both within-site tests happen to reject, and the paper reports that warming increased growth at both sites and says nothing about the difference. Or both fail to reject, and the paper says warming did nothing anywhere. Either way the difference goes unreported, and it is a difference the data could have detected.

The setting for this one has a real interaction. Warming is worth a full standard deviation at the low site and 0.45 of one at the high site, with sixty seedlings a cell rather than thirty, so both sites have decent power and the difference is detectable.

d_low_r <- 1.0
d_high_r <- 0.45
n_rev <- 60
run_rev <- sim_two_site(64000, n_rev, d_low_r, d_high_r, n_rep_main)
r_lo <- run_rev$p_lo < 0.05
r_hi <- run_rev$p_hi < 0.05
r_int <- run_rev$p_dif < 0.05

print(round(c(true_effect_low = d_low_r, true_effect_high = d_high_r,
              true_difference = d_low_r - d_high_r,
              seedlings_per_cell = n_rev,
              power_low_site = mean(r_lo), power_high_site = mean(r_hi),
              power_of_interaction_test = mean(r_int)), 5))
          true_effect_low          true_effect_high           true_difference 
                  1.00000                   0.45000                   0.55000 
       seedlings_per_cell            power_low_site           power_high_site 
                 60.00000                   0.99950                   0.67450 
power_of_interaction_test 
                  0.55875 
print(round(c(verdicts_agree = mean(r_lo == r_hi),
              agree_and_interaction_rejects = mean((r_lo == r_hi) & r_int),
              both_significant_and_interaction_rejects = mean(r_lo & r_hi & r_int),
              neither_significant_and_interaction_rejects =
                mean(!r_lo & !r_hi & r_int)), 5))
                             verdicts_agree 
                                    0.67500 
              agree_and_interaction_rejects 
                                    0.27575 
   both_significant_and_interaction_rejects 
                                    0.27575 
neither_significant_and_interaction_rejects 
                                    0.00000 
print(round(c(p_interaction_given_split = mean(r_int[xor(r_lo, r_hi)]),
              p_interaction_given_both = mean(r_int[r_lo & r_hi]),
              split_and_interaction_does_not_reject =
                mean(xor(r_lo, r_hi) & !r_int)), 5))
            p_interaction_given_split              p_interaction_given_both 
                              0.87077                               0.40882 
split_and_interaction_does_not_reject 
                              0.04200 

The two within-site tests agree in 67.5 per cent of these experiments, and in 27.58 per cent of them they agree while the interaction test rejects. Rather more than a quarter of the studies in this scenario carry a detectable difference between the sites that the comparison-of-verdicts reading throws away, because the reader sees two asterisks and stops.

All of that agreement is of one kind. The low site has power 0.9995 here, so the case where both sites come out non-significant occurs in 0.05 per cent of replicates and contributes 0 per cent to the hidden-difference total. The both-significant route is the one that matters at this sample size; the both-non-significant route belongs to smaller studies, where it becomes the standard way a real interaction is written up as no effect anywhere.

The conditional rates are worth reading in both directions. Given that the verdicts split, the interaction test rejects 87.08 per cent of the time here, so in a scenario with a real and sizeable interaction a split verdict is often pointing at something. Given that both sites came out significant, it still rejects 40.88 per cent of the time. Neither conditional rate is anywhere near zero or one, which is the practical statement: the pattern of verdicts carries some information about the interaction and it is nothing like a substitute for testing it.

A grouped bar chart on warm off-white paper with three groups on the horizontal axis, labelled both sites significant, exactly one significant and neither significant, and a share of experiments from zero to four tenths on the vertical axis. The first group has a tall pale bar reaching four tenths and a dark bar beside it reaching just under three tenths. The second group has a short pale bar of about four hundredths and a dark bar of about two and four fifths tenths. The third group has no visible bars at all. A legend below labels the pale bars interaction test does not reject and the dark bars interaction test rejects.
Figure 4: How the pattern of two within-site verdicts relates to the interaction test, over four thousand experiments in which the warming effect really is more than twice as large at one site as at the other. The bars are split by whether the interaction test rejected. The dark bar in the both-significant column is the hidden difference: experiments the reader would write up as warming working at both sites, in which the difference between the sites was detectable and went unreported.

Testing the difference, two ways that agree

The correct procedure is one term in one model. Fit growth on warming, site and their interaction, and the interaction coefficient is the difference between the two site-specific warming effects, with a standard error that already accounts for both.

The second way is worth doing by hand even though it gives the same answer, because it generalises to every question the coefficient table does not happen to answer directly. Write the quantity you want as a weighted sum of the coefficients, \(k^{\top}\beta\), put the weights in a vector \(k\), and take the standard error from the model’s variance-covariance matrix, \(\sqrt{k^{\top}\,\mathrm{Var}(\hat\beta)\,k}\), which vcov returns for any fitted model in R. No extra package is needed for this.

contrast_of <- function(model, weights, dfree = model$df.residual) {
  bv <- coef(model)
  kv <- rep(0, length(bv))
  names(kv) <- names(bv)
  kv[names(weights)] <- weights
  est <- sum(kv * bv)
  se <- sqrt(drop(t(kv) %*% vcov(model) %*% kv))
  c(estimate = est, se = se, t = est / se,
    p = 2 * pt(-abs(est / se), dfree))
}

print(names(coef(fit_full)))
[1] "(Intercept)"            "warmingwarmed"          "sitehigh"              
[4] "warmingwarmed:sitehigh"
k_low <- c(warmingwarmed = 1)
k_high <- c(warmingwarmed = 1, `warmingwarmed:sitehigh` = 1)
k_gap <- c(`warmingwarmed:sitehigh` = -1)

ctab <- rbind(effect_at_low = contrast_of(fit_full, k_low),
              effect_at_high = contrast_of(fit_full, k_high),
              low_minus_high = contrast_of(fit_full, k_gap))
print(round(ctab, 6))
                estimate       se         t        p
effect_at_low   4.113381 3.221968  1.276667 0.204270
effect_at_high  9.309039 3.221968  2.889240 0.004610
low_minus_high -5.195658 4.556551 -1.140261 0.256526
print(round(summary(fit_full)$coefficients["warmingwarmed:sitehigh", ], 6))
  Estimate Std. Error    t value   Pr(>|t|) 
  5.195658   4.556551   1.140261   0.256526 
print(round(c(contrast_t = unname(ctab["low_minus_high", "t"]),
              interaction_t = unname(summary(fit_full)$coefficients[4, 3]),
              absolute_gap = abs(abs(ctab["low_minus_high", "t"]) -
                                   abs(summary(fit_full)$coefficients[4, 3]))), 12))
   contrast_t interaction_t  absolute_gap 
    -1.140261      1.140261      0.000000 

The hand-built contrast and the interaction row of the coefficient table have the same absolute t value, the difference between the two rounded to 12 decimal places being 0. They differ only in sign, because the contrast was written as low minus high and the model’s treatment coding makes the interaction term high minus low. That is the whole content of the correct procedure: the model already contains the answer, and the contrast vector is a way of asking for it in the direction you meant.

One number in that table does not match the earlier within-site t test, and the mismatch is instructive. The contrast puts the low-site effect at 4.1134 with a standard error of 3.222, while the separate t test on the low-site data gave a p value of 0.1923 against the contrast’s 0.2043. The estimates agree exactly and the standard errors do not, because the model pools the residual variance across all four cells and carries 116 degrees of freedom where the separate test carries 58. Pooling is an assumption, and if the two sites have genuinely different residual variation it is the wrong one.

The same two lines work on a GLM. Counts of surviving seedlings per plot, warming and site crossed, Poisson family: the interaction is now a ratio of ratios on the log link, and the contrast function needs only the change from a t distribution to a normal one.

mk_counts <- function(seed, nc, lam_low, lam_high, rr_low, rr_high) {
  set.seed(seed)
  site <- factor(rep(c("low", "high"), each = 2 * nc),
                 levels = c("low", "high"))
  warming <- factor(rep(rep(c("ambient", "warmed"), each = nc), 2),
                    levels = c("ambient", "warmed"))
  lam <- c(rep(lam_low, nc), rep(lam_low * rr_low, nc),
           rep(lam_high, nc), rep(lam_high * rr_high, nc))
  data.frame(site = site, warming = warming,
             seedlings = rpois(4 * nc, lam))
}

rr_true <- 1.35
cdat <- mk_counts(20260805, n_cell, 9, 5, rr_true, rr_true)
gfit <- glm(seedlings ~ warming * site, family = poisson, data = cdat)
print(round(summary(gfit)$coefficients, 4))
                       Estimate Std. Error z value Pr(>|z|)
(Intercept)              2.1972     0.0609 36.1041   0.0000
warmingwarmed            0.3258     0.0799  4.0801   0.0000
sitehigh                -0.4804     0.0984 -4.8801   0.0000
warmingwarmed:sitehigh  -0.1606     0.1321 -1.2162   0.2239
contrast_g <- function(model, weights) {
  bv <- coef(model)
  kv <- rep(0, length(bv))
  names(kv) <- names(bv)
  kv[names(weights)] <- weights
  est <- sum(kv * bv)
  se <- sqrt(drop(t(kv) %*% vcov(model) %*% kv))
  c(estimate = est, se = se, z = est / se,
    p = 2 * pnorm(-abs(est / se)), rate_ratio = exp(est))
}
gtab <- rbind(low = contrast_g(gfit, k_low),
              high = contrast_g(gfit, k_high),
              ratio_of_ratios = contrast_g(gfit, k_gap))
print(round(gtab, 6))
                estimate       se        z        p rate_ratio
low             0.325834 0.079859 4.080102 0.000045   1.385185
high            0.165210 0.105186 1.570642 0.116266   1.179641
ratio_of_ratios 0.160624 0.132067 1.216232 0.223896   1.174243
print(round(c(true_rate_ratio_both_sites = rr_true,
              true_ratio_of_ratios = 1), 4))
true_rate_ratio_both_sites       true_ratio_of_ratios 
                      1.35                       1.00 

The Poisson fit reproduces the fallacy in its own idiom. Warming multiplies counts by 1.35 at both sites by construction, so the true ratio of ratios is 1, yet the warming effect at the low site comes out at 0.000045 on the p value scale and the effect at the high site at 0.116. Split verdicts again, this time because the high site has fewer seedlings to start with and a Poisson count carries less information when the mean is smaller. The ratio of ratios is 1.1742 with a p value of 0.224, and the honest report is that these data do not distinguish the two sites’ responses.

What the correct test costs

Everyone who has made this error has a reason, and the reason is usually sound: the interaction test is less powerful than the within-site tests, so it does not reject, and the split verdict is right there offering a story. The size of that gap can be measured exactly.

The within-site effect is a difference of two cell means, with a standard error of \(\sigma\sqrt{2/n}\). The interaction is a difference of two such differences, with a standard error of \(\sigma\sqrt{4/n}\). So the interaction is estimated with \(\sqrt{2}\) times the standard error of a within-site effect at the same \(n\), and reaching a given power for a given true value therefore takes twice the seedlings per cell. Since the interaction needs four cells and the within-site test needs two, the total sample size ratio is four.

pow_inter <- function(d, nc) {
  dfi <- 4 * nc - 4
  tc <- qt(0.975, dfi)
  ncp <- d * sqrt(nc / 4)
  pt(-tc, dfi, ncp) + pt(tc, dfi, ncp, lower.tail = FALSE)
}

n_pow <- c(10, 15, 20, 30, 40, 60, 80, 100, 130, 160, 200, 260)
n_rep_pow <- 2000
pow_tab <- as.data.frame(t(vapply(seq_along(n_pow), function(j) {
  rr <- sim_two_site(77000 + j, n_pow[j], d_true, 0, n_rep_pow)
  c(n_per_cell = n_pow[j],
    within_sim = mean(rr$p_lo < 0.05), inter_sim = mean(rr$p_dif < 0.05),
    within_exact = pow_within(d_true, n_pow[j]),
    inter_exact = pow_inter(d_true, n_pow[j]))
}, numeric(5))))
print(round(pow_tab, 4))
   n_per_cell within_sim inter_sim within_exact inter_exact
1          10     0.1885    0.1165       0.1851      0.1201
2          15     0.2615    0.1465       0.2624      0.1585
3          20     0.3180    0.1905       0.3379      0.1971
4          30     0.4745    0.2700       0.4779      0.2740
5          40     0.5980    0.3525       0.5981      0.3490
6          60     0.7835    0.4840       0.7753      0.4875
7          80     0.8805    0.6085       0.8816      0.6062
8         100     0.9450    0.7135       0.9404      0.7033
9         130     0.9795    0.8125       0.9801      0.8120
10        160     0.9910    0.8760       0.9938      0.8845
11        200     0.9990    0.9415       0.9988      0.9419
12        260     1.0000    0.9825       0.9999      0.9807
n80_within <- uniroot(function(x) pow_within(d_true, x) - 0.8, c(5, 500))$root
n80_inter <- uniroot(function(x) pow_inter(d_true, x) - 0.8, c(5, 2000))$root
print(round(c(effect_tested = d_true,
              n_per_cell_within = n80_within,
              n_per_cell_interaction = n80_inter,
              per_cell_ratio = n80_inter / n80_within,
              total_within = 2 * ceiling(n80_within),
              total_interaction = 4 * ceiling(n80_inter),
              total_ratio = 4 * n80_inter / (2 * n80_within)), 4))
         effect_tested      n_per_cell_within n_per_cell_interaction 
                0.5000                63.7656               126.0657 
        per_cell_ratio           total_within      total_interaction 
                1.9770               128.0000               508.0000 
           total_ratio 
                3.9540 
print(round(c(max_abs_sim_minus_exact_within =
                max(abs(pow_tab$within_sim - pow_tab$within_exact)),
              max_abs_sim_minus_exact_inter =
                max(abs(pow_tab$inter_sim - pow_tab$inter_exact))), 4))
max_abs_sim_minus_exact_within  max_abs_sim_minus_exact_inter 
                        0.0199                         0.0120 

For a true effect of half a residual standard deviation, eighty per cent power needs 64 seedlings per arm at one site, which is 128 seedlings in total. Detecting a difference in warming effect of the same size between two sites needs 127 seedlings per cell, which is 508 seedlings in total. The per-cell ratio is 1.977 and the total ratio is 3.954, so the folk statement that an interaction costs four times the sample size is correct when it is read as total sample size, and the factor is two when it is read per cell. The simulated power never departs from the exact calculation by more than 0.012.

That is the honest reason the fallacy is attractive, and it is also the reason it must not be used. An experiment powered to detect a warming effect is not powered to detect a difference in warming effects, and no amount of comparing verdicts changes that. The available responses are to design for the interaction if the interaction is the question, or to report the difference with its interval and say plainly that the data do not settle it. Nakagawa and Cuthill (2007) make the general version of that argument for biologists: report the estimate and its interval, and let the width of the interval say what the study can and cannot support.

There is a further trap on the low-power side. Gelman and Carlin (2014) point out that when power is low the estimates that clear the significance threshold are the ones that came out too large, and often the ones that came out with the wrong sign. A split verdict at low power therefore does not just fail to establish a difference: the site that won is the site whose estimate is most inflated, so the apparent difference between the two sites is larger than the truth even when a difference exists.

Two rising curves on warm off-white paper. The horizontal axis is seedlings per cell from ten to two hundred and sixty, the vertical axis is power from zero to one. A dark green curve with round dots rises steeply and passes eight tenths at about sixty-four seedlings per cell. A red curve with round dots lies well below it across the whole panel and passes eight tenths at about one hundred and twenty-six. A dotted horizontal line marks eight tenths and two dashed vertical lines mark the two crossing points, the right-hand one at roughly twice the horizontal position of the left-hand one.
Figure 5: Power of the within-site test and of the interaction test against seedlings per cell, for a true effect and a true difference in effects both equal to half a residual standard deviation. Points are two thousand simulated experiments per sample size; lines are the exact non-central t calculation. The vertical markers are the sample sizes at which each curve reaches eighty per cent power.

The honest limit: an interaction is a statement about a scale

Everything above treats the interaction test as the right answer, and within the model it is. What the model cannot supply is the scale on which the responses are being compared, and an interaction that exists on one scale can vanish on another. Loftus (1978) made the argument in a psychology journal and it transfers without modification: unless the measurement scale is fixed by something outside the statistics, the presence of an interaction is a property of the units as much as of the biology.

The demonstration is short. Let warming multiply growth by the same factor at both sites, which is a perfectly reasonable ecological hypothesis, and let the two sites differ in baseline growth, which they do. On the raw millimetre scale the two effects differ, because the same percentage of a larger baseline is a larger number of millimetres. On the log scale they are identical.

scale_sim <- function(seed, nc, b_low, b_high, rr, cv, n_rep) {
  set.seed(seed)
  cell <- function(mu) matrix(rlnorm(nc * n_rep, log(mu) - cv^2 / 2, cv),
                              nc, n_rep)
  a0 <- cell(b_low); a1 <- cell(b_low * rr)
  b0 <- cell(b_high); b1 <- cell(b_high * rr)
  int_rate <- function(m1, m2, m3, m4) {
    mats <- list(m1, m2, m3, m4)
    mm <- vapply(mats, colMeans, numeric(n_rep))
    ss <- 0
    for (k in 1:4) ss <- ss + colSums((mats[[k]] -
                                         rep(mm[, k], each = nc))^2)
    dfi <- 4 * nc - 4
    est <- (mm[, 2] - mm[, 1]) - (mm[, 4] - mm[, 3])
    se <- sqrt(ss / dfi) * sqrt(4 / nc)
    c(rate = mean(2 * pt(-abs(est / se), dfi) < 0.05),
      mean_est = mean(est))
  }
  c(raw = int_rate(a0, a1, b0, b1),
    logged = int_rate(log(a0), log(a1), log(b0), log(b1)))
}

sc_30 <- scale_sim(88000, 30, 46, 22, 1.35, 0.28, 3000)
sc_60 <- scale_sim(88001, 60, 46, 22, 1.35, 0.28, 3000)
print(round(rbind(n_30 = sc_30, n_60 = sc_60), 5))
     raw.rate raw.mean_est logged.rate logged.mean_est
n_30    0.467      8.34691     0.05033        -0.00038
n_60    0.759      8.35933     0.05233        -0.00092
print(round(c(baseline_low = 46, baseline_high = 22, ratio_both_sites = 1.35,
              raw_effect_low_mm = 46 * 0.35, raw_effect_high_mm = 22 * 0.35,
              raw_interaction_mm = (46 - 22) * 0.35,
              log_interaction = 0), 4))
      baseline_low      baseline_high   ratio_both_sites  raw_effect_low_mm 
             46.00              22.00               1.35              16.10 
raw_effect_high_mm raw_interaction_mm    log_interaction 
              7.70               8.40               0.00 

With thirty seedlings a cell the raw-scale interaction test rejects 46.7 per cent of the time and the log-scale test rejects 5.03 per cent. Double the sample and the raw-scale test rejects 75.9 per cent while the log-scale test stays at 5.23. Both tests are correct. They are testing different hypotheses, one about millimetres of extra growth and one about proportional growth, and the data cannot arbitrate between them. Running the interaction test does not make the elevation claim safe; it makes it precise, which is a different and smaller thing.

Three further limits are worth stating flatly. The design here has two sites, so elevation is confounded with everything else that differs between two mountainsides, and the interaction term estimates the difference between these two places rather than an effect of elevation. The simulation assumes equal residual variation at both sites, which is what lets the model pool; unequal variation makes the pooled interaction test wrong in a way the within-site Welch tests would not be, and the fix is a variance-per-site model rather than a return to comparing verdicts. And a non-significant interaction is not evidence that the two responses are the same. It is the absence of evidence that they differ, in a test whose sample size requirement is four times the one the study was designed around.

Where to go next

The immediate follow-on is the machinery, not the fallacy. The contrast vector built here took the narrowest case, two group-specific effects and their difference, and the same \(k^{\top}\beta\) with \(\sqrt{k^{\top}\mathrm{Var}(\hat\beta)k}\) answers every other question a coefficient table sidesteps: three sites rather than two, a comparison of slopes at chosen covariate values, an average of several cells against another average. Marginal means and contrasts after a GLM carries that through, including what to do about multiplicity once the number of contrasts stops being one.

If the interaction is going to be the question, it has to be in the design and not in the discussion, and reading a fitted interaction correctly is its own skill: the coefficient is a difference of slopes on the link scale and almost nobody reads it that way on first acquaintance. Interaction terms in ecological GLMs goes through that on count data.

The version of this problem that has nothing to do with interactions is a repeated-measures one. Comparing a before-and-after test in a treated group with a before-and-after test in a control group is the same error wearing different clothes, and the correct object there is also a difference of differences.

References

Gelman A, Stern H 2006 The American Statistician 60(4):328-331 (10.1198/000313006X152649)

Nieuwenhuis S, Forstmann BU, Wagenmakers EJ 2011 Nature Neuroscience 14(9):1105-1107 (10.1038/nn.2886)

Schenker N, Gentleman JF 2001 The American Statistician 55(3):182-186 (10.1198/000313001317097960)

Altman DG, Bland JM 2003 BMJ 326(7382):219 (10.1136/bmj.326.7382.219)

Nakagawa S, Cuthill IC 2007 Biological Reviews 82(4):591-605 (10.1111/j.1469-185X.2007.00027.x)

Gelman A, Carlin J 2014 Perspectives on Psychological Science 9(6):641-651 (10.1177/1745691614551642)

Loftus GR 1978 Memory and Cognition 6(3):312-319 (10.3758/BF03197461)

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.