The Linear Growth Curve Model
Ivan Jacob Agaloos Pesigan
2026-11-01
Source:vignettes/lin-growth.Rmd
lin-growth.RmdOverview
The linear growth curve model describes individuals who each follow their own straight-line trajectory over time, for example, reading scores that start at different levels and grow at different rates. Each individual has a latent intercept (the level at the first occasion) and a latent slope (the change per occasion). The model estimates the average intercept and slope, how much individuals differ in them, and how much the measurements scatter around each trajectory.
The growth curve model is usually fitted as a structural equation model (SEM). It can also be written as a state space model: the intercept and slope are latent states that are set at the first occasion and then follow a fixed rule.
This tutorial does three things:
- It writes the linear growth curve model as a state space model.
- It simulates data from the model with
SimSSMLinGrowth(). - It fits the model to the simulated data with
dynr(state space) and withlavaan(SEM), and compares both sets of estimates with the values used to simulate the data.
No prior knowledge of state space models is needed.
Model
The measurement model is given by \[\begin{equation} Y_{i, t} = \left( \begin{array}{cc} 1 & 0 \\ \end{array} \right) \left( \begin{array}{c} \eta_{0_{i, t}} \\ \eta_{1_{i, t}} \\ \end{array} \right) + \boldsymbol{\varepsilon}_{i, t}, \quad \mathrm{with} \quad \boldsymbol{\varepsilon}_{i, t} \sim \mathcal{N} \left( 0, \theta \right) \end{equation}\] where \(Y_{i, t}\), \(\eta_{0_{i, t}}\), \(\eta_{1_{i, t}}\), and \(\boldsymbol{\varepsilon}_{i, t}\) are random variables and \(\theta\) is a model parameter. \(Y_{i, t}\) is the observed random variable at time \(t\) and individual \(i\), \(\eta_{0_{i, t}}\) (intercept) and \(\eta_{1_{i, t}}\) (slope) form a vector of latent random variables at time \(t\) and individual \(i\), and \(\boldsymbol{\varepsilon}_{i, t}\) a vector of random measurement errors at time \(t\) and individual \(i\). \(\theta\) is the variance of \(\boldsymbol{\varepsilon}\).
In words, the observed score is the current level of the trajectory, \(\eta_{0_{i, t}}\), plus measurement error. The loading of the slope is 0, because the slope affects the score only through its effect on the level over time.
The dynamic structure is given by \[\begin{equation} \left( \begin{array}{c} \eta_{0_{i, t}} \\ \eta_{1_{i, t}} \\ \end{array} \right) = \left( \begin{array}{cc} 1 & 1 \\ 0 & 1 \\ \end{array} \right) \left( \begin{array}{c} \eta_{0_{i, t - 1}} \\ \eta_{1_{i, t - 1}} \\ \end{array} \right) . \end{equation}\]
In words, the level at each occasion is the previous level plus the slope, \(\eta_{0_{i, t}} = \eta_{0_{i, t - 1}} + \eta_{1_{i, t - 1}}\), and the slope stays the same, \(\eta_{1_{i, t}} = \eta_{1_{i, t - 1}}\). There is no process noise, so once an individual’s intercept and slope are set, the trajectory is a straight line. All the variation in the trajectories between individuals comes from the initial condition below.
The mean vector and covariance matrix of the intercept and slope are captured in the mean vector and covariance matrix of the initial condition given by \[\begin{equation} \boldsymbol{\mu}_{\boldsymbol{\eta} \mid 0} = \left( \begin{array}{c} \mu_{\eta_{0}} \\ \mu_{\eta_{1}} \\ \end{array} \right) \quad \mathrm{and,} \end{equation}\]
\[\begin{equation} \boldsymbol{\Sigma}_{\boldsymbol{\eta} \mid 0} = \left( \begin{array}{cc} \sigma^{2}_{\eta_{0}} & \sigma_{\eta_{0}, \eta_{1}} \\ \sigma_{\eta_{1}, \eta_{0}} & \sigma^{2}_{\eta_{1}} \\ \end{array} \right) . \end{equation}\]
Here, \(\mu_{\eta_{0}}\) and \(\mu_{\eta_{1}}\) are the average intercept and slope, \(\sigma^{2}_{\eta_{0}}\) and \(\sigma^{2}_{\eta_{1}}\) are how much individuals differ in them, and \(\sigma_{\eta_{0}, \eta_{1}}\) is their covariance, for example, positive if individuals who start higher also grow faster.
Diagrams

The model as a path diagram. The intercept \(\eta_{0}\) loads on every occasion with a loading of 1, and the slope \(\eta_{1}\) with loadings \(0, 1, 2, \ldots, T - 1\), so the slope is the change per occasion. \(\varepsilon_{t}\) is the measurement error, with variance \(\theta\), and \(\sigma_{\eta_{0}, \eta_{1}}\) is the covariance of the intercept and the slope.

The same model in state space form. The state holds the current level
\(\eta_{0}\) and the slope \(\eta_{1}\). \(\boldsymbol{\beta}\) adds the slope to the
level at each occasion and keeps the slope, \(\boldsymbol{\Lambda}\) picks the level, and
\(\theta\) is the measurement error
variance. There is no process noise: the loadings of the path diagram
are produced by the transition, and the differences between individuals
come from the initial condition. SimSSMLinGrowth() fixes
\(\boldsymbol{\beta}\) and \(\boldsymbol{\Lambda}\) and takes
mu0 and the Cholesky factors of the initial covariance
matrix and of \(\theta\)
(sigma0_l and theta_l).
Data Generation
Notation
Let \(t = 5\) be the number of time points and \(n = 10000\) be the number of individuals.
Let the measurement error variance \(\theta\) be given by
\[\begin{equation} \theta = 0.6 . \end{equation}\]
Let the initial condition \(\boldsymbol{\eta}_{0}\) be given by
\[\begin{equation} \boldsymbol{\eta}_{0} \sim \mathcal{N} \left( \boldsymbol{\mu}_{\boldsymbol{\eta} \mid 0}, \boldsymbol{\Sigma}_{\boldsymbol{\eta} \mid 0} \right) \end{equation}\]
\[\begin{equation} \boldsymbol{\mu}_{\boldsymbol{\eta} \mid 0} = \left( \begin{array}{c} 0.615 \\ 1.006 \\ \end{array} \right) \end{equation}\]
\[\begin{equation} \boldsymbol{\Sigma}_{\boldsymbol{\eta} \mid 0} = \left( \begin{array}{cc} 1.932 & 0.618 \\ 0.618 & 0.587 \\ \end{array} \right) . \end{equation}\]
R Function Arguments
The simulation functions take covariance matrices as lower triangular
Cholesky factors (the arguments ending in _l).
n
#> [1] 10000
time
#> [1] 5
mu0
#> [1] 0.615 1.006
sigma0
#> [,1] [,2]
#> [1,] 1.932 0.618
#> [2,] 0.618 0.587
sigma0_l # sigma0_l <- t(chol(sigma0))
#> [,1] [,2]
#> [1,] 1.3899640 0.0000000
#> [2,] 0.4446158 0.6239525
theta
#> [1] 0.6
theta_l # theta_l <- t(chol(theta))
#> [1] 0.7745967Visualizing the Dynamics Without Measurement Error (n = 5 with Different Initial Condition)
Without measurement error, each of the five simulated individuals follows an exact straight line, with its own intercept and slope.

Using the SimSSMLinGrowth Function from the
simStateSpace Package to Simulate Data
Now we add the measurement error and simulate the full data set.
as.data.frame() returns the data in long format, one row
per individual and time point, with the columns id,
time, and y1.
library(simStateSpace)
sim <- SimSSMLinGrowth(
n = n,
time = time,
mu0 = mu0,
sigma0_l = sigma0_l,
theta_l = theta_l,
type = 0
)
data <- as.data.frame(sim)
head(data)
#> id time y1
#> 1 1 0 1.723564
#> 2 1 1 2.148702
#> 3 1 2 3.384974
#> 4 1 3 6.225426
#> 5 1 4 5.993965
#> 6 2 0 1.951861
summary(data)
#> id time y1
#> Min. : 1 Min. :0 Min. :-10.7863
#> 1st Qu.: 2501 1st Qu.:1 1st Qu.: 0.3943
#> Median : 5000 Median :2 Median : 2.1839
#> Mean : 5000 Mean :2 Mean : 2.5978
#> 3rd Qu.: 7500 3rd Qu.:3 3rd Qu.: 4.4536
#> Max. :10000 Max. :4 Max. : 21.0250
plot(sim)
Model Fitting
We first fit the model with dynr, which estimates state
space models by maximum likelihood using the Kalman filter.
A dynr model is built from pieces that match the
equations above: the data, the initial condition, the measurement model,
the dynamic model, and the noise. Each parameter gets a name (for
example, sigma0_01 for \(\sigma_{\eta_{0}, \eta_{1}}\)`), and the
same name used in two places means one shared parameter. The starting
values are set to the population values for convenience; with real data,
they would be rough guesses. The intercept and slope are indexed 0 and 1
in the parameter names.
Prepare Data
Tell dynr which columns hold the individual, the time,
and the observed variables.
dynr_data <- dynr::dynr.data(
dataframe = data,
id = "id",
time = "time",
observed = "y1"
)Prepare Initial Condition
The mean and covariance of the initial state are the means,
variances, and covariance of the intercept and slope, so these are the
main parameters of the growth model. The covariance matrix is symmetric,
so sigma0_01 appears both above and below the diagonal.
dynr_initial <- dynr::prep.initial(
values.inistate = mu0,
params.inistate = c("mu0_0", "mu0_1"),
values.inicov = sigma0,
params.inicov = matrix(
data = c(
"sigma0_00", "sigma0_01",
"sigma0_01", "sigma0_11"
),
nrow = 2
)
)Prepare Measurement Model
The loadings \(\left( 1, 0 \right)\) are fixed.
dynr_measurement <- dynr::prep.measurement(
values.load = matrix(data = c(1, 0), nrow = 1),
params.load = matrix(data = "fixed", nrow = 1, ncol = 2),
state.names = c("eta_0", "eta_1"),
obs.names = "y1"
)Prepare Dynamic Process
The two formulas are the two rows of the dynamic equation. They have no free parameters.
dynr_dynamics <- dynr::prep.formulaDynamics(
formula = list(
eta_0 ~ eta_0 + eta_1,
eta_1 ~ eta_1
),
isContinuousTime = FALSE
)
#> Warning in dynr::prep.formulaDynamics(formula = list(eta_0 ~ eta_0 + eta_1, :
#> You provided no start values: length(startval)==0. If you have no free
#> parameters, keep calm and carry on.Prepare Process Noise
The process noise is fixed at zero, and the measurement error variance \(\theta\) is estimated.
dynr_noise <- dynr::prep.noise(
values.latent = matrix(data = 0, nrow = 2, ncol = 2),
params.latent = matrix(data = "fixed", nrow = 2, ncol = 2),
values.observed = theta,
params.observed = "theta"
)Prepare the Model
The pieces are put together into one model. dynr writes
the model as C code to outfile and compiles it.
model <- dynr::dynr.model(
data = dynr_data,
initial = dynr_initial,
measurement = dynr_measurement,
dynamics = dynr_dynamics,
noise = dynr_noise,
outfile = "lin-growth.c"
)The plot below shows the model equations with the parameter names.

Fit the Model
results <- dynr::dynr.cook(
model,
debug_flag = TRUE,
verbose = FALSE
)
#> [1] "Get ready!!!!"
#> using C compiler: ‘gcc (Ubuntu 15.2.0-16ubuntu1) 15.2.0’
#> Optimization function called.
#> Starting Hessian calculation ...
#> Finished Hessian calculation.
#> Original exit flag: 3
#> Modified exit flag: 3
#> Optimization terminated successfully: ftol_rel or ftol_abs was reached.
#> Original fitted parameters: -0.5089635 0.60732 0.9952181 0.6477021 0.3152438
#> -0.9463643
#>
#> Transformed fitted parameters: 0.6011183 0.60732 0.9952181 1.911144 0.6024764
#> 0.5780766
#>
#> Doing end processing
#> Successful trial
#> Total Time: 23.52865
#> Backend Time: 23.1697Summary
The summary lists each estimate with its standard error, \(t\) value, confidence interval, and \(p\) value.
summary(results)
#> Coefficients:
#> Estimate Std. Error t value ci.lower ci.upper Pr(>|t|)
#> theta 0.601118 0.004909 122.46 0.591498 0.610739 <0.0000000000000002 ***
#> mu0_0 0.607320 0.015086 40.26 0.577753 0.636887 <0.0000000000000002 ***
#> mu0_1 0.995218 0.007990 124.56 0.979558 1.010878 <0.0000000000000002 ***
#> sigma0_00 1.911144 0.032311 59.15 1.847815 1.974473 <0.0000000000000002 ***
#> sigma0_01 0.602476 0.013013 46.30 0.576971 0.627982 <0.0000000000000002 ***
#> sigma0_11 0.578077 0.009043 63.92 0.560353 0.595801 <0.0000000000000002 ***
#> ---
#> Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#>
#> -2 log-likelihood value at convergence = 167712.56
#> AIC = 167724.56
#> BIC = 167777.48Linear Growth Curve Model in Structural Equation Modeling
The same model in SEM form, fitted with lavaan, should
give nearly the same estimates. In SEM, the five occasions are five
observed variables in one row per individual (wide format). The
intercept factor has loadings of 1 on all occasions, and the slope
factor has loadings 0, 1, 2, 3, and 4.
Wide Data Set
as.data.frame() with long = FALSE returns
the data in wide format.
data <- as.data.frame(sim, long = FALSE)
head(data)
#> id y1_0 y1_1 y1_2 y1_3 y1_4
#> 1 1 1.72356430 2.1487019 3.38497364 6.22542609 5.9939649
#> 2 2 1.95186051 2.6140623 5.60107919 5.59791920 7.6881040
#> 3 3 0.07909854 -1.1410633 -0.02940765 0.07103948 -0.4998955
#> 4 4 -0.14827866 -0.6478676 -0.33440620 -0.29683127 -0.6950588
#> 5 5 1.14625267 2.3844799 0.39101270 -0.76159166 1.6750324
#> 6 6 1.02604168 2.1852479 2.55168739 2.13223414 4.1499727Model
The parameter labels match the dynr names. Giving the five error
variances the same label, theta, constrains them to be
equal, as in the state space model.
model <- "
# factor loadings
eta0 =~ 1 * y1_0 + 1 * y1_1 + 1 * y1_2 + 1 * y1_3 + 1 * y1_4
eta1 =~ 0 * y1_0 + 1 * y1_1 + 2 * y1_2 + 3 * y1_3 + 4 * y1_4
# means of latent variables
eta0 ~ mu0_0 * 1
eta1 ~ mu0_1 * 1
# variances and covariances of latent variables
eta0 ~~ sigma0_00 * eta0
eta0 ~~ sigma0_01 * eta1
eta1 ~~ sigma0_11 * eta1
# constrain error variance theta to be equal
y1_0 ~~ theta * y1_0
y1_1 ~~ theta * y1_1
y1_2 ~~ theta * y1_2
y1_3 ~~ theta * y1_3
y1_4 ~~ theta * y1_4
"
fit <- lavaan::growth(
model = model,
data = data
)
lavaan::summary(fit)
#> lavaan 0.7-3 ended normally after 34 iterations
#>
#> Estimator ML
#> Optimization method NLMINB
#> Number of model parameters 10
#> Number of equality constraints 4
#>
#> Number of observations 10000
#>
#> Model Test User Model:
#>
#> Test statistic 5.386
#> Degrees of freedom 14
#> P-value (Chi-square) 0.980
#>
#> Browne's residual (NT model-based) test
#> Test statistic 5.365
#> Degrees of freedom 14
#> P-value (Chi-square) 0.980
#>
#> Parameter Estimates:
#>
#> Standard errors Standard
#> Information Expected
#> Information saturated (h1) model Structured
#>
#> Latent Variables:
#> Estimate Std.Err z-value P(>|z|)
#> eta0 =~
#> y1_0 1.000
#> y1_1 1.000
#> y1_2 1.000
#> y1_3 1.000
#> y1_4 1.000
#> eta1 =~
#> y1_0 0.000
#> y1_1 1.000
#> y1_2 2.000
#> y1_3 3.000
#> y1_4 4.000
#>
#> Covariances:
#> Estimate Std.Err z-value P(>|z|)
#> eta0 ~~
#> eta1 (s0_0) 0.602 0.013 46.317 0.000
#>
#> Intercepts:
#> Estimate Std.Err z-value P(>|z|)
#> eta0 (m0_0) 0.607 0.015 40.295 0.000
#> eta1 (m0_1) 0.995 0.008 124.582 0.000
#>
#> Variances:
#> Estimate Std.Err z-value P(>|z|)
#> eta0 (s0_0) 1.911 0.032 59.235 0.000
#> eta1 (s0_1) 0.578 0.009 63.955 0.000
#> .y1_0 (thet) 0.601 0.005 122.474 0.000
#> .y1_1 (thet) 0.601 0.005 122.474 0.000
#> .y1_2 (thet) 0.601 0.005 122.474 0.000
#> .y1_3 (thet) 0.601 0.005 122.474 0.000
#> .y1_4 (thet) 0.601 0.005 122.474 0.000Table of Parameters and Estimates
The table below puts each population value used to simulate the data
next to its estimate. Bias is the estimate minus the population value.
Because the model is fitted to a single simulated data set, this is the
error of one estimate, not a Monte Carlo bias, which would average over
many replications. The 95% confidence interval is the Wald interval,
\(\hat{\theta} \pm 1.96 \times
\mathrm{SE}\), and Covers shows whether it contains
the population value.
EstimatesTable <- function(population, estimate, se) {
z <- stats::qnorm(p = 0.975)
ll <- estimate - z * se
ul <- estimate + z * se
data.frame(
Parameter = names(population),
Population = unname(population),
Estimate = unname(estimate),
SE = unname(se),
Bias = unname(estimate - population),
`2.5%` = unname(ll),
`97.5%` = unname(ul),
Covers = unname(population >= ll & population <= ul),
check.names = FALSE
)
}
population <- c(
mu0_0 = mu0[1],
mu0_1 = mu0[2],
sigma0_00 = sigma0[1, 1],
sigma0_01 = sigma0[1, 2],
sigma0_11 = sigma0[2, 2],
theta = theta
)dynr
estimates <- coef(results)[names(population)]
se <- sqrt(diag(vcov(results)))
names(se) <- names(coef(results))
knitr::kable(
x = EstimatesTable(
population = population,
estimate = estimates,
se = se[names(population)]
),
digits = 3,
caption = "Population values and dynr estimates (one simulated data set)"
)| Parameter | Population | Estimate | SE | Bias | 2.5% | 97.5% | Covers |
|---|---|---|---|---|---|---|---|
| mu0_0 | 0.615 | 0.607 | 0.015 | -0.008 | 0.578 | 0.637 | TRUE |
| mu0_1 | 1.006 | 0.995 | 0.008 | -0.011 | 0.980 | 1.011 | TRUE |
| sigma0_00 | 1.932 | 1.911 | 0.032 | -0.021 | 1.848 | 1.974 | TRUE |
| sigma0_01 | 0.618 | 0.602 | 0.013 | -0.016 | 0.577 | 0.628 | TRUE |
| sigma0_11 | 0.587 | 0.578 | 0.009 | -0.009 | 0.560 | 0.596 | TRUE |
| theta | 0.600 | 0.601 | 0.005 | 0.001 | 0.591 | 0.611 | TRUE |
lavaan
pe <- lavaan::parameterEstimates(fit)
# theta is one parameter shared by the five error variances
pe <- pe[pe$label != "" & !duplicated(pe$label), ]
rownames(pe) <- pe$label
knitr::kable(
x = EstimatesTable(
population = population,
estimate = pe[names(population), "est"],
se = pe[names(population), "se"]
),
digits = 3,
caption = "Population values and lavaan estimates (one simulated data set)"
)| Parameter | Population | Estimate | SE | Bias | 2.5% | 97.5% | Covers |
|---|---|---|---|---|---|---|---|
| mu0_0 | 0.615 | 0.607 | 0.015 | -0.008 | 0.578 | 0.637 | TRUE |
| mu0_1 | 1.006 | 0.995 | 0.008 | -0.011 | 0.980 | 1.011 | TRUE |
| sigma0_00 | 1.932 | 1.911 | 0.032 | -0.021 | 1.848 | 1.974 | TRUE |
| sigma0_01 | 0.618 | 0.602 | 0.013 | -0.016 | 0.577 | 0.628 | TRUE |
| sigma0_11 | 0.587 | 0.578 | 0.009 | -0.009 | 0.560 | 0.596 | TRUE |
| theta | 0.600 | 0.601 | 0.005 | 0.001 | 0.592 | 0.611 | TRUE |