Optuna — 하이퍼파라미터 자동 튜닝의 모든 것
ML/DL 모델의 최적 설정값을 자동으로 찾아주는 라이브러리
머신러닝 모델의 성능을 결정하는 것은 데이터와 알고리즘뿐만이 아닙니다. 하이퍼파라미터 — 모델이 학습하기 전에 사람이 미리 정해주는 설정값 — 가 모델 성능을 크게 좌우합니다. 이 글에서는 하이퍼파라미터가 무엇인지부터 시작해, Optuna가 어떻게 최적값을 자동으로 찾아주는지, 그리고 Alchemetric 프로젝트에서 어떻게 적용했는지를 초보자도 이해할 수 있도록 설명합니다.
1. 하이퍼파라미터가 뭔데?
파라미터 vs 하이퍼파라미터
머신러닝을 처음 접하면 "파라미터"와 "하이퍼파라미터"라는 비슷한 용어가 헷갈립니다. 간단히 구분하면 이렇습니다.
| 구분 | 파라미터 (Parameter) | 하이퍼파라미터 (Hyperparameter) |
| 누가 정하나 | 모델이 학습하면서 스스로 결정 | 사람이 학습 전에 미리 지정 |
| 예시 | 가중치(weight), 편향(bias) | 학습률, 트리 깊이, 레이어 수 |
| 데이터에서 학습? | O (역전파, 분기 등으로 자동 학습) | X (사람이 직접 세팅) |
비유: 요리
요리사가 레시피를 만든다고 생각해 보세요.
• 불 세기, 조리 시간 = 하이퍼파라미터 (요리사가 미리 결정)
• 맛 = 파라미터 (요리 과정에서 자연스럽게 결정되는 결과)
불 세기를 잘못 설정하면 아무리 좋은 재료도 태워버립니다. 하이퍼파라미터도 마찬가지입니다.
그래서 뭐가 문제인데?
RandomForest 모델을 학습한다고 합시다. 설정해야 할 하이퍼파라미터가 여러 개 있습니다.
# 트리 몇 개 만들지?
n_estimators = 300? 500?
# 트리 깊이는?
max_depth = 5? 15?
# 분기 최소 샘플 수는?
min_samples_split = 2? 20?
각 파라미터에 가능한 값이 10개씩만 있어도, 조합의 수는 10 × 10 × 10 = 1,000가지입니다. 파라미터가 5개라면? 100,000가지. 하나씩 직접 돌려보는 건 사실상 불가능합니다.
이 고통스러운 작업을 자동화해주는 라이브러리가 바로 Optuna입니다.
2. Optuna란?
한 줄로 요약하면 이렇습니다.
Optuna = "최적의 하이퍼파라미터를 자동으로 찾아주는 라이브러리"
일본의 Preferred Networks에서 개발했으며, Python 생태계에서 가장 널리 쓰이는 HPO(HyperParameter Optimization) 도구입니다.
탐색 전략 비교: Grid vs Random vs TPE(Optuna)
하이퍼파라미터를 찾는 방법은 크게 세 가지가 있습니다.
| 방법 | 전략 | 장점 | 단점 |
| Grid Search | 모든 조합을 전부 시도 | 빠짐없이 탐색 | 파라미터 많으면 시간 폭발 |
| Random Search | 랜덤으로 조합을 뽑아서 시도 | Grid보다 빠름 | 운에 의존, 방향성 없음 |
| TPE (Optuna) | "좋았던 근처에서 더 탐색" | 적은 시도로 최적값 수렴 | 개념이 약간 복잡 |
비유: 보물찾기
• Grid Search = 운동장 전체를 1m 간격으로 다 파본다 (확실하지만 느림)
• Random Search = 눈 감고 아무 데나 삽질 (빠르지만 운빨)
• TPE (Optuna) = "여기서 금 조각이 나왔으니까, 이 주변을 더 파보자!" (지능형)
Study와 Trial — Optuna의 기본 개념
Optuna에서 가장 먼저 이해해야 할 두 가지 용어가 있습니다.
| 용어 | 의미 | 비유 (낚시) |
| Study | 최적화 실험 전체를 관리하는 객체 | 오늘 낚시 전체 |
| Trial | 1번의 하이퍼파라미터 조합 시도 | 낚싯대 한 번 던지기 |
study.optimize(objective, n_trials=50)이라고 하면, Optuna가 50번 낚싯대를 던져서(50개의 trial) 가장 큰 물고기(최적 성능)를 찾아줍니다. 그리고 study.best_params로 최고의 결과를 낸 하이퍼파라미터 조합을 꺼낼 수 있습니다.
3. 핵심 API 3개만 알면 된다
Optuna의 핵심은 trial.suggest_* 메서드입니다. "이 파라미터의 값을 이 범위에서 제안해줘"라고 Optuna에게 요청하는 것이죠. 딱 3개만 기억하면 됩니다.
suggest_int — 정수 범위에서 제안
# "max_depth를 3~10 사이 정수에서 골라줘"
max_depth = trial.suggest_int("max_depth", 3, 10)
# step을 지정하면 50 단위로만 탐색
n_estimators = trial.suggest_int("n_estimators", 100, 500, step=50)
# 가능한 값: 100, 150, 200, 250, ..., 500
suggest_float — 실수 범위에서 제안
# "learning_rate를 0.01~0.3 사이에서 골라줘 (로그 스케일)"
lr = trial.suggest_float("learning_rate", 0.01, 0.3, log=True)
# log=True 가 핵심!
# 0.01 ~ 0.1 구간과 0.1 ~ 0.3 구간을 균등하게 탐색
# (안 쓰면 0.1~0.3에 치우침 — 작은 값이 중요한 lr에 필수)
suggest_categorical — 선택지에서 제안
# "batch_size를 32, 64, 128 중에서 골라줘"
batch_size = trial.suggest_categorical("batch_size", [32, 64, 128])
# 연속적이지 않은 값이나, 미리 정해둔 선택지가 있을 때 사용
전체 흐름 코드 예시
이 3가지 API를 조합하면, 아래와 같이 objective 함수를 만들 수 있습니다.
import optuna
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import f1_score
def objective(trial):
# 1. Optuna에게 파라미터 조합을 제안받는다
n_estimators = trial.suggest_int("n_estimators", 100, 500, step=50)
max_depth = trial.suggest_int("max_depth", 5, 20)
# 2. 제안받은 파라미터로 모델 생성 + 학습
model = RandomForestClassifier(
n_estimators=n_estimators,
max_depth=max_depth,
)
model.fit(X_train, y_train)
# 3. 검증 데이터로 성능 측정 후 반환
return f1_score(y_val, model.predict(X_val))
# Study 생성 (direction="maximize" = F1이 높을수록 좋다)
study = optuna.create_study(direction="maximize")
# 50번 시도
study.optimize(objective, n_trials=50)
# 최고 결과 확인
print(study.best_params) # {'n_estimators': 350, 'max_depth': 12}
print(study.best_value) # 0.7823
이 패턴만 기억하면 됩니다: objective 함수 정의 → Study 생성 → optimize 호출 → best_params 확인.
4. TPE — 어떻게 "똑똑하게" 탐색하나
Tree-structured Parzen Estimator
Optuna의 기본 탐색 알고리즘은 TPE (Tree-structured Parzen Estimator)입니다. 이름은 어렵지만, 핵심 아이디어는 매우 직관적입니다.
핵심 아이디어: 지금까지의 결과를 "좋은 그룹"과 "나쁜 그룹"으로 나누고,
"좋은 그룹에서 많이 나타나고 나쁜 그룹에서 적게 나타나는" 파라미터 영역을 더 집중적으로 탐색한다.
단계별로 살펴보겠습니다.
Step 1. 처음 몇 번은 랜덤으로 파라미터를 뽑아 시도합니다. (탐색의 시작점을 확보)
Step 2. 결과가 쌓이면, 성능 기준으로 상위 그룹(l)과 하위 그룹(g)으로 분리합니다.
Step 3. 상위 그룹에서 자주 등장하는 파라미터 영역을 확률 분포로 모델링합니다.
Step 4. 다음 trial에서는 l(x)/g(x) 비율이 높은 영역에서 파라미터를 샘플링합니다.
탐색 과정 시각화 (learning_rate 예시)
───────────────────────────────────────
0.01 ──────────────────────────── 0.30
Trial 1: lr=0.15 F1=0.62
Trial 2: lr=0.05 F1=0.71 ← 좋은 결과!
Trial 3: lr=0.22 F1=0.58
Trial 4: lr=0.07 F1=0.73 ← 좋은 결과!
TPE 판단: "0.03~0.10 구간이 좋은 결과가 많네!"
→ 다음 trial은 이 근처에서 집중 탐색
Trial 5: lr=0.04 F1=0.75 ← 더 좋은 결과!
Trial 6: lr=0.06 F1=0.77 ← 최적!
이렇게 TPE는 탐색(exploration)과 활용(exploitation)의 균형을 자동으로 맞춥니다. 처음에는 넓게 탐색하다가, 좋은 영역을 발견하면 그 근처를 집중적으로 파고듭니다. Random Search가 50번 시도해서 찾는 결과를, TPE는 20번 만에 찾아낼 수 있습니다.
5. Pruning — 나쁜 시도는 조기 중단
딥러닝 모델은 한 번의 trial에 수십 epoch을 학습해야 합니다. 만약 5번째 epoch에서 이미 성능이 다른 trial들보다 현저히 낮다면? 나머지 25 epoch을 더 학습하는 건 시간 낭비입니다.
Optuna의 Pruning은 이런 "가망 없는 trial"을 중간에 자동으로 중단시켜 줍니다.
MedianPruner
Alchemetric 프로젝트에서 사용하는 Pruner입니다. 동작 원리는 간단합니다.
규칙: 현재 trial의 중간 성능이, 같은 시점에서의 다른 완료된 trial들의 중간값(median)보다 낮으면 즉시 종료한다.
# Alchemetric DL 튜너의 실제 설정
study = optuna.create_study(
direction="maximize",
pruner=optuna.pruners.MedianPruner(
n_startup_trials=5, # 처음 5개 trial은 끝까지 실행 (기준 확보)
n_warmup_steps=10, # 각 trial의 처음 10 epoch은 pruning 하지 않음
),
)
그리고 objective 함수 안에서 매 epoch마다 Optuna에게 현재 성능을 보고합니다.
for epoch in range(30):
# ... 학습 코드 ...
# Optuna에게 현재 epoch의 F1 점수 보고
trial.report(f1, epoch)
# Optuna가 "이 trial은 가망 없다"고 판단하면 중단
if trial.should_prune():
raise optuna.TrialPruned()
이렇게 하면 20개 trial을 돌려도, 실제로 전체 30 epoch을 다 돌리는 trial은 절반 이하입니다. 나머지는 10~15 epoch쯤에서 잘려나가므로, 전체 튜닝 시간이 크게 단축됩니다.
참고: ML 모델(RandomForest, XGBoost, LightGBM)은 학습 자체가 빠르기 때문에 Pruning을 적용하지 않습니다. Pruning은 trial 하나가 오래 걸리는 딥러닝 모델에서 특히 효과적입니다.
6. 프로젝트 적용 (Alchemetric)
이제 Optuna가 Alchemetric 프로젝트에서 어떻게 사용되는지 구체적으로 살펴보겠습니다.
YAML 기반 탐색 공간 선언
Alchemetric의 가장 큰 특징은 탐색 공간을 YAML 파일에 선언한다는 점입니다. 코드를 수정하지 않고도 탐색 범위를 조절할 수 있습니다.
# app/ml/ml_config.yaml
algorithms:
xgboost:
classifier: xgboost.XGBClassifier
defaults:
random_state: 42
eval_metric: "logloss"
verbosity: 0
search_space:
n_estimators: { type: int, low: 100, high: 500, step: 50 }
max_depth: { type: int, low: 3, high: 10 }
learning_rate: { type: float, low: 0.01, high: 0.3, log: true }
subsample: { type: float, low: 0.6, high: 1.0 }
colsample_bytree: { type: float, low: 0.6, high: 1.0 }
scale_pos_weight: { type: float, low: 0.5, high: 2.0 }
YAML에서 defaults는 학습 시 항상 고정되는 기본값이고, search_space는 Optuna가 탐색할 파라미터 범위입니다. "XGBoost의 learning_rate 탐색 범위를 0.001~0.1로 좁히고 싶다" 하면 YAML만 수정하면 됩니다.
YAML을 읽어 자동으로 suggest_* 호출
YAML의 search_space를 읽어서 자동으로 Optuna API를 호출하는 함수가 있습니다.
# app/ml/tuner.py - 실제 프로젝트 코드
def _build_params_from_space(trial, algorithm: str) -> dict:
"""YAML search_space를 읽어 Optuna trial.suggest_* 호출"""
space = get_search_space(algorithm) # YAML에서 탐색 공간 로드
params = {}
for name, spec in space.items():
if spec["type"] == "int":
kwargs = {"name": name, "low": spec["low"], "high": spec["high"]}
if "step" in spec:
kwargs["step"] = spec["step"]
params[name] = trial.suggest_int(**kwargs)
elif spec["type"] == "float":
kwargs = {"name": name, "low": spec["low"], "high": spec["high"]}
if spec.get("log"):
kwargs["log"] = True
params[name] = trial.suggest_float(**kwargs)
return params
이 함수 덕분에 어떤 알고리즘이든 동일한 구조로 튜닝할 수 있습니다. 새 알고리즘을 추가할 때도 YAML에 설정만 추가하면, 코드 변경 없이 바로 Optuna 튜닝이 적용됩니다.
제네릭 Objective 함수
ML 모델용 objective 함수도 알고리즘에 관계없이 동작합니다.
# app/ml/tuner.py - 실제 프로젝트 코드
def _generic_objective(trial, algorithm, X_train, y_train, X_val, y_val):
"""모든 알고리즘에 대응하는 제네릭 objective"""
# YAML에서 탐색 파라미터 생성
params = _build_params_from_space(trial, algorithm)
# YAML에서 기본값 병합
defaults = get_algorithm_defaults(algorithm).copy()
defaults.update(params)
# 분류기 생성 + 학습
clf_class = get_classifier_class(algorithm)
model = clf_class(**defaults)
model.fit(X_train, y_train)
y_pred = model.predict(X_val)
return f1_score(y_val, y_pred, average="binary")
algorithm에 "random_forest"를 넣든 "xgboost"를 넣든 "lightgbm"을 넣든, 동일한 함수가 동작합니다. 이것이 가능한 이유는 YAML에 알고리즘별 설정이 선언되어 있고, get_classifier_class()가 YAML의 classifier 경로를 동적으로 import하기 때문입니다.
ML vs DL — Trial 횟수의 차이
| 구분 | 기본 Trial 수 | Pruning | 이유 |
| ML (RF, XGB, LGBM) | 50 trials | 미적용 | trial당 학습이 빠름 (수 초) |
| DL (LSTM, Transformer) | 20 trials | MedianPruner 적용 | trial당 비용이 높음 (수 분, 축소 30 epoch) |
DL 모델은 한 번의 trial에 30 epoch(축소 학습)을 돌려야 하므로, trial 수를 20으로 제한하되 MedianPruner로 효율을 높입니다. ML 모델은 trial 하나가 수 초 안에 끝나므로, 50번을 돌려도 전체 튜닝 시간이 몇 분 수준입니다.
알고리즘별 탐색 파라미터 한눈에 보기
Alchemetric에서 Optuna가 탐색하는 파라미터를 알고리즘별로 정리하면 다음과 같습니다.
| 알고리즘 | 파라미터 | 타입 | 탐색 범위 |
| Random Forest | n_estimators | int (step=50) | 100 ~ 500 |
| max_depth | int | 5 ~ 20 | |
| min_samples_split | int | 2 ~ 20 | |
| min_samples_leaf | int | 1 ~ 10 | |
| XGBoost | n_estimators | int (step=50) | 100 ~ 500 |
| max_depth | int | 3 ~ 10 | |
| learning_rate | float (log) | 0.01 ~ 0.3 | |
| subsample | float | 0.6 ~ 1.0 | |
| colsample_bytree | float | 0.6 ~ 1.0 | |
| scale_pos_weight | float | 0.5 ~ 2.0 | |
| LightGBM | n_estimators | int (step=50) | 100 ~ 500 |
| num_leaves | int | 15 ~ 63 | |
| learning_rate | float (log) | 0.01 ~ 0.3 | |
| feature_fraction | float | 0.6 ~ 1.0 | |
| bagging_fraction | float | 0.6 ~ 1.0 | |
| bagging_freq | int | 1 ~ 7 | |
| LSTM | seq_len | int (step=5) | 10 ~ 40 |
| hidden_size | categorical | [64, 128, 256] | |
| num_layers | int | 1 ~ 3 | |
| dropout | float | 0.1 ~ 0.5 | |
| lr | float (log) | 0.0001 ~ 0.01 | |
| batch_size | categorical | [32, 64, 128] | |
| Transformer | seq_len | int (step=5) | 10 ~ 40 |
| d_model | categorical | [64, 128, 256] | |
| nhead | categorical | [4, 8] | |
| num_layers | int | 2 ~ 6 | |
| dim_feedforward | categorical | [128, 256, 512] | |
| dropout | float | 0.1 ~ 0.5 | |
| lr | float (log) | 0.0001 ~ 0.01 | |
| batch_size | categorical | [32, 64, 128] |
전체 흐름 — 튜닝에서 본 학습까지
Alchemetric의 학습 파이프라인에서 Optuna가 어떤 위치에 있는지 보겠습니다.
# app/ml/trainer.py - 학습 파이프라인 (핵심 부분 발췌)
# Step 6. Optuna 하이퍼파라미터 튜닝
best_params = {}
if optuna_trials > 0:
tune_result = tune_hyperparameters(
algorithm, X_train, y_train, X_val, y_val,
n_trials=optuna_trials, # ML: 50, DL: 20
)
best_params = tune_result["best_params"]
# Step 7. 최적 파라미터로 본 학습
model = _get_classifier(algorithm, best_params)
model.fit(X_train, y_train)
흐름을 정리하면 이렇습니다.
1. Feature Store에서 데이터 로드
2. 시계열 기반으로 train / val 분할
3. 스케일링 (RobustScaler)
4. Optuna 튜닝 — N번의 trial로 최적 파라미터 탐색
5. study.best_params로 최적 파라미터 추출
6. 최적 파라미터로 본 학습 실행
7. 모델 저장 + DB에 메타데이터 기록
DL 튜닝의 특수 처리
DL 모델(LSTM, Transformer)에는 ML과 다른 점이 몇 가지 있습니다.
| 항목 | ML 튜닝 | DL 튜닝 |
| 튜닝 시 학습 epoch | 해당 없음 (fit 1회) | 축소 30 epoch |
| 본 학습 epoch | 해당 없음 | 최대 100 epoch (Early Stopping) |
| Pruning | 미적용 | MedianPruner (startup=5, warmup=10) |
| 불가능한 조합 처리 | 해당 없음 | TrialPruned (d_model % nhead != 0) |
Transformer 모델에서는 d_model이 nhead로 나누어 떨어지지 않으면 아키텍처가 작동하지 않습니다. 이런 불가능한 조합은 raise optuna.TrialPruned()로 즉시 건너뛰도록 처리합니다.
# app/ml/deep_learning/dl_tuner.py - 실제 프로젝트 코드
if algorithm == "transformer":
params["d_model"] = trial.suggest_categorical("d_model", [64, 128, 256])
params["nhead"] = trial.suggest_categorical("nhead", [4, 8])
# d_model이 nhead로 나누어지지 않으면 prune
if params["d_model"] % params["nhead"] != 0:
raise optuna.TrialPruned()
정리
Alchemetric의 Optuna 적용을 한 문장으로 요약하면 이렇습니다.
YAML에 탐색 공간을 선언하고, 제네릭 objective 함수로 어떤 알고리즘이든 동일한 구조로 튜닝한다.
ML은 빠르니까 50번, DL은 비싸니까 20번 + Pruning. 튜닝이 끝나면 study.best_params를 꺼내서 본 학습에 사용한다.
수동으로 수백 가지 조합을 시도하던 시대는 지났습니다. Optuna를 사용하면 적은 시도로 높은 성능의 모델을 얻을 수 있고, YAML 기반 설정과 결합하면 코드 변경 없이 탐색 전략을 유연하게 조절할 수 있습니다.
'Project > 🚀 AI Finance 2026' 카테고리의 다른 글
| #25 [모듈4-1] PyTorch & Deep Learning (0) | 2026.03.18 |
|---|---|
| #24 [모듈4] Deep Learning (0) | 2026.03.18 |
| #22 [모듈3-1] scikit-learn, XGBoost, LightGBM — 3대 ML 라이브러리 (0) | 2026.03.13 |
| #21 [모듈3] Machine Learning (0) | 2026.03.13 |
| #20 [모듈2-4] 피어 비교 피처 — 섹터 상대강도 + 복합 인덱스 (0) | 2026.03.13 |