Boundaries per coefficient in brms

I’m trying to set individual boundaries for each coefficient in the prior when fitting a linear model in brms.

Here’s the code to simulate a dataset with 3 groups, where each group comes from a truncated normal distribution where the truncation boundary, the mean, and the standard deviation differ per group.

library(truncnorm)
library(dplyr)
library(brms)
library(ggplot2)


LODs = c(2, 3, 4)

n_per_grp   <- 50
groups      <- paste0("0", 1:3)
N           <- n_per_grp * length(groups)

# Example group‑specific means and SDs for the truncated normal
mu     <- 2:4
sigma  <- c(0.5, 1, 2)



set.seed(2024)
dat_sim <- lapply(seq_along(groups), function(i) {
  
  out = data.frame(
    group =  groups[i],
    y0 = rnorm(n = n_per_grp, mean   = mu[i], sd   = sigma[i])
  ) %>% 
    mutate(censored = ifelse(y0 < LODs[i], "left", "none"),
           y = ifelse(y0 < LODs[i], LODs[i], y0))
  
  return(out)
}) %>% 
  bind_rows() 

dat_sim %>% 
  ggplot(aes(x = group, y = y)) +
  geom_jitter(height = 0, width = 0.1) +
  theme_bw()

I’m trying to fit the following model


mod_formula = bf(
  y ~ 0 + group,
  sigma ~  0 + group
)


prior_mod_1 = c(

  prior(normal(2, 2), class = "b", coef = "group01", lb = 1),
  prior(normal(3, 2), class = "b", coef = "group02", lb = 2),
  prior(normal(4, 2), class = "b", coef = "group03", lb = 3),

  prior(normal(0, 3), dpar = "sigma")
)



mod_1 = brm(
  formula = mod_formula,
  data = dat_sim,
  prior = prior_mod_1,
  iter = 5000,
  warmup = 3000,
  chains = 6,
  cores = 6,
  seed = 2024, 
  backend = "cmdstanr"
)

But I get the following error:

Error: Prior argument 'coef' may not be specified when using boundaries.

I know that the fact that we cannot specify a specific boundary per group is well known within the brms team. It is mentioned (at least) here, here and here.

In some of those forums, it is mentioned that the non-linear syntax can be used to circumvent this restriction. However, I think that only works if I have two different boundaries. Is there a way to use the non-linear syntax to set the boundaries in the prior using the example I show with 3 groups? If it is not through the non-linear syntax, is there another way to do it?

Thanks!

R version 4.4.3 (2025-02-28 ucrt)
Platform: x86_64-w64-mingw32/x64
Running under: Windows 11 x64 (build 22631)

Matrix products: default


locale:
[1] LC_COLLATE=English_United States.utf8  LC_CTYPE=English_United States.utf8    LC_MONETARY=English_United States.utf8
[4] LC_NUMERIC=C                           LC_TIME=English_United States.utf8    

time zone: Europe/Berlin
tzcode source: internal

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
[1] rstan_2.32.7        StanHeaders_2.32.10 brms_2.22.0         Rcpp_1.0.14         ggplot2_3.5.2      
[6] dplyr_1.1.4         truncnorm_1.0-9    

loaded via a namespace (and not attached):
 [1] gtable_0.3.6         tensorA_0.36.2.1     QuickJSR_1.7.0       xfun_0.51            processx_3.8.6      
 [6] inline_0.3.21        lattice_0.22-6       vctrs_0.6.5          tools_4.4.3          ps_1.9.1            
[11] generics_0.1.3       stats4_4.4.3         parallel_4.4.3       sandwich_3.1-1       tibble_3.2.1        
[16] cmdstanr_0.9.0       pkgconfig_2.0.3      Matrix_1.7-2         data.table_1.17.0    checkmate_2.3.2     
[21] RColorBrewer_1.1-3   distributional_0.5.0 RcppParallel_5.1.10  lifecycle_1.0.4      compiler_4.4.3      
[26] farver_2.1.2         stringr_1.5.1        Brobdingnag_1.2-9    codetools_0.2-20     htmltools_0.5.8.1   
[31] bayesplot_1.11.1     yaml_2.3.10          pillar_1.10.2        MASS_7.3-64          bridgesampling_1.1-2
[36] abind_1.4-8          multcomp_1.4-28      nlme_3.1-167         posterior_1.6.1      tidyselect_1.2.1    
[41] digest_0.6.37        mvtnorm_1.3-3        stringi_1.8.4        reshape2_1.4.4       labeling_0.4.3      
[46] splines_4.4.3        fastmap_1.2.0        grid_4.4.3           cli_3.6.4            magrittr_2.0.3      
[51] loo_2.8.0.9000       pkgbuild_1.4.7       survival_3.8-3       TH.data_1.1-3        withr_3.0.2         
[56] scales_1.4.0         backports_1.5.0      estimability_1.5.1   rmarkdown_2.29       matrixStats_1.5.0   
[61] emmeans_1.11.0       gridExtra_2.3        zoo_1.8-14           coda_0.19-4.1        evaluate_1.0.3      
[66] knitr_1.50           rstantools_2.4.0     rlang_1.1.5          xtable_1.8-4         glue_1.8.0          
[71] pkgload_1.4.0        rstudioapi_0.17.1    jsonlite_1.9.1       plyr_1.8.9           R6_2.6.1   

I’ve run into these sorts of challenges with brms too. Usually I have to figure out some alternative way to specify more-or-less the same model. But I wasn’t quickly finding a way here as that group factor is trickier for brms syntax than I thought!

I thought maybe you could code your group factor column as 3 dummy variables (0/1) but realised that leaves you with the same issue.

I thought of a potential workaround, but it’s not that great in my opinion. If you know your lower boundaries per group level beforehand, then you could model an offset y variable which is shifted according to the group boundary.

Below code shows the example and runs with no error:

re-jigging the y
# if you know certainly the lower boundary for a specific group, maybe
# offset the y accordingly so they all have the same lower boundary?

dat_sim_v2 <- dat_sim %>% 
  mutate(
    offset = case_when(
      group == '01' ~ 0,
      group == '02' ~ -1,
      group == '03' ~ -2
    ),
    y_off = y + offset
  )

dat_sim_v2 %>% 
  ggplot(aes(x = group, y = y_off)) +
  geom_jitter(height = 0, width = 0.1) +
  theme_bw()

## attempt model

mod_formula_v2 = bf(
  y_off ~ 0 + group,
  sigma ~  0 + group
)

get_prior(mod_formula_v2, dat_sim_v2)

prior_mod_1_v2 = c(
  
  prior(normal(2, 2), class = "b", lb = 1), # just need this one now
  prior(normal(0, 3), dpar = "sigma")
)

mod_1_v2 = brm(
  formula = mod_formula_v2,
  data = dat_sim_v2,
  prior = prior_mod_1_v2,
  iter = 5000,
  warmup = 3000,
  chains = 6,
  cores = 6,
  seed = 2024, 
  backend = "cmdstanr"
)
summary(mod_1_v2)

# fits easily

The obvious downside is that it creates more bookkeeping!

I’m curious to hear if others know a better, more general solution (in addition to praying that the brms developers get the time to implement this feature).