library(tidyverse)
library(easystats)
library(gtsummary)
library(ggformula)
library(sjlabelled)
library(ggeffects)
library(marginaleffects)
Week 4 Worksheet Notes
Interactions: Estimating, graphing and interpreting interaction effects
Aims
By the end of the session you will learn how to:
- fit, visualise and interpret results from regression models that include interaction terms
R packages
Introduction
This session explores examples of interactions in regression models. We will look more closely at some of the multiple regression models we have fit in previous labs and ask whether the effects of core explanatory variables could be said to depend on (or are conditioned on) the values of other explanatory variables included in the model. In practice, this will involve including the product of two explanatory variables in the model. Thus, taking a regression model of the generic form \(y = \beta_0 + \beta_1X_1 + \beta_2X_2 + \epsilon\), we ask whether there is an interaction between variables \(X_1\) and \(X_2\) (i.e. whether the effect of one depends on the values of the other) by fitting a model of the form \(y = \beta_0 + \beta_1X_1 + \beta_2X_2 + \color{green}{\beta_3(X_1 \times X_2)} + \epsilon\), where we are interested in the “interaction effect” represented by \(\beta_3\).
While fitting such interaction models is simple in practice, understanding when they are needed, interpreting, and communicating their results can be challenging, and a growing literature in the social sciences has explored best practices in relation to such models (Berry, Golder, and Milton 2012; Brambor, Clark, and Golder 2006; Clark and Golder 2023; Hainmueller, Mummolo, and Xu 2019). Advancements in software have also made it easier to interpret and present results from interaction models, with some notable R
packages entering this space over the past few years; we have already encountered the {marginaleffects}
and {ggeffects}
packages when visualising results from logistic regression models, and examining results from interaction models presents very similar challenges. In this week’s lab we will use some of these tools to undertake comprehensive analyses of interaction effects.
We have already encountered interactions in all of the application readings we have engaged with so far. However, they mostly involved more complex cases of interactions, whose understanding will first require a more basic knowledge of how interaction effects work and how they can be implemented in practice. Wu (2021) and Dingemans and Van Ingen (2015) make use of “cross-level” interactions, which we will revisit when learning about multilevel models (Week 6). Mitchell (2021) uses higher-level interactions on data aggregated at country level as an additional analysis to their main models, while Österman (2021) operates with a quasi-experimental design in which the interaction effect is taken to divulge a more directly causal effect the data. This latter example is “more complex” only at a conceptual and study design level, and we will replicate it in the lab exercises. However, we begin by looking at a conceptually less involved example where interaction effects are at the core of the analysis, also from the field of social trust research.
Example 2. Österman (2021): Do educational reforms have different effects by levels of parental education?
This research question is at the core of the study conducted by Österman (2021). We have explored this article and used its data to fit simple binary and multiple regression models in Week 2. We can now take that analysis further and replicate some of the models reported in Table 3 of that article:
As our main interest is not so much on their substantive findings regarding the complex effects of different types of educational reforms, but rather on seeing interaction effects operating in real-life data, we will focus on Models 1-3 (All reforms). The main difference compared to the previous example is conceptual: the variable “Reform” is meant to distinguish between respondents who were affected by a major reform of the secondary education system in their countries during their schooling years, or not. This allows the researcher to posit a “quasi-experimental” scenario, in which being exposed to a “reform” can be interpreted in a more causal way, as in an experimental setting. The “reform”, thus, can be thought of as a “treatment” in an experiment, so the examining its statistical effect can be taken to have a more reliably causal interpretation that when exploring associations between purely “descriptive” variables. Technically, however, the models we fit are no different to those in the previous example.
Data preparation
As a first step, let’s import the osterman dataset that underpins the Österman (2021) article (see the Data page on the course website for information about the datasets available in this course). We can download the data to a local folder and load it from there, or we can load it directly from the web (if it works…):
# Import the data
<- data_read("https://cgmoreh.github.io/HSS8005-24/data/osterman.dta") osterman
It’s always a good idea to inspect the dataset after importing, to identify any issues. One option is to check a codebook, for example:
# `View` the codebook:
data_codebook(osterman) |> View()
Remember that some “tagged” NA
values that were imported from the Stata
data format need changing (see Week 2 worksheet):
<- sjlabelled::zap_na_tags(osterman) osterman
Model 1 redux: no covariates
We start by fitting a simplified version of Model1 (in Table 3 of Österman (2021)). The reported model contains the variables highlighted in the table (“Reform”, “Female” and “Ethnic minority”) as well as a number of additional covariates for statistical control (fbrneur
, mbrneur,
fnotbrneur
, mnotbrneur
, agea
, essround
, yrbrn
, eform_id_num
, including “self-interactions” and interactions between some of those control variables: yrbrn*yrbrn
, yrbrn*reform_id_num,
agea*reform_id_num
, agea*agea
, agea*agea*reform_id_num
). The model is also adjusted for sampling weights (dweight
) and the standard errors are clustered in “country-by-birth year cohort” groups (see Österman (2021)[pp. 222] for their reasoning for this modelling choice; we will discuss this aspect of the data in more detail when we consider multilevel models).
Here we will first fit a much simplified model that only includes the few variables highlighted in the table, without any additional control variables, weighting or clustering.
Model 1 doesn’t include an interaction, so it is a simple multiple linear regression model:
<- lm(trustindex3 ~ reform1_7 + female + blgetmg_d, data = osterman)
osterman_m1
# Print model parameters, with digits rounded to three decimal points
model_parameters(osterman_m1, digits = 3)
Parameter | Coefficient | SE | 95% CI | t(68792) | p
------------------------------------------------------------------------
(Intercept) | 5.251 | 0.013 | [ 5.226, 5.276] | 412.067 | < .001
reform1 7 | -0.008 | 0.015 | [-0.037, 0.020] | -0.560 | 0.576
female | 0.007 | 0.015 | [-0.021, 0.036] | 0.493 | 0.622
blgetmg d | -0.478 | 0.060 | [-0.595, -0.361] | -8.010 | < .001
Uncertainty intervals (equal-tailed) and p-values (two-tailed) computed
using a Wald t-distribution approximation.
We find that without the additional model specifications, our results are quite different from those reported in the article. The “Reform” effect is much weaker and in the opposite direction, losing its statistical significance (p = 0.576). The effect of “Female” is also weaker and statistically not significant. On the other hand, belonging to an “ethnic minority group” has a stronger and clearer effect in this simplified model.
As a next step, we aim to expand the model by including all the specified covariates. This, however, will bring up an R
coding challenge which can also serve as an introduction to the concept of polynomial terms.
Problem 1: Polynomials
In the footnotes to Table 3 and Table A.3 in the Appendix, the author tells us:
All models include reform FEs [Fixed Effects], a general quadratic birth year trend, and reform-specific trends for birth year (linear) and age (quadratic). Additional controls: foreign-born parents and ESS round dummies.
The tables in the Appendix (Table A.3) “report all of the coefficients for the control variables, except for the age and birth year controls. Since the latter variables are interacted with all of the reform fixed effects, they are not interpretable as single coefficients”.
In the main text, the author further explains some of the reasoning behind their modelling choices:
One dilemma for the design is that there has been a trend of increasing educational attainment throughout the studied time period, which means that the reform-windows of treated and non-treated cohorts will also pick up the effects of this trend. To counter this, [the list of covariates] includes a general quadratic birth year trend, reform-specific linear controls for birth year and reform-specific quadratic age trends. The quadratic terms are included to allow sufficient flexibility in absorbing possible non-linear trends of increasing education within the reform-window of seven treated and seven untreated birth year cohorts. … The reform-fixed effects are also essential because they imply that only the within-reform-window variation is used to estimate the effects and between-reform differences are factored out, such as pre-reform differences in social trust. (Österman 2021:221–22)
Before we fit the model, some of the concepts in the quotation need unpacking. A quadratic term is a second-degree polynomial term: put simply, it’s the square of the variable concerned. The quadratic of a variable such as \(age\) is therefore \(age^2\), or \(age \times age\). In other words, it is like a variable’s “interaction” with itself. Because of this, there are several ways in which the quadratic terms to be included in a model can be specified:
- We could create the quadratic terms as new variables, and include those in the model. Effectively, we would be creating new variables/columns in the dataset and using those in the model. We could do that as shown below:
# Create new quadratic variables by multiplication with themselves and add them to the dataset, saving it as a new data object:
<- osterman |>
osterman mutate(agea_quad = agea*agea, # quadratic age variable
yrbrn_quad = yrbrn*yrbrn) |> # quadratic birth-year variable
::var_labels(agea_quad = "Age (quadratic)", # we can label the variable if we want
sjlabelledyrbrn_quad = "Birth year (quadratic)")
# We now have two additional variables in the dataset; we can fit a model using those:
<- lm(trustindex3 ~ reform1_7 + female + blgetmg_d + # main covariates reported in Table 3
m1_prequad + mbrneur + fnotbrneur + mnotbrneur + factor(essround) + # additional controls for foreign-born parents and ESS Round dummies (we treat `round` as a factor for this)
fbrneur + yrbrn + agea_quad + yrbrn_quad + # general quadratic birth year trend and quadratic age
agea factor(reform_id_num) + # reform fixed effects dummies
:factor(reform_id_num) + # reform-specific birth year trend
yrbrn:factor(reform_id_num) + agea_quad:factor(reform_id_num), # reform-specific quadratic age trend
ageadata = osterman) # the new expanded dataset
- We can get the same results by creating the quadratic terms directly as part of the modelling function. The one thing we should keep in mind is that if we want to include standard mathematical operations within a formula function, we need to isolate or insulate the operation from
R
’s formula parsing code using theI()
function, which returns the contents ofI(...)
“as.is”. The model formula would then be:
# Create quadratic terms internally as part of the modelling function:
<- lm(trustindex3 ~ reform1_7 + female + blgetmg_d + # main covariates reported in Table 3
m1_funquad + mbrneur + fnotbrneur + mnotbrneur + factor(essround) + # additional controls for foreign-born parents and ESS Round dummies (we treat `round` as a factor for this)
fbrneur + yrbrn + I(agea^2) + I(yrbrn^2) + # general quadratic birth year trend and quadratic age
agea factor(reform_id_num) + # reform fixed effects dummies
:factor(reform_id_num) + # reform-specific birth year trend
yrbrn:factor(reform_id_num) + I(agea^2):factor(reform_id_num), # reform-specific quadratic age trend
ageadata = osterman) # the original dataset
- In the two previous options, the quadratic terms will be correlated with the original variables. To avoid this by relying on so-called orthogonal polynomials we should use the
poly()
function. We can also fit the same correlated polynomial model as the ones above by adding theraw = TRUE
option to thepoly()
function. In the code below, we will fit the correlated version first, then the orthogonal version (This stackoverflow discussion explains in more detail the difference between the two options):
<- lm(trustindex3 ~ reform1_7 + female + blgetmg_d +
m1_polyraw + mbrneur + fnotbrneur + mnotbrneur + factor(essround) +
fbrneur poly(agea, 2, raw = TRUE) + poly(yrbrn, 2, raw = TRUE) +
factor(reform_id_num) +
:factor(reform_id_num) +
yrbrn:factor(reform_id_num) + poly(agea, 2, raw = TRUE):factor(reform_id_num),
ageadata = osterman)
<- lm(trustindex3 ~ reform1_7 + female + blgetmg_d +
m1_polyorth + mbrneur + fnotbrneur + mnotbrneur + factor(essround) +
fbrneur poly(agea, 2) + poly(yrbrn, 2) +
factor(reform_id_num) +
:factor(reform_id_num) +
yrbrn:factor(reform_id_num) + poly(agea, 2):factor(reform_id_num),
ageadata = osterman)
For a side-by-side overview comparison of the models we fitted so far, we can use one of the many model summary tabulation functions that exist in various packages. Some popular options are stargazer::stargazer()
, jtools::export_summs()
, sjPlot::tab_model()
, modelsummary::modelsummary()
or parameters::compare_models
from the {easystats}
ecosystem. Below we will use modelsummary()
, which produces publication-ready summary tables in HTML format, but which can easily be exported to other formats, such as Microsoft Word or PDF:
# It's cleaner to first make a list of the models we want to summarise; we can even name them:
<- list(
models "Pre-calculated quadratic" = m1_prequad,
"Within-function quadratic" = m1_funquad,
"poly() with raw coding" = m1_polyraw,
"poly() with default orthogonal coding" = m1_polyorth
)
# modelsummary table with stars for p-values added
::modelsummary(models, stars = TRUE) modelsummary
The results from the modelsummary()
are not shown here because it’s a long and ugly table, but it’s useful for perusing to compare the results across the different models. We do notice some differences in the affected variables between the orthogonal-coded version and the others. It’s worth noting, however, that the Stata
routine used by the author fitted correlated/raw-coded polynomials, so the orthogonal version from the output above is just for a comparison and we will not use it going forward. We generally want our transformed (polynomial) variables to be correlated with the original variables, as they are in fact measuring the same thing.
For a cleaner table showing only the results included in Table A.3 in the Appendix to Österman (2021), we can use the coef_map
or the coef_omit
option in modelsummary()
and only include m1_funquad, which will be the polynomial fitting routine that we will use going forward:
# It's cleaner to first make a vector of the coefficients we wish to include; we can name the coefficients as they appear in Table A.3; note that we also leave out the Intercept, as in the published table:
<- c("reform1_7" = "Reform",
coefs "female" = "Female",
"blgetmg_d" = "Ethnic minority",
"fbrneur" = "Foreign-born father, Europe",
"mbrneur" = "Foreign-born mother, Europe",
"fnotbrneur" = "Foreign-born father, outside Europe",
"mnotbrneur" = "Foreign-born mother, outside Europe",
"factor(essround)2" = "ESS Round 2",
"factor(essround)3" = "ESS Round 3",
"factor(essround)4" = "ESS Round 4",
"factor(essround)5" = "ESS Round 5",
"factor(essround)6" = "ESS Round 6",
"factor(essround)7" = "ESS Round 7",
"factor(essround)8" = "ESS Round 8",
"factor(essround)9" = "ESS Round 9")
# Then we pass the vector to coef_map to select the coefficients to print
::modelsummary(list(m1_funquad), stars = TRUE, coef_map = coefs) modelsummary
(1) | |
---|---|
Reform | 0.063* |
(0.027) | |
Female | 0.058*** |
(0.013) | |
Ethnic minority | −0.241*** |
(0.054) | |
Foreign-born father, Europe | −0.111** |
(0.042) | |
Foreign-born mother, Europe | −0.108* |
(0.044) | |
Foreign-born father, outside Europe | −0.065 |
(0.073) | |
Foreign-born mother, outside Europe | −0.110 |
(0.078) | |
ESS Round 2 | 0.059 |
(0.045) | |
ESS Round 3 | 0.162* |
(0.075) | |
ESS Round 4 | 0.243* |
(0.108) | |
ESS Round 5 | 0.360* |
(0.144) | |
ESS Round 6 | 0.397* |
(0.179) | |
ESS Round 7 | 0.449* |
(0.212) | |
ESS Round 8 | 0.655** |
(0.246) | |
ESS Round 9 | 0.816** |
(0.283) | |
Num.Obs. | 68796 |
R2 | 0.200 |
R2 Adj. | 0.198 |
AIC | 268913.8 |
BIC | 270056.1 |
Log.Lik. | −134331.877 |
RMSE | 1.71 |
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001 |
We can compare our table to the published one; there will, of course, be some differences, as our model specification is still not the same as that of the original paper. One source of the inconsistencies are the use of “survey weights” in the original article, something we will look into in Week 6.
Model 2
Model 2 includes two additions: an extra predictor for “High Parental Education”, as well as an interaction effect between “Reform” and “High parental education”. As in the previous example, we can fit the model like so:
<- lm(trustindex3 ~ reform1_7 + paredu_a_high + reform1_7*paredu_a_high + female + blgetmg_d + # main covariates reported in Table 3
osterman_m2 + mbrneur + fnotbrneur + mnotbrneur + factor(essround) + # additional controls for foreign-born parents and ESS Round dummies (we treat `round` as a factor for this)
fbrneur + yrbrn + I(agea^2) + I(yrbrn^2) + # general quadratic birth year trend and quadratic age
agea factor(reform_id_num) + # reform fixed effects dummies
:factor(reform_id_num) + # reform-specific birth year trend
yrbrn:factor(reform_id_num) + I(agea^2):factor(reform_id_num), # reform-specific quadratic age trend
ageadata = osterman) # the original dataset
We can now select the coefficients we want to display in the table and create a summary table comparing the results from Model 1 and Model 2 side by side:
<- c("reform1_7" = "Reform",
coefs_m2 "paredu_a_high" = "High parental edu",
"reform1_7:paredu_a_high" = "Ref x High par edu",
"femaleFemale" = "Female",
"blgetmg_d" = "Ethnic minority",
"fbrneur" = "Foreign-born father, Europe",
"mbrneur" = "Foreign-born mother, Europe",
"fnotbrneur" = "Foreign-born father, outside Europe",
"mnotbrneur" = "Foreign-born mother, outside Europe")
# Print model parameters, with digits rounded to three decimal points
::modelsummary(list(m1_funquad, osterman_m2), stars = TRUE, coef_map = coefs_m2) modelsummary
(1) | (2) | |
---|---|---|
Reform | 0.063* | 0.081** |
(0.027) | (0.029) | |
High parental edu | 0.349*** | |
(0.020) | ||
Ref x High par edu | −0.049+ | |
(0.029) | ||
Ethnic minority | −0.241*** | −0.207*** |
(0.054) | (0.056) | |
Foreign-born father, Europe | −0.111** | −0.106* |
(0.042) | (0.044) | |
Foreign-born mother, Europe | −0.108* | −0.115* |
(0.044) | (0.046) | |
Foreign-born father, outside Europe | −0.065 | −0.047 |
(0.073) | (0.076) | |
Foreign-born mother, outside Europe | −0.110 | −0.135+ |
(0.078) | (0.081) | |
Num.Obs. | 68796 | 64960 |
R2 | 0.200 | 0.209 |
R2 Adj. | 0.198 | 0.208 |
AIC | 268913.8 | 252985.1 |
BIC | 270056.1 | 254138.4 |
Log.Lik. | −134331.877 | −126365.537 |
RMSE | 1.71 | 1.69 |
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001 |
While the results of our Model 2 still differ from those reported based on a more complex model, we find that our estimates are much closer now, with all the coefficients pointing in the same direction as in the reported model, including the interaction effect of “Reform” by “High parental education”. Because the two variables involved in this interaction are both simple binary, indicator, variables (coded 0 and 1) it is possible to make (some) sense of these results. Compared to the “main” effects shown in the previous model, the coefficient for “Reform” now refers to the effect of the reforms among individuals with poorly educated parents (coded 0) only. The interaction coefficient shows, by contrast, the difference in the effect of “Reform” for those with highly educated parents (coded 1). In this case, the negative coefficient shows that in the case of those with highly educated parents, the effect of educational “reforms” is smaller.
We can check more closely the meaning of the interaction effect using a marginal plot:
ggpredict(osterman_m2, terms = c("reform1_7", "paredu_a_high")) |>
plot(connect.lines = TRUE)
This visualization in now easier to interpret than the numerical output. a steeper effect among those whose parents were highly educated compared to those whose parents had lower education, however, we can also see more clearly that the effect of educational reforms within the two groups is actually in the opposite direction: among those with lower parental education, educational reforms are associated with higher levels of trust, while among those with lower parental education, the effect is in the opposite direction.
Model 3 is different from Model 2 in that it includes interaction terms not only between “Reform” and “High parental education”, but Parental education is also interacted with all the other covariates that we did not include in our models.