|
| 1 | +--- |
| 2 | +title: "Example of Measurement Invariance" |
| 3 | +author: "Clay Ford" |
| 4 | +date: "2025-06-26" |
| 5 | +output: html_document |
| 6 | +--- |
| 7 | + |
| 8 | +```{r setup, include=FALSE} |
| 9 | +knitr::opts_chunk$set(echo = TRUE) |
| 10 | +``` |
| 11 | + |
| 12 | +This example comes from Chapter 4 of Beaujean (2014), Latent Variable Models with Multiple Groups. The example examines if the structure of the WISC-III scale is the same in children with and without manic symptoms. [Source](https://blogs.baylor.edu/rlatentvariable/sample-page/r-syntax/#Chapter_4_Latent_Variable_Models_with_Multiple_Groups) |
| 13 | + |
| 14 | +Load the package we need. |
| 15 | + |
| 16 | +```{r} |
| 17 | +library(lavaan) |
| 18 | +``` |
| 19 | + |
| 20 | + |
| 21 | +## Read in data |
| 22 | + |
| 23 | +The data is manually entered as covariance matrices. This is not normally how you would enter data in R. Notice we enter two separate covariance matrices: one for children with manic symptoms and those without. |
| 24 | + |
| 25 | +```{r} |
| 26 | +# variable names |
| 27 | +wisc3.names <- c("Info", "Sim", "Vocab","Comp", |
| 28 | + "PicComp", "PicArr", "BlkDsgn", "ObjAsmb") |
| 29 | +
|
| 30 | +# covariance for group with manic symptoms |
| 31 | +manic.cov <- c(9.364, 7.777, 12.461, 6.422, 8.756, 10.112, |
| 32 | + 5.669, 7.445, 6.797, 8.123, 3.048, 4.922, |
| 33 | + 4.513, 4.116, 6.200, 3.505, 4.880, 4.899, |
| 34 | + 5.178, 5.114, 15.603, 3.690, 5.440, 5.220, |
| 35 | + 3.151, 3.587, 6.219, 11.223, 3.640, 4.641, |
| 36 | + 4.877, 3.568, 3.819, 5.811, 6.501, 9.797) |
| 37 | +
|
| 38 | +# lavaan function to create a covariance matrix |
| 39 | +manic.cov <- lav_matrix_lower2full(manic.cov) |
| 40 | +
|
| 41 | +# means of the eight variables |
| 42 | +manic.means <- c(10.09, 12.07, 10.25, 9.96, 10.90, 11.24, 10.30, 10.44) |
| 43 | +
|
| 44 | +# label the covariances and means |
| 45 | +colnames(manic.cov) <- rownames(manic.cov) <- wisc3.names |
| 46 | +names(manic.means) <- wisc3.names |
| 47 | +
|
| 48 | +# preview the covariance matrix |
| 49 | +manic.cov |
| 50 | +``` |
| 51 | + |
| 52 | + |
| 53 | +Now do the same for the children without manic symptoms. |
| 54 | + |
| 55 | +```{r} |
| 56 | +# covariance for group without manic symptoms |
| 57 | +norming.cov <- c(9.610, 5.844, 8.410, 6.324, 6.264, 9.000, |
| 58 | + 4.405, 4.457, 5.046, 8.410, 4.464, 4.547, |
| 59 | + 4.512, 3.712, 10.240, 3.478, 2.967, 2.970, |
| 60 | + 2.871, 3.802, 10.890, 5.270, 4.930, 4.080, |
| 61 | + 3.254, 5.222, 3.590, 11.560, 4.297, 4.594, |
| 62 | + 4.356, 3.158, 4.963, 3.594, 6.620, 10.890) |
| 63 | +norming.cov <- lav_matrix_lower2full(norming.cov) |
| 64 | +
|
| 65 | +# means |
| 66 | +norming.means <- c(10.10, 10.30, 9.80, 10.10, 10.10, 10.10, 9.90, 10.20) |
| 67 | +
|
| 68 | +# label the covariances and means |
| 69 | +colnames(norming.cov) <- rownames(norming.cov) <- wisc3.names |
| 70 | +names(norming.means) <- wisc3.names |
| 71 | +
|
| 72 | +# preview the covariance matrix |
| 73 | +norming.cov |
| 74 | +``` |
| 75 | + |
| 76 | +Finally, combine the covariance matrices, sample sizes, and means into single list objects. |
| 77 | + |
| 78 | +```{r} |
| 79 | +combined.cov <- list(manic = manic.cov, norming = norming.cov) |
| 80 | +combined.n <- list(manic = 81, norming = 200) |
| 81 | +combined.means <- list(manic = manic.means, norming = norming.means) |
| 82 | +``` |
| 83 | + |
| 84 | +Now ready to specify the CFA model. |
| 85 | + |
| 86 | +## CFA Model |
| 87 | + |
| 88 | +The model below says there are two factors: Verbal-Comprehension (VC) and Visual-Spatial (VS). The model hypothesizes these two factors are influencing four variables each. The model also says we want to estimate the covariance between the two factors (last line). |
| 89 | + |
| 90 | +```{r} |
| 91 | +wisc3.model <-' |
| 92 | +VC =~ Info + Sim + Vocab + Comp |
| 93 | +VS =~ PicComp + PicArr + BlkDsgn + ObjAsmb |
| 94 | +VC ~~ VS |
| 95 | +' |
| 96 | +``` |
| 97 | + |
| 98 | +We'll also define a vector of fit indices so we can easily request them after fitting a model. |
| 99 | + |
| 100 | +```{r} |
| 101 | +# specify fit indices of interest |
| 102 | +fit.indices <- c("chisq", "df", "cfi", "rmsea", "srmr", "mfi") |
| 103 | +``` |
| 104 | + |
| 105 | +## Configural Invariance |
| 106 | + |
| 107 | +This fits the same model to both groups and allows all parameters to be freely estimated. In other words, we're doing a CFA with each group. Notice in the summary output each group's parameters are different. |
| 108 | + |
| 109 | +```{r} |
| 110 | +configural.fit <- cfa(wisc3.model, |
| 111 | + sample.cov = combined.cov, |
| 112 | + sample.nobs = combined.n, |
| 113 | + sample.mean = combined.means) |
| 114 | +summary(configural.fit) |
| 115 | +``` |
| 116 | + |
| 117 | +Fit indices look good. |
| 118 | + |
| 119 | +- CFI = comparative fit index (0.95 or greater) |
| 120 | +- RMSEA = root mean square error of approximation (0.06 or lower) |
| 121 | +- SRMR = standardized root mean square residual (0.08 or below) |
| 122 | +- MFI = McDonald's Fit Index (0.95 or greater) |
| 123 | + |
| 124 | +```{r} |
| 125 | +fitMeasures(configural.fit, fit.indices) |
| 126 | +``` |
| 127 | + |
| 128 | +And the residuals are small. Notice everything is smaller than 2. That's good. |
| 129 | + |
| 130 | +```{r} |
| 131 | +residuals(configural.fit, type = "normalized") |
| 132 | +``` |
| 133 | + |
| 134 | +This is a good fitting model for _each group_. |
| 135 | + |
| 136 | +## Weak Invariance |
| 137 | + |
| 138 | +Now constrain loadings to be equal between groups. Same code as above with one additional argument: `group.equal = "loadings"`. Notice in the summary output that all the loadings (listed under Latent Variables) are equal in both groups. |
| 139 | + |
| 140 | +```{r} |
| 141 | +weak.fit <- cfa(wisc3.model, |
| 142 | + sample.cov = combined.cov, |
| 143 | + sample.nobs = combined.n, |
| 144 | + sample.mean = combined.means, |
| 145 | + group.equal = "loadings") |
| 146 | +summary(weak.fit) |
| 147 | +``` |
| 148 | + |
| 149 | +According to fit measures this is also a good model. |
| 150 | + |
| 151 | +```{r} |
| 152 | +fitMeasures(weak.fit, fit.indices) |
| 153 | +``` |
| 154 | + |
| 155 | +And the residuals look good as well, though they are getting bigger. |
| 156 | + |
| 157 | +```{r} |
| 158 | +residuals(weak.fit, type = "normalized") |
| 159 | +``` |
| 160 | +Finally, we could formally compare the models using a Chi-squared difference test (aka, Log-likelihood ratio test). We can do this with the `lavTestLRT()` function. The null hypothesis is no difference between the models. A small p-value provides evidence against this hypothesis and suggests a preference for the more complex model (ie, the model with fewer degrees of freedom). Below there is some evidence against the weak invariance model (p = 0.049), however AIC and BIC metrics suggest otherwise. Lower AIC/BIC values are better and I don't see any reason to reject the weak invariance model. |
| 161 | + |
| 162 | +```{r} |
| 163 | +lavTestLRT(configural.fit, weak.fit) |
| 164 | +``` |
| 165 | + |
| 166 | + |
| 167 | +This is also a good model. It appears the manifest (observed) variables for each group are influenced in the same way by the two factors. |
| 168 | + |
| 169 | +## Strong Invariance |
| 170 | + |
| 171 | +Now constrain both loadings and intercepts to be equal. Notice the `group.equal` argument now has a vector: `c("loadings", "intercepts")`. Notice in the summary output that all the loadings (Latent Variables) and Intercepts are equal in both groups. |
| 172 | + |
| 173 | +```{r} |
| 174 | +strong.fit <- cfa(wisc3.model, |
| 175 | + sample.cov = combined.cov, |
| 176 | + sample.nobs = combined.n, |
| 177 | + sample.mean = combined.means, |
| 178 | + group.equal = c("loadings", "intercepts")) |
| 179 | +summary(strong.fit) |
| 180 | +``` |
| 181 | + |
| 182 | +The fit measures for this model are not so good. |
| 183 | + |
| 184 | +```{r} |
| 185 | +fitMeasures(strong.fit, fit.indices) |
| 186 | +``` |
| 187 | + |
| 188 | +But the residuals are mostly OK. The only point of strain is the residual for the "Sim" mean (2.45) in the manic group. |
| 189 | + |
| 190 | +```{r} |
| 191 | +residuals(strong.fit, type = "normalized") |
| 192 | +``` |
| 193 | + |
| 194 | +The Chi-squared difference test suggests we reject the strong invariance model for the weak invariance model based on the small p-value. |
| 195 | + |
| 196 | +```{r} |
| 197 | +lavTestLRT(weak.fit, strong.fit) |
| 198 | +``` |
| 199 | + |
| 200 | +For sake of completeness, let's move to the next model. |
| 201 | + |
| 202 | +## Strict Invariance |
| 203 | + |
| 204 | +Now constrain loadings, intercepts and variances to be equal. Notice the `group.equal` argument has the vector: `c("loadings", "intercepts", "residuals")`. Notice in the summary output that all the loadings (Latent Variables), intercepts and variances are equal in both groups. |
| 205 | + |
| 206 | + |
| 207 | +```{r} |
| 208 | +strict.fit <- cfa(wisc3.model, |
| 209 | + sample.cov = combined.cov, |
| 210 | + sample.nobs = combined.n, |
| 211 | + sample.mean = combined.means, |
| 212 | + group.equal = c("loadings", "intercepts", "residuals")) |
| 213 | +summary(strict.fit) |
| 214 | +``` |
| 215 | + |
| 216 | +Fit measures are not too good. The CFI is too low and RMSEA is too high. |
| 217 | + |
| 218 | +```{r} |
| 219 | +fitmeasures(strict.fit, fit.indices) |
| 220 | +``` |
| 221 | + |
| 222 | +However, the residuals look good for the most part. The only point of strain is the residual for the "PicCmp" variance (-2.829) in the manic group. |
| 223 | + |
| 224 | +```{r} |
| 225 | +residuals(strict.fit, type = "normalized") |
| 226 | +``` |
| 227 | + |
| 228 | +The Chi-squared test favors the strong invariance model based on AIC and p-value, but the BIC actually points to the strict fit. |
| 229 | + |
| 230 | +```{r} |
| 231 | +lavTestLRT(strong.fit, strict.fit) |
| 232 | +``` |
| 233 | + |
| 234 | + |
| 235 | + |
| 236 | +## Partial Strict Invariance |
| 237 | + |
| 238 | +If we want, we can remove constraints for certain parameters using the `group.partial` argument. For example, to allow the variance for PicCmp to be estimated separately between the groups, add the line `group.partial = "PicComp ~~ PicComp"`. In the summary, notice under Variances that "PicComp" gets a separate estimate in each group. |
| 239 | + |
| 240 | +```{r} |
| 241 | +strict.fit2 <- cfa(wisc3.model, |
| 242 | + sample.cov = combined.cov, |
| 243 | + sample.nobs = combined.n, |
| 244 | + sample.mean = combined.means, |
| 245 | + group.equal = c("loadings", "intercepts", "residuals"), |
| 246 | + group.partial = "PicComp ~~ PicComp") |
| 247 | +summary(strict.fit2) |
| 248 | +``` |
| 249 | + |
| 250 | +The fit measures are not great... |
| 251 | + |
| 252 | +```{r} |
| 253 | +fitmeasures(strict.fit2, fit.indices) |
| 254 | +``` |
| 255 | + |
| 256 | +But the residuals look pretty good. |
| 257 | + |
| 258 | +```{r} |
| 259 | +residuals(strict.fit2, type = "normalized") |
| 260 | +``` |
| 261 | + |
| 262 | +Obviously one has to wonder if we would arrive at the same conclusions using a new sample of data. We don't want to build a model that's too specific to our sample. That's would be overfitting. |
| 263 | + |
| 264 | +## References |
| 265 | + |
| 266 | +- Beaujean, A. A. (2014) _Latent Variable Modeling Using R_. Routledge. |
| 267 | +- Beaujean, A. A., Freeman, M. J., Youngstrom, E., & Carlson, G. (2012). The structure of cognitive abilities in youths with manic symptoms: a factorial invariance study. Assessment, 19(4), 462–471. <https://doi.org/10.1177/1073191111399037> |
| 268 | +- R Core Team (2025). _R: A Language and Environment for Statistical Computing_. R Foundation for Statistical Computing, Vienna, Austria. <https://www.R-project.org/>. |
| 269 | +- Rosseel, Y. (2012). lavaan: An R Package for Structural Equation Modeling. _Journal of Statistical Software_, 48(2), 1-36. <https://doi.org/10.18637/jss.v048.i02> |
0 commit comments