The main question of this project is: Which country has the highest average hotel rating in Europe? What is the difference between positive and negative reviews? Do people provide more detailed narratives in negative reviews? As we all know, the public security in many European countries is not good and not safe, it is a problem when choosing hotel in Europe. By analyzing positive and negative reviews, we can understand the underlying reasons why people are satisfied and dissatisfied with those hotels. It can let us know which country has a higher average hotel rate and we can give priority to staying in countries with higher average hotel ratings when we want to travel in Europe. In addition, travelers can use these research results to understand what other customers really say about a hotel, so we can make smarter accommodation choices and enhance their travel experience, also pay attention to certain issues when choosing a hotel in Europe.
The data of ‘515K Hotel Reviews Data in Europe’ came from Kaggle and updated by JIASHEN LIU. Link: https://www.kaggle.com/datasets/jiashenliu/515k-hotel-reviews-data-in-europe
This data was scraped from Booking.com. All data in the file is publicly available to everyone already and the data is originally owned by Booking.com. The dataset contains 515,000 customer reviews and scoring of 1493 luxury hotels across Europe. Meanwhile, the geographical location of hotels are also provided for further analysis.
The csv file contains 17 fields. The description of each field is as below: * Hotel_Address: Address of hotel. * Review_Date: Date when reviewer posted the corresponding review. * Average_Score: Average Score of the hotel, calculated based on the latest comment in the last year. * Hotel_Name: Name of Hotel * Reviewer_Nationality: Nationality of Reviewer * Negative_Review: Negative Review the reviewer gave to the hotel. If the reviewer does not give the negative review, then it should be: ‘No Negative’ * Review_Total_Negative_Word_Counts: Total number of words in the negative review. * Positive_Review: Positive Review the reviewer gave to the hotel. If the reviewer does not give the negative review, then it should be: ‘No Positive’ * Review_Total_Positive_Word_Counts: Total number of words in the positive review. * Reviewer_Score: Score the reviewer has given to the hotel, based on his/her experience * Total_Number_of_Reviews_Reviewer_Has_Given: Number of Reviews the reviewers has given in the past. * Total_Number_of_Reviews: Total number of valid reviews the hotel has. * Tags: Tags reviewer gave the hotel. * days_since_review: Duration between the review date and scrape date. * Additional_Number_of_Scoring: There are also some guests who just made a scoring on the service rather than a review. This number indicates how many valid scores without review in there. * lat: Latitude of the hotel * lng: longtitude of the hotel
data <- read.csv("Hotel_Reviews.csv")
data <- data %>%
mutate(splits = strsplit(Hotel_Address, " ")) %>%
rowwise() %>%
mutate(Country = ifelse(splits[[length(splits)]] == "Kingdom", "United Kingdom", splits[[length(splits)]])) %>%
ungroup() %>%
select(-splits)
data$Negative_Review <- gsub("No Negative", "", data$Negative_Review)
data$Positive_Review <- gsub("No Positive", "", data$Positive_Review)
neg_reviews <- data %>%
group_by(Negative_Review) %>%
unnest_tokens(input = Negative_Review,
output = word)
pos_reviews <- data %>%
group_by(Positive_Review) %>%
unnest_tokens(input = Positive_Review,
output = word)
hotel <- data %>%
select(Hotel_Name, Average_Score, Total_Number_of_Reviews,
Review_Total_Positive_Word_Counts, Review_Total_Negative_Word_Counts, Country) %>%
group_by(Hotel_Name, Average_Score, Total_Number_of_Reviews, Country) %>%
summarise(pos_counts = sum(Review_Total_Positive_Word_Counts),
neg_counts = sum(Review_Total_Negative_Word_Counts),
words = sum(pos_counts + neg_counts),
pos_rate = percent(pos_counts/words),
neg_rate = percent(neg_counts/words))
## `summarise()` has grouped output by 'Hotel_Name', 'Average_Score',
## 'Total_Number_of_Reviews'. You can override using the `.groups` argument.
hotel
## # A tibble: 1,494 × 9
## # Groups: Hotel_Name, Average_Score, Total_Number_of_Reviews [1,494]
## Hotel_Name Average_Score Total_Number_of_Revi…¹ Country pos_counts neg_counts
## <chr> <dbl> <int> <chr> <int> <int>
## 1 11 Cadoga… 8.7 393 United… 3176 2469
## 2 1K Hotel 7.7 663 France 2309 3690
## 3 25hours H… 8.8 4324 Austria 15097 11135
## 4 41 9.6 244 United… 2606 915
## 5 45 Park L… 9.4 68 United… 323 189
## 6 88 Studios 8.4 955 United… 9852 10987
## 7 9Hotel Re… 8.8 857 France 3539 3102
## 8 A La Vill… 8.8 185 France 805 347
## 9 ABaC Rest… 8.8 111 Spain 579 1092
## 10 AC Hotel … 8.1 1560 Spain 4331 6250
## # ℹ 1,484 more rows
## # ℹ abbreviated name: ¹Total_Number_of_Reviews
## # ℹ 3 more variables: words <int>, pos_rate <chr>, neg_rate <chr>
country <- hotel %>%
select(Country, Average_Score, Total_Number_of_Reviews, pos_counts, neg_counts, words) %>%
group_by(Country) %>%
summarize(avg_reviews = mean(Average_Score),
pos_counts = sum(pos_counts),
neg_counts = sum(neg_counts),
words = sum(words),
pos_rate = percent(pos_counts/words),
neg_rate = percent(neg_counts/words),
Number_of_Hotels = n(),
Total_Number_of_Reviews = sum(Total_Number_of_Reviews))
## Adding missing grouping variables: `Hotel_Name`
country
## # A tibble: 6 × 9
## Country avg_reviews pos_counts neg_counts words pos_rate neg_rate
## <chr> <dbl> <int> <int> <int> <chr> <chr>
## 1 Austria 8.55 738089 664293 1402382 53% 47%
## 2 France 8.49 1132056 1002897 2134953 53% 47%
## 3 Italy 8.32 677569 650853 1328422 51% 49%
## 4 Netherlands 8.41 1122397 1112127 2234524 50% 50%
## 5 Spain 8.50 1180852 1089722 2270574 52% 48%
## 6 United Kingdom 8.46 4317032 5041607 9358639 46% 54%
## # ℹ 2 more variables: Number_of_Hotels <int>, Total_Number_of_Reviews <int>
ggplot(data = country, aes(x=reorder(Country,-Number_of_Hotels),
y = country$avg_reviews,
fill = Country))+
geom_col(show.legend = F) +
geom_text(label=format(country$avg_reviews, digits = 3)) +
labs(x='Country', y='Score') +
ggtitle(label='Average score of each country') +
scale_fill_brewer(palette="Set3")
## Warning: Use of `country$avg_reviews` is discouraged.
## ℹ Use `avg_reviews` instead.
## Use of `country$avg_reviews` is discouraged.
## ℹ Use `avg_reviews` instead.
The highest average hotel rate in this dataset is Austria with a score of 8.55. Also, The lowest average hotel rate is Italy with the score of 8.32. Although the average scores vary little between countries, we can still get an idea of the average level of hotels in Europe.
Bar charts are good at visualizing comparisons, making them ideal for comparing average scores across different countries in Europe. Each bar represents a country, and the different heights of the bars provide a clear visual distinction between average review scores, effectively highlighting the differences. Additionally, using a different fill color for each bar enhances visual appeal and readability, allowing us to easily differentiate between countries. In sum, the bar chart clearly and effectively communicates the differences in average hotel review scores across countries.
# Positive Review's wordcloud
pos_reviews %>%
inner_join(get_sentiments("bing")) %>%
count(word, sentiment, sort = TRUE) %>%
acast(word ~ sentiment, value.var = "n", fill = 0) %>%
comparison.cloud(colors = c("lightcoral", "cornflowerblue"),
max.words = 100)
## Joining with `by = join_by(word)`
## Warning in inner_join(., get_sentiments("bing")): Detected an unexpected many-to-many relationship between `x` and `y`.
## ℹ Row 4560433 of `x` matches multiple rows in `y`.
## ℹ Row 2736 of `y` matches multiple rows in `x`.
## ℹ If a many-to-many relationship is expected, set `relationship =
## "many-to-many"` to silence this warning.
# Negative Review's wordcloud
neg_reviews %>%
inner_join(get_sentiments("bing")) %>%
count(word, sentiment, sort = TRUE) %>%
acast(word ~ sentiment, value.var = "n", fill = 0) %>%
comparison.cloud(colors = c("lightcoral", "cornflowerblue"),
max.words = 100)
## Joining with `by = join_by(word)`
## Warning in inner_join(., get_sentiments("bing")): Detected an unexpected many-to-many relationship between `x` and `y`.
## ℹ Row 2802740 of `x` matches multiple rows in `y`.
## ℹ Row 4229 of `y` matches multiple rows in `x`.
## ℹ If a many-to-many relationship is expected, set `relationship =
## "many-to-many"` to silence this warning.
According to the above two figures, it can be seen that both positive and negative comparison wordclouds are roughly similar. Although the ‘bing’ sentiment lexicon divides words into positive and negative, it can ensure that wordcloud accurately reflects sentiment polarity, there still be some problems with the definition of positive and negative. For example, ‘cheap’ should be a positive word in positive review’s wordcloud, but it was difined as negative. Limiting the wordclouds to the first 100 words ensures that the visualization remains clear and easy to read. Including too many words can clutter the cloud and fail to convey the main message effectively.
The wordclouds showed the sentiment distribution in the reviews, making the analysis visually appealing. The comparison.cloud create wordclouds that juxtaposes positive and negative words, providing a visual comparison of the most commonly used terms in both sentiments. Using mild colors is visually pleasing, so I chose cornflower blue for positive words and light coral for negative words. Light coral is a soft red that’s often associated with negative emotions or caution, and cornflower blue is a calming, positive color that contrasts well with light coral, making it easy to differentiate between positive and negative words. Therefore, using two different colors can see the difference and results clearly.
positive_bigram <- data %>%
unnest_tokens(bigram, Positive_Review, token = "ngrams", n = 2) %>%
filter(!is.na(bigram)) %>%
separate(bigram, c("word1", "word2"), sep = " ") %>%
filter(!word1 %in% stop_words$word,
!word2 %in% stop_words$word) %>%
count(word1, word2, sort = TRUE)
positive_bigram_graph <- positive_bigram %>%
filter(n > 850) %>%
graph_from_data_frame()
set.seed(1234)
ggraph(positive_bigram_graph, layout = "fr") +
geom_edge_link(color = "maroon",
alpha = 0.5) +
geom_node_point(color = "lightcoral",
size = 3) +
geom_node_text(aes(label = name),
repel = T,
size = 3) +
theme_graph()
negative_bigram <- data %>%
unnest_tokens(bigram, Negative_Review, token = "ngrams", n = 2) %>%
filter(!is.na(bigram)) %>%
separate(bigram, c("word1", "word2"), sep = " ") %>%
filter(!word1 %in% stop_words$word,
!word2 %in% stop_words$word) %>%
count(word1, word2, sort = TRUE)
negative_bigram_graph <- negative_bigram %>%
filter(n > 850) %>%
graph_from_data_frame()
set.seed(1234)
ggraph(negative_bigram_graph, layout = "fr") +
geom_edge_link(color = "navy",
alpha = 0.5) +
geom_node_point(color = "cornflowerblue",
size = 3) +
geom_node_text(aes(label = name),
repel = T,
size = 3) +
theme_graph()
The ‘unnest_tokens’ function extracts bigrams which means two-word phrases from the positive and negative reviews. Then removed stop words to focus on meaningful words. There are too many reviews on this dataset, so only bigrams that appear more than 850 times are collected to show the significant connections.
The reason why I created network graphs is because we can discover if people provide more detailed narratives in negative reviews. However, through the above two figures, in the same case of extracting bigrams that appeared more than 850 times, people reviewed in positive words with relatively rich adjectives and advantages, while negative reviews were straightforward and simple.
The warm colors of maroon for edge links and light coral for node points were chosen to evoke positive emotions and create a visual distinction from the negative bigram graph. Red hues are often associated with passion and warmth, which aligns with the concept of positive reviews. Besides, choosing cool colors of navy for edge links and cornflower blue for node points to convey a sense of negativity, as blue tones may be associated with sadness. Additionally, the two colors of the node points are the same as in the previous sentiment analysis.
The consistent size of the nodes helps to visualize the overall structure and the relationships between bigrams. These labels are designed to avoid overlap and ensure that each bigram is clearly readable, so a moderate font size of 3 was chosen to balance readability and avoid clutter.
The primary goal of this analysis is to identify which country in Europe has the highest average hotel rating and to understand the underlying factors that contribute to positive and negative hotel reviews. This insight can help travelers make more informed decisions about their accommodations, ensuring a safer and more pleasant travel experience. Although the difference in average scores of each country is minimal, it provides a benchmark for travelers prioritizing highly-rated hotels in Europe. Sentiments analysis can guide hotel management in improving service and help travelers understand what to expect. Also, the difference between positive and negative bigrams can help potential guests gauge the reliability and detail of all reviews. Considering public safety concerns in many European countries, understanding overall hotel satisfaction can help lead to a safer travel experience. Hotels with higher ratings may be associated with better security and overall service. In conclusion, this project is crucial in providing actionable insights into European hotel ratings and reviews. Hope that it helps improve hotel service in Europe, also helps travelers make informed decisions, and ultimately creates a more enjoyable and safer travel experience.