플레이데이터 엔지니어링 - 머신러닝 경진대회 최종 Report

2023.10.12 ~ 2023.10.20
Oct 20, 2023
플레이데이터 엔지니어링 - 머신러닝 경진대회 최종 Report
notion image
notion image
Public #1, Private #3으로 마감! 동메달에 만족 🤗
 
 
 

사용 데이터셋

모의 경진대회는 titanic을 일부 변형한 자체 dataset으로 진행했음
 

EDA 요약

  • Drop
    • passengerid → index의 성격을 가지고 있어서 drop
    • cabin → 결측치 비율이 과도하게 높아 (0.783) drop
      • kaggle에 쳐보니 타이타닉 배 설계도를 가져와서 cabin의 위치를 파악하고 구역별로 나누어서 범주화시켜서 하던데.. 차마 그렇게까진..
    • ticket → 문자/숫자열 섞여있음, 현실세계에서 티켓번호는 랜덤하게 나누어주기 때문에 특별한 의미를 갖고 있다고 보기 어려움
      • 물론, 비슷한 번호의 티켓끼리는 가족 등의 가까운 사이라고 해석할 수도 있겠으나.. 애매하여 drop
  • Fillna
    • age → pclass와 강한 음의 상관관계가 있어, pclass로 groupby한 뒤 group 내에서 median
      • notion image
    • fare → mean. +) 히스토그램으로 찍어보니 전체적으로 편향되어있어 해소하기 위해 log를 씌움
      • notion image
    • embarked → mode
 

데이터 전처리 : 데이터 분리(split)

X = df_train.drop('survived', axis=1) y = df_train['survived'] X_tr, X_te, y_tr, y_te = train_test_split(X, y, stratify=y, random_state=args.random_state, test_size=0.2) X_tr = X_tr.reset_index(drop=True) X_te = X_te.reset_index(drop=True)
  • stratify = y 옵션을 줘서 train case와 test case를 target 기준으로 균등하게 나눔 → 이진분류에 유리
 

데이터 전처리 : 결측치 치환

  • age
train['age'] = train.groupby(['pclass'])['age'].apply(lambda x: x.fillna(x.median())).reset_index(drop=True) test['age'] = train.groupby(['pclass'])['age'].apply(lambda x: x.fillna(x.median())).reset_index(drop=True) ori_te['age'] = train.groupby(['pclass'])['age'].apply(lambda x: x.fillna(x.median())).reset_index(drop=True)
  • fare → median
  • embarked → mode
 

데이터 전처리 : 데이터 왜곡 수정

  • fare
train["fare"] = train["fare"].map(lambda i: np.log(i) if i > 0 else 0) test["fare"] = test["fare"].map(lambda i: np.log(i) if i > 0 else 0) ori_te["fare"] = ori_te["fare"].map(lambda i: np.log(i) if i > 0 else 0)
 

데이터 전처리 : feature 생성

  • familysize = sibsp + parch + 1
    • sibsp와 parch 자체만으로는 관여도가 높아보이지 않아 두 feature 묶어서 family size를 만들어냄
    • + 1 붙인건 자신을 포함해야하므로 ㅎㅎ (사실 필요없지만)
  • infant
    • def add_infant(age): result = 0 try: if age <= 5: result = 1 except: pass return result
  • is_alone
    • def add_is_alone(family_size): result = 0 try: if family_size == 1: result = 1 except: pass return result
    • 혼자있을 때 생존율이 높음 (가족을 위해 희생해서?)
    • notion image
    • is_alone은 family_size의 파생형이여서, family_size에 또 다른 인사이트가 없었다면 family_size는 drop 하는 편이 나았을 것 같다.
  • title
    • train['title'] = train['name'].str.extract('([A-Za-z]+)\.') test['title'] = test['name'].str.extract('([A-Za-z]+)\.') ori_te['title'] = ori_te['name'].str.extract('([A-Za-z]+)\.') train.drop(['name'], axis=1, inplace=True) test.drop(['name'], axis=1, inplace=True) ori_te.drop(['name'], axis=1, inplace=True) for dataset in [train, test, ori_te]: dataset['title'] = dataset['title'].replace(['Lady', 'Countess','Capt', 'Col','Don', 'Dr', 'Major', 'Rev', 'Sir', 'Jonkheer', 'Dona'], 'Rare') dataset['title'] = dataset['title'].replace('Mlle', 'Miss') dataset['title'] = dataset['title'].replace('Ms', 'Miss') dataset['title'] = dataset['title'].replace('Mme', 'Mrs')

데이터 전처리 : 인코딩

enc_cols = ['gender', 'pclass', 'infant', 'title', 'is_alone', 'embarked']
 

데이터 전처리 : 스케일링

scaling_cols = ['age', 'fare', 'family_size'] from sklearn.preprocessing import StandardScaler ...(생략)
  • fare는 앞에서 log를 씌워 첨도수정을 했기 때문에 굳이 두번 할 필요가 있나 싶었다
    • 그렇지만 output이 두 번 하는 쪽이 더 잘 나왔기 때문에 scaling에 포함함
  • family_size는 솔직히, 스케일링 할 명확한 근거가 없는 상태로 넣었다
    • feature 생성 후에도 EDA를 하는 것이 필요하다고 생각했음
 

모델 : RandomForest(modelV3)

  • Best hyperparameter를 찾기 위해 RandomSearchCV를 사용하였음
from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import KFold, RandomizedSearchCV kf = KFold(n_splits=5, shuffle=True, random_state=args.random_state) hp = { 'n_estimators': np.arange(100, 2001, 100), # 100부터 2000까지 100 간격의 정수 'max_features': ['auto', 'sqrt', 'log2'], # 다양한 옵션 중 선택 'max_depth': [None] + list(np.arange(0, 11)), # None 또는 0부터 10까지 10 간격의 정수 'min_samples_split': np.arange(2, 11), # 2부터 10까지의 정수 'min_samples_leaf': np.arange(1, 11) # 1부터 10까지의 정수 }
  • ‘hp’ 보다는 ‘hp_randomgrid’ 같은 변수로 했으면 좀 더 명확했을 것 같다
  • 각 패러미터들의 Min, Max 값을 정했어야했는데 너무 감이 없어서 구글링으로 대략적으로 어느정도 하는지를 확인해봄
  • 결과는 아래와 같음
rs.best_params_ Output : ({'n_estimators': 300, 'min_samples_split': 6, 'min_samples_leaf': 3, 'max_features': 'auto', 'max_depth': 1}, RandomForestClassifier(max_depth=1, max_features='auto', min_samples_leaf=3, min_samples_split=6, n_estimators=300, random_state=42))
  • 학습 후 train_score, test_score = (0.860655737704918, 0.8641304347826086)
 

모델 : XGBoost(modelV4)

  • 원리는 V3모델과 동일
  • Random Search 진행
hp={ 'n_estimators': range(100,1000,100), 'max_depth': range(3, 10), 'learning_rate': np.arange(0.01, 0.5, 0.01), 'gamma' : np.arange(0, 2, 0.1), 'lambda' : np.arange(0, 2, 0.1) } modelV4 = XGBClassifier(random_state=args.random_state) rs=RandomizedSearchCV(modelV4, hp, scoring='roc_auc', n_iter=100, n_jobs=-1, cv=kf, verbose=False).fit(scaled_tr,y_tr) rs.best_score_, rs.score(scaled_te,y_te)
  • 결과는 아래와 같음
rs.best_params_ Output : ({'n_estimators': 700, 'max_depth': 7, 'learning_rate': 0.25, 'lambda': 0.4, 'gamma': 1.9000000000000001}, XGBClassifier(base_score=None, booster=None, callbacks=None, colsample_bylevel=None, colsample_bynode=None, colsample_bytree=None, device=None, early_stopping_rounds=None, enable_categorical=False, eval_metric=None, feature_types=None, gamma=1.9000000000000001, grow_policy=None, importance_type=None, interaction_constraints=None, lambda=0.4, learning_rate=0.25, max_bin=None, max_cat_threshold=None, max_cat_to_onehot=None, max_delta_step=None, max_depth=7, max_leaves=None, min_child_weight=None, missing=nan, monotone_constraints=None, multi_strategy=None, n_estimators=700, n_jobs=None, num_parallel_tree=None, ...))
  • 학습 후 train_score, test_score = (0.8920765027322405, 0.875)
  • Auc : 0.9008771929824562
  • train score가 높고 (훈련 잘되었고), Auc 점수도 적당해서 (과적합 발생 안함) 위 Case가 Best model이라고 판단되어 최종 Submission 진행함
  • LightGBM은 Score가 생각보다 좋지 않아서, CatBoost는 속도이슈로 진행하지 않았음

학습평가

from sklearn.metrics import confusion_matrix y_pred_binary = modelV4.predict(scaled_te) conf_mx = confusion_matrix(y_te, y_pred_binary, normalize="true") plt.figure(figsize=(7,5)) sns.heatmap(conf_mx, annot=True, cmap="coolwarm", linewidth=0.5) plt.xlabel('Predicted') plt.ylabel('Actual') plt.show()
notion image
  • FP는 0.07, FN은 0.21이어서 FN 데이터에 대해 반복적인 EDA를 진행했으면 좀 더 나은 feature를 만들 수 있었을 것 같음
 
plot_importance(modelV4) plt.show()
notion image
  • fare의 중요도가 엄청 높은 상황을 확인하였는데, 이 결과를 보고 어떤 action을 취해야하는지 잘 감이 잡히지 않았음
    • 지금이라면 fare와 인과관계가 높은 feature들은 drop하여 다시 학습했을 것 같음 (pclass 등)

결론

  • 요약
🍀
Titanic 데이터셋을 이용하여 승객들의 Survived(생존 유무)를 예측하는 이번 프로젝트를 통해 전반적인 머신러닝 과정을 경험해보고 여러가지 모델을 사용해본 의의가 있었음 (train : 0.892077, test : 0.87500, auc score : 0.900877)
  • 느낀점
🍀
나름 Overfitting을 잘 잡았다고 생각했는데, Public → Private 결과가 하락한 것으로 보아 L2가중치를 적용해보았으면 어땠을까 싶었음
🍀
train이 0.90을 넘는 것이 목표였음. feature들을 좀 더 개선했다면 90을 넘길 수 있지 않았을까 싶음
🍀
나름의 버전관리를 열심히 했지만, 노션으로 기록하는 방식이 그닥 효율적으로 느껴지지 않았음. 다음 부터는 ipynb파일 내부 최상단에 버전 및 수정내역을 기록하는 방식을 사용해야할 듯 함
 

강사님 Feedback

  • Feature Importance : 수치형이 상위에 올라오는 것은 당연하다. 수치형과 범주형을 동일선상에서 비교해선 안된다.
  • fare와 pclass는 사실상 동일 의미다. 따라서 pclass를 drop했어야 한다.
    • 각 Feature간의 상관관계는 없어야 한다 (feature 간 유사도가 낮아야한다)
    • fare - pclass 쪽에서 overfitting이 발생했을 것이다
  • 전반적으로, 주먹구구식으로 했다.
    • 점수가 낮게나오더라도 그렇게 한 이유가 명확했다면 다음에는 개선할 수 있는데, 어떤 Action을 취할 때 명확한 이유가 없는 경우도 많았고 그런 Action을 했던 이유를 기록하지 않았던 경우도 있었다.
    • 노력을 한 것 대비, 그런 점이 아쉽다.
Share article

Edwin은 또 모험 중