콘텐츠로 이동
Data Prep
상세

교차 검증 (Cross-Validation)

모델의 일반화 성능을 평가하고 과적합을 탐지하는 기법. 제한된 데이터를 효율적으로 활용함.

왜 교차 검증이 필요한가

단순 Train/Test 분할의 문제

전체 데이터
+---------------------------+
|  Train (80%)  | Test (20%)|
+---------------------------+
               |
               v
         - 특정 분할에 의존
         - 검증 데이터 부족
         - 불안정한 평가

교차 검증의 해결

반복적으로 다른 부분을 검증에 사용
→ 더 안정적인 성능 추정
→ 전체 데이터 활용
→ 분산 추정 가능

K-Fold 교차 검증

원리

K=5 예시:

Fold 1: [Test] [Train] [Train] [Train] [Train]
Fold 2: [Train] [Test] [Train] [Train] [Train]
Fold 3: [Train] [Train] [Test] [Train] [Train]
Fold 4: [Train] [Train] [Train] [Test] [Train]
Fold 5: [Train] [Train] [Train] [Train] [Test]

최종 점수 = 5개 Fold 점수의 평균 ± 표준편차
from sklearn.model_selection import KFold, cross_val_score
import numpy as np

# Scikit-learn
kfold = KFold(n_splits=5, shuffle=True, random_state=42)

scores = cross_val_score(model, X, y, cv=kfold, scoring='accuracy')
print(f"평균: {scores.mean():.4f} ± {scores.std():.4f}")

# 수동 구현
def k_fold_cv(model_fn, X, y, k=5):
    kf = KFold(n_splits=k, shuffle=True, random_state=42)
    scores = []

    for fold, (train_idx, val_idx) in enumerate(kf.split(X)):
        X_train, X_val = X[train_idx], X[val_idx]
        y_train, y_val = y[train_idx], y[val_idx]

        model = model_fn()
        model.fit(X_train, y_train)

        score = model.score(X_val, y_val)
        scores.append(score)
        print(f"Fold {fold+1}: {score:.4f}")

    return np.mean(scores), np.std(scores)

K 값 선택

K 장점 단점 사용
5 균형 잡힘 - 일반적
10 낮은 편향 계산 비용 충분한 데이터
N (LOOCV) 최소 편향 높은 분산, 느림 작은 데이터셋

Stratified K-Fold

클래스 비율 유지. 불균형 데이터에 필수.

from sklearn.model_selection import StratifiedKFold

# 클래스 분포 유지
skfold = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

for train_idx, val_idx in skfold.split(X, y):
    print(f"Train class distribution: {np.bincount(y[train_idx])}")
    print(f"Val class distribution: {np.bincount(y[val_idx])}")
# 불균형 데이터 예시
# 전체: [900 positive, 100 negative]
# 각 Fold: [180 positive, 20 negative] (동일 비율)

Leave-One-Out (LOOCV)

K = N (샘플 수)인 극단적 경우.

from sklearn.model_selection import LeaveOneOut

loo = LeaveOneOut()
scores = cross_val_score(model, X, y, cv=loo)

# 샘플이 작을 때만 사용 (N < 100)

장단점: - 장점: 최대한의 훈련 데이터 사용 - 단점: 계산 비용 O(N), 높은 분산

시계열 교차 검증

시간 순서를 유지해야 하는 데이터.

TimeSeriesSplit

Split 1: [Train] |Val|
Split 2: [Train    ] |Val|
Split 3: [Train       ] |Val|
Split 4: [Train          ] |Val|
Split 5: [Train             ] |Val|
from sklearn.model_selection import TimeSeriesSplit

tscv = TimeSeriesSplit(n_splits=5)

for train_idx, val_idx in tscv.split(X):
    print(f"Train: {train_idx[:3]}...{train_idx[-3:]}")
    print(f"Val: {val_idx}")

슬라이딩 윈도우

고정 크기 훈련 윈도우.

def sliding_window_cv(X, y, train_size, val_size, step=1):
    n = len(X)
    folds = []

    for start in range(0, n - train_size - val_size + 1, step):
        train_idx = range(start, start + train_size)
        val_idx = range(start + train_size, start + train_size + val_size)
        folds.append((list(train_idx), list(val_idx)))

    return folds

# 사용
folds = sliding_window_cv(X, y, train_size=100, val_size=20, step=10)

그룹 기반 교차 검증

동일 그룹이 훈련/검증에 분리되어야 하는 경우.

from sklearn.model_selection import GroupKFold, LeaveOneGroupOut

# 예: 환자별 데이터, 사용자별 데이터
groups = [1, 1, 1, 2, 2, 3, 3, 3, 3, 4]  # 그룹 ID

gkf = GroupKFold(n_splits=4)
for train_idx, val_idx in gkf.split(X, y, groups):
    print(f"Train groups: {set(groups[i] for i in train_idx)}")
    print(f"Val groups: {set(groups[i] for i in val_idx)}")

# Leave One Group Out
logo = LeaveOneGroupOut()
for train_idx, val_idx in logo.split(X, y, groups):
    val_group = groups[val_idx[0]]
    print(f"Validation group: {val_group}")

중첩 교차 검증 (Nested CV)

하이퍼파라미터 튜닝과 모델 평가를 동시에 수행.

외부 루프: 모델 성능 평가
내부 루프: 하이퍼파라미터 튜닝

Outer Fold 1:
  [Inner CV for hyperparameter tuning] | Test
Outer Fold 2:
  [Inner CV for hyperparameter tuning] | Test
...
from sklearn.model_selection import cross_val_score, GridSearchCV

# 중첩 CV
outer_cv = KFold(n_splits=5, shuffle=True, random_state=42)
inner_cv = KFold(n_splits=3, shuffle=True, random_state=42)

# 내부 CV로 하이퍼파라미터 튜닝
param_grid = {'C': [0.1, 1, 10], 'gamma': ['scale', 'auto']}
clf = GridSearchCV(SVC(), param_grid, cv=inner_cv)

# 외부 CV로 성능 평가
nested_scores = cross_val_score(clf, X, y, cv=outer_cv)
print(f"Nested CV Score: {nested_scores.mean():.4f} ± {nested_scores.std():.4f}")

딥러닝에서의 교차 검증

PyTorch 구현

import torch
from torch.utils.data import DataLoader, SubsetRandomSampler

def k_fold_train(dataset, model_fn, k=5, epochs=10, batch_size=32):
    kf = KFold(n_splits=k, shuffle=True, random_state=42)
    fold_results = []

    for fold, (train_idx, val_idx) in enumerate(kf.split(range(len(dataset)))):
        print(f"\n=== Fold {fold+1}/{k} ===")

        # DataLoader 생성
        train_sampler = SubsetRandomSampler(train_idx)
        val_sampler = SubsetRandomSampler(val_idx)

        train_loader = DataLoader(dataset, batch_size=batch_size, sampler=train_sampler)
        val_loader = DataLoader(dataset, batch_size=batch_size, sampler=val_sampler)

        # 모델 초기화
        model = model_fn()
        optimizer = torch.optim.Adam(model.parameters())

        # 학습
        best_val_acc = 0
        for epoch in range(epochs):
            train_loss = train_epoch(model, train_loader, optimizer)
            val_acc = evaluate(model, val_loader)

            if val_acc > best_val_acc:
                best_val_acc = val_acc

        fold_results.append(best_val_acc)
        print(f"Fold {fold+1} Best Val Acc: {best_val_acc:.4f}")

    print(f"\nCV Score: {np.mean(fold_results):.4f} ± {np.std(fold_results):.4f}")
    return fold_results

LLM 평가에서의 고려사항

# LLM은 데이터가 크므로 전통적 CV보다는:
# 1. 고정 Train/Val/Test 분할
# 2. 여러 벤치마크 태스크로 평가

def evaluate_llm(model, eval_datasets):
    results = {}

    for name, dataset in eval_datasets.items():
        score = evaluate_on_dataset(model, dataset)
        results[name] = score

    # 평균 점수 계산
    avg_score = np.mean(list(results.values()))
    return results, avg_score

교차 검증 결과 분석

성능 보고

def report_cv_results(scores, metric_name='Accuracy'):
    """교차 검증 결과 보고"""
    print(f"\n=== Cross-Validation Results ===")
    print(f"Metric: {metric_name}")
    print(f"Folds: {len(scores)}")
    print(f"Scores: {scores}")
    print(f"Mean: {np.mean(scores):.4f}")
    print(f"Std: {np.std(scores):.4f}")
    print(f"Min: {np.min(scores):.4f}")
    print(f"Max: {np.max(scores):.4f}")

    # 95% 신뢰구간
    ci = 1.96 * np.std(scores) / np.sqrt(len(scores))
    print(f"95% CI: [{np.mean(scores) - ci:.4f}, {np.mean(scores) + ci:.4f}]")

모델 비교

from scipy import stats

def compare_models(scores_a, scores_b, alpha=0.05):
    """두 모델의 CV 점수 비교 (paired t-test)"""
    t_stat, p_value = stats.ttest_rel(scores_a, scores_b)

    print(f"Model A: {np.mean(scores_a):.4f} ± {np.std(scores_a):.4f}")
    print(f"Model B: {np.mean(scores_b):.4f} ± {np.std(scores_b):.4f}")
    print(f"t-statistic: {t_stat:.4f}")
    print(f"p-value: {p_value:.4f}")

    if p_value < alpha:
        winner = "A" if np.mean(scores_a) > np.mean(scores_b) else "B"
        print(f"Significant difference: Model {winner} is better")
    else:
        print("No significant difference")

    return p_value < alpha

CV 전략 선택 가이드

상황 권장 방법
일반적 5-Fold CV
불균형 데이터 Stratified K-Fold
시계열 데이터 TimeSeriesSplit
그룹 데이터 GroupKFold
작은 데이터셋 LOOCV 또는 10-Fold
하이퍼파라미터 + 평가 Nested CV
대용량 데이터 Train/Val/Test 고정 분할

하이퍼파라미터 튜닝과 CV

GridSearchCV

from sklearn.model_selection import GridSearchCV
from sklearn.ensemble import RandomForestClassifier

param_grid = {
    'n_estimators': [100, 200, 300],
    'max_depth': [5, 10, 15, None],
    'min_samples_split': [2, 5, 10]
}

grid_search = GridSearchCV(
    RandomForestClassifier(random_state=42),
    param_grid,
    cv=5,
    scoring='f1',
    n_jobs=-1,
    verbose=1
)
grid_search.fit(X_train, y_train)

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

RandomizedSearchCV

파라미터 공간이 클 때 효율적.

from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import randint, uniform

param_dist = {
    'n_estimators': randint(100, 500),
    'max_depth': randint(3, 20),
    'min_samples_split': randint(2, 20),
    'learning_rate': uniform(0.01, 0.3)
}

random_search = RandomizedSearchCV(
    GradientBoostingClassifier(random_state=42),
    param_dist,
    n_iter=50,           # 50번 샘플링
    cv=5,
    scoring='f1',
    n_jobs=-1,
    random_state=42
)
random_search.fit(X_train, y_train)

흔히 하는 실수

1. 전처리 후 CV (데이터 누수!)

가장 흔한 실수. 전처리에서 테스트 정보가 누출됨.

# 나쁜 예: 전체 데이터로 스케일링 후 CV
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)  # 전체 데이터 사용!
scores = cross_val_score(model, X_scaled, y, cv=5)

# 좋은 예: Pipeline 사용
from sklearn.pipeline import Pipeline

pipeline = Pipeline([
    ('scaler', StandardScaler()),
    ('model', LogisticRegression())
])
scores = cross_val_score(pipeline, X, y, cv=5)  # 각 fold에서 별도로 fit

2. 특성 선택 후 CV (데이터 누수!)

# 나쁜 예: 전체 데이터로 특성 선택
from sklearn.feature_selection import SelectKBest
selector = SelectKBest(k=10)
X_selected = selector.fit_transform(X, y)  # 전체 데이터!
scores = cross_val_score(model, X_selected, y, cv=5)

# 좋은 예: Pipeline에 포함
pipeline = Pipeline([
    ('selector', SelectKBest(k=10)),
    ('model', RandomForestClassifier())
])
scores = cross_val_score(pipeline, X, y, cv=5)

3. 시계열에 일반 K-Fold 사용

# 나쁜 예: 미래 데이터로 과거 예측
kf = KFold(n_splits=5, shuffle=True)  # 시간 무시
scores = cross_val_score(model, X, y, cv=kf)

# 좋은 예: TimeSeriesSplit 사용
tscv = TimeSeriesSplit(n_splits=5)
scores = cross_val_score(model, X, y, cv=tscv)

4. 그룹 데이터에 일반 K-Fold 사용

같은 환자, 같은 사용자의 데이터가 훈련/테스트에 나뉘면 누수.

# 나쁜 예: 같은 환자가 훈련/테스트에 나뉨
scores = cross_val_score(model, X, y, cv=5)

# 좋은 예: GroupKFold 사용
from sklearn.model_selection import GroupKFold
gkf = GroupKFold(n_splits=5)
scores = cross_val_score(model, X, y, cv=gkf, groups=patient_ids)

5. 불균형 데이터에 일반 K-Fold 사용

# 나쁜 예: Fold마다 클래스 비율이 다름
kf = KFold(n_splits=5, shuffle=True)

# 좋은 예: StratifiedKFold 사용
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(model, X, y, cv=skf)

6. 테스트 세트에서 CV 수행

# 나쁜 예: 테스트 세트를 CV에 사용
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
scores = cross_val_score(model, X_test, y_test, cv=5)  # 테스트 세트로 CV?!

# 좋은 예: 훈련 세트에서만 CV
scores = cross_val_score(model, X_train, y_train, cv=5)
# 테스트 세트는 최종 평가에만 사용

7. CV 점수만 보고 표준편차 무시

# 나쁜 예
print(f"CV Score: {scores.mean():.4f}")

# 좋은 예: 분산 확인
print(f"CV Score: {scores.mean():.4f} ± {scores.std():.4f}")
print(f"Scores: {scores}")

# 표준편차가 크면:
# - 데이터 불균형 확인
# - 데이터 양 부족 확인
# - 모델 불안정성 확인

8. Nested CV 없이 하이퍼파라미터 튜닝 결과 보고

# 나쁜 예: 같은 CV로 튜닝과 평가
grid_search = GridSearchCV(model, param_grid, cv=5)
grid_search.fit(X, y)
print(f"Best Score: {grid_search.best_score_}")  # 낙관적 편향!

# 좋은 예: Nested CV
outer_cv = KFold(n_splits=5, shuffle=True, random_state=42)
inner_cv = KFold(n_splits=3, shuffle=True, random_state=42)

nested_scores = cross_val_score(
    GridSearchCV(model, param_grid, cv=inner_cv),
    X, y, cv=outer_cv
)
print(f"Nested CV: {nested_scores.mean():.4f} ± {nested_scores.std():.4f}")

9. 같은 random_state로 데이터 분할과 CV 수행

# 나쁜 예: 같은 분할 재현
train_test_split(X, y, random_state=42)
KFold(shuffle=True, random_state=42)  # 같은 패턴

# 좋은 예: 다른 시드 사용하거나, 명시적으로 의도 표현
train_test_split(X, y, random_state=42)
KFold(shuffle=True, random_state=123)  # 다른 시드

참고 자료