探索酒店预订取消的原因#

Screenshot%20from%202020-09-29%2019-08-50.png

我们考虑哪些因素会导致酒店预订被取消。此分析基于来自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]:
$\displaystyle 588.7789$

我们现在考虑没有预订变更的情况,并重新计算预期数量。

[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]:
$\displaystyle 572.7711$

在第二种情况下,我们考虑预订变更(>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]:
$\displaystyle 665.5664$

当预订变更次数不为零时,肯定会有一些变化发生。因此,这给了我们一个提示,即预订变更可能会影响房间取消。

但是预订变更是唯一的混淆变量吗?如果存在一些未观察到的混淆变量,关于这些变量我们在数据集中没有任何信息(特征)。我们是否仍然能够做出与之前相同的声明?

使用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(
../_images/example_notebooks_DoWhy-The_Causal_Story_Behind_Hotel_Booking_Cancellations_25_1.png
../_images/example_notebooks_DoWhy-The_Causal_Story_Behind_Hotel_Booking_Cancellations_25_2.png

步骤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_canceleddifferent_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

我们可以看到我们的估计通过了所有三个反驳测试。这并不能证明其正确性,但增加了对估计的信心。