Representación vectorial de textos

Diferentes métodos

Existen distintos enfoques para vectorizar textos, desde los más simples hasta los más sofisticados:

Método ¿Cómo funciona? Pros Contras
Bag of Words Cuenta frecuencia de palabras Simple, rápido Ignora el orden y el contexto
TF-IDF Pondera palabras por frecuencia y relevancia Mejora BoW, reduce ruido Aún ignora el significado
Embeddings Mapeo semántico en espacios densos Captura relaciones de significado Requiere más recursos y entrenamiento

Frecuencia Inversa de un documento (IDF)

TF-IDF

La estadística tf-idf tiene como objetivo medir la importancia de una palabra para un documento de una colección (o corpus) de documentos, por ejemplo, para una novela de una colección de novelas o para un sitio web de una colección de sitios web.

\[ [\text{idf}(término) = \ln \left( \frac{n_{\text{documentos}}}{n_{\text{documentos que contienen el término}}} \right)] \]

¿Cuáles son las palabras más usadas?

Vamos a analizar las letras de los 10 compositores de tango con más canciones

library(tidyverse)
library(readr)
library(janitor)
library(tidytext)

url <- "https://raw.githubusercontent.com/gefero/tango_scrap/master/Data/Todo_Tango_letras_final.csv"

autores <- read_csv(url) |> 
  clean_names() |> 
  drop_na(letra) |> 
  group_by(compositor) |> 
  mutate(total_canciones = n()) |> 
  ungroup() |> 
  group_by(compositor) |> 
  summarise(letra_completa = paste0(letra, collapse = " , "),
            total_canciones = first(total_canciones)) |> 
  arrange(desc(total_canciones)) |> 
  slice_max(n = 10, order_by = total_canciones)

¿Cuáles son las palabras más usadas?

reactable::reactable(head(autores))

Tokenizamos

canciones_palabras <- autores %>%
  unnest_tokens(word, letra_completa) %>%
  count(compositor, word, sort = TRUE)

reactable::reactable(head(canciones_palabras))

¿Qué porcentaje representa cada palabra?

palabras_totales <- canciones_palabras %>% 
  group_by(compositor) %>% 
  summarize(total = sum(n))

canciones_palabras <- left_join(canciones_palabras, palabras_totales)

reactable::reactable(head(canciones_palabras))

Observamos la distribución

canciones_palabras <- canciones_palabras |> 
  mutate(freq = n/total)

ggplot(canciones_palabras) +
  aes(x = n, fill = compositor) +
  geom_histogram(show.legend = FALSE) +
  scale_fill_hue(direction = 1)  +
  facet_wrap(vars(compositor))

Frecuencias entre compositores

freq_by_rank <- canciones_palabras %>% 
  group_by(compositor) %>% 
  mutate(rank = row_number(), 
         term_frequency = n/total) %>%
  ungroup()

freq_by_rank %>% 
  ggplot(aes(rank, term_frequency, color = compositor)) + 
  geom_line(linewidth = 1.1, alpha = 0.8, show.legend = FALSE) + 
  scale_x_log10() +
  scale_y_log10()

Ley de Zipf

la ley de Zipf establece que cuando las palabras de un texto lo suficientemente extenso se alinean en orden de frecuencia decreciente, exhiben un patrón especial.

En concreto, la segunda palabra más frecuente aparece aproximadamente la mitad de veces que la número uno. La tercera palabra más frecuente aparece aproximadamente un tercio más que la primera, la cuarta una cuarta parte y así sucesivamente

TF-IDF

La idea de tf-idf es encontrar las palabras importantes para el contenido de cada documento, disminuyendo la ponderación de las palabras de uso común y aumentando la de las palabras poco utilizadas en una colección o corpus de documentos

autor_tf_idf <- canciones_palabras %>%
  bind_tf_idf(word, compositor, n)

reactable::reactable(head(autor_tf_idf))

Términos con TD-IDF alto

library(forcats)

autor_tf_idf %>%
  group_by(compositor) %>%
  slice_max(tf_idf, n = 5) %>%
  ungroup() %>%
  ggplot(aes(tf_idf, fct_reorder(word, tf_idf), fill = compositor)) +
  geom_col(show.legend = FALSE) +
  facet_wrap(~compositor, ncol = 3, scales = "free") +
  labs(x = "tf-idf", y = NULL)

Términos con TD-IDF alto

Embeddings: Word2vec. Similitudes semanticas

¿Cómo lo aplicamos en R?

Armamos una lista de vectores:

library(text2vec)

tokens <- word_tokenizer(tolower(autores$letra_completa))

Vectorizador y matriz de co-ocurrencias

Convertimos la lista en un objeto iterable.

create_vocabulary() cuenta cuántas veces aparece cada palabra.

prune_vocabulary() filtra el vocabulario y se queda solo con palabras que aparecen al menos 5 veces.

vocab_vectorizer() convierte el vocabulario en una función que transforma texto en vectores.

it <- itoken(tokens, progressbar = FALSE)

vocab <- create_vocabulary(it) %>%
  prune_vocabulary(term_count_min = 5)

vectorizer <- vocab_vectorizer(vocab)

Entrenar el modelo GloVe

create_tcm() genera la Term Co-occurrence Matrix (TCM): fit_transform() entrena el modelo sobre la matriz tcm.

tcm <- create_tcm(it, vectorizer, skip_grams_window = 5)

glove <- GlobalVectors$new(rank = 50, x_max = 10)
word_vectors <- glove$fit_transform(tcm, n_iter = 20)
INFO  [12:17:34.135] epoch 1, loss 0.1783
INFO  [12:17:34.195] epoch 2, loss 0.1024
INFO  [12:17:34.238] epoch 3, loss 0.0855
INFO  [12:17:34.281] epoch 4, loss 0.0743
INFO  [12:17:34.318] epoch 5, loss 0.0660
INFO  [12:17:34.356] epoch 6, loss 0.0598
INFO  [12:17:34.395] epoch 7, loss 0.0548
INFO  [12:17:34.434] epoch 8, loss 0.0508
INFO  [12:17:34.471] epoch 9, loss 0.0475
INFO  [12:17:34.509] epoch 10, loss 0.0446
INFO  [12:17:34.546] epoch 11, loss 0.0422
INFO  [12:17:34.586] epoch 12, loss 0.0401
INFO  [12:17:34.625] epoch 13, loss 0.0382
INFO  [12:17:34.663] epoch 14, loss 0.0366
INFO  [12:17:34.700] epoch 15, loss 0.0351
INFO  [12:17:34.739] epoch 16, loss 0.0338
INFO  [12:17:34.777] epoch 17, loss 0.0327
INFO  [12:17:34.816] epoch 18, loss 0.0316
INFO  [12:17:34.854] epoch 19, loss 0.0306
INFO  [12:17:34.893] epoch 20, loss 0.0298

Similitudes semanticas

similarity <- sim2(word_vectors, method = "cosine")
similarity["amor", ] |> sort(decreasing = TRUE) |> head(10)
     amor   corazon       dia     dolor      vida      lado    camino     sueño 
1.0000000 0.7226983 0.6542862 0.6520943 0.6458425 0.6133125 0.6022170 0.5977714 
   querer         y 
0.5904431 0.5844952 

Similitudes

library(plotly)
library(umap)

skip_embedding <- as.matrix(word_vectors)
skip_embedding <- na.omit(skip_embedding)
vizualization <- umap(skip_embedding, n_neighbors = 15, n_threads = 2)

df <- data.frame(
  word = rownames(skip_embedding),
  x = vizualization$layout[, 1],
  y = vizualization$layout[, 2],
  stringsAsFactors = FALSE
)

Graficamos

plot_ly(df, x = ~x, y = ~y, type = "scatter", mode = 'text', text = ~word) %>%
  layout(title = "Visualización de embeddings semánticos")

Mostramos solo de algunas tematicas

temas <- c("amor", "pena", "dolor", "llanto", "barrio", "noche", "soledad")
vecinos <- sim2(word_vectors, y = word_vectors[temas, ], method = "cosine") |>
  rowMeans() |>
  sort(decreasing = TRUE) |>
  head(200)

seleccionadas <- names(vecinos)
word_vectors_tema <- word_vectors[seleccionadas, ]

skip_embedding <- as.matrix(word_vectors_tema)
skip_embedding <- na.omit(skip_embedding)
vizualization <- umap(skip_embedding, n_neighbors = 15, n_threads = 2)

df <- data.frame(
  word = rownames(skip_embedding),
  x = vizualization$layout[, 1],
  y = vizualization$layout[, 2],
  stringsAsFactors = FALSE
)

Graficamos

plot_ly(df, x = ~x, y = ~y, type = "scatter", mode = 'text', text = ~word) %>%
  layout(title = "Visualización de embeddings semánticos")