En este documento se presentará cómo realizar operaciones elementales de mineria de textos usando R, las cuales deberían proporcionar suficientes herramientas para análisis más completos y complejos. No pretende ser una guía exahustiva, sólo un documento introductorio de referencia.
Empezamos por cargar a nuestro espacio de trabajo los paquetes que usaremos:
tm
, específico para minería de textos.wordcloud
, para graficar nubes de palabras.ggplot2
, una gramática de gráficas que expande las funciones base de R.dplyr
, con funciones auxiliares para manipular y transformar datos. En particular, el operador %>%
permite escribir funciones más legibles para seres humanos.readr
, facilitará leer y escribir documentos.cluster
, con funciones para realizar análisis de grupos.library(tm)
library(SnowballC)
library(wordcloud)
library(ggplot2)
library(dplyr)
library(readr)
library(cluster)
El texto con el que trabajeremos es el texto del libro Niebla de Miguel de Unamuno que se puede descargar en Gutenberg dando click a este enlace.
Una vez hemos descargado este documento a nuestro directorio de trabajo, procedemos a leerlo usando la función read_lines
de readr
.
Nuestro interés está en el contenido de este libro y no en los avisos legales de Gutenberg, prólogo y anotaciones, así que los omitiremos de la lectura. Empezaremos a leer el documento desde la línea 420, que es donde termina el prólogo, introducción e índice de Niebla, para ello nos saltaremos (skip
) 419 líneas previas. De manera complementaria, detendremos la lectura en la línea 8313, que es donde inician los avisos legales de Gutenberg, por lo tanto leeremos un máximo (nmax
) de 8313-419
líneas.
Realizamos estas operaciones y asignamos el resultado al objeto nov_raw.
nov_raw <- read_lines("49836-0.txt", skip = 419, n_max = 8313-419)
El objeto nov_raw que obtuvimos es uno de tipo character
, con 7894 elementos.
str(nov_raw)
## chr [1:7894] "NIEBLA" "" "" "" "" "I" "" "" ...
Cada uno de estos elementos corresponde a un renglón de Niebla y tiene ancho máximo 70 caracteres, pues este es el ancho usado en los textos electrónicos de Gutenberg. Esta es una cantidad muy pequeña de texto para encontrar asociaciones entre palabras, por lo que necesitamos crear elementos con una mayor cantidad de caracteres en cada uno.
Por lo tanto, crearemos elementos del tamaño aproximado a un párrafo.
Creamos un vector llamado diez con 10 repeticiones (rep
) de los números desde 1 hasta el número de renglones en el documento, dividido entre 10 (length(nov_raw)/10
.
Con esto, tendremos un vector con diez 1, luego diez 2, etc, hasta llegar al número máxico de grupos de diez posibles en función del número de renglones de nuestro documento.
Usaremos estos números para hacer grupos de diez renglones consecutivos.
diez <- rep(1:ceiling(length(nov_raw)/10), each = 10)
De este vector, nos quedamos con un número de elementos igual al número de renglones del objeto nov_raw (length(nov_raw)
), para facilitar combinarlos.
diez <- diez[1:length(nov_raw)]
Combinamos diez con now_raw y los asignamos al objeto nov_text. Así tenemos una columna con los renglones de texto y otra con un número que identifica a qué grupo de diez renglones pertenece.
Además, convertimos a data.frame
para que las columnas estén identificadas con un nombre, lo cual será útil en los siguientes pasos.
nov_text <- cbind(diez, nov_raw) %>% data.frame()
Usamos aggregate
para concatenar los renglones (FUN = paste
, con collapse = " "
para preservar el espacio entre palabras), agrupados por diez
(formula = nov_raw ~ diez
).
nov_text <- aggregate(formula = nov_raw ~ diez,
data = nov_text,
FUN = paste,
collapse = " ")
Como sólo necesitamos la columna con los ahora párrafos de texto, con eso nos quedamos. Aprovechamos para transformar nov_text en una matrix
, pues esto nos facilitará los pasos siguientes.
nov_text <- nov_text %>% select(nov_raw) %>% as.matrix
dim(nov_text)
## [1] 790 1
Por supuesto, podemos realizar todo lo anterior en un único paso.
nov_text <-
cbind(
rep(1:ceiling(length(nov_raw)/10), each = 10) %>%
.[1:length(nov_raw)],
nov_raw
) %>%
data.frame %>%
aggregate(
nov_raw ~ V1,
data = .,
FUN = paste,
collapse=" ") %>%
select(nov_raw) %>%
as.matrix
dim(nov_text)
## [1] 790 1
Necesitamos limpiar el texto de caracteres que son de poca utilidad en la mineria de textos.
Empezamos por aseguramos de que no queden caracteres especiales de la codificación, como saltos de línea y tabulaciones, con un poco de ayuda de Regular Expressions.
nov_text <- gsub("[[:cntrl:]]", " ", nov_text)
Convertimos todo a minúsculas.
nov_text <- tolower(nov_text)
Usamos removeWords
con stopwords("spanish")
para eliminar palabras vacias, es decir, aquellas con poco valor para el análisis, tales como algunas preposiciones y muletillas.
nov_text <- removeWords(nov_text, words = stopwords("spanish"))
Nos deshacemos de la puntuación, puesto que fin y fin. son identificadas como palabras diferentes, lo cual no deseamos.
nov_text <- removePunctuation(nov_text)
En este caso, removemos los números, pues en Niebla no hay fechas y otras cantidades que deseemos conservar.
nov_text <- removeNumbers(nov_text)
Por último eliminamos los espacios vacios excesivos, muchos de ellos introducidos por las transformaciones anteriores.
nov_text <- stripWhitespace(nov_text)
Con nuestro documento preparado, procedemos a crear nuestro Corpus, es decir, esto es nuestro acervo de documentos a analizar.
En nuestro caso, nuestro Corpus se compone de todos los parrafos del libro Niebla y los asignaremos al objeto nov_corpus usando las funciones VectorSource
y Corpus
.
nov_corpus <- Corpus(VectorSource(nov_text))
nov_corpus
## <<VCorpus>>
## Metadata: corpus specific: 0, document level (indexed): 0
## Content: documents: 790
Como podemos ver, nustro Copus está compuesto por 790 documentos. Los siguientes análisis se harán a partir de este Corpus.
Mapearemos nuestro Corpus como un documento de texto plano usando las funciones tm_map
y PlainTextDocument
).
nov_ptd <- tm_map(nov_corpus, PlainTextDocument)
Con nuestro Corpus mapeado de esta manera, podemos crear fácilmente una nube de palabras (wordcloud
de la librería del mismo nombre) que nos muestro los términos más frecuentes en Niebla.
wordcloud(nov_ptd, max.words = 80, random.order = F, colors = brewer.pal(name = "Dark2", n = 8))
Podemos observar que en nuestro Corpus aun se encuentran palabras de poco interés para el análisis, tales como “usted”, “tal” y “sino”, lo cual nos indica que debemos realizar una segunda limpieza de nuestros textos.
Como observamos en las nubes de palabras que generamos, aún tenemos palabras que aparecen con mucha frecuencia en nuestro texto que en realidad no son de mucha utilidad para el análisis.
Usaremos la función removeWords
indicando en el argumento words
que palabras deseamos eliminar de nuestro Corpus.
Esta función requiere de un vector de caracteres como argumento principal, así que para esta operación usaremos nuestro objeto nov_text.
Una vez hecho esto, usaremos nuestra nueva versión de nov_text para generar nuestro Corpus y para mapearlo como documento de texto plano.
nov_text <- removeWords(nov_text, words = c("usted", "pues", "tal", "tan", "así", "dijo", "cómo", "sino", "entonces", "aunque", "don", "doña"))
nov_corpus <- nov_text %>% VectorSource() %>% Corpus()
nov_ptd <- nov_corpus %>% tm_map(PlainTextDocument)
Generamos un nube de palabras nueva, en la cual es posible ver una diferencia significativa en su composición.
wordcloud(
nov_ptd, max.words = 80,
random.order = F,
colors=brewer.pal(name = "Dark2", n = 8)
)
Augusto y Eugenia, como podrás imaginar, son los protagonistas de Niebla y gran parte de la acción en este libro ocurre en la “casa” de uno u otro protagonista, discutiendo las relaciones entre “hombre” y “mujer”.
Esta nube nos da una idea más precisa que la anterior del contenido de Niebla y aunque nuestro Corpus puede ser depurado aún más, lo dejaremos así.
Mapearemos nuestro Corpus indicando que es una matriz de términos, de esta manera podremos hacer realizar operaciones como identificar asociaciones entre palabras.
Usaremos la función TermDocumentMatrix
en nuestro Corpus y asignaremos el resultado al objeto nov_tdm.
nov_tdm <- TermDocumentMatrix(nov_corpus)
nov_tdm
## <<TermDocumentMatrix (terms: 7236, documents: 790)>>
## Non-/sparse entries: 20527/5695913
## Sparsity : 100%
## Maximal term length: 22
## Weighting : term frequency (tf)
Podemos observar que tenemos 7236 terms, esto quiere decir que tenemos 7236 palabras diferentes en nuestro Corpus. Lo cual es una cantidad considerable de vocabulario pero no esperaríamos otra cosa de una obra de Miguel de Unamuno.
Aunque una nube de palabras nos muestra de manera visual la frecuencia de las palabras en nuestro Corpus, no nos devuelve cantidades.
Para obtenerlas, primero transformaremos nuestro objeto nov_tdm en un objeto de clase matrix, que de nuevo tendrá un número de renglones igual al número de palabras distintas de nuestro Corpus y número de columnas igual a su número de documentos.
nov_mat <- as.matrix(nov_tdm)
dim(nov_mat)
## [1] 7236 790
Obtenemos las sumas de renglones (rowSums
) odenadas de mayor a menor (sort
con decreasing = TRUE
)para conocer la frecuencia de cada palabra y después transformamos los resultados a objeto de clase data.frame
de dos columnas, palabra y frec, que nos permitirá graficar fácilmente su contenido.
nov_mat <- nov_mat %>% rowSums() %>% sort(decreasing = TRUE)
nov_mat <- data.frame(palabra = names(nov_mat), frec = nov_mat)
Con este objeto también podemos crear una nube de palabras, aunque con argumentos un poco diferentes.
wordcloud(
words = nov_mat$palabra,
freq = nov_mat$frec,
max.words = 80,
random.order = F,
colors=brewer.pal(name = "Dark2", n = 8)
)
Además, podemos obtener fácilmente las palabras más frecuentes. Por ejemplo, las veinte más frecuentes, pidiendo los primeros veinte renglones de nov_mat.
nov_mat[1:20, ]
## palabra frec
## augusto augusto 335
## eugenia eugenia 214
## mujer mujer 180
## hombre hombre 131
## casa casa 128
## ahora ahora 123
## bien bien 121
## ser ser 102
## mismo mismo 99
## vez vez 91
## pobre pobre 90
## ojos ojos 87
## vida vida 84
## fué fué 79
## dos dos 74
## luego luego 74
## dios dios 71
## cosas cosas 70
## decir decir 70
## madre madre 70
Crearemos un par de gráficas. Primero, la frecuencia de uso de las palabras más frecuentes en Niebla.
Para esto usaremos ggplot2
, que tiene su propia gramática para construir gráficas. Para fines de este documento, no nos detendremos a explicar a detalle su uso. Lo relevante aquí es notar que estamos obteniendo la información para construir las gráficas solicitando renglones del objeto nov_mat
nov_mat[1:10, ] %>%
ggplot(aes(palabra, frec)) +
geom_bar(stat = "identity", color = "black", fill = "#87CEFA") +
geom_text(aes(hjust = 1.3, label = frec)) +
coord_flip() +
labs(title = "Diez palabras más frecuentes en Niebla", x = "Palabras", y = "Número de usos")
La misma información expresada como proporción de uso cada palabra. Para esta gráfica usamos la función mutate
de dplyr
para obtener el porcentaje de uso de cada palabra antes de graficar.
nov_mat %>%
mutate(perc = (frec/sum(frec))*100) %>%
.[1:10, ] %>%
ggplot(aes(palabra, perc)) +
geom_bar(stat = "identity", color = "black", fill = "#87CEFA") +
geom_text(aes(hjust = 1.3, label = round(perc, 2))) +
coord_flip() +
labs(title = "Diez palabras más frecuentes en Niebla", x = "Palabras", y = "Porcentaje de uso")
A partir de las dos gráficas anteriores podemos observar, entre otras cosas, que aunque “augusto” es la palabra más usada, representa menos de 1.5% del total de usos de las palabras en nuestro Corpus.
Veamos ahora cómo se asocian algunas palabras (terms
) en Niebla con la función findAssocs
. Como podemos introducir un vector, podemos obtener las asociaciones de varias palabras a la vez. He elegido “augusto”, “eugenia”, “hombre” y “mujer”.
Es importante recordar que con esto no estamos pidiendo la asociacion de estas cuatro palabras entre si, sino las asociaciones para cada una de las cuatro, que no necesariamente deben coincidir.
Esta también nos pide el límite inferior de correlación (corlimit
) para mostrarnos. Valores cercanos a 1 indican que las palabras aparecen casi siempre asociadas una con otra, valores cercanos a 0 nos indican que nunca o casi nunca lo hacen.
El valor que decidamos depende del tipo de documento y el tipo de asociaciones que nos interesen. para nuestros fines, lo he fijado en .25.
findAssocs(nov_tdm, terms = c("augusto", "eugenia", "hombre", "mujer"), corlimit = .25)
## $augusto
## numeric(0)
##
## $eugenia
## abajo fugitiva hominis lucharemosiba militia
## 0.33 0.33 0.33 0.33 0.33
## super terram venceré victoria vita
## 0.33 0.33 0.33 0.33 0.33
## yunta finalidad lucharemos américa buscarme
## 0.33 0.31 0.31 0.27 0.27
## colón compadecida descubre encontrar pronunciando
## 0.27 0.27 0.27 0.27 0.27
## est
## 0.26
##
## $hombre
## salida cínicos confundían engendrarlo mortal perrunos
## 0.29 0.28 0.28 0.28 0.28 0.28
## respire resucitarlo trajes hipocresía cinismo
## 0.28 0.28 0.28 0.27 0.26
##
## $mujer
## colectividad concretó dejarla enamoras
## 0.32 0.32 0.32 0.32
## enamoraste explicártelo valiesen abstracto
## 0.32 0.32 0.32 0.30
## fogueteiro hermosura busto convertida
## 0.28 0.27 0.26 0.26
## desfigurada desfiguramiento desvanecidos explosión
## 0.26 0.26 0.26 0.26
## gravísimas inspirarle lazarilla musa
## 0.26 0.26 0.26 0.26
## orgulloso pirotécnicas pólvora ponderándola
## 0.26 0.26 0.26 0.26
## prende quemaduras quemó genérico
## 0.26 0.26 0.26 0.25
Aunque “augusto” es la palabra más frecuente en Niebla no tiene relaciones tan fuertes como las demás palabras que elegimos. De hecho, no tiene ninguna superior a .25, por eso el resultado mostrado.
A partir de estos resultados podemos observar algunas cosas, por ejemplo, que “hombre” está asociada con adjetivos negativos, que “mujer” se asocia con palabras que hacen alusión a fuego y explosiones, y que “lucharemosiba” aparece como una palabra, que nos indica que sería buena idea realizar aún más limpieza en nuestro corpus.
Esto es de esperar, pues difícilmente una primera ronda de limpieza capturará todas las anomalías en un conjunto de documentos. Nosotros continuaremos con las agrupaciones.
Realizaremos análisis de agrupaciones jerárquicas para identificar grupos de palabras relacionados entre sí, a partir de la distancia que existe entre ellos.
Empezaremos por eliminar los términos dispersos en nuestra matriz de términos, para así conservar únicamente las palabras más frecuentes y obtener resultados más interpretables del agrupamiento.
Usaremos las función removeSparseItems
para depurar nuestra matriz de términos de aquellas palabras que aparecen con muy poca frecuencia, es decir, son dispersos (“sparse”).
Esta función requiere que especifiquemos el argumento sparse, que puede asumir valores de 0 a 1. Entre valor representa la dispersión de las palabras que queremos conservar. Si lo fijamos muy alto (cerca de 1, pero no 1), conservaremos muchas palabras, casi todas, pues estamos indicando que queremos conservar términos aunque sean muy dispersos. Naturalmente, lo opuesto ocurre si fijamos este valor muy bajo (cerca de 0, pero no 0), pudiendo incluso quedarnos con ningún término, si las palabras en nuestros documentos son dispersas en general.
Qué valor fijemos depende del tipo de documento que tengamos, por lo que es aconsejable realizar ensayos hasta encontrar un equilibrio entre dispersión y número de términos. En este caso, he decidido fijarlo en .95
y guardaremos la nueva matriz de términos en el objeto nov_new
nov_new <- removeSparseTerms(nov_tdm, sparse = .95)
Comparamos cuántos términos teníamos originalmente y con cuántos nos hemos quedado, observando a cuánto equivale terms.
nov_tdm
## <<TermDocumentMatrix (terms: 7236, documents: 790)>>
## Non-/sparse entries: 20527/5695913
## Sparsity : 100%
## Maximal term length: 22
## Weighting : term frequency (tf)
nov_new
## <<TermDocumentMatrix (terms: 42, documents: 790)>>
## Non-/sparse entries: 2861/30319
## Sparsity : 91%
## Maximal term length: 8
## Weighting : term frequency (tf)
De 7236 términos que teníamos, nos hemos quedado con 42, lo cual reduce en gran medida la dificultad y complejidad de los agrupamientos, lo cual es deseable. Es poco útil tener agrupaciones que son únicamente visualizaciones del texto original.
También podemos ver el número de términos pidiéndo el número de renglones de nuestra matriz de términos, que es igual al número de palabras que contiene.
nov_tdm$nrow
## [1] 7236
nov_new$nrow
## [1] 42
Transformamos esta matriz de términos a un objeto de tipo matrix
para así poder realizar las operaciones posteriores.
nov_new <- nov_new %>% as.matrix()
En este objeto de clase matrix
cada renglon es una palabra y cada columna es un documento, de modo tal que cada celda nos indica el número de veces que aparece una palabra en un documento.
Necesitamos crear una matriz de distancias para empezar agrupar, lo cual requiere que los valores en las celdas sean estandarizados de alguna manera.
Podríamos usar la función scale
, pero realiza la estandarización usando la media de cada columna como referencia, mientras que nosotros necesitamos como referencia la media de cada renglón.
Así que obtenemos una estandarización por renglones de manera manual.
nov_new <- nov_new / rowSums(nov_new)
Hecho esto, nuestra matriz ha sido estandarizada.
Procedemos a obtener una matriz de distancia a partir de ella, con el método de distancias euclidianas y la asignamos al objeto nov_dist.
nov_dist <- dist(nov_new, method = "euclidian")
Realizaremos nuestro agrupamiento jerárquico usando la función hclust
, de la base de R. Este es en realidad un procedimiento muy sencillo una vez que hemos realizado la preparación.
Usaremos el método de Ward (ward.D
), que es el método por defecto de la función hclust
y asignaremos sus resultados al objeto nov_hclust.
nov_hclust <- hclust(nov_dist, method = "ward.D")
Graficamos los resultados usando plot
para generar un dendrograma.
plot(nov_hclust, main = "Dendrograma de Niebla - hclust", sub = "", xlab = "")
De este modo podemos observar los grupos de palabras que existen en Niebla. Por ejemplo, “augusto” y “eugenia” forman un grupo, “puede” y “ser”, forman otro grupo (“puede ser” es una frase común en este libro).
Además, podemos ver qué palabras pertenecen a grupos lejanos entre sí, por ejemplo, “quiero” y “verdad”.
Podemos enfatizar los grupos de palabras trazando un rectángulo usando rect.hclust
y con especificando cuántos grupos (k
) deseamos resaltar.
Crearemos el mismo gráfico pidiendo diez grupos.
plot(nov_hclust, main = "Dendrograma de Niebla - hclust", sub = "", xlab = "")
rect.hclust(nov_hclust, k = 10, border="blue")
El paquete cluster
nos proporciona más métodos para realizar agrupamientos. Uno de ellos es agnes
, que inicia asumiendo que cada elemento a agrupar por si mismo es un grupo y después crea grupos de grupos a partir de las distancias entre ellos, hasta que no es posible crear más grupos.
Realizamos prácticamente el mismo procedimiento que con hclust
, sólo cambiamos el método a average. Asignaremos nuestros resultados al objeto nov_agnes.
nov_agnes <- agnes(nov_dist, method = "average")
Ahora graficamos nuestros resultados. Un agrupamiento creado con agnes
genera dos gráficos, el primero muestra cómo se obtuvieron los grupos finales y el segundo es un dendrograma.
Pediremos el segundo gráfico (which.plots = 2
).
plot(nov_agnes, which.plots = 2, main = "Dendrograma de Niebla - Agnes", sub = "", xlab = "")
Enfatizamos diez grupos.
plot(nov_agnes, which.plots = 2, main = "Dendrograma de Niebla - Agnes", sub = "", xlab = "")
rect.hclust(nov_agnes, k = 10, border = "blue")
Las agrupaciones que hemos obtenido usando hclust
y agnes
son diferentes entre sí. La decisión de qué método usemos depende de nuestros propósitos y de nuestra familiaridad con ellos.
En este documento revisamos cómo importar y preparar documentos de texto para realizar distintas operaciones de mineria de textos con ellos, tales como obtener palabras frecuentes, asocioaciones de palabras y análisis de agrupamiento.
Por supuesto la minería de textos es un área de análisis extensa. Las siguientes referencias han sido de gran ayuda para realizar este documento y amplían lo aquí presentado.
Comentarios, correcciones y sugerencias con bienvenidas (email).