---
title: "IA para Científicos Sociales"
subtitle: "Sesión 3.4: Laboratorio 6 - Análisis de texto"
author:
- name: Danilo Freire
orcid: 0000-0002-4712-6810
email: danilofreire@gmail.com
affiliations: "Department of Data and Decision Sciences
Emory University"
format:
clean-revealjs:
self-contained: true
footer: "[Sesión 3.4](https://danilofreire.github.io/introduccion-ia-ucu/clases/dia-03/12-laboratorio-06.html)"
transition: slide
transition-speed: default
scrollable: true
revealjs-plugins:
- multimodal
engine: knitr
editor:
render-on-save: true
lang: es
---
```{r setup, include=FALSE}
options(htmltools.dir.version = FALSE)
library(knitr)
opts_chunk$set(
prompt = T,
fig.align = "center",
dpi = 300,
cache = T,
engine.opts = list(bash = "-l")
)
knit_hooks$set(
prompt = function(before, options, envir) {
options(
prompt = if (options$engine %in% c("sh", "bash", "zsh")) "$ " else "R> ",
continue = if (options$engine %in% c("sh", "bash", "zsh")) "$ " else "+ "
)
}
)
options(repos = c(CRAN = "https://cran.rstudio.com/"))
if (!require("fontawesome", character.only = TRUE)) {
install.packages("fontawesome", dependencies = TRUE)
library(fontawesome, character.only = TRUE)
}
```
# Laboratorio 6: Análisis de texto {background-color="#2d4563"}
## Objetivos del laboratorio
:::{style="margin-top: 30px; font-size: 26px;"}
:::{.columns}
:::{.column width=50%}
**Lo que vamos a hacer:**
1. Cargar y explorar un corpus
2. Tokenizar y limpiar texto
3. Identificar [palabras distintivas]{.alert} con TF-IDF
4. Medir el [tono]{.alert} con un diccionario de sentimiento
5. Conectar las dos lentes para contar una historia
:::
:::{.column width=50%}
**Lo que vamos a aprender:**
- Flujo de trabajo con [tidytext]{.alert}
- Preprocesamiento de texto en español
- [TF-IDF]{.alert} responde *de qué* habla un texto
- [Sentimiento]{.alert} responde *cómo* lo dice
[El corpus contiene 60 textos políticos simulados sobre 5 temas y 18 países]{.alert}
:::
:::
:::
## Estructura del laboratorio
:::{style="margin-top: 40px; font-size: 30px;"}
| 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]{.alert}
:::
# Parte 1: Tokenización y limpieza {background-color="#2d4563"}
## Cargar paquetes
:::{style="margin-top: 30px; font-size: 25px;"}
```{r}
#| label: cargar-paquetes
#| echo: true
#| eval: true
#| message: false
#| warning: false
# Si algún paquete falta:
# install.packages(c("tidyverse", "tidytext", "tm", "ggwordcloud"))
library(tidyverse)
library(tidytext) # Análisis de texto tidy
library(tm) # Stopwords en español
library(ggwordcloud) # Nubes de palabras
set.seed(2026)
```
:::
## Cargar el corpus
:::{style="margin-top: 30px; font-size: 22px;"}
```{r}
#| label: cargar-corpus
#| echo: true
#| eval: true
# Cargar textos políticos
textos <- read_csv("datos/textos_politicos.csv")
# Lean el archivo directo desde la web:
# textos <- read_csv("https://raw.githubusercontent.com/danilofreire/introduccion-ia-ucu/main/clases/dia-03/datos/textos_politicos.csv")
# Explorar estructura
glimpse(textos)
# Textos por tema
textos |>
count(tema)
```
:::
## Ver un texto de ejemplo
:::{style="margin-top: 30px; font-size: 24px;"}
```{r}
#| label: ejemplo-texto
#| echo: true
#| eval: true
# Un texto de cada tema
textos |>
group_by(tema) |>
slice(1) |>
select(tema, texto) |>
ungroup()
```
:::
## Tokenizar
:::{style="margin-top: 30px; font-size: 22px;"}
```{r}
#| label: tokenizar
#| echo: true
#| eval: true
# Tokenizar: una fila por palabra
tokens <- textos |>
unnest_tokens(palabra, texto)
# Ver resultado
tokens |>
head(20)
```
`unnest_tokens()` automáticamente convierte a minúsculas y elimina puntuación
[Para bigramas:]{.alert} `unnest_tokens(palabra, texto, token = "ngrams", n = 2)`
:::
## Stopwords en español
:::{style="margin-top: 30px; font-size: 22px;"}
```{r}
#| label: stopwords
#| echo: true
#| eval: true
# Obtener stopwords en español
stopwords_es <- tibble(palabra = tm::stopwords("spanish"))
# Ver algunas
head(stopwords_es, 20)
# ¿Cuántas hay?
nrow(stopwords_es)
```
:::
## Eliminar stopwords
:::{style="margin-top: 30px; font-size: 22px;"}
```{r}
#| label: eliminar-stopwords
#| echo: true
#| eval: true
# Eliminar stopwords y números
tokens_limpios <- tokens |>
anti_join(stopwords_es, by = "palabra") |>
filter(!str_detect(palabra, "^[0-9]+$"), # no números
nchar(palabra) > 2) # ≥ 3 letras
# Comparar
cat("Tokens antes:", nrow(tokens), "\n")
cat("Tokens después:", nrow(tokens_limpios), "\n")
cat("Reducción:", round((1 - nrow(tokens_limpios)/nrow(tokens)) * 100, 1), "%\n")
```
:::
## Palabras más frecuentes
:::{style="margin-top: 30px; font-size: 22px;"}
```{r}
#| label: frecuencias
#| echo: true
#| eval: true
#| fig-width: 10
#| fig-height: 5
# Top 20 palabras
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 {background-color="#2d4563"}
## ¿Qué es TF-IDF?
:::{style="margin-top: 30px; font-size: 26px;"}
- [TF]{.alert} (Term Frequency): frecuencia del término en el documento
- [IDF]{.alert} (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]{.alert}: frecuentes en pocos documentos → TF-IDF alto
- Palabras [comunes]{.alert}: frecuentes en muchos documentos → TF-IDF bajo
[TF-IDF identifica qué palabras caracterizan a cada documento o grupo]{.alert}
:::
## Calcular TF-IDF por tema
:::{style="margin-top: 30px; font-size: 20px;"}
```{r}
#| label: tfidf-calc
#| echo: true
#| eval: true
# Contar palabras por tema
palabras_tema <- tokens_limpios |>
count(tema, palabra, sort = TRUE)
# Calcular TF-IDF
tfidf <- palabras_tema |>
bind_tf_idf(palabra, tema, n)
# Ver resultado
tfidf |>
arrange(desc(tf_idf)) |>
head(15)
```
:::
## Visualizar TF-IDF por tema
:::{style="margin-top: 30px; font-size: 18px;"}
```{r}
#| label: tfidf-viz
#| echo: true
#| eval: true
#| fig-width: 12
#| fig-height: 7
# Top 6 palabras por tema
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
:::{style="margin-top: 30px; font-size: 24px;"}
:::{.columns}
:::{.column width=50%}
**¿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
:::
:::{.column width=50%}
**Limitaciones:**
- Depende de la [limpieza]{.alert} del texto
- No captura [sinónimos]{.alert}
- No captura [contexto]{.alert}
[TF-IDF es simple pero efectivo para identificar palabras clave]{.alert}
:::
:::
:::
## Ejercicio rápido: TF-IDF por país {#sec:ejercicio-tfidf-pais}
:::{style="margin-top: 20px; font-size: 22px;"}
**Tarea (5 min):** calculen TF-IDF agrupando por `pais` en lugar de `tema`. ¿Qué palabras distinguen a cada país?
```{r}
#| label: ej-tfidf-pais
#| echo: true
#| eval: false
#| prompt: false
# 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))
```
:::{.callout-note}
**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]{.button}](#sec:sol-tfidf-pais)
:::
# Parte 3: Análisis de sentimiento {background-color="#2d4563"}
## ¿Qué es el análisis de sentimiento?
:::{style="margin-top: 30px; font-size: 26px;"}
- [Análisis de sentimiento]{.alert}: 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]{.alert}: no captura negaciones ("no es bueno" cuenta como positivo), ironía, ni contexto. Los diccionarios son [dependientes del dominio]{.alert} (un diccionario médico no sirve para política)
- Aun así, [es transparente y fácil de validar]{.alert} a gran escala
:::
## Construir diccionarios pos/neg
:::{style="margin-top: 30px; font-size: 24px;"}
```{r}
#| label: dict-sentimiento
#| echo: true
#| eval: true
# Diccionarios para texto político (versión inicial — ampliable)
positivas <- c("crecimiento", "mejora", "mejorado", "oportunidad",
"fortalecido", "innovadores", "renovables", "sólido",
"beneficio", "avance", "estabilizar", "reducir")
negativas <- c("crisis", "inflación", "pobreza", "violencia", "amenaza",
"problema", "deuda", "riesgo", "deficiencias", "grave",
"crimen", "narcotráfico", "deforestación", "informalidad",
"inseguridad", "contaminación", "desempleo", "vulnerables")
cat("Positivas:", length(positivas), " Negativas:", length(negativas), "\n")
```
- En la práctica usaríamos un diccionario validado (p. ej. [LIWC](https://www.liwc.app/), [NRC Spanish](https://saifmohammad.com/WebPages/NRC-Emotion-Lexicon.htm))
- Para clases pequeñas, una lista construida a mano funciona si la revisamos con cuidado
:::
## Calcular el score de cada documento
:::{style="margin-top: 30px; font-size: 20px;"}
```{r}
#| label: score-sentimiento
#| echo: true
#| eval: true
# Score por documento = #positivas − #negativas
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")
# Documentos más positivos y más negativos
sentimiento_doc |>
arrange(desc(score)) |>
head(3)
sentimiento_doc |>
arrange(score) |>
head(3)
```
- `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
:::{style="margin-top: 30px; font-size: 20px;"}
```{r}
#| label: viz-sentimiento
#| echo: true
#| eval: true
#| fig-width: 9
#| fig-height: 4.5
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 {#sec:ejercicio-sentimiento}
:::{style="margin-top: 20px; font-size: 22px;"}
**Tarea (8 min):** identifiquen el documento con el score de sentimiento más negativo y léanlo. ¿El método captura bien su tono?
```{r}
#| label: ej-sentimiento
#| echo: true
#| eval: false
#| prompt: false
# 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)
```
:::{.callout-note}
**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`
:::
:::{.callout-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]{.button}](#sec:sol-sentimiento)
:::
# Parte 4: Síntesis {background-color="#2d4563"}
## Dos lentes, dos preguntas
:::{style="margin-top: 30px; font-size: 24px;"}
:::{.columns}
:::{.column width=50%}
**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
:::
:::{.column width=50%}
**Sentimiento: ¿cómo lo dice?**
- Mide el [tono]{.alert} 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:]{.alert} "Los textos sobre **seguridad** (TF-IDF) tienen un tono marcadamente **negativo** (sentimiento)." Una herramienta sola rara vez alcanza.
[Regla práctica:]{.alert} 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
:::{style="margin-top: 30px; font-size: 24px;"}
:::{.columns}
:::{.column width=50%}
**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
:::
:::{.column width=50%}
**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
:::{style="margin-top: 30px;"}
:::
[Diccionarios profesionales]{.alert}: [LIWC](https://www.liwc.app/), [NRC](https://saifmohammad.com/WebPages/NRC-Emotion-Lexicon.htm) (Spanish disponible)
:::
:::
:::{style="margin-top: 30px;"}
:::
[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]{.alert}
:::
# Apéndice 1: Soluciones de ejercicios rápidos {background-color="#2d4563"}
## Solución: TF-IDF por país {#sec:sol-tfidf-pais}
:::{style="margin-top: 20px; font-size: 20px;"}
```{r}
#| label: sol-tfidf-pais
#| echo: true
#| eval: true
tfidf_pais <- tokens_limpios |>
count(pais, palabra) |>
bind_tf_idf(palabra, pais, n)
# Top 5 palabras distintivas por país
tfidf_pais |>
group_by(pais) |>
slice_max(tf_idf, n = 5, with_ties = FALSE) |>
arrange(pais, desc(tf_idf))
```
- 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]{.alert} 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]{.button}](#sec:ejercicio-tfidf-pais)
:::
## Solución: el documento más negativo {#sec:sol-sentimiento}
:::{style="margin-top: 20px; font-size: 20px;"}
```{r}
#| label: sol-sentimiento
#| echo: true
#| eval: true
# Doc con score más negativo
doc_mas_negativo <- sentimiento_doc |>
arrange(score) |>
head(1)
doc_mas_negativo
# Recuperar el texto completo de ese documento
textos |>
filter(id == doc_mas_negativo$id) |>
pull(texto)
```
- 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]{.button}](#sec:ejercicio-sentimiento)
:::
# Apéndice 2: Nube de palabras {background-color="#2d4563"}
## Visualizar frecuencias como wordcloud
:::{style="margin-top: 30px; font-size: 22px;"}
```{r}
#| label: wordcloud
#| echo: true
#| eval: true
#| fig-width: 8
#| fig-height: 6
# Contar frecuencias
freq <- tokens_limpios |>
count(palabra, sort = TRUE) |>
head(100)
# Nube de palabras
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
:::{style="margin-top: 30px; font-size: 24px;"}
**Aprendizaje no supervisado:**
- [K-means]{.alert} y [jerárquico]{.alert}: agrupar observaciones similares
- [PCA]{.alert}: reducir dimensionalidad, visualizar, crear índices
- [Silueta]{.alert}: evaluar calidad de clusters
**Análisis de texto:**
- [Tokenización]{.alert}: dividir texto en palabras
- [TF-IDF]{.alert}: identificar palabras distintivas
- [LDA]{.alert}: descubrir temas latentes
[Estos métodos son exploratorios. Los LLMs (Día 4) los superan en precisión y flexibilidad]{.alert}
:::
# Fin del Día 3 {background-color="#2d4563"}