Go together

If you want to go fast, go alone. If you want to go far, go together.

파이썬 머신 러닝 완벽 가이드

회귀 실습 - 자전거 대여 수요 예측

NowChan 2021. 11. 23. 14:16

배운 내용

  1. DataFrame.apply(pd.to_datetime)
  2. DF.sort_values('Column', ascending=False)[:n_tops]
  3. DataFrame.hist()
  4. sns.barplot(x, y)
  5. pd.get_dummies()

자전거 대여 수요 예측

자전거 대여 수요(Bike Sharing Demand) 예측 경연에서 사용된 데이터 세트를 이용해 선형 회귀와 트리 기반 회귀를 비교해보겠습니다. 데이터 세트는 https://www.kaggle.com/c/bike-sharing-demand/data에서 내려받을 수 있습니다. 

 

Bike Sharing Demand | Kaggle

 

www.kaggle.com

 

데이터 세트의 주요 칼럼은 다음과 같습니다.

  • datetime: hourly date + timestamp
  • season: 1=봄, 2=여름, 3=가을, 4=겨울
  • holiday: 1=토, 일요일의 주말을 제외한 국경일 등의 휴일, 0=휴일이 아닌 날
  • workingday: 1=토,일요일의 주말 및 휴일이 아닌 주중, 0=주말 및 휴일
  • weather:
    • 1=맑음, 약간 흐림
    • 2=안개, 안개 + 흐림
    • 3=가벼운 눈, 가벼운 비 + 천둥
    • 4=심한 눈/비, 천둥/번개
  • temp: 온도(섭씨)
  • atemp: 체감온도(섭씨)
  • humidity: 상대습도
  • windspeed: 풍속
  • casual: 사전에 등록되지 않은 사용자가 대여한 횟수
  • registered: 사전에 등록된 사용자가 대여한 횟수
  • count: 대여 횟수

 

 

데이터 클렌징 및 가공

데이터를 DataFrame으로 로드해 데이터를 대략적으로 확인해 보겠습니다.

import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
%matplotlib inline
import warnings
warnings.filterwarnings("ignore", category=RuntimeWarning)

bike_df = pd.read_csv('/content/drive/MyDrive/military/BikeSharingDemand/train.csv')
print(bike_df.shape)
bike_df.head()
'''
(10886, 12)
'''
'''
결과1
'''

결과1

bike_df.info()
'''
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 10886 entries, 0 to 10885
Data columns (total 12 columns):
 #   Column      Non-Null Count  Dtype  
---  ------      --------------  -----  
 0   datetime    10886 non-null  object 
 1   season      10886 non-null  int64  
 2   holiday     10886 non-null  int64  
 3   workingday  10886 non-null  int64  
 4   weather     10886 non-null  int64  
 5   temp        10886 non-null  float64
 6   atemp       10886 non-null  float64
 7   humidity    10886 non-null  int64  
 8   windspeed   10886 non-null  float64
 9   casual      10886 non-null  int64  
 10  registered  10886 non-null  int64  
 11  count       10886 non-null  int64  
dtypes: float64(3), int64(8), object(1)
memory usage: 1020.7+ KB
'''

datetime 칼럼만 object 형입니다. datetime 칼럼의 경우 년-월-일 시:분:초 문자형식으로 돼있으므로 이에 대한 가공이 필요합니다. datatime 칼럼을 pandas의 'datetime 타입'으로 변경해서 년, 월, 일, 시간, 분, 초로 변환하면 됩니다. 

 

DataFrame.apply(pd.to_datetime)

# 문자열을 datetime 타입으로 변경.
bike_df['datetime'] = bike_df.datetime.apply(pd.to_datetime)

# datetime 타입에서 년, 월, 일, 시간 추출
bike_df['year'] = bike_df.datetime.apply(lambda x : x.year)
bike_df['month'] = bike_df.datetime.apply(lambda x : x.month)
bike_df['day'] = bike_df.datetime.apply(lambda x : x.day)
bike_df['hour'] = bike_df.datetime.apply(lambda x : x.hour)
bike_df.head(3)
'''
결과2
'''

결과2

datetime 칼럼은 year, month, day, hour 칼럼이 있으니 삭제하고, casual과 registered는 count가 있어 따로 필요하지는 않고, 또한 0casual과 registered는 count와 상관도가 높을 것이므로 삭제하겠습니다.

drop_columns = ['datetime', 'casual', 'registered']
bike_df.drop(drop_columns, axis=1, inplace=True)

캐글에서 요구한 성능 평가 방법은 RMSLE(Root Mean Square Log Error)입니다. 즉, 오류 값에 로그를 취한 값에 대한 RMSE 입니다. RMSLE뿐만 아니라 MSE, RMSE까지 한꺼번에 평가하는 함수도 만들겠습니다.

from sklearn.metrics import mean_squared_error, mean_absolute_error

# log 값 변환 시 NaN 등의 이슈로 log()가 아닌 log1p()를 이용해 RMSLE 계산
def rmsle(y, pred):
  log_y = np.log1p(y)
  log_pred = np.log1p(pred)
  squared_error = (log_y - log_pred)**2
  rmsle = np.sqrt(np.mean(squared_error))
  return rmsle

# 사이킷런의 mean_square_error()를 이용해 RMSE 계산
def rmse(y, pred):
  return np.sqrt(mean_squared_error(y, pred))

# MSE, RMSE, RMSLE를 모두 계산
def evaluate_regr(y, pred):
  rmsle_val = rmsle(y, pred)
  rmse_val = rmse(y, pred)
  # MSE는 사이킷런의 mean_absolute_error()로 계산
  mae_val = mean_absolute_error(y, pred)
  print('RMSLE: {0:.3f}, RMSE: {1:.3f}, MAE: {2:.3f}'.format(rmsle_val, rmse_val, mae_val))

오버/언더 플로(overflow/underflow)의 오류로 log()대신 log1p()를 이용해 구합니다. rmsle를 도출하는 과정에서 mean_squred_log_error( ) 를 사용할 수도 있지만, 이 또한 함수 내부에서 log()를 쓰므로 사용하지 않습니다. log1p()로 변환된 값은 다시 넘파이의 expm1()함수로 쉽게 원래 스케일로 복원될 수 있습니다.

 

로그 변환, 피처 인코딩과 모델 학습/예측/평가

회귀 모델을 학습하기 전 데이터 세트에 대해 전처리 해야할 사항은 결과값이 정규 분포로 돼있는지 확인하는 것카테고리형 피처의 경우 원-핫 인코딩으로 피처를 인코딩하는 것입니다.

 

선형 회귀 모델로 먼저 회귀 예측을 하겠습니다.

from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.linear_model import LinearRegression, Ridge, Lasso

y_target = bike_df['count']
X_features = bike_df.drop(['count'], axis=1, inplace=False)

X_train, X_test, y_train, y_test = train_test_split(X_features, y_target, \
                                                    test_size=0.3, random_state=0)

lr_reg = LinearRegression()
lr_reg.fit(X_train, y_train)
pred = lr_reg.predict(X_test)

evaluate_regr(y_test, pred)
'''
RMSLE: 1.165, RMSE: 140.900, MAE: 105.924
'''

실제 Target 데이터 값인 대여 횟수(Count)를 감안하면, 예측 오류로서는 비교적 큰 값입니다. 실제값과 예측값이 얼마나 차이가 나는지 DataFrame의 칼럼으로 만들어 오류값이 가장 큰 순으로 5개만 확인해보겠습니다.

 

DF.sort_values('Column', ascending=False)[:n_tops]

def get_top_error_data(y_test, pred, n_tops=5):
  result_df = pd.DataFrame(y_test.values, columns=['real_count'])
  result_df['predicted_count'] = np.round(pred)
  result_df['diff'] = np.abs(result_df['real_count'] - result_df['predicted_count'])

  # 예측값과 실제값이 가장 큰 데이터 순으로 출력
  print(result_df.sort_values('diff', ascending=False)[:n_tops])

get_top_error_data(y_test, pred, n_tops=5)
'''
      real_count  predicted_count   diff
1618         890            322.0  568.0
3151         798            241.0  557.0
966          884            327.0  557.0
412          745            194.0  551.0
2817         856            310.0  546.0
'''

상위 5개 오류값을 보면 546~568로 큰 값을 가집니다. 이런 큰 예측 오류가 발생할 경우, 가장 먼저 살펴볼 것은 Target 값의 분포가 왜곡된 형태를 띄고 있는지 확인하는 것입니다.

 

DataFrame.hist()

y_target.hist()
'''
결과3
'''

결과3

count 칼럼값이 정규 분포가 아닌 0~200 사이에 왜곡돼 있는 것을 알 수 있습니다. 왜곡된 값을 정규 분포 형태로 바꾸는 가장 일반적인 방법은 log 변환하는 것입니다.

y_log_transform = np.log1p(y_target)
y_log_transform.hist()
'''
결과4
'''

결과4

정규 분포는 아니지만, 왜곡이 많이 완화됐습니다. 이를 이용해 다시 학습한 후 평가를 수행해보겠습니다.

# 타깃 칼럼인 count 값을 log1p로 로그 변환
y_target_log = np.log1p(y_target)

# 로그로 변환된 y_target_log를 반영해 학습/테스트 데이터 세트 분할
X_train, X_test, y_train, y_test = train_test_split(X_features, y_target_log, test_size=0.3,\
                                                    random_state=0)
lr_reg = LinearRegression()
lr_reg.fit(X_train, y_train)
pred = lr_reg.predict(X_test)

# 테스트 데이터 세트의 Target 값은 로그 변환됐으므로 다시 expm1을 이용해 원래 스케일로 변환
y_test_exp = np.expm1(y_test)

# 예측값 역시 로그 변환된 타깃 기반으로 학습돼 예측됐으므로 다시 expm1로 스케일 변환
pred_exp = np.expm1(pred)

evaluate_regr(y_test_exp, pred_exp)
'''
RMSLE: 1.017, RMSE: 162.594, MAE: 109.286
'''

RMSLE 오류는 줄었지만, RMSE는 오히려 더 늘었습니다. 각 피처의 회귀 계수 값을 시각화해 보겠습니다.

 

sns.barplot(x, y)

coef = pd.Series(lr_reg.coef_, index=X_features.columns)
coef_sort = coef.sort_values(ascending=False)
sns.barplot(x=coef_sort.values, y=coef_sort.index)
'''
결과5
'''

결과5

회귀 계수 값이 크다는 것은 피처값의 작은 변화에도 Target 값에 큰 영향을 준다는 것입니다. year 피처는 2011, 2012년 두 개의 값으로 이루어져 있는데, 자전거 대여 횟수에 큰 영향을 준다는 것이 납득하기 어렵습니다. 이는 year 피처가 연도를 뜻하는 Category형 피처이지만, 숫자형 값으로 돼 있습니다. 더군다나 아주 큰 값인 2011, 2012로 돼있습니다. 사이킷런은 카테고리만을 위한 데이터 타입은 없고 모두 숫자로 변환해야 합니다. 하지만, 숫자형 카테고리를 선형 회귀에 사용할 경우 슷자형 값에 크게 영향을 받는 경우가 발생할 수 있습니다. 이러한 피처에서는 원-핫 인코딩을 적용해 변환해야 합니다.

 

pd.get_dummies()

판다스의 get_dummies()를 이용해 모든 카테고리형 피처를 one-hot Encoding 한 후 다시 예측 성능을 확인하겠습니다.

# 'year', 'month', 'day', 'hour' 등의 피처를 One-Hot Encoding
X_features_ohe = pd.get_dummies(X_features, columns=['year', 'month', 'day', 'hour',\
                                                     'holiday', 'workingday', 'season', 'weather'])
X_features_ohe.head(3)
'''
결과6
'''

 

결과6

원-핫 인코딩을 하면 결과 6처럼 카테고리 분류값을 '칼럼명_분류값'으로 칼럼에 추가합니다. 이제 선형 회귀 모델인 LinearRegression, Ridge, Lasso를 이용해 예측 성능을 확인하겠습니다. get_model_predict()함수를 만들어 모델과 학습/테스트 데이터를 입력하면 성능 평가 수치를 반환하겠습니다.

# 원-핫 인코딩이 적용된 피처 데이터 세트 기반으로 학습/예측 데이터 분할.
X_train, X_test, y_train, y_test = train_test_split(X_features_ohe, y_target_log,\
                                                    test_size=0.3, random_state=0)

# 모델과 학습/테스트 데이터 세트를 입력하면 성능 평가 수치를 반환
def get_model_predict(model, X_train, X_test, y_train, y_test, is_expm1=False):
  model.fit(X_train, y_train)
  pred = model.predict(X_test)
  if is_expm1:
    y_test = np.expm1(y_test)
    pred = np.expm1(pred)
  print('###', model.__class__.__name__, '###')
  evaluate_regr(y_test, pred)
# end of function get_model_predict

# 모델별로 평가 수행
lr_reg = LinearRegression()
ridge_reg = Ridge(alpha=10)
lasso_reg = Lasso(alpha=0.01)

for model in [lr_reg, ridge_reg, lasso_reg]:
  get_model_predict(model, X_train, X_test, y_train, y_test, is_expm1=True)
'''
### LinearRegression ###
RMSLE: 0.590, RMSE: 97.688, MAE: 63.382
### Ridge ###
RMSLE: 0.590, RMSE: 98.529, MAE: 63.893
### Lasso ###
RMSLE: 0.635, RMSE: 113.219, MAE: 72.803
'''

원-핫 인코딩을 적용하고난 후 선형 회귀의 예측 성능이 많이 향상됐습니다. 인코딩 된 데이터 세트에서 회귀 계수가 높은 피처를 다시 시각화하겠습니다. 피처가 늘어났으므로 회귀 계수가 상위 25개인 피처를 추출해보겠습니다.

coef = pd.Series(lr_reg.coef_, index=X_features_ohe.columns)
coef_sort = coef.sort_values(ascending=False)[:25]
sns.barplot(x=coef_sort.values, y=coef_sort.index)
'''
결과7
'''

결과7

계절, 날씨 등과 관련된 season, month 등의 회귀 계수가 높아진 것을 확인하실 수 있습니다. 이처럼 선형 회귀 수행 시 피처를 어떻게 인코딩하는가가 성능에 큰 영향을 미칠 수 있습니다.

 

이번엔 회귀 트리를 이용해 예측해보겠습니다. 앞의 X_features_ohe, y_target_log의 데이터 세트를 그대로 사용하고 RandomForest, GBM, XGBoost, LightGBM을 순차적으로 성능 평가 해보겠습니다. XGBoost의 경우 DataFrame이 학습/테스트 데이터로 입력될 경우 버전에 따라 오류가 발생할 수 있습니다. 이를 DataFrame의 values 속성을 이용해 넘파이의 ndarray로 변환하겠습니다.

 

from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor
from xgboost import XGBRegressor
from lightgbm import LGBMRegressor

# 랜덤 포레스트, GBM, XGBoost, LightGBM model별로 평가 수행
rf_reg = RandomForestRegressor(n_estimators=500)
gbm_reg = GradientBoostingRegressor(n_estimators=500)
xgb_reg = XGBRegressor(n_estimators=500)
lgbm_reg = LGBMRegressor(n_estimators=500)

for model in [rf_reg, gbm_reg, xgb_reg, lgbm_reg]:
  # XGBoost의 경우 DataFrame이 입력될 경우 버전에 따라 오류 발생 가능. ndarray로 변환.
  get_model_predict(model, X_train.values, X_test.values, y_train.values,\
                    y_test.values, is_expm1=True)
'''
### RandomForestRegressor ###
RMSLE: 0.353, RMSE: 50.341, MAE: 31.155
### GradientBoostingRegressor ###
RMSLE: 0.330, RMSE: 53.369, MAE: 32.759
[11:35:27] WARNING: /workspace/src/objective/regression_obj.cu:152: 
reg:linear is now deprecated in favor of reg:squarederror.
### XGBRegressor ###
RMSLE: 0.345, RMSE: 58.245, MAE: 35.768
### LGBMRegressor ###
RMSLE: 0.319, RMSE: 47.215, MAE: 29.029
'''

앞의 선형 회귀 모델보다 예측 성능이 뛰어납니다. 하지만, 이것이 회귀 트리가 선형 회귀보다 뛰어난 성능을 가진다는 것을 의미하는게 아니라 회귀 트리가 이 데이터 세트 유형에 더 적합하다는 뜻입니다.

 


궁금한 점

  1. DataFrame.apply(pd.to_datetime) 문법
    • DataFrame의 모든 행과 열의 Field에 pd.to_datetime 함수를 적용한다는 뜻
    • DataFrame.apply(np.sqrt)면, DataFrame의 모든 칸의 값이 루트를 씌운 값으로 변경된다.  
  2. 카테고리형 피처의 경우 원-핫 인코딩으로 피처를 인코딩해야하는 이유
    • 카테고리형 피처의 경우 카테고리 분류 값의 크기에 따라 선형 회귀 모델의 회귀 계수값에 영향을 줄 수 있기 때문이다.
    • 회귀 트리의 경우는 원-핫 인코딩이 영향을 줄까?

 

출처: 파이썬 머신러닝 완벽 가이드(권철민)

사진 출처: