수민 '-'

플오그래밍

제가 작성하는 모든 글은 절대 상업적인 이용이 아니며, 그저 개인적인 공부 용도로만 사용하는 것임을 밝힙니다.

서울 자전거 · 호텔 예약 수요 예측 실습 - 결정 트리, 랜덤 포레스트, 로지스틱 회귀, 교차 검증

머신러닝 수요 예측 응용

서울시 공공자전거 ‘따릉이’와 호텔 예약 데이터를 활용하면, 실제 서비스에서 활용할 수 있는 수요 예측 모델을 만들어 볼 수 있다. 이번 실습에서는 회귀 문제(자전거 대여 수 예측)와 이진 분류 문제(호텔 예약 취소 여부 예측)를 함께 다루면서, 결정 트리, 랜덤 포레스트, 로지스틱 회귀, 교차 검증까지 한 번에 정리해본다.

정말 간단하게 설명하자면, “언제 자전거를 얼마나 빌릴지”, “이 예약이 취소될 확률이 얼마나 될지”를 데이터로 예측해보는 하루치 실습이다.
(서울 자전거 공유 수요 데이터셋 정리, 호텔 예약 수요 데이터셋 정리를 기반으로 재구성)


1. 서울 자전거 공유 수요 예측 (회귀)

1-1. 데이터셋 소개

서울시 공공자전거 ‘따릉이’의 대여 수요를 예측하는 Seoul Bike Sharing Demand 데이터셋은 시간대, 기온, 습도, 가시거리, 눈/비, 계절, 공휴일 여부 등 다양한 정보를 바탕으로 Rented Bike Count(시간당 대여 자전거 수)를 예측하는 회귀 문제에 사용된다.
원본 설명은 블로그 글에 잘 정리되어 있다.

1-2. 데이터 로드 및 기본 정보

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

bike_df = pd.read_csv(
    '/content/drive/MyDrive/랭체인 AI 영상객체탐지분석 플랫폼 구축/10. 머신러닝 딥러닝/data/SeoulBikeData.csv',
    encoding='CP949'
)

bike_df.info()
bike_df.describe()

※ CP949 인코딩

  • Microsoft Windows의 한국어 문자 인코딩
  • EUC-KR을 확장한 형태로 더 많은 한글·한자를 지원
  • 윈도우에서 저장한 한글 CSV 파일에서 자주 사용

컬럼명을 분석하기 좋게 정리해준다.

bike_df.columns = [
    'Date', 'Rented Bike Count', 'Hour', 'Temperature', 'Humidity',
    'Wind speed', 'Visibility', 'Dew point temperature',
    'Solar Radiation', 'Rainfall', 'Snowfall', 'Seasons',
    'Holiday', 'Functioning Day'
]

bike_df.head()

1-3. 기초 EDA

주요 연속형 변수와 대여 수의 관계를 산점도로 본다.

sns.scatterplot(x='Temperature', y='Rented Bike Count', data=bike_df, alpha=0.3)
sns.scatterplot(x='Wind speed', y='Rented Bike Count', data=bike_df, alpha=0.3)
sns.scatterplot(x='Visibility', y='Rented Bike Count', data=bike_df, alpha=0.3)
sns.scatterplot(x='Hour', y='Rented Bike Count', data=bike_df, alpha=0.3)

결측치 확인:

bike_df.isna().sum()

날짜를 datetime으로 변환하고 연·월·일 파생변수를 만든다.

bike_df['Date'] = pd.to_datetime(bike_df['Date'], format='%d/%m/%Y')

bike_df['year'] = bike_df['Date'].dt.year
bike_df['month'] = bike_df['Date'].dt.month
bike_df['day'] = bike_df['Date'].dt.day

bike_df.info()
bike_df.head()

시계열 흐름을 확인하기 위한 라인 플롯:

plt.figure(figsize=(14, 4))
sns.lineplot(x='Date', y='Rented Bike Count', data=bike_df)
plt.xticks(rotation=45)
plt.show()

연도·월별 평균 수요:

bike_df[bike_df['year'] == 2017].groupby('month')['Rented Bike Count'].mean()
bike_df[bike_df['year'] == 2018].groupby('month')['Rented Bike Count'].mean()

1-4. 시간대 구간화 (pd.cut)

Hour를 범주형 시간대(새벽/아침/오후/저녁)로 나눈다.

bike_df['TimeOfDay'] = pd.cut(
    bike_df['Hour'],
    bins=[0, 5, 11, 17, 23],
    labels=['Dawn', 'Morning', 'Afternoon', 'Evening'],
    include_lowest=True
)

bike_df.head()

※ pd.cut() 요약

pd.cut()연속형 숫자 데이터를 구간(bins) 으로 나누어 범주형 데이터로 바꿔주는 함수다.

  • bins: 숫자를 나눌 구간 경계값
  • 기본적으로 오른쪽 경계값을 포함 (예: (5, 11])
시간 범위 레이블
0 ≤ Hour ≤ 5 Dawn
5 < Hour ≤ 11 Morning
11 < Hour ≤ 17 Afternoon
17 < Hour ≤ 23 Evening

1-5. 범주형 처리 및 상관분석

운영일 여부에 따른 평균 대여 수:

sns.barplot(x='Functioning Day', y='Rented Bike Count', data=bike_df)

날짜는 제거하고 범주형은 원-핫 인코딩으로 변환한다.

bike_df = bike_df.drop('Date', axis=1)

cat_cols = bike_df.select_dtypes(exclude=['number']).columns.tolist()

for c in cat_cols:
    print(c, bike_df[c].nunique())

bike_df = pd.get_dummies(
    bike_df,
    columns=cat_cols,
    drop_first=True
)

bike_df.head()

상관계수와 히트맵:

correlation_matrix = bike_df.corr()

target_corr = correlation_matrix['Rented Bike Count'].sort_values(ascending=False)
print(target_corr)

plt.figure(figsize=(16, 12))
sns.heatmap(correlation_matrix, annot=True, fmt='.2f', cmap='coolwarm')
plt.title('Feature Correlation Heatmap')
plt.show()

※ corr()과 다중공선성

  • corr()피어슨 상관계수를 사용해 숫자형 컬럼 간 선형 상관관계를 계산한다.
  • |상관계수|가 0.5 이상이면 강한 상관, 0.2 이하이면 약한 상관으로 보는 경우가 많다.
  • 서로 상관계수가 너무 높은 독립변수들끼리는 다중공선성(Multicollinearity) 를 일으켜 회귀 계수 해석과 예측을 불안정하게 만들 수 있다.

다중공선성이 의심되면, 상관이 높은 컬럼 중 일부를 제거하거나 차원 축소를 고려한다.

1-6. VIF로 다중공선성 점검

from statsmodels.stats.outliers_influence import variance_inflation_factor

X = bike_df.drop(columns=['Rented Bike Count'], errors='ignore')
X = X.select_dtypes(include='number')
X = X.loc[:, X.nunique() > 1]  # 분산 0인 컬럼 제거
X = X.astype(float)

vif_df = pd.DataFrame({
    'Feature': X.columns,
    'VIF': [variance_inflation_factor(X.values, i) for i in range(X.shape[1])]
}).sort_values(by='VIF', ascending=False)

vif_df

VIF 값이 너무 큰(Dew point temperature, Hour, Humidity, year 등) 컬럼은 제거 후 다시 계산한다.

bike_df = bike_df.drop(['Dew point temperature', 'Hour', 'Humidity', 'year'], axis=1)

X = bike_df.drop(columns=['Rented Bike Count'], errors='ignore')
X = X.select_dtypes(include='number')
X = X.loc[:, X.nunique() > 1].astype(float)

vif_df = pd.DataFrame({
    'Feature': X.columns,
    'VIF': [variance_inflation_factor(X.values, i) for i in range(X.shape[1])]
}).sort_values(by='VIF', ascending=False)

vif_df

※ VIF(Variance Inflation Factor)

  • 한 독립변수가 다른 독립변수들의 선형 결합으로 얼마나 잘 설명되는지를 나타내는 수치
  • 해석 기준
    • VIF < 5 : 큰 문제 없음
    • 5 ~ 10 : 주의
    • ≥ 10 : 다중공선성 강하게 의심

1-7. 결정 트리/선형 회귀/랜덤 포레스트 비교

먼저 학습·테스트 데이터를 나눈다.

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    bike_df.drop('Rented Bike Count', axis=1),
    bike_df['Rented Bike Count'],
    test_size=0.3,
    random_state=2026
)

X_train.shape, y_train.shape
X_test.shape, y_test.shape

(1) 결정 트리 회귀

from sklearn.tree import DecisionTreeRegressor
from sklearn.metrics import root_mean_squared_error

dtr = DecisionTreeRegressor(random_state=2025)
dtr.fit(X_train, y_train)
pred1 = dtr.predict(X_test)

sns.scatterplot(x=y_test, y=pred1)
root_mean_squared_error(y_test, pred1)

(2) 선형 회귀

from sklearn.linear_model import LinearRegression

lr = LinearRegression()
lr.fit(X_train, y_train)
pred2 = lr.predict(X_test)

sns.scatterplot(x=y_test, y=pred2)
root_mean_squared_error(y_test, pred2)

(3) 하이퍼파라미터 적용한 결정 트리

dtr = DecisionTreeRegressor(
    random_state=2026,
    max_depth=50,
    min_samples_leaf=30
)
dtr.fit(X_train, y_train)
pred3 = dtr.predict(X_test)

root_mean_squared_error(y_test, pred3)

(4) 트리 구조 시각화

from sklearn.tree import plot_tree

plt.figure(figsize=(24, 12))
plot_tree(dtr, max_depth=5, fontsize=10, feature_names=X_train.columns)
plt.show()

(5) 랜덤 포레스트 회귀

from sklearn.ensemble import RandomForestRegressor

rf = RandomForestRegressor(random_state=2026)
rf.fit(X_train, y_train)
pred4 = rf.predict(X_test)

root_mean_squared_error(y_test, pred4)

RMSE 비교(예시):

  • 결정 트리 : 약 384.74
  • 선형 회귀 : 약 446.47
  • 튜닝된 결정 트리 : 약 334.13
  • 랜덤 포레스트 : 약 288.82

랜덤 포레스트가 가장 낮은 오차를 기록하며, 단일 트리나 선형 회귀보다 안정적인 성능을 보여준다.

중요 특성 확인

feature_imp = pd.DataFrame({
    'features': X_train.columns,
    'importances': rf.feature_importances_
})
top10 = feature_imp.sort_values('importances', ascending=False).head(10)

plt.figure(figsize=(5, 10))
sns.barplot(x='importances', y='features', data=top10)

2. 호텔 예약 취소 예측 (이진 분류)

2-1. 데이터셋 소개

호텔 예약 수요 데이터셋호텔 예약 취소 여부(is_canceled) 를 예측하는 데 널리 사용되는 데이터셋이다.
호텔 타입, 도착 날짜, 숙박 일수, 인원 수, 예약 채널, 고객 유형, 과거 취소 이력, 요금(adr) 등 다양한 정보가 포함되어 있어, 실제 비즈니스에서 활용 가능한 분류 문제를 구성할 수 있다. (자세한 설명)

2-2. 데이터 로드 및 기초 탐색

import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt

hotel_df = pd.read_csv(
    '/content/drive/MyDrive/랭체인 AI 영상객체탐지분석 플랫폼 구축/10. 머신러닝 딥러닝/data/hotel_bookings.csv'
)

hotel_df.info()
hotel_df.describe()

lead_time 분포와 이상치를 확인한다.

sns.displot(hotel_df['lead_time'])
sns.boxplot(hotel_df['lead_time'])

IQR 기반 이상치 제거:

Q1 = hotel_df['lead_time'].quantile(0.25)
Q3 = hotel_df['lead_time'].quantile(0.75)
IQR = Q3 - Q1

lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR

hotel_df = hotel_df[
    (hotel_df['lead_time'] >= lower_bound) &
    (hotel_df['lead_time'] <= upper_bound)
]

len(hotel_df)
sns.boxplot(hotel_df['lead_time'])

분포와 채널별 취소율:

sns.barplot(x=hotel_df['distribution_channel'], y=hotel_df['is_canceled'])
hotel_df['distribution_channel'].value_counts()

sns.barplot(x=hotel_df['hotel'], y=hotel_df['is_canceled'])

월별 취소율:

plt.figure(figsize=(15, 5))
sns.barplot(x=hotel_df['arrival_date_month'], y=hotel_df['is_canceled'])

실제 달 순서대로 정렬:

import calendar

months = [calendar.month_name[i] for i in range(1, 13)]

plt.figure(figsize=(15, 5))
sns.barplot(
    x=hotel_df['arrival_date_month'],
    y=hotel_df['is_canceled'],
    order=months
)

2-3. 결측치 처리 및 파생변수 만들기

hotel_df.isna().sum()
hotel_df['children'].value_counts(dropna=False)

(1) 인원 관련 처리

hotel_df['children'] = hotel_df['children'].fillna(0)

hotel_df['people'] = (
    hotel_df['adults'] +
    hotel_df['children'] +
    hotel_df['babies']
)

hotel_df = hotel_df[hotel_df['people'] != 0]
hotel_df.drop(['adults', 'children', 'babies'], axis=1, inplace=True)

(2) 총 숙박일 파생변수

hotel_df['total_nights'] = (
    hotel_df['stays_in_week_nights'] +
    hotel_df['stays_in_weekend_nights']
)

hotel_df.drop(
    ['stays_in_week_nights', 'stays_in_weekend_nights'],
    axis=1,
    inplace=True
)

(3) 시즌(season) 파생변수

season_dic = {
    'spring': [3, 4, 5],
    'summer': [6, 7, 8],
    'fall': [9, 10, 11],
    'winter': [12, 1, 2]
}

new_season_dic = {}
for season, months_ in season_dic.items():
    for m in months_:
        new_season_dic[calendar.month_name[m]] = season

hotel_df['season'] = hotel_df['arrival_date_month'].map(new_season_dic)
hotel_df.drop(['arrival_date_month'], axis=1, inplace=True)

(4) 기대 객실 타입一致 여부

hotel_df['expected_room_type'] = (
    hotel_df['reserved_room_type'] == hotel_df['assigned_room_type']
).astype(int)

hotel_df.drop(
    ['reserved_room_type', 'assigned_room_type'],
    axis=1,
    inplace=True
)

(5) 과거 취소율(cancel_rate)

hotel_df['cancel_rate'] = (
    hotel_df['previous_cancellations'] /
    (hotel_df['previous_cancellations'] + hotel_df['previous_bookings_not_canceled'])
)

hotel_df['cancel_rate'] = hotel_df['cancel_rate'].fillna(-1)

hotel_df.drop(
    ['previous_cancellations', 'previous_bookings_not_canceled'],
    axis=1,
    inplace=True
)

(6) agent/company 빈도 인코딩

hotel_df['agent'] = hotel_df['agent'].fillna(-1)
agent_freq = hotel_df['agent'].value_counts()
hotel_df['agent_freq'] = hotel_df['agent'].map(agent_freq)
hotel_df.drop(['agent'], axis=1, inplace=True)

hotel_df['company'] = hotel_df['company'].fillna(-1)
company_freq = hotel_df['company'].value_counts()
hotel_df['company_freq'] = hotel_df['company'].map(company_freq)
hotel_df.drop(['company'], axis=1, inplace=True)

(7) 불필요한 범주형 제거 + 원-핫 인코딩

hotel_df.drop(
    ['meal', 'country', 'reservation_status_date'],
    axis=1,
    inplace=True
)

cat_cols = hotel_df.select_dtypes(exclude=['number']).columns.tolist()
for c in cat_cols:
    print(c, hotel_df[c].nunique())

hotel_df = pd.get_dummies(
    hotel_df,
    columns=cat_cols,
    drop_first=True
)

hotel_df.info()

2-4. 로지스틱 회귀 (LogisticRegression)

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    hotel_df.drop('is_canceled', axis=1),
    hotel_df['is_canceled'],
    test_size=0.3,
    random_state=2026
)

X_train.shape, y_train.shape
X_test.shape, y_test.shape

스케일링 후 로지스틱 회귀를 학습한다.

from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression

scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)

model = LogisticRegression(max_iter=1000)
model.fit(X_train_scaled, y_train)
pred = model.predict(X_test_scaled)

정확도 확인:

from sklearn.metrics import accuracy_score

accuracy_score(y_test, pred)
hotel_df['is_canceled'].value_counts()

※ 규제(Regularization)와 스케일링

  • 로지스틱 회귀는 기본적으로 L2 규제를 사용해 과적합을 막는다.
  • 규제항이 가중치의 크기를 제한하기 때문에, 특성 스케일이 크게 다르면 특정 특성에 과도하게 페널티가 가해질 수 있다.
  • 그래서 StandardScaler로 평균 0, 표준편차 1로 맞춰주는 표준화가 많이 사용된다.

2-5. 혼동 행렬과 분류 지표

from sklearn.metrics import (
    confusion_matrix,
    precision_score,
    recall_score,
    f1_score
)

cm = confusion_matrix(y_test, pred)
print(cm)

print(precision_score(y_test, pred))
print(recall_score(y_test, pred))
print(f1_score(y_test, pred))

혼동 행렬 용어 정리

  • TP (True Positive): 실제 1, 예측 1 (실제 취소, 취소로 예측)
  • TN (True Negative): 실제 0, 예측 0 (실제 유지, 유지로 예측)
  • FP (False Positive): 실제 0, 예측 1 (유지인데 취소라고 잘못 예측)
  • FN (False Negative): 실제 1, 예측 0 (취소인데 유지라고 잘못 예측)

혼동 행렬로부터 Accuracy, Precision, Recall, F1 등 다양한 지표를 계산할 수 있다.

예측 확률과 임계값 조정:

proba = model.predict_proba(X_test_scaled)[:, 1]  # 취소할 확률

threshold = 0.5
pred = (proba >= threshold).astype(int)

2-6. 랜덤 포레스트 분류와 ROC AUC

from sklearn.ensemble import RandomForestClassifier

rf = RandomForestClassifier(random_state=2026)
rf.fit(X_train, y_train)
pred1 = rf.predict(X_test)
proba1 = rf.predict_proba(X_test)

성능 평가:

from sklearn.metrics import classification_report, roc_auc_score

print(classification_report(y_test, pred1))
roc_auc_score(y_test, proba1[:, 1])

ROC 곡선 그리기:

from sklearn.metrics import roc_curve
import matplotlib.pyplot as plt

fpr, tpr, thr = roc_curve(y_test, proba1[:, 1])

plt.plot(fpr, tpr, label='ROC Curve')
plt.plot([0, 1], [0, 1])
plt.xlabel('False Positive Rate')
plt.ylabel('True Positive Rate')
plt.legend()
plt.show()

※ ROC Curve & AUC

  • ROC 곡선: x축 FPR, y축 TPR(Recall)로 그린 곡선
  • AUC: ROC 곡선 아래 면적
    • 1에 가까울수록 좋고, 0.5면 랜덤 추측 수준

2-7. K-Fold 교차 검증

from sklearn.model_selection import KFold
from sklearn.metrics import accuracy_score

kf = KFold(n_splits=5, random_state=2026, shuffle=True)
acc_list = []

X = hotel_df.drop('is_canceled', axis=1)
y = hotel_df['is_canceled']

for train_index, test_index in kf.split(range(len(hotel_df))):
    X_train = X.iloc[train_index]
    X_test = X.iloc[test_index]
    y_train = y.iloc[train_index]
    y_test = y.iloc[test_index]

    rf = RandomForestClassifier()
    rf.fit(X_train, y_train)
    pred = rf.predict(X_test)
    acc_list.append(accuracy_score(y_test, pred))

acc_list
np.array(acc_list).mean()

교차 검증 정리

  • 데이터를 여러 조각으로 나누어 번갈아가며 학습/검증에 사용
  • 데이터 분할 운에 따른 편향을 줄이고, 모델의 일반화 성능을 더 안정적으로 측정
  • 데이터가 많지 않은 경우 특히 유용

3. 실습 요약

서울 자전거 수요 예측 (회귀)

  1. 데이터 로드 및 인코딩: CP949 인코딩 CSV 로드, 컬럼명 정리
  2. EDA: 온도, 풍속, 가시거리, 시간과 대여 수 관계 시각화, 연·월별 수요 추세 확인
  3. 전처리: TimeOfDay 구간화, 날짜 제거, 원-핫 인코딩, 상관분석 및 VIF로 다중공선성 점검
  4. 모델링: 결정 트리, 선형 회귀, 튜닝된 결정 트리, 랜덤 포레스트 회귀 비교
  5. 결과: 랜덤 포레스트가 가장 낮은 RMSE로 가장 좋은 성능을 보였으며, 중요 특성을 통해 어떤 요인이 수요에 큰 영향을 주는지 해석 가능

호텔 예약 취소 예측 (이진 분류)

  1. 데이터 로드 및 이상치 처리: lead_time의 IQR 기반 이상치 제거
  2. 파생변수: people, total_nights, season, expected_room_type, cancel_rate, agent_freq, company_freq 등 도메인 의미를 담은 피처 생성
  3. 전처리: 결측치 처리, 불필요한 컬럼 제거, 원-핫 인코딩
  4. 로지스틱 회귀: 스케일링 + 규제 기반 베이스라인 모델, 혼동 행렬과 Precision/Recall/F1로 성능 분석
  5. 랜덤 포레스트 분류: 더 복잡한 모델로 정확도·ROC AUC 개선, 확률 기반 의사결정 가능
  6. 교차 검증: K-Fold로 교차 검증을 수행해, 단일 train/test split에 의존하지 않는 안정적인 성능 평가 수행

결론

  • 서울 자전거 수요 예측에서는 회귀 문제를 통해 결정 트리와 랜덤 포레스트의 차이, VIF·상관계수를 활용한 피처 선택의 중요성을 경험했다.
  • 호텔 예약 취소 예측에서는 로지스틱 회귀와 랜덤 포레스트를 비교하면서, 분류 문제에서의 정밀도·재현율·ROC AUC, 임계값 조정, 교차 검증의 개념까지 한 번에 다뤘다.
  • 두 실습을 통해 실제 데이터 전처리 → 파생변수 생성 → 모델 학습 → 평가 지표 해석 → 교차 검증까지 머신러닝 프로젝트의 전 과정을 “수요 예측”이라는 공통 주제로 경험할 수 있다.

참고 자료