Ggdist::marginalize_lkjcorr() not working as intended

I can’t visualize the lkj priors of my correlation parameters because I can’t find how to convert the lkj_corr_cholesky(eta) priors into .dist objects that stat_slabinterval() would understand. The marginalize_lkjcorr() function should technically do this, but it seems not to be working:

#Load libraries:
require(brms)
require(cmdstanr)
require(ggplot2)
require(ggdist)

# Create mock data:
N = 100
datums <- data.frame(y = rbinom(N, 1, 0.5), x1 = rnorm(N, 0, 1), x2 = rnorm(N, -5, 2), x3 = rnorm(N, 5, 3), g = sample(LETTERS, N, replace = T))

# Fit model:
mod <- brm(y ~ (x1|g) + x1 + x2 + x3, family = bernoulli, prior = prior(normal(0, 2.5), class = b) + prior(normal(0, 5), class = Intercept) + prior(normal(0, 1), lb = 0, class = sd) + prior(lkj(2), class = cor) 
  , data = datums, warmup = 2e3, cores = Cores, chains = Chains, iter = ceiling(25e3/Chains)+2e3, threads = threading(Cores/Chains, static = T), backend = "cmdstanr")


# Extract analytical prior distributions from model:
mypriors <- marginalize_lkjcorr(parse_dist(prior_summary(mod)), K = 2)

# Try to plot the priors:
ggplot(mypriors) + stat_slab(aes(xdist = .dist_obj, y = class))
Error in `layer_slabinterval()`:
! Problem while computing aesthetics.
ℹ Error occurred in the 1st layer.
Caused by error in `get()`:
! object 'rlkj_corr_cholesky' of mode 'function' was not found
Run `rlang::last_trace()` to see where the error occurred.

While these simple analytical priors could of course be plotted using a more basic function such as curve(), that’s not so helpful when you want to combine both the analytical priors and the posterior densities in the same plot while maintaining the full functionality of the ggdist library.

I think this is probably needs to be fixed in ggdist, so I recommend opening an issue at Issues · mjskay/ggdist · GitHub. Until it’s fixed, does this avoid the problem for you?

library(dplyr)

mypriors <- prior_summary(mod) |>
  as.data.frame() |>
  mutate(prior = sub("^lkj_corr_cholesky\\(", "lkj(", prior)) |>
  parse_dist(prior) |>
  marginalize_lkjcorr(K = 2) |>
  filter(!is.na(.dist))

ggplot(mypriors) + stat_slab(aes(xdist = .dist_obj, y = class))

I think the issue might be that brms is reporting the name as lkj_corr_cholesky not lkj in prior_summary(), but ggdist::parse_dist() is expecting lkj. I haven’t actually run the code above, but I think it’s approximately right and you can modify as needed.

Many thanks to @jonah , and apologies for the delayed response (I had to take it all in and then write code). Based on those pointers, I have written a script that takes a brms model and a parameter-specifying regex as input, extracts the analytical priors and sampled posteriors, and plots both in the same plot using ggplot2 and ggdist. It’s a script I’d eventually like to turn into a handy helper function, given I like visualizing analytical priors together with empirical posteriors. But there’s a problem.

As far as I can understand, the marginal density of any pairwise correlation in a prior correlation matrix R should depend not only on the eta parameter of the LKJ distribution, but also on K, namely the dimension of R. Yet, even though my script (which renames the distribution and applies marginalize_lkjcorr() as shown by Jonah) appears to parse the distribution correctly, the plotted prior curves fail to reflect differences in K between correlation matrices with the same eta. Here is mock dataset mydata, constructed for easy illustration of the problem.

Let’s fit a brms model to these data:

require(brms); require(cmdstanr)
Cores <- 6  # your mileage may vary
Chains <- Cores

brmsmod <- brm(y ~ (1+x1|G1) + (1+x1+x2+x3|G2) + x1 + x2 + x3, family = bernoulli, data = mydata, seed = 2026, refresh = 2e3,
  prior = prior(lkj(6), class = cor) + prior(normal(0, 2.5), class = b) + prior(normal(0, 5), class = Intercept) + prior(normal(0, 0.5), class = sd)
  , backend = "cmdstanr", warmup = 2e3, cores = Cores, chains = Chains, iter = ceiling(25e3/Chains)+2e3, threads = threading(Cores/Chains, static = T))

And below is my plotting script, followed by the graphical output evidencing the problem:

require(ggplot2); require(ggdist); require(stringi)
pars_regex <- "^(b|sd|cor)"

# Extract posterior draws for the specified parameters:
as.data.frame(head(posttab.raw <- as_draws_df(brmsmod)[grep(pars_regex, names(as_draws_df(brmsmod)))], 3))
posttab <- data.frame(Parameter = rep(names(posttab.raw), each = nrow(posttab.raw)), postval = unlist(posttab.raw))

# Extract the table of priors:
(priors <- prior_summary(brmsmod)) 
for(i in 2:nrow(priors)){
  for(j in which(names(priors) %in% c("prior","lb","ub"))){
    if(priors[i, j] == ""){priors[i, j] <- priors[i-1, j]}else{next} } }
(priors$prior <- gsub("_corr_cholesky\\(", "(", priors$prior)) #Gabry changes the name like this.

# Convert LKJ priors into a format ggdist supposedly understands:
(Priors <- parse_dist(priors))
if(any(startsWith(names(posttab.raw), "cor"))   ){
  (groupingVars <- unique(priors$group[priors$class == "L" & priors$group != ""]))
  (corrpairs <- character())
 for(g in groupingVars){
   (Rdim <- length(unique(priors$coef[priors$group == g]))-1)
   (eta <- as.integer(stringi::stri_extract(priors$prior[priors$class == "L" & priors$group == g][1], regex = "\\d+(?=\\)$)"), ")"))
   (Priors <- marginalize_lkjcorr(Priors, K = Rdim, predicate = (class == "L" & group == g)))
   (varying <- c("Intercept",    
      unlist(strsplit(gsub("^1[\\W]+", "",stringi::stri_extract(as.character(formula(brmsmod))[1], regex = paste0("(?<=\\()[^|)]+(?= \\| ", g,"\\))") ), perl = T), split = " + ", fixed = T)))) 
   newpairs <- character()
   for(i in 1:(length(varying)-1)){for(j in (i+1):length(varying)){newpairs <- c(newpairs, paste0(varying[i], "__", varying[j]))}}
   (corrpairs <- c(corrpairs, newpairs))
   (Priors <- rbind(Priors, Priors[rep(which(Priors$class == "L" & Priors$group == g)[1], times = length(newpairs)),]))
   (Priors[tail(1:nrow(Priors), length(newpairs)), c("class","coef")] <- data.frame(rep("cor", length(newpairs)), newpairs))
   rownames(Priors)[tail(1:nrow(Priors), length(newpairs))] <- paste0(g, "_cor", 1:length(newpairs))
 }
  }

# Finish a dataframe with all the analytical priors for ggplotting:
(tab.pr <- Priors[Priors$class == "Intercept" | Priors$coef != "",])
(tab.pr$Parameter <- gsub("^Intercept$", "b_Intercept", gsub("^b_+","b_", gsub("_+$", "", paste0(tab.pr$class, "_", tab.pr$group, "__", tab.pr$coef), perl = T), perl = T), perl = T))
(tab.pr <- tab.pr[grep(pars_regex, tab.pr$Parameter, perl = T),])

# Plot all priors and posteriors:
ggplot(posttab, aes(postval, Parameter)) + geom_vline(xintercept = 0, lwd = 0.5, color = "gray80") + #
  stat_slab(alpha = 1, fill = "#b3cde0", lwd = 0.6, , color = "black", point_size = 1) + geom_hline(yintercept = 1:length(unique(posttab$Parameter))) + 
  stat_slab(aes(xdist = .dist_obj, y = Parameter), data = tab.pr, fill = NA, lwd = 0.5, color = "black", lty = 5, inherit.aes = F) +  
  scale_y_discrete(limits = rev(names(posttab.raw)), rev(names(posttab.raw))) + scale_thickness_shared() + theme(axis.title = element_blank()) +
  coord_cartesian(xlim = 8 * c(-1,1)) + scale_x_continuous(name = NA, breaks = seq(-8,8,2))

As you can see, grouping factor G1’s single correlation parameter is portrayed as having the same prior density as those of G2, even though G2’s prior correlation matrix has dimension 4 as opposed to only 2, and both share the same eta value of 6.

The problem appears not to be caused by a misparse ― as can easily be verified by running the code step by step, the .dist_obj vector (as well as the .args and .dist ones) does show the correct argument values, reflecting the difference in matrix dimension between G1 and G2. Yet somehow stat_slab plots the exact same prior curve for G1 and G2. Is it a software bug or am I missing something?

I don’t actually think anything is necessarily going wrong here. I think your code and ggdist (after the parsing fix) are both doing the right thing, but the two priors are close enough that your plot can’t show the difference. I ran your script on your data (with fewer iterations, since the priors don’t depend on the posterior) and checked the objects:

              Parameter                   dist dens_at_0    sd
1 cor_G1__Intercept__x1 lkjcorr_marginal(2, 6)     1.354 0.277
2 cor_G2__Intercept__x1 lkjcorr_marginal(4, 6)     1.466 0.258
...                     (all six G2 rows identical to row 2)

These match the marginal distributions if you do it analytically, which I think for each correlation is Beta(eta − 1 + K/2, eta − 1 + K/2) but rescaled to (−1, 1) instead of (0, 1). So that’s Beta(6, 6) for G1 and Beta(7, 7) for G2. When you go from K = 2 to K = 4 it only adds 1 to the shape parameters, so with eta = 6 the change is very small. I had to zoom in to see the difference:

In the plot you shared I think two things are making this harder to see. The x-axis goes from -8 to 8, so a 0.02 difference in SD is less than a pixel. And scale_thickness_shared() scales the priors against the posteriors, so each prior curve is only a few pixels tall.

For the helper function, it may be helpful to give the correlations their own panel with xlim = c(-1, 1). If you want to see the dependence on K more clearly, I would try with eta = 1. Then K = 2 gives a uniform prior and K = 4 is Beta(2, 2).

Hot damn sir, you’ve resolved everything! There’s not even a hurry with that ggdist fix anymore, given that your/our script works around the issue just fine.