The hardware and bandwidth for this mirror is donated by dogado GmbH, the Webhosting and Full Service-Cloud Provider. Check out our Wordpress Tutorial.
If you wish to report a bug, or if you are interested in having us mirror your free-software or open-source project, please feel free to contact us at mirror[@]dogado.de.
tseLCA implements the BCH and ML bias-adjusted
three-step estimators for latent class analysis (LCA) with covariates
and distal outcomes, following the methodological framework for both BCH
and Vermunt’s ML approaches from Bakk, Tekle & Vermunt (2013).
tseLCA also builds on top of the two-step LCA estimation
procedure outlined by Bakk & Kuha (2018), and using the R package
multilevLCA for efficient measurement model estimation from
Lyrvall et al. (2025). tseLCA provides analytic sandwich
variance estimation that propagates measurement uncertainty through the
classification-error correction in the final step.
The three-step approach separates the model into:
The built-in data-generating process replicates the design of Bakk & Kuha (2018). Each dataset has six binary indicators (\(Y_1, \ldots, Y_6\)) drawn from a three-class LCA, plus either a covariate \(Z_p \sim \text{Uniform}\{1,\ldots,5\}\) predicting class membership, or a continuous distal outcome \(Z_o\) predicted by class membership.
# High separation: P(Y_h = 1 | class) = 0.9 / 0.1
d <- generate_data(
n = 500,
separation = "high",
scenario = "covariate",
seed = 1
)
head(d)
#> Y1 Y2 Y3 Y4 Y5 Y6 X Zp
#> 1 1 1 1 0 0 0 2 1
#> 2 0 0 0 0 0 0 3 4
#> 3 1 0 1 0 0 0 2 1
#> 4 1 1 0 1 1 1 1 2
#> 5 0 0 0 0 0 0 3 5
#> 6 1 1 1 1 1 1 1 3# Low separation: P(Y_h = 1 | class) = 0.7 / 0.3
# Zp and X are identical to 'd' because seed = 1
d.low <- generate_data(
n = 500,
separation = "low",
scenario = "covariate",
seed = 1
)
head(d.low)
#> Y1 Y2 Y3 Y4 Y5 Y6 X Zp
#> 1 1 1 1 0 0 0 2 1
#> 2 1 0 0 1 0 1 3 4
#> 3 0 0 1 0 0 0 2 1
#> 4 1 1 0 0 1 1 1 2
#> 5 0 0 1 1 1 0 3 5
#> 6 1 1 1 1 1 1 1 3three_step() with no Zp.names or
Zo.name fits the measurement model only, returning a
tseLCA_measurement object. Internally this calls
multilevLCA::multiLCA() with random restarts when entropy
\(R^2\) is low.
d.measurement <- three_step(
data = d,
Y.names = paste0("Y", 1:6),
n_classes = 3,
measurement.tol = 1e-8
)
summary(d.measurement)
#> -- tseLCA Measurement Model --------------------------------
#> Latent classes : 3
#> Log-likelihood : -1455.5052
#> AIC : 2951.0104
#> BIC : 3035.3025
#> Entropy R² : 0.8780
#>
#> Class prevalences:
#>
#> P(C1) 0.3570
#> P(C2) 0.3308
#> P(C3) 0.3122
#> attr(,"names")
#> [1] "C1" "C2" "C3"
#>
#> Item-response probabilities (P(Y=1|class)):
#> C1 C2 C3
#> P(Y1|C) 0.9237 0.8621 0.1187
#> P(Y2|C) 0.9083 0.9219 0.1178
#> P(Y3|C) 0.9148 0.9571 0.0731
#> P(Y4|C) 0.8843 0.1481 0.0875
#> P(Y5|C) 0.8817 0.1340 0.1118
#> P(Y6|C) 0.9174 0.0889 0.1252With low separation the measurement model can struggle to find the
global maximum. Use iter.measurement to trigger the number
of random restarts whenever entropy \(R^2\) falls below
R2.threshold.
d.low.measurement <- three_step(
data = d.low,
Y.names = paste0("Y", 1:6),
n_classes = 3,
iter.measurement = 10,
R2.threshold = 0.9
)
summary(d.low.measurement)
#> -- tseLCA Measurement Model --------------------------------
#> Latent classes : 3
#> Log-likelihood : -2019.2458
#> AIC : 4078.4916
#> BIC : 4162.7837
#> Entropy R² : 0.3327
#>
#> Class prevalences:
#>
#> P(C1) 0.2753
#> P(C2) 0.4551
#> P(C3) 0.2696
#> attr(,"names")
#> [1] "C1" "C2" "C3"
#>
#> Item-response probabilities (P(Y=1|class)):
#> C1 C2 C3
#> P(Y1|C) 0.7328 0.6418 0.3345
#> P(Y2|C) 0.5723 0.7549 0.3223
#> P(Y3|C) 0.6937 0.7101 0.3036
#> P(Y4|C) 0.6846 0.4588 0.2105
#> P(Y5|C) 0.6947 0.4058 0.3787
#> P(Y6|C) 0.8651 0.3551 0.2456The plot() S3 method delegates to
multilevLCA’s item-profile plot.
fitZ_from_fit0() fixes the measurement parameters at
their Step-1 values and estimates multinomial logit coefficients \(\gamma\) via EM. These two-step estimates
serve as starting values for Step 3 and are generally close to the final
three-step estimates.
d.fitZ <- fitZ_from_fit0(
fit0 = d.measurement$measurement_model$fit0,
data = d,
Y.names = paste0("Y", 1:6),
Zp.names = "Zp"
)
# True slopes: -1 (C2) and +1 (C3) relative to C1
d.fitZ$mGamma
#> C2 C3
#> Intercept 2.1934130 -3.4524271
#> Zp -0.9411383 0.8971774Starting values from the high-separation fit can be passed to the low-separation fit to help it converge.
d.low.fitZ <- fitZ_from_fit0(
fit0 = d.low.measurement$measurement_model$fit0,
data = d.low,
Y.names = paste0("Y", 1:6),
Zp.names = "Zp",
starting_val = d.fitZ$mGamma
)
d.low.fitZ$mGamma
#> C2 C3
#> Intercept 3.0446359 -3.6948016
#> Zp -0.9832601 0.9487392A single three_step() call handles all three steps. By
default it uses the ML correction of Vermunt (2010) and modal class
assignment.
d.three_step <- three_step(
data = d,
Y.names = paste0("Y", 1:6),
n_classes = 3,
Zp.names = "Zp"
)
summary(d.three_step)
#> -- tseLCA Three-step Covariate Model -----------------------
#> Latent classes : 3
#> Estimator : ML
#> Log-likelihood : -1339.0650
#> AIC : 2758.1299
#> BIC : 2926.7143
#> Entropy R² : 0.8693 (covariate-adjusted)
#>
#> Two-step (starting) estimates:
#> C2 C3
#> Intercept 2.1934 -3.4524
#> Zp -0.9411 0.8972
#>
#> Three-step estimates:
#> Estimate Std.Error z.value p.value
#> Intercept:C2 2.0411 0.3237 6.3050 < 0.001 ***
#> Zp:C2 -0.8821 0.1406 -6.2730 < 0.001 ***
#> Intercept:C3 -3.4836 0.5913 -5.8913 < 0.001 ***
#> Zp:C3 0.8985 0.1435 6.2606 < 0.001 ***
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1The standard coef() and vcov() S3 methods
work on any tseLCA object.
coef(d.three_step)
#> C2 C3
#> Intercept 2.0410764 -3.4835616
#> Zp -0.8820801 0.8984978
vcov(d.three_step)
#> Intercept:C2 Zp:C2 Intercept:C3 Zp:C3
#> Intercept:C2 0.104798063 -0.041343485 -0.01048872 0.001510799
#> Zp:C2 -0.041343485 0.019772531 0.01121397 -0.001932282
#> Intercept:C3 -0.010488717 0.011213968 0.34964328 -0.082842095
#> Zp:C3 0.001510799 -0.001932282 -0.08284210 0.020597096With modal assignment (use.modal.assignment = TRUE, the
default), the Jacobian in the measurement-uncertainty correction is not
mathematically defined. Setting
use.modal.assignment = FALSE uses soft posterior weights
throughout, giving an analytic Jacobian and is recommended when
separation is moderate or low. When
use.modal.assignment = TRUE, the Jacobian \(\frac{\partial\theta_2}{\partial\theta_1}\)
computed using the full posterior weights (e.g., behaving as if
use.modal.assignment = FALSE) to maintain well-defined
derivatives, though three-step estimates would still be computed with
modal assignment as specified. The different is negligible when
separation is high.
d.three_step.prop <- three_step(
data = d,
Y.names = paste0("Y", 1:6),
n_classes = 3,
Zp.names = "Zp",
use.modal.assignment = FALSE
)
summary(d.three_step.prop)
#> -- tseLCA Three-step Covariate Model -----------------------
#> Latent classes : 3
#> Estimator : ML
#> Log-likelihood : -1339.0617
#> AIC : 2758.1234
#> BIC : 2926.7078
#> Entropy R² : 0.8680 (covariate-adjusted)
#>
#> Two-step (starting) estimates:
#> C2 C3
#> Intercept 2.1934 -3.4524
#> Zp -0.9411 0.8972
#>
#> Three-step estimates:
#> Estimate Std.Error z.value p.value
#> Intercept:C2 2.1096 0.3287 6.4183 < 0.001 ***
#> Zp:C2 -0.9121 0.1460 -6.2456 < 0.001 ***
#> Intercept:C3 -3.6508 0.6430 -5.6777 < 0.001 ***
#> Zp:C3 0.9367 0.1547 6.0563 < 0.001 ***
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1Setting use.simple.cov = TRUE skips the
measurement-uncertainty correction and returns the robust sandwich SEs
from Step 3 only. When separation is high the correction is negligible,
so this is a useful computational shortcut for large samples.
d.three_step.simple <- three_step(
data = d,
Y.names = paste0("Y", 1:6),
n_classes = 3,
Zp.names = "Zp",
use.simple.cov = TRUE
)
summary(d.three_step.simple)
#> -- tseLCA Three-step Covariate Model -----------------------
#> Latent classes : 3
#> Estimator : ML
#> Log-likelihood : -1339.0650
#> AIC : 2758.1299
#> BIC : 2926.7143
#> Entropy R² : 0.8693 (covariate-adjusted)
#>
#> Two-step (starting) estimates:
#> C2 C3
#> Intercept 2.1934 -3.4524
#> Zp -0.9411 0.8972
#>
#> Three-step estimates:
#> Estimate Std.Error z.value p.value
#> Intercept:C2 2.0411 0.3214 6.3504 < 0.001 ***
#> Zp:C2 -0.8821 0.1394 -6.3257 < 0.001 ***
#> Intercept:C3 -3.4836 0.5876 -5.9281 < 0.001 ***
#> Zp:C3 0.8985 0.1426 6.3008 < 0.001 ***
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1The BCH correction of Bolck, Croon & Hagenaars (2004) is
available via use.bch = TRUE. It works well with high
separation but can produce an ill-conditioned Hessian when separation is
low (resulting in a covariance matrix that is not positive
semi-definite), in which case the ML estimator is preferred.
d.three_step.bch <- three_step(
data = d,
Y.names = paste0("Y", 1:6),
n_classes = 3,
Zp.names = "Zp",
use.bch = TRUE
)
summary(d.three_step.bch)
#> -- tseLCA Three-step Covariate Model -----------------------
#> Latent classes : 3
#> Estimator : BCH
#> Log-likelihood : -1339.2863
#> AIC : 2758.5726
#> BIC : 2927.1569
#> Entropy R² : 0.8700 (covariate-adjusted)
#>
#> Two-step (starting) estimates:
#> C2 C3
#> Intercept 2.1934 -3.4524
#> Zp -0.9411 0.8972
#>
#> Three-step estimates:
#> Estimate Std.Error z.value p.value
#> Intercept:C2 1.9554 0.3111 6.2844 < 0.001 ***
#> Zp:C2 -0.8424 0.1304 -6.4613 < 0.001 ***
#> Intercept:C3 -3.4634 0.5697 -6.0790 < 0.001 ***
#> Zp:C3 0.8923 0.1385 6.4412 < 0.001 ***
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1BCH with low-separation data can fail to produce a positive semi-definite Hessian. The ML estimator with proportional assignment is more reliable in this setting.
# Not run in vignette build (slow and and produces warnings)
bch.fail <- three_step(
data = d.low,
Y.names = paste0("Y", 1:6),
n_classes = 3,
Zp.names = "Zp",
use.bch = TRUE,
maxIter.measurement = 2000,
iter.measurement = 10
)# Preferred approach for low separation
d.low.three_step.prop <- three_step(
data = d.low,
Y.names = paste0("Y", 1:6),
n_classes = 3,
Zp.names = "Zp",
use.modal.assignment = FALSE
)
summary(d.low.three_step.prop)
#> -- tseLCA Three-step Covariate Model -----------------------
#> Latent classes : 3
#> Estimator : ML
#> Log-likelihood : -1979.3372
#> AIC : 4038.6744
#> BIC : 4207.2588
#> Entropy R² : 0.3518 (covariate-adjusted)
#>
#> Two-step (starting) estimates:
#> C2 C3
#> Intercept 3.0267 -3.6919
#> Zp -0.9767 0.9482
#>
#> Three-step estimates:
#> Estimate Std.Error z.value p.value
#> Intercept:C2 3.2034 2.2929 1.3971 0.1624
#> Zp:C2 -1.0761 1.9088 -0.5638 0.5729
#> Intercept:C3 -3.8431 2.9955 -1.2830 0.1995
#> Zp:C3 0.9554 0.6034 1.5832 0.1134
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1By default, class 1 ("C1") is the reference category for
the multinomial logit parameterization. The rebase argument
changes this. Estimates are reparameterized consistently:
log-likelihoods are invariant, and the coefficients satisfy the
transitivity relation \(\log(\pi_t / \pi_j) =
\log(\pi_t / \pi_1) - \log(\pi_j / \pi_1)\).
# Default: C1 as reference
summary(d.three_step.simple)
#> -- tseLCA Three-step Covariate Model -----------------------
#> Latent classes : 3
#> Estimator : ML
#> Log-likelihood : -1339.0650
#> AIC : 2758.1299
#> BIC : 2926.7143
#> Entropy R² : 0.8693 (covariate-adjusted)
#>
#> Two-step (starting) estimates:
#> C2 C3
#> Intercept 2.1934 -3.4524
#> Zp -0.9411 0.8972
#>
#> Three-step estimates:
#> Estimate Std.Error z.value p.value
#> Intercept:C2 2.0411 0.3214 6.3504 < 0.001 ***
#> Zp:C2 -0.8821 0.1394 -6.3257 < 0.001 ***
#> Intercept:C3 -3.4836 0.5876 -5.9281 < 0.001 ***
#> Zp:C3 0.8985 0.1426 6.3008 < 0.001 ***
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1d.three_step.simpleC2 <- three_step(
data = d,
Y.names = paste0("Y", 1:6),
n_classes = 3,
Zp.names = "Zp",
use.simple.cov = TRUE,
rebase = "C2"
)
summary(d.three_step.simpleC2)
#> -- tseLCA Three-step Covariate Model -----------------------
#> Latent classes : 3
#> Estimator : ML
#> Log-likelihood : -1339.0650
#> AIC : 2758.1299
#> BIC : 2926.7143
#> Entropy R² : 0.8693 (covariate-adjusted)
#>
#> Two-step (starting) estimates:
#> C1 C3
#> Intercept -2.1941 -5.6433
#> Zp 0.9413 1.8377
#>
#> Three-step estimates:
#> Estimate Std.Error z.value p.value
#> Intercept:C1 -2.0411 0.3214 -6.3504 < 0.001 ***
#> Zp:C1 0.8821 0.1394 6.3257 < 0.001 ***
#> Intercept:C3 -5.5246 0.6823 -8.0976 < 0.001 ***
#> Zp:C3 1.7806 0.2078 8.5696 < 0.001 ***
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1d.three_step.simpleC3 <- three_step(
data = d,
Y.names = paste0("Y", 1:6),
n_classes = 3,
Zp.names = "Zp",
use.simple.cov = TRUE,
rebase = "C3"
)
summary(d.three_step.simpleC3)
#> -- tseLCA Three-step Covariate Model -----------------------
#> Latent classes : 3
#> Estimator : ML
#> Log-likelihood : -1339.0650
#> AIC : 2758.1299
#> BIC : 2926.7143
#> Entropy R² : 0.8693 (covariate-adjusted)
#>
#> Two-step (starting) estimates:
#> C1 C2
#> Intercept 3.4492 5.6433
#> Zp -0.8964 -1.8377
#>
#> Three-step estimates:
#> Estimate Std.Error z.value p.value
#> Intercept:C1 3.4836 0.5876 5.9281 < 0.001 ***
#> Zp:C1 -0.8985 0.1426 -6.3008 < 0.001 ***
#> Intercept:C2 5.5246 0.6823 8.0976 < 0.001 ***
#> Zp:C2 -1.7806 0.2078 -8.5696 < 0.001 ***
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1The step1 argument accepts any previously fitted
tseLCA object or the raw output of
lca_step1(). This is useful when you want to:
fitZ_from_fit0().# Reuse the measurement model estimated above
d.three_step.prop2 <- three_step(
data = d,
Y.names = paste0("Y", 1:6),
n_classes = 3,
Zp.names = "Zp",
use.modal.assignment = FALSE,
step1 = d.measurement$measurement_model
)
summary(d.three_step.prop2)
#> -- tseLCA Three-step Covariate Model -----------------------
#> Latent classes : 3
#> Estimator : ML
#> Log-likelihood : -1339.0617
#> AIC : 2758.1234
#> BIC : 2926.7078
#> Entropy R² : 0.8680 (covariate-adjusted)
#>
#> Two-step (starting) estimates:
#> C2 C3
#> Intercept 2.1934 -3.4524
#> Zp -0.9411 0.8972
#>
#> Three-step estimates:
#> Estimate Std.Error z.value p.value
#> Intercept:C2 2.1096 0.3287 6.4183 < 0.001 ***
#> Zp:C2 -0.9121 0.1460 -6.2456 < 0.001 ***
#> Intercept:C3 -3.6508 0.6430 -5.6777 < 0.001 ***
#> Zp:C3 0.9367 0.1547 6.0563 < 0.001 ***
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1# Measurement model from a larger low-separation sample
d.low2000 <- generate_data(
n = 2000,
separation = "low",
scenario = "covariate",
seed = 2
)
d.low.measurement2000 <- three_step(
data = d.low2000,
Y.names = paste0("Y", 1:6),
n_classes = 3
)
# Apply to the smaller sample; get.twostep.vcov returns multilevLCA's
# bias-corrected vcov for the two-step estimates
d.low.three_step.prop2 <- three_step(
data = d.low,
Y.names = paste0("Y", 1:6),
n_classes = 3,
Zp.names = "Zp",
use.modal.assignment = FALSE,
step1 = d.low.measurement2000$measurement_model,
get.twostep.vcov = TRUE
)
summary(d.low.three_step.prop2)
#> -- tseLCA Three-step Covariate Model -----------------------
#> Latent classes : 3
#> Estimator : ML
#> Log-likelihood : -1983.8234
#> AIC : 4047.6468
#> BIC : 4216.2311
#> Entropy R² : 0.3770 (covariate-adjusted)
#>
#> Two-step (starting) estimates:
#> C2 C3
#> Intercept 2.5851 -4.2908
#> Zp -1.3556 1.0806
#>
#> Three-step estimates:
#> Estimate Std.Error z.value p.value
#> Intercept:C2 2.7051 1.1848 2.2831 0.0224 *
#> Zp:C2 -1.3769 0.9785 -1.4072 0.1594
#> Intercept:C3 -3.9488 2.0927 -1.8870 0.0592 .
#> Zp:C3 1.0303 0.4742 2.1730 0.0298 *
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1You can also compute two-step starting values separately and inject
them before calling three_step().
d.low.fitZ2 <- fitZ_from_fit0(
fit0 = d.low.measurement2000$measurement_model$fit0,
data = d.low,
Y.names = paste0("Y", 1:6),
Zp.names = "Zp"
)
d.low.measurement2000$measurement_model$fitZ <- d.low.fitZ2
d.low.three_step.prop3 <- three_step(
data = d.low,
Y.names = paste0("Y", 1:6),
n_classes = 3,
Zp.names = "Zp",
use.modal.assignment = FALSE,
step1 = d.low.measurement2000$measurement_model
)
summary(d.low.three_step.prop3)
#> -- tseLCA Three-step Covariate Model -----------------------
#> Latent classes : 3
#> Estimator : ML
#> Log-likelihood : -1983.8234
#> AIC : 4047.6468
#> BIC : 4216.2311
#> Entropy R² : 0.3770 (covariate-adjusted)
#>
#> Two-step (starting) estimates:
#> C2 C3
#> Intercept 2.5851 -4.2908
#> Zp -1.3556 1.0806
#>
#> Three-step estimates:
#> Estimate Std.Error z.value p.value
#> Intercept:C2 2.7051 1.1848 2.2831 0.0224 *
#> Zp:C2 -1.3769 0.9785 -1.4072 0.1594
#> Intercept:C3 -3.9488 2.0927 -1.8870 0.0592 .
#> Zp:C3 1.0303 0.4742 2.1730 0.0298 *
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1tseLCA uses a two-pass row-filtering strategy that
matches multilevLCA’s approach for the measurement model
while allowing more observations into Steps 1 and 2 than Step 3.
set.seed(42)
d.new <- generate_data(500, separation = "high", seed = 3)
sparsity <- 0.1
missing <- 1 -
matrix(
rbinom(prod(dim(d.new)), size = 1, prob = sparsity),
nrow = nrow(d.new),
ncol = ncol(d.new)
)
missing[missing == 0] <- NA_real_
d.sparse <- d.new * missing
head(d.sparse)
#> Y1 Y2 Y3 Y4 Y5 Y6 X Zp
#> 1 0 0 NA 0 1 0 3 5
#> 2 1 1 NA 0 0 0 2 2
#> 3 1 1 1 1 1 0 1 4
#> 4 0 0 0 0 0 0 3 4
#> 5 1 1 1 0 0 0 NA 2
#> 6 1 1 NA 0 0 0 2 3With incomplete = FALSE (the default), any row with a
missing indicator is dropped before the measurement model is
estimated.
d.sparse.measurement <- three_step(
data = d.sparse,
Y.names = paste0("Y", 1:6),
n_classes = 3,
incomplete = FALSE,
verbose = TRUE
)
#> 242 row(s) dropped from measurement/classification steps (missing Y).
# Rows dropped = number of rows with at least one missing Y
sum(apply(d.sparse[, paste0("Y", 1:6)], 1, \(x) any(is.na(x))))
#> [1] 242
summary(d.sparse.measurement)
#> -- tseLCA Measurement Model --------------------------------
#> Latent classes : 3
#> Log-likelihood : -742.0656
#> AIC : 1524.1311
#> BIC : 1595.1903
#> Entropy R² : 0.9027
#>
#> Class prevalences:
#>
#> P(C1) 0.2995
#> P(C2) 0.3967
#> P(C3) 0.3037
#> attr(,"names")
#> [1] "C1" "C2" "C3"
#>
#> Item-response probabilities (P(Y=1|class)):
#> C1 C2 C3
#> P(Y1|C) 0.8241 0.8869 0.0834
#> P(Y2|C) 0.8600 0.9104 0.0811
#> P(Y3|C) 0.9110 0.9344 0.0633
#> P(Y4|C) 0.9035 0.0486 0.1302
#> P(Y5|C) 0.9349 0.1517 0.0795
#> P(Y6|C) 0.9331 0.1555 0.0762With incomplete = TRUE, only fully-missing rows are
dropped; partially observed rows contribute to the measurement model via
FIML.
d.sparse.measurement2 <- three_step(
data = d.sparse,
Y.names = paste0("Y", 1:6),
n_classes = 3,
incomplete = TRUE,
verbose = TRUE
)
summary(d.sparse.measurement2)
#> -- tseLCA Measurement Model --------------------------------
#> Latent classes : 3
#> Log-likelihood : -1342.8026
#> AIC : 2725.6052
#> BIC : 2809.8974
#> Entropy R² : 0.8425
#>
#> Class prevalences:
#>
#> P(C1) 0.3049
#> P(C2) 0.3652
#> P(C3) 0.3299
#> attr(,"names")
#> [1] "C1" "C2" "C3"
#>
#> Item-response probabilities (P(Y=1|class)):
#> C1 C2 C3
#> P(Y1|C) 0.8797 0.8916 0.0925
#> P(Y2|C) 0.8888 0.8858 0.0677
#> P(Y3|C) 0.9337 0.8859 0.1453
#> P(Y4|C) 0.9079 0.0819 0.1339
#> P(Y5|C) 0.9536 0.1359 0.1056
#> P(Y6|C) 0.9583 0.1590 0.1176Regardless of incomplete, Step 3 drops any row with a
missing covariate. The rows used in Step 3 are a subset of those used in
Steps 1 and 2.
d.sparse.three_step <- three_step(
data = d.sparse,
Y.names = paste0("Y", 1:6),
n_classes = 3,
Zp.names = "Zp",
incomplete = TRUE,
verbose = TRUE
)
#> 43 row(s) excluded from covariate step (missing Z).
#> fitZ EM converged in 9 iterations.
#> 43 row(s) excluded from covariate step (missing Z).
#> EM converged in 8 iterations.
# Additional rows dropped from Step 3 due to missing Zp
sum(is.na(d.sparse$Zp))
#> [1] 43
summary(d.sparse.three_step)
#> -- tseLCA Three-step Covariate Model -----------------------
#> Latent classes : 3
#> Estimator : ML
#> Log-likelihood : -1088.3344
#> AIC : 2256.6688
#> BIC : 2421.6562
#> Entropy R² : 0.8672 (covariate-adjusted)
#>
#> Two-step (starting) estimates:
#> C2 C3
#> Intercept 2.5023 -4.6555
#> Zp -1.0494 1.2929
#>
#> Three-step estimates:
#> Estimate Std.Error z.value p.value
#> Intercept:C2 2.4803 0.3807 6.5149 < 0.001 ***
#> Zp:C2 -1.0136 0.1667 -6.0789 < 0.001 ***
#> Intercept:C3 -4.7754 0.7267 -6.5716 < 0.001 ***
#> Zp:C3 1.3164 0.1796 7.3290 < 0.001 ***
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1A FIML measurement model can be passed in and then reused for the covariate step on the same sparse data.
d.sparse.three_step2 <- three_step(
data = d.sparse,
Y.names = paste0("Y", 1:6),
n_classes = 3,
Zp.names = "Zp",
incomplete = TRUE,
step1 = d.sparse.measurement2$measurement_model,
verbose = TRUE
)
#> 43 row(s) excluded from covariate step (missing Z).
#> fitZ EM converged in 9 iterations.
#> 43 row(s) excluded from covariate step (missing Z).
#> EM converged in 8 iterations.
summary(d.sparse.three_step2)
#> -- tseLCA Three-step Covariate Model -----------------------
#> Latent classes : 3
#> Estimator : ML
#> Log-likelihood : -1088.3344
#> AIC : 2256.6688
#> BIC : 2421.6562
#> Entropy R² : 0.8672 (covariate-adjusted)
#>
#> Two-step (starting) estimates:
#> C2 C3
#> Intercept 2.5023 -4.6555
#> Zp -1.0494 1.2929
#>
#> Three-step estimates:
#> Estimate Std.Error z.value p.value
#> Intercept:C2 2.4803 0.3807 6.5149 < 0.001 ***
#> Zp:C2 -1.0136 0.1667 -6.0789 < 0.001 ***
#> Intercept:C3 -4.7754 0.7267 -6.5716 < 0.001 ***
#> Zp:C3 1.3164 0.1796 7.3290 < 0.001 ***
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1tseLCA supports polytomous indicators, following
multilevLCA’s convention that item categories are coded as
consecutive integers starting at 0.
Here we reproduce the example from the poLCA
package.
data(election, package = "poLCA")
elec <- election
elec.items <- colnames(election)[1:12]
# Recode to 0-based integers as required by multilevLCA
elec[, elec.items] <- lapply(elec[, elec.items], \(x) as.integer(x) - 1L)elec.measurement <- three_step(
data = elec,
Y.names = elec.items,
n_classes = 3,
#The poLCA example drops any row with a missing cell
incomplete = FALSE
)
elec.three_step <- three_step(
data = elec,
Y.names = elec.items,
n_classes = 3,
Zp.names = c("PARTY"),
step1 = elec.measurement$measurement_model,
incomplete = FALSE,
#With the neutral group as the base-category
rebase = "C3"
)
#> Warning: lca_indiv_varmat: Infomat is singular even after removing boundary
#> parameters; returning NA matrix. Check for near-empty classes.
summary(elec.three_step)
#> -- tseLCA Three-step Covariate Model -----------------------
#> Latent classes : 3
#> Estimator : ML
#> Log-likelihood : -16278.0242
#> AIC : 32852.0485
#> BIC : 33617.2262
#> Entropy R² : 0.7956 (covariate-adjusted)
#>
#> Two-step (starting) estimates:
#> C1 C2
#> Intercept -2.5781 1.8687
#> PARTY 0.4289 -0.6983
#>
#> Three-step estimates:
#> Estimate Std.Error z.value p.value
#> Intercept:C1 -2.4701 NA NA NA
#> PARTY:C1 0.4077 NA NA NA
#> Intercept:C2 1.7324 NA NA NA
#> PARTY:C2 -0.6727 NA NA NA
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
party.x <- seq(from = 1, to = 7, length.out = 101)
pidmat <- cbind(1, party.x)
exb <- exp(pidmat %*% coef(elec.three_step))
matplot(
party.x,
(cbind(1, exb)) / (1 + rowSums(exb)),
ylim = c(0, 1),
type = "l",
lwd = 3,
col = 1,
xlab = "Party ID: strong Democratic (1) to strong Republican (7)",
ylab = "Probability of latent class membership",
main = "Party ID as a predictor of candidate affinity class",
)
text(3.9, 0.60, "Other")
text(6.2, 0.6, "Bush affinity")
text(2.0, 0.65, "Gore affinity")For distal outcomes (\(Z_o \leftarrow X
\rightarrow Y\)), supply Zo.name and a
family argument. The available families are
"gaussian" (default), "poisson", and
"binomial". Both ML and BCH estimators are available.
d.distal <- generate_data(
n = 500,
separation = "high",
scenario = "distal",
seed = 4
)
# True class means: mu = (0, 1, -1) for C1, C2, C3d.distal.measurement <- three_step(
data = d.distal,
Y.names = paste0("Y", 1:6),
n_classes = 3
)
# ML estimator
d.distal.three_step.ml <- three_step(
data = d.distal,
Y.names = paste0("Y", 1:6),
n_classes = 3,
Zo.name = "Zo",
step1 = d.distal.measurement$measurement_model,
use.modal.assignment = FALSE,
family = "gaussian"
)
# BCH estimator: closed-form M-step for distal outcomes
d.distal.three_step.bch <- three_step(
data = d.distal,
Y.names = paste0("Y", 1:6),
n_classes = 3,
Zo.name = "Zo",
step1 = d.distal.measurement$measurement_model,
use.modal.assignment = FALSE,
use.bch = TRUE,
family = "gaussian"
)
summary(d.distal.three_step.ml)
#> -- tseLCA Three-step Distal Outcome Model -------------------
#> Latent classes : 3
#> Estimator : ML
#> Family : gaussian
#> Log-likelihood : -2169.0110
#> AIC : 4384.0220
#> BIC : 4480.9580
#>
#> Distal outcome estimates by class:
#> Estimate Std.Error z.value p.value
#> mu_C1 (mean) -1.0821 0.0817 -13.2495 < 0.001 ***
#> mu_C2 (mean) 1.0172 0.0819 12.4151 < 0.001 ***
#> mu_C3 (mean) 0.0254 0.0878 0.2887 0.7728
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
summary(d.distal.three_step.bch)
#> -- tseLCA Three-step Distal Outcome Model -------------------
#> Latent classes : 3
#> Estimator : BCH
#> Family : gaussian
#> Log-likelihood : -2168.8685
#> AIC : 4383.7370
#> BIC : 4480.6730
#>
#> Distal outcome estimates by class:
#> Estimate Std.Error z.value p.value
#> mu_C1 (mean) -1.0941 0.0867 -12.6134 < 0.001 ***
#> mu_C2 (mean) 0.9751 0.0823 11.8534 < 0.001 ***
#> mu_C3 (mean) 0.0578 0.0859 0.6733 0.5008
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1Consistent with how most research in the social sciences construct the relationships between \(Z_p\) and \(X\), and \(X\) and \(Z_o\), the relationship between \(Z_p\) and \(X\) is estimated first, followed by estimation between \(X\) and \(Z_o\), adjusting for the covariate-adjusted posteriors in the estimation procedures for the distal outcome model in step 3.
d.covariate <- generate_data(
n = 500,
separation = "high",
scenario = "covariate",
seed = 4
)
d.covariate$Zo <- draw_Zo(d.covariate$X, bk2018_params$distal_params)
head(d.covariate)
#> Y1 Y2 Y3 Y4 Y5 Y6 X Zp Zo
#> 1 1 1 1 1 0 0 2 3 -0.1624650
#> 2 1 1 1 1 1 1 1 3 -1.1591833
#> 3 1 1 1 1 1 1 1 3 -1.2055132
#> 4 0 0 0 0 0 0 3 4 1.8752276
#> 5 1 1 1 1 1 1 1 3 -2.5582369
#> 6 1 1 1 1 1 1 1 5 -0.4723262
d.covariate.three_step <- three_step(
data = d.covariate,
Y.names = paste0("Y", 1:6),
n_classes = 3,
Zp.names = "Zp",
Zo.name = "Zo",
use.modal.assignment = FALSE
)
summary(d.covariate.three_step)
#> -- tseLCA Three-step Model: Covariate + Distal Outcome -----
#> Latent classes : 3
#> Estimator : ML
#> Family : gaussian
#> Log-likelihood : -1315.6596
#> AIC : 2711.3193
#> BIC : 2879.9036
#>
#> Covariate -- two-step (starting) estimates:
#> C2 C3
#> Intercept 2.4973 -4.2196
#> Zp -1.0177 1.1159
#>
#> Covariate -- three-step estimates:
#> Estimate Std.Error z.value p.value
#> Intercept:C2 2.6602 0.3998 6.6538 < 0.001 ***
#> Zp:C2 -1.0790 0.1632 -6.6134 < 0.001 ***
#> Intercept:C3 -4.6917 0.7070 -6.6365 < 0.001 ***
#> Zp:C3 1.2278 0.1735 7.0773 < 0.001 ***
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#>
#> Distal outcome -- three-step estimates:
#> Estimate Std.Error z.value p.value
#> mu_C1 (mean) -0.9851 0.0828 -11.8995 < 0.001 ***
#> mu_C2 (mean) 0.9298 0.0851 10.9217 < 0.001 ***
#> mu_C3 (mean) 0.1188 0.0722 1.6458 0.0998 .
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1Note that with covariates in a model with high separation, the standard errors above should, on average, by systematically smaller for distal outcome estimation than if there were no covariates in the model (see below).
three_step(
data = d.covariate,
Y.names = paste0("Y", 1:6),
n_classes = 3,
Zo.name = "Zo",
use.modal.assignment = FALSE
) |>
vcov() |>
diag() |>
sqrt()
#> mu_C1 mu_C2 mu_C3
#> 0.08302639 0.08922331 0.07595896Bakk, Z., Tekle, F. B., & Vermunt, J. K. (2013). Estimating the association between latent class membership and external variables using bias-adjusted three-step approaches. Sociological Methodology, 43(1), 272–311. https://doi.org/10.1177/0081175012470644
Bakk, Z., & Kuha, J. (2018). Two-step estimation of models between latent classes and external variables. Psychometrika, 83(4), 871–892. https://doi.org/10.1007/s11336-017-9592-7
Bolck, A., Croon, M., & Hagenaars, J. (2004). Estimating latent structure models with categorical variables: One-step versus three-step estimators. Political Analysis, 12(1), 3–27. https://doi.org/10.1093/pan/mph001
Lyrvall, J., Di Mari, R., Bakk, Z., Oser, J., & Kuha, J. (2025). Multilevel latent class analysis: State-of-the-art methodologies and their implementation in the R package multilevLCA. Multivariate Behavioral Research, 60(4), 731–747. https://doi.org/10.1080/00273171.2025.2473935
Vermunt, J. K. (2010). Latent class modeling with covariates: Two improved three-step approaches. Political Analysis, 18(4), 450–469. https://doi.org/10.1093/pan/mpq025
sessionInfo()
#> R version 4.5.1 (2025-06-13 ucrt)
#> Platform: x86_64-w64-mingw32/x64
#> Running under: Windows 11 x64 (build 26200)
#>
#> Matrix products: default
#> LAPACK version 3.12.1
#>
#> locale:
#> [1] LC_COLLATE=C
#> [2] LC_CTYPE=English_United States.utf8
#> [3] LC_MONETARY=English_United States.utf8
#> [4] LC_NUMERIC=C
#> [5] LC_TIME=English_United States.utf8
#>
#> time zone: America/Denver
#> tzcode source: internal
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] tseLCA_1.0.2
#>
#> loaded via a namespace (and not attached):
#> [1] sass_0.4.10 generics_0.1.4 tidyr_1.3.1 pracma_2.4.6
#> [5] hms_1.1.4 digest_0.6.39 magrittr_2.0.3 evaluate_1.0.5
#> [9] RColorBrewer_1.1-3 iterators_1.0.14 fastmap_1.2.0 foreach_1.5.2
#> [13] jsonlite_2.0.0 combinat_0.0-8 promises_1.5.0 purrr_1.0.4
#> [17] codetools_0.2-20 jquerylib_0.1.4 cli_3.6.6 shiny_1.14.0
#> [21] labelled_2.16.0 rlang_1.2.0 cachem_1.1.0 yaml_2.3.12
#> [25] otel_0.2.0 klaR_1.7-4 parallel_4.5.1 tools_4.5.1
#> [29] dplyr_1.1.4 httpuv_1.6.17 forcats_1.0.1 vctrs_0.6.5
#> [33] R6_2.6.1 mime_0.13 lifecycle_1.0.5 multilevLCA_2.1.4
#> [37] tictoc_1.2.1 MASS_7.3-65 miniUI_0.1.2 cluster_2.1.8.1
#> [41] pkgconfig_2.0.3 pillar_1.11.1 bslib_0.11.0 later_1.4.8
#> [45] glue_1.8.0 Rcpp_1.1.1-1.1 haven_2.5.5 xfun_0.52
#> [49] tibble_3.2.1 tidyselect_1.2.1 highr_0.12 rstudioapi_0.19.0
#> [53] knitr_1.51 xtable_1.8-8 htmltools_0.5.9 rmarkdown_2.31
#> [57] clustMixType_0.5-1 compiler_4.5.1 questionr_0.8.2These binaries (installable software) and packages are in development.
They may not be fully stable and should be used with caution. We make no claims about them.
Health stats visible at Monitor.