探索酒店预订取消的原因#
我们考虑哪些因素会导致酒店预订被取消。此分析基于来自Antonio, Almeida 和 Nunes (2019)的酒店预订数据集。在GitHub上,该数据集可在rfordatascience/tidytuesday获取。
预订取消可能有不同的原因。客户可能请求了不可用的服务(例如,停车位),客户可能后来发现酒店不符合他们的要求,或者客户可能只是取消了整个行程。其中一些原因,如停车位,是酒店可以采取行动的,而其他原因,如行程取消,则超出了酒店的控制范围。无论如何,我们希望更好地了解这些因素中哪些导致了预订取消。
找出这一点的黄金标准是使用诸如随机对照试验这样的实验,其中每个客户被随机分配到两个类别之一,即每个客户要么被分配停车位,要么不被分配。然而,这样的实验可能成本过高,并且在某些情况下也不道德(例如,如果人们了解到酒店随机分配不同级别的服务,酒店可能会开始失去声誉)。
我们能否仅使用观测数据或过去收集的数据来回答我们的查询?
[1]:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import dowhy
数据描述#
为了快速浏览功能及其描述,读者可以参考这里。rfordatascience/tidytuesday
[2]:
dataset = pd.read_csv('https://raw.githubusercontent.com/Sid-darthvader/DoWhy-The-Causal-Story-Behind-Hotel-Booking-Cancellations/master/hotel_bookings.csv')
dataset.head()
[2]:
| 酒店 | 是否取消 | 提前时间 | 到达年份 | 到达月份 | 到达周数 | 到达日期 | 周末住宿夜数 | 工作日住宿夜数 | 成人 | ... | 押金类型 | 代理 | 公司 | 等待列表天数 | 客户类型 | 平均每日房价 | 所需停车位 | 特殊要求总数 | 预订状态 | 预订状态日期 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 度假酒店 | 0 | 342 | 2015 | 七月 | 27 | 1 | 0 | 0 | 2 | ... | 无押金 | NaN | NaN | 0 | 临时 | 0.0 | 0 | 0 | 退房 | 2015-07-01 |
| 1 | 度假酒店 | 0 | 737 | 2015 | 七月 | 27 | 1 | 0 | 0 | 2 | ... | 无押金 | NaN | NaN | 0 | 临时 | 0.0 | 0 | 0 | 退房 | 2015-07-01 |
| 2 | 度假酒店 | 0 | 7 | 2015 | 七月 | 27 | 1 | 0 | 1 | 1 | ... | 无押金 | NaN | NaN | 0 | 临时 | 75.0 | 0 | 0 | 退房 | 2015-07-02 |
| 3 | 度假酒店 | 0 | 13 | 2015 | 七月 | 27 | 1 | 0 | 1 | 1 | ... | 无押金 | 304.0 | NaN | 0 | 临时 | 75.0 | 0 | 0 | 退房 | 2015-07-02 |
| 4 | 度假酒店 | 0 | 14 | 2015 | 七月 | 27 | 1 | 0 | 2 | 2 | ... | 无押金 | 240.0 | NaN | 0 | 临时 | 98.0 | 0 | 1 | 退房 | 2015-07-03 |
5 行 × 32 列
[3]:
dataset.columns
[3]:
Index(['hotel', 'is_canceled', 'lead_time', 'arrival_date_year',
'arrival_date_month', 'arrival_date_week_number',
'arrival_date_day_of_month', 'stays_in_weekend_nights',
'stays_in_week_nights', 'adults', 'children', 'babies', 'meal',
'country', 'market_segment', 'distribution_channel',
'is_repeated_guest', 'previous_cancellations',
'previous_bookings_not_canceled', 'reserved_room_type',
'assigned_room_type', 'booking_changes', 'deposit_type', 'agent',
'company', 'days_in_waiting_list', 'customer_type', 'adr',
'required_car_parking_spaces', 'total_of_special_requests',
'reservation_status', 'reservation_status_date'],
dtype='object')
特征工程#
让我们创建一些新的且有意义的特征,以减少数据集的维度。 - 总停留时间 = stays_in_weekend_nights + stays_in_week_nights - 客人数量 = adults + children + babies - 分配不同房间 = 1 如果 reserved_room_type 和 assigned_room_type 不同,否则为 0。
[4]:
# Total stay in nights
dataset['total_stay'] = dataset['stays_in_week_nights']+dataset['stays_in_weekend_nights']
# Total number of guests
dataset['guests'] = dataset['adults']+dataset['children'] +dataset['babies']
# Creating the different_room_assigned feature
dataset['different_room_assigned']=0
slice_indices =dataset['reserved_room_type']!=dataset['assigned_room_type']
dataset.loc[slice_indices,'different_room_assigned']=1
# Deleting older features
dataset = dataset.drop(['stays_in_week_nights','stays_in_weekend_nights','adults','children','babies'
,'reserved_room_type','assigned_room_type'],axis=1)
dataset.columns
[4]:
Index(['hotel', 'is_canceled', 'lead_time', 'arrival_date_year',
'arrival_date_month', 'arrival_date_week_number',
'arrival_date_day_of_month', 'meal', 'country', 'market_segment',
'distribution_channel', 'is_repeated_guest', 'previous_cancellations',
'previous_bookings_not_canceled', 'booking_changes', 'deposit_type',
'agent', 'company', 'days_in_waiting_list', 'customer_type', 'adr',
'required_car_parking_spaces', 'total_of_special_requests',
'reservation_status', 'reservation_status_date', 'total_stay', 'guests',
'different_room_assigned'],
dtype='object')
我们还删除了其他包含NULL值或具有太多唯一值的列(例如,代理ID)。我们还用最常见的国家填补了country列的缺失值。我们删除了distribution_channel,因为它与market_segment有高度重叠。
[5]:
dataset.isnull().sum() # Country,Agent,Company contain 488,16340,112593 missing entries
dataset = dataset.drop(['agent','company'],axis=1)
# Replacing missing countries with most freqently occuring countries
dataset['country']= dataset['country'].fillna(dataset['country'].mode()[0])
[6]:
dataset = dataset.drop(['reservation_status','reservation_status_date','arrival_date_day_of_month'],axis=1)
dataset = dataset.drop(['arrival_date_year'],axis=1)
dataset = dataset.drop(['distribution_channel'], axis=1)
[7]:
# Replacing 1 by True and 0 by False for the experiment and outcome variables
dataset['different_room_assigned']= dataset['different_room_assigned'].replace(1,True)
dataset['different_room_assigned']= dataset['different_room_assigned'].replace(0,False)
dataset['is_canceled']= dataset['is_canceled'].replace(1,True)
dataset['is_canceled']= dataset['is_canceled'].replace(0,False)
dataset.dropna(inplace=True)
print(dataset.columns)
dataset.iloc[:, 5:20].head(100)
Index(['hotel', 'is_canceled', 'lead_time', 'arrival_date_month',
'arrival_date_week_number', 'meal', 'country', 'market_segment',
'is_repeated_guest', 'previous_cancellations',
'previous_bookings_not_canceled', 'booking_changes', 'deposit_type',
'days_in_waiting_list', 'customer_type', 'adr',
'required_car_parking_spaces', 'total_of_special_requests',
'total_stay', 'guests', 'different_room_assigned'],
dtype='object')
[7]:
| 餐食 | 国家 | 市场细分 | 是否为重复客人 | 先前取消次数 | 先前未取消的预订 | 预订变更 | 押金类型 | 等待列表中的天数 | 客户类型 | 平均每日房价 | 所需停车位 | 特殊要求总数 | 总住宿天数 | 客人数量 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | BB | PRT | 直接 | 0 | 0 | 0 | 3 | 无押金 | 0 | 临时 | 0.00 | 0 | 0 | 0 | 2.0 |
| 1 | BB | PRT | 直接 | 0 | 0 | 0 | 4 | 无押金 | 0 | 临时 | 0.00 | 0 | 0 | 0 | 2.0 |
| 2 | BB | GBR | 直接 | 0 | 0 | 0 | 0 | 无押金 | 0 | 临时 | 75.00 | 0 | 0 | 1 | 1.0 |
| 3 | BB | GBR | 企业 | 0 | 0 | 0 | 0 | 无押金 | 0 | 临时 | 75.00 | 0 | 0 | 1 | 1.0 |
| 4 | BB | GBR | 在线旅行社 | 0 | 0 | 0 | 0 | 无押金 | 0 | 临时 | 98.00 | 0 | 1 | 2 | 2.0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 95 | BB | PRT | 在线旅行社 | 0 | 0 | 0 | 0 | 无押金 | 0 | 临时 | 73.80 | 0 | 1 | 2 | 2.0 |
| 96 | BB | PRT | 在线旅行社 | 0 | 0 | 0 | 0 | 无押金 | 0 | 临时 | 117.00 | 0 | 1 | 7 | 2.0 |
| 97 | HB | ESP | 离线 TA/TO | 0 | 0 | 0 | 0 | 无押金 | 0 | 临时 | 196.54 | 0 | 1 | 7 | 3.0 |
| 98 | BB | PRT | 在线旅行社 | 0 | 0 | 0 | 0 | 无押金 | 0 | 临时 | 99.30 | 1 | 2 | 7 | 3.0 |
| 99 | BB | DEU | 直接 | 0 | 0 | 0 | 0 | 无押金 | 0 | 临时 | 90.95 | 0 | 0 | 7 | 2.0 |
100 行 × 15 列
[8]:
dataset = dataset[dataset.deposit_type=="No Deposit"]
dataset.groupby(['deposit_type','is_canceled']).count()
[8]:
| 酒店 | 提前预订时间 | 到达日期月份 | 到达日期周数 | 餐饮 | 国家 | 市场细分 | 是否为回头客 | 之前取消次数 | 之前未取消的预订次数 | 预订变更次数 | 等待列表中的天数 | 客户类型 | 平均每日房价 | 所需停车位数量 | 特殊要求总数 | 总住宿天数 | 客人数量 | 分配不同房间 | ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 存款类型 | 是否取消 | |||||||||||||||||||
| 无存款 | 错误 | 74947 | 74947 | 74947 | 74947 | 74947 | 74947 | 74947 | 74947 | 74947 | 74947 | 74947 | 74947 | 74947 | 74947 | 74947 | 74947 | 74947 | 74947 | 74947 |
| True | 29690 | 29690 | 29690 | 29690 | 29690 | 29690 | 29690 | 29690 | 29690 | 29690 | 29690 | 29690 | 29690 | 29690 | 29690 | 29690 | 29690 | 29690 | 29690 |
[9]:
dataset_copy = dataset.copy(deep=True)
计算预期计数#
由于取消次数和分配不同房间的次数严重不平衡,我们首先随机选择1000个观察值,看看在多少情况下变量‘is_cancelled’和‘different_room_assigned’达到相同的值。然后,这个过程重复10000次,预期计数接近50%(即这两个变量随机达到相同值的概率)。因此,从统计学的角度来看,我们在这个阶段没有明确的结论。因此,分配与客户在预订时保留的房间不同的房间,可能会导致他/她取消该预订,也可能不会。
[10]:
counts_sum=0
for i in range(1,10000):
counts_i = 0
rdf = dataset.sample(1000)
counts_i = rdf[rdf["is_canceled"]== rdf["different_room_assigned"]].shape[0]
counts_sum+= counts_i
counts_sum/10000
[10]:
我们现在考虑没有预订变更的情况,并重新计算预期数量。
[11]:
# Expected Count when there are no booking changes
counts_sum=0
for i in range(1,10000):
counts_i = 0
rdf = dataset[dataset["booking_changes"]==0].sample(1000)
counts_i = rdf[rdf["is_canceled"]== rdf["different_room_assigned"]].shape[0]
counts_sum+= counts_i
counts_sum/10000
[11]:
在第二种情况下,我们考虑预订变更(>0)的场景,并重新计算预期计数。
[12]:
# Expected Count when there are booking changes = 66.4%
counts_sum=0
for i in range(1,10000):
counts_i = 0
rdf = dataset[dataset["booking_changes"]>0].sample(1000)
counts_i = rdf[rdf["is_canceled"]== rdf["different_room_assigned"]].shape[0]
counts_sum+= counts_i
counts_sum/10000
[12]:
当预订变更次数不为零时,肯定会有一些变化发生。因此,这给了我们一个提示,即预订变更可能会影响房间取消。
但是预订变更是唯一的混淆变量吗?如果存在一些未观察到的混淆变量,关于这些变量我们在数据集中没有任何信息(特征)。我们是否仍然能够做出与之前相同的声明?
使用DoWhy估计因果效应#
步骤1. 创建因果图#
使用假设将你对预测建模问题的先验知识表示为CI图。别担心,你不需要在这个阶段指定完整的图。即使是部分图也足够了,其余的可以由DoWhy来推断 ;-)
以下是一系列假设,这些假设随后被转化为因果图:-
市场细分 有2个层次,“TA”指的是“旅行社”,“TO”指的是“旅游运营商”,因此它应该影响提前期(即预订和到达之间的天数)。
国家也会在决定一个人是否提前预订(因此有更多的提前时间)以及一个人更喜欢哪种类型的餐食方面发挥作用。
提前时间 肯定会影响到 等待天数 的数量(如果你预订得晚,找到预订的机会会更少)。此外,较高的 提前时间 也可能导致 取消。
等待天数、总住宿(以晚为单位)以及客人数量可能会影响预订是否被取消或保留。
Previous Booking Retentions 会影响客户是否是回头客。此外,这两个变量都会影响预订是否会被取消(例如,过去保留了5次预订的客户保留这次预订的可能性更高。同样,经常取消预订的人重复取消的可能性也更高)。
预订变更 会影响客户是否被分配到 不同的房间,这也可能导致 取消。
最后,预订变更的数量是唯一影响治疗和结果的变量,这极不可能,可能存在一些未观察到的混杂因素,关于这些因素,我们的数据中没有捕获到任何信息。
[13]:
import pygraphviz
causal_graph = """digraph {
different_room_assigned[label="Different Room Assigned"];
is_canceled[label="Booking Cancelled"];
booking_changes[label="Booking Changes"];
previous_bookings_not_canceled[label="Previous Booking Retentions"];
days_in_waiting_list[label="Days in Waitlist"];
lead_time[label="Lead Time"];
market_segment[label="Market Segment"];
country[label="Country"];
U[label="Unobserved Confounders",observed="no"];
is_repeated_guest;
total_stay;
guests;
meal;
hotel;
U->{different_room_assigned,required_car_parking_spaces,guests,total_stay,total_of_special_requests};
market_segment -> lead_time;
lead_time->is_canceled; country -> lead_time;
different_room_assigned -> is_canceled;
country->meal;
lead_time -> days_in_waiting_list;
days_in_waiting_list ->{is_canceled,different_room_assigned};
previous_bookings_not_canceled -> is_canceled;
previous_bookings_not_canceled -> is_repeated_guest;
is_repeated_guest -> {different_room_assigned,is_canceled};
total_stay -> is_canceled;
guests -> is_canceled;
booking_changes -> different_room_assigned; booking_changes -> is_canceled;
hotel -> {different_room_assigned,is_canceled};
required_car_parking_spaces -> is_canceled;
total_of_special_requests -> {booking_changes,is_canceled};
country->{hotel, required_car_parking_spaces,total_of_special_requests};
market_segment->{hotel, required_car_parking_spaces,total_of_special_requests};
}"""
在这里,处理是指分配客户在预订期间预订的相同类型的房间。结果将是预订是否被取消。常见原因代表我们认为对结果和处理都有因果影响的变量。根据我们的因果假设,满足此标准的2个变量是预订变更和未观察到的混杂因素。因此,如果我们没有明确指定图形(不推荐!),也可以将这些作为参数提供给下面提到的函数。
为了帮助识别因果效应,我们从图中移除了未观察到的混杂因素节点。(要检查,您可以使用原始图并运行以下代码。identify_effect 方法会发现无法识别该效应。)
[14]:
model= dowhy.CausalModel(
data = dataset,
graph=causal_graph.replace("\n", " "),
treatment="different_room_assigned",
outcome='is_canceled')
model.view_model()
from IPython.display import Image, display
display(Image(filename="causal_model.png"))
/__w/dowhy/dowhy/dowhy/causal_model.py:583: UserWarning: 1 variables are assumed unobserved because they are not in the dataset. Configure the logging level to `logging.WARNING` or higher for additional details.
warnings.warn(
步骤2. 识别因果效应#
我们说,如果改变治疗会导致结果的变化,而保持其他一切不变,那么治疗会导致结果。因此,在这一步中,通过使用因果图的属性,我们确定了要估计的因果效应。
[15]:
#Identify the causal effect
identified_estimand = model.identify_effect(proceed_when_unidentifiable=True)
print(identified_estimand)
Estimand type: EstimandType.NONPARAMETRIC_ATE
### Estimand : 1
Estimand name: backdoor
Estimand expression:
d ↪
──────────────────────────(E[is_canceled|days_in_waiting_list,booking_changes, ↪
d[different_room_assigned] ↪
↪ ↪
↪ lead_time,total_stay,guests,total_of_special_requests,required_car_parking_s ↪
↪ ↪
↪
↪ paces,hotel,is_repeated_guest])
↪
Estimand assumption 1, Unconfoundedness: If U→{different_room_assigned} and U→is_canceled then P(is_canceled|different_room_assigned,days_in_waiting_list,booking_changes,lead_time,total_stay,guests,total_of_special_requests,required_car_parking_spaces,hotel,is_repeated_guest,U) = P(is_canceled|different_room_assigned,days_in_waiting_list,booking_changes,lead_time,total_stay,guests,total_of_special_requests,required_car_parking_spaces,hotel,is_repeated_guest)
### Estimand : 2
Estimand name: iv
No such variable(s) found!
### Estimand : 3
Estimand name: frontdoor
No such variable(s) found!
步骤3. 估计已识别的估计量#
[16]:
estimate = model.estimate_effect(identified_estimand,
method_name="backdoor.propensity_score_weighting",target_units="ate")
# ATE = Average Treatment Effect
# ATT = Average Treatment Effect on Treated (i.e. those who were assigned a different room)
# ATC = Average Treatment Effect on Control (i.e. those who were not assigned a different room)
print(estimate)
*** Causal Estimate ***
## Identified estimand
Estimand type: EstimandType.NONPARAMETRIC_ATE
### Estimand : 1
Estimand name: backdoor
Estimand expression:
d ↪
──────────────────────────(E[is_canceled|days_in_waiting_list,booking_changes, ↪
d[different_room_assigned] ↪
↪ ↪
↪ lead_time,total_stay,guests,total_of_special_requests,required_car_parking_s ↪
↪ ↪
↪
↪ paces,hotel,is_repeated_guest])
↪
Estimand assumption 1, Unconfoundedness: If U→{different_room_assigned} and U→is_canceled then P(is_canceled|different_room_assigned,days_in_waiting_list,booking_changes,lead_time,total_stay,guests,total_of_special_requests,required_car_parking_spaces,hotel,is_repeated_guest,U) = P(is_canceled|different_room_assigned,days_in_waiting_list,booking_changes,lead_time,total_stay,guests,total_of_special_requests,required_car_parking_spaces,hotel,is_repeated_guest)
## Realized estimand
b: is_canceled~different_room_assigned+days_in_waiting_list+booking_changes+lead_time+total_stay+guests+total_of_special_requests+required_car_parking_spaces+hotel+is_repeated_guest
Target units: ate
## Estimate
Mean value: -0.2622285649999514
/github/home/.cache/pypoetry/virtualenvs/dowhy-oN2hW5jr-py3.8/lib/python3.8/site-packages/sklearn/linear_model/_logistic.py:460: ConvergenceWarning: lbfgs failed to converge (status=1):
STOP: TOTAL NO. of ITERATIONS REACHED LIMIT.
Increase the number of iterations (max_iter) or scale the data as shown in:
https://scikit-learn.org/stable/modules/preprocessing.html
Please also refer to the documentation for alternative solver options:
https://scikit-learn.org/stable/modules/linear_model.html#logistic-regression
n_iter_i = _check_optimize_result(
结果令人惊讶。这意味着分配不同的房间减少了取消预订的机会。这里还有更多需要探讨的地方:这是正确的因果效应吗?是否只有在预订的房间不可用时才会分配不同的房间,因此分配不同的房间对客户有积极的影响(而不是不分配房间)?
可能还有其他机制在起作用。也许只有在办理入住时才会分配不同的房间,而且一旦顾客已经到达酒店,取消预订的几率很低?在这种情况下,图表缺少了一个关键变量,即这些事件发生的时间。different_room_assigned 是否主要发生在预订当天?了解这个变量可以帮助改进图表和我们的分析。
虽然之前的关联分析表明is_canceled和different_room_assigned之间存在正相关关系,但使用DoWhy估计因果效应却呈现出不同的情况。这意味着减少酒店中different_room_assigned数量的决策/政策可能会适得其反。
步骤4. 反驳结果#
请注意,因果部分并非来自数据。它来自于你的假设,这些假设导致了识别。数据仅用于统计估计。因此,验证我们的假设在第一步是否正确变得至关重要!
当存在另一个常见原因时会发生什么?当治疗本身是安慰剂时会发生什么?
方法-1#
随机常见原因:- 向数据中添加随机抽取的协变量并重新运行分析,以查看因果估计是否发生变化。如果我们最初的假设是正确的,那么因果估计不应该有太大变化。
[17]:
refute1_results=model.refute_estimate(identified_estimand, estimate,
method_name="random_common_cause")
print(refute1_results)
Refute: Add a random common cause
Estimated effect:-0.2622285649999514
New effect:-0.26222856499995134
p value:1.0
方法-2#
安慰剂治疗反驳者:- 随机分配任何协变量作为治疗并重新运行分析。如果我们的假设是正确的,那么这个新发现的估计值应该趋近于0。
[18]:
refute2_results=model.refute_estimate(identified_estimand, estimate,
method_name="placebo_treatment_refuter")
print(refute2_results)
Refute: Use a Placebo Treatment
Estimated effect:-0.2622285649999514
New effect:0.05420218503316096
p value:0.0
方法-3#
数据子集反驳器:- 创建数据的子集(类似于交叉验证),并检查因果估计是否在子集之间变化。如果我们的假设是正确的,那么不应该有太大的变化。
[19]:
refute3_results=model.refute_estimate(identified_estimand, estimate,
method_name="data_subset_refuter")
print(refute3_results)
Refute: Use a subset of data
Estimated effect:-0.2622285649999514
New effect:-0.26239836091544644
p value:0.88
我们可以看到我们的估计通过了所有三个反驳测试。这并不能证明其正确性,但增加了对估计的信心。