Análisis de sentimiento de Twitter a tiempo real (App)

Ander Fernández Jauregui.

Ander Fernández Jauregui

Data Scientist y Business Intelligence.

Problema Inicial

En las redes sociales continuamente se generan mucha información. Sin embargo, a pesar del valor que ofrecen los comentarios de clientes en redes sociales, pocas veces es explotado.

En este caso, nos centraremos en analizar Twitter para poder detectar el sentimiento general de los usuarios y poder predecir crisis de reputación.

Cuestiones Involucradas

  • Business Intelligence
    • Análisis de los usuarios (RRSS)
    • Información a tiempo real
  • Natural Lenguage Processing
    • Análisis de Sentimiento
    • Análisis de relación del uso de palabras

Solución

He desarrollado una aplicación que permita analizar a tiempo real el sentimiento de los datos generados por usuarios.

Es una herramienta sencilla que puede usar cualquier persona de la organización, simplemente introduciento el hashtag a analizar, la fecha y el número de tweets a extraer.

Desarrollo del Proyecto

Share on linkedin
Share on twitter
Share on email
Share on linkedin
Share on twitter
Share on email

0. Business Understanding

  • Objetivo de negocio: poder hacer seguimiento a tiempo real de hashtags de Twitter para saber de qué se habla, cómo y quién lo hace.
  • Objetivo de minería: tener el sentimiento (numérico) de cada uno de los Tweets.
  • Project Plan: crear un dashboard que permita analizar el sentimiento global de un tweet.
  • Presunciones:
  • Suponemos que la valoración generada por el “afinn” sentiment del paquete sentiments es correcto.
  • Suponemos que cada persona que ha hecho RT a un tweet, tiene 150 seguidores de promedio.
  • Suponemos que el número de personas que han visto el tweet es igual para todos los tweets: 100%.
  • Herramientas y técnicas:
  • Análisis de sentimiento en base a afinidad de cada Tweet.
  • Efecto multiplicador (spread) de un tweet. Se refiere al alcance que obtiene un tweet, en base a los seguidores de la persona que lo ha publicado y el número de personas que lo han hecho retweet.
  • Wordcloud para conocer la temática general de la que se habla.
library(twitteR) #Par poder obtener los tweets de Twitter
library(httpuv) #Para resolver los problemas de twitteR
library(openssl) #Para resolver los problemas de twitteR
library(tm) #Para editar el texto: quitar puntuación, etc. 
library(tidytext) #Para hacer análisis de sentimiento
library(wordcloud) #Para hacer wordclouds
library(tidyr) #Para poder hacer un unnest tokens
library(dplyr) #Para cuestiones generales
library(ggplot2) #Para gráficos
library(qdap) #Para conjuntos de palabras

1. Data Understanding

1.1 Collect Initial Data

tw = twitteR::searchTwitter('#Netflix  -filter:retweets', lang = "en", n = 1e3, since = '2019-06-20', retryOnRateLimit = 1e3)
d = twitteR::twListToDF(tw)
str(d)
## 'data.frame':    1000 obs. of  16 variables:
##  $ text         : chr  "Our Working Ladies Visa card has the purpose to improve the day-to-day living of women entrepreneurs and help t"| __truncated__ "I've confirmed it now. I did #sleep through pretty much all of #47Ronin when I watched it on #Netflix last nigh"| __truncated__ "Finally watching Always Be My Maybe. Haven't laughed this hard in a while! <U+0001F602><U+0001F602><U+0001F602>"| __truncated__ "Netflix to Open UK Production Hub at Shepperton Studios\nhttps://t.co/NUEYT2zfvN\n#blouinartinfo #blouin #artin"| __truncated__ ...
##  $ favorited    : logi  FALSE FALSE FALSE FALSE FALSE FALSE ...
##  $ favoriteCount: num  0 0 1 0 0 0 0 0 0 0 ...
##  $ replyToSN    : chr  NA NA NA NA ...
##  $ created      : POSIXct, format: "2019-07-11 09:18:40" "2019-07-11 09:13:13" ...
##  $ truncated    : logi  TRUE TRUE FALSE TRUE TRUE TRUE ...
##  $ replyToSID   : chr  NA NA NA NA ...
##  $ id           : chr  "1149246617729605632" "1149245248914239489" "1149244565913817088" "1149244134307356673" ...
##  $ replyToUID   : chr  NA NA NA NA ...
##  $ statusSource : chr  "<a href=\"http://twitter.com/download/iphone\" rel=\"nofollow\">Twitter for iPhone</a>" "<a href=\"http://www.facebook.com/twitter\" rel=\"nofollow\">Facebook</a>" "<a href=\"http://twitter.com\" rel=\"nofollow\">Twitter Web Client</a>" "<a href=\"http://twitter.com\" rel=\"nofollow\">Twitter Web Client</a>" ...
##  $ screenName   : chr  "workingladieshb" "jboothmillard" "njmvondo" "Artinfo_ME" ...
##  $ retweetCount : num  0 0 0 0 1 0 0 0 0 0 ...
##  $ isRetweet    : logi  FALSE FALSE FALSE FALSE FALSE FALSE ...
##  $ retweeted    : logi  FALSE FALSE FALSE FALSE FALSE FALSE ...
##  $ longitude    : chr  NA NA NA NA ...
##  $ latitude     : chr  NA NA NA NA ...

La información que tenemos podría clasificarse en dos grupos:

  • Información del usuario: Id y nombre del usuario.
  • Información del tweet: texto, si ha sido likeado o dado a fav o no y cuánto, puntos longitunidales desde donde se ha hecho, etc.

Como la información la proporciona la API de Twitter, consideramos que es verídica.

2. Data Clening

2.1 Select Data

Para nosotros las observaciones más importantes son: el texto, el número de retweets y favs que ha obtenido y quién lo ha hecho. Por tanto, eliminamos el resto de columnas.

d <- d %>% select(screenName, text, favoriteCount, retweetCount)

2.2 Data Clean

Analizamos los diez primeros textos:

d$text[1:10]
##  [1] "Our Working Ladies Visa card has the purpose to improve the day-to-day living of women entrepreneurs and help them… https://t.co/89xHR4njQh"   
##  [2] "I've confirmed it now. I did #sleep through pretty much all of #47Ronin when I watched it on #Netflix last night. I… https://t.co/Zfx0gKgXN6"  
##  [3] "Finally watching Always Be My Maybe. Haven't laughed this hard in a while! <U+0001F602><U+0001F602><U+0001F602>\n#AliWong #RandallPark #KeanuReeves #RomCom #Netflix"
##  [4] "Netflix to Open UK Production Hub at Shepperton Studios\nhttps://t.co/NUEYT2zfvN\n#blouinartinfo #blouin #artinfo… https://t.co/hJYqPAfRgD"    
##  [5] "<U+26A0><U+FE0F> • _tenbatsu Stranger Things. Been binge watching this. I really love it so far on 1st Season. <U+0001F44D><U+0001F44D> #photography… https://t.co/xksEGSOWUu"
##  [6] "Tonight, I watched \"Murder Mystery\" starring Jennifer Aniston and Adam Sandler. A fun whodunnit with a splash of in… https://t.co/5o1rohfpUB"
##  [7] "What should i watch next on #Netflix? @netflix  what's good? I need drama, debauchery, callousness, and filth. What ya got?"                   
##  [8] "Yes ma'am @TiaMowry  #FamilyReunion is a must watch. It's more than just a comedy. It's real issues being talked ab… https://t.co/nwh6ySN5n5"  
##  [9] "Couldn’t think of anything worse than being in a relationship or marriage with the dynamic of Alba and Jorge but Ja… https://t.co/PGCo6oloAC"  
## [10] "@MissJackx at this rate we may need a full #Netflix series on just the Haas vs Rich energy drama #F1 https://t.co/9E57klNBcI"

De cara a limpiar el texto, realizaremos las siguientes transformaciones:

  • Eliminar los hashtags, porque al buscar por hashtag, todos los tweets tendrán el mismo hashtag.
  • Eliminar los links.
  • Eliminar los emojis.
  • Los usuarios, puesto que los tweets pueden contener menciones a otros usuarios.
d$text<- gsub("\\#[A-z]*","", d$text) #Eliminamos los hashtags
d$text <- gsub("[A-z]*\\:{1}\\/*[A-z]*\\.[A-z]*\\/*[A-z0-9]*\\s*","", d$text)  #Eliminamos los links
d$text <- gsub("\n*","", d$text) #Eliminamos el \n
d$text <- gsub("\\p{So}|\\p{Cn}","", d$text, perl=TRUE) #Eliminamos los emoji
d$text <- gsub("\\@[A-z0-9]*","", d$text) #Eliminamos los usuarios
d$text <- gsub("\\<U*[[:punct:]][A-z0-9]*>","", d$text) #Eliminamos caracteres raros

Volvemos a analizar los textos para ver cómo han cambiado:

d$text[1:10]
##  [1] "Our Working Ladies Visa card has the purpose to improve the day-to-day living of women entrepreneurs and help them… "   
##  [2] "I've confirmed it now. I did  through pretty much all of 47Ronin when I watched it on  last night. I… "                 
##  [3] "Finally watching Always Be My Maybe. Haven't laughed this hard in a while!     "                                        
##  [4] "Netflix to Open UK Production Hub at Shepperton Studios… "                                                              
##  [5] "<U+FE0F> • _tenbatsu Stranger Things. Been binge watching this. I really love it so far on 1st Season.  … "             
##  [6] "Tonight, I watched \"Murder Mystery\" starring Jennifer Aniston and Adam Sandler. A fun whodunnit with a splash of in… "
##  [7] "What should i watch next on ?   what's good? I need drama, debauchery, callousness, and filth. What ya got?"            
##  [8] "Yes ma'am    is a must watch. It's more than just a comedy. It's real issues being talked ab… "                         
##  [9] "Couldn’t think of anything worse than being in a relationship or marriage with the dynamic of Alba and Jorge but Ja… "  
## [10] " at this rate we may need a full  series on just the Haas vs Rich energy drama 1 "

Para poder analizar correctamente el texto, aplicamos otras transformaciones, como quitar signos de puntuación, poner todo en mínúsculasy y eliminar espacios sobrantes.

d$text <- tolower(d$text)
d$text <- removePunctuation(d$text)
d$text <- stripWhitespace(d$text)

Por último, deberíamos eliminar las stopwords, es decir, aquellas palabras que no aportan valor a nuestro análisis y que son muy comunes en el lenguaje, como “I”, “she”, “is”, entre muchas otras. Si lo hiciésemos ahora, deberiamos usar la funcíón multigsub, la cual no es muy eficiente a nivel computacional.

En su lugar, podríamos esperar a realizar el unnest (separación de frases en palabras) y usar la función filter del paquete dplyr, que es más eficiente. Por tanto, optamos por esta segunda opción.

Lo que sí vamos a hacer es dejar definido un vector que incluya todas las stopwords.

stop_words <- stopwords("en")
stop_words[1:10]
##  [1] "i"         "me"        "my"        "myself"    "we"       
##  [6] "our"       "ours"      "ourselves" "you"       "your"

Format Data

El dataset final que queremos obtener es uno en el que cada observación es un tweet y tengamos, adémás de la información básica que ya tenemos, el sentimiento general por afinidad de ese tweet.

Para obtener esa base de datos, lo primero que debemos hacer es hacer un unnest de los tokens. Sin embargo, esto puede generar problemas, ya que si hacemos el unnest por monogramas, estaremos perdiendo el sentido de algunas palabras.

Ejemplo: el bigrama “nada bueno” se dividiría en dos monogramas, “nada” + “bueno”. Cada uno de estos monogramas tendría un sentimiento por afinidad, supongamos que 0 para nada y 1 para bueno. Por tanto, la afinidad global de ese tweet sería de 1. Sin embargo, es claro que en el fondo debería ser -1, puesto que la palabra “nada” cambia el sentido positivo inherente de la palabra bueno.

Para evitar que esto ocurra, romperemos los textos en bigramas, que a su vez separaremos en dos columnas. De esta forma, obtendremos el sentimiento para la segunda palabra de cada bigrama y cambiaremos el signo del sentimiento de aquellas palabras que estén precedidas (primera columna), de palabras negativas (no, nada, nunca).

Empezamos por tanto, haciendo el unnest en bigramas de los textos:

d$bigramas <- NA
d$text2 <- d$text
d <- unnest_tokens(d ,output = bigramas,input = text2, token = "ngrams", n = 2 )
d$bigramas[1:20]
##  [1] "i’m addicted"  "addicted to"   "can you"       "you please"   
##  [5] "please fix"    "fix this"      "this problem"  "problem for"  
##  [9] "for me"        "me i"          "i tried"       "tried over"   
## [13] "over manyyy"   "manyyy titles" "titles but"    "but the"      
## [17] "the problem"   "problem is"    "is still"      "still on"

Ahora que tenemos los bigramas, vamos a dividirlos en dos columnas, de tal manera que tengamos a cada palabra del bigrama en una columna y podamos “operar” con ello.

d <- separate(d, bigramas, into = c("palabra1", "palabra2"), sep = " ", remove = FALSE)
d[1:5, 6:7]

#palabra1palabra2
1canyou
2youplease
3pleasefix
4fixthis
5thisproblem

De cara al análisis, es importante que no “premiemos” a aquellas personas que escriben más palabras, puesto que, en proporción puede ser más positivo/negativo un tweet muy hiriente de 5 palabras que uno “poco” hiriente de 30. Por tanto, tenemos que medir el sentimiento medio por tweet, no el sentimiento total.

Para ello, vamos a obtener el número de palabras (limpias) que ha dicho cada usuario en el tweet. De esta forma podremos obtener un sentimiento medio por palabra que evite que favorezcamos el hecho de escribir más palabras. Por tanto:

  1. Obtenemos el número de palabras que dice cada usuario
  2. Añadimos esa información a nuestro datast.
d_palabras <- d%>%
  group_by(screenName) %>%
  summarize(n_palabras = n())

d<- left_join(d, d_palabras)

Por otro lado, tenemos que obtener el sentimiento en base a afinidad. Eso lo haremos en base a la función get_sentiment del paquete tidytext. Usaremos el lexicon “afinn” puesto que es el que aporta la afinidad como valor numérico.

Como no hay sentimiento para todas las palabras (ni mucho menos), haremos un inner join para únicamente quedarnos con aquellas que sí aportan sentimiento.

sentimiento <- get_sentiments("afinn")
d <- inner_join(d, sentimiento, by = c("palabra2" = "word") )

Ahora vamos a corregir el posible error que genera el análisis de sentimiento por monograma, cambiando de signo a aquellas palabras precedidas por “not”, “no”, “never”, etc.

palabras_negativas <- c("no","not","never","without")

for(i in 1:length(d$screenName)){
if(d$palabra1[i] %in% palabras_negativas){
  d$score[i] <- d$score[i] * -1 
  i = i + 1
}
}

Modeling

Ahora que ya tenemos el dataset limpio, vamos a proceder a realizar los diferentes análisis. En la medida de lo posible usaremos el paquete dplyr por su velocidad de computación.

Wordcloud: palabras más usadas

Mediante el paquete dplyr, podemos agrupar las palabras, filtrar para excluir las stopwords y hacer con el resultado una nube de palabras para ver cuáles son las palabras que más se repiten, que puede ser de gran utilidad si lo metemos en un dashboard.

d %>% 
  filter(!palabra1 %in% stop_words) %>%
  group_by(palabra1) %>%
  summarize(n_veces = n()) %>%
  arrange(desc(n_veces)) %>%
  with(wordcloud(palabra1, n_veces, min.freq = 10, max.words = 100, rot.per = 0.35, random.order = FALSE, colors=brewer.pal(8, "Dark2"), fixed.asp = TRUE))

Vemos que las palabras de las que más se habla en este caso tiene mucho sentido: game, season, thrones, jon, night… Sin embargo, esto solo muestra las palabras que más se muestran, pero, ¿cumplen algún patrón? Lo veremos de forma gráfica mediante un análisis de bigramas.

Análisis de bigramas

Consiste en agrupar las palabras por su frecuencia de uso común. De esta forma podemos ver los distintos temas que se tratan en los tweets y cómo se relacionan entre ellos.

library(igraph)
library(ggraph)
prueba <- d %>%
  filter(!palabra1 %in% stop_words | !palabra2 %in% stop_words | !is.na(palabra1) | !is.na(palabra2)) %>%
  group_by(palabra1, palabra2)%>%
  summarize(n = n()) %>%
  filter(n > 1) %>%
  graph_from_data_frame() 

ggraph(prueba, layout = "fr") +
  geom_edge_link() +
  geom_node_point() +
  geom_node_text(aes(label = name), vjust = 1, hjust = 1)

Top 5 tweets positivos y negativos

Con esta corrección, ya podemos obtener métricas interesantes como los 5 tweets más positivos. En ambos casos vamos a filtrar los tweets para quedarnos únicamente con aquellos que tienen 3 o más palabras. De esta forma nos deshacemos de tweets que por muy positivos que sean, son poco relevantes.

usuario_texto <- d %>% select(screenName, text)

d %>%
  filter(n_palabras>3) %>%
  group_by(screenName, n_palabras) %>%
  summarize(sentimiento = sum(score)) %>%
  mutate(sentimiento = sentimiento + 0.01) %>%
  summarize(sentimiento_medio = sentimiento/n_palabras) %>%
  arrange(desc(sentimiento_medio)) %>%
  head(10)%>%
  left_join(usuario_texto) %>%
  distinct(screenName, sentimiento_medio, text)

#screenNamesentimiento_mediotext
1Anushka11051.4300000its terrible like really terrible
2judithjewel_1.0025000ah shit here we go again
3MalkinitHappen0.8190909fuck your fucking product placement you sad fucking gluttons for dollars also get your cigarettes and brand vodka
4_PINK_tomboy0.8020000record of grancrest war
5JaxCantor0.8020000have also recently been on a binge from watching jackass 3 and jackass 35 movies on and…

 

Inversamente, podemos obtener los mayores “haters”

d %>%
  filter(n_palabras>3) %>%
  group_by(screenName, n_palabras) %>%
  summarize(sentimiento = sum(score)) %>%
  mutate(sentimiento = sentimiento + 0.01) %>%
  summarize(sentimiento_medio = sentimiento/n_palabras) %>%
  arrange(sentimiento_medio) %>%
  head(10)%>%
  left_join(usuario_texto) %>%
  distinct(screenName, sentimiento_medio, text)

#screenNamesentimiento_mediotext
1BeeBreezy10-0.9975000its terrible like really terrible
2GOAT_JZ4-0.7980000ah shit here we go again
3ibwatson-0.5876471fuck your fucking product placement you sad fucking gluttons for dollars also get your cigarettes and brand vodka
4ChrisJust420-0.5700000record of grancrest war
5Trillo7359-0.4993750have also recently been on a binge from watching jackass 3 and jackass 35 movies on and…


Asimismo, también podemos hacer un gráfico para ver cómo se distribuye el sentimiento de los usuarios

d %>%
  group_by(screenName) %>%
  summarize(sentimiento_medio = sum((score +0.01)/n_palabras)) %>%
  ggplot(aes(sentimiento_medio)) + geom_histogram(bins = 100) + labs(title = "Distribución del Sentimiento Medio", x = "Sentimiento Medio", y= "Nº de Apariciones")