데싸 이모저모/머신러닝

머신러닝 | 파이썬으로 따라가는 분류 실습 - 신용카드 사기 거래 탐지편

shchannel 2025. 5. 31. 02:17

 

이번 실습에서는 캐글 신용카드 거래 데이터를 활용한 사기 거래 탐지 문제를 통해,
분류 모델의 전처리부터 학습, 평가, 오버샘플링, 튜닝까지 전체 머신러닝 실습 흐름 경험을 공유해 보려고 한다.

 


 

실습은 아이펠 모두의연구소 Main Quest의 일환으로 ⌈파이썬 머신러닝 완벽 가이드⌋의 예제를 참고하여 진행되었다.

📌 사용 데이터: 신용카드 사기 거래 탐지 데이터셋 (Class: 0=정상, 1=사기)
📌 사용 도구: scikit-learn, XGBoost, LightGBM, imbalanced-learn 등

 

📝 실습 구성 단계

  1. 데이터 불러오기 및 탐색
  2. 전처리: 이상치 제거 + 로그 변환
  3. 학습 / 테스트 데이터 분리
  4. 모델 학습 / 예측 / 평가
  5. SMOTE 오버샘플링 적용
  6. 하이퍼파라미터 튜닝
  7. 모델별 성능 비교

1️⃣ 데이터 로드 및 탐색

먼저 학습용과 테스트용 데이터를 불러와, 학습 데이터의 기본 정보를 확인했다.

train = pd.read_csv("train.csv")
test = pd.read_csv("test.csv")

# 170882 를 기점으로 그 뒤 시점으로 자름
train["id"].max(), test["id"].min()

# 학습 데이터 정보 확인
train.info()

# 학습 데이터 요약 통계 확인
train.describe()

train 데이터 정보

  • 컬럼별 정보
    • id: 고유 숫자
    • Time: 각 거래와 데이터셋의 첫 거래 사이의 경과 시간(초) → 큰 의미가 없으므로 제거 예정
    • V1 ~ V28: PCA로 얻은 주성분
    • Amount: 신용카드 트랙잭션 금액
    • Class: 거래 레이블. 0 - 정상 / 1 - 사기 거래
  • 결측치는 없다.
  • id와 Class는 정수형(int), 그 외 피처들 모두 실수형(float)
    → 범주형 데이터가 존재하지 않으므로 인코딩 과정은 생략 가능하다.
  • 이상치 데이터는 확인 이후 처리 예정

클래스 불균형 및 금액 분포 확인

## Class의 0과 1비율 확인
train["Class"].value_counts(normalize=True)*100

# 시각화로 확인해보기
sns.countplot(x='Class', data=train)
plt.title('Transaction Class Distribution')
plt.show()

## Amount 확인
plt.figure(figsize=(8, 4))
plt.xticks(range(0, 30000, 1000), rotation=60)
sns.histplot(train['Amount'], bins=100, kde=True)
plt.show()

Class의 0과 1 비율 / / / Amount 데이터 분포

Class

  • 이상 레이블(1) 데이터 건수가 극소수로 클래스 불균형이 매우 심한 상태이다.
  • 원본 데이터 모델 성능과 오버 샘플링한 모델의 성능을 비교해 볼 필요가 있다.
    → SMOTE 방법을 적용하여 이상 레이블(1)의 데이터를 증가시켜 볼 예정이다.

Amount

  • 1,000불 이하인 데이터가 대부분이며, 약 20,000불까지 드물게 많은 금액을 사용한 경우가 존재한다.
  • 이처럼 심한 왜곡(skewness)이 있는 변수는 그대로 학습에 사용하기보다는 로그 변환(log1p)을 통해 분포를 안정화시켜 주는 것이 좋다.
    → 전처리 단계에서 적용할 예정이다.

2️⃣ 전처리: 로그 변환 + 이상치 제거

신용카드 거래 데이터에는 금액(Amount)처럼 분포가 한쪽으로 치우친 변수가 존재한다.
이러한 경우 모델 성능에 악영향을 줄 수 있기 때문에 적절한 전처리가 필요하다.

✅ 로그 변환(Amount)

  • Amount는 대부분 소액이지만 몇몇 큰 값이 존재해 정규성에 영향을 줄 수 있다.
  • np.log1p()로 로그 변환 수행 후 Amount_Scaled 컬럼으로 삽입한 후 기존의 Amount 컬럼은 삭제한다.

✅ 이상치 제거 (V14 기준, IQR 방식)

 

  • 사기 거래 중 V14 컬럼에서 이상치 데이터를 제거
    • 모든 변수에 대해 일괄적으로 이상치를 제거하는 것은 위험할 수 있다.
    • 따라서 히트맵을 통해 Class와의 상관관계를 확인했을 때, 높은 상관관계를 가지는 V14를 기준으로 선정했다.
  • 이상치 탐지는 IQR (Interquartile Range) 방식을 적용하였고, 이상 범위(하한/상한)를 벗어난 인덱스를 제거 대상으로 분리했다.

위의 과정을 하나의 전처리 함수로 묶어, 로그 변환 → 컬럼 제거 → 이상치 제거의 순서로 진행했다.

# IQR을 이용하여 이상치 검출하는 함수 생성
def get_outlier(df=None, column=None, weight=1.5):
    fraud = df[df["Class"]==1][column]
    quantile_25 = np.percentile(fraud.values, 25)
    quantile_75 = np.percentile(fraud.values, 75)
    
    iqr = quantile_75 - quantile_25
    iqr_weight = iqr * weight
    lowest_val = quantile_25 - iqr_weight
    highest_val = quantile_75 + iqr_weight
    
    outlier_index = fraud[(fraud < lowest_val) | (fraud > highest_val)].index
    return outlier_index
    
# 최종 전처리 함수
def get_preprocessed_df(df=None, train=True):
   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

 

3️⃣ 학습/테스트 데이터 분리

실제 서비스에서는 새로운 데이터에 대한 예측이 목적이므로,
학습을 위해 기존 데이터를 학습용(train) 과 테스트용(test)으로 나누는 작업이 필수이다.

이번 실습에서는 원래 주어진 train 데이터를 내부적으로 70:30으로 분리하여 모델의 예측 성능을 검증하는 방식으로 진행했다.

# 사전 데이터 가공 후 학습과 테스트 데이터 세트를 반환하는 함수
def get_train_test_dataset(df=None):
    df_copy = get_preprocessed_df(df)
    
    X_features = df_copy.iloc[:, :-1]
    y_target = df_copy.iloc[:, -1]
    
    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(train)

# 학습 데이터셋과 테스트 데이터셋의 레이블 값 비율 확인
print('학습 데이터 레이블 값 비율')
print(y_train.value_counts()/y_train.shape[0] * 100)
print('테스트 데이터 레이블 값 비율')
print(y_test.value_counts()/y_test.shape[0] * 100)

stratify=y_target을 통해 학습 데이터와 테스트 데이터에 Class 값이 동일한 비율로 나눠지도록 했기 때문에,
분할된 데이터셋 역시 기존 레이블 분포를 잘 유지하고 있는 걸 확인할 수 있다.

 

4️⃣ 모델 학습 / 예측 / 평가

이제 전처리가 완료된 데이터를 바탕으로 모델을 학습하고, 예측하고, 평가하는 전체 흐름을 진행해 볼 수 있다.
여기서는 로지스틱 회귀(Logistic Regression), LightGBM, XGBoost 모델을 사용하여 성능을 비교해 보았다.

 

그전에 모델을 반복적으로 학습, 예측하고 평가하는 작업을 편리하게 수행하기 위해 각각의 함수를 정의했다.

# 평가 함수 정의
def get_clf_eval(y_test, pred, 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_score(y_test, pred_proba)
    
    
    print("오차 행렬")
    print(confusion)
    print(f"정확도: {accuracy:.4f}, 정밀도: {precision: .4f}, 재현율: {recall: .4f}, f1스코어: {f1:.4f}, roc-auc: {roc_auc:.4f}")
    
    # 모델 학습 함수 정의
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)

 

전처리된 데이터를 바탕으로 학습 / 테스트 데이터를 나누고, 각각의 모델을 적용해 본다.

# 모델 학습/예측/평가
X_train, X_test, y_train, y_test = get_train_test_dataset(train)


print('### 로지스틱 회귀 예측 성능 ###')
lr_clf = LogisticRegression(max_iter=1000)
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, verbose=-1)
get_model_train_eval(lgbm_clf, ftr_train=X_train, ftr_test=X_test, tgt_train=y_train, tgt_test=y_test)

print('### XGBoost 예측 성능 ###')
xgb_clf = XGBClassifier(n_estimators=100, learning_rate=0.1, eval_metric='auc',
                       scale_pos_weight=476, use_label_encoder=False)
get_model_train_eval(xgb_clf, ftr_train=X_train, ftr_test=X_test, tgt_train=y_train, tgt_test=y_test)ㅍ

로그 변환과 이상치 제거를 통해 데이터를 정제한 결과,
별도의 오버샘플링 없이도 모델 성능이 상당히 잘 나오는 것을 확인할 수 있었다. 
하지만 여전히 Class=1 데이터 수가 매우 적기 때문에,
소수 클래스를 인위적으로 늘리는 오버샘플링 기법 SMOTE를 적용해 성능을 비교해 보았다.

 

5️⃣ SMOTE 오버 샘플링

Class=1 (사기 거래) 데이터가 지나치게 적으면 모델은 Class=0으로 치우쳐 예측하게 될 수 있다.
이 문제를 완화하기 위해 SMOTE(Synthetic Minority Oversampling Technique)를 적용하여 소수 클래스를 증식시켜 학습하는 방법을 사용해 보았다.

⚠️ 주의: SMOTE는 반드시 학습용 데이터에만 적용해야 하며, 테스트 데이터에는 적용하면 안 된다.

# SMOTE 객체의 fit_resample() 메서드 이용하여 증식
smote = SMOTE(random_state=0)

X_train_over, y_train_over = smote.fit_resample(X_train, y_train)

# 모델 학습/예측/평가
# X_train_over와 y_train_over로 변수 변경 적용 
X_train, X_test, y_train, y_test = get_train_test_dataset(train)

print('### 로지스틱 회귀 예측 성능 ###')
get_model_train_eval(lr_clf, ftr_train=X_train_over, ftr_test=X_test, tgt_train=y_train_over, tgt_test=y_test)

print('### LightGBM 예측 성능 ###')
get_model_train_eval(lgbm_clf, ftr_train=X_train_over, ftr_test=X_test, tgt_train=y_train_over, tgt_test=y_test)

print('### XGBoost 예측 성능 ###')
xgb_clf = XGBClassifier(n_estimators=100, learning_rate=0.1, eval_metric='auc',
                       use_label_encoder=False) # scale_pos_weight=476 불균형 대응 옵션 제거
get_model_train_eval(xgb_clf, ftr_train=X_train_over, ftr_test=X_test, tgt_train=y_train_over, tgt_test=y_test)

오버샘플링 후 평가 결과를 보면:

  • 재현율(Recall)ROC-AUC상승
  • 반면에 정밀도(Precision)와 F1 Score하락

이는 모델이 Class=1 데이터를 더 많이 학습하게 되어, 실제 테스트 데이터에서도 Class=1로 예측하는 비율이 높아졌기 때문이다.
하지만 실제 사기 거래가 아닌 경우까지 사기로 잘못 예측하는 경우가 늘어나 정밀도는 낮아진 것이다. 

따라서 SMOTE 방식은 정밀도는 낮아도 되고, 재현율을 높이고 싶은 경우 사용하기에 적합한 방식이다.

 

6️⃣ 하이퍼 파라미터 튜닝

기본 모델만으로도 꽤 우수한 성능을 얻을 수 있었지만,
더 나은 결과를 얻기 위해서 LightGBM과 XGBoost 모델을 대상으로 하이퍼 파라미터 튜닝을 진행했다.
이 과정에서는 베이지안 최적화 기법을 사용하는 HyperOpt 라이브러리를 활용했다.

# XGBoost

# 검색 공간 설정
xgb_search_space = {'max_depth': hp.quniform('max_depth', 5, 15, 1), 
                    'min_child_weight': hp.quniform('min_child_weight', 1, 6, 1),
                    'colsample_bytree': hp.uniform('colsample_bytree', 0.5, 0.95),
                    'learning_rate': hp.uniform('learning_rate', 0.01, 0.2)
}

# 목표 함수 생성
def objective_func(search_space):
    xgb_clf = XGBClassifier(n_estimators=100,
                            max_depth=int(search_space['max_depth']),
                            min_child_weight=int(search_space['min_child_weight']),
                            colsample_bytree=search_space['colsample_bytree'],
                            learning_rate=search_space['learning_rate'],
                            early_stopping_rounds=30,
                            eval_metric='auc'
                           )
    # 교차검증 3회
    roc_auc_list= []
    
    kf = KFold(n_splits=3)
    for tr_index, val_index in kf.split(X_train): 
        X_tr, y_tr = X_train.iloc[tr_index], y_train.iloc[tr_index]
        X_val, y_val = X_train.iloc[val_index], y_train.iloc[val_index]
        xgb_clf.fit(X_tr, y_tr, eval_set=[(X_tr, y_tr), (X_val, y_val)], verbose=False)
    
        score = roc_auc_score(y_val, xgb_clf.predict_proba(X_val)[:, 1])
        roc_auc_list.append(score)
        
    # 3개 k-fold로 계산된 roc_auc값의 평균값을 반환하되, 
    # HyperOpt는 목적함수의 최소값을 위한 입력값을 찾으므로 -1을 곱한 뒤 반환. 
    return -1 * np.mean(roc_auc_list)

from hyperopt import fmin, tpe, Trials

trial_val = Trials()
best_xgb = fmin(fn=objective_func,
            space=xgb_search_space,
            algo=tpe.suggest,
            max_evals=10, 
            trials=trial_val, 
            rstate=np.random.default_rng(seed=9))
print('best_xgb:', best_xgb)

튜닝 과정은 실행 시간이 오래 걸릴 수 있으므로,
한 번 최적값을 찾은 후에는 해당 결과를 모델 정의 시 직접 입력하여 사용하였다.

 

7️⃣ 모델별 성능 비교

실습에서는 각 단계마다 모델을 학습하고 예측 결과를 확인해 보며
정확도(Accuracy), 정밀도(Precision), 재현율(Recall), F1 Score, ROC-AUC 등의 다양한 지표를 비교하였다.

  • 원본 데이터
  • 각 단계별 전처리를 적용한 모델
  • SMOTE 오버샘플링 적용 모델
  • 하이퍼파라미터 튜닝 적용 모델
  • 앙상블 모델

이러한 과정을 통해 모델이 어떤 데이터를 어떻게 학습하고 있는지,
또 어떤 방식의 개선이 성능에 긍정적인 영향을 주는지를 비교하고 이해할 수 있었다.

 

💡 최종 모델 선택 & 정리

위에서 다양한 모델을 비교해 본 결과, 전반적으로 XGBoost 모델이 가장 뛰어난 성능을 보였다.

그중에서도 SMOTE 샘플링 적용했을 때 ROC-AUC 점수가 가장 높았지만,

정밀도에서는 다소 불안정한 측면이 있어 1차 캐글 제출은 하이퍼 파라미터를 튜닝한 XGBoost로 진행했었다.

 

그 결과

엄청나게 낮은 점수와 마주했다...

 

내부 평가에서는 좋은 성능을 보이던 모델이 실제 테스트 데이터셋에서는 완전히 무너지는 결과를 보였다.

이는 전형적인 과적합 현상이 발생했다는 점을 의미하며 훈련 데이터에 너무 맞춰진 모델이 실전에서는 성능을 보이지 못한 것이다.

 

도대체 어디서 어떻게 과적합이 발생했을까 머리를 싸매고 고민하다가

결과적으로는 하이퍼 파라미터를 모두 덜어내고 정말 기본적인 설정으로 모델 학습을 진행하고 제출해 보았다.

# 파라미터 재조정
X_train, X_test, y_train, y_test = get_train_test_dataset(train)

xgb_clf = XGBClassifier(n_estimators=100, learning_rate=0.05) 
                        
get_model_train_eval(xgb_clf, ftr_train=X_train_over, ftr_test=X_test, tgt_train=y_train_over, tgt_test=y_test)

 

제출은 SMOTE 샘플링을 적용한 모델과 적용하지 않은 모델 두 가지를 각각 제출해 보았는데,

비록 차이는 크지 않았지만 SMOTE 적용 모델이 ROC-AUC 점수가 소폭 더 높게 나타났다.

 

"복잡한 하이퍼파라미터 설정이 항상 좋은 성능을 보장하지는 않는다."
"기본값(Default)은 단순하지만, 때로는 가장 안정적인 해답이 될 수 있다."

 

✎ 마무리

다양한 하이퍼파라미터 튜닝이나 기법을 적용해 보는 것은 분명히 의미 있는 일이지만

실제 데이터를 마주했을 때 결과가 어떨지에 대한 검증 없이 복잡성만 높이는 건 되려 모델의 일반화 성능을 해칠 수 있다는 것을 체감했다.

 

처음으로 캐글에 제출을 하며 낮은 점수에 당황하고,

급하게 코드를 찾아 수정하다가 허둥지둥하며 과정이 흐트러지기도 했던 것 같다. 

그 과정에서 처음에는 잘 기록하고 기억하던 과정들이 머릿속에서 어지러워졌었다.

 

이런 시행착오 속에서 '어떻게 노트북을 정리하고, 실험과 결과를 기록해야 할지'를 계속해서 고민하고 시도해 보려고 한다.

앞으로는 더 체계적으로 프로젝트를 수행하는 데에 기반이 되기를...💪

 


 

참고 자료

 

파이썬 머신러닝 완벽 가이드(개정2판): 다양한 캐글 예제와 함께 기초 알고리즘부터 최신 기법

자세한 이론 설명과 파이썬 실습을 통해 머신러닝을 완벽하게 배울 수 있습니다! 《파이썬 머신러닝 완벽 가이드》는 이론 위주의 머신러닝 책에서 탈피해, 다양한 실전 예제를 직접 구현해 보

wikibook.co.kr

 

 

모두의연구소 : 비전공자도 가능한 데이터 사이언티스트 과정 | 국비지원 | 부트캠프

데이터 사이언티스트를 꿈꾸시나요? 비전공자도, 커리어 전환을 원하는 분도 내일배움카드만 있으면 수강료 0원! 모두의연구소에서 6개월 만에 데이터 전문가로 거듭나세요.

camp.modulabs.co.kr