교차 검증 (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])}")
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) # 다른 시드