IA para Científicos Sociales

Sesión 3.4: Laboratorio 6 - Análisis de texto

Danilo Freire

Department of Data and Decision Sciences
Emory University

Laboratorio 6: Análisis de texto

Objetivos del laboratorio

Lo que vamos a hacer:

  1. Cargar y explorar un corpus
  2. Tokenizar y limpiar texto
  3. Identificar palabras distintivas con TF-IDF
  4. Medir el tono con un diccionario de sentimiento
  5. Conectar las dos lentes para contar una historia

Lo que vamos a aprender:

  • Flujo de trabajo con tidytext
  • Preprocesamiento de texto en español
  • TF-IDF responde de qué habla un texto
  • Sentimiento responde cómo lo dice


El corpus contiene 60 textos políticos simulados sobre 5 temas y 18 países

Estructura del laboratorio

Parte Contenido Duración
1 Tokenización y limpieza ~15 min
2 TF-IDF + ejercicio ~17 min
3 Sentimiento + ejercicio ~22 min
4 Síntesis y resumen ~6 min


Los textos están etiquetados por tema y país. Vamos a usar dos herramientas complementarias para caracterizarlos. Material extra en los apéndices

Parte 1: Tokenización y limpieza

Cargar paquetes

R> # Si algún paquete falta:
R> # install.packages(c("tidyverse", "tidytext", "tm", "ggwordcloud"))
R> 
R> library(tidyverse)
R> library(tidytext)      # Análisis de texto tidy
R> library(tm)            # Stopwords en español
R> library(ggwordcloud)   # Nubes de palabras
R> 
R> set.seed(2026)

Cargar el corpus

R> # Cargar textos políticos
R> textos <- read_csv("datos/textos_politicos.csv")
R> # Lean el archivo directo desde la web:
R> # textos <- read_csv("https://raw.githubusercontent.com/danilofreire/introduccion-ia-ucu/main/clases/dia-03/datos/textos_politicos.csv")
R> 
R> # Explorar estructura
R> glimpse(textos)
Rows: 60
Columns: 5
$ id    <dbl> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 1…
$ pais  <chr> "México", "Argentina", "Venezuela", "Brasil", "Brasil", "Bolivia…
$ tema  <chr> "economia", "economia", "economia", "economia", "economia", "eco…
$ anio  <dbl> 2020, 2024, 2021, 2024, 2022, 2019, 2021, 2019, 2018, 2024, 2020…
$ texto <chr> "El crecimiento económico del país ha sido sólido este año graci…
R> # Textos por tema
R> textos |>
+   count(tema)
# A tibble: 5 × 2
  tema              n
  <chr>         <int>
1 economia         12
2 educacion        12
3 medioambiente    12
4 salud            12
5 seguridad        12

Ver un texto de ejemplo

R> # Un texto de cada tema
R> textos |>
+   group_by(tema) |>
+   slice(1) |>
+   select(tema, texto) |>
+   ungroup()
# A tibble: 5 × 2
  tema          texto                                                           
  <chr>         <chr>                                                           
1 economia      El crecimiento económico del país ha sido sólido este año graci…
2 educacion     La inversión en educación pública ha permitido aumentar la cobe…
3 medioambiente La deforestación en la Amazonia ha alcanzado niveles récord ame…
4 salud         El sistema de salud pública ha sido fortalecido con nuevos hosp…
5 seguridad     Las políticas de mano dura contra el crimen organizado no han l…

Tokenizar

R> # Tokenizar: una fila por palabra
R> tokens <- textos |>
+   unnest_tokens(palabra, texto)
R> 
R> # Ver resultado
R> tokens |>
+   head(20)
# A tibble: 20 × 5
      id pais   tema      anio palabra      
   <dbl> <chr>  <chr>    <dbl> <chr>        
 1     1 México economia  2020 el           
 2     1 México economia  2020 crecimiento  
 3     1 México economia  2020 económico    
 4     1 México economia  2020 del          
 5     1 México economia  2020 país         
 6     1 México economia  2020 ha           
 7     1 México economia  2020 sido         
 8     1 México economia  2020 sólido       
 9     1 México economia  2020 este         
10     1 México economia  2020 año          
11     1 México economia  2020 gracias      
12     1 México economia  2020 a            
13     1 México economia  2020 las          
14     1 México economia  2020 exportaciones
15     1 México economia  2020 agrícolas    
16     1 México economia  2020 y            
17     1 México economia  2020 la           
18     1 México economia  2020 inversión    
19     1 México economia  2020 extranjera   
20     1 México economia  2020 directa      

unnest_tokens() automáticamente convierte a minúsculas y elimina puntuación

Para bigramas: unnest_tokens(palabra, texto, token = "ngrams", n = 2)

Stopwords en español

R> # Obtener stopwords en español
R> stopwords_es <- tibble(palabra = tm::stopwords("spanish"))
R> 
R> # Ver algunas
R> head(stopwords_es, 20)
# A tibble: 20 × 1
   palabra
   <chr>  
 1 de     
 2 la     
 3 que    
 4 el     
 5 en     
 6 y      
 7 a      
 8 los    
 9 del    
10 se     
11 las    
12 por    
13 un     
14 para   
15 con    
16 no     
17 una    
18 su     
19 al     
20 lo     
R> # ¿Cuántas hay?
R> nrow(stopwords_es)
[1] 308

Eliminar stopwords

R> # Eliminar stopwords y números
R> tokens_limpios <- tokens |>
+   anti_join(stopwords_es, by = "palabra") |>
+   filter(!str_detect(palabra, "^[0-9]+$"),    # no números
+          nchar(palabra) > 2)                  # ≥ 3 letras
R> 
R> # Comparar
R> cat("Tokens antes:", nrow(tokens), "\n")
Tokens antes: 842 
R> cat("Tokens después:", nrow(tokens_limpios), "\n")
Tokens después: 485 
R> cat("Reducción:", round((1 - nrow(tokens_limpios)/nrow(tokens)) * 100, 1), "%\n")
Reducción: 42.4 %

Palabras más frecuentes

R> # Top 20 palabras
R> tokens_limpios |>
+   count(palabra, sort = TRUE) |>
+   head(20) |>
+   mutate(palabra = reorder(palabra, n)) |>
+   ggplot(aes(x = n, y = palabra)) +
+   geom_col(fill = "#2d4563") +
+   labs(title = "20 palabras más frecuentes (sin stopwords)",
+        x = "Frecuencia", y = NULL) +
+   theme_minimal()

Parte 2: TF-IDF

¿Qué es TF-IDF?

  • TF (Term Frequency): frecuencia del término en el documento
  • IDF (Inverse Document Frequency): penaliza palabras muy comunes

\[\text{TF-IDF} = \text{TF} \times \log\left(\frac{\text{Total documentos}}{\text{Documentos con el término}}\right)\]

  • Palabras distintivas: frecuentes en pocos documentos → TF-IDF alto
  • Palabras comunes: frecuentes en muchos documentos → TF-IDF bajo

TF-IDF identifica qué palabras caracterizan a cada documento o grupo

Calcular TF-IDF por tema

R> # Contar palabras por tema
R> palabras_tema <- tokens_limpios |>
+   count(tema, palabra, sort = TRUE)
R> 
R> # Calcular TF-IDF
R> tfidf <- palabras_tema |>
+   bind_tf_idf(palabra, tema, n)
R> 
R> # Ver resultado
R> tfidf |>
+   arrange(desc(tf_idf)) |>
+   head(15)
# A tibble: 15 × 6
   tema          palabra           n     tf   idf tf_idf
   <chr>         <chr>         <int>  <dbl> <dbl>  <dbl>
 1 seguridad     seguridad         4 0.0421 1.61  0.0678
 2 salud         salud             7 0.0737 0.916 0.0675
 3 educacion     educación         4 0.0412 1.61  0.0664
 4 seguridad     violencia         3 0.0316 1.61  0.0508
 5 educacion     estudiantes       3 0.0309 1.61  0.0498
 6 economia      económico         3 0.0286 1.61  0.0460
 7 economia      país              3 0.0286 1.61  0.0460
 8 medioambiente biodiversidad     2 0.0215 1.61  0.0346
 9 medioambiente sostenible        2 0.0215 1.61  0.0346
10 salud         acceso            2 0.0211 1.61  0.0339
11 salud         internacional     2 0.0211 1.61  0.0339
12 salud         sistema           2 0.0211 1.61  0.0339
13 seguridad     justicia          2 0.0211 1.61  0.0339
14 seguridad     social            2 0.0211 1.61  0.0339
15 educacion     escolar           2 0.0206 1.61  0.0332

Visualizar TF-IDF por tema

R> # Top 6 palabras por tema
R> tfidf |>
+   group_by(tema) |>
+   slice_max(tf_idf, n = 6, with_ties = FALSE) |>
+   ungroup() |>
+   mutate(palabra = reorder_within(palabra, tf_idf, tema)) |>
+   ggplot(aes(x = tf_idf, y = palabra, fill = tema)) +
+   geom_col(show.legend = FALSE) +
+   facet_wrap(~tema, scales = "free_y", ncol = 3) +
+   scale_y_reordered() +
+   labs(title = "Palabras más distintivas por tema (TF-IDF)",
+        x = "TF-IDF", y = NULL) +
+   theme_minimal()

Interpretación del TF-IDF

¿Las palabras tienen sentido?

  • Economía: económico, crecimiento, exportaciones, fiscal
  • Educación: educación, estudiantes, escolar, académico
  • Salud: salud, acceso, sistema, biomédica
  • Seguridad: seguridad, violencia, justicia
  • Medioambiente: biodiversidad, sostenible, agua

Ojo: “país” aparece alto en economía. Palabras genéricas pueden colarse; por eso a veces conviene ampliar las stopwords

Limitaciones:

  • Depende de la limpieza del texto
  • No captura sinónimos
  • No captura contexto


TF-IDF es simple pero efectivo para identificar palabras clave

Ejercicio rápido: TF-IDF por país

Tarea (5 min): calculen TF-IDF agrupando por pais en lugar de tema. ¿Qué palabras distinguen a cada país?

# Mismo patrón que antes, cambiando tema → pais
tfidf_pais <- tokens_limpios |>
  count(___, palabra) |>          # TODO: agrupar por
  bind_tf_idf(palabra, ___, n)    # TODO: columna de agrupamiento

# Top 5 palabras distintivas por país
tfidf_pais |>
  group_by(___) |>                # TODO
  slice_max(tf_idf, n = 5, with_ties = FALSE) |>
  arrange(pais, desc(tf_idf))

Nota

Pista: cambien las dos referencias a tema por pais. La función bind_tf_idf() toma (palabra, grupo, n), así que el segundo argumento es el grupo

Apéndice 1: Solución

Parte 3: Análisis de sentimiento

¿Qué es el análisis de sentimiento?

  • Análisis de sentimiento: clasificar un texto por su tono — positivo, negativo o neutro
  • Método más simple: diccionarios de valencia — listas de palabras pre-clasificadas
    • crecimiento, mejora, éxito → positivo
    • crisis, violencia, pobreza → negativo
  • Cada documento recibe un score = (palabras positivas) − (palabras negativas)
  • Limitaciones: no captura negaciones (“no es bueno” cuenta como positivo), ironía, ni contexto. Los diccionarios son dependientes del dominio (un diccionario médico no sirve para política)
  • Aun así, es transparente y fácil de validar a gran escala

Construir diccionarios pos/neg

R> # Diccionarios para texto político (versión inicial — ampliable)
R> positivas <- c("crecimiento", "mejora", "mejorado", "oportunidad",
+                "fortalecido", "innovadores", "renovables", "sólido",
+                "beneficio", "avance", "estabilizar", "reducir")
R> 
R> negativas <- c("crisis", "inflación", "pobreza", "violencia", "amenaza",
+                "problema", "deuda", "riesgo", "deficiencias", "grave",
+                "crimen", "narcotráfico", "deforestación", "informalidad",
+                "inseguridad", "contaminación", "desempleo", "vulnerables")
R> 
R> cat("Positivas:", length(positivas), "  Negativas:", length(negativas), "\n")
Positivas: 12   Negativas: 18 
  • En la práctica usaríamos un diccionario validado (p. ej. LIWC, NRC Spanish)
  • Para clases pequeñas, una lista construida a mano funciona si la revisamos con cuidado

Calcular el score de cada documento

R> # Score por documento = #positivas − #negativas
R> sentimiento_doc <- tokens_limpios |>
+   mutate(valencia = case_when(
+     palabra %in% positivas ~  1L,
+     palabra %in% negativas ~ -1L,
+     TRUE ~ 0L
+   )) |>
+   group_by(id, tema, pais) |>
+   summarise(score = sum(valencia), .groups = "drop")
R> 
R> # Documentos más positivos y más negativos
R> sentimiento_doc |>
+   arrange(desc(score)) |>
+   head(3)
# A tibble: 3 × 4
     id tema          pais    score
  <dbl> <chr>         <chr>   <int>
1     1 economia      México      2
2    50 medioambiente Bolivia     2
3     5 economia      Brasil      1
R> sentimiento_doc |>
+   arrange(score) |>
+   head(3)
# A tibble: 3 × 4
     id tema      pais        score
  <dbl> <chr>     <chr>       <int>
1     2 economia  Argentina      -4
2     7 economia  Panamá         -2
3    26 seguridad El Salvador    -2
  • case_when() asigna +1 a positivas, -1 a negativas, 0 a las demás
  • El score es la suma de valencias por documento. Score positivo → tono optimista; negativo → tono crítico

Visualizar el tono por tema

R> sentimiento_doc |>
+   group_by(tema) |>
+   summarise(score_promedio = mean(score),
+             .groups = "drop") |>
+   mutate(tema = reorder(tema, score_promedio)) |>
+   ggplot(aes(x = score_promedio, y = tema, fill = score_promedio > 0)) +
+   geom_col(show.legend = FALSE) +
+   scale_fill_manual(values = c("TRUE" = "#27AE60", "FALSE" = "#E74C3C")) +
+   labs(title = "Tono promedio por tema",
+        x = "Score promedio (positivas − negativas)", y = NULL) +
+   theme_minimal()

  • Los temas con palabras como crisis, violencia, inseguridad tienden a tener score negativo
  • Los temas con palabras como crecimiento, mejora, innovación tienden a positivo
  • El score absoluto depende del diccionario. Las comparaciones relativas (qué tema es más negativo que otro) son lo informativo

Ejercicio rápido: el documento más negativo

Tarea (8 min): identifiquen el documento con el score de sentimiento más negativo y léanlo. ¿El método captura bien su tono?

# Doc con score más negativo
doc_mas_negativo <- sentimiento_doc |>
  arrange(___) |>           # TODO: ordenar de menor a mayor
  head(1)

doc_mas_negativo

# Recuperar el texto completo de ese documento
textos |>
  filter(id == doc_mas_negativo$___) |>   # TODO: ¿qué columna identifica al documento?
  pull(texto)

Nota

Pista: para ordenar de menor (más negativo) a mayor, arrange() sin desc() ya lo hace. La columna que identifica el documento es la misma en textos y sentimiento_doc

Tip

Checkpoint: van a ver un texto sobre una crisis o un problema grave (por ejemplo, inflación o inseguridad). Pregunta de reflexión: ¿el método le da el “score” correcto, o exagera/subestima el tono real?

Apéndice 1: Solución

Parte 4: Síntesis

Dos lentes, dos preguntas

TF-IDF: ¿de qué habla el texto?

  • Identifica las palabras que caracterizan a un documento o grupo
  • Útil para describir vocabularios, comparar grupos
  • No nos dice nada sobre el tono ni la emoción

Sentimiento: ¿cómo lo dice?

  • Mide el tono a partir de palabras valoradas
  • Útil para detectar crisis, polarización, optimismo
  • No nos dice de qué trata el texto


Combinadas, las dos herramientas construyen una narrativa: “Los textos sobre seguridad (TF-IDF) tienen un tono marcadamente negativo (sentimiento).” Una herramienta sola rara vez alcanza.

Regla práctica: antes de creerle a un método automático, lean una muestra de textos y validen que el análisis cuadra con su lectura

Resumen del laboratorio

Lo que practicamos:

  • Tokenización con unnest_tokens()
  • Limpieza: stopwords, números, palabras cortas
  • TF-IDF con bind_tf_idf() (por tema y por país)
  • Sentimiento con diccionarios de valencia
  • Combinar ambas lentes para interpretar un corpus

Funciones clave:

  • unnest_tokens(): tokenizar
  • anti_join(): eliminar stopwords
  • bind_tf_idf(): calcular TF-IDF
  • case_when(): asignar valencia
  • group_by() + summarise(): agregar por grupo

Diccionarios profesionales: LIWC, NRC (Spanish disponible)

Mañana veremos cómo los LLMs hacen estas dos tareas (caracterizar y medir tono) en una sola llamada. Material extra en los apéndices

Apéndice 1: Soluciones de ejercicios rápidos

Solución: TF-IDF por país

R> tfidf_pais <- tokens_limpios |>
+   count(pais, palabra) |>
+   bind_tf_idf(palabra, pais, n)
R> 
R> # Top 5 palabras distintivas por país
R> tfidf_pais |>
+   group_by(pais) |>
+   slice_max(tf_idf, n = 5, with_ties = FALSE) |>
+   arrange(pais, desc(tf_idf))
# A tibble: 90 × 6
# Groups:   pais [18]
   pais      palabra             n     tf   idf tf_idf
   <chr>     <chr>           <int>  <dbl> <dbl>  <dbl>
 1 Argentina infraestructura     2 0.0513  2.89 0.148 
 2 Argentina problema            2 0.0513  2.89 0.148 
 3 Argentina años                1 0.0256  2.89 0.0741
 4 Argentina bosque              1 0.0256  2.89 0.0741
 5 Argentina comerciantes        1 0.0256  2.89 0.0741
 6 Bolivia   oportunidad         2 0.0526  2.89 0.152 
 7 Bolivia   representan         2 0.0526  1.79 0.0943
 8 Bolivia   acuerdos            1 0.0263  2.89 0.0761
 9 Bolivia   amazonia            1 0.0263  2.89 0.0761
10 Bolivia   amenazando          1 0.0263  2.89 0.0761
# ℹ 80 more rows
  • Aparecen palabras locales (nombres de instituciones, ciudades) y términos políticos específicos
  • Los TF-IDF más altos suelen ser palabras que solo aparecen en un país
  • ¿Quieren ver todas las palabras? Dos cosas recortan la salida: el slice_max() deja cinco por país y las tibbles muestran diez filas por defecto. Saquen el slice_max() y agreguen print(n = Inf): tfidf_pais |> arrange(pais, desc(tf_idf)) |> print(n = Inf). Como la tabla es larga, en RStudio suele ser más cómodo View(tfidf_pais)

Volver al ejercicio

Solución: el documento más negativo

R> # Doc con score más negativo
R> doc_mas_negativo <- sentimiento_doc |>
+   arrange(score) |>
+   head(1)
R> 
R> doc_mas_negativo
# A tibble: 1 × 4
     id tema     pais      score
  <dbl> <chr>    <chr>     <int>
1     2 economia Argentina    -4
R> # Recuperar el texto completo de ese documento
R> textos |>
+   filter(id == doc_mas_negativo$id) |>
+   pull(texto)
[1] "La inflación sigue siendo un problema grave que afecta a las familias más vulnerables del país."
  • El método captura bien temas donde las palabras negativas son explícitas (seguridad, crisis)
  • Cuando el tono está implícito (ironía, comparaciones, frases largas) el método lo pierde
  • Para análisis serio: combinar con lectura humana de una muestra, validar diccionarios contra el dominio

Volver al ejercicio

Apéndice 2: Nube de palabras

Visualizar frecuencias como wordcloud

R> # Contar frecuencias
R> freq <- tokens_limpios |>
+   count(palabra, sort = TRUE) |>
+   head(100)
R> 
R> # Nube de palabras
R> ggplot(freq, aes(label = palabra, size = n, color = n)) +
+   geom_text_wordcloud(rm_outside = TRUE) +
+   scale_size_area(max_size = 15) +
+   scale_color_gradient(low = "#457b9d", high = "#e63946") +
+   theme_minimal() +
+   labs(title = "Nube de palabras del corpus")

Resumen del Día 3

Aprendizaje no supervisado:

  • K-means y jerárquico: agrupar observaciones similares
  • PCA: reducir dimensionalidad, visualizar, crear índices
  • Silueta: evaluar calidad de clusters

Análisis de texto:

  • Tokenización: dividir texto en palabras
  • TF-IDF: identificar palabras distintivas
  • LDA: descubrir temas latentes

Estos métodos son exploratorios. Los LLMs (Día 4) los superan en precisión y flexibilidad

Fin del Día 3