Apunte 27 — Text mining con tidytext: tokenización, frecuencia y sentimiento

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

1. ¿Por qué analizar texto en people analytics?

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.

En la práctica. Una encuesta de clima con 1.200 empleados puede generar 800 respuestas abiertas. Leerlas todas tomaría ~8 horas de trabajo concentrado. Clasificarlas manualmente, ~20 horas. Con text mining, el pipeline completo toma ~30 minutos de código R y produce frecuencias, temas y sentimientos cuantificables. No es perfecto, pero escala.

2. El principio tidy text: un token por fila

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:

idrespuesta
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:

idword
1me
1gusta
1mi
1equipo
1de
1trabajo
2la
2carga
2laboral
......

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.

Instalación. El paquete tidytext se instala con install.packages("tidytext"). Depende de dplyr, tidyr y otros paquetes del tidyverse que ya tienes. Cárgalo con library(tidytext).

3. unnest_tokens(): la puerta de entrada

La 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.

Sintaxis básica

unnest_tokens(tbl, output, input, token = "words")

Ejemplo completo

# 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
Transformaciones automáticas. 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.

4. Stopwords: las palabras que sobran

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 en español con el paquete 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, ...

Eliminar stopwords 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.

Stopwords de dominio

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
No hay lista perfecta. Curar stopwords es una decisión del analista. Si eliminas demasiadas, pierdes señal. Si eliminas pocas, el ruido oscurece los temas. La regla: ejecuta el pipeline, mira las frecuencias, y si una palabra aparece en el top 10 sin aportar información temática, agrégala a la lista y vuelve a correr. Es un proceso iterativo, no un paso único.

5. Frecuencia de palabras: el count() que ya conoces

Una 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
# ...

Visualización: top N palabras

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.

6. Wordclouds: bonitas pero limitadas

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)
  )
Regla de uso. La wordcloud es un complemento visual, no un sustituto del gráfico de barras. En un informe, muestra primero la tabla de frecuencias (datos duros) y luego la wordcloud (impacto visual). Nunca al revés.

7. Bigramas: capturar el contexto local

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.

8. Filtrar bigramas: 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.

Bigramas accionables. Un bigrama es "accionable" si sugiere una intervención concreta. "Jefatura directa" señala un problema de supervisión. "Carga laboral" señala un problema de volumen de trabajo. "Horario flexible" señala una aspiración de beneficios. Identifica los bigramas accionables y destácalos en tu informe. Los bigramas que son solo descripciones genéricas ("buen ambiente", "buena empresa") son contexto, no recomendación.

9. Trigramas y más allá: cuándo ir más allá del par

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).

10. ¿Qué es el análisis de sentimiento?

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éxicoEscalaVentajaLimitación
AFINN (Nielsen, 2011)−5 a +5 (numérico)Permite promediar, mide intensidadLéxico pequeño
Bing (Hu & Liu, 2004)positive / negative (binario)Simple, robustoPierde intensidad
NRC (Mohammad & Turney, 2013)8 emociones + pos/negGranularidad emocionalClasificaciones discutibles

11. Léxicos en español: NRC traducido

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")
Cobertura del léxico. El 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")

12. Sentimiento neto: pivot_wider() y resta

Para 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.

13. Sentimiento por grupo: cruce con datos cuantitativos

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.

14. Limitaciones del análisis de sentimiento con léxicos

Antes de reportar resultados, debes conocer (y declarar) las limitaciones:

  1. Negaciones no capturadas. "No estoy insatisfecho" contiene "insatisfecho" (negativo), pero el significado es positivo. Los léxicos no manejan negaciones. Los bigramas ayudan parcialmente ("no + adjetivo"), pero requieren procesamiento adicional.
  2. Sarcasmo invisible. "Claro, la gerencia realmente se preocupa por nosotros" es sarcástico, pero el léxico clasificará "preocupa" como positivo.
  3. Traducciones imperfectas. "Compromiso" en español puede ser positivo (engagement) o negativo (obligación no deseada). La traducción del inglés "commitment" no captura esta ambigüedad.
  4. Cobertura parcial. Solo las palabras que están en el léxico contribuyen al análisis. Las demás son invisibles. Si la cobertura es baja (< 15 %), los resultados son poco confiables.
  5. No captura intensidad relativa. "Bueno" y "extraordinario" pueden tener la misma clasificación ("positive") pero significan cosas muy diferentes.
En el informe. Siempre incluye un párrafo de limitaciones: "El análisis de sentimiento se basó en el léxico NRC traducido al español. La cobertura del léxico fue del X %. Las limitaciones principales incluyen la incapacidad de capturar negaciones y sarcasmo, y la pérdida de matices culturales en la traducción. Los resultados deben interpretarse como una tendencia, no como una medición precisa."

15. Resumen y conexión con el Apunte 28

En este apunte aprendiste el pipeline básico de text mining con tidytext:

  1. Tokenizar con unnest_tokens(): texto → un token por fila.
  2. Limpiar con anti_join(stopwords): eliminar ruido.
  3. Contar con count(): frecuencias de palabras.
  4. Visualizar con ggplot2 y wordcloud2.
  5. Bigramas con unnest_tokens(token = "ngrams", n = 2): capturar contexto.
  6. Sentimiento con inner_join(lexico): polaridad de los textos.
  7. Cruzar con 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.