Confirmatory Factor Analysis

This mostly follows Bollen (1989) for maximum likelihood estimation of a confirmatory factor analysis. In the following example we will examine a situation where there are two underlying (correlated) latent variables for 8 observed responses. The code as is will only work with this toy data set. Setup uses the psych and mvtnorm packages, and results are checked against the lavaan package.

Data Setup

For the data we will simulate observed variables with specific loadings on two latent constructs (factors).

library(tidyverse)

set.seed(123)

# loading matrix
lambda = matrix(
  c(1.0, 0.5, 0.8, 0.6, 0.0, 0.0, 0.0, 0.0,
    0.0, 0.0, 0.0, 0.0, 1.0, 0.7, 0.6, 0.8), 
  nrow = 2, 
  byrow = TRUE
)

# correlation of factors
phi = matrix(c(1, .25, .25, 1), nrow = 2, byrow = TRUE)  

# factors and some noise
factors = mvtnorm::rmvnorm(1000, mean = rep(0, 2), sigma = phi, "chol")
e = mvtnorm::rmvnorm(1000, sigma = diag(8))

# observed responses
y = 0 + factors%*%lambda + e

# Examine
#dim(y)
psych::describe(y)
   vars    n  mean   sd median trimmed  mad   min  max range  skew kurtosis   se
X1    1 1000  0.05 1.44   0.05    0.05 1.42 -5.13 4.51  9.63  0.00     0.01 0.05
X2    2 1000  0.00 1.08  -0.01    0.00 1.04 -3.34 3.25  6.59  0.00    -0.06 0.03
X3    3 1000  0.01 1.27   0.05    0.01 1.17 -5.33 4.09  9.42 -0.06     0.32 0.04
X4    4 1000  0.00 1.14  -0.03   -0.01 1.13 -3.85 3.98  7.83  0.10     0.16 0.04
X5    5 1000  0.04 1.43   0.10    0.05 1.39 -4.43 5.21  9.63 -0.02     0.07 0.05
X6    6 1000 -0.02 1.22  -0.01   -0.02 1.27 -3.35 4.68  8.03  0.04    -0.10 0.04
X7    7 1000  0.02 1.14   0.02    0.01 1.10 -3.08 3.66  6.74  0.11     0.00 0.04
X8    8 1000  0.01 1.29   0.02    0.01 1.24 -3.68 4.50  8.18 -0.02     0.10 0.04
# round(cor(y), 2)

# see the factor structure
psych::cor.plot(cor(y))

# example exploratory fa
#psych::fa(y, nfactors=2, rotate="oblimin") 

Functions

We will have two separate estimation functions, one for the covariance matrix, and another for the correlation matrix.

# measurement model, covariance approach

# trace function, strangely absent from base R
tr <- function(mat) {
  sum(diag(mat), na.rm = TRUE)
}

cfa_cov <- function (parms, data) {
  # Arguments- 
  # parms: initial values (named)
  # data: raw data
  
  # Extract parameters by name
  
  l1 = c(1, parms[grep('l1', names(parms))])      # loadings for factor 1
  l2 = c(1, parms[grep('l2', names(parms))])      # loadings for factor 2
  
  cov0 = parms[grep('cov', names(parms))]         # factor covariance, variances
  
  # Covariance matrix
  S = cov(data)*((nrow(data)-1)/nrow(data))       # ML covariance div by N rather than N-1, the multiplier adjusts
  
  # loading estimates
  lambda = cbind(
    c(l1, rep(0,length(l2))),
    c(rep(0,length(l1)), l2)
  )
  
  # disturbances
  dist_init = parms[grep('dist', names(parms))]    
  disturbs  = diag(dist_init)
  
  # factor correlation
  phi_init = matrix(c(cov0[1], cov0[2], cov0[2], cov0[3]), 2, 2)  #factor cov/correlation matrix
  
  # other calculations and log likelihood
  sigtheta = lambda%*%phi_init%*%t(lambda) + disturbs
  
  # in Bollen p + q (but for the purposes of this just p) = tr(data)
  pq = dim(data)[2] 
  
  # a reduced version; Bollen 1989 p.107
  # ll = -(log(det(sigtheta)) + tr(S%*%solve(sigtheta)) - log(det(S)) - pq) 
  
  # this should be the same as Mplus H0 log likelihood
  ll = ( (-nrow(data)*pq/2) * log(2*pi) ) - 
    (nrow(data)/2) * ( log(det(sigtheta)) + tr(S%*%solve(sigtheta)) )
  
  -ll
}

We can use the correlation matrix for standardized results. Lines correspond to those in cfa_cov.

cfa_cor <- function (parms, data) {
  
  l1 = parms[grep('l1', names(parms))]      # loadings for factor 1
  l2 = parms[grep('l2', names(parms))]      # loadings for factor 2
  cor0 = parms[grep('cor', names(parms))]   # factor correlation
  
  S = cor(data)
  
  lambda = cbind(
    c(l1, rep(0,length(l2))),
    c(rep(0,length(l1)), l2)
  )
  
  dist_init = parms[grep('dist', names(parms))]
  disturbs  = diag(dist_init)
  
  phi_init = matrix(c(1, cor0, cor0, 1), ncol=2)
  
  sigtheta = lambda%*%phi_init%*%t(lambda) + disturbs
  pq = dim(data)[2]
  
  #ll = ( log(det(sigtheta)) + tr(S%*%solve(sigtheta)) - log(det(S)) - pq )
  
  ll = ( (-nrow(data)*pq/2) * log(2*pi) ) - 
    (nrow(data)/2) * ( log(det(sigtheta)) + tr(S%*%solve(sigtheta)) )
  
  -ll
}

Estimation

Corresponding to the functions, we will get results based on the covariance and correlation matrix respectively.

Raw

Set initial values.

par_init_cov = c(rep(1, 6), rep(.05, 8), rep(.5, 3)) 
names(par_init_cov) = rep(c('l1','l2', 'dist', 'cov'), c(3, 3, 8, 3))

Estimate and extract.

fit_cov = optim(
  par  = par_init_cov,
  fn   = cfa_cov,
  data = y,
  method  = "L-BFGS-B",
  lower   = 0
) 

loadings_cov = data.frame(
  f1 = c(1, fit_cov$par[1:3], rep(0, 4)),
  f2 = c(rep(0, 4), 1, fit_cov$par[4:6])
)

disturbances_cov = fit_cov$par[7:14]

Standardized

par_init_cor = c(rep(1, 8), rep(.05, 8), 0) #for cor
names(par_init_cor) = rep(c('l1', 'l2', 'dist', 'cor'), c(4, 4, 8, 1))
fit_cor = optim(
  par  = par_init_cor,
  fn   = cfa_cor,
  data = y,
  method = "L-BFGS-B",
  lower  = 0,
  upper  = 1
)

loadings_cor = matrix(
  c(fit_cor$par[1:4], rep(0, 4), rep(0, 4), fit_cor$par[5:8]), 
  ncol = 2
)

disturbances_cor = fit_cor$par[9:16]

Comparison

Gather results for summarizing.

results = list(
  raw = list(
    loadings = round(data.frame(loadings_cov, Variances = disturbances_cov), 3),
    cov.fact = round(matrix(c(fit_cov$par[c(15, 16, 16, 17)]), ncol = 2) , 3)
  ),
  
  standardized = list(
    loadings = round(
      data.frame(
        loadings_cor,
        Variances = disturbances_cor,
        Rsq = (1 - disturbances_cor)
      ), 3),
    cor.fact = round(matrix(c(1, fit_cor$par[c(17, 17)], 1), ncol = 2), 3)
  ),
  
  # note inclusion of intercepts for total number of par
  fit_lav = data.frame(
    ll  = fit_cov$value,
    AIC = 2*fit_cov$value + 2 * (length(par_init_cov) + ncol(y)),
    BIC = 2*fit_cov$value + log(nrow(y)) * (length(par_init_cov) + ncol(y))
  )  
)

results
$raw
$raw$loadings
     f1    f2 Variances
1 1.000 0.000     1.073
2 0.459 0.000     0.955
3 0.836 0.000     0.908
4 0.570 0.000     0.961
5 0.000 1.000     1.047
6 0.000 0.739     0.941
7 0.000 0.575     0.972
8 0.000 0.803     1.034

$raw$cov.fact
      [,1]  [,2]
[1,] 1.006 0.185
[2,] 0.185 0.989


$standardized
$standardized$loadings
     X1    X2 Variances   Rsq
1 0.696 0.000     0.516 0.484
2 0.426 0.000     0.819 0.181
3 0.661 0.000     0.563 0.437
4 0.504 0.000     0.746 0.254
5 0.000 0.697     0.514 0.486
6 0.000 0.604     0.636 0.364
7 0.000 0.502     0.748 0.252
8 0.000 0.618     0.618 0.382

$standardized$cor.fact
      [,1]  [,2]
[1,] 1.000 0.186
[2,] 0.186 1.000


$fit_lav
        ll      AIC      BIC
1 12497.68 25045.37 25168.06

Compare with lavaan.

library(lavaan)

y = data.frame(y)

model = '
  F1  =~ X1 + X2 + X3 + X4
  F2  =~ X5 + X6 + X7 + X8
'

fit_lav = cfa(
  model,
  data  = y,
  mimic = 'Mplus',
  estimator = 'ML'
)

fit_lav_std = cfa(
  model,
  data  = y,
  mimic = 'Mplus',
  estimator = 'ML',
  std.lv = TRUE,
  std.ov = TRUE
) 

# note that lavaan does not count the intercepts among the free params for
# AIC/BIC by default, (can get its result via -2 * as.numeric(lls) + k *
# attr(lls, "df")), but the mimic='Mplus' should have them correspond to optim's
# results

summary(fit_lav,
        fit.measures = TRUE,
        standardized = TRUE)
lavaan 0.6-7 ended normally after 30 iterations

  Estimator                                         ML
  Optimization method                           NLMINB
  Number of free parameters                         25
                                                      
  Number of observations                          1000
  Number of missing patterns                         1
                                                      
Model Test User Model:
                                                      
  Test statistic                                25.586
  Degrees of freedom                                19
  P-value (Chi-square)                           0.142

Model Test Baseline Model:

  Test statistic                              1229.322
  Degrees of freedom                                28
  P-value                                        0.000

User Model versus Baseline Model:

  Comparative Fit Index (CFI)                    0.995
  Tucker-Lewis Index (TLI)                       0.992

Loglikelihood and Information Criteria:

  Loglikelihood user model (H0)             -12497.683
  Loglikelihood unrestricted model (H1)     -12484.890
                                                      
  Akaike (AIC)                               25045.366
  Bayesian (BIC)                             25168.060
  Sample-size adjusted Bayesian (BIC)        25088.658

Root Mean Square Error of Approximation:

  RMSEA                                          0.019
  90 Percent confidence interval - lower         0.000
  90 Percent confidence interval - upper         0.035
  P-value RMSEA <= 0.05                          1.000

Standardized Root Mean Square Residual:

  SRMR                                           0.018

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
  F1 =~                                                                 
    X1                1.000                               1.003    0.696
    X2                0.459    0.046   10.043    0.000    0.460    0.426
    X3                0.836    0.066   12.590    0.000    0.839    0.661
    X4                0.570    0.050   11.450    0.000    0.572    0.504
  F2 =~                                                                 
    X5                1.000                               0.994    0.697
    X6                0.739    0.056   13.239    0.000    0.735    0.604
    X7                0.575    0.048   12.071    0.000    0.572    0.502
    X8                0.803    0.060   13.386    0.000    0.799    0.618

Covariances:
                   Estimate  Std.Err  z-value  P(>|z|)   Std.lv  Std.all
  F1 ~~                                                                 
    F2                0.185    0.046    4.014    0.000    0.186    0.186

Intercepts:
                   Estimate  Std.Err  z-value  P(>|z|)   Std.lv  Std.all
   .X1                0.054    0.046    1.173    0.241    0.054    0.037
   .X2               -0.004    0.034   -0.104    0.917   -0.004   -0.003
   .X3                0.007    0.040    0.182    0.855    0.007    0.006
   .X4                0.004    0.036    0.113    0.910    0.004    0.004
   .X5                0.044    0.045    0.964    0.335    0.044    0.031
   .X6               -0.019    0.038   -0.504    0.614   -0.019   -0.016
   .X7                0.024    0.036    0.674    0.500    0.024    0.021
   .X8                0.010    0.041    0.247    0.805    0.010    0.008
    F1                0.000                               0.000    0.000
    F2                0.000                               0.000    0.000

Variances:
                   Estimate  Std.Err  z-value  P(>|z|)   Std.lv  Std.all
   .X1                1.073    0.087   12.356    0.000    1.073    0.516
   .X2                0.955    0.047   20.128    0.000    0.955    0.819
   .X3                0.908    0.065   13.887    0.000    0.908    0.563
   .X4                0.961    0.051   18.885    0.000    0.961    0.746
   .X5                1.048    0.079   13.336    0.000    1.048    0.514
   .X6                0.941    0.056   16.867    0.000    0.941    0.636
   .X7                0.972    0.050   19.255    0.000    0.972    0.748
   .X8                1.034    0.063   16.411    0.000    1.034    0.618
    F1                1.006    0.108    9.351    0.000    1.000    1.000
    F2                0.989    0.100    9.853    0.000    1.000    1.000

Mplus

If you have access to Mplus you can use Mplus Automation to prepare the data. The following code is in Mplus syntax and will produce the above model.

library(MplusAutomation)

prepareMplusData(data.frame(y), "factsim.dat")
MODEL:
 F1 BY X1-X4;
 F2 BY X5-X8;

results:
 STDYX;