교차 검증: K-Fold로 모델 성능을 더 정확히 추정하기

K-겹 교차 검증의 원리와 편향-분산 트레이드오프, Stratified·Time-Series·Leave-One-Out 등 다양한 변형, GridSearchCV와의 결합까지 완전히 이해한다.

· 8 min read · PALDYN Team

지난 글에서 훈련·검증·테스트 세트를 나누는 원칙을 배웠다. 그런데 단순 홀드아웃 방식은 분할 방법에 따라 성능 수치가 크게 흔들릴 수 있다. 데이터가 적을 때는 특히 심각해서, 운 좋게 쉬운 샘플이 검증 세트에 들어오면 성능이 과대평가되고, 반대 경우엔 과소평가된다. **교차 검증(Cross-Validation)**은 이 분산을 줄이고 더 안정적인 성능 추정을 제공한다.

K-겹 교차 검증의 원리

K-Fold CV의 아이디어는 간단하다. 전체 데이터를 K개의 동일한 크기 덩어리(Fold)로 나눈 뒤, K번 반복하면서 매번 다른 Fold를 검증 세트로 쓰고 나머지 K-1개 Fold로 훈련한다. K번의 점수를 평균 내면 최종 CV 점수가 된다.

5-겹 교차 검증 구조

K=5 기준, 각 반복에서 20%의 데이터가 검증에 사용되며 데이터 전체가 정확히 한 번씩 검증 세트에 포함된다. 결국 모든 샘플이 평가에 기여하므로 단일 분할보다 훨씬 안정적인 추정이 가능하다.

기본 구현

from sklearn.model_selection import cross_val_score, KFold
from sklearn.ensemble import RandomForestClassifier
from sklearn.datasets import load_breast_cancer
import numpy as np

X, y = load_breast_cancer(return_X_y=True)
model = RandomForestClassifier(n_estimators=100, random_state=42)

# 가장 간단한 방법: cv=5 (StratifiedKFold 자동 적용)
scores = cross_val_score(model, X, y, cv=5, scoring='accuracy')

print(f"각 Fold 점수: {scores}")
print(f"평균: {scores.mean():.4f}")
print(f"표준편차: {scores.std():.4f}")
# 표준편차가 작을수록 안정적인 모델

# 여러 지표를 동시에 계산
from sklearn.model_selection import cross_validate

results = cross_validate(model, X, y, cv=5,
    scoring=['accuracy', 'f1', 'roc_auc'],
    return_train_score=True)

print(f"Train Acc: {results['train_accuracy'].mean():.4f}")
print(f"Val Acc:   {results['test_accuracy'].mean():.4f}")
print(f"Val F1:    {results['test_f1'].mean():.4f}")
print(f"Val ROC:   {results['test_roc_auc'].mean():.4f}")

cross_validate는 여러 지표를 한 번에 계산하면서 훈련 점수도 함께 반환한다. 훈련 점수와 검증 점수 차이가 크면 과대적합을 의심할 수 있다.

K 값은 어떻게 선택하나

K=5와 K=10이 가장 흔히 사용된다. K가 크면 각 반복에서 훈련 데이터가 많아지므로 편향이 줄지만, 반복 횟수가 늘어 계산 비용이 증가하고 각 검증 세트가 작아져 분산이 커진다.

K 값편향분산계산 비용권장 상황
3높음낮음낮음데이터 많고 학습 오래 걸릴 때
5중간중간중간일반 상황 (기본값)
10낮음높음높음데이터 적을 때
n (LOOCV)최소최대매우 높음소규모 데이터 (<100)

K=n은 **LOOCV(Leave-One-Out Cross-Validation)**로, 한 번에 샘플 하나를 검증 세트로 쓴다. 편향이 가장 낮지만 n번 학습해야 한다.

StratifiedKFold: 클래스 불균형 처리

일반 K-Fold는 Fold를 무작위로 나누므로 어떤 Fold는 특정 클래스 샘플이 너무 적거나 많을 수 있다. 클래스 불균형 데이터에서는 이 문제가 심각해진다. StratifiedKFold는 각 Fold의 클래스 비율을 원본과 동일하게 유지한다.

from sklearn.model_selection import StratifiedKFold, cross_val_score

# 불균형 클래스 데이터 (양성 5%, 음성 95%)
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

# cross_val_score에 cv 객체를 직접 전달
scores = cross_val_score(model, X, y,
    cv=skf,
    scoring='f1_weighted')

# 분류 문제에서 cross_val_score(cv=5)는
# 자동으로 StratifiedKFold를 사용한다

시계열 데이터: TimeSeriesSplit

시간 순서가 있는 데이터는 미래 정보가 과거 예측에 사용되지 않도록 Fold를 시간 순서대로 구성해야 한다.

교차 검증 종류 비교 코드

TimeSeriesSplit은 훈련 구간이 점점 커지는 방식으로 분할한다. 반복 1에서는 처음 20% 훈련 + 다음 20% 검증, 반복 2에서는 처음 40% 훈련 + 다음 20% 검증 식으로 진행된다.

from sklearn.model_selection import TimeSeriesSplit

tscv = TimeSeriesSplit(n_splits=5)

for fold, (train_idx, val_idx) in enumerate(tscv.split(X)):
    X_tr, X_v = X[train_idx], X[val_idx]
    y_tr, y_v = y[train_idx], y[val_idx]

    model.fit(X_tr, y_tr)
    score = model.score(X_v, y_v)
    print(f"Fold {fold+1}: train=[0:{len(train_idx)}] "
          f"val=[{len(train_idx)}:{len(train_idx)+len(val_idx)}] "
          f"score={score:.4f}")

GroupKFold: 동일 그룹 분리 방지

같은 환자의 여러 측정값, 같은 사용자의 여러 거래처럼 관련 샘플들이 있을 때 같은 그룹이 훈련과 검증에 동시에 들어가면 성능이 과대평가된다.

from sklearn.model_selection import GroupKFold
import numpy as np

# groups: 각 샘플이 속한 그룹 ID
# (예: 환자 ID, 사용자 ID, 문서 ID 등)
groups = np.array([1,1,2,2,3,3,4,4,5,5] * 10)

gkf = GroupKFold(n_splits=5)
for train_idx, val_idx in gkf.split(X, y, groups=groups):
    # train_idx와 val_idx는 서로 다른 그룹만 포함
    assert len(set(groups[train_idx]) & set(groups[val_idx])) == 0

GridSearchCV: 교차 검증으로 하이퍼파라미터 튜닝

교차 검증의 핵심 응용은 하이퍼파라미터 탐색이다. GridSearchCV는 모든 파라미터 조합을 K-Fold CV로 평가해 최적 조합을 찾는다.

from sklearn.model_selection import GridSearchCV
from sklearn.svm import SVC
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline

pipe = Pipeline([
    ('scaler', StandardScaler()),
    ('svc',    SVC())
])

param_grid = {
    'svc__C':     [0.1, 1, 10, 100],
    'svc__gamma': ['scale', 'auto', 0.01, 0.001],
    'svc__kernel': ['rbf', 'linear']
}

# cv=5: 각 조합을 5-Fold CV로 평가
# n_jobs=-1: 모든 CPU 코어 사용
grid = GridSearchCV(pipe, param_grid,
    cv=5,
    scoring='accuracy',
    n_jobs=-1,
    verbose=1)

grid.fit(X_train, y_train)

print(f"최적 파라미터: {grid.best_params_}")
print(f"최적 CV 점수: {grid.best_score_:.4f}")

# 최종 평가는 테스트 세트로
test_score = grid.score(X_test, y_test)
print(f"테스트 점수: {test_score:.4f}")

주의: GridSearchCV는 내부적으로 교차 검증을 사용하므로 별도 검증 세트가 필요 없다. grid.fit(X_train, y_train)grid.score(X_test, y_test)로 최종 평가하면 된다.

Nested Cross-Validation: 진정한 비편향 평가

하이퍼파라미터 튜닝까지 CV로 수행하고 싶다면 중첩 교차 검증(Nested CV)을 사용한다. 외부 루프는 성능 추정, 내부 루프는 하이퍼파라미터 탐색을 담당한다.

from sklearn.model_selection import cross_val_score, GridSearchCV

inner_cv = KFold(n_splits=3)
outer_cv = KFold(n_splits=5)

grid = GridSearchCV(model, param_grid, cv=inner_cv, n_jobs=-1)

# outer_cv로 grid(내부 튜닝 포함)를 평가
nested_scores = cross_val_score(
    grid, X, y, cv=outer_cv, scoring='accuracy'
)
print(f"Nested CV: {nested_scores.mean():.4f} ± {nested_scores.std():.4f}")

교차 검증의 한계

CV는 편향을 줄여주지만 계산 비용이 K배 증가한다. 딥러닝처럼 학습에 수 시간이 걸리는 경우 5-Fold CV는 비현실적이다. 이때는 단순 홀드아웃을 쓰되 여러 random_state로 반복해 평균을 취하는 Repeated Holdout을 사용하기도 한다.


지난 글: 훈련·검증·테스트 세트 분리: 올바른 모델 평가의 기초

다음 글: 편향-분산 트레이드오프: 과소적합과 과대적합의 근본 원인


읽어주셔서 감사합니다. 😊