캐글 대회 회고: Binary Prediction of Smoker Status using Bio-Signals (with GMB)

2023.10.25 ~ 2023.11.14 약 20일 동안 참여한 대회에 대한 회고이다.

GMB에 들어와서 가장 길게 임했으며, 기존의 틀에서 벗어나 Data-centric을 찍먹 해본 대회이다.

 

이때 당시 긴 기간만큼 캐글 노트북의 GPU할당에 한계가 와서

중간부터 로컬로 돌렸기에 깃허브에 올릴만한 자료가 꽤 있는 편으로, 아래는 해당 대회에 대해 정리한 깃허브이다.

kaggle-competitions/Kaggle_PS3 E24 at main · gyeom-yee/kaggle-competitions (github.com)

 


 

■ 개인 목표 및 동기

캐글에 자신감이 생기고 이번에는 곧바로 다음 시즌 playground를 도전했다.

이때 당시엔 용어를 몰랐지만 줄곧 Model-Centric한 나였기에 Data-Centric 한 경험을 갖고 싶었다.

실제로 저번 시즌에서 Feature Engineering에 매우 아쉬움을 느낀 터였는데 운이 좋게도 씹고 뜯고 EDA 하기 좋은 데이터셋을 다루게 되어 아주 나이스였다.

 

팀은 저번 팀원들도 좋긴 했지만 다양한 구글 부트캠프 사람들을 만나고 싶어 또 다른 모집글을 올렸다.

그리하여 중고 신입을 노리시는 ㄱㄱㅎ님과 캐나다 거주 중이신 ㅇㅅㅇ님을 뵙게 되었는데 다양한 풀을 만나게 되다보니 인사이트도 그만큼 늘어가는 걸 느낄 수 있는 좋은 기회였던 것 같다.

 

참고로 위 사진에서도 알 수 있듯 이번 대회는 특별 굿즈를 노리는 대회였다. 그렇기에 더욱 열정열정열즈엉..!

 


 

■ 대회 소개

Binary Prediction of Smoker Status using Bio-Signals | Kaggle

 

Binary Prediction of Smoker Status using Bio-Signals | Kaggle

 

www.kaggle.com

이번 시리즈의 에피소드에서는 다양한 건강 지표에 대한 정보가 주어졌을 때 이진 분류를 사용하여 환자의 흡연 상태를 예측하는 것이 과제였는데 아주 좋은 타이밍에 이 대회를 만나게 된 게 행운이었다. 익명화된 컬럼이 주였던 지난 대회들과 달리 EDA 접근성이 굉장히 용이했기에 Data-Centric에 더욱 집중할 수 있던 대회가 될 수 있었다.

 

- 데이터셋

역시 Playground 답게 원본 데이터셋이 존재했다.

Smoker Status Prediction using Bio-Signals (kaggle.com)

이때 당시엔 왜 그랬는지 모르겠지만 외부 데이터를 사용하는건 상도에 어긋난다고 생각했다.

그래서 꾸역꾸역 대회에서 주어진 데이터만 사용했었는데 아주 잘못된 선택이지 않았을까 싶다.

 

- 데이터.info()

데이터의 크기는 159256 x 24로, feature들은 다음과 같다.

  • age : 5-years gap
  • height(cm)
  • weight(kg)
  • waist(cm) : Waist circumference length
  • eyesight(left)
  • eyesight(right)
  • hearing(left)
  • hearing(right)
  • systolic : Blood pressure
  • relaxation : Blood pressure
  • fasting blood sugar
  • Cholesterol : total
  • triglyceride
  • HDL : cholesterol type
  • LDL : cholesterol type
  • hemoglobin
  • Urine protein
  • serum creatinine
  • AST : glutamic oxaloacetic transaminase type
  • ALT : glutamic oxaloacetic transaminase type
  • Gtp : γ-GTP
  • dental caries
  • smoking

- 평가 지표: ROC-AUC

잠깐! GPT 4의 설명을 듣고 넘어가자

ROC-AUC는 이진 분류 문제에서 모델 성능을 평가하는 지표로, 진짜 양성 비율(True Positive Rate)과 거짓 양성 비율(False Positive Rate)을 이용해 ROC 곡선을 그리고 이 곡선 아래 면적(AUC)으로 모델의 성능을 수치화합니다. AUC 값이 1에 가까울수록 모델이 우수하며, 0.5는 무작위 추측 수준입니다.
ROC-AUC는 불균형한 데이터셋에서도 유용하지만, 모든 오류를 동일하게 취급하기 때문에 특정 상황에서는 다른 평가 지표와 함께 사용하는 것이 좋습니다.

 


 

■ 진행 과정

저번 대회 때도 회의를 하긴 했지만 블로그에서 짚고 넘어가지 않았었는데 이번에는 대회에 임하는 기간이 좀 길었던지라 약 7일 간격으로 실시간 회의를 진행했다. 대회치고 회의 텀이 너무 긴 거 아닌가 싶을 수도 있지만 디스코드에서 채팅이 워낙 활발했던지라 괜찮았다.

게더타운 일정 공유 & 글로벌 취준 Talk

이번 대회 역시 이진 분류 문제라서 저번에 만들어뒀던 베이스라인을 거의 수정 없이 이용할 수 있었는데 다시금 베이스라인의 중요함과 위대함을 깨닫고 넘어갈 수 있었다.👍👍

그리하여 모델 쪽은 거의 건드리지 않고 (오히려 모델 수를 줄였다. 10 → 6) Feature 쪽에 집중하며 총 17개의 Feature engineering을 시도할 수 있었다.

  • BMI 피처: 'weight(kg)'와 'height(cm)'를 사용하여 BMI를 계산하고, 이를 'BMI'라는 새로운 피처로 추가
  • BMI 상호작용 피처들: 'BMI'와 각각 'AST', 'ALT', 'Gtp'의 곱을 이용하여 'BMI_AST_interaction', 'BMI_ALT_interaction', 'BMI_GTP_interaction' 피처를 생성
  • 평균 혈압 피처 (average_blood_pressure): 'systolic'와 'relaxation'의 평균값을 계산하여 'average_blood_pressure'라는 새로운 피처로 추가
  • 콜레스테롤 비율 피처 (cholesterol_ratio): 'LDL'과 'HDL'의 비율을 계산하여 'cholesterol_ratio'라는 새로운 피처로 추가
  • 평균 시력 피처 (average_eyesight): 'eyesight(left)'와 'eyesight(right)'의 평균값을 계산하여 'average_eyesight'라는 새로운 피처로 추가
  • BMI 카테고리 피처 (BMI_category): 'BMI'를 범주화하여 'BMI_category'라는 새로운 피처로 추가
  • 허리둘레 관련 피처 (waist_risk): 'waist(cm)'를 범주화하여 'waist_risk'라는 새로운 피처로 추가
  • 나이 관련 피처 (age_risk): 'age'를 기반으로 일정 나이 이상을 위험 그룹으로 분류하는 'age_risk' 피처를 생성
  • 혈압 카테고리 피처 (BP_category): 'systolic'를 범주화하여 'BP_category'라는 새로운 피처로 추가
  • 콜레스테롤 관련 피처들: 'Cholesterol', 'HDL', 'LDL'을 각각 범주화하여 'total_cholesterol_risk', 'HDL_risk', 'LDL_risk'라는 새로운 피처들로 추가
  • 간 효소 관련 피처들: 'AST', 'ALT', 'Gtp' 간 효소 수치의 비율을 이용해 'AST_ALT_ratio', 'AST_GTP_ratio', 'ALT_GTP_ratio'라는 새로운 피처들을 생성
  • 펄스 압 피처 (pulse_pressure): 'systolic'과 'relaxation'의 차이를 계산하여 'pulse_pressure'라는 새로운 피처로 추가
  • 시력 차이 피처들 (eyesight_diff, eyesight(left/right) 조정): 양쪽 눈의 시력 차이를 계산하고, 이상치를 조정하여 새로운 'eyesight_diff' 피처를 추가
  • 청력 차이 피처들 (hearing_diff, hearing(left/right) 조정): 양쪽 귀의 청력 차이를 계산하고, 이상치를 조정하여 새로운 'hearing_diff' 피처를 추가
  • 상호작용 피처들: 'fasting blood sugar'와 'Cholesterol'의 곱을 계산하여 'glucose_cholesterol_interaction'을 생성하고, 'weight(kg)'와 'waist(cm)'의 곱을 이용하여 'weight_waist_interaction' 피처를 생성
  • 이상치 제한 (클리핑): 여러 피처들에 대해 이상치를 특정 범위로 제한
  • 숫자형 피처의 범주화 (Categorize numerical features): 'age', 'height(cm)', 'weight(kg)' 등의 피처들을 범주화
더보기
def add_new_features(df):
    df_col = df.columns.to_list()
    if 'smoking' in df_col:
        smoking_ = df['smoking']
        df = df.drop(columns='smoking')

#     ############ BMI ############
#     df['BMI'] = df['weight(kg)'] / ((df['height(cm)']/100) ** 2)
#     # df = df.drop(columns=['weight(kg)','height(cm)'])

#     if 'BMI' in df.columns:
#         df['BMI_AST_interaction'] = df['BMI'] * df['AST']
#         df['BMI_ALT_interaction'] = df['BMI'] * df['ALT']
#         df['BMI_GTP_interaction'] = df['BMI'] * df['Gtp']

#     ############ 평균 혈압 ############
#     df['average_blood_pressure'] = (df['systolic'] + df['relaxation']) / 2
#     # df = df.drop(columns=['systolic','relaxation'])

#     ############ 콜레스테롤 비율 ############
#     df['cholesterol_ratio'] = np.where(df['HDL'] != 0, df['LDL'] / df['HDL'], 0)
#     # df = df.drop(columns=['HDL','LDL'])

#     ############ 평균 시력 ############
#     df['average_eyesight'] = (df['eyesight(left)'] + df['eyesight(right)']) / 2
#     # df = df.drop(columns=['eyesight(left)','eyesight(right)'])

#     ############ BMI 관련 피처 ############
#     df['BMI'] = df['weight(kg)'] / (df['height(cm)'] / 100) ** 2
#     df['BMI_category'] = pd.cut(df['BMI'], bins=[0, 18.5, 25, 30, 35, 40, np.inf], labels=[0, 1, 2, 3, 4, 5])
#     df['BMI_category'] = df['BMI_category'].astype('int')

#     ############ 허리 둘레 관련 피처 ############
#     df['waist_risk'] = pd.cut(df['waist(cm)'], bins=[0, 94, 102, np.inf], labels=[0, 1, 2])
#     df['waist_risk'] = df['waist_risk'].astype('int')

#     ############ 나이 관련 피처 ############
#     df['age_risk'] = np.where(df['age'] >= 45, 1, 0)

    ############ ★혈압 관련 피처 ############
    # 'systolic' 특성을 카테고리화
    # pd.cut 함수를 사용하면 결과값이 문자열로 반환
    df['BP_category'] = pd.cut(df['systolic'], bins=[0, 120, 130, 140, 180, np.inf], labels=[0, 1, 2, 3, 4])
    df['BP_category'] = df['BP_category'].astype('int')

#     ############ 콜레스테롤 관련 피처 ############
#     df['total_cholesterol_risk'] = pd.cut(df['Cholesterol'], bins=[0, 200, 240, np.inf], labels=[0, 1, 2])
#     df['total_cholesterol_risk'] = df['total_cholesterol_risk'].astype('int')
    
#     df['HDL_risk'] = pd.cut(df['HDL'], bins=[0, 40, 60, np.inf], labels=[0, 1, 2])
#     df['HDL_risk'] = df['HDL_risk'].astype('int')
    
#     df['LDL_risk'] = pd.cut(df['LDL'], bins=[0, 100, 130, 160, 190, np.inf], labels=[0, 1, 2, 3, 4])
#     df['LDL_risk'] = df['LDL_risk'].astype('int')

#     ############ 간 효소 관련 피처 ############
#     df['AST_ALT_ratio'] = df['AST'] / df['ALT']
#     df['AST_GTP_ratio'] = df['AST'] / df['Gtp']
#     df['ALT_GTP_ratio'] = df['ALT'] / df['Gtp']

#     ############ 펄스 압 ############
#     df['pulse_pressure'] = df['systolic'] - df['relaxation']

#     ############ 시력 차이 ############
#     df['eyesight_diff'] = df['eyesight(left)'] - df['eyesight(right)']

#     ############ 청력 차이 ############
#     df['hearing_diff'] = df['hearing(left)'] - df['hearing(right)']
    
#     ############ 혈당과 콜레스테롤의 상호작용 ############
#     df['glucose_cholesterol_interaction'] = df['fasting blood sugar'] * df['Cholesterol']

#     ############ 체중과 허리둘레의 상호작용 ############
#     df['weight_waist_interaction'] = df['weight(kg)'] * df['waist(cm)']

    # ############ 시력 차이2 ############
    # # 보통 시력 상한은 2, 근데 9 이상인 것들? 맹인 데이터일 확률이 큼
    # df['eyesight(left)'] = np.where(df['eyesight(left)'] > 9, 0, df['eyesight(left)'])
    # df['eyesight(right)'] = np.where(df['eyesight(right)'] > 9, 0, df['eyesight(right)'])
    # best = np.where(df['eyesight(left)'] < df['eyesight(right)'], 
    #                 df['eyesight(left)'],  df['eyesight(right)'])
    # worst = np.where(df['eyesight(left)'] < df['eyesight(right)'], 
    #                  df['eyesight(right)'],  df['eyesight(left)'])
    # df['eyesight(left)'] = best
    # df['eyesight(right)'] = worst
    
    # ############ 청력 차이2 ############
    # best = np.where(df['hearing(left)'] < df['hearing(right)'], 
    #                 df['hearing(left)'],  df['hearing(right)'])
    # worst = np.where(df['hearing(left)'] < df['hearing(right)'], 
    #                  df['hearing(right)'],  df['hearing(left)'])
    # df['hearing(left)'] = best - 1
    # df['hearing(right)'] = worst - 1
    
    # ############ 클리핑 ############
    # # 주어진 범위를 벗어나는 값을 범위의 최소값 또는 최대값으로 대체 (이상치 제한)
    # df['Gtp'] = np.clip(df['Gtp'], 0, 300)
    # df['HDL'] = np.clip(df['HDL'], 0, 110)
    # df['LDL'] = np.clip(df['LDL'], 0, 200)
    # df['ALT'] = np.clip(df['ALT'], 0, 150)
    # df['AST'] = np.clip(df['AST'], 0, 100)
    # df['serum creatinine'] = np.clip(df['serum creatinine'], 0, 3)  

    # ############ Categorize numerical features ############
    # for i in range(15, 90, 5):
    #     df.loc[(df.age > i - 2.5) & (df.age < i + 2.5), 'age'] = i
    # for i in range(130, 195, 5):
    #     df.loc[(df['height(cm)'] > i - 2.5) & (df['height(cm)'] < i + 2.5), 'height(cm)'] = i
    # for i in range(30, 140, 5):
    #     df.loc[(df['weight(kg)'] > i - 2.5) & (df['weight(kg)'] < i + 2.5), 'weight(kg)'] = i

    if 'smoking' in df_col:
        df['smoking'] = smoking_

    return df

위 전부를 행한 것은 아니고 하나하나 넣어보고 빼보며 성능 비교를 통해 오리지널 대비 좋았던 feature만 사용했다. 그랬더니 결국 하나가 남았는데 그것은 혈압 관련한 feature를 카테고리화한 'BP_category'였다. (위에 빨간 줄)

일단 키랑 몸무게 feature가 있는 것을 보고 "아ㅋㅋㅋ BMI 무조건 넣어야지~" 했는데 막상 feature 넣으니 이게 웬걸? 성능이 오히려 떨어져서 당황했다. 단순히 키랑 몸무게에서 벗어나 이것을 복합적인 상관관계로 만들어서 예측이 더 쉬울 거라는 생각은 그저 사람 기준이었던 듯하다.

 


 

■ 결과 및 평가

최종적으로 나의 예측값 생성은 아래와 같이 진행됐다.

위 방식으로 생성한 예측값 점수

 

처음으로 플로우 차트로 정리해 보니 보기에도 편하고 베이스라인에서 보충할 것을 효율적으로 정리할 수 있었다.

Upstage 현직자분의 대회 경험 특강을 들으면서 참고해 봤는데 생각보다 훨씬 잘 정리되어 만족스럽다.

 

하지만 최종 제출은 내가 아닌 팀원들의 예측값으로 진행됐다.

사실 이때 당시 파라미터 튜닝할 시간을 계산하지 않고 진행하다가 예측 파일을 제출하지 못했었기 때문에(그래서 late 제출했음) 최종 제출 나머지 하나는 Public LB 점수가 가장 높은 파일로  자동 선택됐다.

 

첫 번째 예측값이 우리 팀의 등수를 멱살 잡고 끌어올렸지만 개인적으로 이 예측값은 최종 제출하고 싶지 않았었다. 자체적으로 학습하지 않고 단지 여러 공유 노트북들의 예측값을 불러와 가중치 블렌딩한 것뿐이라서 우리의 힘으로 이룩한 게 아니었기에 굉장한 허망함이 몰려왔기 때문이다.

 


 

■ 대회에서 얻은 인사이트

- Adversarial Validation

Train과 Test 데이터셋이 얼마나 유사한지 알려주는 방법으로, Train set으로 학습한 모델이 Test set의 다른 분포 때문에 성능이 떨어지거나 무용지물이 되는 경우를 예방할 수 있다.

아래는 그 방법의 순서이다.

  1. Train set의 target 컬럼 삭제
  2. Train 및 Test 데이터셋을 0과 1로 레이블 지정
  3. Train와 Test 데이터셋을 하나의 큰 데이터셋으로 결합
  4. 이진 분류 수행(예: XGboost 사용)
  5. AUC ROC 점수를 확인 (0.5에 가까울수록 두 분포가 유사한 것)

- 범주화(Categorizing, binning)

일정 값들을 하나의 범주로 묶게 되면 좀 더 Robust 한 모델을 얻을 수 있다.

여기서 'Robust'란? 모델이 데이터의 작은 변화나 이상치(outliers)에 대해 덜 민감하다는 것을 의미하며, 이는 다양한 데이터셋에 대해 더 안정적인 성능을 보인다는 것이다. 하지만 과할 경우 중요한 정보를 잃을 수 있기에 적절한 범주의 크기와 수를 결정하는 것이 중요하다.

Feature Engineering은 무조건 새로운 피처를 조합하거나 생성해야 하는 줄 알았는데 이런 방법도 있다는 것을 알게 되어 캐글에 대해 좀 더 레벨 업한 기분이었다.

 

- 외부 데이터 대한 거부감을 떨쳐내자!

대회 종료 후 아쉬운 마음에 Discussion을 좀 더 보던 중, 성별 피처를 만든 이가 있어 유심히 보게 됐다.

요약하자면 원본 데이터셋에는 성별 피처가 있는데 가공된 데이터셋에는 없기 때문에 성별 피처를 target으로 두고 예측하여 가공 데이터셋에 성별 피처를 추가하는 것이었다.

 

원본에서 예측했을 때 ROC-AUC 기준 0.9973...이라는 매우 준수한 성능을 보여 성별 예측 모델이 유효함을 보여줬다. 그리하여 가공 데이터셋에도 성별 피처를 생성할 수 있었지만 이진분류가 아닌 '남성'일 확률이라는 점에서 원본과 달랐다.

좀 아쉽긴 해도 여성에 비해 남성이 담배를 더 많이 피기 때문에 이것만 해도 꽤 좋은 feature라고 할 수 있겠다.

(이걸 늦게 봐서 베이스라인에 적용해보지 못한 게 아쉽)

 


 

■  후기 및 소감

뭔가 많은 걸 했음에도 성과가 나오지 않아서 아쉬움의 연속이었던 대회였지만

이것도 러닝커브의 시작점에 있기에 그런 것이라 믿고 앞으로도 꾸준히 하면 될 것이라 믿는다!

 

추후 다시 EDA 하고 Feature Engineering 해봐야지 하는 다짐에도 불구하고 다른 일에 치여 회고를 쓰는 지금도 엄두를 못 내고 있지만 데이터셋 자체가 좋기 때문에 지금 하고 있는 부트캠프가 끝나고 한번 몰입하여 비공식 10위 이내로 랭킹 되는 것이 목표이다.