Time Structure
Now that we have covered the basic forms of each of the four modeling frameworks, we can start thinking more deeply about how to include time in our longitudinal models. We will begin by visualizing different kinds of assessment schedules and how our models might change depending on the structure of our observations. We will read in subsets of datasets that appear elsewhere in this primer codebook that follow each of three exemplar assessment types. The single.cohort
and accelerated
datasets were drawn from the executive.function
and feedback.learning
datasets we saw in the canonical models chapter. The multiple.cohort
dataset has been drawn from the adversity
dataset (for details, see the Datasets chapter), which measures white matter development across 8 waves. These data contain fractional anisotropy (FA) measures derived from the forceps minor (fmin
), a white matter tract that spans the hemispheres of the medial prefrontal cortex. We will see this dataset again when we consider predictors in the Covariates chapter.
<- read.csv("data/single-cohort.csv", header = TRUE)
single.cohort
<- read.csv("data/multiple-cohort.csv", header = TRUE)
multiple.cohort
<- read.csv("data/accelerated.csv", header = TRUE) accelerated
Assessment Schedules
Single Cohort Data
As we cover in the main text, single cohort studies are by far the most common in longitudinal modeling. Below we can plot the assessment schedule for the canonical models we worked with in the last chapter. To declutter the plot, we have selected 50 individuals from the larger dataset and ordered them by their age at the first assessment.
set.seed(12345)
ggplot(single.cohort %>%
pivot_longer(cols=starts_with("age"),
names_to = c(".value", "wave"),
names_pattern = "(.+)(.)"),
aes(x = age, y = id)) +
geom_line(aes(group = id), size = .5) +
geom_point(aes(color = as.factor(wave))) +
labs(x = "Age", y = "Individual", color = "Wave") +
theme(legend.position = "top")
We can see that there is some noise in the assessment schedule, as individuals are not observed at exactly intervals, however, we can see clear separation in ages between each wave. Because we cover this data extensively in the Canonical Models chapter, we will not show model fits here. However, we should note that because of the structure of this data, age and wave of assessment are highly collinear (and are more so the better job we do at observing individuals at regular intervals). This will impact our considerations about time later.
Multiple Cohort Data
A slightly more complex assessment schedule design is the multiple cohort design. In these designs, we have multiple, discrete ages at the first assessment which vary across cohorts. Here we can plot our multiple.cohort
data, which is organized into three cohorts, beginning at ages \(4\), \(5\), and \(6\). While at later ages the cohorts mix somewhat in terms of ages sampled, no individual is observed at consecutive ages. This highlights a key advantage of the multiple cohort design; they can often provide coverage of a wider developmental window without requiring additional subjects or waves of assessment using principles of planned missingness.
ggplot(multiple.cohort %>%
pivot_longer(cols=starts_with("fmin"),
names_to = c(".value", "age"),
names_pattern = "(....)(.+)") %>%
drop_na(fmin) %>%
mutate(age = as.numeric(age)),
aes(x = age, y = id)) +
geom_line(aes(group = id), size = .5) +
geom_point(aes(color = as.factor(age), shape = factor(cohort)), size=2) +
labs(x = "Age", y = "Individual", color = "Wave", shape = "Cohort") +
theme(legend.position = "top") + guides(color = "none")
Here we will demonstrate how one might model this type of data using the linear latent curve model for simplicity; however, we could implement this model in any of the 4 modeling frameworks we discussed. To preface the model results, the values of the fmin
outcome were normalized to the first age assessed (fmin4
) because of the relatively small natural scale of FA values. We have also read in the full adversity
data file due to convergence issues with the subsample in multiple.cohort
.
<- "int =~ 1*fmin4 + 1*fmin5 + 1*fmin6 + 1*fmin7 +
mc.wm.model 1*fmin8 + 1*fmin9 + 1*fmin10 + 1*fmin11
slp =~ 0*fmin4 + 1*fmin5 + 2*fmin6 + 3*fmin7 +
4*fmin8 + 5*fmin9 + 6*fmin10 + 7*fmin11
"
<- growth(mc.wm.model,
mc.wm data = adversity,
estimator = "ML",
missing = "FIML")
The real leverage that FIML gives us is apparent using this model. We can measure across \(8\) ages despite no individual having more than \(4\) observations. We can see the abbreviated results below.
summary(mc.wm, fit.measures = FALSE, estimates = TRUE,
standardize = TRUE, rsquare = FALSE)
## lavaan 0.6.13 ended normally after 33 iterations
##
## Estimator ML
## Optimization method NLMINB
## Number of model parameters 13
##
## Number of observations 398
## Number of missing patterns 40
##
## Model Test User Model:
##
## Test statistic 42.960
## Degrees of freedom 31
## P-value (Chi-square) 0.075
##
## Parameter Estimates:
##
## Standard errors Standard
## Information Observed
## Observed information based on Hessian
##
## Latent Variables:
## Estimate Std.Err z-value P(>|z|) Std.lv Std.all
## int =~
## fmin4 1.000 0.656 0.638
## fmin5 1.000 0.656 0.685
## fmin6 1.000 0.656 0.623
## fmin7 1.000 0.656 0.582
## fmin8 1.000 0.656 0.531
## fmin9 1.000 0.656 0.535
## fmin10 1.000 0.656 0.542
## fmin11 1.000 0.656 0.480
## slp =~
## fmin4 0.000 0.000 0.000
## fmin5 1.000 0.125 0.130
## fmin6 2.000 0.249 0.237
## fmin7 3.000 0.374 0.331
## fmin8 4.000 0.499 0.404
## fmin9 5.000 0.623 0.508
## fmin10 6.000 0.748 0.618
## fmin11 7.000 0.872 0.638
##
## Covariances:
## Estimate Std.Err z-value P(>|z|) Std.lv Std.all
## int ~~
## slp 0.006 0.023 0.276 0.783 0.077 0.077
##
## Intercepts:
## Estimate Std.Err z-value P(>|z|) Std.lv Std.all
## .fmin4 0.000 0.000 0.000
## .fmin5 0.000 0.000 0.000
## .fmin6 0.000 0.000 0.000
## .fmin7 0.000 0.000 0.000
## .fmin8 0.000 0.000 0.000
## .fmin9 0.000 0.000 0.000
## .fmin10 0.000 0.000 0.000
## .fmin11 0.000 0.000 0.000
## int 0.026 0.053 0.485 0.628 0.039 0.039
## slp 0.049 0.012 4.020 0.000 0.393 0.393
##
## Variances:
## Estimate Std.Err z-value P(>|z|) Std.lv Std.all
## .fmin4 0.627 0.141 4.440 0.000 0.627 0.593
## .fmin5 0.458 0.102 4.475 0.000 0.458 0.500
## .fmin6 0.591 0.105 5.646 0.000 0.591 0.533
## .fmin7 0.664 0.091 7.315 0.000 0.664 0.522
## .fmin8 0.796 0.125 6.389 0.000 0.796 0.522
## .fmin9 0.621 0.095 6.565 0.000 0.621 0.413
## .fmin10 0.398 0.116 3.438 0.001 0.398 0.272
## .fmin11 0.589 0.125 4.694 0.000 0.589 0.315
## int 0.431 0.117 3.690 0.000 1.000 1.000
## slp 0.016 0.006 2.685 0.007 1.000 1.000
With this model, we will be warned that coverage for certain pairwise combinations is \(< 10\%\), which just reflects the fact that almost no individuals gave data at adjacent ages. We could inspect the model coverage using the lavInspect()
function using the argument what = "coverage"
. Note the values at or near \(0\) on the 1-off diagonal (e.g., fmin5
and fmin4
).
lavInspect(mc.wm, what = "coverage")
## fmin4 fmin5 fmin6 fmin7 fmin8 fmin9 fmin10 fmin11
## fmin4 0.299
## fmin5 0.000 0.420
## fmin6 0.078 0.000 0.359
## fmin7 0.209 0.264 0.000 0.472
## fmin8 0.030 0.123 0.239 0.008 0.372
## fmin9 0.196 0.204 0.070 0.342 0.003 0.430
## fmin10 0.070 0.113 0.198 0.040 0.244 0.035 0.334
## fmin11 0.151 0.239 0.055 0.342 0.040 0.359 0.030 0.430
Finally, we can generate a path diagram, highlighting that the intercept and slope are estimated from all ages even though no individual is measured across all of those ages.
semPaths(mc.wm,
intercepts = TRUE,
edge.color = "black")
Accelerated Design
The most complex assessment schedule is the accelerated longitudinal design, where no two individual are assessed at the same maturational state (usually age). While it isn’t strictly necessary that we have some smooth continuum of starting ages, accelerated longitudinal studies most often follow this approach. We can see an example below.
set.seed(12345)
ggplot(accelerated %>% group_by(id) %>% filter(length(unique(wave)) == 3) %>%
ungroup() %>% filter(id %in% sample(id, 100)),
aes(x = age, y = id)) +
geom_line(aes(group = id), size = .5) +
geom_point(aes(color = as.factor(wave))) +
labs(x = "Age", y = "Individual", color = "Wave", shape = "Cohort") +
theme(legend.position = "top")
Unlike the single or multiple-cohort data, where we have discrete timepoints of observations, here we have as many as three unique timepoints per person since individuals are unlikely to be the exact same age. While it is possible to fit this type of model using definition variables in an SEM (see the Mplus code for the TSCORE option), it is far more common to fit these assessment schedules with mixed effects models, where we can include precise age into the model as a continuous predictor. We can use the multilevel model to fit a quadratic growth curve to this data. Note that the linear and quadratic effects are fixed effects that are pooled across individuals, and that only the random intercept is included. We could include additional random effects if the data support them, although we would need sufficient numbers of observations within-person to do so.
<- lmer(scale(modularity) ~ 1 + age + I(age^2) + (1 | id),
accel na.action = na.omit,
REML = TRUE,
data = accelerated)
We can see the results of this model below.
summary(accel)
## Linear mixed model fit by REML. t-tests use Satterthwaite's method [
## lmerModLmerTest]
## Formula: scale(modularity) ~ 1 + age + I(age^2) + (1 | id)
## Data: accelerated
##
## REML criterion at convergence: 642.4
##
## Scaled residuals:
## Min 1Q Median 3Q Max
## -3.02380 -0.55867 -0.00133 0.56810 2.43080
##
## Random effects:
## Groups Name Variance Std.Dev.
## id (Intercept) 0.4079 0.6386
## Residual 0.5131 0.7163
## Number of obs: 243, groups: id, 81
##
## Fixed effects:
## Estimate Std. Error df t value Pr(>|t|)
## (Intercept) -4.229027 0.928266 239.194448 -4.556 8.32e-06 ***
## age 0.460537 0.105741 239.506334 4.355 1.97e-05 ***
## I(age^2) -0.011828 0.002949 238.656609 -4.011 8.10e-05 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Correlation of Fixed Effects:
## (Intr) age
## age -0.982
## I(age^2) 0.940 -0.986
If we want to get the overall fixed effects trend, rather than individual trajectories (as we did in the Canonical models), we can use the ggpredict()
function from the ggeffects package to generate predicted values of the outcome at specific values of the predictor. This package is especially useful for generating ggplot-compatible dataframes when plotting interactions, but we can still use it for main effects (although a polynomial is a special form of an interaction). We can specify the levels of the predictor within the brackets ([]
) or set them to all
as we will here to get the full range of ages in our plot. We can then pass this dataframe to ggplot and plot the effect of age with confidence bands shaded.
= ggeffects::ggpredict(accel, terms='age [all]')
accel.effects
ggplot(data=accel.effects, aes(x=x,y=predicted)) +
geom_line() +
geom_ribbon(aes(ymin=conf.low, ymax=conf.high), alpha=.2) +
labs(x='Age', y = 'Predicted Learning Rate')
We can see quite a pronounced inverted-U quadratic effect here, with a peak around twenty. While we have this plot here, we might wish to know if the simple linear slope is significant at various points along the curve (i.e., is modularity significantly increasing or decreasing at a given age). To do so, we can use the simple_slopes()
function from the interactions package. One quirk is that this function does not recognize a polynomial term like the one we have here. Instead, the function looks for separate variable labels. Well fortunately we can just trick it into working as we would like by duplicating the age variable under a new name (age_temp
). We can then re-estimate our model with the interaction of age and our temporary age variable included (i.e., age:age_temp
).
<- accelerated %>% mutate(age_temp = age)
accelerated
<- lmer(scale(modularity) ~ 1 + age + age:age_temp + (1 | id),
probe.accel na.action = na.omit,
REML = TRUE,
data = accelerated)
We can then pass the new model object to the simple_slopes()
function with age
as our focal predictor and age_temp
as the moderator (of course in this case, it doesn’t matter which variable we put in which position). We can toggle the jnplot
argument to TRUE
in order to generate a plot of the linear effect of age as a function of age. This plot will show us where within the quadratic curve, we have significant increases or decreases, and where the linear effect is not significant.
::sim_slopes(probe.accel,
interactionsdata = accelerated,
pred = age,
modx = age_temp,
jnplot = TRUE,
jnalpha = 0.05)
## JOHNSON-NEYMAN INTERVAL
##
## When age_temp is OUTSIDE the interval [18.79, 27.02], the slope of age is p
## < .05.
##
## Note: The range of observed values of age_temp is [8.01, 28.46]
## SIMPLE SLOPES ANALYSIS
##
## Slope of age when age_temp = 13.14157 (- 1 SD):
##
## Est. S.E. t val. p
## ------ ------ -------- ------
## 0.10 0.02 4.40 0.00
##
## Slope of age when age_temp = 17.17288 (Mean):
##
## Est. S.E. t val. p
## ------ ------ -------- ------
## 0.05 0.02 3.01 0.00
##
## Slope of age when age_temp = 21.20419 (+ 1 SD):
##
## Est. S.E. t val. p
## ------ ------ -------- ------
## 0.01 0.02 0.33 0.74
We can see that from around 19 to 27, the linear effect of age (i.e., the slope of the tangent line) is not significant but is significantly positive before and significantly negative after this age range. We round these numbers because we cannot really think we have such precise measurements to say that linear increases in modularity become non-significant exactly at 18.79. However, this sort of plot can help us distinguish between truly quadratic effects (within increases and decreases) versus a plateauing of the outcome at later ages.
Time Coding
One point of frequent confusion when modeling data (nevermind longitudinal models) is the role of centering the predictors for model results/fit/etc. In general, centering predictors does not change the fundamental information contained within the model, although sometimes it is necessary for practical reason (e.g., reducing collinearity between main effects and product terms). In longitudinal models, the main centering concern is where to place the intercept (i.e., where time is coded 0). While many of our parameter estimates will indeed change based on where we choose to estimate the intercept (most notably the…wait for it…intercept, as well as covariances with the intercept). Here we will demonstrate with the LCM framework since the factor-loading matrix makes what is happening very explicit, but you could replicate these results with any of the other approaches.
First, we can fit the linear model to the single-cohort data we showed above. Here we will place the \(0\) factor loading at the first time point which will result in the intercept reflecting individual differences in the level of the outcome at the initial assessment (i.e., initial status).
<- "int =~ 1*dlpfc1 + 1*dlpfc2 + 1*dlpfc3 + 1*dlpfc4
initial.status slp =~ 0*dlpfc1 + 1*dlpfc2 + 2*dlpfc3 + 3*dlpfc4"
<- growth(initial.status,
initial.status.fit data = single.cohort,
estimator = "ML",
missing="FIML")
summary(initial.status.fit, fit.measures = TRUE, estimates = FALSE,
standardize = FALSE, rsquare = FALSE)
## lavaan 0.6.13 ended normally after 22 iterations
##
## Estimator ML
## Optimization method NLMINB
## Number of model parameters 9
##
## Number of observations 50
## Number of missing patterns 5
##
## Model Test User Model:
##
## Test statistic 12.140
## Degrees of freedom 5
## P-value (Chi-square) 0.033
##
## Model Test Baseline Model:
##
## Test statistic 72.730
## Degrees of freedom 6
## P-value 0.000
##
## User Model versus Baseline Model:
##
## Comparative Fit Index (CFI) 0.893
## Tucker-Lewis Index (TLI) 0.872
##
## Robust Comparative Fit Index (CFI) 0.905
## Robust Tucker-Lewis Index (TLI) 0.886
##
## Loglikelihood and Information Criteria:
##
## Loglikelihood user model (H0) -248.174
## Loglikelihood unrestricted model (H1) -242.104
##
## Akaike (AIC) 514.347
## Bayesian (BIC) 531.555
## Sample-size adjusted Bayesian (SABIC) 503.306
##
## Root Mean Square Error of Approximation:
##
## RMSEA 0.169
## 90 Percent confidence interval - lower 0.045
## 90 Percent confidence interval - upper 0.293
## P-value H_0: RMSEA <= 0.050 0.055
## P-value H_0: RMSEA >= 0.080 0.903
##
## Robust RMSEA 0.173
## 90 Percent confidence interval - lower 0.034
## 90 Percent confidence interval - upper 0.305
## P-value H_0: Robust RMSEA <= 0.050 0.062
## P-value H_0: Robust RMSEA >= 0.080 0.897
##
## Standardized Root Mean Square Residual:
##
## SRMR 0.113
We will focus first on fit and then circle back to parameter estimates later. Here the chi-square test statistics is \(12.14\) on \(5%\) degrees of freedom (\(p = 0.033\)), the \(CFI = 0.893\) and the \(RMSEA = 0.169\). We can then re-estimate the model where we code the last factor loading as \(0\) instead so the intercept will represent final status.
<- "int =~ 1*dlpfc1 + 1*dlpfc2 + 1*dlpfc3 + 1*dlpfc4
final.status slp =~ -3*dlpfc1 + -2*dlpfc2 + -1*dlpfc3 + 0*dlpfc4"
<- growth(final.status,
final.status.fit data = single.cohort,
estimator = "ML",
missing = "FIML")
summary(final.status.fit, fit.measures = TRUE, estimates = FALSE,
standardize = FALSE, rsquare = FALSE)
## lavaan 0.6.13 ended normally after 25 iterations
##
## Estimator ML
## Optimization method NLMINB
## Number of model parameters 9
##
## Number of observations 50
## Number of missing patterns 5
##
## Model Test User Model:
##
## Test statistic 12.140
## Degrees of freedom 5
## P-value (Chi-square) 0.033
##
## Model Test Baseline Model:
##
## Test statistic 72.730
## Degrees of freedom 6
## P-value 0.000
##
## User Model versus Baseline Model:
##
## Comparative Fit Index (CFI) 0.893
## Tucker-Lewis Index (TLI) 0.872
##
## Robust Comparative Fit Index (CFI) 0.905
## Robust Tucker-Lewis Index (TLI) 0.886
##
## Loglikelihood and Information Criteria:
##
## Loglikelihood user model (H0) -248.174
## Loglikelihood unrestricted model (H1) -242.104
##
## Akaike (AIC) 514.347
## Bayesian (BIC) 531.555
## Sample-size adjusted Bayesian (SABIC) 503.306
##
## Root Mean Square Error of Approximation:
##
## RMSEA 0.169
## 90 Percent confidence interval - lower 0.045
## 90 Percent confidence interval - upper 0.293
## P-value H_0: RMSEA <= 0.050 0.055
## P-value H_0: RMSEA >= 0.080 0.903
##
## Robust RMSEA 0.173
## 90 Percent confidence interval - lower 0.034
## 90 Percent confidence interval - upper 0.305
## P-value H_0: Robust RMSEA <= 0.050 0.062
## P-value H_0: Robust RMSEA >= 0.080 0.897
##
## Standardized Root Mean Square Residual:
##
## SRMR 0.113
Looking at the model fit values, they are exactly identical to the initial status model. We have effectively re-arranged deck chairs on the Titanic (and Rose is about to shove Jack off that door; don’t @ me) as far as model fit goes. So why might we wish to reformulate this model? Well, individual differences in level might be interesting to interpret substantively at one point over another depending on our application of interest. While many research hypotheses will lend themselves naturally to the initial status approach, in intervention work or a training study we might be far more interesting in interpreting where individuals end up (spoken with all the authority of someone who does not do intervention work).
Parameter | Estimate | SE | Statistic | p-value | Std.LV | Std.All |
---|---|---|---|---|---|---|
dlpfc1 ~~ dlpfc1 | 0.280 | 0.196 | 1.427 | 0.154 | 0.280 | 0.307 |
dlpfc2 ~~ dlpfc2 | 0.459 | 0.140 | 3.275 | 0.001 | 0.459 | 0.435 |
dlpfc3 ~~ dlpfc3 | 0.389 | 0.128 | 3.039 | 0.002 | 0.389 | 0.368 |
dlpfc4 ~~ dlpfc4 | 0.640 | 0.278 | 2.302 | 0.021 | 0.640 | 0.428 |
int ~~ int | 0.631 | 0.207 | 3.039 | 0.002 | 1.000 | 1.000 |
slp ~~ slp | 0.055 | 0.054 | 1.010 | 0.313 | 1.000 | 1.000 |
int ~~ slp | -0.045 | 0.097 | -0.467 | 0.641 | -0.243 | -0.243 |
int ~1 | 0.628 | 0.132 | 4.761 | 0.000 | 0.791 | 0.791 |
slp ~1 | 0.114 | 0.054 | 2.120 | 0.034 | 0.487 | 0.487 |
Parameter | Estimate | SE | Statistic | p-value | Std.LV | Std.All |
---|---|---|---|---|---|---|
dlpfc1 ~~ dlpfc1 | 0.280 | 0.196 | 1.427 | 0.154 | 0.280 | 0.307 |
dlpfc2 ~~ dlpfc2 | 0.459 | 0.140 | 3.275 | 0.001 | 0.459 | 0.435 |
dlpfc3 ~~ dlpfc3 | 0.389 | 0.128 | 3.039 | 0.002 | 0.389 | 0.368 |
dlpfc4 ~~ dlpfc4 | 0.640 | 0.278 | 2.302 | 0.021 | 0.640 | 0.428 |
int ~~ int | 0.853 | 0.262 | 3.262 | 0.001 | 1.000 | 1.000 |
slp ~~ slp | 0.055 | 0.054 | 1.010 | 0.313 | 1.000 | 1.000 |
int ~~ slp | 0.119 | 0.093 | 1.284 | 0.199 | 0.552 | 0.552 |
int ~1 | 0.970 | 0.158 | 6.124 | 0.000 | 1.050 | 1.050 |
slp ~1 | 0.114 | 0.054 | 2.120 | 0.034 | 0.487 | 0.487 |
We can see that some parameters won’t change based on the time-coding. For instance, the residual variances of the repeated measures (and therefore the R-squares), and the mean and variance of the slope factor. However, the mean and variance of the intercept changes, as does the correlation between the slope and intercept (initial status: \(r = -0.243\); final status: \(r = 0.552\)). Note that the sign of the correlation flips and the magnitude of the difference is quite substantial. This should urge caution not to interpret the intercept outside of the specific timepoint it is estimated for since it’s parameter values will be context specific (due to rank-order shifts due to random slopes).
If we wanted to use a mixed effect model, we might be better off just transforming our predictor variable to reflect the code we want. We can transform the single.cohort
data below to suit our purposes.
<- single.cohort %>%
single.cohort.long pivot_longer(cols=starts_with(c("age", "dlpfc")),
names_to = c(".value", "wave"),
names_pattern = "(.+)(.)") %>%
mutate(wave = as.numeric(wave),
age_initial = age - min(age, na.rm = TRUE),
age_final = age - max(age, na.rm = TRUE))
We can then fit the models as we usually do to see the equivalencies.
<- lmer(dlpfc ~ 1 + age_initial + (1 + age_initial | id),
initial.status.mlm na.action = na.omit,
REML = TRUE,
data = single.cohort.long)
<- lmer(dlpfc ~ 1 + age_final + (1 + age_final | id),
final.status.mlm na.action = na.omit,
REML = TRUE,
data = single.cohort.long)
tab_model(initial.status.mlm, final.status.mlm,
show.se = TRUE,
show.df = FALSE,
show.ci = FALSE,
digits = 3,
pred.labels = c("Intercept", "Age - min(Age)", "Age - max(Age)"),
dv.labels = c("Initial Status", "Final Status"),
string.se = "SE",
string.p = "P-Value")
Initial Status | Final Status | |||||
---|---|---|---|---|---|---|
Predictors | Estimates | SE | P-Value | Estimates | SE | P-Value |
Intercept | 0.619 | 0.145 | <0.001 | 0.984 | 0.166 | <0.001 |
Age - min(Age) | 0.106 | 0.056 | 0.062 | |||
Age - max(Age) | 0.106 | 0.056 | 0.062 | |||
Random Effects | ||||||
σ2 | 0.43 | 0.43 | ||||
τ00 | 0.66 id | 0.98 id | ||||
τ11 | 0.07 id.age_initial | 0.07 id.age_final | ||||
ρ01 | -0.33 id | 0.63 id | ||||
ICC | 0.62 | 0.62 | ||||
N | 50 id | 50 id | ||||
Observations | 191 | 191 | ||||
Marginal R2 / Conditional R2 | 0.012 / 0.624 | 0.012 / 0.624 |
Note that the slope estimates are identical, while estimates involving the intercept change as we saw before.
Additional Considerations
One thing that has been consistent across all the models that we have fit thus far is that they are structured primarily by the chronological age of the individuals in question. Even when we have used wave of assessment as the nominal variable encoding time, waves have been spaced into yearly assessments and the lack of heterogeneity in ages collapses age and wave (either in reality or in practice when we fix factor loadings). However, nothing stops us from structuring the repeated measures by some other metric of maturation, practice, or anything else (except for pesky things like convention and serious measurement/modeling challenges, but that’s all). For instance, we could plot our repeated measures in the accelerated
data again, but instead of chronological age, we could put pubertal status (measured by Tanner stage) on the x-axis. That would result in the plot below.
Alternative Metrics of Time
ggplot(accelerated, aes(x = puberty, y = id)) +
geom_line(aes(group = id), size = .5) +
geom_point(aes(color = as.factor(wave))) +
labs(x = "Tanner Stage", y = "Individual", color = "Wave", shape = "Cohort") +
theme(legend.position = "top")
In some ways, this roughly resembles the age plot, with some individuals being measured at earlier stages of puberty and other later. However, close inspection starts to uncover some issues. First, we often stop measuring puberty once individual reach a certain age/stage of development, so puberty as a measure of maturation is limited in it’s utility outside of a fairly specific phase of development. We can already see for subjects who are measured first at later ages having missing data for later observations because puberty measures were not taken. There is also the curious case that some subset of individuals seem to move backwards in pubertal development (this should be treated as an error, but it’s amusing nonetheless). However, the key feature of interest in a plot like this is that, unlike age, there is not reason to expect that all individuals “age” at the same rate across puberty, seen by the uneven spacing between waves within individuals. This variation is just one of the exciting opportunities that modeling maturation using metric other than age present (although they do come with their own challenges; there is an admittedly understandable reason that age is the predominate variable in longitudinal models). One other advantage is within-age heterogeneity, which we can see by plotting Tanner Stage by age in the plot below.
ggplot(accelerated, aes(x = age, y = puberty)) +
geom_line(aes(group = id), size = .5) +
geom_point(aes(color = as.factor(wave))) +
labs(x = "Age", y = "Tanner Stage", color = "Wave", shape = "Cohort") +
theme(legend.position = "top")
We can see that same-age peers may differ considerable (depending on when we measure them) in their pubertal development. We can also fit puberty-related trajectories to the modularity data below.
<- lmer(scale(modularity) ~ 1 + puberty + (1 | id),
accel na.action = na.omit,
REML = TRUE,
data = accelerated)
summary(accel, correlation = FALSE)
## Linear mixed model fit by REML. t-tests use Satterthwaite's method [
## lmerModLmerTest]
## Formula: scale(modularity) ~ 1 + puberty + (1 | id)
## Data: accelerated
##
## REML criterion at convergence: 417.9
##
## Scaled residuals:
## Min 1Q Median 3Q Max
## -2.49262 -0.65415 0.04191 0.58260 2.43878
##
## Random effects:
## Groups Name Variance Std.Dev.
## id (Intercept) 0.3322 0.5763
## Residual 0.6698 0.8184
## Number of obs: 150, groups: id, 67
##
## Fixed effects:
## Estimate Std. Error df t value Pr(>|t|)
## (Intercept) -0.96653 0.28495 133.98649 -3.392 0.000913 ***
## puberty 0.34156 0.09681 145.04845 3.528 0.000561 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Of course, we might wish to model age and puberty simultaneously. That may present practical challenges in this data, since age and puberty are highly correlated \(r = 0.796\), but some strategies for modeling multiple growth processes have been suggested elsewhere (see the relevant portion of the main text for details).
Residual Estimates
The final consideration we will explore in our discussion of time is the structure of the residuals of our repeated measures. This is often not of great theoretical importance (I don’t really believe you if you tell me your theory is specific enough to know if residual variance should be constant or not…call me a grinch), but it can be really important for model results. Essentially, an overly restrictive assumption about residual variances at the individual observation level can radiate out into the random effects structure and bias your effects. Here we will show how to specify both forms of a model in a mixed effect and structural equation model, and test which one is a better fit to the data.
We can start with the LCM, where heteroscedasticity (i.e., unique residual estimates across) is the default. To obtain this model, we use the same syntax we have seen repeatedly so far.
<- "int =~ 1*dlpfc1 + 1*dlpfc2 + 1*dlpfc3 + 1*dlpfc4
lcm.het slp =~ 0*dlpfc1 + 1*dlpfc2 + 2*dlpfc3 + 3*dlpfc4"
<- growth(lcm.het,
lcm.het.fit data = single.cohort,
estimator = "ML",
missing = "FIML")
summary(lcm.het.fit, fit.measures = FALSE, estimates = TRUE,
standardize = FALSE, rsquare = FALSE)
## lavaan 0.6.13 ended normally after 22 iterations
##
## Estimator ML
## Optimization method NLMINB
## Number of model parameters 9
##
## Number of observations 50
## Number of missing patterns 5
##
## Model Test User Model:
##
## Test statistic 12.140
## Degrees of freedom 5
## P-value (Chi-square) 0.033
##
## Parameter Estimates:
##
## Standard errors Standard
## Information Observed
## Observed information based on Hessian
##
## Latent Variables:
## Estimate Std.Err z-value P(>|z|)
## int =~
## dlpfc1 1.000
## dlpfc2 1.000
## dlpfc3 1.000
## dlpfc4 1.000
## slp =~
## dlpfc1 0.000
## dlpfc2 1.000
## dlpfc3 2.000
## dlpfc4 3.000
##
## Covariances:
## Estimate Std.Err z-value P(>|z|)
## int ~~
## slp -0.045 0.097 -0.467 0.641
##
## Intercepts:
## Estimate Std.Err z-value P(>|z|)
## .dlpfc1 0.000
## .dlpfc2 0.000
## .dlpfc3 0.000
## .dlpfc4 0.000
## int 0.628 0.132 4.761 0.000
## slp 0.114 0.054 2.120 0.034
##
## Variances:
## Estimate Std.Err z-value P(>|z|)
## .dlpfc1 0.280 0.196 1.427 0.154
## .dlpfc2 0.459 0.140 3.275 0.001
## .dlpfc3 0.389 0.128 3.039 0.002
## .dlpfc4 0.640 0.278 2.302 0.021
## int 0.631 0.207 3.039 0.002
## slp 0.055 0.054 1.010 0.313
We can easily see that each residual obtains a unique value with an associated inference test (.dlpfc1 - .dlpfc4
). To constrain these residuals equal across time, we have to explicitly write out the residual term (we have been relying on sensible defaults thus far) and then pre-multiply each estimate by the same alpha-numeric label. Lavaan will interpret repeated labels as a request for an equality constraint on those parameters. We can see this constraint in action below.
<- "int =~ 1*dlpfc1 + 1*dlpfc2 + 1*dlpfc3 + 1*dlpfc4
lcm.hom slp =~ 0*dlpfc1 + 1*dlpfc2 + 2*dlpfc3 + 3*dlpfc4
dlpfc1 ~~ c1*dlpfc1
dlpfc2 ~~ c1*dlpfc2
dlpfc3 ~~ c1*dlpfc3
dlpfc4 ~~ c1*dlpfc4"
The choice of constraint (here c1
) is somewhat arbitrary, but the point is that it can contain characters and numbers (although it must begin with a character). When we fit the model, we will see those parameters will be held equal.
<- growth(lcm.hom,
lcm.hom.fit data = single.cohort,
estimator = "ML",
missing = "FIML")
summary(lcm.hom.fit, fit.measures = FALSE, estimates = TRUE,
standardize = FALSE, rsquare = FALSE)
## lavaan 0.6.13 ended normally after 19 iterations
##
## Estimator ML
## Optimization method NLMINB
## Number of model parameters 9
## Number of equality constraints 3
##
## Number of observations 50
## Number of missing patterns 5
##
## Model Test User Model:
##
## Test statistic 14.724
## Degrees of freedom 8
## P-value (Chi-square) 0.065
##
## Parameter Estimates:
##
## Standard errors Standard
## Information Observed
## Observed information based on Hessian
##
## Latent Variables:
## Estimate Std.Err z-value P(>|z|)
## int =~
## dlpfc1 1.000
## dlpfc2 1.000
## dlpfc3 1.000
## dlpfc4 1.000
## slp =~
## dlpfc1 0.000
## dlpfc2 1.000
## dlpfc3 2.000
## dlpfc4 3.000
##
## Covariances:
## Estimate Std.Err z-value P(>|z|)
## int ~~
## slp -0.041 0.061 -0.668 0.504
##
## Intercepts:
## Estimate Std.Err z-value P(>|z|)
## .dlpfc1 0.000
## .dlpfc2 0.000
## .dlpfc3 0.000
## .dlpfc4 0.000
## int 0.640 0.134 4.761 0.000
## slp 0.108 0.055 1.969 0.049
##
## Variances:
## Estimate Std.Err z-value P(>|z|)
## .dlpfc1 (c1) 0.435 0.065 6.732 0.000
## .dlpfc2 (c1) 0.435 0.065 6.732 0.000
## .dlpfc3 (c1) 0.435 0.065 6.732 0.000
## .dlpfc4 (c1) 0.435 0.065 6.732 0.000
## int 0.590 0.189 3.129 0.002
## slp 0.059 0.032 1.838 0.066
Lavaan conveniently includes the labels in the output and we can see that now all of the residuals obtain the exact same value, and exact same inferential test (note how that changes the inference on the residual of dlpfc1
from above). However, note how other parameter estimates (namely the factor means and variances) change as well. These changes are small (for reasons we will see) but it is important to note we are making real changes to the model with the introduction of this constraint. We can then get a likelihood-ratio test of difference in fit, using the lavTestLRT()
function, because the simplified homoscedastic model is nested within the more complex heteroscedastic model. Note that we list the homoscedastic model first.
::lavTestLRT(lcm.hom.fit, lcm.het.fit) lavaan
##
## Chi-Squared Difference Test
##
## Df AIC BIC Chisq Chisq diff RMSEA Df diff Pr(>Chisq)
## lcm.het.fit 5 514.35 531.56 12.140
## lcm.hom.fit 8 510.93 522.40 14.724 2.5837 0 3 0.4604
However, in the output, the heteroscedastic model is listed first. This is because the more complex model is always the reference and we are testing whether the imposition of model simplifications (i.e., constraints; here homoscedastic residuals) significantly decreases model fit. Here we can see that the chi-square difference test is not significant (\(p = 0.460\)), so we could retain the simplification without decreasing the fit of the model to our data. This is the reason that our parameters did not change much. In models where we would reject homoscedasticity, the imposition of the constraint would lead to more pronounced parameter changes elsewhere in the model. While there is not an associated inference test for the AIC/BIC, we could also use those values for model comparisons if we wished.
To implement heteroscedastic residuals in mixed-effects models, we have to leave our familiar lmer()
and travel back in time to the days of lme()
. We can first fit the canonical model we know and love, where the default is homoscedasticity.
<- nlme::lme(dlpfc ~ 1 + wave,
mlm.hom random = ~ 1 + wave | id,
na.action = na.omit,
method = "REML",
data = single.cohort.long)
summary(mlm.hom, correlation = FALSE)
## Linear mixed-effects model fit by REML
## Data: single.cohort.long
## AIC BIC logLik
## 517.3124 536.7629 -252.6562
##
## Random effects:
## Formula: ~1 + wave | id
## Structure: General positive-definite, Log-Cholesky parametrization
## StdDev Corr
## (Intercept) 0.8717474 (Intr)
## wave 0.2500535 -0.489
## Residual 0.6595289
##
## Fixed effects: dlpfc ~ 1 + wave
## Value Std.Error DF t-value p-value
## (Intercept) 0.5321058 0.16886967 140 3.150985 0.0020
## wave 0.1076007 0.05524678 140 1.947638 0.0535
## Correlation:
## (Intr)
## wave -0.705
##
## Standardized Within-Group Residuals:
## Min Q1 Med Q3 Max
## -2.09037990 -0.49924910 -0.04034052 0.65971936 2.07352349
##
## Number of Observations: 191
## Number of Groups: 50
Note that in the Random effects
portion of the output, we obtain a single numerical value for the residual \(\sigma^2 = 0.660\). To fit a heteroscedastic model, we need to make use of a new argument, weights
. We can specify the form
argument of the varIdent()
(“variance identity) function to obtain a unique estimate per wave
.
<- nlme::lme(dlpfc ~ 1 + wave,
mlm.het random = ~ 1 + wave | id,
weights = varIdent(form = ~ 1 | wave),
na.action = na.omit,
method = "REML",
data = single.cohort.long)
summary(mlm.het, correlation = FALSE)
## Linear mixed-effects model fit by REML
## Data: single.cohort.long
## AIC BIC logLik
## 520.7754 549.9511 -251.3877
##
## Random effects:
## Formula: ~1 + wave | id
## Structure: General positive-definite, Log-Cholesky parametrization
## StdDev Corr
## (Intercept) 0.8975009 (Intr)
## wave 0.2413003 -0.495
## Residual 0.5269518
##
## Variance function:
## Structure: Different standard deviations per stratum
## Formula: ~1 | wave
## Parameter estimates:
## 1 2 3 4
## 1.000000 1.287069 1.184203 1.515231
## Fixed effects: dlpfc ~ 1 + wave
## Value Std.Error DF t-value p-value
## (Intercept) 0.5144231 0.16258045 140 3.164114 0.0019
## wave 0.1139612 0.05386346 140 2.115743 0.0361
## Correlation:
## (Intr)
## wave -0.675
##
## Standardized Within-Group Residuals:
## Min Q1 Med Q3 Max
## -2.09852285 -0.47236320 -0.01588848 0.64203216 2.12263672
##
## Number of Observations: 191
## Number of Groups: 50
Looking at the Random effects
output, we still only see a single value for the residual. Unfortunately the output structure here leaves something to be desired. We need to look to the Variance function
output section where we see for unique values. Well these are not technically the residual estimates, but rather they indicate the relative size of the residual scaled to the first estimate (the one we see in the Random effects
section). To extract the actual estimates, we can use the following code.
$sigma * exp(as.numeric(mlm.het$modelStruct$varStruct)) mlm.het
## [1] 0.6782232 0.6240178 0.7984535
So the first residual can be extracted as model$sigma
, but the residual weights from the model are stuck in the varStruct
portion of the fitted lme()
object and are in the \(log(\sigma)\) scale. So we have to extract the values, convert them to numeric format, exponentiate them, and then multiple the weights by the estimated residual to obtain the other three estimates (tired yet?).
Fortunately, model comparisons are straightforward with the anova()
function, which we can use in the same way we used lavTestLRT()
to compare LCMs early.
anova(mlm.het, mlm.hom)
## Model df AIC BIC logLik Test L.Ratio p-value
## mlm.het 1 9 520.7754 549.9511 -251.3877
## mlm.hom 2 6 517.3124 536.7629 -252.6562 1 vs 2 2.537015 0.4686
Like before, these results indicate that imposing homoscedasticity does not significantly decrease model fit.