Apunte 28 — Tópicos, redes de co-ocurrencia y cruce texto-cuantitativo

Analítica de Personas · Semestre otoño 2026 · Semana 11 · Prof. René Gempp

1. De las palabras a los temas

En el Apunte 27 aprendiste a contar palabras y a calcular sentimientos. Eso responde a qué palabras se usan y qué tono tienen. Pero no responde a de qué hablan. Para eso necesitas herramientas que descubran temas — agrupaciones de palabras que aparecen juntas porque se refieren al mismo asunto.

Este apunte cubre tres herramientas complementarias para descubrir temas:

  1. Redes de co-ocurrencia (9–11): un enfoque visual que muestra qué palabras aparecen juntas en las mismas respuestas, revelando clusters temáticos.
  2. Topic modeling con LDA (2–8): un modelo probabilístico que asigna automáticamente cada documento a una mezcla de tópicos.
  3. Cruce texto × cuantitativo (12): la integración de los temas descubiertos con los datos numéricos que ya tienes.

2. La Document-Term Matrix (DTM)

Antes de ajustar un modelo de tópicos, necesitas convertir tus datos tidy en una document-term matrix (DTM): una matriz donde cada fila es un documento (una respuesta), cada columna es un término (una palabra), y cada celda contiene la frecuencia de ese término en ese documento. La mayoría de las celdas son 0 (cada respuesta usa una fracción pequeña del vocabulario total), por eso la DTM se almacena como una matriz dispersa.

# Preparar los datos: contar palabras por documento
conteo_por_doc <- tokens_limpios |>
  count(id_empleado, word)

# Convertir a DTM con cast_dtm()
dtm <- conteo_por_doc |>
  cast_dtm(id_empleado, word, n)

dtm
# <<DocumentTermMatrix (documents: 800, terms: 1247)>>
# Non-/sparse entries: 8932/989668
# Sparsity           : 99%

La función cast_dtm() de tidytext es el puente entre el formato tidy (un token por fila) y el formato matricial que el LDA necesita. Nota la esparcidad del 99 %: eso es normal. La mayoría de las palabras no aparecen en la mayoría de las respuestas.

La operación inversa. Si alguna vez recibes una DTM y necesitas volver al formato tidy, usa tidy(dtm). La función tidy() de tidytext funciona con objetos de tm y topicmodels, igual que broom::tidy() funciona con modelos estadísticos.

3. LDA: la intuición del modelo generativo

El Latent Dirichlet Allocation (LDA; Blei, Ng & Jordan, 2003) es el modelo de tópicos más utilizado. Su intuición generativa es que cada documento fue "escrito" por un proceso en dos pasos:

  1. Primero, el autor elige una mezcla de tópicos. Por ejemplo: "esta respuesta es 60 % sobre liderazgo y 40 % sobre carga laboral."
  2. Luego, para cada palabra, elige un tópico de esa mezcla y saca una palabra del vocabulario de ese tópico. Las palabras de "liderazgo" incluyen "jefatura", "comunicación", "escucha"; las de "carga laboral" incluyen "horas", "estrés", "presión".

El LDA invierte este proceso: dado un corpus de documentos, descubre los tópicos latentes y las mezclas que mejor explican las frecuencias observadas. No sabe de antemano que hay un tema de "liderazgo" — lo descubre a partir de que las palabras "jefatura", "comunicación" y "escucha" tienden a aparecer juntas.

Analogía con el EFA. Si hiciste la Clase 6 (engagement), esto te sonará familiar. El EFA descubre factores latentes a partir de las correlaciones entre ítems. El LDA descubre tópicos latentes a partir de las co-ocurrencias entre palabras. La lógica es la misma: reducir muchas variables observadas (ítems/palabras) a pocas dimensiones latentes (factores/tópicos). La diferencia es que el EFA usa correlaciones continuas y el LDA usa frecuencias discretas.

4. Ajustar un LDA con topicmodels::LDA()

# install.packages("topicmodels")
library(topicmodels)

# Ajustar LDA con k = 4 tópicos
set.seed(2026)  # IMPORTANTE: fijar semilla para reproducibilidad
lda_modelo <- LDA(dtm, k = 4, control = list(seed = 2026))

lda_modelo
# A LDA_VEM topic model with 4 topics.

Los argumentos clave:

La semilla importa. El LDA es un algoritmo iterativo que parte de una inicialización aleatoria. Diferentes semillas pueden producir tópicos diferentes (aunque usualmente similares si el corpus tiene estructura clara). Siempre fija la semilla y repórtala en tu informe. Si los tópicos cambian mucho con distintas semillas, eso indica que la estructura temática del corpus es ambigua.

5. Extraer β: las palabras que definen cada tópico

La distribución β (beta) te dice, para cada tópico, la probabilidad de cada palabra. Las palabras con mayor β en un tópico son las que lo definen. Usa tidy() de tidytext para extraerla:

# Extraer β: probabilidades palabra-tópico
topicos_beta <- tidy(lda_modelo, matrix = "beta")

topicos_beta
# # A tibble: N × 3
#   topic term         beta
#   <int> <chr>       <dbl>
# 1     1 jefatura  0.0234
# 2     1 equipo    0.0089
# 3     1 comunicar 0.0178
# ...

# Top 10 palabras por tópico
top_beta <- topicos_beta |>
  group_by(topic) |>
  slice_max(beta, n = 10) |>
  ungroup()

6. Visualizar tópicos: facet_wrap() con reorder_within()

Quieres un gráfico de barras donde cada faceta muestra las top 10 palabras de un tópico, ordenadas por β. El problema: fct_reorder() ordena globalmente, pero necesitas ordenar dentro de cada faceta independientemente. Para eso, tidytext ofrece reorder_within() y scale_y_reordered():

top_beta |>
  mutate(term = reorder_within(term, beta, topic)) |>
  ggplot(aes(x = beta, y = term, fill = factor(topic))) +
  geom_col(show.legend = FALSE) +
  facet_wrap(~ topic, scales = "free_y") +
  scale_y_reordered() +
  scale_fill_manual(values = c("#1B2A4A", "#B85042",
                                "#0D9488", "#E8B96E")) +
  labs(
    title = "Top 10 palabras por tópico (LDA, k = 4)",
    x = "Probabilidad (β)",
    y = NULL
  ) +
  theme_minimal(base_size = 12)

El truco de reorder_within() agrega un sufijo invisible al término (e.g., "jefatura___1") para que el reordenamiento sea independiente por faceta. scale_y_reordered() elimina el sufijo en la visualización. Es un patrón específico de tidytext que se usa en casi todo gráfico facetado de tópicos.

Nombrar los tópicos

El LDA te da números (Tópico 1, 2, 3, 4). Tú les pones nombre mirando las palabras con mayor β. Si el Tópico 2 tiene alta probabilidad para "jefatura", "comunicación", "escucha", "retroalimentación", "apoyo", lo nombras "Liderazgo y supervisión". Si el Tópico 3 tiene "capacitación", "oportunidades", "carrera", "desarrollo", "aprender", lo nombras "Desarrollo profesional".

Los nombres son tuyos, no del modelo. El LDA no sabe que el Tópico 2 es sobre liderazgo. Eso es tu interpretación. Dos analistas pueden mirar las mismas palabras y nombrar el tópico de manera diferente. Es una decisión sustantiva, no algorítmica. Defiéndela con las palabras que la respaldan.

7. Extraer γ: a qué tópico pertenece cada documento

La distribución γ (gamma) te dice, para cada documento, la probabilidad de pertenecer a cada tópico:

# Extraer γ: probabilidades documento-tópico
topicos_gamma <- tidy(lda_modelo, matrix = "gamma")

topicos_gamma
# # A tibble: N × 3
#   document topic     gamma
#   <chr>    <int>     <dbl>
# 1 E001         1 0.0543
# 2 E001         2 0.812 
# 3 E001         3 0.0234
# 4 E001         4 0.110 
# ...

Cada documento tiene una γ para cada tópico. Las γ de un documento suman 1. En el ejemplo, el documento E001 tiene un γ de 0,81 para el Tópico 2 — es mayoritariamente sobre ese tema.

Tópico dominante

# Asignar cada documento a su tópico dominante
topico_dom <- topicos_gamma |>
  group_by(document) |>
  slice_max(gamma, n = 1) |>
  ungroup() |>
  rename(id_empleado = document, topico = topic)

# ¿Cuántos documentos en cada tópico?
topico_dom |>
  count(topico) |>
  mutate(pct = n / sum(n) * 100)

8. Elegir k: ¿cuántos tópicos?

El número de tópicos (k) es la decisión más influyente del análisis y no tiene una respuesta única. Opciones:

Enfoque cuantitativo: perplexity

La perplexity mide qué tan bien el modelo predice palabras nuevas. Menor perplexity = mejor ajuste. Puedes calcularla para varios k y buscar un "codo":

# Calcular perplexity para varios k
ks <- c(2, 3, 4, 5, 6, 8)
perp <- map_dbl(ks, ~ {
  m <- LDA(dtm, k = .x, control = list(seed = 2026))
  perplexity(m)
})

tibble(k = ks, perplexity = perp) |>
  ggplot(aes(x = k, y = perplexity)) +
  geom_line() + geom_point() +
  labs(title = "Perplexity por número de tópicos")

Enfoque cualitativo: coherencia semántica

Ajusta el modelo con k = 3, 4, 5, 6. Mira las top 10 palabras de cada tópico. Pregúntate:

Regla práctica. Para un corpus de 800 respuestas cortas, k entre 3 y 6 suele funcionar. Con k = 2, los tópicos son demasiado genéricos (uno positivo, uno negativo). Con k > 8, los tópicos empiezan a fragmentarse y ser ininterpretables. Para un curso de magíster, el enfoque cualitativo es suficiente y más formativo que la perplexity.

9. Redes de co-ocurrencia: de los pares al grafo

Una red de co-ocurrencia es un enfoque complementario al LDA. En vez de asignar cada documento a un tópico, muestra qué palabras aparecen juntas en los mismos documentos. Las palabras que co-ocurren frecuentemente forman clusters visuales que representan temas.

widyr::pairwise_count()

El paquete widyr (Robinson, 2020) tiene una función elegante para calcular co-ocurrencias:

# install.packages("widyr")
library(widyr)

# Contar pares de palabras que co-ocurren en la misma respuesta
cooc <- tokens_limpios |>
  pairwise_count(word, id_empleado, sort = TRUE)

cooc
# # A tibble: N × 3
#   item1       item2           n
#   <chr>       <chr>       <dbl>
# 1 jefatura    comunicación   45
# 2 comunicación jefatura      45
# 3 carga       horario        38
# ...

La función cuenta cuántas veces cada par de palabras aparece en el mismo documento (la misma respuesta). El resultado es simétrico: si "jefatura" co-ocurre con "comunicación" 45 veces, lo mismo al revés.

10. Crear y visualizar grafos con igraph y ggraph

Para visualizar la red necesitas dos paquetes: igraph (crea y manipula grafos) y ggraph (los visualiza con la gramática de ggplot2).

# install.packages(c("igraph", "ggraph"))
library(igraph)
library(ggraph)

# Filtrar: solo pares con co-ocurrencia ≥ 8
cooc_filt <- cooc |>
  filter(n >= 8)

# Crear el grafo
grafo <- graph_from_data_frame(cooc_filt, directed = FALSE)

# Visualizar
set.seed(42)
ggraph(grafo, layout = "fr") +
  geom_edge_link(aes(edge_alpha = n, edge_width = n),
                 color = "#A7BEAE", show.legend = FALSE) +
  geom_node_point(color = "#0D9488", size = 4) +
  geom_node_text(aes(label = name), repel = TRUE,
                 size = 3.5, color = "#1B2A4A") +
  labs(title = "Red de co-ocurrencia de palabras") +
  theme_void()

Desglose de la gramática de ggraph:

Conexión con la Clase 12. La semana que viene construirás redes de personas, no de palabras. Los nodos serán empleados y las aristas serán relaciones de colaboración. Las funciones — igraph, ggraph, graph_from_data_frame() — son exactamente las mismas. Lo que cambia es lo que representan los nodos.

11. Correlación phi entre palabras: pairwise_cor()

Además de la co-ocurrencia cruda (que favorece las palabras frecuentes), puedes calcular la correlación phi entre pares de palabras. La phi mide si dos palabras co-ocurren más de lo esperado por azar, controlando por sus frecuencias individuales:

# Correlación phi entre palabras
cor_palabras <- tokens_limpios |>
  group_by(word) |>
  filter(n() >= 10) |>  # solo palabras con frecuencia ≥ 10
  ungroup() |>
  pairwise_cor(word, id_empleado, sort = TRUE)

# Top correlaciones: palabras que aparecen juntas más de lo esperado
head(cor_palabras, 20)

La correlación phi va de −1 a 1. Un phi alto (e.g., 0,45) entre "carga" y "laboral" significa que estas palabras co-ocurren mucho más de lo que esperarías dada su frecuencia individual. Es la misma lógica de la correlación de Pearson, pero para datos binarios (la palabra aparece o no en cada documento).

12. Cruce texto × cuantitativo: la triangulación

El momento más poderoso del análisis es cuando cruzas los resultados del text mining con los datos cuantitativos que ya tienes. El mecanismo es el left_join() que dominas desde la Clase 2:

# Join del tópico dominante con datos de empleados
topicos_con_datos <- topico_dom |>
  left_join(empleados, by = "id_empleado")

# Pregunta 1: ¿Qué tópicos dominan en cada departamento?
topicos_con_datos |>
  count(departamento, topico) |>
  group_by(departamento) |>
  mutate(pct = n / sum(n) * 100) |>
  ungroup()

# Pregunta 2: ¿Los que se fueron hablan de temas distintos?
topicos_con_datos |>
  count(rotacion, topico) |>
  group_by(rotacion) |>
  mutate(pct = n / sum(n) * 100)

# Pregunta 3: ¿Los jóvenes hablan más de desarrollo?
topicos_con_datos |>
  mutate(grupo_edad = if_else(edad < 30, "< 30", "≥ 30")) |>
  count(grupo_edad, topico) |>
  group_by(grupo_edad) |>
  mutate(pct = n / sum(n) * 100)
El argumento para el directorio. La triangulación — encontrar la misma conclusión desde dos fuentes independientes — es más persuasiva que cualquier fuente por separado. Si la encuesta de engagement (Clase 6) dice que Soporte Técnico tiene el peor engagement, y el text mining dice que Soporte Técnico es donde más se habla de liderazgo deficiente, el argumento se fortalece. "No les traemos solo un número ni solo una queja. Les traemos la convergencia de la evidencia."

13. Text mining vs. IA generativa: cuándo usar cada uno

En 2026, tienes dos herramientas para analizar texto: el text mining algorítmico (lo que aprendiste en estos apuntes) y la IA generativa (pedirle a un LLM que resuma los textos). No son sustitutos; son complementos:

DimensiónText mining algorítmicoResumen de IA (LLM)
ReproducibilidadTotal: mismo código → mismo resultadoBaja: mismo prompt → respuestas distintas
TransparenciaAlta: cada paso es auditableBaja: no sabes qué ponderó
Sensibilidad al matizBaja: bag-of-wordsAlta: entiende sarcasmo, contexto
CuantificabilidadAlta: frecuencias, proporciones, probabilidadesBaja: produce texto, no números
EscalabilidadAlta: millones de documentosLimitada: ventana de contexto

La recomendación práctica: usa text mining para las métricas (frecuencias, sentimiento, distribución de tópicos) y la IA para la interpretación (nombrar temas, detectar matices, redactar el informe). Pero siempre reporta que usaste ambos y cómo los combinaste.

14. Resumen del kit técnico

FunciónPaquete¿Para qué?
unnest_tokens()tidytextTokenizar texto (palabras, bigramas, oraciones)
anti_join(stopwords)dplyrEliminar stopwords
count(word)dplyrFrecuencia de palabras
wordcloud2()wordcloud2Wordcloud interactiva
separate() / unite()tidyrManipular bigramas
inner_join(lexico)dplyrAnálisis de sentimiento
cast_dtm()tidytextTidy → Document-Term Matrix
LDA()topicmodelsAjustar modelo de tópicos
tidy(lda, "beta")tidytextPalabras por tópico
tidy(lda, "gamma")tidytextDocumentos por tópico
reorder_within()tidytextReordenar en facetas
pairwise_count()widyrCo-ocurrencia de palabras
pairwise_cor()widyrCorrelación phi entre palabras
graph_from_data_frame()igraphCrear grafo
ggraph() + geom_edge_link() + geom_node_point()ggraphVisualizar redes

Todas las funciones de análisis (count(), filter(), group_by(), left_join(), ggplot()) son las mismas que usas desde la Clase 1. Lo nuevo es la preparación (tokenización, stopwords, DTM) y dos paquetes específicos (topicmodels para LDA, widyr/ggraph para redes). El texto es un dato más; el tidyverse es la gramática.