In questo progetto abbiamo voluto analizzare le recensioni degli ultimi 4 anni scritte dai clienti della British Airlines. Il nostro obiettivo è quello di applicare delle tecniche di Text Mining per poter confrontare , raggrupare e individuare il significato che si cela dietro ogni recensione.
library(tm)
## Warning: il pacchetto 'tm' è stato creato con R versione 4.3.3
## Caricamento del pacchetto richiesto: NLP
library(tidyr)
library(tidyselect)
library(tidyverse)
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr 1.1.3 ✔ purrr 1.0.2
## ✔ forcats 1.0.0 ✔ readr 2.1.4
## ✔ ggplot2 3.4.4 ✔ stringr 1.5.1
## ✔ lubridate 1.9.3 ✔ tibble 3.2.1
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ ggplot2::annotate() masks NLP::annotate()
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag() masks stats::lag()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(gridExtra)
##
## Caricamento pacchetto: 'gridExtra'
##
## Il seguente oggetto è mascherato da 'package:dplyr':
##
## combine
library(lubridate)
library(ggplot2)
library(SnowballC)
library(wordcloud)
## Warning: il pacchetto 'wordcloud' è stato creato con R versione 4.3.3
## Caricamento del pacchetto richiesto: RColorBrewer
library(tidytext)
## Warning: il pacchetto 'tidytext' è stato creato con R versione 4.3.3
library(knitr)
Abbiamo lavorato utilizzando il dataset BA_AirlineReviews.csv ottenuto dalla piattaforma Kaggle (https://www.kaggle.com/code/tanshihjen/textanalysis-airlinereviews?rvi=1)
aerei <- read.csv("C:/Users/Utente/Desktop/LUCA/università/TERZO_ANNO/DATA MINING/text_mining/BA_AirlineReviews.csv",header=TRUE, sep=",")
aerei<- aerei[,-1] #rimuovo variabile X
summary(aerei)
## OverallRating ReviewHeader Name Datetime
## Min. : 1.000 Length:3701 Length:3701 Length:3701
## 1st Qu.: 2.000 Class :character Class :character Class :character
## Median : 4.000 Mode :character Mode :character Mode :character
## Mean : 4.734
## 3rd Qu.: 8.000
## Max. :10.000
## NA's :5
## VerifiedReview ReviewBody TypeOfTraveller SeatType
## Length:3701 Length:3701 Length:3701 Length:3701
## Class :character Class :character Class :character Class :character
## Mode :character Mode :character Mode :character Mode :character
##
##
##
##
## Route DateFlown SeatComfort CabinStaffService
## Length:3701 Length:3701 Min. :1.000 Min. :1.000
## Class :character Class :character 1st Qu.:2.000 1st Qu.:2.000
## Mode :character Mode :character Median :3.000 Median :3.000
## Mean :2.875 Mean :3.254
## 3rd Qu.:4.000 3rd Qu.:5.000
## Max. :5.000 Max. :5.000
## NA's :116 NA's :127
## GroundService ValueForMoney Recommended Aircraft
## Min. :1.000 Length:3701 Length:3701 Length:3701
## 1st Qu.:1.000 Class :character Class :character Class :character
## Median :3.000 Mode :character Mode :character Mode :character
## Mean :2.784
## 3rd Qu.:4.000
## Max. :5.000
## NA's :846
## Food.Beverages InflightEntertainment Wifi.Connectivity
## Length:3701 Length:3701 Min. :1.000
## Class :character Class :character 1st Qu.:1.000
## Mode :character Mode :character Median :1.000
## Mean :1.925
## 3rd Qu.:3.000
## Max. :5.000
## NA's :3092
Sono presenti 19 variabili totali, nelle ultime 9 sono compresi i voti che il cliente ha espresso riguardo i diversi confort del volo. Per l’analisi teniamo in considerazione le seguenti variabili: OverallRating, ReviewHeader, ReviewBody, Datetime, TypeOfTraveller.
Consideriamo solamente gli utlimi 4 anni, per farlo bisogna cambiare il formato della data
aerei= as_tibble(aerei) #lo converto in tibble
aerei= aerei %>%
select( OverallRating, ReviewHeader, ReviewBody, Datetime, TypeOfTraveller)
# Rimuove le parti "th", "st", "rd", e "nd" dalle stringhe delle date
aerei$Datetime <- gsub("(\\d+)(th|st|rd|nd) ", "\\1 ", aerei$Datetime)
# Converte le date in formato data utilizzando lubridate
aerei$Datetime <- dmy(aerei$Datetime)
#consideriamo solo gli anni dal 2020 in poi:
aerei= aerei[year(aerei$Datetime) >= 2020, ]
aerei #658 recensioni
## # A tibble: 658 × 5
## OverallRating ReviewHeader ReviewBody Datetime TypeOfTraveller
## <int> <chr> <chr> <date> <chr>
## 1 1 "\"Service level far wor… 4 Hours b… 2023-11-19 Couple Leisure
## 2 3 "\"do not upgrade member… I recentl… 2023-11-19 Business
## 3 8 "\"Flight was smooth and… Boarded o… 2023-11-16 Couple Leisure
## 4 1 "\"Absolutely hopeless a… 5 days be… 2023-11-16 Couple Leisure
## 5 1 "\"Customer Service is n… We travel… 2023-11-14 Couple Leisure
## 6 1 "\"I can’t imagine a wor… Booked a … 2023-11-12 Solo Leisure
## 7 8 "\"sufficient leg and ar… Booked on… 2023-11-08 Couple Leisure
## 8 7 "“crew were polite”" The fligh… 2023-11-07 Solo Leisure
## 9 2 "\"Angry, disappointed, … Angry, di… 2023-11-05 Family Leisure
## 10 3 "\"BA now stands for Bas… As an inf… 2023-11-05 Couple Leisure
## # ℹ 648 more rows
Si crea un Virtual Corpus (VCorpus) al fine di applicare le funzioni disponibili per il Text Cleaning contenute nella library(tm) senza però caricare i documenti completamente in memoria ma caricarli solo quando sono necessari per l’analisi. Per la creazione del VCorpus bisogna creare una variabile chiave denominato doc_id e il testo deve essere contenuto in una variabile di nome (‘text’)
aerei$doc_id= sample(10000:99999, nrow(aerei), replace = F)
#per creare un id di 5 cifre in modo semplice si estrae casualmente senza reinserimento #da 10000 a 99999
aerei= aerei %>% rename (text = ReviewBody) #modifico il nome della variabile #ReviewBody in text
corpus_aerei= DataframeSource(aerei)
corpus_aerei= VCorpus(corpus_aerei) #creazione del corpus virtuale
Di seguito viene stampata la prima recensione
content(corpus_aerei[[1]])
## [1] "4 Hours before takeoff we received a Mail stating a cryptic message that there are disruptions to be expected as there is a limit on how many planes can leave at the same time. So did the capacity of the Heathrow Airport really hit British Airways by surprise, 4h before departure? Anyhow - we took the one hour delay so what - but then we have been forced to check in our Hand luggage. I travel only with hand luggage to avoid waiting for the ultra slow processing of the checked in luggage. Overall 2h later at home than planed, with really no reason, just due to incompetent people. Service level far worse then Ryanair and triple the price. Really never again. Thanks for nothing."
Prima dell’analisi è opportuno eseguire un’adeguata pulizia del testo rimuovendo.
toSpace = content_transformer(function(x, pattern) { return(gsub(pattern, ' ', x))})
# Funzione 'tm_map' ricerca i simboli speciali all'interno del corpus e applica la #funzione 'toSpace'.
corpus_aerei <- tm_map(corpus_aerei, toSpace, "-")
corpus_aerei <- tm_map(corpus_aerei, toSpace, ":")
corpus_aerei <- tm_map(corpus_aerei, toSpace, "'")
corpus_aerei <- tm_map(corpus_aerei, toSpace, "'")
corpus_aerei <- tm_map(corpus_aerei, toSpace, " -")
# Rimozione della punteggiatura.
corpus_aerei <- tm_map(corpus_aerei, removePunctuation)
#Trasform tutte le lettere in minuscol
corpus_aerei= tm_map(corpus_aerei, content_transformer(tolower))
#rimozione stopwords
mystopwords <- c("airways", "british") #stopWords scelte da noi
corpus_aerei <- tm_map(corpus_aerei, removeWords, c(stopwords("english"), mystopwords))
#rimozione dei numeri
corpus_aerei= tm_map(corpus_aerei, removeNumbers)
#funzione per rendere gli spazi solo 1
corpus_aerei <- tm_map(corpus_aerei, stripWhitespace)
Il processo di stemming è fondamentale per ridurre le parole alle loro forme base o radici. Bisogna però tener conto che si tratta di una tecnica invasiva che potrebbe falsare l’analisi.
corpus_aerei<- tm_map(corpus_aerei,stemDocument)
La matrice Document Term ha per righe i documenti e per colonne le parole trasformando il VCorpus in una matrice di conteggio.
dtm_air= DocumentTermMatrix(corpus_aerei)
#in freq sono riportati i conteggi di apparizione per ogni parola
freq = colSums(as.matrix(dtm_air))
length(freq) #rilevate 4249 parole
## [1] 4174
# Ordiniamo in maniera decrescente i termini in base alla loro frequenza di apparizione.
ordr <- order(freq,decreasing=TRUE)
# Verifichiamo ora le paroli più frequenti.
findFreqTerms(dtm_air,lowfreq=80)
## [1] "aircraft" "airlin" "airport" "also" "anoth" "arriv"
## [7] "ask" "back" "bag" "baggag" "board" "book"
## [13] "busi" "cabin" "call" "can" "cancel" "chang"
## [19] "check" "class" "club" "comfort" "connect" "covid"
## [25] "crew" "custom" "day" "delay" "differ" "drink"
## [31] "due" "economi" "email" "even" "experi" "find"
## [37] "first" "fli" "flight" "food" "friend" "full"
## [43] "gate" "get" "given" "good" "got" "great"
## [49] "heathrow" "help" "hour" "howev" "inform" "just"
## [55] "last" "late" "later" "left" "lhr" "like"
## [61] "london" "long" "loung" "luggag" "made" "make"
## [67] "meal" "minut" "miss" "money" "much" "need"
## [73] "never" "new" "next" "now" "number" "offer"
## [79] "one" "onlin" "option" "paid" "passeng" "pay"
## [85] "peopl" "phone" "plane" "poor" "premium" "provid"
## [91] "realli" "rebook" "receiv" "refund" "return" "said"
## [97] "say" "seat" "seem" "servic" "staff" "still"
## [103] "take" "ticket" "time" "told" "took" "travel"
## [109] "tri" "trip" "two" "use" "voucher" "wait"
## [115] "way" "week" "well" "will" "work" "year"
Si è interessati a comprendere il contenuto delle recensioni per poter distinguere le recensioni positive da quelle negative. Nella library ‘tidytext’ è presente un tibble (‘get_sentiments(“bing”)’)che contiene migliaia di termini a cui sono associati un sentimento (positivo o negativo). L’idea è quella di confrontare le parole presenti nelle nostre recensioni sulla base di questo tibble e contare le parole che hanno un significato positivo e quelle con un significato negativo.
Per farlo è però opportuno ritornare al formato tibble dal formato Corpus, per poter applicare le funzioni del tidyr
tibble_aerei= corpus_aerei$dmeta #tibble su cui sono salvate le variabili OverallRating-ReviewHeader-Datetime-TypeOfTraveller
tibble_aerei$text=NA
for (j in 1:nrow(tibble_aerei))
{
tibble_aerei$text[j]= content(corpus_aerei[[j]])
}
#un ciclo che scorre tutti i testi e li salva nella variabile text che implementiamo manulamente
Dal tibble ottenuto si è intenzionati a scomporlo ottenendo ‘words_aerei’ (un tibble che per ogni riga riporta una parola) Si procede poi tramite inner_join a ottenere un ulteriore tibble ‘sentiment’ che presenta tutte le parole contenute nelle recensioni che sono presenti anche in ‘get_sentiments(“bing”)’ alle quali corrispondono un ‘sentiment’ negativo o positivo
get_sentiments("bing") #tibble contenente la seria di parole con significato positivo o negativo
## # A tibble: 6,786 × 2
## word sentiment
## <chr> <chr>
## 1 2-faces negative
## 2 abnormal negative
## 3 abolish negative
## 4 abominable negative
## 5 abominably negative
## 6 abominate negative
## 7 abomination negative
## 8 abort negative
## 9 aborted negative
## 10 aborts negative
## # ℹ 6,776 more rows
words_aerei= tibble_aerei %>% unnest_tokens(word, text)
sentiment= words_aerei %>% inner_join(get_sentiments("bing"), by="word")
sentiment
## # A tibble: 4,345 × 6
## OverallRating ReviewHeader Datetime TypeOfTraveller word sentiment
## <int> <chr> <date> <chr> <chr> <chr>
## 1 1 "\"Service level fa… 2023-11-19 Couple Leisure disr… negative
## 2 1 "\"Service level fa… 2023-11-19 Couple Leisure limit negative
## 3 1 "\"Service level fa… 2023-11-19 Couple Leisure delay negative
## 4 1 "\"Service level fa… 2023-11-19 Couple Leisure slow negative
## 5 1 "\"Service level fa… 2023-11-19 Couple Leisure thank positive
## 6 3 "\"do not upgrade m… 2023-11-19 Business delay negative
## 7 3 "\"do not upgrade m… 2023-11-19 Business delay negative
## 8 3 "\"do not upgrade m… 2023-11-19 Business clear positive
## 9 3 "\"do not upgrade m… 2023-11-19 Business delay negative
## 10 3 "\"do not upgrade m… 2023-11-19 Business ente… positive
## # ℹ 4,335 more rows
In s sono salvate le 80 parole più utilizzate (40 per sentimento) ordinate in ordine decrescente
s = sentiment %>% count(word, sentiment, sort=TRUE) %>%
group_by(sentiment) %>% arrange(desc(n)) %>% slice(1:40)
Rappresentzione grafica
s %>% ggplot(aes(x=reorder(word,n), y=n))+ #sull'asse delle X inseriamo le parole ordinate sulla base delle ripetizioni, sull'asse delle y il conteggio
geom_col(aes(fill=sentiment), show.legend=F) +
coord_flip()+ #giriamo il grafico per ragioni estetiche
facet_wrap(~sentiment, scales="free_y") +#si divide sulla base del sentimento
labs(x="parole", y="numero di ripetzioni", title="Parole più usate per sentimento") #titoli
Nella variabile OverallRating è assegnato un punteggio in decimi con cui il cliente ha valutato la sua esperienza di volo, ci chiediamo se la votazione sia corelata al conteggio di parole positive e negative presenti nel testo. Per ogni recensione selezioniamo le parole che sono presenti anche nel tibble (‘get_sentiments(“bing”)’), selezioniamo però solo le recensioni con più contenuto che quindi presentano almeno 10 parole con significato positivo o negativo. Si salva un punteggio salvato nella variabile ‘rating’ (rating = numero di parole positive - numero di parole negative), e infine si calcola la corelazione tra la variabile rating e la variabile OverallRating.
valutazioni= c() #vettore in cui ci salveremo i valori rating
index= c() #vettore un cui salveremo gli indici (importante per poi far la corelazione solo con le recensioni selezionate)
for(j in 1:nrow(tibble_aerei))
{
recensione=tibble_aerei[j,] %>% unnest_tokens(word, text)
sent= recensione %>% inner_join(get_sentiments("bing"), by="word")
rating=0
if(nrow(sent)>10)
{
index=cbind(index, j)
for(k in 1:nrow(sent))
{
if(sent$sentiment[k]== 'negative')
{
rating= rating -1
}
else
{
rating= rating +1
}
}
valutazioni= cbind(valutazioni, rating)
}
}
valutazioni= as.numeric(valutazioni)
index= as.numeric(index)
cor(tibble_aerei$OverallRating[index], valutazioni)
## [1] 0.6603381
La corelazione di 0.66 non è un pessimo risultato ma neanche ottimale. La corelazione però è condizioata dall’utilizzo delle parole del cliente, non è detto che un cliente che ha avuto una pessima esperienza con la compagnia e che quindi la valuti estremamente male, si spenda troppo a scrivere una recensione completa e arricchita di termini in questo caso negativi.
Avendo a disposizione le date in cui sono state scritte le recensioni è interessante visualizzare una serie storica dell’andamento della variabile rating (rating = numero di parole positive - numero di parole negative).
parole_per_data = sentiment %>% group_by(Datetime) %>% count(sentiment) %>%
spread(sentiment, n)
#in parole_per_data sono contate il numero totale di parole negative e parole positive scritte raggruppate per ogni data
#ovviamente se non sono sate scritte parole di significato positivo o negativo sono preseti degli NA, li sostituiamo semplicemente con degli zero
parole_per_data <- parole_per_data %>%
mutate_all(~replace_na(., 0))
## `mutate_all()` ignored the following grouping variables:
## • Column `Datetime`
## ℹ Use `mutate_at(df, vars(-group_cols()), myoperation)` to silence the message.
Rappresentazione grafica della serie storica
parole_per_data %>% mutate(rating=positive-negative)%>% #si crea la variabile rating
ggplot(aes(x=Datetime, y=rating)) +
scale_x_date(limits=c(as.Date("2020-01-01"), as.Date("2023-11-19")), date_breaks = "6 month", date_labels = "%b") +
geom_line(stat="identity", col="darkblue") + geom_smooth(col="yellow") + labs(title="Serie Storica di Rating")+
theme_gray()
## `geom_smooth()` using method = 'loess' and formula = 'y ~ x'
descrizione serie…
Di seguito vengono considerate solo le recensioni dei primi 3 mesi per visualizzare almeglio il picco iniziale
parole_per_data %>% mutate(rating=positive-negative)%>%
ggplot(aes(x=Datetime, y=rating)) +
scale_x_date(limits=c(as.Date("2020-01-01"), as.Date("2020-04-01")), date_breaks = "1 week", date_labels = "%b") +
geom_line(stat="identity", col="darkblue") + geom_smooth(col="yellow") + labs(title="Serie Storica di Rating") +
theme_gray()
## `geom_smooth()` using method = 'loess' and formula = 'y ~ x'
## Warning: Removed 426 rows containing non-finite values (`stat_smooth()`).
## Warning: Removed 426 rows containing missing values (`geom_line()`).
parole_per_data$Datetime[which.max(parole_per_data$positive)]
## [1] "2020-01-11"
Il giorno in cui sono state scritte le recensioni più positive è stato l’11 gennaio 2020
kable(tibble_aerei[tibble_aerei$Datetime == parole_per_data$Datetime[which.max(parole_per_data$positive)],])
| OverallRating | ReviewHeader | Datetime | TypeOfTraveller | text |
|---|---|---|---|---|
| 9 | “could not fault one aspect of the service” | 2020-01-11 | Solo Leisure | fantast flight travel solo london gatwick glasgow airway’ club europ cabin fault one aspect servic arriv gatwick around hour prior departur ba club world passeng entitl fast track secur gatwick quick staff pleasant enough loung gatwick clean modern made nice relax start journey board quick flight depart time land minut earli crew attent friend flight meal delici smoke salmon serv curri rice overal realli love fli experi ever slight leg room flight entertain ’d given flight fantast crew credit airlin |
| 10 | “All in all great service” | 2020-01-11 | Solo Leisure | rio de janeiro london heathrow love aircraft class busi world travel plus economi class cabin busi class front row rear row seat a opinion seat window seat bit space usual seat littl extra help privat tuck behind second galley area toilet side jk settl sleep even meal crew accommod offer water bottl immedi good hour sleep normal woke minut breakfast realli enjoy fruit yoghurt muesli full english breakfast great servic good flight felt fulli relax |
| 3 | “disappointed by the service” | 2020-01-11 | Business | gatwick tirana incred disappoint servic provid flight virtual imposs differenti made flight differ economi busi class passeng board time economi class passeng curtain divid economi class busi class peopl economi class taken liberti remov cabin bag space seat three seat behind seat seat economi differ middl seat empti thus creat divid window aisl seat encount odd problem meal well love cabin crew member notifi choic eat pasta dinner choic finish better economi pay less get servic flight left time land earli opinion best inflight w fi servic pound get unlimit high speed wi fi can stream youtub video offer cheaper packag well can get unlimit text |
| 10 | “Crew very friendly” | 2020-01-11 | Couple Leisure | keflavik london heathrow cabin crew amaz weather terrifi drove airport knew thing go tough expect delay see european flight cancel expect surpris see flight board snow blizzard condit surpris got settl seat crew friend long stori short hour ground wait di ice machin final made way take staff relax calm passeng includ connect flight sat one person look option ba onlin app help non alcohol drink snack provid time first offic kept us inform time ba fault de ice machin delay much eventu procedur done readi take pilot team manag condit will never now smooth take cloud smooth journey way back heathrow cabin crew provid excel servic busi lucki champagn meal excel veg shepherd pie chicken curri love just want thank ba know get lot negat review say can t fault particular crew made real effort talk us well done |
Sono infatti state scritte 4 recensioni, 3 hanno una valutazione molto buona (OverallRating >= 9) e una recensione valuta il servizio 3/10