머신러닝 모델을 만들었다고 끝은 아니다. KNN은 이웃을 몇 개 살펴볼지, 결정트리는 얼마나 깊게 성장시킬지, 랜덤 포레스트는 트리를 몇 개 만들지 선택해야 한다.
이처럼 학습 전에 개발자가 설정하는 값을 하이퍼파라미터(Hyperparameter)라고 한다.
좋은 설정을 찾기 위해 여러 조합을 비교하는 과정을 하이퍼파라미터 튜닝이라고 한다.
| 구분 | 의미 | 예시 |
|---|---|---|
| 모델 파라미터 | 데이터로부터 학습되는 값 | 선형회귀의 계수와 절편 |
| 하이퍼파라미터 | 학습 전에 설정하는 값 | KNN의 n_neighbors, 트리의 max_depth |
하이퍼파라미터를 조절할 때는 학습 데이터 성능이 가장 높은 설정을 선택해서는 안 된다. 학습 데이터에만 지나치게 잘 맞는 모델을 선택할 수 있기 때문이다.
교차검증은 학습 데이터를 여러 부분으로 나누어 번갈아 검증하는 방법이다.
Fold 1: [검증] [학습] [학습] [학습] [학습]
Fold 2: [학습] [검증] [학습] [학습] [학습]
Fold 3: [학습] [학습] [검증] [학습] [학습]
Fold 4: [학습] [학습] [학습] [검증] [학습]
Fold 5: [학습] [학습] [학습] [학습] [검증]
보통 각 Fold의 성능을 평균 내어 후보를 비교한다. 클래스 불균형이 있는 일반적인 이진 분류 문제에서는 클래스 비율을 고려하는 StratifiedKFold가 유용하다.
단, 사용자별로 여러 행이 있거나 시계열 데이터라면 같은 사용자 정보가 훈련과 검증에 동시에 나타나거나 미래가 과거에 섞이지 않도록 GroupKFold, TimeSeriesSplit 등 문제에 맞는 분할을 선택해야 한다.
Grid Search는 정해 둔 후보값의 가능한 모든 조합을 비교한다.
예를 들어:
param_grid = {
"max_depth": [3, 5, 8],
"min_samples_leaf": [1, 3, 5],
}
이 경우 3 × 3 = 9개 조합을 시험한다. 5-Fold 교차검증이면 모델 학습이 기본적으로 9 × 5 = 45회 수행된다. 기본 설정의 refit=True로 최종 선택 모델을 전체 학습 데이터에 다시 학습시키므로 추가 학습도 이루어진다.
외부 데이터 없이 웹 서비스 이벤트 성공 여부를 예측하는 인공 데이터를 사용한다.
from sklearn.datasets import make_classification
from sklearn.metrics import classification_report, f1_score
from sklearn.model_selection import GridSearchCV, train_test_split, StratifiedKFold
from sklearn.tree import DecisionTreeClassifier
X, y = make_classification(
n_samples=900,
n_features=10,
n_informative=6,
n_redundant=2,
weights=[0.75, 0.25],
random_state=42,
)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y
)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
search = GridSearchCV(
estimator=DecisionTreeClassifier(random_state=42),
param_grid={
"max_depth": [3, 5, 8, None],
"min_samples_leaf": [1, 3, 7],
},
scoring="f1",
cv=cv,
n_jobs=-1,
refit=True,
)
search.fit(X_train, y_train)
print("최적 파라미터:", search.best_params_)
print("교차검증 F1:", round(search.best_score_, 3))
pred = search.predict(X_test)
print("Test F1:", round(f1_score(y_test, pred), 3))
print(classification_report(y_test, pred, zero_division=0))
best_params_는 교차검증에서 가장 좋았던 파라미터 설정이고, best_score_는 그 설정의 평균 교차검증 점수다.
search.predict()는 refit=True일 때 최적 설정으로 전체 학습 데이터에 다시 학습된 모델을 이용한다.
후보가 많으면 모든 조합을 검사하는 Grid Search는 느려진다. RandomizedSearchCV는 설정 공간에서 정해진 횟수만큼 조합을 샘플링한다.
from scipy.stats import randint
from sklearn.model_selection import RandomizedSearchCV
random_search = RandomizedSearchCV(
estimator=DecisionTreeClassifier(random_state=42),
param_distributions={
"max_depth": randint(2, 21),
"min_samples_leaf": randint(1, 15),
"min_samples_split": randint(2, 21),
},
n_iter=25,
scoring="f1",
cv=cv,
random_state=42,
n_jobs=-1,
)
random_search.fit(X_train, y_train)
print("선택 설정:", random_search.best_params_)
print("CV F1:", round(random_search.best_score_, 3))
n_iter=25는 25개 설정을 시도한다는 뜻이다. 최적 조합을 반드시 찾는다는 보장은 없지만 탐색 공간이 클 때 계산량을 제한하기 좋다.
| 구분 | GridSearchCV | RandomizedSearchCV |
|---|---|---|
| 탐색 방식 | 설정한 조합 전부 | 설정 공간에서 지정 횟수 샘플링 |
| 핵심 인자 | param_grid | param_distributions, n_iter |
| 장점 | 작은 탐색 공간에서 빠짐없는 비교 | 큰 탐색 공간의 계산 예산 제어 |
| 주의할 점 | 조합 수가 급증 | 일부 좋은 조합을 놓칠 수 있음 |
어떤 방법이 절대적으로 우수하다고 보기보다 탐색 공간과 시간 예산을 보고 선택하면 된다.
KNN에는 스케일링이 중요하다. 여기서 실수하기 쉬운 코드가 있다.
# 피해야 하는 방식: 전체 X를 먼저 스케일링한 다음 교차검증
# X_scaled = StandardScaler().fit_transform(X)
# cross_val_score(KNeighborsClassifier(), X_scaled, y, cv=5)
전체 데이터로 평균과 표준편차를 먼저 구하면 검증에 사용될 데이터의 정보가 전처리에 반영된다.
올바른 방식은 스케일러도 교차검증 안에서 학습시키는 것이다.
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
pipeline = Pipeline([
("scaler", StandardScaler()),
("knn", KNeighborsClassifier()),
])
knn_search = GridSearchCV(
pipeline,
param_grid={
"knn__n_neighbors": [3, 5, 7, 9, 13],
"knn__weights": ["uniform", "distance"],
},
scoring="f1",
cv=cv,
n_jobs=-1,
)
knn_search.fit(X_train, y_train)
print(knn_search.best_params_)
knn__n_neighbors처럼 단계 이름 + 언더스코어 2개 + 파라미터 이름을 작성하면 Pipeline 내부 모델의 옵션을 탐색할 수 있다.
scoring을 제대로 선택하자GridSearchCV의 기본 scoring=None은 모델의 score()를 사용한다. 분류 모델은 보통 Accuracy, 회귀 모델은 보통 R²가 선택된다.
하지만 실제 목표에 맞게 명시하는 편이 좋다.
scoring="accuracy"
scoring="f1"
scoring="recall"
scoring="r2"
scoring="neg_mean_absolute_error"
오차는 작을수록 좋지만 Scikit-learn의 탐색기는 기본적으로 점수가 클수록 좋은 규칙을 사용한다. 그래서 MAE를 선택할 때는 neg_mean_absolute_error처럼 음수로 바꾼 점수를 사용한다.
# 이 값을 사용할 때 best_score_는 음수
# 사람에게 보여 줄 MAE는 부호를 다시 바꿔야 한다.
mae_cv = -search.best_score_ if search.scoring == "neg_mean_absolute_error" else None
R²는 음수가 나올 수도 있는 점수이며, Accuracy와 동일한 뜻이 아니다.
import pandas as pd
results = pd.DataFrame(search.cv_results_)
columns = [
"params",
"mean_test_score",
"std_test_score",
"rank_test_score",
]
print(results[columns].sort_values("rank_test_score").head())
평균 점수만 보지 말고 Fold 간 성능의 표준편차도 확인하면 설정이 안정적인지 판단하는 데 도움이 된다.
특히 모델 비교, 결측치 처리, 인코딩, 스케일링까지 모두 튜닝 범위에 들어갈 수 있다. 전처리와 모델을 함께 파이프라인화하면 여러 Fold에 일관된 규칙을 적용하기 쉽다.
best_params_는 선택된 설정, best_score_는 평균 교차검증 점수다.scoring으로 명시하는 편이 좋다.neg_mean_absolute_error처럼 부호 규칙에 주의한다.