어제는 누수가 검증을 어떻게 망치는지 봤다. 오늘은 그 거울상 — 누수 없이 검증을 설계해 모델의 일반화 성능을 정직하게 추정하는 일이다. 좋은 모델 선택은 결국 "본 적 없는 데이터에서 얼마나 잘할까?"를 믿을 만하게 추정하는 능력이고, 그 신뢰도는 전적으로 검증 설계에 달려 있다.
가장 기본은 데이터를 셋으로 나누는 것이다. 각각 역할이 다르다.
분할
역할
핵심
사용 횟수
학습(training)
모델 파라미터를 학습
보통 60~80%로 가장 큰 비중
매 학습마다
검증(validation)
하이퍼파라미터 튜닝·모델 선택
반복해서 본다
여러 번
테스트(test)
최종 성능 평가
끝까지 봉인
정확히 한 번
핵심 원칙: 테스트셋은 모든 결정이 끝날 때까지 봉인하고 정확히 한 번만 본다. 테스트 점수를 보고 모델을 수정하면 테스트셋이 사실상 검증셋이 되어 최종 추정치가 낙관적으로 오염된다.
from sklearn.model_selection import train_test_split# 1차: train+val 대 test (테스트는 봉인)X_temp, X_test, y_temp, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y)# 2차: train 대 validationX_train, X_val, y_train, y_val = train_test_split( X_temp, y_temp, test_size=0.25, random_state=42, stratify=y_temp)# 결과: 60% / 20% / 20%
검증셋과 테스트셋을 분리하는 이유는 다중 비교 문제(multiple comparisons problem) 때문이다. 검증셋 위에서 수십 개 하이퍼파라미터 조합을 비교하면, 우연히 그 검증셋에 잘 맞는 조합이 선택되어 검증 점수에 "선택 편향"이 쌓인다. 그 점수는 더 이상 일반화 성능을 공정하게 추정하지 못한다. 그래서 모든 선택이 끝난 뒤, 한 번도 쓰지 않은 테스트셋에서 최종 성능을 잰다. 그때에야 추정이 정직해진다.
교차검증의 이득: (1) 모든 행이 한 번씩 검증에 쓰여 데이터를 아끼고, (2) k개 점수의 평균과 분산을 얻어 성능의 안정성까지 볼 수 있다.
from sklearn.model_selection import StratifiedKFold, cross_val_scoreskf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)scores = cross_val_score(pipe, X, y, cv=skf, scoring="f1")print(f"F1: {scores.mean():.3f} ± {scores.std():.3f}") # 평균 ± 표준편차
k 선택에는 편향-분산 트레이드오프가 있다. k가 작으면(예: 2-fold) 각 학습셋이 작아져 추정이 비관적으로 편향된다. k가 매우 크면(LOO, k=n) 학습셋이 거의 전체라 편향은 작지만 fold 결과가 서로 비슷해져 추정 분산이 커지고 계산량이 폭발한다. 경험적으로 k=5 또는 10이 편향·분산·비용의 균형점이라 널리 쓰인다.
클래스가 불균형한 분류(예: 사기 1%)에서 무작위 분할은 위험하다. 운이 나쁘면 검증에 소수 클래스가 거의 없거나 아예 0개일 수 있다. 층화 표집(stratified sampling)은 각 분할에서 원본 클래스 비율을 유지한다.
train_test_split(..., stratify=y): 분할 시 비율 유지
StratifiedKFold: 각 fold에서 비율 유지
분류에서는 층화가 거의 항상 권장되며, 불균형 데이터에서는 사실상 필수다. 회귀에서는 타깃을 구간(bin)으로 나눠 층화하기도 한다.
from sklearn.model_selection import train_test_split# stratify=y로 원본 클래스 비율을 분할에 유지한다X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42)print(y_train.value_counts(normalize=True)) # 원본과 같은 비율인지 확인
⚠️ 함정: 불균형 데이터를 층화 없이 무작위 분할하면 어떤 fold에는 소수 클래스가 0개가 되어 F1·recall이 계산 불가하거나 왜곡된다. 분류에서 accuracy는 높은데 소수 클래스를 전혀 잡지 못한다면 층화 분할부터 확인한다.
Day 3에서 봤듯 시계열에 무작위 분할이나 일반 k-fold를 쓰면 미래가 과거로 샌다. 시계열에는 시간 순서를 지키는 분할이 필요하다.
방식
설명
특징
고정 분할(holdout)
기준 시점을 정해 과거=학습, 미래=테스트
가장 단순, 한 번만 평가
확장 윈도(expanding)
학습 구간을 늘려가며 다음 구간 평가
데이터를 모두 활용, 오래된 패턴도 반영
이동 윈도(rolling/sliding)
고정 길이 학습 구간을 앞으로 밀며 평가
최근 패턴 위주, 분포 변화에 대응
scikit-learn의 TimeSeriesSplit이 이 forward-chaining을 구현한다. 각 분할에서 학습은 항상 검증보다 과거다.
TimeSeriesSplit (확장 윈도, n_splits=4)
시간축 ──────────────────────────────────────────▶
Split 1 [■■■■]───[▨▨] · · · · ■ 학습 ▨ 검증
Split 2 [■■■■ ■■]───[▨▨] · ·
Split 3 [■■■■ ■■ ■■]───[▨▨] ·
Split 4 [■■■■ ■■ ■■ ■■]───[▨▨]
· 학습 구간은 매번 확장되고, 검증 구간은 항상 그 "다음" 시점
· 실제 배포(과거 모델로 미래 예측)와 동일한 구조
from sklearn.model_selection import TimeSeriesSplit# 시간 순 분할: 학습이 항상 검증보다 과거 (누수 차단)tscv = TimeSeriesSplit(n_splits=5)for train_idx, test_idx in tscv.split(X): # train_idx의 모든 시점 < test_idx의 시점 X_tr, X_te = X.iloc[train_idx], X.iloc[test_idx] # 모델 학습/평가 ...
시계열 검증이 일반 교차검증과 다른 이유는 행 독립성 가정이 깨지기 때문이다. 일반 k-fold는 행이 서로 교환 가능하다고 가정하지만, 시계열에서는 순서 자체가 정보다. TimeSeriesSplit은 항상 "과거로 학습, 미래로 평가"를 유지해 실제 배포 상황(과거 모델로 미래를 예측)을 모사한다. 이 정렬 덕분에 검증 점수가 실제 운영 성능과 닮게 된다.