Analítica de Personas · Semestre otoño 2026 · Semana 11 · Prof. René Gempp
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:
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.
tidy(dtm). La función tidy() de tidytext funciona con objetos de tm y topicmodels, igual que broom::tidy() funciona con modelos estadísticos.
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:
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.
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:
dtm: la document-term matrix.k: el número de tópicos. Tú lo eliges. No hay un valor correcto a priori.control = list(seed = ...): fija la semilla para que el resultado sea reproducible. Sin semilla, el LDA produce resultados diferentes cada vez que lo corres.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()
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.
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".
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.
# 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)
El número de tópicos (k) es la decisión más influyente del análisis y no tiene una respuesta única. Opciones:
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")
Ajusta el modelo con k = 3, 4, 5, 6. Mira las top 10 palabras de cada tópico. Pregúntate:
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.
igraph y ggraphPara 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:
ggraph(grafo, layout = "fr"): inicia el gráfico. "fr" es el algoritmo de layout Fruchterman-Reingold, que coloca nodos conectados cerca entre sí. Otras opciones: "kk" (Kamada-Kawai), "circle", "tree".geom_edge_link(): dibuja las aristas (conexiones). edge_alpha y edge_width modulan la transparencia y el grosor según la co-ocurrencia.geom_node_point(): dibuja los nodos (palabras) como puntos.geom_node_text(): agrega las etiquetas. repel = TRUE evita la superposición.igraph, ggraph, graph_from_data_frame() — son exactamente las mismas. Lo que cambia es lo que representan los nodos.
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).
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)
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ón | Text mining algorítmico | Resumen de IA (LLM) |
|---|---|---|
| Reproducibilidad | Total: mismo código → mismo resultado | Baja: mismo prompt → respuestas distintas |
| Transparencia | Alta: cada paso es auditable | Baja: no sabes qué ponderó |
| Sensibilidad al matiz | Baja: bag-of-words | Alta: entiende sarcasmo, contexto |
| Cuantificabilidad | Alta: frecuencias, proporciones, probabilidades | Baja: produce texto, no números |
| Escalabilidad | Alta: millones de documentos | Limitada: 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.
| Función | Paquete | ¿Para qué? |
|---|---|---|
unnest_tokens() | tidytext | Tokenizar texto (palabras, bigramas, oraciones) |
anti_join(stopwords) | dplyr | Eliminar stopwords |
count(word) | dplyr | Frecuencia de palabras |
wordcloud2() | wordcloud2 | Wordcloud interactiva |
separate() / unite() | tidyr | Manipular bigramas |
inner_join(lexico) | dplyr | Análisis de sentimiento |
cast_dtm() | tidytext | Tidy → Document-Term Matrix |
LDA() | topicmodels | Ajustar modelo de tópicos |
tidy(lda, "beta") | tidytext | Palabras por tópico |
tidy(lda, "gamma") | tidytext | Documentos por tópico |
reorder_within() | tidytext | Reordenar en facetas |
pairwise_count() | widyr | Co-ocurrencia de palabras |
pairwise_cor() | widyr | Correlación phi entre palabras |
graph_from_data_frame() | igraph | Crear grafo |
ggraph() + geom_edge_link() + geom_node_point() | ggraph | Visualizar 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.