본문 바로가기
kaggle

Santander Customer Satisfaction EDA (2)

by 짱태훈 2024. 9. 5.
728x90

이전 포스트를 이어서 계속 모델을 학습하겠다. 예측이 제대로 되지 않아서 수정을 해기 때문에 코드가 이전 게시물과 달라졌다.


  1. 문제에 대한 정보 수집
    1. 문제 정의
    2. 분석 대상에 대한 이해
  2. Santander Customer Satisfaction data set을 이용한 EDA
    1. 공통 코드
      1. 오차행렬(Confusion matrix) 및 평가 지표
    2. 분석 및 시각화
      1. Santander Customer Satisfaction data set에 대한 기본적인 정보
      2. feature 분석
      3. 이상치 탐색
      4. Data cleaning
      5. Feature Engineering
      6. noise 처리
  3. 모델 학습
    1. XGBoost
    2. LightGBM
    3. CatBoost
    4. Ensemble
  4. 결론

3. 모델 학습

2. LightGBM

지금까지 var3과 var38에 대한 처리를 아래와 같이 하고 있었다. 여러 가지 방법을 토대로 진행하려 했지만 포스트가 너무 길어질 것을 우려해 가장 잘 나온 것만 게시하기로 했다. 또한, LightGBM을 토대로 가장 잘 나온 EDA를 가지고 CatBoost, Ensemble에도 적용하려고 한다.

이전 게시물에서는 noise 즉, 중복된 피처 값을 가진 데이터 중에서 타겟 값이 다른 데이터를 모델을 통해 TARGET 값을 새롭게 예측했다. 하지만 좋은 결과가 나오지 않아 모델을 XGBoost에서 LightGBM으로 교체했다.

var3과 var38은 이전과 같이  -1로 교체하고 진행했다.

X['var3'].replace(-999999, -1, inplace=True)
test_df['var3'].replace(-999999, -1, inplace=True)

X.loc[np.isclose(X['var38'], 117310.979016), 'var38'] = -1
test_df.loc[np.isclose(test_df['var38'], 117310.979016), 'var38'] = -1

print(X['var38'].value_counts(), '\n')
print(test_df['var38'].value_counts())

이전 XGBoost에서 LightGBM으로 교체한 부분이다. 뿐만 아니라 하이퍼파라미터 튜닝하는 방법을 hyperopt에서 optuna로 변경했다. 모델을 바꾼 이유는 시간과 시간대비 성능의 차이때문에 LightGBM으로 바꿨으며, 역시 시간이 너무 오래 걸려 hyperopt에서 optuna로 변경했다.

import optuna
from lightgbm import LGBMClassifier
from sklearn.metrics import classification_report, accuracy_score, f1_score, precision_score, recall_score
from scipy.sparse import csr_matrix


train_parts = np.array_split(X, 5)
train_y_parts = np.array_split(y, 5)

def objective(trial):
    param = {
        'objective': 'binary',
        'metric': 'binary_logloss',
        'boosting_type': 'gbdt',
        'num_leaves': trial.suggest_int('num_leaves', 20, 60),
        'min_child_samples': trial.suggest_int('min_child_samples', 5, 100),
        'min_child_weight': trial.suggest_float('min_child_weight', 0.1, 10.0),
        'max_depth': trial.suggest_int('max_depth', 2, 10),
        'subsample': trial.suggest_float('subsample', 0.5, 0.8),
        'colsample_bytree': trial.suggest_float('colsample_bytree', 0.5, 0.8),
        'learning_rate': trial.suggest_float('learning_rate', 0.001, 0.05, log=True),
        'scale_pos_weight': trial.suggest_float('scale_pos_weight', 1.0, 50.0),
        'reg_alpha': trial.suggest_float('reg_alpha', 1.0, 20.0),
        'reg_lambda': trial.suggest_float('reg_lambda', 1.0, 20.0),
        'n_estimators': trial.suggest_int('n_estimators', 100, 1000)
    }

    f1_scores = []
    
    for train_part, train_y_part in zip(train_parts, train_y_parts):
        lgb_model = LGBMClassifier(**param, random_state=42)
        lgb_model.fit(train_part, train_y_part)
        
        y_val_pred = lgb_model.predict(train_part)
        f1 = f1_score(train_y_part, y_val_pred)
        f1_scores.append(f1)
    
    return np.mean(f1_scores)

study = optuna.create_study(direction='maximize')
study.optimize(objective, n_trials=100)

best_params = study.best_params
print("Best params: ", best_params)
best_lgb_model = LGBMClassifier(**best_params, random_state=42)
bst_models = []

for train_part, train_y_part in zip(train_parts, train_y_parts):
    best_lgb_model.fit(train_part, train_y_part)
    bst_models.append(best_lgb_model)

noise['TARGET'] = 0
noise_preds = np.mean([model.predict(noise.drop('TARGET', axis=1)) for model in bst_models], axis=0)

noise['TARGET'] = (noise_preds >= 0.5).astype(int)

X = pd.concat([X, noise.drop('TARGET', axis=1)])
y = pd.concat([y, noise['TARGET']])

print(f"Final train shape: {X.shape}")
print(f"Final train_y shape: {y.shape}")
Final train shape: (75725, 306)
Final train_y shape: (75725,)

결과는 isolationforest로 제거한 이상치 행을 제외한 모든 행이 다시 복구되었다. 즉 noise였던 행을 모델로 TARGET을 새롭게 예측한 것이다. 아래 코드는 LightGBM으로 학습한 후 var15에 대해 하드코딩으로 수정하는 부분이다.

from imblearn.under_sampling import RandomUnderSampler
from sklearn.model_selection import train_test_split, KFold

X_resampled, y_resampled = RandomUnderSampler(random_state=42, sampling_strategy=0.3).fit_resample(X, y)
X_train, X_val, y_train, y_val = train_test_split(X_resampled, y_resampled, test_size=0.2, random_state=42)

import optuna

def objective(trial):
    param = {
        'objective': 'binary',
        'metric': 'binary_logloss',
        'boosting_type': 'gbdt',
        'num_leaves': trial.suggest_int('num_leaves', 20, 60),
        'min_child_samples': trial.suggest_int('min_child_samples', 5, 100),
        'min_child_weight': trial.suggest_float('min_child_weight', 0.1, 10.0),
        'max_depth': trial.suggest_int('max_depth', 2, 10),
        'subsample': trial.suggest_float('subsample', 0.5, 0.8),
        'colsample_bytree': trial.suggest_float('colsample_bytree', 0.5, 0.8),
        'learning_rate': trial.suggest_float('learning_rate', 0.001, 0.05, log=True),
        'scale_pos_weight': trial.suggest_float('scale_pos_weight', 1.0, 50.0),
        'reg_alpha': trial.suggest_float('reg_alpha', 1.0, 20.0),
        'reg_lambda': trial.suggest_float('reg_lambda', 1.0, 20.0),
        'n_estimators': trial.suggest_int('n_estimators', 100, 1000)
    }

    lgb_model = LGBMClassifier(**param, random_state=42, verbose=-1)
    lgb_model.fit(X_train, y_train)
    y_val_pred = lgb_model.predict(X_val)
    f1 = f1_score(y_val, y_val_pred, pos_label=1) 
    return f1

# Optuna를 사용한 하이퍼파라미터 튜닝
study = optuna.create_study(direction='maximize')
study.optimize(objective, n_trials=200)

# 최적의 하이퍼파라미터 출력
best_params = study.best_params
print("Best params: ", best_params)

# 최적의 하이퍼파라미터로 모델 학습 및 평가
best_lgb_model = LGBMClassifier(**best_params, random_state=42)
best_lgb_model.fit(X_train, y_train)

# 검증 데이터에 대한 예측
y_val_pred = best_lgb_model.predict(X_val)


# 훈련 데이터에 대한 예측
y_train_pred = best_lgb_model.predict(X_train)
y_train_pred_proba = best_lgb_model.predict_proba(X_train)[:, 1]

# 검증 데이터에 대한 예측
y_test_pred = best_lgb_model.predict(X_val)
y_test_pred_proba = best_lgb_model.predict_proba(X_val)[:, 1]

get_clf_eval(y_train, y_train_pred, y_train_pred_proba)
get_clf_eval(y_val, y_test_pred, y_test_pred_proba)

결과는 아래와 같이 나온다. 과적합이 많이 해소된 것을 확인할 수 있다. 또한 재현율은 이전보다 크게 개선되었다. 즉, Validation 데이터에서 실제 양성을 더 잘 잡아내고 있다는 것이다. 따라서 아직 정밀도를 더 높이는 방향으로 개선해야 한다.

Train Data Evaluation:
오차 행렬
[[7468 1219]
 [ 674 1999]]
정확도: 0.8334, 정밀도: 0.6212, 재현율: 0.7478,    F1: 0.6787, AUC:0.8911

Validation Data Evaluation:
오차 행렬
[[1901  335]
 [ 176  428]]
정확도: 0.8201, 정밀도: 0.5609, 재현율: 0.7086,    F1: 0.6262, AUC:0.8672
# scaling으로 dataframe에서 ndarray로 변환된 값을 다시 dataframe으로 변환
test_df = pd.DataFrame(test_df, columns=columns)

# test_df를 예측한 후 test_df의 TARGET 컬럼을 만든 후 값을 저장
# 이후 test_df를 X, y로 분리
predict_santander_pred_xgb = best_lgb_model.predict(test_df)
test_df['TARGET'] = predict_santander_pred_xgb
test_y = test_df['TARGET']
test_X = test_df.drop(['TARGET'], axis=1)

# test_df를 inverse_transform()을 이용해 scaling 전으로 되돌린다.
test_df_original = sc.inverse_transform(test_X)
test_df_original = pd.DataFrame(test_df_original, columns=columns)
test_df_original['TARGET'] = test_y.values

# test_df_original에서 var15의 값이 23보다 작으면 0으로 변경
test_df_original.loc[test_df_original['var15'] < 23, 'TARGET'] = 0

# submission_df에 test_df_original의 TARGET을 저장
santander_submission_df['TARGET'] = test_df_original['TARGET']
santander_submission_df.to_csv('santander_submission_lgbm.csv', index=False)
santander_submission_df

kaggle에 제출했었을 때 점수 또한 이전과 비교해서 많이 오른 것을 확인할 수 있다.

 

3. CatBoost

다음으로 진행할 모델은 CatBoost이다. CatBoost는 범주형 변수가 많은 데이터셋에서 탁월한 성능을 보여준다. 하지만 다양한 유형의 데이터에서도 높은 성능을 발휘하며, 과적합 방지, 병렬 처리 및 효율적인 메모리 사용으로 학습과 예측이 빠르다는 장점이 있다. 또한, 자동 하이퍼파라미터 튜닝이 있어 편리하다. 하지만 필자는 하이퍼파라미터 튜닝을 optuna를 통해 할 예정이다.

from catboost import CatBoostClassifier

def objective(trial):
    param = {
        'loss_function': 'Logloss',
        'eval_metric': 'F1',
        'iterations': trial.suggest_int('iterations', 100, 1000),
        'depth': trial.suggest_int('depth', 4, 10),  # max_depth에 해당
        'learning_rate': trial.suggest_float('learning_rate', 0.001, 0.05, log=True),
        'l2_leaf_reg': trial.suggest_float('l2_leaf_reg', 1.0, 10.0),
        'random_strength': trial.suggest_float('random_strength', 0.5, 2.0),
        'border_count': trial.suggest_int('border_count', 32, 255),  # colsample_bytree에 해당하는 역할
        'bagging_temperature': trial.suggest_float('bagging_temperature', 0.0, 1.0),
        'scale_pos_weight': trial.suggest_float('scale_pos_weight', 1.0, 50.0)
    }

    cat_model = CatBoostClassifier(**param, random_state=42, verbose=0)
    cat_model.fit(X_train, y_train, eval_set=(X_val, y_val), use_best_model=True, early_stopping_rounds=50)

    y_val_pred = cat_model.predict(X_val)
    f1 = f1_score(y_val, y_val_pred)
    return f1

study = optuna.create_study(direction='maximize')
study.optimize(objective, n_trials=100)

best_params = study.best_params
print("Best params: ", best_params)

best_cat_model = CatBoostClassifier(**best_params, random_state=42, verbose=0)
best_cat_model.fit(X_train, y_train)

y_val_pred = best_cat_model.predict(X_val)


y_train_pred = best_cat_model.predict(X_train)
y_train_pred_proba = best_cat_model.predict_proba(X_train)[:, 1]

y_test_pred = best_cat_model.predict(X_val)
y_test_pred_proba = best_cat_model.predict_proba(X_val)[:, 1]


print("Train Data Evaluation:")
get_clf_eval(y_train, y_train_pred, y_train_pred_proba)
print("\nValidation Data Evaluation:")
get_clf_eval(y_val, y_test_pred, y_test_pred_proba)
Train Data Evaluation:
오차 행렬
[[6439  994]
 [ 620 1615]]
정확도: 0.8331, 정밀도: 0.6190, 재현율: 0.7226,    F1: 0.6668, AUC:0.8838

Validation Data Evaluation:
오차 행렬
[[1578  285]
 [ 192  362]]
정확도: 0.8026, 정밀도: 0.5595, 재현율: 0.6534,    F1: 0.6028, AUC:0.8411

위와 같은 결과를 확인할 수 있다. XGBoost 보다는 과적합이 많이 해소된 것으로 보이지만 전체적으로 모델의 성능이 낮다. 특히, 정밀도가 낮은데, 이는 양성으로 예측한 것들 중 실제로 맞춘 비율이 낮다. 그럼에도 이번 데이터의 평가 지표인 AUC는 양호한 점수를 보여주고 있다. kaggle에 제출하면 아래와 같은 점수를 확인할 수 있다.

LightGBM 보다 점수가 조금 향상된 것을 확인할 수 있다.

 

3. Ensemble

마지막으로 진행할 모델은 Ensemble 이다. 여러 개의 모델을 결합하여 하나의 모델보다 더 나은 성능을 얻는 기법이다. 이런 방법을 사용하면 개별 모델이 가지는 약점을 보완하고 예측의 안정성을 높이는 데 유리하다.

  • 성능 향상: 개별 모델보다 더 높은 성능을 보일 수 있습니다.
  • 안정성: 하나의 모델에서 발생할 수 있는 오류나 편향을 줄입니다.
  • 유연성: 서로 다른 유형의 모델을 결합하여 더 복잡한 문제를 해결할 수 있습니다.

위와 같은 장점이 있으며, Bagging (배깅), Boosting (부스팅), Stacking (스태킹), Voting (보팅)이 있다. 이번에는 보팅 그 중에서도 소프트 보팅을 사용하려고 한다. 보팅은 여러 개의 모델을 학습한 후, 각 모델의 예측값을 투표 방식으로 결합하여 최종 예측을 도출하는 방식이다. 다수결 또는 가중치 기반 방식으로 최종 결과를 산출할 수 있다.

하드 보팅은 각 모델이 예측한 클래스(라벨) 중 다수결로 최종 클래스를 선택하며, 소프트 보팅은 모델들이 예측한 클래스 확률 값을 평균 내서 최종 클래스를 선택하는 것으로 확률 값이 반영되므로 더 정확한 예측이 가능할 수 있다.

from sklearn.ensemble import VotingClassifier, AdaBoostClassifier, RandomForestClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.svm import SVC
from sklearn.linear_model import LogisticRegression
from lightgbm import LGBMClassifier
from catboost import CatBoostClassifier

# 모델 정의
decision_tree_clf = DecisionTreeClassifier(random_state=42)
svm_clf = SVC(probability=True, random_state=42)
log_reg_clf = LogisticRegression(random_state=42)
adaboost_clf = AdaBoostClassifier(random_state=42)
lgbm_clf = LGBMClassifier(random_state=42, verbose=-1)
catboost_clf = CatBoostClassifier(random_state=42, verbose=0)
rf_clf = RandomForestClassifier(random_state=42)

# VotingClassifier를 사용한 소프트 보팅 모델 정의 (voting='soft')
voting_clf = VotingClassifier(
    estimators=[
        ('decision_tree', decision_tree_clf), 
        ('svm', svm_clf), 
        ('log_reg', log_reg_clf),
        ('adaboost', adaboost_clf), 
        ('lgbm', lgbm_clf), 
        ('catboost', catboost_clf), 
        ('rf', rf_clf)
    ], 
    voting='soft'  # 소프트 보팅 사용
)

voting_clf.fit(X_train, y_train)
y_pred = voting_clf.predict(X_val)

accuracy = accuracy_score(y_val, y_pred)
print(f"Soft Voting Classifier Accuracy: {accuracy:.4f}")

# 개별 모델의 성능 확인 (각 모델의 성능도 출력)
for clf in (decision_tree_clf, svm_clf, log_reg_clf, adaboost_clf, lgbm_clf, catboost_clf, rf_clf, voting_clf):
    clf.fit(X_train, y_train)
    y_pred = clf.predict(X_val)
    print(f"{clf.__class__.__name__} Accuracy: {accuracy_score(y_val, y_pred):.4f}")
Soft Voting Classifier Accuracy: 0.8481
LGBMClassifier Accuracy: 0.8415
CatBoostClassifier Accuracy: 0.8422
XGBClassifier Accuracy: 0.8394
RandomForestClassifier Accuracy: 0.8299
SVC Accuracy: 0.7952

각각 모델에 대해 위의 결과처럼 출력이 된다. 비슷한 점수대를 보여주지만 VotingClassifier가 개별 모델들보다 약간 더 높은 성능을 보이고 있으므로 잘 작동하고 있다고 볼 수 있다. 

y_train_pred = voting_clf.predict(X_train)
y_train_pred_proba = voting_clf.predict_proba(X_train)[:, 1]

y_test_pred = voting_clf.predict(X_val)
y_test_pred_proba = voting_clf.predict_proba(X_val)[:, 1]

print("Train Data Evaluation:")
get_clf_eval(y_train, y_train_pred, y_train_pred_proba)

print("\nValidation Data Evaluation:")
get_clf_eval(y_val, y_test_pred, y_test_pred_proba)
Train Data Evaluation:
오차 행렬
[[8464  282]
 [ 693 1962]]
정확도: 0.9145, 정밀도: 0.8743, 재현율: 0.7390,    F1: 0.8010, AUC:0.9758

Validation Data Evaluation:
오차 행렬
[[2069  148]
 [ 285  349]]
정확도: 0.8481, 정밀도: 0.7022, 재현율: 0.5505,    F1: 0.6172, AUC:0.8619

결과에 대한 평가는 다음과 같다. 이전에 학습했던 모델들에 비해 과적합이 다시 심해졌다. kaggle에 제출하면 다음과 같은 점수를 확인할 수 있다. Ensemble은 어떤 모델을 사용하냐에 따라 성능이 달라진다. 특히 Voting은 사용하는 개별 모델의 특성에 따라 성능이 크게 달라질 수 있기 때문에 모델 선택이 중요하다. 그 이유는 다음과 같다. 

성능 향상의 핵심은 모델의 다양성으로 Voting 앙상블은 서로 다른 특성을 가진 모델을 결합할 때 더 효과적이기 때문이다. 동일한 특성을 가진 모델들을 결합하면 성능 개선 효과가 제한적일 수 있어 모델 선택이 중요하다. 따라서 지금과 같은 점수는 어떤 모델을 사용하냐에 따라 달라질 수 있다.


추가적으로 noise 값의 TARGET 값을 예측하지 않고 제거한 다음 LightGBM으로 예측했을 때가 가장 좋은 성능을 보여줬다. 결과는 아래와 같다.

Train Data Evaluation:
오차 행렬
[[5830 1506]
 [ 519 1692]]
정확도: 0.7879, 정밀도: 0.5291, 재현율: 0.7653,    F1: 0.6256, AUC:0.8581

Validation Data Evaluation:
오차 행렬
[[1482  362]
 [ 150  393]]
정확도: 0.7855, 정밀도: 0.5205, 재현율: 0.7238,    F1: 0.6055, AUC:0.8385

train data의 결과가 재현율이 높은데 정밀도가 낮다. 즉, 양성 클래스를 잘 잡아내지만, 많은 잘못된 긍정 (False Positive)도 예측하고 있다. 그래도 이전에 비해 과적합이 많이 해소된 것을 확인할 수 있다. 재현율, 정밀도에 대한 해결 방법으로 정밀도와 재현율의 균형 조정, 모델 복잡도 줄이기, 모델 앙상블이 있다.

결과를 kaggle에 제출했을 때 private, public 모두 이전과 많이 좋아진 것을 확인할 수 있다.

베스트 파라미터는 아래와 같다.

Best params:  {'num_leaves': 44, 'min_child_samples': 30, 'min_child_weight': 7.026190601165056, 'max_depth': 3, 'subsample': 0.6587318096159958, 'colsample_bytree': 0.6087483644642271, 'learning_rate': 0.01961668163863785, 'scale_pos_weight': 2.762646582387838, 'reg_alpha': 2.1002708478959153, 'reg_lambda': 18.205433395806338, 'n_estimators': 586}

결론
이번 Santander Customer Satisfaction 프로젝트를 통해 다양한 데이터 분석 및 머신러닝 기법을 학습하고 적용할 수 있었습니다. 특히, 대규모의 익명화된 특성들을 다루며, 이 데이터셋의 특징을 파악하고 처리하는 데 상당한 노력을 기울였습니다. Feature Engineering을 통해 데이터를 정제하고, 이상치 및 노이즈를 처리하는 과정에서 여러 가지 접근 방식을 시도하였습니다.

모델 학습에서는 voting을 사용하기도 했지만 XGBoost, LightGBM, CatBoost와 같은 부스팅 기법을 사용해 높은 성능을 목표로 했습니다. 특히, 이 프로젝트에서 중요한 평가지표는 AUC (ROC 곡선 아래 면적)였으며, 이는 고객 불만족 예측이라는 문제 특성에 맞추어 재현율과 정밀도를 균형 있게 고려한 모델 평가를 가능하게 해주었습니다.

한계점
프로젝트 진행 중 가장 아쉬웠던 점은 데이터의 비대칭성(imbalance)이었습니다. 데이터셋에서 불만족 고객의 비율이 매우 낮았기 때문에, 불균형 문제를 해결하기 위해 언더샘플링을 사용했지만, 데이터 손실로 인해 모델의 성능이 완벽히 개선되지는 않았습니다. 또한, 익명화된 특성 때문에 변수들의 의미를 명확히 이해하지 못하고, 그로 인해 효과적인 도메인 지식 기반의 피처 엔지니어링이 어려웠습니다.

배운 점 / 어려웠던 점
이번 프로젝트는 데이터 전처리 및 특성 공학의 중요성을 다시 한번 깨닫게 해주었습니다. 특히 이상치 탐지, 노이즈 처리는 모델 성능을 크게 향상시키는 중요한 단계임을 확인할 수 있었습니다. 또한, 다양한 앙상블 기법과 그들의 장단점을 비교하는 경험을 통해 Voting, Boosting의 차이를 체감하며 Ensemble에 대해 더 깊게 공부할 수 있었습니다.

비록 목표했던 최고 성능에는 미치지 못했지만, 여러 시행착오를 거치며 성장할 수 있었던 의미 있는 프로젝트였습니다.

728x90