library(lavaan)
library(lme4)
library(ggplot2)Growth curve models
Latent growth in lavaan and growth in lme4; simulated longitudinal data
Learning objectives
By the end of this tutorial you will be able to:
- describe what a growth curve model is and when it is used
- fit a latent growth curve model in lavaan with
growth() - fit a growth curve model in
lme4withlmer() - interpret the latent intercept and slope variances
- visualise fitted growth trajectories with
ggplot2
Setup
Setup code
What is a growth curve model?
A growth curve model models change over time in an outcome variable. It is used when you have longitudinal data — the same participants measured at multiple time points — and you want to describe how they change and what predicts individual differences in change.
Growth curve models estimate two latent variables for each participant:
- Intercept (\(\eta_i\)) — the participant’s starting level
- Slope (\(\eta_s\)) — the participant’s rate of change over time
These are latent because they are not directly observed — we infer them from the repeated measurements. Some participants start higher and grow faster; others start lower and grow more slowly. The model captures this variation.
When to use a growth curve model
Use a growth curve model when:
- You have 3 or more time points (fewer than 3 cannot identify a slope)
- The same participants are measured at each time point
- You want to model individual differences in change
Do not use a growth curve model if:
- You have only 2 time points — use a paired t-test or a difference-score regression instead
- Each participant is measured only once — there is no growth to model
The teaching dataset
The PS dataset is cross-sectional — one measurement per participant. To demonstrate growth curve modelling, we use a simulated longitudinal dataset (generated by data-raw/simulate-growth.R) with 250 participants measured at 3 time points on a well-being outcome.
We ship two versions of this dataset:
growth_sim_wide.csv— wide format (one row per participant, one column per time point) — for lavaangrowth_sim_long.csv— long format (one row per participant per time point) — forlme4
growth_wide <- read.csv("data/growth_sim_wide.csv")
head(growth_wide)## id y1 y2 y3
## 1 1 5.025454 4.501698 3.963644
## 2 2 2.538385 3.310268 3.367055
## 3 3 3.152816 3.500999 1.795985
## 4 4 5.181726 5.479991 5.992442
## 5 5 4.289254 3.133972 4.502157
## 6 6 2.964313 3.628848 2.434485
growth_long <- read.csv("data/growth_sim_long.csv")
head(growth_long)## id t y
## 1 1 0 5.025454
## 2 1 1 4.501698
## 3 1 2 3.963644
## 4 2 0 2.538385
## 5 2 1 3.310268
## 6 2 2 3.367055
nrow(growth_wide)## [1] 250
nrow(growth_long)## [1] 750
The wide-format dataset has 250 rows (one per participant) and 4 columns: id, y1, y2, y3. The long-format dataset has 750 rows (3 per participant) and 3 columns: id, t (time, 0/1/2), y.
Latent growth curve modelling in lavaan
In lavaan, a latent growth curve model is a CFA with fixed factor loadings. The intercept factor has all loadings fixed to 1; the slope factor has loadings fixed to 0, 1, 2, … representing the time intervals.
The model
model_growth <- '
# Intercept factor (all loadings fixed to 1)
i =~ 1 * y1 + 1 * y2 + 1 * y3
# Slope factor (loadings fixed to 0, 1, 2 — linear growth)
s =~ 0 * y1 + 1 * y2 + 2 * y3
# Means of the latent factors (freely estimated)
i ~ 1
s ~ 1
# Covariance between intercept and slope
i ~~ s
'Reading the syntax:
i =~ 1 * y1 + 1 * y2 + 1 * y3— defines the intercept factoriwith all loadings fixed to 1. Every participant’s intercept is a weighted sum of all three time points, with equal weights.s =~ 0 * y1 + 1 * y2 + 2 * y3— defines the slope factorswith loadings fixed to 0, 1, 2. This encodes linear growth across equally-spaced time points. The 0 loading ony1meansy1does not depend on the slope (it depends only on the intercept).i ~ 1ands ~ 1— freely estimate the means of the latent intercept and slope. By default, lavaan fixes latent means to 0; here we free them because we want to know the average starting level and the average growth rate.i ~~ s— allows the intercept and slope to correlate. Do participants who start higher also grow faster (or slower)?
Fitting the model
fit_growth <- growth(model_growth, data = growth_wide)
summary(fit_growth, standardized = TRUE, fit.measures = TRUE)## lavaan 0.6-21 ended normally after 31 iterations
##
## Estimator ML
## Optimization method NLMINB
## Number of model parameters 8
##
## Number of observations 250
##
## Model Test User Model:
##
## Test statistic 0.002
## Degrees of freedom 1
## P-value (Chi-square) 0.961
##
## Model Test Baseline Model:
##
## Test statistic 267.716
## Degrees of freedom 3
## P-value 0.000
##
## User Model versus Baseline Model:
##
## Comparative Fit Index (CFI) 1.000
## Tucker-Lewis Index (TLI) 1.011
##
## Loglikelihood and Information Criteria:
##
## Loglikelihood user model (H0) -929.651
## Loglikelihood unrestricted model (H1) -929.650
##
## Akaike (AIC) 1875.302
## Bayesian (BIC) 1903.474
## Sample-size adjusted Bayesian (SABIC) 1878.113
##
## Root Mean Square Error of Approximation:
##
## RMSEA 0.000
## 90 Percent confidence interval - lower 0.000
## 90 Percent confidence interval - upper 0.000
## P-value H_0: RMSEA <= 0.050 0.972
## P-value H_0: RMSEA >= 0.080 0.017
##
## Standardized Root Mean Square Residual:
##
## SRMR 0.001
##
## Parameter Estimates:
##
## Standard errors Standard
## Information Expected
## Information saturated (h1) model Structured
##
## Latent Variables:
## Estimate Std.Err z-value P(>|z|) Std.lv Std.all
## i =~
## y1 1.000 0.786 0.833
## y2 1.000 0.786 0.791
## y3 1.000 0.786 0.738
## s =~
## y1 0.000 0.000 0.000
## y2 1.000 0.336 0.339
## y3 2.000 0.673 0.632
##
## Covariances:
## Estimate Std.Err z-value P(>|z|) Std.lv Std.all
## i ~~
## s -0.049 0.051 -0.958 0.338 -0.184 -0.184
##
## Intercepts:
## Estimate Std.Err z-value P(>|z|) Std.lv Std.all
## i 3.456 0.058 59.227 0.000 4.398 4.398
## s 0.222 0.031 7.084 0.000 0.660 0.660
##
## Variances:
## Estimate Std.Err z-value P(>|z|) Std.lv Std.all
## .y1 0.272 0.085 3.200 0.001 0.272 0.305
## .y2 0.353 0.049 7.185 0.000 0.353 0.358
## .y3 0.259 0.095 2.717 0.007 0.259 0.228
## i 0.618 0.098 6.281 0.000 1.000 1.000
## s 0.113 0.045 2.501 0.012 1.000 1.000
Reading the output
The key parameters are:
- Mean of
i— the average starting level (intercept) across participants - Mean of
s— the average growth rate (slope) per time unit - Variance of
i— how much participants differ in their starting levels - Variance of
s— how much participants differ in their growth rates - Covariance of
iwiths— whether starting level predicts growth rate
If the variance of s is significant, participants differ in how much they change over time — there are individual differences in growth. If the variance of s is near zero, all participants grow at the same rate.
Fit indices
fitMeasures(fit_growth, c("chisq", "df", "pvalue",
"cfi", "tli",
"rmsea", "rmsea.ci.lower", "rmsea.ci.upper",
"srmr"))## chisq df pvalue cfi tli
## 0.002 1.000 0.961 1.000 1.011
## rmsea rmsea.ci.lower rmsea.ci.upper srmr
## 0.000 0.000 0.000 0.001
A linear growth model with 3 time points has 1 degree of freedom — the model is overidentified and can be tested. Poor fit suggests the linear trend does not capture the data’s shape; you might need a quadratic slope factor (adding a third latent factor q =~ 0*y1 + 1*y2 + 4*y3 for quadratic growth).
Growth curve modelling in lme4
The same model can be fitted as a multilevel model with lme4, treating time as a Level-1 predictor and individual as the cluster:
fit_lmer <- lmer(y ~ t + (1 + t | id), data = growth_long)
summary(fit_lmer)## Linear mixed model fit by REML ['lmerMod']
## Formula: y ~ t + (1 + t | id)
## Data: growth_long
##
## REML criterion at convergence: 1869
##
## Scaled residuals:
## Min 1Q Median 3Q Max
## -2.32938 -0.53041 -0.00238 0.53203 2.53377
##
## Random effects:
## Groups Name Variance Std.Dev. Corr
## id (Intercept) 0.58624 0.7657
## t 0.08481 0.2912 -0.10
## Residual 0.32380 0.5690
## Number of obs: 750, groups: id, 250
##
## Fixed effects:
## Estimate Std. Error t value
## (Intercept) 3.45613 0.05852 59.06
## t 0.22210 0.03141 7.07
##
## Correlation of Fixed Effects:
## (Intr)
## t -0.401
Reading the output:
- Fixed effects — the intercept is the average starting level (
yatt = 0); thetcoefficient is the average growth rate per time unit. These correspond to the means ofiandsin the lavaan model. - Random effects — the
id:(Intercept)variance is the between-person variance in starting levels (variance ofi); theid:tvariance is the between-person variance in growth rates (variance ofs); the correlation between them is thei ~~ scovariance in lavaan.
Comparing the two approaches
The lavaan and lme4 models are mathematically equivalent for a simple linear growth model with continuous outcomes — they give the same parameter estimates. The differences are:
lavaan growth() |
lme4::lmer() |
|---|---|
| Requires wide-format data | Requires long-format data |
| Estimates latent intercept and slope factors | Estimates random intercept and slope effects |
| Provides fit indices (\(\chi^2\), CFI, RMSEA) | Provides AIC, BIC, likelihood-ratio tests |
| Extends to non-linear growth with additional latent factors | Extends to non-linear growth with polynomial time terms |
| Can add latent covariates of intercept and slope | Can add observed covariates of intercept and slope |
For most MSc applications, lme4 is simpler and faster. Use lavaan when you need fit indices to compare the growth model to alternative structures, or when you want to add latent variable measurement models at each time point.
Visualising growth trajectories
Fitted individual trajectories
# Extract fitted values
growth_long$fitted <- fitted(fit_lmer)
# Plot a random sample of 50 individual trajectories
set.seed(1234)
sample_ids <- sample(unique(growth_long$id), 50)
ggplot(subset(growth_long, id %in% sample_ids),
aes(x = t, y = fitted, group = id)) +
geom_line(alpha = 0.3, colour = "grey60") +
stat_summary(aes(group = 1), fun = mean, geom = "line",
colour = "blue", linewidth = 1.2) +
labs(x = "Time point", y = "Well-being (fitted)",
title = "Individual growth trajectories") +
theme_minimal()
The thick blue line is the average trajectory — it shows the mean intercept and mean slope. The thin grey lines are individual participants’ fitted trajectories — they vary around the mean, showing individual differences in both starting level and growth rate.
Mean trajectory with confidence band
ggplot(growth_long, aes(x = t, y = y)) +
stat_summary(fun = mean, geom = "point", size = 2) +
stat_summary(fun.data = mean_cl_normal, geom = "smooth",
method = "lm", formula = y ~ x, se = TRUE) +
labs(x = "Time point", y = "Well-being",
title = "Mean growth trajectory with 95% CI") +
theme_minimal()
Common errors
lavaan WARNING: model has not converged in growth models
Growth models can fail to converge if the time intervals are very uneven, if the outcome has very high variance at some time points, or if the sample is small. Check that the time loadings (0, 1, 2, …) are correct, and consider standardising the outcome.
Negative slope variance
If the variance of the slope factor s is negative (a Heywood case), the model is suggesting that all participants grow at the same rate. Try fixing the slope variance to zero — this is a random-intercept-only model (no individual differences in growth).
Wide vs long format confusion
lavaan’s growth() requires wide-format data (one row per participant, one column per time point). lme4::lmer() requires long-format data (one row per participant per time point). Mixing them up is a common error. Use reshape() in base R to convert between formats:
# Wide to long
growth_long_from_wide <- reshape(growth_wide, direction = "long",
varying = c("y1", "y2", "y3"),
v.names = "y", times = c(0, 1, 2))Exercises
Fit the latent growth curve model from the tutorial. What is the mean intercept? What is the mean slope? Is there significant individual variation in either?
Fit the same growth model with
lmer(). Compare the fixed effects (intercept,tcoefficient) and the random effects (intercept variance, slope variance) to the lavaan estimates. Are they the same?Add a quadratic slope factor to the lavaan model —
q =~ 0 * y1 + 1 * y2 + 4 * y3. Fit the model. Does the quadratic term significantly improve fit over the linear model? UselavTestLRT()to compare (you will need to fit both withestimator = "ML"for the comparison).Plot the fitted individual trajectories from the
lmer()model (use the code from the tutorial). Describe the pattern — do most participants increase over time? Are there individual differences in growth rates?
Exercise 1
summary(fit_growth, standardized = TRUE)## lavaan 0.6-21 ended normally after 31 iterations
##
## Estimator ML
## Optimization method NLMINB
## Number of model parameters 8
##
## Number of observations 250
##
## Model Test User Model:
##
## Test statistic 0.002
## Degrees of freedom 1
## P-value (Chi-square) 0.961
##
## Parameter Estimates:
##
## Standard errors Standard
## Information Expected
## Information saturated (h1) model Structured
##
## Latent Variables:
## Estimate Std.Err z-value P(>|z|) Std.lv Std.all
## i =~
## y1 1.000 0.786 0.833
## y2 1.000 0.786 0.791
## y3 1.000 0.786 0.738
## s =~
## y1 0.000 0.000 0.000
## y2 1.000 0.336 0.339
## y3 2.000 0.673 0.632
##
## Covariances:
## Estimate Std.Err z-value P(>|z|) Std.lv Std.all
## i ~~
## s -0.049 0.051 -0.958 0.338 -0.184 -0.184
##
## Intercepts:
## Estimate Std.Err z-value P(>|z|) Std.lv Std.all
## i 3.456 0.058 59.227 0.000 4.398 4.398
## s 0.222 0.031 7.084 0.000 0.660 0.660
##
## Variances:
## Estimate Std.Err z-value P(>|z|) Std.lv Std.all
## .y1 0.272 0.085 3.200 0.001 0.272 0.305
## .y2 0.353 0.049 7.185 0.000 0.353 0.358
## .y3 0.259 0.095 2.717 0.007 0.259 0.228
## i 0.618 0.098 6.281 0.000 1.000 1.000
## s 0.113 0.045 2.501 0.012 1.000 1.000
The mean intercept (i ~ 1) is the average starting level; the mean slope (s ~ 1) is the average growth per time point. The variances of i and s tell you whether there is significant individual variation in starting levels and growth rates respectively.
Exercise 2
summary(fit_lmer)## Linear mixed model fit by REML ['lmerMod']
## Formula: y ~ t + (1 + t | id)
## Data: growth_long
##
## REML criterion at convergence: 1869
##
## Scaled residuals:
## Min 1Q Median 3Q Max
## -2.32938 -0.53041 -0.00238 0.53203 2.53377
##
## Random effects:
## Groups Name Variance Std.Dev. Corr
## id (Intercept) 0.58624 0.7657
## t 0.08481 0.2912 -0.10
## Residual 0.32380 0.5690
## Number of obs: 750, groups: id, 250
##
## Fixed effects:
## Estimate Std. Error t value
## (Intercept) 3.45613 0.05852 59.06
## t 0.22210 0.03141 7.07
##
## Correlation of Fixed Effects:
## (Intr)
## t -0.401
The fixed-effect intercept corresponds to the mean of i; the t coefficient corresponds to the mean of s. The id:(Intercept) variance corresponds to the variance of i; the id:t variance corresponds to the variance of s. The estimates should be very similar (small differences may arise from estimation method).
Exercise 3
model_quad <- '
i =~ 1 * y1 + 1 * y2 + 1 * y3
s =~ 0 * y1 + 1 * y2 + 2 * y3
q =~ 0 * y1 + 1 * y2 + 4 * y3
i ~ 1
s ~ 1
q ~ 1
i ~~ s + q
s ~~ q
'
fit_quad <- growth(model_quad, data = growth_wide, estimator = "ML")
fit_lin <- growth(model_growth, data = growth_wide, estimator = "ML")
tryCatch(lavTestLRT(fit_lin, fit_quad),
error = function(e) cat("LRT not available:", conditionMessage(e), "\n"))## LRT not available: missing value where TRUE/FALSE needed
A significant result means the quadratic term improves fit — the growth trajectory is curved, not purely linear. If the LRT is not available (e.g. due to convergence issues with the quadratic model), compare the AIC values instead: a lower AIC for the quadratic model indicates better fit.
Exercise 4
Use the plotting code from the tutorial:
growth_long$fitted <- fitted(fit_lmer)
set.seed(1234)
sample_ids <- sample(unique(growth_long$id), 50)
ggplot(subset(growth_long, id %in% sample_ids),
aes(x = t, y = fitted, group = id)) +
geom_line(alpha = 0.3, colour = "grey60") +
stat_summary(aes(group = 1), fun = mean, geom = "line",
colour = "blue", linewidth = 1.2) +
labs(x = "Time point", y = "Well-being (fitted)") +
theme_minimal()The individual lines should fan out from the mean line, showing individual differences in both starting level and growth rate.
Summary & next tutorial
You can now fit growth curve models in both lavaan (with growth() and the latent intercept/slope factor specification) and lme4 (with lmer() and random intercepts and slopes). The two approaches are mathematically equivalent for simple linear growth; choose based on whether you need fit indices (lavaan) or simpler syntax (lme4). In the final tutorial we cover moderation — testing whether the effect of one variable on another depends on a third variable.