9.4 More than one moderator

None of this is a problem for brms. But instead of using the model=i syntax in Hayes’s PROCESS, you just have to specify your model formula in brm().

9.4.1 Additive multiple moderation.

It’s trivial to add sex, its interaction with negemot, and the two covariates (i.e., posemot and ideology) to the model. We can even do it within update().

model7 <- 
  update(model1, newdata = glbwarm,
         govact ~ 1 + negemot + sex + age + posemot + ideology + negemot:sex + negemot:age,
         chains = 4, cores = 4)

Our output matches nicely with the formula at the bottom of page 232 and the PROCESS output in Figure 9.2.

print(model7, digits = 3)
##  Family: gaussian 
##   Links: mu = identity; sigma = identity 
## Formula: govact ~ negemot + sex + age + posemot + ideology + negemot:sex + negemot:age 
##    Data: glbwarm (Number of observations: 815) 
## Samples: 4 chains, each with iter = 2000; warmup = 1000; thin = 1;
##          total post-warmup samples = 4000
## 
## Population-Level Effects: 
##             Estimate Est.Error l-95% CI u-95% CI Eff.Sample  Rhat
## Intercept      5.274     0.341    4.614    5.946       2315 0.999
## negemot        0.091     0.084   -0.074    0.252       2051 0.999
## sex           -0.747     0.194   -1.129   -0.365       2176 1.001
## age           -0.018     0.006   -0.030   -0.006       2068 0.999
## posemot       -0.023     0.027   -0.075    0.031       3936 0.999
## ideology      -0.207     0.027   -0.261   -0.156       3764 1.001
## negemot:sex    0.206     0.050    0.103    0.305       2148 1.001
## negemot:age    0.005     0.002    0.002    0.008       1947 1.000
## 
## Family Specific Parameters: 
##       Estimate Est.Error l-95% CI u-95% CI Eff.Sample  Rhat
## sigma    1.048     0.027    0.997    1.101       3812 1.000
## 
## Samples were drawn using sampling(NUTS). For each parameter, Eff.Sample 
## is a crude measure of effective sample size, and Rhat is the potential 
## scale reduction factor on split chains (at convergence, Rhat = 1).

On page 325, Hayes discussed the unique variance each of the two moderation terms accounted for after controlling for the other covariates. In order to get our Bayesian version of these, we’ll have to fit two additional models, one after removing each of the interaction terms.

model8 <- 
  update(model7, newdata = glbwarm,
         govact ~ 1 + negemot + sex + age + posemot + ideology + negemot:sex,
         chains = 4, cores = 4)

model9 <- 
  update(model7, newdata = glbwarm,
         govact ~ 1 + negemot + sex + age + posemot + ideology + negemot:age,
         chains = 4, cores = 4)

Here we’ll extract the bayes_R2() iterations for each of the three models, place them all in a single tibble, and then do a little arithmetic to get the difference scores. After all that data wrangling, we’ll summarize() as usual.

r2_without_age_interaction <- bayes_R2(model8, summary = F) %>% as_tibble()
r2_without_sex_interaction <- bayes_R2(model9, summary = F) %>% as_tibble()
r2_with_both_interactions  <- bayes_R2(model7, summary = F) %>% as_tibble()

r2s <-
  tibble(r2_without_age_interaction = r2_without_age_interaction$R2,
         r2_without_sex_interaction = r2_without_sex_interaction$R2,
         r2_with_both_interactions  = r2_with_both_interactions$R2) %>% 
  mutate(`delta R2 due to age interaction` = r2_with_both_interactions - r2_without_age_interaction,
         `delta R2 due to sex interaction` = r2_with_both_interactions - r2_without_sex_interaction)

r2s %>% 
  select(`delta R2 due to age interaction`:`delta R2 due to sex interaction`) %>% 
  gather() %>% 
  group_by(key) %>% 
  summarize(mean = mean(value),
            ll = quantile(value, probs = .025),
            ul = quantile(value, probs = .975)) %>% 
  mutate_if(is.double, round, digits = 3)
## # A tibble: 2 x 4
##   key                              mean     ll    ul
##   <chr>                           <dbl>  <dbl> <dbl>
## 1 delta R2 due to age interaction 0.007 -0.049 0.062
## 2 delta R2 due to sex interaction 0.012 -0.041 0.068

Recall that \(R^2\) is in a 0-to-1 metric. It’s a proportion. If you want to convert that to a percentage, as in percent of variance explained, you’d just multiply by 100. To make it explicit, let’s do that.

r2s %>% 
  select(`delta R2 due to age interaction`:`delta R2 due to sex interaction`) %>% 
  gather() %>% 
  group_by(key) %>%
  summarize(mean = mean(value)*100,
            ll = quantile(value, probs = .025)*100,
            ul = quantile(value, probs = .975)*100) %>% 
  mutate_if(is.double, round, digits = 3)
## # A tibble: 2 x 4
##   key                              mean    ll    ul
##   <chr>                           <dbl> <dbl> <dbl>
## 1 delta R2 due to age interaction 0.665 -4.88  6.19
## 2 delta R2 due to sex interaction 1.23  -4.12  6.84

Hopefully it’s clear how our proportions turned percentages correspond to the figures on page 325. However, note how our 95% credible intervals do not cohere with the \(p\)-values from Hayes’s \(F\)-tests.

If we want to prep for our version of Figure 9.3, we’ll need to carefully specify the predictor values we’ll pass through the fitted() function. Here we do so and save them in nd.

nd <-
  tibble(negemot = rep(seq(from = .5, to = 6.5, length.out = 30),
                       times = 6),
         sex = rep(rep(0:1, each = 30),
                   times = 3),
         age = rep(c(30, 50, 70), each = 60),
         posemot = mean(glbwarm$posemot),
         ideology = mean(glbwarm$ideology))

head(nd)
## # A tibble: 6 x 5
##   negemot   sex   age posemot ideology
##     <dbl> <int> <dbl>   <dbl>    <dbl>
## 1   0.5       0    30    3.13     4.08
## 2   0.707     0    30    3.13     4.08
## 3   0.914     0    30    3.13     4.08
## 4   1.12      0    30    3.13     4.08
## 5   1.33      0    30    3.13     4.08
## 6   1.53      0    30    3.13     4.08

With our nd values in hand, we’re ready to make our version of Figure 9.3.

fitted(model7, newdata = nd) %>% 
  as_tibble() %>% 
  bind_cols(nd) %>% 
  # These lines will make the strip text match with those with Hayes's Figure
  mutate(sex = ifelse(sex == 0, str_c("Females, W = ", sex),
                      str_c("Males, W = ", sex)),
         age = str_c("Age, Z, = ", age)) %>% 

  # finally, the plot!
  ggplot(aes(x = negemot, group = sex)) +
  geom_ribbon(aes(ymin = Q2.5, ymax = Q97.5, fill = sex),
              alpha = 1/3, color = "transparent") +
  geom_line(aes(y = Estimate, color = sex),
            size = 1) +
  scale_x_continuous(breaks = 1:6) +
  coord_cartesian(xlim = 1:6,
                  ylim = 3:6) +
  labs(x = expression(paste("Negative Emotions about Climate Change, ", italic(X))),
       y = expression(paste("Support for Government Action to Mitigate Climate Change, ", italic(Y)))) +
  theme_xkcd() +
  theme(legend.position = "top",
        legend.title = element_blank()) +
  facet_grid(age ~ .)

9.4.2 Moderated moderation.

To fit the moderated moderation model in brms, just add to two new interaction terms to the formula.

model10 <- 
  update(model7, newdata = glbwarm,
         govact ~ 1 + negemot + sex + age + posemot + ideology + 
           negemot:sex + negemot:age + sex:age + 
           negemot:sex:age,
         chains = 4, cores = 4)
print(model10, digits = 3)
##  Family: gaussian 
##   Links: mu = identity; sigma = identity 
## Formula: govact ~ negemot + sex + age + posemot + ideology + negemot:sex + negemot:age + sex:age + negemot:sex:age 
##    Data: glbwarm (Number of observations: 815) 
## Samples: 4 chains, each with iter = 2000; warmup = 1000; thin = 1;
##          total post-warmup samples = 4000
## 
## Population-Level Effects: 
##                 Estimate Est.Error l-95% CI u-95% CI Eff.Sample  Rhat
## Intercept          4.558     0.477    3.638    5.446       1084 1.001
## negemot            0.272     0.116    0.056    0.500       1088 1.001
## sex                0.517     0.631   -0.713    1.745        957 1.001
## age               -0.003     0.009   -0.021    0.015       1062 1.001
## posemot           -0.020     0.028   -0.078    0.035       3262 0.999
## ideology          -0.205     0.026   -0.257   -0.154       3391 1.001
## negemot:sex       -0.127     0.164   -0.452    0.189        999 1.000
## negemot:age        0.001     0.002   -0.004    0.005       1109 1.000
## sex:age           -0.025     0.012   -0.048   -0.002        922 1.001
## negemot:sex:age    0.007     0.003    0.001    0.013        969 1.000
## 
## Family Specific Parameters: 
##       Estimate Est.Error l-95% CI u-95% CI Eff.Sample  Rhat
## sigma    1.047     0.026    0.997    1.098       3661 0.999
## 
## Samples were drawn using sampling(NUTS). For each parameter, Eff.Sample 
## is a crude measure of effective sample size, and Rhat is the potential 
## scale reduction factor on split chains (at convergence, Rhat = 1).

Our print() output matches fairly well with the OLS results on pages 332 and 333. Our new Bayesian \(R^2\) is:

bayes_R2(model10) %>% round(digits = 3)
##    Estimate Est.Error  Q2.5 Q97.5
## R2    0.417      0.02 0.375 0.457

Because we haven’t changed the predictor variables in the model–just added interactions among them–there’s no need to redo our nd values. Rather, all we need to do is pass them through fitted() based on our new model10 and plot. Without further ado, here’s our Figure 9.6.

fitted(model10, newdata = nd) %>% 
  as_tibble() %>% 
  bind_cols(nd) %>% 
  # These lines will make the strip text match with those with Hayes's Figure
  mutate(sex = ifelse(sex == 0, str_c("Females, W = ", sex),
                      str_c("Males, W = ", sex)),
         age = str_c("Age, Z, = ", age)) %>% 
  
  # behold, Figure 9.6!
  ggplot(aes(x = negemot, group = sex)) +
  geom_ribbon(aes(ymin = Q2.5, ymax = Q97.5, fill = sex),
              alpha = 1/3, color = "transparent") +
  geom_line(aes(y = Estimate, color = sex),
            size = 1) +
  scale_x_continuous(breaks = 1:6) +
  coord_cartesian(xlim = 1:6,
                  ylim = 3:6) +
  labs(x = expression(paste("Negative Emotions about Climate Change, ", italic(X))),
       y = expression(paste("Support for Government Action to Mitigate Climate Change, ", italic(Y)))) +
  theme_xkcd() +
  theme(legend.position = "top",
        legend.title = element_blank()) +
  facet_grid(age ~ .)

For the pick-a-point values Hayes covered on page 338, recall that when using posterior_sample(), our \(b_{4}\) is b_negemot:sex and our \(b_{7}\) is b_negemot:sex:age.

post <- posterior_samples(model10)

post %>% 
  transmute(`age = 30` = `b_negemot:sex` + `b_negemot:sex:age`*30, 
            `age = 50` = `b_negemot:sex` + `b_negemot:sex:age`*50, 
            `age = 70` = `b_negemot:sex` + `b_negemot:sex:age`*70) %>% 
  gather(theta_XW_on_Y_given, value) %>%
  group_by(theta_XW_on_Y_given) %>%
  summarize(mean = mean(value),
            ll = quantile(value, probs = .025),
            ul = quantile(value, probs = .975)) %>% 
  mutate_if(is.double, round, digits = 3)
## # A tibble: 3 x 4
##   theta_XW_on_Y_given  mean     ll    ul
##   <chr>               <dbl>  <dbl> <dbl>
## 1 age = 30            0.071 -0.085 0.227
## 2 age = 50            0.204  0.109 0.3  
## 3 age = 70            0.336  0.183 0.496

Our method for making a JN technique plot with fitted() way back in chapter 7 isn’t going to work, here. At least not as far as I can see. Rather, we’re going to have to skillfully manipulate our post object. For those new to R, this might be a little confusing at first. So I’m going to make a crude attempt first and then get more sophisticated.

Crude attempt:

post %>% 
  transmute(`age = 30` = `b_negemot:sex` + `b_negemot:sex:age`*30, 
            `age = 50` = `b_negemot:sex` + `b_negemot:sex:age`*50, 
            `age = 70` = `b_negemot:sex` + `b_negemot:sex:age`*70) %>% 
  gather(theta_XW_on_Y_given, value) %>%
  mutate(`theta XW on Y given` = str_extract(theta_XW_on_Y_given, "\\d+") %>% as.double()) %>% 
  group_by(`theta XW on Y given`) %>%
  summarize(mean = mean(value),
            ll = quantile(value, probs = .025),
            ul = quantile(value, probs = .975)) %>%
  
  # the plot
  ggplot(aes(x = `theta XW on Y given`)) +
  geom_hline(yintercept = 0) +
  geom_vline(xintercept = 38.114) +
  geom_ribbon(aes(ymin = ll, ymax = ul),
              alpha = 1/2) +
  geom_line(aes(y = mean), 
            size = 1) +
  coord_cartesian(xlim = 20:85,
                  ylim = c(-.25, .75)) +
  theme_xkcd()

Notice how we just took the code from our pick-a-point analysis, left out the mutate_if() rounding part, and dumped it into a plot. So one obvious approach would be to pick like 30 or 50 age values to plug into transmute() and just do the same thing. If you’re super afraid of coding, that’d be one intuitive but extremely verbose attempt. And I’ve done stuff like that earlier in my R career. There’s no shame in being extremely verbose and redundant if that’s what makes sense. Another way is to think in terms of functions. When we made age = 30 within transmute(), we took a specific age value (i.e., 30) and plugged it into the formula b_negemot:sex + b_negemot:sex:age*i where \(i\) = 30. And when we made age = 50 we did exactly the same thing but switched out the 30 for a 50. So what we need is a function that will take a range of values for \(i\), plug them into our b_negemot:sex + b_negemot:sex:age*i formula, and then neatly return the output. A nice base R function for that is sapply().

sapply(15:90, function(i){
  post$`b_negemot:sex` + post$`b_negemot:sex:age`*i
}) %>% 
  as_tibble() %>% 
  str()
## Classes 'tbl_df', 'tbl' and 'data.frame':    4000 obs. of  76 variables:
##  $ V1 : num  -0.0712 -0.0131 0.0422 0.0194 -0.1911 ...
##  $ V2 : num  -0.0651 -0.0047 0.0481 0.0248 -0.1783 ...
##  $ V3 : num  -0.05887 0.00366 0.05405 0.03022 -0.16561 ...
##  $ V4 : num  -0.0527 0.012 0.06 0.0356 -0.1529 ...
##  $ V5 : num  -0.0465 0.0204 0.0659 0.041 -0.1402 ...
##  $ V6 : num  -0.0403 0.0287 0.0718 0.0464 -0.1274 ...
##  $ V7 : num  -0.0341 0.0371 0.0777 0.0518 -0.1147 ...
##  $ V8 : num  -0.0279 0.0455 0.0837 0.0572 -0.102 ...
##  $ V9 : num  -0.0218 0.0538 0.0896 0.0626 -0.0893 ...
##  $ V10: num  -0.0156 0.0622 0.0955 0.068 -0.0765 ...
##  $ V11: num  -0.0094 0.0705 0.1014 0.0734 -0.0638 ...
##  $ V12: num  -0.00321 0.07889 0.10737 0.07876 -0.05107 ...
##  $ V13: num  0.00297 0.08725 0.11329 0.08416 -0.03835 ...
##  $ V14: num  0.00915 0.09561 0.11921 0.08955 -0.02562 ...
##  $ V15: num  0.0153 0.104 0.1251 0.0949 -0.0129 ...
##  $ V16: num  0.021523 0.112324 0.131062 0.100336 -0.000164 ...
##  $ V17: num  0.0277 0.1207 0.137 0.1057 0.0126 ...
##  $ V18: num  0.0339 0.129 0.1429 0.1111 0.0253 ...
##  $ V19: num  0.0401 0.1374 0.1488 0.1165 0.038 ...
##  $ V20: num  0.0463 0.1458 0.1548 0.1219 0.0507 ...
##  $ V21: num  0.0524 0.1541 0.1607 0.1273 0.0635 ...
##  $ V22: num  0.0586 0.1625 0.1666 0.1327 0.0762 ...
##  $ V23: num  0.0648 0.1708 0.1725 0.1381 0.0889 ...
##  $ V24: num  0.071 0.179 0.178 0.143 0.102 ...
##  $ V25: num  0.0772 0.1876 0.1844 0.1489 0.1144 ...
##  $ V26: num  0.0834 0.1959 0.1903 0.1543 0.1271 ...
##  $ V27: num  0.0895 0.2043 0.1962 0.1597 0.1398 ...
##  $ V28: num  0.0957 0.2126 0.2022 0.1651 0.1526 ...
##  $ V29: num  0.102 0.221 0.208 0.17 0.165 ...
##  $ V30: num  0.108 0.229 0.214 0.176 0.178 ...
##  $ V31: num  0.114 0.238 0.22 0.181 0.191 ...
##  $ V32: num  0.12 0.246 0.226 0.187 0.203 ...
##  $ V33: num  0.127 0.254 0.232 0.192 0.216 ...
##  $ V34: num  0.133 0.263 0.238 0.197 0.229 ...
##  $ V35: num  0.139 0.271 0.244 0.203 0.242 ...
##  $ V36: num  0.145 0.279 0.25 0.208 0.254 ...
##  $ V37: num  0.151 0.288 0.255 0.214 0.267 ...
##  $ V38: num  0.158 0.296 0.261 0.219 0.28 ...
##  $ V39: num  0.164 0.305 0.267 0.224 0.293 ...
##  $ V40: num  0.17 0.313 0.273 0.23 0.305 ...
##  $ V41: num  0.176 0.321 0.279 0.235 0.318 ...
##  $ V42: num  0.182 0.33 0.285 0.241 0.331 ...
##  $ V43: num  0.188 0.338 0.291 0.246 0.343 ...
##  $ V44: num  0.195 0.346 0.297 0.251 0.356 ...
##  $ V45: num  0.201 0.355 0.303 0.257 0.369 ...
##  $ V46: num  0.207 0.363 0.309 0.262 0.382 ...
##  $ V47: num  0.213 0.371 0.315 0.268 0.394 ...
##  $ V48: num  0.219 0.38 0.321 0.273 0.407 ...
##  $ V49: num  0.226 0.388 0.327 0.278 0.42 ...
##  $ V50: num  0.232 0.397 0.332 0.284 0.433 ...
##  $ V51: num  0.238 0.405 0.338 0.289 0.445 ...
##  $ V52: num  0.244 0.413 0.344 0.295 0.458 ...
##  $ V53: num  0.25 0.422 0.35 0.3 0.471 ...
##  $ V54: num  0.257 0.43 0.356 0.305 0.483 ...
##  $ V55: num  0.263 0.438 0.362 0.311 0.496 ...
##  $ V56: num  0.269 0.447 0.368 0.316 0.509 ...
##  $ V57: num  0.275 0.455 0.374 0.321 0.522 ...
##  $ V58: num  0.281 0.463 0.38 0.327 0.534 ...
##  $ V59: num  0.287 0.472 0.386 0.332 0.547 ...
##  $ V60: num  0.294 0.48 0.392 0.338 0.56 ...
##  $ V61: num  0.3 0.488 0.398 0.343 0.573 ...
##  $ V62: num  0.306 0.497 0.404 0.348 0.585 ...
##  $ V63: num  0.312 0.505 0.409 0.354 0.598 ...
##  $ V64: num  0.318 0.514 0.415 0.359 0.611 ...
##  $ V65: num  0.325 0.522 0.421 0.365 0.623 ...
##  $ V66: num  0.331 0.53 0.427 0.37 0.636 ...
##  $ V67: num  0.337 0.539 0.433 0.375 0.649 ...
##  $ V68: num  0.343 0.547 0.439 0.381 0.662 ...
##  $ V69: num  0.349 0.555 0.445 0.386 0.674 ...
##  $ V70: num  0.355 0.564 0.451 0.392 0.687 ...
##  $ V71: num  0.362 0.572 0.457 0.397 0.7 ...
##  $ V72: num  0.368 0.58 0.463 0.402 0.713 ...
##  $ V73: num  0.374 0.589 0.469 0.408 0.725 ...
##  $ V74: num  0.38 0.597 0.475 0.413 0.738 ...
##  $ V75: num  0.386 0.605 0.481 0.419 0.751 ...
##  $ V76: num  0.393 0.614 0.487 0.424 0.763 ...

Okay, to that looks a little monstrous. But what we did in the first argument was tell sapply() which values we’d like to use in some function. We chose each integer ranging from 15 to 90–which, if you do the math, is 76 values. We then told sapply() to plug those values into a custom function, which we defined as function(i){post$b_negemot:sex + post$b_negemot:sex:age*i}. In our custom function, i was a placeholder for each of those 76 integers. But remember that post has 4000 rows, each one corresponding to one of the 4000 posterior iterations. Thus, for each of our 76 i-values, we got 4000 results. After all that sapply() returned a matrix. Since we like to work within the tidyverse and use ggplot2, we just went ahead and put those results in a tibble.

With our sapply() output in hand, all we need to do is a little more indexing and summarizing and we’re ready to plot. The result is our very own version of Figure 9.7.

sapply(15:90, function(i){
  post$`b_negemot:sex` + post$`b_negemot:sex:age`*i
}) %>% 
  as_tibble() %>% 
  gather() %>% 
  mutate(age = rep(15:90, each = 4000)) %>% 
  group_by(age) %>% 
  summarize(mean = mean(value),
            ll = quantile(value, probs = .025),
            ul = quantile(value, probs = .975)) %>% 
  
  ggplot(aes(x = age)) +
  geom_hline(yintercept = 0, color = "grey75") +
  geom_vline(xintercept = 38.114, color = "grey75") +
  geom_ribbon(aes(ymin = ll, ymax = ul),
              alpha = 1/2) +
  geom_line(aes(y = mean), 
            size = 1) +
  coord_cartesian(xlim = 20:85,
                  ylim = c(-.25, .75)) +
  labs(x = expression(paste("Age, ", italic(Z))),
       y = "Conditional Two-way Interaction Between\nNegative Emotions and Sex") +
  theme_xkcd()

Or for kicks and giggles, another way to get a clearer sense of how our data informed the shape of the plot, here we replace our geom_ribbon() + geom_line() code with geom_pointrange().

sapply(15:90, function(i){
  post$`b_negemot:sex` + post$`b_negemot:sex:age`*i
}) %>% 
  as_tibble() %>% 
  gather() %>% 
  mutate(age = rep(15:90, each = 4000)) %>% 
  group_by(age) %>% 
  summarize(mean = mean(value),
            ll = quantile(value, probs = .025),
            ul = quantile(value, probs = .975)) %>% 
  
  ggplot(aes(x = age)) +
  geom_hline(yintercept = 0, color = "grey75") +
  geom_vline(xintercept = 38.114, color = "grey75") +
  geom_pointrange(aes(y = mean, ymin = ll, ymax = ul),
                  shape = 16, size = 1/3) +
  coord_cartesian(xlim = 20:85,
                  ylim = c(-.25, .75)) +
  labs(x = expression(paste("Age, ", italic(Z))),
       y = "Conditional Two-way Interaction Between\nNegative Emotions and Sex") +
  theme_xkcd()

Although I probably wouldn’t try to use a plot like this in a manuscript, I hope it makes clear how the way we’ve been implementing the JN technique is just the pick-a-point approach in bulk. No magic.

For all you tidyverse fanatics out there, don’t worry. There are more tidyverse-centric ways to get the plot values than with sapply(). We’ll get to them soon enough. It’s advantageous to have good old base R sapply() up your sleeve, too. And new R users, it’s helpful to know that sapply() is one part of the apply() family of base R functions, which you might learn more about here or here or here.