[kaggle] - 안전 운전자 예측

Jeonghwan Kim·2022년 11월 26일
0

Intro

캐글의 안전 운전자 예측 경진대회 'Porto Seguro's Safe Driver Prediction' compeition에 참가해 다양한 모델링 기법을 연습해보았다.
Porto Seguro라는 브라질의 보험사에서 제공한 고객 데이터를 활용해 운전자가 보험을 청구할 확률을 예측하는 대회이다. 데이터에 결측값이 많기에 이를 잘 해결하는 것이 핵심이다. 타깃값은 0과 1로 구분되는데, 0이면 보험금을 청구하지 않는다는 것, 1이면 보험금을 청구한다는 것이다. 타깃값이 2개이므로 이진분류 문제에 속한다.


EDA

데이터 둘러보기

  • 데이터를 불러와 training data와 test data를 확인해본다.

  • train.info()로 training data를 자세히 살펴본다.

    • feature명에서 데이터 타입을 알 수 있는데, bin이면 이진 feature, cat이면 명목 feature임을 알 수 있다.

    • 또한 non-null로 결측값이 없다고 표기되는데, 이는 결측값 자리에 -1로 값이 모두 입력되어 있어서 그런것으로, -1을 np.NaN으로 변환한 다음 결측값의 개수를 세면 된다.

    • missingno 패키지를 통해 결측값을 시각화해서 볼 수 있다. 원본을 바로 바꾸면 안되니 copy()로 복사본을 만들었고, feature가 57개로 많은 편이기에 먼저 절반만 보면 다음과 같다.

      import numpy as np
      import missingno as msno
      
      # 훈련 데이터 복사본에서 -1을 np.NaN로 변환
      train_copy = train.copy().replace(-1, np.NaN)
      
      # 결측값 시각화(처음 28개만)
      msno.bar(df=train_copy.iloc[:, 1:29], figsize=(13, 6));

      • 막대 그래프 높이가 낮을수록 결측값이 많은 것이다.
    • 나머지 feature들의 결측값도 살펴본다.

      msno.bar(df=train_copy.iloc[:, 29:], figsize=(13, 6));

    • bar() 대신 matrix()를 사용하면 matrix 형태로 시각화할 수 있다. 오른쪽 막대가 결측값의 상대적인 분포를 보여주는데, 튀어나온 부분이 결측값이 몰려있는 행이다. 22는 결측값이 없는 열 개수, 28은 전체 열 개수이다.

      msno.matrix(df=train_copy.iloc[:, 1:29], figsize=(13, 6));

feature 요약표

def resumetable(df):
    print(f'데이터 세트 형상: {df.shape}')
    summary = pd.DataFrame(df.dtypes, columns=['데이터 타입'])
    summary['결측값 개수'] = (df == -1).sum().values # 피처별 -1 개수
    summary['고윳값 개수'] = df.nunique().values
    summary['데이터 종류'] = None
    for col in df.columns:
        if 'bin' in col or col == 'target':
            summary.loc[col, '데이터 종류'] = '이진형'
        elif 'cat' in col:
            summary.loc[col, '데이터 종류'] = '명목형'
        elif df[col].dtype == float:
            summary.loc[col, '데이터 종류'] = '연속형'
        elif df[col].dtype == int:
            summary.loc[col, '데이터 종류'] = '순서형'

    return summary
  • 결측값이 -1이므로 -1의 개수를 구해 결측값의 개수를 알아본다. 또한 for문을 순회하며 데이터 종류를 추가해주었다.

  • training data의 feature 요약표는 다음과 같다.

    summary = resumetable(train)
    summary

  • feature 요약표를 통해 feature 별 데이터 종류, 결측값 개수, 고윳값 개수 등을 한눈에 볼 수 있는데, 이 중 원하는 feature를 추출할 수 도 있다.

    summary[summary['데이터 종류'] == '명목형'].index

    summary[summary['데이터 타입'] == 'float64'].index

데이터 시각화

  • 고윳값별 타깃값 비율을 보고 모델링 시 어떤 feature가 필요한지 구분할 수 있다.

  • 타깃값 분포

    def write_percent(ax, total_size):
        '''도형 객체를 순회하며 막대 그래프 상단에 타깃값 비율 표시'''
        for patch in ax.patches:
            height = patch.get_height()     # 도형 높이(데이터 개수)
            width = patch.get_width()       # 도형 너비
            left_coord = patch.get_x()      # 도형 왼쪽 테두리의 x축 위치
            percent = height/total_size*100 # 타깃값 비율
    
            # (x, y) 좌표에 텍스트 입력
            ax.text(left_coord + width/2.0,     # x축 위치
                    height + total_size*0.001,  # y축 위치
                    '{:1.1f}%'.format(percent), # 입력 텍스트
                    ha='center')                # 가운데 정렬
    
    mpl.rc('font', size=15)
    plt.figure(figsize=(7, 6))
    
    ax = sns.countplot(x='target', data=train)
    write_percent(ax, len(train)) # 비율 표시
    ax.set_title('Target Distribution');

    • 타깃값 1은 단 3.6%로, 타깃값이 불균형함을 알 수 있다. 타깃값의 비율 차이가 크므로 비율이 작은 타깃값 1을 잘 예측하는 것이 중요하다.
    • feature의 고윳값마다 타깃값 비율이 다르면 유의미한 결과가 있는 것이므로 고윳값 별 타깃값 1의 비율을 알아본다. 또한 신뢰구간이 너무 넓으면 불필요한 feature로 판단한다. 따라서 고윳값별 1비율이 차이가 나고 신뢰구간이 작은 feature만 남기고 나머지는 제거하도록한다.
  • 이진 feature

    • 위의 내용을 참고해 이진 feature의 고윳값별 타깃값 비율을 구한다.
    import matplotlib.gridspec as gridspec
    
    def plot_target_ratio_by_features(df, features, num_rows, num_cols, 
                                      size=(12, 18)):
        mpl.rc('font', size=9) 
        plt.figure(figsize=size)                     # 전체 Figure 크기 설정
        grid = gridspec.GridSpec(num_rows, num_cols) # 서브플롯 배치
        plt.subplots_adjust(wspace=0.3, hspace=0.3)  # 서브플롯 좌우/상하 여백 설정
    
        for idx, feature in enumerate(features):
            ax = plt.subplot(grid[idx])
            # ax축에 고윳값별 타깃값 1 비율을 막대 그래프로 그리기
            sns.barplot(x=feature, y='target', data=df, palette='Set2', ax=ax)
    bin_features = summary[summary['데이터 종류'] == '이진형'].index # 이진 피처
    # 이진 피처 고윳값별 타깃값 1 비율을 막대 그래프로 그리기
    plot_target_ratio_by_features(train, bin_features, 6, 3) # 6행 3열 배치

    • 시각화된 그래프를 보고 제거해야할 feature를 파악할 수 있다. 신뢰구간이 넓어 통계적 유효성이 떨어지는 10~13_bin feature와 고윳값별 타깃값 비율 차이가 작은 15~20_calc feature는 모델링에 도움이 되지 않으므로 제거할 수 있다. calc 분류의 이진 feature는 모두 타깃값 비율에 차이가 없는 것으로 보인다.
  • 명목형 feature

    • 명목형 feature도 시각화해준다.

      nom_features = summary[summary['데이터 종류'] == '명목형'].index # 명목형 피처
      
      plot_target_ratio_by_features(train, nom_features, 7, 2) # 7행 2열

      • 첫번째 그래프의 경우처럼 결측값이 타깃값에 대한 유의미한 예측력이 있을 경우 그대로 두고 모델링한다.
      • 애매한 feature의 경우 제거한 경우와 제거하지 않은 경우의 성능을 서로 비교해본다.
      • 우선 명목형 feature는 모두 모델링에 이용하도록 한다.
  • 순서형 feature

    • 순서형 feature도 시각화해준다.

      ord_features = summary[summary['데이터 종류'] == '순서형'].index # 순서형 피처
      
      plot_target_ratio_by_features(train, ord_features, 8, 2, (12, 20)) # 8행 2열

      • ind_14 feature의 경우 고윳값 4의 신뢰구간이 너무 크므로 제거하도록 하고, calc 04~14의 경우 신뢰구간이 크거나 타깃값 비율의 차이가 없기에 제거하도록한다.
  • 연속형 feature

    • 연속형 feature는 연속된 값이므로 고윳값이 매우 많기에 몇 개의 구간으로 나누어 구간별 타깃값 1의 비율을 알아보도록한다.

    • cut()함수로 여러 개의 값을 구간으로 나눈다. 값들의 리스트를 첫 인수로 전달하고, 구간의 개수를 두번째 인수로 전달한다. 연속형 데이터가 범주형 데이터로 바뀐다.

      cont_features = summary[summary['데이터 종류'] == '연속형'].index # 연속형 피처
      
      plt.figure(figsize=(12, 16))                # Figure 크기 설정
      grid = gridspec.GridSpec(5, 2)              # GridSpec 객체 생성
      plt.subplots_adjust(wspace=0.2, hspace=0.4) # 서브플롯 간 여백 설정
      
      for idx, cont_feature in enumerate(cont_features):
          # 값을 5개 구간으로 나누기
          train[cont_feature] = pd.cut(train[cont_feature], 5)
      
          ax = plt.subplot(grid[idx])                # 분포도를 그릴 서브플롯 설정
          sns.barplot(x=cont_feature, y='target', data=train, palette='Set2', ax=ax)
          ax.tick_params(axis='x', labelrotation=10) # x축 라벨 회전

      • 고윳값 별로 타깃값의 비율이 차이가 없는 calc 분류의 feature들을 제거해준다.
  • 연속형 feature 2 - 상관관계

    • 상관관계가 강한 feature들은 예측력이 비슷하므로 하나만 남겨두는 것이 좋다. 상황에 따라 다르므로 종합적으로 보고 제거할지 판단한다.

    • 상관계수의 강도는 보통 피어슨 상관계수 기준 0 ~ 0.2는 매우 약함, 0.2 ~ 0.4는 약함, 0.4 ~ 0.6은 보통, 0.6 ~ 0.8은 강함, 0.8 ~ 1.0은 매우 강함으로 평가한다. 0.8 이상의 매우 강한 상관관계를 보이는 feature가 있다면 제거하는 것도 하나의 방법이다.

    • 결측값이 있으면 상관계수를 구하기 힘드므로 제거하고 히트맵을 그려본다.

      train_copy = train_copy.dropna() # np.NaN 값 삭제
      
      plt.figure(figsize=(10, 8))
      cont_corr = train_copy[cont_features].corr()     # 연속형 피처 간 상관관계 
      sns.heatmap(cont_corr, annot=True, cmap='OrRd'); # 히트맵 그리기

      • 강한 상관관계를 보이는 것은 car_12와 car_14 feature, reg_02와 reg_03 feature인데 각각 제거하고 테스트 해본 결과 car_14 feature는 제거하는 것이 성능이 더 좋아지기에 제거해주고 다른 feature들은 그대로 둔다.

Baseline Model

  • LightGBM 모델로 Baseline Model을 설계한다.

Feature Engineering

데이터 합치기

  • 동일한 인코딩을 적용해 주기 위해 training data, test data를 합쳐준다. 인코딩은 타깃값이 아닌 feature에만 적용해야 하므로 타깃값은 제외해준다.
    all_data = pd.concat([train, test], ignore_index=True)
    all_data = all_data.drop('target', axis=1) # 타깃값 제거
    • all_data의 모든 feature를 all_features에 저장해준다. 나중에 원하는 feature만 추출할 때 이용한다.
    all_features = all_data.columns # 전체 피처
    all_features

명목형 feature 원-핫 인코딩

  • 고윳값별 순서가 없는 명목형 데이터에 원-핫 인코딩을 적용해준다. 이름에 cat이 포함된 feature가 명목형 feature에 해당한다.

    from sklearn.preprocessing import OneHotEncoder
    
    # 명목형 피처 추출
    cat_features = [feature for feature in all_features if 'cat' in feature] 
    
    onehot_encoder = OneHotEncoder() # 원-핫 인코더 객체 생성
    # 인코딩
    encoded_cat_matrix = onehot_encoder.fit_transform(all_data[cat_features]) 
    
    encoded_cat_matrix

    • 184개의 열이 생긴다.

필요없는 feature 제거

  • EDA에서 결정한 제거할 feature(calc 분류의 feature들 및 그 외 6개 feature)를 제거해주어야 한다. 원-핫 인코딩에 사용한 명목형 feature, calc feature, 제거할 6개 feature를 제외하고 나머지 feature들을 모두 remaining_features에 따로 저장해준다.

    # 추가로 제거할 피처
    drop_features = ['ps_ind_14', 'ps_ind_10_bin', 'ps_ind_11_bin', 
                     'ps_ind_12_bin', 'ps_ind_13_bin', 'ps_car_14']
    
    # '1) 명목형 피처, 2) calc 분류의 피처, 3) 추가 제거할 피처'를 제외한 피처
    remaining_features = [feature for feature in all_features 
                          if ('cat' not in feature and 
                              'calc' not in feature and 
                              feature not in drop_features)]
  • reminaing_features와 원-핫 인코딩한 명목형 feature들이 담긴 encoded_cat_matrix를 합쳐주어 인코딩과 feature 제거를 완료한다.

    from scipy import sparse
    
    all_data_sprs = sparse.hstack([sparse.csr_matrix(all_data[remaining_features]),
                                   encoded_cat_matrix],
                                  format='csr')

데이터 나누기

  • 합쳐놓은 전체 데이터를 training data, test data로 다시 나눈다. 타깃값도 y에 할당해준다.

    num_train = len(train) # 훈련 데이터 개수
    
    # 훈련 데이터와 테스트 데이터 나누기
    X = all_data_sprs[:num_train]
    X_test = all_data_sprs[num_train:]
    
    y = train['target'].values

평가지표 계산 함수 작성

  • 평가지표로는 정규화된 지니계수를 사용한다. 정규화 지니계수는 '예측 값에 대한 지니계수 / 예측이 완벽할 때의 지니계수'로, 0에 가까울수록 성능이 나쁘고 1에 가까울수록 좋은 것이다. 다른 사람이 만들어놓은 평가지표함수 코드를 가져다 쓰는 경우가 많다.

    import numpy as np
    
    def eval_gini(y_true, y_pred):
        # 실제값과 예측값의 크기가 같은지 확인 (값이 다르면 오류 발생)
        assert y_true.shape == y_pred.shape
    
        n_samples = y_true.shape[0]                      # 데이터 개수
        L_mid = np.linspace(1 / n_samples, 1, n_samples) # 대각선 값
    
        # 1) 예측값에 대한 지니계수
        pred_order = y_true[y_pred.argsort()] # y_pred 크기순으로 y_true 값 정렬
        L_pred = np.cumsum(pred_order) / np.sum(pred_order) # 로렌츠 곡선
        G_pred = np.sum(L_mid - L_pred)       # 예측 값에 대한 지니계수
    
        # 2) 예측이 완벽할 때 지니계수
        true_order = y_true[y_true.argsort()] # y_true 크기순으로 y_true 값 정렬
        L_true = np.cumsum(true_order) / np.sum(true_order) # 로렌츠 곡선
        G_true = np.sum(L_mid - L_true)       # 예측이 완벽할 때 지니계수
    
        # 정규화된 지니계수
        return G_pred / G_true
  • 만든 eval_gini()를 활용해 모델 훈련 시 검증 파라미터에 전달하기 위한 함수를 만든다.

    def gini(preds, dtrain):
        labels = dtrain.get_label()
        return 'gini', eval_gini(labels, preds), True # 반환값
    • get_label()은 데이터셋의 타깃값을 반환하는 함수로, 실제 타깃값 label과 예측 확률값 preds를 활용해 정규화 지니계수를 구한다. True는 평가 점수가 높을수록 좋다는 것을 의미한다.

모델 훈련 및 성능 검증

  • OOF 예측 방식으로 모델을 훈련하고 예측한다. OOF 예측(Out Of Fold Prediction) 방식은 K-Fold Cross Validation을 하면서 Fold마다 training data로 훈련하고, valid data로 모델 성능을 측정하며 test data로 최종 타깃 확률도 예측하는 것으로 각 Fold별 모델로 여러 번 예측해 평균을 내는 방식이다.

    • 1) K-Fold 방식으로 평가하기에 training data와 valid data가 서로 달라 Overfitting 방지에 좋고, 2) 단일 모델로 한 번만 예측하는 것이 아니라 K개 모델로 K번 예측하기에 앙상블 효과가 있어 성능이 좋다.
  • 타깃값이 불균형하기에 타깃값이 균등하게 배치되게 Fold를 나눠주는 StratifiedKFold()를 사용한다.

    from sklearn.model_selection import StratifiedKFold
    
    # 층화 K 폴드 교차 검증기
    folds = StratifiedKFold(n_splits=5, shuffle=True, random_state=1991)
    • n_splits 파라미터로 전달한 수만큼 Fold를 나누어주고 suffle=True로 Fold를 나눌 때 데이터를 섞어준다. 시계열 데이터라면 순서가 중요하기에 섞으면 안된다.
  • 이어서 LightGBM의 하이퍼파라미터를 설정해주는데, 이진분류 문제이므로 objetive 파라미터를 binary로 설정해준다.

    params = {'objective': 'binary',
              'learning_rate': 0.01,
              'force_row_wise': True,
              'random_state': 0}
  • 이어서 훈련된 모델로 타깃값을 예측할 확률을 담을 1차원 배열을 두 개 만들어 준다.

    # OOF 방식으로 훈련된 모델로 검증 데이터 타깃값을 예측한 확률을 담을 1차원 배열
    oof_val_preds = np.zeros(X.shape[0]) 
    # OOF 방식으로 훈련된 모델로 테스트 데이터 타깃값을 예측한 확률을 담을 1차원 배열
    oof_test_preds = np.zeros(X_test.shape[0]) 
  • LightGBM 모델을 훈련하며 OOF 예측도 수행해준다.

    import lightgbm as lgb
    
    # OOF 방식으로 모델 훈련, 검증, 예측
    for idx, (train_idx, valid_idx) in enumerate(folds.split(X, y)):
        # 각 폴드를 구분하는 문구 출력
        print('#'*40, f'폴드 {idx+1} / 폴드 {folds.n_splits}', '#'*40)
    
        # 훈련용 데이터, 검증용 데이터 설정 
        X_train, y_train = X[train_idx], y[train_idx] # 훈련용 데이터
        X_valid, y_valid = X[valid_idx], y[valid_idx] # 검증용 데이터
    
        # LightGBM 전용 데이터셋 생성 
        dtrain = lgb.Dataset(X_train, y_train) # LightGBM 전용 훈련 데이터셋
        dvalid = lgb.Dataset(X_valid, y_valid) # LightGBM 전용 검증 데이터셋
    
        # LightGBM 모델 훈련 
        lgb_model = lgb.train(params=params,        # 훈련용 하이퍼파라미터
                              train_set=dtrain,     # 훈련 데이터셋
                              num_boost_round=1000, # 부스팅 반복 횟수
                              valid_sets=dvalid,    # 성능 평가용 검증 데이터셋
                              feval=gini,           # 검증용 평가지표
                              early_stopping_rounds=100, # 조기종료 조건
                              verbose_eval=100)     # 100번째마다 점수 출력
    
        # 테스트 데이터를 활용해 OOF 예측
        oof_test_preds += lgb_model.predict(X_test)/folds.n_splits
    
        # 모델 성능 평가를 위한 검증 데이터 타깃값 예측
        oof_val_preds[valid_idx] += lgb_model.predict(X_valid)
    
        # 검증 데이터 예측 확률에 대한 정규화 지니계수 
        gini_score = eval_gini(y_valid, oof_val_preds[valid_idx])
        print(f'폴드 {idx+1} 지니계수 : {gini_score}\n')

    • folds.split(X,y)로 데이터를 K개로 나눠주고 enumarte()함수를 적용해 인덱스, K Fold로 나뉜 training data 인덱스, valid data 인덱스를 사용할 수 있게 해주었다.

    • 전체 training data를 훈련용과 검증용으로 나눈 후, lightGBM 전용 데이터셋을 만들어 훈련해준다. 훈련된 모델에 test data로 타깃 확률값을 OOF 예측해주고, 모델 성능 평가를 위해 valid data로도 예측하여 정규화 지니계수를 계산해준다. Fold마다 계산되는 중간 점검용 점수를 알 수 있다.

    • 모델 훈련 시 verbose_eval=100으로 입력했기에 100번째 iteration마다 성능 평가 점수를 출력하는데, logloss는 lightGBM 모델의 기본 평가지표이고 gini는 feval 파라미터에 추가로 전달했던 평가지표이다. iteration이 반복되며 지니계수가 커지다 early stopping 조건이 100으로 되어있기에 100번 연속으로 지니계수가 최댓값을 갱신하지 못해 훈련이 종료된다. 부스팅 최대 반복 횟수는 1000번인데 이를 기록한 경우는 없다. feval 파라미터에 전달한 gini를 기준으로 early stopping을 판단한다.

    • valid data로 예측한 확률을 실제 타깃값과 비교해 지니계수를 출력하면 다음과 같다.

    print('OOF 검증 데이터 지니계수:', eval_gini(y, oof_val_preds))

예측 및 결과 제출

submission['target'] = oof_test_preds
submission.to_csv('submission.csv')
  • 최종 예측 확률은 oof_test_preds에 담겨있기에 위의 코드로 제출할 수 있다. 제출 시 등수는 상위 48% 정도로 낮은 편이기에 feature engineering이나 하이퍼파라미터 최적화를 해 모델 성능을 개선해주도록 한다.

성능 개선 1: LightGBM 모델

  • LightGBM 모델을 그대로 사용하되, feature engineering과 하이퍼파라미터 최적화를 해주어 모델 성능을 개선하도록 한다.

Feature Engineering

데이터 합치기

  • training data와 test data를 합치고, 타깃값은 제거하며 전체 feature는 따로 저장해준다.

    all_data = pd.concat([train, test], ignore_index=True)
    all_data = all_data.drop('target', axis=1) # 타깃값 제거
    
    all_features = all_data.columns # 전체 피처

명목형 feature 원-핫 인코딩

from sklearn.preprocessing import OneHotEncoder

# 명목형 피처
cat_features = [feature for feature in all_features if 'cat' in feature] 

# 원-핫 인코딩 적용
onehot_encoder = OneHotEncoder()
encoded_cat_matrix = onehot_encoder.fit_transform(all_data[cat_features]) 

파생 feature 추가

  1. 한 데이터가 가진 결측값 개수를 파생 feature로 만들기
  • 'all_data'와 명목형 feature, calc feature를 제거한 'reamining_features'에 결측값 개수를 구한 num_missing feature를 추가해준다.
    # '데이터 하나당 결측값 개수'를 파생 피처로 추가
    all_data['num_missing'] = (all_data==-1).sum(axis=1)
    # 명목형 피처, calc 분류의 피처를 제외한 피처
    remaining_features = [feature for feature in all_features
                          if ('cat' not in feature and 'calc' not in feature)] 
    # num_missing을 remaining_features에 추가
    remaining_features.append('num_missing')
  1. ind 분류의 모든 feature 값들을 연결해 새로운 feature mix_ind를 만들어본다.

    # 분류가 ind인 피처
    ind_features = [feature for feature in all_features if 'ind' in feature]
    
    is_first_feature = True
    for ind_feature in ind_features:
        if is_first_feature:
            all_data['mix_ind'] = all_data[ind_feature].astype(str) + '_'
            is_first_feature = False
        else:
            all_data['mix_ind'] += all_data[ind_feature].astype(str) + '_'
    
    all_data['mix_ind']

  2. 명목형 feature의 고윳값별 개수를 새로운 feature로 추가한다. value_counts()함수로 개수를 구하는데, 이는 Series 타입이기에 to_dict()함수를 이용해 딕셔너리 타입으로 바꿔준다. 방금 추가한 mix_ind feature도 명목형 feature 이므로 cat 분류에 속하는 feature들과 mix_ind feature의 고윳값별 개수를 파생 feature로 만든다.

    cat_count_features = []
    for feature in cat_features+['mix_ind']:
        val_counts_dict = all_data[feature].value_counts().to_dict()
        all_data[f'{feature}_count'] = all_data[feature].apply(lambda x: 
                                                               val_counts_dict[x])
        cat_count_features.append(f'{feature}_count')
  • cat_count_features에 새로 추가한 feature명들이 들어가있다.
    cat_count_features = []
    for feature in cat_features+['mix_ind']:
        val_counts_dict = all_data[feature].value_counts().to_dict()
        all_data[f'{feature}_count'] = all_data[feature].apply(lambda x: 
                                                               val_counts_dict[x])
        cat_count_features.append(f'{feature}_count')

필요 없는 feature 제거

  • EDA에서 필요없다고 판단한 feature들을 제거하고, 새로 만들어준 encoded_cat matrix, cat_count_features를 추가해준다.

    from scipy import sparse
    # 필요 없는 피처들
    drop_features = ['ps_ind_14', 'ps_ind_10_bin', 'ps_ind_11_bin', 
                     'ps_ind_12_bin', 'ps_ind_13_bin', 'ps_car_14']
    
    # remaining_features, cat_count_features에서 drop_features를 제거한 데이터
    all_data_remaining = all_data[remaining_features+cat_count_features].drop(drop_features, axis=1)
    
    # 데이터 합치기
    all_data_sprs = sparse.hstack([sparse.csr_matrix(all_data_remaining),
                                   encoded_cat_matrix],
                                  format='csr')

    1) 명목형 feature에 원-핫 인코딩을 적용했다.
    2) 데이터 하나 당 가지고 있는 결측값 개수를 새로운 feature로 만들었다.
    3) 모든 ind feature 값을 연결해서 새로운 명목형 feature를 만들어 4에서 활용했다.
    4) 명목형 feature의 고윳값별 개수를 새로운 feature로 만들었다.
    5) 필요 없는 featue를 제거했다.

데이터 나누기

  • Feature engineering을 마무리했으니 다시 training data와 test data로 나눈다.

    num_train = len(train) # 훈련 데이터 개수
    
    # 훈련 데이터와 테스트 데이터 나누기
    X = all_data_sprs[:num_train]
    X_test = all_data_sprs[num_train:]
    
    y = train['target'].values

하이퍼파라미터 최적화

  • 그리드서치보다 빠르고 효율적인 베이지안 최적화 기법을 사용한다.

데이터셋 준비

  • 베이지안 최적화를 위한 데이터셋을 준비한다.

    import lightgbm as lgb
    from sklearn.model_selection import train_test_split
    
    # 8:2 비율로 훈련 데이터, 검증 데이터 분리 (베이지안 최적화 수행용)
    X_train, X_valid, y_train, y_valid = train_test_split(X, y, 
                                                          test_size=0.2, 
                                                          random_state=0)
    
    # 베이지안 최적화용 데이터셋
    bayes_dtrain = lgb.Dataset(X_train, y_train)
    bayes_dvalid = lgb.Dataset(X_valid, y_valid)
    • train_test_split()에서 test_size=0.2로 설정하여 전체 중 80%를 training data, 20%를 valid data로 나눈다

하이퍼파라미터 범위 설정

  • 하이퍼파라미터를 설정할 때는 범위를 점점 좁히거나 다른 상위권 코드를 참고해서 설정한다. 범위를 좁힐 때는 0~1 범위의 파라미터의 경우 0~1 전체로 최적화를 수행하고, 0.5가 최적 하이퍼파라미터라면 다시 0.5 주변으로 0.4~0.6 정도로 좁혀 최적화를 수행하는 식으로 진행한다.

  • 탐색할 하이퍼파라미터 범위를 param_bounds에 지정하고, 값을 고정할 하이퍼파라미터는 fixed_params에 저장해둔다.

    # 베이지안 최적화를 위한 하이퍼파라미터 범위
    param_bounds = {'num_leaves': (30, 40),
                    'lambda_l1': (0.7, 0.9),
                    'lambda_l2': (0.9, 1),
                    'feature_fraction': (0.6, 0.7),
                    'bagging_fraction': (0.6, 0.9),
                    'min_child_samples': (6, 10),
                    'min_child_weight': (10, 40)}
    
    # 값이 고정된 하이퍼파라미터
    fixed_params = {'objective': 'binary',
                    'learning_rate': 0.005,
                    'bagging_freq': 1,
                    'force_row_wise': True,
                    'random_state': 1991}
    • 이진분류 문제이므로 objective을 binary로 설정하고, 학습률과 배깅 수행빈도를 설정한다. 이 두 파라미터도 탐색할 범위에 포함시켜도 된다. force_row_wise는 경고문구를 없애기 위해 추가했다.

베이지안 최적화용 평가지표 계산 함수 작성

  • 베이지안 최적화를 수행하기 위한 평가지표 지니계수 계산 함수를 만들어 지니계수를 계산해 최적 하이퍼파라미터를 찾도록한다. 최적화하려는 LightGBM 모델의 하이퍼파라미터 7개를 인수로 전달받아 지니계수를 반환한다.

    def eval_function(num_leaves, lambda_l1, lambda_l2, feature_fraction,
                      bagging_fraction, min_child_samples, min_child_weight):
        '''최적화하려는 평가지표(지니계수) 계산 함수'''
    
        # 베이지안 최적화를 수행할 하이퍼파라미터 
        params = {'num_leaves': int(round(num_leaves)),
                  'lambda_l1': lambda_l1,
                  'lambda_l2': lambda_l2,
                  'feature_fraction': feature_fraction,
                  'bagging_fraction': bagging_fraction,
                  'min_child_samples': int(round(min_child_samples)),
                  'min_child_weight': min_child_weight,
                  'feature_pre_filter': False}
        # 고정된 하이퍼파라미터도 추가
        params.update(fixed_params)
    
        print('하이퍼파라미터:', params)    
    
        # LightGBM 모델 훈련
        lgb_model = lgb.train(params=params, 
                               train_set=bayes_dtrain,
                               num_boost_round=2500,
                               valid_sets=bayes_dvalid,
                               feval=gini,
                               early_stopping_rounds=300,
                               verbose_eval=False)
        # 검증 데이터로 예측 수행
        preds = lgb_model.predict(X_valid) 
        # 지니계수 계산
        gini_score = eval_gini(y_valid, preds)
        print(f'지니계수 : {gini_score}\n')
    
        return gini_score

최적화 수행

  • 베이지안 최적화 객체를 생성하고, maximize()메서드로 베이지안 회적화를 수행한다.

    from bayes_opt import BayesianOptimization
    
    # 베이지안 최적화 객체 생성
    optimizer = BayesianOptimization(f=eval_function,      # 평가지표 계산 함수
                                     pbounds=param_bounds, # 하이퍼파라미터 범위
                                     random_state=0)
    
    # 베이지안 최적화 수행
    optimizer.maximize(init_points=3, n_iter=6)
    • init_points는 무작위로 하이퍼파라미터를 탐색하는 횟수, n_iter는 베이지안 최적화 반복횟수이다. 둘을 더한 값만큼 반복한다.

결과 확인

  • 최적화가 끝나면 지니계수가 최대가 되는 최적 하이퍼파라미터를 출력해본다.
    # 평가함수 점수가 최대일 때 하이퍼파라미터
    max_params = optimizer.max['params']
    max_params
  • num_leaves와 min_child_samples는 원래 정수형 파라미터이므로 정수형으로 변환하여 다시 저장한다.
    # 정수형 하이퍼파라미터 변환
    max_params['num_leaves'] = int(round(max_params['num_leaves']))
    max_params['min_child_samples'] = int(round(max_params['min_child_samples']))
  • 값이 고정된 하이퍼파라미터들도 추가해주고, 최종 하이퍼파라미터를 출력한다.
    # 값이 고정된 하이퍼파라미터 추가
    max_params.update(fixed_params)
    max_params

모델 훈련 및 성능 검증

  • 베이지안 최적화는 그리드서치와 달리 최적 예측기를 제공하지 않는다. 베이지안 최적화로 찾은 하이퍼파라미터를 활용해 LightGBM 모델을 다시 훈련한다. 베이스라인 모델 훈련 코드와 비슷한데, 파라미터만 max_parmas로 바꿔주었다.

    from sklearn.model_selection import StratifiedKFold
    
    # 층화 K 폴드 교차 검증기 생성
    folds = StratifiedKFold(n_splits=5, shuffle=True, random_state=1991)
    
    # OOF 방식으로 훈련된 모델로 검증 데이터 타깃값을 예측한 확률을 담을 1차원 배열
    oof_val_preds = np.zeros(X.shape[0]) 
    # OOF 방식으로 훈련된 모델로 테스트 데이터 타깃값을 예측한 확률을 담을 1차원 배열
    oof_test_preds = np.zeros(X_test.shape[0]) 
    
    # OOF 방식으로 모델 훈련, 검증, 예측
    for idx, (train_idx, valid_idx) in enumerate(folds.split(X, y)):
        # 각 폴드를 구분하는 문구 출력
        print('#'*40, f'폴드 {idx+1} / 폴드 {folds.n_splits}', '#'*40)
    
        # 훈련용 데이터, 검증용 데이터 설정
        X_train, y_train = X[train_idx], y[train_idx] # 훈련용 데이터
        X_valid, y_valid = X[valid_idx], y[valid_idx] # 검증용 데이터
    
        # LightGBM 전용 데이터셋 생성
        dtrain = lgb.Dataset(X_train, y_train) # LightGBM 전용 훈련 데이터셋
        dvalid = lgb.Dataset(X_valid, y_valid) # LightGBM 전용 검증 데이터셋
    
        # LightGBM 모델 훈련
        lgb_model = lgb.train(params=max_params,    # 최적 하이퍼파라미터
                              train_set=dtrain,     # 훈련 데이터셋
                              num_boost_round=2500, # 부스팅 반복 횟수
                              valid_sets=dvalid,    # 성능 평가용 검증 데이터셋
                              feval=gini,           # 검증용 평가지표
                              early_stopping_rounds=300, # 조기종료 조건
                              verbose_eval=100)     # 100번째마다 점수 출력
    
        # 테스트 데이터를 활용해 OOF 예측
        oof_test_preds += lgb_model.predict(X_test)/folds.n_splits
        # 모델 성능 평가를 위한 검증 데이터 타깃값 예측 
        oof_val_preds[valid_idx] += lgb_model.predict(X_valid)
    
        # 검증 데이터 예측확률에 대한 정규화 지니계수
        gini_score = eval_gini(y_valid, oof_val_preds[valid_idx])
        print(f'폴드 {idx+1} 지니계수 : {gini_score}\n')

  • 검증 데이터로 예측한 확률과 실제 타깃값의 지니계수를 보면 베이스라인 모델보다 0.0085 정도 높아졌다.

    print('OOF 검증 데이터 지니계수 :', eval_gini(y, oof_val_preds))

예측 및 결과 제출

  • 최종 예측 확률인 oof_test_preds를 활용해 제출 파일을 만들어 제출하면 베이스라인 모델보다 0.00722점 정도 올라 23등 정도의 결과를 기록했다. 다른 모델을 활용해 등수를 더 높여보겠다.

성능 개선 2: XGBOOST 모델

  • XGBOOST 모델로 성능을 향상시켜본다. 전체적인 프로세스는 동일하나, 지니계수 반환값, 데이터셋 객체, 모델 하이퍼파라미터명 등을 수정해주어야 한다.

Feature Engineering

  • 앞의 LightGBM 모델과 feature engineering 코드 부분은 거의 같고, gini()함수만 변경해주면 된다. 3개였던 반환값을 2개로 바꿔준다.
    # XGBoost용 gini() 함수
    def gini(preds, dtrain):
        labels = dtrain.get_label()
        return 'gini', eval_gini(labels, preds)

하이퍼파라미터 최적화

  • LightGBM과 마찬가지로 베이지안 최적화를 수행하는데, 데이터셋을 만드는 코드와 하이퍼파라미터명을 조금 바꾸어 준다.

데이터셋 준비

  • xgb.DMatrix()로 베이지안 최적화용 데이터셋을 만들어 준다.
import pandas as pd

# 데이터 경로
data_path = '/kaggle/input/porto-seguro-safe-driver-prediction/'

train = pd.read_csv(data_path + 'train.csv', index_col='id')
test = pd.read_csv(data_path + 'test.csv', index_col='id')
submission = pd.read_csv(data_path + 'sample_submission.csv', index_col='id')

하이퍼파라미터 범위 설정

# 베이지안 최적화를 위한 하이퍼파라미터 범위
param_bounds = {'max_depth': (4, 8),
                'subsample': (0.6, 0.9),
                'colsample_bytree': (0.7, 1.0),
                'min_child_weight': (5, 7),
                'gamma': (8, 11),
                'reg_alpha': (7, 9),
                'reg_lambda': (1.1, 1.5),
                'scale_pos_weight': (1.4, 1.6)}

# 값이 고정된 하이퍼파라미터
fixed_params = {'objective': 'binary:logistic',
                'learning_rate': 0.02,
                'random_state': 1991}

베이지안 최적화용 평가지표 계산 함수 작성

  • LightGBM의 eval_function()과 전체적으로 유사하지만 4가지 다른 부분이 있다.
    1) 하이퍼파라미터명
    2) train() 메서드 내 검증 데이터 전달 방식
    3) train() 메서드 내 maximize 파라미터
    4) predict() 메서드에 Dmatrix 타입을 전달하는 점

    def eval_function(max_depth, subsample, colsample_bytree, min_child_weight,
                     reg_alpha, gamma, reg_lambda, scale_pos_weight):
        '''최적화하려는 평가지표(지니계수) 계산 함수'''
        # 베이지안 최적화를 수행할 하이퍼파라미터
        params = {'max_depth': int(round(max_depth)),
                  'subsample': subsample,
                  'colsample_bytree': colsample_bytree,
                  'min_child_weight': min_child_weight,
                  'gamma': gamma,
                  'reg_alpha':reg_alpha,
                  'reg_lambda': reg_lambda,
                  'scale_pos_weight': scale_pos_weight}
        # 값이 고정된 하이퍼파라미터도 추가
        params.update(fixed_params)
    
        print('하이퍼파라미터 :', params)    
    
        # XGBoost 모델 훈련
        xgb_model = xgb.train(params=params, 
                              dtrain=bayes_dtrain,
                              num_boost_round=2000,
                              evals=[(bayes_dvalid, 'bayes_dvalid')],
                              maximize=True,
                              feval=gini,
                              early_stopping_rounds=200,
                              verbose_eval=False)
    
        best_iter = xgb_model.best_iteration # 최적 반복 횟수
        # 검증 데이터로 예측 수행
        preds = xgb_model.predict(bayes_dvalid, 
                                  iteration_range=(0, best_iter))
        # 지니계수 계산
        gini_score = eval_gini(y_valid, preds)
        print(f'지니계수 : {gini_score}\n')
    
        return gini_score
    • LightGBM은 훈련 단계에서 성능이 가장 좋았던 반복 횟수 때의 모델을 활용해 예측하는 반면 XGBoost는 성능이 가장 좋을 때의 부스팅 반복 횟수를 iteration_range 파라미터에 명시해줘야 한다.
    • evals는 검증 데이터를 전달받는 파라미터로, 검증 데이터와 검증 데이터의 이름 쌍을 전달한다.
    • maximize 파라미터에는 True를 전달해 평가점수(지니계수)가 클수록 좋다는 뜻을 담는다.
    • predict 부분에서는 LightGBM에서는 원본 데이터 타입인 X_valid를 전달한 반면, XGBoost는 데이터를 DMatrix 타입으로 전달해야하기에 bayes_dvalid를 전달했다.

최적화 수행 및 결과 확인

  • 베이지안 최적화를 수행하고 최적화된 파라미터를 출력해본다.

    from bayes_opt import BayesianOptimization
    
    # 베이지안 최적화 객체 생성
    optimizer = BayesianOptimization(f=eval_function, 
                                     pbounds=param_bounds, 
                                     random_state=0)
    
    # 베이지안 최적화 수행
    optimizer.maximize(init_points=3, n_iter=6)
    • max_depth는 트리 깊이를 의미하므로 정수형이어야하니 정수형으로 바꾸고, 고정 하이퍼파라미터도 추가한다.

      # 정수형 하이퍼파라미터 변환
      max_params['max_depth'] = int(round(max_params['max_depth']))
      
      # 값이 고정된 하이퍼파라미터 추가
      max_params.update(fixed_params)
      max_params

모델 훈련 및 성능 검증

  • OOF 방식을 이용해 XGBoost 모델을 훈련한다.

    from sklearn.model_selection import StratifiedKFold
    
    # 층화 K 폴드 교차 검증기 생성
    folds = StratifiedKFold(n_splits=5, shuffle=True, random_state=1991)
    
    # OOF 방식으로 훈련된 모델로 검증 데이터 타깃값을 예측한 확률을 담을 1차원 배열
    oof_val_preds = np.zeros(X.shape[0]) 
    # OOF 방식으로 훈련된 모델로 테스트 데이터 타깃값을 예측한 확률을 담을 1차원 배열
    oof_test_preds = np.zeros(X_test.shape[0]) 
    
    # OOF 방식으로 모델 훈련, 검증, 예측
    for idx, (train_idx, valid_idx) in enumerate(folds.split(X, y)):
        # 각 폴드를 구분하는 문구 출력
        print('#'*40, f'폴드 {idx+1} / 폴드 {folds.n_splits}', '#'*40)
    
        # 훈련용 데이터, 검증용 데이터 설정
        X_train, y_train = X[train_idx], y[train_idx]
        X_valid, y_valid = X[valid_idx], y[valid_idx]
    
        # XGBoost 전용 데이터셋 생성 
        dtrain = xgb.DMatrix(X_train, y_train)
        dvalid = xgb.DMatrix(X_valid, y_valid)
        dtest = xgb.DMatrix(X_test)
        # XGBoost 모델 훈련
        xgb_model = xgb.train(params=max_params, 
                              dtrain=dtrain,
                              num_boost_round=2000,
                              evals=[(dvalid, 'valid')],
                              maximize=True,
                              feval=gini,
                              early_stopping_rounds=200,
                              verbose_eval=100)
    
        # 모델 성능이 가장 좋을 때의 부스팅 반복 횟수 저장
        best_iter = xgb_model.best_iteration
        # 테스트 데이터를 활용해 OOF 예측
        oof_test_preds += xgb_model.predict(dtest,
                                            iteration_range=(0, best_iter))/folds.n_splits
    
        # 모델 성능 평가를 위한 검증 데이터 타깃값 예측 
        oof_val_preds[valid_idx] += xgb_model.predict(dvalid, 
                                                      iteration_range=(0, best_iter))
    
        # 검증 데이터 예측 확률에 대한 정규화 지니계수
        gini_score = eval_gini(y_valid, oof_val_preds[valid_idx])
        print(f'폴드 {idx+1} 지니계수 : {gini_score}\n')
    • 1706번째에서 조기종료되었는데, early stopping 조건이 200회 이므로 1506번째에서 가장 좋은 성능을 보였다는 뜻이다.
  • OOF 검증 데이터 지니계수도 출력해보면 LightGBM의 계수보다 높다.

    print('OOF 검증 데이터 지니계수 :', eval_gini(y, oof_val_preds))
  • 이를 바탕으로 최종 예측 확률을 제출하면 LightGBM의 점수보다 0.0002정도 높고, 상위 17등 정도된다.


성능 개선 3: LightGBM과 XGBoost 앙상블

  • 두 모델의 예측값을 결합하면 더 좋은 점수를 얻을 수 있는데, 이렇게 예측값을 도출하는 방식을 앙상블이라고한다. 비슷한 모델보다는 다양한 모델을 사용하는게 효과가 좋다.
  • LightGBM으로 예측한 확률값과 XGBoost로 예측한 확률값 각각에 50%씩 가중치를 주어 구한 가중평균을 최종 예측 확률로 한다.
    oof_test_preds = oof_test_preds_lgb * 0.5 + oof_test_preds_xgb * 0.5
  • 가중치 비율은 상황에 따라 테스트해보고 결정한다.
  • 앙상블 모델의 점수는 private 기준 0.29186으로, 전체 9등을 기록했다.

최종

  • LightGBM과 XGBoost 모델을 앙상블하여 나온 최종 예측 확률을 제출하였고, Private 0.29186으로 전체 5157명의 참가자 중 9위 수준의 결과를 기록할 수 있다.
  • 앙상블의 강력함을 체감할 수 있는 경진대회였다.
  • github에 해당 코드를 올려두었다.

참고: 머신러닝·딥러닝 문제해결 전략 (캐글 수상작 리팩터링으로 배우는 문제해결 프로세스와 전략)

참고: https://www.kaggle.com/code/bertcarremans/data-preparation-exploration/notebook,
https://www.kaggle.com/code/xiaozhouwang/2nd-place-lightgbm-solution/script

0개의 댓글