Skip to contents

Overview

Latent change score models describe change itself (McArdle, 2009). Take vocabulary and reading comprehension measured once a year in children. From one occasion to the next, each child’s vocabulary changes. In the dual change score model, the change has two sources: a constant amount each child gains every year (the slope), and an amount that depends on where the child stood the year before (the self-feedback). In the bivariate model, the change in one variable can also depend on the previous level of the other variable (the coupling), for example, if children with a larger vocabulary gain more in reading comprehension.

The dual change score model is usually fitted as a structural equation model (SEM). It can also be written as a state space model: the true levels of the two variables are latent states, and the slopes are latent states that never change.

This tutorial does three things:

  1. It writes the bivariate dual change score model as a state space model.
  2. It simulates data from the model with SimSSMFixed().
  3. It fits the model to the simulated data with dynr (state space) and with lavaan (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} \left( \begin{array}{c} Y_{1_{i, t}} \\ Y_{2_{i, t}} \\ \end{array} \right) = \left( \begin{array}{cccc} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ \end{array} \right) \left( \begin{array}{c} \eta_{1_{i, t}} \\ \eta_{2_{i, t}} \\ \eta_{3_{i, t}} \\ \eta_{4_{i, t}} \\ \end{array} \right) + \left( \begin{array}{c} \varepsilon_{1_{i, t}} \\ \varepsilon_{2_{i, t}} \\ \end{array} \right), \quad \mathrm{with} \quad \left( \begin{array}{c} \varepsilon_{1_{i, t}} \\ \varepsilon_{2_{i, t}} \\ \end{array} \right) \sim \mathcal{N} \left( \mathbf{0}, \left( \begin{array}{cc} \theta_{11} & 0 \\ 0 & \theta_{22} \\ \end{array} \right) \right) \end{equation}\] where \(Y_{1_{i, t}}\) and \(Y_{2_{i, t}}\) are the two observed variables at time \(t\) and individual \(i\), \(\eta_{1_{i, t}}\) and \(\eta_{2_{i, t}}\) are their true levels, \(\eta_{3_{i, t}}\) and \(\eta_{4_{i, t}}\) are their slopes, and \(\theta_{11}\) and \(\theta_{22}\) are the measurement error variances.

In words, each observed score is the true level plus measurement error. The slopes have loadings of 0: they affect the scores only through the change in the true levels.

The change in the true level of the first variable from occasion \(t - 1\) to occasion \(t\) is \[\begin{equation} \Delta \eta_{1_{i, t}} = \eta_{3_{i, t - 1}} + b_{1} \eta_{1_{i, t - 1}} + g_{12} \eta_{2_{i, t - 1}} , \end{equation}\] and similarly for the second variable, \(\Delta \eta_{2_{i, t}} = \eta_{4_{i, t - 1}} + b_{2} \eta_{2_{i, t - 1}} + g_{21} \eta_{1_{i, t - 1}}\). The true level at occasion \(t\) is the previous true level plus the change, \(\eta_{1_{i, t}} = \eta_{1_{i, t - 1}} + \Delta \eta_{1_{i, t}}\).

Writing the change into the level gives the dynamic structure \[\begin{equation} \left( \begin{array}{c} \eta_{1_{i, t}} \\ \eta_{2_{i, t}} \\ \eta_{3_{i, t}} \\ \eta_{4_{i, t}} \\ \end{array} \right) = \left( \begin{array}{cccc} 1 + b_{1} & g_{12} & 1 & 0 \\ g_{21} & 1 + b_{2} & 0 & 1 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \\ \end{array} \right) \left( \begin{array}{c} \eta_{1_{i, t - 1}} \\ \eta_{2_{i, t - 1}} \\ \eta_{3_{i, t - 1}} \\ \eta_{4_{i, t - 1}} \\ \end{array} \right) . \end{equation}\]

In words, \(b_{1}\) and \(b_{2}\) are the self-feedback effects: a negative value means that individuals who stand higher gain less, so the levels level off over time. \(g_{12}\) is the coupling from the second variable to the first: a positive value means that a higher level of the second variable is followed by a larger gain in the first. \(g_{21}\) is the reverse. The slopes stay the same over time. As in most applications of the model, there is no process noise: once the initial levels and the slopes are set, the true trajectories are fixed.

The initial condition holds the means, variances, and covariances of the initial true levels and the slopes, \[\begin{equation} \boldsymbol{\eta}_{i, 0} \sim \mathcal{N} \left( \boldsymbol{\mu}_{\boldsymbol{\eta} \mid 0}, \boldsymbol{\Sigma}_{\boldsymbol{\eta} \mid 0} \right) . \end{equation}\]

For example, a positive covariance between the initial level and the slope of the same variable means that individuals who start higher also tend to gain more each year.

Diagrams

Path diagram of the dual change score model.
Path diagram of the dual change score model.

The bivariate dual change score model as a path diagram, for the first three occasions. The true level at each occasion, \(\eta_{1}\) or \(\eta_{2}\), is the previous level plus the latent change \(\Delta \eta_{1}\) or \(\Delta \eta_{2}\) (the arrows with a loading of 1). Each change is the slope (\(\eta_{3}\) or \(\eta_{4}\)), plus the self-feedback effect (\(b_{1}\) or \(b_{2}\)) of the previous level of the same variable, plus the coupling effect (\(g_{12}\) or \(g_{21}\)) of the previous level of the other variable. \(\varepsilon_{1}\) and \(\varepsilon_{2}\) are the measurement errors. The initial levels and the slopes all covary; to keep the diagram readable, only \(\sigma_{12}\), \(\sigma_{13}\), \(\sigma_{24}\), and \(\sigma_{34}\) are drawn.

State space form of the dual change score model.
State space form of the dual change score model.

The same model in state space form. All the latent variables at one occasion form the state vector \(\boldsymbol{\eta}_{t}\), and all the observed variables form \(\mathbf{y}_{t}\). \(\boldsymbol{\beta}\) carries the state from one occasion to the next, \(\boldsymbol{\Lambda}\) maps the state to the observed variables, and \(\boldsymbol{\Theta}\) is the covariance matrix of the measurement error \(\boldsymbol{\varepsilon}_{t}\). The latent changes are not states: writing each change into the level gives the first two rows of \(\boldsymbol{\beta}\). There is no process noise. SimSSMFixed() takes beta, lambda, and the Cholesky factor of \(\boldsymbol{\Theta}\) (theta_l); psi_l is a matrix of zeros.

Data Generation

Notation

Let \(t = 6\) be the number of time points and \(n = 10000\) be the number of individuals.

Let the self-feedback effects be \(b_{1} = -0.2\) and \(b_{2} = -0.3\), and the coupling effects be \(g_{12} = 0.1\) and \(g_{21} = 0.2\), so that

\[\begin{equation} \boldsymbol{\beta} = \left( \begin{array}{cccc} 0.8 & 0.1 & 1 & 0 \\ 0.2 & 0.7 & 0 & 1 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \\ \end{array} \right) . \end{equation}\]

Let the initial condition be

\[\begin{equation} \boldsymbol{\mu}_{\boldsymbol{\eta} \mid 0} = \left( \begin{array}{c} 2 \\ 1 \\ 0.5 \\ 0.4 \\ \end{array} \right) \quad \mathrm{and,} \quad \boldsymbol{\Sigma}_{\boldsymbol{\eta} \mid 0} = \left( \begin{array}{cccc} 1 & 0.3 & 0.15 & 0.05 \\ 0.3 & 1 & 0.05 & 0.15 \\ 0.15 & 0.05 & 0.25 & 0.05 \\ 0.05 & 0.15 & 0.05 & 0.25 \\ \end{array} \right) , \end{equation}\]

and the measurement error variances be \(\theta_{11} = 0.2\) and \(\theta_{22} = 0.2\).

R Function Arguments

The simulation functions take covariance matrices as lower triangular Cholesky factors (the arguments ending in _l). There is no process noise, so psi and psi_l are zero matrices.

n
#> [1] 10000
time
#> [1] 6
mu0
#> [1] 2.0 1.0 0.5 0.4
sigma0
#>      [,1] [,2] [,3] [,4]
#> [1,] 1.00 0.30 0.15 0.05
#> [2,] 0.30 1.00 0.05 0.15
#> [3,] 0.15 0.05 0.25 0.05
#> [4,] 0.05 0.15 0.05 0.25
sigma0_l # sigma0_l <- t(chol(sigma0))
#>      [,1]        [,2]       [,3]      [,4]
#> [1,] 1.00 0.000000000 0.00000000 0.0000000
#> [2,] 0.30 0.953939201 0.00000000 0.0000000
#> [3,] 0.15 0.005241424 0.47694080 0.0000000
#> [4,] 0.05 0.141518453 0.08755435 0.4688355
alpha
#> [1] 0 0 0 0
beta
#>      [,1] [,2] [,3] [,4]
#> [1,]  0.8  0.1    1    0
#> [2,]  0.2  0.7    0    1
#> [3,]  0.0  0.0    1    0
#> [4,]  0.0  0.0    0    1
psi
#>      [,1] [,2] [,3] [,4]
#> [1,]    0    0    0    0
#> [2,]    0    0    0    0
#> [3,]    0    0    0    0
#> [4,]    0    0    0    0
psi_l
#>      [,1] [,2] [,3] [,4]
#> [1,]    0    0    0    0
#> [2,]    0    0    0    0
#> [3,]    0    0    0    0
#> [4,]    0    0    0    0
nu
#> [1] 0 0
lambda
#>      [,1] [,2] [,3] [,4]
#> [1,]    1    0    0    0
#> [2,]    0    1    0    0
theta
#>      [,1] [,2]
#> [1,]  0.2  0.0
#> [2,]  0.0  0.2
theta_l # theta_l <- t(chol(theta))
#>           [,1]      [,2]
#> [1,] 0.4472136 0.0000000
#> [2,] 0.0000000 0.4472136

Visualizing the Dynamics Without Measurement Error (n = 5 with Different Initial Condition)

Without measurement error, each individual’s true levels follow smooth curves that bend as the self-feedback slows the gains.

Using the SimSSMFixed 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, y1, and y2.

library(simStateSpace)
sim <- SimSSMFixed(
  n = n,
  time = time,
  mu0 = mu0,
  sigma0_l = sigma0_l,
  alpha = alpha,
  beta = beta,
  psi_l = psi_l,
  nu = nu,
  lambda = lambda,
  theta_l = theta_l,
  type = 0
)
data <- as.data.frame(sim)
head(data)
#>   id time       y1           y2
#> 1  1    0 1.239124 -0.034741096
#> 2  1    1 2.372287 -0.050275519
#> 3  1    2 2.550748 -0.363167146
#> 4  1    3 3.417814 -0.164406246
#> 5  1    4 3.685087  0.007406512
#> 6  1    5 3.473986 -0.348501993
summary(data)
#>        id             time           y1               y2         
#>  Min.   :    1   Min.   :0.0   Min.   :-4.626   Min.   :-5.4249  
#>  1st Qu.: 2501   1st Qu.:1.0   1st Qu.: 1.474   1st Qu.: 0.8794  
#>  Median : 5000   Median :2.5   Median : 2.462   Median : 1.8642  
#>  Mean   : 5000   Mean   :2.5   Mean   : 2.543   Mean   : 1.9673  
#>  3rd Qu.: 7500   3rd Qu.:4.0   3rd Qu.: 3.524   3rd Qu.: 2.9522  
#>  Max.   :10000   Max.   :5.0   Max.   :10.947   Max.   :10.9199
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, g_1_2 for \(g_{12}\)), 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.

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 = c("y1", "y2")
)

Prepare Initial Condition

The means, variances, and covariances of the initial true levels and the slopes are all estimated.

dynr_initial <- dynr::prep.initial(
  values.inistate = mu0,
  params.inistate = c("mu0_1_1", "mu0_2_1", "mu0_3_1", "mu0_4_1"),
  values.inicov = sigma0,
  params.inicov = matrix(
    data = c(
      "sigma0_1_1", "sigma0_2_1", "sigma0_3_1", "sigma0_4_1",
      "sigma0_2_1", "sigma0_2_2", "sigma0_3_2", "sigma0_4_2",
      "sigma0_3_1", "sigma0_3_2", "sigma0_3_3", "sigma0_4_3",
      "sigma0_4_1", "sigma0_4_2", "sigma0_4_3", "sigma0_4_4"
    ),
    nrow = 4
  )
)

Prepare Measurement Model

The loadings are fixed.

dynr_measurement <- dynr::prep.measurement(
  values.load = lambda,
  params.load = matrix(data = "fixed", nrow = 2, ncol = 4),
  state.names = c("eta_1", "eta_2", "eta_3", "eta_4"),
  obs.names = c("y1", "y2")
)

Prepare Dynamic Process

The first two formulas read as “the new true level is the previous level plus the change”, with the change made of the slope, the self-feedback, and the coupling. The slopes carry over unchanged.

dynr_dynamics <- dynr::prep.formulaDynamics(
  formula = list(
    eta_1 ~ eta_1 + eta_3 + b_1 * eta_1 + g_1_2 * eta_2,
    eta_2 ~ eta_2 + eta_4 + b_2 * eta_2 + g_2_1 * eta_1,
    eta_3 ~ eta_3,
    eta_4 ~ eta_4
  ),
  startval = c(
    b_1 = b_1, b_2 = b_2,
    g_1_2 = g_1_2, g_2_1 = g_2_1
  ),
  isContinuousTime = FALSE
)

Prepare Process Noise

The process noise is fixed at zero, and the two measurement error variances are estimated.

dynr_noise <- dynr::prep.noise(
  values.latent = psi,
  params.latent = matrix(data = "fixed", nrow = 4, ncol = 4),
  values.observed = theta,
  params.observed = matrix(
    data = c(
      "theta_1_1", "fixed",
      "fixed", "theta_2_2"
    ),
    nrow = 2
  )
)

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 = "lcs.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.202338 -0.2992614 0.1033187 0.1967423 -1.61751 
#> -1.613147 2.022656 1.001674 0.511175 0.4046414 -0.004442931 0.2905909 0.1520702 
#> 0.05344748 -0.07850012 -0.0001924172 0.139024 -1.458823 0.1612331 -1.510621 
#> 
#> Transformed fitted parameters:  -0.202338 -0.2992614 0.1033187 0.1967423 
#> 0.1983921 0.1992595 2.022656 1.001674 0.511175 0.4046414 0.9955669 0.2893027 
#> 0.151396 0.05321054 1.008571 0.04381642 0.1439905 0.2555325 0.04555527 
#> 0.2475296 
#> 
#> Doing end processing
#> Successful trial
#> Total Time: 5.37442 
#> Backend Time: 5.367714

Summary

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
#> b_1        -0.202338   0.004387 -46.126 -0.210936 -0.193740
#> b_2        -0.299261   0.003588 -83.397 -0.306294 -0.292228
#> g_1_2       0.103319   0.003646  28.339  0.096173  0.110464
#> g_2_1       0.196742   0.004332  45.420  0.188253  0.205232
#> theta_1_1   0.198392   0.001405 141.231  0.195639  0.201145
#> theta_2_2   0.199259   0.001418 140.495  0.196480  0.202039
#> mu0_1_1     2.022656   0.010712 188.829  2.001662  2.043650
#> mu0_2_1     1.001674   0.010812  92.642  0.980483  1.022866
#> mu0_3_1     0.511175   0.009376  54.519  0.492798  0.529552
#> mu0_4_1     0.404641   0.009274  43.631  0.386464  0.422819
#> sigma0_1_1  0.995567   0.015910  62.574  0.964383  1.026750
#> sigma0_2_1  0.289303   0.011681  24.766  0.266408  0.312198
#> sigma0_3_1  0.151396   0.006071  24.939  0.139498  0.163294
#> sigma0_4_1  0.053211   0.006363   8.362  0.040738  0.065683
#> sigma0_2_2  1.008571   0.016296  61.891  0.976631  1.040510
#> sigma0_3_2  0.043816   0.006092   7.193  0.031876  0.055756
#> sigma0_4_2  0.143990   0.005868  24.538  0.132489  0.155492
#> sigma0_3_3  0.255533   0.005438  46.990  0.244874  0.266191
#> sigma0_4_3  0.045555   0.003570  12.762  0.038559  0.052552
#> sigma0_4_4  0.247530   0.004588  53.948  0.238537  0.256522
#>                        Pr(>|t|)    
#> b_1        < 0.0000000000000002 ***
#> b_2        < 0.0000000000000002 ***
#> g_1_2      < 0.0000000000000002 ***
#> g_2_1      < 0.0000000000000002 ***
#> theta_1_1  < 0.0000000000000002 ***
#> theta_2_2  < 0.0000000000000002 ***
#> mu0_1_1    < 0.0000000000000002 ***
#> mu0_2_1    < 0.0000000000000002 ***
#> mu0_3_1    < 0.0000000000000002 ***
#> mu0_4_1    < 0.0000000000000002 ***
#> sigma0_1_1 < 0.0000000000000002 ***
#> sigma0_2_1 < 0.0000000000000002 ***
#> sigma0_3_1 < 0.0000000000000002 ***
#> sigma0_4_1 < 0.0000000000000002 ***
#> sigma0_2_2 < 0.0000000000000002 ***
#> sigma0_3_2    0.000000000000322 ***
#> sigma0_4_2 < 0.0000000000000002 ***
#> sigma0_3_3 < 0.0000000000000002 ***
#> sigma0_4_3 < 0.0000000000000002 ***
#> sigma0_4_4 < 0.0000000000000002 ***
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> -2 log-likelihood value at convergence = 259171.90
#> AIC = 259211.90
#> BIC = 259391.94

The Dual Change Score Model in Structural Equation Modeling

The same model in SEM form, fitted with lavaan, should give nearly the same estimates. In SEM, each variable at each occasion is a separate observed variable in one row per individual (wide format). Each observed score has a true-level factor; each true level after the first is the previous true level plus a change factor; and each change factor is the slope plus the self-feedback and coupling effects.

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       y2_0     y1_1        y2_1     y1_2        y2_2     y1_3
#> 1  1 1.239124 -0.0347411 2.372287 -0.05027552 2.550748 -0.36316715 3.417814
#> 2  2 1.414760 -1.7045943 1.880544 -0.62495355 2.246019 -0.23016644 1.492737
#> 3  3 1.076995  1.6913778 2.687536  2.38251121 3.147829  3.29079426 3.582296
#> 4  4 3.371977  0.2882310 2.850104  1.22590392 2.501115  1.20157000 2.255159
#> 5  5 2.953999  0.3501444 1.791110 -0.73608084 1.715968  0.05072464 1.850961
#> 6  6 1.076737 -1.5194131 0.735993  1.07098420 1.650132  1.57132134 2.070286
#>         y2_3     y1_4         y2_4     y1_5       y2_5
#> 1 -0.1644062 3.685087  0.007406512 3.473986 -0.3485020
#> 2 -0.7540751 1.117000  0.347719984 1.811812  0.3486327
#> 3  4.0998228 4.878402  4.993348339 4.269622  4.8999756
#> 4  1.7383942 2.659687  1.991782688 2.388665  2.2080090
#> 5 -0.8198842 1.300108 -1.526556346 1.172956 -0.7951067
#> 6  1.7259835 1.860267  1.958970444 1.452103  2.4188041

Model

The model has the same structure for every occasion, so we write it with paste0(). The parameter labels match the dynr names. Giving the self-feedback and coupling effects and the measurement error variances the same label on every occasion constrains them to be equal over time, as in the state space model.

waves <- 0:(time - 1)
now <- waves[-1]
before <- waves[-time]
model <- paste(
  c(
    "# true levels",
    paste0("l1_", waves, " =~ 1 * y1_", waves),
    paste0("l2_", waves, " =~ 1 * y2_", waves),
    "# each true level is the previous level plus the change",
    paste0("l1_", now, " ~ 1 * l1_", before),
    paste0("l2_", now, " ~ 1 * l2_", before),
    paste0("l1_", now, " ~~ 0 * l1_", now),
    paste0("l2_", now, " ~~ 0 * l2_", now),
    paste0("d1_", now, " =~ 1 * l1_", now),
    paste0("d2_", now, " =~ 1 * l2_", now),
    "# the change: self-feedback and coupling",
    paste0("d1_", now, " ~ b_1 * l1_", before, " + g_1_2 * l2_", before),
    paste0("d2_", now, " ~ b_2 * l2_", before, " + g_2_1 * l1_", before),
    paste0("d1_", now, " ~~ 0 * d1_", now),
    paste0("d2_", now, " ~~ 0 * d2_", now),
    "# the change: slopes",
    paste0("s1 =~ ", paste0("1 * d1_", now, collapse = " + ")),
    paste0("s2 =~ ", paste0("1 * d2_", now, collapse = " + ")),
    "# initial true levels and slopes: means",
    "l1_0 ~ mu0_1_1 * 1",
    "l2_0 ~ mu0_2_1 * 1",
    "s1 ~ mu0_3_1 * 1",
    "s2 ~ mu0_4_1 * 1",
    "# initial true levels and slopes: variances and covariances",
    "l1_0 ~~ sigma0_1_1 * l1_0",
    "l2_0 ~~ sigma0_2_2 * l2_0",
    "s1 ~~ sigma0_3_3 * s1",
    "s2 ~~ sigma0_4_4 * s2",
    "l1_0 ~~ sigma0_2_1 * l2_0",
    "l1_0 ~~ sigma0_3_1 * s1",
    "l1_0 ~~ sigma0_4_1 * s2",
    "l2_0 ~~ sigma0_3_2 * s1",
    "l2_0 ~~ sigma0_4_2 * s2",
    "s1 ~~ sigma0_4_3 * s2",
    "# measurement error variances, equal over time; no observed intercepts",
    paste0("y1_", waves, " ~~ theta_1_1 * y1_", waves),
    paste0("y2_", waves, " ~~ theta_2_2 * y2_", waves),
    paste0("y1_", waves, " ~ 0 * 1"),
    paste0("y2_", waves, " ~ 0 * 1")
  ),
  collapse = "\n"
)
cat(model)
#> # true levels
#> l1_0 =~ 1 * y1_0
#> l1_1 =~ 1 * y1_1
#> l1_2 =~ 1 * y1_2
#> l1_3 =~ 1 * y1_3
#> l1_4 =~ 1 * y1_4
#> l1_5 =~ 1 * y1_5
#> l2_0 =~ 1 * y2_0
#> l2_1 =~ 1 * y2_1
#> l2_2 =~ 1 * y2_2
#> l2_3 =~ 1 * y2_3
#> l2_4 =~ 1 * y2_4
#> l2_5 =~ 1 * y2_5
#> # each true level is the previous level plus the change
#> l1_1 ~ 1 * l1_0
#> l1_2 ~ 1 * l1_1
#> l1_3 ~ 1 * l1_2
#> l1_4 ~ 1 * l1_3
#> l1_5 ~ 1 * l1_4
#> l2_1 ~ 1 * l2_0
#> l2_2 ~ 1 * l2_1
#> l2_3 ~ 1 * l2_2
#> l2_4 ~ 1 * l2_3
#> l2_5 ~ 1 * l2_4
#> l1_1 ~~ 0 * l1_1
#> l1_2 ~~ 0 * l1_2
#> l1_3 ~~ 0 * l1_3
#> l1_4 ~~ 0 * l1_4
#> l1_5 ~~ 0 * l1_5
#> l2_1 ~~ 0 * l2_1
#> l2_2 ~~ 0 * l2_2
#> l2_3 ~~ 0 * l2_3
#> l2_4 ~~ 0 * l2_4
#> l2_5 ~~ 0 * l2_5
#> d1_1 =~ 1 * l1_1
#> d1_2 =~ 1 * l1_2
#> d1_3 =~ 1 * l1_3
#> d1_4 =~ 1 * l1_4
#> d1_5 =~ 1 * l1_5
#> d2_1 =~ 1 * l2_1
#> d2_2 =~ 1 * l2_2
#> d2_3 =~ 1 * l2_3
#> d2_4 =~ 1 * l2_4
#> d2_5 =~ 1 * l2_5
#> # the change: self-feedback and coupling
#> d1_1 ~ b_1 * l1_0 + g_1_2 * l2_0
#> d1_2 ~ b_1 * l1_1 + g_1_2 * l2_1
#> d1_3 ~ b_1 * l1_2 + g_1_2 * l2_2
#> d1_4 ~ b_1 * l1_3 + g_1_2 * l2_3
#> d1_5 ~ b_1 * l1_4 + g_1_2 * l2_4
#> d2_1 ~ b_2 * l2_0 + g_2_1 * l1_0
#> d2_2 ~ b_2 * l2_1 + g_2_1 * l1_1
#> d2_3 ~ b_2 * l2_2 + g_2_1 * l1_2
#> d2_4 ~ b_2 * l2_3 + g_2_1 * l1_3
#> d2_5 ~ b_2 * l2_4 + g_2_1 * l1_4
#> d1_1 ~~ 0 * d1_1
#> d1_2 ~~ 0 * d1_2
#> d1_3 ~~ 0 * d1_3
#> d1_4 ~~ 0 * d1_4
#> d1_5 ~~ 0 * d1_5
#> d2_1 ~~ 0 * d2_1
#> d2_2 ~~ 0 * d2_2
#> d2_3 ~~ 0 * d2_3
#> d2_4 ~~ 0 * d2_4
#> d2_5 ~~ 0 * d2_5
#> # the change: slopes
#> s1 =~ 1 * d1_1 + 1 * d1_2 + 1 * d1_3 + 1 * d1_4 + 1 * d1_5
#> s2 =~ 1 * d2_1 + 1 * d2_2 + 1 * d2_3 + 1 * d2_4 + 1 * d2_5
#> # initial true levels and slopes: means
#> l1_0 ~ mu0_1_1 * 1
#> l2_0 ~ mu0_2_1 * 1
#> s1 ~ mu0_3_1 * 1
#> s2 ~ mu0_4_1 * 1
#> # initial true levels and slopes: variances and covariances
#> l1_0 ~~ sigma0_1_1 * l1_0
#> l2_0 ~~ sigma0_2_2 * l2_0
#> s1 ~~ sigma0_3_3 * s1
#> s2 ~~ sigma0_4_4 * s2
#> l1_0 ~~ sigma0_2_1 * l2_0
#> l1_0 ~~ sigma0_3_1 * s1
#> l1_0 ~~ sigma0_4_1 * s2
#> l2_0 ~~ sigma0_3_2 * s1
#> l2_0 ~~ sigma0_4_2 * s2
#> s1 ~~ sigma0_4_3 * s2
#> # measurement error variances, equal over time; no observed intercepts
#> y1_0 ~~ theta_1_1 * y1_0
#> y1_1 ~~ theta_1_1 * y1_1
#> y1_2 ~~ theta_1_1 * y1_2
#> y1_3 ~~ theta_1_1 * y1_3
#> y1_4 ~~ theta_1_1 * y1_4
#> y1_5 ~~ theta_1_1 * y1_5
#> y2_0 ~~ theta_2_2 * y2_0
#> y2_1 ~~ theta_2_2 * y2_1
#> y2_2 ~~ theta_2_2 * y2_2
#> y2_3 ~~ theta_2_2 * y2_3
#> y2_4 ~~ theta_2_2 * y2_4
#> y2_5 ~~ theta_2_2 * y2_5
#> y1_0 ~ 0 * 1
#> y1_1 ~ 0 * 1
#> y1_2 ~ 0 * 1
#> y1_3 ~ 0 * 1
#> y1_4 ~ 0 * 1
#> y1_5 ~ 0 * 1
#> y2_0 ~ 0 * 1
#> y2_1 ~ 0 * 1
#> y2_2 ~ 0 * 1
#> y2_3 ~ 0 * 1
#> y2_4 ~ 0 * 1
#> y2_5 ~ 0 * 1

lavaan::lavaan() adds nothing to the model by itself, so every parameter above is exactly the one written.

fit <- lavaan::lavaan(
  model = model,
  data = data
)
lavaan::summary(fit)
#> lavaan 0.7-3 ended normally after 48 iterations
#> 
#>   Estimator                                         ML
#>   Optimization method                           NLMINB
#>   Number of model parameters                        46
#>   Number of equality constraints                    26
#> 
#>   Number of observations                         10000
#> 
#> Model Test User Model:
#>                                                       
#>   Test statistic                                72.843
#>   Degrees of freedom                                70
#>   P-value (Chi-square)                           0.385
#>                                                       
#>   Browne's residual (NT model-based) test             
#>   Test statistic                                72.381
#>   Degrees of freedom                                70
#>   P-value (Chi-square)                           0.399
#> 
#> Parameter Estimates:
#> 
#>   Standard errors                             Standard
#>   Information                                 Expected
#>   Information saturated (h1) model          Structured
#> 
#> Latent Variables:
#>                    Estimate  Std.Err  z-value  P(>|z|)
#>   l1_0 =~                                             
#>     y1_0              1.000                           
#>   l1_1 =~                                             
#>     y1_1              1.000                           
#>   l1_2 =~                                             
#>     y1_2              1.000                           
#>   l1_3 =~                                             
#>     y1_3              1.000                           
#>   l1_4 =~                                             
#>     y1_4              1.000                           
#>   l1_5 =~                                             
#>     y1_5              1.000                           
#>   l2_0 =~                                             
#>     y2_0              1.000                           
#>   l2_1 =~                                             
#>     y2_1              1.000                           
#>   l2_2 =~                                             
#>     y2_2              1.000                           
#>   l2_3 =~                                             
#>     y2_3              1.000                           
#>   l2_4 =~                                             
#>     y2_4              1.000                           
#>   l2_5 =~                                             
#>     y2_5              1.000                           
#>   d1_1 =~                                             
#>     l1_1              1.000                           
#>   d1_2 =~                                             
#>     l1_2              1.000                           
#>   d1_3 =~                                             
#>     l1_3              1.000                           
#>   d1_4 =~                                             
#>     l1_4              1.000                           
#>   d1_5 =~                                             
#>     l1_5              1.000                           
#>   d2_1 =~                                             
#>     l2_1              1.000                           
#>   d2_2 =~                                             
#>     l2_2              1.000                           
#>   d2_3 =~                                             
#>     l2_3              1.000                           
#>   d2_4 =~                                             
#>     l2_4              1.000                           
#>   d2_5 =~                                             
#>     l2_5              1.000                           
#>   s1 =~                                               
#>     d1_1              1.000                           
#>     d1_2              1.000                           
#>     d1_3              1.000                           
#>     d1_4              1.000                           
#>     d1_5              1.000                           
#>   s2 =~                                               
#>     d2_1              1.000                           
#>     d2_2              1.000                           
#>     d2_3              1.000                           
#>     d2_4              1.000                           
#>     d2_5              1.000                           
#> 
#> Regressions:
#>                    Estimate  Std.Err  z-value  P(>|z|)
#>   l1_1 ~                                              
#>     l1_0              1.000                           
#>   l1_2 ~                                              
#>     l1_1              1.000                           
#>   l1_3 ~                                              
#>     l1_2              1.000                           
#>   l1_4 ~                                              
#>     l1_3              1.000                           
#>   l1_5 ~                                              
#>     l1_4              1.000                           
#>   l2_1 ~                                              
#>     l2_0              1.000                           
#>   l2_2 ~                                              
#>     l2_1              1.000                           
#>   l2_3 ~                                              
#>     l2_2              1.000                           
#>   l2_4 ~                                              
#>     l2_3              1.000                           
#>   l2_5 ~                                              
#>     l2_4              1.000                           
#>   d1_1 ~                                              
#>     l1_0     (b_1)   -0.202    0.004  -46.128    0.000
#>     l2_0    (g_1_)    0.103    0.004   28.352    0.000
#>   d1_2 ~                                              
#>     l1_1     (b_1)   -0.202    0.004  -46.128    0.000
#>     l2_1    (g_1_)    0.103    0.004   28.352    0.000
#>   d1_3 ~                                              
#>     l1_2     (b_1)   -0.202    0.004  -46.128    0.000
#>     l2_2    (g_1_)    0.103    0.004   28.352    0.000
#>   d1_4 ~                                              
#>     l1_3     (b_1)   -0.202    0.004  -46.128    0.000
#>     l2_3    (g_1_)    0.103    0.004   28.352    0.000
#>   d1_5 ~                                              
#>     l1_4     (b_1)   -0.202    0.004  -46.128    0.000
#>     l2_4    (g_1_)    0.103    0.004   28.352    0.000
#>   d2_1 ~                                              
#>     l2_0     (b_2)   -0.299    0.004  -83.055    0.000
#>     l1_0    (g_2_)    0.197    0.004   45.372    0.000
#>   d2_2 ~                                              
#>     l2_1     (b_2)   -0.299    0.004  -83.055    0.000
#>     l1_1    (g_2_)    0.197    0.004   45.372    0.000
#>   d2_3 ~                                              
#>     l2_2     (b_2)   -0.299    0.004  -83.055    0.000
#>     l1_2    (g_2_)    0.197    0.004   45.372    0.000
#>   d2_4 ~                                              
#>     l2_3     (b_2)   -0.299    0.004  -83.055    0.000
#>     l1_3    (g_2_)    0.197    0.004   45.372    0.000
#>   d2_5 ~                                              
#>     l2_4     (b_2)   -0.299    0.004  -83.055    0.000
#>     l1_4    (g_2_)    0.197    0.004   45.372    0.000
#> 
#> Covariances:
#>                    Estimate  Std.Err  z-value  P(>|z|)
#>   l1_0 ~~                                             
#>     l2_0    (s0_2)    0.289    0.012   24.739    0.000
#>     s1    (s0_3_1)    0.151    0.006   24.944    0.000
#>     s2    (s0_4_1)    0.053    0.006    8.352    0.000
#>   l2_0 ~~                                             
#>     s1    (s0_3_2)    0.044    0.006    7.203    0.000
#>     s2    (s0_4_2)    0.144    0.006   24.545    0.000
#>   s1 ~~                                               
#>     s2    (s0_4_3)    0.045    0.004   12.734    0.000
#> 
#> Intercepts:
#>                    Estimate  Std.Err  z-value  P(>|z|)
#>     l1_0    (m0_1)    2.023    0.011  188.807    0.000
#>     l2_0    (m0_2)    1.002    0.011   92.590    0.000
#>     s1      (m0_3)    0.511    0.009   54.486    0.000
#>     s2      (m0_4)    0.404    0.009   43.571    0.000
#>    .y1_0              0.000                           
#>    .y1_1              0.000                           
#>    .y1_2              0.000                           
#>    .y1_3              0.000                           
#>    .y1_4              0.000                           
#>    .y1_5              0.000                           
#>    .y2_0              0.000                           
#>    .y2_1              0.000                           
#>    .y2_2              0.000                           
#>    .y2_3              0.000                           
#>    .y2_4              0.000                           
#>    .y2_5              0.000                           
#> 
#> Variances:
#>                    Estimate  Std.Err  z-value  P(>|z|)
#>    .l1_1              0.000                           
#>    .l1_2              0.000                           
#>    .l1_3              0.000                           
#>    .l1_4              0.000                           
#>    .l1_5              0.000                           
#>    .l2_1              0.000                           
#>    .l2_2              0.000                           
#>    .l2_3              0.000                           
#>    .l2_4              0.000                           
#>    .l2_5              0.000                           
#>    .d1_1              0.000                           
#>    .d1_2              0.000                           
#>    .d1_3              0.000                           
#>    .d1_4              0.000                           
#>    .d1_5              0.000                           
#>    .d2_1              0.000                           
#>    .d2_2              0.000                           
#>    .d2_3              0.000                           
#>    .d2_4              0.000                           
#>    .d2_5              0.000                           
#>     l1_0    (s0_1)    0.996    0.016   62.526    0.000
#>     l2_0    (s0_2)    1.009    0.016   61.769    0.000
#>     s1      (s0_3)    0.255    0.005   46.955    0.000
#>     s2      (s0_4)    0.248    0.005   53.860    0.000
#>    .y1_0    (t_1_)    0.198    0.001  141.226    0.000
#>    .y1_1    (t_1_)    0.198    0.001  141.226    0.000
#>    .y1_2    (t_1_)    0.198    0.001  141.226    0.000
#>    .y1_3    (t_1_)    0.198    0.001  141.226    0.000
#>    .y1_4    (t_1_)    0.198    0.001  141.226    0.000
#>    .y1_5    (t_1_)    0.198    0.001  141.226    0.000
#>    .y2_0    (t_2_)    0.199    0.001  140.463    0.000
#>    .y2_1    (t_2_)    0.199    0.001  140.463    0.000
#>    .y2_2    (t_2_)    0.199    0.001  140.463    0.000
#>    .y2_3    (t_2_)    0.199    0.001  140.463    0.000
#>    .y2_4    (t_2_)    0.199    0.001  140.463    0.000
#>    .y2_5    (t_2_)    0.199    0.001  140.463    0.000

Table 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_1_1 = mu0[1],
  mu0_2_1 = mu0[2],
  mu0_3_1 = mu0[3],
  mu0_4_1 = mu0[4],
  sigma0_1_1 = sigma0[1, 1],
  sigma0_2_1 = sigma0[2, 1],
  sigma0_3_1 = sigma0[3, 1],
  sigma0_4_1 = sigma0[4, 1],
  sigma0_2_2 = sigma0[2, 2],
  sigma0_3_2 = sigma0[3, 2],
  sigma0_4_2 = sigma0[4, 2],
  sigma0_3_3 = sigma0[3, 3],
  sigma0_4_3 = sigma0[4, 3],
  sigma0_4_4 = sigma0[4, 4],
  b_1 = b_1,
  b_2 = b_2,
  g_1_2 = g_1_2,
  g_2_1 = g_2_1,
  theta_1_1 = theta[1, 1],
  theta_2_2 = theta[2, 2]
)

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)"
)
Population values and dynr estimates (one simulated data set)
Parameter Population Estimate SE Bias 2.5% 97.5% Covers
mu0_1_1 2.00 2.023 0.011 0.023 2.002 2.044 FALSE
mu0_2_1 1.00 1.002 0.011 0.002 0.980 1.023 TRUE
mu0_3_1 0.50 0.511 0.009 0.011 0.493 0.530 TRUE
mu0_4_1 0.40 0.405 0.009 0.005 0.386 0.423 TRUE
sigma0_1_1 1.00 0.996 0.016 -0.004 0.964 1.027 TRUE
sigma0_2_1 0.30 0.289 0.012 -0.011 0.266 0.312 TRUE
sigma0_3_1 0.15 0.151 0.006 0.001 0.139 0.163 TRUE
sigma0_4_1 0.05 0.053 0.006 0.003 0.041 0.066 TRUE
sigma0_2_2 1.00 1.009 0.016 0.009 0.977 1.041 TRUE
sigma0_3_2 0.05 0.044 0.006 -0.006 0.032 0.056 TRUE
sigma0_4_2 0.15 0.144 0.006 -0.006 0.132 0.155 TRUE
sigma0_3_3 0.25 0.256 0.005 0.006 0.245 0.266 TRUE
sigma0_4_3 0.05 0.046 0.004 -0.004 0.039 0.053 TRUE
sigma0_4_4 0.25 0.248 0.005 -0.002 0.239 0.257 TRUE
b_1 -0.20 -0.202 0.004 -0.002 -0.211 -0.194 TRUE
b_2 -0.30 -0.299 0.004 0.001 -0.306 -0.292 TRUE
g_1_2 0.10 0.103 0.004 0.003 0.096 0.110 TRUE
g_2_1 0.20 0.197 0.004 -0.003 0.188 0.205 TRUE
theta_1_1 0.20 0.198 0.001 -0.002 0.196 0.201 TRUE
theta_2_2 0.20 0.199 0.001 -0.001 0.196 0.202 TRUE

lavaan

pe <- lavaan::parameterEstimates(fit)
# a label shared by several occasions is one parameter
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)"
)
Population values and lavaan estimates (one simulated data set)
Parameter Population Estimate SE Bias 2.5% 97.5% Covers
mu0_1_1 2.00 2.023 0.011 0.023 2.002 2.044 FALSE
mu0_2_1 1.00 1.002 0.011 0.002 0.981 1.023 TRUE
mu0_3_1 0.50 0.511 0.009 0.011 0.493 0.529 TRUE
mu0_4_1 0.40 0.404 0.009 0.004 0.386 0.423 TRUE
sigma0_1_1 1.00 0.996 0.016 -0.004 0.964 1.027 TRUE
sigma0_2_1 0.30 0.289 0.012 -0.011 0.266 0.312 TRUE
sigma0_3_1 0.15 0.151 0.006 0.001 0.139 0.163 TRUE
sigma0_4_1 0.05 0.053 0.006 0.003 0.041 0.066 TRUE
sigma0_2_2 1.00 1.009 0.016 0.009 0.977 1.041 TRUE
sigma0_3_2 0.05 0.044 0.006 -0.006 0.032 0.056 TRUE
sigma0_4_2 0.15 0.144 0.006 -0.006 0.133 0.156 TRUE
sigma0_3_3 0.25 0.255 0.005 0.005 0.245 0.266 TRUE
sigma0_4_3 0.05 0.045 0.004 -0.005 0.038 0.052 TRUE
sigma0_4_4 0.25 0.248 0.005 -0.002 0.238 0.257 TRUE
b_1 -0.20 -0.202 0.004 -0.002 -0.211 -0.194 TRUE
b_2 -0.30 -0.299 0.004 0.001 -0.306 -0.292 TRUE
g_1_2 0.10 0.103 0.004 0.003 0.096 0.110 TRUE
g_2_1 0.20 0.197 0.004 -0.003 0.188 0.205 TRUE
theta_1_1 0.20 0.198 0.001 -0.002 0.196 0.201 TRUE
theta_2_2 0.20 0.199 0.001 -0.001 0.196 0.202 TRUE

References

Chow, S.-M., Ho, M. R., Hamaker, E. L., & Dolan, C. V. (2010). Equivalence and differences between structural equation modeling and state-space modeling techniques. Structural Equation Modeling: A Multidisciplinary Journal, 17(2), 303–332. https://doi.org/10.1080/10705511003661553
McArdle, J. J. (2009). Latent variable modeling of differences and changes with longitudinal data. Annual Review of Psychology, 60(1), 577–605. https://doi.org/10.1146/annurev.psych.60.110707.163612
Ou, L., Hunter, M. D., & Chow, S.-M. (2019). What’s for dynr: A package for linear and nonlinear dynamic modeling in R. The R Journal, 11(1), 91. https://doi.org/10.32614/rj-2019-012
R Core Team. (2026). R: A language and environment for statistical computing. R Foundation for Statistical Computing. https://www.R-project.org/
Rosseel, Y. (2012). lavaan: An R package for structural equation modeling. Journal of Statistical Software, 48(2). https://doi.org/10.18637/jss.v048.i02