Interaction terms in ecological GLMs

GLM
interactions
count data
ecology
Interaction terms in count GLMs set how one predictor bends another’s slope. Read them through predictions, centre your variables, and check the link scale, not the count scale.
Author

Tidy Ecology

Published

2026-06-26

An interaction says the effect of one predictor depends on another. A species might respond to soil moisture in abandoned plots but barely at all under grazing; canopy might matter more for abundance high on a mountain than at its foot. These are routine in field data, and the coefficient table for a model that contains them is one of the most misread outputs in ecology. The fix is to stop reading the interaction off the coefficients and start reading it off the predicted values.

A model with an interaction

We simulate counts along a moisture gradient under three management types, built so the moisture slope differs by management. The factor levels are set with Grazed first, which makes it the reference category.

library(MASS)
library(ggplot2)

set.seed(99)
ng    <- 60
mgmt  <- factor(rep(c("Grazed","Mown","Abandoned"), each = ng),
                levels = c("Grazed","Mown","Abandoned"))     # Grazed = reference
moist <- runif(3 * ng, 0, 1)
slope <- c(Grazed = 0.2, Mown = 1.3, Abandoned = 2.5)[as.character(mgmt)]
abund <- rnbinom(3 * ng, size = 4, mu = exp(1.5 + slope * moist))
A     <- data.frame(abund, moist, mgmt)

mA <- glm.nb(abund ~ moist * mgmt, data = A)
round(summary(mA)$coefficients, 3)
                    Estimate Std. Error z value Pr(>|z|)
(Intercept)            1.466      0.186   7.867    0.000
moist                  0.482      0.310   1.555    0.120
mgmtMown               0.008      0.248   0.033    0.974
mgmtAbandoned          0.209      0.260   0.807    0.420
moist:mgmtMown         0.880      0.427   2.059    0.040
moist:mgmtAbandoned    1.943      0.422   4.600    0.000

The table has a moist row, two mgmt rows, and two moist:mgmt rows. The moist:mgmtMown and moist:mgmtAbandoned entries are the interaction; everything written A:B is a product term. Reading these six numbers as six separate effects is where it goes wrong.

The interaction is a difference in slopes

With treatment contrasts, the moist coefficient is the moisture slope in the reference group, and each moist:mgmt coefficient is how much that slope changes in another group. The slope in any group is the sum of the two:

b <- coef(mA)
c(Grazed    = b["moist"],
  Mown      = b["moist"] + b["moist:mgmtMown"],
  Abandoned = b["moist"] + b["moist:mgmtAbandoned"])
   Grazed.moist      Mown.moist Abandoned.moist 
       0.482260        1.361797        2.425547 

The moisture slope runs from about 0.48 in grazed plots to 1.36 under mowing to 2.43 in abandoned plots, all on the log scale. Plotting the predicted abundance per management shows the same thing without the arithmetic: three lines of visibly different slope. That divergence in slope is the interaction.

gA <- expand.grid(moist = seq(0,1,length.out=100), mgmt = levels(A$mgmt))
pA <- predict(mA, gA, type = "link", se.fit = TRUE)
gA$mu <- exp(pA$fit); gA$lo <- exp(pA$fit-crit*pA$se.fit); gA$hi <- exp(pA$fit+crit*pA$se.fit)
ggplot(gA, aes(moist, mu, colour = mgmt, fill = mgmt)) +
  geom_point(data = A, aes(moist, abund, colour = mgmt), alpha=.35, size=1.1, inherit.aes=FALSE) +
  geom_ribbon(aes(ymin = lo, ymax = hi), alpha = .15, colour = NA) +
  geom_line(linewidth = 1) +
  scale_colour_manual(values = mgmt_cols) + scale_fill_manual(values = mgmt_cols) +
  coord_cartesian(ylim = c(0, 60)) +
  labs(x = "soil moisture", y = "predicted abundance", colour = "management", fill = "management") +
  theme_te()
Three rising curves of predicted abundance against soil moisture. The grazed curve is nearly flat, the mown curve rises moderately, and the abandoned curve rises steeply, with points coloured by management.
Figure 1: Predicted abundance against soil moisture for each management type, from the model with a moisture by management interaction. The slopes differ, which is what the interaction encodes.

A non-significant main effect is not no effect

Look again at the moist row of the table: an estimate near 0.48 with a p-value around 0.12, not significant. It is tempting to write “moisture had no effect.” That reading is wrong, and the plot says why. The moist coefficient is not the effect of moisture in general; it is the slope in the reference group, grazed plots, where the curve really is close to flat. Moisture matters a great deal under mowing and abandonment, where the interaction terms carry the steeper slopes.

The clearest demonstration is to change nothing but the reference category. Releveling so abandoned plots are the reference makes the moist row report that group’s slope instead:

A2  <- transform(A, mgmt = relevel(mgmt, ref = "Abandoned"))
mA2 <- glm.nb(abund ~ moist * mgmt, data = A2)
round(summary(mA2)$coefficients["moist", ], 4)
  Estimate Std. Error    z value   Pr(>|z|) 
    2.4255     0.2868     8.4572     0.0000 

Now the moisture main effect is about 2.43 with a vanishingly small p-value. Same data, same model, same fit; only the label “main effect of moisture” has moved to a different group. A main effect in a model with interactions is always conditional on where the other predictors sit, so a single significance star on that row answers a narrower question than it appears to.

The predictions, in contrast, do not care which group is the reference:

g  <- expand.grid(moist = c(0.2, 0.8), mgmt = levels(A$mgmt))
p1 <- predict(mA,  newdata = g, type = "response")
g2 <- transform(g, mgmt = relevel(factor(mgmt, levels = levels(A$mgmt)), ref = "Abandoned"))
p2 <- predict(mA2, newdata = g2, type = "response")
max(abs(p1 - p2))
[1] 4.973799e-14

The two parameterisations give the same predicted counts to machine precision. This is the general lesson: coefficients shift with the contrast coding, predictions do not, so report the predictions.

Centre your predictors so the main effect means something

The same conditional logic bites with two continuous predictors. Here abundance depends on canopy openness and elevation, with an interaction so the canopy slope grows with elevation. Elevation is in hundreds of metres.

set.seed(91)
nB      <- 200
canopy  <- runif(nB, 0.05, 0.95)
elev_hm <- runif(nB, 2, 15)                  # elevation, hundreds of metres (200 to 1500 m)
count   <- rnbinom(nB, size = 3,
                   mu = exp(1.6 + 0.3*canopy - 0.08*elev_hm + 0.18*canopy*elev_hm))
B  <- data.frame(count, canopy, elev_hm)

mB <- glm.nb(count ~ canopy * elev_hm, data = B)
round(coef(mB), 4)
   (Intercept)         canopy        elev_hm canopy:elev_hm 
        1.6790         0.0662        -0.0812         0.1923 

The canopy coefficient is 0.066. Taken at face value that says canopy hardly matters, yet the canopy slope is 0.066 + 0.192 * elevation, so it climbs steeply across the gradient:

bc <- coef(mB)
sapply(c(`200 m` = 2, `850 m` = 8.5, `1500 m` = 15),
       function(e) unname(bc["canopy"] + bc["canopy:elev_hm"] * e))
    200 m     850 m    1500 m 
0.4508475 1.7010490 2.9512505 

The puzzle dissolves once you see where 0.066 applies: it is the canopy slope at elev_hm = 0, sea level, which is far below the lowest plot at 200 m. The main effect is being read at a place no data exist. Centre elevation on its mean and the same coefficient becomes the slope at the middle of the gradient instead:

B$elev_c <- B$elev_hm - mean(B$elev_hm)
mBc <- glm.nb(count ~ canopy * elev_c, data = B)
c(uncentred_canopy = unname(coef(mB)["canopy"]),
  centred_canopy   = unname(coef(mBc)["canopy"]),
  mean_elev_m      = mean(B$elev_hm) * 100)
uncentred_canopy   centred_canopy      mean_elev_m 
      0.06617017       1.66363541     830.54806595 

The centred canopy effect is about 1.66, the slope at the mean elevation of 831 m, which is a number you can defend. Two things did not change: the interaction coefficient is identical either way, and the fitted predictions are identical, since centring only shifts where the intercept and main effects are evaluated (Schielzeth 2010).

ev   <- seq(0, 15, length.out = 100)
sl   <- data.frame(elev_hm = ev, slope = bc["canopy"] + bc["canopy:elev_hm"] * ev)
me   <- mean(B$elev_hm)
pts  <- data.frame(elev_hm = c(0, me),
                   slope = bc["canopy"] + bc["canopy:elev_hm"] * c(0, me),
                   lab = c("main effect\n(slope at elev = 0)", "centred main effect\n(slope at mean elev)"))
ggplot(sl, aes(elev_hm, slope)) +
  annotate("rect", xmin = 2, xmax = 15, ymin = -Inf, ymax = Inf, fill = forest, alpha = .05) +
  annotate("text", x = 8.5, y = 0.2, label = "range of sampled elevations", colour = faint, size = 3.4) +
  geom_line(colour = forest, linewidth = 1) +
  geom_vline(xintercept = 0, colour = faint, linetype = "dotted") +
  geom_point(data = pts, aes(elev_hm, slope), colour = c(aban, ink), size = 2.6) +
  geom_text(data = pts, aes(elev_hm, slope, label = lab), colour = c(aban, ink),
            hjust = c(-0.05, 1.05), vjust = c(1.4, -0.5), size = 3.3, lineheight = .9) +
  labs(x = "elevation (hundreds of metres)", y = "canopy effect (slope on log scale)") +
  theme_te()
A rising straight line of canopy slope against elevation. A point at elevation zero, left of the shaded sampled range, is labelled main effect; a point at the mean elevation inside the range is labelled centred main effect.
Figure 2: The canopy slope as a function of elevation. The uncentred main effect reads this line at elevation zero, outside the sampled range; centring reads it at the mean elevation.

Fanning is not the same as interaction

One more trap is specific to GLMs with a log link. Drop the interaction from the management model and fit an additive one: every group now shares a single moisture slope. On the log scale that means parallel lines. On the count scale it does not.

mAdd <- glm.nb(abund ~ moist + mgmt, data = A)
gAdd <- expand.grid(moist = c(0, 1), mgmt = levels(A$mgmt))
gAdd$pred <- predict(mAdd, gAdd, type = "response")
w <- reshape(gAdd, idvar = "mgmt", timevar = "moist", direction = "wide")
data.frame(mgmt = w$mgmt, count_gap = round(w$"pred.1" - w$"pred.0", 2))
       mgmt count_gap
1    Grazed      8.54
2      Mown     13.59
3 Abandoned     30.73

With one shared slope of about 1.44 on the log scale, the additive model still predicts a moisture gap of roughly 9 counts in grazed plots and 31 in abandoned plots. The exponential turns a shared additive slope into group-specific multiplicative change, so the count-scale curves fan apart even though there is no interaction. The figure shows both scales side by side.

gA2 <- expand.grid(moist = seq(0,1,length.out=100), mgmt = levels(A$mgmt))
gA2$link <- predict(mAdd, gA2, type = "link"); gA2$resp <- exp(gA2$link)
add2 <- rbind(
  data.frame(moist=gA2$moist, mgmt=gA2$mgmt, y=gA2$link, scale="link scale: log(mean)"),
  data.frame(moist=gA2$moist, mgmt=gA2$mgmt, y=gA2$resp, scale="response scale: count"))
add2$scale <- factor(add2$scale, levels = unique(add2$scale))
ggplot(add2, aes(moist, y, colour = mgmt)) +
  geom_line(linewidth = 1) +
  facet_wrap(~scale, scales = "free_y") +
  scale_colour_manual(values = mgmt_cols) +
  labs(x = "soil moisture", y = NULL, colour = "management") +
  theme_te()
Two panels of the same additive model. On the log scale, three parallel rising lines. On the count scale, the same three lines spread apart as moisture increases.
Figure 3: An additive model with no interaction, drawn on both scales. The lines are parallel on the link scale and fan out on the count scale, so fanning curves alone are not evidence of an interaction.

To judge whether an interaction is present, compare slopes on the link scale, where the model is additive and parallel lines mean no interaction. Divergence on the count scale is the default behaviour of a log link, not a finding.

What to carry over

Three habits cover most of the trouble. Read interactions from predicted values, not from the coefficient table, because the coefficients are slope differences against an arbitrary reference. Centre continuous predictors so each main effect is the slope at the centre of the data rather than at an extrapolated zero. Check parallelism on the link scale, since a log link fans the lines apart on its own.

Interactions also cost information: estimating how a slope changes needs more data than estimating the slope itself, so a marginal interaction term deserves caution rather than a paragraph. The helpers from the prediction post, effects (Fox 2003) and ggeffects (Ludecke 2018), build these predicted lines and surfaces directly and are the practical way to plot an interaction once the mechanics are clear. The grouping in this example also ignored that plots within a management type are not independent, which is the subject of the GLMM post.

References

Fox J (2003). Effect Displays in R for Generalised Linear Models. Journal of Statistical Software 8(15):1-27. doi:10.18637/jss.v008.i15

Ludecke D (2018). ggeffects: Tidy Data Frames of Marginal Effects from Regression Models. Journal of Open Source Software 3(26):772. doi:10.21105/joss.00772

McCullagh P, Nelder JA (1989). Generalized Linear Models, 2nd ed. Chapman and Hall. ISBN 0-412-31760-5

Schielzeth H (2010). Simple Means to Improve the Interpretability of Regression Coefficients. Methods in Ecology and Evolution 1(2):103-113. doi:10.1111/j.2041-210X.2010.00012.x