
지난 번에는 와인의 화학적 특성에 따른 품질 점수에 대해 EDA 실습을 진행하였다. 스스로 가설을 세우고, 검증하며 어떤 방식으로 시각화를 할지 고민하는 과정에서 인사이트를 얻을 수 있었다.
다만, 이번에는 좀더 실무와 연관 있는 Hotel Booking Demand Dataset 을 활용하여, 고객들의 예약 취소를 줄이기 위해 호텔 관리인이 어떤 조치를 해야 하는지 알아보고자 한다.
이를 통해 비즈니스 문제를 EDA 와 연결하여 해결하는 역량을 기르고자 한다.
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
hotel = pd.read_csv("data/hotel_data_modified.csv")
hotel.head()
# hotel.head() 실행 결과
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 agent company days_in_waiting_list required_car_parking_spaces total_of_special_requests reservation_status reservation_status_date
Resort Hotel 0 342 2015 July 27 1 0 0 2 0 0 BB PRT Direct TA/TO 0 0 0 C C 3 NaN NaN 0 0 0 Check-Out 2015-07-01
Resort Hotel 0 737 2015 July 27 1 0 0 2 0 0 BB PRT Direct TA/TO 0 0 0 C C 4 NaN NaN 0 0 0 Check-Out 2015-07-01
Resort Hotel 0 7 2015 July 27 1 0 1 1 0 0 BB PRT Direct TA/TO 0 0 0 A C 0 NaN NaN 0 0 0 Check-Out 2015-07-02
Resort Hotel 0 13 2015 July 27 1 0 1 1 0 0 BB PRT Direct TA/TO 0 0 0 A A 0 304.0 NaN 0 0 0 Check-Out 2015-07-02
Resort Hotel 0 14 2015 July 27 1 0 2 2 0 0 BB PRT Direct TA/TO 0 0 0 A A 0 240.0 NaN 0 0 1 Check-Out 2015-07-03
기본적으로 주피터 노트북에서 나오는 결과값은 열이 너무 많아 velog 마크다운에서 짤리는 현상이 발생하였다.
*이에 코드블록으로 해당 데이터를 확인할 수 있도록 처리하여 표시하였다.
본 프로젝트에서는 Hotel Booking Demand Dataset을 활용하여 호텔 예약 취소 현상을 분석함.
해당 데이터는 2015년 7월부터 2017년 8월까지의 City Hotel과 Resort Hotel 예약 정보를 포함하고 있음.
예약 고객의 특성, 예약 방식, 체류 기간, 동반 인원 수, 특수 요청 사항 등 다양한 변수를 담고 있음.
호텔 예약 취소는 매출에 직접적인 영향을 미치는 중요한 요인으로, 고객 행동 패턴과 외부 요인을 함께 고려해야 관리할 수 있는 문제임.
본 분석에서는 호텔 관리자의 입장에서 예약 취소율에 영향을 주는 주요 요인을 파악하고, 이를 통해 취소율을 줄일 수 있는 아이디어를 도출하는 것을 목표로 함.
예약 취소와 관련된 요인 파악
City Hotel과 Resort Hotel 비교
예약 취소율을 줄이기 위한 개선 아이디어 제안
| 컬럼명 | 설명 |
|---|---|
hotel | 호텔명 (Resort Hotel 혹은 City Hotel) |
is_canceled | 호텔 예약이 취소되었는지(1) 혹은 취소되지 않았는지(0)를 나타내는 값 |
lead_time | 호텔 예약 시점부터 고객의 호텔 도착 시점까지의 기간 (단위: 일) |
arrival_date_year | 고객의 호텔 도착 연도 |
arrival_date_month | 고객의 호텔 도착 월 |
arrival_date_week_number | 고객의 호텔 도착 주 (예: 2015년도 셋째 주 도착 → 3) |
arrival_date_day_of_month | 고객의 호텔 도착 일 (예: 3월 2일 도착 → 2) |
stays_in_weekend_nights | 고객이 예약한 주말 숙박 수 (토~일). 예: 평일 3일, 주말 2일 예약 → 2 |
stays_in_week_nights | 고객이 예약한 평일 숙박 수 (월~금). 예: 평일 3일, 주말 2일 예약 → 3 |
adults | 예약된 성인 수 |
children | 예약된 어린이 수 |
babies | 예약된 아기 수 |
meal | 예약된 식사 유형 - Undefined/SC: 식사 불포함 - BB: Bed & Breakfast - HB: Half board (아침+저녁) - FB: Full board (아침+점심+저녁) |
country | 투숙객의 출신 국가 (ISO 3155-3:2013 형식) |
market_segment | 시장 세그먼트 ("TA"=Travel Agent, "TO"=Tour Operators 등) |
distribution_channel | 예약 유통 채널 ("TA"=Travel Agent, "TO"=Tour Operators 등) |
is_repeated_guest | 이전에 방문한 고객인지 여부 (1=재방문, 0=신규) |
previous_cancellations | 현재 예약 이전에 고객이 취소한 예약 수 |
previous_bookings_not_canceled | 현재 예약 이전에 취소하지 않은 예약 수 |
reserved_room_type | 예약한 객실 타입 코드 |
assigned_room_type | 배정된 객실 타입 코드 (호텔 운영 사정 또는 고객 요청으로 예약과 다른 경우 가능) |
booking_changes | 예약 후 취소/체크인까지 변경·수정된 횟수 |
agent | 예약을 진행한 여행사 ID |
company | 예약을 한 회사/단체의 ID (또는 예약금 지불 책임 단체) |
days_in_waiting_list | 예약 확정 전 대기자 명단에 있었던 일수 |
required_car_parking_spaces | 고객이 요청한 주차 공간 수 |
total_of_special_requests | 고객의 특별 요청 수 (예: 트윈 베드, 아기 침대, 고층 등) |
reservation_status | 예약의 최종 상태 - Canceled: 예약 취소 - Check-Out: 체크인 후 체크아웃 완료 - No-Show: 고객 미도착, 사유 불명 |
reservation_status_date | 예약 최종 상태(reservation_status)가 설정된 날짜 |
print(hotel.shape)
hotel.info()
(119390, 29)
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 119390 entries, 0 to 119389
Data columns (total 29 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 hotel 119390 non-null object
1 is_canceled 119390 non-null int64
2 lead_time 119390 non-null int64
3 arrival_date_year 119390 non-null int64
4 arrival_date_month 119390 non-null object
5 arrival_date_week_number 119390 non-null int64
6 arrival_date_day_of_month 119390 non-null int64
7 stays_in_weekend_nights 119390 non-null int64
8 stays_in_week_nights 119390 non-null int64
9 adults 119390 non-null int64
10 children 119386 non-null float64
11 babies 119390 non-null int64
12 meal 119390 non-null object
13 country 118902 non-null object
14 market_segment 119390 non-null object
15 distribution_channel 119390 non-null object
16 is_repeated_guest 119390 non-null int64
17 previous_cancellations 119390 non-null int64
18 previous_bookings_not_canceled 119390 non-null int64
19 reserved_room_type 119390 non-null object
20 assigned_room_type 119390 non-null object
21 booking_changes 119390 non-null int64
22 agent 103050 non-null float64
23 company 6797 non-null float64
24 days_in_waiting_list 119390 non-null int64
25 required_car_parking_spaces 119390 non-null int64
26 total_of_special_requests 119390 non-null int64
27 reservation_status 119390 non-null object
28 reservation_status_date 119390 non-null object
dtypes: float64(3), int64(16), object(10)
memory usage: 26.4+ MB
해당 데이터셋은 119,390행 * 29열 로 이루어져 있다.
결측치가 존재하는 열은 agent 와 company 열로, 총 2개이다.
*(추가 내용) 실습 종료 이후 확인된 점은 결측치가 존재하는 열은 총 4개이다. (children, country, agent, company)
결측치를 한눈에 확인하려면 isna().sum() 사용하는 것을 권장한다...
company_count = hotel['company'].value_counts(dropna=False) # 각 값별 빈도수
company_count
company
NaN 112593
40.0 927
223.0 784
67.0 267
45.0 250
...
18.0 1
273.0 1
368.0 1
393.0 1
132.0 1
Name: count, Length: 353, dtype: int64
company_unique = hotel['company'].nunique() # 고유 값 종류 개수
company_unique
352
hotel.loc[hotel['company'] == 0]
| 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 | ... | reserved_room_type | assigned_room_type | booking_changes | agent | company | days_in_waiting_list | required_car_parking_spaces | total_of_special_requests | reservation_status | reservation_status_date |
|---|
0 rows × 29 columns
hotel['company'] = hotel['company'].fillna(0)
print(hotel['company'].value_counts())
print(hotel['company'].isna().value_counts())
company
0.0 112593
40.0 927
223.0 784
67.0 267
45.0 250
...
18.0 1
273.0 1
368.0 1
393.0 1
132.0 1
Name: count, Length: 353, dtype: int64
company
False 119390
Name: count, dtype: int64
해당 데이터 셋에 대해 검색한 결과, 호텔이 위치한 국가는 포르투갈로 확인하였다.
value_counts 를 통해 국가별 예약 건수를 재확인하였다. 총 177개의 국가가 있다.
그 중 포르투갈 국민이 가장 많은 예약을 했음을 알 수 있다.
print(f"호텔 예약 고객들의 국가 수는 총 {hotel['country'].nunique()}개 입니다.")
호텔 예약 고객들의 국가 수는 총 177개 입니다.
hotel['country'].value_counts().head(20)
country
PRT 48590
GBR 12129
FRA 10415
ESP 8568
DEU 7287
ITA 3766
IRL 3375
BEL 2342
BRA 2224
NLD 2104
USA 2097
CHE 1730
CN 1279
AUT 1263
SWE 1024
CHN 999
POL 919
ISR 669
RUS 632
NOR 607
Name: count, dtype: int64
# 내외국인 구분 컬럼 생성
hotel['Nationality'] = hotel['country'].apply(lambda x: 'Domestic' if x == 'PRT' else 'Foreign')
# 예약 취소 여부 컬럼 생성: 'Canceled' 만 취소, 'No-Show', 'Check-Out'은 취소하지 않음으로 간주
hotel['Cancelled'] = hotel['reservation_status'].apply(
lambda x: 'Cancelled' if x == 'Canceled' else 'Completed'
)
# 내국인 데이터 필터링 및 집계, 순서 지정
domestic_data = hotel[hotel['Nationality'] == 'Domestic']
domestic_counts = domestic_data['Cancelled'].value_counts().reindex(['Cancelled', 'Completed'], fill_value=0)
# 외국인 데이터 필터링 및 집계, 순서 지정
foreign_data = hotel[hotel['Nationality'] == 'Foreign']
foreign_counts = foreign_data['Cancelled'].value_counts().reindex(['Cancelled', 'Completed'], fill_value=0)
# 파이 차트 그리기
fig, axes = plt.subplots(1, 2, figsize=(12, 6))
axes[0].pie(domestic_counts, labels=domestic_counts.index, autopct='%1.1f%%', colors=['red', 'green'], startangle=90)
axes[0].set_title('Reservation Status for Domestic Visitors')
axes[1].pie(foreign_counts, labels=foreign_counts.index, autopct='%1.1f%%', colors=['tomato', 'skyblue'], startangle=90)
axes[1].set_title('Reservation Status for Foreign Visitors')
plt.show()

hotel['reserved_room_type'].unique()
array(['C', 'A', 'D', 'E', 'G', 'F', 'H', 'L', 'P', 'B'], dtype=object)
hotel['reserved_room_type'].value_counts(dropna=False)
reserved_room_type
A 85994
D 19201
E 6535
F 2897
G 2094
B 1118
C 932
H 601
P 12
L 6
Name: count, dtype: int64
hotel['total_of_special_requests'].value_counts(dropna=False)
total_of_special_requests
0 70318
1 33226
2 12969
3 2497
4 340
5 40
Name: count, dtype: int64
# requests 컬럼 생성: 0이면 False, 그 외 True
hotel['requests'] = hotel['total_of_special_requests'].apply(lambda x : False if x == 0 else True)
hotel['requests'].value_counts()
requests
False 70318
True 49072
Name: count, dtype: int64
# 'requests'와 'reservation_status'별 방문 수 집계
count_data = hotel.groupby(['requests', 'reservation_status']).size().reset_index(name='count')
# 막대그래프 그리기
ax = sns.barplot(data=count_data, x='requests', y='count', hue='reservation_status')
plt.title('Special Request Presence vs Reservation Status')
plt.xlabel('Special Request Made')
plt.ylabel('Number of Reservations')
plt.xticks([0, 1], ['No Requests', 'Has Requests'])
plt.legend(title='Reservation Status')
# 각 막대 위에 값 표시
for p in ax.patches:
height = p.get_height()
if height > 0:
ax.annotate(f'{int(height)}',
(p.get_x() + p.get_width() / 2, height + 3),
ha='center', va='bottom', fontsize=10, color='black')
plt.show()

# 'Canceled' 상태의 데이터만 필터링
canceled_data = hotel[hotel['reservation_status'] == 'Canceled']
# 히스토그램 그리기 (special_requests가 0~5의 범주를 갖기 때문에, bins=range(0,7)로 설정)
ax = sns.histplot(
data=canceled_data,
x='total_of_special_requests',
bins=range(0, 7), # 0~6 구간(0,1,2,3,4,5)로 나눔
discrete=True,
color='skyblue'
)
plt.title('Histogram of Special Requests (Cancelled Reservations)')
plt.xlabel('Total of Special Requests')
plt.ylabel('Count')
plt.xticks(range(6))
# 막대 위에 값 표시
for p in ax.patches:
height = p.get_height()
if height > 0:
# 막대의 중앙 x 위치와 y값(높이) 위에 값을 적음
ax.annotate(f'{int(height)}',
(p.get_x() + p.get_width() / 2, height),
ha='center', va='bottom', fontsize=10, color='black')
plt.show()

# 각 특별 요청별 전체 예약 수
total_counts = hotel['total_of_special_requests'].value_counts().sort_index()
# 각 특별 요청별 취소 예약 수
cancel_counts = hotel[hotel['reservation_status'] == 'Canceled']['total_of_special_requests'].value_counts().sort_index()
# 0~5 범위 보장 (없는 값 0으로 채움)
index = np.arange(6)
total_vals = [total_counts.get(i, 0) for i in index]
cancel_vals = [cancel_counts.get(i, 0) for i in index]
# 취소되지 않은 예약 수 계산
not_cancel_vals = [total_vals[i] - cancel_vals[i] for i in range(len(index))]
# 각 점수별 취소 비율 계산
cancel_rates = [(cancel_vals[i] / total_vals[i] * 100) if total_vals[i] > 0 else 0 for i in range(len(index))]
fig, ax = plt.subplots(figsize=(10,6))
# 취소되지 않은 예약 (하단)
p1 = ax.bar(index, not_cancel_vals, color='lightgreen', label='Not Canceled')
# 취소된 예약 (상단)
p2 = ax.bar(index, cancel_vals, bottom=not_cancel_vals, color='salmon', label='Canceled')
ax.set_xlabel('Total of Special Requests')
ax.set_ylabel('Number of Reservations')
ax.set_title('Reservations by Special Requests (Canceled / Not Canceled)')
ax.set_xticks(index)
ax.legend()
# 막대 안에 % 표시 (취소 비율)
for i in range(len(index)):
if cancel_vals[i] > 0:
# 취소된 예약 막대 중앙에 텍스트 위치 지정
xpos = p2[i].get_x() + p2[i].get_width() / 2
ypos = p1[i].get_height() + p2[i].get_height() / 2
ax.text(xpos, ypos, f'{cancel_rates[i]:.1f}%', ha='center', va='center', fontsize=10, color='black')
plt.show()

print(hotel['required_car_parking_spaces'].unique())
print(hotel['required_car_parking_spaces'].nunique())
[0 1 2 8 3]
5
print(hotel['required_car_parking_spaces'].value_counts())
required_car_parking_spaces
0 111974
1 7383
2 28
3 3
8 2
Name: count, dtype: int64
# 'parking' 컬럼 생성
hotel['parking'] = hotel['required_car_parking_spaces'].apply(lambda x: False if x == 0 else True)
# 그룹별 취소율 계산
cancel_rates = hotel.groupby('parking')['reservation_status'].apply(lambda x: (x == 'Canceled').mean()).reset_index(name='cancel_rate')
# barplot 그리기
ax = sns.barplot(data=cancel_rates, x='parking', y='cancel_rate')
plt.title('Cancellation Rate by Parking Request')
plt.xlabel('Requested Parking Space')
plt.ylabel('Cancellation Rate')
plt.xticks([0, 1], ['No Parking Requested', 'Parking Requested'])
# 막대 위에 취소율 (%) 텍스트 표시
for p in ax.patches:
height = p.get_height()
ax.annotate(f'{height*100:.1f}%',
(p.get_x() + p.get_width()/2, height),
ha='center', va='bottom')
plt.show()

parking_counts = hotel.groupby('parking')['reservation_status'].value_counts().unstack(fill_value=0)
print(parking_counts)
reservation_status Canceled Check-Out No-Show
parking
False 43017 67750 1207
True 0 7416 0
print(hotel['lead_time'].nunique())
print(hotel['lead_time'].min(), hotel['lead_time'].max())
print(hotel['lead_time'].value_counts())
479
0 737
lead_time
0 6345
1 3460
2 2069
3 1816
4 1715
...
435 1
532 1
371 1
380 1
463 1
Name: count, Length: 479, dtype: int64
import matplotlib.pyplot as plt
plt.figure(figsize=(7,5))
plt.boxplot(hotel['lead_time'])
plt.title('Boxplot of Lead Time')
plt.ylabel('Lead Time')
plt.show()

q1 = hotel['lead_time'].quantile(0.25) # 1분위수 (Q1)
q2 = hotel['lead_time'].quantile(0.50) # 2분위수 (Q2, 중앙값)
q3 = hotel['lead_time'].quantile(0.75) # 3분위수 (Q3)
q4 = hotel['lead_time'].quantile(1.00) # 4분위수 (Q4, 최댓값)
iqr = q3 - q1 # IQR(Interquartile Range)
# 윗수염, 아랫수염 계산 (수염 기준: 1.5*IQR)
lower_whisker = hotel['lead_time'][hotel['lead_time'] >= (q1 - 1.5*iqr)].min()
upper_whisker = hotel['lead_time'][hotel['lead_time'] <= (q3 + 1.5*iqr)].max()
print(f"아랫수염: {lower_whisker}")
print(f"1분위수 (Q1): {q1}")
print(f"2분위수 (중앙값, Q2): {q2}")
print(f"3분위수 (Q3): {q3}")
print(f"윗수염: {upper_whisker}")
아랫수염: 0
1분위수 (Q1): 18.0
2분위수 (중앙값, Q2): 69.0
3분위수 (Q3): 160.0
윗수염: 373
박스 플롯의 분위수에 따라 리드타임을 아래와 같이 범주화하였다.
0: 즉시 예약/당일 예약
1~18: Q1(1분위수)까지 - 단기 예약 (25% 이내)
19~69: Q1~Q2 - 약간 더 미리 예약한 군 (25~50%)
70~160: Q2~Q3 - 중장기 리드타임 군 (50~75%)
161~373: Q3~윗수염까지 - '정상' 범위 내 장기 예약 (75~100%)
373~: 이상치(Outlier) - 극단적으로 미리 예약한 경우
# 박스플롯 기준으로 리드타임 구간 정의
bins = [0, 0.99, 18, 69, 160, 373, hotel['lead_time'].max()]
labels = ['0', '1~18', '19~69', '70~160', '161~373', f'374~{int(hotel["lead_time"].max())}']
# lead_time 범주화
hotel['lead_time_cat'] = pd.cut(hotel['lead_time'], bins=bins, labels=labels, right=True, include_lowest=True)
# 범주별 취소 건수 집계
cancel_counts = hotel[hotel['reservation_status'] == 'Canceled']['lead_time_cat'].value_counts().sort_index()
print(cancel_counts)
lead_time_cat
0 317
1~18 3609
19~69 10310
70~160 12338
161~373 14440
374~737 2003
Name: count, dtype: int64
plt.figure(figsize=(8,5))
bars = plt.bar(cancel_counts.index.astype(str), cancel_counts.values, color='tomato')
plt.xlabel('Lead Time Category')
plt.ylabel('Canceled Reservations')
plt.title('Canceled Reservations by Lead Time Category')
# 막대 위에 건수 표시
for bar in bars:
height = bar.get_height()
plt.text(bar.get_x() + bar.get_width()/2, height, f'{int(height)}',
ha='center', va='bottom', fontsize=10, color='black')
plt.show()

# 전체 예약 건수 대비 범주별 건수 집계
total_counts = hotel['lead_time_cat'].value_counts().sort_index()
# 범주별 취소 건수 집계 (이전 코드)
cancel_counts = hotel[hotel['reservation_status'] == 'Canceled']['lead_time_cat'].value_counts().sort_index()
# 취소 비율 계산
cancel_ratio = (cancel_counts / total_counts).fillna(0) * 100 # % 비율로 변환
plt.figure(figsize=(8,5))
bars = plt.bar(cancel_ratio.index.astype(str), cancel_ratio.values, color='tomato')
plt.xlabel('Lead Time Category')
plt.ylabel('Cancellation Rate (%)')
plt.title('Cancellation Rate by Lead Time Category')
# 막대 위에 비율 표시
for bar in bars:
height = bar.get_height()
plt.text(bar.get_x() + bar.get_width()/2, height, f'{height:.1f}%',
ha='center', va='bottom', fontsize=10, color='black')
plt.show()

hotel['is_repeated_guest'].value_counts()
is_repeated_guest
0 115580
1 3810
Name: count, dtype: int64
# 취소 혹은 노쇼 상태만 필터링
filtered = hotel[hotel['reservation_status'].isin(['Canceled', 'No-Show'])]
# 그룹별 건수 집계
count_data = filtered.groupby(['is_repeated_guest', 'reservation_status']).size().reset_index(name='count')
# 전체 예약 건수 집계 (비율 계산용)
total_by_group = hotel.groupby('is_repeated_guest').size()
count_data['percent'] = count_data.apply(lambda x: x['count']/total_by_group[x['is_repeated_guest']] * 100, axis=1)
# 막대 그래프 (비율 기준, hue로 취소/노쇼)
ax = sns.barplot(data=count_data, x='is_repeated_guest', y='percent', hue='reservation_status')
plt.title('Cancel & No-Show Ratio by Repeated Guest')
plt.xlabel('Repeated Guest')
plt.ylabel('Percentage (%)')
plt.xticks([0, 1], ['Not Repeated', 'Repeated'])
plt.legend(title='Reservation Status')
# 막대 위에 비율(00%) 표시
for bars in ax.containers:
for bar in bars:
height = bar.get_height()
ax.text(bar.get_x() + bar.get_width()/2, height, f'{height:.1f}%', ha='center', va='bottom', fontsize=10, color='black')
plt.show()

이번 분석을 통해 단순히 데이터의 통계적 특성을 파악하는 데 그치지 않고, 실제 호텔 운영 전략과 연결할 수 있는 분석적 사고를 기를 수 있었다.
EDA를 통해 고객의 예약 취소에 영향을 주는 주요 요인을 탐색하면서, 데이터 기반으로 문제를 정의하고 개선 방향을 도출하는 과정의 중요성을 다시 한 번 느낄 수 있었다.
다음에는 이번 EDA 경험을 바탕으로, 소규모 표본 데이터셋을 활용해 비즈니스에 영향을 주는 요인을 예측하는 모델을 만들어보고자 한다.
이를 통해 단순한 데이터 탐색을 넘어, 데이터 기반 의사결정으로 확장되는 분석 과정을 경험해볼 계획이다.