
Multilevel latent profile analysis
Written by Mohammed Saqr and Sonsoles López-Pernas
Source:vignettes/lpa.Rmd
lpa.RmdMultilevel latent profile analysis (MLPA) estimates latent profiles at the observation level and, at a higher level, identifies latent group classes that differ in how probable each profile is for their observations. Each profile is characterized by a mean and variance for each indicator and is defined identically across all group classes. The group classes therefore differ only in the probabilities associated with the profiles.
Data
course_engagement has one row per course enrolment:
1,422 enrolments from 106 students. multilpa() needs two
kinds of variables.
-
Group:
studentidentifies which enrolments belong to the same student. -
Indicators: five activity measures
(
browse,lectures,forum_read,forum_post,attendance), each standardized within course, so 0 is the course average.
The data are simulated, and two further variables record the
structure that generated them: engagement for each
enrolment and student_type for each student.
Clustering within groups
To check how much each indicator varies between groups, we call
descriptives() with vars for the indicators
and id for the group. icc is the share of an
indicator’s variance that lies between groups.
descriptives(course_engagement,
vars = c("browse", "lectures", "forum_read", "forum_post",
"attendance"),
id = "student")
#> variable n n_missing mean sd min max n_distinct n_groups icc
#> 1 browse 1422 0 -2.81e-05 0.989 -3.14 2.49 398 106 0.1923
#> 2 lectures 1422 0 -4.22e-05 0.989 -2.91 3.22 399 106 0.0878
#> 3 forum_read 1422 0 7.03e-05 0.989 -2.73 2.81 400 106 0.2240
#> 4 forum_post 1422 0 -2.11e-05 0.989 -2.61 3.10 397 106 0.1503
#> 5 attendance 1422 0 7.74e-05 0.989 -2.45 2.41 401 106 0.2215Fit
To fit the model, we call multilpa() with
vars for the indicators, id for the group,
n_profiles for the number of profiles,
n_group_classes for the number of group classes,
n_starts for the number of random EM starts, and
seed for reproducible starts. Printing the fit gives the
profile means.
fit <- multilpa(course_engagement,
vars = c("browse", "lectures", "forum_read", "forum_post",
"attendance"),
id = "student", n_profiles = 2, n_group_classes = 2,
n_starts = 10, seed = 1)
fit
#> Two-level latent profile analysis: 2 profiles, 2 group classes
#> 1422 individuals in 106 groups; varying diagonal residual covariance (VVI)
#> Log likelihood: -8439.816424 | AIC: 16925.633 | BIC (groups): 16986.892
#> Converged: TRUE | iterations: 9 | best start: 1/10
#>
#> profile browse lectures forum_read forum_post attendance count proportion
#> 1 0.540 0.427 0.620 0.530 0.649 834 0.587
#> 2 -0.766 -0.606 -0.879 -0.753 -0.921 588 0.413
#>
#> Variances and standard errors: get_results(x, "profiles").
#> Every other table: get_results(x, what = ), or get_results(x, "all").To check whether the starts agree, we call get_results()
with "starts". Starts that reach the same log likelihood
support the solution.
get_results(fit, "starts")
#> start log_likelihood converged iterations error boundary
#> 1 1 -8440 TRUE 9 <NA> FALSE
#> 2 2 -8440 TRUE 12 <NA> FALSE
#> 3 3 -8440 TRUE 9 <NA> FALSE
#> 4 4 -8440 TRUE 12 <NA> FALSE
#> 5 5 -8440 TRUE 9 <NA> FALSE
#> 6 6 -8440 TRUE 12 <NA> FALSE
#> 7 7 -8440 TRUE 9 <NA> FALSE
#> 8 8 -8440 TRUE 12 <NA> FALSE
#> 9 9 -8440 TRUE 9 <NA> FALSE
#> 10 10 -8440 TRUE 9 <NA> FALSEProfiles
To obtain the profile means and variances, we call
get_results() on the fit. There is one row per profile and
indicator.
get_results(fit)
#> profile indicator mean variance standard_deviation mean_standard_error
#> 1 1 browse 0.540 0.590 0.768 0.0271
#> 2 1 lectures 0.427 0.845 0.919 0.0325
#> 3 1 forum_read 0.620 0.481 0.694 0.0247
#> 4 1 forum_post 0.530 0.689 0.830 0.0295
#> 5 1 attendance 0.649 0.384 0.620 0.0223
#> 6 2 browse -0.766 0.528 0.727 0.0312
#> 7 2 lectures -0.606 0.538 0.734 0.0309
#> 8 2 forum_read -0.879 0.363 0.603 0.0262
#> 9 2 forum_post -0.753 0.421 0.649 0.0277
#> 10 2 attendance -0.921 0.374 0.611 0.0264
#> variance_standard_error
#> 1 0.0295
#> 2 0.0421
#> 3 0.0244
#> 4 0.0348
#> 5 0.0196
#> 6 0.0322
#> 7 0.0321
#> 8 0.0228
#> 9 0.0261
#> 10 0.0228To see the profiles, we call plot() on the fit. Each
line is a profile’s mean on each indicator, and its label gives the
profile’s share of the cases.
plot(fit)
Group classes
To obtain the profile probabilities of each group class, we call
get_results() with "profile_probabilities".
probability is the chance that an observation of a group in
that class falls in each profile, and
group_class_probability is the share of groups in the
class.
get_results(fit, "profile_probabilities")
#> group_class profile probability group_class_probability
#> 1 1 1 0.786 0.675
#> 2 1 2 0.214 0.675
#> 3 2 1 0.170 0.325
#> 4 2 2 0.830 0.325To compare the group classes, we call plot() with
what = "probabilities": one line per group class across the
profiles.
plot(fit, what = "probabilities")
Class sizes
To obtain class sizes, we call get_results() with
"counts". effective_count sums the posterior
probabilities, so it need not be a whole number.
get_results(fit, "counts")
#> level class effective_count effective_proportion
#> 1 individuals 1 834.1 0.587
#> 2 individuals 2 587.9 0.413
#> 3 groups 1 71.5 0.675
#> 4 groups 2 34.5 0.325Classification
To obtain classification certainty, we call
get_results() with "entropy". Relative entropy
is reported for observations and for groups: 1 means every case is
assigned with certainty, 0 means no separation.
get_results(fit, "entropy")
#> level n_classes n_units entropy_sum relative_entropy
#> 1 individuals 2 1422 60.94 0.938
#> 2 groups 2 106 4.43 0.940To add each observation’s assigned profile and group class to the
data, we call get_results() with "assignments"
and data for the data frame. It returns the data in its
original row order with five added columns: profile and
group_class hold the most probable classes,
uncertainty is one minus the posterior probability of the
assigned profile, and the posterior_profile_ columns hold
the posterior probability of each profile. Assignment takes the most
probable class, so profile and group_class
alone ignore classification uncertainty.
get_results(fit, "assignments", data = course_engagement) |> head()
#> student course sequence browse lectures forum_read forum_post attendance previous_grade
#> 1 1 23 1 0.73 -0.08 0.30 0.67 0.72 0.54
#> 2 1 19 2 0.68 0.53 -0.02 -0.55 0.70 -0.71
#> 3 1 4 3 -1.51 -0.51 -0.91 -0.80 -0.97 1.98
#> 4 1 18 4 0.64 -1.02 -1.62 -1.20 -1.26 -1.42
#> 5 1 16 5 -0.94 -0.37 -0.71 -0.28 -1.52 -0.67
#> 6 1 29 6 -0.36 -0.46 -0.77 -0.43 -1.34 -0.40
#> engagement student_type profile group_class uncertainty posterior_profile_1 posterior_profile_2
#> 1 engaged wavering 1 2 4.44e-04 1.00e+00 0.000444
#> 2 engaged wavering 1 2 1.49e-02 9.85e-01 0.014854
#> 3 disengaged wavering 2 2 2.42e-06 2.42e-06 0.999998
#> 4 disengaged wavering 2 2 3.86e-06 3.86e-06 0.999996
#> 5 disengaged wavering 2 2 5.75e-06 5.75e-06 0.999994
#> 6 disengaged wavering 2 2 2.28e-05 2.28e-05 0.999977Recovery of the known structure
To compare the fitted classes with known labels, we call
get_results() with "assignments",
data for the data frame, and truth for the
columns that hold the labels. engagement is compared with
the profiles and student_type with the group classes. Class
numbers are arbitrary, so the table is read by its largest cells.
get_results(fit, "assignments", data = course_engagement,
truth = c("engagement", "student_type"))
#> assignment class truth value n proportion
#> 1 profile 1 engagement disengaged 16 0.0274
#> 2 profile 2 engagement disengaged 569 0.9726
#> 3 profile 1 engagement engaged 820 0.9797
#> 4 profile 2 engagement engaged 17 0.0203
#> 5 group_class 1 student_type committed 61 0.9839
#> 6 group_class 2 student_type committed 1 0.0161
#> 7 group_class 1 student_type wavering 10 0.2273
#> 8 group_class 2 student_type wavering 34 0.7727Reference
The calls below list the other tables, functions and views available for a fitted model. Each comment states what the call returns.
# Tables: get_results(fit, what)
get_results(fit, "all") # every table, as a named list
get_results(fit, "covariances") # within-profile residual covariances
get_results(fit, "posteriors") # one row per observation and profile
get_results(fit, "posteriors", format = "wide") # one row per observation
get_results(fit, "group_posteriors") # one row per group and group class
get_results(fit, "classification") # class sizes and average posteriors
get_results(fit, "average_posteriors") # mean posterior by assigned class
get_results(fit, "classification_errors") # P(assigned class | true class)
get_results(fit, "bch_weights") # weights for three-step analysis
get_results(fit, "residuals") # residual correlations within profiles
get_results(fit, "information_criteria") # AIC, BIC and the other criteria
get_results(fit, "model") # one row describing the fit
get_results(fit, "data") # the variables used in the fit
as.data.frame(fit) # the profile table
# Summaries and diagnostics
descriptives(fit) # indicator summaries with the ICC
descriptives(fit, by = "profile") # the same, by assigned profile
diagnostics(fit) # every classification diagnostic
report(fit) # summary, diagnostics and plots together
# Inference and likelihood
parameter_inference(fit) # standard errors and Wald intervals
confint(fit) # confidence intervals
vcov(fit) # covariance matrix of the estimates
logLik(fit) # log likelihood
AIC(fit) # Akaike information criterion
BIC(fit) # BIC with the number of groups
nobs(fit) # number of groups
# Plots: plot(fit, what)
plot_views() # every view and what it shows
plot(fit, what = "bars") # profile means with 95% intervals
plot(fit, what = "heatmap") # means in standard deviations
plot(fit, what = "sizes") # effective size of each profile
plot(fit, what = "entropy") # per-case classification uncertainty
plot(fit, what = "posteriors") # posterior of each assigned profile
plot(fit, what = "avepp") # average posteriors, assigned by posterior class
plot(fit, what = "all") # every view this fit supports
# Other functions
sensitivity(fit, seeds = 1:10) # refits under several seeds
starting_values(fit) # parameters to start another fitThe evaluation vignette covers enumerate_classes(),
candidate_fit() and bootstrap_lrt(); the
covariates vignette covers three_step(),
r3step() and fit_staged(); the transitions
vignette covers lta(), get_tna() and
get_group_tna().
Sampling weights
When students were sampled unequally, for example wavering students
at half the rate of committed ones, each student carries a sampling
weight, and the fit should estimate the population rather than the
sample. We pass the weight column as weights. The weight
belongs to the independent unit, here the student, so it must be
constant within id. The fit maximizes the weighted (pseudo)
log likelihood, with the weights scaled to sum to the number of
students, and reports Kish’s effective sample size.
weighted_data <- transform(course_engagement,
survey_weight = ifelse(student_type == "wavering", 2, 1))
weighted_fit <- multilpa(weighted_data,
vars = c("browse", "lectures", "forum_read",
"forum_post", "attendance"),
id = "student", n_profiles = 2, n_group_classes = 2,
weights = "survey_weight", n_starts = 10, seed = 1)
weighted_fit
#> Two-level latent profile analysis: 2 profiles, 2 group classes
#> 1422 individuals in 106 groups; varying diagonal residual covariance (VVI)
#> Sampling weights `survey_weight` (pseudo maximum likelihood): Kish effective n 94.5 of 106 units
#> Log likelihood: -8341.125778 | AIC: 16728.252 | BIC (groups): 16789.511
#> Converged: TRUE | iterations: 9 | best start: 1/10
#>
#> profile browse lectures forum_read forum_post attendance count proportion
#> 1 0.525 0.446 0.605 0.515 0.655 701 0.495
#> 2 -0.770 -0.608 -0.877 -0.742 -0.921 715 0.505
#>
#> Variances and standard errors: get_results(x, "profiles").
#> Every other table: get_results(x, what = ), or get_results(x, "all").Under weights the inverse information is not the variance of the
estimates, so parameter_inference() reports sandwich
standard errors by default and refuses
vcov_type = "observed".
head(parameter_inference(weighted_fit), 4)
#> level outcome term parameter estimate standard_error statistic p_value p_adjusted
#> 1 measurement profile_1 browse mean 0.525 0.0323 16.3 1.76e-59 1.76e-59
#> 2 measurement profile_1 lectures mean 0.446 0.0365 12.2 2.23e-34 2.23e-34
#> 3 measurement profile_1 forum_read mean 0.605 0.0263 23.0 6.60e-117 6.60e-117
#> 4 measurement profile_1 forum_post mean 0.515 0.0317 16.2 2.72e-59 2.72e-59
#> conf_low conf_high
#> 1 0.462 0.589
#> 2 0.375 0.518
#> 3 0.553 0.656
#> 4 0.453 0.578lta(), mixture_regression() and every
family of multilpa() take weights
the same way. The bootstrap likelihood ratio test does not: it simulates
from the model, not from the sampling design.
All tables
To see every table at once, we call summary() on the
fit, with rows for the number of rows shown per table.
print(summary(fit), rows = 3)
#> Multilevel LPA: 2 profiles and 2 group classes
#> Individuals: 1422; groups: 106; parameters: 23; converged: TRUE
#> Log likelihood: -8439.816424; AIC: 16925.633
#> BIC (groups): 16986.892; BIC (individuals): 17046.609
#> Best likelihood replicated in 10/10 starts (absolute tolerance 0.00844).
#>
#> -- profiles --------------------------------------------------------
#> profile indicator mean variance standard_deviation mean_standard_error variance_standard_error
#> 1 browse 0.5396 0.5899 0.7680 0.02710 0.02948
#> 1 lectures 0.4274 0.8447 0.9191 0.03249 0.04210
#> 1 forum_read 0.6198 0.4815 0.6939 0.02473 0.02440
#> ... 7 more rows. get_results(x, what = "profiles")
#>
#> -- responses -------------------------------------------------------
#> (no rows)
#>
#> -- covariances -----------------------------------------------------
#> profile indicator indicator_2 covariance
#> 1 browse browse 0.5899
#> 1 lectures browse 0.0000
#> 1 forum_read browse 0.0000
#> ... 47 more rows. get_results(x, what = "covariances")
#>
#> -- profile_probabilities -------------------------------------------
#> group_class profile probability group_class_probability
#> 1 1 0.7859 0.6747
#> 1 2 0.2141 0.6747
#> 2 1 0.1705 0.3253
#> ... 1 more rows. get_results(x, what = "profile_probabilities")
#>
#> -- counts ----------------------------------------------------------
#> level class effective_count effective_proportion
#> individuals 1 834.10 0.5866
#> individuals 2 587.90 0.4134
#> groups 1 71.52 0.6747
#> ... 1 more rows. get_results(x, what = "counts")
#>
#> -- posteriors ------------------------------------------------------
#> row group profile posterior modal
#> 1 1 1 9.996e-01 TRUE
#> 2 1 1 9.851e-01 TRUE
#> 3 1 1 2.421e-06 FALSE
#> ... 2841 more rows. get_results(x, what = "posteriors")
#>
#> -- group_posteriors ------------------------------------------------
#> group group_size log_likelihood group_class posterior modal
#> 1 13 -61.52 1 1.473e-05 FALSE
#> 2 13 -71.94 1 6.391e-08 FALSE
#> 3 14 -76.69 1 1.000e+00 TRUE
#> ... 209 more rows. get_results(x, what = "group_posteriors")
#>
#> -- assignments -----------------------------------------------------
#> student browse lectures forum_read forum_post attendance profile group_class uncertainty
#> 1 0.73 -0.08 0.30 0.67 0.72 1 2 4.436e-04
#> 1 0.68 0.53 -0.02 -0.55 0.70 1 2 1.485e-02
#> 1 -1.51 -0.51 -0.91 -0.80 -0.97 2 2 2.421e-06
#> posterior_profile_1 posterior_profile_2
#> 9.996e-01 0.0004436
#> 9.851e-01 0.0148536
#> 2.421e-06 0.9999976
#> ... 1419 more rows. get_results(x, what = "assignments")
#>
#> -- classification --------------------------------------------------
#> level class n_modal proportion_modal estimated_n estimated_proportion average_posterior
#> individuals 1 836 0.5879 834.10 0.5866 0.9849
#> individuals 2 586 0.4121 587.90 0.4134 0.9818
#> groups 1 71 0.6698 71.52 0.6747 0.9920
#> odds_correct_classification
#> 46.08
#> 76.35
#> 59.89
#> ... 1 more rows. get_results(x, what = "classification")
#>
#> -- average_posteriors ----------------------------------------------
#> level assigned_class class n_assigned average_posterior
#> individuals 1 1 836 0.98493
#> individuals 1 2 836 0.01507
#> individuals 2 1 586 0.01824
#> ... 5 more rows. get_results(x, what = "average_posteriors")
#>
#> -- classification_errors -------------------------------------------
#> level true_class assigned_class probability
#> individuals 1 1 0.98718
#> individuals 1 2 0.01282
#> individuals 2 1 0.02142
#> ... 5 more rows. get_results(x, what = "classification_errors")
#>
#> -- bch_weights -----------------------------------------------------
#> level unit assigned_class class weight
#> individuals 1 1 1 1.01327
#> individuals 1 1 2 -0.01327
#> individuals 2 1 1 1.01327
#> ... 3053 more rows. get_results(x, what = "bch_weights")
#>
#> -- entropy ---------------------------------------------------------
#> level n_classes n_units entropy_sum relative_entropy
#> individuals 2 1422 60.940 0.9382
#> groups 2 106 4.435 0.9396
#>
#> -- residuals -------------------------------------------------------
#> profile indicator_1 indicator_2 kind observed expected residual effective_n statistic df
#> profile_1 lectures attendance gaussian 0.2339 0 0.2339 834.1 6.871 NA
#> profile_2 forum_read attendance gaussian 0.2287 0 0.2287 587.9 5.630 NA
#> profile_1 forum_read attendance gaussian 0.2088 0 0.2088 834.1 6.109 NA
#> p_value p_adjusted
#> 6.374e-12 6.374e-12
#> 1.799e-08 1.799e-08
#> 1.001e-09 1.001e-09
#> ... 17 more rows. get_results(x, what = "residuals")
#>
#> -- information_criteria --------------------------------------------
#> log_likelihood n_parameters aic kic bic_groups bic_individual sabic_groups sabic_individual
#> -8440 23 16926 16952 16987 17047 16914 16974
#> caic_groups caic_individual awe_groups awe_individual icl_groups icl_individual clc_groups
#> 17010 17070 17172 17404 16996 17168 16889
#> clc_individual
#> 17002
#>
#> -- model -----------------------------------------------------------
#> n_observations n_informative n_groups n_profiles n_group_classes centering covariance_structure
#> 1422 1422 106 2 2 none VVI
#> n_parameters n_parameters_with_measurement log_likelihood aic bic_groups bic_individual
#> 23 23 -8440 16926 16987 17047
#> converged iterations boundary small_classes best_start n_best_replicated weights
#> TRUE 9 FALSE FALSE 1 10 <NA>
#>
#> -- stages ----------------------------------------------------------
#> stage group_classes fixed log_likelihood parameters parameters_with_measurement converged
#> joint 2 <NA> -8440 23 23 TRUE
#>
#> -- starts ----------------------------------------------------------
#> start log_likelihood converged iterations error boundary
#> 1 -8440 TRUE 9 <NA> FALSE
#> 2 -8440 TRUE 12 <NA> FALSE
#> 3 -8440 TRUE 9 <NA> FALSE
#> ... 7 more rows. get_results(x, what = "starts")
#>
#> -- data ------------------------------------------------------------
#> student browse lectures forum_read forum_post attendance
#> 1 0.73 -0.08 0.30 0.67 0.72
#> 1 0.68 0.53 -0.02 -0.55 0.70
#> 1 -1.51 -0.51 -0.91 -0.80 -0.97
#> ... 1419 more rows. get_results(x, what = "data")
#>
#> 19 tables above, truncated to fit. get_results(x, what = ) returns any
#> of them whole, and get_results(x, what = "all") returns every one.Assumptions
- Profiles have the same means and variances in every group class.
- Observations are independent given their group’s class, and groups are independent of one another.
- Indicators are independent within a profile (the default diagonal
covariance;
covariance_model = "full"relaxes it). - The numbers of profiles and group classes are chosen by the analyst; see the evaluation vignette for comparing them.