최종 프로젝트

Chapter.9 최종 실전 자동화 프로젝트 (9)

devjjun 2026. 7. 8. 20:52

TIL(Today I Learned)


1️⃣실전 자동화 프로젝트 (7)

문제 설정

  • 과제: 품질을 3등급(Y_Class = 0/1/2)으로 분류, 제품군 2개(A_31 316×551, TO_31 592×334)를 독립 처리
  • 클래스 불균형: A_31은 24/48/29%로 비교적 균형, TO_31은 8/83/9%로 극심(소수 클래스 각 48·54건)
  • 결측 처리: 트리 모델의 native 결측 처리를 살리려 NaN을 일부러 그대로 둔다

피처 선택 파이프라인

  1. 상관 덴드로그램 클러스터링 — 강상관 컬럼을 묶어 클러스터당 분산 최대 1개만 남김. 상관은 pairwise-complete로 계산하되 모델 입력 데이터의 NaN은 건드리지 않는다.
  2. native feature_importances_ 사전 필터 — 학습 시 나오는 중요도로 상위 200개.
  3. Permutation importance — 그 200개에 대해서만 계산해 상위 50개.
  4. SHAP (TreeExplainer) — 최종 50개에만 적용.

리키지 방지의 핵심: 2~4는 타깃을 쓰므로 반드시 CV 안쪽에서만 돌린다. 1번은 비지도 단계지만 그래도 train으로만 계산했다.

검증 전략: train test split vs nested CV

작은 데이터에서 성능 숫자를 일반화하여 내는게 계속 문제였다. 최종적으로 두 제품군을 다르게 처리했다.

  • A_31 (균형)train test split: 8:2로 한 번 나누고 test는 피처 선택에 관여시키지 않다가 마지막 참고 평가에만 사용.
  • TO_31 (불균형)Nested CV: outer 5-fold로 나눠 outer-test는 피처 선택에서 배제, outer-train 안 inner 5-fold로 피처를 고른 뒤 outer-test로 딱 한 번 평가.

발견 1: 단일 split시 문제 — "recall 0"은 우연이었다

TO_31을 sealed split으로 평가하니 클래스 0 recall이 0.000이었다. test에 클래스 0이 10건뿐인데 하나도 못 맞춘 것. 이게 "모델이 원래 못하는 것"인지 "이번 10건이 유독 어려웠던 것"인지 단일 split으로는 알 수 없었다. 그래서 TO_31만 nested CV로 소수 클래스를 5 fold에 골고루 나눠 평가했다.

지표 Sealed split Nested CV (5-fold 평균)
macro-F1 0.478 0.624 ± 0.049
클래스 0 recall 0.000 0.440 (fold별 0.2~0.7)
클래스 2 recall 0.455 0.315 (fold별 0.18~0.36)

 

클래스 0의 "0"은 우연이었다(평균 44%). 반면 클래스 2는 5 fold 모두 18~36%로 꾸준히 낮았다 — 이쪽이 진짜 문제였다.

교훈: 불균형 + 작은 데이터에서 단일 split은 믿을 게 못 된다. fold를 반복해 분산을 봐야 우연과 구조를 구분한다.

발견 2: 이론적으로 맞는 개선이 실측에서 틀릴 수 있다

파이프라인 리뷰 중 "버그"를 찾았다고 생각했다: permutation_importance에 scoring을 안 줘서 기본값 accuracy로 피처를 고르고 있었다. TO_31은 83%가 다수 클래스라 accuracy가 다수에 쏠린다. 평가 지표는 macro-F1인데 피처는 accuracy로 뽑으니 소수 클래스용 피처가 탈락하는 것 아닐까? 그럴듯해서 scoring='f1_macro'로 바꿔 동일 조건 A/B 실험을 돌렸다.

지표 accuracy (기존) f1_macro (수정안)
macro-F1 0.624 ± 0.049 0.600 ± 0.042
클래스 0 recall 0.440 0.356
클래스 2 recall 0.315 0.295

 

예상과 반대로 전부 나빠졌다. inner validation fold 하나에 소수 클래스가 8~9건뿐이라, 표본이 적으면 f1_macro는 몇 건에 요동치는 노이즈 큰 scorer가 되고 permutation importance가 그 흔들리는 값으로 피처를 골라 더 불안정해진다. accuracy는 편향돼 있어도 표본이 많아 안정적이라 오히려 더 나은 피처를 골랐다.

발견 3: 혼동행렬이 알려준 진짜 원인

마지막으로 class_weight를 실험했다(소수 클래스 가중치를 balanced의 여러 배율로).

배율 macro-F1 0 recall 1 recall 2 recall
1.0 0.624 ± 0.049 0.440 0.971 0.315
2.0 0.629 ± 0.055 0.440 0.967 0.333
3.0 0.637 ± 0.075 0.484 0.953 0.351

 

소폭 오르지만 이득이 표준편차 안에 들고 불안정성은 커졌다.

          예측0   예측1   예측2
실제0  [  21     26      1  ]   ← 48건 중 26건이 다수 클래스로 흡수
실제1  [   7    476      7  ]   ← 거의 완벽
실제2  [   2     35     17  ]   ← 54건 중 35건(65%)이 클래스 1로 흡수

 

클래스 2의 65%가 클래스 1로 빨려들어가고, 가중치를 3배로 올려도 그 35건 그대로. 이 35건은 모델이 보기에 클래스 1과 구분이 안 되는 샘플. 즉 가중치 문제가 아니라 피처가 클래스 2를 클래스 1과 분리할 신호를 못 담은 것 — 튜닝으로 개선할 수 없는 한계

 

교훈: 성능이 안 나올 때 "어디로 틀리는지(혼동행렬)"를 봐야 원인을 안다. 클래스 2는 데이터·피처의 한계였고, 개선하려면 T계열 세부 품질을 구분하는 새 도메인 피처나 소수 클래스 데이터가 더 필요하다.


결론

  1. 데이터 누수: 피처 선택도 타깃을 쓰면 CV 안에서. 밖에서 고르면 성능이 부풀려진다.
  2. 불균형 + 작은 데이터: 단일 split 숫자는 믿지 말고 반복해 분산을 볼 것.
  3. 가설은 실험으로: 이론적으로 맞는 수정(f1_macro)이 실측에선 해로웠다.
  4. 한계: 혼동행렬이 클래스 2의 한계가 튜닝 밖에 있음을 보여줬다.

💡 느낀점

솔직히 코드 짜는 시간보다 이 숫자 믿어도 되나 의심하는 시간이 더 길었다. recall 0 보고 좌절했는데 알고 보니 그냥 운 나쁜 split이었고, 맞다고 확신한 f1_macro 수정은 오히려 성능을 깎았다. 실험 안 돌렸으면 둘 다 틀린 결론으로 넘어갔을 거다. 클래스 2는 결국 못 살렸지만, 튜닝으로 안 되는 걸 붙잡는 대신 데이터 문제인 근거를 명확히하고 이를 인지하며 개선하는 방향을 찾아봐야할 것 같다. 추후에는 트리모델 기반으로 다양화하여 성적을 비교해볼 예정이다.