Introducción

El análisis computacional de texto reúne procedimientos para transformar lenguaje no estructurado en representaciones que puedan describirse, compararse y modelarse estadísticamente.

El propósito no es asumir que un algoritmo comprende el lenguaje como una persona, sino construir representaciones explícitas de frecuencia, asociación, contexto, similitud y estructura.

Las técnicas de redes de texto complementan la minería de texto tradicional. En una representación de bolsa de palabras interesa principalmente cuánto aparece cada término; en una red interesa además con qué otros términos aparece y qué posición ocupa dentro de la estructura de asociaciones.

Objetivos

  • preparar y tokenizar un corpus de manera reproducible;
  • distinguir tokens, tipos léxicos, frecuencia y relevancia;
  • comparar documentos mediante Jaccard, similitud coseno y keyness;
  • estudiar polaridad léxica reconociendo sus limitaciones;
  • construir redes de bigramas y coocurrencia por ventanas;
  • interpretar correctamente pesos, distancias, centralidades y comunidades;
  • evaluar la sensibilidad de una red textual a decisiones de construcción;
  • complementar las redes con TF–IDF, reducción de dimensión y representaciones densas;
  • usar texto como información explicativa en un modelo predictivo regularizado.

Caso de estudio: discursos de Petro y Duque ante la ONU

Se comparan dos intervenciones ante la Asamblea General de las Naciones Unidas: la de Gustavo Petro, pronunciada el 20 de septiembre de 2022, y la de Iván Duque, pronunciada el 21 de septiembre de 2021.

El análisis es descriptivo y comparativo, dado que con dos discursos no es posible separar el efecto del orador de diferencias de año, coyuntura, agenda internacional, longitud o propósito comunicativo.

Fuentes oficiales de referencia:

Importación y estructura del corpus

Los archivos de texto deben leerse como texto, no como archivos separados por comas. Esto evita que las comas del discurso sean interpretadas accidentalmente como separadores de columnas.

# Función para leer un discurso
read_speech <- function(path, author) {

  # Verifica que el archivo exista
  if (!file.exists(path)) {
    stop("No se encontró el archivo: ", path)
  }

  # Lee el texto y asigna el autor
  tibble(
    author = author,
    text = readr::read_file(
      path,
      locale = locale(encoding = "UTF-8")
    )
  )
}

# Une los discursos en un solo objeto
speeches <- bind_rows(
  read_speech("discurso_onu_petro.txt", "Petro"),
  read_speech("discurso_onu_duque.txt", "Duque")
)

# Muestra los discursos
speeches
## # A tibble: 2 × 2
##   author text                                                                   
##   <chr>  <chr>                                                                  
## 1 Petro  "Vengo de uno de los tres países más bellos de la Tierra.\r\nAllí hay …
## 2 Duque  "Nos encontramos de nuevo en este foro global que ha sido históricamen…

Para estudiar contexto local es conveniente dividir primero cada documento en oraciones.

Así evitamos que una coocurrencia atraviese artificialmente el límite entre dos oraciones.

# Divide cada discurso en oraciones
sentences <- speeches |>
  unnest_tokens(
    output = sentence,
    input = text,
    token = "sentences",
    to_lower = FALSE
  ) |>

  # Numera las oraciones dentro de cada discurso
  group_by(author) |>
  mutate(sentence_id = row_number()) |>
  ungroup()

# Cuenta el número de oraciones por autor
sentences |>
  count(author, name = "n_sentences")
## # A tibble: 2 × 2
##   author n_sentences
##   <chr>        <int>
## 1 Duque           95
## 2 Petro          114

Tokenización y preprocesamiento

Un token es una ocurrencia concreta de una unidad lingüística. Un tipo léxico es una forma distinta. Por ejemplo, en paz, vida, paz hay tres tokens pero dos tipos léxicos.

La tokenización utilizada aquí convierte a minúsculas y elimina puntuación.

Se conservan tildes y demás caracteres del español porque forman parte de la escritura y, en algunos casos, distinguen palabras diferentes.

Tampoco se aplica stemming por defecto, es decir, reducir palabras a raíces artificiales puede dificultar la interpretación de los nodos de una red.

# Carga las palabras vacías en español
stop_words_es <- tibble(
  word = stopwords::stopwords("es")
)

# Tokeniza cada oración en palabras
tokens_full <- sentences |>
  unnest_tokens(
    output = word,
    input = sentence,
    token = "words",
    to_lower = TRUE
  ) |>

  # Conserva únicamente palabras
  filter(str_detect(word, "^\\p{L}+$"))

# Elimina palabras vacías y términos de un carácter
tokens_clean <- tokens_full |>
  anti_join(stop_words_es, by = "word") |>
  filter(str_length(word) > 1)

# Resume tokens y tipos léxicos por autor
summary_tokens <- tokens_full |>
  summarise(
    tokens = n(),
    types = n_distinct(word),
    .by = author
  )

# Muestra el resumen
summary_tokens
## # A tibble: 2 × 3
##   author tokens types
##   <chr>   <int> <int>
## 1 Petro    2159   682
## 2 Duque    2663   950

Las stopwords se eliminan cuando interesa enfatizar contenido léxico, pero no deben eliminarse mecánicamente en todos los análisis. Por ejemplo, pueden ser importantes para estudiar estilo, negación o sintaxis.

Por eso conservamos tanto tokens_full como tokens_clean.

Exploración léxica

Palabras más frecuentes

La frecuencia indica cuánto aparece una palabra; no implica por sí sola importancia temática ni capacidad para distinguir documentos.

# Calcula la frecuencia de cada palabra por autor
word_freq <- tokens_clean |>
  count(author, word, sort = TRUE) |>
  group_by(author) |>

  # Calcula proporciones y posiciones en el ranking
  mutate(
    total = sum(n),
    proportion = n / total,
    rank = row_number()
  ) |>
  ungroup()

# Selecciona las 12 palabras más frecuentes por autor
top_words <- word_freq |>
  filter(rank <= 12)

# Muestra frecuencia y proporción
top_words |>
  select(author, word, n, proportion)
## # A tibble: 24 × 4
##    author word          n proportion
##    <chr>  <chr>     <int>      <dbl>
##  1 Duque  colombia     27    0.0201 
##  2 Petro  selva        23    0.0224 
##  3 Petro  guerra       14    0.0136 
##  4 Petro  drogas       13    0.0127 
##  5 Petro  poder        13    0.0127 
##  6 Duque  acción       11    0.00818
##  7 Duque  pandemia     11    0.00818
##  8 Petro  vida         11    0.0107 
##  9 Petro  adicción     10    0.00975
## 10 Petro  humanidad    10    0.00975
## # ℹ 14 more rows
# Ordena las palabras dentro de cada autor
top_words |>
  mutate(
    word = reorder_within(word, n, author)
  ) |>

  # Construye el gráfico de barras
  ggplot(aes(word, n)) +
  geom_col() +
  coord_flip() +

  # Separa los resultados por autor
  facet_wrap(
    ~ author,
    scales = "free"
  ) +

  # Restaura las etiquetas originales
  scale_x_reordered() +

  # Define títulos y etiquetas
  labs(
    x = NULL,
    y = "Frecuencia",
    title = "Palabras más frecuentes por discurso"
  )

Las barras permiten comparar cantidades con mayor precisión que una nube de palabras. Una nube puede conservarse como recurso visual, pero no debe sustituir una comparación cuantitativa.

Similitud de vocabulario

Sea \(A\) el conjunto de tipos léxicos usados por Petro y \(B\) el de Duque. La similitud de Jaccard mide la fracción de vocabulario compartido:

\[ J(A,B)=\frac{|A\cap B|}{|A\cup B|}. \]

# Extrae el vocabulario de Petro
vocab_petro <- tokens_clean |>
  filter(author == "Petro") |>
  distinct(word) |>
  pull(word)

# Extrae el vocabulario de Duque
vocab_duque <- tokens_clean |>
  filter(author == "Duque") |>
  distinct(word) |>
  pull(word)

# Calcula la similitud de Jaccard
jaccard <- length(intersect(vocab_petro, vocab_duque)) /
  length(union(vocab_petro, vocab_duque))

# Muestra el resultado
tibble(jaccard = jaccard)
## # A tibble: 1 × 1
##   jaccard
##     <dbl>
## 1  0.0875

El índice de Jaccard es aproximadamente 0.088, lo que indica que solo cerca del 8.8% de los tipos léxicos presentes en al menos uno de los dos discursos aparecen en ambos. Esto sugiere una baja similitud entre sus vocabularios.

Jaccard compara presencia o ausencia de tipos léxicos.

La similitud coseno entre dos vectores \(\boldsymbol{p}\) y \(\boldsymbol{q}\) se define como

\[ \operatorname{cos}(\boldsymbol{p},\boldsymbol{q}) = \frac{\boldsymbol{p}^{\top}\boldsymbol{q}} {\|\boldsymbol{p}\|\,\|\boldsymbol{q}\|}. \]

La similitud coseno compara los perfiles de frecuencias relativas: valores cercanos a 1 indican mayor similitud y valores cercanos a 0, mayor diferencia. Toma valores entre 0 y 1. Valores cercanos a 1 indican perfiles más similares; valores cercanos a 0, perfiles más diferentes.

La divergencia de Jensen–Shannon entre dos distribuciones \(\boldsymbol{p}\) y \(\boldsymbol{q}\) se define como

\[ \operatorname{JS}(\boldsymbol{p},\boldsymbol{q}) = \frac{1}{2}\operatorname{KL}(\boldsymbol{p}\,\|\,\boldsymbol{m}) + \frac{1}{2}\operatorname{KL}(\boldsymbol{q}\,\|\,\boldsymbol{m}), \qquad \boldsymbol{m}=\frac{\boldsymbol{p}+\boldsymbol{q}}{2}, \]

donde

\[ \operatorname{KL}(\boldsymbol{p}\,\|\,\boldsymbol{m}) = \sum_i p_i \log_2\!\left(\frac{p_i}{m_i}\right). \]

La divergencia de Jensen–Shannon cuantifica la diferencia entre dos distribuciones de frecuencias. Ttoma valores entre 0 y 1. Valores cercanos a 0 indican distribuciones similares; valores cercanos a 1, distribuciones diferentes.

Ambas medidas son apropiadas para comparar perfiles léxicos y evitan interpretar los tipos de palabra como observaciones independientes.

# Construye la matriz de frecuencias relativas
freq_matrix <- word_freq |>
  select(author, word, proportion) |>
  pivot_wider(
    names_from = author,
    values_from = proportion,
    values_fill = 0
  )

# Define los perfiles de frecuencias
p <- freq_matrix$Petro
q <- freq_matrix$Duque

# Calcula la similitud coseno
cosine_similarity <- sum(p * q) /
  sqrt(sum(p^2) * sum(q^2))

# Calcula la distribución promedio
m <- (p + q) / 2

# Define la divergencia de Kullback--Leibler
kl_divergence <- function(x, y) {
  idx <- x > 0
  sum(x[idx] * log2(x[idx] / y[idx]))
}

# Calcula la divergencia de Jensen--Shannon
js_divergence <- 0.5 * kl_divergence(p, m) +
  0.5 * kl_divergence(q, m)

# Muestra ambas medidas
tibble(
  cosine_similarity = cosine_similarity,
  jensen_shannon_divergence = js_divergence
)
## # A tibble: 1 × 2
##   cosine_similarity jensen_shannon_divergence
##               <dbl>                     <dbl>
## 1             0.165                     0.811

La similitud coseno de 0.165 indica una baja similitud entre los perfiles de frecuencias de Petro y Duque. Consistentemente, la divergencia de Jensen–Shannon de 0.811 es alta, lo que indica que las distribuciones léxicas de ambos discursos son marcadamente diferentes.

Términos distintivos: keyness

La frecuencia muestra qué palabras aparecen más, mientras que la keyness identifica cuáles distinguen un discurso frente a otro.

Para cada término, la keyness compara su frecuencia observada con la esperada bajo igual distribución entre los discursos.

Para un término dado, las frecuencias observadas se organizan como

\[ \begin{array}{c|cc} & \text{término} & \text{otros términos}\\ \hline \text{Petro} & O_{11} & O_{12}\\ \text{Duque} & O_{21} & O_{22} \end{array} \]

La razón de verosimilitudes es

\[ G^2 = 2\sum_{i,j} O_{ij}\log\left(\frac{O_{ij}}{E_{ij}}\right), \]

donde \(i\) identifica el discurso y \(j\) la categoría término/otros términos. Las frecuencias esperadas son

\[ E_{ij} = \frac{(\text{total fila }i)(\text{total columna }j)} {\text{total general}}. \]

Valores altos de \(G^2\) indican términos más distintivos entre los discursos.

La medida identifica distintividad léxica, no importancia política ni causalidad.

# Construye el corpus a partir de los discursos
corpus_es <- corpus(
  setNames(speeches$text, speeches$author)
)

# Tokeniza y limpia el texto
tokens_es <- tokens(
  corpus_es,
  remove_punct = TRUE,
  remove_numbers = TRUE,
  remove_symbols = TRUE
) |>
  tokens_tolower() |>
  tokens_remove(stopwords::stopwords("es"))

# Construye la matriz documento-término
dfm_es <- dfm(tokens_es) |>

  # Conserva términos con frecuencia mínima de 2
  dfm_trim(min_termfreq = 2)

# Calcula la keyness tomando a Petro como objetivo
keyness_petro <- textstat_keyness(
  dfm_es,
  target = 1,
  measure = "lr"
)

# Identifica automáticamente la columna del estadístico
statistic_name <- setdiff(
  names(keyness_petro),
  c("feature", "p", "n_target", "n_reference")
)[1]

# Selecciona los términos más distintivos
keyness_plot <- keyness_petro |>
  as_tibble() |>
  transmute(
    feature,
    score = .data[[statistic_name]]
  ) |>

  # Conserva los 24 valores de mayor magnitud
  slice_max(
    order_by = abs(score),
    n = 24
  ) |>

  # Identifica el discurso favorecido
  mutate(
    favored = if_else(score > 0, "Petro", "Duque"),
    feature = reorder(feature, score)
  )

# Muestra los resultados
keyness_plot
## # A tibble: 26 × 3
##    feature  score favored
##    <fct>    <dbl> <chr>  
##  1 colombia -33.9 Duque  
##  2 selva     28.9 Petro  
##  3 guerra    21.8 Petro  
##  4 drogas    20.3 Petro  
##  5 poder     20.3 Petro  
##  6 acción   -13.7 Duque  
##  7 pandemia -13.7 Duque  
##  8 vida      11.5 Petro  
##  9 adicción  11.4 Petro  
## 10 petróleo  11.4 Petro  
## # ℹ 16 more rows
# Grafica los términos más distintivos
ggplot(
  keyness_plot,
  aes(feature, score)
) +
  geom_col() +
  coord_flip() +

  # Separa los términos favorecidos por discurso
  facet_wrap(
    ~ favored,
    scales = "free_y"
  ) +

  # Define títulos y etiquetas
  labs(
    x = NULL,
    y = "Keyness: razón de verosimilitudes con signo",
    title = "Términos que diferencian los discursos"
  )

En textstat_keyness(), la magnitud de \(G^2\) mide la fuerza de la diferencia y el signo indica su dirección: valores positivos corresponden a términos proporcionalmente más frecuentes en Petro y valores negativos a términos más frecuentes en Duque.

Contexto: concordancias

Una palabra aislada puede ser ambigua. Una concordancia o análisis keyword in context (KWIC) muestra el término dentro de su entorno inmediato y permite validar interpretaciones antes de asignarle un significado discursivo.

# Tokeniza el corpus conservando puntuación y números
tokens_context <- tokens(
  corpus_es,
  remove_punct = FALSE,
  remove_numbers = FALSE
)

# Muestra cada aparición de "paz" con 6 tokens de contexto a cada lado
kwic(tokens_context, pattern = "paz", window = 6)
## Keyword-in-context with 17 matches.
##                                                             
##  [Petro, 2176]   permitir que nuestro pueblo viva en | paz |
##  [Petro, 2332]                    . Es la hora de la | PAZ |
##  [Petro, 2383]            Ucrania y Rusia a hacer la | paz |
##  [Petro, 2387]                 hacer la paz. Solo en | paz |
##  [Petro, 2399]           esta nuestra tierra. No hay | paz |
##  [Petro, 2421]                 , con el planeta. Sin | paz |
##  [Petro, 2428]              con el planeta, no habrá | paz |
##  [Petro, 2439]           Sin justicia social, no hay | paz |
##    [Duque, 27]        , la construcción global de la | paz |
##   [Duque, 396] las afectaciones a la construcción de | paz |
##  [Duque, 1908]       avanza en la construcción de la | Paz |
##  [Duque, 1945] narcoterrorista. El frágil acuerdo de | paz |
##  [Duque, 2121]         que nunca han apostado por la | paz |
##  [Duque, 2151]          pesar de tantos desafíos, la | Paz |
##  [Duque, 2260]    Colombia, más coca significa menos | paz |
##  [Duque, 2349]      y también la consolidación de la | Paz |
##  [Duque, 2750]         migratoria. La Colombia de la | Paz |
##                                             
##  . Convoco a toda América Latina            
##  . Que los pueblos eslavos hablen           
##  . Solo en paz podremos salvar              
##  podremos salvar la vida en esta            
##  total, sin justicia social,                
##  con el planeta, no habrá                   
##  entre las naciones. Sin justicia           
##  social.                                    
##  y de soluciones a las amenazas             
##  y al cumplimiento de los Objetivos         
##  con Legalidad. Ni siquiera los             
##  firmado en 2016 con el grupo               
##  , quieren persistir en sus atentados       
##  con Legalidad nos muestra que podemos      
##  y menos medio ambiente. Cada               
##  con Legalidad demanda que sigamos avanzando
##  con verdad, justicia, reparación

La concordancia es especialmente importante para negaciones, citas y términos afectivos.

La presencia de una palabra negativa no implica que el hablante apoye su contenido; puede estar denunciándolo, negándolo o describiéndolo.

Análisis de sentimiento

El análisis léxico de sentimiento asigna a ciertas palabras una polaridad o categoría afectiva.

Es una aproximación transparente, pero depende fuertemente del diccionario y no representa una medida directa de la emoción o intención del hablante.

Esta sección reutiliza los archivos positive_words_es.txt y negative_words_es.txt.

# Define las rutas de los diccionarios de sentimiento
sentiment_files <- c(
  positive = "positive_words_es.txt",
  negative = "negative_words_es.txt"
)

# Verifica que ambos diccionarios estén disponibles
sentiment_available <- all(file.exists(sentiment_files))

# Función para leer y limpiar cada diccionario
read_lexicon <- function(path, sentiment) {

  tibble(
    word = read_lines(path) |>
      str_trim() |>
      str_remove('^"') |>
      str_remove('"$') |>
      str_to_lower()
  ) |>

    # Elimina entradas vacías y duplicadas
    filter(word != "") |>
    distinct(word) |>

    # Asigna la categoría de sentimiento
    mutate(sentiment = sentiment)
}

# Ejecuta el análisis si los diccionarios están disponibles
if (sentiment_available) {

  # Une los diccionarios positivo y negativo
  sentiment_words <- bind_rows(
    read_lexicon(
      sentiment_files[["positive"]],
      "Positivo"
    ),
    read_lexicon(
      sentiment_files[["negative"]],
      "Negativo"
    )
  )

  # Cuenta las palabras por categoría
  sentiment_words |>
    count(sentiment)

} else {

  # Informa si faltan los diccionarios
  cat(
    "No se encontraron los diccionarios de sentimiento. ",
    "La sección se omite sin interrumpir la compilación."
  )
}
## # A tibble: 2 × 2
##   sentiment     n
##   <chr>     <int>
## 1 Negativo   2720
## 2 Positivo   1553

Una comparación entre discursos debe controlar por su longitud y por la cobertura del diccionario.

Definimos un puntaje neto por mil tokens como la diferencia entre coincidencias positivas y negativas dividida por el número total de tokens,

\[ S = 1000\, \frac{N_{+}-N_{-}}{N}, \]

donde \(N_{+}\) y \(N_{-}\) son los números de tokens positivos y negativos, respectivamente, y \(N\) es el número total de tokens.

Los resultados deben describirse como uso de palabras asociadas con polaridad, no como medición directa del estado emocional del orador.

Además, expresiones como no es bueno muestran por qué un análisis de unigramas puede fallar al ignorar la negación y el contexto.

# Ejecuta el análisis si los diccionarios están disponibles
if (sentiment_available) {

  # Identifica palabras con sentimiento
  sentiment_matches <- tokens_full |>
    inner_join(
      sentiment_words,
      by = "word"
    )

  # Resume el sentimiento por autor
  sentiment_summary <- tokens_full |>
    count(
      author,
      name = "total_tokens"
    ) |>

    # Agrega conteos positivos y negativos
    left_join(
      sentiment_matches |>
        count(author, sentiment) |>
        pivot_wider(
          names_from = sentiment,
          values_from = n,
          values_fill = 0
        ),
      by = "author"
    ) |>

    # Calcula cobertura y balance de sentimiento
    mutate(
      matched = Positivo + Negativo,
      coverage = matched / total_tokens,
      net_score_per_1000 =
        1000 * (Positivo - Negativo) / total_tokens
    )

  # Muestra el resumen
  sentiment_summary
}
## # A tibble: 2 × 7
##   author total_tokens Negativo Positivo matched coverage net_score_per_1000
##   <chr>         <int>    <int>    <int>   <int>    <dbl>              <dbl>
## 1 Duque          2663      183      130     313    0.118              -19.9
## 2 Petro          2159      214       59     273    0.126              -71.8

Ambos discursos presentan un balance neto negativo, pero este es mucho más marcado en Petro: \(-71.8\) frente a \(-19.9\) por cada 1000 tokens.

Además, la cobertura del diccionario es similar en ambos casos, alrededor del 12%, lo que hace más comparable la diferencia observada.

# Ejecuta el gráfico si los diccionarios están disponibles
if (sentiment_available) {

  sentiment_matches |>

    # Cuenta las palabras por autor y polaridad
    count(
      author,
      word,
      sentiment,
      sort = TRUE
    ) |>

    # Selecciona las 8 palabras más frecuentes por grupo
    group_by(author, sentiment) |>
    slice_max(
      n,
      n = 8,
      with_ties = FALSE
    ) |>
    ungroup() |>

    # Asigna signo según la polaridad
    mutate(
      signed_n = if_else(
        sentiment == "Negativo",
        -n,
        n
      ),
      word = reorder_within(
        word,
        signed_n,
        author
      )
    ) |>

    # Construye el gráfico
    ggplot(
      aes(word, signed_n)
    ) +
    geom_col() +
    coord_flip() +

    # Separa los resultados por autor
    facet_wrap(
      ~ author,
      scales = "free"
    ) +

    # Restaura las etiquetas originales
    scale_x_reordered() +

    # Define títulos y etiquetas
    labs(
      x = NULL,
      y = "Frecuencia con signo",
      title = "Palabras asociadas con polaridad"
    )
}

Bigramas

Un bigrama es una secuencia ordenada de dos palabras consecutivas.

Por tanto, una representación natural es una red dirigida: una arista \(i\rightarrow j\) indica que la palabra \(j\) aparece inmediatamente después de \(i\).

# Construye los bigramas dentro de cada oración
bigrams <- sentences |>
  unnest_tokens(
    output = bigram,
    input = sentence,
    token = "ngrams",
    n = 2
  ) |>

  # Separa cada bigrama en sus dos palabras
  separate_wider_delim(
    bigram,
    delim = " ",
    names = c("word1", "word2")
  ) |>

  # Conserva palabras válidas y elimina stopwords
  filter(
    str_detect(word1, "^\\p{L}+$"),
    str_detect(word2, "^\\p{L}+$"),
    !word1 %in% stop_words_es$word,
    !word2 %in% stop_words_es$word
  )

# Cuenta la frecuencia de cada bigrama por autor
bigram_counts <- bigrams |>
  count(
    author,
    word1,
    word2,
    name = "weight",
    sort = TRUE
  )

# Muestra los 12 bigramas más frecuentes por autor
bigram_counts |>
  group_by(author) |>
  slice_max(
    weight,
    n = 12,
    with_ties = FALSE
  ) |>
  ungroup()
## # A tibble: 24 × 4
##    author word1      word2         weight
##    <chr>  <chr>      <chr>          <int>
##  1 Duque  señores    delegados          6
##  2 Duque  acción     climática          5
##  3 Duque  cambio     climático          3
##  4 Duque  fuerza     pública            3
##  5 Duque  agenda     social             2
##  6 Duque  ciudadanía juvenil            2
##  7 Duque  comunidad  internacional      2
##  8 Duque  comunidad  mundial            2
##  9 Duque  cuatro     millones           2
## 10 Duque  derechos   humanos            2
## # ℹ 14 more rows

Frecuencia frente a asociación

Una arista puede tener alta frecuencia simplemente porque sus palabras son frecuentes. Para medir la fuerza de asociación entre dos términos se utiliza la pointwise mutual information (PMI):

\[ \operatorname{PMI}(i,j) = \log_2\!\left( \frac{p(i,j)} {p_1(i)p_2(j)} \right), \]

donde \(p(i,j)\) es la probabilidad de observar el bigrama \((i,j)\), y \(p_1(i)\) y \(p_2(j)\) son las probabilidades marginales de \(i\) y \(j\) en la primera y segunda posición, respectivamente.

Un valor positivo indica que las palabras aparecen juntas más de lo esperado bajo independencia; un valor negativo indica lo contrario. No implica causalidad ni equivalencia semántica.

La PPMI (positive PMI) conserva únicamente las asociaciones positivas:

\[ \operatorname{PPMI}(i,j) = \max\{\operatorname{PMI}(i,j),0\}. \]

Por ejemplo, si entre 100 bigramas cambio aparece 20 veces en la primera posición, climático 10 veces en la segunda y cambio climático aparece 8 veces, entonces \(p(i,j)=0.08\), \(p_1(i)=0.20\) y \(p_2(j)=0.10\). Por tanto, \(\operatorname{PMI}=\log_2(4)=2\), lo que indica una asociación positiva fuerte entre ambas palabras.

# Calcula probabilidades conjuntas y marginales por autor
bigram_assoc <- bigram_counts |>
  group_by(author) |>

  # Total de bigramas observados
  mutate(
    total_bigrams = sum(weight)
  ) |>
  ungroup() |>

  # Frecuencia marginal de la primera palabra
  left_join(
    bigram_counts |>
      summarise(
        n_word1 = sum(weight),
        .by = c(author, word1)
      ),
    by = c("author", "word1")
  ) |>

  # Frecuencia marginal de la segunda palabra
  left_join(
    bigram_counts |>
      summarise(
        n_word2 = sum(weight),
        .by = c(author, word2)
      ),
    by = c("author", "word2")
  ) |>

  # Calcula PMI y PPMI
  mutate(
    p_pair = weight / total_bigrams,
    p_word1 = n_word1 / total_bigrams,
    p_word2 = n_word2 / total_bigrams,
    pmi = log2(
      p_pair / (p_word1 * p_word2)
    ),
    ppmi = pmax(pmi, 0)
  )

# Selecciona las asociaciones más fuertes
bigram_assoc |>

  # Evita asociaciones basadas en una sola aparición
  filter(weight >= 2) |>

  # Conserva los 10 valores de PPMI más altos por autor
  group_by(author) |>
  slice_max(
    ppmi,
    n = 10,
    with_ties = FALSE
  ) |>
  ungroup() |>

  # Muestra las variables principales
  select(
    author,
    word1,
    word2,
    weight,
    ppmi
  )
## # A tibble: 20 × 5
##    author word1       word2       weight  ppmi
##    <chr>  <chr>       <chr>        <int> <dbl>
##  1 Duque  ciudadanía  juvenil          2  7.81
##  2 Duque  derechos    humanos          2  7.81
##  3 Duque  fondo       monetario        2  7.81
##  4 Duque  hogares     vulnerables      2  7.81
##  5 Duque  importante  reforma          2  7.81
##  6 Duque  transición  energética       2  7.81
##  7 Duque  cambio      climático        3  7.23
##  8 Duque  cuatro      millones         2  7.23
##  9 Duque  desarrollo  sostenible       2  7.23
## 10 Duque  fraternidad migratoria       2  7.23
## 11 Petro  crisis      climática        2  6.97
## 12 Petro  acumulación ampliada         3  6.38
## 13 Petro  justicia    social           2  6.38
## 14 Petro  propia      sociedad         2  6.38
## 15 Petro  ustedes     hacen            2  6.38
## 16 Petro  verán       morir            2  6.38
## 17 Petro  cada        vez              4  5.97
## 18 Petro  cómo        puede            2  5.97
## 19 Petro  américa     latina           4  5.64
## 20 Petro  especie     humana           3  5.64

Red dirigida de bigramas

La red de bigramas usa los 45 bigramas más frecuentes. Cada palabra es un nodo y cada bigrama define una arista dirigida. El grosor de la arista representa su frecuencia y el tamaño del nodo, su grado.

# Función para construir una red de bigramas
make_bigram_graph <- function(author_name, n_edges = 45) {

  bigram_counts |>

    # Selecciona el discurso y elimina nombres propios de los autores
    filter(
      author == author_name,
      !word1 %in% c("petro", "duque"),
      !word2 %in% c("petro", "duque")
    ) |>

    # Conserva los bigramas más frecuentes
    slice_max(
      weight,
      n = n_edges,
      with_ties = FALSE
    ) |>

    # Define correctamente origen, destino y peso
    select(
      word1,
      word2,
      weight
    ) |>

    # Construye la red dirigida
    graph_from_data_frame(
      directed = TRUE
    )
}

# Construye las redes de ambos discursos
graph_petro <- make_bigram_graph("Petro")
graph_duque <- make_bigram_graph("Duque")

# Función para visualizar una red de bigramas
plot_bigram_graph <- function(graph, title, n_labels = 15) {

  # Calcula el grado total
  node_degree <- degree(graph, mode = "all")
  V(graph)$node_degree <- node_degree

  # Etiqueta los nodos de mayor grado
  V(graph)$node_label <- ifelse(
    rank(-node_degree, ties.method = "first") <= n_labels,
    V(graph)$name,
    NA
  )

  # Layout reproducible
  set.seed(123)
  layout_graph <- create_layout(graph, layout = "fr")

  # Construye la visualización
  ggraph(layout_graph) +

    # Aristas dirigidas
    geom_edge_link(
      aes(width = weight),
      alpha = 0.25,
      colour = "gray55",
      arrow = arrow(
        length = grid::unit(2, "mm"),
        type = "closed"
      ),
      end_cap = circle(2.5, "mm"),
      show.legend = FALSE
    ) +
    scale_edge_width(range = c(0.2, 2.2)) +

    # Nodos
    geom_node_point(
      aes(size = node_degree),
      shape = 21,
      fill = "white",
      colour = "gray25",
      stroke = 0.4
    ) +
    scale_size_continuous(
      range = c(2.5, 7),
      guide = "none"
    ) +

    # Etiquetas
    geom_node_text(
      aes(label = node_label),
      repel = TRUE,
      size = 3.2,
      family = "sans"
    ) +

    # Conserva la proporción del layout
    coord_fixed(
      clip = "off"
    ) +

    # Título y estilo
    labs(title = title) +
    theme_void() +
    theme(
      plot.title = element_text(
        hjust = 0.5,
        face = "bold",
        size = 14,
        margin = margin(b = 2)
      ),
      plot.margin = margin(2, 2, 2, 2)
    )
}

# Librerias
library(ggraph) 
library(patchwork)

# Construye las visualizaciones
plot_petro <- plot_bigram_graph(graph_petro, "Petro")
plot_duque <- plot_bigram_graph(graph_duque, "Duque")

# Muestra ambas redes lado a lado
plot_petro + plot_duque

Si se ignora deliberadamente el orden y se usa una red no dirigida, debe explicitarse que se está cambiando de una relación de sucesión a una relación de coocurrencia.

Redes de coocurrencia por ventanas

Los bigramas capturan únicamente palabras adyacentes. Una generalización consiste en conectar dos términos cuando aparecen dentro de una ventana de contexto.

Sea \(c_{ij}(h)\) el número de veces que los términos \(i\) y \(j\) aparecen separados por, a lo sumo, \(h\) posiciones en el texto.

La red puede definir el peso como \(w_{ij}=c_{ij}(h)\) o usar una ponderación que asigne mayor peso a coocurrencias entre palabras más próximas.

Se utiliza una ventana de cinco posiciones y un peso decreciente \(1/d\), donde \(d\) es la distancia entre los términos. Cada oración se trata como una unidad independiente para evitar coocurrencias entre oraciones distintas.

Construcción reproducible

Para comparar redes se mantienen constantes: preprocesamiento, ventana, vocabulario candidato y número de aristas retenidas. Esto reduce diferencias producidas únicamente por decisiones de construcción.

# Selecciona los 180 términos más frecuentes del corpus combinado
common_vocab <- tokens_clean |>
  count(word, sort = TRUE) |>
  slice_head(n = 180) |>
  pull(word)

# Función para construir una red de coocurrencia por ventanas
build_window_graph <- function(
    sentence_data,
    author_name,
    vocabulary,
    window = 5,
    top_edges = 220) {

  # Extrae las oraciones del autor
  author_sentences <- sentence_data |>
    filter(author == author_name) |>
    pull(sentence)

  # Construye un corpus con una oración por documento
  author_corpus <- corpus(author_sentences)

  # Tokeniza, limpia y restringe al vocabulario común
  author_tokens <- tokens(
    author_corpus,
    remove_punct = TRUE,
    remove_numbers = TRUE,
    remove_symbols = TRUE
  ) |>
    tokens_tolower() |>
    tokens_remove(stopwords::stopwords("es")) |>
    tokens_keep(vocabulary)

  # Construye la matriz de coocurrencias
  fcm_author <- fcm(
    author_tokens,
    context = "window",
    window = window,
    count = "weighted",
    weights = 1 / seq_len(window),
    ordered = FALSE,
    tri = TRUE
  )

  # Convierte la matriz a formato de aristas
  mat <- as.matrix(fcm_author)

  # Selecciona únicamente coocurrencias positivas
  idx <- which(
    upper.tri(mat) & mat > 0,
    arr.ind = TRUE
  )

  # Construye la tabla de aristas
  edges <- tibble(
    word1 = rownames(mat)[idx[, 1]],
    word2 = colnames(mat)[idx[, 2]],
    weight = mat[idx]
  ) |>

    # Conserva las aristas de mayor peso
    arrange(desc(weight)) |> 
       slice_head(n = top_edges)

  # Construye la red no dirigida
  graph <- graph_from_data_frame(
    edges,
    directed = FALSE
  ) |>

    # Elimina aristas múltiples y lazos
    simplify(
      remove.multiple = TRUE,
      remove.loops = TRUE,
      edge.attr.comb = list(
        weight = "sum",
        "ignore"
      )
    )

  # Convierte fuerza de asociación en distancia
  E(graph)$distance <- 1 / E(graph)$weight

  # Devuelve la red
  graph
}

# Construye la red de Petro
g_petro <- build_window_graph(
  sentences,
  "Petro",
  common_vocab
)

# Construye la red de Duque
g_duque <- build_window_graph(
  sentences,
  "Duque",
  common_vocab
)

Componente conexa gigante

# Extrae la componente conexa más grande
get_gcc <- function(graph) {

  # Identifica los componentes conexos
  comp <- components(graph)

  # Localiza el componente de mayor tamaño
  largest <- which.max(comp$csize)

  # Extrae la componente gigante
  induced_subgraph(
    graph,
    vids = which(comp$membership == largest)
  )
}

# Obtiene las componentes gigantes
gcc_petro <- get_gcc(g_petro)
gcc_duque <- get_gcc(g_duque)
# Función para visualizar una red de coocurrencia
plot_text_network <- function(graph, title, n_labels = 15) {

  # Calcula la fuerza de cada nodo
  node_strength <- strength(graph, weights = E(graph)$weight)

  # Guarda la fuerza como atributo nodal
  V(graph)$node_strength <- node_strength

  # Etiqueta únicamente los nodos de mayor fuerza
  V(graph)$node_label <- ifelse(
    rank(-node_strength, ties.method = "first") <= n_labels,
    V(graph)$name,
    NA
  )

  # Fija la semilla para reproducibilidad
  set.seed(123)

  # Construye la visualización
  ggraph(graph, layout = "fr") +

    # Aristas: grosor proporcional a la coocurrencia
    geom_edge_link(
      aes(width = weight),
      alpha = 0.25,
      colour = "gray55",
      show.legend = FALSE
    ) +
    scale_edge_width(range = c(0.2, 2.5)) +

    # Nodos: tamaño proporcional a la fuerza
    geom_node_point(
      aes(size = node_strength),
      shape = 21,
      fill = "white",
      colour = "gray25",
      stroke = 0.5
    ) +
    scale_size_continuous(range = c(2.5, 8), guide = "none") +

    # Etiqueta los nodos más centrales
    geom_node_text(
      aes(label = node_label),
      repel = TRUE,
      size = 3.3,
      family = "sans"
    ) +

    # Título y estilo
    labs(title = title) +
    theme_void() +
    theme(
      plot.title = element_text(hjust = 0.5, face = "bold", size = 14),
      plot.margin = margin(5, 5, 5, 5)
    )
}

# Librerias
library(ggraph) 
library(patchwork)

# Construye las dos figuras
plot_petro <- plot_text_network(gcc_petro, "Petro")

plot_duque <- plot_text_network(gcc_duque, "Duque")

# Muestra ambas redes lado a lado
plot_petro + plot_duque

El tamaño de un nodo representa aquí su fuerza, es decir, la suma de los pesos de sus aristas. Una conexión gruesa indica coocurrencia frecuente y próxima bajo la regla de ponderación elegida.

Comparación estructural de las redes

Los pesos de coocurrencia representan fuerza de asociación, no distancia.

Por ello, para caminos ponderados definimos distance = 1 / weight: una asociación fuerte corresponde a una distancia corta.

# Función para resumir las propiedades estructurales de una red
network_summary <- function(graph, full_graph, label) {

  # Detecta comunidades mediante Louvain
  community <- cluster_louvain(
    graph,
    weights = E(graph)$weight
  )

  # Calcula las principales métricas de la red
  tibble(
    author = label,

    # Tamaño de la red
    nodes = vcount(graph),
    edges = ecount(graph),

    # Proporción de nodos pertenecientes a la componente gigante
    gcc_share = vcount(graph) / vcount(full_graph),

    # Distribución del grado
    mean_degree = mean(degree(graph)),
    sd_degree = sd(degree(graph)),

    # Densidad
    density = edge_density(graph),

    # Distancia media sin considerar pesos
    mean_distance_unweighted = mean_distance(
      graph,
      directed = FALSE,
      weights = NA
    ),

    # Distancia media usando el inverso de la fuerza como distancia
    mean_distance_weighted = mean_distance(
      graph,
      directed = FALSE,
      weights = E(graph)$distance
    ),

    # Transitividad global
    transitivity = transitivity(
      graph,
      type = "global"
    ),

    # Asortatividad por grado
    assortativity_degree = assortativity_degree(
      graph,
      directed = FALSE
    ),

    # Tamaño de la clique máxima
    max_clique_size = clique_num(graph),

    # Número de comunidades
    communities = length(
      unique(membership(community))
    ),

    # Modularidad de la partición
    modularity = modularity(community)
  )
}

# Compara las redes de Petro y Duque
network_metrics <- bind_rows(
  network_summary(
    gcc_petro,
    g_petro,
    "Petro"
  ),
  network_summary(
    gcc_duque,
    g_duque,
    "Duque"
  )
)

# Organiza las métricas para facilitar la comparación
network_metrics |>
  pivot_longer(
    cols = -author,
    names_to = "Métrica",
    values_to = "Valor"
  ) |>
  pivot_wider(
    names_from = author,
    values_from = Valor
  ) |>

  # Muestra la tabla completa
  knitr::kable(
    digits = 3,
    align = c("l", "c", "c")
  )
Métrica Petro Duque
nodes 97.000 103.000
edges 219.000 219.000
gcc_share 0.980 0.981
mean_degree 4.515 4.252
sd_degree 3.479 3.575
density 0.047 0.042
mean_distance_unweighted 3.310 3.167
mean_distance_weighted 2.250 2.200
transitivity 0.175 0.128
assortativity_degree 0.023 -0.100
max_clique_size 5.000 4.000
communities 9.000 10.000
modularity 0.542 0.525

La red de Petro es ligeramente más densa y cohesionada, con mayor grado medio, transitividad y clique máxima.

La de Duque tiene más nodos, más comunidades y distancias medias algo menores.

Ambas presentan una componente gigante dominante y una modularidad similar (≈0.54), lo que sugiere una organización comunitaria comparable, aunque algo más fragmentada en Duque.

Interpretación de las medidas:

  • grado medio: número promedio de vecinos distintos;
  • densidad: proporción de aristas observadas respecto a las posibles;
  • distancia media no ponderada: número promedio de pasos topológicos;
  • distancia media ponderada: distancia basada en el inverso de la fuerza de coocurrencia;
  • transitividad: tendencia topológica a formar triángulos;
  • asortatividad de grado: asociación entre los grados de nodos adyacentes;
  • tamaño de la clique máxima: número de nodos de la mayor clique; no es el número de comunidades;
  • comunidades: grupos encontrados por Louvain;
  • modularidad: separación entre comunidades respecto a la esperada bajo un modelo nulo de conexiones.

Estas cantidades dependen del tamaño del vocabulario, la ventana, el filtrado y el número de aristas.

La comparación solo es interpretable porque esas decisiones se mantienen iguales entre discursos.

Centralidad

No existe una única definición de palabra importante. Cada medida representa una propiedad estructural diferente.

# Función para calcular medidas de centralidad
centrality_table <- function(graph, author_name) {

  tibble(
    author = author_name,
    word = V(graph)$name,

    # Número de conexiones
    degree = degree(graph),

    # Suma de los pesos de las conexiones
    strength = strength(graph, weights = E(graph)$weight),

    # Conexión con nodos también centrales
    eigenvector = eigen_centrality(
      graph,
      weights = E(graph)$weight
    )$vector,

    # Papel como puente entre otros nodos
    betweenness = betweenness(
      graph,
      weights = E(graph)$distance,
      normalized = TRUE
    ),

    # Cercanía al resto de la red
    closeness = closeness(
      graph,
      weights = E(graph)$distance,
      normalized = TRUE
    )
  )
}

# Calcula las centralidades para ambos discursos
centralities <- bind_rows(
  centrality_table(gcc_petro, "Petro"),
  centrality_table(gcc_duque, "Duque")
)

# Muestra las 10 palabras con mayor centralidad de eigenvector
centralities |>
  group_by(author) |>
  slice_max(eigenvector, n = 10, with_ties = FALSE) |>
  ungroup() |>
  select(author, word, degree, strength, eigenvector, betweenness)
## # A tibble: 20 × 6
##    author word         degree strength eigenvector betweenness
##    <chr>  <chr>         <dbl>    <dbl>       <dbl>       <dbl>
##  1 Duque  colombia         27    50.1        1          0.463 
##  2 Duque  avanza            3    11.1        0.618      0.0196
##  3 Duque  social           12    20.4        0.370      0.172 
##  4 Duque  acción            7    16.3        0.348      0.123 
##  5 Duque  agenda           10    16.9        0.341      0.0678
##  6 Duque  reactivación      5     7.28       0.294      0.0157
##  7 Duque  pandemia         13    18.3        0.261      0.117 
##  8 Duque  mayor            10    15.0        0.254      0.132 
##  9 Duque  frente           10    16.2        0.253      0.0802
## 10 Duque  delegados         2     8.83       0.247      0.0171
## 11 Petro  adicción         11    27          1          0.145 
## 12 Petro  petróleo          9    20.7        0.865      0.0379
## 13 Petro  poder            12    26.2        0.780      0.123 
## 14 Petro  carbón            7    15.2        0.676      0.0193
## 15 Petro  drogas           13    21.7        0.668      0.152 
## 16 Petro  dinero            8    15.3        0.649      0.0526
## 17 Petro  selva            22    36.9        0.545      0.313 
## 18 Petro  cocaína           7    10.9        0.487      0.0154
## 19 Petro  guerra           11    16.5        0.343      0.0989
## 20 Petro  salvar            7    16.3        0.322      0.141

En el discurso de Duque, colombia domina claramente la estructura de la red, con el mayor grado, fuerza, centralidad de eigenvector y betweenness, seguida por términos asociados con social, acción, agenda y pandemia.

En Petro, la centralidad se concentra en adicción, petróleo, poder, drogas y selva, lo que refleja un núcleo discursivo más ligado a recursos naturales, drogas y conflicto. En particular, selva destaca como un nodo con alta conectividad y capacidad de puente.

La fuerza resume coocurrencias acumuladas; la centralidad de eigenvector favorece palabras conectadas con otras palabras centrales; la betweenness identifica posibles puentes entre regiones de la red. Para betweenness y closeness se utiliza el inverso del peso como distancia.

Comunidades

Las comunidades son conjuntos de palabras con mayor conectividad interna de la esperada. No deben llamarse automáticamente temas: la interpretación temática requiere revisar sus términos y sus concordancias.

# Función para resumir las comunidades de una red
community_words <- function(graph, author_name, n_words = 6) {

  # Detecta comunidades mediante Louvain
  community <- cluster_louvain(
    graph,
    weights = E(graph)$weight
  )

  tibble(
    word = V(graph)$name,
    community = membership(community),

    # Calcula la fuerza de cada palabra
    strength = strength(
      graph,
      weights = E(graph)$weight
    )
  ) |>

    # Organiza los nodos por comunidad
    group_by(community) |>
    arrange(desc(strength), .by_group = TRUE) |>

    # Resume el tamaño y las palabras más representativas
    summarise(
      size = n(),
      representative_words = paste(
        head(word, n_words),
        collapse = ", "
      ),
      .groups = "drop"
    ) |>

    # Añade el autor
    mutate(
      author = author_name,
      .before = 1
    ) |>

    # Ordena las comunidades por tamaño
    arrange(desc(size))
}

# Resume las comunidades de ambos discursos
community_summary <- bind_rows(
  community_words(gcc_petro, "Petro"),
  community_words(gcc_duque, "Duque")
)

# Muestra los resultados
community_summary
## # A tibble: 17 × 4
##    author community   size representative_words                                 
##    <chr>  <membrshp> <int> <chr>                                                
##  1 Petro  3             19 selva, vida, salvar, amazónica, humanidad, mundo     
##  2 Petro  2             17 adicción, poder, petróleo, dinero, carbón, cocaína   
##  3 Petro  7             16 planta, millones, coca, norte, solo, ser             
##  4 Petro  4             12 drogas, guerra, sociedad, ocultar, medio, propia     
##  5 Petro  9             12 venenos, selvas, si, absorbe, plantas, mientras      
##  6 Petro  6              7 belleza, cómo, puede, muerte, acumulación, ampliada  
##  7 Petro  1              5 climático, produce, vez, cada, desastre              
##  8 Petro  5              5 américa, latina, democracia, convoco, morir          
##  9 Petro  8              4 paz, social, justicia, naciones                      
## 10 Duque  4             22 hoy, paz, desarrollo, global, multilateralismo, cons…
## 11 Duque  5             16 social, agenda, mayor, ciento, equidad, historia     
## 12 Duque  1             14 colombia, avanza, señores, delegados, reactivación, …
## 13 Duque  2             11 acción, crisis, efectos, mayores, cambio, climática  
## 14 Duque  6             11 salud, mundial, vulnerables, internacional, comunida…
## 15 Duque  3             10 pública, manera, tiempo, fuerza, cualquier, irrestri…
## 16 Duque  7             10 frente, fiscal, materia, requiere, endeudamiento, dé…
## 17 Duque  8              9 pandemia, naciones, ser, si, país, proceso

En Petro, las comunidades se concentran principalmente en temas de drogas, recursos naturales, selva, cambio climático, paz y justicia social. Destacan grupos como adicción–poder–petróleo–dinero–carbón–cocaína y selva–vida–salvar–amazónica–humanidad, que muestran núcleos discursivos claramente diferenciados.

En Duque, las comunidades se organizan alrededor de acción internacional, reactivación, crisis climática, paz, pandemia, salud, crecimiento y asuntos fiscales. Su red presenta una agenda más distribuida entre política internacional, recuperación económica y temas sociales.

# Función para visualizar las comunidades
plot_communities <- function(graph, title, n_labels = 2) {

  # Detecta comunidades mediante Louvain
  community <- cluster_louvain(graph, weights = E(graph)$weight)

  # Calcula comunidad y fuerza de cada nodo
  community_id <- membership(community)
  node_strength <- strength(graph, weights = E(graph)$weight)

  # Guarda los atributos nodales
  V(graph)$community <- factor(community_id)
  V(graph)$node_strength <- node_strength

  # Etiqueta los nodos con mayor fuerza dentro de cada comunidad
  label_rank <- ave(
    -node_strength,
    community_id,
    FUN = function(x) rank(x, ties.method = "first")
  )

  V(graph)$node_label <- ifelse(
    label_rank <= n_labels,
    V(graph)$name,
    NA
  )

  # Calcula la modularidad
  Q <- modularity(community)

  # Layout reproducible
  set.seed(123)
  layout_graph <- create_layout(graph, layout = "fr")

  # Construye la visualización
  ggraph(layout_graph) +

    # Aristas
    geom_edge_link(
      aes(width = weight),
      colour = "gray60",
      alpha = 0.20,
      show.legend = FALSE
    ) +
    scale_edge_width(range = c(0.2, 2)) +

    # Nodos coloreados por comunidad
    geom_node_point(
      aes(size = node_strength, fill = community),
      shape = 21,
      colour = "white",
      stroke = 0.4
    ) +
    scale_size_continuous(range = c(2.5, 8), guide = "none") +
    scale_fill_viridis_d(guide = "none") +

    # Etiqueta las palabras principales de cada comunidad
    geom_node_text(
      aes(label = node_label),
      repel = TRUE,
      size = 3.2,
      family = "sans"
    ) +

    # Conserva las proporciones del layout
    coord_fixed(clip = "off") +

    # Título y modularidad
    labs(
      title = title,
      subtitle = paste0("Modularidad Q = ", round(Q, 3))
    ) +
    theme_void() +
    theme(
      plot.title = element_text(hjust = 0.5, face = "bold", size = 14),
      plot.subtitle = element_text(hjust = 0.5, size = 10),
      plot.margin = margin(4, 4, 4, 4)
    )
}

# Construye las visualizaciones
plot_petro <- plot_communities(gcc_petro, "Petro")
plot_duque <- plot_communities(gcc_duque, "Duque")

# Muestra ambas redes lado a lado
plot_petro + plot_duque

El número asignado a cada comunidad es solo una etiqueta algorítmica. Su valor numérico no tiene interpretación sustantiva y puede cambiar entre algoritmos o ejecuciones.

Sensibilidad de la red

Una red textual es el resultado de decisiones analíticas. Para estudiar su estabilidad conviene variar al menos el tamaño de ventana y el número de aristas retenidas.

# Define las combinaciones de ventana y número de aristas
sensitivity_grid <- expand_grid(
  window = c(2, 5, 8),
  top_edges = c(120, 220, 320)
)

# Evalúa la sensibilidad de la red a ambas decisiones
sensitivity_results <- sensitivity_grid |>
  mutate(
    # Construye una red para cada combinación de parámetros
    graph = map2(
      window,
      top_edges,
      ~ build_window_graph(
        sentences,
        "Petro",
        common_vocab,
        window = .x,
        top_edges = .y
      )
    ),

    # Extrae la componente gigante
    gcc = map(graph, get_gcc),

    # Calcula métricas estructurales
    nodes = map_int(gcc, vcount),
    edges = map_int(gcc, ecount),
    density = map_dbl(gcc, edge_density),
    transitivity = map_dbl(
      gcc,
      ~ transitivity(.x, type = "global")
    )
  ) |>

  # Elimina los objetos de red de la tabla final
  select(-graph, -gcc)

# Muestra los resultados
sensitivity_results
## # A tibble: 9 × 6
##   window top_edges nodes edges density transitivity
##    <dbl>     <dbl> <int> <int>   <dbl>        <dbl>
## 1      2       120    73   113  0.0430        0.194
## 2      2       220    91   215  0.0525        0.167
## 3      2       320   119   320  0.0456        0.156
## 4      5       120    75   115  0.0414        0.233
## 5      5       220    97   219  0.0470        0.175
## 6      5       320   119   320  0.0456        0.164
## 7      8       120    73   114  0.0434        0.249
## 8      8       220    98   219  0.0461        0.173
## 9      8       320   119   320  0.0456        0.164

La red es estable frente al tamaño de la ventana.

El número de aristas tiene mayor efecto: al aumentarlo, crece la red y disminuye ligeramente la transitividad, mientras la densidad permanece relativamente estable.

Si las conclusiones cambian drásticamente con pequeñas modificaciones del parámetro, deben reportarse como sensibles a la especificación.

La sensibilidad no es un defecto: es información sobre qué tan determinada está la estructura por los datos frente a las decisiones del analista.

Representaciones vectoriales y similitud textual

Las redes permiten estudiar relaciones entre términos, pero no son la única forma de representar un texto.

Una alternativa fundamental consiste en transformar cada documento en un vector numérico.

Una vez realizada esta transformación, podemos comparar documentos, reducir la dimensión del espacio y estudiar semejanzas léxicas o semánticas.

En esta sección estudiaremos cuatro herramientas complementarias:

  1. Matriz documento–término y TF–IDF.
  2. SVD y análisis semántico latente (LSA).
  3. Similitud coseno.
  4. Embeddings contextuales.

Como ejemplo utilizaremos reseñas de películas clasificadas como positivas o negativas.

Este caso permite observar una diferencia importante, ya que dos reseñas pueden expresar opiniones similares utilizando palabras diferentes y, por tanto, ser poco similares léxicamente pero muy similares semánticamente.

Preparación

Utilizamos una muestra balanceada de reseñas de IMDB incluida en text2vec. Para mantener los cálculos rápidos seleccionamos 100 reseñas positivas y 100 negativas de longitud moderada.

library(tidyverse)  # Manipulación y visualización de datos
library(quanteda)   # Procesamiento y análisis de texto
library(text2vec)   # Representación vectorial de texto
library(Matrix)     # Manejo de matrices dispersas
library(irlba)      # Descomposición SVD eficiente
library(reticulate) # Integración de R con Python

# Fija la semilla para reproducibilidad
set.seed(123)

# Carga las reseñas de IMDB
data("movie_review", package = "text2vec")

# Limpia y prepara los textos
reviews <- movie_review |>
  mutate(
    sentiment = if_else(sentiment == 1, "Positiva", "Negativa"),
    review_clean = review |>
      str_replace_all("<[^>]+>", " ") |>
      str_squish(),
    n_words = str_count(review_clean, "\\S+")
  ) |>
  # Conserva reseñas de longitud moderada
  filter(between(n_words, 40, 100)) |>
  group_by(sentiment) |>
  slice_sample(n = 100) |>
  ungroup() |>
  mutate(
    doc_id = paste0("review_", row_number())
  )

# Verifica el número de documentos
reviews |>
  count(sentiment)
## # A tibble: 2 × 2
##   sentiment     n
##   <chr>     <int>
## 1 Negativa    100
## 2 Positiva    100

El objeto contiene 200 documentos y una variable externa, sentiment, que utilizaremos únicamente para interpretar las representaciones obtenidas.

Matriz documento–término

La representación más directa de un corpus es la matriz documento–término.

Si tenemos \(D\) documentos y \(V\) términos diferentes, construimos una matriz \(\boldsymbol X=(x_{ij})\) de dimensión \(D\times V\), donde \(x_{ij}\) representa la frecuencia del término \(j\) en el documento \(i\).

Cada documento queda entonces representado por una fila de \(\boldsymbol X\). Dos documentos que utilizan vocabulario similar tendrán vectores parecidos.

Esta representación es normalmente dispersa, pues cada documento utiliza solo una pequeña fracción del vocabulario total.

# Construye el corpus a partir de las reseñas
review_corpus <- corpus(
  reviews,
  text_field = "review_clean",
  docid_field = "doc_id"
)

# Tokeniza los textos y elimina puntuación, números y símbolos
review_tokens <- tokens(
  review_corpus,
  remove_punct = TRUE,
  remove_numbers = TRUE,
  remove_symbols = TRUE
) |>
  # Convierte todas las palabras a minúsculas
  tokens_tolower() |>
  # Elimina palabras vacías en inglés
  tokens_remove(stopwords::stopwords("en"))

# Construye la matriz documento--término
review_dfm <- dfm(review_tokens) |>
  # Conserva términos presentes en al menos 5 documentos
  dfm_trim(
    min_docfreq = 5,
    docfreq_type = "count"
  ) |>
  # Conserva términos presentes en máximo el 80% de los documentos
  dfm_trim(
    max_docfreq = 0.80,
    docfreq_type = "prop"
  )

# Muestra el número de documentos y términos
dim(review_dfm)
## [1] 200 276
# Muestra la matriz documento--término
review_dfm
## Document-feature matrix of: 200 documents, 276 features (94.44% sparse) and 4
## docvars.
##           features
## docs       one action used video hope wish acting terrible well bad
##   review_1   1      1    1     1    1    1      1        1    1   1
##   review_2   2      0    0     0    0    0      1        0    0   0
##   review_3   0      0    0     0    0    0      0        0    0   0
##   review_4   1      0    0     0    0    0      1        0    2   1
##   review_5   0      0    0     0    0    0      0        0    0   0
##   review_6   0      0    0     0    0    0      0        0    0   2
## [ reached max_ndoc ... 194 more documents, reached max_nfeat ... 266 more
## features ]

TF–IDF

Las frecuencias brutas tienen una limitación, pues términos que aparecen en muchos documentos pueden dominar la representación aunque aporten poca información para distinguirlos.

TF–IDF combina dos componentes. El primero es la frecuencia relativa del término dentro del documento (Term Frequency, TF),

\[ \operatorname{TF}_{ij} = \frac{n_{ij}}{\sum_k n_{ik}}, \]

donde \(n_{ij}\) es el número de apariciones del término \(j\) en el documento \(i\). El segundo es la frecuencia inversa de documentos (Inverse Document Frequency, IDF),

\[ \operatorname{IDF}_j = \log\left(\frac{D}{df_j}\right), \]

donde \(D\) es el número total de documentos y \(df_j\) el número de documentos que contienen el término \(j\).

Así, IDF asigna menor peso a los términos muy comunes en el corpus y mayor peso a los más específicos.

Su producto es

\[ \operatorname{TFIDF}_{ij} = \operatorname{TF}_{ij} \times \operatorname{IDF}_j. \]

Un término recibe un peso alto cuando es frecuente en un documento pero relativamente raro en el corpus.

# Calcula la matriz TF--IDF
review_tfidf <- dfm_tfidf(review_dfm, scheme_tf = "prop")

review_tfidf
## Document-feature matrix of: 200 documents, 276 features (94.44% sparse) and 4
## docvars.
##           features
## docs              one     action      used     video      hope      wish
##   review_1 0.03459782 0.09131436 0.1171445 0.1075338 0.1119948 0.1000792
##   review_2 0.05291431 0          0         0         0         0        
##   review_3 0          0          0         0         0         0        
##   review_4 0.01550937 0          0         0         0         0        
##   review_5 0          0          0         0         0         0        
##   review_6 0          0          0         0         0         0        
##           features
## docs           acting   terrible       well        bad
##   review_1 0.05637141 0.09689518 0.06946846 0.06815820
##   review_2 0.04310755 0          0          0         
##   review_3 0          0          0          0         
##   review_4 0.02526994 0          0.06228207 0.03055368
##   review_5 0          0          0          0         
##   review_6 0          0          0          0.11075708
## [ reached max_ndoc ... 194 more documents, reached max_nfeat ... 266 more
## features ]

Podemos examinar qué términos reciben, en promedio, mayor peso dentro de cada tipo de reseña.

# Convierte la matriz TF--IDF a una matriz convencional
tfidf_matrix <- as.matrix(review_tfidf)

# Calcula el peso TF--IDF promedio de cada término por sentimiento
mean_tfidf <- map_dfr(
  c("Negativa", "Positiva"),
  function(group) {

    # Selecciona las reseñas del grupo y promedia cada término
    tibble(
      term = colnames(tfidf_matrix),
      mean_tfidf = colMeans(
        tfidf_matrix[reviews$sentiment == group, , drop = FALSE]
      ),
      sentiment = group
    )
  }
)

# Selecciona los 12 términos con mayor TF--IDF promedio por sentimiento
top_tfidf <- mean_tfidf |>
  group_by(sentiment) |>
  slice_max(
    mean_tfidf,
    n = 12,
    with_ties = FALSE
  ) |>
  ungroup()

# Ordena los términos dentro de cada grupo para la visualización
top_tfidf |>
  mutate(
    term = tidytext::reorder_within(
      term,
      mean_tfidf,
      sentiment
    )
  ) |>

  # Construye el gráfico de barras
  ggplot(aes(term, mean_tfidf)) +
  geom_col() +
  coord_flip() +

  # Separa las reseñas positivas y negativas
  facet_wrap(
    ~ sentiment,
    scales = "free"
  ) +

  # Restaura las etiquetas originales de los términos
  tidytext::scale_x_reordered() +

  # Define títulos y etiquetas
  labs(
    x = NULL,
    y = "TF--IDF promedio",
    title = "Términos característicos de las reseñas"
  )

La representación TF–IDF describe principalmente qué palabras caracterizan cada documento.

Sin embargo, TF–IDF se basa en las palabras observadas y trata términos diferentes como dimensiones independientes, incluso cuando tienen significados similares.

SVD y análisis semántico latente

En una matriz TF–IDF, cada término del vocabulario representa una dimensión. Por ejemplo, si después del preprocesamiento quedan 2,000 términos, cada documento queda representado por un vector de 2,000 componentes.

Muchas de estas dimensiones presentan patrones similares o redundantes.

La descomposición en valores singulares (SVD) permite resumir esa información en un número menor de dimensiones latentes.

La SVD de una matriz \(\boldsymbol X\) se expresa como

\[ \boldsymbol X=\boldsymbol U\boldsymbol D\boldsymbol V^\top, \]

donde las columnas de \(\boldsymbol U\) describen las dimensiones latentes de los documentos, las columnas de \(\boldsymbol V\) describen las correspondientes dimensiones latentes de los términos, y \(\boldsymbol D\) contiene los valores singulares, que indican la importancia de cada dimensión.

Conservando únicamente las primeras \(k\) dimensiones obtenemos la aproximación

\[ \boldsymbol X \approx \boldsymbol U_k \boldsymbol D_k \boldsymbol V_k^\top. \]

Esta es la base del análisis semántico latente (Latent Semantic Analysis, LSA). Cada documento pasa de estar representado por miles de términos a un vector de solo \(k\) dimensiones latentes.

Interpretación

Las dimensiones latentes no representan necesariamente palabras o temas específicos, sino patrones de coocurrencia entre términos a través de los documentos.

Por ello, dos documentos pueden quedar próximos en el espacio LSA incluso si no comparten exactamente las mismas palabras, siempre que utilicen vocabulario asociado con patrones similares.

# Convierte la matriz TF--IDF a formato disperso
tfidf_sparse <- methods::as(
  review_tfidf,
  "dgCMatrix"
)

# Calcula una SVD truncada conservando 20 dimensiones latentes
lsa_fit <- irlba(
  tfidf_sparse,
  nv = 20,
  nu = 20
)

# Multiplica cada componente de U por su valor singular correspondiente
lsa_documents <- sweep(
  lsa_fit$u,
  MARGIN = 2,
  STATS = lsa_fit$d,
  FUN = "*"
)

# Muestra el número de documentos y dimensiones latentes
dim(lsa_documents)
## [1] 200  20

Cada reseña está ahora representada mediante 20 dimensiones latentes en lugar de una dimensión por término.

Visualización en dos dimensiones

Podemos utilizar las dos primeras dimensiones para visualizar los documentos.

# Construye el mapa con las dos primeras dimensiones latentes
lsa_map <- reviews |>
  transmute(
    sentiment,
    dim1 = lsa_documents[, 1],
    dim2 = lsa_documents[, 2]
  )

# Visualiza las reseñas en el espacio LSA
ggplot(
  lsa_map,
  aes(
    dim1,
    dim2,
    color = sentiment,
    shape = sentiment
  )
) +
  # Representa cada reseña como un punto
  geom_point(
    alpha = 0.7,
    size = 2.5
  ) +
  # Asigna colores según el sentimiento
  scale_color_manual(
    values = c(
      "Negativa" = "#D55E00",
      "Positiva" = "#0072B2"
    )
  ) +
  # Define títulos y etiquetas
  labs(
    x = "Dimensión latente 1",
    y = "Dimensión latente 2",
    color = "Sentimiento",
    shape = "Sentimiento",
    title = "Representación LSA de las reseñas"
  ) +
  theme_minimal()

Una separación entre reseñas positivas y negativas sugiere que las dimensiones latentes identificadas por LSA capturan patrones léxicos asociados con la valoración de la película.

El signo de cada dimensión es arbitrario; por ello, la interpretación debe centrarse en la posición relativa y agrupamiento de los documentos en el espacio latente.

Similitud coseno

Una representación vectorial permite cuantificar la semejanza entre textos mediante medidas basadas en la proximidad entre sus vectores.

Sean \(\boldsymbol x_i\) y \(\boldsymbol x_j\) los vectores asociados con dos documentos. Su similitud coseno es

\[ \operatorname{cos} (\boldsymbol x_i,\boldsymbol x_j) = \frac{ \boldsymbol x_i^\top\boldsymbol x_j }{ \|\boldsymbol x_i\| \|\boldsymbol x_j\| }. \]

La similitud coseno compara la orientación de los vectores, no su magnitud.

Por ello, documentos de diferente longitud pueden considerarse similares si presentan patrones relativos de términos semejantes.

Para vectores TF–IDF no negativos, la similitud coseno toma valores entre 0 y 1. Valores cercanos a 1 indican perfiles textuales muy similares, mientras que valores cercanos a 0 indican poca semejanza.

Similitud entre reseñas

Primero se normaliza los vectores TF–IDF y se calcula la similitud coseno entre todas las parejas de reseñas.

Luego se compara si las reseñas con el mismo sentimiento presentan, en promedio, mayor similitud que aquellas con sentimientos distintos.

# Calcula la norma euclidiana de cada vector TF--IDF
tfidf_norm <- sqrt(Matrix::rowSums(tfidf_sparse^2))

# Normaliza cada documento para que su vector tenga norma igual a 1
tfidf_unit <- Matrix::Diagonal(
  x = 1 / pmax(tfidf_norm, 1e-12)
) %*% tfidf_sparse

# Calcula la similitud coseno entre todos los pares de documentos
tfidf_similarity <- as.matrix(tcrossprod(tfidf_unit))

# Selecciona únicamente una vez cada pareja de documentos
pair_index <- which(
  upper.tri(tfidf_similarity),
  arr.ind = TRUE
)

# Construye una tabla con las similitudes entre pares
tfidf_pairs <- tibble(
  # Índices de los documentos comparados
  i = pair_index[, 1],
  j = pair_index[, 2],

  # Similitud coseno entre ambos documentos
  similarity = tfidf_similarity[pair_index],

  # Identifica si las reseñas tienen el mismo sentimiento
  comparison = if_else(
    reviews$sentiment[i] == reviews$sentiment[j],
    "Mismo sentimiento",
    "Sentimientos distintos"
  )
)

# Resume la similitud por tipo de comparación
tfidf_pairs |>
  summarise(
    # Similitud promedio
    mean_similarity = mean(similarity),

    # Similitud mediana
    median_similarity = median(similarity),

    .by = comparison
  )
## # A tibble: 2 × 3
##   comparison             mean_similarity median_similarity
##   <chr>                            <dbl>             <dbl>
## 1 Mismo sentimiento               0.0597            0.0431
## 2 Sentimientos distintos          0.0515            0.0350

Las reseñas con el mismo sentimiento presentan mayor similitud léxica que aquellas con sentimientos distintos, tanto en promedio (0.060 vs. 0.051) como en la mediana (0.043 vs. 0.035).

Sin embargo, la diferencia es pequeña, lo que indica que el sentimiento explica solo una parte de la similitud entre los textos.

Si el vocabulario está relacionado con la evaluación de las películas, esperamos que dos reseñas con el mismo sentimiento sean, en promedio, más similares que dos reseñas con sentimientos diferentes.

# Compara las distribuciones de similitud
ggplot(
  tfidf_pairs,
  aes(comparison, similarity)
) +
  geom_boxplot(
    outlier.alpha = 0.15
  ) +
  labs(
    x = NULL,
    y = "Similitud coseno",
    title = "Similitud léxica entre reseñas"
  )

La similitud coseno no está asociada exclusivamente con TF–IDF. Puede calcularse sobre cualquier representación vectorial, incluyendo LSA y embeddings.

Embeddings contextuales

TF–IDF y LSA representan los textos a partir de patrones léxicos observados en el corpus.

En cambio, los embeddings contextuales utilizan modelos de lenguaje entrenados sobre grandes colecciones de texto para representar palabras, oraciones o documentos mediante vectores densos con información semántica.

Su característica principal es que la representación depende del contexto, pues una misma palabra puede tener vectores diferentes según las palabras y expresiones que la acompañan.

Transformers

Los transformers son arquitecturas de redes neuronales diseñadas para representar secuencias considerando simultáneamente las relaciones entre sus elementos.

Su componente central es el mecanismo de atención, que permite que cada palabra determine qué otras palabras del texto son más relevantes para construir su representación contextual.

En el mecanismo de self-attention, cada token se transforma en tres vectores:

  • query (\(\boldsymbol Q\)), que representa la información que el token busca;
  • key (\(\boldsymbol K\)), que describe la información disponible en cada token;
  • value (\(\boldsymbol V\)), que contiene la información que finalmente se combina.

La atención se calcula como

\[ \operatorname{Attention} (\boldsymbol Q,\boldsymbol K,\boldsymbol V) = \operatorname{softmax} \left( \frac{\boldsymbol Q\boldsymbol K^\top}{\sqrt{d_k}} \right) \boldsymbol V, \]

donde \(d_k\) es la dimensión de los vectores key. El producto \(\boldsymbol Q\boldsymbol K^\top\) mide la compatibilidad entre los tokens, softmax convierte estos valores en pesos de atención, y la multiplicación por \(\boldsymbol V\) genera una combinación ponderada de la información contextual.

Este mecanismo permite que la representación de cada token incorpore información procedente de otros tokens del texto.

Para obtener un único vector para una oración o documento, las representaciones de los tokens se combinan mediante una operación de pooling. El resultado es un vector denso

\[ \boldsymbol e_i\in\mathbb R^p \]

que intenta conservar información relevante sobre el significado global del texto.

Construcción de embeddings

Utilizamos sentence-transformers mediante reticulate. La declaración de la dependencia Python se realiza directamente desde R.

# Usa un entorno Python administrado por reticulate
Sys.setenv(RETICULATE_PYTHON = "managed")

# Declara la dependencia Python
py_require(packages = "sentence-transformers")

# Importa sentence-transformers
sentence_transformers <- import("sentence_transformers")

# Carga el modelo
embedding_model <- sentence_transformers$SentenceTransformer(
  "sentence-transformers/all-MiniLM-L6-v2"
)

Generamos un embedding para cada reseña.

Solicitamos directamente vectores normalizados, por lo que su producto interno coincide con la similitud coseno.

# Calcula los embeddings contextuales
review_embeddings <- embedding_model$encode(
  reviews$review_clean,
  batch_size = 16L,
  show_progress_bar = FALSE,
  normalize_embeddings = TRUE
)

# Convierte el resultado en una matriz de R
review_embeddings <- as.matrix(review_embeddings)

# Muestra sus dimensiones
dim(review_embeddings)
## [1] 200 384

A diferencia de TF–IDF, la dimensión del embedding no depende del número de palabras presentes en nuestro corpus.

Visualización del espacio semántico

Los embeddings siguen siendo multidimensionales. Podemos utilizar PCA para obtener una visualización bidimensional.

# Reduce los embeddings a dos dimensiones
embedding_pca <- prcomp(
  review_embeddings,
  center = TRUE,
  scale. = FALSE
)

# Construye el mapa semántico
embedding_map <- reviews |>
  transmute(
    sentiment,
    dim1 = embedding_pca$x[, 1],
    dim2 = embedding_pca$x[, 2]
  )

# Visualiza las reseñas
ggplot(
  embedding_map,
  aes(
    dim1,
    dim2,
    color = sentiment,
    shape = sentiment
  )
) +
  # Representa cada reseña como un punto
  geom_point(
    alpha = 0.7,
    size = 2.5
  ) +
  # Asigna colores según el sentimiento
  scale_color_manual(
    values = c(
      "Negativa" = "#D55E00",
      "Positiva" = "#0072B2"
    )
  ) +
  # Define títulos y etiquetas
  labs(
    x = "Componente 1",
    y = "Componente 2",
    color = "Sentimiento",
    shape = "Sentimiento",
    title = "Representación contextual de las reseñas"
  ) +
  theme_minimal()

La interpretación cambia respecto de LSA. La proximidad en este espacio busca reflejar principalmente semejanza semántica, no simplemente patrones de palabras compartidas.

Similitud semántica

Como los embeddings fueron normalizados, podemos calcular todas las similitudes coseno mediante un producto matricial.

# Calcula la similitud coseno entre todas las reseñas
# Como los embeddings están normalizados, el producto interno equivale al coseno
embedding_similarity <- tcrossprod(
  review_embeddings
)

# Construye una tabla con las mismas parejas usadas en TF--IDF
embedding_pairs <- tibble(
  # Índices de las reseñas comparadas
  i = pair_index[, 1],
  j = pair_index[, 2],

  # Similitud semántica entre ambas reseñas
  similarity = embedding_similarity[pair_index],

  # Identifica si tienen el mismo sentimiento
  comparison = if_else(
    reviews$sentiment[i] == reviews$sentiment[j],
    "Mismo sentimiento",
    "Sentimientos distintos"
  )
)

# Resume la similitud semántica por tipo de comparación
embedding_pairs |>
  summarise(
    # Similitud promedio
    mean_similarity = mean(similarity),

    # Similitud mediana
    median_similarity = median(similarity),

    .by = comparison
  )
## # A tibble: 2 × 3
##   comparison             mean_similarity median_similarity
##   <chr>                            <dbl>             <dbl>
## 1 Mismo sentimiento                0.336             0.337
## 2 Sentimientos distintos           0.318             0.322

Las reseñas con el mismo sentimiento presentan mayor similitud semántica que aquellas con sentimientos distintos, tanto en promedio (0.336 vs. 0.318) como en la mediana (0.337 vs. 0.322).

La diferencia es moderada, lo que sugiere que el sentimiento explica parte, pero no toda, de la estructura semántica de las reseñas.

Esta comparación pregunta ahora si reseñas con la misma valoración presentan mayor proximidad semántica, aunque no necesariamente utilicen las mismas palabras.

Similitud léxica frente a similitud semántica

Una de las diferencias más importantes entre TF–IDF y los embeddings puede verse comparando ambas medidas para exactamente las mismas parejas de documentos.

# Une similitud léxica y semántica
similarity_comparison <- tfidf_pairs |>
  transmute(
    i,
    j,
    comparison,
    lexical_similarity = similarity,
    semantic_similarity = embedding_similarity[
      cbind(i, j)
    ]
  )

# Calcula la asociación entre ambas medidas
similarity_comparison |>
  summarise(
    correlation = cor(
      lexical_similarity,
      semantic_similarity
    )
  )
## # A tibble: 1 × 1
##   correlation
##         <dbl>
## 1       0.170
# Compara ambas nociones de similitud
ggplot(
  similarity_comparison,
  aes(
    lexical_similarity,
    semantic_similarity
  )
) +
  geom_point(
    alpha = 0.25,
    size = 1.5
  ) +
  geom_smooth(
    method = "lm",
    se = FALSE
  ) +
  labs(
    x = "Similitud TF--IDF",
    y = "Similitud mediante embeddings",
    title = "Similitud léxica y semántica"
  )

Una correlación positiva indica que ambas representaciones capturan parte de la misma estructura.

Sin embargo, la relación no debería ser perfecta porque responden a preguntas diferentes.

Textos semánticamente similares con vocabulario diferente

El caso más interesante aparece cuando dos reseñas tienen baja similitud TF–IDF pero alta similitud mediante embeddings.

# Define el umbral de baja similitud léxica
# Se toma el 25% inferior de las similitudes TF--IDF
lexical_cutoff <- quantile(
  similarity_comparison$lexical_similarity,
  0.25
)

# Define el umbral de alta similitud semántica
# Se toma el 10% superior de las similitudes entre embeddings
semantic_cutoff <- quantile(
  similarity_comparison$semantic_similarity,
  0.90
)

# Busca pares léxicamente distintos pero semánticamente próximos
semantic_pairs <- similarity_comparison |>

  # Conserva pares con baja similitud léxica y alta similitud semántica
  filter(
    lexical_similarity <= lexical_cutoff,
    semantic_similarity >= semantic_cutoff
  ) |>

  # Ordena los pares desde la mayor similitud semántica
  arrange(
    desc(semantic_similarity)
  ) |>

  # Selecciona los seis pares más representativos
  slice_head(n = 6) |>

  # Construye una tabla con los textos y sus similitudes
  transmute(
    # Sentimiento de cada reseña
    sentiment_1 = reviews$sentiment[i],
    sentiment_2 = reviews$sentiment[j],

    # Muestra una versión abreviada de cada reseña
    review_1 = str_trunc(
      reviews$review_clean[i],
      180
    ),
    review_2 = str_trunc(
      reviews$review_clean[j],
      180
    ),

    # Similitud basada en TF--IDF
    lexical_similarity,

    # Similitud basada en embeddings
    semantic_similarity
  )

# Muestra los pares seleccionados
semantic_pairs
## # A tibble: 6 × 6
##   sentiment_1 sentiment_2 review_1                   review_2 lexical_similarity
##   <chr>       <chr>       <chr>                      <chr>                 <dbl>
## 1 Negativa    Negativa    This movie was o.k. but i… In one …            0.00255
## 2 Negativa    Positiva    I hired this movie expect… I found…            0      
## 3 Negativa    Positiva    The only thing remarkable… I found…            0      
## 4 Negativa    Negativa    This movie was o.k. but i… For get…            0.00415
## 5 Negativa    Positiva    This movie should have ne… I found…            0      
## 6 Negativa    Positiva    For getting so many posit… This is…            0      
## # ℹ 1 more variable: semantic_similarity <dbl>

Estos ejemplos permiten observar directamente la diferencia conceptual: TF–IDF necesita palabras compartidas, mientras que los embeddings pueden reconocer expresiones semánticamente relacionadas construidas con vocabulario distinto.

Centro semántico de cada grupo

Los embeddings también permiten resumir un conjunto de documentos mediante su centroide semántico.

Para un grupo con \(n\) textos,

\[ \boldsymbol c = \frac{1}{n} \sum_{i=1}^{n} \boldsymbol e_i. \]

Normalizando posteriormente \(\boldsymbol c\), podemos medir la similitud de cada documento con el centro de su grupo.

# Identifica los grupos
positive_id <- reviews$sentiment == "Positiva"
negative_id <- reviews$sentiment == "Negativa"

# Función para normalizar un vector
normalize_vector <- function(x) {
  x / sqrt(sum(x^2))
}

# Calcula los centroides semánticos
centroid_positive <- normalize_vector(
  colMeans(
    review_embeddings[positive_id, , drop = FALSE]
  )
)

centroid_negative <- normalize_vector(
  colMeans(
    review_embeddings[negative_id, , drop = FALSE]
  )
)

# Compara ambos centroides
centroid_similarity <- sum(centroid_positive * centroid_negative)

tibble(centroid_similarity = centroid_similarity)
## # A tibble: 1 × 1
##   centroid_similarity
##                 <dbl>
## 1               0.929

La similitud entre los centroides semánticos de las reseñas positivas y negativas es muy alta (\(0.929\)). Esto indica que, en términos de contenido general, ambos grupos comparten una estructura semántica muy similar.

En otras palabras, las reseñas positivas y negativas suelen hablar de aspectos parecidos de las películas; la principal diferencia está más en cómo los valoran que en qué temas mencionan.

Una similitud alta entre centroides indica que ambos conjuntos comparten buena parte de su contenido semántico general. Una similitud menor indica que las reseñas positivas y negativas ocupan regiones más diferenciadas del espacio.

Reseñas más representativas

Podemos identificar los textos más próximos al centro semántico de cada grupo.

# Calcula proximidad a cada centroide
similarity_positive <- as.vector(
  review_embeddings %*%
    centroid_positive
)

similarity_negative <- as.vector(
  review_embeddings %*%
    centroid_negative
)

# Asigna la similitud con el centroide propio
representative_reviews <- reviews |>
  mutate(
    centroid_similarity = if_else(
      sentiment == "Positiva",
      similarity_positive,
      similarity_negative
    )
  ) |>
  group_by(sentiment) |>
  slice_max(
    centroid_similarity,
    n = 5,
    with_ties = FALSE
  ) |>
  ungroup() |>
  transmute(
    sentiment,
    centroid_similarity,
    review = str_trunc(
      review_clean,
      200
    )
  )

representative_reviews
## # A tibble: 10 × 3
##    sentiment centroid_similarity review                                         
##    <chr>                   <dbl> <chr>                                          
##  1 Negativa                0.758 I hired this movie expecting a few laughs, hop…
##  2 Negativa                0.755 This was one of the worst movies i have ever s…
##  3 Negativa                0.744 It was just a terrible movie. No one should wa…
##  4 Negativa                0.741 A top contender for worst film ever made. Joan…
##  5 Negativa                0.731 I was talked into watching this movie by a fri…
##  6 Positiva                0.755 This is a great film!! The first time I saw it…
##  7 Positiva                0.755 I found this film by mistake many years ago & …
##  8 Positiva                0.753 Although it has been remade several times, thi…
##  9 Positiva                0.750 I saw this film earlier today, and I was amaze…
## 10 Positiva                0.746 Excellent film dealing with the life of an old…

Estas reseñas pueden interpretarse como prototipos observados: textos particularmente próximos al contenido semántico promedio de cada grupo.

Comparación de las tres representaciones

Finalmente, podemos aplicar la misma medida de similitud a TF–IDF, LSA y embeddings. Esto permite distinguir el efecto de cambiar la medida del efecto de cambiar la representación.

# Función para normalizar las filas de una matriz
normalize_rows <- function(x) {
  x / sqrt(rowSums(x^2))
}

# Normaliza la representación LSA
lsa_unit <- normalize_rows(lsa_documents)

# Calcula similitudes LSA
lsa_similarity <- tcrossprod(lsa_unit)

# Construye una tabla conjunta
all_similarities <- bind_rows(
  tibble(
    representation = "TF-IDF",
    similarity = tfidf_similarity[pair_index],
    comparison = tfidf_pairs$comparison
  ),
  tibble(
    representation = "LSA",
    similarity = lsa_similarity[pair_index],
    comparison = tfidf_pairs$comparison
  ),
  tibble(
    representation = "Embeddings",
    similarity = embedding_similarity[pair_index],
    comparison = tfidf_pairs$comparison
  )
)

# Resume las tres representaciones
all_similarities |>
  summarise(
    mean_similarity = mean(similarity),
    median_similarity = median(similarity),
    .by = c(
      representation,
      comparison
    )
  )
## # A tibble: 6 × 4
##   representation comparison             mean_similarity median_similarity
##   <chr>          <chr>                            <dbl>             <dbl>
## 1 TF-IDF         Mismo sentimiento               0.0597            0.0431
## 2 TF-IDF         Sentimientos distintos          0.0515            0.0350
## 3 LSA            Mismo sentimiento               0.257             0.244 
## 4 LSA            Sentimientos distintos          0.224             0.212 
## 5 Embeddings     Mismo sentimiento               0.336             0.337 
## 6 Embeddings     Sentimientos distintos          0.318             0.322
# Compara las distribuciones de similitud
ggplot(
  all_similarities,
  aes(comparison, similarity)
) +
  geom_boxplot(
    outlier.alpha = 0.1
  ) +
  facet_wrap(
    ~ representation,
    scales = "free_y"
  ) +
  labs(
    x = NULL,
    y = "Similitud coseno",
    title = "Comparación de representaciones textuales"
  )

Esta comparación resume las diferencias entre los métodos:

  • TF–IDF representa directamente el vocabulario observado;
  • LSA comprime esos patrones léxicos en dimensiones latentes;
  • embeddings contextuales incorporan información semántica aprendida previamente.

La similitud coseno es la herramienta común que permite comparar documentos dentro de cualquiera de estos espacios.

El punto fundamental es que la noción de similitud textual depende de la representación elegida. Dos documentos pueden ser distintos en vocabulario y, al mismo tiempo, muy próximos en significado.

Por ello, TF–IDF, LSA y embeddings no deben entenderse como métodos competidores, sino como representaciones que describen niveles diferentes de la estructura del texto.

Temas para profundizar

Además de las herramientas estudiadas, existen otras áreas importantes del análisis computacional de texto:

  1. Modelos de tópicos: métodos como LDA, STM y BERTopic permiten identificar estructuras temáticas latentes y estudiar cómo se distribuyen entre documentos.

  2. Clustering de documentos: agrupa textos con contenido léxico o semántico similar sin utilizar categorías definidas previamente.

  3. Clasificación y regresión de textos: utilizan el texto como información explicativa para predecir categorías o variables, mediante modelos estadísticos o de aprendizaje automático.

  4. Reconocimiento de entidades (NER): identifica automáticamente personas, organizaciones, lugares, fechas y otras entidades mencionadas en los textos.

  5. Análisis sintáctico y dependencias: estudia las relaciones gramaticales entre palabras, permitiendo identificar sujetos, objetos, modificadores y estructuras de negación.

  6. Análisis temporal de texto: estudia cómo cambian el vocabulario, los tópicos, el sentimiento y las representaciones semánticas a lo largo del tiempo.

  7. Estilometría y autoría: analiza patrones lingüísticos para caracterizar estilos de escritura, comparar autores o estudiar cambios estilísticos.

  8. Modelos generativos y LLM: permiten realizar tareas como clasificación, extracción de información, resumen y respuesta a preguntas, aunque requieren especial atención a validación, reproducibilidad y sesgos.

Referencias

  • Benoit, K., Watanabe, K., Wang, H., Nulty, P., Obeng, A., Müller, S. y Matsuo, A. (2018). quanteda: An R package for the quantitative analysis of textual data. Journal of Open Source Software.
  • Silge, J. y Robinson, D. (2017). Text Mining with R: A Tidy Approach. O’Reilly Media.
  • Deerwester, S., Dumais, S. T., Furnas, G. W., Landauer, T. K. y Harshman, R. (1990). Indexing by latent semantic analysis. Journal of the American Society for Information Science.
  • Vaswani, A. et al. (2017). Attention Is All You Need. Advances in Neural Information Processing Systems.
  • Reimers, N. y Gurevych, I. (2019). Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks. Proceedings of EMNLP-IJCNLP.
  • Blondel, V. D., Guillaume, J.-L., Lambiotte, R. y Lefebvre, E. (2008). Fast unfolding of communities in large networks. Journal of Statistical Mechanics: Theory and Experiment.