Hotels offer a intangible experience; an unoccupied room for the night cannot be sold later. When a guest cancels a reservation, the hotel risks losing the associated revenue and may encounter difficulties in reselling the room. Alternatively, hotels may overbook to mitigate potential losses. In the dataset analyzed in this study, a substantial proportion of bookings are canceled. Cancellations introduce uncertainty into revenue forecasting and complicate staffing, pricing, and inventory management decisions. These factors increase operational risk for hotels.
Research question: Which booking characteristics are associated with cancellation, and how accurately can we predict whether a booking will be canceled before the guest arrives?
The analysis uses the Hotel Booking Demand dataset, which records bookings for a city hotel and a resort hotel with arrival dates from July 2015 to August 2017. Each booking has details such as lead time, deposit type, market segment, length of stay, and whether it was canceled. The plan is to clean the data, explore how cancellation rates vary across these factors, and build models that predict cancellation.
We are aiming to use a logistic regression, which models the probability of a yes/no outcome and gives results that are easy to explain. The goal is to hopefully compare it with a random forest to see whether a more flexible model predicts better. Models will be trained on earlier bookings and tested on later ones, and variables that reveal the outcome will be excluded.
A revenue manager could use the results to flag bookings at high risk of canceling and act on them (request a deposit, send a reminder, or adjust overbooking levels). It would also support more consistent demand forecasts and reduce revenue lost to vacancy
| Package | Purpose |
|---|---|
tidyverse |
Data import, cleaning (dplyr, tidyr,
readr), and plotting (ggplot2) |
scales |
Formatting percentages and numbers on plots and tables |
knitr |
Printing clean tables in the HTML report |
ranger |
Fast random forest models (used for the comparison model) |
pROC |
Computing the ROC curve and AUC to evaluate classification models |
library(tidyverse)
library(scales)
library(knitr)
library(ranger)
library(pROC)
children has true blank values (4 of them).country, agent, and
company record missing values as the text
“NULL” instead of a blank. This is easy to miss, because R does
not treat the text “NULL” as missing unless we tell it to.
company is about 94% missing.reservation_status and
reservation_status_date describe what happened to the
booking after the fact, so they reveal whether it was canceled. We will
not use them as predictors.First, we import the data. We tell R to treat blanks and the text “NULL” as missing values so that they are counted correctly.
hotels_raw <- read_csv("hotels.csv", na = c("", "NA", "NULL"),
show_col_types = FALSE)
Next, we check which variables have missing values.
# Count missing values by variable, keeping only variables that have any
hotels_raw %>%
summarise(across(everything(), ~ sum(is.na(.)))) %>%
pivot_longer(everything(), names_to = "variable", values_to = "n_missing") %>%
filter(n_missing > 0) %>%
mutate(pct_missing = percent(n_missing / nrow(hotels_raw), accuracy = 0.1)) %>%
kable(caption = "Variables with missing values in the raw data")
| variable | n_missing | pct_missing |
|---|---|---|
| children | 4 | 0.0% |
| country | 488 | 0.4% |
| agent | 16340 | 13.7% |
| company | 112593 | 94.3% |
Here is how we handle each case and why:
company and agent are
removed. company is about 94% missing, so it
carries almost no usable information. agent is an ID code
with hundreds of different values, which is not useful for our
analysis.country values are recoded as
“Unknown.” This keeps those bookings in the data instead of
throwing them away.children values are set to 0.
There are only 4, and we are assuming that no value means no
children.total_nights and total_guests combine related
columns, lead_bucket groups lead time into ranges,
arrival_month puts the months in calendar order, and
is_canceled becomes a labeled category. These make tables
and plots easier to read and are used in the exploratory analysis
section.hotels <- hotels_raw %>%
select(-company, -agent) %>%
mutate(country = replace_na(country, "Unknown"),
children = replace_na(children, 0)) %>%
filter(adults + children + babies > 0) %>%
filter(market_segment != "Undefined") %>%
mutate(is_canceled = factor(is_canceled, levels = c(0, 1),
labels = c("stayed", "canceled")),
total_nights = stays_in_weekend_nights + stays_in_week_nights,
total_guests = adults + children + babies,
arrival_month = factor(arrival_date_month, levels = month.name),
lead_bucket = cut(lead_time, breaks = c(-1, 7, 30, 90, 180, Inf),
labels = c("0-7 days", "8-30 days", "31-90 days",
"91-180 days", "180+ days")))
# Error checks: no missing outcomes, and cleaning removed only a small share of rows
stopifnot(!anyNA(hotels$is_canceled))
stopifnot(nrow(hotels) > 0.98 * nrow(hotels_raw))
cat("Rows removed during cleaning:", nrow(hotels_raw) - nrow(hotels))
## Rows removed during cleaning: 182
To keep the view short, we show the first 6 rows of the main variables instead of all 32 columns.
hotels %>%
select(hotel, is_canceled, lead_time, arrival_date_year, total_nights,
total_guests, market_segment, deposit_type) %>%
head(6) %>%
kable(caption = "First six rows of the cleaned data (main variables)")
| hotel | is_canceled | lead_time | arrival_date_year | total_nights | total_guests | market_segment | deposit_type |
|---|---|---|---|---|---|---|---|
| Resort Hotel | stayed | 342 | 2015 | 0 | 2 | Direct | No Deposit |
| Resort Hotel | stayed | 737 | 2015 | 0 | 2 | Direct | No Deposit |
| Resort Hotel | stayed | 7 | 2015 | 1 | 1 | Direct | No Deposit |
| Resort Hotel | stayed | 13 | 2015 | 1 | 1 | Corporate | No Deposit |
| Resort Hotel | stayed | 14 | 2015 | 2 | 2 | Online TA | No Deposit |
| Resort Hotel | stayed | 14 | 2015 | 2 | 2 | Online TA | No Deposit |
n_rows <- nrow(hotels)
cancel_rate <- mean(hotels$is_canceled == "canceled")
rate_by_hotel <- hotels %>%
group_by(hotel) %>%
summarise(rate = mean(is_canceled == "canceled"))
city_rate <- rate_by_hotel$rate[rate_by_hotel$hotel == "City Hotel"]
resort_rate <- rate_by_hotel$rate[rate_by_hotel$hotel == "Resort Hotel"]
After cleaning, the data contain 119,208 bookings, and 37.1% of them were canceled. The cancellation rate differs by hotel: 41.8% at the city hotel versus 27.8% at the resort hotel. The median lead time (days between booking and arrival) is 69 days, and the average stay is 3.4 nights.
The tables below summarize the numeric variables and the cancellation rate for the main categorical variables.
# Numeric variables of concern
hotels %>%
select(lead_time, total_nights, total_guests,
previous_cancellations, total_of_special_requests) %>%
pivot_longer(everything(), names_to = "variable") %>%
group_by(variable) %>%
summarise(mean = round(mean(value), 2), median = median(value),
min = min(value), max = max(value)) %>%
kable(caption = "Numeric variables")
| variable | mean | median | min | max |
|---|---|---|---|---|
| lead_time | 104.11 | 69 | 0 | 737 |
| previous_cancellations | 0.09 | 0 | 0 | 26 |
| total_guests | 1.97 | 2 | 1 | 55 |
| total_nights | 3.43 | 3 | 0 | 69 |
| total_of_special_requests | 0.57 | 0 | 0 | 5 |
# Categorical variables of concern: number of bookings and cancellation rate
cat_rate <- function(var) {
hotels %>%
group_by(level = as.character(.data[[var]])) %>%
summarise(bookings = n(),
cancel_rate = percent(mean(is_canceled == "canceled"), accuracy = 0.1),
.groups = "drop") %>%
mutate(variable = var, .before = 1)
}
bind_rows(cat_rate("hotel"), cat_rate("deposit_type"),
cat_rate("market_segment"), cat_rate("customer_type")) %>%
kable(caption = "Cancellation rate by categorical variables")
| variable | level | bookings | cancel_rate |
|---|---|---|---|
| hotel | City Hotel | 79161 | 41.8% |
| hotel | Resort Hotel | 40047 | 27.8% |
| deposit_type | No Deposit | 104459 | 28.4% |
| deposit_type | Non Refund | 14587 | 99.4% |
| deposit_type | Refundable | 162 | 22.2% |
| market_segment | Aviation | 235 | 22.1% |
| market_segment | Complementary | 728 | 12.2% |
| market_segment | Corporate | 5282 | 18.8% |
| market_segment | Direct | 12582 | 15.4% |
| market_segment | Groups | 19791 | 61.1% |
| market_segment | Offline TA/TO | 24182 | 34.3% |
| market_segment | Online TA | 56408 | 36.8% |
| customer_type | Contract | 4072 | 31.0% |
| customer_type | Group | 574 | 10.1% |
| customer_type | Transient | 89476 | 40.8% |
| customer_type | Transient-Party | 25086 | 25.4% |
# Preview: cancellation rate rises sharply with lead time
hotels %>%
group_by(lead_bucket) %>%
summarise(rate = mean(is_canceled == "canceled")) %>%
ggplot(aes(lead_bucket, rate)) +
geom_col(fill = "steelblue") +
geom_text(aes(label = percent(rate, accuracy = 1)), vjust = -0.4) +
scale_y_continuous(labels = percent, limits = c(0, 0.7)) +
labs(title = "Cancellation rate by lead time",
x = "Lead time", y = "Share of bookings canceled")
# Preview: cancellation rate by market segment
hotels %>%
group_by(market_segment) %>%
summarise(rate = mean(is_canceled == "canceled")) %>%
ggplot(aes(reorder(market_segment, rate), rate)) +
geom_col(fill = "steelblue") +
coord_flip() +
scale_y_continuous(labels = percent) +
labs(title = "Cancellation rate by market segment", x = NULL, y = "Share canceled")
# Preview: seasonality, by hotel
hotels %>%
group_by(hotel, arrival_month) %>%
summarise(rate = mean(is_canceled == "canceled"), .groups = "drop") %>%
ggplot(aes(arrival_month, rate, colour = hotel, group = hotel)) +
geom_line() + geom_point() +
scale_y_continuous(labels = percent) +
labs(title = "Cancellation rate by arrival month", x = NULL, y = "Share canceled",
colour = "Hotel") +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
# Preview: lead time by market segment (groups with fewer than 100 bookings hidden)
hotels %>%
group_by(lead_bucket, market_segment) %>%
summarise(rate = mean(is_canceled == "canceled"), n = n(), .groups = "drop") %>%
filter(n >= 100) %>%
ggplot(aes(lead_bucket, market_segment, fill = rate)) +
geom_tile() +
geom_text(aes(label = percent(rate, accuracy = 1)), size = 3) +
scale_fill_gradient(low = "white", high = "firebrick", labels = percent) +
labs(title = "Cancellation rate: lead time by market segment",
x = "Lead time", y = NULL, fill = "Rate")
tidymodels framework
(planned for the final).Yes. Because the outcome is yes/no, I will use classification instead of linear regression.
reservation_status
and its date are excluded.