A simulated dataset with the structure this package exists for, and all of it in one place: enrolments nested in students, ordered within each student, with a covariate that predicts which pattern an enrolment follows.
Format
A data frame with 1422 rows (106 students, 32 courses, up to 15 enrolments each) and 11 columns:
- student
Integer student identifier, 1 to 106. The nesting unit; pass it as
id.- course
Integer course identifier, 1 to 32. Which course the enrolment was in.
- sequence
Integer, the position of this course in the student's own order, 1 upwards. Pass it as
time. Students who took fewer courses have shorter sequences, so the panel is ragged.- browse
Numeric, course page views on the
log1p(events)scale, standardized within course.- lectures
Numeric, lecture videos viewed on the
log1p(events)scale, standardized within course.- forum_read
Numeric, forum posts read on the
log1p(events)scale, standardized within course.- forum_post
Numeric, forum posts written on the
log1p(events)scale, standardized within course.- attendance
Numeric, days the student was active in the course as on the
log1p(events)scale, standardized within course. It is generated from the four click measures and then blurred, because a student is recorded present on a day precisely because they clicked something, and a day is a coarse unit – one tick however much happened inside it. The indicators are therefore locally dependent on purpose:attendancecorrelates 0.51 to 0.67 with the click measures overall and 0.16 to 0.26 within a single profile, which is more association than a shared profile alone would produce. See the note on local dependence below.- previous_grade
Numeric, the grade the student earned in the course before this one, standardised across the cohort. At a student's first course an entry grade stands in its place. It is a predictor of engagement here, not an outcome of it: pass it as
profile_covariatestomultilpa(profile_covariates = ), or as the predictor tor3step().- engagement
Factor,
"disengaged"or"engaged", the pattern the row was generated from. This is the truth behind the data, not something a study would observe; it is shipped so a fitted model can be checked against what produced it. It is deliberately not calledprofile:get_results(fit, "assignments")adds a column of that name, and a dataset already carrying it would make the verb refuse to join its own output.- student_type
Factor,
"committed"or"wavering", the kind of student the row's student was generated as. The truth behind the group classes, shipped for the same reason.
Source
Simulated by data-raw/course-engagement.R, seed 2026. The
simulation is calibrated to the shape of a real learning-analytics export
– its scales, its spreads, its correlations and how persistent engagement
is across a student's courses – but only those aggregate constants were
used. No row, identifier or value of any real student is present, and the
source is not distributed.
Details
Each row is one student's enrolment in one course. A student takes up to
fifteen courses, and sequence orders them, so the same rows support a
cross-sectional two-level model (id = "student"), a latent transition model
(time = "sequence"), and a membership-covariate model
(profile_covariates = "previous_grade").
Two kinds of student were generated. Both contain both patterns of engagement; they differ in the mix, which is the two-level quantity this package estimates. A committed student is engaged in about four enrolments in five, a wavering student in about one in four. Pooled across the sample the engaged share describes neither kind, which is the point: recovering the split is what a two-level model does and a pooled one cannot.
Local independence does not hold here, on purpose
A latent profile model with a diagonal residual covariance assumes the
indicators are independent given the profile. attendance is built from the
click measures, so for these data that assumption is false, and the package
finds it: get_results(fit, "residuals") reports a largest residual of 0.21
p = 1.7e-15, and covariance_model = "full" improves the log likelihood
from -7618.5 to -7494.7 for twenty more parameters, which every information
criterion prefers.
This is deliberate. A bundled dataset that satisfies local independence
exactly can only ever show those verbs agreeing that nothing is wrong; here
the diagnostic has a real violation to find, its cause is known, and the fix
can be seen to work. One consequence is worth knowing before it surprises
you: under a diagonal model enumerate_classes() keeps preferring more
classes, because extra classes absorb residual association the model is not
allowed to express directly.
The five activity indicators are standardized within course: each value is
that enrolment's distance from its own course's mean, in that course's
standard deviations. Every indicator therefore has mean zero and standard
deviation one within every course, and a browse of 1.2 reads as "1.2
standard deviations more active than the average enrolment in that course".
This is the scale a learning-analytics reader meets, because activity volume
is not comparable across courses: a course with weekly quizzes generates more
clicks than a seminar whatever its students are like, so an analyst compares a
student with their coursemates rather than with the whole cohort.
In this simulation the courses do not in fact differ in activity volume, so
the standardization sets the scale rather than removing a course effect. It is
stated here rather than left implicit because a reader who assumed otherwise
would misread what the course column does.
The indicators are simulated directly on the log1p(events) scale, the scale
of a logged activity export; no raw counts are generated. They are
standardized on that scale, not as counts.
Standardizing is affine within a course, so it leaves the within-course skew
exactly as it found it, and raw learning-analytics counts are strongly
right-skewed: on an unlogged draft of these data the skew alone looked like an
extra class, exactly as a floor or ceiling effect does. The log removes the
skew; the standardization puts the courses on a common footing afterwards.
See also
vignette("lpa", package = "latents") for the analysis these data are used in,
multilpa() for the two-level model, lta() for the
sequence, and multilpa(profile_covariates = ) for previous_grade.
Examples
fit <- multilpa(
course_engagement,
vars = c("browse", "lectures", "forum_read", "forum_post", "attendance"),
id = "student", n_profiles = 2, n_group_classes = 2, n_starts = 4,
seed = 1
)
as.data.frame(fit)
#> profile indicator mean variance standard_deviation
#> 1 1 browse 0.5395774 0.5898715 0.7680309
#> 2 1 lectures 0.4274032 0.8446922 0.9190714
#> 3 1 forum_read 0.6198249 0.4814742 0.6938834
#> 4 1 forum_post 0.5304889 0.6887894 0.8299334
#> 5 1 attendance 0.6490499 0.3841262 0.6197792
#> 6 2 browse -0.7655996 0.5284686 0.7269584
#> 7 2 lectures -0.6064852 0.5383733 0.7337393
#> 8 2 forum_read -0.8792135 0.3631131 0.6025887
#> 9 2 forum_post -0.7526882 0.4211624 0.6489703
#> 10 2 attendance -0.9206598 0.3736294 0.6112523
#> mean_standard_error variance_standard_error
#> 1 0.02710160 0.02948401
#> 2 0.03248649 0.04210053
#> 3 0.02473030 0.02439626
#> 4 0.02953027 0.03481644
#> 5 0.02226482 0.01955857
#> 6 0.03121145 0.03221701
#> 7 0.03093059 0.03209025
#> 8 0.02621510 0.02279296
#> 9 0.02768122 0.02607601
#> 10 0.02642702 0.02278996
get_results(fit, what = "profile_probabilities")
#> group_class profile probability group_class_probability
#> 1 1 1 0.7859317 0.6747028
#> 2 1 2 0.2140683 0.6747028
#> 3 2 1 0.1704509 0.3252972
#> 4 2 2 0.8295491 0.3252972
# The generating truth ships alongside, so recovery can be checked directly.
get_results(fit, what = "assignments", data = course_engagement,
truth = "engagement")
#> assignment class truth value n proportion
#> 1 profile 1 engagement disengaged 16 0.02735043
#> 2 profile 2 engagement disengaged 569 0.97264957
#> 3 profile 1 engagement engaged 820 0.97968937
#> 4 profile 2 engagement engaged 17 0.02031063
