Cas pratique de text mining et analyse de sentiment à partir des avis TripAdvisor

De nombreuses études ont montré que TripAdvisor joue un rôle de plus en plus important dans le processus de prise de décision d’un voyageur.
Cependant, comprendre les nuances du score présenté sous forme de bulle par rapport à chacun des milliers de textes d’avis et commentaires postés sur le site TripAdvisor, peut s’avérer difficile.
Une Data scientist a décidé de s’y intéresser en analysant les avis et commentaires postés sur TripAdvisor d’un hôtel en particulier – Hilton Hawaiian Village.

Pour les plus techniciens d’entre nous, vous trouverez le code Python employé dans cet exercice de text mining et d’analyse de sentiment.

Etape 1 : Charger les bibliothèques


library(dplyr)
library(readr)
library(lubridate)
library(ggplot2)
library(tidytext)
library(tidyverse)
library(stringr)
library(tidyr)
library(scales)
library(broom)
library(purrr)
library(widyr)
library(igraph)
library(ggraph)
library(SnowballC)
library(wordcloud)
library(reshape2)
theme_set(theme_minimal())

Etape 2 : Sélectionner le set de données


df <- read_csv("hilton_hawaiian_village_waikiki_beach_resort-honolulu_oahu_hawaii__en.csv") df <- df[complete.cases(df), ] df$review_date as.date(df$review_date, format=%d-%B-%y>

Il y a eu 13 701 commentaires en anglais sur TripAdvisor pour l’hôtel Hilton Hawaiian Village et les dates de mise en ligne des avis s’étalent sur la période du 21 mars 2002 au 2 août 2018.


df %>%
count(Week = round_date(review_date, « week »)) %>%
ggplot(aes(Week, n)) +
geom_line() +
ggtitle(‘The Number of Reviews Per Week’)

Le plus grand nombre d’avis sur le site TripAdvisor a été déposé à la fin de l’année 2014. L’hôtel a reçu plus de 70 critiques en une semaine.

Etape 3 : Extraire le texte des avis et commentaires déposés


df <- tibble::rowid_to_column(df, "id" ) df <- df %>%
mutate(review_date = as.POSIXct(review_date, origin = « 1970-01-01 »),month = round_date(review_date, « month »))
review_words <- df %>%
distinct(review_body, .keep_all = TRUE) %>%
unnest_tokens(word, review_body, drop = FALSE) %>%
distinct(ID, word, .keep_all = TRUE) %>%
anti_join(stop_words, by = « word ») %>%
filter(str_detect(word, « [^\\d] »)) %>%
group_by(word) %>%
mutate(word_total = n()) %>%
ungroup()
word_counts <- review_words %>%
count(word, sort = TRUE)
word_counts %>%
head(25) %>%
mutate(word = reorder(word, n)) %>%
ggplot(aes(word, n)) +
geom_col(fill = « lightblue ») +
scale_y_continuous(labels = comma_format()) +
coord_flip() +
labs(title = « Most common words in review text 2002 to date »,
subtitle = « Among 13,701 reviews; stop words removed »,
y = « # of uses »)

Nous pouvons certainement faire un peu mieux pour combiner « rester et resté », et « piscine » et « piscines ». C’est ce que les spécialistes appellent stemming. Il s’agit d’un processus qui consiste à réduire les mots infléchis (ou parfois dérivés) au format de la racine, de la base ou de la tige du mot.


word_counts %>%
head(25) %>%
mutate(word = wordStem(word)) %>%
mutate(word = reorder(word, n)) %>%
ggplot(aes(word, n)) +
geom_col(fill = « lightblue ») +
scale_y_continuous(labels = comma_format()) +
coord_flip() +
labs(title = « Most common words in review text 2002 to date »,
subtitle = « Among 13,701 reviews; stop words removed and stemmed »,
y = « # of uses »)

Etape 4 : Rechercher les bigrams

En matière de text mining, nous sommes amenés à comprendre la relation entre les mots au sein d’un même texte. Il s’agit de savoir plus précisément : quelles séquences de mots sont communes ? Compte tenu d’une séquence de mots, quel mot est le plus susceptible de suivre ? Quels mots sont corrélés ? De très nombreuses analyses de texte sont basées sur ces relations. Lorsque nous examinons des paires de deux mots consécutifs, on parle de bigram (terme anglais).

Dans notre cas pratique, quels sont les bigrams ou paires de mots les plus usités dans les commentaires TripAdvisor pour l’hôtel Hilton Hawaiian Village ?


review_bigrams <- df %>%
unnest_tokens(bigram, review_body, token = « ngrams », n = 2)
bigrams_separated <- review_bigrams %>%
separate(bigram, c(« word1 », « word2″), sep =  » « )
bigrams_filtered <- bigrams_separated %>%
filter(!word1 %in% stop_words$word) %>%
filter(!word2 %in% stop_words$word)
bigram_counts <- bigrams_filtered %>%
count(word1, word2, sort = TRUE)
bigrams_united <- bigrams_filtered %>%
unite(bigram, word1, word2, sep =  » « )
bigrams_united %>%
count(bigram, sort = TRUE)

Les réponses sont « rainbow tower » et « hawaiian village ». Nous pouvons visualiser les bigrams dans les réseaux de mots :


review_subject <- df %>%
unnest_tokens(word, review_body) %>%
anti_join(stop_words)
my_stopwords <- data_frame(word=c(as.character(1:10)))review_subject <- review_subject %>%
anti_join(my_stopwords)
title_word_pairs <- review_subject %>%
pairwise_count(word, ID, sort = TRUE, upper = FALSE)
set.seed(1234)
title_word_pairs %>%
filter(n >= 1000) %>%
graph_from_data_frame() %>%
ggraph(layout = « fr ») +
geom_edge_link(aes(edge_alpha = n, edge_width = n), edge_colour = « cyan4 ») +
geom_node_point(size = 5) +
geom_node_text(aes(label = name), repel = TRUE,
point.padding = unit(0.2, « lines »)) +
ggtitle(‘Word network in TripAdvisor reviews’)
theme_void()

Le graphique ci-dessus permet de visualiser les bigrams communs dans les revues de TripAdvisor, montrant ceux qui se sont produits au moins 1 000 fois et où ni l’un ni l’autre mot n’était – ce que l’on appelle – un stop-word. Le graphique montre des liens forts entre les mots situés en haut (« hawaïen », « village », « océan » et « vue »). sans pour autant nous permettre de dégager une structure de regroupement claire parmi le réseau.

Etape 5 : Continuer l’analyse avec les trigrams

Parfois, les bigrams ne suffisent pas. La bonne marche à suivre est alors de répérer les trigrams. Quels sont donc les trigrams les plus couramment employés dans les avis TripAdvisor pour l’hôtel Hilton Hawaiian Village ?


review_trigrams <- df %>%
unnest_tokens(trigram, review_body, token = « ngrams », n = 3)

trigrams_separated <- review_trigrams %>%
separate(trigram, c(« word1 », « word2 », « word3″), sep =  » « )

trigrams_filtered <- trigrams_separated %>%
filter(!word1 %in% stop_words$word) %>%
filter(!word2 %in% stop_words$word) %>%
filter(!word3 %in% stop_words$word)

trigram_counts <- trigrams_filtered %>%
count(word1, word2, word3, sort = TRUE)

trigrams_united <- trigrams_filtered %>%
unite(trigram, word1, word2, word3, sep =  » « )

trigrams_united %>%
count(trigram, sort = TRUE)

Le trigram le plus commun est « hilton hawaiian village », suivi de « diamond head tower », et ainsi de suite.

Etape 6 : Saisir les tendances parmi les mots importants exprimés dans les avis

Quels mots deviennent de plus en plus fréquents dans les avis déposés au fil des années ? Inversement, quels sont les sujets dont les clients accordent une importance moindre ? Les réponses à ces deux questions permettent de préciser les points cruciaux de l’hôtel, comme le service, la rénovation, la résolution de problèmes, etc. Ce qui permet infine de prédire les sujets qui continueront à gagner en importance auprès des clients – et donc auprès de la direction de l’hôtel.
Voici le code pour savoir quels mots ont augmenté en fréquence au fil du temps dans les reviews TripAdvisor :


reviews_per_month <- df %>%
group_by(month) %>%
summarize(month_total = n())
word_month_counts <- review_words %>%
filter(word_total >= 1000) %>%
count(word, month) %>%
complete(word, month, fill = list(n = 0)) %>%
inner_join(reviews_per_month, by = « month ») %>%
mutate(percent = n / month_total) %>%
mutate(year = year(month) + yday(month) / 365)
mod <- ~ glm(cbind(n, month_total - n) year, ., family=binomial>%
nest(-word) %>%
mutate(model = map(data, mod)) %>%
unnest(map(model, tidy)) %>%
filter(term == « year ») %>%
arrange(desc(estimate))
slopes %>%
head(9) %>%
inner_join(word_month_counts, by = « word ») %>%
mutate(word = reorder(word, -estimate)) %>%
ggplot(aes(month, n / month_total, color = word)) +
geom_line(show.legend = FALSE) +
scale_y_continuous(labels = percent_format()) +
facet_wrap(~ word, scales = « free_y ») +
expand_limits(y = 0) +
labs(x = « Year »,
y = « Percentage of reviews containing this word »,
title = « 9 fastest growing words in TripAdvisor reviews »,
subtitle = « Judged by growth rate over 15 years »)

Nous pouvons voir un pic de discussion autour des « friday fireworks » (feux d’artifice du vendredi, en français) et du « lagoon » avant 2010. Et c’est avant 2005 que des mots tels que « resort fee » et « busy » ont connu la croissance la plus rapide.

A contrario quels mots sont de moins en moins usités par les clients ?


slopes %>%
tail(9) %>%
inner_join(word_month_counts, by = « word ») %>%
mutate(word = reorder(word, estimate)) %>%
ggplot(aes(month, n / month_total, color = word)) +
geom_line(show.legend = FALSE) +
scale_y_continuous(labels = percent_format()) +
facet_wrap(~ word, scales = « free_y ») +
expand_limits(y = 0) +
labs(x = « Year »,
y = « Percentage of reviews containing this term »,
title = « 9 fastest shrinking words in TripAdvisor reviews »,
subtitle = « Judged by growth rate over 4 years »)

Cela montre quelques sujets dans lesquels l’intérêt s’est éteint depuis 2010, y compris « hhv » (certainement, l’abbréviation de hilton hawaiian village), « breakfast », « upgraded » « prices » et « free ».

Comparons quelques mots choisis.


word_month_counts %>%
filter(word %in% c(« service », « food »)) %>%
ggplot(aes(month, n / month_total, color = word)) +
geom_line(size = 1, alpha = .8) +
scale_y_continuous(labels = percent_format()) +
expand_limits(y = 0) +
labs(x = « Year »,
y = « Percentage of reviews containing this term », title = « service vs food in terms of reviewers interest »)

Le service et l’alimentation semblent bien avoir été les deux sujets principaux avant 2010 – avec un pic d’intérêt en 2003.

Etape 7 : Lancer l’analyse des sentiments

L’analyse des sentiments s’applique généralement sur les textes qui expriment la Voix du Client, comme les avis de consommateurs, les commentaires publiés sur les plateformes et réseaux sociaux (Facebook, Twitter, YouTube, Instagram…) ou encore les réponses à des sondages et enquêtes de satisfaction.
Dans notre cas, nous visons à déterminer l’attitude d’un examinateur (c.-à-d. un client de l’hôtel) par rapport à son expérience passée ou à sa réaction émotionnelle envers l’hôtel. L’attitude peut être un jugement ou une évaluation.
L’analyse des sentiments permet de relever les mots positifs et négatifs les plus courants dans les avis.


reviews <- df %>%
filter(!is.na(review_body)) %>%
select(ID, review_body) %>%
group_by(row_number()) %>%
ungroup()
tidy_reviews <- reviews %>%
unnest_tokens(word, review_body)
tidy_reviews <- tidy_reviews %>%
anti_join(stop_words)

bing_word_counts <- tidy_reviews %>%
inner_join(get_sentiments(« bing »)) %>%
count(word, sentiment, sort = TRUE) %>%
ungroup()

bing_word_counts %>%
group_by(sentiment) %>%
top_n(10) %>%
ungroup() %>%
mutate(word = reorder(word, n)) %>%
ggplot(aes(word, n, fill = sentiment)) +
geom_col(show.legend = FALSE) +
facet_wrap(~sentiment, scales = « free ») +
labs(y = « Contribution to sentiment », x = NULL) +
coord_flip() +
ggtitle(‘Words that contribute to positive and negative sentiment in the reviews’)

Essayons une autre bibliothèque de sentiments pour voir si les résultats sont les mêmes.


contributions <- tidy_reviews %>%
inner_join(get_sentiments(« afinn »), by = « word ») %>%
group_by(word) %>%
summarize(occurences = n(),
contribution = sum(score))
contributions %>%
top_n(25, abs(contribution)) %>%
mutate(word = reorder(word, contribution)) %>%
ggplot(aes(word, contribution, fill = contribution > 0)) +
ggtitle(‘Words with the greatest contributions to positive/negative
sentiment in reviews’) +
geom_col(show.legend = FALSE) +
coord_flip()

Il est intéressant de voir que « diamand » (comme diamond head) a été classé dans la catégorie des sentiments positifs.
Il y a ici un problème potentiel, par exemple, « clean », selon le contexte, a un sentiment négatif si précédé du mot « not ». En fait, les unigrams auront ce problème de négation dans la plupart des cas. Ceci nous amène au sujet suivant.

Etape 8 : Jouer avec les bigrams pour améliorer les résultats issus de l’analyse des sentiments

Le recours ici aux bigrams permet d’apporter du contexte dans l’analyse du sentiment. Nous voulons notamment voir combien de fois les mots sont précédés d’un mot comme « not ».


AFINN <- get_sentiments("afinn") not_words <- bigrams_separated %>%
filter(word1 == « not ») %>%
inner_join(AFINN, by = c(word2 = « word »)) %>%
count(word2, score, sort = TRUE) %>%
ungroup()

not_words

Le tableau ci-dessus montre 850 occurences dans le set de données où le mot « a » est précédé du mot « not », et 698 autres occurrences où « the » est précédé de « not ». Toutefois, cette information n’est pas significative.


bigrams_separated %>%
filter(word1 == « not ») %>%
count(word1, word2, sort = TRUE)

Cela nous indique que dans les données, le mot le plus commun associé à un sentiment qui suit « not » est « worth », et le deuxième mot associé à un sentiment commun qui suit « not » est « recommend », qui aurait normalement un score (positif) de 2.
On peut alors se demander dans nos données, quels mots ont le plus contribué à nous orienter dans une mauvaise direction ?


not_words %>%
mutate(contribution = n * score) %>%
arrange(desc(abs(contribution))) %>%
head(20) %>%
mutate(word2 = reorder(word2, contribution)) %>%
ggplot(aes(word2, n * score, fill = n * score > 0)) +
geom_col(show.legend = FALSE) +
xlab(« Words preceded by \ »not\ » ») +
ylab(« Sentiment score * number of occurrences ») +
ggtitle(‘The 20 words preceded by « not » that had the greatest contribution to
sentiment scores, positive or negative direction’) +
coord_flip()

Les bigrams « not worth », « not great », « not good », « not recommend », « not recommend » et « not like » ont été les principales causes des erreurs d’identification, rendant le texte beaucoup plus positif qu’il ne l’est en réalité.
En plus de « not », il y a d’autres mots qui annulent le terme suivant. C’est le cas de « no », « never » et « without ». Allons les vérifier.


negation_words <- c("not", "no" , "never" , "without" ) negated_words <- bigrams_separated %>%
filter(word1 %in% negation_words) %>%
inner_join(AFINN, by = c(word2 = « word »)) %>%
count(word1, word2, score, sort = TRUE) %>%
ungroup()

negated_words %>%
mutate(contribution = n * score,
word2 = reorder(paste(word2, word1, sep = « __ »), contribution)) %>%
group_by(word1) %>%
top_n(12, abs(contribution)) %>%
ggplot(aes(word2, contribution, fill = n * score > 0)) +
geom_col(show.legend = FALSE) +
facet_wrap(~ word1, scales = « free ») +
scale_x_discrete(labels = function(x) gsub(« __.+$ », «  », x)) +
xlab(« Words preceded by negation term ») +
ylab(« Sentiment score * # of occurrences ») +
ggtitle(‘The most common positive or negative words to follow negations
such as « no », « not », « never » and « without »‘) +
coord_flip()

Il semble que les sources les plus importantes d’identification erronée d’un mot comme positif proviennent de « not worth/great/good/recommend », et la source la plus importante de sentiment négatif mal classé est « not bad » et « no problem ».

Etape 9 (dernière !) : Repérer l’avis le plus positif et le plus négatif


sentiment_messages <- tidy_reviews %>%
inner_join(get_sentiments(« afinn »), by = « word ») %>%
group_by(ID) %>%
summarize(sentiment = mean(score),
words = n()) %>%
ungroup() %>%
filter(words >= 5)
sentiment_messages %>%
arrange(desc(sentiment))

L’avis le plus positif est 2363. Quel est-il ?


df[ which(df$ID==2363), ]$review_body[1]

Et le pire commentaire laissé sur l’hôtel ?


sentiment_messages %>%
arrange(sentiment)

Il s’agit du 3748.


df[ which(df$ID==3748), ]$review_body[1]

Cet article est une traduction de l’analyse menée par Susan Li, une Data scientist travaillant à Toronto. Le texte original est disponible sur Towards Data Science, accessible depuis Médium. Le code est en ligne sur Github (extension : susanli2016/Data-Analysis-with-R/blob/master/Text%20Mining%20Hilton%20Hawaiian%20Village%20TripAdvisor%20Reviews.Rmd).