--- 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"}