Interaction Effects

When one relationship depends on another

Merlin Schaeffer · Department of Sociology

2026-07-23

By the end of today you can …

  1. recognise a conditional relationship — when the association between \(Y\) and one predictor depends on another — and specify it as an interaction;

  2. interpret the conditional main terms and the interaction term, and mean-centre so the main terms mean something;

  3. visualise an interaction and read the conditional slopes straight off the picture.

One part per goal. Two running questions: does education reduce xenophobia the same way in Denmark and Bulgaria — and do state ownership and civil liberties interact in fighting poverty?

Additive, so far

Everything we have built assumes predictors act additively — each adds its slice, independently of the others:

\[Y_i = \alpha + \beta_1 X_1 + \beta_2 X_2 + e_i\]

Beware: this quietly assumes the effect of \(X_1\) is the same at every value of \(X_2\). Often it is not.

Discuss: does more education reduce xenophobia by the same amount in a long-established democracy and in a post-socialist state? Should we even expect it to?

Does education fight xenophobia everywhere?

Part 1 of 2

Education is the classic antidote to prejudice — in the West. Does the same hold in a very different context?

The research question of the day

Both Denmark and Bulgaria have seen fierce debates over immigration. In both, we can ask whether the more educated hold warmer views of immigrants.

Research question: is the link between education and xenophobia the same in Denmark and Bulgaria — or does it depend on the country?

Our outcome: agreement that “immigrants make Denmark/Bulgaria a worse place to live” — reversed so higher = more xenophobic, on a 0–10 scale.

Data: European Social Survey round 9 (2018), Danish and Bulgarian respondents.

Preparation

pacman::p_load(
  tidyverse,    # Data manipulation and visualization
  haven,        # Read SPSS files & handle labelled data
  estimatr,     # OLS with robust / weighted standard errors
  modelsummary  # Regression tables
)
ESS <- read_spss("../assets/ESS9e03_1.sav") %>%
  filter(cntry == "DK" | cntry == "BG") %>%        # Danes and Bulgarians
  mutate(
    cntry  = as_factor(cntry) %>% fct_relevel("Denmark"),  # Denmark = reference
    gndr   = as_factor(gndr),
    eduyrs = case_when(                             # Sensible bounds
      eduyrs < 9  ~ 9,
      eduyrs > 21 ~ 21,
      TRUE        ~ zap_labels(eduyrs)
    ),
    agea    = zap_labels(agea),
    imwbcnt = 10 - zap_labels(imwbcnt)              # Reverse: higher = more xenophobic
  ) %>%
  select(idno, cntry, pspwght, gndr, eduyrs, agea, imwbcnt) %>%
  drop_na()

Additive multiple OLS: one slope for everyone

ols_bi   <- lm_robust(imwbcnt ~ cntry, data = ESS, weights = pspwght)
ols_add  <- lm_robust(imwbcnt ~ cntry + eduyrs, data = ESS, weights = pspwght)

modelsummary(
  list("Country only" = ols_bi, "+ education" = ols_add),
  coef_rename = c("cntryBulgaria" = "Bulgaria", "eduyrs" = "Years of education"),
  stars = TRUE, gof_map = c("nobs", "r.squared"), output = "kableExtra"
)
Country only + education
(Intercept) 4.326*** 4.909***
(0.071) (0.213)
Bulgaria 1.680*** 1.635***
(0.098) (0.100)
Years of education −0.043**
(0.014)
Num.Obs. 3330 3330
R2 0.113 0.116
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001

Bulgarians are markedly more xenophobic on average, and each year of education goes with 0.043 points less xenophobia. But this forces one education slope on both countries.

Additive = a flat, tilted plane: the education slope is the same at every age (and every country).

But what if the relationship is conditional?

Two predictors interact when the association between \(Y\) and one of them depends on the value of the other.

Look at the two clouds separately:

  • In Denmark, education slopes clearly downward — the educated are less xenophobic.
  • In Bulgaria, the line is nearly flat — education barely moves xenophobia.

\(\rightarrow\) The education effect depends on the country. That is an interaction.

Specify it as multiplicative

We add the product of the two predictors:

\[Y_i = \alpha + \beta_1 X_1 + \beta_2 X_2 + \color{#901A1E}{\beta_3 (X_1 \times X_2)} + e_i\]

# The * asks R for both main terms AND their product
ols_int <- lm_robust(imwbcnt ~ cntry * eduyrs,
                     data = ESS, weights = pspwght)

cntry * eduyrs is shorthand: R adds cntry, eduyrs, and cntry:eduyrs, the interaction term.

Additive &nbsp;Interaction
(Intercept) 4.909*** 5.873***
(0.213) (0.279)
Bulgaria 1.635*** −0.375
(0.100) (0.385)
Education −0.043** −0.113***
(0.014) (0.019)
Bulgaria × Education 0.153***
(0.027)
Num.Obs. 3330 3330
R2 0.116 0.127
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001

OLS handles a multiplicative idea by adding one column — the product of the two variables. The conditional relationship is linearised.

The trap: what do the main terms mean now?

&nbsp;Interaction
(Intercept) 5.873***
(0.279)
Bulgaria −0.375
(0.385)
Education −0.113***
(0.019)
Bulgaria × Education 0.153***
(0.027)
Num.Obs. 3330
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001

Once you add the product, each main term is conditional — it holds only where the other variable is zero:

\[\beta_{\text{Education}} \;\big|\; \text{Bulgaria} = 0 \quad(\text{i.e. in Denmark})\] \[\beta_{\text{Bulgaria}} \;\big|\; \text{Education} = 0\]

Beware: the “Bulgaria” coefficient is now the country gap at zero years of education — a person who does not exist. No wonder it looks strange.

The fix: mean-centre

Shift education so that zero = the average:

ESS <- ESS %>%
  mutate(mc_eduyrs = eduyrs - mean(eduyrs))

ols_intc <- lm_robust(imwbcnt ~ cntry * mc_eduyrs,
                      data = ESS, weights = pspwght)

Now Education = 0 means “a person with average schooling” — a real, typical case. The interaction term is unchanged; only the main terms move to where we can read them.

Raw Mean-centred
(Intercept) 5.873*** 4.378***
(0.279) (0.072)
Bulgaria −0.375 1.650***
(0.385) (0.099)
Education −0.113***
(0.019)
Bulgaria × Educ 0.153*** 0.153***
(0.027) (0.027)
Education (centred) −0.113***
(0.019)
Num.Obs. 3330 3330
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001

The Bulgaria gap becomes the gap at average education — sensible, and significant.

Reading the interaction term

The interaction term (0.153) reads two ways:

  1. It is how much the education slope changes from Denmark to Bulgaria: \[\underbrace{-0.113}_{\text{DK slope}} + \underbrace{0.153}_{\text{interaction}} = \underbrace{0.04}_{\text{BG slope}}\]

  2. It is how much the country gap changes with each year of education.

Education fights xenophobia in Denmark (slope -0.113) but is essentially flat in Bulgaria (slope 0.04).

A second example: education × gender

ols_eg <- lm_robust(imwbcnt ~ mc_eduyrs * gndr,
                    data = ESS, weights = pspwght)

modelsummary(list("Xenophobia" = ols_eg),
             coef_rename = c("mc_eduyrs" = "Education (centred)",
                             "gndrFemale" = "Female",
                             "mc_eduyrs:gndrFemale" = "Education × Female"),
             stars = TRUE, gof_map = c("nobs"), output = "kableExtra")
&nbsp;Xenophobia
(Intercept) 5.278***
(0.075)
Education (centred) −0.060**
(0.021)
Female −0.097
(0.102)
Education × Female −0.037
(0.029)
Num.Obs. 3330
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001

Discuss: interpret the Female coefficient. At what value of education does it hold — and is that why it may look small?

Break

Your turn: exercise 1

You test whether the Bali terror-attack effect was the same across ten countries — a treatment × country interaction on Legewie’s data.

Open exercise 1 in a new tab ↗

Two continuous predictors

Part 2 of 2

Back to the state-ownership triangle from Lectures 2 and 9 — but now the two arms of the triangle interact.

Revised research question

In Lecture 9 we asked whether civil liberties confound or mediate the link between state ownership of the economy and poverty.

Today we ask something sharper:

Research question: does state ownership fight poverty differently depending on the level of civil liberties — strong under one, weak under the other?

Build the triangle data

pacman::p_load(tidyverse, estimatr, vdemdata, wbstats, modelsummary)

# V-Dem: civil liberties, and state ownership (reversed so higher = more)
Dat_vdem <- vdem %>%
  as_tibble() %>%
  select(country_text_id, year,
         civ_liberties = v2xcl_rol,
         state_own_raw = v2clstown) %>%
  mutate(state_ownership = -state_own_raw)

# World Bank: extreme poverty (< $3.00 a day). Live, with cached fallback.
Dat_pov <- tryCatch(
  wb_data("SI.POV.DDAY", start_date = 1972, end_date = 2025),
  error = function(e) readRDS("data/wb_poverty_raw.rds")
) %>%
  rename(poverty = SI.POV.DDAY, year = date, country_text_id = iso3c) %>%
  select(country_text_id, year, country, poverty) %>%
  drop_na(poverty) %>%
  group_by(country) %>% filter(year == max(year)) %>% ungroup()

Dat <- inner_join(Dat_pov, Dat_vdem, by = c("country_text_id", "year")) %>%
  drop_na(poverty, state_ownership, civ_liberties) %>%
  mutate(                                # Mean-centre BOTH, ready for the interaction
    mc_state = state_ownership - mean(state_ownership),
    mc_civ   = civ_liberties  - mean(civ_liberties)
  )

Additive first: one flat plane

ols_add2 <- lm_robust(poverty ~ mc_state + mc_civ,
                      data = Dat)

modelsummary(list("Poverty" = ols_add2),
             coef_rename = c("mc_state" = "State ownership",
                             "mc_civ" = "Civil liberties"),
             stars = TRUE, gof_map = c("nobs", "r.squared"),
             output = "kableExtra")

The additive state-ownership slope is -3.78 — the very number from Lecture 9. One flat plane, one slope for all.

Now let the plane bend

ols_int2 <- lm_robust(poverty ~ mc_state * mc_civ,
                      data = Dat)

modelsummary(
  list("Additive" = ols_add2, "Interaction" = ols_int2),
  coef_rename = c("mc_state" = "State ownership",
                  "mc_civ" = "Civil liberties",
                  "mc_state:mc_civ" = "State own. × Civ. lib."),
  stars = TRUE, gof_map = c("nobs", "r.squared"),
  output = "kableExtra"
)

An interaction implies non-linearity: the surface is no longer flat. The fitted effect of state ownership now twists as civil liberties rise — though whether that twist is real is a question the table, not the plane, must answer.

Read it as lines, not a surface

# 1. One standard deviation of the moderator (civil liberties)
sd_c <- sd(Dat$mc_civ)

# 2. A grid: many state-ownership values, at THREE civil-liberties levels
grid <- expand.grid(
  mc_state = seq(min(Dat$mc_state), max(Dat$mc_state), length.out = 50),
  mc_civ   = c(-sd_c, 0, sd_c)             # low, average, high
)

# 3. Predict poverty from the interaction model on that grid
grid$poverty   <- predict(ols_int2, newdata = grid)
grid$Liberties <- factor(grid$mc_civ,
  labels = c("Low (autocratic)", "Average", "High (liberal)"))

# 4. One line per civil-liberties level
ggplot(grid, aes(x = mc_state, y = poverty, color = Liberties)) +
  geom_line(linewidth = 1.1) +
  scale_color_manual(values = c("#901A1E", "#b5892c", "#425570")) +
  labs(x = "State ownership (centred)",
       y = "Predicted % below $3.00 a day",
       color = "Civil liberties") +
  theme_minimal(base_size = 14) + theme(legend.position = "bottom")

The same model, drawn as the state-ownership slope at three levels of civil liberties:

  • Low civil liberties (autocratic): slope -5.05 — state ownership appears to go with less poverty.
  • Average: slope -2.98.
  • High civil liberties (liberal): slope -0.9 — state ownership appears to barely matter.

Beware — read the table first. The interaction term is not statistically significant (0.24). The fan of lines is suggestive, but we cannot conclude the slopes genuinely differ. A picture is not a p-value.

Two layers of humility

First: here the interaction was not significant — so we cannot even claim the slopes truly differ. The suggestive fan of lines may be noise.

Second: even if it had been significant, an interaction is still only a description. It says the associations depend on each other — never which way the causation runs.

For that we would need a clever causal research design — exactly the kind the rest of the course is about.

Today’s general lessons

  1. Two predictors interact when the effect of one depends on the value of the other. You specify it by adding their producty ~ x1 * x2.

  2. With an interaction, each main term is conditional — the effect of one variable where the other is zero. Mean-centre continuous predictors so “zero” is the average and the main terms are readable.

  3. The interaction term has two equivalent readings: how one slope changes with the other variable, and vice versa.

  4. Between two continuous variables an interaction is a form of non-linearity — a twisted surface. Read it as predicted lines at a few values of the moderator, not off the 3D plane.

  5. Always check the interaction’s significance before telling its story — a suggestive fan of lines is not a finding. And even a significant interaction is still only a description: it says the slopes depend on each other, not which way the causation runs.

Check yourself: today’s goals

  • Given a scatter with two visibly different slopes, write the model that captures it and say which term is the interaction.
  • Explain what the “main term” coefficients mean in an interaction model — and why you would mean-centre first.
  • Read an interaction as predicted lines, and state the effect of one predictor at low, average and high values of the other.

Shaky on any of these? That is what this week’s Absalon quiz and the Friday exercise class are for.

Today’s important functions

  • lm_robust(y ~ x1 * x2, ...): fit an interaction — * adds both main terms and their product.
  • lm_robust(y ~ x1 * x2 + c, ...): an interaction plus an additive control.
  • mutate(mc_x = x - mean(x)): mean-centre, so a main term reads at the average.
  • predict(model, newdata = ...): predicted values on a grid — the basis for the predicted-line plot.
  • factor(..., labels = ...): turn low / average / high moderator values into readable line labels.

References

NULL