Tekst classificatie 2: machine learning met Naive Bayes

Tekst classificatie 2: machine learning met Naive Bayes

Stel, je werkt als marketing analist bij een grote online retailer. Om te weten te komen hoe klanten over je denken, vraag je ze geregeld om inhoudelijke feedback. Je kan iedere beoordeling natuurlijk inhoudelijk lezen (en dat zou je eigenlijk ook moeten doen als klanten de moeite nemen je feedback te geven), maar misschien wil je uiteindelijk toch weten of je klanten overall tevreden met je dienstverlening zijn of niet. Dan is het handig om een manier te hebben om reviews automatisch te classificeren. Dat is precies wat ik in deze post ga doen – op basis van 4000 openbare reviews die al wel geclassificeerd zijn, ga ik een classifier trainen en testen die kan voorspellen of een review positief of negatief is.

Naive Bayes
Voor deze post gebruik ik een Naive Bayes classifier. Naive Bayes is een methode om de kans dat iets gebeurt te berekenen (of in dit geval, de kans dat een tekst positief of negatief is), op basis van kennis over voorgaande keren dat hetzelfde gebeurde. Naive Bayes is een simpele vorm van machine learning, maar desondanks presteert het algoritme meestal redelijk goed. Een nadeel is dat het ervan uitgaat dat de variabelen die gebruikt worden onafhankelijk van elkaar zijn – vandaar het woord ‘naive’ -,  wat natuurlijk in tekstclassificatie eigenlijk niet het geval is. Dit verklaart waarom andere algoritmen vaak beter presteren. Het voordeel is echter dat het sneller is om te trainen en te testen dan andere machine learning algoritmes. In de praktijk wordt het gebruikt voor bijvoorbeeld het classificeren van spam. In dit geval ga ik het gebruiken om reviews te classificeren als positief of negatief. In R gebruik ik hiervoor de pakketten e1071 (voor de Naive Bayes functie), dplyr, caret, stringr en tm.

library(e1071)
library(dplyr)
library(tm)
library(stringr)
library(caret)

Data prepareren
Mijn dataset bestaat uit 4200 reviews van 21 bedrijven die ik door middel van webscraping (zie vorige post) heb opgehaald van een reviewsite. Ik heb ervoor gekozen per bedrijf ‘slechts’ 200 reviews op te halen om een gebalanceerde dataset dat verschillende branches dekt, te krijgen. In mijn dataset heb ik 4 variabelen: de datum, de naam van het bedrijf waarover de review gaat, de score en de inhoud van de review. Het eerste wat ik ga doen, is de score ‘3’ uit de dataset halen. Ik ben immers geïnteresseerd of reviews positief of negatief zijn, en een score van 3  is geen van beide.

#scores 3 eruit halen
reviewsdef <- filter(reviews, !rating == "3")

Ik houd 4010 reviews over – blijkbaar waren er slechts 190 reviews met de score 3. Dan creëer ik een nieuwe variabel ‘categorie’, waarin de scores 4 en 5, en 1 en 2 worden samengevoegd in positief en negatief.

#samenvoegen in positief en negatief
reviewsdef$categorie <- NA
reviewsdef$categorie[reviewsdef$rating %in% c("4","5")] <- "Positief"
reviewsdef$categorie[reviewsdef$rating %in% c("1","2")] <- "Negatief"

Voor het gemak filter ik de variabelen die ik niet gebruik eruit, en houd ik alleen de variabel ‘categorie’ en ‘review’ over. Daarna randomiseer ik de dataset en verander ik categorie in een factor-variabel.

#alleen variabelen review en categorie
reviewsdef <- reviewsdef[,c(4,5)]

#randomisatie
set.seed(1)
reviewsdef <- reviewsdef[sample(nrow(reviewsdef)), ]

#factor van categorie
reviewsdef$categorie <- as.factor(reviewsdef$categorie)

Tekstverwerking
Dan ga ik de tekstdata omzetten naar een corpus (uit het tm-package) om de tekst verder te verwerken.

#omzetten naar corpus
corpus <- Corpus(VectorSource(reviewsdef$review))

Ik maak daarna een bestandje met namen van de bedrijven, zodat ik deze ook uit mijn corpus kan filteren. Stel bijvoorbeeld dat ik een bedrijf heb dat 90% slechte reviews heeft waarin de naam veelal genoemd wordt, dan heeft de naam een negatieve connotatie. Positieve reviews over het bedrijf worden dan al als negatief gezien doordat de naam genoemd wordt.

namen <- reviews$naam %>%
unique() %>%
tolower() %>% 
str_replace_all("[.]", " ") %>%
str_replace_all("(?s) .*", "")

De namen filter ik vervolgens uit het corpus, en ik pas enkele standaard cleaning-functies toe.

corpus.clean <- corpus %>%
tm_map(content_transformer(tolower)) %>%
tm_map(removePunctuation) %>%
tm_map(removeWords, namen) %>%
tm_map(removeNumbers) %>%
tm_map(stripWhitespace)

Misschien valt het op dat ik bij het opschonen geen stopwoorden uit het corpus heb gehaald. Ik heb de analyse twee keer uitgevoerd, een keer met en een keer zonder stopwoorden. De classifier met stopwoorden was net iets succesvoller, wellicht omdat woorden als ‘nooit’ of ‘niet’ wel degelijk vaker voorkomen binnen een van de categorieën. Overige woorden zoals ‘dat’ en ‘ik’ zullen in beide categorieën voorkomen en doen dus eigenlijk ook geen kwaad.

Om tekstclassificatie op basis van woorden te doen, moet je de woorden zien als factoren die in sommige teksten wel en in andere niet voorkomen. Daarom is de volgende stap dan ook om de corpus om te zetten naar een DTM – een Document Term Matrix. In dit document veranderen alle woorden in variabelen en wordt per review aangegeven hoe vaak deze woorden voorkomen.

dtm <- DocumentTermMatrix(corpus.clean)

Training- en testdatasets
Vervolgens maak ik training- een testdatasets op basis van een 75-25 verdeling. Dit doe ik zowel voor de data in de Document Term Matrix als wel de originele dataset in het reviewsdef bestand, omdat ik daar de labels van ga gebruiken.

#bestand met labels verdelen in training en test
reviewsdef.train <- reviewsdef[1:3010,]
reviewsdef.test <- reviewsdef[3011:4010,]

#dtm verdelen in training en test
dtm.train <- dtm[1:3010,]
dtm.test <- dtm[3011:4010,]

Het is handig om woorden die in weinig reviews voorkomen uit te sluiten. Als een woord bijvoorbeeld slechts 3 keer voorkomt en altijd in een positieve reviews, dan is op basis van dit woord de kans dat een review positief is ontzettend hoog. Echter komt het woord zo weinig voor, dat je hier voorzichtig mee moet zijn omdat het gewoonweg toeval kan zijn. Ik heb verschillende threshold-waarden tussen 1 en 10 geprobeerd, maar hoe lager, hoe slechter de resultaten van de classifier. Vanaf 10 blijft het ongeveer gelijk, dus ik houd 10 aan als het minimum aantal reviews waarin een woord moet voorkomen.

#maak een lijst met frequente woorden
freqword <- findFreqTerms(dtm.train, 10)

#maak een nieuwe corpus test- en trainset
corpus.clean.train <- corpus.clean[1:3010]
corpus.clean.test <- corpus.clean[3011:4010]

#verander in dtm met alleen frequente woorden
dtm.train <- DocumentTermMatrix(corpus.clean.train, control=list(dictionary = freqword))
dtm.test <- DocumentTermMatrix(corpus.clean.test, control=list(dictionary = freqword))

Boolean Feature Naive Bayes
Ik maak voor deze post gebruik van Boolean Feature Naive Bayes. Dit houdt in dat ik niet kijk naar hoe vaak een woord genoemd wordt, maar alleen naar of een woord genoemd wordt – ja of nee. Dit doe ik omdat het gaat over het sentiment van reviews, en het daarom minder uitmaakt hoe vaak het woord ‘slecht’ voorkomt, maar alleen of het überhaupt in een review voorkomt. Daarom ga ik de variabelen omcoderen naar een 1 of 0.

#omcoderen naar boolean
dtm.train$v[dtm.train$v > 0] <- 1
dtm.test$v[dtm.test$v > 0] <- 1

Trainen en testen
Voor de classifier kan ik niet werken met een dtm, dus zet ik het training- en testbestand eerst om naar een dataframe.

dftrain <- as.data.frame(as.matrix(dtm.train), stringsAsFactors=False)
dftest <- as.data.frame(as.matrix(dtm.test), stringsAsFactors=False)

Dan is het tijd om het model te trainen. Daarvoor gebruik ik het trainings-dataframe dat ik net gemaakt heb en de labels uit het opgesplitste reviewsdef-bestand.

classifier <- naiveBayes(dftrain, reviewsdef.train$categorie, laplace = 1)

Daarna test ik het model. Hier moet R wel even over nadenken, dus het duurt wat langer dan dan de classifier trainen.

pred <- predict(classifier, dftest)

In het pred-bestand kan ik vervolgens zien wat de classifier voorspelt heeft, maar het is handiger om er gelijk een tabel van te maken waarin de voorspellingen vergeleken worden met de daadwerkelijke labels.

table("Predictions"= pred,  "Actual" = reviewsdef.test$categorie )

Het resultaat is de volgende tabel.

##           Actual
##Predictions     Negatief Positief
##Negatief         251       47
##Positief         97         605

Niet slecht dus! Mijn classifier voorspelt de labels in 86% van de gevallen correct. Wil je nog wat meer statistische informatie over je model zoals de Confidence Intervals, dan kun je een confusion matrix maken.

confusionMatrix(pred, reviewsdef.test$categorie)

Onderstaand is een screenshot van het resultaat in R.

Op zich ben ik tevreden met dit model, maar ik ben nog wel benieuwd welke reviews verkeerd geclassificeerd worden. Om de voorspellingen te vergelijken met de daadwerkelijke labels, voeg ik beide bestanden en filter ik op observaties waarvan de labels niet overeenkomen met de voorspelling.

inzage <- reviewsdef.test %>%
cbind(pred) %>%
filter(categorie != pred)

Sommige reviews zijn inderdaad lastig te classificeren. Een negatieve review als “Een bestelling van 2 artikelen is in 3 keer bezorgt” heeft geen duidelijk negatieve woorden (wel een irritante spelfout). En omdat er in mijn trainingsdata meer positieve reviews en dus woorden met positieve connotatie zijn, wordt deze review als positief gezien. Dit lijkt in een groot deel van de verkeerd geclassificeerde reviews het geval. Hier kom je wellicht nooit helemaal vanaf, maar het zou allicht helpen om een betere balans in positieve en negatieve reviews in de trainingsdata te krijgen.

In deze post heb ik laten zien hoe makkelijk het is om in R een tekst classifier te bouwen waarmee je automatisch reviews kan indelen in positief en negatief. Ik hoop dat je er iets aan had, bij vragen/tips kun je me altijd mailen op gibbon@datagibbon.nl.