Introducción al análisis de texto
Iván Recalde, ingeniero en sistemas de la UTN, se desempeña actualmente como Data Analyst en el Área de Gestión de Información Estadística en Salud (AGISE) del Ministerio de Salud CABA y hace aquí una introducción muy clara a la visualización y el procesamiento de texto libre usando tidyverse, tidytext, stringr y wordcloud.
Modelo tidy en datos de texto
Los datos no estructurados se definen como datos que no tienen estructura interna identificable. Es un conglomerado masivo y desorganizado de varios objetos que no tienen valor hasta que se identifican y almacenan de manera organizada.
Una vez que se organizan, los elementos que conforman su contenido pueden ser buscados y categorizados (al menos hasta cierto punto) para obtener información.
detalle |
---|
se realizaron 45 observaciones del tipo A en el anio 2017 |
se realizaron 60 observaciones del tipo B en el anio 2017 |
se realizaron 23 observaciones del tipo C en el anio 2017 |
se realizaron 32 observaciones del tipo A en el anio 2018 |
se realizaron 63 observaciones del tipo B en el anio 2018 |
se realizaron 19 observaciones del tipo C en el anio 2018 |
Cuando estos están dispuestos de forma tal que variables en las columnas y observaciones en las filas, sin que queden ni filas ni columnas con valores en blanco, podemos decir que se encuentran en formato Tidy y eso ya es un gran avance hacia su procesamiento.
anio | tipo | n |
---|---|---|
2017 | A | 45 |
2017 | B | 60 |
2017 | C | 23 |
2018 | A | 32 |
2018 | B | 63 |
2018 | C | 19 |
Este formato nos permite trabajar de manera eficiente y fácilmente acomodable a las funciones de… Sí, tidyverse! Nos permite saber que cada fila va a ser una observación y que no vamos a tener una tabla con infinitas columnas. El problema es que muchas veces los datos de entrada a nuestros scripts/algoritmos no vienen en tidy, sino que los encontramos de la siguiente manera.
anio | tipo_a | tipo_b | tipo_c |
---|---|---|---|
2017 | 45 | 60 | 23 |
2018 | 32 | 63 | 19 |
Así como vimos, el modelo tidy de los datos nos permite manejar de manera mas sencilla y efectiva los datos, incluidos los datos de texto. En este caso para empezar la visualización buscamos que cada fila tenga un solo token: cuando hablamos de token nos referimos a lo que para nuestro problema significa una unidad significativa de texto. En la mayoría de los casos básicos vamos a hablar de token como palabras individuales pero es importante saber que cuando el análisis es más complejo podriamos buscar que nuestros token sean frases o párrafos enteros e intentar identifcar significado de ello.
Tokenizar
¡Arranquemos! Vamos a trabajar con un bello poema de Tamara Grosso, editada por el sello Santos Locos.
# Tamara Grosso @tamaraestaloca cuando todo refugio se vuelve hostil @santoslocospoesia
poema <- c('ADVERTENCIA:',
'No se decirte',
'si todo va a mejorar',
'pero seguro la ficha',
'que te hizo ser quien sos',
'te cayo despues',
'de uno de los peores',
'dias de tu vida')
En este caso tenemos un vector de datos en formato character, un primer paso útil sería pasarlo a un data frame, de esta manera lo traemos a un formato con el que estamos mas acostumbradxs a trabajar.
poema_df <- tibble(linea = 1:8, texto = poema)
poema_df
## # A tibble: 8 x 2
## linea texto
## <int> <chr>
## 1 1 ADVERTENCIA:
## 2 2 No se decirte
## 3 3 si todo va a mejorar
## 4 4 pero seguro la ficha
## 5 5 que te hizo ser quien sos
## 6 6 te cayo despues
## 7 7 de uno de los peores
## 8 8 dias de tu vida
Ahora vamos a usar una funcion para tokenizar nuestro texto. En este caso el analisis lo podriamos pensar sobre palabras por separado, que en principio podrian ser nuestra unidad significativa de texto. ¿Podríamos usar los versos? La funcion que vamos a usar es unnest_tokens() de la biblioteca ‘tidytext’. Su uso más simple es usar los pipes de magrittr para pasarle el data frame como primer parámetro implícito, luego el nombre que queremos que la columna de tokens tenga y por último el nombre de la columna de origen donde deberia buscar el texto a tokenizar.
texto_tokenizado <- poema_df %>%
unnest_tokens(palabra_poema,texto)
texto_tokenizado
## # A tibble: 31 x 2
## linea palabra_poema
## <int> <chr>
## 1 1 advertencia
## 2 2 no
## 3 2 se
## 4 2 decirte
## 5 3 si
## 6 3 todo
## 7 3 va
## 8 3 a
## 9 3 mejorar
## 10 4 pero
## # ... with 21 more rows
Veamos poner la lupa en un par de cositas bellas que nos dejo la función. En principio vemos que cada palabra quedo en una fila, estaríamos ahora en condiciones de afirmar que cada observacion está contenida en un registro diferente. Luego vemos que para facilitar el manejo nos transformó todos los tokens a minusúculas; en el caso de no querer esto, podemos pasarle a la funcion como parametro to_lower = FALSE de la siguiente manera.
poema_df %>%
unnest_tokens(palabra_poema,texto, to_lower = FALSE)
## # A tibble: 31 x 2
## linea palabra_poema
## <int> <chr>
## 1 1 ADVERTENCIA
## 2 2 No
## 3 2 se
## 4 2 decirte
## 5 3 si
## 6 3 todo
## 7 3 va
## 8 3 a
## 9 3 mejorar
## 10 4 pero
## # ... with 21 more rows
Completemos un circuito basico de analisis y armemos unas visualizaciones
Vamos entonces a imaginar que tenemos varios textos consecutivos (poemas en nuestro caso) para hacer un poco más divertido el análisis.
varios_poemas <- poema_df %>%
bind_rows(poema2_df, poema3_df, poema4_df)
## # A tibble: 23 x 1
## texto
## <chr>
## 1 ADVERTENCIA:
## 2 No se decirte
## 3 si todo va a mejorar
## 4 pero seguro la ficha
## 5 que te hizo ser quien sos
## 6 te cayo despues
## 7 de uno de los peores
## 8 dias de tu vida
## 9 ENTRE NOSOTROS:
## 10 Quisiera saber si alguna vez
## # ... with 13 more rows
Vamos de nuevo a tokenizar este df, como ya habíamos visto anteriormente y vamos a proceder a armar una visualizacion. Usaremos count() para que nos cuente cuantas ocurrencia de cada token hay, simplemente debemos decirle en qué columna se encuentra lo que queremos cuantificar. Usamos la funcion reorder(), para que luego el gráfico nos muestre las barras ordenadas. Por último usaremos ggplot2.
library(ggplot2)
varios_poemas_tokenizados <- varios_poemas %>%
unnest_tokens(palabra_poema,texto)
varios_poemas_tokenizados%>%
count(palabra_poema) %>%
filter(n > 1) %>%
mutate(palabra_poema = reorder(palabra_poema, n)) %>%
ggplot(aes(palabra_poema, n)) +
geom_col() +
coord_flip()
Otra biblioteca bastante útil para visualizar de manera rápida ocurrencia de tokens (palabras) es wordcloud. Vamos a usar la funcion de base with(), para poder aplicarlo en nuestro formato con pipes de magrittr. Tenemos que tener cuidado que por defecto la mínima frecuencia de aparicion es 3.
library(wordcloud)
varios_poemas_tokenizados %>%
# filter(!palabra_poema %in% stopwords::stopwords(language = 'spanish')) %>%
count(palabra_poema) %>%
with(wordcloud(palabra_poema, n,min.freq = 0))
Como vemos hay una linea comentada, que es un antijoin con stop_words, pero, ¿qué son stop_words?
## [1] "de" "la" "que" "el" "en" "y" "a" "los" "del" "se"
## [11] "las" "por" "un" "para" "con" "no" "una" "su" "al" "lo"
## [21] "como" "más" "pero" "sus" "le"
Se trata un vector de palabras típicas usadas en algún lenguaje que le pasemos por parametro, pero que no aportan significado al texto en la mayoria de las ocasiones. Esto sirve para que las palabras con más ocurrencias no sean siempre las mismas sino que sean palabras significativas que aporten valor del mensaje. No lo usamos porque nuestro ejemplo tenia una cantidad muy baja de palabras y haberlo usado hubiese eliminado casi todas las palabras con mas de una ocurrencia como vemos abajo. Lo importante de todas formas es tener presente la existencia de estas colecciones de palabras.
library(wordcloud)
varios_poemas_tokenizados %>%
filter(!palabra_poema %in% stopwords::stopwords(language = 'spanish')) %>%
count(palabra_poema) %>%
with(wordcloud(palabra_poema, n,min.freq = 0))
Stringr
En este punto vamos a presentar otra herramienta muy potente llamada stringr. Este paquete nos propociona un conjunto de funciones para recuperar de manera sencilla informacion de texto. Esta está construida sobre stringi, otra biblioteca mas extensa. Para explotar más su uso y si se quedan con ganas, siempre es buena idea explorar el cheatsheet que tiene.
https://rstudio.com/resources/cheatsheets/
Vamos entonces a ver un vistazo por algunas funciones. Comencemos con algo simple, contemos cuántos caracteres tiene cada verso.
library(stringr)
poema_df %>%
mutate(cantidad_caracteres = str_count(texto))
## # A tibble: 8 x 3
## linea texto cantidad_caracteres
## <int> <chr> <int>
## 1 1 ADVERTENCIA: 12
## 2 2 No se decirte 13
## 3 3 si todo va a mejorar 20
## 4 4 pero seguro la ficha 20
## 5 5 que te hizo ser quien sos 25
## 6 6 te cayo despues 15
## 7 7 de uno de los peores 20
## 8 8 dias de tu vida 15
Quedemonos ahora solo con una parte del texto, en este caso los primeros 5 caracteres. Veamos que a nivel gráfico nos muestra el resultado con comillas para denotar que quedó un espacio [‘’], al principio o al final.
poema_df %>%
mutate(solo_primeros_cinco = str_sub(texto,1,5))
## # A tibble: 8 x 3
## linea texto solo_primeros_cinco
## <int> <chr> <chr>
## 1 1 ADVERTENCIA: ADVER
## 2 2 No se decirte No se
## 3 3 si todo va a mejorar si to
## 4 4 pero seguro la ficha "pero "
## 5 5 que te hizo ser quien sos que t
## 6 6 te cayo despues te ca
## 7 7 de uno de los peores de un
## 8 8 dias de tu vida "dias "
Las posiciones son relativas al largo del texto, podemos entonces decirle que tome los últimos 5 caracteres de la siguiente manera
poema_df %>%
mutate(solo_primeros_cinco = str_sub(texto,-5,-1))
## # A tibble: 8 x 3
## linea texto solo_primeros_cinco
## <int> <chr> <chr>
## 1 1 ADVERTENCIA: NCIA:
## 2 2 No se decirte cirte
## 3 3 si todo va a mejorar jorar
## 4 4 pero seguro la ficha ficha
## 5 5 que te hizo ser quien sos n sos
## 6 6 te cayo despues spues
## 7 7 de uno de los peores eores
## 8 8 dias de tu vida " vida"
Otro uso tipico es querer modificar todo a minusculas, pero como sobre gustos no hay nada definido también podríamos modificar todo a mayusculas.
poema_df %>%
mutate(mayusculas = str_to_upper(texto),
minusculas = str_to_lower(texto))
## # A tibble: 8 x 4
## linea texto mayusculas minusculas
## <int> <chr> <chr> <chr>
## 1 1 ADVERTENCIA: ADVERTENCIA: advertencia:
## 2 2 No se decirte NO SE DECIRTE no se decirte
## 3 3 si todo va a mejorar SI TODO VA A MEJORAR si todo va a mejorar
## 4 4 pero seguro la ficha PERO SEGURO LA FICHA pero seguro la ficha
## 5 5 que te hizo ser quie~ QUE TE HIZO SER QUIEN~ que te hizo ser quien~
## 6 6 te cayo despues TE CAYO DESPUES te cayo despues
## 7 7 de uno de los peores DE UNO DE LOS PEORES de uno de los peores
## 8 8 dias de tu vida DIAS DE TU VIDA dias de tu vida
str_detect()
Veamos ahora como identificar patrones dentro del texto libre desde su manera mas sencilla y veamos algunos ejemplos que pueden servir como disparadores. Generemos una columna nueva que nos diga si este patron estaba en el texto de cada registro.
poema_df %>%
mutate(tengo_de = str_detect(texto, 'de'))
## # A tibble: 8 x 3
## linea texto tengo_de
## <int> <chr> <lgl>
## 1 1 ADVERTENCIA: FALSE
## 2 2 No se decirte TRUE
## 3 3 si todo va a mejorar FALSE
## 4 4 pero seguro la ficha FALSE
## 5 5 que te hizo ser quien sos FALSE
## 6 6 te cayo despues TRUE
## 7 7 de uno de los peores TRUE
## 8 8 dias de tu vida TRUE
Podriamos tambien querer filtrar y quedarnos solo con las ocurrencias de este patron.
poema_df %>%
filter(str_detect(texto, 'de'))
## # A tibble: 4 x 2
## linea texto
## <int> <chr>
## 1 2 No se decirte
## 2 6 te cayo despues
## 3 7 de uno de los peores
## 4 8 dias de tu vida
Volvamos ahora al caso donde teniamos todos los poemas juntos, ¿sería posible identificar de alguna manera cada poema por separado?
## # A tibble: 23 x 1
## texto
## <chr>
## 1 ADVERTENCIA:
## 2 No se decirte
## 3 si todo va a mejorar
## 4 pero seguro la ficha
## 5 que te hizo ser quien sos
## 6 te cayo despues
## 7 de uno de los peores
## 8 dias de tu vida
## 9 ENTRE NOSOTROS:
## 10 Quisiera saber si alguna vez
## # ... with 13 more rows
Busquemos entonces generar un corte cada vez que encuentre los ‘:’, que es en este caso por lo menos lo que nos indica que hay un titulo. Una funcion que nos podria servir es cumsum(), que nos va a mantener un contador como suma acumulada cada vez que se cumpla una condicion que le pasemos por parametro.
poemas_separados <- varios_poemas %>%
mutate(poema = cumsum(str_detect(texto, ':')))
poemas_separados
## # A tibble: 23 x 2
## texto poema
## <chr> <int>
## 1 ADVERTENCIA: 1
## 2 No se decirte 1
## 3 si todo va a mejorar 1
## 4 pero seguro la ficha 1
## 5 que te hizo ser quien sos 1
## 6 te cayo despues 1
## 7 de uno de los peores 1
## 8 dias de tu vida 1
## 9 ENTRE NOSOTROS: 2
## 10 Quisiera saber si alguna vez 2
## # ... with 13 more rows
Excelente, esto nos permitirá abstraernos de la cantidad de registros que tengamos tokenizados por cada archivo de texto original.
Como recuperar lo que partimos? [lo rompi?]
Una vez que aprendimos a separar [romper] algo, estaría buenisimo tambien saber volverlo a armar, ¿verdad? Vamos a usar la funcion paste() y le vamos a pasar por parametro collapse, para definirle que queremos qué nos deje en el medio de cada verso del poema en este caso.
poemas_unidos <- poemas_separados %>%
group_by(poema) %>%
mutate(poema_entero = paste(texto,
collapse = ' ')) %>%
slice(1) %>%
ungroup() %>%
select(poema_entero)
poemas_unidos
## # A tibble: 4 x 1
## poema_entero
## <chr>
## 1 ADVERTENCIA: No se decirte si todo va a mejorar pero seguro la ficha que~
## 2 ENTRE NOSOTROS: Quisiera saber si alguna vez se van a poder leer las men~
## 3 LOOP: Todavía me parece Que vas a venir un día Y me vas a decir lo que y~
## 4 VARIACIONES SOBRE LA TRISTEZA: Meter la mano en el cajón de las aspirina~
Proximos pasos -> str_extract() y regex()
Para ir finalizando con estas herramientas baáicas, volvamos al ejemplo original que usamos para ver como podíamos encontrarnos la informacion no estructurada
detalle |
---|
se realizaron 45 observaciones del tipo A en el anio 2017 |
se realizaron 60 observaciones del tipo B en el anio 2017 |
se realizaron 23 observaciones del tipo C en el anio 2017 |
se realizaron 32 observaciones del tipo A en el anio 2018 |
se realizaron 63 observaciones del tipo B en el anio 2018 |
se realizaron 19 observaciones del tipo C en el anio 2018 |
Vamos a usar str_extract para obtener la informacion que esta perdida dentro del campo libre, pero para que esta función realmente explote su potencia va a necesitar que le agreguemos expresiones regulares. Las expresiones pueden ir desde algo muy simple, hasta algo super complejo, podemos ayudarnos del cheatsheet y de paginas como https://regex101.com/ que nos permiten en tiempo real ir probando nuestras expresiones. Vamos a ver como a priori este caso se resuelve con expresiones bastante amigables
tabla_valores_no_estructurada %>%
mutate(n = str_extract(detalle, regex('[0-9]+')), #uno o mas numeros
tipo = str_extract(detalle, regex('[ABC] ')), #letras en mayuscula A, B o C
anio = str_extract(detalle, regex('[0-9]+$'))) #uno o mas numeros y fin de texto
## # A tibble: 6 x 4
## detalle n tipo anio
## <chr> <chr> <chr> <chr>
## 1 se realizaron 45 observaciones del tipo A en el anio 2~ 45 "A " 2017
## 2 se realizaron 60 observaciones del tipo B en el anio 2~ 60 "B " 2017
## 3 se realizaron 23 observaciones del tipo C en el anio 2~ 23 "C " 2017
## 4 se realizaron 32 observaciones del tipo A en el anio 2~ 32 "A " 2018
## 5 se realizaron 63 observaciones del tipo B en el anio 2~ 63 "B " 2018
## 6 se realizaron 19 observaciones del tipo C en el anio 2~ 19 "C " 2018
Algo interesante a tener en cuenta es observar como la primera expresión regular no trae el año además del n. Esto se debe a que salvo que le indiquemos lo contrario str_extract(), nos trae solo lo primero que encuentra. Entonces una vez que encuentra uno o mas numeros seguidos, deja de mirar el texto.
Conclusion
Este pequeño tutorial tiene como finalidad presentar un vistazo rapido por bastantes herramientas para el analisis de texto. ¡Ojalá sirva como disparador para investigar más!