포스트

머신러닝 기초 (5) - 교차검증과 하이퍼파라미터 튜닝

단일 train/valid 분할이 왜 불안정한지에서 출발해 k-겹 교차검증, 하이퍼파라미터와 파라미터의 구분, GridSearchCV와 RandomizedSearchCV로 탐색하는 법, 그리고 테스트셋을 튜닝에 쓰면 왜 누수인지를 정리한 5편입니다.

머신러닝 기초 (5) - 교차검증과 하이퍼파라미터 튜닝

머신러닝 기초 시리즈의 5편입니다. 4편의 “과대적합과 일반화: train/valid/test와 편향-분산”에 이어집니다.

단일 분할의 문제

4편에서 데이터를 train/valid/test 셋으로 나눴습니다. 모델은 train으로 배우고, valid로 성능을 보며 결정을 내리고, test는 마지막에 딱 한 번만 씁니다. 여기까지는 옳습니다. 문제는 valid를 한 번만 잘라 쓴다는 데 있습니다.

검증 점수는 결국 “valid에 우연히 어떤 행들이 들어갔는가”에 대한 하나의 표본입니다. 운 좋게 쉬운 행들이 valid에 몰리면 점수가 높게 나오고, 어려운 행들이 몰리면 낮게 나옵니다. 데이터가 작을수록 이 흔들림은 커집니다. 즉 단일 분할로 얻은 점수 하나만 보고는 그 값이 대표값인지 운 좋은 값인지 구분할 수 없습니다.

여기에 더해, valid로 떼어둔 행들은 모델 학습에 한 번도 쓰이지 못합니다. 데이터가 귀할 때 이건 그냥 낭비입니다. 두 문제, 곧 추정의 불안정함과 데이터 낭비를 동시에 푸는 방법이 교차검증입니다.

k-겹 교차검증

k-겹 교차검증(k-fold cross-validation)의 발상은 단순합니다. 데이터를 k개의 조각(폴드)으로 나누고, 그중 하나를 검증용으로, 나머지 k-1개를 학습용으로 씁니다. 검증에 쓰는 폴드를 바꿔가며 이걸 k번 반복합니다. k=5라면 이렇게 돕니다.

라운드학습에 쓰는 폴드검증에 쓰는 폴드
12, 3, 4, 51
21, 3, 4, 52
31, 2, 4, 53
41, 2, 3, 54
51, 2, 3, 45

결과로 검증 점수가 k개 나옵니다. 이 k개를 평균한 값이 그 모델의 성능 추정치이고, 표준편차는 그 추정이 얼마나 흔들리는지를 알려줍니다. 핵심은 두 가지입니다. 모든 행이 정확히 한 번씩 검증에 쓰이고, 모든 행이 언젠가는 학습에도 쓰입니다. 단일 분할의 두 문제가 함께 풀립니다.

scikit-learncross_val_score가 이 과정을 한 줄로 처리합니다. 먼저 4편의 규칙대로 test를 떼어 봉인해 두고, 나머지로만 교차검증을 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.tree import DecisionTreeClassifier

X, y = load_breast_cancer(return_X_y=True)   # 유방암 진단 이진분류, 569행 30열

# test는 지금 떼어 봉인한다. 튜닝이 끝날 때까지 건드리지 않는다.
X_tr, X_test, y_tr, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42)

model = DecisionTreeClassifier(random_state=42)
cv_scores = cross_val_score(model, X_tr, y_tr, cv=5, scoring="accuracy")

cv_scores          # 폴드별 정확도 = array([0.912, 0.901, 0.901, 0.89 , 0.945])
cv_scores.mean()   # 0.910
cv_scores.std()    # 0.019

폴드별 점수가 0.89에서 0.945까지 흩어져 있습니다. 이 숫자 하나하나가 곧 “한 번의 분할로 얻는 검증 점수”입니다. 단일 분할이었다면 이 중 하나만 보고 성능을 보고했을 것이고, 하필 0.89 폴드에 걸렸다면 이 모델을 실제보다 나쁘게 판단했을 것입니다. 5개를 평균하면 “대략 0.91, ±0.02” 라는 훨씬 믿을 만한 진술이 됩니다. 이게 교차검증이 주는 것입니다. 점수 하나가 아니라, 점수와 그 불확실성까지.

cross_val_score는 여기서 손댈 곳이 몇 군데 있습니다.

  • cv=5: 폴드 수. 5나 10을 흔히 씁니다. 크게 잡을수록 추정은 안정되지만 그만큼 학습을 여러 번 반복해야 합니다.
  • scoring="accuracy": 무엇으로 점수를 매길지. 분류는 "accuracy""roc_auc", 회귀는 "r2""neg_mean_squared_error" 등을 씁니다. 평가 지표 자체는 7편과 8편에서 다룹니다.

분류 문제에서 cv에 정수를 넘기면 scikit-learn은 자동으로 계층별(stratified) 분할을 씁니다. 각 폴드의 클래스 비율을 전체와 같게 맞춰준다는 뜻입니다. 클래스가 불균형할 때 어떤 폴드에는 양성이 거의 안 들어가는 사고를 막아줍니다. 회귀거나 순서가 의미를 가지는 데이터라면 KFold(shuffle=True, random_state=42)로 직접 섞어 넘기는 것이 안전합니다.

교차검증의 대가는 계산량입니다. k겹이면 학습을 k번 하므로, 단일 분할보다 대략 k배 느립니다. 데이터가 아주 크거나 학습이 무거우면 이 비용이 부담이 되지만, 대부분의 경우 얻는 신뢰도가 그 값을 합니다.

하이퍼파라미터와 파라미터는 다르다

교차검증으로 “이 모델이 얼마나 좋은가”를 잴 수 있게 됐습니다. 그런데 애초에 “이 모델”을 어떻게 정할까요. 여기서 파라미터하이퍼파라미터를 구분해야 합니다.

  • 파라미터(parameter): 모델이 학습 데이터로부터 fit 과정에서 스스로 알아내는 값. 선형 회귀의 계수와 절편, 로지스틱 회귀의 가중치, 결정트리가 고른 분기 기준이 여기 해당합니다. 우리가 정하지 않습니다.
  • 하이퍼파라미터(hyperparameter): 학습이 시작되기 전에 우리가 정해서 넣어주는 값. 트리의 max_depth, 이웃 수 n_neighbors, 규제 강도 alpha(6편) 같은 것들입니다. 모델의 표현력(용량)이나 학습 방식 자체를 통제합니다.

scikit-learn에는 이 둘을 구분하는 관례가 코드에 그대로 드러납니다. 하이퍼파라미터는 생성자 인자로 넣고, 학습으로 얻어진 파라미터는 끝에 밑줄이 붙은 속성(coef_, feature_importances_ 등)으로 나옵니다.

1
2
3
4
m = DecisionTreeClassifier(max_depth=4, random_state=42)  # 하이퍼파라미터: 학습 전에 지정
m.get_params()          # 지금 설정된 하이퍼파라미터 목록
m.fit(X_tr, y_tr)
m.tree_.node_count      # 파라미터: fit이 데이터로부터 만들어낸 트리 구조

이 구분이 왜 중요한가. 하이퍼파라미터는 학습 데이터의 성능만 보고 고를 수 없기 때문입니다. 4편에서 봤듯, 트리의 깊이를 키우면 train 성능은 거의 항상 좋아집니다. 그리고 어느 순간부터 과대적합입니다. 즉 train 점수는 하이퍼파라미터를 고르는 기준이 될 수 없습니다. 우리에게 필요한 건 학습에 쓰지 않은 데이터에서의 성능 추정이고, 그걸 안정적으로 주는 것이 방금 본 교차검증입니다. 하이퍼파라미터 탐색은 결국 “여러 후보를 교차검증으로 비교해 가장 좋은 것을 고르는 일”입니다.

결정트리 자체는 10편에서 다룹니다. 여기서는 max_depth 같은 뚜렷한 하이퍼파라미터를 가진 모델이 필요해 도구로 빌려 쓸 뿐이고, 아래 내용은 어떤 모델에도 그대로 적용됩니다.

그리드 서치: 모든 조합을 훑는다

GridSearchCV는 우리가 후보 값을 표로 적어주면, 그 모든 조합을 각각 교차검증으로 평가하고 가장 좋은 조합을 골라줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from sklearn.model_selection import GridSearchCV

param_grid = {
    "max_depth": [2, 3, 4, 5, 6, None],
    "min_samples_leaf": [1, 5, 10, 20],
}   # 6 × 4 = 24가지 조합

grid = GridSearchCV(
    DecisionTreeClassifier(random_state=42),
    param_grid, cv=5, scoring="accuracy", n_jobs=-1)
grid.fit(X_tr, y_tr)   # 24조합 × 5폴드 = 120번 학습

grid.best_params_      # {'max_depth': 4, 'min_samples_leaf': 1}
grid.best_score_       # 최적 조합의 교차검증 평균 점수 = 0.938

grid.fit이 한 일을 풀어보면 이렇습니다. 24개 조합 각각에 대해 5겹 교차검증을 돌려 평균 점수를 내고(총 120번의 학습), 그중 평균이 가장 높은 조합을 best_params_에 담습니다. 그리고 기본값 refit=True에 따라, 그 최적 조합으로 X_tr 전체를 다시 학습한 모델을 best_estimator_로 준비해 둡니다. 이후 grid.predict(...)는 이 최종 모델을 씁니다. 조합별 상세 점수가 필요하면 grid.cv_results_에 다 들어 있습니다.

기본 트리의 교차검증 점수가 0.910이었는데(앞 절), 깊이를 4로 제한하니 0.938로 올랐습니다. max_depth=None(제한 없음)이 아니라 4가 뽑혔다는 것 자체가 4편의 이야기를 그대로 보여줍니다. 용량을 적당히 줄이는 편이 일반화에 낫다는 이야기입니다.

랜덤 서치: 조합이 많을 때

그리드 서치의 약점은 조합 폭발입니다. 하이퍼파라미터가 3개고 각각 후보가 10개면 벌써 1,000조합, 거기에 5겹이면 5,000번 학습입니다. 게다가 대부분의 경우 하이퍼파라미터마다 중요도가 다릅니다. 어떤 것은 성능을 크게 좌우하고 어떤 것은 거의 영향이 없는데, 그리드 서치는 덜 중요한 축에도 똑같이 촘촘하게 시간을 씁니다.

RandomizedSearchCV는 모든 조합을 훑는 대신, 지정한 분포에서 조합을 무작위로 n번 뽑아 평가합니다. 시도 횟수(n_iter)로 계산 예산을 직접 정할 수 있고, 넓은 범위를 성기게 훑기 때문에 중요한 하이퍼파라미터의 좋은 값을 더 빨리 만나는 경향이 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import randint

param_dist = {
    "max_depth": randint(2, 20),        # 값 목록이 아니라 분포를 넘긴다
    "min_samples_leaf": randint(1, 30),
}

rand = RandomizedSearchCV(
    DecisionTreeClassifier(random_state=42),
    param_dist, n_iter=20, cv=5,        # 20번만 뽑아서 시도
    scoring="accuracy", random_state=42, n_jobs=-1)
rand.fit(X_tr, y_tr)

rand.best_params_      # {'max_depth': 4, 'min_samples_leaf': 5}
rand.best_score_       # 0.934

20번만 뽑고도 그리드 서치와 거의 같은 성능(0.934 vs 0.938)에 도달했습니다. 후보를 나열하기 어려운 연속형 하이퍼파라미터(예: 규제 강도)에는 scipy.statsuniform, loguniform 같은 분포를 넘길 수 있습니다. 실무 감각은 대체로 이렇습니다. 후보가 적고 촘촘히 다 보고 싶으면 그리드, 공간이 넓어 예산을 정해두고 훑고 싶으면 랜덤.

마지막 한 번: test로 튜닝하면 누수다

지금까지 test는 봉인된 채였습니다. 이제 튜닝이 끝났으니, 고른 모델을 test로 딱 한 번 평가합니다.

1
2
3
4
5
6
best = grid.best_estimator_
best.score(X_test, y_test)    # 튜닝한 모델의 최종 test 성능 = 0.939

# 비교: 튜닝 안 한 기본 트리
DecisionTreeClassifier(random_state=42).fit(X_tr, y_tr).score(X_test, y_test)
# 0.912

기본 트리 0.912에서 튜닝한 트리 0.939로, test에서도 개선이 확인됩니다. 여기서 반드시 지켜야 할 규칙이 있습니다.

test 점수를 보고 하이퍼파라미터를 바꾸는 순간, 그 test 점수는 더 이상 일반화 성능의 정직한 추정치가 아니다. 여러 설정을 test에 돌려보고 그중 제일 좋은 걸 고르는 것은, test에 대고 튜닝하는 것이고 곧 누수(leakage)다. test는 모든 결정이 끝난 뒤 딱 한 번만 본다.

왜 누수인가. test의 존재 이유는 “본 적 없는 데이터에서의 성능”을 추정하는 것입니다. 그런데 test 점수를 기준으로 무언가를 고르는 순간, test는 더 이상 본 적 없는 데이터가 아니라 우리가 최적화한 대상이 됩니다. 여러 조합 중 test 점수가 가장 높은 것을 고르면 그 값에는 우연히 test에 잘 맞은 운까지 섞여 들어가, 실제 성능보다 낙관적인 숫자가 됩니다. 그래서 하이퍼파라미터 선택은 train 안에서 교차검증으로만 하고(GridSearchCV가 정확히 그렇게 합니다), test는 마지막 확인용으로 아껴야 합니다.

교차검증 평균(best_score_ 0.938)과 최종 test 점수(0.939)가 이번엔 가깝게 나왔습니다. 다만 best_score_는 이미 “많은 후보 중 최고”를 고른 값이라 살짝 낙관적일 수 있고, 그래서 독립적인 test 확인이 여전히 필요합니다. 데이터 누수의 여러 형태와 그것을 파이프라인으로 원천 차단하는 방법은 13편(데이터 누수와 파이프라인)에서 따로 정리합니다.

정리

개념한 줄 요약
단일 분할의 한계검증 점수가 어떤 행이 valid에 들었는지에 좌우돼 불안정하고, valid 행은 학습에 못 쓴다
k-겹 교차검증검증 폴드를 바꿔가며 k번 평가. 모든 행을 한 번씩 검증에, 언젠가 학습에도 쓴다
cross_val_score점수 k개를 반환. 평균은 성능 추정치, 표준편차는 그 불확실성
파라미터fit이 데이터로 알아내는 값(계수, 분기 기준). 밑줄 속성(coef_)
하이퍼파라미터학습 전 우리가 정하는 값(max_depth, alpha). 생성자 인자, 교차검증으로 고른다
GridSearchCV후보 조합을 모두 교차검증. 후보가 적을 때
RandomizedSearchCV분포에서 n번 무작위로 뽑아 평가. 공간이 넓을 때 예산을 정해 훑는다
test 누수test로 튜닝하면 추정이 낙관 편향된다. test는 마지막에 딱 한 번

다음 편에서는 하이퍼파라미터로 모델의 용량을 통제하는 대표적 방법, 곧 손실에 벌점을 더해 계수를 눌러주는 규제를 Ridge와 Lasso로 봅니다.

다음 글: 머신러닝 기초 (6) - 규제: Ridge와 Lasso

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.