위키북스의 파이썬 머신러닝 완벽 가이드 책을 토대로 공부한 내용입니다.
캐글의 신용카드 dataset을 이용해 신용카드 사기 검출 분류 실습을 수행해 보겠다. 해당 dataset의 label인 Class 속성은 매우 불균형한 분포를 가지고 있다. Class에서 0은 정상적인 신용카드 트랜잭션 데이터이고, 1은 신용카드 사기 트랜잭션을 의마하며, 전체 데이터의 약 0.172%만이 사기 트랜잭션이다. 일반적으로 사기 검출(Fraud Detection)이나 이상 검출(Anomaly Detection)과 같은 dataset은 label 값이 극도로 불균형한 분포를 가지기 쉽다.
label이 불균형한 분포를 가진 dataset을 학습시킬 때 예측 성능의 문제가 발생할 수 있는데, 이는 이상 label을 가지는 데이터 건수가 정상 label을 가진 데이터 건수에 비해 너무 적기 때문에 발생한다. 즉, 이상 label의 데이터 수가 적어서 다양한 유형의 학습을 하지 못하는 반면 정상 label을 가진 데이터는 매우 많기 때문에 일방적으로 정상 label로 치우치는 학습이 되어 이상 데이터 검출이 어려워진다. 지도학습에서 극도로 불균형한 label 값 분포로 인한 문제점을 해결하기 위해 적절한 학습 데이터를 확보하는 방안이 필요한데, 대표적으로 오버 샘플링(Oversampling)과 언더 샘플링(Undersampling) 방법이 있으며, 오버 샘플링이 예측 성능상 더 유리한 경우가 많다.
- 언더 샘플링은 많은 dataset를 적은 dataset 수준으로 감소시키는 방식이다. 즉 정상 label을 가진 데이터가 10000건, 이상 label을 가진 데이터가 100건이 있으면 정상 label을 가진 데이터가 100건으로 줄여 버리는 방식이다. 이렇게 정상 label 데이터를 이상 label 데이터만큼으로 줄이면 정상 label로 과도하게 학습/예측하는 부작용은 개선할 수 있지만, 오히려 정상 label에 대해 제대로 학습을 수행할 수 없다는 단점이 있어 잘 사용하지 않는 방법이다.
- 오버 샘플링은 이상 데이터와 같이 적은 dataset을 증식하여 학습을 위한 충분한 dataset을 확보하는 방법이다. 동일한 데이터를 단순히 증식하는 방법은 과적합(Overfitting)이 되기 때문에 의미가 없으므로 원본 데이터의 feature 값들을 아주 약간만 변경하여 증식한다. 대표적으로 SMOTE(Synthetic Minority Over-sampling Technique) 방법이 있다. SMOTE는 적은 dataset에 있는 개별 데이터들의 K 최근접 이웃 (K Nearest Neighbor)을 찾아서 이 데이터와 K개 이웃들의 차리를 일정 값으로 만들어서 기존 데이터와 약간 차이가 나는 새로운 데이터를 생성하는 방식이다.SMOTE를 구현한 대표적인 파이썬 패키지는 imbalanced-learn이다.
필요한 모듈과 데이터를 로딩하고 데이터를 확인해보겠다.
import pandas as pd import numpy as np import matplotlib.pyplot as plt import warnings warnings.filterwarnings("ignore") %matplotlib inline card_df = pd.read_csv('./creditcard.csv') card_df.head(3)
[output] V로 시작하는 feature는 의미를 알 수 없고, Time feature는 데이터 생성과 관련한 작업용 속성으로서 큰 의미가 없어 제거하였다. Amount feature는 신용카드 트랙잭션 금액을 의미하고, card_df.info()로 확인해 보면 결측치(Missing Value) 값은 없다. DataFrame을 가공하는 get_preprocessed_df() 함수와 데이터 가공 후 학습/테스트 dataset을 반환하는 get_train_test_df() 함수를 생성하겠다.
from sklearn.model_selection import train_test_split # 인자로 입력받은 DataFrame을 복사 한 뒤 Time 컬럼만 삭제하고 복사된 DataFrame 반환 def get_preprocessed_df(df=None): df_copy = df.copy() df_copy.drop('Time', axis=1, inplace=True) return df_copy # 사전 데이터 가공 후 학습과 테스트 데이터 세트를 반환하는 함수. def get_train_test_dataset(df=None): # 인자로 입력된 DataFrame의 사전 데이터 가공이 완료된 복사 DataFrame 반환 df_copy = get_preprocessed_df(df) # DataFrame의 맨 마지막 컬럼이 레이블, 나머지는 피처들 X_features = df_copy.iloc[:, :-1] y_target = df_copy.iloc[:, -1] # train_test_split( )으로 학습과 테스트 데이터 분할. stratify=y_target으로 Stratified 기반 분할 X_train, X_test, y_train, y_test = \ train_test_split(X_features, y_target, test_size=0.3, random_state=0, stratify=y_target) # 학습과 테스트 데이터 세트 반환 return X_train, X_test, y_train, y_test X_train, X_test, y_train, y_test = get_train_test_dataset(card_df) print('학습 데이터 레이블 값 비율') print(y_train.value_counts()/y_train.shape[0] * 100) print('테스트 데이터 레이블 값 비율') print(y_test.value_counts()/y_test.shape[0] * 100)
[output] 학습 dataset과 테스트 dataset의 label 비율을 보니 서로 비슷하게 분할된 것을 알 수 있다. 로지스틱 회귀를 통해 예측해 보겠다.
from sklearn.metrics import confusion_matrix, accuracy_score, precision_score, recall_score, f1_score from sklearn.metrics import roc_auc_score def get_clf_eval(y_test, pred=None, pred_proba=None): confusion = confusion_matrix( y_test, pred) accuracy = accuracy_score(y_test , pred) precision = precision_score(y_test , pred) recall = recall_score(y_test , pred) f1 = f1_score(y_test,pred) # ROC-AUC 추가 roc_auc = roc_auc_score(y_test, pred_proba) print('오차 행렬') print(confusion) # ROC-AUC print 추가 print('정확도: {0:.4f}, 정밀도: {1:.4f}, 재현율: {2:.4f},\ F1: {3:.4f}, AUC:{4:.4f}'.format(accuracy, precision, recall, f1, roc_auc)) from sklearn.linear_model import LogisticRegression lr_clf = LogisticRegression() lr_clf.fit(X_train, y_train) lr_pred = lr_clf.predict(X_test) lr_pred_proba = lr_clf.predict_proba(X_test)[:, 1] # 3장에서 사용한 get_clf_eval() 함수를 이용하여 평가 수행. get_clf_eval(y_test, lr_pred, lr_pred_proba)
[output] 이번엔 LightGBM을 이용한 모델을 만들어 보겠다. 그전에 반복적으로 모델을 변결해 학습/예측/평가할 것이므로 이를 위한 함수를 생성하겠다. 그리고 LightGBM으로 학습을 할 때 주어진 dataset이 극도로 불균형한 label 분포도를 가지고 있으므로 boost_from_average=Flase로 설정해야 한다.
# 인자로 사이킷런의 Estimator객체와, 학습/테스트 데이터 세트를 입력 받아서 학습/예측/평가 수행. def get_model_train_eval(model, ftr_train=None, ftr_test=None, tgt_train=None, tgt_test=None): model.fit(ftr_train, tgt_train) pred = model.predict(ftr_test) pred_proba = model.predict_proba(ftr_test)[:, 1] get_clf_eval(tgt_test, pred, pred_proba) from lightgbm import LGBMClassifier lgbm_clf = LGBMClassifier(n_estimators=1000, num_leaves=64, n_jobs=-1, boost_from_average=False) get_model_train_eval(lgbm_clf, ftr_train=X_train, ftr_test=X_test, tgt_train=y_train, tgt_test=y_test)
[output] 앞의 로지스틱 회귀보다는 높은 수치가 나왔다.
왜곡된 분포를 가지는 데이터를 재가공한 뒤에 모델을 다시 테스트 해보겠다. 로지스틱 회귀는 선형 모델이다. 대부분의 선형 모델은 중요 feature들의 값이 정규 분포 형태를 유지하는 것을 선호한다. Amount feature는 신용 카드 사용 금액으로 정상/사기 트랜잭션을 결정하는 매우 중요한 속성일 가능성이 높다. Amount feature의 분포를 확인해보겠다.
import seaborn as sns plt.figure(figsize=(8, 4)) plt.xticks(range(0, 30000, 1000), rotation=60) sns.distplot(card_df['Amount'])
[output] 대부분의 데이터가 1,000불 이하이며, 27,000불의 많은 금액까지 사용한 경우가 있어 긴 꼬리의 형태를 가지는 분포 곡선이 나온다. Amount를 표준 정규 분포로 변환한 후 로지스틱 회귀와 LightGBM의 예측 성능을 다시 확인해 보겠다. 정규분포로 바꾸기 위해 get_processed_df() 함수에서 사이킷런의 StandardScaler 클래스를 이용해 Amount feature를 정규 분포로 변환하겠다.
from sklearn.preprocessing import StandardScaler # 사이킷런의 StandardScaler를 이용하여 정규분포 형태로 Amount 피처값 변환하는 로직으로 수정. def get_preprocessed_df(df=None): df_copy = df.copy() scaler = StandardScaler() amount_n = scaler.fit_transform(df_copy['Amount'].values.reshape(-1, 1)) # 변환된 Amount를 Amount_Scaled로 피처명 변경후 DataFrame맨 앞 컬럼으로 입력 df_copy.insert(0, 'Amount_Scaled', amount_n) # 기존 Time, Amount 피처 삭제 df_copy.drop(['Time','Amount'], axis=1, inplace=True) return df_copy # Amount를 정규분포 형태로 변환 후 로지스틱 회귀 및 LightGBM 수행. X_train, X_test, y_train, y_test = get_train_test_dataset(card_df) print('### 로지스틱 회귀 예측 성능 ###') lr_clf = LogisticRegression() get_model_train_eval(lr_clf, ftr_train=X_train, ftr_test=X_test, tgt_train=y_train, tgt_test=y_test) print('### LightGBM 예측 성능 ###') lgbm_clf = LGBMClassifier(n_estimators=1000, num_leaves=64, n_jobs=-1, boost_from_average=False) get_model_train_eval(lgbm_clf, ftr_train=X_train, ftr_test=X_test, tgt_train=y_train, tgt_test=y_test)
[output] 두 모델 모두 이전과 성능이 크게 개선되지 않았다. 이번에는 StandardScaler가 아닌 로그 변환을 수행해 보겠다. 로그 변환은 데이터 분포도가 심하게 왜곡되어 있을 경우 적용하는 중요 기법 중 하나이다. log 변환으로 원래 큰 값을 상대적으로 작은 값으로 변환하기 때문에 데이터 분포도의 왜곡을 상당 수준 개선해준다.
def get_preprocessed_df(df=None): df_copy = df.copy() # 넘파이의 log1p( )를 이용하여 Amount를 로그 변환 amount_n = np.log1p(df_copy['Amount']) df_copy.insert(0, 'Amount_Scaled', amount_n) df_copy.drop(['Time','Amount'], axis=1, inplace=True) return df_copy X_train, X_test, y_train, y_test = get_train_test_dataset(card_df) print('### 로지스틱 회귀 예측 성능 ###') get_model_train_eval(lr_clf, ftr_train=X_train, ftr_test=X_test, tgt_train=y_train, tgt_test=y_test) print('### LightGBM 예측 성능 ###') get_model_train_eval(lgbm_clf, ftr_train=X_train, ftr_test=X_test, tgt_train=y_train, tgt_test=y_test)
[output] 두 모델 모두 정밀도와 재현율, ROC-AUC에서 약간의 성능 개선을 확인할 수 있다.
이상치 데이터(Outlier)는 전체 데이터의 패턴에서 벗어난 이상 값을 가진 데이터이며, 이로 인해 머신러닝 모델의 성능에 영향을 받는 경우가 발생하기 쉽다. 따라서 이상치를 찾아내는 방법을 알아보고, 이상치 데이터를 제거한 뒤 다시 모델을 평가해보겠다. 여러 방법들이 있지만 이 중 IQR(Inter Quantile Range) 방식을 적용해 보겠다. IQR은 사분위(Quantile) 값의 편차를 이용하는 기법으로 흔히 박스 플롯(Box Plot) 방식으로 시각화할 수 있다.
사분위는 전체 데이터를 값이 높은 순으로 정렬하고, 이를 25%씩으로 구간을 분할하는 것을 지칭한다. IQR을 이용해 이상치 데이터를 검출하는 방식은 보통 IQR에 1.5를 곱해서 생성된 범위를 이용해 최대값과 최솟값을 결정한 뒤 최댓값을 초과하거나 최솟값에 미달하는 데이터를 이상치로 간주하는 것이다. Q3에 IQR1.5를 더해서 일반적인 데이터가 가질 수 있는 최대값으로 가정하고, Q1에 IQR1.5를 빼서 일반적인 데이터가 가질 수 있는 최솟값으로 가정하였다. 경우에 따라 1.5가 아닌 값을 적용할 수도 있지만 일반적으로 1.5를 적용한다. 이렇게 결정된 최댓값보다 크거나, 최솟값보다 작은 값을 이상치 데이터로 간주한다.
이상치 데이터 IQR을 제거하기 위해 먼저 어떤 feature의 이상치 데이터를 검출할 것인지 선택이 필요하다. 매우 많은 feature가 있을 경우 이들 중 결정값(label)과 가장 상관성이 높은 feature들을 위주로 이상치를 검출하는 것이 좋다. 모든 feature들의 이상치를 검출하는 것은 시간이 많이 소모되며, 결정값과 상관성이 높지 않은 feature들의 경우는 이상치를 제거하더라도 크게 성능 향상에 기여하지 않기 때문이다. DataFrame의 corr()을 이용해 각 feature 별로 상관도를 구한 뒤 시본의 heatmap을 통해 시각화해보았다.
import seaborn as sns plt.figure(figsize=(9, 9)) corr = card_df.corr() sns.heatmap(corr, cmap='RdBu')
[output] cmap을 'RdBu'로 설정해 양의 상관관계가 높을수록 색깔이 진한 파란색에 가까우며, 음의 상관관계가 높을수록 색깔이 진한 빨간색에 가깝게 표현된다. 결정 label인 Class feature와 음의 상관관계가 가장 높은 feature는 V14, V17이다. 이 중 V14만 이상치를 찾아서 제거해보겠다. get_outlier() 함수는 인자로 DataFrame과 이상치를 검출할 column을 입력 받으며, 넘파이의 percentile()을 이용해 Q1과 Q3를 구하고, 이에 기반해 IQR을 계산하겠다.
import numpy as np def get_outlier(df=None, column=None, weight=1.5): # fraud에 해당하는 column 데이터만 추출, 1/4 분위와 3/4 분위 지점을 np.percentile로 구함. fraud = df[df['Class']==1][column] quantile_25 = np.percentile(fraud.values, 25) quantile_75 = np.percentile(fraud.values, 75) # IQR을 구하고, IQR에 1.5를 곱하여 최대값과 최소값 지점 구함. iqr = quantile_75 - quantile_25 iqr_weight = iqr * weight lowest_val = quantile_25 - iqr_weight highest_val = quantile_75 + iqr_weight # 최대값 보다 크거나, 최소값 보다 작은 값을 아웃라이어로 설정하고 DataFrame index 반환. outlier_index = fraud[(fraud < lowest_val) | (fraud > highest_val)].index return outlier_index outlier_index = get_outlier(df=card_df, column='V14', weight=1.5) print('이상치 데이터 인덱스:', outlier_index)
[output] 총 4개의 data가 이상치로 추출되었다. 이제 get_outlier()를 이용해 이상치를 추출하고 이를 삭제하는 로직을 get_processed_df() 함수에 추가한 후 데이터를 다시 가공해 로지스틱 회귀와 LightGBM 모델에 적용시켜보겠다.
# get_processed_df( )를 로그 변환 후 V14 피처의 이상치 데이터를 삭제하는 로직으로 변경. def get_preprocessed_df(df=None): df_copy = df.copy() amount_n = np.log1p(df_copy['Amount']) df_copy.insert(0, 'Amount_Scaled', amount_n) df_copy.drop(['Time','Amount'], axis=1, inplace=True) # 이상치 데이터 삭제하는 로직 추가 outlier_index = get_outlier(df=df_copy, column='V14', weight=1.5) df_copy.drop(outlier_index, axis=0, inplace=True) return df_copy X_train, X_test, y_train, y_test = get_train_test_dataset(card_df) print('### 로지스틱 회귀 예측 성능 ###') get_model_train_eval(lr_clf, ftr_train=X_train, ftr_test=X_test, tgt_train=y_train, tgt_test=y_test) print('### LightGBM 예측 성능 ###') get_model_train_eval(lgbm_clf, ftr_train=X_train, ftr_test=X_test, tgt_train=y_train, tgt_test=y_test)
[output] 두 모델 모두 예측 성능이 크게 향상되었다.
SMOTE 기법으로 오버 샘플링을 적용한 뒤 로지스틱 회귀와 LightGBM 모델의 예측 성능을 평가해보겠다. SMOTE는 imbalanced-learn 패키지의 SMOTE 클래스를 이용해 간단하게 구현이 가능하며, SMOTE를 적용할 경우엔 반드시 학습 dataset만 오버 샘플링 해야한다. 검증 dataset이나 테스트 dataset을 오버 샘플링하는 경우 결국은 원본 데이터가 아닌 dataset에서 검증/테스트를 수행하기 때문에 올바른 검증/테스트가 될 수 없다.
from imblearn.over_sampling import SMOTE smote = SMOTE(random_state=0) X_train_over, y_train_over = smote.fit_sample(X_train, y_train) print('SMOTE 적용 전 학습용 피처/레이블 데이터 세트: ', X_train.shape, y_train.shape) print('SMOTE 적용 후 학습용 피처/레이블 데이터 세트: ', X_train_over.shape, y_train_over.shape) print('SMOTE 적용 후 레이블 값 분포: \n', pd.Series(y_train_over).value_counts())
[output] 이렇게 생성된 학습 dataset으로 로지스틱 회귀 먼저 학습/예측 해보겠다.
lr_clf = LogisticRegression() # ftr_train과 tgt_train 인자값이 SMOTE 증식된 X_train_over와 y_train_over로 변경됨에 유의 get_model_train_eval(lr_clf, ftr_train=X_train_over, ftr_test=X_test, tgt_train=y_train_over, tgt_test=y_test)
[output] 재현율이 92.4%로 크게 증가하지만, 정밀도가 5.4%로 급격하게 저하되는 것을 볼 수 있다. 이는 로지스틱 회귀 모델이 오버 샘플링으로 인해 실제 원본 데이터의 유형보다 너무나 많은 Class가 1인 데이터를 학습하여 실제 테스트 dataset에서 예측을 시나치게 Class 1로 적용해 정밀도가 급격하게 떨어지는 것이다. 분류 결정 임계값을 조정하여 정밀도와 재현율 곡선을 그려보겠다.
import matplotlib.pyplot as plt import matplotlib.ticker as ticker from sklearn.metrics import precision_recall_curve %matplotlib inline def precision_recall_curve_plot(y_test , pred_proba_c1): # threshold ndarray와 이 threshold에 따른 정밀도, 재현율 ndarray 추출. precisions, recalls, thresholds = precision_recall_curve( y_test, pred_proba_c1) # X축을 threshold값으로, Y축은 정밀도, 재현율 값으로 각각 Plot 수행. 정밀도는 점선으로 표시 plt.figure(figsize=(8,6)) threshold_boundary = thresholds.shape[0] plt.plot(thresholds, precisions[0:threshold_boundary], linestyle='--', label='precision') plt.plot(thresholds, recalls[0:threshold_boundary],label='recall') # threshold 값 X 축의 Scale을 0.1 단위로 변경 start, end = plt.xlim() plt.xticks(np.round(np.arange(start, end, 0.1),2)) # x축, y축 label과 legend, 그리고 grid 설정 plt.xlabel('Threshold value'); plt.ylabel('Precision and Recall value') plt.legend(); plt.grid() plt.show() precision_recall_curve_plot( y_test, lr_clf.predict_proba(X_test)[:, 1] )
[output] 임계값이 0.99 이하에서는 재현율이 매우 좋고 정밀도가 극단적으로 낮다가 0.99 이상에서는 반대로 재현율이 대폭 떨어지고 정밀도가 높아진다. 임계값의 민감도가 너무 심하여 올바른 재현율/정밀도 성능을 얻을 수 없으므로 로지스틱 회귀 모델의 경우엔 SMOTE를 적용하여 올바른 모델을 생성하지 못했다. 다음으로 LightGBM 모델을 예측하여 보겠다.
lgbm_clf = LGBMClassifier(n_estimators=1000, num_leaves=64, n_jobs=-1, boost_from_average=False) get_model_train_eval(lgbm_clf, ftr_train=X_train_over, ftr_test=X_test, tgt_train=y_train_over, tgt_test=y_test)
[output] 재현율이 약간 상승하였지만 정밀도는 이전보다 낮아진 것을 볼 수 있다. SMOTE를 적용하면 재현율은 높아지지만 정밀도는 낮아지는 것이 일반적인 결과이다. 좋은 SMOTE 패키지일수록 재현율의 증가율은 높이고 정밀도의 감소율은 낮출 수 있도록 효과적으로 데이터를 증식시킨다.