콘텐츠로 이동

훈련·검증·테스트 (Train, Validation, Test)

난이도: 초급
선수 지식: 모델·학습·예측
관련 문서: 과적합 첫걸음 | 교차 검증 | 데이터 누수

핵심 요약: 데이터를 셋으로 나눈다. 훈련셋으로 파라미터를 배우고, 검증셋으로 하이퍼파라미터를 고르고, 테스트셋은 금고에 넣어두었다가 맨 마지막에 딱 한 번 연다. 이유는 하나다 — 시험 문제를 미리 본 사람의 점수는 실력이 아니기 때문이다. 이 규칙을 어기면 검증 점수는 좋은데 배포 후 무너진다.

  • 훈련셋(Train set): 모델이 파라미터를 배우는 데 쓰는 데이터. 보통 가장 크다.
  • 검증셋(Validation set): 하이퍼파라미터를 고르고 모델끼리 비교하는 데 쓰는 데이터. 여러 번 봐도 된다.
  • 테스트셋(Test set): 최종 성능을 보고할 때 딱 한 번 쓰는 데이터. 여러 번 보면 의미가 사라진다.
  • 홀드아웃(Hold-out): 데이터를 한 번만 잘라 나누는 가장 단순한 방식.
  • 데이터 누수(Data Leakage): 알아서는 안 될 정보가 학습에 새어 들어가 성능이 부풀려지는 현상.

머신러닝에서 가장 중요한 질문은 “이 모델이 처음 보는 데이터에서도 잘할까”이다. 그런데 이 질문에 답하려면 처음 보는 데이터가 필요하다. 그래서 가진 데이터의 일부를 일부러 감춰두는 것이 데이터 분할이다.

역할몇 번 보는가
훈련파라미터 학습수없이
검증하이퍼파라미터 선택, 모델 비교여러 번
테스트최종 성능 보고단 한 번

비유: 문제집 · 모의고사 · 수능이다. 문제집(훈련셋)은 풀고 또 풀며 실력을 쌓는 데 쓴다. 모의고사(검증셋)는 “이 공부법이 맞나”를 점검하고 전략을 바꾸는 데 쓴다 — 여러 번 봐도 된다. 수능(테스트셋)은 딱 한 번뿐이고, 미리 문제를 봤다면 그 점수는 실력이 아니다.


“학습에 쓴 데이터로 성능을 재면 안 된다”는 사실은 통계학에서 오래전부터 알려져 있었다. 모델이 복잡할수록 같은 데이터에 대한 오차는 얼마든지 0으로 만들 수 있기 때문이다. 극단적으로, 모든 데이터를 통째로 외우는 모델은 훈련 오차가 0이지만 새 데이터에서는 쓸모가 없다.

1974년 StoneGeisser교차 검증(cross-validation)을 체계화하면서 “데이터를 나눠 평가한다”는 방법론이 정립되었다.

그런데 실무에서는 검증셋을 여러 번 보며 하이퍼파라미터를 조정하다 보니 검증셋에도 간접적으로 과적합되는 현상이 발견되었다. 이를 막기 위해 테스트셋을 따로 떼어 봉인하는 3분할 관행이 자리 잡았다.

Kaggle 같은 대회가 public/private 리더보드를 나누는 것도 같은 이유다. public 점수만 보고 튜닝한 참가자들이 private에서 순위가 크게 떨어지는 일이 반복되었기 때문이다.


1. 왜 세 개인가 — 두 개로는 부족한 이유

섹션 제목: “1. 왜 세 개인가 — 두 개로는 부족한 이유”

“훈련/테스트 둘로만 나누면 되지 않나?”라고 생각하기 쉽다. 문제는 하이퍼파라미터를 고르는 과정에 있다.

트리 깊이를 1, 3, 5, 10 중에서 고른다고 하자. 테스트셋으로 넷을 비교해 가장 좋은 것을 고르면, 그 순간 테스트셋 정보가 모델 선택에 사용된 것이다. 이후 보고하는 테스트 점수는 이미 낙관적으로 편향되어 있다.

비교 횟수가 늘수록 이 편향은 커진다. 100번 비교하면 사실상 100번의 검정을 한 셈이라, 우연히 좋아 보이는 설정이 반드시 하나는 나온다.

데이터 크기권장
~1,00060 : 20 : 20 (또는 교차 검증)
~100,00080 : 10 : 10
1,000,000+98 : 1 : 1

60:20:20은 법칙이 아니라 데이터가 적을 때의 관습이다. 검증셋 크기는 비율이 아니라 필요한 추정 정밀도로 정해야 한다 — 오차 ±1%를 95% 신뢰도로 보장하려면 약 18,000건이면 충분하고, 데이터가 100만 건이어도 그 이상은 필요 없다. 자세한 계산은 ML 파이프라인 설계에 있다.

상황반드시 지킬 것
클래스가 불균형층화(stratified) 분할 — 각 셋의 클래스 비율을 원본과 동일하게
시계열 데이터시간 순서 유지 — 과거로 학습, 미래로 검증. 절대 셔플 금지
같은 사용자/환자가 여러 행그룹 단위 분할 — 같은 사람이 훈련과 검증에 동시에 있으면 누수
중복 데이터 존재분할 전에 중복 제거

데이터 1,000건으로 트리 깊이를 고르고 최종 성능을 보고해 보자.

1단계: 테스트 200건을 먼저 떼어 금고에 넣는다. 남은 800건을 훈련 600 / 검증 200으로 나눈다.

2단계: 훈련 600건으로 학습하고, 검증 200건으로 깊이를 비교한다.

깊이훈련 정확도검증 정확도
10.8920.865
30.9330.925
50.9680.935 ← 최고
100.9970.920
제한 없음1.0000.920

여기서 두 가지가 보인다.

  • 훈련 정확도는 계속 오른다(0.892 → 1.000). 깊이를 늘리면 훈련 데이터를 더 잘 외우기 때문이다.
  • 검증 정확도는 깊이 5에서 꺾인다(0.935 → 0.920). 이 지점부터 과적합이다.

훈련 정확도만 봤다면 “제한 없음”을 골랐을 것이고, 그것은 틀린 선택이다.

3단계: 깊이 5로 정하고, 이제서야 금고를 열어 테스트 200건으로 잰다 → 0.960.

이 0.960이 보고할 수 있는 유일한 숫자다. 검증 점수 0.935는 “깊이를 고르는 데 쓴 값”이지 성능이 아니다.


데이터가 1,000건도 안 되면 20%를 떼는 것이 아깝고, 한 번 자른 결과가 운에 크게 좌우된다.

이때는 교차 검증을 쓴다. 데이터를 kk조각으로 나눠 돌아가며 한 번씩 검증에 쓰고 kk번의 성능을 평균낸다. 모든 데이터가 한 번씩 검증에 쓰이므로 버리는 데이터가 없고 운의 영향도 줄어든다.

단, 테스트셋은 교차 검증과 별개로 여전히 떼어둬야 한다. 교차 검증은 검증셋을 대체하는 것이지 테스트셋을 대체하지 않는다.


상황방식
데이터가 충분(수만 건 이상)훈련/검증/테스트 3분할
데이터가 적음(수천 건 이하)교차 검증 + 별도 테스트셋
하이퍼파라미터 탐색이 많음중첩 교차 검증
시계열TimeSeriesSplit (시간 순서 유지)

리더보드 1위가 최종 순위 47위가 된 이유

섹션 제목: “리더보드 1위가 최종 순위 47위가 된 이유”

한 Kaggle 대회에서 참가자 A는 public 리더보드 1위를 몇 주간 유지했다. 그는 매일 여러 번 제출하며 public 점수가 오르는 방향으로 특성과 하이퍼파라미터를 조정했다.

대회가 끝나고 private 리더보드가 공개되자 그의 최종 순위는 47위였다. 반면 public 순위가 30위권이던 참가자 B는 3위로 올라갔다.

차이는 방법론에 있었다.

  • A는 public 점수를 검증셋처럼 사용했다. 수백 번 제출하며 튜닝했으니, 사실상 public 데이터에 과적합된 것이다.
  • B는 자체 교차 검증 점수를 기준으로 삼고, public 점수는 “크게 어긋나지 않는지” 확인하는 용도로만 봤다.

교훈: 어떤 데이터든 반복해서 보고 그에 맞춰 조정하는 순간 그것은 훈련 데이터가 된다. 이름이 “테스트셋”이어도 마찬가지다. 그래서 최종 평가용 데이터는 물리적으로 봉인하는 것이 안전하다.


import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
X, y = make_classification(n_samples=1000, n_features=20, n_informative=5, random_state=0)
# 1단계: 테스트셋을 먼저 떼어 금고에 넣는다 (마지막에 딱 한 번만 연다)
X_rest, X_test, y_rest, y_test = train_test_split(X, y, test_size=0.2, random_state=0)
# 2단계: 남은 것을 훈련 / 검증으로 나눈다
X_tr, X_val, y_tr, y_val = train_test_split(X_rest, y_rest, test_size=0.25, random_state=0)
print(f"훈련 {len(X_tr)}건 / 검증 {len(X_val)}건 / 테스트 {len(X_test)}건")
# 검증셋으로 하이퍼파라미터(깊이)를 고른다
print(f"\n{'깊이':>4} {'훈련':>8} {'검증':>8}")
best = (None, -1)
for d in (1, 3, 5, 10, None):
m = DecisionTreeClassifier(max_depth=d, random_state=0).fit(X_tr, y_tr)
tr, va = m.score(X_tr, y_tr), m.score(X_val, y_val)
print(f"{str(d):>4} {tr:>8.3f} {va:>8.3f}")
if va > best[1]: best = (d, va)
final = DecisionTreeClassifier(max_depth=best[0], random_state=0).fit(X_rest, y_rest)
print(f"\n검증이 고른 깊이 = {best[0]}")
print(f"이제서야 테스트셋을 연다 -> 최종 정확도 {final.score(X_test, y_test):.3f}")
# 실행 결과:
# 훈련 600건 / 검증 200건 / 테스트 200건
#
# 깊이 훈련 검증
# 1 0.892 0.865
# 3 0.933 0.925
# 5 0.968 0.935 <- 검증 최고
# 10 0.997 0.920
# None 1.000 0.920 <- 훈련은 만점인데 검증은 하락
#
# 검증이 고른 깊이 = 5
# 이제서야 테스트셋을 연다 -> 최종 정확도 0.960

훈련 정확도만 봤다면 “제한 없음”(1.000)을 골랐을 것이고, 그것은 틀린 선택이다. 검증셋이 깊이 5에서 꺾이는 것을 보여줬기 때문에 올바른 선택이 가능했다.

마지막 줄도 중요하다 — 깊이를 정한 뒤에 훈련+검증(800건)을 합쳐 다시 학습하고, 그 다음에 테스트셋을 열었다. 데이터를 최대한 쓰면서도 테스트셋의 순수성은 지킨 것이다.


  1. “테스트셋으로 모델을 고르면 안 되나?” — 안 된다. 고르는 데 쓰는 순간 그것은 검증셋이 되고, 보고하는 점수는 낙관적으로 편향된다.

  2. “검증 점수가 최종 성능이다” — 검증 점수는 하이퍼파라미터를 고르는 데 쓴 값이라 이미 편향되어 있다. 보고할 수 있는 것은 테스트 점수뿐이다.

  3. “랜덤하게 나누면 항상 안전하다” — 시계열은 시간 순서를, 같은 사람의 여러 기록은 그룹 단위를 지켜야 한다. 랜덤 분할이 오히려 누수를 만든다.

  4. “전처리는 나누기 전에 해도 된다”절대 안 된다. 전체 데이터로 스케일러를 학습시키면 검증/테스트 정보가 이미 섞여 들어간다. 반드시 파이프라인 안에서 처리한다.

  5. “데이터가 많으면 20%를 검증에 써야 한다” — 비율이 아니라 필요한 정밀도로 정한다. 100만 건이면 1~2%로도 충분하다.


1. 훈련/테스트 두 개로만 나누면 무엇이 문제인가?

정답 보기

하이퍼파라미터를 고르려면 여러 설정을 비교해야 하는데, 그 비교에 테스트셋을 쓰면 테스트셋 정보가 모델 선택에 사용된다. 이후 보고하는 테스트 점수는 낙관적으로 편향된다. 그래서 고르는 용도의 검증셋을 따로 둔다.

2. 훈련 정확도 1.000, 검증 정확도 0.920인 모델과 훈련 0.968, 검증 0.935인 모델 중 무엇을 고를 것인가?

정답 보기

후자다. 우리가 원하는 것은 새 데이터에서의 성능이므로 검증 점수가 높은 쪽이 맞다. 전자는 훈련 데이터를 외운 것(과적합)이며, 훈련 정확도 1.000은 좋은 신호가 아니라 경고 신호다.

3. 데이터를 나누기 전에 전체에 StandardScaler를 적용하면 왜 안 되는가?

정답 보기

스케일러가 계산하는 평균과 표준편차 안에 검증/테스트 데이터의 정보가 들어가기 때문이다. 모델이 간접적으로 시험 문제를 미리 본 셈이라 성능이 부풀려진다. 전처리는 반드시 파이프라인 안에 넣어 폴드마다 다시 학습시켜야 한다.