Analítica de Personas · Semestre otoño 2026 · Semana 11 · Prof. René Gempp
Hasta ahora, todos los datos que has analizado eran estructurados: columnas con tipos definidos (numérico, categórico, fecha), filas con observaciones (un empleado, una postulación, un ítem de encuesta). El texto libre — la respuesta que alguien escribe cuando le preguntas "¿hay algo que quieras agregar?" — es un dato no estructurado. No tiene columnas. No tiene categorías predefinidas. No tiene escala. Es lenguaje natural, con toda su riqueza y toda su ambigüedad.
¿Por qué importa? Porque las escalas Likert miden lo que tú decidiste preguntar. Las preguntas abiertas dejan que la persona diga lo que ella quiere decir. A veces coinciden; a veces no. Los temas que emergen del texto libre pueden revelar preocupaciones que ninguna escala anticipó: la calidad de las sillas, los microondas del comedor, la sensación de que "las promociones están arregladas", el orgullo por un proyecto reciente. El text mining es la herramienta que convierte esa masa de texto en algo analizable — sin reemplazar la lectura humana, sino complementándola.
Silge y Robinson (2017) propusieron un framework elegante: tratar el texto como un data frame donde cada fila es un token — la unidad mínima de análisis. Un token puede ser una palabra (lo más común), un bigrama, una oración o un párrafo. La idea es que una vez que el texto está en formato tidy, puedes usar todas las herramientas de dplyr y ggplot2 que ya conoces.
Imagina que tienes tres respuestas de encuesta:
| id | respuesta |
|---|---|
| 1 | "Me gusta mi equipo de trabajo" |
| 2 | "La carga laboral es excesiva" |
| 3 | "Buen ambiente pero falta comunicación" |
Después de tokenizar con unnest_tokens(), el data frame se transforma en:
| id | word |
|---|---|
| 1 | me |
| 1 | gusta |
| 1 | mi |
| 1 | equipo |
| 1 | de |
| 1 | trabajo |
| 2 | la |
| 2 | carga |
| 2 | laboral |
| ... | ... |
Ahora es un data frame tidy con dos columnas. Puedes hacer count(word) para saber qué palabras son más frecuentes. Puedes hacer filter(word == "equipo") para ver en qué respuestas aparece "equipo". Puedes hacer group_by(id) |> summarise(n_palabras = n()) para calcular la longitud de cada respuesta. Son las mismas operaciones que usas desde la Clase 1.
tidytext se instala con install.packages("tidytext"). Depende de dplyr, tidyr y otros paquetes del tidyverse que ya tienes. Cárgalo con library(tidytext).
unnest_tokens(): la puerta de entradaLa función unnest_tokens() es la piedra angular de tidytext. Toma un data frame con una columna de texto y devuelve un data frame con una fila por token.
unnest_tokens(tbl, output, input, token = "words")
tbl: el data frame de entrada.output: nombre de la nueva columna que contendrá los tokens (sin comillas).input: nombre de la columna que contiene el texto original (sin comillas).token: tipo de token. Por defecto "words". Otras opciones: "ngrams", "sentences", "lines", "paragraphs".# Supongamos que tienes respuestas de encuesta
respuestas <- tibble(
id_empleado = c("E001", "E002", "E003"),
respuesta = c(
"Me gusta mi equipo de trabajo",
"La carga laboral es excesiva y nadie nos escucha",
"Buen ambiente pero falta comunicación con jefatura"
)
)
# Tokenizar: una palabra por fila
tokens <- respuestas |>
unnest_tokens(word, respuesta)
tokens
# # A tibble: 21 × 2
# id_empleado word
# <chr> <chr>
# 1 E001 me
# 2 E001 gusta
# 3 E001 mi
# 4 E001 equipo
# 5 E001 de
# 6 E001 trabajo
# 7 E002 la
# 8 E002 carga
# 9 E002 laboral
# 10 E002 es
# # ... y 11 filas más
unnest_tokens() hace varias cosas por ti: convierte a minúsculas, elimina la puntuación, elimina los números (por defecto). Puedes desactivar estas opciones con to_lower = FALSE, strip_punct = FALSE y strip_numeric = FALSE, pero en la mayoría de los casos las opciones por defecto son las correctas.
Si cuentas las palabras del ejemplo anterior sin limpiar, las más frecuentes serán "de", "la", "mi", "es" — palabras funcionales que no aportan significado temático. Estas son las stopwords (palabras vacías). Eliminarlas es esencial para que emerjan los términos sustantivos.
stopwords# install.packages("stopwords")
library(stopwords)
# Lista de stopwords en español
sw_es <- tibble(word = stopwords("es"))
nrow(sw_es)
# → 308 stopwords
# Ver las primeras
head(sw_es, 20)
# de, la, que, el, en, y, a, los, del, se, las, por, un, para, con, ...
anti_join()# anti_join: quédate con las filas de 'tokens' que NO están en 'sw_es'
tokens_limpios <- tokens |>
anti_join(sw_es, by = "word")
tokens_limpios
# Ahora sin "me", "mi", "de", "la", "es", "y", "nos", "con", "pero"
La lógica es exactamente la misma de los anti_join() que usaste en la Clase 2 para encontrar empleados que no aparecían en otra tabla. Aquí, estás encontrando palabras que no aparecen en la lista de stopwords.
La lista genérica elimina artículos, preposiciones y conjunciones. Pero en el contexto de una encuesta de clima de InnovaCo, hay palabras que aparecen en casi toda respuesta sin aportar discriminación: "empresa", "trabajo", "innovaco". Estas son stopwords de dominio que debes agregar manualmente:
# Stopwords de dominio: aparecen en todo sin discriminar
sw_dominio <- tibble(word = c("empresa", "innovaco", "trabajo",
"trabajar", "creo", "cosas"))
# Combinar ambas listas
sw_completas <- bind_rows(sw_es, sw_dominio)
# Aplicar
tokens_limpios <- tokens |>
anti_join(sw_completas, by = "word") |>
filter(str_length(word) > 2) # eliminar palabras de 1-2 letras
count() que ya conocesUna vez que tienes los tokens limpios, calcular frecuencias es literalmente count(word, sort = TRUE) — la misma función que usas desde la Clase 1 para contar empleados por departamento. Solo que ahora cuentas palabras.
freq_palabras <- tokens_limpios |>
count(word, sort = TRUE)
freq_palabras
# # A tibble: N × 2
# word n
# <chr> <int>
# 1 equipo 87
# 2 jefatura 72
# 3 carga 65
# ...
freq_palabras |>
slice_max(n, n = 20) |>
mutate(word = fct_reorder(word, n)) |>
ggplot(aes(x = n, y = word)) +
geom_col(fill = "#0D9488") +
labs(
title = "Top 20 palabras más frecuentes",
subtitle = "Respuestas abiertas de encuesta de clima",
x = "Frecuencia",
y = NULL
) +
theme_minimal(base_size = 13)
El fct_reorder(word, n) ordena las barras de menor a mayor frecuencia (el más frecuente arriba). Es un patrón de ggplot2 que ya usaste en la Clase 2 para ordenar departamentos.
Una wordcloud (nube de palabras) es una visualización donde el tamaño de cada palabra es proporcional a su frecuencia. Es visualmente atractiva y funciona bien para presentaciones ejecutivas, pero tiene limitaciones serias como herramienta analítica: no muestra la frecuencia exacta, dificulta las comparaciones precisas, y privilegia las palabras largas (que ocupan más espacio visual).
# install.packages("wordcloud2")
library(wordcloud2)
freq_palabras |>
filter(n >= 5) |>
rename(freq = n) |>
wordcloud2(
size = 0.8,
color = rep(c("#1B2A4A", "#B85042", "#0D9488", "#A7BEAE"), 100)
)
Un bigrama es un par de palabras consecutivas. Los bigramas capturan expresiones compuestas que los unigramas destruyen. "Jefatura directa" es un concepto distinto de "jefatura" + "directa" por separado. "No escuchan" es una negación que el análisis de unigramas clasificaría incorrectamente.
# Tokenizar en bigramas
bigramas <- respuestas |>
unnest_tokens(bigram, respuesta, token = "ngrams", n = 2)
bigramas
# # A tibble: N × 2
# id_empleado bigram
# <chr> <chr>
# 1 E001 me gusta
# 2 E001 gusta mi
# 3 E001 mi equipo
# 4 E001 equipo de
# 5 E001 de trabajo
# ...
El problema: muchos bigramas contienen stopwords ("de trabajo", "la carga", "es excesiva"). Necesitas filtrarlos.
separate(), filter(), unite()El truco es separar el bigrama en sus dos palabras, filtrar las filas donde alguna de las dos es una stopword, y volver a unirlas:
# Paso 1: separar las dos palabras
bigramas_sep <- bigramas |>
separate(bigram, into = c("word1", "word2"), sep = " ")
# Paso 2: filtrar — ninguna de las dos debe ser stopword
bigramas_filt <- bigramas_sep |>
filter(!word1 %in% sw_completas$word,
!word2 %in% sw_completas$word) |>
filter(str_length(word1) > 2,
str_length(word2) > 2)
# Paso 3: reunir y contar
bigramas_freq <- bigramas_filt |>
unite(bigram, word1, word2, sep = " ") |>
count(bigram, sort = TRUE)
bigramas_freq
# # A tibble: N × 2
# bigram n
# <chr> <int>
# 1 jefatura directa 34
# 2 carga laboral 28
# 3 horario flexible 19
# ...
Las funciones separate() y unite() son de tidyr — el mismo paquete que usaste para pivot_wider() y pivot_longer(). Aquí las usas para manipular texto, no datos numéricos.
Un trigrama es una secuencia de tres palabras. Se construye igual que el bigrama, cambiando n = 2 por n = 3:
trigramas <- respuestas |>
unnest_tokens(trigram, respuesta, token = "ngrams", n = 3)
Los trigramas son más informativos ("falta de comunicación" es más claro que "falta comunicación"), pero son mucho más escasos. Con 800 respuestas, la mayoría de los trigramas aparecen solo 1–2 veces. La regla práctica: usa unigramas para el panorama general, bigramas para las expresiones compuestas, y trigramas solo si tienes un corpus grande (> 5.000 documentos).
El análisis de sentimiento intenta clasificar un texto como positivo, negativo o neutro. El enfoque más simple — y el que usaremos — es el basado en léxicos: una lista de palabras con su polaridad pre-asignada. Cada palabra del texto se busca en el léxico; si está, se suma su polaridad; si no está, se ignora. El sentimiento del documento es la agregación de las polaridades de sus palabras.
Existen tres tipos principales de léxicos:
| Léxico | Escala | Ventaja | Limitación |
|---|---|---|---|
| AFINN (Nielsen, 2011) | −5 a +5 (numérico) | Permite promediar, mide intensidad | Léxico pequeño |
| Bing (Hu & Liu, 2004) | positive / negative (binario) | Simple, robusto | Pierde intensidad |
| NRC (Mohammad & Turney, 2013) | 8 emociones + pos/neg | Granularidad emocional | Clasificaciones discutibles |
Ninguno de los léxicos principales fue creado en español. Para esta clase usamos una traducción del NRC al español. La operación es un inner_join() entre los tokens limpios y el léxico:
# Cargar el léxico NRC en español
lexico <- read_csv("lexico_sentimiento_es.csv")
# Estructura del léxico:
# word: la palabra en español
# sentiment: "positive", "negative", "anger", "joy", etc.
# Inner join: solo las palabras que están en AMBOS (tokens y léxico)
sentimiento <- tokens_limpios |>
inner_join(lexico, by = "word")
inner_join() solo retiene las palabras que están en el léxico. Si tu corpus tiene 10.000 tokens limpios y el léxico tiene 5.000 entradas, es posible que solo 1.500 tokens coincidan (15 % de cobertura). Las otras 8.500 palabras son invisibles para el análisis de sentimiento. Reporta siempre la cobertura: es una medida de la limitación del análisis.
# Calcular la cobertura
n_match <- nrow(sentimiento)
n_total <- nrow(tokens_limpios)
cat("Cobertura del léxico:", round(n_match / n_total * 100, 1), "%\n")
pivot_wider() y restaPara calcular el sentimiento neto de cada respuesta (palabras positivas menos negativas), necesitas pivotar y restar:
sent_neto <- sentimiento |>
filter(sentiment %in% c("positive", "negative")) |>
count(id_empleado, sentiment) |>
pivot_wider(
names_from = sentiment,
values_from = n,
values_fill = 0 # si no hay palabras de un tipo, poner 0
) |>
mutate(neto = positive - negative)
sent_neto
# # A tibble: N × 4
# id_empleado negative positive neto
# <chr> <int> <int> <int>
# 1 E001 0 2 2
# 2 E002 2 0 -2
# 3 E003 1 1 0
Un sentimiento neto > 0 indica que la respuesta tiene más palabras positivas que negativas. Un neto < 0, lo contrario. Un neto = 0 puede significar equilibrio o ausencia de palabras en el léxico.
El verdadero poder del análisis de sentimiento aparece cuando lo cruzas con las variables que ya tienes. Usa left_join() para agregar las variables del dataset de empleados:
# Join con datos de empleados
empleados <- read_csv("innovaco_empleados.csv")
sent_con_datos <- sent_neto |>
left_join(
empleados |> select(id_empleado, departamento, satisfaccion_laboral, genero),
by = "id_empleado"
)
# Sentimiento promedio por departamento
sent_con_datos |>
group_by(departamento) |>
summarise(
n = n(),
media_neto = mean(neto, na.rm = TRUE),
.groups = "drop"
) |>
arrange(media_neto)
Esto te permite responder: "¿los departamentos con peor engagement cuantitativo también tienen peor sentimiento en sus respuestas abiertas?" Si la respuesta es sí, la triangulación refuerza la evidencia. Si es no, hay algo interesante que investigar.
Antes de reportar resultados, debes conocer (y declarar) las limitaciones:
En este apunte aprendiste el pipeline básico de text mining con tidytext:
unnest_tokens(): texto → un token por fila.anti_join(stopwords): eliminar ruido.count(): frecuencias de palabras.ggplot2 y wordcloud2.unnest_tokens(token = "ngrams", n = 2): capturar contexto.inner_join(lexico): polaridad de los textos.left_join(empleados): integrar texto y números.Todo esto usa funciones que ya conoces: count(), filter(), mutate(), group_by(), summarise(), left_join(), anti_join(), ggplot(). Lo que es nuevo es la preparación (tokenización y stopwords), no el análisis.
El Apunte 28 extiende este pipeline con tres técnicas más avanzadas: redes de co-ocurrencia (¿qué palabras aparecen juntas?), topic modeling con LDA (¿qué temas emergen?), y el cruce sistemático entre texto y datos cuantitativos.