Binomial-normal (meta-analysis) model in brms

Hi, I want to fit a Binomial-Normal (meta-analysis) model in brms. As further described below, this model has been applied in {lme4} and {MetaStan} R packages. Although all three packages are supposed to fit the same model, MetaStan doesn’t generate an Intercept and I would like to replicate this parameterization in brms.

Background

There is an article that describes 6 different Binomial-Normal models. More specifically, I want to fit their “Model 4”, which is an extension of their “Model 2”. The latter is described as:

while “Model 4” is described as:

lme4 model

Fortunately, to facilitate our understanding, the authors share a code on how to fit “Model 4” with the R package {lme4}:

lme4::glmer(
  cbind(responders,sampleSize-responders)~  
  1 + factor(study) + factor(treat) + (treat12-1|study),
  data=data,
  family=binomial(link="logit")
 )

where data would be a data frame with this structure:

MetaStan model

Further, I found another article fitting the same “Model 4” above (as the authors explicitly say in the article), but with Stan through the MetaStan package. In this 2019 article, they share the following Stan code:

The authors implemented the non-centered parameterization, as described below:

" In the presence of sparse data such as in the meta-analysis of few studies involving rare events, Betancourt et al44 showed that centered parametrization of a hierarchical model (such as the BNHM) brings computational issues compared with a noncentered parametrization. Thus, we use the noncentered reparametrized version of the BNHM for our implementations. Specifically, applying both location and scale reparametrization, 1 becomes μi + θ xij + ui τxij where ui ~ N(0, 1) and xij=+0.5 (experimental) or xij=−0.5 (control)."

After the publication of this article in 2019, the authors recently put their MetaStan package in CRAN and published an article last week specifically on this package and its multiple new models. Now, their Stan model is a little bit different (since they also support different models other than “Model 4” above). Nevertheless, it should still fit “Model 4” as the binomial option in the code below, as explicitly referenced in their package vignette:

/*
   Author: Burak Kuersad Guenhan
   A standard Meta-analysis model
   Dataset need is one-row-per-arm format
*/


data {
  // num of observations
  int<lower=1> Nobs;

  // treatment arm indicator (0=control, 1=experimental)
  vector[Nobs] t;

  // Study number for each observation
  int<lower=1> st[Nobs];

  // link function (1=normal, 2=binary, 3=poisson)
  int<lower=1,upper=3> link;

  // normal data, link=identity=1
  vector[Nobs] y;
  vector[Nobs] y_se;

  // binomial data, link=logit=2
  int<lower=0>  r[Nobs];
  int<lower=1>  n[Nobs];


  // count data, link=log=3
  int<lower=0> count[Nobs];
  vector[Nobs]    exposure;

  // Priors
  vector[2] mu_prior;                         // Prior parameters for mu
  vector[2] theta_prior;                      // Prior parameters for theta
  real tau_prior;                             // Prior parameter for tau
  int tau_prior_dist;                        // Indicator for distribution of prior for tau
  vector[2] beta_prior;                         // Prior parameters for beta

  // Fixed-effects or Random-effects model: (0: fe, 1: re)
  int<lower=0,upper=1> re;

  // non-centered parametrization: (0: NO, 1: YES)
  int<lower=0,upper=1> ncp;

  // met-regression: (0: NO, 1: YES)
  int<lower=0,upper=1> mreg;

  // number of covariates
  int<lower=0> ncov;

  // trial-level covariate information
  matrix[ncov,max(st)] cov;

}

parameters {
  vector[Nobs] mu;                             // baseline risks (log odds)
  real theta;                               // relative treatment effect (log odds ratio)
  vector[Nobs] u[re];                          // individual treatment effects
  real<lower=0> tau[re];                        // heterogeneity stdev.
  vector[ncov] beta[mreg];                      // beta coeffients in meta-regression
}

transformed parameters {
  vector[Nobs] d;
  vector[max(st)] temp;
  vector[max(st)] beta_cov;

  if(mreg) {
    for(i in 1:Nobs)
      temp[st[i]] = 0.5 * beta[1,1] * cov[1,st[i]];

    if(ncov == 1) {
      for(i in 1:Nobs)
      beta_cov[st[i]] = temp[st[i]];

    } else {

      for(j in 1:ncov) {
        for(i in 1:Nobs) {
          beta_cov[st[i]] = temp[st[i]] + 0.5 * beta[1,j] * cov[j,st[i]];
          temp[st[i]] = beta_cov[st[i]];
        }
      }
    }
  } else {
    for(i in 1:Nobs) {
     beta_cov[st[i]] = 0;
     temp[st[i]] = 0;
    }
    };



  if(re) {

  if (ncp) {
    for(i in 1:Nobs) {
      if (t[i] == 0) {
        d[i] =  mu[st[i]]  - 0.5 * theta - 0.5 * u[1,st[i]] * tau[1] - beta_cov[st[i]];
       } else {
        d[i] = mu[st[i]] + 0.5 * theta + 0.5 * u[1,st[i]] * tau[1] + beta_cov[st[i]];
       }
    }

   } else {
      for(i in 1:Nobs) {
        if (t[i] == 0) {
          d[i] = mu[st[i]] - 0.5 * u[1,st[i]] - beta_cov[st[i]];
        }   else {
          d[i] = mu[st[i]] + 0.5 * u[1,st[i]] + beta_cov[st[i]];
        }
      }
  }
} else {
        for(i in 1:Nobs) {
          if (t[i] == 0) {
            d[i] = mu[st[i]] - 0.5 * theta - beta_cov[st[i]];
          }   else {
            d[i] = mu[st[i]] + 0.5 * theta + beta_cov[st[i]];
        }
      }
}

}

model {

    if(re) {
      if (ncp) {
      // latent variable (random effects)
      u[1] ~ normal(0, 1);
      } else {
      // random effect distribution
      u[1] ~ normal(d, tau[1]);
      }
    }
  // prior distributions
  mu ~ normal(mu_prior[1], mu_prior[2]);
  theta ~ normal(theta_prior[1], theta_prior[2]);

  if(re) {
    // prior for tau
    if(tau_prior_dist == 1)  tau[1] ~ normal(0, tau_prior)T[0,];
    if(tau_prior_dist == 2)  tau[1] ~ uniform(0, tau_prior);
    if(tau_prior_dist == 3)  tau[1] ~ cauchy(0, tau_prior)T[0,];
  }

  if(mreg)  beta[1] ~ normal(beta_prior[1], beta_prior[2]);

  // likelihood
  if(link == 1) y ~ normal(d, y_se);
  if(link == 2) r ~ binomial_logit(n, d);
  if(link == 3) count ~ poisson_log(exposure + d);

}

generated quantities {
  vector[Nobs] log_lik;                // pointwise log-likelihood contribution
  real theta_pred[re];                 // predicted log-odds ratio for the new study


  for (s in 1:Nobs) {
    if(link == 1)  log_lik[s] = normal_lpdf(y[s] | d[s], y_se[s]);
    if(link == 2)  log_lik[s] = binomial_logit_lpmf(r[s] | n[s], d[s]);
    if(link == 3)  log_lik[s] = poisson_log_lpmf(count[s] | exposure[s] + d[s]);
  }

  // Prediction for new study
   if(re) { theta_pred[1] = normal_rng(theta, tau[1]);}

}

brms model

As shown in the code below, I am able to replicate the model fitted with lme4 in brms. However, the model is different from the MetaStan model and I don’t know why.

Here is the code to load packages, data, and fit lme4/brms models:

# Ensures the package "pacman" is installed
if (!require("pacman")) install.packages("pacman")

### Install/Load packages
pacman::p_load(dplyr,
               lme4,
               brms,
               MetaStan)

pacman::p_load_gh("stan/cmdstanr")


### Data ----
data("dat.Crins2014", package = "MetaStan")
# Remove NAs
data = subset(dat.Crins2014, is.finite(exp.PTLD.events))

# Adapt dataset to MetaStan's format
dat_MetaStan = MetaStan::create_MetaStan_dat(
  dat = data,
  armVars = c(responders = "r",
              sampleSize = "n"))
dat_MetaStan

# Adapt dataset to lme4 and brms's format
data_jackson = 
  dat_MetaStan$data_long |> 
  dplyr::mutate(treat = rep(c(0, 1), 3),
                treat12 = rep(c(-0.5, 0.5), 3))
data_jackson

### Fit models

## Model 4 in Jackson et al 2018 ----
# https://onlinelibrary.wiley.com/doi/10.1002/sim.7588

jackson_model4 = lme4::glmer(
  cbind(responders,sampleSize-responders)~  
    1 + factor(study) + factor(treat) + (treat12-1|study),
  data=data_jackson,
  family=binomial(link="logit")
  )

fixef(jackson_model4)

# factor(treat)1 
# -1.7149217

## brms model ----

# Formula
mf = 
  responders | trials(sampleSize) ~ 
  1 + factor(study) + factor(treat) + (treat12 - 1 | study)

brms::get_prior(mf, family = binomial, data = data_jackson)

brms_model = brms::brm(
  formula = mf,
  family = binomial,
  prior =
    prior(normal(0, 100), class = Intercept) +
    prior(normal(0, 100), class = b, coef = "factortreat1") +
    prior(normal(0, 0.5), class = sd),
  data = data_jackson,
  backend = "cmdstanr"
)

brms_model

fixef(brms_model)
#               Estimate        Q2.5          Q97.5
# factortreat1 -1.7715522   -2.6682835    -0.903934675

As expected (given the similarities between their model syntax), both models yield the same parameters, including an Intercept:

Discrepancies with MetaStan

Now, here is the code to fit MetaStan’s model:

## MetaStan ----
# 3 sources about the same model

# 01: "Note that this model corresponds to Model 4 in Jackson et al (2018)."
# https://cran.r-project.org/web/packages/MetaStan/vignettes/MetaStan_BNHM.html

# 02: "2.2. Pairwise meta-analysis"
# https://arxiv.org/pdf/2202.00502.pdf  

# 03: Corresponding Stan code

# Non-centered parametrization (ncp = TRUE);
# Random-effects (re = TRUE),
# Binomial
# No meta-regression 

# https://github.com/gunhanb/MetaStan/blob/master/inst/stan/SMA.stan

MetaStan_model = MetaStan::meta_stan(data = dat_MetaStan,
                                     likelihood = "binomial",
                                     mu_prior = c(0, 10),
                                     theta_prior = c(0, 100),
                                     tau_prior = 0.5,
                                     tau_prior_dist = "half-normal")

MetaStan_model
# Treatment effect (theta) estimates
#  mean  2.5%   50% 97.5% 
# -1.78 -2.66 -1.76 -0.97 

Although fitting the corresponding model with MetaStan yields very similar results (“Treatment effect (theta) estimates”), there is no intercept:

Summary

Can anyone give me some help on how to fit MetaStan’s model in brms?

Thanks!

Here is the full code which I had broken down in separate pieces above:

# Ensures the package "pacman" is installed
if (!require("pacman")) install.packages("pacman")

### Install/Load packages
pacman::p_load(dplyr,
               lme4,
               brms,
               MetaStan)

pacman::p_load_gh("stan/cmdstanr")


### Data ----
data("dat.Crins2014", package = "MetaStan")
# Remove NAs
data = subset(dat.Crins2014, is.finite(exp.PTLD.events))

# Adapt dataset to MetaStan's format
dat_MetaStan = MetaStan::create_MetaStan_dat(
  dat = data,
  armVars = c(responders = "r",
              sampleSize = "n"))
dat_MetaStan

# Adapt dataset to lme4 and brms's format
data_jackson = 
  dat_MetaStan$data_long |> 
  dplyr::mutate(treat = rep(c(0, 1), 3),
                treat12 = rep(c(-0.5, 0.5), 3))
data_jackson

### Fit models

## Model 4 in Jackson et al 2018 ----
# https://onlinelibrary.wiley.com/doi/10.1002/sim.7588

jackson_model4 = lme4::glmer(
  cbind(responders,sampleSize-responders)~  
    1 + factor(study) + factor(treat) + (treat12-1|study),
  data=data_jackson,
  family=binomial(link="logit")
  )

fixef(jackson_model4)

# factor(treat)1 
# -1.7149217

## brms model ----

# Formula
mf = 
  responders | trials(sampleSize) ~ 
  1 + factor(study) + factor(treat) + (treat12 - 1 | study)

brms::get_prior(mf, family = binomial, data = data_jackson)

brms_model = brms::brm(
  formula = mf,
  family = binomial,
  prior =
    prior(normal(0, 100), class = Intercept) +
    prior(normal(0, 100), class = b, coef = "factortreat1") +
    prior(normal(0, 0.5), class = sd),
  data = data_jackson,
  backend = "cmdstanr"
)

brms_model

fixef(brms_model)
#               Estimate        Q2.5          Q97.5
# factortreat1 -1.7715522   -2.6682835    -0.903934675

## MetaStan ----
# 3 sources about the same model

# 01: "Note that this model corresponds to Model 4 in Jackson et al (2018)."
# https://cran.r-project.org/web/packages/MetaStan/vignettes/MetaStan_BNHM.html

# 02: "2.2. Pairwise meta-analysis"
# https://arxiv.org/pdf/2202.00502.pdf  

# 03: Corresponding Stan code

# Non-centered parametrization (ncp = TRUE);
# Random-effects (re = TRUE),
# Binomial
# No meta-regression 

# https://github.com/gunhanb/MetaStan/blob/master/inst/stan/SMA.stan

MetaStan_model = MetaStan::meta_stan(data = dat_MetaStan,
                                     likelihood = "binomial",
                                     mu_prior = c(0, 10),
                                     theta_prior = c(0, 100),
                                     tau_prior = 0.5,
                                     tau_prior_dist = "half-normal")

MetaStan_model
# Treatment effect (theta) estimates
#  mean  2.5%   50% 97.5% 
# -1.78 -2.66 -1.76 -0.97 

MetaStan_model$fit

## MetaStan vs. brms

# 01: 
# MetaStan does not have an "Intercept". In fact, it models
# 1 baseline odds per study k ("mu[k]")

MetaStan_model$fit

# Here we have an intercept (which is the baseline odds of study 1 )
# Then each "factostudy..." corresponds the deviation from this intercept
brms_model 

I tried to solve this problem with the following code. Although I am able to isolate each study log odds (“factor(study…)”), there is still an intercept (which I have no idea what it means).

# One can remove the baseline odds Intercept with the following formula:

mf2 = 
  bf(responders | trials(sampleSize) ~ a + b,
     a ~ 0 + factor(study), 
     b ~ 0 + Intercept + factor(treat) + (treat12 - 1 | study),
     nl = TRUE)

# However, it is still necessary to put an intercept regarding treatment effect
# "factor(treat)", which is "theta" in MetaStan model

brms::get_prior(mf2, family = binomial, data = data_jackson)

brms_model2 = brms::brm(
  formula = mf2,
  family = binomial,
  prior =
    # baseline log odds, "mu" factor(study)
    prior(normal(0, 10), nlpar = a) + 
    # treatment effect, log odds ratio, "theta" factor(treat)
    prior(normal(0, 100), class = b, coef = "factortreat1", nlpar = b) + 
    # between-study SD, "tau"
    prior(normal(0, 0.5), class = sd, nlpar = b), 
  data = data_jackson,
  backend = "cmdstanr"
)

# The treatment effect is very similar to MetaStan and brms1 models above
# However, there is still an intercept, which I am not sure what it means

fixef(brms_model2)
#                   Estimate    Q2.5     Q97.5
# b_Intercept     0.10830134  -11.36471 11.5292425
# b_factortreat1 -1.75723692  -2.58776 -0.8908411

Let me further clarify why I want to remove the overall intercept parameter. Although the lme4::glmer() formula above includes an intercept, the article from which both model description and lme4 code come does not include an intercept when describing the model:

image

Accordingly, MetaStan syntax does not include an intercept.

I tried to fit “Model 4” with both {rethinking} and with another syntax with {brms}. The treatment effect, between-study SD, and random-effects results are very similar. However, the study-specific log-odds are different. I can’t understand why. Further, only treatment-effect and between-study SD results from MetaStan match the ones from brms and rethinking. Can’t understand why either.

Here is a table I made to summarize the correspondence between packages, model parameters, and output. Please note it is solely based on my understanding, so it could be completely wrong.

Full code:

# Ensures the package "pacman" is installed
if (!require("pacman")) install.packages("pacman")

### Install/Load packages
pacman::p_load(dplyr,
               MetaStan,
               brms)

pacman::p_load_gh("stan/cmdstanr",
                  "rmcelreath/rethinking")

### Toy Data ----
data("dat.Crins2014", package = "MetaStan")
# Remove NAs
data = subset(dat.Crins2014, is.finite(exp.PTLD.events))

# Adapt dataset to MetaStan's format
dat_MetaStan = MetaStan::create_MetaStan_dat(
  dat = data,
  armVars = c(responders = "r",
              sampleSize = "n"))

# Adapt dataset to brms's format
brms_data = 
  dat_MetaStan$data_long |> 
  dplyr::mutate(study = factor(study),
                treat = factor(rep(c(0, 1), 3)),
                treat12 = rep(c(-0.5, 0.5), 3))

# Adapt dataset to rethinking's format
dat_rethinking = list(
  study = brms_data$study,
  events = brms_data$responders,
  total = brms_data$sampleSize,
  treat = brms_data$treat,
  treat12 = brms_data$treat12
)

# Models ---- 

## MetaStan ----

MetaStan_model = MetaStan::meta_stan(data = dat_MetaStan,
                                     likelihood = "binomial",
                                     mu_prior = c(0, 10),
                                     theta_prior = c(0, 2.82),
                                     tau_prior = 0.5,
                                     tau_prior_dist = "half-normal",
                                     seed = 123)


## Rethinking ----

# Great post explaining the non-centered parameterization applied below
# https://elevanth.org/blog/2017/09/07/metamorphosis-multilevel-model/

rethinking_model = rethinking::ulam(
  alist(
    # Likelihood
    events ~ dbinom(total, risk),
    
    logit(risk) <- mu[study] + theta*treat + u[study]*tau*treat12,
    # latent variable (random-effects)
    u[study] ~ dnorm(0, 1),
    
    # Priors
    mu[study] ~ dnorm(0, 10),
    theta ~ dnorm(0, 2.82),
    tau ~ half_normal(0, 0.5)
  ),
  data=dat_rethinking,
  chains=4, cores=4, seed = 123)

rethinking::stancode(rethinking_model)

## brms ----

# Formula
mf = 
  responders | trials(sampleSize) ~ 
  0 + study + treat + (treat12 - 1 | study)

brms::get_prior(mf, family = binomial, data = brms_data)

brms_model = brms::brm(
  formula = mf,
  family = binomial,
  prior =
    prior(normal(0, 10), class = b) +
    prior(normal(0, 2.82), class = b, coef = "treat1") +
    prior(normal(0, 0.5), class = sd),
  data = brms_data,
  backend = "cmdstanr",
  seed = 123
)

# Compare model parameters ----
MetaStan_model$fit
rethinking::precis(rethinking_model, depth = 2, prob=0.95)
brms::posterior_summary(brms_model)

edit: now including brms with further clarifications

They are different parametrizations of the same model. They give you slightly different outputs, but they are equivalent. Because brms uses a design-matrix approach vs. MetaStan’s index variable approach, you won’t get exactly the same outputs from each. But you can turn the outputs of one into the other with some algebra and a change of variable.

Because of the small number of studies, MetaStan and brms will give slightly different answers for what’s below, but it’s within simulation error given the small number of studies.

First off, in brms we’re going to switch factor(treat) with treat12 so that we align with the MetaStan model. This will give the same treatment effect, and has the advantage below of making the equivalence clearer. I’m also going to drop the intercept and instead use all three levels from the study variable.

# drop intercept and put in treat12
mf3 = 
  bf(responders | trials(sampleSize) ~ 0 + factor(study) + treat12 + (treat12 - 1 | study))

brms_model3 = brms::brm(
  formula = mf3,
  family = binomial,
  prior =
    # baseline log odds, "mu" factor(study)
    prior(normal(0, 1.5), class = b) + 
    # treatment effect, log odds ratio, "theta" factor(treat)
    prior(normal(0, 1.5), class = b, coef = "treat12") + 
    # between-study SD, "tau"
    prior(normal(0, 0.5), class = sd), 
  data = data_jackson,
  backend = "cmdstanr"
)

brms_model3

If you look at the summary of brms_model 3, you should see the three studies (factorstudy1, factorstudy2, factorstudy3). These studies will align with mu[1], mu[2], and mu[3] in the MetaStan output. You can ignore the mu[4], mu[5], and mu[6] because there are only 3 studies in the dataset.

You’ll also see the treatment effect, treat12 here. It should be around 1.7, similar to theta in MetaStan.

Moving along to the d[#] outputs, we can use our brms coefficients to build them up. See the code below. We can copy the MetaStan equation but use our brms coefficients.


# get coefficients
res <- data.frame(ranef(brms_model3))
fes <- data.frame(fixef(brms_model3))

# How brms compares with the values I got from MetaStan
# equivalent to d[1] = 0.42
d1 <- fes[1, 1] - fes[4, 1] *.5 - res[1, 1]

# equivalent to d[2] = -1.32
d2 <- fes[1, 1] + fes[4, 1] *.5 + res[1, 1]

# equivalent to d[3] = 0.12
d3 <- fes[2, 1] - fes[4, 1] *.5 - res[2, 1]

# equivalent to d[4] = -1.55
d4 <- fes[2, 1] + fes[4, 1] *.5 + res[2, 1]

# equivalent to d[5] = -0.71
d5 <- fes[3, 1] - fes[4, 1] *.5 - res[3, 1]

# equivalent to d[6] = -2.26
d6 <- fes[3, 1] + fes[4, 1] *.5 + res[3, 1]

I hope that helps.

EDIT: I forgot to multiply the res by 0.5 as well. So it should be d1 <- fes[1, 1] - fes[4, 1] *.5 - res[1, 1] * .5 and so on.

2 Likes

This is perfect. Thank you very much, Stephen.

One quick question, do you know what these parameters mean? I could not figure it out by myself.

If I understood the code right, MetaStan builds a container that can holder 1 value per row. In this case, it would build a contained to hold 6 values even though there are 3 studies. So mu 1 - 3 should hold the values for three studies in this case, while mu 4-6 should be empty, or 0.

1 Like