R/three_step.R

Defines functions .estimator_label .new_structural_fit .new_measurement_fit three_step

Documented in three_step

#' Three-step LCA estimation with covariates and/or distal outcomes
#'
#' Fits a three-step latent class model through the following steps:
#' \enumerate{
#'   \item \strong{Measurement model}: estimates latent class parameters
#'     (\eqn{\pi}, \eqn{\phi}) using \pkg{multilevLCA}
#'     (Lyrvall et al., 2025).
#'   \item \strong{Classification-error matrix}: computes posterior class
#'     probabilities and the T x T misclassification probability matrix
#'     \eqn{P(W = s \mid X = t)}, with standard errors corrected for
#'     classification-error propagation (Bakk, Oberski & Vermunt, 2014).
#'   \item \strong{Structural model}: estimates covariate effects using
#'     two-step starting values (Bakk & Kuha, 2018) and/or distal outcome
#'     means following Bakk, Tekle & Vermunt (2013), with the ML correction (Vermunt, 2010) or BCH correction
#'     (Bolck, Croon & Hagenaars, 2004). See
#'     \code{vignette("tseLCA", package = "tseLCA")} for a worked example.
#' }
#'
#' @param data A data.frame containing all columns referenced by \code{Y.names},
#'   \code{Zp.names}, and \code{Zo.name}.
#' @param Y.names Character vector of indicator column names. Indicators
#'   may be factors, logicals, character, or numeric codes (see [tse_lca()]).
#' @param n_classes Integer. Number of latent classes.
#' @param Zp.names Character vector of covariate column names, or \code{NULL}
#'   for a measurement-only fit. Default \code{NULL}.
#' @param Zo.name Single character name of the distal outcome column, or
#'   \code{NULL}. Default \code{NULL}.
#' @param step1 Pre-fitted Step-1 object (output of [tseLCA::lca_step1()] or a
#'   prior \code{three_step()} call), or \code{NULL} to run Step 1 internally.
#'   Default \code{NULL}.
#' @param startval Optional starting classification for the Step-1
#'   measurement model, either an integer vector of length \code{nrow(data)}
#'   (a class assignment \code{1..n_classes} for every row) or a numeric
#'   matrix of conditional item-response probabilities
#'   \eqn{P(Y_h = k \mid X = t)} (one row per item-category pair in
#'   \code{Y.names} order, one column per class) from which a classification
#'   is derived internally. See [lca_step1_startval()] for the full
#'   description of both forms and typical sources (an external solver run
#'   with many random starts, or a published item-response table).
#'   \pkg{multilevLCA}'s default initialization (k-means on principal
#'   components) is deterministic given the data and can converge to a local
#'   optimum of the Step-1 log-likelihood; supplying \code{startval} bypasses
#'   it (\code{kmea = FALSE} with the classification injected as
#'   multilevLCA's \code{startval}). Mutually exclusive with \code{step1} and
#'   \code{n_init}. Default \code{NULL}.
#' @param n_init Optional positive integer. If supplied, fits the Step-1
#'   measurement model \code{n_init} times from independent uniform-random
#'   classifications (\code{kmea = FALSE}, not multilevLCA's k-means-on-PCA
#'   path) and keeps the fit with the highest log-likelihood -- the
#'   unconditional multi-start analog of \code{n_init} in \pkg{StepMix} or
#'   \code{nrep} in \pkg{poLCA}. This is distinct from
#'   \code{iter.measurement}, which reruns multilevLCA's own k-means
#'   initialization, and only when the entropy R\eqn{^2} of the default fit
#'   is below \code{R2.threshold}; \code{n_init} restarts always run.
#'   Mutually exclusive with \code{step1} and \code{startval}. Default
#'   \code{NULL}.
#' @param use.two.step Logical. Initialize Step-3 from two-step estimates.
#'   Default \code{TRUE}.
#' @param use.modal.assignment Logical. Use modal (hard) class assignments in
#'   Step 2 and 3. \code{FALSE} uses soft posterior weights. Default \code{TRUE}.
#' @param include.intercept Logical. Prepend an intercept column to the
#'   covariate design matrix. Default \code{TRUE}.
#' @param use.simple.cov Logical. Skip the Step-1 measurement-uncertainty
#'   correction and return only the robust sandwich variance. Faster but
#'   underestimates standard errors when class separation is low. Default
#'   \code{FALSE}.
#' @param incomplete Logical. FIML for partially missing indicators. See the
#'   \code{Missing Data} section of \code{vignette("tseLCA", package = "tseLCA")}.
#'   Default \code{FALSE}.
#' @param boundary.tol Scalar. Parameters within this tolerance of 0 or 1 are
#'   treated as fixed when computing the Step-1 variance matrix for numerical stability. Default
#'   \code{1e-2}.
#' @param maxIter.measurement Integer. Maximum EM iterations for Step 1.
#'   Default \code{5000L}.
#' @param measurement.tol Scalar. Convergence tolerance for the Step-1 EM
#'   algorithm. Default \code{1e-8}.
#' @param covariate.tol Scalar. Convergence tolerance for the Step-3
#'   Newton-Raphson or EM algorithm. Default \code{1e-6}.
#' @param iter.measurement Integer. Number of random restarts triggered when
#'   the Step-1 entropy R\eqn{^2} falls below \code{R2.threshold}. Default
#'   \code{10L}.
#' @param R2.threshold Scalar. Entropy R\eqn{^2} threshold below which Step-1
#'   random restarts are triggered. Default \code{0.70}.
#' @param use.bch Logical. Use the BCH estimator in Step 3 (default: the ML
#'   estimator). May error if BCH weights induce a non-positive semi-definite Hessian in the third step (common in cases of low separation). Default \code{FALSE}.
#' @param em.maxIter Integer. Maximum EM iterations for the Step-3 covariate
#'   or distal outcome model. Default \code{200L}.
#' @param get.twostep.vcov Logical. If \code{TRUE}, obtain \pkg{multilevLCA}'s
#'   bias-corrected variance-covariance matrix for the two-step gamma estimates
#'   and store it in \code{$two_step_vcov}. If the \code{fitZ} object passed
#'   through \code{step1} already contains a \code{Varmat_cor} (from a prior
#'   [fitZ_from_multiLCA()] or plain \code{multiLCA} call), it is attached
#'   automatically even when \code{get.twostep.vcov = FALSE}. Default
#'   \code{FALSE}.
#' @param rebase Character (e.g. \code{"C1"}, \code{"C2"}) or integer
#'   specifying which latent class to use as the reference category in the
#'   multinomial logit. The measurement model is permuted so this class becomes
#'   column 1 before any structural estimation. Default \code{"C1"}.
#' @param family Character. Distal outcome family: one of \code{"gaussian"}
#'   (class means), \code{"poisson"} (log-rates), \code{"binomial"}
#'   (logits), or \code{"multinomial"} (a saturated model for a nominal
#'   categorical outcome with 2 or more categories -- \code{Zo.name} may be
#'   a factor, character, or integer column; categories are taken from
#'   \code{sort(unique(data[[Zo.name]]))} with \code{factor()}). For
#'   \code{"multinomial"}, \code{coef()} returns a \code{T x C} matrix of
#'   class-conditional category probabilities
#'   \eqn{\hat\pi_{tc} = P(Zo = c \mid X = t)} (rows sum to 1), not a
#'   length-\code{T} vector, and \code{vcov()} returns its
#'   \code{(T*C) x (T*C)} sandwich covariance (necessarily singular, since
#'   each class's row sums to 1 -- see \code{\link{omnibus_test}()} for a
#'   Wald test that accounts for this). Unlike \code{"binomial"}, whose
#'   \code{coef()}/\code{vcov()} are on the logit scale, \code{"multinomial"}
#'   reports \code{coef()}/\code{vcov()} directly on the probability scale,
#'   so \code{Std.Error} is directly interpretable without a delta-method
#'   back-transform -- but a symmetric interval
#'   \code{Estimate +/- 1.96*Std.Error} can fall outside \eqn{[0, 1]} for a
#'   probability near a boundary, the same well-known limitation as a naive
#'   Wald interval for any sample proportion. The \code{z.value}/\code{p.value}
#'   columns \code{summary()}/\code{print()} show for this family test each
#'   probability against 0, which is rarely the question of interest;
#'   \code{\link{omnibus_test}()} is the intended, boundary-safe test of
#'   whether the outcome's distribution differs across classes. Combining
#'   \code{family = "multinomial"} with both \code{Zp.names} and
#'   \code{Zo.name} fully
#'   propagates both Step-1 measurement and Step-3 covariate uncertainty
#'   under \code{use.simple.cov = FALSE}, the same as the other families.
#'   Default \code{"gaussian"}.
#' @param correct.spec Logical. Estimate the Step-3 information matrix by the
#'   outer product of the case-wise scores, not the observed-data Hessian.
#'   Valid only when the Step-3 model is correctly specified; the default
#'   observed-Hessian sandwich is robust to misspecification. Default
#'   \code{FALSE}.
#' @param verbose Logical. Print convergence messages. Default \code{FALSE}.
#'
#' @return An S3 object of class \code{tseLCA}. The subclass depends on which
#'   models were estimated:
#'   \describe{
#'     \item{`tseLCA_measurement`}{Returned when neither \code{Zp.names} nor
#'       \code{Zo.name} is supplied. Contains the following elements:
#'       \describe{
#'         \item{`measurement_model`}{Step-1 output list from [tseLCA::lca_step1()].}
#'         \item{`llik`}{Final Step-1 log-likelihood.}
#'         \item{`AIC`, `BIC`}{Information criteria from the measurement model.}
#'         \item{`R2entr`}{Entropy R\eqn{^2} of the measurement model.}
#'         \item{`n_classes`}{Number of latent classes.}
#'         \item{`posteriors`}{n x T matrix of soft posterior class probabilities.}
#'         \item{`classifications`}{Length-n integer vector of modal class assignments.}
#'       }
#'     }
#'     \item{`tseLCA_covariate`}{Returned when \code{Zp.names} is supplied and
#'       \code{Zo.name} is \code{NULL}. Contains all elements of
#'       \code{tseLCA_measurement} plus:
#'       \describe{
#'         \item{`three_step`}{(Q+1) x (T-1) matrix of Step-3 gamma coefficients.}
#'         \item{`three_step_vcov`}{(Q+1)(T-1) x (Q+1)(T-1) variance-covariance matrix
#'           for \code{three_step}, with measurement-uncertainty correction
#'           unless \code{use.simple.cov = TRUE}.}
#'         \item{`two_step`}{(Q+1) x (T-1) matrix of two-step starting values, or
#'           \code{NULL} if \code{use.two.step = FALSE}.}
#'         \item{`two_step_vcov`}{\pkg{multilevLCA} bias-corrected vcov for the
#'           two-step estimates, or \code{NULL}.}
#'         \item{`estimator`}{Character: \code{"ML"} or \code{"BCH"}.}
#'         \item{`entropy.R2`}{Covariate-adjusted entropy R\eqn{^2}.}
#'         \item{`llik`}{Profile log-likelihood
#'           \eqn{\sum_i \log \sum_t P(X=t|Z_{p,i};\hat{\gamma}) P(Y_i|X=t;\hat{\phi})},
#'           with Step-1 parameters \eqn{\hat{\phi}} held fixed. By construction
#'           smaller than the equivalent one-step MLE likelihood.}
#'       }
#'     }
#'     \item{`tseLCA_distal`}{Returned when \code{Zo.name} is supplied and
#'       \code{Zp.names} is \code{NULL}. Contains:
#'       \describe{
#'         \item{`three_step`}{Named length-T vector of Step-3 distal outcome
#'           parameters (means, log-rates, or logits depending on
#'           \code{family}) -- or, for \code{family = "multinomial"}, a
#'           \code{T x C} matrix of class-conditional category probabilities
#'           (rows sum to 1).}
#'         \item{`three_step_vcov`}{T x T variance-covariance matrix for
#'           \code{three_step}, named \code{mu_C1} through \code{mu_CT} --
#'           or, for \code{family = "multinomial"}, a \code{(T*C) x (T*C)}
#'           (necessarily rank-deficient) matrix named \code{"C{t}:{category}"}.}
#'         \item{`three_step.llik`}{Step-3 distal log-likelihood
#'           \eqn{\log P(Z_o|X=t)} at converged estimates.}
#'         \item{`llik`}{Profile log-likelihood
#'           \eqn{\sum_i \log \sum_t P(X=t|\hat{\pi}) P(Z_{o,i}|X=t;\hat{\mu}) P(Y_i|X=t;\hat{\phi})},
#'           with Step-1 parameters \eqn{\hat{\pi}, \hat{\phi}} held fixed.
#'           By construction smaller than the equivalent one-step MLE likelihood.}
#'         \item{`AIC`}{Akaike information criterion based on \code{llik}.}
#'         \item{`BIC`}{Bayesian information criterion based on \code{llik},
#'           using the number of distal-complete observations.}
#'         \item{`family`}{Character. The distal outcome family used.}
#'         \item{`estimator`}{Character: \code{"ML"} or \code{"BCH"}.}
#'         \item{`posteriors`}{n x T soft posterior matrix.}
#'         \item{`classifications`}{Length-n modal class assignment vector.}
#'       }
#'     }
#'     \item{`tseLCA_both`}{Returned when both \code{Zp.names} and
#'       \code{Zo.name} are supplied. Contains:
#'       \describe{
#'         \item{`covariate`}{A \code{tseLCA_covariate}-structured sub-list
#'           (see above), including \code{llik}, \code{AIC}, \code{BIC},
#'           \code{entropy.R2}.}
#'         \item{`distal`}{A \code{tseLCA_distal}-structured sub-list
#'           (see above), including \code{llik}, \code{AIC}, \code{BIC},
#'           \code{three_step.llik}.}
#'         \item{`family`, `n_classes`, `estimator`}{Shared top-level fields.}
#'         \item{`posteriors`, `classifications`}{Shared n x T posterior
#'           matrix and length-n modal class vector.}
#'       }
#'     }
#'   }
#'
#' @references
#' Bakk, Z., Tekle, F. B., & Vermunt, J. K. (2013). Estimating the association
#'   between latent class membership and external variables using bias-adjusted
#'   three-step approaches. \emph{Sociological Methodology}, 43(1), 272--311.
#'   \doi{10.1177/0081175012470644}
#'
#' Bakk, Z., & Kuha, J. (2018). Two-step estimation of models between latent
#'   classes and external variables. \emph{Psychometrika}, 83(4), 871--892.
#'   \doi{10.1007/s11336-017-9592-7}
#'
#' Bakk, Z., Pohle, M. J., & Kuha, J. (2025). Bias-adjusted three-step
#'   estimation of structural models for latent classes. \emph{Multivariate
#'   Behavioral Research}. \doi{10.1080/00273171.2025.2473935}
#'
#' @seealso \code{vignette("tseLCA", package = "tseLCA")} for a full worked
#'   example; [tseLCA::lca_step1()] for standalone Step-1 estimation
#'   (including from an externally supplied starting classification, with its
#'   own `startval` argument); [fitZ_from_fit0()] and [fitZ_from_multiLCA()]
#'   for two-step covariate estimation.
#'
#' @examples
#' d <- generate_data(n = 200, separation = "high",
#'                    scenario = "covariate", seed = 1)
#'
#' # Measurement model only
#' fit_m <- three_step(d, Y.names = paste0("Y", 1:6), n_classes = 3)
#' summary(fit_m)
#'
#' # ML three-step with simple SEs (fast)
#' fit <- three_step(d, Y.names = paste0("Y", 1:6), n_classes = 3,
#'                   Zp.names = "Zp", use.simple.cov = TRUE)
#' summary(fit)
#' coef(fit)
#' vcov(fit)
#'
#' # Full measurement-uncertainty correction (see vignette for interpretation)
#' fit_cor <- three_step(d, Y.names = paste0("Y", 1:6), n_classes = 3,
#'                       Zp.names = "Zp", use.simple.cov = FALSE,
#'                       use.modal.assignment = FALSE)
#' summary(fit_cor)
#'
#' # BCH estimator
#' fit_bch <- three_step(d, Y.names = paste0("Y", 1:6), n_classes = 3,
#'                       Zp.names = "Zp", use.bch = TRUE,
#'                       use.simple.cov = TRUE)
#' summary(fit_bch)
#'
#' # Change reference class
#' fit_c2 <- three_step(d, Y.names = paste0("Y", 1:6), n_classes = 3,
#'                      Zp.names = "Zp", use.simple.cov = TRUE,
#'                      rebase = "C2")
#' summary(fit_c2)
#'
#' # Gaussian distal outcome
#' d2 <- generate_data(200, "high", "distal", seed = 2)
#' fit_dis <- three_step(d2, Y.names = paste0("Y", 1:6), n_classes = 3,
#'                       Zo.name = "Zo", family = "gaussian",
#'                       use.simple.cov = TRUE)
#' summary(fit_dis)
#'
#' # Nominal categorical distal outcome (3+ categories): coef() returns a
#' # T x C matrix of class-conditional category probabilities; omnibus_test()
#' # gives a single Wald test of whether the category distribution differs
#' # across classes at all.
#' d2$Zcat <- factor(sample(c("low", "mid", "high"), nrow(d2), replace = TRUE))
#' fit_cat <- three_step(d2, Y.names = paste0("Y", 1:6), n_classes = 3,
#'                       Zo.name = "Zcat", family = "multinomial",
#'                       use.simple.cov = TRUE)
#' coef(fit_cat)
#' omnibus_test(fit_cat)
#'
#' # Pass a pre-fitted measurement model to skip Step 1
#' fit_step1 <- three_step(d, Y.names = paste0("Y", 1:6), n_classes = 3)
#' fit2 <- three_step(d, Y.names = paste0("Y", 1:6), n_classes = 3,
#'                    Zp.names = "Zp", step1 = fit_step1,
#'                    use.simple.cov = TRUE)
#' summary(fit2)
#'
#' # Supply an external starting classification for Step 1 (bypasses
#' # multilevLCA's k-means-on-PCA initialization; here we use the DGP's own
#' # true classes as a stand-in for e.g. a StepMix solution with many
#' # random starts)
#' fit_ext <- three_step(d, Y.names = paste0("Y", 1:6), n_classes = 3,
#'                       startval = d$X, use.simple.cov = TRUE)
#' summary(fit_ext)
#'
#' # Many random-classification restarts for Step 1, keeping the best
#' # (analogous to n_init in StepMix or nrep in poLCA)
#' fit_ninit <- three_step(d, Y.names = paste0("Y", 1:6), n_classes = 3,
#'                         n_init = 20L, use.simple.cov = TRUE)
#' summary(fit_ninit)
#'
#' # Plot item-response profiles from the measurement model
#' plot(fit)
#'
#' @section Deprecated:
#' Deprecated as of tseLCA 2.0.0. It keeps working (and gives the same
#' estimates) but warns once per session; set
#' `options(tseLCA.warn.deprecated = FALSE)` to silence the warning. Use
#' [tseLCA()] or the step-wise functions:
#'
#' | `three_step()` | tseLCA 2.0 |
#' |---|---|
#' | `Y.names`, `n_classes` | `tse_lca(cbind(...) ~ 1, nclass = )` |
#' | `Zp.names` | `tse_covariate(, ~ ...)` or `tseLCA(... ~ covariates)` |
#' | `Zo.name`, `family` | `tse_distal(, outcome ~ 1, family = )`, or [tseLCA()] with the outcome after the bar |
#' | `step1` (measurement model from another sample) | `tse_classify(, newdata = )` |
#' | `startval` | `tse_lca(start = )` |
#' | `use.modal.assignment` | `tse_classify(assignment = )` |
#' | `use.bch` | `method = "BCH"` |
#' | `use.simple.cov` | `se = "robust"` |
#' | `rebase` | `ref` argument, or `relevel()` |
#' | `incomplete` | `tse_lca(missing = "fiml")` |
#' | `n_init`, `maxIter.measurement`, `measurement.tol`, `iter.measurement`, `R2.threshold`, `em.maxIter`, `covariate.tol`, `boundary.tol`, `correct.spec` | [tse_control()] |
#' | `get.twostep.vcov` | `tse_twostep(se = TRUE)` |
#'
#' @keywords internal
#' @export
three_step <- function(
  data,
  Y.names,
  n_classes,
  Zp.names = NULL,
  Zo.name = NULL,
  step1 = NULL,
  startval = NULL,
  n_init = NULL,
  use.two.step = TRUE,
  use.modal.assignment = TRUE,
  include.intercept = TRUE,
  use.simple.cov = FALSE,
  incomplete = FALSE,
  boundary.tol = 1e-2,
  maxIter.measurement = 5000,
  measurement.tol = 1e-8,
  covariate.tol = 1e-6,
  iter.measurement = 10L,
  R2.threshold = 0.70,
  use.bch = FALSE,
  em.maxIter = 200L,
  get.twostep.vcov = FALSE,
  rebase = "C1",
  family = "gaussian",
  correct.spec = FALSE,
  verbose = FALSE
) {
  .tse_deprecated(
    "three_step()",
    "tseLCA() or the step-wise tse_lca(), tse_classify(), tse_covariate(), and tse_distal()"
  )
  n_step1_inputs <- sum(!is.null(step1), !is.null(startval), !is.null(n_init))
  if (n_step1_inputs > 1L) {
    stop(
      "`step1`, `startval`, and `n_init` are mutually exclusive ways of ",
      "controlling Step 1: supply a pre-fitted measurement model with ",
      "`step1`, a starting classification (or item-response probability ",
      "matrix) to fit one with `startval`, or a number of random restarts ",
      "with `n_init`, not more than one of these.",
      call. = FALSE
    )
  }

  opts <- list(
    use.two.step = use.two.step,
    use.modal.assignment = use.modal.assignment,
    include.intercept = include.intercept,
    use.simple.cov = use.simple.cov,
    incomplete = incomplete,
    boundary.tol = boundary.tol,
    maxIter.measurement = maxIter.measurement,
    measurement.tol = measurement.tol,
    covariate.tol = covariate.tol,
    iter.measurement = iter.measurement,
    R2.threshold = R2.threshold,
    use.bch = use.bch,
    em.maxIter = em.maxIter,
    get.twostep.vcov = get.twostep.vcov,
    rebase = rebase,
    correct.spec = correct.spec,
    startval = startval,
    n_init = n_init,
    verbose = verbose
  )
  ref_idx <- parse_rebase(rebase, n_classes)

  # Indicators as 0-based codes; a reused measurement model brings its own
  # categories.
  rec <- .recode_indicators(data, Y.names, .measurement_levels(step1))
  data <- rec$data

  # -- Step 1: measurement model ----------------------------------------------
  s1 <- .fit_step1(data, Y.names, n_classes, Zp.names, step1, ref_idx, opts,
                   Y.levels = rec$levels)
  dat <- .prepare_data(data, Y.names, Zp.names, Zo.name, family, opts, rec$levels)
  s1 <- .attach_step1_data(s1, dat, ref_idx, fitted_here = is.null(step1))

  if (is.null(Zp.names) && is.null(Zo.name)) {
    return(.new_measurement_fit(s1, dat, n_classes))
  }

  # -- Step 2: classification --------------------------------------------------
  s2 <- .step2(dat, s1$fit0, n_classes, opts)

  # -- Step 3: structural models -----------------------------------------------
  # Step-1 variance for the uncertainty correction (not needed for BCH or
  # robust-only standard errors)
  no_vcov <- !(use.simple.cov || use.bch) && .lacks_step1_vcov(s1)
  if (no_vcov) {
    warning(.no_step1_vcov_warning(), call. = FALSE)
    opts$use.simple.cov <- TRUE
  }
  Sigma.1 <- if (use.simple.cov || use.bch || no_vcov) {
    NULL
  } else {
    .step1_varmat(s1, dat, ref_idx, boundary.tol)
  }

  cov <- if (!is.null(dat$Z_mat)) {
    .fit_covariate(dat, s1, s2, Sigma.1, n_classes, opts)
  }
  if (!is.null(cov)) {
    s1 <- cov$s1
  }
  dis <- if (!is.null(dat$Zo_mat)) {
    .fit_distal(dat, s1, s2, Sigma.1, cov, n_classes, family, opts)
  }

  .new_structural_fit(s1, s2, cov$fit, dis, n_classes, family, use.bch)
}

#' Measurement-only tseLCA object
#' @noRd
.new_measurement_fit <- function(s1, dat, n_classes) {
  posts <- step1_posteriors(dat$Y.obs, dat$mDesign, s1$fit0, dat$ivItemcat)
  structure(
    list(
      measurement_model = s1,
      llik = s1$fit0$LLKSeries[nrow(s1$fit0$LLKSeries)],
      AIC = s1$fit0$AIC,
      BIC = s1$fit0$BIC,
      R2entr = s1$fit0$R2entr,
      n_classes = n_classes,
      npar = (n_classes - 1L) + n_classes * sum(dat$ivItemcat - 1L),
      nobs = nrow(dat$Y.obs),
      posteriors = posts,
      classifications = max.col(posts)
    ),
    class = c("tseLCA_measurement", "tseLCA")
  )
}

#' Structural tseLCA object: covariate, distal, or both
#'
#' `cov_fit` is the tseLCA_covariate object from .fit_covariate() and `dis`
#' the distal component from .fit_distal(); either may be NULL.
#' @noRd
.new_structural_fit <- function(s1, s2, cov_fit, dis, n_classes, family, use.bch,
                                estimator = if (use.bch) "BCH" else "ML") {
  if (is.null(dis)) {
    return(cov_fit)
  }
  shared <- list(
    family = family,
    n_classes = n_classes,
    estimator = estimator,
    posteriors = s2$all$p.xy,
    classifications = max.col(s2$all$p.xy)
  )
  if (is.null(cov_fit)) {
    out <- c(dis, list(measurement_model = s1), shared)
    class(out) <- c("tseLCA_distal", "tseLCA_structural", "tseLCA")
    return(out)
  }
  out <- c(list(measurement_model = s1, covariate = cov_fit, distal = dis), shared)
  class(out) <- c("tseLCA_both", "tseLCA_structural", "tseLCA")
  out
}

#' Label of the Step-3 estimator
#' @noRd
.estimator_label <- function(opts) {
  if (isTRUE(opts$uncorrected)) "uncorrected" else if (opts$use.bch) "BCH" else "ML"
}

Try the tseLCA package in your browser

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

tseLCA documentation built on Nov. 11, 2026, 9:07 a.m.