Multilevel SEM with ordinal data at each level and latent moderated predictors

I am interested in building multilevel structural equation model using international large-scale student survey data. The model is hierarchical, with response items from students, teachers and school admins, used to predict student achievement. Furthermore, the schools are nested within countries and different years. The response items on student, teacher and school level is mainly ordinal (Likert-scale), with a few categorical background variables and continuous achievement variables at student level. From this data I want to build a multivariate
multilevel model
with

  • latent predictors, and covariates, with ordinal indicators at each level,
  • within-level and cross-level latent moderation/interaction,
  • dependent latent variables with continuous indicators, and
  • ideally, a way to account for missing data.

I started out using OpenMx due to its flexibility with multilevel models and integration in R (also Linux support, and being freely available open-source software). However, due to limitations in the software, such as lack of support ordinal data and WLS in multilevel models, I’ve been advised that this may only be achieved using Bayesian methods.

However, I am not very familiar with Bayesian statistics or the software used. I looked into OpenBUGS and Stan, which both can be used with R in Linux. However, the syntax is unfamiliar to me, and I can find little information on mulitlevel SEM in Stan (this is the only example I’ve come across).

I could really use some help/advise on how to (if at all possible) fit multilevel SEMs in Stan, with the aformentioned requirements. Are there any articles with descriptive methods, documentation, tutorials, or examples on how to build a model fitting the description above?

Below I’ve included an example model in OpenMx, I was hoping somebody could help me translate:

 MPlus: Three-level growth model with a continuous outcome and one covariate on each of the three levels
# https://www.statmodel.com/usersguide/chapter9.shtml
Sys.setenv(OMP_NUM_THREADS=parallel::detectCores())
library(OpenMx)
library(dplyr)
library(magrittr)
#mxOption(NULL, "Number of Threads", 8L)
options(width=120)

ex923 <- suppressWarnings(try(read.table("ex9.23.dat")))
#if (is(ex923, "try-error")) ex923 <- read.table("ex9.23.dat")
colnames(ex923) <- c(paste0('y',1:4), 'x', 'w', 'z', 'level2', 'level3')
ex923$level2 <- as.integer(ex923$level2)
ex923$level3 <- as.integer(ex923$level3)

level3Model <- mxModel(
    'level3Model', type='RAM',
    latentVars=c(paste0('y',1:4), 'ib3', 'sb3', 'z'),
    productVars=c("ib3×sb3"),
    mxPath(from=c("ib3","sb3"), to="ib3×sb3", arrows=1, free=FALSE, values=1),
    mxPath(from="ib3×sb3", to=paste0('y',1:4), arrows=1, free=TRUE, values=.5), #, labels="b1"),
    mxPath('ib3', paste0('y',1:4), free=FALSE, values=1),
    mxPath('sb3', paste0('y',1:4), free=FALSE, values=0:3),
    mxPath(c('ib3','sb3'), arrows=2, connect="unique.pairs", values=c(1,0,1)),
    mxPath('one', 'z', free=FALSE, labels="data.z"),
    mxPath('z', c('ib3','sb3')),
    mxPath('one', c('ib3','sb3')),
    mxData(ex923[!duplicated(ex923$level3),], 'raw', primaryKey='level3')
)

level2Model <- mxModel(
    'level2Model', type='RAM', level3Model,
    latentVars=c(paste0('y',1:4), 'ib2', 'sb2', 'w'),
    productVars=c("ib2×sb2"),
    mxPath(from=c("ib2","sb2"), to="ib2×sb2", arrows=1, free=FALSE, values=1),
    mxPath(from="ib2×sb2", to=paste0('y',1:4), arrows=1, free=TRUE, values=.5), #, labels="b1"),
    mxPath('ib2', paste0('y',1:4), free=FALSE, values=1),
    mxPath('sb2', paste0('y',1:4), free=FALSE, values=0:3),
    mxPath(c('ib2','sb2'), arrows=2, connect="unique.pairs", values=c(1,0,1)),
    mxPath('one', 'w', free=FALSE, labels="data.w"),
    mxPath('w', c('ib2','sb2')),
    mxPath(paste0('y',1:4), arrows=2),
    mxPath(paste0('level3Model.y', 1:4), paste0('y',1:4), free=FALSE, values=1,
	   joinKey="level3"),
    mxData(ex923[!duplicated(ex923$level2),], 'raw', primaryKey='level2')
)

withinModel <- mxModel(
    'withinModel', type='RAM', level2Model,
    manifestVars=c(paste0('y',1:4)),
    latentVars=c('iw','sw', 'x'),
    productVars=c("iw×sw"),
    mxPath(from=c("iw","sw"), to="iw×sw", arrows=1, free=FALSE, values=1),
    mxPath(from="iw×sw", to=paste0('y',1:4), arrows=1, free=TRUE, values=.5), #, labels="b1"),
    mxPath('iw', paste0('y',1:4), free=FALSE, values=1),
    mxPath('sw', paste0('y',1:4), free=FALSE, values=0:3),
    mxPath(paste0('y',1:4), arrows=2),
    mxPath(c('iw','sw'), arrows=2, connect="unique.pairs", values=c(1,0,1)),
    mxPath('one', 'x', free=FALSE, labels="data.x"),
    mxPath('x', c('iw','sw')),
    mxPath(paste0('level2Model.y', 1:4), paste0('y',1:4), free=FALSE, values=1,
	   joinKey="level2"),
    mxData(ex923, 'raw'),
    mxFitFunctionWLS() 
)

withinModel <- mxRun(withinModel)
summary(withinModel)

Data file: ex9.23.dat

I’m not familiar with OpenMX code. Any chance you can write the model equations or share a path diagram?

I realised the model I posted has some problems, so I rewrote it to something that will work. Unfortunately, I am unable to edit my original post, so I added the updated OpenMx code at the bottom. I’ve tried translating the model to lavaan pseudo-code, since it offers an easier to read and more mathematically oriented syntax. ( The lavaan code won’t work, however, as lavaan does not support more than two levels, nor latent interaction effects.) If this doesn’t help, I’ll try come up with a path diagram.

lavaan pseudo-code:

model <- '
    level: 1 # withinModel
        # measurement model (latent variables predicted by their manifest indicators)
        iw =~ y1 + y2
        sw =~ y3 + y4
        # Regressions
        x ~ iw + sw + iw*sw
        # residual manifest variances (fixed to 1)
        y1 ~~ 1*y1
        y2 ~~ 1*y2
        y3 ~~ 1*y3
        y4 ~~ 1*y4
        # residual manifest covariances
        y1 ~~ y2
        y3 ~~ y4
   
        # latent variance & covariance
        iw ~~ iw
        sw ~~ sw
        iw ~~ sw

    level: 2
        # measurement model (latent variables)
        ib2 =~ y1 + y2
        sb2 =~ y3 + y4
        # Regressions
        w ~ ib2 + sb2 + ib2*sb2
        # residual manifest variances (fixed to 1)
        w ~~ 1*w
        # latent variance & covariance
        y1 ~~ y1
        y2 ~~ y2
        y3 ~~ y3
        y4 ~~ y4
        y1 ~~ y2
        y3 ~~ y4
        ib2 ~~ ib2
        sb2 ~~ sb2
        ib2 ~~ sb2

     level: 3
        # measurement model (latent variables)
        ib3 =~ y1 + y2
        sb3 =~ y3 + y4
        # Regressions
        z ~ ib2 + sb2 + ib2*sb2
        # residual manifest variances (fixed to 1)
        z ~~ 1*z
        # latent variance & covariance
        y1 ~~ y1
        y2 ~~ y2
        y3 ~~ y3
        y4 ~~ y4
        y1 ~~ y2
        y3 ~~ y4
        ib3 ~~ ib3
        sb3 ~~ sb3
        ib3 ~~ sb3
'

The modified OpenMx code


## ---------------------------------------------------------------------------------------------------------------------
Sys.setenv(OMP_NUM_THREADS=parallel::detectCores())
library(OpenMx)
library(dplyr)
library(magrittr)
#mxOption(NULL, "Number of Threads", 8L)
options(width=120)


## ---------------------------------------------------------------------------------------------------------------------
ex923 <- suppressWarnings(try(read.table("ex9.23.dat")))
colnames(ex923) <- c(paste0('y',1:4), 'x', 'w', 'z', 'level2', 'level3')

ex923 %<>% 
mutate(across( x:z, ~ { round(. - min(.) + 1) } ) )

ex923$x %<>% as.ordered()

## ---------------------------------------------------------------------------------------------------------------------
# Level 3 model
level3Model <- mxModel(
    # Model name
    'level3Model', 
    # Model type
    type = 'RAM',
    # Variables. Manifest, latent and product variables, respectively.
    manifestVars = 'z',
    latentVars=c(paste0('y',1:4), 'ib3', 'sb3'),
    productVars=c("ib3×sb3"),
    # residual manifest variances
    mxPath(
        from = 'z',
        arrows = 2,
        free = FALSE,
        values = 1
    ),
    # latent variances & covariances
    mxPath(
        from = paste0('y',3:4),
        connect = "unique.bivariate",
        arrows = 2,
        free = TRUE,
        values = 0.1,
        labels = "cov_y3_y4"
    ),
    mxPath(
        from = c('ib3','sb3'),
        connect = "unique.pairs",
        arrows = 2,
        free = TRUE,
        values = 0.4,
        labels = c("e_ib3", "cov_ib3_sb3", "e_sb3")
    ),
    # pattern coefficients
    mxPath(
        from = "ib3",
        to = paste0('y',1:2),
        arrows = 1,
        free = c(FALSE, TRUE),
        values = 1,
        lbound = 1e-6,
        labels = paste0('ib3_y',1:2)
    ),
    mxPath(
        from = "sb3",
        to = paste0('y',3:4),
        arrows = 1,
        free = c(FALSE, TRUE),
        values = 1,
        lbound = 1e-6,
        labels = paste0('sb3_y', 3:4)
    ),
    mxPath(
        from = c("ib3", "sb3"),
        to = "z",
        arrows = 1,
        free = TRUE,
        values = 1,
        lbound = 1e-6,
        labels = paste0(c("ib3", "sb3"), "_z")
    ),
    # Latent interactions
    mxPath(
        from = c("ib3","sb3"),
        to = "ib3×sb3",
        arrows = 1,
        free = FALSE,
        values = 1
    ),
    mxPath(
        from = "ib3×sb3",
        to = "z",
        arrows = 1,
        free = TRUE,
        values = .5,
        labels = "ib3×sb3_z"
    ),
    # means
    means = mxPath(
        from = "one",
        to = c(paste0('y',1:4), 'z', 'ib3', 'sb3'),
        arrows = 1,
        free = TRUE,
        values = 0,
        labels = paste0("μ_", c(paste0('y',1:4), 'z', 'ib3', 'sb3'))
    ),
    # # thresholds
    # mxThreshold( 
    #     vars = "z", 
    #     nThresh = max(as.double(ex923$z)) - 1,
    #     free = TRUE,
    #     values = mxNormalQuantiles(max(as.double(ex923$z)) - 1),
    #     labels = paste0("w_thresh_", 1:(max(as.double(ex923$z)) - 1)),
    # ),
    mxData(ex923[!duplicated(ex923$level3),], 'raw', primaryKey='level3')
)

# Level 2 model
level2Model <- mxModel(
    # Model name
    'level2Model', 
    # Model type
    type = 'RAM',
    # Higher level model to insert
    level3Model,
    # Variables. Manifest, latent and product variables, respectively.
    manifestVars = 'w',
    latentVars=c(paste0('y',1:4), 'ib2', 'sb2'),
    productVars=c("ib2×sb2"),
    # residual manifest variances
    mxPath(
        from = 'w',
        arrows = 2,
        free = FALSE,
        values = 1
    ),
    # latent variance & covariance
    mxPath(
        from = paste0('y',3:4),
        connect = "unique.bivariate",
        arrows = 2,
        free = TRUE,
        values = 0.1,
        labels = "cov_y3_y4"
    ),
    mxPath(
        from = c('ib2','sb2'),
        connect = "unique.pairs",
        arrows = 2,
        free = TRUE,
        values = 0.4,
        labels = c("e_ib2", "cov_ib2_sb2", "e_sb2")
    ),
    # pattern coefficients
    mxPath(
        from = "ib2",
        to = paste0('y',1:2),
        arrows = 1,
        free = c(FALSE, TRUE),
        values = 1,
        lbound = 1e-6,
        labels = paste0('ib2_y',1:2)
    ),
    mxPath(
        from = "sb2",
        to = paste0('y',3:4),
        arrows = 1,
        free = c(FALSE, TRUE),
        values = 1,
        lbound = 1e-6,
        labels = paste0('sb2_y', 3:4)
    ),
    mxPath(
        from = c("ib2", "sb2"),
        to = "w",
        arrows = 1,
        free = TRUE,
        values = 1,
        lbound = 1e-6,
        labels = paste0(c("ib2", "sb2"), "_w")
    ),
    # Latent interactions
    mxPath(
        from = c("ib2","sb2"),
        to = "ib2×sb2",
        arrows = 1,
        free = FALSE,
        values = 1
    ),
    mxPath(
        from = "ib2×sb2",
        to = "w",
        arrows = 1,
        free = TRUE,
        values = .5,
        labels = "ib2×sb2_w"
    ),
    # means
    means = mxPath(
        from = "one",
        to = c(paste0('y',1:4), 'w', 'ib2', 'sb2'),
        arrows = 1,
        free = TRUE,
        values = 0,
        labels = paste0("μ_", c(paste0('y',1:4), 'w', 'ib2', 'sb2'))
    ),
    # # thresholds
    # mxThreshold( 
    #     vars = "w", 
    #     nThresh = max(as.double(ex923$w)) - 1,
    #     free = TRUE,
    #     values = mxNormalQuantiles(max(as.double(ex923$w)) - 1),
    #     labels = paste0("w_thresh_", 1:(max(as.double(ex923$w)) - 1)),
    # ),
    # Cross-level
    mxPath(
        from = paste0('level3Model.y', 1:4), 
        to = paste0('y',1:4), 
        free = FALSE, 
        values = 1, 
        joinKey = "level3"
    ),
    # Data
    mxData(ex923[!duplicated(ex923$level2),], 'raw', primaryKey='level2')
)

# Level 1 model. (Full model.)
withinModel <- mxModel(
    # Model name
    'withinModel',
    # Model type
    type='RAM',
    # Higher level model to insert
    level2Model,
    # Variables. Manifest, latent and product variables, respectively.
    manifestVars=c(paste0('y',1:4), 'x'),
    latentVars=c('iw','sw'),
    productVars=c("iw×sw"),
    # residual variances
    mxPath(
        from = c(paste0('y',1:4), 'x'),
        arrows = 2,
        free = FALSE,
        values = 1
    ),
    # residual covariances
    mxPath(
        from = paste0('y',1:2),
        connect = "unique.bivariate",
        arrows = 2,
        free = TRUE,
        values = 0.1,
        labels = "cov_y1_y2"
    ),
    mxPath(
        from = paste0('y',3:4),
        connect = "unique.bivariate",
        arrows = 2,
        free = TRUE,
        values = 0.1,
        labels = "cov_y3_y4"
    ),
    # latent variance & covariance
    mxPath(
        from = c('iw','sw'),
        connect = "unique.pairs",
        arrows = 2,
        free = TRUE,
        values = 0.4,
        labels = c("e_iw", "cov_iw_sw", "e_sw")
    ),
    # pattern coefficients
    mxPath(
        from = "iw",
        to = paste0('y',1:2),
        arrows = 1,
        free = c(FALSE, TRUE),
        values = 1,
        lbound = 1e-6,
        labels = paste0('iw_y',1:2)
    ),
    mxPath(
        from = "sw",
        to = paste0('y',3:4),
        arrows = 1,
        free = c(FALSE, TRUE),
        values = 1,
        lbound = 1e-6,
        labels = paste0('sw_y', 3:4)
    ),
    mxPath(
        from = c("iw", "sw"),
        to = "x",
        arrows = 1,
        free = TRUE,
        values = 1,
        lbound = 1e-6,
        labels = paste0(c("iw", "sw"), "_x")
    ),
    # Latent interactions
    mxPath(
        from = c("iw","sw"),
        to = "iw×sw",
        arrows = 1,
        free = FALSE,
        values = 1
    ),
    mxPath(
        from = "iw×sw",
        to = "x",
        arrows = 1,
        free = TRUE,
        values = .5,
        labels = "iw×sw_x"
    ),
    # means
    means = mxPath(
        from = "one",
        to = c(paste0('y',1:4), 'x', 'iw', 'sw'),
        arrows = 1,
        free = TRUE,
        values = 0,
        labels = paste0("μ_", c(paste0('y',1:4), 'x', 'iw', 'sw'))
    ),
    # thresholds
    mxThreshold( 
        vars = "x", 
        nThresh = max(as.double(ex923$x)) - 1,
        free = TRUE,
        values = mxNormalQuantiles(max(as.double(ex923$x)) - 1),
        labels = paste0("x_thresh_", 1:(max(as.double(ex923$x)) - 1)),
    ),
    # Cross-level
    mxPath(
        from = paste0('level2Model.y', 1:4),
        to = paste0('y',1:4),
        free = FALSE,
        values = 1, joinKey = "level2"
    ),
    # Data
    mxData(ex923, 'raw'),
    # Fit function: WLS
    mxFitFunctionWLS(
        # allContinuousMethod = 'marginals'
    )    
)


## ---------------------------------------------------------------------------------------------------------------------
withinModel %>%
mxRun() %>%
# mxCheckIdentification(.)[-2] %>%
summary()

Pinging a few people who I know work with SEM @edm @Mauricio_Garnier-Villarre . Maybe they know of some relevant tutorials.

Stan is very flexible, so I would guess that it could estimate such a model. However, it might take a lot of tuning of priors/constraints to get it to converge. With all that in mind, here are some thoughts that will hopefully be helpful (my apologies if I retread basic SEM stuff that you’re already familiar with).

Learning Stan: blavaan is a Bayesian SEM package that uses lavaan syntax and estimates the model in Stan (or JAGS) while brms uses R’s formula notation for models generally (not SEMs). I doubt either of those can get everything you’re looking for, but they might get you most of the way there. But this means you’ll probably need to learn base Stan if you want everything. The user guide is a great resource to learn the language. I ran through the early sections on basic models and picked up most of what I needed to get started. Also check out this thread.

Conditional vs Marginal models: SEMs can be expressed in two different ways. The conditional approach treats values of the latent variables as model parameters. For example, if you have 100 observations and 2 latent variables, then this would add 200 parameters to the model. In Kevin McKee’s post that you linked to, he uses this approach where matrix[N,D] z; indicates that he is treating the latent variables as parameters which he then uses to predict the outcomes (y[n, d, q] ~ ordered_logistic( z[n,d] * lambda[q, d], c[q, d]);). Note that the values of y are assumed to be independent of one another conditional on z.

The marginal approach uses parameters describing latent variable distributions (loadings, variances, and covariances) to generate a model-implied covariance matrix which is used to model y without treating estimating the latent variables themselves. In other words, the number of model parameters does not scale with the number of observations. If the model requires 10 parameters with 100 observations, then it will require 10 parameters for 10,000 observations. It can be very efficient (see paper here or post here) but is easiest for multivariate normal variables. To do this in Stan, you would need to specify a multivariate ordered distribution, which is not built-in, and I have not see anyone do yet.

Missing data: Either approach (conditional or marginal) can be used to handle missing data in a FIML-like way (see here). Alternatively, you can always treat missing values as parameters as with multiple imputation and splice them in (see here). In some cases, you may need to use both strategies together.

Building the model: One of the nice things about Stan is that you can keep adding complexity to the model without having to jump to another package. For example, you have to jump from lm to lmer once you add in the multilevel structure. If you do end up using Stan, I’d suggest you break down the problem into discrete steps and build it up that way. Something like

  1. Single-level SEM with continuous indicators, listwise deletion
  2. Single-level SEM with continuous indicators, handle missing data
  3. Single-level SEM with ordinal indicators
  4. Two-level SEM with ordinal indicators (no cross-level interactions)
  5. Two-level SEM with ordinal indicators (with cross-level interactions)
  6. Three-level SEM with ordinal indicators

You might have a more reasonable sequence. For example, you might switch steps 3 and 6 and push off the complexity introduced by the ordinal variables. But the point (that I often neglect myself) is that Stan is a great tool for starting simple and building in complexity along the way. It is much easier to get a simple model working and make it slightly more complex than it is to start with a massive, complex model and make it work (again, guilty as charged). Especially if you’re just learning the language.

2 Likes

Thank you for your comprehensive reply. As you mentioned, there are Stan frontends for R that somewhat addresses my needs, but does not fully account for the complexity of the model I want to build. blavaan does not support multilevel models, and brms is seemingly does not do SEM at all. This leaves me the option of building the model from scratch, if any option at all.

After I posted I did come over the book “Structural Equation Modeling: A Bayesian Approach” by Sik-Yum Lee (2007), which seem to address many of the issues I face. It comes with examples written in WinBUGS syntax. Since I use Linux, my option would be OpenBUGS, so I’d be interested in knowing the differences in capabilities between Stan and OpenBUGS. From the little I’ve seen, they offer similar syntax and functionality. From what I understand Stan seems to have gained traction over the years, and is under active development. On the other hand, OpenBUGS is no longer under active development. It does, however, have examples for SEM developed for it. Is there a good argument I should choose one language over the other for my purposes?

In Lee’s book, chapter 9, they demonstrate how a two-level SEM can be fitted in WinBUGS (code below). I’d be interested in knowing if and how this model could be translated to Stan syntax, and if it’s possible to expand into three or more levels?


This is a WinBUGS Codes for the artificial example in Chapter 9, Section 9.7.

Model: Two-level nonlinear structural equation model
Data Set Name: YO.dat
Sample Size: N=1555
Note: pi[g,j] is for omega(2g,j), lb and lw are for lambda2 and lambda_1, respectively.

model{
for(g in 1:50){
for(i in 1:N[g]){
for(j in 1:9){
y[kk[g]+i,j]~dnorm(u[kk[g]+i,j],psi[j])
ephat[kk[g]+i,j]<-y[kk[g]+i,j]-u[kk[g]+i,j]
}
u[kk[g]+i,1]<- mu[1]+pi[g,1]+eta[g,i]
u[kk[g]+i,2]<- mu[2]+lb[1]*pi[g,1]+lw[1]*eta[g,i]
u[kk[g]+i,3]<- mu[3]+lb[2]*pi[g,1]+lw[2]*eta[g,i]
u[kk[g]+i,4]<- mu[4]+pi[g,2]+xi[g,i,1]
u[kk[g]+i,5]<- mu[5]+lb[3]*pi[g,2]+lw[3]*xi[g,i,1]
u[kk[g]+i,6]<- mu[6]+lb[4]*pi[g,2]+lw[4]*xi[g,i,1]
u[kk[g]+i,7]<- mu[7]+pi[g,3]+xi[g,i,2]
u[kk[g]+i,8]<- mu[8]+lb[5]*pi[g,3]+lw[5]*xi[g,i,2]
u[kk[g]+i,9]<- mu[9]+lb[6]*pi[g,3]+lw[6]*xi[g,i,2]
xi[g,i,1:2]~dmnorm(ux[1:2],phi[1:2,1:2])
# ux=[0 0]^T is fixed constant
eta[g,i]~dnorm(nu[g,i], psd)
nu[g,i]<- gam[1]*xi[g,i,1]+gam[2]*xi[g,i,2]+gam[3]*xi[g,i,1]*xi[g,i,2]
dthat[g,i]<-eta[g,i]-nu[g,i]
}# end of i
pi[g,1:3]~ dmnorm(uu[1:3],phip[1:3,1:3])
}# end of g
uu[1]<- 0.0 uu[2]<- 0.0 uu[3]<- 0.0 ux[1]<- 0.0 ux[2]<- 0.0
# priors on loadings and coefficients
mu[1]~dnorm(4.248,4.0)
mu[2]~dnorm(4.668,4.0)
mu[3]~dnorm(4.56,4.0)
mu[4]~dnorm(2.389,4.0)
mu[5]~dnorm(3.161,4.0)
mu[6]~dnorm(3.445,4.0)
mu[7]~dnorm(0.526,4.0)
mu[8]~dnorm(0.375,4.0)
mu[9]~dnorm(0.596,4.0)
var.bw[1]<-4.0*psi[2]
var.bw[2]<-4.0*psi[3]
var.bw[3]<-4.0*psi[5]
var.bw[4]<-4.0*psi[6]
var.bw[5]<-4.0*psi[8]
var.bw[6]<-4.0*psi[9]
lb[1]~dnorm(1.096,var.bw[1]) lb[2]~dnorm(0.861,var.bw[2])
lb[3]~dnorm(0.590,var.bw[3])
lb[4]~dnorm(1.470,var.bw[4]) lb[5]~dnorm(0.787,var.bw[5])
lb[6]~dnorm(0.574,var.bw[6])
lw[1]~dnorm(0.825,var.bw[1]) lw[2]~dnorm(0.813,var.bw[2]) lw[3]~dnorm(0.951,var.bw[3])
lw[4]~dnorm(0.692,var.bw[4]) lw[5]~dnorm(0.986,var.bw[5]) lw[6]~dnorm(0.800,var.bw[6])
var.gam<-4.0*psd
gam[1]~dnorm(0.577,var.gam) gam[2]~dnorm(1.712,var.gam) gam[3]~dnorm(-0.571,var.gam)
# priors on precisions
for(j in 1:9){psi[j]~dgamma(10.0,4.0)
ivpsi[j]<-1/psi[j]}
psd~dgamma(10.0,4.0)
ivpsd<-1/psd
phi[1:2,1:2]~dwish(R0[1:2,1:2],5)
phx[1:2,1:2]<-inverse(phi[1:2,1:2])phip[1:3,1:3]~dwish(R1[1:3,1:3],5)
php[1:3,1:3]<-inverse(phip[1:3,1:3])
}# end of model

Data

list(N=c(28,27,25,28,33,26,18,26,17,24,26,24,24,30,23,24,29,27,34,18,20,14,27,28,28,
26,43,32,43,43,41,47,45,41,25,36,32,36,44,36,32,37,36,27,38,34,39,40,37,37),
kk=c(0,28,55,80,108,141,167,185,211,228,252,278,302,326,356,379,403,432,459,493,511,
531,545,572,600,628,654,697,729,772,815,856,903,948,989,1014,1050,1082,1118,1162,
1198,1230,1267,1303,1330,1368,1402,1441,1481,1518),
R0=structure(.Data= c(1.940,0.775,0.775,0.600), .Dim= c(2,2)),
R1=structure(.Data= c(13.6,-0.61,0.48,-0.61,0.24,0.06,0.48,0.06,0.22), .Dim= c(3,3)),
y=structure(.Data= c(paste YO.dat here), .Dim= c(1555,9)))

Three different initial values

list(lb=c(0.6,0.6,0.5,2.2,0.6,0.4), lw=c(0.3,0.3,0.3,0.3,0.3,0.3),
mu=c(3.0,3.5,3.3,1.0,2.0,2.2,0.2,0.0,0.2),
psi=c(0.3, 0.3, 0.3, 0.3, 0.3, 0.3,0.3,0.3,0.3), psd=0.6, gam=c(0.2,1.0,-0.4),
phip=structure(.Data=c(0.7,-0.1,0.0,-0.1,0.2,0.0,0.0,0.0,0.18), .Dim=c(3,3)),
phi=structure(.Data=c(0.7, 0.4,0.4,0.7), .Dim= c(2,2)))
list(lb=c(0.8,0.8,0.7,2.5,0.8,0.6), lw=c(0.7,0.7,0.7,0.7,0.7,0.7),
mu=c(4.0,4.0,4.0,2.0,3.0,3.0,0.5,0.4,0.6),
psi=c(0.5, 0.5, 0.5, 0.5, 0.5, 0.5,0.5,0.5,0.5), psd=0.36, gam=c(0.5,1.7,0.6),
phip=structure(.Data=c(0.5,0.1,-0.1,0.1,0.2,0.0,-0.1,0.0,0.5), .Dim=c(3,3)),
phi=structure(.Data=c(0.5, 0.1,0.1,0.5), .Dim= c(2,2)))
list(lb=c(1.0,1.0,1.0,3.0,1.0,1.0), lw=c(1.0,1.0,1.0,1.0,1.0,1.0),
mu=c(4.8,4.8,4.8,3.5,4.0,4.2,0.8,0.8,0.8),
psi=c(0.8, 0.8, 0.8, 0.8, 0.8, 0.8,0.8,0.8,0.8), psd=0.9, gam=c(0.8,1.2,0.0),
phip=structure(.Data=c(0.6,-0.2,0.2,-0.2,0.4,0.1,0.2,0.1,0.3), .Dim=c(3,3)),
phi=structure(.Data=c(0.9, 0.0,0.0,0.6), .Dim= c(2,2)))

I’ve never used open/WinBUGS (or JAGS for that matter), so can’t speak much to the syntax or performance. BUGS and JAGS are Gibbs samplers (the G in each of them), which sample one parameter at a time. The general consensus is that they are inefficient compared to Stan. I found @Ben_Lambert 's book A Student’s Guide to Bayesian Statistics to be an accessible introduction to these issues (supplementary info here, see sections 13-16).

I think the ease with which you can fit similar models in Mplus or OpenMx hides the amount of developer work that went into enabling those features. It will be difficult to get the desired model up and running in BUGS or Stan, and even more difficult to ensure that the model is working correctly. I think that is why there are not many examples of these models. With that said, here are a few thoughts:

  • I completely agree about starting simple and working your way up to a complex model.
  • I think the approach described by Kevin McKee is your best bet, and it is similar to the model you posted from Sik-Yum Lee. I think you would add an extra loop around the likelihood for y, then modify the definitions of the means and other parameters to include the third level. But this approach will be fairly inefficient in both BUGS and Stan (= slow run time, autocorrelation, low effective sample size), because it will sample an extra parameter (latent variable) for each unit at each level.
  • Missing data are slightly easier to handle in BUGS because you can pass in NA values. Of course, this requires you to assume missing at random.
  • Efficient sampling for two-level models with continuous variables is under development in blavaan, but this functionality will still not do everything you want.
  • There is some literature about fitting these multilevel models as separate, one-level models, for example here. I’m not immediately sure whether this literature extends to ordinal data.
  • So, given these things, I think you want to consider your commitment to fitting the model. If you are committed, you can get it to work, but it will take time and effort.
2 Likes

Thanks for the tips!

While OpenMx initially seemed promising, due to being some of the most flexible SEM software out there, it has become increasingly clear that frequentist approaches are simply not suited to fit models of the specified complexity, regardless of the choice of software. This leaves me with the Bayesian approach as the only remaining option, and Stan currently seem to be my best bet. I realise there will be a lot of work and reading ahead, hence my asking for examples of a model of the specified characteristics: If I am to invest the time (which I unfortunately do not have in abundance) and effort learning Bayesian statistics and Stan it would be comforting to know whether this has the potential to fit the model I’m working on.

There is some literature about fitting these multilevel models as separate, one-level models,

That certainly is an interesting take worth considering.

1 Like