When one relationship depends on another
2026-07-23
recognise a conditional relationship — when the association between \(Y\) and one predictor depends on another — and specify it as an interaction;
interpret the conditional main terms and the interaction term, and mean-centre so the main terms mean something;
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?
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?
Part 1 of 2
Education is the classic antidote to prejudice — in the West. Does the same hold in a very different context?
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.
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()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).
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:
\(\rightarrow\) The education effect depends on the country. That is an interaction.

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\]
cntry * eduyrs is shorthand: R adds cntry, eduyrs, and cntry:eduyrs, the interaction term.
| Additive | 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.
| 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.
Shift education so that zero = the average:
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.

The interaction term (0.153) reads two ways:
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}}\]
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).

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")| 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?
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 ↗
Part 2 of 2
Back to the state-ownership triangle from Lectures 2 and 9 — but now the two arms of the triangle interact.
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?

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)
)The additive state-ownership slope is -3.78 — the very number from Lecture 9. One flat plane, one slope for all.

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.


# 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:
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.
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.
Two predictors interact when the effect of one depends on the value of the other. You specify it by adding their product — y ~ x1 * x2.
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.
The interaction term has two equivalent readings: how one slope changes with the other variable, and vice versa.
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.
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.
Shaky on any of these? That is what this week’s Absalon quiz and the Friday exercise class are for.
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.NULL

Lecture 11 · Interaction effects