Understanding Interactions in Regression Models

Categorical × numerical and numerical × numerical interactions, marginal vs. conditional effects, and common pitfalls in ecological data analysis

Allen Bush-Beaupré

2026-10-05

Welcome

This workshop is adapted from a three-part tutorial series on interactions in regression models:

Audience: graduate students and researchers in ecology who fit GL(M)Ms and want to interpret interaction terms correctly.

Format: short lecture blocks, and practice exercises.

Learning objectives

By the end of this workshop you should be able to:

  1. Explain what an interaction term means for a categorical × numerical model and for a numerical × numerical model
  2. Distinguish conditional effects from marginal effects, and choose the right one for a research question
  3. Use marginaleffects (predictions(), slopes(), avg_predictions(), avg_slopes(), hypotheses()) to extract and test the quantities you actually care about

Learning objectives (continued)

  1. Visualize interaction effects (slope-by-moderator plots, five-number-summary predictions, response surfaces) and know their trade-offs
  2. Recognize that a non-linear link function (logit, log) creates an implicit interaction even when the model formula has none, and convert a raw GLM coefficient to a probability-scale or count-scale effect
  3. Interpret a hurdle/zero-inflated model’s two components separately and combined, and recognize that two-way interactions are symmetric

Actual learning objective

Model summary outputs rarely answer a research question and definitely do not communicate the results in a comprehensible way.

Setup

library(tidyverse)       # Data manipulation and plotting
library(glmmTMB)         # Generalized linear (mixed) models
library(marginaleffects) # Compute marginal/conditional effects
library(patchwork)       # Combine multiple plots
library(GGally)          # Pairwise plots
library(distributional)  # Simulate mixture/gamma/normal distributions (Part 3)
library(ggdist)          # stat_slab() for predicted-distribution plots (Part 3)
library(tinytable)       # tt(): simple, nice-looking results tables
library(performance)     # r2(), r2_tjur(): model R-squared measures

# Set ggplot2 theme
black_theme <- theme(
  axis.line = element_line(linewidth = 2, lineend = "round", color = "white"),
  panel.grid = element_blank(),
  panel.background = element_rect(fill = "black", color = NA),
  axis.ticks = element_blank(),
  axis.text = element_text(size = 21, face = "bold", color = "white"),
  axis.title = element_text(size = 21, face = "bold", color = "white"),
  plot.title = element_text(size = 25, face = "bold", color = "white"),
  plot.background = element_rect(fill = "black", color = NA),
  legend.background = element_rect(fill = "black", color = NA),
  legend.text = element_text(size = 21, color = "white"),
  legend.title = element_text(size = 21, face = "bold", color = "white"),
  legend.position = "bottom"
)

theme_set(black_theme)

# Set global default for geom_point shape
update_geom_defaults("point", list(shape = 1, size = 3, alpha = 0.8))

# silence marginaleffects warnings about glmmTMB
options(marginaleffects_safe = FALSE)

set.seed(333)

Coding style & functions used throughout

A few conventions and workhorse functions you’ll see on repeat:

  • Native pipe |>: data |> mutate(...) |> ggplot(...) reads left-to-right, “and then”
  • tibble() + rnorm()/rbinom()/rpois()/runif(): how we simulate data with a known true effect, so we can check whether our model recovers it
  • glmmTMB(y ~ x1 * x2, data = ..., family = ...): our model-fitting workhorse; * expands to x1 + x2 + x1:x2
  • marginaleffects verbs:
    • predictions() / avg_predictions() — model-implied values of \(Y\)
    • slopes() / avg_slopes() — model-implied effects (derivatives) of a predictor on \(Y\)
    • datagrid() — build a covariate grid to evaluate predictions/slopes at (e.g., fivenum, specific values, mean)
    • by = — keep results split by a grouping variable instead of averaging over it
    • hypotheses() — test linear combinations of estimates (e.g., “is slope A different from slope B?”)
  • set.seed(): called once per script so simulated data (and thus every number you see) is reproducible

Part 1 — Categorical × Numerical Interactions

What is an interaction?

An interaction means the effect of one predictor depends on the value of another.

  • No interaction: snow depth affects weight the same way for every sheep
  • Interaction: the effect of snow depth on weight differs by sex

For a categorical moderator (e.g., sex) and a continuous predictor (e.g., snow depth): the interaction means the slope differs between categories.

Our focus today isn’t the algebra — it’s using marginaleffects to find, test, and plot “it depends.”

Conditional vs. marginal effects

  • Conditional effects: the effect of a predictor at specific values of other predictors (e.g., the slope of snow depth for males)
  • Marginal effects: the effect averaged across the distribution of other predictors (e.g., the average slope of snow depth across the whole population)

Both are legitimate; they answer different questions. Interactions are exactly where the distinction starts to matter.

Case study: Bighorn sheep

Ram Mountain, Alberta. Question: how does winter snow depth relate to spring mass in Bighorn sheep, and does it differ by sex? Males and females segregate in winter — females use lower, less snowy habitat.

n_individuals_population <- 1000
sexes <- c("male", "female")
male_intercept <- 220; female_intercept <- 180
sd_weight <- 15
male_loss <- -0.8; female_loss <- -2.5

bighorn_population <- tibble(
  sex = rep(sexes, n_individuals_population / length(sexes)),
  snow_depth = runif(n_individuals_population,
    min = ifelse(sex == "male", 35, 0),
    max = ifelse(sex == "male", 70, 35)
  ),
  weight = rnorm(n_individuals_population,
    mean = ifelse(sex == "male",
      male_intercept + male_loss * snow_depth,
      female_intercept + female_loss * snow_depth),
    sd = sd_weight
  )
)

Built-in features: different intercepts, different slopes, and different snow-depth distributions by sex.

Simpson’s Paradox

Pooling males and females together and fitting a single trend line can hide — or even reverse — the true relationship.

Simpson’s Paradox

Code
ggplot(bighorn_population, aes(x = snow_depth, y = weight)) +
  geom_point(aes(color = sex)) +
  geom_smooth(method = "lm", se = FALSE, color = "red", linewidth = 2)

Ignoring sex hides the true within-sex trends.

Pooled vs. within-sex

Pooled: snow depth looks positively associated with weight — even though both sexes lose weight with more snow.

Why? Sex is a confounder

  • Within each sex: more snow → lower weight (true negative relationship)
  • Between sexes: males see more snow and are heavier (confounded)
  • Pooled data compares heavy, high-snow males to light, low-snow females → spurious positive trend

Exercise 1

Using bighorn_population:

  1. Fit weight ~ sex * snow_depth with glmmTMB(). Call it mod_mass_true.
  2. From the summary, identify which coefficient represents the male slope of snow depth. (Hint: it’s not printed directly — you have to add two rows together.)
  3. In one sentence, explain why a naive weight ~ snow_depth model (ignoring sex) would mislead a manager trying to understand the effect of a harsh winter. Can you think of an example of such situation in your own field or research?

(~5 minutes — discuss with a neighbor)

Sampling from the population

In practice we can’t measure everyone. Two sampling scenarios:

  • Scenario 1: balanced trap, 50 males / 50 females
  • Scenario 2: biased trap, 80 males / 20 females
n_individuals <- 100
bighorn_mass_1 <- bighorn_population |>
  group_by(sex) |>
  slice_sample(n = n_individuals / 2, replace = FALSE) |>
  ungroup()

mod_mass_1 <- glmmTMB(weight ~ sex * snow_depth,
                       data = bighorn_mass_1, family = gaussian())

Model coefficients

Code
summary(mod_mass_1)$coefficients$cond
                     Estimate Std. Error    z value     Pr(>|z|)
(Intercept)        179.711531  3.4913367  51.473560 0.000000e+00
sexmale             39.011436 11.0092824   3.543504 3.948474e-04
snow_depth          -2.495525  0.1717861 -14.526933 8.180169e-48
sexmale:snow_depth   1.686797  0.2685643   6.280794 3.368472e-10
attr(,"ddf")
[1] "asymptotic"

Reading the coefficients

  • Intercept: female weight at snow_depth = 0
  • sexmale: male − female intercept difference
  • snow_depth: female slope
  • sexmale:snow_depth: male − female slope difference

Useful for hypothesis tests, not directly interpretable for “what’s the effect for males?” — that’s what marginaleffects is for.

Q1: Do sexes differ in spring mass?

Code
predictions(mod_mass_1, by = "sex")

    sex Estimate Std. Error    z Pr(>|z|)   S 2.5 % 97.5 %
 female      137       1.88 73.0   <0.001 Inf   133    141
 male        178       1.88 95.1   <0.001 Inf   175    182

Type: response

This is a marginal prediction: the model-implied mean for each sex, averaged over the observed snow-depth distribution in the sample.

Testing the sex difference

Code
predictions(mod_mass_1, by = "sex") |>
  hypotheses(hypothesis = "b1 - b2 = 0")

 Hypothesis Estimate Std. Error     z Pr(>|z|)     S 2.5 % 97.5 %
    b1-b2=0    -41.5       2.65 -15.7   <0.001 181.2 -46.7  -36.3

Q2: Does snow depth affect mass equally by sex?

Code
slopes(mod_mass_1, variables = "snow_depth", by = "sex", vcov = TRUE) |>
  ggplot(aes(x = estimate, xmax = conf.high, xmin = conf.low, y = sex, color = sex)) +
  geom_pointrange(size = 1.5, lineend = "round", linewidth = 2, pch = 21, stroke = 4) +
  geom_vline(xintercept = female_loss, color = "red") +
  geom_vline(xintercept = male_loss, color = "blue") +
  theme(legend.position = "none")

Slopes of snow depth by sex, with true simulated values as vertical lines.

Testing the slope difference

Code
slopes(mod_mass_1, variables = "snow_depth", by = "sex", vcov = TRUE) |>
  hypotheses(hypothesis = "b1 - b2 = 0")

 Hypothesis Estimate Std. Error     z Pr(>|z|)    S 2.5 % 97.5 %
    b1-b2=0    -1.69      0.269 -6.28   <0.001 31.5 -2.21  -1.16

Q3: What’s the population-average slope?

avg_slopes() computes the slope for every observation, then averages:

Code
avg_slopes(mod_mass_1, variables = "snow_depth", vcov = TRUE)

 Estimate Std. Error     z Pr(>|z|)     S 2.5 % 97.5 %
    -1.65      0.134 -12.3   <0.001 113.1 -1.92  -1.39

Term: snow_depth
Type: response
Comparison: dY/dX
mean(c(male_loss, female_loss))  # true simulated average
[1] -1.65

Marginal vs conditional, visualized

Code
avg_pred_mass_1 <- avg_predictions(mod_mass_1, variables = "snow_depth")
pred_sex_mass_1 <- predictions(mod_mass_1, newdata = bighorn_mass_1)

ggplot() +
  geom_ribbon(data = avg_pred_mass_1,
              aes(x = snow_depth, ymin = conf.low, ymax = conf.high),
              color = "lightgreen", fill = "lightgreen", alpha = 0.4) +
  geom_line(data = avg_pred_mass_1, aes(x = snow_depth, y = estimate),
            color = "lightgreen", lineend = "round", linewidth = 2) +
  geom_ribbon(data = pred_sex_mass_1,
              aes(x = snow_depth, ymin = conf.low, ymax = conf.high, color = sex, fill = sex),
              alpha = 0.8) +
  geom_line(data = pred_sex_mass_1, aes(x = snow_depth, y = estimate, color = sex),
            lineend = "round", linewidth = 2) +
  geom_point(data = bighorn_mass_1, aes(y = weight, x = snow_depth, color = sex)) +
  guides(alpha = "none", fill = "none") +
  labs(y = "Predicted weight (kg)") +
  coord_cartesian(ylim = c(50, 220))

Scenario 2: biased sampling (80% male)

Code
bighorn_mass_2 <- bind_rows(
  bighorn_population |> filter(sex == "male") |> slice_sample(n = 80),
  bighorn_population |> filter(sex == "female") |> slice_sample(n = 20)
)
mod_mass_2 <- glmmTMB(weight ~ sex * snow_depth, data = bighorn_mass_2, family = gaussian())
summary(mod_mass_2)
 Family: gaussian  ( identity )
Formula:          weight ~ sex * snow_depth
Data: bighorn_mass_2

      AIC       BIC    logLik -2*log(L)  df.resid 
    845.8     858.9    -417.9     835.8        95 


Dispersion estimate for gaussian family (sigma^2):  250 

Conditional model:
                   Estimate Std. Error z value Pr(>|z|)    
(Intercept)        182.7351     7.5600  24.171  < 2e-16 ***
sexmale             35.8867    12.7414   2.817  0.00485 ** 
snow_depth          -2.5263     0.3534  -7.149 8.76e-13 ***
sexmale:snow_depth   1.7521     0.4027   4.351 1.36e-05 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Code
avg_slopes(mod_mass_2, variables = "snow_depth", vcov = TRUE)

 Estimate Std. Error     z Pr(>|z|)    S 2.5 % 97.5 %
    -1.12       0.17 -6.62   <0.001 34.7 -1.46 -0.792

Term: snow_depth
Type: response
Comparison: dY/dX

Reading the biased-sample result

Conditional (by-sex) slopes stay close to truth. But the marginal slope is biased toward the overrepresented, weaker-effect sex (males).

Correcting for known sampling bias

If we know the true population sex ratio (50:50), we can average over a balanced newdata grid instead of our biased sample.

newdata_balanced <- bighorn_mass_2 |>
  group_by(sex) |>
  reframe(snow_depth = seq(min(snow_depth), max(snow_depth), length.out = 50)) |>
  ungroup()

Exercise 2

Using the model that was fit to the entire population and the one with a biased sample, use the avg_predictions() function along with a balanced and unbalanced prediction grid to create a plot comparing the marginal predictions between the true population, biased population predictions and corrected population predictions. There should be three trend lines.

(~15 minutes)

Comparing biased and unbiased prediction grids

Code
mod_mass_true <- glmmTMB(weight ~ sex * snow_depth,
                          data = bighorn_population, family = gaussian())

avg_pred_mass_2 <- avg_predictions(mod_mass_2, variables = "snow_depth")
avg_pred_mass_3 <- avg_predictions(mod_mass_2, variables = "snow_depth", newdata = newdata_balanced)
avg_pred_mass_true <- avg_predictions(mod_mass_true, variables = "snow_depth")

bind_rows(
  avg_pred_mass_2 |> mutate(type = "biased"),
  avg_pred_mass_3 |> mutate(type = "corrected"),
  avg_pred_mass_true |> mutate(type = "true")
) |>
  ggplot() +
  geom_line(aes(x = snow_depth, y = estimate, color = type), linewidth = 2) +
  labs(y = "Predicted weight (kg)",
       x = "Snow depth (cm)",
       color = "Prediction type")

Part 1 takeaways

  1. Interactions → conditional effects that depend on other variables
  2. Simpson’s Paradox is a real risk when pooling across confounded groups
  3. Marginal ≠ conditional; both are valid but answer different questions
  4. Raw model coefficients rarely equal your research quantity of interest — use marginaleffects
  5. Marginal effects inherit your sample’s covariate distribution unless you override it with newdata

Part 2 — Numerical × Numerical Interactions

Two numerical predictors

With two continuous predictors, an interaction means: the slope of one predictor changes as the other predictor changes — not a separate slope per group, but a sliding one.

A raw coefficient is only “the slope when the other predictor equals zero” — meaningful only if zero is a meaningful value. This motivates centering predictors on their mean, so the intercept and main-effect coefficients describe average conditions.

Case study: petal length

Simulated flowers; petal length as a function of temperature and rain, with an interaction.

Predictors are mean-centered: the intercept becomes the predicted response at average conditions rather than at (biologically meaningless) zero temperature/rain.

Simulating the data

n_flowers <- 200
temperature <- rnorm(n_flowers, mean = 10, sd = 15)
rain <- rgamma(n_flowers, shape = 2)
avg_petal_length <- 25.6; sd_petal_length <- 4.5
temperature_effect <- 0.4; rain_effect <- 0.9; interaction_effect <- 0.2

flower_measurements_1 <- tibble(
  temp_centered = temperature - mean(temperature),
  rain_centered = rain - mean(rain),
  petal_length = rnorm(n_flowers,
    mean = avg_petal_length +
      temperature_effect * temp_centered +
      rain_effect * rain_centered +
      interaction_effect * temp_centered * rain_centered,
    sd = sd_petal_length)
)

Fit the model

Code
mod_flower_1 <- glmmTMB(petal_length ~ temp_centered * rain_centered,
                         data = flower_measurements_1)
summary(mod_flower_1)$coefficients$cond
                              Estimate Std. Error   z value     Pr(>|z|)
(Intercept)                 24.7315526 0.34191019 72.333477 0.000000e+00
temp_centered                0.4441477 0.02209133 20.105064 6.663435e-90
rain_centered                1.0517379 0.26351075  3.991253 6.572525e-05
temp_centered:rain_centered  0.2181736 0.01616323 13.498145 1.603630e-41
attr(,"ddf")
[1] "asymptotic"

Every term has a small p-value — but what do these numbers mean for our research question? Time to unpack them.

Slope of one predictor at a single value of the other

Code
# Effect of temperature when rain_centered = 0 (i.e., average rain)
slopes(mod_flower_1, newdata = datagrid(rain_centered = 0),
       variables = "temp_centered")

 rain_centered Estimate Std. Error    z Pr(>|z|)     S 2.5 % 97.5 %
             0    0.444     0.0221 20.1   <0.001 296.2 0.401  0.487

Term: temp_centered
Type: response
Comparison: dY/dX

This matches the temp_centered coefficient directly — because rain is centered, “at rain = 0” means “at average rain.”

Slope across the range of the moderator

Code
slopes_by_temp <- slopes(mod_flower_1,
  newdata = datagrid(temp_centered = seq(min(flower_measurements_1$temp_centered),
                                          max(flower_measurements_1$temp_centered), length.out = 30)),
  variables = "rain_centered", by = "temp_centered")

trend_slope_temp <- coef(lm(estimate ~ temp_centered, data = slopes_by_temp))[["temp_centered"]]

slopes_by_temp |>
  ggplot() +
  geom_hline(yintercept = 0, color = "blue", linewidth = 1.5, linetype = 2) +
  geom_ribbon(aes(x = temp_centered + mean(temperature), ymin = conf.low, ymax = conf.high),
    alpha = 0.4, fill = "lightgrey") +
  geom_line(aes(x = temp_centered + mean(temperature), y = estimate),
    lineend = "round", linewidth = 2, color = "white") +
  annotate("text", x = -Inf, y = Inf, hjust = -0.2, vjust = 1.5,
    label = paste0("slope = ", round(trend_slope_temp, 3)),
    color = "white", size = 6, fontface = "bold") +
  scale_x_continuous(breaks = seq(-25, 50, by = 5)) +
  labs(y = "Effect of rain on petal length", x = "Temperature values")

Effect of rain on petal length, across observed temperatures.

Reading the slope-by-moderator plot

The interaction coefficient is exactly the slope of this line — how much the effect of rain changes per unit of temperature.

Visualizing predictions at chosen moderator values

Common choice: the five-number summary of the moderator.

Code
predictions(mod_flower_1,
  newdata = datagrid(
    temp_centered = seq(min(flower_measurements_1$temp_centered),
                         max(flower_measurements_1$temp_centered), length.out = 30),
    rain_centered = fivenum
  )) |>
  as_tibble() |>
  mutate(rain = round(rain_centered + mean(rain), 2)) |>
  ggplot() +
  geom_line(aes(y = estimate, x = temp_centered + mean(temperature), color = as.factor(rain)),
            lineend = "round", linewidth = 1.5) +
  geom_ribbon(aes(ymin = conf.low, ymax = conf.high,
                  x = temp_centered + mean(temperature), fill = as.factor(rain)), alpha = 0.2) +
  labs(y = "Predicted petal length", x = "Temperature", color = "Rain", fill = "Rain") +
  coord_cartesian(ylim = c(0, 50))

Response surfaces — use with caution

Code
predictions(mod_flower_1,
  newdata = datagrid(
    temp_centered = seq(min(flower_measurements_1$temp_centered),
                         max(flower_measurements_1$temp_centered), length.out = 30),
    rain_centered = seq(min(flower_measurements_1$rain_centered),
                         max(flower_measurements_1$rain_centered), length.out = 30)
  )) |>
  as_tibble() |>
  mutate(rain = rain_centered + mean(rain), temp = temp_centered + mean(temperature)) |>
  ggplot(aes(x = rain, y = temp, z = estimate)) +
  stat_contour_filled() +
  labs(x = "Rain", y = "Temperature", fill = "Predicted petal length")

Response surfaces: a caution

Contour bins are arbitrary and hide uncertainty — good for exploration, risky for publication-quality inference. Prefer line-and-ribbon plots when you can.

Exercise 3

Using mod_flower_1:

  1. Compute the slope of temperature across the observed range of rain (mirror the rain-across-temperature plot above).
  2. At what value of rain does the temperature effect become non-significant (CI crosses zero), if at all?
  3. Rewrite the research question this model could answer as a single sentence, identifying which variable is the exposure and which is the moderator.

(~10 minutes)

Interactions beyond the mean: distributional regression

Interactions don’t have to act only on the average response. glmmTMB’s dispformula lets predictors affect the standard deviation too — useful when variance itself is ecologically meaningful (e.g., reduced trait variance under stress).

Simulating heteroscedastic data

log_sd_intercept <- log(sd_petal_length)
log_sd_temp_slope <- 0.03

flower_measurements_2 <- tibble(
  temp_centered = temperature - mean(temperature),
  rain_centered = rain - mean(rain),
  petal_length = rnorm(n_flowers,
    mean = avg_petal_length + temperature_effect * temp_centered,
    sd = exp(log_sd_intercept + log_sd_temp_slope * temp_centered))
)

The spread around the trend increases with temperature

Code
ggplot(flower_measurements_2, aes(x = temp_centered + mean(temperature), y = petal_length)) +
  geom_smooth(method = 'lm',     
              lineend = "round",
              linewidth = 2,
              color = "white",
              se = F) + 
  geom_point(color = "white") +
  labs(x = "Temperature",
       y = "Observed petal length")

Specifying the model

mod_flower_2 <- glmmTMB(petal_length ~ temp_centered,
                         dispformula = ~ temp_centered,
                         data = flower_measurements_2)

Checking the dispersion model

Code
predictions(mod_flower_2, newdata = flower_measurements_2, type = "disp") |>
  as_tibble() -> pred_sd_temp

true_sd_temp <- flower_measurements_2 |>
  mutate(true_sd = exp(log_sd_intercept + log_sd_temp_slope * temp_centered))

ggplot() +
  geom_line(data = true_sd_temp,
            aes(x = temp_centered + mean(temperature), y = true_sd, color = "Simulated"),
            linewidth = 2, linetype = "dashed") +
  geom_ribbon(data = pred_sd_temp,
              aes(ymin = conf.low, ymax = conf.high, x = temp_centered + mean(temperature)),
              fill = "red", alpha = 0.2) +
  geom_line(data = pred_sd_temp,
            aes(x = temp_centered + mean(temperature), y = estimate, color = "Estimated"),
            linewidth = 2, lineend = "round") +
  scale_color_manual(values = c("Simulated" = "green", "Estimated" = "red"), name = "Standard deviation") +
  labs(x = "Temperature", y = "Standard deviation")

Recovered vs. simulated standard deviation of petal length.

Reading the dispersion-model plot

type = "disp" retrieves predictions/slopes for the dispersion sub-model, exactly as type = "conditional" does for the mean.

Simulating and fitting interactions on the standard deviation

log_sd_rain_slope <- 0.02        
log_sd_interaction_effect <- 0.005 

tibble(temp_centered = temperature - mean(temperature),
       rain_centered = rain - mean(rain),
       petal_length = rnorm(
                        n = n_flowers,
                        mean = avg_petal_length +
                          temperature_effect * temp_centered +
                          rain_effect * rain_centered,
                        sd = exp(log_sd_intercept + 
                           log_sd_temp_slope * temp_centered +
                           log_sd_rain_slope * rain_centered +
                           log_sd_interaction_effect* temp_centered * rain_centered))) -> flower_measurements_3

mod_flower_measurements_3 <- glmmTMB(petal_length ~ temp_centered + rain_centered,
                                     dispformula = ~ temp_centered+ rain_centered + temp_centered*rain_centered,
                                     data = flower_measurements_3)

Interpretation

We can interpret the interactive effects of rain and temperature on the standard deviation of petal length in the same way that we would for the average. Examples are shown in the Numerical x Numerical Interactions tutorial online. I want to display another way to communicate the results. Knowing that the two parameters of the Normal distribution act together to generate the observations, we can predict new distributions of observations for different predictor values.

Predicting new observations

Code
predictions(mod_flower_measurements_3,
            newdata = datagrid(
              temp_centered = fivenum,
              rain_centered = fivenum
            ),
            type = "conditional") |>
  as_tibble() |>
  select(temp_centered, rain_centered, estimate) |>
  rename(mu = estimate) |>
  left_join(predictions(mod_flower_measurements_3,
            newdata = datagrid(
              temp_centered = fivenum,
              rain_centered = fivenum
            ),
            type = "disp") |>
  as_tibble() |>
  select(temp_centered, rain_centered, estimate) |>
  rename(sd = estimate)) |>
  rowwise() |>
  mutate(temp_centered = round(temp_centered),
         rain_centered = round(rain_centered),
         pred_obs = dist_normal(mu = mu, sd = sd)) -> predicted_observations

# Build facet labels dynamically from the actual rounded temp_centered values
# (hardcoding them would silently show NA facet labels if the simulated data changes)
temp_labels <- setNames(
  paste0("temp = ", sort(unique(predicted_observations$temp_centered))),
  sort(unique(predicted_observations$temp_centered))
)

ggplot(predicted_observations, aes(xdist = pred_obs, color = as.factor(rain_centered))) +
  stat_slab(fill = NA, normalize = "xy") +
  scale_color_brewer(palette = "Accent") +
  facet_wrap(~ as.factor(temp_centered), scales = "free", labeller = as_labeller(temp_labels)) +
  labs(x = "Predicted petal length", color = "Rain (centered)") +
  theme(axis.line.y = element_blank(),
        axis.text.y = element_blank(),
        axis.title.y = element_blank(),
        strip.text = element_text(color = "black", face = "bold", size = 12))

Part 2 takeaways

  1. With two numerical predictors, the interaction coefficient is the rate of change of one slope per unit of the other — not a separate slope for each group
  2. Centering predictors makes main-effect coefficients interpretable as “effect at average conditions”
  3. Plot slopes across the moderator’s range, not just the coefficient table
  4. Five-number-summary prediction plots are usually more honest than response surfaces (they retain uncertainty)
  5. Interactions can act on variance, not just the mean, via distributional regression (dispformula)

A different kind of interaction

So far, every interaction we’ve seen was written explicitly into the model formula (X1 * X2).

Non-linear link functions (logit, log, …) introduce a second, implicit source of interaction: even a model with no interaction term at all can show predictors whose response-scale effect depends on the values of other predictors.

This happens because the link function bends a straight line (on the link scale) into a curve (on the response scale) — and how much a fixed push on the link scale moves the response depends on where you are on that curve.

Case study: species occurrence

Simulated presence/absence of a species across 200 plots, as a function of tree cover, rain, and temperature — no interaction terms, fit with a binomial GLM (logit link):

n_plots <- 200
tree_cover <- dist_mixture(dist_beta(4, 0.8), dist_beta(0.8, 4), weights = c(0.5, 0.5))
avg_yearly_rain <- dist_gamma(shape = 3, rate = 0.7)
avg_yearly_temp <- dist_normal(mu = 20, sd = 10)

occurence_intercept <- 0.047
occurence_tree_effect <- 0.45
occurence_rain_effect <- 0.02
occurence_temp_effect <- 0.03

occ_data <- tibble(
  tree_cov = generate(tree_cover, n_plots),
  rain = generate(avg_yearly_rain, n_plots),
  temp = generate(avg_yearly_temp, n_plots)
) |>
  unnest(everything()) |>
  mutate(occurence = rbinom(n_plots, size = 1,
    prob = occurence_intercept +
      occurence_tree_effect * tree_cov +
      occurence_rain_effect * rain +
      occurence_temp_effect * temp))

Fit the model

Code
mod_1 <- glmmTMB(occurence ~ tree_cov + rain + temp, family = binomial(link = "logit"), data = occ_data)
summary(mod_1)$coefficients$cond
               Estimate Std. Error    z value     Pr(>|z|)
(Intercept) -2.17374411  0.8385507 -2.5922632 9.534679e-03
tree_cov     3.76005897  0.9372601  4.0117561 6.026875e-05
rain        -0.05046963  0.1077608 -0.4683488 6.395352e-01
temp         0.15045030  0.0371749  4.0470932 5.185760e-05
attr(,"ddf")
[1] "asymptotic"

We simulated the effect of tree cover to be 0.45 on the probability scale, but the model reports ~3.76. Did the model get it wrong?

No — it’s reporting a different scale

The logit-link GLM draws a straight line through the log-odds of occurrence, then bends that line into an S-shape to get back to a probability.

Reading the S-curve

Near the middle (p ≈ 0.5) the curve is steep: a small log-odds push moves probability a lot. Near the ends (p ≈ 0 or 1) the curve flattens: the same push barely moves probability.

No manual derivative needed — marginaleffects converts to the scale we want.

marginaleffects does this for us

Code
slopes(mod_1, variables = "tree_cov",
       newdata = datagrid(temp = mean(occ_data$temp), rain = mean(occ_data$rain)))

 temp rain Estimate Std. Error    z Pr(>|z|)    S 2.5 % 97.5 %
 19.5 4.22    0.426      0.104 4.09   <0.001 14.5 0.221   0.63

Term: tree_cov
Type: response
Comparison: dY/dX

slopes() in this case asks: “at these covariate values, how much does predicted probability change for a small increase in tree cover?” — a probability-scale answer, directly comparable to how we simulated the data.

The implicit interaction, visualized

Code
slopes(mod_1, variables = "tree_cov", newdata = datagrid(temp = fivenum, rain = fivenum)) |>
  mutate(temp = round(temp), rain = round(rain)) |>
  ggplot() +
  geom_pointrange(aes(x = estimate, xmin = conf.low, xmax = conf.high, y = 1), color = "white") +
  geom_vline(xintercept = occurence_tree_effect, color = "white", alpha = .8) +
  facet_grid(rain ~ temp) +
  theme(axis.title.y = element_blank(), axis.text.y = element_blank(), axis.line.y = element_blank(), axis.text.x = element_text(size = 10))

The tree-cover slope on the probability scale changes across rain × temp, though no interaction term exists in the model.

Why the slope moves around

Because the amount of “bending” depends on where you are on the S-curve, tree cover’s probability-scale effect depends on rain and temp too — even though we never told the model to include that interaction.

A note on measurement scale (Spake et al. 2023)

Whether an interaction “exists” can depend on which scale you look at: additive (probability, or log-odds as a straight difference) vs. multiplicative (ratios, or the log scale itself).

mod_1 has no interaction term, yet:

  • on the log-odds scale: no interaction, by construction
  • on the probability scale: an interaction appears, purely from the S-curve’s shape

Getting the scale question wrong is one way real interactions get mis-detected or mis-sized (a “Type-D” error in Spake et al.’s terminology).

Reading these panels

  • Link (log-odds) scale: the slope of tree_cov is flat (~1.98) across both moderators — exactly as it should be, since mod_1’s formula has no interaction term at all.
  • Response (probability) scale: the slope visibly curves with temp (and more weakly with rain) — the implicit interaction made concrete, purely from the logit link’s curvature.
  • With three predictors, a focal variable’s slope can no longer be shown across “the” moderator in one 2D plot — each panel above holds the other remaining predictor fixed at its mean. Alternatives: facet by the third variable, use small multiples at representative (e.g. five-number-summary) values, or report a single avg_slopes() that integrates over the observed distribution instead of fixing it.

The “overall” effect of tree cover

Code
avg_predictions(mod_1, variables = "tree_cov", vcov = TRUE) |>
  ggplot() +
  geom_ribbon(aes(x = tree_cov, ymin = conf.low, ymax = conf.high), fill = "white", alpha = 0.2) +
  geom_line(aes(x = tree_cov, y = estimate), color = "white", linewidth = 1.5, lineend = "round") +
  labs(y = "Predicted occurence")

Predicted probability of occurrence across tree_cov, averaged over the observed rain and temp values (counterfactual averaging).

avg_predictions()/avg_slopes() give one number (or curve) per focal value, integrating over rain and temp as observed in the sample — a marginal, “average effect” alternative to picking a single moderator value to condition on.

Exercise 4

Using mod_1:

  1. Plot the predicted trend of occurence as a function of tree cover for the unique combinations of the five number summary of rain and temperature. Hint: use the predictions() function.
  2. Plot the prediction for the average effect of temperature.

(~20 minutes)

Exercise 4: answer (task 1)

Code
predictions(mod_1, variables = "tree_cov", newdata = datagrid(temp = fivenum, rain = fivenum)) |>
  mutate(temp = round(temp), rain = round(rain)) |>
  ggplot() +
  geom_ribbon(aes(x = tree_cov, ymin = conf.low, ymax = conf.high), fill = "white", alpha = 0.2) +
  geom_line(aes(x = tree_cov, y = estimate), color = "white", linewidth = 1.5, lineend = "round") +
  facet_grid(rain ~ temp) +
  labs(y = "Predicted occurence") +
  theme(axis.text = element_text(size = 5))

Predicted probability of occurrence across tree_cov, at each combination of the five-number summary of rain and temp.

Exercise 4: answer (task 2)

Code
avg_predictions(mod_1, variables = "temp", vcov = TRUE) |>
  ggplot() +
  geom_ribbon(aes(x = temp, ymin = conf.low, ymax = conf.high), fill = "white", alpha = 0.2) +
  geom_line(aes(x = temp, y = estimate), color = "white", linewidth = 1.5, lineend = "round") +
  labs(y = "Predicted occurence")

Average predicted probability of occurrence across temp, integrating over the observed distribution of rain and tree_cov.

Reviewer 2 wants a table

Code
# Average slopes on the probability scale for all three predictors
slopes_tbl_mod1 <- avg_slopes(mod_1, variables = c("tree_cov", "rain", "temp"), type = "response") |>
  as_tibble() |>
  select(term, estimate, conf.low, conf.high, p.value) |>
  mutate(
    across(c(estimate, conf.low, conf.high), ~ round(.x, 3)),
    p.value = if_else(p.value < 0.001, "<0.001", as.character(round(p.value, 3)))
  ) |>
  mutate(CI_95 = glue::glue("[{conf.low}, {conf.high}]")) |>
  select(Predictor = term, Slope = estimate, `95% CI` = CI_95, `p-value` = p.value)

# Tjur's R2 as an extra row in the same 4-column layout
r2_row <- tibble(
  Predictor = "Tjur's R\u00b2",
  Slope = as.character(round(r2_tjur(mod_1), 3)),
  `95% CI` = "",
  `p-value` = ""
)

mod1_tbl <- bind_rows(
  slopes_tbl_mod1 |> mutate(across(everything(), as.character)),
  r2_row
)

mod1_tbl |>
  tt(caption = "Average slopes of occurrence on the probability scale, and Tjur's R2 for mod_1") |>
  style_tt(i = nrow(slopes_tbl_mod1) + 1, bold = TRUE, line = "t", line_color = "black", line_width = 0.1)
Average slopes of occurrence on the probability scale, and Tjur's R2 for mod_1
Predictor Slope 95% CI p-value
rain -0.008 [-0.04, 0.025] 0.639
temp 0.023 [0.015, 0.031] <0.001
tree_cov 0.583 [0.371, 0.795] <0.001
Tjur's R² 0.272

Case study: species abundance

The same phenomenon shows up with count data. Abundance (when present) as a function of the same three predictors, fit with a Poisson GLM (log link), again no interaction terms:

abundance_intercept <- exp(1)
abundance_tree_effect <- 4.5
abundance_rain_effect <- 0.1
abundance_temp_effect <- 0.1

abund_data <- tibble(
  tree_cov = generate(tree_cover, n_plots),
  rain = generate(avg_yearly_rain, n_plots),
  temp = generate(avg_yearly_temp, n_plots)
) |>
  unnest(everything()) |>
  mutate(abundance = rpois(n_plots, lambda =
    abundance_intercept +
    abundance_tree_effect * tree_cov +
    abundance_rain_effect * rain +
    abundance_temp_effect * temp))

mod_2 <- glmmTMB(abundance ~ tree_cov + rain + temp, family = poisson(link = "log"), data = abund_data)

From log scale to counts

The log-scale tree_cov coefficient is ~0.73, not 4.5 — the same phenomenon as the logit link, just a different curve.

Computing the rate ratio

Code
exp(fixef(mod_2)$cond["tree_cov"])  # rate ratio: ~2.08, i.e. a ~108% increase per unit tree cover
tree_cov 
2.010566 

Both describe the same underlying model — pick the one that matches your audience (percent change vs. a concrete headcount).

Exercise 5

Using mod_2:

  1. Compute the rate ratio and the local count-scale slope for temp, holding rain at its minimum (a “drought” scenario) and tree_cov at its mean.
  2. Repeat holding tree_cov at a low vs. a high value (e.g., the 10th and 90th percentiles) — does the rate ratio change? Does the local slope?
  3. Explain, in your own words, why one of these two summaries stays constant across tree_cov and the other doesn’t.

(~7 minutes)

Exercise 5: answer (task 1)

Code
# Count-scale (additive) slope of temp under drought (rain = min), tree_cov at its mean
temp_count_drought <- avg_slopes(
  mod_2,
  variables = "temp",
  newdata = datagrid(rain = min(abund_data$rain), tree_cov = mean(abund_data$tree_cov)),
  type = "response"
) |> as_tibble()

# Rate ratio (exponentiated log-scale) slope of temp at the same point
temp_link_drought <- avg_slopes(
  mod_2,
  variables = "temp",
  newdata = datagrid(rain = min(abund_data$rain), tree_cov = mean(abund_data$tree_cov)),
  type = "link"
) |> as_tibble()

temp_drought_tbl_ex6 <- temp_count_drought |>
  select(count_est = estimate, count_low = conf.low, count_high = conf.high) |>
  bind_cols(
    temp_link_drought |>
      mutate(rr = exp(estimate), rr_low = exp(conf.low), rr_high = exp(conf.high)) |>
      select(rr, rr_low, rr_high, p.value)
  ) |>
  mutate(
    across(c(count_est, count_low, count_high, rr, rr_low, rr_high), ~ round(.x, 3)),
    p.value = if_else(p.value < 0.001, "<0.001", as.character(round(p.value, 3))),
    count_CI = glue::glue("[{count_low}, {count_high}]"),
    rr_CI = glue::glue("[{rr_low}, {rr_high}]")
  ) |>
  select(`Count-scale slope` = count_est, `(95% CI)` = count_CI,
         `Rate ratio` = rr, `(95% CI) ` = rr_CI, `p-value` = p.value)

temp_drought_tbl_ex6 |>
  tt(caption = "Effect of temperature on abundance under drought (rain at its minimum), at mean tree cover")
Effect of temperature on abundance under drought (rain at its minimum), at mean tree cover
Count-scale slope (95% CI) Rate ratio (95% CI) p-value
0.121 [0.082, 0.159] 1.018 [1.012, 1.024] <0.001

Exercise 5: answer (task 2)

Code
# Low vs. high tree cover: 10th and 90th percentiles
low_tree_q <- quantile(abund_data$tree_cov, 0.1)
high_tree_q <- quantile(abund_data$tree_cov, 0.9)

# Count-scale slope of temp at each tree_cov percentile, under drought (rain = min)
temp_count_scale_ex6 <- avg_slopes(
  mod_2,
  variables = "temp",
  by = "tree_cov",
  newdata = datagrid(tree_cov = c(low_tree_q, high_tree_q), rain = min(abund_data$rain)),
  type = "response"
) |> as_tibble()

# Rate ratio (exponentiated log-scale) slope of temp at the same two points
temp_link_scale_ex6 <- avg_slopes(
  mod_2,
  variables = "temp",
  by = "tree_cov",
  newdata = datagrid(tree_cov = c(low_tree_q, high_tree_q), rain = min(abund_data$rain)),
  type = "link"
) |> as_tibble()

temp_tree_tbl_ex6 <- temp_count_scale_ex6 |>
  select(tree_cov, count_est = estimate, count_low = conf.low, count_high = conf.high) |>
  left_join(
    temp_link_scale_ex6 |>
      mutate(rr = exp(estimate), rr_low = exp(conf.low), rr_high = exp(conf.high)) |>
      select(tree_cov, rr, rr_low, rr_high, p.value),
    by = "tree_cov"
  ) |>
  mutate(
    tree_cov_label = if_else(tree_cov == low_tree_q, "Low tree cover (p10)", "High tree cover (p90)"),
    across(c(count_est, count_low, count_high, rr, rr_low, rr_high), ~ round(.x, 3)),
    p.value = if_else(p.value < 0.001, "<0.001", as.character(round(p.value, 3))),
    count_CI = glue::glue("[{count_low}, {count_high}]"),
    rr_CI = glue::glue("[{rr_low}, {rr_high}]")
  ) |>
  select(`Tree cover` = tree_cov_label, `Count-scale slope` = count_est, `(95% CI)` = count_CI,
         `Rate ratio` = rr, `(95% CI) ` = rr_CI, `p-value` = p.value)

temp_tree_tbl_ex6 |>
  tt(caption = "Effect of temperature on abundance under drought (rain at its minimum), at low vs. high tree cover")
Effect of temperature on abundance under drought (rain at its minimum), at low vs. high tree cover
Tree cover Count-scale slope (95% CI) Rate ratio (95% CI) p-value
Low tree cover (p10) 0.087 [0.058, 0.115] 1.018 [1.012, 1.024] <0.001
High tree cover (p90) 0.166 [0.113, 0.22] 1.018 [1.012, 1.024] <0.001

The rate ratio stays essentially constant across tree cover (it’s a fixed multiplicative effect, \[e^\beta\], independent of covariate values), while the count-scale slope grows substantially from low to high tree cover, since it’s rescaled by the much larger expected count \[\lambda\] there.

Case study: occurrence and abundance together

In reality, a species first has to be present before we can measure its abundance. A hurdle model combines two sub-models fit jointly:

  • A binomial (logit-link) component: does a plot clear the “hurdle” of zero? (occurrence)
  • A truncated count component: given it’s non-zero, how many individuals? (abundance when present)

Two ways to interpret: each part separately (what drives occurrence vs. abundance), or combined on the population scale (expected abundance across all plots = P(occurrence) × E[abundance | present]).

A simple hurdle example

# Simple hurdle simulation: abundance_pres = counts if present; occurence = presence;
# abundance_pop = observed count in every plot (zero wherever the species is absent)
tibble(
  tree_cov = generate(tree_cover, n_plots)
) |>
  unnest(everything()) |>
  mutate(abundance_pres = rpois(n_plots, lambda =
                     abundance_intercept +
                     abundance_tree_effect * tree_cov
                     ),
         occurence = rbinom(n_plots, size = 1,
                     prob = occurence_intercept +
                            occurence_tree_effect * tree_cov),
         abundance_pop = abundance_pres * occurence) -> simple_pop_data

# Hurdle model: main formula is the (zero-truncated Poisson) abundance-when-present part;
# zi = ~ tree_cov models the probability of a structural zero (absence) with a logit link
mod_hu_simple <- glmmTMB(abundance_pop ~ tree_cov,
                         zi = ~ tree_cov,
                         family = truncated_poisson,
                         data = simple_pop_data)

Population vs. present-only predictions

Code
# type = "response": population-scale expected abundance across ALL plots (P(present) x count)
predictions(mod_hu_simple, type = "response") |> mutate(type = "response") -> pred_resp_simple

# type = "zprob": predicted probability of a structural zero (i.e. NOT occurring)
predictions(mod_hu_simple, type = "zprob") -> pred_prob_simple

# type = "conditional": expected abundance given the species is present
predictions(mod_hu_simple, type = "conditional") |> mutate(type = "conditional") -> pred_cond_simple

bind_rows(pred_resp_simple, pred_cond_simple) -> pred_abun_simple

plot_pred_abun_simple <- ggplot(pred_abun_simple) +
  geom_ribbon(aes(x = tree_cov, ymin = conf.low, ymax = conf.high, fill = type), alpha = 0.2) +
  geom_line(aes(x = tree_cov, y = estimate, color = type), linewidth = 1.5, lineend = "round") +
  labs(x = "Tree cover", y = "Abundance", color = "Prediction type", fill = "Prediction type")

# zprob is P(structural zero), so 1 - zprob is the occurrence probability
plot_pred_prob_simple <- ggplot(pred_prob_simple) +
  geom_ribbon(aes(x = tree_cov, ymin = 1 - conf.high, ymax = 1 - conf.low), fill = "white", alpha = 0.2) +
  geom_line(aes(x = tree_cov, y = 1 - estimate), color = "white", linewidth = 1.5, lineend = "round") +
  labs(x = "Tree cover", y = "Occurence") 

plot_pred_abun_simple / plot_pred_prob_simple

Top: predicted abundance, population-scale (response) vs. when present (conditional). Bottom: predicted occurrence probability (1 - zprob).

Predicted abundance when present (conditional) is always higher than population-scale abundance (response), since occurrence probability is below 100%. This is an implicit interaction between the model’s two parts: tree cover’s effect on population-scale abundance depends on its effect on both occurrence and abundance-when-present.

Simulating with an explicit interaction

Prey and competitors interact in their effect on abundance (when present); tree cover drives occurrence only. Predictors are simulated through the model’s own inverse link (exp(), plogis()) so they stay in a valid range:

more_plots <- 4000
prey_abun <- dist_poisson(lambda = 6)
competitor_abun <- dist_poisson(lambda = 3)

pop_data <- tibble(
  tree_cov = generate(tree_cover, more_plots),
  prey = generate(prey_abun, more_plots),
  competitors = generate(competitor_abun, more_plots)
) |>
  unnest(everything()) |>
  mutate(
    abundance_pres = rpois(more_plots,
      lambda = exp(1 + 0.12 * prey - 0.15 * competitors - 0.045 * prey * competitors)),
    occurence = rbinom(more_plots, size = 1, prob = plogis(-2 + 0.45 * tree_cov)),
    abundance_pop = abundance_pres * occurence
  ) |>
  mutate(across(c(tree_cov, prey, competitors), ~ (.x - mean(.x)) / sd(.x), .names = "scl_{.col}"))

Fitting the hurdle model

Code
mod_hu <- glmmTMB(
  abundance_pop ~ scl_prey * scl_competitors,
  zi = ~ scl_tree_cov,
  family = truncated_poisson,
  data = pop_data
)

zi = ~ scl_tree_cov: the zero-inflation (occurrence) part. The conditional formula’s scl_prey * scl_competitors is an explicit interaction, layered on top of the log link’s implicit one.

The interaction on the count scale

Code
slopes(mod_hu, variables = "scl_prey",
       newdata = datagrid(scl_competitors = seq(min(pop_data$scl_competitors),
                                                 max(pop_data$scl_competitors), length.out = 30)),
       type = "response") |>
  # unscale back to raw competitor counts for an interpretable x-axis
  mutate(competitors = scl_competitors * sd(pop_data$competitors) + mean(pop_data$competitors)) |>
  ggplot(aes(x = competitors, y = estimate)) +
  geom_hline(yintercept = 0, color = "blue", linewidth = 1, linetype = 2) +
  geom_ribbon(aes(ymin = conf.low, ymax = conf.high), alpha = 0.4, fill = "lightgrey") +
  geom_line(linewidth = 1.5, lineend = "round", color = "white") +
  labs(y = "Slope of scl_prey", x = "Competitors",
       title = "Count-scale effect of prey availability")

Count-scale effect of prey weakens (and reverses) as competitors increase.

Isolating the interaction: rate ratios

To isolate something closer to “the interaction itself,” we move to the rate-ratio scale: for each one-SD increase in competitors, by what factor does the effect of one extra SD of prey get multiplied? That’s exp() of the interaction coefficient, and because it lives on the model’s own additive (log) scale, it doesn’t depend on where competitors are evaluated — unlike the count-scale slope.

Code
rate_ratio_interaction_tbl <- avg_slopes(mod_hu, variables = "scl_prey",
                                          by = "scl_competitors",
                                          newdata = datagrid(scl_competitors = c(-1, 0, 1)),
                                          type = "link") |>
  as_tibble() |>
  mutate(rate_ratio = exp(estimate), rr_low = exp(conf.low), rr_high = exp(conf.high)) |>
  select(scl_competitors, rate_ratio, rr_low, rr_high, p.value) |>
  mutate(
    across(c(rate_ratio, rr_low, rr_high), ~ round(.x, 3)),
    p.value = if_else(p.value < 0.001, "<0.001", as.character(round(p.value, 3)))
  ) |>
  mutate(CI_95 = glue::glue("[{rr_low}, {rr_high}]")) |>
  select(`Competitors (scaled)` = scl_competitors, `Rate ratio for scl_prey` = rate_ratio, `95% CI` = CI_95, `p-value` = p.value)

rate_ratio_interaction_tbl |>
  tt(caption = "Rate ratio of scl_prey's effect on abundance, evaluated at three levels of scl_competitors")
Rate ratio of scl_prey's effect on abundance, evaluated at three levels of scl_competitors
Competitors (scaled) Rate ratio for scl_prey 95% CI p-value
-1 1.227 [1.143, 1.318] <0.001
0 0.953 [0.861, 1.055] 0.349
1 0.740 [0.615, 0.89] 0.001

Prey’s rate ratio drops from >1 (increasing abundance) at low competitor density to <1 (decreasing abundance) at high competitor density.

Predicted abundance, by competitor level

Code
predictions(mod_hu,
  newdata = datagrid(
    scl_prey = seq(min(pop_data$scl_prey), max(pop_data$scl_prey), length.out = 30),
    scl_competitors = fivenum
  ),
  type = "conditional"
) |>
  mutate(prey = scl_prey * sd(pop_data$prey) + mean(pop_data$prey),
         competitors = round(scl_competitors * sd(pop_data$competitors) + mean(pop_data$competitors), 1)) |>
  ggplot() +
  geom_ribbon(aes(x = prey, ymin = conf.low, ymax = conf.high, fill = as.factor(competitors)), alpha = 0.2) +
  geom_line(aes(x = prey, y = estimate, color = as.factor(competitors)), linewidth = 1.5, lineend = "round") +
  labs(y = "Predicted abundance\n(when present)", x = "Prey", color = "Competitors", fill = "Competitors")

Abundance-when-present predictions across prey, one line per competitor five-number-summary level.

At low competitor density, abundance climbs steeply with prey; by the time competitors are abundant, the prey curve is nearly flat — the same shrinking slope we saw before, now shown as predicted trends rather than slopes.

Population-scale picture: low vs. high tree cover

Code
# Locate the two peaks of tree_cov's bimodal distribution, as scaled values
d <- density(pop_data$tree_cov)
peaks <- d$x[which(diff(sign(diff(d$y))) == -2) + 1]
low_tree_pop <- round(peaks[1], 2)
high_tree_pop <- round(peaks[length(peaks)], 2)
low_tree_pop_scl <- (low_tree_pop - mean(pop_data$tree_cov)) / sd(pop_data$tree_cov)
high_tree_pop_scl <- (high_tree_pop - mean(pop_data$tree_cov)) / sd(pop_data$tree_cov)

predictions(mod_hu,
  newdata = datagrid(
    scl_prey = seq(min(pop_data$scl_prey), max(pop_data$scl_prey), length.out = 30),
    scl_competitors = fivenum,
    scl_tree_cov = c(low_tree_pop_scl, high_tree_pop_scl)
  ),
  type = "response"
) |>
  mutate(prey = scl_prey * sd(pop_data$prey) + mean(pop_data$prey),
         competitors = round(scl_competitors * sd(pop_data$competitors) + mean(pop_data$competitors), 1),
         `Tree cover` = if_else(scl_tree_cov == low_tree_pop_scl, "Low tree cover", "High tree cover")) |>
  ggplot() +
  geom_ribbon(aes(x = prey, ymin = conf.low, ymax = conf.high, fill = as.factor(competitors)), alpha = 0.2) +
  geom_line(aes(x = prey, y = estimate, color = as.factor(competitors)), linewidth = 1.5, lineend = "round") +
  facet_wrap(~ `Tree cover`) +
  labs(y = "Predicted population abundance", x = "Prey", color = "Competitors", fill = "Competitors")

Population-scale (response) predictions: prey x competitors, faceted by low vs. high tree cover.

Within each panel, the prey × competitors shape is the same. But the high-tree-cover panel sits above the low-tree-cover panel overall — more plots are occupied there. Tree cover mostly shifts how many plots have the species at all; prey and competitors jointly shape abundance within occupied plots.

A symmetric interaction, viewed from the other predictor

Code
predictions(mod_hu,
  newdata = datagrid(
    scl_competitors = seq(min(pop_data$scl_competitors), max(pop_data$scl_competitors), length.out = 30),
    scl_prey = fivenum
  ),
  type = "conditional"
) |>
  mutate(competitors = scl_competitors * sd(pop_data$competitors) + mean(pop_data$competitors),
         prey = round(scl_prey * sd(pop_data$prey) + mean(pop_data$prey), 1)) |>
  ggplot() +
  geom_ribbon(aes(x = competitors, ymin = conf.low, ymax = conf.high, fill = as.factor(prey)), alpha = 0.2) +
  geom_line(aes(x = competitors, y = estimate, color = as.factor(prey)), linewidth = 1.5, lineend = "round") +
  labs(y = "Predicted abundance\n(when present)", x = "Competitors", color = "Prey", fill = "Prey")

Same interaction, axes swapped: competitors on x, one line per prey five-number-summary level.

A two-way interaction is mathematically symmetric: scl_prey:scl_competitors is the same coefficient as scl_competitors:scl_prey. This view makes a different point than the prey-on-x-axis plot: regardless of prey availability, enough competitors drive abundance down to roughly the same low floor. Neither plot is “more correct” — reporting only one risks showing half the story (Spake et al. 2023).

Average predictions: one curve per predictor

Code
avg_pred_prey_cond <- avg_predictions(mod_hu,
  newdata = datagrid(model = mod_hu,
                      scl_prey = seq(min(pop_data$scl_prey), max(pop_data$scl_prey), length.out = 30),
                      grid_type = "counterfactual"),
  by = "scl_prey", type = "conditional"
) |>
  mutate(prey = scl_prey * sd(pop_data$prey) + mean(pop_data$prey))

avg_pred_comp_cond <- avg_predictions(mod_hu,
  newdata = datagrid(model = mod_hu,
                      scl_competitors = seq(min(pop_data$scl_competitors), max(pop_data$scl_competitors), length.out = 30),
                      grid_type = "counterfactual"),
  by = "scl_competitors", type = "conditional"
) |>
  mutate(competitors = scl_competitors * sd(pop_data$competitors) + mean(pop_data$competitors))

p_avg_prey_cond <- avg_pred_prey_cond |>
  ggplot() +
  geom_ribbon(aes(x = prey, ymin = conf.low, ymax = conf.high), fill = "white", alpha = 0.2) +
  geom_line(aes(x = prey, y = estimate), color = "white", linewidth = 1.5, lineend = "round") +
  labs(y = "Average predicted abundance\n(when present)", x = "Prey")

p_avg_comp_cond <- avg_pred_comp_cond |>
  ggplot() +
  geom_ribbon(aes(x = competitors, ymin = conf.low, ymax = conf.high), fill = "white", alpha = 0.2) +
  geom_line(aes(x = competitors, y = estimate), color = "white", linewidth = 1.5, lineend = "round") +
  labs(y = "Average predicted abundance\n(when present)", x = "Competitors")

p_avg_prey_cond + p_avg_comp_cond

Abundance-when-present, averaged over the full observed range of the other predictor.

datagrid(..., grid_type = "counterfactual") pairs each focal value with every observed row, then averages — a true average over the sample rather than a slope evaluated at the other predictors’ means. Averaged over all observed competitor density, prey still shows a positive effect; averaged over all observed prey, competitors show a diminishing shape.

Population-scale average, by tree cover

Code
avg_pred_prey_resp <- avg_predictions(mod_hu,
  newdata = datagrid(model = mod_hu,
                      scl_prey = seq(min(pop_data$scl_prey), max(pop_data$scl_prey), length.out = 30),
                      scl_tree_cov = c(low_tree_pop_scl, high_tree_pop_scl),
                      grid_type = "counterfactual"),
  by = c("scl_prey", "scl_tree_cov"), type = "response"
) |>
  mutate(prey = scl_prey * sd(pop_data$prey) + mean(pop_data$prey),
         `Tree cover` = if_else(scl_tree_cov == low_tree_pop_scl, "Low tree cover", "High tree cover"))

avg_pred_comp_resp <- avg_predictions(mod_hu,
  newdata = datagrid(model = mod_hu,
                      scl_competitors = seq(min(pop_data$scl_competitors), max(pop_data$scl_competitors), length.out = 30),
                      scl_tree_cov = c(low_tree_pop_scl, high_tree_pop_scl),
                      grid_type = "counterfactual"),
  by = c("scl_competitors", "scl_tree_cov"), type = "response"
) |>
  mutate(competitors = scl_competitors * sd(pop_data$competitors) + mean(pop_data$competitors),
         `Tree cover` = if_else(scl_tree_cov == low_tree_pop_scl, "Low tree cover", "High tree cover"))

p_avg_prey_resp <- avg_pred_prey_resp |>
  ggplot(aes(x = prey, y = estimate, color = `Tree cover`, fill = `Tree cover`)) +
  geom_ribbon(aes(ymin = conf.low, ymax = conf.high), alpha = 0.2, color = NA) +
  geom_line(linewidth = 1.5, lineend = "round") +
  labs(y = "Average predicted\npopulation abundance", x = "Prey")

p_avg_comp_resp <- avg_pred_comp_resp |>
  ggplot(aes(x = competitors, y = estimate, color = `Tree cover`, fill = `Tree cover`)) +
  geom_ribbon(aes(ymin = conf.low, ymax = conf.high), alpha = 0.2, color = NA) +
  geom_line(linewidth = 1.5, lineend = "round") +
  labs(y = "Average predicted\npopulation abundance", x = "Competitors")

p_avg_prey_resp + p_avg_comp_resp

Population-scale average curves, one pair per tree-cover peak.

The shapes barely change between tree-cover panels — the prey × competitors interaction lives entirely in the abundance-when-present part. What changes is the overall height: high-tree-cover populations sit consistently above low-tree-cover ones, a pure occupancy effect, cleanly separated from the abundance-when-present shape.

A full rate-ratio table, by model component

Code
# Conditional-model rate ratio for scl_prey, at three levels of scl_competitors
prey_rr_tbl <- avg_slopes(mod_hu, variables = "scl_prey", by = "scl_competitors",
                           newdata = datagrid(scl_competitors = c(-1, 0, 1)),
                           type = "link") |>
  mutate(rate_ratio = exp(estimate), rr_low = exp(conf.low), rr_high = exp(conf.high),
         Term = glue::glue("scl_prey (at scl_competitors = {scl_competitors} SD)")) |>
  select(Term, rate_ratio, rr_low, rr_high, p.value)

# Conditional-model rate ratio for scl_competitors, at three levels of scl_prey
comp_rr_tbl <- avg_slopes(mod_hu, variables = "scl_competitors", by = "scl_prey",
                           newdata = datagrid(scl_prey = c(-1, 0, 1)),
                           type = "link") |>
  mutate(rate_ratio = exp(estimate), rr_low = exp(conf.low), rr_high = exp(conf.high),
         Term = glue::glue("scl_competitors (at scl_prey = {scl_prey} SD)")) |>
  select(Term, rate_ratio, rr_low, rr_high, p.value)

# The interaction itself: ratio of rate ratios per 1-SD increase in the moderator
hu_confint <- confint(mod_hu)
hu_coef_cond <- summary(mod_hu)$coefficients$cond
hu_coef_zi <- summary(mod_hu)$coefficients$zi

interaction_rr_tbl <- tibble(
  Term = "scl_prey:scl_competitors (interaction)",
  rate_ratio = exp(hu_coef_cond["scl_prey:scl_competitors", "Estimate"]),
  rr_low = exp(hu_confint["cond.scl_prey:scl_competitors", "2.5 %"]),
  rr_high = exp(hu_confint["cond.scl_prey:scl_competitors", "97.5 %"]),
  p.value = hu_coef_cond["scl_prey:scl_competitors", "Pr(>|z|)"]
)

# Zero-inflation-model odds ratio for scl_tree_cov (no moderator in this model)
zi_rr_tbl <- tibble(
  Term = "scl_tree_cov (zero-inflation)",
  rate_ratio = exp(hu_coef_zi["scl_tree_cov", "Estimate"]),
  rr_low = exp(hu_confint["zi.scl_tree_cov", "2.5 %"]),
  rr_high = exp(hu_confint["zi.scl_tree_cov", "97.5 %"]),
  p.value = hu_coef_zi["scl_tree_cov", "Pr(>|z|)"]
)

hu_rr_tbl <- bind_rows(
  prey_rr_tbl |> mutate(Component = "Conditional (abundance, when present)"),
  comp_rr_tbl |> mutate(Component = "Conditional (abundance, when present)"),
  interaction_rr_tbl |> mutate(Component = "Conditional (abundance, when present)"),
  zi_rr_tbl |> mutate(Component = "Zero-inflation (occupancy, as an odds ratio)")
) |>
  relocate(Component) |>
  mutate(
    across(c(rate_ratio, rr_low, rr_high), ~ round(.x, 3)),
    p.value = if_else(p.value < 0.001, "<0.001", as.character(round(p.value, 3))),
    CI_95 = glue::glue("[{rr_low}, {rr_high}]")
  ) |>
  select(Component, Term, `Rate/odds ratio` = rate_ratio, `95% CI` = CI_95, `p-value` = p.value)

hu_rr_tbl |>
  tt(caption = "Standardized rate ratios (conditional model) and odds ratio (zero-inflation model) for mod_hu") |>
  style_tt(i = which(hu_rr_tbl$Component != dplyr::lag(hu_rr_tbl$Component, default = "")) - 1,
           j = 1:5, line = "b", line_color = "black", line_width = 0.05) |>
  style_tt(fontsize = 0.6)
Standardized rate ratios (conditional model) and odds ratio (zero-inflation model) for mod_hu
Component Term Rate/odds ratio 95% CI p-value
Conditional (abundance, when present) scl_prey (at scl_competitors = -1 SD) 1.227 [1.143, 1.318] <0.001
Conditional (abundance, when present) scl_prey (at scl_competitors = 0 SD) 0.953 [0.861, 1.055] 0.349
Conditional (abundance, when present) scl_prey (at scl_competitors = 1 SD) 0.740 [0.615, 0.89] 0.001
Conditional (abundance, when present) scl_competitors (at scl_prey = -1 SD) 0.650 [0.565, 0.748] <0.001
Conditional (abundance, when present) scl_competitors (at scl_prey = 0 SD) 0.505 [0.456, 0.559] <0.001
Conditional (abundance, when present) scl_competitors (at scl_prey = 1 SD) 0.392 [0.341, 0.45] <0.001
Conditional (abundance, when present) scl_prey:scl_competitors (interaction) 0.776 [0.705, 0.855] <0.001
Zero-inflation (occupancy, as an odds ratio) scl_tree_cov (zero-inflation) 0.898 [0.81, 0.996] 0.041

Prey’s rate ratio crosses 1 somewhere around average competitor density (why its raw summary() estimate looked like noise); competitors suppress abundance more as prey increases; the interaction row (~0.81) is the single number behind both patterns, and it’s the same number whichever predictor you treat as the moderator — the symmetry point from a few slides back, visible directly in the table. Tree cover’s odds ratio in the zero-inflation part tells a simpler, interaction-free story about occupancy alone.

Part 3 takeaways

  1. Non-linear link functions (logit, log) create an implicit interaction: every predictor’s response-scale effect depends on the values of all other predictors, even with no interaction term in the formula
  2. Raw GLM coefficients live on the link scale; marginaleffects::slopes() converts them to the natural (probability, count) scale for you
  3. Multiplicative summaries (rate ratios, odds ratios) stay constant across the range of other predictors; additive, response-scale summaries do not
  4. Whether an interaction “exists” can depend on the scale you’re measuring on (Spake et al. 2023) — this applies just as much to implicit link-function interactions as to explicit interaction terms
  5. Hurdle/zero-inflated models separate “does it occur” from “how much, given it occurs” — interpret each part separately, or combine them (type = "response") for a population-scale answer
  6. Interactions are mathematically symmetric: report (or at least check) both “A moderated by B” and “B moderated by A”

Exercise 6 / Homework

Now apply what we learned today to your data!

Wrap-up and further reading