22 Manipulación de textos: extrayendo atributos de textos

Una de las maneras de entender los textos es organizarlos en matrices de términos del documento (DTM), o su transpuesta, TDM.

En DTM, cada fila representa un documento o corpus (colección de documentos) individual.

En la transposición (TDM), las palabras o grupos de palabras son las filas, mientras que los documentos son las columnas.

La siguiente imagen ilustra estos elementos:

DTM & TDM En estos ejemplos, DTM y TDM simplemente muestran un conteo de palabras. La matriz muestra la suma de las palabras tal como aparecieron para el tweet específico.

22.1 Número de caracteres y sustitución

La función nchar devuelve el número de caracteres en un texto

x <- "INEC"
nchar(x)
## [1] 4

Notemos que los espacios también son interpretados como caracteres:

x <- "INEC "
nchar(x)
## [1] 5

Importemos datos de twitter y analicemos el número de caracteres por tweet:

uu <- "https://raw.githubusercontent.com/vmoprojs/DataLectures/master/tweets_CrisisCarcelaria.RData"
load(url(uu))

Hacemos el cálculo:

summary(nchar(tweets$text))
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
##    32.0   175.0   258.0   227.7   291.0   948.0

Tenemos que el número de caracteres del tweet varía entre 32 y 948 caracteres. Esto puede incluir links, menciones, etc.

Ejercicio

¿Cuántos tweets alcanzan hasta 140 y 280 caracteres respectivamente?

Ahora vamos a filtrar los tweets que hayan alcanzado más de 100 retweets:

subset.doc <- subset(tweets,tweets$retweet_count>100)

Tenemos un total de 8375 Tweets con ese alcance, el 48% del total.

Ahora veremos funciones que reemplazan patrones definidos en las cadenas de texto.

  • sub: busca la primera coincidencia de patrón en una cadena y la reemplaza (si ignore.case=FALSE entonces es case sensitive o diferencia entre mayúsculas y minúsculas)
x <- c("ola q ace")
sub("q",'que',x, ignore.case=T)
## [1] "ola que ace"

En los 5 primeros tweets, es decir, la función también funciona sobre un vector. Por ejemplo:

sub('Ecuador',' El país medallista ',tweets$text[1:5], ignore.case=T)
## [1] "@MinGobiernoEc @LassoGuillermo @Policia El país medallista  @FFAAECUADOR 🔴🔴 D que resultados hablan ? 🔴🔴\n🔴 Que sucedió con los 1000 paco/milicos que ingresaron a la #peni ?🔴\n.\n🔵🔵 Para q estado de excepción?, si la #delincuencia común sigue imparable 🔵🔵\n.\n⚪ #despiertaEc #ecuador⚪\n⚪ #HuelgaEc #CrisisCarcelaria⚪\n.\n🟡Xq despiden profesores🟡"
## [2] "@LassoGuillermo Proyectos publicos de inversión?\n.\nWTF??\n.\n#Despierta El país medallista \n#Ecuador\n#HuelgaEc\n#PandoraPapers \n#CrisisCarcelaria"                                                                                                                                                                                                                   
## [3] "@sin_socialismo @LassoGuillermo No le.paren bola q es.otro Trol de Lasso.\n#trollasso\n#huelgaEc\n# El país medallista \n#despierta\n#CrisisCarcelaria \n#AnitaPagaTusDeudas \n#PandoraPapers"                                                                                                                                                                            
## [4] "@LassoGuillermo @LassoGuillermo  está en su papayal, \n.\nLe descubrieron lo de #PandoraPapers y va saliendo ileso.\n# El país medallista  vive una #CrisisCarcelaria constante, la #delincuencia está #imparable y a el solo le preocupa crear mas #IMPUESTOS y chacharear con y sobre #USA.\n.\n#DespiertaEc\n#huelgaEc"                                                
## [5] "@LassoGuillermo @LassoGuillermo  está en su papayal, \n.\nLe descubrieron lo de #PandoraPapers y va saliendo ileso.\n# El país medallista  vive una #CrisisCarcelaria constante, la #delincuencia está #imparable y a el solo le preocupa crear mas #IMPUESTOS y chacharear con y sobre #USA.\n.\n#DespiertaEc\n#huelgaEc"
  • gsub: Esta función de sustitución global reemplazará no solo la primera instancia de un patrón, sino todas las instancias.
fake.text <- 'minería de texto en R es bueno, pero la minería de texto en Python también'
sub('Minería de texto','tm', fake.text, ignore.case=TRUE)
## [1] "tm en R es bueno, pero la minería de texto en Python también"
gsub('Minería de texto','tm', fake.text, ignore.case=TRUE)
## [1] "tm en R es bueno, pero la tm en Python también"

Usando solo sub, la primera coincidencia de patrón minería de texto se reemplaza con tm, mientras que la segunda no. gsub reemplaza ambos.

gsub también es adecuada para remover patrones específicos de todo el texto. Por ejemplo, varios tweets tienen como mención a @LassoGuillermo, podemos reemplazarlo por vacío:

tweets$text[1:3]
## [1] "@MinGobiernoEc @LassoGuillermo @PoliciaEcuador @FFAAECUADOR 🔴🔴 D que resultados hablan ? 🔴🔴\n🔴 Que sucedió con los 1000 paco/milicos que ingresaron a la #peni ?🔴\n.\n🔵🔵 Para q estado de excepción?, si la #delincuencia común sigue imparable 🔵🔵\n.\n⚪ #despiertaEc #ecuador⚪\n⚪ #HuelgaEc #CrisisCarcelaria⚪\n.\n🟡Xq despiden profesores🟡"
## [2] "@LassoGuillermo Proyectos publicos de inversión?\n.\nWTF??\n.\n#DespiertaEcuador\n#Ecuador\n#HuelgaEc\n#PandoraPapers \n#CrisisCarcelaria"                                                                                                                                                                                                                   
## [3] "@sin_socialismo @LassoGuillermo No le.paren bola q es.otro Trol de Lasso.\n#trollasso\n#huelgaEc\n#ecuador\n#despierta\n#CrisisCarcelaria \n#AnitaPagaTusDeudas \n#PandoraPapers"
gsub('@LassoGuillermo','',tweets$text[1:3])
## [1] "@MinGobiernoEc  @PoliciaEcuador @FFAAECUADOR 🔴🔴 D que resultados hablan ? 🔴🔴\n🔴 Que sucedió con los 1000 paco/milicos que ingresaron a la #peni ?🔴\n.\n🔵🔵 Para q estado de excepción?, si la #delincuencia común sigue imparable 🔵🔵\n.\n⚪ #despiertaEc #ecuador⚪\n⚪ #HuelgaEc #CrisisCarcelaria⚪\n.\n🟡Xq despiden profesores🟡"
## [2] " Proyectos publicos de inversión?\n.\nWTF??\n.\n#DespiertaEcuador\n#Ecuador\n#HuelgaEc\n#PandoraPapers \n#CrisisCarcelaria"                                                                                                                                                                                                                   
## [3] "@sin_socialismo  No le.paren bola q es.otro Trol de Lasso.\n#trollasso\n#huelgaEc\n#ecuador\n#despierta\n#CrisisCarcelaria \n#AnitaPagaTusDeudas \n#PandoraPapers"

También se usa gsub para remover puntuación (más info en ?regex):

gsub('[[:punct:]]','',tweets$text[1:3])
## [1] "MinGobiernoEc LassoGuillermo PoliciaEcuador FFAAECUADOR  D que resultados hablan  \n Que sucedió con los 1000 pacomilicos que ingresaron a la peni \n\n Para q estado de excepción si la delincuencia común sigue imparable \n\n despiertaEc ecuador\n HuelgaEc CrisisCarcelaria\n\nXq despiden profesores"
## [2] "LassoGuillermo Proyectos publicos de inversión\n\nWTF\n\nDespiertaEcuador\nEcuador\nHuelgaEc\nPandoraPapers \nCrisisCarcelaria"                                                                                                                                                                            
## [3] "sinsocialismo LassoGuillermo No leparen bola q esotro Trol de Lasso\ntrollasso\nhuelgaEc\necuador\ndespierta\nCrisisCarcelaria \nAnitaPagaTusDeudas \nPandoraPapers"

Dentro del paquete qdap tenemos la función mgsub para sustituciones globales múltiples que permite hacer sustitución de manera vectorizada:

library(qdap)
fake.text
## [1] "minería de texto en R es bueno, pero la minería de texto en Python también"
patterns <- c('bueno','sin duda!','minería de texto')
replacements <- c('excelente','just as suitable','tm')
mgsub(patterns,replacements,fake.text)
## [1] "tm en R es excelente, pero la tm en Python también"

En el caso anterior se hacen tres reemplazos de manera simultánea.

22.2 Pegar, dividir y extraer caracteres

Para los analistas que usan Excel, pegar (paste) es lo mismo que la función de concatenación que se usa para los vectores.

Supongamos que necesitamos hacer un único código usando el usuario user_id y el estado status_id:

head(paste(tweets$user_id,tweets$status_id,sep = "-"))
## [1] "1459076906519773184-1461835930147373061" "1459076906519773184-1461391963064872963"
## [3] "1459076906519773184-1461401624543387655" "1459076906519773184-1461486636013965313"
## [5] "1459076906519773184-1461486827974578178" "1459076906519773184-1461767316379783177"

Notemos que las fechas están en formato de minutos y segundos, podemos usar la función as.Date para cambiar sus formatos y tener los valores por día:

tweets$fecha <- as.Date(tweets$created_at)
  • strsplit: crea cadenas de subconjuntos haciendo coincidir patrones de caracteres.
x <- "Es fácil mentir con estadísticas, pero es más fácil mentir sin ellas"
hash <- strsplit(x,'[,]')
hash
## [[1]]
## [1] "Es fácil mentir con estadísticas"    " pero es más fácil mentir sin ellas"
  • substring: extrae partes de una cadena basándose en un número inicial y final.
substring('La minería de texto en R es bacán',29,33)
## [1] "bacán"

Ejercicio

Cree una función llamada last.chars que devuelva los n últimos caracteres de la cadena de texto de entrada:

22.3 Buscando palabras

Las funciones grep y grepl buscan un patrón en el texto (global regular expression print). La diferencia es que la segunda devuelve un vector lógico.

En el siguiente código buscamos la palabra delincuencia en los primeros 5 tweets:

grep('delincuencia', tweets$text[1:5],ignore.case=T)
## [1] 1 4 5
grepl('delincuencia', tweets$text[1:5],ignore.case=T)
## [1]  TRUE FALSE FALSE  TRUE  TRUE

grep devuelve las posiciones donde se encuentra la palabra mientas grepl devuelve un vector lógico del mismo tamaño que el de entrada. Esta función puede ser muy últil cuando queremos ver la frecuencia de una palabra.

Por ejemplo, buscamos el número de tweets que contiene la palabra disculpa:

sum(grepl('disculpa', tweets$text,ignore.case=T))
## [1] 151

A continuación, es posible que desee buscar más de un término a la vez, usamos |:

sum(grepl(c('disculpa|perdón'),tweets$text,ignore.case=T))
## [1] 155

Ahora identifiquemos los tweets que contiene un link:

(sum(grepl('http', tweets$text, ignore.case =T))/nrow(tweets))*100
## [1] 83.42053

Vemos que el 83.4% de los tweets contienen un link.

Si queremos contar el número de veces que aparece una palabra en todos los tweets, includo dentro del tweet y no solo si existe, podemos usar la función stri_count de la librería stringi.

library(stringi)
sum(grepl("http",tweets$text))
## [1] 14989
sum(stri_count(tweets$text, fixed='http'))
## [1] 16172

Es decir, tenemos 14989 tweets con links, y 16172 veces que aparece http. Nota que en stri_count el patrón de texto está en segundo lugar por defecto.

Anteriormente usamos | dentro de grep para buscar uno u otro patrón, si queremos usar &, podemos usar la función str_detect del paquete stringr:

library(stringr)
sum(str_detect(tweets$text,'http'))
## [1] 14989
sum(with(tweets, str_detect(text,'http') & str_detect(text, 'Quito')))
## [1] 77

22.4 Limpieza de texto

La siguiente tabla muestra algunas funciones comunes de limpieza de texto:

Función Descripción Antes Después
tolower Hace que todo el texto esté en minúsculas Desde Pelileo-Ecuador desde pelileo-ecuador
removePunctuation Elimina signos de puntuación como puntos y signos de exclamación. Cuidado! Es verdad? Cuidado Es verdad
stripWhitespace Elimina pestañas, espacios adicionales Me gusta el café Me gusta el café
removeNumbers Elimina números Tomé 2 tasas de café hace 1 semana Tomé tasas de café hace semana
removeWords Elimina palabras específicas (por ejemplo, tasas y semana) definidas por los analistas Tomé 2 tasas de café hace 1 semana Tomé 2 de café hace 1
stemDocument Reduce prefijos y sufijos en palabras, lo que facilita la agregación de términos. La convención es supranacional La convención es nacional
tw_df <- data.frame(ID=seq(1:nrow(tweets)),text=tweets$text)

Se ha encontrado que la función tolower suele fallar cuando se encuentra con caracteres especiales. Se propone este wrapper:

# Devuelve NA en lugar de un error de la función tolower
tryTolower <- function(x)
{
  # regresa NA cuando hay un error
  y = NA
  # tryCatch error
  try_error = tryCatch(tolower(x), error = function(e) e)
  # si no es error
  if (!inherits(try_error, 'error'))
    y = tolower(x)
  return(y)
}

En cada idioma se suele tener stop words. Las stop words son palabras comunes que a menudo no brindan información adicional, como los artículos (el, la, los, las, etc).

library(tm)
head(stopwords('spanish'))
## [1] "de"  "la"  "que" "el"  "en"  "y"
custom.stopwords <- c(stopwords('spanish'), 'ola')

Aquí se crea una función llamada clean.corpus. Dentro de esta función se puede ver funciones de limpieza específicas: removePunctuation, stripWhitespace, removeNumbers, tryTolower y removeWords.

Tenga en cuenta que se utiliza tm_map, es una función de interfaz para transformar cuerpos completos.

clean.corpus<-function(corpus)
{
  corpus <- tm_map(corpus,content_transformer(tryTolower))
  corpus <- tm_map(corpus, removeWords,custom.stopwords)
  corpus <- tm_map(corpus, removePunctuation,ucp = TRUE)
  corpus <- tm_map(corpus, stripWhitespace)
  corpus <- tm_map(corpus, removeNumbers)
  return(corpus)
}

Antes de aplicar estas funciones de limpieza, se debe definir el objeto de tweets como su corpus o colección de documentos en lenguaje natural.

Notemos que los nombres de las columnas deben ser doc_id y text. Un DataframeSource interpreta cada fila del data.frame como un documento.

names(tw_df) <- c("doc_id","text")
tw_df$text <- chartr("áéíóú", "aeiou", tw_df$text) #quito acentos
tw_df$text <- iconv(tw_df$text,"latin1", "ASCII", sub="") #caracteres especiales
# convert to data frame
corpus <- VCorpus(DataframeSource(tw_df))

Observe que está creando un VCorpus. Este tipo particular de corpus, es un corpus volátil. Esto significa que se mantiene en la memoria RAM de tu computadora. Si cierras R, apagas tu computadora o te quedas sin energía y sin guardar, el VCorpus se pierde, de ahí la volatilidad.

corpus <- clean.corpus(corpus)

Una forma de ver información sobre el objeto corpus es mirar la lista de documentos. Aquí examinas el primer documento dentro del corpus, un tweet en nuestro caso:

as.list(corpus)[1]
## $`1`
## <<PlainTextDocument>>
## Metadata:  7
## Content:  chars: 231

22.5 Textos frecuentes

Creamos un TDM donde las entradas son poderadas por frecuencia (weighting =weightTf):

tdm <- TermDocumentMatrix(corpus,control=list(weighting =weightTf))
tdm.tweets.m <- as.matrix(tdm)
dim(tdm.tweets.m)
## [1] 11075 17968

Tenemos 11075 filas y 17968 columnas.

tdm.tweets.m[1:20,1:10]
##               Docs
## Terms          1 2 3 4 5 6 7 8 9 10
##   |entrevista| 0 0 0 0 0 0 0 0 0  0
##   $$$fumo      0 0 0 0 0 0 0 0 0  0
##   $piece       0 0 0 0 0 0 0 0 0  0
##   aade         0 0 0 0 0 0 0 0 0  0
##   aadio        0 0 0 0 0 0 0 0 0  0
##   abajito      0 0 0 0 0 0 0 0 0  0
##   abajo        0 0 0 0 0 0 0 0 0  0
##   abandonado   0 0 0 0 0 0 0 0 0  0
##   abandonen    0 0 0 0 0 0 0 0 0  0
##   abandono     0 0 0 0 0 0 0 0 0  0
##   abasteciendo 0 0 0 0 0 0 0 0 0  0
##   abcmn        0 0 0 0 0 0 0 0 0  0
##   abcorderoc   0 0 0 0 0 0 0 0 0  0
##   aberracion   0 0 0 0 0 0 0 0 0  0
##   aberrante    0 0 0 0 0 0 0 0 0  0
##   abg          0 0 0 0 0 0 0 0 0  0
##   abierta      0 0 0 0 0 0 0 0 0  0
##   abiertagtgt  0 0 0 0 0 0 0 0 0  0
##   abierto      0 0 0 0 0 0 0 0 0  0
##   abogada      0 0 0 0 0 0 0 0 0  0

Para que pueda resumir la frecuencia de los términos, deberá sumar en cada fila porque cada fila es un término único en el corpus

term.freq <- rowSums(tdm.tweets.m)

Guardamos las frecuencias en un data.frame:

freq.df <- data.frame(word=names(term.freq),frequency=term.freq)

Ordenamos las palabras:

freq.df <- freq.df[order(freq.df[,2], decreasing=T),]
freq.df[1:10,]
##                                            word frequency
## crisiscarcelaria               crisiscarcelaria     17951
## gobierno                               gobierno      4204
## cada                                       cada      3771
## ecuador                                 ecuador      2929
## pais                                       pais      2418
## penitenciariadellitoral penitenciariadellitoral      2160
## personas                               personas      1826
## lasso                                     lasso      1764
## lassoguillermo                   lassoguillermo      1752
## presidente                           presidente      1683

Ejercicio

Realiza la limpieza de texto en los datos de whatsapp

22.6 Asociación

library(ggplot2)
library(ggthemes)
freq.df$word <- factor(freq.df$word,levels=unique(as.character(freq.df$word)))
ggplot(freq.df[1:20,], aes(x=word,y=frequency))+geom_bar(stat="identity", fill='darkred')+coord_flip()+theme_gdocs()+ geom_text(aes(label=frequency), colour="white",hjust=1.25, size=2.0)

Dado que el análisis de asociación se limita a palabras específicas interesantes del análisis de frecuencia, es de esperar que no esté buscando asociaciones que produzcan un resultado no revelador.

Dado que todas las palabras tendrían alguna palabra asociativa, es posible que mirar los valores atípicos no sea apropiado y, por lo tanto, el análisis de frecuencia generalmente se realiza primero.

En el siguiente código, buscamos palabras altamente asociadas mayores que \(0.2\).

associations <- findAssocs(tdm, 'lasso', 0.2)
associations <- as.data.frame(associations)
associations$terms <- row.names(associations)
associations$terms <- factor(associations$terms, levels=associations$terms)
ggplot(associations, aes(y=terms)) + geom_point(aes(x=lasso), data=associations, size=2)+ theme_gdocs()+ geom_text(aes(x=lasso, label=lasso), colour="darkred",hjust=-.25,size=3)+ theme(text=element_text(size=5), axis.title.y=element_blank())

Ejercicio

Encuentra las palabras asociadas con lasso en los datos de whatsapp con un umbral mínimo de 0.2.

22.7 Redes

Otra forma de ver las conexiones de palabras es tratarlas como una estructura de red o gráfica.

Una advertencia: estos pueden volverse densos y difíciles de interpretar visualmente.

Filtramos los tweets que incluyen la palabra Lasso:

library(igraph)
lasso <- tw_df[grep("lasso", tw_df$text,
ignore.case=T), ]
lasso <- lasso[1:2,]

Creamos el corpus:

corpus_lasso <- VCorpus(DataframeSource(lasso))
corpus_lasso <- clean.corpus(corpus_lasso)
tdm_lasso <- TermDocumentMatrix(corpus_lasso,control=list(weighting =weightTf))
lasso.m <- as.matrix(tdm_lasso)

A continuación, necesitamos crear una matriz de adyacencia, que es una matriz simple con los mismos nombres de fila y columna, haciéndola cuadrada. En las intersecciones, hay un operador binario, 1 o 0, que muestra una conexión o no.

library(igraph)
lasso.adj <- lasso.m %*% t(lasso.m)
lasso.adj <- graph.adjacency(lasso.adj, weighted=TRUE,mode="undirected", diag=T)
## Warning: `graph.adjacency()` was deprecated in igraph 2.0.0.
## ℹ Please use `graph_from_adjacency_matrix()` instead.
## This warning is displayed once every 8 hours.
## Call `lifecycle::last_lifecycle_warnings()` to see where this warning was generated.
lasso.adj <- simplify(lasso.adj)
plot.igraph(lasso.adj, vertex.shape="none", vertex.label.font=2, vertex.label.color="darkred", vertex.label.cex=.5, edge.color="gray85")
title(main='Lasso Network')

Ejercicio

Usando la misma configuración anterior, encuentra el grafo de asociación de la palabra lasso usando redes en los datos de whatsapp.

22.8 Dendograma

La función removeSparseTerms nos permite omitir valores con muchos ceros. El parámetro sparse indica un valor numérico para la dispersión máxima permitida en el rango de cero (no incluido) a uno (no incluido).

tdm2 <- removeSparseTerms(tdm, sparse=0.9)

Aplicamos el histograma sobre la matriz de distancias:

hc <- hclust(dist(tdm2, method="euclidean"),
method="average")

Visualizamos el histograma:

plot(hc,yaxt='n', main='@CrisisCarcelaria')

Ejercicio

Usando la misma configuración anterior, encuentra el dendograma de asociación de la palabra lasso con los datos de whatsapp.

22.9 Nube de palabras

Otra visualización común se llama nube de palabras o nube de etiquetas. Generalmente, una nube de palabras es una visualización basada en la frecuencia. En una nube de palabras, las palabras se representan con diferentes tamaños de fuente.

library(wordcloud)
## 
## Attaching package: 'wordcloud'
## The following object is masked from 'package:gplots':
## 
##     textplot
head(freq.df)
##                                            word frequency
## crisiscarcelaria               crisiscarcelaria     17951
## gobierno                               gobierno      4204
## cada                                       cada      3771
## ecuador                                 ecuador      2929
## pais                                       pais      2418
## penitenciariadellitoral penitenciariadellitoral      2160
wordcloud(freq.df$word,freq.df$frequency, max.words =
50, min.freq=500,scale=c(3,.8),
colors=palette())

Ejercicio

Usando la misma configuración anterior, encuentra una nube de palabras para los datos de whatsapp.