A customer may have requested something that was not available (e.g., car parking), a customer may have found later that the hotel did not meet their requirements, or a customer may have simply cancelled their entire trip. Some of these like car parking are actionable by the hotel whereas others like trip cancellation are outside the hotel's control. In any case, we would like to better understand which of these factors cause booking cancellations.
이러한 상황에서, 호텔 예약을 취소하는 원인을 찾기 위해서는 각 고객들이 랜덤하게 두 가지의 카테고리에 속하는 방식의 Randomized Controlled Trials 와 같은 실험이 가장 Golden Standard인데요. 이러한 실험은 특정 상황에서는 너무 비용이 크거나, 윤리적이지 않습니다. 예를 들어 호텔이 고객들에게 서비스의 차등을 준다는 점을 깨닫게 된다면, 호텔의 명성에 손해가 갈 수 있습니다.
관측 데이터를 통해서, 이러한 질문에 대답할 수는 없을까요?
# Config dict to set the logging level : 로깅의 수준을 세팅하기 위한 config 라이브러리import logging.configDEFAULT_LOGGING ={'version':1,'disable_existing_loggers':False,'loggers':{'':{'level':'INFO',},}}logging.config.dictConfig(DEFAULT_LOGGING)# Disabling warnings outputimport warningsfrom sklearn.exceptions import DataConversionWarning, ConvergenceWarningwarnings.filterwarnings(action='ignore', category=DataConversionWarning)warnings.filterwarnings(action='ignore', category=ConvergenceWarning)warnings.filterwarnings(action='ignore', category=UserWarning)warnings.filterwarnings('ignore')#!pip install dowhy : dowhy 설치 되지 않았을 경우import dowhyimport pandas as pdimport numpy as npimport matplotlib.pyplot as plt
의미 있는 변수들을 만들고, 데이터 셋의 디멘션을 줄이기 위해서 Feature Engineering 을 진행합니다.
Total Stay = stays_in_weekend_nights + stays_in_week_nights
Guests = adults + children + babies
Different_room_assigned = 1 if reserved_room_type & assigned_room_type are different, 0 otherwise.
# Total stay in nightsdataset['total_stay']= dataset['stays_in_week_nights']+dataset['stays_in_weekend_nights']# Total number of guestsdataset['guests']= dataset['adults']+dataset['children']+dataset['babies']# Creating the different_room_assigned featuredataset['different_room_assigned']=0slice_indices =dataset['reserved_room_type']!=dataset['assigned_room_type']dataset.loc[slice_indices,'different_room_assigned']=1
# Total stay in nightsdataset['total_stay']= dataset['stays_in_week_nights']+dataset['stays_in_weekend_nights']# Total number of guestsdataset['guests']= dataset['adults']+dataset['children']+dataset['babies']# Creating the different_room_assigned featuredataset['different_room_assigned']=0slice_indices =dataset['reserved_room_type']!=dataset['assigned_room_type']# Reserved room type 과 Assigned room type 이 다른 인덱스에 대해서, 'different_room_assigned' 컬럼을 1로 채워라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
NULL 값이 있거나, 유저 단위에 1:1 대응이 될 정도로 유니크한 값이 많은 컬럼은 삭제합니다. 또한 country의 결측치는 최빈값으로 메웁니다. distribution_channel 이라는 컬럼은 market_segment 와 겹치는 부분이 많기 때문에 삭제됩니다.
dataset = dataset.drop(['agent','company'],axis=1)# Replacing missing countries with most freqently occuring countriesdataset['country']= dataset['country'].fillna(dataset['country'].mode()[0])
불필요한 컬럼을 판단해 삭제하고, different_room_assigned 와 is_canceled 라는 컬럼을 여부에 따라 1,0으로 대체합니다.
# Replacing 1 by True and 0 by False for the experiment and outcome variablesdataset['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)
판다스에는 데이터프레임의 복사본을 만들어주는 pandas.DataFrame.copy가 있고 a = b와는 다른 방식의 복사입니다.
a = b는 원본 데이터가 변하면 똑같이 변하는 얕은 복사인 반면, pandas.DataFrame.copy는 복사 당시의 데이터프레임 상태만 복사되는 깊은 복사입니다.
얕은 복사를 하면 복사본은 원본과 데이터/index를 공유하지만, 깊은 복사는 복사본이 자신만의 데이터/index를 갖게합니다.
dataset_copy = dataset.copy(deep=True)
Calculating Expected Counts : Confounder 를 유추할 수 있는 간단한 방법
예약 취소의 개수와, 다른 방에 배정되는 케이스의 개수가 심각하게 불균형적이기 때문에, 랜덤으로 최초 1000개의 관측치를 선택하며 is_cancelled & different_room_assigned 가 같은 값을 달성하는지를 확인합니다. 이 전체적인 프로세스는 10000회 반복되고, 그 중에서 예상되는 달성 횟수는 50%에 가깝습니다. 그 이유는 두개의 변수가 같은 값을 랜덤하게 가질 수 있을 확률은 50% 이기 때문입니다.
따라서 통계적으로 이야기하면, 이 단계에서 유한한 결론이 있는 것은 아닙니다. 따라서, 고객이 예약한 방과 다른 방을 배정하는 상황은 그 고객이 예약을 취소하게 할수도, 아닐수도 있습니다.
첫번째 시나리오는, 아무 조건 가정 없이 샘플링을 진행합니다.
counts_sum=0for i inrange(1,10000): counts_i =0# 데이터셋에서 1000개의 행을 랜덤 샘플링합니다. rdf = dataset.sample(1000)# is_canceled FALSE, different_room_assigned FALSE# is_canceled TRUE, different_room_assigned TRUE 인 행들의 개수를 셉니다. counts_i = rdf[rdf["is_canceled"]== rdf["different_room_assigned"]].shape[0]# 이 과정을 10000회 반복하면서, 총 몇개의 행들이 같았는지를 더합니다. counts_sum+= counts_i# 10000회 반복 시행을 종료했을 때, 평균적으로 몇개의 행들이 같았는지를 파악합니다.counts_sum/10000
588.7015
두번째 시나리오는, 예약에 변경이 없었을 것이라는 조건을 가정한 후에 샘플링을 진행합니다.
# Expected Count when there are no booking changes : booking_changes = 0 이라는 조건을 걸어 계층화할 경우counts_sum=0for i inrange(1,10000): counts_i =0# 조건을 건 후 그 중에서 1000개의 행을 랜덤 샘플링합니다. rdf = dataset[dataset["booking_changes"]==0].sample(1000) counts_i = rdf[rdf["is_canceled"]== rdf["different_room_assigned"]].shape[0] counts_sum+= counts_icounts_sum/10000
572.608
세번째 시나리오는 예약에 변경이 있었을 것이라는 조건(>0)을 가정한 후에 샘플링을 진행합니다.
# Expected Count when there are booking changes = 66.4%counts_sum=0for i inrange(1,10000): counts_i =0# 조건을 건 후 그 중에서 1000개의 행을 랜덤 샘플링합니다. rdf = dataset[dataset["booking_changes"]>0].sample(1000) counts_i = rdf[rdf["is_canceled"]== rdf["different_room_assigned"]].shape[0] counts_sum+= counts_icounts_sum/10000
666.015
확실히 예약에 변경이 있었을 것이라는 조건을 통해서, 무언가 숫자에 변화를 볼 수 있습니다. booking_changes 변수가 곧 교란변수가 될 수 있다는 힌트가 됩니다.
하지만 booking_changes 변수가 유일한 교란변수일까요? 만일 관측되지 않은 교란변수가 있고, 그것이 데이터셋에 변수 형태로 포함되지 않은 정보라면, 우리는 이 booking_changes 변수가 곧 교란변수가 될 수 있다는 주장을 계속할 수 있을까요?
DoWhy 의 시작
Step-1. Create a Causal Graph : 인과 그래프 생성하기
예측 모델링 문제에 대한 사전지식을, 인과 그래프 형태로 가정을 사용해서 표현합니다. 전체 그래프를 이 단계에서 표현하지 않아도, 일부 그래프만 표현하여도 충분합니다. 나머지는 DoWhy 를 통해서 찾을 수 있습니다.
아래 내용이 인과 모형으로 해석된 몇 개의 가정들입니다.
Market Segment 변수 : TA(Travel Agents), TO(Tour Operators) 라는 2가지 값으로 구성되며, Lead Time 변수에 영향을 줍니다.
Country 변수 : 한 사람이 일찍 호텔을 예약할지, 아닐지에 대해 좌우하는 역할을 할 수 있으며, 궁극적으로 일찍 호텔을 예약하는 행동은 Lead Time 변수에 영향을 줍니다. 그 외로도, Meal 변수에도 영향을 줍니다.
Lead Time 변수 : Days in Waitlist 변수에 확실하게 영향을 줍니다. 늦게 예약할수록, 예약을 할 수 있는 기회가 적어지기 때문입니다. 추가적으로 높은 Lead Time 은 Cancellations 변수를 높아지게 만들 수 있습니다.
Previous Booking Retentions 변수 : 고객의 Repeated Guest 여부를 좌우하고, 이 두개의 변수는 모두 Cancelled 에도 영향을 줄 수 있습니다. 리텐션이 높았던 고객일수록 취소할 확률이 낮고, 리텐션이 낮았던 즉 늘 취소했던 고객이라면 취소할 확률이 높습닌다.
Booking Changes 변수 : 예약을 변경하는 것은 Different room assigned 변수를 좌우하고, Cancellation 에도 영향을 줄 수 있습니다.
정리하면, Booking Changes 변수가 Treatment, Outcome 에 영향을 주는 가장 유일한 교란변수일 확률은 낮습니다. 현재 변수는 물론, 데이터셋에 고려되지 않은 정보들까지 포함하여 다른 관측되지 않은 교란변수가 많을 것입니다.
Pygraphviz 활용
pygraphviz 설치에서 에러가 발생할 경우, 터미널에서 아래와 같이 해결할 수 있습니다.
Diagraph 란, Edge 와 Node 로 구성되고 추가적인 데이터나 특성을 포함합니다. Self loops 는 가능하지만, Multiple edge 는 허용되지 않습니다. 노드는 주로 임의의 해싱가능한 Python 객체입니다. Edge는 노드간의 링크입니다. (설명)
import pygraphviz# 변수명[label = 그림에 표기될 이름]# 변수명(label 없으면 변수명 그대로 그림에 표기됨)# U = Unobserved confounder# 변수명 -> 변수명 : 영향을 주는 관계# 변수명 -> {변수명, 변수명} : 다수에 영향을 주는 변수 관계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"]; is_repeated_guest; total_stay; guests; meal; hotel; U->different_room_assigned; U->is_canceled; U->required_car_parking_spaces; 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; previous_bookings_not_canceled -> is_canceled; previous_bookings_not_canceled -> is_repeated_guest; is_repeated_guest -> is_canceled; total_stay -> is_canceled; guests -> is_canceled; booking_changes -> different_room_assigned; booking_changes -> is_canceled; hotel -> is_canceled; required_car_parking_spaces -> is_canceled; total_of_special_requests -> is_canceled; country -> {hotel, required_car_parking_spaces,total_of_special_requests,is_canceled}; market_segment -> {hotel, required_car_parking_spaces,total_of_special_requests,is_canceled}; }"""
여기서, Treatment 는 고객이 예약한 방을 그대로 배정해주는 것입니다. Outcome은 예약이 취소되었는지에 대한 여부입니다.
Common Causes : 우리의 인과 그래프에 따라 Treatment, Outcome 에 영향을 줄 수 있는 변수들을 의미합니다.
앞서 세운 인과적 가정에 따르면 이 Common Causes 가 될 수 있는 조건을 만족하는 변수는 Booking Changes 그리고 Unobserved Confounders 변수 2가지 입니다.
따라서, 만일 그래프를 명시적으로 작성하지 않을 경우에는 아래 함수에 따라 파라미터들을 제공할 수 있습니다.
So if we are not specifying the graph explicitly (Not Recommended!), one can also provide these as parameters in the function mentioned below.
model= dowhy.CausalModel( data = dataset, graph = causal_graph.replace("\n", " "), # causal_graph 라는 문자열에서 띄어쓰기를 space 로 대체해주는 역할 treatment ='different_room_assigned', outcome ='is_canceled')model.view_model()from IPython.display import Image, displaydisplay(Image(filename ="causal_model.png"))
INFO:dowhy.causal_model:Model to find the causal effect of treatment ['different_room_assigned'] on outcome ['is_canceled']
Step-2. Identify Causal Effect : 인과 효과 식별하기
우리는 모든 다른 것들을 유지한 채로 Treatment 를 변화시킬 때, Outcome 에 변화가 있다면 Treatment가 Outcome의 원인이 된다라고 이야기합니다. 따라서 이 단계에서는, 인과 그래프의 특성들을 활용해서 추정하고자 하는 인과 효과를 식별합니다.
dowhy.CausalModel() 클래스를 활용해서 모델을 입력하게 되면, 아래 함수들을 사용할 수 있습니다. (모듈 코드)
identify_effect()
Identified estimand 를 리턴하는 주요한 메소드. 만일 Estimand 의 타입이 non-parametric ATE라면, 주어진 인과 모형에서 Identified estimand가 있는지를 확인하기 위해서 Backdoor / Instrumental Variable / Frontdoor Identification 메소드들을 사용합니다.
estimate_effect()
Step-3 에서 이어집니다.
do()
refute_estimate()
view_model()
interpret()
summary()
Estimand
예를 들어, 7년간의 월별 수익이 Gaussian 분포를 따른다고 가정합니다. Gaussian 분포는 평균, 분산을 Parameter 로 가지고 있어, 이 경우에 개별 월 수입은 Random variable 이라고 할 수 있습니다. (출처)
Estimand : (예시) Gaussian의 Parameter 가 되는 평균, 분산
Estimate : Random variable 로 월별 수익을 추정한 값
평균의 Estimate : 100만원 +- standard error
표준 편차의 Estimate : 10만원
Estimator : Random variable 로부터 Estimate를 얻어내는 함수
평균의 Estimator : mu = sigma(월 수입) / 총 월 수
표준 편차의 Estimate : s = sqrt(sigma(월 수입 - 평균 월 수입) / (총 월 수 -1))
인과효과 연구에서 관심 모집단의 특성과 연관지어 분석 결과를 해석하고 추론하는 것은 중요하며, 이에 연구자는 통계적 추론(inference)를 위한 관심 모수(estimand; parameter of interest)를 정의하고 이에 대한 추정치(estimates)를 산출하게 된다.
import statsmodels# Identify the causal effect : 아까 위에서 명시한 model 을 활용합니다.identified_estimand = model.identify_effect(proceed_when_unidentifiable =True)print(identified_estimand)
Estimand type: nonparametric-ate
### Estimand : 1
Estimand name: backdoor
Estimand expression:
d
──────────────────────────(Expectation(is_canceled|country,booking_changes,mar
d[different_room_assigned]
ket_segment,required_car_parking_spaces,total_of_special_requests,guests,days_
in_waiting_list,lead_time,total_stay,meal,is_repeated_guest,previous_bookings_
not_canceled,hotel))
Estimand assumption 1, Unconfoundedness: If U→{different_room_assigned} and U→is_canceled then P(is_canceled|different_room_assigned,country,booking_changes,market_segment,required_car_parking_spaces,total_of_special_requests,guests,days_in_waiting_list,lead_time,total_stay,meal,is_repeated_guest,previous_bookings_not_canceled,hotel,U) = P(is_canceled|different_room_assigned,country,booking_changes,market_segment,required_car_parking_spaces,total_of_special_requests,guests,days_in_waiting_list,lead_time,total_stay,meal,is_repeated_guest,previous_bookings_not_canceled,hotel)
### Estimand : 2
Estimand name: iv
No such variable found!
### Estimand : 3
Estimand name: frontdoor
No such variable found!
Step-3. Estimate Identified Estimand :
dowhy.CausalModel() 클래스를 활용해서 모델을 입력하게 되면, 아래 함수들을 사용할 수 있습니다. 모듈 코드
estimate = model.estimate_effect(identified_estimand, method_name="backdoor.propensity_score_stratification",target_units="ate")# target_units can be these three : # 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)
결과는 꽤나 놀라웠습니다. 다른 방에 배정받는게 취소할 확률을 낮춰준다는 결과를 의미하기 때문입니다. (-0.251만큼) 여기서, 이게 정말 맞는 인과 효과일까요?
다른 메커니즘이 작용했을 수 있습니다. 다른 방에 배정받는 시간적인 상황이 체크인 때만 발생한다면? 이미 호텔에 와있기 때문에 취소할 수 있는 확률이 당연히 낮습니다. 이러한 케이스라면, 이 예약 취소가 언제 발생했는지에 대한 시각 정보가 주요한 변수가 됩니다. 예를 들어, different_room_assigned 변수가 예약을 한 당일에 주로 발생한다면 또 어떨까요? 이런 변수를 알게 됨으로써 그래프와 분석을 향상시킬 수 있습니다.
앞서 진행했던 연관성 분석이, is_canceled 와 different_room_assigned 사이의 양의 상관관계를 보였는데요. DoWhy를 통해서 인과 효과를 예측하는 것은 그 반대로, 아예 다른 그림을 보였습니다. 이는 곧, 다른 방에 배정하는 현상의 수를 줄이는 정책은 곧 호텔에게 비생산적인 방향의 정책일 수 있다는 점을 암시합니다.
Step-4. Refute results
주의해야 할 점은 인과효과는 데이터셋으로부터 발견되는 것이 아니라, Identification 으로 이끄는 연구자의 가정(Assumptions)을 통해서 발견 됩니다. 데이터는 단순히 통계적인 추정(Estimation)에만 사용됩니다. 즉, 가정이 맞았는지 아닌지에 대해서 증명하는 것이 정말 중요합니다.
다른 Common cause 가 존재한다면?
Treatment 자체가 Placebo 효과라면?
Method-1
Add Random Common Cause:
랜덤한 독립 변수를 Common cause로 데이터셋에 추가했을 때, 현재의 추정 메소드가 다른 추정치를 돌려줄까요?
데이터에서 랜덤하게 공변량 변수들을 Draw하고, 동일한 분석을 재 진행하여 인과 추정치가 변화하는지를 봅니다. (CNN 돌릴 때 랜덤 값 조정하여 강건성 확인하듯이)