Introducción

Hasta este punto, hemos considerado las palabras como unidades individuales y hemos examinado sus relaciones con los sentimientos o los documentos. Sin embargo, muchos análisis de texto interesantes se basan en las relaciones entre palabras, ya sea al examinar qué palabras tienden a seguir a otras inmediatamente, o qué palabras tienden a co-ocurrir dentro de los mismos documentos.

En este capítulo, exploraremos algunos de los métodos que ofrece tidytext para calcular y visualizar relaciones entre palabras en tu conjunto de datos de texto. Esto incluye el argumento token = "ngrams", que realiza una tokenización de pares de palabras adyacentes en lugar de palabras individuales. También introduciremos dos paquetes nuevos: ggraph, que extiende ggplot2 para construir gráficos de red, y widyr, que calcula correlaciones y distancias entre pares dentro de un marco de datos ordenado. Estas herramientas amplían nuestra caja de herramientas para explorar el texto dentro del marco de datos ordenado.

library(tidyverse)
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr     1.1.2     ✔ readr     2.1.4
## ✔ forcats   1.0.0     ✔ stringr   1.5.0
## ✔ ggplot2   3.4.2     ✔ tibble    3.2.1
## ✔ lubridate 1.9.2     ✔ tidyr     1.3.0
## ✔ purrr     1.0.1     
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag()    masks stats::lag()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(tidytext)

Ya tenemos organizado los tres tomos en un dataset con información sobre el tomo, el captítulo y la entrada del diario. Vamos a mejorar un poco las cateogrías de los tomos:

renzi <- read_csv('../data/renzi.csv')
## Rows: 1997 Columns: 3
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## chr (2): tomo, entry
## dbl (1): chapter
## 
## ℹ Use `spec()` to retrieve the full column specification for this data.
## ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
renzi <- renzi %>%
        mutate(tomo = case_when(
                tomo == '1_diarios_renzi_años_de_formacion.txt' ~ 'I-Años de formación',
                tomo == '2_diarios_renzi_los_años_felices.txt' ~ 'II-Los años felices',
                tomo == '3_diarios_renzi_un_dia_en_la_vida.txt' ~ 'III-Un día en la vida',
        ))

Vamos a eliminar acentos:

renzi <- renzi %>%
        mutate(entry = stringi::stri_trans_general(str = entry, 
                                   id = "Latin-ASCII"))

Tokenización por n-gramas

Hasta ahora, hemos estado utilizando la función unnest_tokens para tokenizar por palabra, o a veces por oración, lo cual es útil para los tipos de análisis de sentimiento y frecuencia que hemos estado haciendo hasta este punto. Pero también podemos utilizar esta función para tokenizar en secuencias consecutivas de palabras, lo que llamamos n-gramas. Al observar con qué frecuencia la palabra X es seguida por la palabra Y, podemos construir un modelo de sus relaciones.

Para lograr esto, agregamos la opción token = "ngrams" a la función unnest_tokens(), y establecemos el valor de n en el número de palabras que deseamos capturar en cada n-grama. Cuando establecemos n en 2, estamos examinando pares de dos palabras consecutivas, a menudo llamadas “bigramas”.

renzi_bigrams <- renzi %>%
  unnest_tokens(bigram, entry, token = "ngrams", n = 2) %>%
  filter(!is.na(bigram))

renzi_bigrams
## # A tibble: 384,583 × 3
##    tomo                chapter bigram      
##    <chr>                 <dbl> <chr>       
##  1 I-Años de formación       1 capitulo 1  
##  2 I-Años de formación       1 1 en        
##  3 I-Años de formación       1 en el       
##  4 I-Años de formación       1 el umbral   
##  5 I-Años de formación       1 umbral desde
##  6 I-Años de formación       1 desde chico 
##  7 I-Años de formación       1 chico repito
##  8 I-Años de formación       1 repito lo   
##  9 I-Años de formación       1 lo que      
## 10 I-Años de formación       1 que no      
## # ℹ 384,573 more rows

Esta estructura de datos sigue siendo una variación del formato de texto ordenado. Está estructurada con un token por fila (con metadatos adicionales, como el libro, todavía conservados), pero cada token representa un bigrama.

Es importante notar que estos bigramas se superponen: “el umbral” es un token, mientras que “umbral desde” es otro.

Conteo y filtrado de n-gramas

Nuestras herramientas habituales de tidy data se aplican igualmente bien al análisis de n-gramas. Podemos examinar los bigramas más comunes utilizando la función count() de dplyr:

renzi_bigrams %>%
  count(bigram, sort = TRUE)
## # A tibble: 166,824 × 2
##    bigram     n
##    <chr>  <int>
##  1 de la   3343
##  2 en el   2527
##  3 en la   2420
##  4 a la    1688
##  5 lo que  1140
##  6 de los   994
##  7 que se   894
##  8 en un    695
##  9 de un    657
## 10 que no   637
## # ℹ 166,814 more rows

Como uno podría esperar, muchas de las bigramas más comunes son pares de palabras comunes (poco interesantes), como “de la” y “en el”: lo que llamamos “stopword”.

Este es un momento adecuado para usar la función separate() de tidyr, que divide una columna en varias basadas en un delimitador. Esto nos permite separarla en dos columnas, “palabra1” y “palabra2”, momento en el cual podemos eliminar los casos en los que cualquiera de ellas sea una stopword.

Para ello, cargamos nuestro diccionario de stopwords y eliminamos acentos:

stop_words <- read_csv('../data/stop_words_complete.csv')
## Rows: 1766 Columns: 2
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## chr (2): word, lexicon
## 
## ℹ Use `spec()` to retrieve the full column specification for this data.
## ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
stop_words <- stop_words %>%
  mutate(word = stringi::stri_trans_general(str = word, 
                                   id = "Latin-ASCII")) %>%
  add_row(word=c("capitulo", "uno", "dos", "tres", "cuatro", "cinco",
                 "seis", "siete", "ocho", "nueve", "diez", "siempre", "solo"), 
          lexicon=c(rep("custom", 13)))

bigrams_separated <- renzi_bigrams %>%
  separate(bigram, c("word1", "word2"), sep = " ")

bigrams_filtered <- bigrams_separated %>%
  filter(!word1 %in% stop_words$word) %>%
  filter(!word2 %in% stop_words$word)

# new bigram counts:
bigram_counts <- bigrams_filtered %>% 
  count(word1, word2, sort = TRUE)

bigram_counts
## # A tibble: 52,221 × 3
##    word1   word2       n
##    <chr>   <chr>   <int>
##  1 buenos  aires     195
##  2 mil     pesos     126
##  3 cada    vez       116
##  4 mismo   tiempo     66
##  5 primera vez        64
##  6 puede   ser        56
##  7 jorge   alvarez    55
##  8 tal     vez        49
##  9 quiere  decir      43
## 10 debe    ser        41
## # ℹ 52,211 more rows

Podemos ver que hay bastantes nombres de lugares (buenos_aires), de personas (jorge_alvarez), números (mil_pesos), etc.

En otros análisis, es posible que deseemos trabajar con las palabras recombinadas. La función unite() de tidyr es el inverso de separate(), y nos permite combinar las columnas en una sola. Así, “separate/filter/count/unite” nos permite encontrar las bigramas más comunes que no contienen stopwords.

bigrams_united <- bigrams_filtered %>%
  unite(bigram, word1, word2, sep = " ")

bigrams_united
## # A tibble: 63,201 × 3
##    tomo                chapter bigram            
##    <chr>                 <dbl> <chr>             
##  1 I-Años de formación       1 chico repito      
##  2 I-Años de formación       1 reia retrospectivo
##  3 I-Años de formación       1 radiante emilio   
##  4 I-Años de formación       1 emilio renzi      
##  5 I-Años de formación       1 abuelo emilio     
##  6 I-Años de formación       1 emilio sentado    
##  7 I-Años de formación       1 cuero ausente     
##  8 I-Años de formación       1 ojos fijos        
##  9 I-Años de formación       1 misterioso objeto 
## 10 I-Años de formación       1 objeto rectangular
## # ℹ 63,191 more rows

En otros análisis, es posible que estés interesado en los trigramas más comunes, que son secuencias consecutivas de 3 palabras. Podemos encontrar esto estableciendo n = 3:

renzi %>%
  unnest_tokens(trigram, entry, token = "ngrams", n = 3) %>%
  filter(!is.na(trigram)) %>%
  separate(trigram, c("word1", "word2", "word3"), sep = " ") %>%
  filter(!word1 %in% stop_words$word,
         !word2 %in% stop_words$word,
         !word3 %in% stop_words$word) %>%
  count(word1, word2, word3, sort = TRUE)
## # A tibble: 19,786 × 4
##    word1      word2  word3             n
##    <chr>      <chr>  <chr>         <int>
##  1 veinte     mil    pesos            17
##  2 cada       vez    menos            15
##  3 cincuenta  mil    pesos            13
##  4 dipi       di     paola            12
##  5 cien       mil    pesos            10
##  6 quinientos mil    pesos            10
##  7 mata       hari   55                9
##  8 dia        tras   dia               8
##  9 editorial  tiempo contemporaneo     8
## 10 nestor     garcia canclini          7
## # ℹ 19,776 more rows

Analizando bigramas

Este formato de un bigrama por fila es útil para análisis exploratorios del texto. Como ejemplo sencillo, podríamos estar interesados en la forma en que Renzi menciona la palabra “literatura”:

bigrams_filtered %>%
  filter(word1 == "literatura" | word2 == "literatura") %>%
  count(tomo, chapter,word1, word2, sort = TRUE)
## # A tibble: 182 × 5
##    tomo                  chapter word1      word2               n
##    <chr>                   <dbl> <chr>      <chr>           <int>
##  1 II-Los años felices         3 literatura norteamericana      5
##  2 I-Años de formación        18 literatura argentina           4
##  3 II-Los años felices         3 literatura argentina           4
##  4 I-Años de formación        11 literatura contemporanea       3
##  5 II-Los años felices         1 literatura argentina           3
##  6 II-Los años felices         1 literatura contemporanea       3
##  7 III-Un día en la vida       5 literatura argentina           3
##  8 I-Años de formación        16 literatura argentina           2
##  9 I-Años de formación        18 literatura latinoamericana     2
## 10 I-Años de formación        18 literatura norteamericana      2
## # ℹ 172 more rows

Un bigrama también puede ser tratado como un término en un documento de la misma manera que tratamos palabras individuales. Por ejemplo, podemos analizar el tf-idf de bigramas en los diarios de Piglia. Estos valores de tf-idf pueden visualizarse dentro de cada tomo, al igual que hicimos para las palabras.

bigram_tf_idf <- bigrams_united %>%
  count(tomo, bigram) %>%
  bind_tf_idf(bigram, tomo, n) %>%
  arrange(desc(tf_idf))

bigram_tf_idf
## # A tibble: 55,677 × 6
##    tomo                  bigram                n       tf   idf   tf_idf
##    <chr>                 <chr>             <int>    <dbl> <dbl>    <dbl>
##  1 I-Años de formación   moneda griega        10 0.000486 1.10  0.000534
##  2 II-Los años felices   norberto soares      12 0.000468 1.10  0.000514
##  3 III-Un día en la vida mil dolares          21 0.00124  0.405 0.000502
##  4 II-Los años felices   serie negra          10 0.000390 1.10  0.000428
##  5 II-Los años felices   vino david           10 0.000390 1.10  0.000428
##  6 III-Un día en la vida anita barrenechea     6 0.000354 1.10  0.000388
##  7 III-Un día en la vida penso renzi           6 0.000354 1.10  0.000388
##  8 II-Los años felices   cosas concretas       9 0.000351 1.10  0.000385
##  9 I-Años de formación   dijo despues          7 0.000340 1.10  0.000374
## 10 I-Años de formación   dijo lucia            7 0.000340 1.10  0.000374
## # ℹ 55,667 more rows

Similar a lo que descubrimos las clases anteriores, aparecen muchos nombres propios. También aparecen cuestiones monetarias, libros y revistas.

Existen ventajas y desventajas en examinar el tf-idf de bigramas en lugar de palabras individuales. Pares de palabras consecutivas pueden capturar estructuras que no están presentes cuando solo se cuentan palabras individuales, y pueden proporcionar contexto que hace que los tokens sean más comprensibles (por ejemplo, “serie negra” en “II - Los años felices” es más informativo que “serie”).

Sin embargo, las métricas por bigrama también son más dispersas: un par de palabras típico es más raro que cualquiera de sus palabras componentes. Por lo tanto, los bigramas pueden ser especialmente útiles cuando tienes un conjunto de datos de texto muy grande.

Uso de bigramas para proporcionar contexto en el análisis de sentimiento

Nuestro enfoque de análisis de sentimiento que vimos la clase pasada simplemente contó la aparición de palabras positivas o negativas, de acuerdo con un léxico de referencia. Uno de los problemas con este enfoque es que el contexto de una palabra puede ser tan importante como su presencia. Por ejemplo, las palabras “feliz” y “gustar” serán contadas como positivas, incluso en una frase como “¡No estoy feliz y no me gusta!”

Ahora que tenemos los datos organizados en bigramas, es fácil determinar con qué frecuencia las palabras están precedidas por una palabra como “no”:

bigrams_separated %>%
  filter(word1 == "no") %>%
  count(word1, word2, sort = TRUE)
## # A tibble: 819 × 3
##    word1 word2     n
##    <chr> <chr> <int>
##  1 no    se      353
##  2 no    me      204
##  3 no    es      192
##  4 no    hay     148
##  5 no    lo       90
##  6 no    puedo    89
##  7 no    le       76
##  8 no    puede    62
##  9 no    tiene    57
## 10 no    habia    51
## # ℹ 809 more rows

Realizando análisis de sentimiento en los datos de bigramas, podemos examinar con qué frecuencia las palabras asociadas al sentimiento son precedidas por “no” u otras palabras negadoras. Esto nos permitiría ignorar o incluso revertir su contribución al puntaje de sentimiento.

Utilizaremos el lexicon elaborado por el LIIA-UBA para el análisis de sentimiento, que recordarás otorga un valor numérico entre 1 y 3 de “likeness” para cada palabra.

sentiment_words_liia <- read_csv('../data/sentiment_lexicon_liia.csv')
## Rows: 2880 Columns: 3
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## chr (1): word
## dbl (2): mean_likeness, std_likeness
## 
## ℹ Use `spec()` to retrieve the full column specification for this data.
## ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
sentiment_words_liia <- sentiment_words_liia %>% 
        mutate(sentiment = case_when(
                round(mean_likeness) == 1 ~ 'negativo',
                round(mean_likeness) == 2 ~ 'neutral',
                round(mean_likeness) == 3 ~ 'positivo',
        ))

sentiment_words_liia <- sentiment_words_liia %>%
  mutate(word = stringi::stri_trans_general(str = word, 
                                   id = "Latin-ASCII"))

Luego podemos examinar las palabras más frecuentes que fueron precedidas por “no” y estaban asociadas con un sentimiento.

not_words <- bigrams_separated %>%
  filter(word1 == "no") %>%
  inner_join(sentiment_words_liia, by = c(word2 = "word")) %>%
  count(word2, mean_likeness, sort = TRUE)
## Warning in inner_join(., sentiment_words_liia, by = c(word2 = "word")): Detected an unexpected many-to-many relationship between `x` and `y`.
## ℹ Row 23 of `x` matches multiple rows in `y`.
## ℹ Row 2543 of `y` matches multiple rows in `x`.
## ℹ If a many-to-many relationship is expected, set `relationship =
##   "many-to-many"` to silence this warning.
not_words
## # A tibble: 146 × 3
##    word2    mean_likeness     n
##    <chr>            <dbl> <int>
##  1 solo               2      72
##  2 habia              2.4    51
##  3 era                2.4    47
##  4 solo               1.4    36
##  5 pensar             2.8    27
##  6 bien               3      24
##  7 tenia              1      24
##  8 tener              2.6    23
##  9 ficcion            2.6    18
## 10 recuerdo           2.2    17
## # ℹ 136 more rows

Por ejemplo, la palabra asociada con el sentimiento más común que sigue a “no” es “solo”, que normalmente tendría una puntuación neutral.

“No” no es el único término que proporciona algún contexto para la palabra siguiente. Podríamos elegir cuatro palabras comunes (o más) que nieguen el término subsiguiente, y utilizar el mismo enfoque de unión y conteo para examinar todas ellas a la vez.

negation_words <- c("no", "sin", "nunca", "jamas")

negated_words <- bigrams_separated %>%
  filter(word1 %in% negation_words) %>%
  inner_join(sentiment_words_liia, by = c(word2 = "word")) %>%
  count(word1, word2, mean_likeness, sort = TRUE)
## Warning in inner_join(., sentiment_words_liia, by = c(word2 = "word")): Detected an unexpected many-to-many relationship between `x` and `y`.
## ℹ Row 27 of `x` matches multiple rows in `y`.
## ℹ Row 2543 of `y` matches multiple rows in `x`.
## ℹ If a many-to-many relationship is expected, set `relationship =
##   "many-to-many"` to silence this warning.
negated_words
## # A tibble: 383 × 4
##    word1 word2  mean_likeness     n
##    <chr> <chr>          <dbl> <int>
##  1 no    solo             2      72
##  2 no    habia            2.4    51
##  3 no    era              2.4    47
##  4 no    solo             1.4    36
##  5 sin   saber            2.8    31
##  6 sin   saber            3      31
##  7 sin   plata            2.8    28
##  8 no    pensar           2.8    27
##  9 no    bien             3      24
## 10 no    tenia            1      24
## # ℹ 373 more rows

Luego podríamos visualizar cuáles son las palabras más comunes que siguen a cada negación en particular. Si bien “no solo” y “no era” siguen siendo los dos ejemplos más comunes, también podemos ver combinaciones como “sin saber” y “sin plata”.

Podríamos combinar esto con los enfoques de la clase pasada para revertir los valores del lexicon de cada palabra que sigue a una negación. Estos son solo algunos ejemplos de cómo encontrar palabras consecutivas puede proporcionar contexto a los métodos de minería de texto.

Visualización de una Red de Bigramas con ggraph

Podríamos estar interesados en visualizar todas las relaciones entre palabras de manera simultánea, en lugar de solo las principales algunas veces. Como una visualización común, podemos organizar las palabras en una red o “grafo”. En este caso, nos referiremos a un “grafo” no en el sentido de una visualización, sino como una combinación de nodos conectados. Un grafo puede construirse a partir de un objeto tidy, ya que tiene tres variables:

- from: el nodo desde el cual parte un borde - to: el nodo hacia el cual se dirige un borde - weight: un valor numérico asociado con cada borde

El paquete igraph tiene muchas funciones poderosas para manipular y analizar redes. Una forma de crear un objeto igraph a partir de datos tidy es la función graph_from_data_frame(), que toma un data frame de bordes con columnas para “from”, “to” y atributos de los bordes (en este caso, n):

library(igraph)
## 
## Attaching package: 'igraph'
## The following objects are masked from 'package:lubridate':
## 
##     %--%, union
## The following objects are masked from 'package:dplyr':
## 
##     as_data_frame, groups, union
## The following objects are masked from 'package:purrr':
## 
##     compose, simplify
## The following object is masked from 'package:tidyr':
## 
##     crossing
## The following object is masked from 'package:tibble':
## 
##     as_data_frame
## The following objects are masked from 'package:stats':
## 
##     decompose, spectrum
## The following object is masked from 'package:base':
## 
##     union
# Recuentos originales
bigram_counts
## # A tibble: 52,221 × 3
##    word1   word2       n
##    <chr>   <chr>   <int>
##  1 buenos  aires     195
##  2 mil     pesos     126
##  3 cada    vez       116
##  4 mismo   tiempo     66
##  5 primera vez        64
##  6 puede   ser        56
##  7 jorge   alvarez    55
##  8 tal     vez        49
##  9 quiere  decir      43
## 10 debe    ser        41
## # ℹ 52,211 more rows
# Filtrar solo combinaciones relativamente comunes
bigram_graph <- bigram_counts %>%
  filter(n > 15) %>%
  graph_from_data_frame()

bigram_graph
## IGRAPH 115e9cd DN-- 113 73 -- 
## + attr: name (v/c), n (e/n)
## + edges from 115e9cd (vertex names):
##  [1] buenos    ->aires     mil       ->pesos     cada      ->vez      
##  [4] mismo     ->tiempo    primera   ->vez       puede     ->ser      
##  [7] jorge     ->alvarez   tal       ->vez       quiere    ->decir    
## [10] debe      ->ser       santa     ->fe        jose      ->sazbon   
## [13] alguna    ->vez       literatura->argentina veinte    ->anos     
## [16] muchas    ->veces     nueva     ->york      roberto   ->arlt     
## [19] varias    ->veces     abuelo    ->emilio    aquel     ->tiempo   
## [22] miguel    ->briante   hace      ->anos      siglo     ->xix      
## + ... omitted several edges

igraph tiene funciones de graficación incorporadas, pero muchos otros paquetes han desarrollado métodos de visualización para objetos de grafo. El paquete ggraph implementa estas visualizaciones en términos de la gramática de gráficos, con la que ya estamos familiarizados por ggplot2.

Podemos convertir un objeto igraph en un ggraph con la función ggraph, después de lo cual agregamos capas a él, de manera similar a cómo se agregan capas en ggplot2. Por ejemplo, para un gráfico básico necesitamos agregar tres capas: nodos, bordes y texto.

library(ggraph)
set.seed(2017)

ggraph(bigram_graph, layout = "fr") +
  geom_edge_link() +
  geom_node_point() +
  geom_node_text(aes(label = name), vjust = 1, hjust = 1)
## Warning: Using the `size` aesthetic in this geom was deprecated in ggplot2 3.4.0.
## ℹ Please use `linewidth` in the `default_aes` field and elsewhere instead.
## This warning is displayed once every 8 hours.
## Call `lifecycle::last_lifecycle_warnings()` to see where this warning was
## generated.

Más arriba, podemos visualizar algunos detalles de la estructura del texto. Aparecen algunos clústers con claro sentido sintáctico: (parece/debe/puede-se). Luego, el término “tiempo” aparece como el centro de un nodo importante.

Una vez más, aparecen numerosos nombres propios que son nombres de autores y/o de personajes (“fierro-martín-san”) y nodos de palabras asociadas a la “teoría literaria”: “novola-policial-género”.

Concluimos con algunas operaciones de pulido para hacer un gráfico de mejor apariencia:

  • Agregamos el atributo edge_alpha a la capa de enlaces para hacer los enlaces transparentes según la frecuencia del bigrama (común o raro)

  • Agregamos direccionalidad con una flecha, construida usando grid::arrow(), incluyendo la opción end_cap que indica que la flecha debe terminar antes de tocar el nodo

  • Jugamos con las opciones de la capa de nodos para hacer los nodos más atractivos (puntos más grandes y azules)

  • Agregamos un tema útil para trazar redes, theme_void()

set.seed(2020)

a <- grid::arrow(type = "closed", length = unit(.15, "inches"))

ggraph(bigram_graph, layout = "fr") +
  geom_edge_link(aes(edge_alpha = n), show.legend = FALSE,
                 arrow = a, end_cap = circle(.07, 'inches')) +
  geom_node_point(color = "lightblue", size = 5) +
  geom_node_text(aes(label = name), vjust = 1, hjust = 1) +
  theme_void()

Puede requerir algo de experimentación con ggraph para lograr que las redes tengan un formato presentable como este, pero la estructura de red es una forma útil y flexible de visualizar datos relacionales tidy.

Para hacer que la visualización sea interpretable, elegimos mostrar solo las conexiones más comunes de palabra a palabra, pero uno podría imaginar un grafo enorme que represente todas las conexiones que ocurren en el texto.

Conteo y correlación de pares de palabras con el paquete widyr

La tokenización por n-grama es una forma útil de explorar pares de palabras adyacentes. Sin embargo, también podríamos estar interesados en palabras que tienden a coocurrir dentro de documentos particulares o capítulos particulares, incluso si no ocurren una al lado de la otra.

Los datos tidy son una estructura útil para comparar entre variables o agrupar por filas, pero puede ser desafiante comparar entre filas: por ejemplo, contar la cantidad de veces que dos palabras aparecen en el mismo documento o ver cuán correlacionadas están. La mayoría de las operaciones para encontrar recuentos o correlaciones por pares necesitan convertir los datos en una matriz primero.

Examinaremos algunas de las formas en que el texto tidy se puede convertir en una matriz dispersa más adelante, pero en este caso no es necesario. El paquete widyr facilita operaciones como calcular recuentos y correlaciones, al simplificar el patrón de “expandir datos, realizar una operación y luego reorganizar datos”. Nos centraremos en un conjunto de funciones que hacen comparaciones por pares entre grupos de observaciones (por ejemplo, entre documentos o secciones de texto).

Conteo y Correlación entre entradas del diario

Consideremos el primer tomo de los diarios de Renzi, “I-Años de formación”, dividido en entradas. Es posible que estemos interesados en las palabras que tienden a aparecer dentro de la misma sección.

renzi_section_words <- renzi %>%
  filter(tomo == "I-Años de formación") %>%
  mutate(entry_code = row_number()) %>%
  #filter(section > 0) %>%
  unnest_tokens(word, entry) %>%
  filter(!word %in% stop_words$word)

renzi_section_words
## # A tibble: 63,857 × 4
##    tomo                chapter entry_code word         
##    <chr>                 <dbl>      <int> <chr>        
##  1 I-Años de formación       1          1 1            
##  2 I-Años de formación       1          1 umbral       
##  3 I-Años de formación       1          1 chico        
##  4 I-Años de formación       1          1 repito       
##  5 I-Años de formación       1          1 entiendo     
##  6 I-Años de formación       1          1 reia         
##  7 I-Años de formación       1          1 retrospectivo
##  8 I-Años de formación       1          1 radiante     
##  9 I-Años de formación       1          1 emilio       
## 10 I-Años de formación       1          1 renzi        
## # ℹ 63,847 more rows

Una función útil de widyr es pairwise_count(). El prefijo pairwise_ significa que esta función resultará en una fila por cada par de palabras en la variable “word”. Esto nos permite contar pares comunes de palabras que coaparecen en la misma sección:

library(widyr)

# cuenta palabras que co-ocurren en cada entrada
word_pairs <- renzi_section_words %>%
  pairwise_count(word, entry_code, sort = TRUE)

word_pairs
## # A tibble: 13,019,932 × 3
##    item1  item2      n
##    <chr>  <chr>  <dbl>
##  1 mismo  tiempo    70
##  2 tiempo mismo     70
##  3 vida   tiempo    69
##  4 tiempo vida      69
##  5 mismo  vida      69
##  6 vida   mismo     69
##  7 vez    mismo     68
##  8 mismo  vez       68
##  9 ahora  vida      64
## 10 ser    vida      64
## # ℹ 13,019,922 more rows

Es importante notar que mientras la entrada tenía una fila por cada par de un documento (una entrada) y una palabra, la salida tiene una fila por cada par de palabras. Esto también es un formato tidy, pero de una estructura muy diferente que podemos usar para responder nuevas preguntas.

Por ejemplo, podemos ver que el par de palabras más común en una sección es “mismo” y “tiempo”. También podemos encontrar fácilmente las palabras que más a menudo ocurren con “tiempo”:

word_pairs %>%
  filter(item1 == "tiempo")
## # A tibble: 9,809 × 3
##    item1  item2        n
##    <chr>  <chr>    <dbl>
##  1 tiempo mismo       70
##  2 tiempo vida        69
##  3 tiempo hace        60
##  4 tiempo ahora       58
##  5 tiempo vez         58
##  6 tiempo ser         56
##  7 tiempo despues     54
##  8 tiempo noche       52
##  9 tiempo hacer       48
## 10 tiempo historia    48
## # ℹ 9,799 more rows

Correlación por pares

Pares como “siempre” y “solo” son las palabras que más comúnmente coocurren, pero eso no es particularmente significativo ya que también son las palabras individuales más comunes. En cambio, es posible que deseemos examinar la correlación entre palabras, lo que indica con qué frecuencia aparecen juntas en comparación con cuántas veces aparecen por separado.

En particular, aquí nos centraremos en el coeficiente phi, una medida común para la correlación binaria. El foco del coeficiente phi es cuánto más probable es que tanto la palabra X como la palabra Y aparezcan, o que ninguna aparezca, en comparación con que una aparezca sin la otra.

Considera la siguiente tabla:

Tiene la palabra Y No iene la palabra Y Total
Tiene la palabra X \(n_{11}\) \(n_{10}\) \(n_{1.}\)
No tiene la palabra X \(n_{01}\) \(n_{00}\) \(n_{0.}\)
Total \(n_{.1}\) \(n_{.0}\) \(n\)

Por ejemplo, que \(n_{11}\), representa la cantidad de documentos en los que tanto la palabra X como la palabra Y aparecen, \(n_{00}\) es el número en el que ninguna de las dos aparece, y \(n_{10}\) y \(n_{01}\) son los casos en los que una aparece sin la otra.

En términos de esta tabla, el coeficiente phi es:

\[\phi=\frac{n_{11}n_{00}-n_{10}n_{01}}{\sqrt{n_{1\cdot}n_{0\cdot}n_{\cdot0}n_{\cdot1}}}\]

El coeficiente phi es equivalente a la correlación de Pearson, que tal vez hayas escuchado en otros contextos cuando se aplica a datos binarios.

La función pairwise_cor() en widyr nos permite encontrar el coeficiente phi entre palabras basado en cuán frecuentemente aparecen juntas en la misma sección. Su sintaxis es similar a pairwise_count().

# we need to filter for at least relatively common words first
word_cors <- renzi_section_words %>%
  group_by(word) %>%
  filter(n() >= 20) %>%
  pairwise_cor(word, entry_code, sort = TRUE)

word_cors
## # A tibble: 336,980 × 3
##    item1   item2   correlation
##    <chr>   <chr>         <dbl>
##  1 buenos  aires         0.944
##  2 aires   buenos        0.944
##  3 pesos   mil           0.751
##  4 mil     pesos         0.751
##  5 alvarez jorge         0.638
##  6 jorge   alvarez       0.638
##  7 renzi   emilio        0.592
##  8 emilio  renzi         0.592
##  9 abuelo  emilio        0.515
## 10 emilio  abuelo        0.515
## # ℹ 336,970 more rows

Este ouput es útil para hacer un análisis exploratorio. Por ejemplo, podemos encontrar las palabras mas correlaciones con una palabra como “vinas”, referida a David Viñas, usando una operación de filtrado:

word_cors %>%
  filter(item1 == "vinas")
## # A tibble: 580 × 3
##    item1 item2     correlation
##    <chr> <chr>           <dbl>
##  1 vinas peronismo       0.235
##  2 vinas beatriz         0.235
##  3 vinas posible         0.199
##  4 vinas narra           0.199
##  5 vinas zona            0.183
##  6 vinas escritura       0.176
##  7 vinas prosa           0.167
##  8 vinas concepto        0.161
##  9 vinas varios          0.154
## 10 vinas luego           0.152
## # ℹ 570 more rows

Esto nos permite elegir palabras específicas interesantes y encontrar las otras palabras más asociadas con ellas.

word_cors %>%
  filter(item1 %in% c("novela", "literatura", "escritor", "libro")) %>%
  group_by(item1) %>%
  slice_max(correlation, n = 10) %>%
  ungroup() %>%
  mutate(item2 = reorder(item2, correlation)) %>%
  ggplot(aes(item2, correlation)) +
  geom_bar(stat = "identity") +
  facet_wrap(~ item1, scales = "free") +
  coord_flip() +
  theme_minimal()

Así como utilizamos ggraph para visualizar los bigrams, podemos usarlo para visualizar las correlaciones y agrupaciones de palabras que fueron encontradas por el paquete widyr:

set.seed(2016)

word_cors %>%
  filter(correlation > .39) %>%
  graph_from_data_frame() %>%
  ggraph(layout = "fr") +
  geom_edge_link(aes(edge_alpha = correlation), show.legend = FALSE) +
  geom_node_point(color = "lightblue", size = 2) +
  geom_node_text(aes(label = name), repel = TRUE) +
  theme_void()

Es importante destacar que, a diferencia del análisis de bigramas, las relaciones aquí son simétricas en lugar de direccionales (no hay flechas). También podemos observar que, aunque las combinaciones de nombres y títulos que dominaban las parejas de bigrams son comunes, como ‘buenos_aires’, también podemos ver combinaciones de palabras que aparecen cerca una de la otra, como ‘cartas’, ‘abuelo’, ‘guerra’ o ‘adrogué’.

Resumen

Este clase demostró cómo el enfoque de texto ordenado es útil no solo para analizar palabras individuales, sino también para explorar las relaciones y conexiones entre palabras. Tales relaciones pueden involucrar n-gramas, que nos permiten ver qué palabras tienden a aparecer después de otras, o coocurrencias y correlaciones, para palabras que aparecen en proximidad una de la otra. La clase también demostró el paquete ggraph para visualizar ambos tipos de relaciones como redes. Estas visualizaciones de redes son una herramienta flexible para explorar relaciones.