Skip to contents

Longitudinal data are nested: every measurement belongs to a subject, and every subject is measured repeatedly over time. One observation therefore sits inside two groupings at once, the people measured at the same moment and the times the same person was measured, and its value only means something against one of them. Two hundred forum reads is a lot for a quiet student and ordinary for a busy one, and it is high in a slow week and unremarkable in a busy one. There is no scale-free measure of how engaged an observation is until you fix a reference: relative to whom, or relative to when.

Studies pick a level of that nesting on purpose. Comparing students against their peers at each occasion is the inter-personal reading: Saqr and López-Pernas (2021) bin engagement by course and describe who is active, average, or disengaged relative to the cohort at each point. Comparing a student against their own history is the intra-personal reading: Saqr and López-Pernas (2025) follow within-person change, asking whether a student rises or falls from their own baseline rather than where they stand in the cohort. Neither is more correct; they answer different questions.

The standardize argument is where VaSSTra makes that choice. It puts the indicators on a common scale before clustering observations into states, and the choice is not a formality: it decides what the states are about. Standardizing within each time point asks who is high or low relative to their peers at that moment. Standardizing within each subject asks who is high or low relative to their own baseline. Not standardizing at all lets the indicator with the largest raw numbers dominate. This article shows how much the resulting states move when you change it.

The four options

standardize z-scores each indicator within a grouping:

Value Groups by The state reflects
"time" each time point standing relative to peers at that moment (the default)
"subject" each subject deviation from a person’s own baseline over time
"global" the whole sample standing relative to the overall mean and spread
"none" (no grouping) the raw values, so the largest-scale indicator dominates

The choice is substantive. "time" and "global" are inter-personal: they compare people to each other. "subject" is intra-personal: it compares a person to themselves, so a student who is always highly active but dips mid-course reads as a drop rather than as still above average. "none" is rarely what you want unless the indicators are already comparable.

A dataset where it matters

The built-in engagement data ship pre-standardized (the _z columns), so to see the effect we use the raw count columns, which live on very different scales: from a handful of forum posts to thousands of seconds of duration.

raw_indicators <- c(
  "course_view_count", "forum_consume_count", "forum_contribute_count",
  "lecture_view_count", "course_view_regularity", "session_count",
  "total_duration", "active_days"
)

Fitting the same states four ways

We estimate three states with k-means on the raw indicators, changing only standardize; the method, count, and seed are held fixed, so any difference is the rescaling. K-means is used on purpose: it clusters by Euclidean distance, so it reacts directly to scale (which is what standardize controls) and separates states by profile shape. The default LPA models each indicator’s variance separately, so it barely reacts to standardization and would hide the effect this article is about.

fits <- list()
for (s in c("none", "global", "time", "subject")) {
  fits[[s]] <- step1_states(
    engagement, variables = raw_indicators,
    n_states = 3, method = "kmeans", standardize = s, seed = 1
  )
}

Pull each scheme’s state assignment out once, to compare them below:

state_none    <- as.data.frame(fits[["none"]])$state
state_global  <- as.data.frame(fits[["global"]])$state
state_time    <- as.data.frame(fits[["time"]])$state
state_subject <- as.data.frame(fits[["subject"]])$state

State sizes move

cbind(
  none    = table(state_none),
  global  = table(state_global),
  time    = table(state_time),
  subject = table(state_subject)
)
#>         none global time subject
#> State 1  311    256  263     287
#> State 2  541    526  524     395
#> State 3  284    354  349     454

The three groups are carved differently under each scheme: the balance shifts by dozens of students.

Assignments reshuffle

The clearest measure is how much the membership changes. The adjusted Rand index (ARI) compares two groupings of the same students by looking at every pair of them and asking whether the two solutions agree: either both put the pair in one state, or both keep them apart. It is 1 when the groupings are identical and about 0 when they agree no more than chance would predict. It ignores the state labels: when a scheme is refitted, “state 1” and “state 3” may swap names, and ARI compares only who is grouped with whom, not what the group is called. Here it compares the other three schemes with the default, "time":

c(
  none    = mclust::adjustedRandIndex(state_none,    state_time),
  global  = mclust::adjustedRandIndex(state_global,  state_time),
  subject = mclust::adjustedRandIndex(state_subject, state_time)
)
#>    none  global subject 
#>   0.377   0.924   0.059

"global" agrees closely with "time": both compare people to each other, so on these data they land in nearly the same place. "subject" barely agrees at all. Because it removes each person’s baseline, it groups students by how they change rather than how active they are, and most land in a different state. "none" also diverges, but for the opposite reason: it is following total_duration’s raw scale.

The states themselves

The profile plots show what each scheme calls a state. Under peer-relative standardization ("time") the three states are an ordered engagement ladder: three roughly parallel lines at high, middle, and low levels across every indicator, so a state is “more” or “less” engaged. Under within-subject standardization ("subject") the lines cross. Because each person’s overall level has been removed, the states no longer stack by level but separate on which activities a student is above or below their own baseline on. The same three-way split, but about a different thing.

plot(fits[["time"]], type = "profile", main = 'standardize = "time"')

plot(fits[["subject"]], type = "profile", main = 'standardize = "subject"')

Which to use

  • "time" (default) when the question is comparative: who stands out at each point relative to the cohort. This is the usual choice for learning-analytics engagement data and the one the VaSSTra chapter uses.
  • "subject" when the question is about change: whether a person rises, holds, or falls relative to their own baseline, regardless of their level. Useful when absolute level is a nuisance (e.g. trait differences you want to factor out).
  • "global" when a single overall scale is appropriate and time points are not meaningfully different populations.
  • "none" only when the indicators are already comparable (as the engagement _z columns are). Otherwise the largest-scale variable silently takes over.

When in doubt, fit more than one and compare the states directly, as above. The ARI tells you how much the choice matters for your data, and the profile plots tell you what each choice means.