1. Introduction

1.1 The problem

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?

1.2 Data and method

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.

1.3 Proposed approach

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.

1.4 How this helps the reader

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

2. Packages Required

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)       

3: Data Preparation

3.1 Source of the data

3.2 About the source data

  • Origin: The data was published with the paper above by Nuno Antonio, Ana de Almeida, and Luis Nunes. TidyTuesday later re-shared it for R practice.
  • Purpose: The dataset is a comprehensive list of hotel bookings that were due to arrive between July 1st, 2015 and August 31st, 2017, including bookings that were actually used and bookings that were canceled. It was published to support research on hotel booking demand, such as revenue management and predicting cancellations. [Confirm the exact stated purpose in the paper’s abstract.]
  • Date published: February 2019 (collection period: July 2015 to August 2017).
  • Hotels covered: Two hotels, a city hotel and a resort hotel. [Add the locations from the paper.]
  • Size: 119,390 observations and 32 variables.
  • Content: Details about each hotel booking, including the guests, arrival date, room type, deposit type, market segment, and whether or not the booking was canceled.
  • Missing values:
    • Only children has true blank values (4 of them).
    • The variables 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.
  • Other peculiarities:
    • 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.
    • A small number of records look like errors, such as bookings with zero guests.

3.3 Importing and cleaning

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")
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:

  1. 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.
  2. Missing country values are recoded as “Unknown.” This keeps those bookings in the data instead of throwing them away.
  3. Missing children values are set to 0. There are only 4, and we are assuming that no value means no children.
  4. Bookings with zero guests are removed. A booking with no adults, children, or babies is almost certainly a data error.
  5. The market segment “Undefined” is removed. It has only 2 bookings, which is too few to be useful.
  6. We create helper variables. 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

3.4 The cleaned data

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)")
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

3.5 Summary of the variables

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")
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")
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%

4. Proposed Exploratory Data Analysis

4.1 Uncovering information that is not self-evident

  • Slice the data by hotel, market segment, deposit type, customer type, arrival month, and arrival year to see where cancellation rates differ most.
  • Create new variables: lead-time buckets (done), total nights and total guests (done), and the share of the stay that falls on weekends. I also checked a flag for whether the assigned room differs from the reserved room. Bookings with a room change cancel far less often (about 5% versus 42%), but this is almost certainly leakage: a guest who cancels never checks in, so no room is ever reassigned. I will use it to understand the data but exclude it from the prediction model.
  • Combine factors: for example, lead time by deposit type, to see whether long lead times are risky only for certain booking types.
  • Check seasonality by looking at cancellation rates by month and year.
  • Investigate oddities, such as the very high cancellation rate for non-refundable deposits. This may reflect how the data were recorded, so I will treat it as a question to examine instead of a conclusion.
  • Joining data: I may join an external table, such as country region or holiday calendar, if it adds explanatory power.

4.2 Plots and tables

  • Bar chart of cancellation rate by lead-time bucket (preview below).
  • Bar chart of cancellation rate by market segment (preview below).
  • Line chart of cancellation rate by month, split by hotel (preview below).
  • Heat map of cancellation rate for lead time by market segment (preview below).
  • Model results: an odds-ratio table, ROC curve, confusion matrix, and variable-importance plot.
# 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")

4.3 What I still need to learn

  • Evaluating classification models properly (confusion matrix, precision, recall, AUC).
  • Interpreting logistic regression coefficients as odds ratios.
  • Handling class imbalance and avoiding data leakage.
  • Using cross-validation and the tidymodels framework (planned for the final).
  • Choosing a decision threshold based on business costs.

4.4 Machine learning

Yes. Because the outcome is yes/no, I will use classification instead of linear regression.

  • Primary model: logistic regression. It estimates the probability that a booking is canceled, and each predictor produces an odds ratio that a manager can interpret.
  • Comparison model: random forest. It can capture interactions that logistic regression misses. I will compare accuracy against explainability.
  • Evaluation: train on 2015-2016 bookings, test on 2017 bookings (mimicking real use), and report AUC, precision, and recall instead of accuracy alone.
  • Leakage control: reservation_status and its date are excluded.
  • Possible extension: a cost-based threshold that weighs an unexpected cancellation against an unnecessary intervention.