Nothing
# two-level ML estimation with random slopes (the rv() modifier)
#
# YR July 2026 (with help from Claude)
#
# Reference:
# - Rockwood, N. J. (2020). Maximum likelihood estimation of multilevel
# structural equation models with random slopes for latent covariates.
# Psychometrika, 85(2), 275-300.
#
# Two settings are implemented:
#
# 1) all random slopes involve *observed* level-1 covariates: all random
# effects are 'linear' (Rockwood, 2020, section 3.2) and the marginal
# (observed-data) loglikelihood is available in closed form (no
# numerical integration);
#
# 2) one or more random slopes involve a *latent* level-1 covariate:
# these slopes are 'nonlinear' random effects; conditional on their
# values, the model reduces to setting (1), so the loglikelihood is
# obtained by (Gauss-Hermite) quadrature over the nonlinear slopes
# only, while all linear random effects are still integrated out
# analytically (Rockwood, 2020, sections 3.3-3.6); see the
# 'quadrature' section below (lav_mvn_cl_rs_loglik_nl).
#
# NOTE: the likelihood is *conditional* on the covariates that carry random
# slopes (and on the between-level exogenous covariates): their joint
# distribution with the outcomes is no longer normal.
#
# Missing data (missing = "ml") is handled throughout: the per-case
# weight matrix becomes pattern-specific (see lav_mvn_cl_rs_loglik_m),
# the E-step additionally completes the missing y-values (see
# lav_mvn_cl_rs_estep_m), and lavPredict() conditions on the observed
# coordinates only.
#
# The model for cluster j (complete data, single group):
#
# v_b = (y_b, z) ~ N(mu_v(w_j), Sigma_v) [between; z = random slopes]
# y_ij | v_b ~ N(mu_w + P x_ij + Q_ij v_b, Sigma_w), independent over i
#
# where
# - y_b = between components of the 'both-level' variables (pb)
# - z = the random slopes, latent variables at level 2 (q)
# - w_j = between-level exogenous covariates (conditioned upon)
# - x_ij = level-1 covariates with random slopes (conditioned upon)
# - Sigma_w = within-implied covariance of the level-1 y-vector (the
# random-slope cells in GLIST are zero, so the standard
# LISREL formula gives the *conditional* covariance)
# - P = reduced-form *fixed* slopes of y on x (from the within GLIST)
# - Q_ij = [ E_b , L_1 x_(1)ij , ..., L_q x_(q)ij ] with
# L_r = (Lambda_w (I - B_w)^{-1})[, lhs(r)]
#
# Because Q_ij is linear in x_ij, all cluster-wise sums that appear in the
# loglikelihood (and in the EM E-step) reduce to per-cluster crossproducts
# of (y, x) -- computed once (lav_mvn_cl_rs_stats), so that each evaluation
# of the loglikelihood costs O(nclusters * (p + q)^3), without per-case
# loops. (cfr. the 'partial sufficient statistics' of Mplus.)
# collect and validate all static information needed for the random-slope
# kernels; returns an index map ('rs_info') that is computed only once
# (and cached in lavcache[[g]]$rs)
lav_mvn_cl_rs_info <- function(lavmodel = NULL, lavpartable = NULL,
lavdata = NULL, lavoptions = NULL) {
# single group only (for now)
if (lavdata@ngroups > 1L) {
lav_msg_stop(gettext(
"random slopes (rv() modifier) are not supported (yet) for
multiple-group models."))
}
if (lavdata@nlevels != 2L) {
lav_msg_stop(gettext(
"random slopes (rv() modifier) require a two-level model."))
}
# number of quadrature nodes per dimension (latent covariates only)
ngh <- 21L
if (!is.null(lavoptions) && !is.null(lavoptions$integration.ngh)) {
ngh <- as.integer(lavoptions$integration.ngh)
}
# path list: from the parameter table (with validation), or from
# lavmodel@rv.ov/@rv.lv (no validation; used post-estimation)
if (!is.null(lavpartable)) {
pt <- lavpartable
if (is.null(pt$level)) {
lav_msg_stop(gettext("parameter table has no level column."))
}
# the random-slope paths at level 1
# (the level column may hold user-chosen labels instead of 1L/2L)
level_values <- lav_pt_level_values(pt)
rs_idx <- which(nchar(pt$rv) > 0L & pt$op == "~" &
pt$level == level_values[1])
# random slopes at level 2 are not allowed
bad_idx <- which(nchar(pt$rv) > 0L & pt$op == "~" &
pt$level %in% level_values[-1L])
if (length(bad_idx) > 0L) {
lav_msg_stop(gettext(
"random slopes (rv() modifier) can only be used in the level-1
(within) part of the model."))
}
if (length(rs_idx) == 0L) {
lav_msg_stop(gettext("no random-slope (rv) regressions found at
level 1."))
}
path_rv <- pt$rv[rs_idx]
path_lhs <- pt$lhs[rs_idx]
path_rhs <- pt$rhs[rs_idx]
# covariate type per path: latent ("lv") or observed ("ov")
# (this mirrors the rv.lv/rv.ov classification in lav_model();
# 'split' both-level observed covariates count as latent: the
# random slope multiplies their latent within-cluster component)
lv_names_1 <- lav_pt_vnames(pt, "lv", block = 1L)
ov_names_pt_1 <- lav_pt_vnames(pt, "ov", block = 1L)
ov_names_pt_2 <- lav_pt_vnames(pt, "ov", block = 2L)
ov_12 <- ov_names_pt_2[ov_names_pt_2 %in% ov_names_pt_1]
path_type <- ifelse(path_rhs %in% c(lv_names_1, ov_12),
"lv", "ov")
} else {
pt <- NULL
rv_all <- c(lavmodel@rv.ov, lavmodel@rv.lv)
path_rv <- names(rv_all)
path_lhs <- sapply(rv_all, "[[", 1L)
path_rhs <- sapply(rv_all, "[[", 2L)
path_type <- rep(c("ov", "lv"),
c(length(lavmodel@rv.ov), length(lavmodel@rv.lv)))
}
z_names <- unique(path_rv) # canonical order of the random slopes
q <- length(z_names)
# slope type: a slope multiplying a latent covariate is a 'nonlinear'
# random effect (numerical integration); a single label may not mix
# covariate types
z_type <- character(q)
for (r in seq_len(q)) {
tps <- unique(path_type[path_rv == z_names[r]])
if (length(tps) > 1L) {
lav_msg_stop(gettextf(
"random-slope label %s is attached to both an observed and a
latent covariate; this is not supported (yet).", z_names[r]))
}
z_type[r] <- tps
}
nl_z_idx <- which(z_type == "lv")
nl_flag <- length(nl_z_idx) > 0L
# variable names per level
ov_names_1 <- lavdata@ov.names.l[[1]][[1]] # within
ov_names_2 <- lavdata@ov.names.l[[1]][[2]] # between
both_names <- ov_names_1[ov_names_1 %in% ov_names_2]
within_only <- ov_names_1[!ov_names_1 %in% ov_names_2]
between_only <- ov_names_2[!ov_names_2 %in% ov_names_1]
# the observed covariates that are conditioned upon: the (observed)
# rv covariates PLUS all other within-only exogenous (fixed.x)
# covariates -- the loglikelihood is conditional on ALL of them
# (cfr. Mplus TYPE = TWOLEVEL RANDOM, which conditions on all
# observed x-covariates); the fixed regression paths of the
# non-rv covariates enter through the reduced-form P matrix
x_rv <- unique(path_rhs[path_type == "ov"])
x_extra <- within_only[within_only %in% lavdata@ov.names.x[[1]] &
!within_only %in% x_rv]
x_names <- c(x_rv, x_extra)
# validation (only if we have a parameter table)
if (!is.null(pt)) {
# note on 'split' both-level covariates (rhs in ov_12): the random
# slope multiplies the *latent* within-cluster component of the
# covariate (latent decomposition/centering, cfr. Rockwood's
# simulation model); the covariate is then jointly modeled: it is
# part of the y-vector, its within dummy lv is the beta_w
# injection target, and its between dummy lv is an ordinary linear
# random effect inside v_b -- no special handling needed below
# exogenous variables (fixed.x)
ov_x_1 <- lav_pt_vnames(pt, "ov.x", block = 1L)
ov_x_2 <- lav_pt_vnames(pt, "ov.x", block = 2L)
# the observed rv covariates must be observed, within-only, and
# exogenous
bad <- x_rv[!x_rv %in% within_only]
if (length(bad) > 0L) {
lav_msg_stop(gettextf(
"covariate(s) with a random slope must be observed within-only
variables (they should not appear in the level-2 part of the
model): %s.", paste(bad, collapse = " ")))
}
bad <- x_rv[!x_rv %in% ov_x_1]
if (length(bad) > 0L) {
lav_msg_stop(gettextf(
"covariate(s) with a random slope must be exogenous (fixed_x)
variables: %s.", paste(bad, collapse = " ")))
}
# the lhs of each random-slope path must be a level-1 dependent
# variable
bad <- path_lhs[!(path_lhs %in% lv_names_1 |
path_lhs %in% ov_names_1)]
if (length(bad) > 0L) {
lav_msg_stop(gettextf(
"left-hand side of random-slope regression(s) not found among the
level-1 (latent or observed) variables: %s.",
paste(bad, collapse = " ")))
}
bad <- path_lhs[path_lhs %in% x_names]
if (length(bad) > 0L) {
lav_msg_stop(gettextf(
"left-hand side of random-slope regression(s) cannot be an
exogenous covariate: %s.", paste(bad, collapse = " ")))
}
# the random slopes must be 'pure' latent variables at level 2:
# no (real) indicators
bad_idx <- which(pt$op == "=~" & pt$lhs %in% z_names &
pt$rhs != pt$lhs)
if (length(bad_idx) > 0L) {
lav_msg_stop(gettextf(
"random slope(s) cannot have indicators at level 2: %s.",
paste(unique(pt$lhs[bad_idx]), collapse = " ")))
}
}
# the within y-vector: all within variables, except the rv covariates
y_names <- ov_names_1[!ov_names_1 %in% x_names]
p1 <- length(y_names)
# path table
path_tab <- data.frame(
rv = path_rv,
lhs = path_lhs,
rhs = path_rhs,
type = path_type,
z.idx = match(path_rv, z_names),
x.idx = match(path_rhs, x_names), # NA for latent covariates
stringsAsFactors = FALSE
)
# data column indices (in lavdata@X[[1]], columns = lavdata@ov.names[[1]])
ov_names_data <- lavdata@ov.names[[1]]
y_data_idx <- match(y_names, ov_names_data)
x_data_idx <- match(x_names, ov_names_data)
# between-only variables: exogenous covariates (w; conditioned
# upon) vs *endogenous* z-outcomes (Rockwood's z-variables: observed
# once per cluster, and modeled -- e.g., indicators of a
# between-level factor)
mm_b0 <- 1:lavmodel@nmat[2] + lavmodel@nmat[1]
glist_names_b0 <- names(lavmodel@GLIST)[mm_b0]
lambda_mm_b <- mm_b0[which(glist_names_b0 == "lambda")]
lv_names_b_all <- lavmodel@dimNames[[lambda_mm_b]][[2]]
if (!is.null(pt)) {
exo_b_names <- between_only[between_only %in% ov_x_2]
} else {
# without a parameter table: the exogenous covariates appear as
# dummy latent variables in the between block; the z-outcomes
# (pure indicators) do not
exo_b_names <- between_only[between_only %in% lv_names_b_all]
}
zb_names <- between_only[!between_only %in% exo_b_names]
kz <- length(zb_names)
exo_b_data_idx <- match(exo_b_names, ov_names_data)
zb_data_idx <- match(zb_names, ov_names_data)
# z-outcomes must be pure indicators: a z-variable that is itself
# involved in a regression gets a dummy latent variable (and a
# zero residual variance), which does not fit the 'extra
# observation block' treatment below
bad <- zb_names[zb_names %in% lv_names_b_all]
if (length(bad) > 0L) {
lav_msg_stop(gettextf(
"between-only endogenous observed variables can only appear as
indicators of a between-level latent variable (for now); not
as outcome or predictor in a regression: %s.",
paste(bad, collapse = " ")))
}
# the both-level variables
yb_names <- both_names
pb <- length(yb_names)
# position of the y_b variables within the y-vector
yb_y_idx <- match(yb_names, y_names)
# the between random vector v_b = (eta_b, eps_b) lives in 'factor'
# space: the between latent variables (excluding the dummy lv's of the
# between-level exogenous covariates, which are conditioned out),
# followed by the between residuals (eps_b) of the both-level
# variables -- but only those with a free (or fixed to a nonzero
# value) residual (co)variance. This keeps v_b minimal, and Sigma_v
# positive definite even when (some) between residual variances are
# fixed to zero (as in Mplus ex9.8).
mm_b <- 1:lavmodel@nmat[2] + lavmodel@nmat[1]
glist_names_b <- names(lavmodel@GLIST)[mm_b]
theta_mm <- mm_b[which(glist_names_b == "theta")]
theta_b <- lavmodel@GLIST[[theta_mm]]
free_mat <- matrix(FALSE, nrow(theta_b), ncol(theta_b))
free_mat[lavmodel@m.free.idx[[theta_mm]]] <- TRUE
nonzero_mat <- (theta_b != 0) | free_mat
ov_names_b_theta <- lavmodel@dimNames[[theta_mm]][[1]]
yb_b_idx <- match(yb_names, ov_names_b_theta)
eps_flag <- apply(nonzero_mat[yb_b_idx, , drop = FALSE], 1L, any)
eps_names <- yb_names[eps_flag]
# z-outcomes: the residual block Sigma_z must be positive definite
# (all diagonal elements free or fixed to a nonzero value), and may
# not covary with the between residuals of the both-level variables
if (kz > 0L) {
zb_b_idx <- match(zb_names, ov_names_b_theta)
bad <- zb_names[!nonzero_mat[cbind(zb_b_idx, zb_b_idx)]]
if (length(bad) > 0L) {
lav_msg_stop(gettextf(
"between-only endogenous observed variables must have a free
(or fixed to a nonzero value) residual variance: %s.",
paste(bad, collapse = " ")))
}
if (pb > 0L &&
any(nonzero_mat[zb_b_idx, yb_b_idx, drop = FALSE])) {
lav_msg_stop(gettext(
"residual covariances between between-only endogenous
observed variables and the both-level variables are not
supported (yet)."))
}
}
# structural fixed paths from the rv covariates? (a nonzero 'P'
# matrix); the EM version cannot handle these (the per-case mean
# P x_{ji} does not fit in the two-group complete-data model);
# nlminb handles the general case
mm_w <- 1:lavmodel@nmat[1]
glist_names_w <- names(lavmodel@GLIST)[mm_w]
beta_mm <- mm_w[which(glist_names_w == "beta")]
p_flag <- FALSE
if (length(beta_mm) > 0L) {
beta_w <- lavmodel@GLIST[[beta_mm]]
free_mat_w <- matrix(FALSE, nrow(beta_w), ncol(beta_w))
free_mat_w[lavmodel@m.free.idx[[beta_mm]]] <- TRUE
lv_names_w <- lavmodel@dimNames[[beta_mm]][[2]]
x_lv_idx_w <- match(x_names, lv_names_w)
x_lv_idx_w <- x_lv_idx_w[!is.na(x_lv_idx_w)]
if (length(x_lv_idx_w) > 0L) {
p_flag <- any(free_mat_w[, x_lv_idx_w]) ||
any(beta_w[, x_lv_idx_w] != 0)
}
}
# shared rv labels (the same slope attached to more than one path)?
# the EM version requires a one-to-one path <-> slope mapping
shared_flag <- (nrow(path_tab) != q) ||
!identical(path_tab$z.idx, seq_len(q))
# latent covariates: GLIST cell positions of the (fixed-to-zero)
# within regression paths; the quadrature driver injects the node
# values for the nonlinear slopes there
nl_beta_row <- nl_beta_col <- integer(0L)
nl_beta_mm <- integer(0L)
if (nl_flag) {
if (length(beta_mm) == 0L) {
lav_msg_stop(gettext(
"no beta matrix found in the within part of the model
(random slopes for latent covariates)."))
}
lv_names_w_all <- lavmodel@dimNames[[beta_mm]][[2]]
nl_path_idx <- which(path_type == "lv")
nl_beta_row <- match(path_lhs[nl_path_idx], lv_names_w_all)
nl_beta_col <- match(path_rhs[nl_path_idx], lv_names_w_all)
if (anyNA(nl_beta_row) || anyNA(nl_beta_col)) {
lav_msg_stop(gettext(
"unable to locate the latent-covariate random-slope path(s) in
the within beta matrix."))
}
nl_beta_mm <- beta_mm
}
list(
z.names = z_names, q = q,
x.names = x_names, nx = length(x_names),
y.names = y_names, p1 = p1,
yb.names = yb_names, pb = pb, yb.y.idx = yb_y_idx,
eps.names = eps_names, neps = length(eps_names),
exo.b.names = exo_b_names, nexo.b = length(exo_b_names),
zb.names = zb_names, kz = kz, zb.data.idx = zb_data_idx,
path.tab = path_tab,
y.data.idx = y_data_idx,
x.data.idx = x_data_idx,
exo.b.data.idx = exo_b_data_idx,
p.flag = p_flag, shared.flag = shared_flag,
# latent covariates (nonlinear random slopes; quadrature)
z.type = z_type, nl.flag = nl_flag,
nl.z.idx = nl_z_idx, q.nl = length(nl_z_idx),
nl.beta.mm = nl_beta_mm,
nl.beta.row = nl_beta_row, nl.beta.col = nl_beta_col,
ngh = ngh
)
}
# per-cluster crossproduct statistics; computed once
#
# with missing data (missing = "ml"), the y-block may contain NAs; the
# crossproducts are then computed per (cluster, missing-pattern) cell
# and stored in the 'mp' component; the covariates (both the rv
# covariates and the between-level exogenous covariates) must be
# complete: cases with missing values on exogenous variables have
# already been removed by lav_data (fixed.x = TRUE)
lav_mvn_cl_rs_stats <- function(y1 = NULL, lp = NULL, rs_info = NULL) {
cluster_idx <- lp$cluster.idx[[2]]
nclusters <- lp$nclusters[[2]]
cluster_size <- lp$cluster.size[[2]]
p1 <- rs_info$p1
nx <- rs_info$nx
yy <- y1[, rs_info$y.data.idx, drop = FALSE]
xx <- y1[, rs_info$x.data.idx, drop = FALSE]
if (anyNA(xx)) {
lav_msg_stop(gettext(
"unexpected missing values in the covariate(s) with a random
slope; such cases should have been removed (fixed_x = TRUE)."))
}
# between-level exogenous covariates: one row per cluster
if (rs_info$nexo.b > 0L) {
first_idx <- which(!duplicated(cluster_idx))
# order by cluster index (1..G)
first_idx <- first_idx[order(cluster_idx[first_idx])]
exo_b <- y1[first_idx, rs_info$exo.b.data.idx, drop = FALSE]
if (anyNA(exo_b)) {
lav_msg_stop(gettext(
"unexpected missing values in the between-level exogenous
covariate(s); such cases should have been removed
(fixed_x = TRUE)."))
}
} else {
exo_b <- matrix(0, nclusters, 0L)
}
# between-only endogenous z-outcomes: one row per cluster
if (rs_info$kz > 0L) {
first_idx <- which(!duplicated(cluster_idx))
first_idx <- first_idx[order(cluster_idx[first_idx])]
zb <- y1[first_idx, rs_info$zb.data.idx, drop = FALSE]
if (anyNA(zb)) {
lav_msg_stop(gettext(
"missing values in between-only endogenous observed variables
are not supported (yet) in combination with random slopes."))
}
} else {
zb <- matrix(0, nclusters, 0L)
}
# missing data in the y-block?
mp <- NULL
if (anyNA(yy)) {
obs <- !is.na(yy)
nobs_case <- rowSums(obs)
# cases with no observed outcome values carry no information (the
# likelihood is conditional on the covariates)
empty_idx <- which(nobs_case == 0L)
if (length(empty_idx) > 0L) {
lav_msg_warn(gettextf(
"%s case(s) have missing values on all within-level outcome
variables; they do not contribute to the loglikelihood.",
length(empty_idx)))
keep_idx <- seq_len(nrow(yy))[-empty_idx]
} else {
keep_idx <- seq_len(nrow(yy))
}
# NA -> 0: the padded crossproduct entries are annihilated by the
# (embedded) per-pattern weight matrices later on
yy0 <- yy
yy0[is.na(yy0)] <- 0
# missing patterns (over the y-block only)
case_id <- apply(1L * obs, 1L, paste, collapse = "")
pat_ids <- unique(case_id[keep_idx])
npatterns <- length(pat_ids)
pattern <- vector("list", npatterns)
for (p in seq_len(npatterns)) {
case_p <- keep_idx[case_id[keep_idx] == pat_ids[p]]
o <- which(obs[case_p[1L], ])
clus_p <- cluster_idx[case_p]
yp <- yy0[case_p, , drop = FALSE]
xp <- xx[case_p, , drop = FALSE]
# per-cluster sums for this pattern (rows ordered by cluster id)
sy_p <- rowsum.default(yp, clus_p, reorder = TRUE)
sx_p <- rowsum.default(xp, clus_p, reorder = TRUE)
n_p <- as.numeric(rowsum.default(
rep(1, length(case_p)), clus_p, reorder = TRUE))
j1 <- sort(unique.default(clus_p))
idx_split <- split.default(seq_along(case_p), clus_p)
nclus_p <- length(j1)
sxx_p <- vector("list", nclus_p)
sxy_p <- vector("list", nclus_p)
syy_p <- vector("list", nclus_p)
for (jj in seq_len(nclus_p)) {
ii <- idx_split[[jj]]
yjj <- yp[ii, , drop = FALSE]
xjj <- xp[ii, , drop = FALSE]
syy_p[[jj]] <- crossprod(yjj)
sxx_p[[jj]] <- crossprod(xjj)
sxy_p[[jj]] <- crossprod(xjj, yjj)
}
pattern[[p]] <- list(
o = o, j1 = j1, n = n_p, sy = sy_p, sx = sx_p,
sxx = sxx_p, sxy = sxy_p, syy = syy_p
)
}
mp <- list(
npatterns = npatterns, pattern = pattern,
nobs = sum(nobs_case), empty.idx = empty_idx
)
return(list(
nclusters = nclusters, cluster.size = cluster_size,
sy = NULL, sx = NULL, sxx = NULL, sxy = NULL, syy = NULL,
exo.b = exo_b, zb = zb, ntotal = sum(cluster_size),
ntotal.used = sum(cluster_size) - length(empty_idx), mp = mp
))
}
# per-cluster sums and crossproducts
sy <- rowsum.default(yy, cluster_idx, reorder = TRUE) # G x p1
sx <- rowsum.default(xx, cluster_idx, reorder = TRUE) # G x nx
sxx <- vector("list", nclusters)
sxy <- vector("list", nclusters)
syy <- vector("list", nclusters)
# split row indices per cluster (loop over clusters; matrices are small)
row_idx <- split.default(seq_len(nrow(y1)), cluster_idx)
for (j in seq_len(nclusters)) {
idx <- row_idx[[j]]
yj <- yy[idx, , drop = FALSE]
xj <- xx[idx, , drop = FALSE]
syy[[j]] <- crossprod(yj)
sxx[[j]] <- crossprod(xj)
sxy[[j]] <- crossprod(xj, yj)
}
list(
nclusters = nclusters, cluster.size = cluster_size,
sy = sy, sx = sx, sxx = sxx, sxy = sxy, syy = syy,
exo.b = exo_b, zb = zb, ntotal = sum(cluster_size),
ntotal.used = sum(cluster_size), mp = NULL
)
}
# model-implied ingredients for the random-slope loglikelihood,
# computed from GLIST
#
# returns: sigma_w (p1 x p1), mu_w (p1), P (p1 x nx),
# lmat (p1 x npaths), sigma.v (pv x pv), mu.v (pv),
# cc (pv x nexo.b), mu.exo (nexo.b)
lav_mvn_cl_rs_implied <- function(lavmodel = NULL, glist = NULL,
rs_info = NULL) {
if (is.null(glist)) {
glist <- lavmodel@GLIST
}
nmat <- lavmodel@nmat
# within block
mm_w <- 1:nmat[1]
mlist_w <- glist[mm_w]
dimnames_w <- lavmodel@dimNames[mm_w]
names(dimnames_w) <- names(glist)[mm_w]
ov_names_w <- dimnames_w[["lambda"]][[1]]
lv_names_w <- dimnames_w[["lambda"]][[2]]
y_idx <- match(rs_info$y.names, ov_names_w)
x_lv_idx <- match(rs_info$x.names, lv_names_w)
lambda_w <- mlist_w$lambda
psi_w <- mlist_w$psi
theta_w <- mlist_w$theta
beta_w <- mlist_w$beta
nu_w <- mlist_w$nu
alpha_w <- mlist_w$alpha
nlv_w <- ncol(lambda_w)
if (is.null(beta_w)) {
ib_inv_w <- diag(nlv_w)
} else {
ib_inv_w <- try(solve(diag(nlv_w) - beta_w), silent = TRUE)
if (inherits(ib_inv_w, "try-error")) {
return(NULL)
}
}
# reduced form: ov on lv
rf_w <- lambda_w %*% ib_inv_w
# condition on the x-covariates: zero out their psi rows/cols and alpha
psi_w0 <- psi_w
alpha_w0 <- if (is.null(alpha_w)) {
matrix(0, nlv_w, 1L)
} else {
alpha_w
}
if (length(x_lv_idx) > 0L) {
psi_w0[x_lv_idx, ] <- 0
psi_w0[, x_lv_idx] <- 0
alpha_w0[x_lv_idx, 1L] <- 0
}
# Sigma_w: conditional (on x and z) covariance of the y-vector
rf_y <- rf_w[y_idx, , drop = FALSE]
sigma_w <- rf_y %*% psi_w0 %*% t(rf_y) +
theta_w[y_idx, y_idx, drop = FALSE]
# mu_w: within intercept part of the y-vector
nu_w0 <- if (is.null(nu_w)) {
matrix(0, nrow(lambda_w), 1L)
} else {
nu_w
}
mu_w <- as.numeric(nu_w0[y_idx, 1L] + rf_y %*% alpha_w0)
# P: reduced-form *fixed* slopes of y on the x-covariates
# (the random-slope cells in beta are zero, so only the fixed paths
# contribute)
pmat <- rf_y[, x_lv_idx, drop = FALSE]
# L_r columns, one per random-slope path
path_tab <- rs_info$path.tab
npaths <- nrow(path_tab)
lmat <- matrix(0, rs_info$p1, npaths)
for (i in seq_len(npaths)) {
lhs_lv_idx <- match(path_tab$lhs[i], lv_names_w)
if (is.na(lhs_lv_idx)) {
# lhs is an observed variable without a dummy lv: direct row
lhs_ov_idx <- match(path_tab$lhs[i], rs_info$y.names)
lmat[lhs_ov_idx, i] <- 1
} else {
lmat[, i] <- rf_y[, lhs_lv_idx]
}
}
# between block
mm_b <- 1:nmat[2] + nmat[1]
mlist_b <- glist[mm_b]
dimnames_b <- lavmodel@dimNames[mm_b]
names(dimnames_b) <- names(glist)[mm_b]
ov_names_b <- dimnames_b[["lambda"]][[1]]
lv_names_b <- dimnames_b[["lambda"]][[2]]
yb_idx <- match(rs_info$yb.names, ov_names_b)
z_lv_idx <- match(rs_info$z.names, lv_names_b)
exo_ov_idx <- match(rs_info$exo.b.names, ov_names_b)
lambda_b <- mlist_b$lambda
psi_b <- mlist_b$psi
theta_b <- mlist_b$theta
beta_b <- mlist_b$beta
nu_b <- mlist_b$nu
alpha_b <- mlist_b$alpha
nlv_b <- ncol(lambda_b)
if (is.null(beta_b)) {
ib_inv_b <- diag(nlv_b)
} else {
ib_inv_b <- try(solve(diag(nlv_b) - beta_b), silent = TRUE)
if (inherits(ib_inv_b, "try-error")) {
return(NULL)
}
}
e_eta <- if (is.null(alpha_b)) {
matrix(0, nlv_b, 1L)
} else {
ib_inv_b %*% alpha_b
}
v_eta <- ib_inv_b %*% psi_b %*% t(ib_inv_b)
nu_b0 <- if (is.null(nu_b)) {
matrix(0, nrow(lambda_b), 1L)
} else {
nu_b
}
# the between random vector v_b = (eta_b, eps_b) in 'factor' space:
# - eta_b: all between latent variables (including the random slopes,
# and excluding the dummy lv's of the between-level exogenous
# covariates, which are conditioned out)
# - eps_b: the between residuals of the both-level variables with a
# non-zero residual (co)variance (see rs_info$eps.names)
pb <- rs_info$pb
q <- rs_info$q
nexo <- rs_info$nexo.b
neps <- rs_info$neps
exo_lv_idx <- match(rs_info$exo.b.names, lv_names_b)
if (nexo > 0L) {
eta_idx <- seq_len(nlv_b)[-exo_lv_idx]
} else {
eta_idx <- seq_len(nlv_b)
}
meta <- length(eta_idx)
pv <- meta + neps
# position of the random slopes within v_b
z_v_idx <- match(z_lv_idx, eta_idx)
# condition eta on the between-level exogenous covariates
if (nexo > 0L) {
v_ee <- v_eta[exo_lv_idx, exo_lv_idx, drop = FALSE]
v_ee_inv <- lav_mat_sym_inverse(v_ee)
cc_eta <- v_eta[eta_idx, exo_lv_idx, drop = FALSE] %*% v_ee_inv
v_eta_c <- v_eta[eta_idx, eta_idx, drop = FALSE] -
cc_eta %*% v_eta[exo_lv_idx, eta_idx, drop = FALSE]
v_eta_c <- (v_eta_c + t(v_eta_c)) / 2
mu_exo <- as.numeric(nu_b0[exo_ov_idx, 1L] + e_eta[exo_lv_idx, 1L])
} else {
cc_eta <- matrix(0, meta, 0L)
v_eta_c <- v_eta
mu_exo <- numeric(0L)
}
# Sigma_v and (unconditional part of) mu_v
sigma_v <- matrix(0, pv, pv)
sigma_v[seq_len(meta), seq_len(meta)] <- v_eta_c
mu_v <- numeric(pv)
mu_v[seq_len(meta)] <- e_eta[eta_idx, 1L]
cc <- matrix(0, pv, nexo)
cc[seq_len(meta), ] <- cc_eta
if (neps > 0L) {
eps_b_idx <- match(rs_info$eps.names, ov_names_b)
eps_pos <- meta + seq_len(neps)
sigma_v[eps_pos, eps_pos] <-
theta_b[eps_b_idx, eps_b_idx, drop = FALSE]
}
# constant loading matrix of v_b into the y-vector
q0 <- matrix(0, rs_info$p1, pv)
yb_y_idx <- rs_info$yb.y.idx
q0[yb_y_idx, seq_len(meta)] <- lambda_b[yb_idx, eta_idx, drop = FALSE]
if (neps > 0L) {
eps_y_idx <- match(rs_info$eps.names, rs_info$y.names)
for (i in seq_len(neps)) {
q0[eps_y_idx[i], meta + i] <- 1
}
}
# constant part of the mean of the y-vector: within intercepts +
# between (fixed) intercepts of the both-level variables
mu_y_b <- numeric(rs_info$p1)
mu_y_b[yb_y_idx] <- nu_b0[yb_idx, 1L]
mu_y <- mu_w + mu_y_b
# between-only endogenous z-outcomes: conditional on v_b,
# z_j ~ N(mu_z + G v_b, sigma_z)
# with G the between loadings of the z-block on the eta part of v_b
# (the z-outcomes are pure indicators: no loadings on the eps part,
# no dummy latent variables)
kz <- rs_info$kz
gmat <- matrix(0, kz, pv)
mu_z <- numeric(kz)
sigma_z <- matrix(0, kz, kz)
if (kz > 0L) {
zb_idx <- match(rs_info$zb.names, ov_names_b)
gmat[, seq_len(meta)] <- lambda_b[zb_idx, eta_idx, drop = FALSE]
mu_z <- as.numeric(nu_b0[zb_idx, 1L])
sigma_z <- theta_b[zb_idx, zb_idx, drop = FALSE]
}
list(
sigma_w = sigma_w, mu_y = mu_y, mu_y_b = mu_y_b, P = pmat,
lmat = lmat, q0 = q0, z.v.idx = z_v_idx, pv = pv,
sigma.v = sigma_v, mu.v = mu_v, cc = cc, mu.exo = mu_exo,
eta.names = lv_names_b[eta_idx], meta = meta,
mu_z = mu_z, gmat = gmat, sigma.z = sigma_z
)
}
# -2 * (observed-data, conditional-on-x) loglikelihood
# computed from the per-cluster crossproduct statistics
lav_mvn_cl_rs_loglik <- function(rs_stats = NULL, imp = NULL,
rs_info = NULL,
sinv_method = "eigen",
log2pi = TRUE,
minus_two = TRUE,
per_cluster = FALSE) {
# missing data? use the pattern-based version
if (!is.null(rs_stats$mp)) {
return(lav_mvn_cl_rs_loglik_m(
rs_stats = rs_stats, imp = imp, rs_info = rs_info,
sinv_method = sinv_method, log2pi = log2pi,
minus_two = minus_two, per_cluster = per_cluster
))
}
# non-PD or failed implied?
if (is.null(imp)) {
return(+Inf)
}
p1 <- rs_info$p1
nx <- rs_info$nx
pv <- imp$pv
path_tab <- rs_info$path.tab
npaths <- nrow(path_tab)
nclusters <- rs_stats$nclusters
nj_all <- rs_stats$cluster.size
# invert sigma_w
sigma_w_inv <- lav_mat_sym_inverse(imp$sigma_w,
logdet = TRUE, sinv_method = sinv_method
)
logdet_w <- attr(sigma_w_inv, "logdet")
if (!is.finite(logdet_w)) {
return(+Inf)
}
# NOTE: we never invert sigma.v; we use the identities
# logdet(Sigma_v) + logdet(Sigma_v^{-1} + A) = logdet(I + A Sigma_v)
# (Sigma_v^{-1} + A)^{-1} = Sigma_v (I + A Sigma_v)^{-1}
# so that the loglikelihood remains well-defined when Sigma_v is
# singular (eg between residual variances fixed to zero) or even
# (mildly) indefinite (Heywood territory), as long as every
# cluster-level marginal covariance matrix remains positive definite
sigma_v <- imp$sigma.v
if (anyNA(sigma_v)) {
return(+Inf)
}
# is sigma.v PSD? (fast path: no per-cluster eigenvalue checks needed)
ev_v <- eigen(sigma_v, symmetric = TRUE, only.values = TRUE)$values
v_psd <- ev_v[length(ev_v)] > -1e-12
w_mat <- sigma_w_inv
lmat <- imp$lmat
pmat <- imp$P
mu_y <- imp$mu_y
q0 <- imp$q0
z_v_idx <- imp$z.v.idx
# precompute
wl <- w_mat %*% lmat # p1 x npaths
ltwl <- crossprod(lmat, wl) # npaths x npaths
wq0 <- w_mat %*% q0 # p1 x pv
q0twq0 <- crossprod(q0, wq0) # pv x pv
q0twl <- crossprod(q0, wl) # pv x npaths
wp <- w_mat %*% pmat # p1 x nx
ptwp <- crossprod(pmat, wp) # nx x nx
mu_y_w <- as.numeric(w_mat %*% mu_y) # p1
path_z <- path_tab$z.idx # 1..q
path_x <- path_tab$x.idx # 1..nx
path_zcol <- z_v_idx[path_z] # column in v_b, per path
# between-only endogenous z-outcomes: one extra (per-cluster)
# Gaussian observation block, z_j | v_b ~ N(mu_z + G v_b, Sigma_z)
kz <- rs_info$kz
if (kz > 0L) {
wzb <- lav_mat_sym_inverse(imp$sigma.z,
logdet = TRUE, sinv_method = sinv_method
)
logdet_zb <- attr(wzb, "logdet")
if (!is.finite(logdet_zb)) {
return(+Inf)
}
wzg <- wzb %*% imp$gmat # kz x pv
a_zb <- crossprod(imp$gmat, wzg) # pv x pv
zb_all <- rs_stats$zb
mu_zb <- imp$mu_z
}
# more constants (hoisted out of the cluster loop)
pmat_t <- t(pmat)
wp_t <- t(wp)
mu_y_w_p <- as.numeric(mu_y_w %*% pmat) # nx
mu_y_quad <- sum(mu_y_w * mu_y)
dpv <- diag(pv)
sigma_v_local <- sigma_v
mu_v <- imp$mu.v
cc_mat <- imp$cc
mu_exo <- imp$mu.exo
nexo <- rs_info$nexo.b
exo_b <- rs_stats$exo.b
sy_all <- rs_stats$sy
sx_all <- rs_stats$sx
sxx_all <- rs_stats$sxx
sxy_all <- rs_stats$sxy
syy_all <- rs_stats$syy
loglik_j <- numeric(nclusters)
ok <- TRUE
ok <- tryCatch({
for (j in seq_len(nclusters)) {
nj <- nj_all[j]
sy_j <- sy_all[j, ]
sx_j <- sx_all[j, ]
sxx_j <- sxx_all[[j]]
sxy_j <- sxy_all[[j]]
syy_j <- syy_all[[j]]
# centered sums: e~_i = y_i - mu_y - P x_i
# sum_i e~_i
se_j <- sy_j - nj * mu_y - as.numeric(pmat %*% sx_j)
# sum_i x_ai e~_i' (nx x p1)
sxe_j <- sxy_j - outer(sx_j, mu_y) - sxx_j %*% pmat_t
# sum_i e~_i' W e~_i
quad0 <- sum(w_mat * syy_j) -
2 * sum(mu_y_w * sy_j) +
nj * mu_y_quad -
2 * sum(wp_t * sxy_j) +
2 * sum(mu_y_w_p * sx_j) +
sum(ptwp * sxx_j)
# Q_ij = Q0 + sum_p L_p x_pij e_{z(p)}'
# A_j = sum_i Q_ij' W Q_ij; p_j = sum_i Q_ij' W e~_i
# constant part
a_j <- nj * q0twq0
p_j <- as.numeric(crossprod(wq0, se_j))
# cross part: Q0' W (sum_i Lx_i) (only the z columns are involved)
cz <- matrix(0, pv, pv)
for (i in seq_len(npaths)) {
zcol <- path_zcol[i]
cz[, zcol] <- cz[, zcol] + q0twl[, i] * sx_j[path_x[i]]
}
a_j <- a_j + cz + t(cz)
# z x z part: sum_i (Lx_i)' W (Lx_i)
for (i in seq_len(npaths)) {
for (k in seq_len(npaths)) {
a_j[path_zcol[i], path_zcol[k]] <-
a_j[path_zcol[i], path_zcol[k]] +
ltwl[i, k] * sxx_j[path_x[i], path_x[k]]
}
}
# p_j z part: sum_p L_p' W (sum_i x_pi e~_i)
for (i in seq_len(npaths)) {
zi <- path_zcol[i]
p_j[zi] <- p_j[zi] + sum(wl[, i] * sxe_j[path_x[i], ])
}
# z-outcome contributions (the extra observation block)
if (kz > 0L) {
e_zb <- zb_all[j, ] - mu_zb
a_j <- a_j + a_zb
p_j <- p_j + as.numeric(crossprod(wzg, e_zb))
quad0 <- quad0 + sum(e_zb * (wzb %*% e_zb))
}
# mean of v_b for this cluster (given the between covariates)
if (nexo > 0L) {
d_j <- mu_v + as.numeric(cc_mat %*% (exo_b[j, ] - mu_exo))
} else {
d_j <- mu_v
}
# center v_b at its mean
p_tilde <- p_j - as.numeric(a_j %*% d_j)
quad_j <- quad0 - 2 * sum(d_j * p_j) + sum(d_j * (a_j %*% d_j))
# Z_j = I + A_j Sigma_v; logdet(Z_j) and Sigma_v Z_j^{-1} p~
z_j <- dpv + a_j %*% sigma_v_local
if (v_psd) {
dt <- determinant(z_j, logarithm = TRUE)
if (dt$sign <= 0 || !is.finite(dt$modulus)) {
ok <- FALSE
break
}
logdet_z <- as.numeric(dt$modulus)
} else {
# sigma.v indefinite: check that all eigenvalues of Z_j are
# (real and) positive, so that the cluster-level marginal
# covariance matrix is positive definite
lambda <- Re(eigen(z_j, only.values = TRUE)$values)
if (any(lambda < sqrt(.Machine$double.eps))) {
ok <- FALSE
break
}
logdet_z <- sum(log(lambda))
}
zp_j <- solve(z_j, p_tilde)
# -2 loglik for this cluster
loglik_j[j] <- nj * logdet_w + logdet_z +
quad_j - sum(p_tilde * (sigma_v_local %*% zp_j))
if (kz > 0L) {
loglik_j[j] <- loglik_j[j] + logdet_zb
}
if (log2pi) {
loglik_j[j] <- loglik_j[j] + (nj * p1 + kz) * log(2 * pi)
}
}
ok
}, error = function(e) FALSE) # tryCatch around the cluster loop
if (!ok) {
return(+Inf)
}
out <- sum(loglik_j)
if (!minus_two) {
out <- out / (-2)
loglik_j <- loglik_j / (-2)
}
if (per_cluster) {
attr(out, "loglik.cluster") <- loglik_j
}
out
}
# -2 * (observed-data, conditional-on-x) loglikelihood with missing
# data in the y-block (missing = "ml")
#
# the only change wrt the complete-data version: the per-case weight
# matrix is now pattern-specific, W_p = Sigma_w[o,o]^{-1} embedded in
# a p1 x p1 zero matrix; because the zero rows/columns of W_p
# annihilate the zero-padded entries of the per-(cluster, pattern)
# crossproducts, all accumulation formulas carry over unchanged --
# they are simply applied per pattern, and summed per cluster before
# the marginalization over v_b
lav_mvn_cl_rs_loglik_m <- function(rs_stats = NULL, imp = NULL,
rs_info = NULL,
sinv_method = "eigen",
log2pi = TRUE,
minus_two = TRUE,
per_cluster = FALSE) {
# non-PD or failed implied?
if (is.null(imp)) {
return(+Inf)
}
p1 <- rs_info$p1
pv <- imp$pv
path_tab <- rs_info$path.tab
npaths <- nrow(path_tab)
nclusters <- rs_stats$nclusters
mp <- rs_stats$mp
sigma_v <- imp$sigma.v
if (anyNA(sigma_v)) {
return(+Inf)
}
# is sigma.v PSD? (fast path: no per-cluster eigenvalue checks needed)
ev_v <- eigen(sigma_v, symmetric = TRUE, only.values = TRUE)$values
v_psd <- ev_v[length(ev_v)] > -1e-12
lmat <- imp$lmat
pmat <- imp$P
mu_y <- imp$mu_y
q0 <- imp$q0
z_v_idx <- imp$z.v.idx
path_z <- path_tab$z.idx # 1..q
path_x <- path_tab$x.idx # 1..nx
path_zcol <- z_v_idx[path_z] # column in v_b, per path
pmat_t <- t(pmat)
dpv <- diag(pv)
mu_v <- imp$mu.v
cc_mat <- imp$cc
mu_exo <- imp$mu.exo
nexo <- rs_info$nexo.b
exo_b <- rs_stats$exo.b
log2pi_c <- if (log2pi) log(2 * pi) else 0
# between-only endogenous z-outcomes (always complete)
kz <- rs_info$kz
if (kz > 0L) {
wzb <- lav_mat_sym_inverse(imp$sigma.z,
logdet = TRUE, sinv_method = sinv_method
)
logdet_zb <- attr(wzb, "logdet")
if (!is.finite(logdet_zb)) {
return(+Inf)
}
wzg <- wzb %*% imp$gmat # kz x pv
a_zb <- crossprod(imp$gmat, wzg) # pv x pv
zb_all <- rs_stats$zb
mu_zb <- imp$mu_z
}
# per-cluster accumulators
ld_j <- numeric(nclusters) # sum_p n_jp (logdet_wp + p_o log(2 pi))
quad_j <- numeric(nclusters)
a_arr <- array(0, dim = c(pv, pv, nclusters))
p_all <- matrix(0, nclusters, pv)
loglik_j <- numeric(nclusters)
ok <- tryCatch({
# ---- accumulation: loop over patterns, then clusters within ----
for (p in seq_len(mp$npatterns)) {
cell <- mp$pattern[[p]]
o <- cell$o
po <- length(o)
w_o <- lav_mat_sym_inverse(imp$sigma_w[o, o, drop = FALSE],
logdet = TRUE, sinv_method = sinv_method
)
logdet_o <- attr(w_o, "logdet")
if (!is.finite(logdet_o)) {
# caught by the enclosing tryCatch() -> objective returns +Inf
lav_msg_stop(gettext("sigma_w is not positive definite."))
}
# embed in the full p1 x p1 space
w_mat <- matrix(0, p1, p1)
w_mat[o, o] <- w_o
# per-pattern constants
wl <- w_mat %*% lmat # p1 x npaths
ltwl <- crossprod(lmat, wl) # npaths x npaths
wq0 <- w_mat %*% q0 # p1 x pv
q0twq0 <- crossprod(q0, wq0) # pv x pv
q0twl <- crossprod(q0, wl) # pv x npaths
wp <- w_mat %*% pmat # p1 x nx
ptwp <- crossprod(pmat, wp) # nx x nx
wp_t <- t(wp)
mu_y_w <- as.numeric(w_mat %*% mu_y) # p1
mu_y_w_p <- as.numeric(mu_y_w %*% pmat) # nx
mu_y_quad <- sum(mu_y_w * mu_y)
ld_p <- logdet_o + po * log2pi_c
j1 <- cell$j1
for (jj in seq_along(j1)) {
j <- j1[jj]
n_jp <- cell$n[jj]
sy_jp <- cell$sy[jj, ]
sx_jp <- cell$sx[jj, ]
sxx_jp <- cell$sxx[[jj]]
sxy_jp <- cell$sxy[[jj]]
syy_jp <- cell$syy[[jj]]
ld_j[j] <- ld_j[j] + n_jp * ld_p
# centered sums: e~_i = y_i - mu_y - P x_i
se_jp <- sy_jp - n_jp * mu_y - as.numeric(pmat %*% sx_jp)
sxe_jp <- sxy_jp - outer(sx_jp, mu_y) - sxx_jp %*% pmat_t
# sum_i e~_i' W_p e~_i
quad_j[j] <- quad_j[j] + sum(w_mat * syy_jp) -
2 * sum(mu_y_w * sy_jp) +
n_jp * mu_y_quad -
2 * sum(wp_t * sxy_jp) +
2 * sum(mu_y_w_p * sx_jp) +
sum(ptwp * sxx_jp)
# A contribution
a_jp <- n_jp * q0twq0
p_jp <- as.numeric(crossprod(wq0, se_jp))
cz <- matrix(0, pv, pv)
for (i in seq_len(npaths)) {
zcol <- path_zcol[i]
cz[, zcol] <- cz[, zcol] + q0twl[, i] * sx_jp[path_x[i]]
}
a_jp <- a_jp + cz + t(cz)
for (i in seq_len(npaths)) {
for (k in seq_len(npaths)) {
a_jp[path_zcol[i], path_zcol[k]] <-
a_jp[path_zcol[i], path_zcol[k]] +
ltwl[i, k] * sxx_jp[path_x[i], path_x[k]]
}
}
for (i in seq_len(npaths)) {
zi <- path_zcol[i]
p_jp[zi] <- p_jp[zi] + sum(wl[, i] * sxe_jp[path_x[i], ])
}
a_arr[, , j] <- a_arr[, , j] + a_jp
p_all[j, ] <- p_all[j, ] + p_jp
}
}
# ---- marginalization over v_b, per cluster ----
for (j in seq_len(nclusters)) {
a_j <- matrix(a_arr[, , j], pv, pv)
p_j <- p_all[j, ]
# z-outcome contributions (the extra observation block)
if (kz > 0L) {
e_zb <- zb_all[j, ] - mu_zb
a_j <- a_j + a_zb
p_j <- p_j + as.numeric(crossprod(wzg, e_zb))
quad_j[j] <- quad_j[j] + sum(e_zb * (wzb %*% e_zb))
ld_j[j] <- ld_j[j] + logdet_zb + kz * log2pi_c
}
# mean of v_b for this cluster (given the between covariates)
if (nexo > 0L) {
d_j <- mu_v + as.numeric(cc_mat %*% (exo_b[j, ] - mu_exo))
} else {
d_j <- mu_v
}
# center v_b at its mean
p_tilde <- p_j - as.numeric(a_j %*% d_j)
quad_full <- quad_j[j] - 2 * sum(d_j * p_j) +
sum(d_j * (a_j %*% d_j))
# Z_j = I + A_j Sigma_v; logdet(Z_j) and Sigma_v Z_j^{-1} p~
z_j <- dpv + a_j %*% sigma_v
if (v_psd) {
dt <- determinant(z_j, logarithm = TRUE)
if (dt$sign <= 0 || !is.finite(dt$modulus)) {
# caught by the enclosing tryCatch() -> objective returns +Inf
lav_msg_stop(gettext("Z matrix is not positive definite."))
}
logdet_z <- as.numeric(dt$modulus)
} else {
# sigma.v indefinite: check that all eigenvalues of Z_j are
# (real and) positive
lambda <- Re(eigen(z_j, only.values = TRUE)$values)
if (any(lambda < sqrt(.Machine$double.eps))) {
# caught by the enclosing tryCatch() -> objective returns +Inf
lav_msg_stop(gettext("Z matrix is not positive definite."))
}
logdet_z <- sum(log(lambda))
}
zp_j <- solve(z_j, p_tilde)
# -2 loglik for this cluster (log 2 pi already in ld_j)
loglik_j[j] <- ld_j[j] + logdet_z +
quad_full - sum(p_tilde * (sigma_v %*% zp_j))
}
TRUE
}, error = function(e) FALSE) # tryCatch around both loops
if (!ok) {
return(+Inf)
}
out <- sum(loglik_j)
if (!minus_two) {
out <- out / (-2)
loglik_j <- loglik_j / (-2)
}
if (per_cluster) {
attr(out, "loglik.cluster") <- loglik_j
}
out
}
# ---------------------------------------------------------------------
# latent covariates: quadrature over the nonlinear random slopes
# ---------------------------------------------------------------------
#
# random slopes that multiply a *latent* level-1 covariate enter the
# within model nonlinearly (a product of two random variables); the
# marginal loglikelihood is no longer available in closed form.
# Following Rockwood (2020), we split the between random vector v_b
# into the nonlinear slopes z_nl and the remaining ('linear') part
# v_lin. Conditional on z_nl = b:
#
# - the within model is the *linear* random-slope model with the
# slope values b injected into the (fixed-to-zero) beta_w cells:
# the SAME lav_mvn_cl_rs_implied() gives sigma_w(b), lmat(b), P(b)
# - v_lin | z_nl = b is normal with moments given by standard
# Gaussian conditioning (a common covariance matrix, and a mean
# that is linear in b)
# - the conditional (on b) loglikelihood is computed by the SAME
# crossproduct-based lav_mvn_cl_rs_loglik() -- including the
# missing-data version
#
# the marginal loglikelihood is then obtained by Gauss-Hermite
# quadrature over z_nl only (q.nl dimensions, typically 1):
#
# loglik_j = logsumexp_q [ loglik_j(b_q) + log w_q ]
#
# with nodes b_q = mu_nl + chol(Sigma_nl)' t_q placed at the prior of
# the nonlinear slopes (Rockwood, 2020, eq. 36-37). Per-node cost is
# one closed-form evaluation (the crossproduct statistics are shared
# across the nodes).
#
# when between-level covariates are related to the nonlinear slopes
# ('Case B', e.g. a regression s ~ w; Rockwood's PISA model), the
# prior means mu_nl_j become cluster-specific. We then keep ONE
# shared grid -- centered at the average prior mean, with the grid
# covariance inflated by the between-cluster spread of the prior
# means (so the grid covers every cluster's prior) -- and repair the
# weights per cluster with the exact density ratio:
#
# loglik_j = logsumexp_q [ loglik_j(b_q) + log w_q + log r_jq ],
# log r_jq = log phi(b_q; mu_nl_j, Sigma_nl)
# + log|A| - log phi(t_q; 0, I),
#
# with b_q = c + A' t_q the shared grid. This keeps the per-node cost
# structure of Case A (the within matrices are rebuilt per node, not
# per cluster x node); without between-level covariates (Case A) the
# corrections are skipped entirely (bit-identical to the plain
# scheme), and when covariates are present but unrelated to the
# slopes they vanish up to roundoff. Note that Sigma_nl (the
# conditional-on-w covariance) is always common across clusters:
# conditioning on the between covariates shifts means only.
# single entry point for the -2 loglikelihood: dispatches between the
# closed-form kernel (observed covariates only) and the quadrature
# version (one or more latent covariates)
lav_mvn_cl_rs_m2ll <- function(lavmodel = NULL, glist = NULL, rs = NULL,
sinv_method = "eigen", log2pi = TRUE,
minus_two = TRUE, per_cluster = FALSE) {
if (isTRUE(rs$info$nl.flag)) {
return(lav_mvn_cl_rs_loglik_nl(
lavmodel = lavmodel, glist = glist,
rs_stats = rs$stats, rs_info = rs$info,
sinv_method = sinv_method, log2pi = log2pi,
minus_two = minus_two, per_cluster = per_cluster
))
}
imp <- lav_mvn_cl_rs_implied(
lavmodel = lavmodel, glist = glist, rs_info = rs$info
)
lav_mvn_cl_rs_loglik(
rs_stats = rs$stats, imp = imp, rs_info = rs$info,
sinv_method = sinv_method, log2pi = log2pi,
minus_two = minus_two, per_cluster = per_cluster
)
}
# build the conditional-model ingredients at all quadrature nodes:
# the reduced index map (rs_info_c), and one 'imp' list per node with
# the node values injected into beta_w and v_b conditioned on the
# nonlinear slopes; when the prior means of the nonlinear slopes are
# cluster-specific (Case B), exo_b (the per-cluster between-covariate
# rows) is needed, and the returned 'logr' matrix (nclusters x
# nnodes) holds the per-cluster log density-ratio corrections;
# returns NULL if the base implied matrices fail or Sigma_nl is not
# positive definite (individual nodes may still fail: those imp
# entries are NULL)
lav_mvn_cl_rs_cond <- function(lavmodel = NULL, glist = NULL,
rs_info = NULL, exo_b = NULL) {
if (is.null(glist)) {
glist <- lavmodel@GLIST
}
# base implied ingredients; only the between pieces (sigma.v, mu.v,
# cc, z.v.idx) are used here -- they do not depend on the injected
# beta_w cells
imp0 <- lav_mvn_cl_rs_implied(
lavmodel = lavmodel, glist = glist, rs_info = rs_info
)
if (is.null(imp0)) {
return(NULL)
}
path_tab <- rs_info$path.tab
nl_z <- rs_info$nl.z.idx # indices in z.names
nl_v <- imp0$z.v.idx[nl_z] # positions in v_b
lin_v <- setdiff(seq_len(imp0$pv), nl_v)
# prior (between) moments of the nonlinear slopes; the quadrature
# requires a positive-definite Sigma_nl
s_nl <- imp0$sigma.v[nl_v, nl_v, drop = FALSE]
mu_nl <- imp0$mu.v[nl_v]
if (anyNA(s_nl)) {
return(NULL)
}
ch <- try(chol(s_nl), silent = TRUE) # s_nl = t(ch) %*% ch
if (inherits(ch, "try-error")) {
return(NULL)
}
s_nl_inv <- chol2inv(ch)
# cluster-specific shifts of the nonlinear prior means (Case B):
# mu_nl_j = mu_nl + delta_j, delta_j = cc[nl, ] (w_j - mu_exo).
# NOTE: the branch is taken whenever between-level covariates are
# present (a *structural* decision), not only when the current
# cc[nl, ] values are nonzero: a free slope-on-covariate parameter
# that happens to be zero (e.g., at the starting values) still has
# a nonzero *derivative* of the correction terms, which the
# gradient must see. When cc[nl, ] = 0, the corrections vanish (up
# to roundoff) and the general path reduces to the plain scheme.
q_nl <- length(nl_v)
delta <- NULL
case_b <- rs_info$nexo.b > 0L
if (case_b) {
if (is.null(exo_b)) {
lav_msg_stop(gettext(
"internal error: exo_b is needed when the model contains both
between-level covariates and nonlinear random slopes."))
}
wc <- exo_b - rep(imp0$mu.exo, each = nrow(exo_b))
delta <- wc %*% t(imp0$cc[nl_v, , drop = FALSE]) # G x q_nl
}
# conditional distribution of the linear part given the slopes:
# v_lin | z_nl = b ~ N(mu_lin + R (b - mu_nl) + cc_c (w_j - mu_exo),
# sigma_v_c)
# with cc_c the *conditional* regression on the between covariates
# (equal to cc[lin, ] when the slopes do not depend on them)
r_mat <- imp0$sigma.v[lin_v, nl_v, drop = FALSE] %*% s_nl_inv
sigma_v_c <- imp0$sigma.v[lin_v, lin_v, drop = FALSE] -
r_mat %*% imp0$sigma.v[nl_v, lin_v, drop = FALSE]
sigma_v_c <- (sigma_v_c + t(sigma_v_c)) / 2
cc_c <- imp0$cc[lin_v, , drop = FALSE]
if (case_b) {
cc_c <- cc_c - r_mat %*% imp0$cc[nl_v, , drop = FALSE]
}
# conditional index map: only the observed-covariate paths remain
ov_rows <- which(path_tab$type == "ov")
z_keep <- which(rs_info$z.type == "ov")
path_tab_c <- path_tab[ov_rows, , drop = FALSE]
path_tab_c$z.idx <- match(path_tab$rv[ov_rows],
rs_info$z.names[z_keep])
rs_info_c <- rs_info
rs_info_c$path.tab <- path_tab_c
rs_info_c$z.names <- rs_info$z.names[z_keep]
rs_info_c$q <- length(z_keep)
rs_info_c$nl.flag <- FALSE
# positions of the remaining (observed-type) slopes in reduced v_b
z_v_idx_c <- match(imp0$z.v.idx[z_keep], lin_v)
# Gauss-Hermite grid (dnorm kernel; tensor product over q.nl dims)
ngh <- rs_info$ngh
if (is.null(ngh)) {
ngh <- 21L
}
gh <- lav_integration_gauss_hermite(n = ngh, dnorm = TRUE)
if (q_nl == 1L) {
tmat <- matrix(gh$x, nrow = 1L)
logw <- log(gh$w)
} else {
tmat <- t(as.matrix(expand.grid(rep(list(gh$x), q_nl))))
logw <- rowSums(as.matrix(
expand.grid(rep(list(log(gh$w)), q_nl))
))
}
nnodes <- ncol(tmat)
# grid placement: b_q = c + A' t_q
# - Case A: c = mu_nl, A = chol(Sigma_nl) (no correction needed)
# - Case B: c = average prior mean; grid covariance = Sigma_nl +
# between-cluster covariance of the prior means (so the grid
# covers every cluster's prior); exact per-cluster density-ratio
# corrections below
if (case_b) {
grid_c <- mu_nl + colMeans(delta)
dc <- delta - rep(colMeans(delta), each = nrow(delta))
s_grid <- s_nl + crossprod(dc) / nrow(delta)
a_up <- try(chol(s_grid), silent = TRUE)
if (inherits(a_up, "try-error")) {
return(NULL)
}
} else {
grid_c <- mu_nl
a_up <- ch
}
bmat <- grid_c + t(a_up) %*% tmat # q_nl x nnodes
# per-cluster log density-ratio corrections (Case B only):
# log r_jq = log phi(b_q; mu_nl + delta_j, Sigma_nl)
# + log|A| - log phi(t_q; 0, I)
logr <- NULL
if (case_b) {
mu_mat <- rep(mu_nl, each = nrow(delta)) + delta # G x q_nl
mb <- s_nl_inv %*% bmat # q_nl x nnodes
bmb <- colSums(bmat * mb) # nnodes
mm <- rowSums((mu_mat %*% s_nl_inv) * mu_mat) # G
cross <- mu_mat %*% mb # G x nnodes
logdet_s <- 2 * sum(log(diag(ch)))
logdet_a <- sum(log(diag(a_up)))
tt <- colSums(tmat * tmat) # nnodes
logr <- -0.5 * (outer(mm, bmb, "+") - 2 * cross) -
0.5 * logdet_s + logdet_a +
rep(0.5 * tt, each = nrow(delta))
}
# node-value injection map: nonlinear path -> beta_w cell
nl_path_rows <- which(path_tab$type == "lv")
path_nl_z <- match(path_tab$z.idx[nl_path_rows], nl_z)
beta_mm <- rs_info$nl.beta.mm
beta_cells <- cbind(rs_info$nl.beta.row, rs_info$nl.beta.col)
imp_list <- vector("list", nnodes)
glist_i <- glist
for (i in seq_len(nnodes)) {
b_i <- bmat[, i]
glist_i[[beta_mm]][beta_cells] <- b_i[path_nl_z]
imp_i <- lav_mvn_cl_rs_implied(
lavmodel = lavmodel, glist = glist_i, rs_info = rs_info
)
if (is.null(imp_i)) {
next
}
imp_list[[i]] <- list(
sigma_w = imp_i$sigma_w, mu_y = imp_i$mu_y,
mu_y_b = imp_i$mu_y_b, P = imp_i$P,
lmat = imp_i$lmat[, ov_rows, drop = FALSE],
q0 = imp_i$q0[, lin_v, drop = FALSE],
z.v.idx = z_v_idx_c, pv = length(lin_v),
sigma.v = sigma_v_c,
mu.v = imp0$mu.v[lin_v] + as.numeric(r_mat %*% (b_i - mu_nl)),
cc = cc_c,
mu.exo = imp0$mu.exo,
# z-outcomes: the slopes have no indicators, so the nl columns
# of G are structurally zero and simply drop out
mu_z = imp_i$mu_z,
gmat = imp_i$gmat[, lin_v, drop = FALSE],
sigma.z = imp_i$sigma.z
)
}
list(rs_info_c = rs_info_c, imp = imp_list, logw = logw,
nnodes = nnodes, logr = logr,
# extras for the EAP (lavPredict) machinery
bmat = bmat, lin.v = lin_v, nl.v = nl_v,
eta.names = imp0$eta.names, meta = imp0$meta, pv = imp0$pv,
z.v.idx = imp0$z.v.idx)
}
# -2 * loglikelihood by Gauss-Hermite quadrature over the nonlinear
# (latent-covariate) random slopes; all failure modes return +Inf
# (mirroring lav_mvn_cl_rs_loglik)
lav_mvn_cl_rs_loglik_nl <- function(lavmodel = NULL, glist = NULL,
rs_stats = NULL, rs_info = NULL,
sinv_method = "eigen",
log2pi = TRUE,
minus_two = TRUE,
per_cluster = FALSE) {
cond <- lav_mvn_cl_rs_cond(
lavmodel = lavmodel, glist = glist, rs_info = rs_info,
exo_b = rs_stats$exo.b
)
if (is.null(cond)) {
return(+Inf)
}
nclusters <- rs_stats$nclusters
nnodes <- cond$nnodes
hmat <- matrix(-Inf, nclusters, nnodes)
for (i in seq_len(nnodes)) {
if (is.null(cond$imp[[i]])) {
next
}
m2_i <- lav_mvn_cl_rs_loglik(
rs_stats = rs_stats, imp = cond$imp[[i]],
rs_info = cond$rs_info_c,
sinv_method = sinv_method, log2pi = log2pi,
minus_two = TRUE, per_cluster = TRUE
)
if (!is.finite(m2_i)) {
next
}
hmat[, i] <- -0.5 * attr(m2_i, "loglik.cluster") + cond$logw[i]
}
# cluster-specific prior means (Case B): density-ratio corrections
if (!is.null(cond$logr)) {
hmat <- hmat + cond$logr
}
# per-cluster log-sum-exp over the nodes
hmax <- apply(hmat, 1L, max)
if (any(!is.finite(hmax))) {
return(+Inf)
}
loglik_j <- hmax + log(rowSums(exp(hmat - hmax)))
out <- sum(loglik_j)
if (minus_two) {
out <- -2 * out
loglik_j <- -2 * loglik_j
}
if (per_cluster) {
attr(out, "loglik.cluster") <- loglik_j
}
out
}
# analytic (adjoint-based) gradient of the quadrature loglikelihood:
#
# d loglik_j / d theta = sum_i pi_ji d loglik_j(b_i) / d theta
#
# with pi_ji the posterior node weights (the derivative of the
# log-sum-exp); the conditional (per-node) derivatives reuse the
# closed-form adjoint pass (lav_mvn_cl_rs_dfphi) on the conditional
# model, chained through a small numerical Jacobian of the per-node
# ingredient map theta -> phi_c(theta; node i) -- which includes the
# dependence of the node values b_i(theta) = mu_nl + chol(Sigma_nl)'t_i
# on theta (no cluster loops); returns d(-2 loglik)/d theta, or the
# per-cluster score matrix (natural scale) when per_cluster = TRUE;
# NULL on failure (callers fall back to numerical differentiation)
lav_mvn_cl_rs_grad_nl <- function(lavmodel = NULL, rs = NULL,
sinv_method = "eigen",
per_cluster = FALSE,
h = 1e-06) {
rs_info <- rs$info
rs_stats <- rs$stats
cond0 <- lav_mvn_cl_rs_cond(lavmodel = lavmodel, rs_info = rs_info,
exo_b = rs_stats$exo.b)
if (is.null(cond0)) {
return(NULL)
}
nnodes <- cond0$nnodes
nclusters <- rs_stats$nclusters
case_b <- !is.null(cond0$logr)
# per-node conditional logliks + adjoints at the current parameters
hmat <- matrix(-Inf, nclusters, nnodes)
gphi_list <- vector("list", nnodes)
for (i in seq_len(nnodes)) {
imp_i <- cond0$imp[[i]]
if (is.null(imp_i)) {
return(NULL)
}
m2_i <- lav_mvn_cl_rs_loglik(
rs_stats = rs_stats, imp = imp_i, rs_info = cond0$rs_info_c,
sinv_method = sinv_method, log2pi = FALSE,
minus_two = TRUE, per_cluster = TRUE
)
if (!is.finite(m2_i)) {
return(NULL)
}
hmat[, i] <- -0.5 * attr(m2_i, "loglik.cluster") + cond0$logw[i]
gphi_list[[i]] <- lav_mvn_cl_rs_dfphi(
rs_stats = rs_stats, imp = imp_i, rs_info = cond0$rs_info_c,
sinv_method = sinv_method
)
if (is.null(gphi_list[[i]])) {
return(NULL)
}
}
if (case_b) {
hmat <- hmat + cond0$logr
}
hmax <- apply(hmat, 1L, max)
if (any(!is.finite(hmax))) {
return(NULL)
}
loglik_j <- hmax + log(rowSums(exp(hmat - hmax)))
pw <- exp(hmat - loglik_j) # posterior node weights (rows sum to 1)
# numerical Jacobian of the per-node ingredient vectors phi_c
# (central differences; no cluster loops); in Case B, the log
# density-ratio corrections logr also depend on theta -- their
# derivatives ride along in the same loop
x0 <- lav_model_get_parameters(lavmodel)
npar <- length(x0)
cond_of <- function(x) {
lavmodel2 <- lav_model_set_parameters(lavmodel, x = x)
cond <- lav_mvn_cl_rs_cond(lavmodel = lavmodel2, rs_info = rs_info,
exo_b = rs_stats$exo.b)
if (is.null(cond)) {
return(NULL)
}
list(
phi = lapply(cond$imp, function(m) {
if (is.null(m)) NULL else lav_mvn_cl_rs_phi_vec(m)
}),
logr = cond$logr
)
}
nphi <- length(lav_mvn_cl_rs_phi_vec(cond0$imp[[1]]))
jac <- lapply(seq_len(nnodes), function(i) matrix(0, nphi, npar))
dlr_sc <- if (case_b) matrix(0, nclusters, npar) else NULL
for (k in seq_len(npar)) {
h_k <- h * max(1, abs(x0[k]))
x_right <- x_left <- x0
x_right[k] <- x0[k] + h_k
x_left[k] <- x0[k] - h_k
cond_r <- cond_of(x_right)
cond_l <- cond_of(x_left)
if (is.null(cond_r) || is.null(cond_l)) {
return(NULL)
}
for (i in seq_len(nnodes)) {
if (is.null(cond_r$phi[[i]]) || is.null(cond_l$phi[[i]])) {
return(NULL)
}
jac[[i]][, k] <- (cond_r$phi[[i]] - cond_l$phi[[i]]) / (2 * h_k)
}
if (case_b) {
if (is.null(cond_r$logr) || is.null(cond_l$logr)) {
return(NULL)
}
dlr_k <- (cond_r$logr - cond_l$logr) / (2 * h_k)
dlr_sc[, k] <- rowSums(pw * dlr_k)
}
}
if (per_cluster) {
# per-cluster scores, natural loglikelihood scale
sc <- matrix(0, nclusters, npar)
for (i in seq_len(nnodes)) {
sc <- sc + (pw[, i] * (-0.5) * gphi_list[[i]]) %*% jac[[i]]
}
if (case_b) {
sc <- sc + dlr_sc
}
sc
} else {
# d(-2 loglik)/d theta
dx <- numeric(npar)
for (i in seq_len(nnodes)) {
dx <- dx +
as.numeric(colSums(pw[, i] * gphi_list[[i]]) %*% jac[[i]])
}
if (case_b) {
dx <- dx - 2 * colSums(dlr_sc)
}
dx
}
}
# ---------------------------------------------------------------------
# the EM algorithm
# ---------------------------------------------------------------------
#
# complete data = observed data + the between random vector v_b (in
# factor space, see above) + the 'within slope' variables
# z_w[r] = v_b[z_r] * x_r (one per random-slope path; completed with an
# arbitrary standard-normal marginal).
#
# - E-step: the posterior of v_b given the cluster data is
# Sigma0_j = Sigma_v (I + A_j Sigma_v)^{-1},
# mu0_j = d_j + Sigma0_j (p_j - A_j d_j)
# (the same A_j/p_j workhorses as the marginal loglikelihood); the
# expected complete-data moments then collapse onto the same
# per-cluster crossproducts (sy, sx, sxx, sxy, syy)
# - M-step: a standard TWO-GROUP SEM, fitted to the expected moments:
# group 1 ('within', N obs) contains the y-vector plus the phantom
# z_w variables (unit variance, zero mean, fixed loading 1 on the
# lhs of their path); group 2 ('between', G obs) contains the
# both-level variables, the random slopes (now 'observed') and the
# between covariates
#
# limitations (fall back to nlminb): fixed paths from the rv
# covariates (P != 0), shared rv labels; between-level variances that
# are fixed to (near) zero are floored at 1e-4 in the M-step model
# (cfr. the Mplus 'minimum variance'); the nlminb hand-off (enabled by
# default) polishes the solution of the *exact* model afterwards
# check if the EM version can handle this model; returns character(0)
# if ok, otherwise the reason(s)
lav_mvn_cl_rs_em_ok <- function(rs = NULL) {
reason <- character(0L)
if (isTRUE(rs$info$nl.flag)) {
reason <- c(reason, gettext(
"the model contains random slopes for latent covariates"))
}
if (isTRUE(rs$info$kz > 0L)) {
reason <- c(reason, gettext(
"the model contains between-only endogenous observed variables"))
}
if (rs$info$p.flag) {
reason <- c(reason, gettext(
"the model contains fixed regression paths from the random-slope
covariates"))
}
if (rs$info$shared.flag) {
reason <- c(reason, gettext(
"the same rv() label is attached to more than one regression
path"))
}
reason
}
# E-step: expected complete-data moments
#
# returns:
# r1 (p1 + q), t1 (p1 + q square): mean and covariance (about r1)
# of the within complete-data vector (y_w, z_w) -- N observations
# r2 (pb + q + nexo), t2 (square): mean and covariance of the
# between complete-data vector (y_b, z, w) -- G observations
lav_mvn_cl_rs_estep <- function(rs_stats = NULL, imp = NULL,
rs_info = NULL,
sinv_method = "eigen") {
# missing data? use the pattern-based version
if (!is.null(rs_stats$mp)) {
return(lav_mvn_cl_rs_estep_m(
rs_stats = rs_stats, imp = imp, rs_info = rs_info,
sinv_method = sinv_method
))
}
p1 <- rs_info$p1
nx <- rs_info$nx
q <- rs_info$q
pb <- rs_info$pb
nexo <- rs_info$nexo.b
pv <- imp$pv
path_tab <- rs_info$path.tab
npaths <- nrow(path_tab)
nclusters <- rs_stats$nclusters
nj_all <- rs_stats$cluster.size
ntotal <- rs_stats$ntotal
sigma_w_inv <- lav_mat_sym_inverse(imp$sigma_w,
logdet = TRUE, sinv_method = sinv_method
)
if (!is.finite(attr(sigma_w_inv, "logdet"))) {
lav_msg_stop(gettext(
"within covariance matrix is not positive definite in the
E-step."))
}
w_mat <- sigma_w_inv
sigma_v <- imp$sigma.v
mu_y <- imp$mu_y
nu_map <- imp$mu_y_b
q0 <- imp$q0
z_v_idx <- imp$z.v.idx
path_zcol <- z_v_idx[path_tab$z.idx]
path_x <- path_tab$x.idx
# constants
wq0 <- w_mat %*% q0
q0twq0 <- crossprod(q0, wq0)
wl <- w_mat %*% imp$lmat
q0twl <- crossprod(q0, wl)
ltwl <- crossprod(imp$lmat, wl)
dpv <- diag(pv)
# between mapping: (y_b, z) = c0 + G v
gmat <- rbind(
q0[rs_info$yb.y.idx, , drop = FALSE],
dpv[z_v_idx, , drop = FALSE]
)
c0 <- c(nu_map[rs_info$yb.y.idx], rep(0, q))
# accumulators
sum1_w <- numeric(p1 + q) # within first moments
sum2_w <- matrix(0, p1 + q, p1 + q) # within second moments
sum1_b <- numeric(pb + q + nexo)
sum2_b <- matrix(0, pb + q + nexo, pb + q + nexo)
y_idx1 <- seq_len(p1)
z_idx1 <- p1 + seq_len(q)
b_idx2 <- seq_len(pb + q)
w_idx2 <- pb + q + seq_len(nexo)
for (j in seq_len(nclusters)) {
nj <- nj_all[j]
sy_j <- rs_stats$sy[j, ]
sx_j <- rs_stats$sx[j, ]
sxx_j <- rs_stats$sxx[[j]]
sxy_j <- rs_stats$sxy[[j]]
syy_j <- rs_stats$syy[[j]]
# A_j and p_j (centered at mu_y; no P term: the EM requires P = 0)
se_j <- sy_j - nj * mu_y
a_j <- nj * q0twq0
p_j <- as.numeric(crossprod(wq0, se_j))
cz <- matrix(0, pv, pv)
for (i in seq_len(npaths)) {
zcol <- path_zcol[i]
cz[, zcol] <- cz[, zcol] + q0twl[, i] * sx_j[path_x[i]]
}
a_j <- a_j + cz + t(cz)
for (i in seq_len(npaths)) {
for (k in seq_len(npaths)) {
a_j[path_zcol[i], path_zcol[k]] <-
a_j[path_zcol[i], path_zcol[k]] +
ltwl[i, k] * sxx_j[path_x[i], path_x[k]]
}
}
sxe_j <- sxy_j - outer(sx_j, mu_y)
for (i in seq_len(npaths)) {
zi <- path_zcol[i]
p_j[zi] <- p_j[zi] + sum(wl[, i] * sxe_j[path_x[i], ])
}
# prior mean of v_b for this cluster
if (nexo > 0L) {
w_j <- rs_stats$exo.b[j, ]
d_j <- imp$mu.v + as.numeric(imp$cc %*% (w_j - imp$mu.exo))
} else {
w_j <- numeric(0L)
d_j <- imp$mu.v
}
# posterior of v_b
z_j <- dpv + a_j %*% sigma_v
sigma0_j <- sigma_v %*% solve(z_j)
sigma0_j <- (sigma0_j + t(sigma0_j)) / 2
p_tilde <- p_j - as.numeric(a_j %*% d_j)
mu0_j <- d_j + as.numeric(sigma0_j %*% p_tilde)
m0_j <- sigma0_j + tcrossprod(mu0_j) # E[v v']
# ---------- within complete-data moments: (y_w, z_w) ----------
# y_w = a - Q0 v, with a = y - nu_map (observed)
sa_j <- sy_j - nj * nu_map # sum_i a_i
saa_j <- syy_j - outer(sy_j, nu_map) - outer(nu_map, sy_j) +
nj * tcrossprod(nu_map) # sum_i a_i a_i'
q0mu <- as.numeric(q0 %*% mu0_j)
q0m0q0 <- q0 %*% m0_j %*% t(q0)
sum1_w[y_idx1] <- sum1_w[y_idx1] + sa_j - nj * q0mu
sum2_w[y_idx1, y_idx1] <- sum2_w[y_idx1, y_idx1] +
saa_j - outer(sa_j, q0mu) - outer(q0mu, sa_j) + nj * q0m0q0
# z_w[r] = v[z_r] * (the covariate of path r)
# (one-to-one path <-> slope mapping, so path r defines slope r;
# but note that two slopes may share the same covariate)
sxa_j <- sxy_j - outer(sx_j, nu_map) # sum_i x_ai a_i'
for (r in seq_len(q)) {
zc_r <- z_v_idx[r]
xr <- path_x[r] # covariate (column in the x-block) of path r
sum1_w[z_idx1[r]] <- sum1_w[z_idx1[r]] + mu0_j[zc_r] * sx_j[xr]
for (s in seq_len(q)) {
zc_s <- z_v_idx[s]
xs <- path_x[s]
sum2_w[z_idx1[r], z_idx1[s]] <- sum2_w[z_idx1[r], z_idx1[s]] +
m0_j[zc_r, zc_s] * sxx_j[xr, xs]
}
# cross y_w x z_w[r]:
# sum_i x_(xr)i ( a_i mu0[z_r] - Q0 M0[, z_r] )
cross_r <- sxa_j[xr, ] * mu0_j[zc_r] -
sx_j[xr] * as.numeric(q0 %*% m0_j[, zc_r])
sum2_w[y_idx1, z_idx1[r]] <- sum2_w[y_idx1, z_idx1[r]] + cross_r
sum2_w[z_idx1[r], y_idx1] <- sum2_w[z_idx1[r], y_idx1] + cross_r
}
# ---------- between complete-data moments: (y_b, z, w) ----------
b_j <- c0 + as.numeric(gmat %*% mu0_j)
bb_j <- gmat %*% m0_j %*% t(gmat) +
outer(c0, b_j) + outer(b_j, c0) - tcrossprod(c0)
sum1_b[b_idx2] <- sum1_b[b_idx2] + b_j
sum2_b[b_idx2, b_idx2] <- sum2_b[b_idx2, b_idx2] + bb_j
if (nexo > 0L) {
sum1_b[w_idx2] <- sum1_b[w_idx2] + w_j
sum2_b[b_idx2, w_idx2] <- sum2_b[b_idx2, w_idx2] +
outer(b_j, w_j)
sum2_b[w_idx2, b_idx2] <- sum2_b[w_idx2, b_idx2] +
outer(w_j, b_j)
sum2_b[w_idx2, w_idx2] <- sum2_b[w_idx2, w_idx2] +
tcrossprod(w_j)
}
} # j
r1 <- sum1_w / ntotal
t1 <- sum2_w / ntotal - tcrossprod(r1)
r2 <- sum1_b / nclusters
t2 <- sum2_b / nclusters - tcrossprod(r2)
list(r1 = r1, t1 = t1, r2 = r2, t2 = t2)
}
# E-step with missing data in the y-block (missing = "ml")
#
# the complete data now include the missing y-values. Per pattern
# (observed set o, missing set m), define
# B_mo = Sigma_w[m,o] Sigma_w[o,o]^{-1}
# K_p = (embedded m-selector) - (embedded B_mo) [p1 x p1]
# R_p = I - K_p
# C_p = Schur complement Sigma_w[m,m] - B_mo Sigma_w[o,m], embedded
# then, conditional on (v_b, y_obs), the completed y-vector is
# y_i = R_p y~_i + K_p (mu_y + Q_i v_b) + eps*, Var(eps*) = C_p
# (y~_i = the zero-padded observed vector). Because K_p Q_i remains
# affine in x_i, all expected-moment sums again collapse onto the
# per-(cluster, pattern) crossproducts; for a complete pattern
# (K_p = 0, R_p = I, C_p = 0) every term reduces to the complete-data
# E-step. The M-step (the two-group SEM) is unchanged.
lav_mvn_cl_rs_estep_m <- function(rs_stats = NULL, imp = NULL,
rs_info = NULL,
sinv_method = "eigen") {
p1 <- rs_info$p1
nx <- rs_info$nx
q <- rs_info$q
pb <- rs_info$pb
nexo <- rs_info$nexo.b
pv <- imp$pv
path_tab <- rs_info$path.tab
npaths <- nrow(path_tab)
nclusters <- rs_stats$nclusters
mp <- rs_stats$mp
sigma_w <- imp$sigma_w
sigma_v <- imp$sigma.v
mu_y <- imp$mu_y
nu_map <- imp$mu_y_b
q0 <- imp$q0
lmat <- imp$lmat
z_v_idx <- imp$z.v.idx
path_zcol <- z_v_idx[path_tab$z.idx]
path_x <- path_tab$x.idx
dpv <- diag(pv)
# between mapping: (y_b, z) = c0 + G v
gmat <- rbind(
q0[rs_info$yb.y.idx, , drop = FALSE],
dpv[z_v_idx, , drop = FALSE]
)
c0 <- c(nu_map[rs_info$yb.y.idx], rep(0, q))
# ---- per-pattern constants ----
npat <- mp$npatterns
pc <- vector("list", npat)
for (p in seq_len(npat)) {
o <- mp$pattern[[p]]$o
m <- seq_len(p1)[-o]
if (length(m) == 0L) {
m <- integer(0L)
}
w_o <- lav_mat_sym_inverse(sigma_w[o, o, drop = FALSE],
logdet = TRUE, sinv_method = sinv_method
)
if (!is.finite(attr(w_o, "logdet"))) {
lav_msg_stop(gettext(
"within covariance matrix is not positive definite in the
E-step."))
}
w_mat <- matrix(0, p1, p1)
w_mat[o, o] <- w_o
k_mat <- matrix(0, p1, p1)
ct_mat <- matrix(0, p1, p1)
if (length(m) > 0L) {
b_mo <- sigma_w[m, o, drop = FALSE] %*% w_o
k_mat[cbind(m, m)] <- 1
k_mat[m, o] <- -b_mo
ct_mat[m, m] <- sigma_w[m, m, drop = FALSE] -
b_mo %*% sigma_w[o, m, drop = FALSE]
}
r_mat <- diag(p1) - k_mat
pc[[p]] <- list(
w = w_mat,
k = k_mat, r = r_mat, ct = ct_mat,
g = as.numeric(k_mat %*% mu_y) - nu_map,
h0 = -r_mat %*% q0, # = K Q0 - Q0 (p1 x pv)
kl = k_mat %*% lmat, # p1 x npaths
# constants for the A_j/p_j accumulation
wl = w_mat %*% lmat,
wq0 = w_mat %*% q0,
q0twq0 = crossprod(q0, w_mat %*% q0),
q0twl = crossprod(q0, w_mat %*% lmat),
ltwl = crossprod(lmat, w_mat %*% lmat)
)
}
# ---- phase 1: pattern-aware A_j and p_j (P = 0) ----
a_arr <- array(0, dim = c(pv, pv, nclusters))
p_all <- matrix(0, nclusters, pv)
sx_tot <- matrix(0, nclusters, nx)
sxx_tot <- array(0, dim = c(nx, nx, nclusters))
ntot_used <- 0
for (p in seq_len(npat)) {
cell <- mp$pattern[[p]]
cst <- pc[[p]]
j1 <- cell$j1
for (jj in seq_along(j1)) {
j <- j1[jj]
n_jp <- cell$n[jj]
sy_jp <- cell$sy[jj, ]
sx_jp <- cell$sx[jj, ]
sxx_jp <- cell$sxx[[jj]]
sxy_jp <- cell$sxy[[jj]]
ntot_used <- ntot_used + n_jp
sx_tot[j, ] <- sx_tot[j, ] + sx_jp
sxx_tot[, , j] <- sxx_tot[, , j] + sxx_jp
se_jp <- sy_jp - n_jp * mu_y
a_jp <- n_jp * cst$q0twq0
p_jp <- as.numeric(crossprod(cst$wq0, se_jp))
cz <- matrix(0, pv, pv)
for (i in seq_len(npaths)) {
zcol <- path_zcol[i]
cz[, zcol] <- cz[, zcol] + cst$q0twl[, i] * sx_jp[path_x[i]]
}
a_jp <- a_jp + cz + t(cz)
for (i in seq_len(npaths)) {
for (k in seq_len(npaths)) {
a_jp[path_zcol[i], path_zcol[k]] <-
a_jp[path_zcol[i], path_zcol[k]] +
cst$ltwl[i, k] * sxx_jp[path_x[i], path_x[k]]
}
}
sxe_jp <- sxy_jp - outer(sx_jp, mu_y)
for (i in seq_len(npaths)) {
zi <- path_zcol[i]
p_jp[zi] <- p_jp[zi] + sum(cst$wl[, i] * sxe_jp[path_x[i], ])
}
a_arr[, , j] <- a_arr[, , j] + a_jp
p_all[j, ] <- p_all[j, ] + p_jp
}
}
# ---- posterior of v_b, per cluster ----
mu0 <- matrix(0, nclusters, pv)
m0_list <- vector("list", nclusters)
for (j in seq_len(nclusters)) {
a_j <- matrix(a_arr[, , j], pv, pv)
if (nexo > 0L) {
d_j <- imp$mu.v +
as.numeric(imp$cc %*% (rs_stats$exo.b[j, ] - imp$mu.exo))
} else {
d_j <- imp$mu.v
}
z_j <- dpv + a_j %*% sigma_v
sigma0_j <- sigma_v %*% solve(z_j)
sigma0_j <- (sigma0_j + t(sigma0_j)) / 2
p_tilde <- p_all[j, ] - as.numeric(a_j %*% d_j)
mu0_j <- d_j + as.numeric(sigma0_j %*% p_tilde)
mu0[j, ] <- mu0_j
m0_list[[j]] <- sigma0_j + tcrossprod(mu0_j) # E[v v']
}
# ---- accumulators ----
sum1_w <- numeric(p1 + q)
sum2_w <- matrix(0, p1 + q, p1 + q)
sum1_b <- numeric(pb + q + nexo)
sum2_b <- matrix(0, pb + q + nexo, pb + q + nexo)
y_idx1 <- seq_len(p1)
z_idx1 <- p1 + seq_len(q)
b_idx2 <- seq_len(pb + q)
w_idx2 <- pb + q + seq_len(nexo)
# ---- phase 2: within y-moments, per (pattern, cluster) cell ----
# completed within part: y_w = c_i + H_i v + eps*, with
# c_i = R y~_i + g, H_i = H0 + sum_r x_ir (K L_r) e_{z_r}'
for (p in seq_len(npat)) {
cell <- mp$pattern[[p]]
cst <- pc[[p]]
j1 <- cell$j1
for (jj in seq_along(j1)) {
j <- j1[jj]
n_jp <- cell$n[jj]
sy_jp <- cell$sy[jj, ]
sx_jp <- cell$sx[jj, ]
sxx_jp <- cell$sxx[[jj]]
sxy_jp <- cell$sxy[[jj]]
syy_jp <- cell$syy[[jj]]
mu0_j <- mu0[j, ]
m0_j <- m0_list[[j]]
# sum_i c_i and sum_i c_i c_i'
rsy <- as.numeric(cst$r %*% sy_jp)
sc <- rsy + n_jp * cst$g
cc_mat <- cst$r %*% syy_jp %*% t(cst$r) +
outer(rsy, cst$g) + outer(cst$g, rsy) +
n_jp * tcrossprod(cst$g)
# sum_i H_i (p1 x pv)
sh <- n_jp * cst$h0
for (r in seq_len(q)) {
zc <- path_zcol[r]
sh[, zc] <- sh[, zc] + sx_jp[path_x[r]] * cst$kl[, r]
}
# sum_i x_ir c_i, per slope r (p1 vectors)
cx <- vector("list", q)
for (r in seq_len(q)) {
xr <- path_x[r]
cx[[r]] <- as.numeric(cst$r %*% sxy_jp[xr, ]) +
sx_jp[xr] * cst$g
}
# sum_i c_i (H_i mu0)'
h0mu <- as.numeric(cst$h0 %*% mu0_j)
ch <- outer(sc, h0mu)
for (r in seq_len(q)) {
ch <- ch + mu0_j[path_zcol[r]] * outer(cx[[r]], cst$kl[, r])
}
# sum_i H_i M0 H_i'
h0m0 <- cst$h0 %*% m0_j # p1 x pv
hmh <- n_jp * h0m0 %*% t(cst$h0)
for (r in seq_len(q)) {
zc_r <- path_zcol[r]
h0m_r <- h0m0[, zc_r]
hmh <- hmh + sx_jp[path_x[r]] *
(outer(h0m_r, cst$kl[, r]) + outer(cst$kl[, r], h0m_r))
for (s in seq_len(q)) {
hmh <- hmh + sxx_jp[path_x[r], path_x[s]] *
m0_j[zc_r, path_zcol[s]] *
outer(cst$kl[, r], cst$kl[, s])
}
}
sum1_w[y_idx1] <- sum1_w[y_idx1] + sc + as.numeric(sh %*% mu0_j)
sum2_w[y_idx1, y_idx1] <- sum2_w[y_idx1, y_idx1] +
cc_mat + ch + t(ch) + hmh + n_jp * cst$ct
# cross y_w x z_w[r]
for (r in seq_len(q)) {
zc_r <- path_zcol[r]
xr <- path_x[r]
cross_r <- mu0_j[zc_r] * cx[[r]] +
sx_jp[xr] * h0m0[, zc_r]
for (s in seq_len(q)) {
cross_r <- cross_r + sxx_jp[xr, path_x[s]] *
m0_j[path_zcol[s], zc_r] * cst$kl[, s]
}
sum2_w[y_idx1, z_idx1[r]] <- sum2_w[y_idx1, z_idx1[r]] + cross_r
sum2_w[z_idx1[r], y_idx1] <- sum2_w[z_idx1[r], y_idx1] + cross_r
}
}
}
# ---- z_w moments and between moments, per cluster ----
for (j in seq_len(nclusters)) {
mu0_j <- mu0[j, ]
m0_j <- m0_list[[j]]
for (r in seq_len(q)) {
zc_r <- z_v_idx[r]
xr <- path_x[r]
sum1_w[z_idx1[r]] <- sum1_w[z_idx1[r]] +
mu0_j[zc_r] * sx_tot[j, xr]
for (s in seq_len(q)) {
sum2_w[z_idx1[r], z_idx1[s]] <- sum2_w[z_idx1[r], z_idx1[s]] +
m0_j[zc_r, z_v_idx[s]] * sxx_tot[xr, path_x[s], j]
}
}
# between complete-data moments: (y_b, z, w)
b_j <- c0 + as.numeric(gmat %*% mu0_j)
bb_j <- gmat %*% m0_j %*% t(gmat) +
outer(c0, b_j) + outer(b_j, c0) - tcrossprod(c0)
sum1_b[b_idx2] <- sum1_b[b_idx2] + b_j
sum2_b[b_idx2, b_idx2] <- sum2_b[b_idx2, b_idx2] + bb_j
if (nexo > 0L) {
w_j <- rs_stats$exo.b[j, ]
sum1_b[w_idx2] <- sum1_b[w_idx2] + w_j
sum2_b[b_idx2, w_idx2] <- sum2_b[b_idx2, w_idx2] +
outer(b_j, w_j)
sum2_b[w_idx2, b_idx2] <- sum2_b[w_idx2, b_idx2] +
outer(w_j, b_j)
sum2_b[w_idx2, w_idx2] <- sum2_b[w_idx2, w_idx2] +
tcrossprod(w_j)
}
}
r1 <- sum1_w / ntot_used
t1 <- sum2_w / ntot_used - tcrossprod(r1)
r2 <- sum1_b / nclusters
t2 <- sum2_b / nclusters - tcrossprod(r2)
list(r1 = r1, t1 = t1, r2 = r2, t2 = t2, ntotal.used = ntot_used)
}
# construct the two-group M-step parameter table (computed once)
#
# returns: list(pt = the two-group partable (data.frame),
# ov1/ov2 = the observed-variable order per group,
# perm1/perm2 = permutations from the canonical E-step
# order to ov1/ov2,
# free.idx = which(pt$free > 0),
# fixed.x = any exo)
lav_mvn_cl_rs_mstep_pt <- function(lavpartable = NULL, rs_info = NULL,
floor_value = 1e-4) {
pt <- as.data.frame(lavpartable, stringsAsFactors = FALSE)
# drop columns we do not need
pt$est <- NULL
pt$se <- NULL
pt$start <- NULL
pt$rv <- NULL
pt$mod.idx <- NULL
# two-group version: level -> group
pt$group <- NULL
names(pt)[names(pt) == "level"] <- "group"
# remove all level-1 rows involving the rv covariates (their moments,
# and the frozen random-slope paths)
x_names <- rs_info$x.names
rm_idx <- which(pt$group == 1L &
(pt$lhs %in% x_names | pt$rhs %in% x_names))
# remove the phantom marker rows (s =~ s) at the between level
rm_idx <- c(rm_idx, which(pt$op == "=~" & pt$lhs == pt$rhs &
pt$lhs %in% rs_info$z.names))
if (length(rm_idx) > 0L) {
pt <- pt[-rm_idx, , drop = FALSE]
}
# Mplus-style variance floor: between-level variances fixed to (near)
# zero would make the complete-data model singular; floor them in the
# M-step model only (the nlminb hand-off afterwards uses the exact
# model)
floor_idx <- which(pt$group == 2L & pt$op == "~~" &
pt$lhs == pt$rhs & pt$free == 0L &
!is.na(pt$ustart) & abs(pt$ustart) < floor_value)
floored <- length(floor_idx) > 0L
if (floored) {
pt$ustart[floor_idx] <- floor_value
}
# phantom z_w variables at the within level; make sure the names do
# not clash with existing variables
znames <- paste0(rs_info$z.names, ".zw")
taken <- unique(c(pt$lhs, pt$rhs))
while (any(znames %in% taken)) {
znames <- paste0(".", znames)
}
q <- rs_info$q
path_tab <- rs_info$path.tab
new_lhs <- character(0L)
new_op <- character(0L)
new_rhs <- character(0L)
# the fixed unit loading of the path lhs on its z_w variable
for (r in seq_len(q)) {
new_lhs <- c(new_lhs, path_tab$lhs[r])
new_op <- c(new_op, "~")
new_rhs <- c(new_rhs, znames[r])
}
# unit variances, zero covariances, zero means (the standard-normal
# completion)
for (r in seq_len(q)) {
for (s in r:q) {
new_lhs <- c(new_lhs, znames[r])
new_op <- c(new_op, "~~")
new_rhs <- c(new_rhs, znames[s])
}
}
new_lhs <- c(new_lhs, znames)
new_op <- c(new_op, rep("~1", q))
new_rhs <- c(new_rhs, rep("", q))
nnew <- length(new_lhs)
new_ustart <- numeric(nnew)
new_ustart[seq_len(q)] <- 1 # the fixed loadings
var_idx <- q + which(new_op[-seq_len(q)] == "~~" &
new_lhs[-seq_len(q)] == new_rhs[-seq_len(q)])
new_ustart[var_idx] <- 1 # the unit variances
new_rows <- pt[rep(1L, nnew), , drop = FALSE]
new_rows$id <- max(pt$id) + seq_len(nnew)
new_rows$lhs <- new_lhs
new_rows$op <- new_op
new_rows$rhs <- new_rhs
new_rows$user <- 2L
new_rows$block <- 1L
new_rows$group <- 1L
new_rows$free <- 0L
new_rows$ustart <- new_ustart
new_rows$exo <- 0L
if (!is.null(new_rows$label)) new_rows$label <- ""
if (!is.null(new_rows$plabel)) new_rows$plabel <- ""
if (!is.null(new_rows$efa)) new_rows$efa <- ""
if (!is.null(new_rows$lower)) new_rows$lower <- -Inf
if (!is.null(new_rows$upper)) new_rows$upper <- +Inf
pt <- rbind(pt, new_rows)
rownames(pt) <- NULL
# observed-variable order per group
ov1 <- lav_pt_vnames(pt, "ov", block = 1L)
ov2 <- lav_pt_vnames(pt, "ov", block = 2L)
# canonical E-step order: (y.names, znames) and (yb, z, exo)
can1 <- c(rs_info$y.names, znames)
can2 <- c(rs_info$yb.names, rs_info$z.names, rs_info$exo.b.names)
if (length(ov1) != length(can1) || !all(ov1 %in% can1)) {
lav_msg_fixme("within M-step variables do not match the E-step")
}
if (length(ov2) != length(can2) || !all(ov2 %in% can2)) {
lav_msg_fixme("between M-step variables do not match the E-step")
}
list(
pt = pt, ov1 = ov1, ov2 = ov2,
perm1 = match(ov1, can1), perm2 = match(ov2, can2),
free.idx = which(pt$free > 0L),
fixed.x = any(pt$exo == 1L),
znames = znames, floored = floored
)
}
# EM estimation of the h0 model with random slopes
# (mirrors lav_mvn_cl_em_h0; single group)
lav_mvn_cl_rs_em_h0 <- function(lavsamplestats = NULL, lavdata = NULL,
lavpartable = NULL, lavmodel = NULL,
lavoptions = NULL, lavcache = NULL,
fx_tol = 1e-08, dx_tol = 1e-04,
max_iter = 5000L,
mstep_max_iter = 10000L,
mstep_rel_tol = 1e-10,
mstep_verbose = FALSE,
acceleration = "none") {
stopifnot(lavdata@ngroups == 1L)
# the rs cache
rs <- lavcache[[1]]$rs
if (is.null(rs)) {
rs_info <- lav_mvn_cl_rs_info(lavmodel = lavmodel,
lavdata = lavdata)
rs_stats <- lav_mvn_cl_rs_stats(
y1 = lavdata@X[[1]], lp = lavdata@Lp[[1]], rs_info = rs_info
)
rs <- list(info = rs_info, stats = rs_stats)
}
# feasibility
reason <- lav_mvn_cl_rs_em_ok(rs)
if (length(reason) > 0L) {
lav_msg_stop(gettextf(
"the EM algorithm is not available for this random-slope model
(%s); use optim_method = \"nlminb\" instead.",
paste(reason, collapse = "; ")))
}
# the two-group M-step model (built once)
mstep <- lav_mvn_cl_rs_mstep_pt(
lavpartable = lavpartable, rs_info = rs$info
)
ntot_used <- rs$stats$ntotal.used
if (is.null(ntot_used)) {
ntot_used <- rs$stats$ntotal
}
nobs_list <- list(ntot_used, rs$stats$nclusters)
# M-step: fit the two-group model to the expected moments
em_mstep <- function(estep, x_start) {
local_pt <- mstep$pt
local_pt$ustart[mstep$free.idx] <- x_start
t1 <- estep$t1[mstep$perm1, mstep$perm1, drop = FALSE]
r1 <- estep$r1[mstep$perm1]
t2 <- estep$t2[mstep$perm2, mstep$perm2, drop = FALSE]
r2 <- estep$r2[mstep$perm2]
dimnames(t1) <- list(mstep$ov1, mstep$ov1)
dimnames(t2) <- list(mstep$ov2, mstep$ov2)
names(r1) <- mstep$ov1
names(r2) <- mstep$ov2
if (!mstep_verbose) {
current_verbose <- lav_verbose()
if (lav_verbose(FALSE)) {
on.exit(lav_verbose(current_verbose), TRUE)
}
}
lavaan(local_pt,
sample_cov = list(within = t1, between = t2),
sample_mean = list(within = r1, between = r2),
sample_nobs = nobs_list,
sample.cov.rescale = FALSE,
control = list(
iter.max = mstep_max_iter,
rel.tol = mstep_rel_tol
),
fixed.x = mstep$fixed.x,
estimator = "ML",
warn = FALSE,
check.start = FALSE,
check.post = FALSE,
check.gradient = FALSE,
check.vcov = FALSE,
baseline = FALSE,
fit.by.level = FALSE,
h1 = FALSE,
se = "none",
test = "none"
)
}
# one EM step as a map in the model parameter space: x -> x'
em_step <- function(x, logl = FALSE) {
lavmodel2 <- lav_model_set_parameters(lavmodel, x = x)
imp <- lav_mvn_cl_rs_implied(lavmodel2, rs_info = rs$info)
if (is.null(imp)) {
lav_msg_stop(gettext(
"model-implied matrices could not be computed in the E-step."))
}
estep <- lav_mvn_cl_rs_estep(
rs_stats = rs$stats, imp = imp, rs_info = rs$info
)
local_fit <- em_mstep(estep, x_start = x)
local_fit@optim$x
}
# observed-data loglikelihood at x
em_logl <- function(x) {
lavmodel2 <- lav_model_set_parameters(lavmodel, x = x)
imp <- lav_mvn_cl_rs_implied(lavmodel2, rs_info = rs$info)
out <- lav_mvn_cl_rs_loglik(
rs_stats = rs$stats, imp = imp, rs_info = rs$info,
log2pi = TRUE, minus_two = FALSE
)
as.numeric(out)
}
# initial values
x_current <- lav_model_get_parameters(lavmodel)
fx <- em_logl(x_current)
if (lav_verbose()) {
cat(
"EM iter:", sprintf("%3d", 0),
" fx =", sprintf("%17.10f", fx),
"\n"
)
}
if (acceleration %in% c("squarem", "qn")) {
em_conv <- function(theta_old, theta, fx_old, fx) {
if ((fx - fx_old) < fx_tol) {
if (lav_verbose()) {
cat("EM stopping rule reached: fx.delta < ", fx_tol, "\n")
}
return(TRUE)
}
# the gradient is numerical (2 x npar objective evaluations):
# only check it when the loglikelihood is no longer moving much
if ((fx - fx_old) > 0.01) {
return(FALSE)
}
lavmodel2 <- lav_model_set_parameters(lavmodel, x = theta)
dx <- lav_model_grad(lavmodel2,
lavdata = lavdata,
lavsamplestats = lavsamplestats,
lavcache = lavcache
)
if (max(abs(dx)) < dx_tol) {
if (lav_verbose()) {
cat("EM stopping rule reached: max.dx < ", dx_tol, "\n")
}
return(TRUE)
}
FALSE
}
accel_fn <- if (acceleration == "qn") lav_em_qn else lav_em_squarem
out_em <- accel_fn(
theta = x_current, step_fn = em_step, logl_fn = em_logl,
fx0 = fx, conv_fn = em_conv, max_iter = max_iter,
logl_dec = 0 # inexact M-step: only accept monotone extrapolations
)
x <- out_em$theta
fx <- out_em$fx
em_converged <- out_em$converged || out_em$stalled
em_iterations <- out_em$fpeval
} else {
# plain EM iterations
fx_old <- fx
x <- x_current
for (i in 1:max_iter) {
x <- em_step(x)
fx <- em_logl(x)
fx_delta <- fx - fx_old
# the gradient is numerical (2 x npar objective evaluations):
# only check it when the loglikelihood is no longer moving much
if (fx_delta < 0.01) {
lavmodel2 <- lav_model_set_parameters(lavmodel, x = x)
dx <- lav_model_grad(lavmodel2,
lavdata = lavdata,
lavsamplestats = lavsamplestats,
lavcache = lavcache
)
max_dx <- max(abs(dx))
} else {
max_dx <- as.numeric(NA)
}
if (lav_verbose()) {
cat(
"EM iter:", sprintf("%3d", i),
" fx =", sprintf("%17.10f", fx),
" fx.delta =", sprintf("%9.8f", fx_delta),
" max.dx = ", sprintf("%9.8f", max_dx),
"\n"
)
}
if (fx_delta < fx_tol) {
if (lav_verbose()) {
cat("EM stopping rule reached: fx.delta < ", fx_tol, "\n")
}
break
} else {
fx_old <- fx
}
if (is.finite(max_dx) && max_dx < dx_tol) {
if (lav_verbose()) {
cat("EM stopping rule reached: max.dx < ", dx_tol, "\n")
}
break
}
}
em_converged <- i < max_iter
em_iterations <- i
}
# add attributes
if (em_converged) {
attr(x, "converged") <- TRUE
attr(x, "warn.txt") <- ""
} else {
attr(x, "converged") <- FALSE
attr(x, "warn.txt") <- paste("maximum number of iterations (",
max_iter, ") ",
"was reached without convergence.\n",
sep = ""
)
}
attr(x, "iterations") <- em_iterations
attr(x, "control") <- list(
em.args = list(
max_iter = max_iter,
fx_tol = fx_tol,
dx_tol = dx_tol,
acceleration = acceleration
)
)
attr(fx, "fx.group") <- fx # single group for now
attr(x, "fx") <- fx
# if the M-step model used the variance floor, the EM fixed point is
# NOT the optimum of the exact model: the nlminb hand-off must always
# run (even if the gradient looks small)
attr(x, "rs.floored") <- mstep$floored
x
}
# ---------------------------------------------------------------------
# analytic gradient (and per-cluster scores)
# ---------------------------------------------------------------------
#
# the gradient of the -2 loglikelihood is computed in two (chained)
# layers:
#
# 1) an ADJOINT pass (lav_mvn_cl_rs_dfphi): analytic derivatives of
# F_j = sum_P n_jP ln|Sigma_w[o,o]| + ln|Z_j| + q~_j
# - p~_j' Sigma_v Z_j^{-1} p~_j
# with respect to the model-implied ingredients
# phi = (Sigma_w, mu_y, P, lmat, q0, Sigma_v, mu_v, cc, mu_exo).
# With u = Sigma_v Z^{-1} p~ (so mu0 = d + u, Sigma0 = Sigma_v
# Z^{-1}), the derivatives wrt the per-cluster workhorses are
# remarkably clean (Fisher's identity at work):
# dF/dA = Sigma0 + mu0 mu0' = M0
# dF/dp = -2 mu0
# dF/dq = 1
# dF/dd = -2 (p - A mu0)
# dF/dSigma_v = sym(Z^{-1} A) - sym( (p~ - A u) (Z^{-1} p~)' )
# (all inverse-free: valid for singular/indefinite Sigma_v);
# chaining through A_j, p_j, q_j collapses -- once again -- onto
# the per-(cluster, pattern) crossproducts: ONE pass over the data
# cells, cost comparable to a single loglikelihood evaluation
#
# 2) the (small) Jacobian d phi / d theta of the implied map,
# computed by central differences of lav_mvn_cl_rs_implied():
# 2 x npar evaluations of a few small matrix products -- no
# cluster loops, negligible cost
#
# the same adjoint pass, kept per cluster, yields the per-cluster
# scores (for the robust/sandwich standard errors) at no extra cost
# flatten the theta-dependent implied ingredients into one vector
# (the adjoint pass uses the same layout)
lav_mvn_cl_rs_phi_vec <- function(imp) {
c(
as.numeric(imp$sigma_w), imp$mu_y, as.numeric(imp$P),
as.numeric(imp$lmat), as.numeric(imp$q0), as.numeric(imp$sigma.v),
imp$mu.v, as.numeric(imp$cc), imp$mu.exo,
imp$mu_z, as.numeric(imp$gmat), as.numeric(imp$sigma.z)
)
}
# adjoint pass: returns a (nclusters x nphi) matrix with the
# per-cluster derivatives of F_j wrt phi (NULL if something fails)
lav_mvn_cl_rs_dfphi <- function(rs_stats = NULL, imp = NULL,
rs_info = NULL,
sinv_method = "eigen") {
p1 <- rs_info$p1
nx <- rs_info$nx
pv <- imp$pv
path_tab <- rs_info$path.tab
npaths <- nrow(path_tab)
z_v_idx <- imp$z.v.idx
path_zcol <- z_v_idx[path_tab$z.idx]
path_x <- path_tab$x.idx
nclusters <- rs_stats$nclusters
nexo <- rs_info$nexo.b
exo_b <- rs_stats$exo.b
sigma_v <- imp$sigma.v
mu_v <- imp$mu.v
cc_mat <- imp$cc
mu_exo <- imp$mu.exo
mu_y <- imp$mu_y
pmat <- imp$P
pmat_t <- t(pmat)
lmat <- imp$lmat
q0 <- imp$q0
dpv <- diag(pv)
mu_y_op <- tcrossprod(mu_y)
# flattened phi layout (must match lav_mvn_cl_rs_phi_vec)
kz <- rs_info$kz
sizes <- c(p1 * p1, p1, p1 * nx, p1 * npaths, p1 * pv,
pv * pv, pv, pv * nexo, nexo,
kz, kz * pv, kz * kz)
off <- cumsum(c(0, sizes))
i_sw <- off[1] + seq_len(sizes[1])
i_muy <- off[2] + seq_len(sizes[2])
i_p <- off[3] + seq_len(sizes[3])
i_lm <- off[4] + seq_len(sizes[4])
i_q0 <- off[5] + seq_len(sizes[5])
i_sv <- off[6] + seq_len(sizes[6])
i_mv <- off[7] + seq_len(sizes[7])
i_cc <- off[8] + seq_len(sizes[8])
i_me <- off[9] + seq_len(sizes[9])
i_mz <- off[10] + seq_len(sizes[10])
i_g <- off[11] + seq_len(sizes[11])
i_sz <- off[12] + seq_len(sizes[12])
nphi <- off[13]
gphi <- matrix(0, nclusters, nphi)
# z-outcome constants (the extra observation block)
if (kz > 0L) {
gmat_z <- imp$gmat
mu_zb <- imp$mu_z
zb_all <- rs_stats$zb
wzb <- lav_mat_sym_inverse(imp$sigma.z,
logdet = TRUE, sinv_method = sinv_method
)
if (!is.finite(attr(wzb, "logdet"))) {
return(NULL)
}
wzg <- wzb %*% gmat_z # kz x pv
a_zb <- crossprod(gmat_z, wzg) # pv x pv
}
# ---- z-block adjoints (mu_z, G, Sigma_z); the z-block is one
# extra 'cell' with constant loading G and weight Wz ----
add_zcell <- function(j, g_a, g_p) {
e_z <- zb_all[j, ] - mu_zb
wze <- as.numeric(wzb %*% e_z)
gga <- gmat_z %*% g_a # kz x pv
ggp <- as.numeric(gmat_z %*% g_p) # kz
# G adjoint (cfr. the q0 adjoint with n = 1, e~ = e_z)
gg <- 2 * (wzb %*% gga) + outer(wze, g_p)
# mu_z adjoint (cfr. the mu_y adjoint)
gmz <- -as.numeric(wzb %*% (ggp + 2 * e_z))
# Sigma_z adjoint (via Wz, plus the ln|Sigma_z| term)
gw_z <- gga %*% t(gmat_z) + outer(ggp, e_z) + tcrossprod(e_z)
gw_z <- (gw_z + t(gw_z)) / 2
gsz <- wzb - wzb %*% gw_z %*% wzb
gphi[j, i_mz] <<- gphi[j, i_mz] + gmz
gphi[j, i_g] <<- gphi[j, i_g] + as.numeric(gg)
gphi[j, i_sz] <<- gphi[j, i_sz] + as.numeric(gsz)
invisible(NULL)
}
# ---- posterior pieces + (Sigma_v, mu_v, cc, mu_exo) adjoints ----
# (returns the g_a/g_p adjoints needed for the cell collapse)
post_adj <- function(j, a_j, p_j) {
if (nexo > 0L) {
d_j <- mu_v + as.numeric(cc_mat %*% (exo_b[j, ] - mu_exo))
} else {
d_j <- mu_v
}
p_t <- p_j - as.numeric(a_j %*% d_j)
z_j <- dpv + a_j %*% sigma_v
z_inv <- solve(z_j)
zp <- as.numeric(z_inv %*% p_t)
u <- as.numeric(sigma_v %*% zp)
mu0 <- d_j + u
s0 <- sigma_v %*% z_inv
s0 <- (s0 + t(s0)) / 2
g_a <- s0 + tcrossprod(mu0)
g_p <- -2 * mu0
g_d <- -2 * (p_j - as.numeric(a_j %*% mu0))
za <- z_inv %*% a_j
m1 <- outer(p_t - as.numeric(a_j %*% u), zp)
g_sv <- (za + t(za)) / 2 - (m1 + t(m1)) / 2
gphi[j, i_sv] <<- gphi[j, i_sv] + as.numeric(g_sv)
gphi[j, i_mv] <<- gphi[j, i_mv] + g_d
if (nexo > 0L) {
gphi[j, i_cc] <<- gphi[j, i_cc] +
as.numeric(outer(g_d, exo_b[j, ] - mu_exo))
gphi[j, i_me] <<- gphi[j, i_me] -
as.numeric(crossprod(cc_mat, g_d))
}
list(g_a = g_a, g_p = g_p)
}
# ---- collapse of one (cluster, pattern) cell onto the
# (Sigma_w, mu_y, P, lmat, q0) adjoints ----
add_cell <- function(j, w_mat, n_c, sx_c, sxx_c, sy_c, sxy_c, syy_c,
se_c, sxe_c, g_a, g_p) {
# SQ = sum_i Q_i
sq <- n_c * q0
for (r in seq_len(npaths)) {
sq[, path_zcol[r]] <- sq[, path_zcol[r]] +
sx_c[path_x[r]] * lmat[, r]
}
q0ga <- q0 %*% g_a
# G_W (raw): <G_A, dA/dW> + <g_p, dp/dW> + dq/dW
gw <- n_c * (q0ga %*% t(q0))
for (r in seq_len(npaths)) {
arv <- as.numeric(q0 %*% g_a[, path_zcol[r]])
gw <- gw + sx_c[path_x[r]] *
(outer(lmat[, r], arv) + outer(arv, lmat[, r]))
}
for (r in seq_len(npaths)) {
for (s in seq_len(npaths)) {
gw <- gw + sxx_c[path_x[r], path_x[s]] *
g_a[path_zcol[r], path_zcol[s]] *
outer(lmat[, r], lmat[, s])
}
}
q0gp <- as.numeric(q0 %*% g_p)
gw <- gw + outer(q0gp, se_c)
for (r in seq_len(npaths)) {
gw <- gw + g_p[path_zcol[r]] * outer(lmat[, r], sxe_c[path_x[r], ])
}
# sum_i e~_i e~_i'
psx <- as.numeric(pmat %*% sx_c)
see <- syy_c - outer(sy_c, mu_y) - outer(mu_y, sy_c) +
n_c * mu_y_op -
t(sxy_c) %*% pmat_t - pmat %*% sxy_c +
outer(mu_y, psx) + outer(psx, mu_y) +
pmat %*% sxx_c %*% pmat_t
gw <- gw + see
gw <- (gw + t(gw)) / 2
# chain W = Sigma_w[o,o]^{-1} (embedded) -> Sigma_w,
# including the n_c ln|Sigma_w[o,o]| term
gsw <- n_c * w_mat - w_mat %*% gw %*% w_mat
# q0 adjoint: sum_i (2 W Q_i G_A + W e~_i g_p')
gq0 <- 2 * (w_mat %*% (sq %*% g_a)) +
outer(as.numeric(w_mat %*% se_c), g_p)
# lmat adjoint
glm <- matrix(0, p1, npaths)
for (r in seq_len(npaths)) {
xq <- sx_c[path_x[r]] * q0
for (s in seq_len(npaths)) {
xq[, path_zcol[s]] <- xq[, path_zcol[s]] +
sxx_c[path_x[r], path_x[s]] * lmat[, s]
}
glm[, r] <- 2 * as.numeric(w_mat %*% (xq %*% g_a[, path_zcol[r]])) +
g_p[path_zcol[r]] * as.numeric(w_mat %*% sxe_c[path_x[r], ])
}
# mu_y adjoint: -sum_i (W Q_i g_p + 2 W e~_i)
gmy <- -as.numeric(w_mat %*% (as.numeric(sq %*% g_p) + 2 * se_c))
# P adjoint: -[ W sum_i (Q_i g_p) x_i' + 2 W sum_i e~_i x_i' ]
mm <- outer(q0gp, sx_c)
for (r in seq_len(npaths)) {
mm <- mm + g_p[path_zcol[r]] * outer(lmat[, r], sxx_c[path_x[r], ])
}
gp_mat <- -(w_mat %*% (mm + 2 * t(sxe_c)))
gphi[j, i_sw] <<- gphi[j, i_sw] + as.numeric(gsw)
gphi[j, i_muy] <<- gphi[j, i_muy] + gmy
if (nx > 0L) {
gphi[j, i_p] <<- gphi[j, i_p] + as.numeric(gp_mat)
}
gphi[j, i_lm] <<- gphi[j, i_lm] + as.numeric(glm)
gphi[j, i_q0] <<- gphi[j, i_q0] + as.numeric(gq0)
invisible(NULL)
}
if (is.null(rs_stats$mp)) {
# -------------------- complete data --------------------
w_mat <- lav_mat_sym_inverse(imp$sigma_w,
logdet = TRUE, sinv_method = sinv_method
)
if (!is.finite(attr(w_mat, "logdet"))) {
return(NULL)
}
wl <- w_mat %*% lmat
ltwl <- crossprod(lmat, wl)
wq0 <- w_mat %*% q0
q0twq0 <- crossprod(q0, wq0)
q0twl <- crossprod(q0, wl)
nj_all <- rs_stats$cluster.size
for (j in seq_len(nclusters)) {
nj <- nj_all[j]
sy_j <- rs_stats$sy[j, ]
sx_j <- rs_stats$sx[j, ]
sxx_j <- rs_stats$sxx[[j]]
sxy_j <- rs_stats$sxy[[j]]
syy_j <- rs_stats$syy[[j]]
se_j <- sy_j - nj * mu_y - as.numeric(pmat %*% sx_j)
sxe_j <- sxy_j - outer(sx_j, mu_y) - sxx_j %*% pmat_t
a_j <- nj * q0twq0
p_j <- as.numeric(crossprod(wq0, se_j))
cz <- matrix(0, pv, pv)
for (i in seq_len(npaths)) {
zcol <- path_zcol[i]
cz[, zcol] <- cz[, zcol] + q0twl[, i] * sx_j[path_x[i]]
}
a_j <- a_j + cz + t(cz)
for (i in seq_len(npaths)) {
for (k in seq_len(npaths)) {
a_j[path_zcol[i], path_zcol[k]] <-
a_j[path_zcol[i], path_zcol[k]] +
ltwl[i, k] * sxx_j[path_x[i], path_x[k]]
}
}
for (i in seq_len(npaths)) {
zi <- path_zcol[i]
p_j[zi] <- p_j[zi] + sum(wl[, i] * sxe_j[path_x[i], ])
}
if (kz > 0L) {
a_j <- a_j + a_zb
p_j <- p_j + as.numeric(crossprod(wzg, zb_all[j, ] - mu_zb))
}
adj <- post_adj(j, a_j, p_j)
if (kz > 0L) {
add_zcell(j, adj$g_a, adj$g_p)
}
add_cell(
j, w_mat, nj, sx_j, sxx_j, sy_j, sxy_j, syy_j,
se_j, sxe_j, adj$g_a, adj$g_p
)
}
} else {
# -------------------- missing data --------------------
mp <- rs_stats$mp
npat <- mp$npatterns
# per-pattern (embedded) weight matrices and constants
w_list <- vector("list", npat)
for (p in seq_len(npat)) {
o <- mp$pattern[[p]]$o
w_o <- lav_mat_sym_inverse(imp$sigma_w[o, o, drop = FALSE],
logdet = TRUE, sinv_method = sinv_method
)
if (!is.finite(attr(w_o, "logdet"))) {
return(NULL)
}
w_p <- matrix(0, p1, p1)
w_p[o, o] <- w_o
w_list[[p]] <- w_p
}
# pass 1: per-cluster A_j and p_j
a_arr <- array(0, dim = c(pv, pv, nclusters))
p_all <- matrix(0, nclusters, pv)
for (p in seq_len(npat)) {
cell <- mp$pattern[[p]]
w_p <- w_list[[p]]
wl <- w_p %*% lmat
ltwl <- crossprod(lmat, wl)
wq0 <- w_p %*% q0
q0twq0 <- crossprod(q0, wq0)
q0twl <- crossprod(q0, wl)
for (jj in seq_along(cell$j1)) {
j <- cell$j1[jj]
n_jp <- cell$n[jj]
sy_jp <- cell$sy[jj, ]
sx_jp <- cell$sx[jj, ]
sxx_jp <- cell$sxx[[jj]]
sxy_jp <- cell$sxy[[jj]]
se_jp <- sy_jp - n_jp * mu_y - as.numeric(pmat %*% sx_jp)
sxe_jp <- sxy_jp - outer(sx_jp, mu_y) - sxx_jp %*% pmat_t
a_jp <- n_jp * q0twq0
p_jp <- as.numeric(crossprod(wq0, se_jp))
cz <- matrix(0, pv, pv)
for (i in seq_len(npaths)) {
zcol <- path_zcol[i]
cz[, zcol] <- cz[, zcol] + q0twl[, i] * sx_jp[path_x[i]]
}
a_jp <- a_jp + cz + t(cz)
for (i in seq_len(npaths)) {
for (k in seq_len(npaths)) {
a_jp[path_zcol[i], path_zcol[k]] <-
a_jp[path_zcol[i], path_zcol[k]] +
ltwl[i, k] * sxx_jp[path_x[i], path_x[k]]
}
}
for (i in seq_len(npaths)) {
zi <- path_zcol[i]
p_jp[zi] <- p_jp[zi] + sum(wl[, i] * sxe_jp[path_x[i], ])
}
a_arr[, , j] <- a_arr[, , j] + a_jp
p_all[j, ] <- p_all[j, ] + p_jp
}
}
# z-outcome contributions (once per cluster)
if (kz > 0L) {
for (j in seq_len(nclusters)) {
a_arr[, , j] <- a_arr[, , j] + a_zb
p_all[j, ] <- p_all[j, ] +
as.numeric(crossprod(wzg, zb_all[j, ] - mu_zb))
}
}
# posterior adjoints per cluster
ga_list <- vector("list", nclusters)
gp_list <- vector("list", nclusters)
for (j in seq_len(nclusters)) {
adj <- post_adj(j, matrix(a_arr[, , j], pv, pv), p_all[j, ])
ga_list[[j]] <- adj$g_a
gp_list[[j]] <- adj$g_p
if (kz > 0L) {
add_zcell(j, adj$g_a, adj$g_p)
}
}
# pass 2: cell collapse
for (p in seq_len(npat)) {
cell <- mp$pattern[[p]]
w_p <- w_list[[p]]
for (jj in seq_along(cell$j1)) {
j <- cell$j1[jj]
n_jp <- cell$n[jj]
sy_jp <- cell$sy[jj, ]
sx_jp <- cell$sx[jj, ]
sxx_jp <- cell$sxx[[jj]]
sxy_jp <- cell$sxy[[jj]]
syy_jp <- cell$syy[[jj]]
se_jp <- sy_jp - n_jp * mu_y - as.numeric(pmat %*% sx_jp)
sxe_jp <- sxy_jp - outer(sx_jp, mu_y) - sxx_jp %*% pmat_t
add_cell(
j, w_p, n_jp, sx_jp, sxx_jp, sy_jp, sxy_jp, syy_jp,
se_jp, sxe_jp, ga_list[[j]], gp_list[[j]]
)
}
}
}
gphi
}
# gradient of the -2 loglikelihood wrt the free parameters
# (per_cluster = TRUE: the per-cluster scores d loglik_j / d theta,
# on the natural loglikelihood scale)
lav_mvn_cl_rs_grad <- function(lavmodel = NULL, rs = NULL,
sinv_method = "eigen",
per_cluster = FALSE,
h = 1e-06) {
# latent covariates: posterior-weighted adjoint pass per quadrature
# node (numerical fallback on failure, via the callers)
if (isTRUE(rs$info$nl.flag)) {
out <- try(lav_mvn_cl_rs_grad_nl(
lavmodel = lavmodel, rs = rs, sinv_method = sinv_method,
per_cluster = per_cluster, h = h
), silent = TRUE)
if (inherits(out, "try-error")) {
return(NULL)
}
return(out)
}
imp0 <- lav_mvn_cl_rs_implied(lavmodel, rs_info = rs$info)
if (is.null(imp0)) {
return(NULL)
}
gphi <- lav_mvn_cl_rs_dfphi(
rs_stats = rs$stats, imp = imp0, rs_info = rs$info,
sinv_method = sinv_method
)
if (is.null(gphi)) {
return(NULL)
}
# the (small) Jacobian of the implied map: central differences,
# no cluster loops
x0 <- lav_model_get_parameters(lavmodel)
npar <- length(x0)
phi0 <- lav_mvn_cl_rs_phi_vec(imp0)
jac <- matrix(0, length(phi0), npar)
for (k in seq_len(npar)) {
h_k <- h * max(1, abs(x0[k]))
x_right <- x_left <- x0
x_right[k] <- x0[k] + h_k
x_left[k] <- x0[k] - h_k
imp_r <- lav_mvn_cl_rs_implied(
lav_model_set_parameters(lavmodel, x = x_right),
rs_info = rs$info
)
imp_l <- lav_mvn_cl_rs_implied(
lav_model_set_parameters(lavmodel, x = x_left),
rs_info = rs$info
)
if (is.null(imp_r) || is.null(imp_l)) {
return(NULL)
}
jac[, k] <- (lav_mvn_cl_rs_phi_vec(imp_r) -
lav_mvn_cl_rs_phi_vec(imp_l)) / (2 * h_k)
}
if (per_cluster) {
-0.5 * (gphi %*% jac)
} else {
as.numeric(colSums(gphi) %*% jac)
}
}
# H0 scaling correction factor (cfr. the Mplus MLR output):
#
# c_H0 = tr( H^{-1} J ) / npar
#
# with H the total *observed* information (minus the second derivative
# of the loglikelihood; via lav_model_hessian() with method =
# "central": central differences of the analytic gradient, relative
# step size) and J = crossprod of the per-cluster scores (the
# first-order information); this is the ingredient for scaled
# (loglikelihood) difference tests
lav_mvn_cl_rs_scaling_h0 <- function(lavobject = NULL, rs = NULL,
h = 1e-04) {
lavmodel <- lavobject@Model
sc <- try(lav_mvn_cl_rs_scores(lavmodel = lavmodel, rs = rs),
silent = TRUE)
if (inherits(sc, "try-error")) {
return(as.numeric(NA))
}
j_mat <- crossprod(sc)
# hessian of the objective ( = -2 loglik / (2 N) ): the observed
# information is hessian * ntotal
h_obj <- lav_model_hessian(
lavmodel = lavmodel,
lavsamplestats = lavobject@SampleStats,
lavdata = lavobject@Data,
lavoptions = lavobject@Options,
lavcache = lavobject@Cache,
h = h, method = "central"
)
if (anyNA(h_obj)) {
return(as.numeric(NA))
}
h_mat <- h_obj * lavobject@SampleStats@ntotal
npar <- lavmodel@nx.free
out <- try(sum(diag(solve(h_mat, j_mat))) / npar, silent = TRUE)
if (inherits(out, "try-error") || !is.finite(out)) {
return(as.numeric(NA))
}
out
}
# ---------------------------------------------------------------------
# per-cluster scores and the first-order (`meat') information
# ---------------------------------------------------------------------
# per-cluster scores: d loglik_j / d theta (natural loglikelihood
# scale); analytic (adjoint pass), with a numerical fallback
# (central differences of the per-cluster loglikelihood vector)
#
# returns a (nclusters x npar) matrix
lav_mvn_cl_rs_scores <- function(lavmodel = NULL, rs = NULL,
h = 1e-05) {
# analytic scores
sc <- try(
lav_mvn_cl_rs_grad(lavmodel = lavmodel, rs = rs,
per_cluster = TRUE),
silent = TRUE
)
if (!inherits(sc, "try-error") && !is.null(sc)) {
return(sc)
}
# numerical fallback
x0 <- lav_model_get_parameters(lavmodel)
npar <- length(x0)
nclusters <- rs$stats$nclusters
ll_cluster <- function(x) {
lavmodel2 <- lav_model_set_parameters(lavmodel, x = x)
out <- lav_mvn_cl_rs_m2ll(
lavmodel = lavmodel2, rs = rs,
log2pi = FALSE, minus_two = FALSE, per_cluster = TRUE
)
attr(out, "loglik.cluster")
}
sc <- matrix(0, nclusters, npar)
for (k in seq_len(npar)) {
h_k <- h * max(1, abs(x0[k]))
x_right <- x_left <- x0
x_right[k] <- x0[k] + h_k
x_left[k] <- x0[k] - h_k
ll_right <- ll_cluster(x_right)
ll_left <- ll_cluster(x_left)
if (is.null(ll_right) || is.null(ll_left)) {
lav_msg_stop(gettext(
"per-cluster scores could not be computed (non-finite
loglikelihood near the parameter estimates)."))
}
sc[, k] <- (ll_right - ll_left) / (2 * h_k)
}
sc
}
# ---------------------------------------------------------------------
# empirical Bayes (posterior mean) predictions
# ---------------------------------------------------------------------
#
# - level 2: the posterior means mu0_j of the between latent variables
# (the between factors AND the random slopes) -- the same posterior
# as the EM E-step
# - level 1: the within factor scores, conditional on the posterior
# means of the between random vector; because everything is linear
# and Gaussian, plugging in mu0_j gives the *exact* posterior mean
# E(eta_wij | all data of cluster j)
lav_mvn_cl_rs_eb <- function(lavmodel = NULL, lavdata = NULL,
lavcache = NULL, se = FALSE) {
# the rs cache
rs <- lavcache[[1]]$rs
if (is.null(rs)) {
rs_info <- lav_mvn_cl_rs_info(lavmodel = lavmodel,
lavdata = lavdata)
rs_stats <- lav_mvn_cl_rs_stats(
y1 = lavdata@X[[1]], lp = lavdata@Lp[[1]], rs_info = rs_info
)
rs <- list(info = rs_info, stats = rs_stats)
}
rs_info <- rs$info
rs_stats <- rs$stats
# latent covariates: EAP predictions (posterior-weighted mixture
# over the quadrature nodes)
if (isTRUE(rs_info$nl.flag)) {
return(lav_mvn_cl_rs_eb_nl(
lavmodel = lavmodel, lavdata = lavdata, rs = rs, se = se
))
}
imp <- lav_mvn_cl_rs_implied(lavmodel, rs_info = rs_info)
if (is.null(imp)) {
lav_msg_stop(gettext(
"model-implied matrices could not be computed."))
}
core <- lav_mvn_cl_rs_eb_core(
lavmodel = lavmodel, glist = lavmodel@GLIST, lavdata = lavdata,
rs_stats = rs_stats, rs_info = rs_info, imp = imp, se = se
)
# level-2 scores: the between latent variables (eta part of v_b)
meta <- imp$meta
l2 <- core$mu0[, seq_len(meta), drop = FALSE]
colnames(l2) <- imp$eta.names
out <- list(l1 = core$l1, l2 = l2, cluster.idx = core$cluster.idx)
if (se) {
se2 <- sqrt(pmax(core$var0[, seq_len(meta), drop = FALSE], 0))
colnames(se2) <- imp$eta.names
out$se2 <- se2
}
out
}
# the (linear-model) EB workhorse: per-cluster posterior of v_b
# (mu0, and the posterior variances var0 = diag(Sigma0)) plus the
# level-1 (within) factor scores, for the model described by
# (glist, imp, rs_info) -- which may be the *conditional* model of
# one quadrature node (latent covariates); the caller assembles
lav_mvn_cl_rs_eb_core <- function(lavmodel = NULL, glist = NULL,
lavdata = NULL,
rs_stats = NULL, rs_info = NULL,
imp = NULL, se = FALSE) {
p1 <- rs_info$p1
nx <- rs_info$nx
pv <- imp$pv
meta <- imp$meta
path_tab <- rs_info$path.tab
npaths <- nrow(path_tab)
z_v_idx <- imp$z.v.idx
path_zcol <- z_v_idx[path_tab$z.idx]
path_x <- path_tab$x.idx
nclusters <- rs_stats$nclusters
nj_all <- rs_stats$cluster.size
sigma_w_inv <- lav_mat_sym_inverse(imp$sigma_w, logdet = TRUE)
if (!is.finite(attr(sigma_w_inv, "logdet"))) {
lav_msg_stop(gettext(
"within covariance matrix is not positive definite."))
}
w_mat <- sigma_w_inv
sigma_v <- imp$sigma.v
mu_y <- imp$mu_y
# constants (as in the loglikelihood/E-step)
wl <- w_mat %*% imp$lmat
wq0 <- w_mat %*% imp$q0
q0twq0 <- crossprod(imp$q0, wq0)
q0twl <- crossprod(imp$q0, wl)
ltwl <- crossprod(imp$lmat, wl)
dpv <- diag(pv)
# between-only endogenous z-outcomes
kz <- rs_info$kz
if (kz > 0L) {
wzb <- lav_mat_sym_inverse(imp$sigma.z)
wzg <- wzb %*% imp$gmat
a_zb <- crossprod(imp$gmat, wzg)
zb_all <- rs_stats$zb
mu_zb <- imp$mu_z
}
# ---- per-cluster posterior of v_b: mu0_j (and posterior sd) ----
pmat <- imp$P
pmat_t <- t(pmat)
mu0 <- matrix(0, nclusters, pv)
var0 <- if (se) matrix(0, nclusters, pv) else NULL
if (!is.null(rs_stats$mp)) {
# missing data: pattern-based accumulation of A_j and p_j
# (cfr. lav_mvn_cl_rs_loglik_m)
mp <- rs_stats$mp
a_arr <- array(0, dim = c(pv, pv, nclusters))
p_all <- matrix(0, nclusters, pv)
for (p in seq_len(mp$npatterns)) {
cell <- mp$pattern[[p]]
o <- cell$o
w_o <- lav_mat_sym_inverse(imp$sigma_w[o, o, drop = FALSE],
logdet = TRUE)
if (!is.finite(attr(w_o, "logdet"))) {
lav_msg_stop(gettext(
"within covariance matrix is not positive definite."))
}
w_p <- matrix(0, p1, p1)
w_p[o, o] <- w_o
wl_p <- w_p %*% imp$lmat
wq0_p <- w_p %*% imp$q0
q0twq0_p <- crossprod(imp$q0, wq0_p)
q0twl_p <- crossprod(imp$q0, wl_p)
ltwl_p <- crossprod(imp$lmat, wl_p)
for (jj in seq_along(cell$j1)) {
j <- cell$j1[jj]
n_jp <- cell$n[jj]
sy_jp <- cell$sy[jj, ]
sx_jp <- cell$sx[jj, ]
sxx_jp <- cell$sxx[[jj]]
sxy_jp <- cell$sxy[[jj]]
se_jp <- sy_jp - n_jp * mu_y - as.numeric(pmat %*% sx_jp)
sxe_jp <- sxy_jp - outer(sx_jp, mu_y) - sxx_jp %*% pmat_t
a_jp <- n_jp * q0twq0_p
p_jp <- as.numeric(crossprod(wq0_p, se_jp))
cz <- matrix(0, pv, pv)
for (i in seq_len(npaths)) {
zcol <- path_zcol[i]
cz[, zcol] <- cz[, zcol] + q0twl_p[, i] * sx_jp[path_x[i]]
}
a_jp <- a_jp + cz + t(cz)
for (i in seq_len(npaths)) {
for (k in seq_len(npaths)) {
a_jp[path_zcol[i], path_zcol[k]] <-
a_jp[path_zcol[i], path_zcol[k]] +
ltwl_p[i, k] * sxx_jp[path_x[i], path_x[k]]
}
}
for (i in seq_len(npaths)) {
zi <- path_zcol[i]
p_jp[zi] <- p_jp[zi] + sum(wl_p[, i] * sxe_jp[path_x[i], ])
}
a_arr[, , j] <- a_arr[, , j] + a_jp
p_all[j, ] <- p_all[j, ] + p_jp
}
}
for (j in seq_len(nclusters)) {
a_j <- matrix(a_arr[, , j], pv, pv)
if (kz > 0L) {
a_j <- a_j + a_zb
p_all[j, ] <- p_all[j, ] +
as.numeric(crossprod(wzg, zb_all[j, ] - mu_zb))
}
if (rs_info$nexo.b > 0L) {
d_j <- imp$mu.v +
as.numeric(imp$cc %*% (rs_stats$exo.b[j, ] - imp$mu.exo))
} else {
d_j <- imp$mu.v
}
z_j <- dpv + a_j %*% sigma_v
sigma0_j <- sigma_v %*% solve(z_j)
sigma0_j <- (sigma0_j + t(sigma0_j)) / 2
p_tilde <- p_all[j, ] - as.numeric(a_j %*% d_j)
mu0[j, ] <- d_j + as.numeric(sigma0_j %*% p_tilde)
if (se) {
var0[j, ] <- diag(sigma0_j)
}
}
} else {
for (j in seq_len(nclusters)) {
nj <- nj_all[j]
sy_j <- rs_stats$sy[j, ]
sx_j <- rs_stats$sx[j, ]
sxx_j <- rs_stats$sxx[[j]]
sxy_j <- rs_stats$sxy[[j]]
# centered sums: e~_i = y_i - mu_y - P x_i (P = the reduced-form
# *fixed* slopes of y on the rv covariates)
se_j <- sy_j - nj * mu_y - as.numeric(pmat %*% sx_j)
a_j <- nj * q0twq0
p_j <- as.numeric(crossprod(wq0, se_j))
cz <- matrix(0, pv, pv)
for (i in seq_len(npaths)) {
zcol <- path_zcol[i]
cz[, zcol] <- cz[, zcol] + q0twl[, i] * sx_j[path_x[i]]
}
a_j <- a_j + cz + t(cz)
for (i in seq_len(npaths)) {
for (k in seq_len(npaths)) {
a_j[path_zcol[i], path_zcol[k]] <-
a_j[path_zcol[i], path_zcol[k]] +
ltwl[i, k] * sxx_j[path_x[i], path_x[k]]
}
}
sxe_j <- sxy_j - outer(sx_j, mu_y) - sxx_j %*% pmat_t
for (i in seq_len(npaths)) {
zi <- path_zcol[i]
p_j[zi] <- p_j[zi] + sum(wl[, i] * sxe_j[path_x[i], ])
}
if (kz > 0L) {
a_j <- a_j + a_zb
p_j <- p_j + as.numeric(crossprod(wzg, zb_all[j, ] - mu_zb))
}
if (rs_info$nexo.b > 0L) {
d_j <- imp$mu.v +
as.numeric(imp$cc %*% (rs_stats$exo.b[j, ] - imp$mu.exo))
} else {
d_j <- imp$mu.v
}
z_j <- dpv + a_j %*% sigma_v
sigma0_j <- sigma_v %*% solve(z_j)
sigma0_j <- (sigma0_j + t(sigma0_j)) / 2
p_tilde <- p_j - as.numeric(a_j %*% d_j)
mu0[j, ] <- d_j + as.numeric(sigma0_j %*% p_tilde)
if (se) {
var0[j, ] <- diag(sigma0_j)
}
}
} # complete data
# ---- level-1 scores: within factor scores given (mu0_j, x_ij) ----
# within model pieces (same construction as lav_mvn_cl_rs_implied;
# note: from 'glist', which may hold node-injected beta_w cells)
nmat <- lavmodel@nmat
mm_w <- 1:nmat[1]
mlist_w <- glist[mm_w]
dimnames_w <- lavmodel@dimNames[mm_w]
names(dimnames_w) <- names(lavmodel@GLIST)[mm_w]
ov_names_w <- dimnames_w[["lambda"]][[1]]
lv_names_w <- dimnames_w[["lambda"]][[2]]
y_idx <- match(rs_info$y.names, ov_names_w)
x_lv_idx <- match(rs_info$x.names, lv_names_w)
lambda_w <- mlist_w$lambda
psi_w <- mlist_w$psi
beta_w <- mlist_w$beta
alpha_w <- mlist_w$alpha
nlv_w <- ncol(lambda_w)
if (is.null(beta_w)) {
ib_inv_w <- diag(nlv_w)
} else {
ib_inv_w <- solve(diag(nlv_w) - beta_w)
}
psi_w0 <- psi_w
alpha_w0 <- if (is.null(alpha_w)) {
matrix(0, nlv_w, 1L)
} else {
alpha_w
}
if (length(x_lv_idx) > 0L) {
psi_w0[x_lv_idx, ] <- 0
psi_w0[, x_lv_idx] <- 0
alpha_w0[x_lv_idx, 1L] <- 0
}
# 'real' latent variables: not an observed variable (dummy lv), and
# not an rv covariate
ov_names_data <- lavdata@ov.names[[1]]
real_idx <- which(!lv_names_w %in% ov_names_data)
y1 <- lavdata@X[[1]]
yy <- y1[, rs_info$y.data.idx, drop = FALSE]
xx <- y1[, rs_info$x.data.idx, drop = FALSE]
cluster_idx <- lavdata@Lp[[1]]$cluster.idx[[2]]
n1 <- nrow(y1)
if (length(real_idx) > 0L) {
# residuals: y_ij - E(y_ij | mu0_j, x_ij)
vhat <- mu0[cluster_idx, , drop = FALSE]
yhat <- matrix(mu_y, n1, p1, byrow = TRUE) +
xx %*% t(imp$P) + vhat %*% t(imp$q0)
# per-case slope inputs: z-hat * x
zx <- matrix(0, n1, npaths)
for (i in seq_len(npaths)) {
zx[, i] <- vhat[, path_zcol[i]] * xx[, path_x[i]]
yhat <- yhat + outer(zx[, i], imp$lmat[, i])
}
e_mat <- yy - yhat
# conditional mean of the (full) within lv vector:
# (I - B)^{-1} [ alpha0 + x (in the x-dummy slots)
# + zhat*x (in the lhs equations) ]
t_inp <- matrix(alpha_w0[, 1L], n1, nlv_w, byrow = TRUE)
if (length(x_lv_idx) > 0L) {
t_inp[, x_lv_idx] <- t_inp[, x_lv_idx, drop = FALSE] + xx
}
for (i in seq_len(npaths)) {
lhs_col <- match(path_tab$lhs[i], lv_names_w)
if (!is.na(lhs_col)) {
t_inp[, lhs_col] <- t_inp[, lhs_col] + zx[, i]
}
}
m_full <- t_inp %*% t(ib_inv_w)
# regression update: + Cov(eta, y) Sigma_w^{-1} e
veta0 <- ib_inv_w %*% psi_w0 %*% t(ib_inv_w)
ceta_y <- (veta0 %*% t(lambda_w[y_idx, , drop = FALSE]))[real_idx, ,
drop = FALSE
]
if (is.null(rs_stats$mp)) {
upd <- e_mat %*% (w_mat %*% t(ceta_y))
} else {
# missing data: the update uses the observed coordinates only
# (cases with no observed outcomes get the plain conditional
# mean, without a residual update)
upd <- matrix(0, n1, length(real_idx))
obs <- !is.na(yy)
case_id <- apply(1L * obs, 1L, paste, collapse = "")
for (pid in unique(case_id)) {
ridx <- which(case_id == pid)
o <- which(obs[ridx[1L], ])
if (length(o) == 0L) {
next
}
w_o <- lav_mat_sym_inverse(imp$sigma_w[o, o, drop = FALSE])
upd[ridx, ] <- e_mat[ridx, o, drop = FALSE] %*%
(w_o %*% t(ceta_y[, o, drop = FALSE]))
}
}
l1 <- m_full[, real_idx, drop = FALSE] + upd
colnames(l1) <- lv_names_w[real_idx]
} else {
l1 <- matrix(0, n1, 0L)
}
list(mu0 = mu0, var0 = var0, l1 = l1, cluster.idx = cluster_idx)
}
# EAP predictions for latent-covariate (quadrature) models: the
# posterior of the cluster-level random effects is a *mixture* over
# the quadrature nodes, with the posterior node weights
# pi_jq = exp(h_jq - loglik_j) (the same weights that drive the
# analytic gradient). Per node, the conditional model is
# linear-Gaussian, so the ordinary EB workhorse applies; the mixture
# is then assembled:
#
# E[z_nl | y_j] = sum_q pi_jq b_q
# E[v_lin | y_j] = sum_q pi_jq mu0_jq
# Var[.. | y_j] = sum_q pi_jq (Sigma0 + mu0 mu0') - mean mean'
#
# and analogously for the level-1 (within) factor scores, which are
# linear in the cluster-level effects per node
lav_mvn_cl_rs_eb_nl <- function(lavmodel = NULL, lavdata = NULL,
rs = NULL, se = FALSE) {
rs_info <- rs$info
rs_stats <- rs$stats
cond <- lav_mvn_cl_rs_cond(
lavmodel = lavmodel, rs_info = rs_info, exo_b = rs_stats$exo.b
)
if (is.null(cond)) {
lav_msg_stop(gettext(
"model-implied matrices could not be computed."))
}
nnodes <- cond$nnodes
nclusters <- rs_stats$nclusters
# posterior node weights (cfr. lav_mvn_cl_rs_grad_nl)
hmat <- matrix(-Inf, nclusters, nnodes)
for (i in seq_len(nnodes)) {
if (is.null(cond$imp[[i]])) {
next
}
m2_i <- lav_mvn_cl_rs_loglik(
rs_stats = rs_stats, imp = cond$imp[[i]],
rs_info = cond$rs_info_c,
log2pi = FALSE, minus_two = TRUE, per_cluster = TRUE
)
if (!is.finite(m2_i)) {
next
}
hmat[, i] <- -0.5 * attr(m2_i, "loglik.cluster") + cond$logw[i]
}
if (!is.null(cond$logr)) {
hmat <- hmat + cond$logr
}
hmax <- apply(hmat, 1L, max)
if (any(!is.finite(hmax))) {
lav_msg_stop(gettext(
"the (quadrature) loglikelihood could not be computed for one
or more clusters."))
}
loglik_j <- hmax + log(rowSums(exp(hmat - hmax)))
pw <- exp(hmat - loglik_j) # nclusters x nnodes; rows sum to 1
# per-node conditional EB, mixed with the posterior node weights
beta_mm <- rs_info$nl.beta.mm
beta_cells <- cbind(rs_info$nl.beta.row, rs_info$nl.beta.col)
path_tab <- rs_info$path.tab
nl_path_rows <- which(path_tab$type == "lv")
path_nl_z <- match(path_tab$z.idx[nl_path_rows], rs_info$nl.z.idx)
pv_c <- length(cond$lin.v)
m_lin <- matrix(0, nclusters, pv_c) # E[v_lin | y]
v_lin <- if (se) matrix(0, nclusters, pv_c) else NULL # E[v^2] part
l1_mix <- NULL
glist_i <- lavmodel@GLIST
for (i in seq_len(nnodes)) {
if (is.null(cond$imp[[i]])) {
# a failed node: its posterior weights are (numerically) zero
next
}
b_i <- cond$bmat[, i]
glist_i[[beta_mm]][beta_cells] <- b_i[path_nl_z]
core <- lav_mvn_cl_rs_eb_core(
lavmodel = lavmodel, glist = glist_i, lavdata = lavdata,
rs_stats = rs_stats, rs_info = cond$rs_info_c,
imp = cond$imp[[i]], se = se
)
m_lin <- m_lin + pw[, i] * core$mu0
if (se) {
v_lin <- v_lin + pw[, i] * (core$var0 + core$mu0^2)
}
if (is.null(l1_mix)) {
l1_mix <- matrix(0, nrow(core$l1), ncol(core$l1))
colnames(l1_mix) <- colnames(core$l1)
cluster_idx <- core$cluster.idx
}
if (ncol(l1_mix) > 0L) {
l1_mix <- l1_mix + pw[cluster_idx, i] * core$l1
}
}
if (se) {
v_lin <- v_lin - m_lin^2
}
# EAP of the nonlinear slopes: moments of the discrete posterior
q_nl <- length(cond$nl.v)
bt <- t(cond$bmat) # nnodes x q_nl
m_nl <- pw %*% bt
v_nl <- if (se) pw %*% bt^2 - m_nl^2 else NULL
# assemble the level-2 output in the full eta ordering
meta <- cond$meta
l2 <- matrix(0, nclusters, meta)
colnames(l2) <- cond$eta.names
se2 <- if (se) l2 else NULL
for (m in seq_len(meta)) {
lin_pos <- match(m, cond$lin.v)
if (!is.na(lin_pos)) {
l2[, m] <- m_lin[, lin_pos]
if (se) {
se2[, m] <- sqrt(pmax(v_lin[, lin_pos], 0))
}
} else {
nl_pos <- match(m, cond$nl.v)
l2[, m] <- m_nl[, nl_pos]
if (se) {
se2[, m] <- sqrt(pmax(v_nl[, nl_pos], 0))
}
}
}
out <- list(l1 = l1_mix, l2 = l2, cluster.idx = cluster_idx)
if (se) {
out$se2 <- se2
}
out
}
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.