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.
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:
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
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.
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.
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.
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.
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.
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"
)
}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
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
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_duqueSi 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.
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.
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
)# 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_duqueEl 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.
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:
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.
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.
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_duqueEl 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.
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.
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:
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.
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.
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
## 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 ]
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.
## 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.
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.
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.
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.
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.
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.
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.
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:
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.
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.
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.
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.
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.
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.
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.
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.
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:
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.
Además de las herramientas estudiadas, existen otras áreas importantes del análisis computacional de texto:
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.
Clustering de documentos: agrupa textos con contenido léxico o semántico similar sin utilizar categorías definidas previamente.
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.
Reconocimiento de entidades (NER): identifica automáticamente personas, organizaciones, lugares, fechas y otras entidades mencionadas en los textos.
Análisis sintáctico y dependencias: estudia las relaciones gramaticales entre palabras, permitiendo identificar sujetos, objetos, modificadores y estructuras de negación.
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.
Estilometría y autoría: analiza patrones lingüísticos para caracterizar estilos de escritura, comparar autores o estudiar cambios estilísticos.
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.