# Exploratory factor analysis for ordinal categorical data

## Install required packages

wants <- c("GPArotation", "mvtnorm", "polycor", "psych")
has   <- wants %in% rownames(installed.packages())
if(any(!has)) install.packages(wants[!has])

## Factor analysis

### Simulate categorical data based on continuous variables

First, let’s simulate 200 observations from 6 variables, coming from 2 orthogonal factors. I’ll take a couple of intermediate steps and start with multivariate normal continuous data that I later dichotomize. That way, we can compare Pearson correlations with polychoric correlations, and compare factor loadings from continuous data with that from dichotomous data and the true loadings.

set.seed(123)
N <- 200                       # number of observations
P <- 6                         # number of variables
Q <- 2                         # number of factors

Lambda <- matrix(c(0.7,-0.4, 0.8,0, -0.2,0.9, -0.3,0.4, 0.3,0.7, -0.8,0.1),
nrow=P, ncol=Q, byrow=TRUE)

Now simulate the actual data from the model $$x = \Lambda f + e$$, with $$x$$ being the observed variable values of a person, $$\Lambda$$ the true loadings matrix, $$f$$ the latent factor score, and $$e$$ iid, mean 0, normal errors.

# factor scores (uncorrelated factors)
library(mvtnorm)               # for rmvnorm()
FF <- rmvnorm(N, mean=c(5, 15), sigma=diag(Q))

# matrix with iid, mean 0, normal errors
E   <- rmvnorm(N, rep(0, P), diag(P))
X   <- FF %*% t(Lambda) + E    # matrix with variable values
dfX <- data.frame(X)           # data also as a data frame

Now let’s categorize the data. We’ll keep the data in two formats: as a data frame with ordered factors, and as a numeric matrix. hetcor() from package polycor gives us the polychoric correlation matrix we’ll later use for the FA.

# categorize variables into a list of ordered factors
lOrd <- lapply(dfX, function(x) {
cut(x, breaks=quantile(x), include.lowest=TRUE,
ordered=TRUE, labels=LETTERS[1:4]) })
dfOrd  <- data.frame(lOrd)     # combine list into a data frame
ordNum <- data.matrix(dfOrd)   # categorized data as a numeric matrix

### Factor analysis for ordered categorical data

Use the polychoric correlation matrix to do a regular FA.

library(polycor)               # for hetcor()
pc <- hetcor(dfOrd, ML=TRUE)   # polychoric corr matrix
library(psych)
faPC <- fa(r=pc$correlations, nfactors=2, n.obs=N, rotate="varimax") faPC$loadings

MR1    MR2
X1  0.558 -0.225
X2  0.605
X3 -0.161  0.845
X4 -0.192  0.306
X5  0.341  0.527
X6 -0.610

MR1   MR2
Proportion Var 0.205 0.189
Cumulative Var 0.205 0.394

It is possible to skip the step of calculating the polychoric correlation matrix, and directly use fa.poly() from package psych, which does the same thing in the end. This function accepts the raw dichotomous data as a numeric matrix.

# polychoric FA
faPCdirect <- fa.poly(ordNum, nfactors=2, rotate="varimax")
faPCdirect$fa$loadings         # loadings are the same as above ...

MR1    MR2
X1  0.559 -0.224
X2  0.603
X3 -0.162  0.844
X4 -0.192  0.306
X5  0.340  0.527
X6 -0.612

MR1   MR2
Proportion Var 0.205 0.189
Cumulative Var 0.205 0.394

## Factor scores

For factor scores, look at package ltm which has a factor.scores() function specifically for polytomous outcome data. An example is provided on this page.

You can visualize the loadings from the factor analysis using factor.plot() and fa.diagram(), both from package psych. For some reason, factor.plot() accepts only the $fa component of the result from fa.poly(), not the full object. factor.plot(faPCdirect$fa, cut=0.5)
fa.diagram(faPCdirect)

## Determine number of factors

Parallel analysis and a “very simple structure” analysis provide help in selecting the number of factors. Again, package psych has the required functions. vss() takes the polychoric correlation matrix as an argument.

fap <- fa.parallel.poly(ordNum)   # parallel analysis for dichotomous data
fap
Call: fa.parallel.poly(x = ordNum)
Error in data.frame(fa = x$fa.values, fa.sam = x$fa.simr, fa.sim = x$fa.sim, : arguments imply differing number of rows: 6, 0 vss(pc$correlations, n.obs=N, rotate="varimax")  # very simple structure

Very Simple Structure
Call: vss(x = pc\$correlations, rotate = "varimax", n.obs = N)
VSS complexity 1 achieves a maximimum of 0.62  with  4  factors
VSS complexity 2 achieves a maximimum of 0.74  with  5  factors

The Velicer MAP achieves a minimum of NA  with  1  factors
BIC achieves a minimum of  NA  with  2  factors
Sample Size adjusted BIC achieves a minimum of  NA  with  2  factors

Statistics by number of factors
vss1 vss2   map dof   chisq    prob sqresid  fit RMSEA BIC SABIC complex
1 0.41 0.00 0.076   9 7.8e+01 4.4e-13     4.6 0.41 0.195  30  58.6     1.0
2 0.58 0.69 0.107   4 8.2e+00 8.4e-02     2.4 0.69 0.072 -13  -0.3     1.3
3 0.60 0.73 0.223   0 4.7e-01      NA     1.9 0.76    NA  NA    NA     1.4
4 0.62 0.73 0.400  -3 8.3e-09      NA     1.6 0.79    NA  NA    NA     1.5
5 0.62 0.74 1.000  -5 0.0e+00      NA     1.6 0.79    NA  NA    NA     1.6
6 0.62 0.74    NA  -6 0.0e+00      NA     1.6 0.79    NA  NA    NA     1.6
eChisq    SRMR eCRMS eBIC
1 1.3e+02 1.5e-01 0.188   79
2 6.7e+00 3.3e-02 0.064  -15
3 3.1e-01 7.2e-03    NA   NA
4 7.4e-09 1.1e-06    NA   NA
5 7.4e-19 1.1e-11    NA   NA
6 7.4e-19 1.1e-11    NA   NA

## Detach (automatically) loaded packages (if possible)

try(detach(package:psych))
try(detach(package:polycor))
try(detach(package:mvtnorm))

(based on an answer I wrote on CrossValidated)