Analisis de sentimiento y medidas de distancia

Analizar el contenido emocional

Podemos utilizar las herramientas de minería de texto para analizar el contenido emocional del texto mediante programación

En el análisis de sentimiento se suelen distinguir dos grandes enfoques:

  1. Enfoque léxico (basado en diccionarios)
  2. Enfoque basado en clasificación automática

Enfoque léxico

Una forma de analizar el sentimiento de un texto es considerarlo como una combinación de sus palabras individuales y el contenido sentimental del texto completo como la suma del contenido sentimental de cada palabra. Se analiza el texto buscando qué palabras están en ese diccionario y se calcula un promedio de sentimiento

✅ Ventajas: fácil de interpretar, rápido, transparente.

❌ Desventajas: no capta contexto, ironía, negaciones o intensificadores (ej: “no está nada mal”).

Enfoque Clasificación Automática

Este segundo método usa técnicas de aprendizaje automático: se entrena un modelo con textos previamente clasificados por humanos (como “positivo”, “negativo”, “neutral”) para que aprenda patrones de lenguaje que predicen el sentimiento, incluso cuando no aparecen palabras explícitamente “positivas” o “negativas”.

✅ Ventajas: capta contexto, negaciones, matices.

❌ Desventajas: requiere muchos datos, mayor complejidad técnica y computacional.

1. LEXICONES

Los sentimientos

El paquete tidytext proporciona acceso a varios léxicos de sentimiento

Lexicones

Estos tres léxicos se basan en unigramas, es decir, palabras individuales. Contienen muchas palabras en inglés y se les asigna una puntuación según su sentimiento positivo o negativo, y también, posiblemente, emociones como alegría, ira, tristeza, etc

¿Qué pasa en español?

La mayoría de los modelos y paquetes de análisis de sentimiento están entrenados en inglés.

👉 Por eso, en esta clase:

Tango

¿Qué emociones tienen las letras de tango?

¿Qué emociones tienen las letras de tango?

PASO 1 : Descargamos los datos

Dataset de German Rosati obtenido a través de scrapeo web

library(tidyverse)
library(readr)
library(janitor)
url <- "https://raw.githubusercontent.com/gefero/tango_scrap/master/Data/Todo_Tango_letras_final.csv"
tango <- read_csv(url) |> 
  clean_names() |> 
  # Elimino los que no tienen letra
  drop_na(letra) 

reactable::reactable(head(tango))

¿Cómo lo hacemos?

Vamos a seleccionar letras de Celedonio Flores

letras_flores <- tango |> 
  filter(compositor == "celedonio flores") |> 
  filter(ritmo == "tango")

¿Cómo lo hacemos?

Paso 2: Traemos los lexicones

Una vez que tenemos la data, nos traemos los lexicones

En este caso: Los valores van de 1 (negativo) a 3 (positivo).

Si observamos el mismo tiene un puntaje. Ese es el que usaremos para clasificar las letras de tango.

Valor Categoría de sentimiento
1 Negativo
2 Neutral
3 Positivo

Lexicones

lexicones <- readr::read_csv('sentiment_lexicon_liia.csv') 

reactable::reactable(head(lexicones))

Paso 3: Lematizamos nuestro corpus de texto

Esto es para poder unirlo con los lexicones y su puntaje. Para ello vamos a utilizar la librería {udpipe}

library(udpipe)
ud_model <- udpipe_load_model("spanish-gsd-ud-2.5-191206.udpipe") # udpipe_download_model(language = "spanish")
#ud_model <- udpipe_load_model(ud_model$file_model)
anotado <- udpipe_annotate(ud_model, x = letras_flores$letra, doc_id = letras_flores$link)
anotado_df <- as_tibble(anotado)

reactable::reactable(anotado_df %>%
  select(doc_id, token, lemma) |> 
    head())

Paso 4: Unimos con los lexicones

Utilizamos left_join para unir palabras entre datasets

df_sentimiento <- anotado_df |> 
  select(doc_id, lemma) |> 
  left_join(lexicones, by = c('lemma' = 'word')) 

reactable::reactable(df_sentimiento |> 
                       arrange(desc(mean_likeness))|> 
    head())

Paso 5: Calculamos el promedio por canción

df_sentimiento <- df_sentimiento |> 
  group_by(doc_id) |> 
  summarise(valor=mean(mean_likeness, na.rm = TRUE), # valoración media
    cruzadas_n=length(mean_likeness[!is.na(mean_likeness)]), # cantidad de palabras con valoracion
    cruzadas_lemmas=paste(lemma[!is.na(mean_likeness)], collapse = " ")) |>  # palabras con valoracion)
left_join(letras_flores, by = c("doc_id" = "link"))


reactable::reactable(df_sentimiento |> 
    head())

Paso 6: Asignamos categorías para poder analizar y visualizar

df_sentimiento <- df_sentimiento |> 
  mutate(sentimiento_cat = cut(valor,
                             breaks = c(1, 1.5, 2.5, 3),
                             include.lowest = TRUE,
                             labels = c("Negativo", "Neutral", "Positivo")))  %>%
      mutate(colores = case_when(
        sentimiento_cat == "Positivo" ~ "#38C477",
            sentimiento_cat == "Neutro" ~ "#737272", 
        sentimiento_cat == "Negativo" ~ "#F2543D"
      )) |> 
  arrange(desc(sentimiento_cat))

Paso 7: Visualizamos

library(highcharter)
plot <-hchart(df_sentimiento,
       type = "bar",
       hcaes(y = valor, x = titulo, group = sentimiento_cat)) %>%
  hc_chart(inverted = TRUE) %>%
  hc_yAxis(title = list(text = "Sentimiento promedio"), min = 1, max = 3) %>%
  hc_exporting(enabled = TRUE) %>% 
  hc_title(text = "Sentimiento promedio en letras de Celedonio Flores",
           align = 'left',
           style = list(fontSize = "18px")) %>% 
  hc_subtitle(text = "Clasificación con lexicón UBA") %>% 
  hc_colors(unique(df_sentimiento$colores)) %>%
  hc_add_theme(hc_theme_flat())

Paso 8: Visualizamos

¿Qué pasa si usamos otro lexicon?

Originalmente desarrollado por Finn Årup Nielsen, AFINN es un lexicón de sentimientos que asigna a cada palabra un valor entero de sentimiento en una escala de -5 (muy negativo) a +5 (muy positivo).

Intervalo de puntaje Categoría de sentimiento
-5 a -2.5 Negativo
-2.5 a -0.5 Algo negativo
-0.5 a 0.5 Neutral
0.5 a 2.5 Algo positivo
2.5 a 5 Positivo
lexicon_afinn <- readr::read_csv('https://raw.githubusercontent.com/jboscomendoza/lexicos-nrc-afinn/refs/heads/master/lexico_afinn.csv') 

Procesamos

df_sentimiento_2 <- anotado_df |> 
  select(doc_id, lemma) |> 
  left_join(lexicon_afinn, by = c('lemma' = 'palabra')) |> 
  group_by(doc_id) |> 
  summarise(valor=mean(puntuacion, na.rm = TRUE), # valoración media
    cruzadas_n=length(puntuacion[!is.na(puntuacion)]), # cantidad de palabras con valoracion
    cruzadas_lemmas=paste(lemma[!is.na(puntuacion)], collapse = " ")) |>  # palabras con valoracion)
left_join(letras_flores, by = c("doc_id" = "link"))  |> 
  mutate( sentimiento_cat = cut(valor,
                          breaks = c(-5, -2.5, -0.5, 0.5, 2.5, 5),
                          include.lowest = TRUE,
                          labels = c("Negativo", 
                                     "Algo negativo", 
                                     "Neutral", 
                                     "Algo positivo", 
                                     "Positivo")))  %>%
      mutate(colores = case_when(
        sentimiento_cat == "Positivo" ~ "#38C477",
        sentimiento_cat == "Algo positivo" ~ "#87EBA8",
        sentimiento_cat == "Neutral" ~ "#737272", 
        sentimiento_cat == "Algo negativo" ~ "#F28268", 
        sentimiento_cat == "Negativo" ~ "#F2543D"
      ))

Graficamos

hchart(df_sentimiento_2,
       type = "bar",
       hcaes(y = valor, x = titulo, group = sentimiento_cat)) %>%
  hc_chart(inverted = TRUE) %>%
  hc_yAxis(title = list(text = "Sentimiento promedio"), min = -5, max = 5) %>%
  hc_exporting(enabled = TRUE) %>% 
  hc_title(text = "Sentimiento promedio en letras de Celedonio Flores",
           align = 'left',
           style = list(fontSize = "18px")) %>% 
  hc_subtitle(text = "Clasificación con lexicón AFINN") %>% 
  hc_colors(unique(df_sentimiento_2$colores)) %>%
  hc_add_theme(hc_theme_flat())

Recreo

Volvemos en 10 minutos para comenzar con el taller

Medidas de similitud y distancia

¿Cuales podemos usar? stringdist

El paquete stringdist ofrece una serie de métricas de distancia entre cadenas de texto. Estas permiten cuantificar cuán diferentes son dos secuencias de caracteres, ya sea para comparar palabras, nombres, textos o realizar tareas como deduplicación, corrección ortográfica o búsquedas aproximadas.

Métodos

Método Tipo Rango + similar si Qué compara
"jaccard" Distancia 0 a 1 Valor más bajo Conj. de caracteres/palabras
"cosine" Distancia 0 a 1 Valor más bajo N de caracteres o tokens
"lv" (Levenshtein) Distancia 0 a n (n = largo texto) Valor más bajo Ediciones mínimas
"dl" (Damerau-Levenshtein) Distancia 0 a n Valor más bajo Como "lv" + transposiciones
"jw" (Jaro-Winkler) Distancia 0 a 1 Valor más bajo Caracteres coincidentes
"soundex" Similitud Códigos de sonido 0 (igual) 1 (distinto) Sonido parecido (ej. “barrio” y “vario”)

Más información sobre cada medida acá

Ejemplo de uso

Elegimos dos letras del autor para comparar:

#install.packages("stringdist")
library(stringdist)


texto1 <- letras_flores$letra[1]
texto2 <- letras_flores$letra[2]

# Comparación básica con varias métricas
stringdist(texto1, texto2, method = "jaccard")
[1] 0.1428571
stringdist(texto1, texto2, method = "lv")         # Levenshtein
[1] 491
stringdist(texto1, texto2, method = "jw")         # Jaro-Winkler
[1] 0.287485
stringdist(texto1, texto2, method = "cosine")     # Coseno
[1] 0.01130927

Podriamos comparar las canciones del autor

library(stringdist)
library(pheatmap)

letras_flores <- letras_flores |> 
  sample_n(size = 20)

# Creamos la matriz de distancias
mat_dist <- stringdistmatrix(letras_flores$letra, method = "jw")
mat_dist <- as.matrix(mat_dist)

# Asignamos nombres de fila y columna
rownames(mat_dist) <- letras_flores$titulo
colnames(mat_dist) <- letras_flores$titulo

Visualizamos

# Graficamos
pheatmap(mat_dist,
         cluster_rows = TRUE,
         cluster_cols = TRUE,
         main = "Distancia Jaro-Winkler entre letras de tango")

¿Qué otros análisis podríamos hacer?

Referencias