R/helpers.R

Defines functions getEstimates.lavaan getEstimates.mirt

Documented in getEstimates.lavaan getEstimates.mirt

#' Prepare \code{mirt} estimates for alignment
#'
#' Not generally intended to be used on its own, but exported anyway for
#' didactic purposes.
#'
#' See example for \code{\link{Alignment}} for examples
#'
#' This program was designed based on the published work of Asparouhov & Muthen,
#' and was not intended to match Mplus exactly, and may not.
#'
#' @param fit A \code{mirt} object compatible with \code{\link{Alignment}}
#' @param SE logical; whether to also obtain standard errors.
#' @param bifactor.marginal See \code{\link{Alignment}} documentation.
#'
#' @returns A `list` of estimates in a format amenable to subsequent alignment
#'
#' @export
getEstimates.mirt=function(fit,SE=FALSE,bifactor.marginal=FALSE){

  #coefficients
  coef.raw=mirt::coef(fit,printSE=SE)
  if('lr.betas'%in%names(coef.raw))
    coef.raw=coef.raw[which(names(coef.raw)!='lr.betas')]
  #convert to vector if appropriate; just get item parameters
  if(SE) se.raw=coef.raw%>%purrr::map(function(x)
    if(is.matrix(x))x['SE',] else NULL)
  coef.raw=coef.raw%>%purrr::map(function(x)if(is.matrix(x))x['par',] else x)
  #item parameters
  coef.raw=coef.raw[fit@Data$tabdata%>%colnames]
  coef.raw=coef.raw[!is.na(names(coef.raw))]
  if(SE) se.raw=se.raw[fit@Data$tabdata%>%colnames]
  if(SE) se.raw=se.raw[!is.na(names(se.raw))]


  #loadings
  a.raw=NULL
  if(SE) se.a.raw=NULL
  for(j in 1:(length(coef.raw))){
    a.raw.j=coef.raw[[j]][which(substr(names(coef.raw[[j]]),1,1)=='a')]
    if(SE) se.a.raw.j=se.raw[[j]][which(substr(names(se.raw[[j]]),1,1)=='a')]
    if(length(a.raw.j)>1 & bifactor.marginal){
      c1=(1/1.7)/sqrt(1+sum((a.raw.j/1.7)^2))
      a.raw.j=a.raw.j*c1 #compare to summary(fit)
      a.sigmasq.i=1-a.raw.j^2
      c2=(1/sqrt(a.sigmasq.i)*1.7)
      a.raw.j=(a.raw.j*c2)[1]
      if(SE)se.a.raw.j=se.a.raw.j[1]*(c1*c2)[1]
    }
    if(j==1){
      a.raw=matrix(a.raw.j)
      if(SE) se.a.raw=matrix(se.a.raw.j)
    } else {
      a.raw=rbind(a.raw,a.raw.j)
      if(SE) se.a.raw=rbind(se.a.raw,se.a.raw.j)
    }
    rownames(a.raw)[j]=names(coef.raw)[j]
    if(SE) rownames(se.a.raw)[j]=names(coef.raw)[j]
  }

  #maximum number of categories maxcats
  maxcats=10
  d.raw=matrix(0,1,maxcats)
  if(SE) se.d.raw=matrix(0,1,maxcats)
  for(j in 1:(length(coef.raw))){
    dindices=which(substr(names(coef.raw[[j]]),1,1)=="d")
    d.raw=rbind(d.raw,c(coef.raw[[j]][dindices],
                        rep(0,maxcats-length(dindices))))
    if(SE) se.d.raw=rbind(se.d.raw,c(se.raw[[j]][dindices],
                                     rep(0,maxcats-length(dindices))))
    rownames(d.raw)[j+1]=names(d.raw)[j]
    if(SE) rownames(se.d.raw)[j+1]=names(se.raw)[j]
  }
  d.raw=d.raw[-1,]
  if(SE) se.d.raw=se.d.raw[-1,]
  d.raw=as.matrix(d.raw[,-which(apply(abs(d.raw),2,sum)==0)])
  if(SE) se.d.raw=as.matrix(se.d.raw[,-which(apply(abs(se.d.raw),2,sum)==0)])
  rownames(d.raw)=names(coef.raw)
  if(SE) rownames(se.d.raw)=names(se.raw)
  if(is.null(colnames(d.raw))) colnames(d.raw)='d.1'
  if(SE) if(is.null(colnames(se.d.raw))) colnames(se.d.raw)='d.1'

  #parameter matrix for alignment input
  pars.raw=data.frame(itemname=rownames(a.raw),a.raw,d.raw)
  names(pars.raw)=c('itemname',paste0("a.",1:ncol(a.raw)),
                    paste0("d.",1:ncol(d.raw)))
  if(SE) ses.raw=data.frame(itemname=rownames(se.a.raw),se.a.raw,se.d.raw)
  if(SE) names(ses.raw)=c('itemname',paste0("a.",1:ncol(se.a.raw)),
                          paste0("d.",1:ncol(se.d.raw)))

  #get model parameters, ready to align
  a.toalign=as.matrix(pars.raw[,c(paste0("a.1"))])
  row.names(a.toalign)=pars.raw[,1]
  d.toalign=as.matrix(pars.raw[,which(substr(names(pars.raw),1,2)=="d.")])
  if(SE) se.a.toalign=as.matrix(ses.raw[,c(paste0("a.1"))])
  if(SE) row.names(se.a.toalign)=ses.raw[,1]
  if(SE) se.d.toalign=as.matrix(
    ses.raw[,which(substr(names(ses.raw),1,2)=="d.")])
  rownames(d.toalign)=names(coef.raw)
  if(SE) rownames(se.d.toalign)=names(se.raw)
  if(is.null(colnames(d.toalign))) colnames(d.toalign)='d.1'
  if(SE) if(is.null(colnames(se.d.toalign))) colnames(se.d.toalign)='d.1'

  #intercepts
  for(j in 1:ncol(d.toalign)){
    which.na=which(d.toalign[,j]==0)
    if(length(which.na)>0)d.toalign[which.na,j]=NA
    if(length(which.na)>0 & SE)se.d.toalign[which.na,j]=NA
  }
  #shift intercepts according to data mins
  mins=(fit@Data$mins-min(fit@Data$mins))%>%
    stats::setNames(NULL) #fixed min to 0
  d.toalign2=d.toalign
  if(SE) se.d.toalign2=se.d.toalign
  for(r in 1:nrow(d.toalign2)){
    if(mins[r]>0){
      #calculate # of columns to add
      whichExist=which(!is.na(d.toalign2[r,]))
      nCols2add=whichExist[length(whichExist)]-whichExist[1]+mins[r]-
        ncol(d.toalign2)+1
      # if(!is.na(d.toalign2[r,ncol(d.toalign2)])){
      if(nCols2add>0){
        for(i in 1:nCols2add){
          d.toalign2=cbind(d.toalign2,NA)
          colnames(d.toalign2)[ncol(d.toalign2)]=
            paste0('d.',
                   as.numeric(
                     strsplit(colnames(d.toalign2)[ncol(d.toalign2)-1],'.',
                              fixed=TRUE)%>%purrr::map_chr(~.[2])
                   )+1)
          if(SE){
            se.d.toalign2=cbind(se.d.toalign2,NA)
            colnames(se.d.toalign2)[ncol(se.d.toalign2)]=
              paste0('d.',
                     as.numeric(
                       strsplit(colnames(se.d.toalign2)
                                [ncol(se.d.toalign2)-1],'.',
                                fixed=TRUE)%>%purrr::map_chr(~.[2])
                     )+1)
          }
        }
      }
      d.toalign2[r,(mins[r]+1):(mins[r]+sum(!is.na(d.toalign2[r,])))]=
        d.toalign2[r,1:(sum(!is.na(d.toalign2[r,])))]
      d.toalign2[r,1:mins[r]]=NA
      if(SE){
        se.d.toalign2[r,(mins[r]+1):(mins[r]+sum(!is.na(se.d.toalign2[r,])))]=
          se.d.toalign2[r,1:(sum(!is.na(se.d.toalign2[r,])))]
        se.d.toalign2[r,1:mins[r]]=NA
      }
    }
  }
  d.toalign=d.toalign2
  if(SE) se.d.toalign=se.d.toalign2
  #set column names
  colnames(d.toalign)=paste0('d.',
                             min(mins):(ncol(d.toalign)-min(mins)-1),
                             '.',
                             (min(mins)+1):(ncol(d.toalign)-min(mins)))
  if(SE) colnames(se.d.toalign)=paste0('d.',
                                       min(mins):(ncol(se.d.toalign)-
                                                    min(mins)-1),
                                       '.',
                                       (min(mins)+1):(ncol(se.d.toalign)-
                                                        min(mins)))

  #return
  if(!SE){
    se.a.toalign=NULL
    se.d.toalign=NULL
  } else {
    colnames(se.a.toalign)='G'
  }

  #set column names
  colnames(a.toalign)='G'

  return(list(a=a.toalign,d=d.toalign,
              se.a=se.a.toalign,se.d=se.d.toalign))
}

#' Prepare \code{lavaan} estimates for alignment
#'
#' Not generally intended to be used on its own, but exported anyway for
#' didactic purposes.
#'
#' See example for \code{\link{Alignment}} for examples
#'
#' This program was designed based on the published work of Asparouhov &
#' Muthen, and was not intended to match Mplus exactly, and may not.
#'
#' @param fit A \code{lavaan} object compatible with \code{\link{Alignment}}
#' @param SE logical; whether to also obtain standard errors.
#'
#' @returns A `list` of estimates in a format amenable to subsequent alignment
#'
#' @export
getEstimates.lavaan=function(fit,SE=TRUE){

  #coefficients
  coef.raw=lavaan::lavInspect(fit,'est')
  if(SE)se.raw=lavaan::lavInspect(fit,'se')

  #loadings
  lambda.raw=coef.raw$lambda
  #thresholds
  tau.raw=coef.raw$tau%>%as.data.frame%>%tibble::rownames_to_column('rowname')
  tau.raw=tau.raw%>%
    dplyr::mutate(Item=strsplit(.data$rowname,'|',fixed=TRUE)%>%
                    purrr::map_chr(~.[1]),
                  Threshold=strsplit(.data$rowname,'|',fixed=TRUE)%>%
                    purrr::map_chr(~.[2]))%>%
    dplyr::select(-.data$rowname)
  #apply observed levels to thresholds
  obs.thresh=fit@Data@ov$lnam%>%
    strsplit('|',fixed=TRUE)%>%
    purrr::map(~paste0(.[1:(length(.)-1)],'_',.[2:length(.)]))%>%
    purrr::map(~tibble::tibble(Threshold=as.character(1:length(.)),
                               boundary=.))%>%
    stats::setNames(fit@Data@ov$name)%>%
    purrr::imap(~dplyr::mutate(.x,Item=.y))%>%dplyr::bind_rows()
  tau.raw=tau.raw%>%
    dplyr::mutate(Threshold=gsub('t','',.data$Threshold,fixed=TRUE))%>%
    dplyr::left_join(obs.thresh,by=c('Item','Threshold'))
  tau.raw=tau.raw%>%
    dplyr::mutate(boundary=paste0('t',.data$boundary))%>%
    dplyr::select(-.data$Threshold)%>%dplyr::rename(Threshold=.data$boundary)
  #finish preparing
  tau.raw=tau.raw%>%
    tidyr::pivot_wider(id_cols=.data$Item,
                       names_from=.data$Threshold,
                       values_from=.data$threshold)%>%
    as.data.frame
  rownames(tau.raw)=tau.raw$Item
  tau.raw=tau.raw%>%dplyr::select(-.data$Item)%>%as.matrix
  if(SE){
    #loadings
    se.lambda.raw=se.raw$lambda
    #thresholds
    se.tau.raw=se.raw$tau%>%as.data.frame%>%
      tibble::rownames_to_column('rowname')%>%
      dplyr::mutate(Item=strsplit(.data$rowname,'|',fixed=TRUE)%>%
                      purrr::map_chr(~.[1]),
                    Threshold=strsplit(.data$rowname,'|',fixed=TRUE)%>%
                      purrr::map_chr(~.[2]))%>%
      dplyr::select(-.data$rowname)%>%
      dplyr::mutate(Threshold=gsub('t','',.data$Threshold,fixed=TRUE))%>%
      dplyr::left_join(obs.thresh,by=c('Item','Threshold'))%>%
      dplyr::mutate(boundary=paste0('t',.data$boundary))%>%
      dplyr::select(-.data$Threshold)%>%
      dplyr::rename(Threshold=.data$boundary)%>%
      tidyr::pivot_wider(id_cols=.data$Item,
                         names_from=.data$Threshold,
                         values_from=.data$threshold)%>%
      as.data.frame
    rownames(se.tau.raw)=se.tau.raw$Item
    se.tau.raw=se.tau.raw%>%dplyr::select(-.data$Item)%>%as.matrix
  } else {
    se.lambda.raw=NULL
    se.tau.raw=NULL
  }

  #return
  return(list(lambda=lambda.raw,tau=tau.raw,
              se.lambda=se.lambda.raw,se.tau=se.tau.raw,
              parameterization=fit@Options$parameterization))
}

#' Transform \code{mirt} estimates using aligned estimates of latent mean
#' and variance
#'
#' Not generally intended to be used on its own, but exported anyway for
#' didactic purposes.
#'
#' See example for \code{\link{Alignment}} for examples
#'
#' This program was designed based on the published work of Asparouhov & Muthen,
#' and was not intended to match Mplus exactly, and may not.
#'
#' @param align.mean Mean to transform model to.
#' @param align.variance Variance to transform model to.
#' @param est Estimates to transform, from \code{\link{getEstimates.mirt}}
#'
#' @returns Estimates in the same structure as from
#'  \code{\link{getEstimates.mirt}}, but transformed from (assumed) mean 0 and
#'  variance 1 to the metric specified by `align.mean` and `align.variance`.
#'
#' @export
transformEstimates.mirt.grm=function(align.mean,align.variance,est){

  #unpack est
  a=est$a
  d=est$d
  se.a=est$se.a
  se.d=est$se.d
  if(is.null(se.a)) SE=FALSE else SE=TRUE
  #transform a and SE's
  if(length(dim(est[[1]]))>2 & length(align.mean)==length(align.variance) &
     length(align.mean)==dim(est[[1]])[3]){
    #assume 3d arrays
    a=a*array(1/sqrt(align.variance),
              dim(a)[c(3,1,2)])%>%
      aperm(c(2,3,1))
    if(SE) se.a=se.a*array(1/sqrt(align.variance),
                           dim(a)[c(3,1,2)])%>%
        aperm(c(2,3,1))
    d=d-(array(align.mean,
               dim(d)[c(3,1,2)])%>%
           aperm(c(2,3,1)))*
      (array(a,dim(d)[c(1,3,2)])%>%
         aperm(c(1,3,2)))
  } else if(length(dim(est[[1]]))==2 &
            length(align.mean)==1 &
            length(align.variance)==1){
    #assume one group
    a=a*1/sqrt(align.variance)
    if(SE) se.a=se.a*1/sqrt(align.variance)
    d=d-align.mean*matrix(a,
                          nrow(d),
                          ncol(d),byrow=FALSE)
  } else stop(paste0('Either transform one model (scalar mean & variance, ',
                     '2D arrays in est) or a set (vector mean & variance ',
                     'with equal lengths, ',
                     '3D arrays in est, and length(mean)==dim(est)[3]'))
  out=list(a=a,d=d)
  if(SE) out=c(out,list(se.a=se.a,se.d=se.d))
  return(out)
}

#' Transform \code{lavaan} estimates using aligned estimates of latent
#' mean and variance
#'
#' Not generally intended to be used on its own, but exported anyway for
#' didactic purposes.
#'
#' See example for \code{\link{Alignment}} for examples
#'
#' This program was designed based on the published work of Asparouhov & Muthen,
#' and was not intended to match Mplus exactly, and may not.
#'
#' @param align.mean Mean to transform model to.
#' @param align.variance Variance to transform model to.
#' @param est Estimates to transform, from \code{\link{getEstimates.lavaan}}
#' @param toCompare Accounts for discrepancies between delta and theta
#' parameterizations; see \code{\link{Alignment}} documentation.
#'
#' @returns Estimates in the same structure as from
#'  \code{\link{getEstimates.lavaan}}, but transformed from (assumed) mean 0 and
#'  variance 1 to the metric specified by `align.mean` and `align.variance`.
#'
#' @export
transformEstimates.lavaan.ordered=function(align.mean,
                                           align.variance,
                                           est,toCompare=FALSE){

  #My current thinking: under the delta parameterization, the transformed
  #estimates (calculate delta, incorporate it into parameters, then
  #transform parameters, BUT don't reverse the delta transformation) do NOT
  #yield an equivalent model, but DO yield a model that can be compared
  #across groups. In order to get an equivalent model, you also need to
  #reverse the delta transformation at the end.
  #To account for this, the extra argument toCompare should be turned on if
  #transformed parameters are to be compared for equivalence across groups.
  #Turning it on results in NOT applying the reverse of the delta transformation
  #at the end.

  #unpack est
  lambda=est$lambda
  tau=est$tau
  se.lambda=est$se.lambda
  se.tau=est$se.tau
  if(length(dim(est[[1]]))>2 & length(align.mean)==length(align.variance) &
     length(align.mean)==dim(est[[1]])[3]){
    if(all(est$parameterization=='theta')){
      #assume 3d arrays
      lambda=lambda*array(1/sqrt(align.variance),
                          dim(lambda)[c(3,1,2)])%>%
        aperm(c(2,3,1))
      se.lambda=se.lambda*array(1/sqrt(align.variance),
                                dim(lambda)[c(3,1,2)])%>%
        aperm(c(2,3,1))
      tau=tau+(array(align.mean,
                     dim(tau)[c(3,1,2)])%>%
                 aperm(c(2,3,1)))*
        (array(lambda,dim(tau)[c(1,3,2)])%>%
           aperm(c(1,3,2)))
    } else if(all(est$parameterization=='delta') & toCompare){
      #get deltas
      delta=sqrt(1-lambda^2)
      #convert to theta parameterization
      lambda=lambda/delta
      tau=tau/(array(delta,dim(tau)[c(1,3,2)])%>%
                 aperm(c(1,3,2)))
      #now, transform
      lambda=lambda*array(1/sqrt(align.variance),
                          dim(lambda)[c(3,1,2)])%>%
        aperm(c(2,3,1))
      se.lambda=se.lambda*array(1/sqrt(align.variance),
                                dim(lambda)[c(3,1,2)])%>%
        aperm(c(2,3,1))
      tau=tau+(array(align.mean,
                     dim(tau)[c(3,1,2)])%>%
                 aperm(c(2,3,1)))*
        (array(lambda,dim(tau)[c(1,3,2)])%>%
           aperm(c(1,3,2)))
      #convert back if !toCompare
      if(!toCompare){
        lambda=lambda*delta
        tau=tau*(array(delta,dim(tau)[c(1,3,2)])%>%
                   aperm(c(1,3,2)))
      }
    } else stop(paste0('Parameterization not found! Must be "delta" or "theta"',
                       'and must be the same for all models'))
  } else if(length(dim(est[[1]]))==2 &
            length(align.mean)==1 &
            length(align.variance)==1){
    if(est$parameterization=='theta'){
      #assume one group
      lambda=lambda*1/sqrt(align.variance)
      se.lambda=se.lambda*1/sqrt(align.variance)
      tau=tau+align.mean*matrix(lambda,
                                nrow(tau),
                                ncol(tau),byrow=FALSE)
    } else if(est$parameterization=='delta' & toCompare){
      #get deltas
      delta=sqrt(1-lambda^2)
      #convert to theta parameterization
      lambda=lambda/delta
      tau=tau/matrix(delta,nrow(tau),ncol(tau),byrow=FALSE)
      #now, transform
      lambda=lambda*1/sqrt(align.variance)
      se.lambda=se.lambda*1/sqrt(align.variance)
      tau=tau+align.mean*matrix(lambda,
                                nrow(tau),
                                ncol(tau),byrow=FALSE)
      #convert back?
      if(!toCompare){
        lambda=lambda*delta
        tau=tau*matrix(delta,nrow(tau),ncol(tau),byrow=FALSE)
      }
    } else stop('Parameterization not found! Must be "delta" or "theta"')
  } else
    stop(paste0('Either transform one model (scalar mean & variance, ',
                '2D arrays in est) or a set (vector mean & variance ',
                'with equal lengths, 3D arrays in est, and ',
                'length(mean)==dim(est)[3]. If delta pararameterization ',
                'is used (the default in lavaan), toCompare must be set ',
                'to determine whether you want the parameters of an ',
                'equivalent model with incomparable parameters ',
                '(toCompare=FALSE) or comparable parameters (toCompare=TRUE).'))
  return(list(lambda=lambda,tau=tau,
              se.lambda=se.lambda,se.tau=se.tau))
}

#' Estimate \code{mirt} models using aligned parameter estimates
#'
#' Not generally intended to be used on its own, but exported anyway for
#' didactic purposes.
#'
#' See example for \code{\link{Alignment}} for examples
#'
#' This program was designed based on the published work of Asparouhov & Muthen,
#' and was not intended to match Mplus exactly, and may not.
#'
#' @param fit A \code{mirt} object compatible with \code{\link{Alignment}}
#' @param align.mean Mean to transform model to.
#' @param align.variance Variance to transform model to.
#' @param newpars New (transformed) estimates to load into model object.
#' @param do.fit Whether to re-fit the model after loading and fixing estimates.
#' @param verbose See \code{\link{Alignment}} documentation.
#'
#' @returns A `mirt`, object based on `fit` but with modified
#' parameters.
#'
#' @export
loadEstimates.mirt.grm=function(fit,align.mean,
                                align.variance,newpars,
                                do.fit=TRUE,verbose=TRUE){

  #get call
  call=fit@Call%>%deparse
  if(length(call)>1)call=paste(call,collapse='')
  #if "fun" made its way in there, replace it with mirt
  call=gsub('fun(','mirt(',call,fixed=TRUE)
  call=gsub('..4','fit@Options$technical',call,fixed=TRUE)
  #replace data
  call.split=strsplit(call,'=',fixed=TRUE)
  call.split=purrr::map(call.split[[1]],strsplit,',',fixed=TRUE)%>%
    purrr::map(~.[[1]])
  call.split[[2]]=c('fit@Data$data',call.split[[2]][length(call.split[[2]])])
  call=call.split%>%purrr::map_chr(paste,collapse=',')%>%paste(collapse='=')
  #run it again with pars='values'
  if(verbose) cat(call)
  # call.pars=gsub("pars = ([^,\\)]+)([,\\)])", "pars = \\1\\2", call,
  #  perl = TRUE)
  call.pars=gsub("pars = [^,\\)]+", "", call)
  call.pars=gsub("verbose = [^,\\)]+", "", call.pars)
  if(verbose) cat(call.pars)

  call.pars=paste0(substr(call.pars,1,nchar(call.pars)-1),
                   ", pars = 'values', verbose = ",verbose,')')
  # call.pars=gsub('pars = pars,','',call.pars,fixed=TRUE)
  #chatgpt gave me this

  if(verbose) cat(call.pars)

  #bring in mod from @Model$model if model = mod is contained in call.pars
  if(!grepl('model = 1',call.pars,fixed=TRUE)){
    modName=strsplit(call.pars,'model = ',fixed=TRUE)[[1]][2]%>%
      strsplit(',',fixed=TRUE)%>%purrr::map_chr(~.[1])%>%trimws
    call.pars=gsub(modName,'myMod',call.pars,fixed=TRUE)
    call=gsub(modName,'myMod',call,fixed=TRUE)
    myMod=fit@Model$model
  }
  pars=eval(parse(text=call.pars))
  #load 'em up: slopes
  for(i in 1:nrow(newpars$a)){
    for(j in 1:ncol(newpars$a)){
      if(!is.na(newpars$a[i]))
        pars$value[pars$item==rownames(newpars$a)[i] &
                     pars$name=='a1']=newpars$a[i]
    }
  }
  #load 'em up: locations
  #mins
  mins=(fit@Data$mins-min(fit@Data$mins))[
    rownames(newpars$d)
  ]
  for (i in 1:nrow(newpars$d)) {
    for (j in 1:ncol(newpars$d)) {
      if (!is.na(newpars$d[i,j]))
        pars$value[pars$item == rownames(newpars$d)[i] &
                     pars$name == gsub(".","",colnames(newpars$d)[j],
                                       fixed=TRUE)%>%purrr::map_chr(function(x)
                                         paste0(
                                           substr(x,1,1),
                                           as.numeric(substr(x,3,3))-mins[i]
                                         ))]=newpars$d[i,j]
    }
  }
  #load 'em up: means and variances
  pars$value[pars$name=='MEAN_1']=align.mean
  pars$value[pars$name=='COV_11']=align.variance
  if(do.fit){
    #add pars, if not there already (?)
    # if(!grepl('pars = pars',call,fixed=TRUE)){
    #   newcall=paste0(substr(call,1,nchar(call)-1),
    #                  ", pars = pars, verbose = ",verbose,')')
    #   out=eval(parse(text=newcall))
    # } else out=eval(parse(text=call))
    #replace pars='values' with pars=pars
    call.final=gsub("pars = 'values'","pars = pars", call.pars,fixed=TRUE)
    out=eval(parse(text=call.final))
  } else out=pars
  out
}

#' Estimate \code{lavaan} models using aligned parameter estimates
#'
#' Not generally intended to be used on its own, but exported anyway for
#' didactic purposes.
#'
#' See example for \code{\link{Alignment}} for examples
#'
#' This program was designed based on the published work of Asparouhov & Muthen,
#' and was not intended to match Mplus exactly, and may not.
#'
#' @param fit A \code{mirt} object compatible with \code{\link{Alignment}}
#' @param align.mean Mean to transform model to.
#' @param align.variance Variance to transform model to.
#' @param newpars New (transformed) estimates to load into model object.
#' @param do.fit Whether to re-fit the model after loading and fixing estimates.
#' @param verbose See \code{\link{Alignment}} documentation.
#'
#' @returns A `lavaan` object, based on `fit` but with modified
#' parameters.
#'
#' @export
loadEstimates.lavaan.ordered=function(fit,align.mean,align.variance,
                                      newpars,do.fit=TRUE,verbose=TRUE){

  #get data and partable
  dat=fit@Data
  pt=fit@ParTable%>%tibble::as_tibble()
  #lv name
  fname=pt$lhs[pt$op=='=~']%>%unique

  #load 'em up: slopes
  for(i in 1:nrow(newpars$lambda)){
    for(j in 1:ncol(newpars$lambda)){
      pt$start[pt$op=='=~' &
                 pt$rhs==rownames(newpars$lambda)[i]]=newpars$lambda[i]
    }
  }
  #load 'em up: intercepts
  for(i in 1:nrow(newpars$tau)){
    for(j in 1:ncol(newpars$tau)){
      pt$start[pt$lhs==rownames(newpars$tau)[i] &
                 pt$rhs==gsub(".",'',colnames(newpars$tau)[j],fixed=TRUE)]=
        newpars$tau[i,j]
    }
  }
  #load 'em up: means and variances
  pt$start[pt$lhs==fname & pt$op=='~1']=align.mean
  pt$start[pt$lhs==fname & pt$op=='~~' & pt$rhs==fname]=align.variance
  #just map everything from start to est
  pt$est=pt$start
  if(do.fit){
    out=lavaan::lavaan(pt%>%as.list,data=dat,parameterization=
                         fit@Options$parameterization,verbose=verbose)
  } else out=pt
  out
}

#' Stack estimates for optimization
#'
#' Not generally intended to be used on its own, but exported anyway for
#' didactic purposes.
#'
#' See example for \code{\link{Alignment}} for examples
#'
#' @param estList List of estimates from \code{\link{getEstimates.lavaan}} or
#' \code{\link{getEstimates.mirt}} to stack to feed into
#' \code{\link{SF.mplus3D}}
#'
#' @returns A set of estimates prepared for efficient use with
#' \code{\link{SF.mplus3D}}
#'
#' @export
stackEstimates=function(estList){

  if(length(estList)==1){
    estList
  } else {
    #output; build iteratively
    out=estList[[1]]
    for(i in 2:length(estList)){
      #get row and column names from each element
      curRows=out%>%purrr::map(rownames)
      curCols=out%>%purrr::map(colnames)
      newRows=estList[[i]]%>%purrr::map(rownames)
      newCols=estList[[i]]%>%purrr::map(colnames)
      for(j in 1:length(out)){
        if(is.null(estList[[i]][[j]])) next
        if(names(estList[[i]])[j]=='parameterization'){
          out[[j]]=c(out[[j]],estList[[i]][[j]])
        } else {
          #add empty new rows to out
          if(any(!newRows[[j]]%in%curRows[[j]])){
            for(n in newRows[[j]][!newRows[[j]]%in%curRows[[j]]]){
              dim.out=dim(out[[j]])
              dim.out[1]=1
              out[[j]]=abind::abind(out[[j]],array(NA,dim.out),along=1)
              rownames(out[[j]])[nrow(out[[j]])]=n
            }
          }
          if(any(!newCols[[j]]%in%curCols[[j]])){
            for(n in newCols[[j]][!newCols[[j]]%in%curCols[[j]]]){
              dim.out=dim(out[[j]])
              dim.out[2]=1
              out[[j]]=abind::abind(out[[j]],array(NA,dim.out),along=2)
              colnames(out[[j]])[ncol(out[[j]])]=n
            }
          }
          if(any(!curRows[[j]]%in%newRows[[j]])){
            for(n in curRows[[j]][!curRows[[j]]%in%newRows[[j]]]){
              dim.out=dim(estList[[i]][[j]])
              dim.out[1]=1
              estList[[i]][[j]]=abind::abind(estList[[i]][[j]],
                                             array(NA,dim.out),along=1)
              rownames(estList[[i]][[j]])[nrow(estList[[i]][[j]])]=n
            }
          }
          if(any(!curCols[[j]]%in%newCols[[j]])){
            for(n in curCols[[j]][!curCols[[j]]%in%newCols[[j]]]){
              dim.out=dim(estList[[i]][[j]])
              dim.out[2]=1
              estList[[i]][[j]]=abind::abind(estList[[i]][[j]],
                                             array(NA,dim.out),along=2)
              colnames(estList[[i]][[j]])[ncol(estList[[i]][[j]])]=n
            }
          }
          #ugh
          what2add=estList[[i]][[j]][rownames(out[[j]]),colnames(out[[j]])]
          if(!is.matrix(what2add)){
            what2add=matrix(what2add,nrow(estList[[i]][[j]]),
                            ncol(estList[[i]][[j]]))
            rownames(what2add)=rownames(estList[[i]][[j]])
            colnames(what2add)=colnames(estList[[i]][[j]])
          }
          out[[j]]=abind::abind(out[[j]],what2add,along=3)
        }
      }
    }
    out
  }
}

#simplicity function
CLF=function(x,e=0.01){
  return(sqrt(sqrt(x^2+e)))
}

#weight function
W=function(x,y) return(sqrt(x*y))

#' Simplicity function for alignment
#'
#' Not generally intended to be used on its own, but exported anyway for
#' didactic purposes.
#'
#' See example for \code{\link{Alignment}} for examples
#'
#' This program was designed based on the published work of Asparouhov & Muthen,
#' and was not intended to match Mplus exactly, and may not.
#'
#' @param pars Hyperparameters to feed into optimizer
#' @param est Estimates to transform, from \code{\link{getEstimates.mirt}} or
#' \code{\link{getEstimates.lavaan}}
#' @param comb All combinations of groups from \code{\link[utils]{combn}}
#' @param nobs Sample size in each group
#' @param estimator See \code{\link{Alignment}} documentation.
#' @param eps.alignment See \code{\link{Alignment}} documentation.
#' @param clf.ignore.quantile See \code{\link{Alignment}} documentation.
#' @param hyper Hyperparameter to calculate simplicity function for; see
#' \code{\link{Alignment}} documentation.
#' @param otherHyper Non-included hyperparameter
#'
#' @returns A value of the simplicity function from Asparouhuv & Muthen, 2014.
#'
#' @export
SF.mplus3D=function(pars,est,comb,nobs,estimator,
                    eps.alignment,clf.ignore.quantile,
                    hyper='all',otherHyper=NULL){

  #unlist sample sizes
  if(is.list(nobs))nobs=unlist(nobs)
  ngroups=length(nobs)

  #extract hyperparameters
  if(hyper=='all'){
    means=pars[1:(ngroups-1)]
    variances=pars[((ngroups-1)+1):(2*(ngroups-1))]
  } else if(hyper=='means'){
    means=pars
    variances=otherHyper
  } else if(hyper=='variances'){
    means=otherHyper
    variances=pars
  }
  means=c(0,means)
  variances=c(1/prod(variances),variances)

  #transform parameters
  if(estimator=='mirt.grm'){
    t.est=transformEstimates.mirt.grm(means,variances,est)
    t.est=t.est[c('a','d')]
  } else if(estimator=='lavaan.ordered'){
    t.est=transformEstimates.lavaan.ordered(means,variances,est,toCompare=TRUE)
    t.est=t.est[c('lambda','tau')]
  }
  out=t.est%>%purrr::map(function(x)CLF(x[,,comb[1,]]-x[,,comb[2,]],
                                        e=eps.alignment)*
                           W(nobs[comb[1,]],nobs[comb[2,]])/
                           sum(!is.na(x[,,comb[1,]]-x[,,comb[2,]])))
  out=Reduce(c,out)
  out=ifelse(out<stats::quantile(out,clf.ignore.quantile,na.rm=TRUE),
             0,out)%>%sum(na.rm=TRUE)
}

#' Runs alignment optimizer
#'
#' Not generally intended to be used on its own, but exported anyway
#' for didactic purposes.
#'
#' See example for \code{\link{Alignment}} for examples
#'
#' @param stacked Stacked parameter estimates from \code{\link{stackEstimates}}
#' @param n Sample size in each group
#' @param estimator See \code{\link{Alignment}} documentation.
#' @param nstarts Number of starting values for alignment; default is 10
#' @param ncores See \code{\link{Alignment}} documentation.
#' @param hyper.first See \code{\link{Alignment}} documentation.
#' @param center.means See \code{\link{Alignment}} documentation.
#' @param eps.alignment See \code{\link{Alignment}} documentation.
#' @param clf.ignore.quantile See \code{\link{Alignment}} documentation.
#' @param verbose See \code{\link{Alignment}} documentation.
#'
#' @returns A `list` of results from multiple runs of the alignment optimizer:
#'
#' * `mv` Means and variances from each alignment run.
#' * `parout` A table of outputs from \code{link[stats]{optim}} containing the
#' function values, convergence information, and resulting estimates of means
#' and variances from each run.
#' * `nFailedRuns` The number of runs that failed to complete. An error is
#' returned if no runs fail.
#'
#' @export
align.optim=function(stacked,n,estimator,nstarts=50,ncores=3,
                     hyper.first,center.means,eps.alignment,
                     clf.ignore.quantile,verbose){

  #get sample sizes from mirt objects
  ngroups=length(n)

  if(ngroups>1){
    #matrices to subtract
    comb=utils::combn(1:ngroups,2)

    ############################################################################
    ############################################################################
    ############################################################################
    ############################################################################

    if(verbose)("Parameters ready to align, beginning alignment... ")

    #alignment optimization
    parmx=NULL
    #align
    pct=proc.time()
    if(ncores>1 &
       requireNamespace("doRNG", quietly = TRUE) &
       requireNamespace("doParallel", quietly = TRUE)){
      cl=parallel::makeCluster(ncores)
      doParallel::registerDoParallel(cl)
      `%dorng%` <- doRNG::`%dorng%`
      parmx=foreach::foreach(
        k = 1:nstarts,
        .export=c("W","SF.mplus3D",
                  "CLF",'eps.alignment',
                  paste0('transformEstimates.',
                         estimator),
                  'clf.ignore.quantile'),
        .packages=c("abind",'purrr','dplyr','tibble')) %dorng% {
          # set.seed(k)
          if(hyper.first=='means'){
            outM=stats::optim(par=stats::rnorm(ngroups-1,0,5),
                              fn=SF.mplus3D,method="L-BFGS-B",
                              est=stacked,comb=comb,nobs=n,
                              estimator=estimator,
                              eps.alignment=eps.alignment,
                              clf.ignore.quantile=clf.ignore.quantile,
                              hyper='means',otherHyper=rep(1,ngroups-1),
                              control=list(maxit=10000,trace=0),
                              lower=rep(-Inf,ngroups-1))
            outV=stats::optim(par=abs(stats::rnorm(ngroups-1,0,3)),
                              fn=SF.mplus3D,method="L-BFGS-B",
                              est=stacked,comb=comb,nobs=n,
                              estimator=estimator,
                              eps.alignment=eps.alignment,
                              clf.ignore.quantile=clf.ignore.quantile,
                              hyper='variances',otherHyper=outM$par,
                              control=list(maxit=10000,trace=0),
                              lower=rep(1e-6,ngroups-1))
            c(value=outM$value+outV$value,
              convergence=outM$convergence & outV$convergence,
              par=c(outM$par,outV$par))
          } else if(hyper.first=='variances') {
            outV=stats::optim(par=abs(stats::rnorm(ngroups-1,0,3)),
                              fn=SF.mplus3D,method="L-BFGS-B",
                              est=stacked,comb=comb,nobs=n,
                              estimator=estimator,
                              eps.alignment=eps.alignment,
                              clf.ignore.quantile=clf.ignore.quantile,
                              hyper='variances',
                              otherHyper=rep(0,ngroups-1),
                              control=list(maxit=10000,trace=0),
                              lower=rep(1e-6,ngroups-1))
            outM=stats::optim(par=stats::rnorm(ngroups-1,0,5),
                              fn=SF.mplus3D,method="L-BFGS-B",
                              est=stacked,comb=comb,nobs=n,
                              estimator=estimator,
                              eps.alignment=eps.alignment,
                              clf.ignore.quantile=clf.ignore.quantile,
                              hyper='means',otherHyper=outV$par,
                              control=list(maxit=10000,trace=0),
                              lower=rep(-Inf,ngroups-1))

            c(value=outM$value+outV$value,
              convergence=outM$convergence & outV$convergence,
              par=c(outM$par,outV$par))
          } else if(hyper.first=='no') {
            out=stats::optim(par=c(stats::rnorm(ngroups-1,0,5),
                                   abs(stats::rnorm(ngroups-1,0,3))),
                             fn=SF.mplus3D,method="L-BFGS-B",
                             est=stacked,comb=comb,nobs=n,
                             estimator=estimator,
                             eps.alignment=eps.alignment,hyper='both',
                             control=list(maxit=10000,trace=0),
                             lower=c(rep(-Inf,ngroups-1),
                                     rep(1e-6,ngroups-1)))
            c(value=out$value,
              convergence=out$convergence,
              par=out$par)
          }
        }
      parallel::stopCluster(cl)
    } else {
      if(ncores>1)
        warning(
          paste0('ncores>1 but doRNG is not installed.',
                 'Parallel processing not used'))
      parmx=list()
      for(k in 1:nstarts) {
        if(hyper.first=='means'){
          outM=stats::optim(par=stats::rnorm(ngroups-1,0,5),
                            fn=SF.mplus3D,method="L-BFGS-B",
                            est=stacked,comb=comb,nobs=n,estimator=estimator,
                            eps.alignment=eps.alignment,
                            clf.ignore.quantile=clf.ignore.quantile,
                            hyper='means',otherHyper=rep(1,ngroups-1),
                            control=list(maxit=10000,trace=0),
                            lower=rep(-Inf,ngroups-1))
          outV=stats::optim(par=abs(stats::rnorm(ngroups-1,0,3)),
                            fn=SF.mplus3D,method="L-BFGS-B",
                            est=stacked,comb=comb,nobs=n,estimator=estimator,
                            eps.alignment=eps.alignment,
                            clf.ignore.quantile=clf.ignore.quantile,
                            hyper='variances',otherHyper=outM$par,
                            control=list(maxit=10000,trace=0),
                            lower=rep(1e-6,ngroups-1))
          parmx[[k]]=c(value=outM$value+outV$value,
                       convergence=outM$convergence & outV$convergence,
                       par=c(outM$par,outV$par))
        } else if(hyper.first=='variances') {
          outV=stats::optim(par=abs(stats::rnorm(ngroups-1,0,3)),
                            fn=SF.mplus3D,method="L-BFGS-B",
                            est=stacked,comb=comb,nobs=n,estimator=estimator,
                            eps.alignment=eps.alignment,
                            clf.ignore.quantile=clf.ignore.quantile,
                            hyper='variances',otherHyper=rep(0,ngroups-1),
                            control=list(maxit=10000,trace=0),
                            lower=rep(1e-6,ngroups-1))
          outM=stats::optim(par=stats::rnorm(ngroups-1,0,5),
                            fn=SF.mplus3D,method="L-BFGS-B",
                            est=stacked,comb=comb,nobs=n,estimator=estimator,
                            eps.alignment=eps.alignment,
                            clf.ignore.quantile=clf.ignore.quantile,
                            hyper='means',otherHyper=outV$par,
                            control=list(maxit=10000,trace=0),
                            lower=rep(-Inf,ngroups-1))
          parmx[[k]]=c(value=outM$value+outV$value,
                       convergence=outM$convergence & outV$convergence,
                       par=c(outM$par,outV$par))
        } else if(hyper.first=='no') {
          out=stats::optim(par=c(stats::rnorm(ngroups-1,0,5),
                                 abs(stats::rnorm(ngroups-1,0,3))),
                           fn=SF.mplus3D,method="L-BFGS-B",
                           est=stacked,comb=comb,nobs=n,estimator=estimator,
                           eps.alignment=eps.alignment,hyper='all',
                           clf.ignore.quantile=clf.ignore.quantile,
                           control=list(maxit=10000,trace=0),
                           lower=c(rep(-Inf,ngroups-1),rep(1e-6,ngroups-1)))
          parmx[[k]]=c(value=out$value,convergence=out$convergence,par=out$par)
        }
      }
    }
    parout=parmx%>%stats::setNames(1:nstarts)%>%dplyr::bind_cols()%>%t()

    #reject runs that failed
    failedruns=which(parout[,2]!=0)
    nFailedRuns=length(failedruns)
    if(length(failedruns)==nrow(parout))
      stop('No starting values converged. No aligned solution has been found.
Increase starting values or add more common items across groups.')
    if(length(failedruns)>0)parout=parout[-failedruns,]
    if(is.null(nrow(parout)))parout=matrix(parout,nrow=1)

    #name parameter list
    colnames(parout)=c("f","convergence",
                       paste0("M.",2:ngroups),
                       paste0("V.",2:ngroups))

    #populate
    #this line takes the lowest objective function value
    align.hyperpars=parout[order(parout[,1],decreasing=FALSE)[1],-c(1,2)]
    align.means=align.hyperpars[1:(ngroups-1)]
    align.variances=align.hyperpars[((ngroups-1)+1):((ngroups-1)*2)]
    #populate means, centering at zero to start
    align.means=c(0,align.means)
    align.variances=c(1/prod(align.variances),align.variances)
    #re-center at weighted averages for grand mean of zero
    if(center.means)align.means=
      align.means-stats::weighted.mean(align.means,unlist(n))

    #re-center such that weighted product is 1
    weighted.prod=exp(stats::weighted.mean(log(align.variances),unlist(n)))
    align.variances=align.variances/weighted.prod
    weighted.prod=exp(stats::weighted.mean(log(align.variances),unlist(n)))
    weighted.prod #nice

    #means are in SD units for first group; divide by SD of first group
    align.means=align.means/sqrt(align.variances[1])
    #variances just need to be divided by the first one
    align.variances=align.variances/align.variances[1]
  } else {
    align.means=0
    align.variances=1
    parout=NULL
    nFailedRuns=NA
  }

  #return means and variances, plus parout
  out=list(mv=mapply(function(x,y)c(mean=x,var=y),
                     as.list(align.means),
                     as.list(align.variances),SIMPLIFY=FALSE),
           parout=parout,nFailedRuns=nFailedRuns)
  return(out)
}

Try the AlignLV package in your browser

Any scripts or data that you put into this service are public.

AlignLV documentation built on Oct. 4, 2024, 5:11 p.m.