16 min read

Análisis de Clases Latentes

El Análisis de Clases Latentes

En una entrada anterior exploramos algunas respuestas al cuestionario de la Encuestra Nacional de Migración, específicamente a un bloque que reporta actitudes sobre tolerancia. Analizando el gráfico encontramos que hay más tolerancia a la diversidad política, cultural, racial, religiosa, étnica y de capacidades que a la diversidad de género y estigmas asociados (HIV).

El gráfico nos permite ver los patrones de respuesta de cada pregunta, pero no nos dice nada sobre las relaciones entre respuestas. Es razonable pensar que quienes respondieron “Sí” a una pregunta en general también lo hicieron para las demás, pero no lo sabemos. Quizás hay más que estos dos grupos tolerantes/intolerantes que señalan nuestra limitadísima hipótesis y haya ámbitos de tolerancia. Algo de eso parece indicarnos el patrón de respuesta diferente a las preguntas sobre diversidad de género y estigmas.

El Análisis de Clases Latentes nos permite explorar exactamente esto: patrones de respuestas en variables observadas categóricas, infiriendo una variable latente también categórica que define grupos con patrones de respuestas similares. De este modo en lugar de tener que describir las respuestas en todo un bloque de variables observadas podemos describir las probabilidades de respuesta en la variable latente. No solo eso, también podemos estimar la proporción de observaciones en cada clase. Esto nos dará un mapa más preciso de las actitudes de tolerancia en México.

Los detalles de los modelos LCA quedarán para otro post. Por ahora veremos como ajustar, validar y explorar con gráficos un modelo usando R.

Análisis de Clases Latentes en R

R cuenta con varias librerías para estimar modelos de clases latentes. Lamentablemente es uno de los ámbitos en los que, si bien hay software, no está a la altura de los programas comerciales líderes en el campo, como LatentGold y mplus. El paquete más utilizado es poLCA, que permite ajustar modelos de conglomerados con covariadas. Sin embargo poLCA omite algunas funciones avanzadas como especificar restricciones al modelo, calcular medidas relevantes como los Residuos Bivariados a efectos de validación, manejar casos perdidos o, más importante, aplicar ponderadores muestrales. Otro paquete interesante es LCCA, que es mucho más rápido que poLCA y permite usar pesos muestrales, incluyendo diseños estratificados. El gran problema de LCCA es que no es software libre, no está disponible en CRAN y funciona solamente en Windows. Además su desarrollo se detuvo hace un par de años.

En este caso, y a fines exploratorios, vamos a utilizar poLCA. Los resultados no podrían considerarse finales, ya que no incorporan el ponderador muestral. Sin embargo serán una aproximación suficiente y, sin dudas, mucho más valiosos en lo sustantivo que el análisis que podemos hacer con un simple gráfico. El tipo de modelo que ajustaremos es uno de conglomerados latentes, es decir, no vamos a incluir covariadas.

Los datos

Utilizaremos el bloque p7 de la Encuesta Nacional de Migración. La base está disponible en línea en varios formatos, vamos a usar la versión de SPSS porque conserva muy bien los metadatos de los reactivos (etiquetas, códigos). Naturalmente R puede leer archivos de SPSS.

library(foreign)
library(tidyverse)
library(knitr)
library(reshape)
library(poLCA)
library(parallel)
library(broom)

migracion <- read.spss("http://www.losmexicanos.unam.mx/migracion/encuesta_nacional/base_datos/Encuesta_Nacional_de_Migracion.sav", 
                       to.data.frame = TRUE)
diccionario <- tibble(nombre = names(migracion), 
                    etiquetas = str_trim(attr(migracion, "variable.labels")))
migracion <- as.tibble(migracion)

Con el script anterior se carga la base de datos en el entorno de R como un objeto de la clase tibble. Además se genera un diccionario con los nombres largos (las preguntas), que podemos unir con muestros datos y usar para presentar resultados.

Ajuste de un modelo básico con dos clases

En esta primera prueba ajustaremos un modelo con dos clases latentes. En el ajuste de LCA es necesario indicar cuantas clases queremos que se estimen. Se trata de una de las decisiones más importantes en este tipo de análisis1 y más adelante veremos como aprovechar las cantidades de interés que arroja el LCA para informar esa decisión.

Para ajustar el modelo usamos la función poLCA del paquete del mismo nombre. Esta función recibe cuatro argumentos importantes:

  • formula, que es la formula que especifica las variables que entran al modelo y su función como observadas o covariadas.
  • data, un data frame con columnas de la clase factor.
  • nclass, el número de clases que queremos que estime.
  • nrep, el número de veces que se repetirá el ajuste. Como los modelos LCA utilizan el criterio de máxima verosimilitud es posible que, dados unos numeros iniciales “malos”, el modelo converja en un óptimo local y los resultados sean deficientes. Es buena práctica correr varias veces el modelo para controlar este posible problema.

La fórmula

La sintaxis de fórmulas de poLCA es bastante peculiar, se trata de una fórmula de R envuelta en la función cbind(). Por los motivos que sea, así es la especificación del modelo. Miremos los datos para conocer los nombres de columna, que son el elemento principal de la formula.

migracion %>% 
  dplyr::select(starts_with("p7_")) %>% 
  names() 
##  [1] "p7_1"  "p7_2"  "p7_3"  "p7_4"  "p7_5"  "p7_6"  "p7_7"  "p7_8" 
##  [9] "p7_9"  "p7_10"

Sabiendo los nombres podemos armar la fórmula, que serán los nombres de columna dentro de cbind separados comas, y al final un ~1 para indicar que la covariada es una constante.

La formula resultante es f <- cbind(p7_1,p7_2,p7_3,p7_4,p7_5,p7_6,p7_7,p7_8,p7_9,p7_10)~1

Modelo de dos clases

Ajustamos el modelo y asignamos un nombre al resultado. poLCA produce una salida en consola de todas maneras (mala práctica…).

library(poLCA)
f <- cbind(p7_1,p7_2,p7_3,p7_4,p7_5,p7_6,p7_7,p7_8,p7_9,p7_10)~1

migracion %>% 
  dplyr::select(starts_with("p7_")) %>%  
  poLCA(f, ., nclass = 2, nrep = 4) -> LCA_2clases
## Model 1: llik = -10640.35 ... best llik = -10640.35
## Model 2: llik = -10619.9 ... best llik = -10619.9
## Model 3: llik = -10619.9 ... best llik = -10619.9
## Model 4: llik = -10619.9 ... best llik = -10619.9
## Conditional item response (column) probabilities,
##  by outcome variable, for each class (row) 
##  
## $p7_1
##            Pr(1)  Pr(2)  Pr(3)  Pr(4)  Pr(5)
## class 1:  0.9051 0.0389 0.0498 0.0062 0.0000
## class 2:  0.2536 0.4037 0.2805 0.0553 0.0069
## 
## $p7_2
##            Pr(1)  Pr(2)  Pr(3)  Pr(4)  Pr(5)
## class 1:  0.9385 0.0375 0.0191 0.0049 0.0000
## class 2:  0.1941 0.4568 0.2615 0.0773 0.0103
## 
## $p7_3
##            Pr(1)  Pr(2)  Pr(3)  Pr(4)  Pr(5)
## class 1:  0.9475 0.0430 0.0095 0.0000 0.0000
## class 2:  0.2328 0.4801 0.2150 0.0653 0.0069
## 
## $p7_4
##            Pr(1)  Pr(2)  Pr(3)  Pr(4)  Pr(5)
## class 1:  0.7510 0.1365 0.0949 0.0176 0.0000
## class 2:  0.0665 0.3347 0.4990 0.0844 0.0155
## 
## $p7_5
##            Pr(1)  Pr(2)  Pr(3)  Pr(4)  Pr(5)
## class 1:  0.8983 0.0651 0.0319 0.0031 0.0016
## class 2:  0.1320 0.4790 0.3098 0.0672 0.0121
## 
## $p7_6
##            Pr(1)  Pr(2)  Pr(3)  Pr(4)  Pr(5)
## class 1:  0.7008 0.1461 0.1279 0.0236 0.0016
## class 2:  0.0612 0.3451 0.4778 0.1021 0.0137
## 
## $p7_7
##            Pr(1)  Pr(2)  Pr(3)  Pr(4)  Pr(5)
## class 1:  0.9356 0.0490 0.0122 0.0032 0.0000
## class 2:  0.2986 0.4463 0.1984 0.0464 0.0103
## 
## $p7_8
##            Pr(1)  Pr(2)  Pr(3) Pr(4)  Pr(5)
## class 1:  0.9328 0.0587 0.0085 0.000 0.0000
## class 2:  0.1436 0.4978 0.2813 0.067 0.0103
## 
## $p7_9
##            Pr(1)  Pr(2)  Pr(3)  Pr(4)  Pr(5)
## class 1:  0.9524 0.0418 0.0049 0.0010 0.0000
## class 2:  0.1589 0.5227 0.2405 0.0677 0.0103
## 
## $p7_10
##            Pr(1)  Pr(2)  Pr(3)  Pr(4)  Pr(5)
## class 1:  0.7814 0.0996 0.1062 0.0112 0.0016
## class 2:  0.0601 0.3188 0.5128 0.0929 0.0155
## 
## Estimated class population shares 
##  0.5145 0.4855 
##  
## Predicted class memberships (by modal posterior prob.) 
##  0.5113 0.4887 
##  
## ========================================================= 
## Fit for 2 latent classes: 
## ========================================================= 
## number of observations: 1199 
## number of estimated parameters: 81 
## residual degrees of freedom: 1118 
## maximum log-likelihood: -10619.9 
##  
## AIC(2): 21401.81
## BIC(2): 21814.04
## G^2(2): 9525.526 (Likelihood ratio/deviance statistic) 
## X^2(2): 1.301352e+18 (Chi-square goodness of fit) 
## 

El resultado es algo parco y puede parecer decepcionante. En realidad la información más valiosa está en LCA_2clases. De todos modos con la salida en consola ya podemos ir viendo algunas características de la variable latente.

El primer bloque menciona 4 modelos (los que especificamos con nrep =) e indican el logaritmo de verosimilitud. El primero es un poco más alto, luego se estabiliza en el valor -10619.9. Esto es una buena señal –es muy probable que ese valor refleje el óptimo global- y un recordatorio de por qué es buena idea repetir el ajuste. Si nos hubiéramos quedado con el primero modelo hubiéramos analizado un modelo suboptimo.

El segundo bloque es más interes y presenta probabilidades de respuesta a los ítems –categorías de respuesta en el formulario de encuesta- para cada clase latente. Para la pregunta p7_1 definida como “¿Estaría dispuesto o no estaría dispuesto a permitir que en su casa vivieran personas de otra religión?” y la clase 1 la probabilidad de respuesta Sí es baja, mientras que es mucho más alta la probabilidad de responder `“Sí, en parte” o “No”. Mirando sólo esta pregunta encontramos que la clase 2 corresponde al grupo con actitudes de mayor tolerancia, mientras que la Clase 1 corresponde al grupo de menor tolerancia. Ese patrón se repite en cada pregunta.

El tercer dato a tener en cuenta es la proporción estimada de membresía a cada clase. En este caso la clase 1 cubre a un 48.55% de la muestra, mientras que la clase 2 abarca al 51.45% restante.

De todas maneras y a esta altura del análisis quizás el dato más relevante sea uno de los últimos en aparecer, el BIC. Se trata del criterio de inferencia bayesiano y es muy útil para informar la decisión sobre el número de clases óptimo. Dicho valor no es interpretable de manera absoluta, es necesario ajustar más de un modelo y comparar los BIC resultantes.

Comparar resultados con diferentes números de clases

La selección de número óptimo de clases se facilita cuando podemos comprar el ajuste –manteniendo constantes las variables y su codificación- que obtienen modelos con distinto número de clases. En este contexto un menor BIC se interpreta como el resultado de un modelo que obtiene un mejor ajuste sin agregar demasiados grados de libertad.2

En términos prácticos lo que buscamos es una lista con los resultados de 9 modelos, especificados con 1 a 9 clases. Con esa lista podemos explorar los estadísticos de cada modelo (entre ellos el BIC) para decidir cuál es el número óptimo.

Técnicamente podemos resolverlo de diferentes maneras. Una aproximación es especificar manualmente cada modelo, asignarle un nombre y luego reunirlo en una lista. Otra es generar un bucle for y generar iterativamente los modelos. R tiene una alternativa mucho mejor: funciones auxiliares que se encargan de aplicar una función a una lista, con la posibilidad de hacerlo de manera paralela aprovechando los múltiples núcleos de los procesadores actuales. El ajuste de modelos de clases latentes es un proceso que puede hacerse muy lento cuando aumentamos el número de clases, la cantidad de variables, las categorías de las variables y el número de filas. Es el tipo de tareas ideal para paralelizar. Por eso en lugar de las más conocida lapply utilizaremos mclapply(), del paquete parallel, para aplicar poLCA a una lista de clases, ya que paraleliza de manera automática la aplicación de la función.

mclapply aprovecha una característica de los sistemas operativos UNIX3 para abrir múltiples hilos, por lo que no funciona en entornos Windows. Se presenta una alternativa con lapply() para hacer reproducible al análisis en Windows.

migracion %>% 
  dplyr::select(starts_with("p7_")) -> bloque_p7

# Para UNIX
mclapply(1:9, function(x) {poLCA(f, bloque_p7, nclass = x, nrep = 5)}) -> LCA_1a9

# Para Windows: 
# lapply(1:9, function(x) {poLCA(f, bloque_p7, nclass = x, nrep = 5)}) -> LCA_1a9

En términos de R LCA_1a9 es una lista dentro de la que hay… más listas. Exactamente 9, una por cada modelo. Tenemos toda la información, solo falta consultarla.

Modelos prolijos con broom

R dispone de una gama muy amplia de modelos, la mayoría de los cuales están disponibles a través de librerías. El problema es el autor o autora de cada librería utiliza sus propias convenciones para presentar el output –los resultados- del modelo. Generalmente se trata de listas complejas y es necesario conocer en profundidad la estructura de ese objeto para poder hacer consultas. Si además debemos consultar varios modelos la situación se hace más complicada. De los creadores del problema –muchas librerías creadas por autores independientes- viene la solución: una librería que se encarga leer la salida de diferentes modelos y presentarlos de una manera consistente y en un formato que nos permite continuar el análisis, integrando el resultado del modelado a nuestro flujo de trabajo de creación de tablas, gráficos, etc. Es la librería broom, que funciona razonablemente bien como objetos de la clase poLCA.

broom tiene tres funciones básicas:

  • glance: ofrece un sumario del modelo con estadísticos de diagnóstico.

  • tidy: ofrece un resumen prolijo de los coeficientes del modelo.

  • augment: genera una tabla que reúne a los datos originales con los que se modeló con la predicción del modelo para cada fila.

Para nuestro problema necesitamos extraer los valores BIC y luego visualizarlos para identificar el punto en el que la pendiente se hace plana y agregar una clase más al modelo aporta menos información. glance puede hacerlo con facilidad, pero aún debenos sortear el problema de nuestros modelos están en una lista de listas. La solución es map_df, de la librería purrr. Esta función es similar a lapply, con una sintaxis ligeramente distinta y la capacidad de reducir el resultado a la estructura de datos especifiquemos. En este caso un data frame, por eso map_df.

map_df(LCA_1a9, glance) %>% 
  kable(caption = "Comparación de modelos con 1 a nueve clases")
Table 1: Comparación de modelos con 1 a nueve clases
logLik AIC BIC g.squared chi.squared df df.residual
-13473.847 27027.69 27231.26 15233.412 6.098074e+20 40 1159
-10619.904 21401.81 21814.04 9525.526 1.301349e+18 81 1118
-9800.385 19844.77 20465.66 7886.489 1.500443e+17 122 1077
-9211.070 18748.14 19577.69 6707.859 2.363790e+10 163 1036
-8851.133 18110.27 19148.47 5987.985 9.178111e+09 204 995
-8716.310 17922.62 19169.48 5718.338 3.849627e+13 245 954
-8536.479 17644.96 19100.48 5358.676 1.309171e+09 286 913
-8365.857 17385.71 19049.90 5017.432 1.756608e+07 327 872
-8254.691 17245.38 19118.22 4795.101 5.747826e+06 368 831
map_df(LCA_1a9, glance) %>% 
  dplyr::select(-df, -df.residual, -chi.squared) %>% 
  mutate(modelo = as.character(1:nrow(.))) %>%
  gather(cantidad, valor, -modelo) %>% 
  ggplot(aes(x= modelo, y = valor, group = 1)) + 
    geom_line() + 
    geom_point() + 
    facet_wrap(~cantidad, scales = "free") +
    labs(title = "Comparación de modelos para el bloque p7", 
         subtitle = "La pendiente se aplana a partir de modelos con cinco clases") + 
    theme_minimal()

De acuerdo con el criterio de inferencia bayesiano el número óptimo es 5, ya que agregar una sexta clase no mejora significativamente el desempeño del modelo y complica su interpretación. La clase más pequeña tiene aproximadamente un 5% de las observaciones: es un grupo pequeño, pero no insignificante.

Análisis de un modelo con 5 clases

Una vez seleccionado el modelo podemos analizarlo con mayor profundidad. En primer lugar nos interesa conocer la conformación de cada clase ¿cómo responden a las preguntas los miembros de cada clase?

Por supuesto, podemos revisar la salida en consola, pero un gráfico facilita las cosas. Para consultar las cantidades en el modelo usamos la función tidy del paquete broom, que nos regresa un data frame en el que cada fila es una combinación de pregunta, respuesta, clase y probabilidad. Con esos elementos podemos hacer nuestro gráfico.

Sugerencia los joins son una excelente alternativa cuando estamos trabajando con etiquetas cortas y etiquetas largas. Si tenemos un diccionario de variables como pares de claves y valores (variables y etiquetas) podemos usar los nombres cortos durante el manejo de datos y, justo antes de graficar o generar una tabla, unir los nombres largos y usarlos para el output.

etiquetas_item <- tibble(outcome = 1:length(levels(migracion$p7_1)), 
                         etiqueta_item = levels(migracion$p7_1)
)

LCA_1a9[[5]] %>% 
  tidy %>% 
  left_join(dplyr::rename(diccionario, variable = nombre)) %>% 
  left_join(etiquetas_item) %>% 
  mutate(etiquetas = str_remove(etiquetas, 
                                "7 ¿Estaría dispuesto o no estaría dispuesto a permitir que en su casa vivieran personas "), 
         etiquetas = str_wrap(etiquetas, 30),
         etiqueta_item = factor(etiqueta_item, 
                                levels = c("No", "Sí, en parte", "Sí", "NS", "NC")), 
         class = paste("Clase", class)) %>%
  ggplot(aes(x = etiquetas, fill = etiqueta_item, y = estimate)) + 
  geom_col() + 
  facet_wrap(~class, nrow = 1) + 
  scale_fill_viridis_d() + 
  theme_minimal() +
  theme(legend.position = "bottom") + 
  labs(fill = "Respuesta", 
       x = "¿Estaría dispuesto o no estaría dispuesto a
       permitir que en su casa vivieran personas ", 
       y = "Probabilidad de respuesta por clase") + 
  coord_flip()
Actitudes de tolerancia. Probabilidades de item/respuesta para 5 clases latentes.

Figure 1: Actitudes de tolerancia. Probabilidades de item/respuesta para 5 clases latentes.

Descripción de las clases latentes a partir de las probabilidades item-respuesta

¿Qué nos dice el gráfico? La composición de las clases por probabilidades de ítem respuesta es bastante elocuente.

La clase 4 incluye a quienes son indecisos o prefieren no opinar. Tienen probabilidades muy altas de reponder “No sabe” “No contesta”.

La clase 5 incluye a las personas menos tolerantes a la diversidad, con probabilidades altas de no estar de acuerdo a permitir que en su casa vivieran cualquiera de las personas mencionadas en las preguntas. Dentro de ese conjunto están más abiertos a recibir personas indígenas y menos abiertos a recibir personas gais, lesbianas o enfermas de SIDA.

La clase 3 es el otro extremo y responde “Sí” con similar intensidad para todas las categorías, siendo ligeramente refractaria a tolerar la diversidad religiosa (quizás la religiosidad).

La clase 1 se siente cómoda con la respuesta “Sí, en parte”, que combina con probabilidades relativamente altas de “No”.

La clase 2 es quizás la más interesante. Es el patrón de respuestas de quienes son tolerantes a la diversidad religiosa, cultural, étnica, política y física, pero mucho menos a la diversidad de género, probabilidades altas de respuestas “No” o “Sí, en parte” sólo en esos items.

Proporciones de membresía

tibble(clase = paste("Clase", 1:5), 
       prop = LCA_1a9[[5]]$P) %>% 
  ggplot(aes(x = clase, y = prop)) + 
    geom_col(fill = "white", color = "black") + 
    scale_y_continuous(labels = scales::percent) + 
    theme_minimal() + 
    labs(x = NULL, 
         y = NULL, 
         caption = "Elaboración propia
         Datos: Encuesta Nacional de Migración", 
         title = "Mayoría de actitudes tolerantes", 
         subtitle = "Excepto en diversidad de género")
Proporciones de pertenencia a clases latentes

Figure 2: Proporciones de pertenencia a clases latentes

La clase 3, aquella presenta mayor apertura, es la clase modal, como poco del 33% de la muestra, seguida de las clases 2, 1 y 5. La suma de proporciones de la clases 2 y 5, en ambos casos poco tolerantes a la diversidad de género y estigmas asociados alcanza un 40%.

El siguiente paso será incorporar covariadas a este análisis para explorar si estas actitudes varían en dependencia del género, edad, educación y ubicación geográfica de los y las respondentes. Eso será en otra entrega.

¿Para qué usar LCA?

En este simple ejercicio, que se irá refinando hasta convertirse en cuaderno, queda clara la utilidad de los mdoelos LCA. Aún utilizados sólo para generar conglomerados no permiten reducir la dimensionsionalidad de un bloque de preguntas con respuestas categóricas, revelando patrones claros en las respuestas y delimitando grupos en el proceso. Además nos ofrecen medidas muy útiles para tomar decisiones sobre la cantidad de clases o grupos óptima y nos permiten inferir, a partir de un modelo, las proporciones de membresía en cada grupo. Aún asumiendo las limitaciones de este ejemplo4 hay una conclusión clara: la sociedad mexicana es tolerante, pero falta un camino largo para extender esa tolerancia a la diversidad de género. En este caso esto nos permite afirmar


  1. Pocas clases y es posible que nos queden grupos “mezclados” en cada clase, demasiadas clases y el modelo pierde su carácter sintético, dificultando la interpretación.

  2. En un caso extremo un modelo con una clase para cada patrón de respuestas observado tendría un ajuste perfecto, pero no sería un resúmen de la información contenida en la base de datos.

  3. Como cualquier variante de Linux o el propio macOS.

  4. Para mantenerlo simple no se llevaron a cabo algunas validaciones de cumplimiento de supuestos, además de que no se aplicó el ponderador muestral.