12.2 Moderation of the direct and indirect effects in a conditional process model
We don’t need to do anything particularly special to fit a model like this in brms. It just requires we do a careful job specifying the formulas in our bf()
arguments. If you find this syntax a little too cumbersome, you can always specify the formulas outside of brm()
, save them as one or multiple objects, and plug those objects into brm()
.
model3 <-
brm(data = disaster, family = gaussian,
bf(justify ~ 1 + frame + skeptic + frame:skeptic) +
bf(donate ~ 1 + frame + justify + skeptic + frame:skeptic) +
set_rescor(FALSE),
chains = 4, cores = 4)
The model summary:
print(model3, digits = 3)
## Family: MV(gaussian, gaussian)
## Links: mu = identity; sigma = identity
## mu = identity; sigma = identity
## Formula: justify ~ 1 + frame + skeptic + frame:skeptic
## donate ~ 1 + frame + justify + skeptic + frame:skeptic
## Data: disaster (Number of observations: 211)
## 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
## justify_Intercept 2.449 0.150 2.162 2.739 4000 1.000
## donate_Intercept 7.293 0.276 6.735 7.823 4000 1.000
## justify_frame -0.556 0.219 -0.977 -0.128 3612 1.000
## justify_skeptic 0.106 0.038 0.030 0.180 4000 1.000
## justify_frame:skeptic 0.199 0.056 0.092 0.305 3302 1.000
## donate_frame 0.157 0.273 -0.380 0.694 3437 1.000
## donate_justify -0.924 0.082 -1.089 -0.763 4000 1.000
## donate_skeptic -0.043 0.047 -0.135 0.050 4000 1.000
## donate_frame:skeptic 0.016 0.070 -0.121 0.152 2988 1.000
##
## Family Specific Parameters:
## Estimate Est.Error l-95% CI u-95% CI Eff.Sample Rhat
## sigma_justify 0.819 0.041 0.742 0.902 4000 1.000
## sigma_donate 0.989 0.049 0.897 1.091 4000 1.001
##
## 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).
12.2.1 Estimation using PROCESS.
We just fit the model. Next.
12.2.2 Quantifying direct and indirect effects.
Here are \(a_{1}\) through \(a_{3}\).
fixef(model3)[c(3:5), ] %>% round(digits = 3)
## Estimate Est.Error Q2.5 Q97.5
## justify_frame -0.556 0.219 -0.977 -0.128
## justify_skeptic 0.106 0.038 0.030 0.180
## justify_frame:skeptic 0.199 0.056 0.092 0.305
This is \(b\).
fixef(model3)[7, ] %>% round(digits = 3)
## Estimate Est.Error Q2.5 Q97.5
## -0.924 0.082 -1.089 -0.763
We’ll need to employ posterior_samples()
to compute \((a_{1} + a_{3}W)b\).
post <-
posterior_samples(model3) %>%
mutate(`indirect effect when W is 1.592` = (b_justify_frame + `b_justify_frame:skeptic`*1.592)*b_donate_justify,
`indirect effect when W is 2.800` = (b_justify_frame + `b_justify_frame:skeptic`*2.800)*b_donate_justify,
`indirect effect when W is 5.200` = (b_justify_frame + `b_justify_frame:skeptic`*5.200)*b_donate_justify)
post %>%
select(starts_with("indirect")) %>%
gather() %>%
group_by(key) %>%
median_qi(value, .prob = .95) %>%
mutate_if(is.double, round, digits = 3)
## # A tibble: 3 x 5
## # Groups: key [3]
## key value conf.low conf.high .prob
## <chr> <dbl> <dbl> <dbl> <dbl>
## 1 indirect effect when W is 1.592 0.22 -0.058 0.502 0.95
## 2 indirect effect when W is 2.800 -0.004 -0.215 0.21 0.95
## 3 indirect effect when W is 5.200 -0.442 -0.735 -0.161 0.95
12.2.2.1 The conditional direct effect of \(X\).
This process is very similar.
post <-
post %>%
mutate(`direct effect when W is 1.592` = b_donate_frame + `b_donate_frame:skeptic`*1.592,
`direct effect when W is 2.800` = b_donate_frame + `b_donate_frame:skeptic`*2.800,
`direct effect when W is 5.200` = b_donate_frame + `b_donate_frame:skeptic`*5.200)
post %>%
select(starts_with("direct")) %>%
gather() %>%
group_by(key) %>%
median_qi(value, .prob = .95) %>%
mutate_if(is.double, round, digits = 3)
## # A tibble: 3 x 5
## # Groups: key [3]
## key value conf.low conf.high .prob
## <chr> <dbl> <dbl> <dbl> <dbl>
## 1 direct effect when W is 1.592 0.185 -0.181 0.55 0.95
## 2 direct effect when W is 2.800 0.202 -0.079 0.484 0.95
## 3 direct effect when W is 5.200 0.243 -0.129 0.617 0.95
12.2.3 Visualizing the direct and indirect effects.
In order to make Figure 12.7, we’ll use sapply()
to get the conditional effects for justify
and donate
.
justify_effects <-
sapply(seq(from = 0, to = 6, length.out = 30), function(w){
(post$b_justify_frame + post$`b_justify_frame:skeptic`*w)*post$b_donate_justify
}) %>%
as_tibble() %>%
gather() %>%
select(-key) %>%
mutate(skeptic = seq(from = 0, to = 6, length.out = 30) %>% rep(., each = 4000)) %>%
group_by(skeptic) %>%
summarize(median = median(value),
ll = quantile(value, probs = .025),
ul = quantile(value, probs = .975))
donate_effects <-
sapply(seq(from = 0, to = 6, length.out = 30), function(w){
post$b_donate_frame + post$`b_donate_frame:skeptic`*w
}) %>%
as_tibble() %>%
gather() %>%
select(-key) %>%
mutate(skeptic = seq(from = 0, to = 6, length.out = 30) %>% rep(., each = 4000)) %>%
group_by(skeptic) %>%
summarize(median = median(value),
ll = quantile(value, probs = .025),
ul = quantile(value, probs = .975))
# here's what they look like:
glimpse(justify_effects)
## Observations: 30
## Variables: 4
## $ skeptic <dbl> 0.0000000, 0.2068966, 0.4137931, 0.6206897, 0.8275862, 1.0344828, 1.2413793, 1.4482759, 1...
## $ median <dbl> 0.50539067, 0.46857364, 0.43162692, 0.39424740, 0.35772917, 0.32106437, 0.28395008, 0.246...
## $ ll <dbl> 0.118283325, 0.097916638, 0.074469002, 0.053676202, 0.032305387, 0.006548239, -0.01733970...
## $ ul <dbl> 0.94217458, 0.88300546, 0.82420454, 0.76548241, 0.71310256, 0.65693234, 0.59807208, 0.539...
glimpse(donate_effects)
## Observations: 30
## Variables: 4
## $ skeptic <dbl> 0.0000000, 0.2068966, 0.4137931, 0.6206897, 0.8275862, 1.0344828, 1.2413793, 1.4482759, 1...
## $ median <dbl> 0.1597819, 0.1630075, 0.1678151, 0.1705533, 0.1731486, 0.1770148, 0.1808678, 0.1831889, 0...
## $ ll <dbl> -0.37958850, -0.35153604, -0.32329946, -0.29206770, -0.26686317, -0.24508634, -0.22429952...
## $ ul <dbl> 0.6936796, 0.6765022, 0.6547631, 0.6400564, 0.6175050, 0.5998254, 0.5765224, 0.5634475, 0...
Next we’ll combine those two tibbles by stacking donate_effects
underneath justify_effects
and then indexing them by effect
. Then we’re ready to plot.
# combining the tibbles
figure_12.7 <-
justify_effects %>%
bind_rows(donate_effects) %>%
mutate(effect = rep(c("Indirect effect", "Direct effect"), each = nrow(justify_effects)))
# we'll need this for `geom_text()`
text_tibble <-
tibble(x = c(4.2, 4.7),
y = c(.28, -.28),
angle = c(3.6, 335),
effect = c("Direct effect", "Indirect effect"))
# the plot
figure_12.7 %>%
ggplot(aes(x = skeptic, group = effect)) +
geom_ribbon(aes(ymin = ll, ymax = ul, fill = effect),
alpha = 1/3) +
geom_line(aes(y = median, color = effect)) +
geom_text(data = text_tibble,
aes(x = x, y = y,
angle = angle,
color = effect,
label = effect),
size = 5) +
scale_fill_brewer(type = "qual") +
scale_color_brewer(type = "qual") +
coord_cartesian(xlim = c(1, 5.5),
ylim = c(-.6, .4)) +
labs(x = expression(paste("Climate Change Skepticism (", italic(W), ")")),
y = "Effects of Disaster Frame on Willingness to Donate") +
theme(legend.position = "none")
Note how wide those 95% intervals are relative to the scale of the y-axis. I specifically kept the y-axis within the same range as Figure 12.7 in the text. To me the message is clear: include credible-interval ribbons in your regression slope plots. They help depict how uncertain the posterior is in a way a simple line slopes just don’t.
12.2.4 Bonus: Let’s replace sapply()
with map()
.
Good old base R sapply()
worked just fine for our purposes, above. However, we can use purrr::map()
to accomplish those goals in a more tidyverse-consistent fashion. First we’ll define two custom functions to do what our two sapply()
statements did for us.
# defining two custom functions
make_justify <- function(w){
(post$b_justify_frame + post$`b_justify_frame:skeptic`*w)*post$b_donate_justify
}
make_donate <-function(w){
post$b_donate_frame + post$`b_donate_frame:skeptic`*w
}
Next, we’ll make a 30-row tibble with each row containing a value for skeptic
, ranging from 0 ot 6, just like what we did with sapply()
. Because we’ll be performing a nested operation for each value of skeptic
, we’ll group the tibble by skeptic
. Then with the mutate()
function, we’ll use map()
to apply our custom make_justify
and make_donate
functions to each of the 30 skeptic
values.
tidyverse_style_tibble <-
tibble(skeptic = seq(from = 0, to = 6, length.out = 30)) %>%
group_by(skeptic) %>%
mutate(`indirect effect` = map(skeptic, make_justify),
`direct effect` = map(skeptic, make_donate))
tidyverse_style_tibble
## # A tibble: 30 x 3
## # Groups: skeptic [30]
## skeptic `indirect effect` `direct effect`
## <dbl> <list> <list>
## 1 0 <dbl [4,000]> <dbl [4,000]>
## 2 0.207 <dbl [4,000]> <dbl [4,000]>
## 3 0.414 <dbl [4,000]> <dbl [4,000]>
## 4 0.621 <dbl [4,000]> <dbl [4,000]>
## 5 0.828 <dbl [4,000]> <dbl [4,000]>
## 6 1.03 <dbl [4,000]> <dbl [4,000]>
## 7 1.24 <dbl [4,000]> <dbl [4,000]>
## 8 1.45 <dbl [4,000]> <dbl [4,000]>
## 9 1.66 <dbl [4,000]> <dbl [4,000]>
## 10 1.86 <dbl [4,000]> <dbl [4,000]>
## # ... with 20 more rows
This yielded a nested tibble. At one level of investigation, we have 30 rows–one for each of the 30 skeptic
values. However, for both the idirect effect
and direct effect
columns, we’ve packed an entire 4000-row list into each of those rows. Those lists are 4000-rows long because both of our custom functions entailed pushing those skeptic
values through the posterior, which itself had 4000 iterations. Next we’ll use unnest()
to unnest the tibble.
tidyverse_style_tibble <-
tidyverse_style_tibble %>%
unnest()
head(tidyverse_style_tibble)
## # A tibble: 6 x 3
## # Groups: skeptic [1]
## skeptic `indirect effect` `direct effect`
## <dbl> <dbl> <dbl>
## 1 0 0.338 0.00597
## 2 0 0.491 0.338
## 3 0 0.639 0.185
## 4 0 0.707 -0.312
## 5 0 0.533 -0.0665
## 6 0 0.175 0.0652
After un-nesting, the tibble is now \(4000\times30 = 120,000\) rows long. With just a little more wrangling, we’ll have our familiar summaries for each level of skeptic
.
tidyverse_style_tibble <-
tidyverse_style_tibble %>%
ungroup() %>%
mutate(iter = rep(1:4000, times = 30)) %>%
gather(effect, value, -skeptic, -iter) %>%
group_by(effect, skeptic) %>%
median_qi(value, .prob = .95)
head(tidyverse_style_tibble)
## # A tibble: 6 x 6
## # Groups: effect, skeptic [6]
## effect skeptic value conf.low conf.high .prob
## <chr> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 direct effect 0 0.160 -0.380 0.694 0.95
## 2 direct effect 0.207 0.163 -0.352 0.677 0.95
## 3 direct effect 0.414 0.168 -0.323 0.655 0.95
## 4 direct effect 0.621 0.171 -0.292 0.640 0.95
## 5 direct effect 0.828 0.173 -0.267 0.618 0.95
## 6 direct effect 1.03 0.177 -0.245 0.600 0.95
Now we have 60 row, 30 for direct effect
and another 30 for indirect effect
. Each has the typical summary values for all 30 levels of skeptic
. We’re ready to plot.
tidyverse_style_tibble %>%
ggplot(aes(x = skeptic, group = effect)) +
geom_ribbon(aes(ymin = conf.low, ymax = conf.high, fill = effect),
alpha = 1/3) +
geom_line(aes(y = value, color = effect)) +
scale_fill_brewer(type = "qual", palette = 2) +
scale_color_brewer(type = "qual", palette = 2) +
coord_cartesian(xlim = c(1, 5.5),
ylim = c(-.6, .4)) +
labs(x = expression(paste("Climate Change Skepticism (", italic(W), ")")),
y = "Effects of Disaster Frame on Willingness to Donate") +
theme(legend.position = "none")
Do note how, in our plot above, we used tidybayes terms value
(i.e., median–the specified measure of central tendency), conf.low
and conf.high
, the lower- and upper-levels of the 95% interval.
To learn more about nested data and using the map()
function, check out this subsection of Grolemund and Wickham’s R4DS or starting from this point on in this video of one of Wickham’s workshops.