--- title: "Tarea 6: Análisis de texto — Respuestas" subtitle: "IA para Científicos Sociales - UCU" author: "Danilo Freire" date: "2026-04-24" format: html: toc: true toc-depth: 2 number-sections: true code-fold: false lang: es engine: knitr --- ## Instrucciones Esta es la clave de respuestas de la Tarea 6. Cada pregunta incluye el código R completo y una respuesta escrita. ### Configuración ```{r setup, message=FALSE, warning=FALSE} library(tidyverse) library(tidytext) library(tm) set.seed(2026) textos <- read_csv("datos/textos_politicos.csv", show_col_types = FALSE) stopwords_es <- tibble(palabra = tm::stopwords("spanish")) tokens <- textos |> unnest_tokens(palabra, texto) tokens_limpios <- tokens |> anti_join(stopwords_es, by = "palabra") |> filter(!str_detect(palabra, "^[0-9]+$"), nchar(palabra) > 2) ``` ## Exploración ### Pregunta 1: Distribución por año ```{r p1, fig.width=8, fig.height=4} textos |> count(anio) |> ggplot(aes(x = factor(anio), y = n, fill = factor(anio))) + geom_col() + labs(title = "Textos por año", x = "Año", y = "Cantidad") + theme_minimal() + theme(legend.position = "none") ``` **Respuesta:** La distribución es desigual: **2019** es el año con más textos (14), seguido de **2024** (13) y **2018** (12), mientras que **2020** tiene solo 2 textos. Esto es relevante porque cualquier análisis comparativo entre años debe tener en cuenta que algunos están subrepresentados: las conclusiones sobre 2020 serán mucho menos robustas que sobre 2019 o 2024. ### Pregunta 2: Longitud de los textos ```{r p2} longitudes <- tokens |> count(id, name = "n_tokens") # Texto más corto mas_corto <- longitudes |> slice_min(n_tokens, n = 1, with_ties = FALSE) mas_corto # Texto más largo mas_largo <- longitudes |> slice_max(n_tokens, n = 1) mas_largo # Contenido de ambos textos |> filter(id %in% c(mas_corto$id, mas_largo$id)) |> select(id, tema, texto) ``` **Respuesta:** El texto más corto tiene 10 palabras (tokens) y el más largo 20; la variación es moderada (hay un empate en el mínimo, con dos textos de 10 palabras; `with_ties = FALSE` se queda con uno solo para la comparación). Para análisis con corpora reales (artículos, discursos completos), la diferencia sería de órdenes de magnitud y habría que normalizar (TF-IDF ya lo hace por su construcción). ### Pregunta 3: Bigramas más frecuentes ```{r p3} bigramas <- textos |> unnest_tokens(par, texto, token = "ngrams", n = 2) |> separate(par, c("palabra1", "palabra2"), sep = " ") |> filter(!palabra1 %in% stopwords_es$palabra, !palabra2 %in% stopwords_es$palabra, !str_detect(palabra1, "^[0-9]+$"), !str_detect(palabra2, "^[0-9]+$")) |> unite("bigrama", palabra1, palabra2, sep = " ") |> count(bigrama, sort = TRUE) |> head(10) bigramas ``` **Respuesta:** En este corpus pequeño casi ningún bigrama se repite mucho: los más frecuentes aparecen solo 2 veces (**"crecimiento económico"**, **"comunidades indígenas"**, **"implementado programas"**, **"alcanzado niveles"**, **"necesita mayor"**). Aun así ilustran la ventaja de los bigramas: recuperan colocaciones (palabras que van juntas con significado conjunto, como "crecimiento económico") que las palabras sueltas pierden. Con un corpus más grande, estas colocaciones se repetirían lo suficiente como para ser muy informativas. ## TF-IDF ### Pregunta 4: Frecuencia bruta vs. TF-IDF ```{r p4} # Top 10 por frecuencia bruta (todo el corpus) tokens_limpios |> count(palabra, sort = TRUE) |> head(10) # Top 3 por tema según TF-IDF tokens_limpios |> count(tema, palabra) |> bind_tf_idf(palabra, tema, n) |> group_by(tema) |> slice_max(tf_idf, n = 3) |> arrange(tema, desc(tf_idf)) |> select(tema, palabra, tf_idf) ``` **Respuesta:** La lista por frecuencia bruta está dominada por palabras genéricas que aparecen en muchos temas: **región, programas, sigue, comunidades, representan**. Son frecuentes pero no distinguen un tema de otro. En la lista de TF-IDF esas palabras genéricas desaparecen y emergen términos específicos de cada tema (económico, estudiantes, biodiversidad, etc.). La razón es el factor IDF: una palabra que aparece en muchos grupos tiene IDF bajo (el logaritmo se acerca a cero), así que su TF-IDF se hunde por más frecuente que sea. Esta es justamente la razón de ser de TF-IDF: la frecuencia no es lo mismo que la capacidad de distinguir. ### Pregunta 5: ¿Cuándo informa el TF-IDF por grupo? ```{r p5} tokens_limpios |> count(anio, palabra) |> bind_tf_idf(palabra, anio, n) |> group_by(anio) |> slice_max(tf_idf, n = 3) |> arrange(anio, desc(tf_idf)) |> select(anio, palabra, tf_idf) ``` **Respuesta:** Las palabras distintivas por año son idiosincráticas (*agrícolas*, *fiscal*, *año*, *agua*, *esenciales*…) y no cuentan una historia temporal: no aparece un "año de la pandemia" con vocabulario sanitario, aunque el corpus sí contenga textos de salud que mencionan "pandemia". El motivo es que los 5 temas están repartidos de forma pareja entre los años, así que el vocabulario casi no correlaciona con el tiempo. TF-IDF solo revela una señal interpretable cuando la variable de agrupamiento influye de verdad en las palabras que se usan, como sí ocurre con `tema`. La lección práctica: antes de confiar en un TF-IDF por grupo, pregúntense si ese grupo debería cambiar el vocabulario; agrupar por una variable arbitraria produce ruido que puede parecer señal. ### Pregunta 6: TF-IDF de bigramas ```{r p6} bigramas_tema <- textos |> unnest_tokens(par, texto, token = "ngrams", n = 2) |> separate(par, c("palabra1", "palabra2"), sep = " ") |> filter(!palabra1 %in% stopwords_es$palabra, !palabra2 %in% stopwords_es$palabra, !str_detect(palabra1, "^[0-9]+$"), !str_detect(palabra2, "^[0-9]+$")) |> unite("bigrama", palabra1, palabra2, sep = " ") |> count(tema, bigrama) |> bind_tf_idf(bigrama, tema, n) bigramas_tema |> group_by(tema) |> slice_max(tf_idf, n = 3, with_ties = FALSE) |> arrange(tema, desc(tf_idf)) |> select(tema, bigrama, n, tf_idf) ``` **Respuesta:** Algunos bigramas son más interpretables que las palabras sueltas porque recuperan colocaciones con significado conjunto: **"crecimiento económico"** (economía), **"alimentación escolar"** (educación), **"atención primaria"** (salud), **"agricultura sostenible"** (medioambiente). Pero la limitación del corpus chico salta a la vista en la columna `n`: casi todos los bigramas aparecen [una sola vez]{.alert}, así que casi cualquier bigrama resulta "distintivo" de su tema y el ranking dentro de cada tema es en la práctica un empate roto de forma arbitraria (por eso hizo falta `with_ties = FALSE`, y por eso se cuelan bigramas poco informativos como *"cada año"* o *"asia representan"*). El único bigrama que se repite es "crecimiento económico" (n = 2). La conclusión práctica: los n-gramas ganan interpretabilidad pero fragmentan los conteos; necesitan corpus bastante más grandes que las palabras sueltas para que el TF-IDF discrimine de verdad. ## Sentimiento ### Pregunta 7: Ampliar diccionarios ```{r p7} positivas <- c("crecimiento", "mejora", "mejorado", "oportunidad", "fortalecido", "innovadores", "renovables", "sólido", "beneficio", "avance", "estabilizar", "reducir", "éxito", "progreso", "esperanza", "desarrollo", "inversión", "expansión", "estable", "positivo", "consolidación", "fortalecimiento", "incremento", "modernización", "inclusión", "sustentable", "eficiente", "transparencia", "participación", "cooperación", "innovación", "competitividad") negativas <- c("crisis", "inflación", "pobreza", "violencia", "amenaza", "problema", "deuda", "riesgo", "deficiencias", "grave", "crimen", "narcotráfico", "deforestación", "informalidad", "inseguridad", "contaminación", "desempleo", "vulnerables", "corrupción", "recesión", "desigualdad", "exclusión", "abandono", "fracaso", "estancamiento", "deterioro", "marginación", "represión", "polarización", "fragmentación", "atraso", "conflicto", "tensión", "deficiente", "precario") cat("Positivas:", length(positivas), " Negativas:", length(negativas), "\n") ``` **Respuesta:** Los diccionarios deben adaptarse al dominio. Para texto político en español agregué palabras sobre [desarrollo institucional]{.alert} (transparencia, participación, modernización) en positivas y sobre [conflicto político]{.alert} (corrupción, polarización, fragmentación) en negativas. La calidad del análisis depende fuertemente de qué palabras incluyamos. ### Pregunta 8: Sentimiento por país ```{r p8} sentimiento_doc <- tokens_limpios |> mutate(valencia = case_when( palabra %in% positivas ~ 1L, palabra %in% negativas ~ -1L, TRUE ~ 0L )) |> group_by(id, pais) |> summarise(score = sum(valencia), .groups = "drop") sentimiento_pais <- sentimiento_doc |> group_by(pais) |> summarise( n_textos = n(), score_promedio = round(mean(score), 2), .groups = "drop" ) |> arrange(score_promedio) sentimiento_pais ``` **Respuesta:** Los países con tono más negativo en promedio son **El Salvador** (≈ -1.5), **Panamá** (≈ -1.3) y **Colombia** (≈ -0.7); Argentina también aparece negativa (≈ -0.6), consistente con sus textos sobre inflación. Pero conviene no leer demasiado en esto: con tan pocos textos por país (El Salvador y Panamá tienen 2 o 3), el promedio es muy sensible al azar. De hecho, Venezuela sale ligeramente *positiva* pese a la intuición de "país en crisis", solo porque sus pocos textos no contienen muchas palabras del diccionario negativo. Es un recordatorio de que los promedios sobre muestras minúsculas son poco fiables. ### Pregunta 9: El documento más positivo ```{r p9} doc_mas_positivo <- sentimiento_doc |> arrange(desc(score)) |> head(1) doc_mas_positivo textos |> filter(id == doc_mas_positivo$id) |> select(id, tema, pais, anio, texto) ``` **Respuesta:** El texto más positivo es el id 1 (tema **economía**, México, 2020), con palabras como crecimiento, económico, sólido. El método capta bien el tono explícitamente positivo. Lo que NO capta: matices, condicionales ("podríamos crecer si..."), o palabras positivas específicas al dominio que no están en el diccionario (p. ej. "renacimiento", "auge"). ### Pregunta 10: Heatmap sentimiento por tema y año ```{r p10, fig.width=8, fig.height=4} sentimiento_tema_anio <- tokens_limpios |> mutate(valencia = case_when( palabra %in% positivas ~ 1L, palabra %in% negativas ~ -1L, TRUE ~ 0L )) |> group_by(id, tema, anio) |> summarise(score = sum(valencia), .groups = "drop") |> group_by(tema, anio) |> summarise(score_promedio = mean(score), .groups = "drop") ggplot(sentimiento_tema_anio, aes(x = factor(anio), y = tema, fill = score_promedio)) + geom_tile() + geom_text(aes(label = round(score_promedio, 1)), color = "white", size = 3) + scale_fill_gradient2(low = "#E74C3C", mid = "white", high = "#27AE60", midpoint = 0) + labs(title = "Tono promedio por tema × año", x = "Año", y = "Tema", fill = "Score") + theme_minimal() ``` **Respuesta:** El heatmap se ve colorido, pero conviene leerlo con cautela: cada celda (tema × año) resume apenas 1 a 4 textos, así que los valores son sobre todo ruido. No emerge una tendencia temporal estable. Por ejemplo, seguridad es negativo en los años recientes (2022-2024) pero positivo en 2018 y 2021, y salud ni siquiera tiene textos en 2020. Las celdas más negativas se concentran en seguridad y economía de los últimos años, pero con tan pocos datos por celda no se puede afirmar una evolución. Es el mismo problema de muestras minúsculas que vimos en el sentimiento por país: cruzar dos dimensiones reparte 60 textos en demasiadas celdas como para que el resultado sea confiable. ## Síntesis ### Pregunta 11: Clasificador por palabras clave ```{r p11} ancla <- list( economia = c("crecimiento", "inflación", "empleo", "pib", "mercado", "inversión", "fiscal", "monetaria", "desempleo"), educacion = c("estudiantes", "docentes", "escuelas", "universidad", "educativa", "alumnos", "aprendizaje", "escolar"), salud = c("hospitales", "vacunación", "pandemia", "médicos", "sanitaria", "enfermedades", "covid"), seguridad = c("violencia", "crimen", "policía", "delincuencia", "narcotráfico", "robos", "inseguridad"), medioambiente = c("deforestación", "biodiversidad", "bosque", "clima", "renovables", "contaminación", "sustentable") ) # Para cada documento, contar palabras de cada ancla clasificacion <- tokens_limpios |> mutate(tema_predicho = case_when( palabra %in% ancla$economia ~ "economia", palabra %in% ancla$educacion ~ "educacion", palabra %in% ancla$salud ~ "salud", palabra %in% ancla$seguridad ~ "seguridad", palabra %in% ancla$medioambiente ~ "medioambiente", TRUE ~ NA_character_ )) |> filter(!is.na(tema_predicho)) |> count(id, tema_predicho) |> group_by(id) |> slice_max(n, n = 1, with_ties = FALSE) |> ungroup() |> left_join(textos |> select(id, tema_real = tema), by = "id") # Matriz de confusión conf_mat <- table(Real = clasificacion$tema_real, Predicho = clasificacion$tema_predicho) conf_mat # Accuracy accuracy <- sum(diag(conf_mat)) / sum(conf_mat) cat("Accuracy:", round(accuracy, 3), "\n") ``` **Respuesta:** Entre los documentos que sí logra clasificar, el accuracy es muy alto (≈ 0.96): cuando un texto contiene una palabra-ancla, casi siempre apunta al tema correcto. Pero el verdadero límite no es la precisión sino la **cobertura**: solo unos 28 de los 60 textos contienen alguna palabra-ancla, y el resto queda sin clasificar. Es el clásico compromiso precisión-cobertura de los métodos por diccionario: son **transparentes** (sabemos exactamente por qué se clasifica cada texto), rápidos y fáciles de mejorar (agregar más anclas sube la cobertura), pero un diccionario corto deja muchos casos afuera. Para subir la cobertura sin perder transparencia, habría que ampliar las anclas o combinarlas con TF-IDF. ### Pregunta 12: TF-IDF + sentimiento **Respuesta:** A nivel de tema la relación es clara: los temas cuyo vocabulario distintivo (TF-IDF) es más negativo, como **seguridad** ("violencia", "crimen"), son también los de sentimiento promedio más bajo. Las dos herramientas capturan la misma señal de fondo por caminos distintos, lo que da confianza en el resultado. A nivel de país la relación se diluye: con tan pocos textos por país, un solo texto muy negativo arrastra el promedio, así que el orden de países es más ruido que señal. La moraleja es metodológica: TF-IDF (qué palabras) y sentimiento (con qué tono) se refuerzan cuando el agrupamiento tiene suficientes datos y vocabulario propio (tema), y se vuelven poco fiables cuando no (país con 1-2 textos). ### Pregunta 13: Reflexión **Respuesta:** 1. **TF-IDF vs. sentimiento — cuándo usar cuál:** - **TF-IDF** responde preguntas como: "¿Qué vocabulario distingue a [grupo A] de [grupo B]?" Ejemplo: "¿Qué palabras eran más comunes en los discursos del partido X frente al partido Y en 2023?" - **Sentimiento** responde preguntas como: "¿Cuál es el tono dominante de este corpus? ¿Cómo varía entre grupos?" Ejemplo: "¿Los medios de derecha cubren la migración con tono más negativo que los de izquierda?" 2. **Dos limitaciones serias del sentimiento por diccionario:** - **Negaciones e ironía**: "no es un buen presidente" cuenta como positivo. Mitigación: usar bigramas como contexto, o un modelo entrenado con anotaciones humanas. - **Dependencia del dominio**: "viral" es positivo en marketing, negativo en epidemiología. Mitigación: validar diccionarios contra una muestra anotada manualmente para el dominio específico. 3. **Cuándo preferir un LLM:** - Textos largos con tono mixto, donde un score promedio pierde matiz - Tareas que requieren entender contexto: detectar ironía, sarcasmo, alusiones culturales - Necesidad de extraer información estructurada (eventos, actores, fechas) sin reglas pre-definidas - Sin embargo, los métodos clásicos siguen siendo preferibles cuando se necesita **transparencia y replicabilidad** (un revisor puede inspeccionar exactamente qué palabra activó qué decisión, lo que un LLM no permite).