For this, we broadly need three components. First is of course access to Twitter API and the ability to collect data from it. For this, we use excellent TwitteR package. Here is one tutorial on how to set it up.
Second, we want an algorithm for estimating expressed sentiment. Since the R's sentiment package which did all this for us is apparently now defunct, we can either access one of the many sentiment analysis APIs on the Internet or create our own classification algorithm. For this purpose, I will loan the ideas of Jeffrey Breen and an opinion lexicon from Hu and Liu (also used by Jeffrey). We don't concern ourselves so much with the quality of individual classifications but the overall sentiment expressed in the tweets. For this we will calculate a positivity index.
Thirdly, some datetime functions to limit the tweets to be analysed to a pre-defined time frame is required. For this example I select only the tweets tweeted by official accounts of EPL clubs during the match itself and two hours afterwards. (the reason to download only official tweets from clubs is the fact that keyword -based search produces far too noisy data as supporters of a club, those likely to feel based on the scoreline, are hard to filter from the great mass of tweets. Although, as we shall see, official tweets of EPL clubs contain about as much information as interviews of managers)
We will mash all this functionality together to define a single function that is easy to call for different clubs. Functions are in R what macros are in SAS, allowing to run same batch of code over and over again dynamically by changing few parameters. Of course, R functions are more than that and all in all more flexible than SAS macros but let's not get into that on any deeper level.
Ok, lets begin! Again we start with loading the required libraries.
library(twitteR)Twitter is to access the API of, well you know. Stringr is mainly needed for one function in order to dissect tweets into individual words to be matched to opinion lexicons. Dplyr we can't live without and lubridate provides the function to work with datetime columns.
library(stringr)
library(dplyr)
library(lubridate)
The function we define is as follows. We will dissect that beast to smaller sections below:
tweetsentiment <- function(user, n, dateday, datemonth, hourstart, hourend, minutestart, minuteend) {
tweets <- userTimeline(user,n=n)
tweetdf <- twListToDF(tweets)
tweetdf <- filter(tweetdf, month(created) == datemonth)
tweetdf <- filter(tweetdf, day(created) == dateday)
tweetdf <- filter(tweetdf, hour(created) >= hourstart &hour(created) <= hourend)
tweetdf <- filter(tweetdf, !(hour(created) == hourstart & minute(created) < minutestart)
& !(hour(created) == hourend & minute(created) > minuteend) )
tweetdf$text = gsub("[[:punct:]]", "", tweetdf$text)
tweetdf$text = gsub("[[:digit:]]", "", tweetdf$text)
tweetdf$text <- tolower(tweetdf$text)
pos <- scan('C:/data/positive-words.txt',
what='character', comment.char=';')
neg <- scan('C:/data/negative-words.txt',
what='character', comment.char=';')
tweetdf$score <- 0
for(i in 1:length(tweetdf$text)) {
words = str_split(tweetdf$text[i], '\\s+')
words = unlist(words)
pos.matches = match(words, pos)
neg.matches = match(words, neg)
pos.matches = !is.na(pos.matches)
neg.matches = !is.na(neg.matches)
tweetdf$score[i] = sum(pos.matches) - sum(neg.matches)
}
tweetdf$class[tweetdf$score < 0] <- "negative"
tweetdf$class[tweetdf$score == 0] <- "neutral"
tweetdf$class[tweetdf$score > 0] <- "positive"
x <- table(tweetdf$class)
x <- as.matrix(x)
y <- (x[3,]-x[1,])/colSums(x)
returnlist <- list(y, tweetdf)
return(returnlist)
}
Ok, lets start diving into individual pieces:
tweetsentiment <- function(user, n, dateday, datemonth, hourstart, hourend, minutestart, minuteend) {
The code starts with a function definition ending with curly brackets. Here we define the name and the parameters of the function. At the end of the function we obviously need to close the curly brackets.
tweets <- userTimeline(user,n=n)In this portion we access the Twitter API with userTimeline function, feeding it parameters user and n in the function call. Then we create a dataframe out of the resulting object.
tweetdf <- twListToDF(tweets)
tweetdf <- filter(tweetdf, month(created) == datemonth)
tweetdf <- filter(tweetdf, day(created) == dateday)
tweetdf <- filter(tweetdf, hour(created) >= hourstart &hour(created) <= hourend)
tweetdf <- filter(tweetdf, !(hour(created) == hourstart & minute(created) < minutestart)
& !(hour(created) == hourend & minute(created) > minuteend) )
Next, we use a bunch of filter-statements from dplyr to narrow down the resulting time frame, using the datetime parameters specified in the function call. We begin by selecting the correct month, then day and then hours. Finally we exclude the rows based on starting and ending minutes knowledge. Lubridate functions are conveniently named and it is easy to figure which datetime unit specific functions return.
tweetdf$text = gsub("[[:punct:]]", "", tweetdf$text)This portion is much the same as in Jeffrey's example. Text-field is slightly cleaned, opinion lexicons are loaded and sentiment score is calculated by matching words used in the tweets to two opinion lexicons. To bring atleast some of my own flair, I did the scoring via a for-loop.
tweetdf$text = gsub("[[:digit:]]", "", tweetdf$text)
tweetdf$text <- tolower(tweetdf$text)
pos <- scan('C:/data/positive-words.txt',
what='character', comment.char=';')
neg <- scan('C:/data/negative-words.txt',
what='character', comment.char=';')
tweetdf$score <- 0
for(i in 1:length(tweetdf$text)) {
words = str_split(tweetdf$text[i], '\\s+')
words = unlist(words)
pos.matches = match(words, pos)
neg.matches = match(words, neg)
pos.matches = !is.na(pos.matches)
neg.matches = !is.na(neg.matches)
tweetdf$score[i] = sum(pos.matches) - sum(neg.matches)
}
tweetdf$class[tweetdf$score < 0] <- "negative"In the final portion we first classify any sentiment scores below 0 as negative, above 0 as positive and those exactly 0 as neutral. This is done via the somewhat unintuitive base R syntax.
tweetdf$class[tweetdf$score == 0] <- "neutral"
tweetdf$class[tweetdf$score > 0] <- "positive"
x <- table(tweetdf$class)
x <- as.matrix(x)
y <- (x[3,]-x[1,])/colSums(x)
returnlist <- list(y, tweetdf)
return(returnlist)
}
Next we calculate an index which I call the "positivity index". It is one simple answer to the problem of presenting classified data in a simple, numeric format to gauge at the general feeling. It can get values from -1 (all tweets classified as "negative" to 1 (all tweets classified as "positive"), with 0 possibly meaning both "all tweets classified as neutral" or "amount of tweets classified as negative equals the amount of tweets classified as positive". The index has a tendency to hover near zero just because of the latter quality but it still serves a purpose of quantifying classification data.
Without explicitly returning the dataframe or other resulting temporary help objects the function works as a "black box", with all the resulting objects vanishing into the thin air. This is not always desirable as we might want to check out the raw data or processed data from different phases of the function, especially in testing phase. In this case we might want to see individual examples of classification, to gauge at the quality of our algorithm.
So, I want to return the dataframe with tweets classified into either positive, neutral or negative. But I also want the positivity index already calculated for me inside the function. This means that I want to return two objects. In R, if we want to return more than one object, we need to use a list. This is why we save the positivity index to vector y and save that along with tweetdf to returnlist, which we pass to return-function. This makes the function to return a list, with first slot occupied by the positivity index and second the data frame with all the tweets classified. When calling the function, we should want to save the results to an object as well.
Ok, now we have a new toy to play with. Let's try it out! We pass to the function call the parameters user, the amount of tweets we collect, and the date-parameters to limit the final dataframe to be analysed to tweets made during and soon after the match.
results <- tweetsentiment("@arsenal",500,26,12,19,23,45,45)We begin with the most surprising result of the match day, Southampton's 4-0 trashing of title contenders Arsenal. As a team looking to leapfrog Leicester to pole position, having your hopes crushed by such a rubbing must result in some pretty negative tweets, right?
results2 <- tweetsentiment("@southamptonfc",500,26,12,19,23,45,45)
results[1]
0.1428571results2[1]
0.2909091
Nooot exactly. Arsenal's official twitter account receives a positivity index of 0.14, as opposed to 0.29 of Southampton's. Southampton's official tweeter is obviously more thrilled but still Arsenal manages to be surprisingly positive. This means that our very simple and naive sentiment classifier doesn't quite work up to 100 % accuracy and that the official Twitter accounts are not exactly the forum to vent the frustration.
Lets try it out the second time:
results <- tweetsentiment("@manutd",500,26,12,12,16,45,45)
results2 <- tweetsentiment("@stokecity",500,26,12,12,16,45,45)
Manchester United slumped to another defeat at the hands of Stoke City, resulting in the most successive defeats in over 40 years. This put Louis van Gaal's position as the manager on a very insecure footing and sacking can be thought to be coming in the very near future. Manchester United should then omit little positive signals while Stoke should celebrate the defeat of the fallen giant of English football. results[1]Again, we notice a clear difference in sentiments. Manchester United is overall nearly neutral while Stoke's positivity index is a solid 0.19. Not quite the ecstasy exhibited by Southampton but pretty high still.
0.03703704
results2[1]
0.1935484
Overall, based on this small sample, we can be reasonably satisfied with our effort. Our naive classifier seems to find a signal amid all the noise and meaningless football jargon. And our function possessess enough flexibility to allow quick analysis of match and post-match tweets by certain users. And it would be reasonably simple to add another parameter to the function to control an if-else statement, which could then be used to control whether we want the download the tweets of specific user or search for a specific keyword.
