Programming

Customer Satisfaction 예측 프로젝트 (XGBoost/LightGBM + HyperOpt 튜닝)

Lucas.Kim 2025. 12. 26. 04:45
반응형

이번 사이드 프로젝트는 Santander Customer Satisfaction 데이터로 고객 불만족(타겟=1)을 예측하는 이진 분류(Binary Classification) 문제입니다.
데이터의 특징은 극심한 클래스 불균형이며, 실제 분포는 다음과 같습니다.

  • TARGET=0(만족): 73,012건
  • TARGET=1(불만족): 3,008건
  • 불만족 비율: 0.04(약 4%)

즉, “대부분이 만족(0)”인 데이터에서 “소수의 불만족(1)”을 얼마나 잘 찾아내느냐가 핵심이며, 그래서 평가 지표를 Accuracy가 아닌 ROC-AUC로 두는 흐름이 매우 합리적입니다.

1. 데이터 전처리

1-1) 데이터 로딩 및 기본 확인

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')

# (1) 데이터 로딩
cust_df = pd.read_csv('./customer/santander-customer-satisfaction/train_santander.csv')

# (2) shape 확인: (행 수, 열 수)
print(f"shape : {cust_df.shape}")

# (3) 상위 3개 샘플 확인 (데이터 감 잡기)
display(cust_df.head(3))

# (4) 컬럼 타입/결측치/메모리 사용량 확인
cust_df.info()

1-2) 타겟 분포 확인 (불균형 데이터)

# TARGET=1(불만족) 개수
unsatisfied_cnt = cust_df[cust_df['TARGET'] == 1].TARGET.count()

# 전체 데이터 개수
total_cnt = cust_df.TARGET.count()

# 불만족 비율(=양성 클래스 비율) 계산
print(f'Unsatisfied Rate : {round((unsatisfied_cnt / total_cnt), 2)}')

# 결과(사용자 실행 로그):
# Unsatisfied Rate : 0.04
  • 위 결과에서 확인되듯 양성 클래스(불만족=1)가 4% 수준입니다.
  • 이런 경우 단순 정확도는 왜곡될 수 있어, 이후 평가를 ROC-AUC로 진행합니다.

1-3) 이상치/불필요 컬럼 처리

사용자 분석대로 var3에서 -999999는 정상적인 값이라 보기 어렵고, 보통 “결측/이상값 마킹”으로 쓰이는 경우가 많습니다. 따라서 이를 대표값(여기서는 2)으로 치환합니다.

# (1) describe()로 전반적 통계 확인
cust_df.describe()

# (2) var3의 값 분포 확인: -999999가 다수 존재하면 이상치/결측 마킹일 가능성 큼
cust_df['var3'].value_counts()

# (3) var3의 -999999를 2로 대체
# - 사용자 판단: -999999는 이상치로 보고 대표값 2로 치환
cust_df['var3'].replace(-999999, 2, inplace=True)

# (4) ID 컬럼 제거
# - 식별자(ID)는 모델 학습에 의미 있는 신호가 아니라서 보통 제거
cust_df.drop('ID', axis=1, inplace=True)

1-4) 피처/레이블 분리

# (1) 피처(X): TARGET 제외 나머지
X_features = cust_df.iloc[:, :-1]

# (2) 레이블(y): TARGET
y_labels = cust_df.iloc[:, -1]

print(f'feature shape : {X_features.shape}')
print(f'label shape : {y_labels.shape}')

# 사용자 로그 기준:
# feature shape : (76020, 369)
# label shape   : (76020,)

2. 데이터 셋 분리 (Train/Test + Train/Validation)

이 프로젝트는 **조기 종료(Early Stopping)**를 쓰기 때문에, 일반 Train/Test 외에도 Train 내부를 Train/Validation으로 한 번 더 분리합니다.

from sklearn.model_selection import train_test_split

# =========================================================
# 2-1) Train/Test 분리 (80% / 20%)
# - stratify=y_labels: 불균형 클래스 비율을 Train/Test에서 동일하게 유지
# =========================================================
X_train, X_test, y_train, y_test = train_test_split(
    X_features,
    y_labels,
    test_size=0.2,
    random_state=0,
    stratify=y_labels
)

print(f'학습셋 : {X_train.shape}')
print(f'테스트셋 : {X_test.shape}')

# 사용자 실행 로그:
# 학습셋 : (60816, 369)
# 테스트셋 : (15204, 369)

# =========================================================
# 2-2) Train을 다시 Train/Validation으로 분리 (70% / 30%)
# - Early Stopping 성능 평가용 Validation 세트
# =========================================================
X_tr, X_val, y_tr, y_val = train_test_split(
    X_train,
    y_train,
    test_size=0.3,
    random_state=0,
    stratify=y_train
)

print(f'학습셋(분리 후) : {X_tr.shape}')
print(f'검증셋 : {X_val.shape}')

# 사용자 실행 로그:
# 학습셋(분리 후) : (42571, 369)
# 검증셋 : (18245, 369)

3. XGBoost 모델 학습 및 HyperOpt 튜닝

3-1) 1차 시도: 기본 XGBoost + Early Stopping

from xgboost import XGBClassifier
from sklearn.metrics import roc_auc_score

# (1) 기본 모델 구성
# - n_estimators=500: 트리 500개까지 학습 가능
# - learning_rate=0.05: 학습률(작을수록 천천히, 보통 n_estimators와 함께 조정)
# - random_state=156: 재현성(항상 같은 결과)
xgb_clf = XGBClassifier(
    n_estimators=500,
    learning_rate=0.05,
    random_state=156
)

# (2) Early Stopping을 위한 eval_set 구성
# - (X_tr, y_tr): 학습 성능 추적용
# - (X_val, y_val): 검증 성능 추적용(중요: early stopping 기준)
eval_set = [(X_tr, y_tr), (X_val, y_val)]

# (3) 학습 수행
# - eval_metric='auc': 검증 성능을 ROC-AUC로 측정하며 개선 여부 판단
# - early_stopping_rounds=100: 100번 연속으로 auc 개선 없으면 중단
xgb_clf.fit(
    X_tr,
    y_tr,
    early_stopping_rounds=100,
    eval_metric='auc',
    eval_set=eval_set
)

# (4) 테스트 데이터에서 확률 예측(ROC-AUC는 확률 기반 지표)
pred_proba = xgb_clf.predict_proba(X_test)[:, 1]

# (5) ROC-AUC 평가
xgb_roc_score = roc_auc_score(y_test, pred_proba)
print(f'ROC AUC : {xgb_roc_score}')

# 사용자 실행 로그:
# ROC AUC : 0.8233085191533859

3-2) 2차 시도: HyperOpt(TPE) 기반 베이지안 튜닝

(1) Search Space 정의

from hyperopt import hp

# HyperOpt는 "범위"를 주면 그 안에서 값을 뽑아 시도합니다.
# quniform은 간격(step)이 있는 분포(하지만 결과가 float이므로 int 변환 필요)
xgb_search_space = {
    'max_depth': hp.quniform('max_depth', 5, 20, 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)
}

(2) Objective Function 정의: “ROC-AUC 평균을 최대화”

여기서 중요한 흐름은 다음과 같습니다.

  • HyperOpt는 기본적으로 loss를 최소화
  • 우리는 ROC-AUC를 최대화하고 싶음
  • 따라서 loss = -roc_auc_mean 형태로 반환

또한 사용자는 KFold(n_splits=3)로 직접 CV를 구현했습니다.
(불균형 데이터라면 StratifiedKFold가 더 일반적이지만, 사용 코드 흐름을 유지하되 주석에서 관점을 보완합니다.)

from sklearn.model_selection import KFold
from sklearn.metrics import roc_auc_score
from xgboost import XGBClassifier

def objective_func(search_space):
    """
    목적 함수(Objective Function)
    - 입력: HyperOpt가 샘플링한 하이퍼파라미터 조합(search_space)
    - 처리: 3-Fold 교차검증으로 ROC-AUC 평균 계산
    - 출력: HyperOpt가 최소화할 loss 반환 (loss = -mean_auc)
    """

    # (1) HyperOpt에서 넘어오는 값은 float 형태 → 정수형 파라미터는 int로 변환
    xgb_clf = XGBClassifier(
        n_estimators=100,  # 튜닝 단계에서는 속도 절약을 위해 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'],
        eval_metric='auc',
        random_state=156,
        n_jobs=-1
    )

    roc_auc_list = []

    # (2) KFold로 Train 데이터를 3개 폴드로 분할
    # - 불균형 문제가 크면 StratifiedKFold가 더 안정적일 수 있음
    kf = KFold(n_splits=3)

    for tr_index, val_index in kf.split(X_train):
        # (3) fold별 학습/검증 데이터 구성
        X_tr_fold, y_tr_fold = X_train.iloc[tr_index], y_train.iloc[tr_index]
        X_val_fold, y_val_fold = X_train.iloc[val_index], y_train.iloc[val_index]

        # (4) fold 내부에서도 early stopping 적용
        # - 30번 동안 AUC 개선 없으면 학습 중지 → 시간 절약 + 과적합 방지
        xgb_clf.fit(
            X_tr_fold,
            y_tr_fold,
            early_stopping_rounds=30,
            eval_metric='auc',
            eval_set=[(X_tr_fold, y_tr_fold), (X_val_fold, y_val_fold)],
            verbose=False
        )

        # (5) 검증 fold에서 ROC-AUC 계산
        val_pred_proba = xgb_clf.predict_proba(X_val_fold)[:, 1]
        score = roc_auc_score(y_val_fold, val_pred_proba)
        roc_auc_list.append(score)

    # (6) 3개 fold ROC-AUC 평균
    mean_auc = np.mean(roc_auc_list)

    # (7) HyperOpt는 loss 최소화 → AUC 최대화를 위해 -1 곱해서 반환
    return -1 * mean_auc

(3) fmin 실행: 50회 탐색

from hyperopt import fmin, tpe, Trials

trials = Trials()

best = fmin(
    fn=objective_func,
    space=xgb_search_space,
    algo=tpe.suggest,
    max_evals=50,
    trials=trials,
    rstate=np.random.default_rng(seed=30)
)

print(f'Best 하이퍼 파라미터 : {best}')

# 사용자 실행 로그:
# Best 하이퍼 파라미터 : {
#   'colsample_bytree': 0.7958795930205148,
#   'learning_rate': 0.11599863372320877,
#   'max_depth': 5.0,
#   'min_child_weight': 4.0
# }

(4) Best 파라미터로 “실제 학습(n_estimators=500)” 후 테스트 평가

튜닝 단계에서는 n_estimators=100으로 속도를 줄였고,
최종 학습에서는 다시 n_estimators=500으로 올려 성능을 확인하는 흐름이 깔끔합니다.

from sklearn.metrics import roc_auc_score
from xgboost import XGBClassifier

# (1) HyperOpt가 찾아준 best 파라미터를 반영
xgb_clf = XGBClassifier(
    n_estimators=500,
    learning_rate=round(best['learning_rate'], 5),
    max_depth=int(best['max_depth']),
    min_child_weight=int(best['min_child_weight']),
    colsample_bytree=round(best['colsample_bytree'], 5),
    eval_metric='auc',
    random_state=156,
    n_jobs=-1
)

evals = [(X_tr, y_tr), (X_val, y_val)]

# (2) early stopping 적용 후 학습
xgb_clf.fit(
    X_tr,
    y_tr,
    early_stopping_rounds=100,
    eval_metric='auc',
    eval_set=evals
)

# (3) 테스트 ROC-AUC 평가
pred_proba = xgb_clf.predict_proba(X_test)[:, 1]
xgb_roc_score = roc_auc_score(y_test, pred_proba)
print(f'ROC AUC : {xgb_roc_score}')

# 사용자 실행 로그:
# ROC AUC : 0.825153599311249
  • 1차(기본): 0.8233085
  • 2차(HyperOpt): 0.8251536

개선 폭이 크진 않지만, 조합 탐색을 “체계적으로” 수행해 개선을 확인했다는 점이 프로젝트 관점에서 의미가 있습니다.

(5) 피처 중요도 시각화

from xgboost import plot_importance
import matplotlib.pyplot as plt

fig, ax = plt.subplots(1, 1, figsize=(10, 8))

plot_importance(
    xgb_clf,
    ax=ax,
    max_num_features=20,
    height=0.4
)

plt.show()

4. LightGBM 모델 학습 및 HyperOpt 튜닝

사용자 코멘트처럼 LightGBM은 트리 구조에서 max_depth도 중요하지만, 실무에서는 **num_leaves(리프 개수)**가 핵심 파라미터로 자주 언급됩니다. (리프 기반 성장 특성 때문에)

4-1) 튜닝 전 기본 LightGBM

from lightgbm import LGBMClassifier, early_stopping
from sklearn.metrics import roc_auc_score

# (1) 기본 모델
lgbm_clf = LGBMClassifier(
    n_estimators=500,
    random_state=156
)

eval_set = [(X_tr, y_tr), (X_val, y_val)]

# (2) 학습 (AUC 기준 early stopping)
lgbm_clf.fit(
    X_tr,
    y_tr,
    eval_set=eval_set,
    eval_metric='auc',
    callbacks=[early_stopping(stopping_rounds=100)]
)

# (3) 테스트 ROC-AUC
pred_proba = lgbm_clf.predict_proba(X_test)[:, 1]
lgbm_roc_score = roc_auc_score(y_test, pred_proba)
print(f"ROC AUC : {lgbm_roc_score}")

# 사용자 실행 로그:
# ROC AUC : 0.8213213522381906

4-2) HyperOpt로 LightGBM 튜닝

(1) Search Space

from hyperopt import hp

lgbm_search_space = {
    'num_leaves': hp.quniform('num_leaves', 32, 64, 1),
    'max_depth': hp.quniform('max_depth', 100, 160, 1),
    'min_child_samples': hp.quniform('min_child_samples', 60, 100, 1),
    'subsample': hp.uniform('subsample', 0.7, 1.0),
    'learning_rate': hp.uniform('learning_rate', 0.01, 0.2)
}

(2) Objective Function

from sklearn.model_selection import KFold
from sklearn.metrics import roc_auc_score
from lightgbm import LGBMClassifier, early_stopping

def objective_func(search_space):
    """
    LightGBM 목적 함수
    - 3-Fold CV로 ROC-AUC 평균을 계산
    - HyperOpt가 최소화하도록 loss=-mean_auc 반환
    """

    lgbm_clf = LGBMClassifier(
        n_estimators=100,  # 튜닝 속도 절약
        num_leaves=int(search_space['num_leaves']),
        max_depth=int(search_space['max_depth']),
        min_child_samples=int(search_space['min_child_samples']),
        subsample=search_space['subsample'],
        learning_rate=search_space['learning_rate'],
        random_state=156
    )

    roc_auc_list = []
    kf = KFold(n_splits=3)

    for tr_index, val_index in kf.split(X_train):
        X_tr_fold, y_tr_fold = X_train.iloc[tr_index], y_train.iloc[tr_index]
        X_val_fold, y_val_fold = X_train.iloc[val_index], y_train.iloc[val_index]

        eval_set = [(X_tr_fold, y_tr_fold), (X_val_fold, y_val_fold)]

        lgbm_clf.fit(
            X_tr_fold,
            y_tr_fold,
            eval_metric='auc',
            eval_set=eval_set,
            callbacks=[early_stopping(30)],
            verbose=False
        )

        val_pred_proba = lgbm_clf.predict_proba(X_val_fold)[:, 1]
        score = roc_auc_score(y_val_fold, val_pred_proba)
        roc_auc_list.append(score)

    return -1 * np.mean(roc_auc_list)

(3) fmin 실행 및 Best 파라미터로 최종 학습/평가

from hyperopt import fmin, tpe, Trials

trials = Trials()

best = fmin(
    fn=objective_func,
    space=lgbm_search_space,
    algo=tpe.suggest,
    max_evals=50,
    trials=trials,
    rstate=np.random.default_rng(seed=30)
)

print(f'best 파라미터 : {best}')

# 사용자 실행 로그:
# best 파라미터 : {
#   'learning_rate': 0.03551933341235436,
#   'max_depth': 160.0,
#   'min_child_samples': 61.0,
#   'num_leaves': 32.0,
#   'subsample': 0.8555931419795748
# }

# 최종 모델(트리 500개)로 재학습 후 테스트 ROC-AUC 확인
lgbm_clf = LGBMClassifier(
    n_estimators=500,
    num_leaves=int(best['num_leaves']),
    max_depth=int(best['max_depth']),
    min_child_samples=int(best['min_child_samples']),
    subsample=round(best['subsample'], 5),
    learning_rate=round(best['learning_rate'], 5),
    random_state=156
)

eval_set = [(X_tr, y_tr), (X_val, y_val)]

lgbm_clf.fit(
    X_tr,
    y_tr,
    eval_metric='auc',
    eval_set=eval_set,
    callbacks=[early_stopping(30)]
)

lgbm_roc_score = roc_auc_score(y_test, lgbm_clf.predict_proba(X_test)[:, 1])
print(f'roc score : {lgbm_roc_score}')

# 사용자 실행 로그 일부:
# [88] training's auc: 0.895356 ... valid_1's auc: 0.844823 ...
# roc score : 0.8239600819256998

 

  • 기본 LightGBM: 0.8213213
  • HyperOpt LightGBM: 0.8239601

정리: 이번 프로젝트에서 얻은 결론

  1. **데이터가 불균형(불만족 4%)**이므로, 정확도보다 ROC-AUC가 더 적절한 평가 지표입니다.
  2. 기본 XGBoost가 이미 성능이 좋았고, HyperOpt 튜닝으로 **AUC가 소폭 개선(0.8233 → 0.8251)**되었습니다.
  3. LightGBM도 HyperOpt 튜닝으로 **AUC가 개선(0.8213 → 0.8240)**되었습니다.
  4. 튜닝에서 하이퍼파라미터를 너무 많이 잡으면 탐색이 어려워지므로, 사용자가 정리한 것처럼 6~9개 내외로 제한하는 접근이 실무적으로 합리적입니다.

 

반응형