A guide to correctly calculating posterior predictions and average marginal effects with multilievel Bayesian models

How to calculate grand means, conditional group means, and hypothetical group means of posterior predictions from multilevel brms models.
r
tidyverse
regression
statistics
data visualization
bayes
brms
stan
beta
Author
Published

Wednesday, November 10, 2021

At the end of my previous post on beta and zero-inflated-beta regression, I included an example of a multilevel model that predicted the proportion of women members of parliament based on whether a country implements gender-based quotas for their legislatures, along with a few different control variables. I also included random effects for year and region in order to capture time- and geography-specific trends. When interpreting the results, I naively said that calculating posterior predictions from the model (i.e. plugging arbitrary values into the model and finding the predicted value of the outcome) magically and automatically includes information about year and region (or the random effects included in the model). But I was wrong-ish! Incorporating information about the random effects of a multilevel model is actually a lot more involved and detailed than I thought, and the nuances are really important and make a big difference!

As TJ Mahr recently tweeted, this is hard stuff!

Fortunately, over the past couple days I’ve been part of several really long Twitter threads (with people who know waaaay more about this than me!) about different ways of incorporating group-level effects into posterior predictions. HUGE THANKS to Isabella Ghement, Brenton Wiernik, TJ Mahr, Donald Williams, Solomon Kurz, and Mattan Ben-Shachar for all their help and tweeting! (This set of tweets by Brenton was pivotal!)

Who this guide is for

To borrow from Solomon Kurz, here’s what this guide assumes you know:

Example data

Put simply, multilevel models let you account for nested structures in data:

• Students inside schools inside school districts inside states
• Study participants inside cities inside states across months
• Countries within regions and across years

Since most of my own work deals with international politics, we’ll use an example with countries and years. We’ll use data from the Varieties of Democracy project (V-Dem) to explore the relationship between press freedom and the degree to which political opposition is allowed. Is there more press freedom in countries that allow opposition parties?

There’s a great R package for accessing V-Dem data without needing to download it manually from their website, so we’ll use that to make a smaller dataset of countries for just 2015. Let’s load all the libraries we need, clean up the data, and get started!

library(tidyverse)    # ggplot, dplyr, %>%, and friends
library(brms)         # Bayesian modeling through Stan
library(tidybayes)    # Manipulate Stan objects in a tidy way
library(broom)        # Convert model objects to data frames
library(broom.mixed)  # Convert brms model objects to data frames
library(emmeans)      # Calculate marginal effects in even fancier ways
library(vdemdata)     # Use data from the Varieties of Democracy (V-Dem) project
library(patchwork)    # Combine ggplot objects
library(ggokabeito)   # Neat accessible color palette
library(gghalves)     # Special half geoms
library(ggbeeswarm)   # Special distribution-shaped point jittering

# Custom ggplot theme to make pretty plots
# Get the News Cycle font at https://fonts.google.com/specimen/News+Cycle
theme_clean <- function() {
theme_minimal(base_family = "News Cycle") +
theme(panel.grid.minor = element_blank(),
plot.title = element_text(face = "bold"),
axis.title = element_text(face = "bold"),
strip.text = element_text(face = "bold", size = rel(1), hjust = 0),
strip.background = element_rect(fill = "grey80", color = NA),
legend.title = element_text(face = "bold"))
}

V-Dem includes hundreds of different variables, but we only need a few, and we’ll make a few adjustments to the ones we do need. Here’s what we’ll do:

• Main outcome: Alternative sources of information index (v2xme_altinf in V-Dem). This is a 0–1 scale that measures…

To what extent the [is] media (a) un-biased in their coverage or lack of coverage of the opposition, (b) allowed to be critical of the regime, and (c) representative of a wide array of political perspectives

Higher values represent more media freedom.

• Main binary explanatory variable: Opposition parties autonomy (v2psoppaut in V-Dem). This is an ordinal variable with these possible values:

• 0: Opposition parties are not allowed.
• 1: There are no autonomous, independent opposition parties. Opposition parties are either selected or co-opted by the ruling regime.
• 2: At least some opposition parties are autonomous and independent of the ruling regime.
• 3: Most significant opposition parties are autonomous and independent of the ruling regime.
• 4: All opposition parties are autonomous and independent of the ruling regime.

For the sake of simplicity, we’ll collapse this into a binary variable. Parties are autonomous if they score a 3 or a 4; they’re not autonomous if they score a 0, 1, or 2. Also, there are a handful of countries (Saudi Arabia, Qatar, etc.) with missing data for this column, since they don’t have any opposition parties. We’ll treat them as not-autonomous here.

• Main continuous explanatory variable: Civil liberties index (v2x_civlib). This is a continuous variable measured from 0–1 with higher values representing better respect for human rights and civil liberties.

• Grouping: Finally, we’ll use region as groups in these models (i.e. countries nested in regions). V-Dem provides multiple regional variables with varying specificity (19 different regions, 10 different regions, and 6 different regions). We’ll use the 6-region version (e_regionpol_6C) for simplicity here:

• 1: Eastern Europe and Central Asia (including Mongolia)
• 2: Latin America and the Caribbean
• 3: The Middle East and North Africa (including Israel and Turkey, excluding Cyprus)
• 4: Sub-Saharan Africa
• 5: Western Europe and North America (including Cyprus, Australia and New Zealand)
• 6: Asia and Pacific (excluding Australia and New Zealand)
# Make a subset of the full V-Dem data
vdem_2015 <- vdem %>%
select(country_name, country_text_id, year, region = e_regionpol_6C,
media_index = v2xme_altinf, party_autonomy_ord = v2psoppaut_ord,
polyarchy = v2x_polyarchy, civil_liberties = v2x_civlib) %>%
filter(year == 2015) %>%
mutate(party_autonomy = party_autonomy_ord >= 3,
party_autonomy = ifelse(is.na(party_autonomy), FALSE, party_autonomy)) %>%
mutate(region = factor(region,
labels = c("Eastern Europe and Central Asia",
"Latin America and the Caribbean",
"Middle East and North Africa",
"Sub-Saharan Africa",
"Western Europe and North America",
"Asia and Pacific")))

Explore the data

We’re interested in a couple relationships here. First, we want to know if there is more press freedom in countries that allow opposition parties in elections. Let’s look at the data really quick:

autonomy_halves <- ggplot(vdem_2015, aes(x = party_autonomy, y = media_index)) +
geom_half_point(aes(color = party_autonomy),
transformation = position_quasirandom(width = 0.1),
side = "l", size = 0.5, alpha = 0.5) +
geom_half_boxplot(aes(fill = party_autonomy), side = "r") +
scale_fill_okabe_ito() +
scale_color_okabe_ito() +
guides(color = "none", fill = "none") +
labs(x = "Opposition party autonomy", y = "Media freedom index") +
theme_clean()

autonomy_densities <- ggplot(vdem_2015, aes(x = media_index, fill = party_autonomy)) +
geom_density(alpha = 0.6) +
scale_fill_okabe_ito() +
labs(x = "Media freedom index", y = "Density", fill = "Opposition party autonomy") +
theme_clean() +
theme(legend.position = "bottom")

autonomy_halves | autonomy_densities

That confirms our initial suspicions: countries that do not allow opposition parties to participate in elections tend to have substantially lower media freedom index scores than countries that have opposition parties.

Let’s see how respect for civil liberties is related to media freedom:

ggplot(vdem_2015, aes(x = civil_liberties, y = media_index)) +
geom_point(aes(color = region)) +
geom_smooth(method = "lm") +
labs(x = "Civil liberties index", y = "Media freedom index",
color = "Region") +
scale_color_okabe_ito() +
theme_clean()
## geom_smooth() using formula = 'y ~ x'

Countries with greater respect for civil liberties and human rights tend to have higher values of media freedom. There are perhaps some regional trends here too—most of the countries in Western Europe and North America are clustered in the top right corner of the plot, but all other regions are spread throughout.

Model

Let’s make a model! We’ll look just at 2015 data to estimate the effect of party autonomy (a binary variable) and civil liberties (a continuous variable) on a country’s media freedom index. We’ll include random effects for region, assuming that there are regional differences in this relationship (i.e. the relationship between party autonomy and media freedom looks different in Asia from Western Europe).

Because media_index ranges from 0 to 1 but does not include 0 or 1, we’ll use a beta distribution. It’s a really neat and fun distribution to work with, and it has extra moving parts we can play with—we can model the $$\phi$$ parameter (or the precision/variance of the distribution) with random effects too.

Formally, we can define the full model like this:

\begin{aligned} &\text{[likelihood]} \\ \text{Media freedom index}_i &\sim \operatorname{Beta}(\mu_i, \phi_i) \\ \ \\ &\text{[} \mu \text{ part of beta distribution]} \\ \operatorname{logit}(\mu_i) &= \alpha_{j[i]} + \beta_1 \text{Party autonomy}_i + \beta_2 \text{Civil liberties}_i\\ \ \\ &\text{[} \phi \text{ part of beta distribution]} \\ \log(\phi_i) &= \alpha_{j[i]}\\ \ \\ & \text{[region-specific intercepts]} \\ \alpha_{j} &\sim \mathcal{N}(\mu_{\alpha_j}, \sigma^2_{\alpha_j}), \text{ for region } j \text{ in } 1 .. J \end{aligned}

We’ll model the $$\mu$$ (or mean) parameter of the beta distribution with party_autonomy, civil_liberties, and random intercepts for region, and we’ll model the $$\phi$$ part (or precision) with just random intercepts for region (though we could use whatever, too—see this for an explanation of what this parameter does).

The syntax for defining random effects can get complex, depending on if the effects are nested or crossed, and if slopes get involved. This table by Ben Bolker is indispensable for remembering how to define which kinds of effects. Here we’re just adding a random intercept for region, so we’ll use a (1 | region) term in the model code.

model_basic <- brm(
bf(media_index ~ party_autonomy + civil_liberties + (1 | region),
phi ~ (1 | region)),
data = vdem_2015,
family = Beta(),
chains = 4, iter = 2000, warmup = 1000,
cores = 4, seed = 12345,
# Use the cmdstanr backend for Stan because it's faster and more modern than
# the default rstan. You need to install the cmdstanr package first
# (https://mc-stan.org/cmdstanr/) and then run cmdstanr::install_cmdstan() to
# install cmdstan on your computer.
backend = "cmdstanr"
)
## Start sampling
## Warning: 1 of 4000 (0.0%) transitions ended with a divergence.
## See https://mc-stan.org/misc/warnings for details.

Posterior predictions

Because this model uses beta regression, the coefficients for the $$\mu$$ part are on the logit (log odds) scale, while the coefficients for the $$\phi$$ part are on the log scale. This makes them really hard to interpret when just looking at a table of coefficient estimates, like this one here:

tidy(model_basic)
## # A tibble: 6 × 8
##   effect   component group  term                estimate std.error conf.low conf.high
##   <chr>    <chr>     <chr>  <chr>                  <dbl>     <dbl>    <dbl>     <dbl>
## 1 fixed    cond      <NA>   (Intercept)           -2.46      0.302   -3.04      -1.87
## 2 fixed    cond      <NA>   phi_(Intercept)        2.35      0.474    1.35       3.22
## 3 fixed    cond      <NA>   party_autonomyTRUE     1.03      0.172    0.698      1.38
## 4 fixed    cond      <NA>   civil_liberties        3.51      0.316    2.90       4.12
## 5 ran_pars cond      region sd__(Intercept)        0.499     0.256    0.211      1.19
## 6 ran_pars cond      region sd__phi_(Intercept)    0.925     0.451    0.365      2.06

Even if we back-transform the estimates to the response scale (with plogis() for the logits and exp() for the logs), we still cannot interpret the coefficients directly. Unlike regular OLS-like regression models, we can’t say things like “a one-unit increase in party_autonomy is associated with a plogis(2.27) unit increase in media_freedom”—we have to incorporate the intercept and all the other model parameters in order to make marginal interpretations like that.

In this case, we are interested in these marginal effects, though. What is the effect of flipping party_autonomy from FALSE to TRUE? What is the effect of a one-unit increase in the respect for human rights and civil liberties measured by civil_liberties? You can’t easily figure it out with the raw results of the model because there are so many moving parts: the different pieces (intercept, other coefficients) for the $$\mu$$ part need to be combined, the precision parameter $$\phi$$ needs to be incorporated somehow, and random regional effects for both the $$\mu$$ and the $$\phi$$ parts need to be accounted for. That’s a mess.

Instead of trying to algebraically piece all this together, we can plug hypothetical data into the model’s posterior distribution and generate posterior predictions of our media freedom outcome.

Draws from the posterior predictive distribution

There are a few general approaches to generating posterior predictions with brms. First, we can use posterior_predict() to plug a new dataset into the model. As an example, we’ll create a little dataset where party_autonomy is both TRUE and FALSE, and we’ll arbitrarily set civil_liberties to 0.5 (right in the middle of its range) and region to the Middle East and North Africa. We’ll then plug that in to the model and generate predictions

newdata <- expand_grid(party_autonomy = c(TRUE, FALSE),
civil_liberties = c(0.5),
region = "Middle East and North Africa")
newdata
## # A tibble: 2 × 3
##   party_autonomy civil_liberties region
##   <lgl>                    <dbl> <chr>
## 1 TRUE                       0.5 Middle East and North Africa
## 2 FALSE                      0.5 Middle East and North Africa

##       [,1]  [,2]
## [1,] 0.894 0.401
## [2,] 0.400 0.751
## [3,] 0.864 0.476
## [4,] 0.374 0.332
## [5,] 0.544 0.495
## [6,] 0.564 0.596

This returns a matrix with 4,000 rows and 2 columns—a column for when party_autonomy is TRUE and one for when it is FALSE. This format isn’t in the nicest shape for plotting and doing tidy type of stuff with, so we can alternatively use predicted_draws() from the tidybayes package. This is really just a fancy wrapper around brms::posterior_predict() that returns a much nicer tidier data frame:

tidy_pred <- model_basic %>%
predicted_draws(newdata = newdata)
tidy_pred
## # A tibble: 8,000 × 8
## # Groups:   party_autonomy, civil_liberties, region, .row [2]
##    party_autonomy civil_liberties region                 .row .chain .iter…¹ .draw .pred…²
##    <lgl>                    <dbl> <chr>                 <int>  <int>   <int> <int>   <dbl>
##  1 TRUE                       0.5 Middle East and Nort…     1     NA      NA     1   0.669
##  2 TRUE                       0.5 Middle East and Nort…     1     NA      NA     2   0.811
##  3 TRUE                       0.5 Middle East and Nort…     1     NA      NA     3   0.378
##  4 TRUE                       0.5 Middle East and Nort…     1     NA      NA     4   0.329
##  5 TRUE                       0.5 Middle East and Nort…     1     NA      NA     5   0.559
##  6 TRUE                       0.5 Middle East and Nort…     1     NA      NA     6   0.518
##  7 TRUE                       0.5 Middle East and Nort…     1     NA      NA     7   0.927
##  8 TRUE                       0.5 Middle East and Nort…     1     NA      NA     8   0.823
##  9 TRUE                       0.5 Middle East and Nort…     1     NA      NA     9   0.475
## 10 TRUE                       0.5 Middle East and Nort…     1     NA      NA    10   0.802
## # … with 7,990 more rows, and abbreviated variable names ¹​.iteration, ²​.prediction

That’s much nicer to work with!

Expected values of the posterior predictive distribution

We don’t necessarily want to use these predicted draws when thinking about marginal effects of coefficients, though. When we use posterior_predict()/predicted_draws() here, brms accounts for the uncertainty of all these things:

• The uncertainty of the fixed coefficients (e.g., party_autonomy and civil_liberties)
• The uncertainty of the variance parameters of the groups (e.g., sd__phi_(Intercept) for each region)
• The uncertainty for each individual observation (e.g., observational-level residual variance)

When thinking about marginal effects, though, we’re more interested in the expected value of the outcome, which means we’re more focused on the uncertainty in the model parameters and not necessarily the individual-level residuals (See this post or this discussion for an explanation of the difference between the two types of predictions). To get these expected values, we can use brms::posterior_epred() (or the nicer tidybayes::epred_draws()), which accounts for the uncertainty of just these two things:

• The uncertainty of the fixed coefficients (e.g., party_autonomy and civil_liberties)
• The uncertainty of the variance parameters of the groups (e.g., sd__phi_(Intercept) for each region)

The averages of these draws that are generated by regular posterior_predict() and posterior_epred() should be generally the same, but the variance for expected values will be smaller (since we’re not dealing with individual observation-level variance).

# Expected values with brms
##       [,1]  [,2]
## [1,] 0.677 0.413
## [2,] 0.605 0.387
## [3,] 0.619 0.395
## [4,] 0.593 0.304
## [5,] 0.697 0.389
## [6,] 0.527 0.353

# Expected values with tidybayes
tidy_epred <- model_basic %>%
epred_draws(newdata = newdata)
tidy_epred
## # A tibble: 8,000 × 8
## # Groups:   party_autonomy, civil_liberties, region, .row [2]
##    party_autonomy civil_liberties region                  .row .chain .iter…¹ .draw .epred
##    <lgl>                    <dbl> <chr>                  <int>  <int>   <int> <int>  <dbl>
##  1 TRUE                       0.5 Middle East and North…     1     NA      NA     1  0.677
##  2 TRUE                       0.5 Middle East and North…     1     NA      NA     2  0.605
##  3 TRUE                       0.5 Middle East and North…     1     NA      NA     3  0.619
##  4 TRUE                       0.5 Middle East and North…     1     NA      NA     4  0.593
##  5 TRUE                       0.5 Middle East and North…     1     NA      NA     5  0.697
##  6 TRUE                       0.5 Middle East and North…     1     NA      NA     6  0.527
##  7 TRUE                       0.5 Middle East and North…     1     NA      NA     7  0.756
##  8 TRUE                       0.5 Middle East and North…     1     NA      NA     8  0.659
##  9 TRUE                       0.5 Middle East and North…     1     NA      NA     9  0.524
## 10 TRUE                       0.5 Middle East and North…     1     NA      NA    10  0.701
## # … with 7,990 more rows, and abbreviated variable name ¹​.iteration

Here’s what the difference in variance actually looks like:

plot_preds <- bind_rows(
"Predicted draws" = tidy_pred,
"Expectation of predicted draws" = rename(tidy_epred, .prediction = .epred),
.id = "draw_type") %>%
mutate(draw_type = fct_inorder(draw_type))

plot_preds %>%
group_by(draw_type, party_autonomy) %>%
median_hdi(.prediction)
## # A tibble: 4 × 8
##   draw_type                      party_auton…¹ .pred…² .lower .upper .width .point .inte…³
##   <fct>                          <lgl>           <dbl>  <dbl>  <dbl>  <dbl> <chr>  <chr>
## 1 Predicted draws                FALSE           0.355 0.0183  0.725   0.95 median hdi
## 2 Predicted draws                TRUE            0.638 0.242   0.976   0.95 median hdi
## 3 Expectation of predicted draws FALSE           0.369 0.287   0.461   0.95 median hdi
## 4 Expectation of predicted draws TRUE            0.622 0.532   0.707   0.95 median hdi
## # … with abbreviated variable names ¹​party_autonomy, ²​.prediction, ³​.interval

ggplot(plot_preds, aes(x = .prediction, fill = party_autonomy)) +
stat_halfeye() +
labs(x = "Predicted media index", y = "Density", fill = "Party autonomy") +
facet_wrap(vars(draw_type)) +
scale_fill_okabe_ito() +
theme_clean() +
theme(legend.position = "bottom")

The median values from both types of draws are the same. When there’s no party autonomy, the median predicted media index is around 0.11; when there is party autonomy, the median predicted media index is around 0.6. But the variance is wildly different and is much narrower when looking at the expectation of predicted draws.

For all our marginal effects calculations here, we’re going to use expected values, or epred_draws().

Average marginal effects

In the plot above, we got predicted values of our outcome across different levels of party autonomy when holding civil liberties and region constant, and that’s neat, but what we’re really interested in is the difference between those two distributions, or the marginal effect of flipping party autonomy from false to true. How much of an increase in the predicted media index when there is party autonomy?

We already have all the information we need to calculate this with our tidy_epred. If we rearrange the dataset so that it’s wide, with a column for party autonomy being true and a column for when it’s false, we can subtract the two columns and get the difference:

tidy_epred %>%
ungroup() %>%
select(-.row) %>%
pivot_wider(names_from = "party_autonomy", values_from = ".epred") %>%
mutate(autonomy_effect = TRUE - FALSE)
## # A tibble: 4,000 × 8
##    civil_liberties region                      .chain .iter…¹ .draw TRUE FALSE auton…²
##              <dbl> <chr>                        <int>   <int> <int>  <dbl>   <dbl>   <dbl>
##  1             0.5 Middle East and North Afri…     NA      NA     1  0.677   0.413   0.265
##  2             0.5 Middle East and North Afri…     NA      NA     2  0.605   0.387   0.218
##  3             0.5 Middle East and North Afri…     NA      NA     3  0.619   0.395   0.224
##  4             0.5 Middle East and North Afri…     NA      NA     4  0.593   0.304   0.290
##  5             0.5 Middle East and North Afri…     NA      NA     5  0.697   0.389   0.308
##  6             0.5 Middle East and North Afri…     NA      NA     6  0.527   0.353   0.174
##  7             0.5 Middle East and North Afri…     NA      NA     7  0.756   0.500   0.256
##  8             0.5 Middle East and North Afri…     NA      NA     8  0.659   0.454   0.205
##  9             0.5 Middle East and North Afri…     NA      NA     9  0.524   0.294   0.230
## 10             0.5 Middle East and North Afri…     NA      NA    10  0.701   0.424   0.276
## # … with 3,990 more rows, and abbreviated variable names ¹​.iteration, ²​autonomy_effect

We could then plot that autonomy_effect column and see the average marginal effect of party autonomy. But that’s a lot of data wrangling and pivoting! There’s an easier way! Instead of manually working with the posterior predictions like this, we can use the emmeans package, which was designed specifically for this kind of work.

With emmeans(), we don’t have to create our own hypothetical newdata data frame—the function does that for us. We need to specify epred = TRUE to get the expectation of predicted draws, since it’ll give us predicted draws by default.

If we run emmeans() by itself, it’ll print a summary table of the predicted values:

model_basic %>%
emmeans(~ party_autonomy,
at = list(civil_liberties = 0.5,
region = "Middle East and North Africa"),
epred = TRUE)
##  party_autonomy emmean lower.HPD upper.HPD
##  FALSE           0.329     0.223     0.445
##   TRUE           0.578     0.461     0.691
##
## Point estimate displayed: median
## HPD interval probability: 0.95

We also don’t need to calculate the autonomy effect by hand—the constrast() function will do that for us too (the revpairwise option here makes it so it’ll calculate TRUE - FALSE instead of FALSE - TRUE):

model_basic %>%
emmeans(~ party_autonomy,
at = list(civil_liberties = 0.5,
region = "Middle East and North Africa"),
epred = TRUE) %>%
contrast(method = "revpairwise")
##  contrast     estimate lower.HPD upper.HPD
##  TRUE - FALSE    0.246     0.171     0.327
##
## Point estimate displayed: median
## HPD interval probability: 0.95

If we store the results of emmeans() as an object, we actually get access to all the posterior draws that it created for the predictions, which we can then extract and rearrange with tidybayes’s gather_emmeans_draws(). (Without the contrast() step, we’d get predicted values for both levels of party_autonomy.)

autonomy_effect_draws <- model_basic %>%
emmeans(~ party_autonomy,
at = list(civil_liberties = 0.5,
region = "Middle East and North Africa"),
epred = TRUE) %>%
contrast(method = "revpairwise") %>%
gather_emmeans_draws()
autonomy_effect_draws
## # A tibble: 4,000 × 5
## # Groups:   contrast [1]
##    contrast     .chain .iteration .draw .value
##    <chr>         <int>      <int> <int>  <dbl>
##  1 TRUE - FALSE     NA         NA     1  0.254
##  2 TRUE - FALSE     NA         NA     2  0.217
##  3 TRUE - FALSE     NA         NA     3  0.223
##  4 TRUE - FALSE     NA         NA     4  0.292
##  5 TRUE - FALSE     NA         NA     5  0.311
##  6 TRUE - FALSE     NA         NA     6  0.169
##  7 TRUE - FALSE     NA         NA     7  0.275
##  8 TRUE - FALSE     NA         NA     8  0.205
##  9 TRUE - FALSE     NA         NA     9  0.237
## 10 TRUE - FALSE     NA         NA    10  0.270
## # … with 3,990 more rows

autonomy_effect_draws %>% median_hdi()
## # A tibble: 1 × 7
##   contrast     .value .lower .upper .width .point .interval
##   <chr>         <dbl>  <dbl>  <dbl>  <dbl> <chr>  <chr>
## 1 TRUE - FALSE  0.246  0.171  0.327   0.95 median hdi

ggplot(autonomy_effect_draws, aes(x = .value)) +
stat_halfeye(fill = palette_okabe_ito(order = 5)) +
labs(x = "Average marginal effect of party autonomy", y = "Density") +
theme_clean() +
theme(legend.position = "bottom")

That’s so cool! There’s our marginal effect. On average, having party autonomy is associated with a 0.247 point higher media freedom index score. Since it’s measured on a 0–1 scale, that’s a pretty sizable effect!

Where emmeans really shines is figuring out the average marginal effect for continuous predictors. Working with party autonomy was easy enough: get the predicted value when it’s false, get the predicted value when it’s true, and find the difference. Getting a continuous effect, though, is a lot trickier since it involves slopes and first derivatives and calculus. Plus we’re working with a nonlinear model, so the slope is different across the whole range of predictions.

For instance, here’s the predicted media freedom index across a range of respect for civil liberties. It’s curvy! The slope is really flat down at low levels of civil liberties, and it’s steep up at high levels

pred_civlib <- model_basic %>%
epred_draws(newdata = expand_grid(party_autonomy = FALSE,
region = "Middle East and North Africa",
civil_liberties = seq(0, 1, by = 0.05)))

ggplot(pred_civlib, aes(x = civil_liberties, y = .epred)) +
stat_lineribbon() +
scale_fill_brewer(palette = "Reds") +
labs(x = "Civil liberties index", y = "Predicted media freedom index",
fill = "Credible interval") +
theme_clean() +
theme(legend.position = "bottom")

The emtrends() function from emmeans lets us find the slope at different hypothetical values. (Technically behind the scenes, if we tell it to show us the slope when civil_liberties is 0.5, it’ll calculate the predicted value at 0.5 and the predicted value at 0.50001, and then average those predictions, rather than try to figure out the true calculus-based derivative).

If we don’t specify possible values of civil_liberties, it will give us the slope at the average value of civil_liberties (0.698):

model_basic %>%
emtrends(~ civil_liberties,
var = "civil_liberties",
at = list(party_autonomy = FALSE,
region = "Middle East and North Africa"),
epred = TRUE)
##  civil_liberties civil_liberties.trend lower.HPD upper.HPD
##            0.696                 0.865     0.702      1.03
##
## Point estimate displayed: median
## HPD interval probability: 0.95

If we use ~ 1 in the formula instead of ~ civil_liberties, it will give us the average overall slope:

model_basic %>%
emtrends(~ 1,
var = "civil_liberties",
at = list(party_autonomy = FALSE,
region = "Middle East and North Africa"),
epred = TRUE)
##  1       civil_liberties.trend lower.HPD upper.HPD
##  overall                 0.865     0.702      1.03
##
## Point estimate displayed: median
## HPD interval probability: 0.95

Or we can specify specific values of civil_liberties to get the slope at those points:

model_basic %>%
emtrends(~ civil_liberties,
var = "civil_liberties",
at = list(party_autonomy = FALSE,
region = "Middle East and North Africa",
civil_liberties = c(0.2, 0.5, 0.8)),
epred = TRUE)
##  civil_liberties civil_liberties.trend lower.HPD upper.HPD
##              0.2                 0.436     0.295     0.607
##              0.5                 0.767     0.577     0.951
##              0.8                 0.838     0.695     0.982
##
## Point estimate displayed: median
## HPD interval probability: 0.95

The civil liberties effect varies a lot depending on existing levels of civil liberties! Let’s visualize these three effects. We’ll scale the effect down, though, since civil liberties is on a 0–1 scale and a 1-unit change means going from 0 to 1, which is huge. We’ll think about this as a 0.1-point increase in the civil liberties scale.

ame_civlib <- model_basic %>%
emtrends(~ civil_liberties,
var = "civil_liberties",
at = list(party_autonomy = FALSE,
region = "Middle East and North Africa",
civil_liberties = c(0.2, 0.5, 0.8)),
epred = TRUE) %>%
gather_emmeans_draws() %>%
# Scale this down
mutate(.value = .value / 10)

ggplot(ame_civlib, aes(x = .value, fill = factor(civil_liberties))) +
stat_halfeye(slab_alpha = 0.75) +
scale_fill_okabe_ito(order = c(3, 4, 6)) +
labs(x = "Average marginal effect of a\n0.1-point increase in the civil liberties index",
y = "Density", fill = "Civil liberties index",
caption = "80% and 95% credible intervals shown in black") +
theme_clean() +
theme(legend.position = "bottom")

For countries with low levels of civil liberties, a 0.1-point increase in respect for civil liberties (e.g., moving from 0.2 to 0.3) is associated with a 0.0436-point increase in the media freedom index. For a middle ground country, though, moving from 0.5 to 0.6 in civil liberties is associated with a 0.0763-point increase in the media freedom index, while countries with strong civil liberties see a strong increase in the media freedom index (0.0833 points). Cool cool cool.

Different kinds of average predictions with multilevel models

BUT THERE’S A HUGE CAVEAT HERE!

Guess what?! All these predicted average effects aren’t quite what we think they are. (That’s what TJ was referring to in the tweet at the beginning of this post!)

Because we’re working with multilevel models, we actually have other moving parts to think about: the regional effects. When we talk about average marginal effects with multilevel models, we have to be explicit about what kinds of averages we’re working with. There are a few different kinds of averages we can calculate:

• Global grand mean
• Conditional effects for existing groups
• Conditional effects for a single hypothetical group, either typical or brand new

We can specify all these different kinds of calculations with certain combinations of a few arguments to posterior_epred() / epred_draws() / emmeans() / emmtrends():

• newdata: Specify which regions to include in the predictions
• re_formula: Specify how (and whether) to handle the model’s random effects in the predictions
• sample_new_levels: Specify how to handle the uncertainty in new random effects in the predictions

(These are all documented in the help pages for ?brms::prepare_predictions())

Global grand mean

With a global grand mean, we calculate the expected value of the media freedom index while ignoring any region-specific deviations of the intercept or slope. We do not incorporate any of the region-specific information from the model into the predictions. This provides us with a global grand mean—an average that transcends regional differences.

To calculate this, we need to feed brms a new dataset that doesn’t include any region, and we need to tell it to not use any random effects by including re_formula = NA.

Binary effect

Let’s look at the global average marginal effect for party autonomy, which is binary:

# Posterior predictions across autonomy
grand_mean_autonomy_dist <- model_basic %>%
epred_draws(newdata = expand_grid(party_autonomy = c(TRUE, FALSE),
civil_liberties = c(0.5)),
re_formula = NA)

plot_grand_mean_autonomy <- ggplot(grand_mean_autonomy_dist,
aes(x = .epred, y = "Grand mean",
fill = party_autonomy)) +
stat_halfeye() +
scale_fill_okabe_ito() +
labs(x = "Predicted media index", y = NULL,
fill = "Opposition parties allowed",
subtitle = "Posterior predictions") +
theme_clean() +
theme(legend.position = "bottom")

# emmeans()-based difference across autonomy
grand_mean_autonomy_ame <- model_basic %>%
emmeans(~ party_autonomy,
at = list(civil_liberties = 0.5),
epred = TRUE, re_formula = NA) %>%
contrast(method = "revpairwise") %>%
gather_emmeans_draws()

plot_grand_mean_autonomy_ame <- ggplot(grand_mean_autonomy_ame,
aes(x = .value, y = "Grand AME")) +
stat_halfeye(fill = palette_okabe_ito(order = 7)) +
labs(x = "Average marginal effect of party autonomy", y = NULL,
subtitle = "Marginal effect (TRUE − FALSE)") +
theme_clean() +
theme(legend.position = "bottom")

# Combined plot
(plot_grand_mean_autonomy | plot_grand_mean_autonomy_ame) +
plot_annotation(title = "Global grand mean",
subtitle = "re_formula = NA; no region in newdata",
theme = theme_clean())

And here are the actual medians and credible intervals:

grand_mean_autonomy_ame %>% median_hdi()
## # A tibble: 1 × 7
##   contrast     .value .lower .upper .width .point .interval
##   <chr>         <dbl>  <dbl>  <dbl>  <dbl> <chr>  <chr>
## 1 TRUE - FALSE  0.246  0.171  0.327   0.95 median hdi

The region-free grand average marginal effect for party autonomy is thus 0.247.

Continuous effect

Next we can look at the average marginal effect for civil liberties, which is continuous:

# Posterior predictions across civil_liberties
grand_mean_civlib_dist <- model_basic %>%
epred_draws(newdata = expand_grid(party_autonomy = FALSE,
civil_liberties = seq(0, 1, by = 0.05)),
re_formula = NA)

plot_grand_mean_civlib <- ggplot(grand_mean_civlib_dist,
aes(x = civil_liberties, y = .epred)) +
stat_lineribbon() +
scale_fill_brewer(palette = "Reds") +
labs(x = "Civil liberties index", y = "Predicted media freedom index",
fill = "Credible interval") +
theme_clean() +
theme(legend.position = "bottom")

# emtrends()-based AMEs
grand_mean_civlib_ame <- model_basic %>%
emtrends(~ civil_liberties,
var = "civil_liberties",
at = list(party_autonomy = FALSE,
civil_liberties = c(0.2, 0.8)),
epred = TRUE, re_formula = NA) %>%
gather_emmeans_draws()

plot_grand_mean_civlib_ame <- ggplot(grand_mean_civlib_ame,
aes(x = .value / 10, fill = factor(civil_liberties))) +
stat_halfeye(slab_alpha = 0.75) +
scale_fill_okabe_ito(order = c(3, 4)) +
labs(x = "Average marginal effect of a\n0.1-point increase in the civil liberties index",
y = "Density", fill = "Civil liberties index") +
theme_clean() +
theme(legend.position = "bottom")

# Combined plot
(plot_grand_mean_civlib | plot_grand_mean_civlib_ame) +
plot_annotation(title = "Grand mean",
subtitle = "re_formula = NA; no region in newdata",
theme = theme_clean())

And here are the actual medians for these effects:

grand_mean_civlib_ame %>% median_hdi()
## # A tibble: 2 × 7
##   civil_liberties .value .lower .upper .width .point .interval
##             <dbl>  <dbl>  <dbl>  <dbl>  <dbl> <chr>  <chr>
## 1             0.2  0.436  0.295  0.607   0.95 median hdi
## 2             0.8  0.838  0.695  0.982   0.95 median hdi

The region-free grand average marginal effect for civil liberties is 0.436 for low values of civil liberties (0.2), and 0.833 for high values (0.8).

Conditional effects for existing groups

Instead of calculating a grand mean without any group information, we can incorporate regional effects into our predictions. We can create conditional effects for specific regions that already exist in the data, incorporating their region-specific deviations in slope and intercept.

To calculate this, we need to feed brms a new dataset that does include one or more regions that are already in the data, and we need to tell it to incorporate the random effects into its predictions by including re_formula = NULL. That feels weird, since re_formula = NA made it so there were no group effects, but that’s the syntax.

We can also be more explicit about which group effects to include. When using re_formula = NULL, all group effects are included. If we made a model with both region and year effects, for instance, and only wanted to predict using the region effects, we could use re_formula = ~ (1 | region). Or if we don’t want to remember that NULL means “everything”, we could also just use re_formula = ~ (1 | region) on our basic model with just regional effects. It’s all the same.

Binary effect

Let’s look at the binary region-specific average marginal effect for party autonomy across all six regions:

# Posterior predictions across autonomy and region
all_regions_autonomy_dist <- model_basic %>%
epred_draws(newdata = expand_grid(party_autonomy = c(TRUE, FALSE),
region = levels(vdem_2015$region), civil_liberties = c(0.5)), re_formula = NULL) # or re_formula = ~ (1 | region) plot_all_regions_autonomy <- ggplot(all_regions_autonomy_dist, aes(x = .epred, y = region, fill = party_autonomy)) + stat_halfeye() + scale_fill_okabe_ito() + labs(x = "Predicted media index", y = NULL, fill = "Opposition parties allowed", subtitle = "Posterior predictions") + theme_clean() + theme(legend.position = "bottom") # emmeans()-based difference across autonomy, by region all_regions_autonomy_ame <- model_basic %>% emmeans(~ party_autonomy + region, at = list(civil_liberties = 0.5, region = levels(vdem_2015$region)),
epred = TRUE, re_formula = NULL) %>%
contrast(method = "revpairwise", by = "region") %>%
gather_emmeans_draws()

plot_all_regions_autonomy_ame <- ggplot(all_regions_autonomy_ame,
aes(x = .value, y = region)) +
stat_halfeye(fill = palette_okabe_ito(order = 7)) +
labs(x = "Average marginal effect of party autonomy", y = NULL,
subtitle = "Marginal effect (TRUE − FALSE)") +
theme_clean() +
theme(legend.position = "bottom")

# Combined plot
(plot_all_regions_autonomy | plot_all_regions_autonomy_ame + theme(axis.text.y = element_blank())) +
plot_annotation(title = "Region-specific means",
subtitle = "re_formula = NULL; existing region(s) included in newdata",
theme = theme_clean())

And here are the actual medians and credible intervals:

all_regions_autonomy_ame %>% median_hdi()
## # A tibble: 6 × 8
##   contrast     region                           .value .lower .upper .width .point .inte…¹
##   <fct>        <fct>                             <dbl>  <dbl>  <dbl>  <dbl> <chr>  <chr>
## 1 TRUE - FALSE Eastern Europe and Central Asia   0.231  0.155  0.303   0.95 median hdi
## 2 TRUE - FALSE Latin America and the Caribbean   0.241  0.161  0.314   0.95 median hdi
## 3 TRUE - FALSE Middle East and North Africa      0.250  0.172  0.330   0.95 median hdi
## 4 TRUE - FALSE Sub-Saharan Africa                0.250  0.173  0.330   0.95 median hdi
## 5 TRUE - FALSE Western Europe and North America  0.243  0.162  0.320   0.95 median hdi
## 6 TRUE - FALSE Asia and Pacific                  0.239  0.166  0.314   0.95 median hdi
## # … with abbreviated variable name ¹​.interval

The average marginal effect changes depending on region, but not by much. Across all regions, having party autonomy is associated with a 0.24 to 0.25-point increase in the media freedom index, on average. Importantly, these average effects incorporate the uncertainty that comes from the regional multilevel structure that we built in the model.

Continuous effect

Next we’ll look at the continuous region-specific average marginal effect for civil liberties across all six regions:

# Posterior predictions across civil_liberties and regions
all_regions_civlib_dist <- model_basic %>%
epred_draws(newdata = expand_grid(party_autonomy = FALSE,
civil_liberties = seq(0, 1, by = 0.05),
region = levels(vdem_2015$region)), re_formula = NULL) plot_all_regions_civlib <- ggplot(all_regions_civlib_dist, aes(x = civil_liberties, y = .epred)) + stat_lineribbon() + scale_fill_brewer(palette = "Reds") + labs(x = "Civil liberties index", y = "Predicted media freedom index", fill = "Credible interval") + facet_wrap(vars(region)) + theme_clean() + theme(legend.position = "bottom") # emtrends()-based AMEs across region all_regions_civlib_ame <- model_basic %>% emtrends(~ civil_liberties + region, var = "civil_liberties", at = list(party_autonomy = FALSE, civil_liberties = c(0.2, 0.8), region = levels(vdem_2015$region)),
epred = TRUE, re_formula = NULL) %>%
gather_emmeans_draws()

plot_all_regions_civlib_ame <- ggplot(all_regions_civlib_ame,
aes(x = .value / 10, fill = factor(civil_liberties))) +
stat_halfeye(slab_alpha = 0.75) +
scale_fill_okabe_ito(order = c(3, 4)) +
labs(x = "Average marginal effect of a\n0.1-point increase in the civil liberties index",
y = "Density", fill = "Civil liberties index") +
facet_wrap(vars(region)) +
theme_clean() +
theme(legend.position = "bottom")

(plot_all_regions_civlib / plot_all_regions_civlib_ame) +
plot_annotation(title = "Region-specific means",
subtitle = "re_formula = NULL; existing region(s) included in newdata",
theme = theme_clean())

And here are the actual medians for these effects:

all_regions_civlib_ame %>% median_hdi()
## # A tibble: 12 × 8
##    civil_liberties region                       .value .lower .upper .width .point .inte…¹
##              <dbl> <fct>                         <dbl>  <dbl>  <dbl>  <dbl> <chr>  <chr>
##  1             0.2 Eastern Europe and Central …  0.327  0.246  0.418   0.95 median hdi
##  2             0.2 Latin America and the Carib…  0.380  0.290  0.475   0.95 median hdi
##  3             0.2 Middle East and North Africa  0.492  0.377  0.638   0.95 median hdi
##  4             0.2 Sub-Saharan Africa            0.479  0.373  0.590   0.95 median hdi
##  5             0.2 Western Europe and North Am…  0.609  0.469  0.729   0.95 median hdi
##  6             0.2 Asia and Pacific              0.366  0.267  0.479   0.95 median hdi
##  7             0.8 Eastern Europe and Central …  0.871  0.709  1.02    0.95 median hdi
##  8             0.8 Latin America and the Carib…  0.865  0.725  1.01    0.95 median hdi
##  9             0.8 Middle East and North Africa  0.808  0.687  0.924   0.95 median hdi
## 10             0.8 Sub-Saharan Africa            0.819  0.708  0.926   0.95 median hdi
## 11             0.8 Western Europe and North Am…  0.724  0.585  0.885   0.95 median hdi
## 12             0.8 Asia and Pacific              0.865  0.712  1.00    0.95 median hdi
## # … with abbreviated variable name ¹​.interval

As expected, the average marginal effect for civil liberties varies across regions. For the Middle East and North Africa, for instance, it is 0.49 for low values of civil liberties (0.2), and 0.8 for high values (0.8), while in Eastern Europe and Central Asia, it is 0.328 for low values and 0.865 for high values.

Neat!

Conditional effects for a single new hypothetical group, either typical or brand new

So far we’ve calculated a grand mean with no regional effects and a bunch of conditional means for specific regions. But what if we want a single mean that also includes regional effects?

We have a few really nuanced options for doing this. In general, we’ll create a brand new hypothetical region and make predictions based on it. Where the nuance comes into play is how we construct this region and think about its variation. We can build its variance based on an average of the existing regions’ variances, or we can simulate a brand new kind of variance all together.

To do this, we need to include region in the new data we feed brms, but we need to include a name of a region that doesn’t exist (or alternatively, you can feed it region = NA). It will yell at you initially because that group doesn’t exist, but you can allow it to make these extrapolated predictions by including allow_new_levels = TRUE. We can include the random effects for region by setting re_formula = NULL (or re_formula = ~ (1 | region) if we want to be super explicit).

Finally, we have to specify how to handle the regional variation for this imaginary new group. We can either draw from the variation in all the other groups by setting sample_new_levels = "uncertainty" (this is the default), or we can randomly simulate a whole new kind of regional uncertainty based on the model’s existing parameters using sample_new_levels = "gaussian". The documentation for all this is included in ?brms::prepare_predictions.

In summary, we have these two general approaches:

1. Create a hypothetical region that is based on the observed variation in the existing regions with region = "Something new" in the new data and re_formula = NULL, allow_new_levels = TRUE, sample_new_levels = "uncertainty" in the prediction function
2. Create a hypothetical region that is completely brand new with region = "Something new" in the new data and re_formula = NULL, allow_new_levels = TRUE, sample_new_levels = "gaussian" in the prediction function

For the sake of space I’ll only show marginal effects for the binary party_autonomy variable here. Follow the same process as all the examples above, but set re_formula = NULL, allow_new_levels = TRUE, and either sample_new_levels = "uncertainty" or sample_new_levels = "gaussian" when making the predictions.

Technically there are other ways to create simulated groups or regions, like sample_new_levels = "old_levels". Isabella Ghement has a great comprehensive table summarizing them all here. But we’re not going to worry about that here.

Binary effect for amalgamated hypothetical region

First we’ll create predictions for a hypothetical region that draws its group characteristics and variance from all the other regions in the data. To do this, we include region = "Something new" in the new data and re_formula = NULL, allow_new_levels = TRUE, sample_new_levels = "uncertainty" in the prediction function.

ame_hypothetical_amalgamated <- model_basic %>%
emmeans(~ party_autonomy + region,
at = list(civil_liberties = 0.5,
region = "Generic world region"),
epred = TRUE, re_formula = NULL,
allow_new_levels = TRUE, sample_new_levels = "uncertainty") %>%
contrast(method = "revpairwise") %>%
gather_emmeans_draws()

ame_hypothetical_amalgamated %>% median_hdi()
## # A tibble: 1 × 7
##   contrast                                      .value .lower .upper .width .point .inte…¹
##   <chr>                                          <dbl>  <dbl>  <dbl>  <dbl> <chr>  <chr>
## 1 TRUE Generic world region - FALSE Generic wo…  0.242  0.165  0.321   0.95 median hdi
## # … with abbreviated variable name ¹​.interval

plot_ame_hypothetical_amalgamated <- ggplot(ame_hypothetical_amalgamated,
aes(x = .value, y = "Generic world region")) +
stat_halfeye(fill = palette_okabe_ito(order = 7)) +
labs(x = "Average marginal effect of party autonomy", y = NULL,
title = "Marginal effect in a generic world region") +
theme_clean() +
theme(legend.position = "bottom")
plot_ame_hypothetical_amalgamated

In this case, the distribution is pretty smooth and normal looking, but if there’s a lot of variation in regional distributions, that variation will transfer to these predictions too.

For instance, in an earlier working version of this post, I had region-specific average predictions that varied substantially across regions. The resulting new hypothetical region reflected those different components—because the new generic world region is an amalgamation of uncertainty of all the existing regions, it includes both high values and low values (thanks to TJ Mahr for this insight!):

Binary effect for completely new hypothetical region

Alternatively, we can make it so that the new fake region uses a multivariate normal distribution implied by the model’s group-level standard deviations and correlations. This creates a brand new region that would be plausible in the universe, but that is not sampled directly from the existing regions. As a result, there’s no possibility of a weird bimodal blip—the overall shape of the distribution will be much smoother and consistent.

ame_hypothetical_new <- model_basic %>%
emmeans(~ party_autonomy + region,
at = list(civil_liberties = 0.5,
region = "Atlantis"),
epred = TRUE, re_formula = NULL,
allow_new_levels = TRUE, sample_new_levels = "gaussian") %>%
contrast(method = "revpairwise") %>%
gather_emmeans_draws()

ame_hypothetical_new %>% median_hdi()
## # A tibble: 1 × 7
##   contrast                       .value .lower .upper .width .point .interval
##   <chr>                           <dbl>  <dbl>  <dbl>  <dbl> <chr>  <chr>
## 1 TRUE Atlantis - FALSE Atlantis  0.235  0.139  0.321   0.95 median hdi

plot_ame_hypothetical_new <- ggplot(ame_hypothetical_new,
aes(x = .value, y = "Atlantis")) +
stat_halfeye(fill = palette_okabe_ito(order = 7)) +
labs(x = "Average marginal effect of party autonomy", y = NULL,
title = "Marginal effect in the Atlantis region") +
theme_clean() +
theme(legend.position = "bottom")
plot_ame_hypothetical_new

Which average is best?

You’ve run a great multilevel model and want to report the average marginal effects for your coefficients of interest. Which average do you report? The global grand mean? Means conditional on existing groups? The mean of a new typical-ish looking region? The mean of a completely brand new region?

Who knows!

It all depends on the story you’re trying to tell about your data, theory, and results.

Overall summary of different approaches

Phew. We did a lot here with a ton of really neat moving parts. Here’s a summary of all the different average effects you can calculate with multilevel models and how to do them:

Type of average Description newdata re_formula Other options
Global grand mean Average predicted outcome ignoring group-specific deviations in intercept or slope Omit group from newdata re_formula = NA
Conditional effects for existing groups Average predicted outcomes for existing groups, incorporating group-specific deviations in intercept or slope Include existing group(s) in newdata re_formula = NULL or actual group term, like re_formula = ~ (1 | group)
Conditional effects for a single typical hypothetical group Average predicted outcome for a new group that is based on variation of existing groups Include new group in newdata re_formula = NULL or actual group term, like re_formula = ~ (1 | group) allow_new_levels = TRUE,
sample_new_levels = "uncertainty"
Conditional effects for a single brand new hypothetical group Average predicted outcome for a new group that is based on random draws from model Include new group in newdata re_formula = NULL or actual group term, like re_formula = ~ (1 | group) allow_new_levels = TRUE,
sample_new_levels = "gaussian"

Type of average Generic code
Global grand mean
posterior_epred(model,                newdata = __OMIT GROUP__,                re_formula = NA)
Conditional effects for existing groups
posterior_epred(model,                newdata = __INCLUDE EXISTING GROUP(S)__,                re_formula = NULL)
Conditional effects for a single typical hypothetical group
posterior_epred(model,                newdata = __INCLUDE NEW GROUP__,                re_formula = NULL, allow_new_levels = TRUE,                sample_new_levels = "uncertainty")
Conditional effects for a single brand new hypothetical group
posterior_epred(model,                newdata = __INCLUDE NEW GROUP__,                re_formula = NULL, allow_new_levels = TRUE,                sample_new_levels = "gaussian")

And here’s what they all look like!