Optuna — DL & RL Tuning
딥러닝과 강화학습에서 Optuna를 쓰는 법은 ML과 다르다
이전 글(M3 Optuna)에서 ML 모델의 하이퍼파라미터 튜닝을 다뤘다. RandomForest, XGBoost, LightGBM은 model.fit() 한 번이면 끝나기 때문에 50번의 trial도 부담이 없었다. 하지만 딥러닝(LSTM, Transformer)과 강화학습(DQN, PPO)은 이야기가 다르다. trial 하나에 수십 epoch 또는 수만 timestep이 필요하기 때문에 탐색 전략 자체를 바꿔야 한다. 이 글에서는 Alchemetric 프로젝트의 실제 코드를 보며, DL/RL에 맞는 Optuna 전략을 설명한다.
목차
1. M3 Optuna 복습 — ML 튜닝은 쉬웠다
이전 글에서 다룬 ML 튜닝의 핵심을 한 문장으로 요약하면 이렇다.
objective 함수 안에서 suggest_*로 파라미터를 제안받고, fit() + f1_score()로 성능을 반환한다.
Optuna(TPE)가 50번 반복하며 최적 조합을 수렴시킨다.
ML 튜닝이 쉬운 이유는 단순하다. trial 하나가 빠르다.
# ML objective (복습) def objective(trial): params = _build_params_from_space(trial, "xgboost") model = XGBClassifier(**params) model.fit(X_train, y_train) # 수 초면 끝 return f1_score(y_val, model.predict(X_val)) study.optimize(objective, n_trials=50) # 50번 해도 수 분
XGBoost의 fit()은 보통 2~5초면 끝난다. 50번 반복해도 전체 튜닝 시간이 5분을 넘지 않는다. Pruning이 필요 없다 — trial을 중간에 끊을 "중간 단계"가 없기 때문이다.
하지만 DL과 RL은 trial 하나가 수 분에서 수십 분이 걸린다. 같은 전략을 쓰면 안 된다.
2. DL 튜닝의 어려움 — 왜 trial당 비용이 높은가
ML vs DL: 학습 구조의 차이
ML 모델은 fit() 한 번이면 학습이 완료된다. 반면 DL 모델은 epoch 단위로 반복 학습한다. 본 학습에서는 100 epoch까지 돌리되 Early Stopping으로 조기 종료하는데, 튜닝에서도 100 epoch을 돌리면 trial 하나에 수 분이 걸린다.
| 항목 | ML (XGBoost) | DL (LSTM) |
| trial 1회 소요 | 2~5초 | 30~120초 (30 epoch 기준) |
| 50 trials 총 시간 | ~5분 | 25~100분 |
| 중간 관찰 가능? | X (한 번에 완료) | O (매 epoch) |
Alchemetric의 해법: 3가지 절감 전략
Alchemetric 프로젝트의 dl_tuner.py는 3가지 방법으로 비용을 줄인다.
1) 축소 epoch — 본 학습은 100 epoch이지만, 튜닝은 30 epoch(_TUNE_MAX_EPOCHS = 30)만 돌린다. 30 epoch이면 "이 조합이 가망 있는지"를 판단하기에 충분하다.
2) trial 수 축소 — ML의 50 trials 대신 20 trials로 제한한다. 적은 trial이지만 TPE가 효율적으로 탐색하므로, 좋은 영역을 놓칠 확률은 낮다.
3) MedianPruner — 중간 epoch에서 성능이 나쁜 trial을 조기 종료한다. 30 epoch 중 10~15 epoch 만에 잘려나가는 trial이 절반 이상이므로 전체 시간이 대폭 줄어든다.
# app/ml/deep_learning/dl_tuner.py _TUNE_MAX_EPOCHS = 30 # 본 학습 100 epoch → 튜닝 30 epoch study = optuna.create_study( direction="maximize", pruner=optuna.pruners.MedianPruner( n_startup_trials=5, # 처음 5 trial은 끝까지 실행 n_warmup_steps=10, # 각 trial의 처음 10 epoch은 보호 ), ) study.optimize(objective, n_trials=20)
비유: 오디션
100곡을 다 부르게 하지 않는다. 30초 미리듣기(30 epoch)로 가능성을 판단하고, 중간에 음이탈하면 다음 참가자로 넘어간다(Pruning). 최종 합격자에게만 풀 앨범(100 epoch)을 녹음시킨다.
3. LSTM 탐색 공간 설계
DL 모델에서 탐색해야 할 파라미터는 아키텍처 파라미터와 학습 파라미터 두 종류다. ML 모델은 아키텍처를 건드릴 일이 거의 없지만, DL은 "레이어를 몇 개 쌓을지", "각 레이어의 크기를 얼마로 할지"가 성능을 크게 좌우한다.
LSTM 탐색 파라미터 정리
| 파라미터 | 타입 | 범위 | 역할 |
| seq_len | int (step=5) | 10 ~ 40 | 과거 몇 일치 데이터를 볼지 |
| hidden_size | categorical | [64, 128, 256] | LSTM 은닉 상태 크기 (모델 용량) |
| num_layers | int | 1 ~ 3 | LSTM 레이어 수 (깊이) |
| dropout | float | 0.1 ~ 0.5 | 과적합 방지 |
| lr | float (log) | 1e-4 ~ 1e-2 | 학습률 |
| batch_size | categorical | [32, 64, 128] | 한 번에 학습하는 샘플 수 |
실제 코드
# app/ml/deep_learning/dl_tuner.py — LSTM 탐색 공간 # 공통 탐색 (LSTM, Transformer 모두) seq_len = trial.suggest_int("seq_len", 10, 40, step=5) lr = trial.suggest_float("lr", 1e-4, 1e-2, log=True) batch_size = trial.suggest_categorical("batch_size", [32, 64, 128]) dropout = trial.suggest_float("dropout", 0.1, 0.5) # LSTM 전용 if algorithm == "lstm": params["hidden_size"] = trial.suggest_categorical( "hidden_size", [64, 128, 256], ) params["num_layers"] = trial.suggest_int("num_layers", 1, 3)
왜 이렇게 설계했나
seq_len은 step=5. 시퀀스 길이를 1 단위로 바꿔봐야 의미 있는 차이가 나지 않는다. 10, 15, 20, 25, 30, 35, 40 — 7가지면 충분하다.
hidden_size는 categorical. 연속 값(예: 73, 149)은 GPU 메모리 정렬에 비효율적이다. 2의 거듭제곱인 64, 128, 256이 표준이다.
lr은 log 스케일. 학습률은 0.0001과 0.001의 차이가 0.005와 0.006의 차이보다 훨씬 크다. log=True로 작은 구간을 더 밀도 있게 탐색한다.
num_layers는 1~3. LSTM은 레이어를 4개 이상 쌓으면 오히려 학습이 불안정해지고, 주가 데이터의 크기를 고려하면 3이 상한이다.
4. Transformer 탐색 공간 — d_model % nhead 제약
Transformer는 LSTM보다 탐색해야 할 아키텍처 파라미터가 많다. 그리고 파라미터 간 제약 조건이 존재한다.
Transformer 탐색 파라미터
| 파라미터 | 타입 | 범위 | 역할 |
| d_model | categorical | [64, 128, 256] | 임베딩 차원 |
| nhead | categorical | [4, 8] | 어텐션 헤드 수 |
| num_layers | int | 2 ~ 6 | 인코더 레이어 수 |
| dim_feedforward | categorical | [128, 256, 512] | FFN 은닉 크기 |
| dropout | float | 0.1 ~ 0.5 | 과적합 방지 |
| lr | float (log) | 1e-4 ~ 1e-2 | 학습률 |
| batch_size | categorical | [32, 64, 128] | 배치 크기 |
d_model % nhead == 0 제약
Multi-Head Attention은 d_model을 nhead개로 균등 분할한다. 따라서 d_model이 nhead로 나누어 떨어지지 않으면 PyTorch가 에러를 던진다. 예를 들어 d_model=64, nhead=8이면 각 헤드가 8차원을 담당하므로 문제없다. 하지만 만약 d_model이 nhead로 나누어지지 않는 조합이 생기면 trial 자체가 불가능하다.
# app/ml/deep_learning/dl_tuner.py — Transformer 제약 처리 elif algorithm == "transformer": params["d_model"] = trial.suggest_categorical("d_model", [64, 128, 256]) params["nhead"] = trial.suggest_categorical("nhead", [4, 8]) params["num_layers"] = trial.suggest_int("num_layers", 2, 6) params["dim_feedforward"] = trial.suggest_categorical( "dim_feedforward", [128, 256, 512], ) # d_model이 nhead로 나누어지지 않으면 즉시 prune if params["d_model"] % params["nhead"] != 0: raise optuna.TrialPruned()
현재 설정에서 가능한 조합을 확인해 보자.
| d_model | nhead=4 | nhead=8 |
| 64 | 64 % 4 = 0 (OK) | 64 % 8 = 0 (OK) |
| 128 | 128 % 4 = 0 (OK) | 128 % 8 = 0 (OK) |
| 256 | 256 % 4 = 0 (OK) | 256 % 8 = 0 (OK) |
현재 설정에서는 d_model이 모두 64의 배수이고 nhead가 4와 8이므로, 사실 모든 조합이 유효하다. 하지만 방어적 코드로 남겨둔 이유가 있다. 나중에 d_model에 96이나 192를 추가하거나, nhead에 6을 추가하면 일부 조합이 불가능해질 수 있다. 그때 에러로 전체 튜닝이 멈추는 대신, TrialPruned()로 해당 trial만 건너뛰고 다음으로 넘어간다.
핵심 원칙: "불가능한 조합"은 에러를 내지 말고, raise optuna.TrialPruned()로 조용히 건너뛴다. Optuna는 pruned trial을 자연스럽게 처리하며, TPE 통계에서 이 trial을 무시한다.
5. MedianPruner — 가망 없는 trial 조기 종료
DL 튜닝에서 가장 중요한 절감 수단이 MedianPruner다. 매 epoch마다 현재 trial의 F1 점수를 보고하면, Optuna가 "이 trial이 계속할 가치가 있는지"를 판단해준다.
동작 원리
MedianPruner의 규칙은 단순하다.
현재 trial의 epoch N에서의 성능이, 이전에 완료된 trial들의 같은 epoch N에서의 중간값(median)보다 낮으면 즉시 종료한다.
두 가지 안전 장치가 있다.
| 파라미터 | 값 | 의미 |
| n_startup_trials | 5 | 처음 5개 trial은 끝까지 실행 (비교 기준 확보) |
| n_warmup_steps | 10 | 각 trial의 처음 10 epoch은 pruning 안 함 |
코드에서 보는 pruning 흐름
# app/ml/deep_learning/dl_tuner.py — 학습 루프 핵심 best_f1 = 0.0 for epoch in range(_TUNE_MAX_EPOCHS): # 30 epoch # ... 학습 코드 (train_loader) ... # 검증 F1 계산 f1 = sklearn_f1_score(all_labels, all_preds, average="binary") best_f1 = max(best_f1, f1) # Optuna에게 현재 epoch의 F1 보고 trial.report(f1, epoch) # "이 trial 가망 없어" 판단 시 조기 종료 if trial.should_prune(): raise optuna.TrialPruned() return best_f1
실제 pruning 시나리오
Trial 1~5: n_startup_trials이므로 30 epoch 전부 실행 median F1 기준선 확보 Trial 6: epoch 0~9 → warmup 구간, pruning 안 함 epoch 10 → F1=0.42 (median=0.58) → PRUNED! 20 epoch 절약 Trial 7: epoch 0~9 → warmup epoch 10 → F1=0.55 (median=0.58) → PRUNED! Trial 8: epoch 0~9 → warmup epoch 10 → F1=0.61 (median=0.58) → 통과! epoch 11~29 → 계속 학습 최종 F1=0.72 ...20 trials 중 실제로 30 epoch을 다 도는 trial은 절반 이하
MedianPruner가 없으면 20 trials × 30 epochs = 600 epoch을 돌려야 한다. Pruner를 적용하면 절반 이상의 trial이 10~15 epoch에서 잘려나가므로, 실제 총 epoch 수는 350~400 정도로 줄어든다.
참고: objective 함수는 best_f1 (전체 epoch 중 최고 F1)을 반환하지만, trial.report(f1, epoch)에는 매 epoch의 현재 F1을 보고한다. Pruner는 "현재 시점의 성능"으로 판단하고, 최종 기록은 "최고 성능"으로 남기는 것이다.
6. RL 튜닝 전략 — 환경 시뮬레이션의 비용
강화학습(RL)은 DL과 또 다르다. "정답 레이블"이 없고, 환경(Environment)과 상호작용하며 학습한다. 매매 시뮬레이션 환경에서 수만 step을 거래하고, 그 결과로 얻는 수익률(total_return)이 유일한 평가 지표다.
RL 튜닝이 더 까다로운 이유
| 항목 | DL 튜닝 | RL 튜닝 |
| 학습 단위 | epoch (데이터 한 바퀴) | timestep (환경 1 step) |
| 중간 관찰 | 매 epoch F1 보고 가능 | 에피소드 종료 시에만 수익률 확인 |
| Pruner | MedianPruner | 없음 (에피소드 길이가 불규칙) |
| 평가 대상 | 1개 종목 | 여러 종목의 평균 수익률 |
RL에서는 에피소드(하나의 종목을 처음부터 끝까지 거래)가 끝나야 수익률을 알 수 있다. 에피소드 길이가 종목마다 다르고, 중간 시점의 수익률은 의미가 없다. 그래서 Pruner를 쓸 수 없다.
RL의 비용 절감 전략
1) 축소 timesteps — 본 학습은 100K timesteps이지만, 튜닝은 20K timesteps(_TUNE_TIMESTEPS = 20_000)만 돌린다.
2) 축소 종목 수 — 본 학습에서는 8개 종목을 쓰지만, 튜닝은 최대 4개 종목만 사용한다.
3) trial 수 축소 — DL의 20 trials보다 더 줄여서 10 trials로 제한한다.
DQN 탐색 공간
# app/ml/reinforcement/rl_tuner.py — DQN 탐색 if algorithm == "dqn": params["learning_rate"] = trial.suggest_float( "learning_rate", 5e-5, 1e-3, log=True, ) params["buffer_size"] = trial.suggest_categorical( "buffer_size", [50_000, 100_000, 200_000], ) params["batch_size"] = trial.suggest_categorical( "batch_size", [32, 64, 128], ) params["gamma"] = trial.suggest_float("gamma", 0.95, 0.999) params["target_update_interval"] = trial.suggest_categorical( "target_update_interval", [500, 1_000, 2_000], )
PPO 탐색 공간
# app/ml/reinforcement/rl_tuner.py — PPO 탐색 elif algorithm == "ppo": params["learning_rate"] = trial.suggest_float( "learning_rate", 5e-5, 1e-3, log=True, ) params["n_steps"] = trial.suggest_categorical( "n_steps", [1024, 2048, 4096], ) params["batch_size"] = trial.suggest_categorical( "batch_size", [32, 64, 128], ) params["n_epochs"] = trial.suggest_int("n_epochs", 3, 15) params["gamma"] = trial.suggest_float("gamma", 0.95, 0.999) params["clip_range"] = trial.suggest_float("clip_range", 0.1, 0.3) params["ent_coef"] = trial.suggest_float( "ent_coef", 1e-3, 5e-2, log=True, )
RL 평가: val 에피소드 수익률
RL의 objective가 반환하는 값은 F1이 아니라 total_return(수익률)이다. 평가 방법도 독특하다. 각 종목의 학습 데이터에서 마지막 20%를 val 구간으로 잘라, 학습된 에이전트가 이 구간에서 매매했을 때의 수익률을 계산한다.
# app/ml/reinforcement/rl_tuner.py — val 평가 returns = [] for code in selected_codes: # 최대 4개 종목 code_df = train_df[train_df["code"] == code] val_start = int(len(code_df) * 0.8) val_df = code_df.iloc[val_start:] # 마지막 20% # val 환경에서 에피소드 실행 eval_env = StockTradingEnv(df=val_df, ...) obs, _ = eval_env.reset() while True: action, _ = model.predict(obs, deterministic=True) obs, reward, terminated, truncated, info = eval_env.step(action) if terminated or truncated: break returns.append(info["total_return"]) avg_return = np.mean(returns) # 4개 종목의 평균 수익률 return avg_return
왜 Pruner를 안 쓰나?
DL은 매 epoch F1을 보고할 수 있다. 하지만 RL은 에피소드가 끝나야 수익률을 알 수 있고, 에피소드 길이가 종목마다 다르며, 학습 중간의 수익률은 노이즈가 심하다. 의미 있는 중간 지표를 정의하기 어려우므로, Pruner 없이 trial 수와 timesteps를 줄이는 전략을 택했다.
7. ML vs DL vs RL 튜닝 비교표
세 가지 카테고리의 튜닝 전략을 한눈에 비교한다.
| 항목 | ML (RF, XGB, LGBM) | DL (LSTM, Transformer) | RL (DQN, PPO) |
| 기본 trial 수 | 50 | 20 | 10 |
| trial 1회 소요 | 수 초 (fit 1회) | 수 분 (30 epoch) | 수 분 (20K steps) |
| 학습량 축소 | 없음 (이미 빠름) | 100 epoch → 30 epoch | 100K steps → 20K steps |
| 데이터 축소 | 없음 (전체 데이터) | 없음 | 8 codes → 4 codes |
| Pruner | 미적용 | MedianPruner | 미적용 |
| Objective 지표 | F1 score | F1 score | total_return (수익률) |
| 방향 | maximize | maximize | maximize |
| 불가능 조합 처리 | 없음 | TrialPruned (d_model % nhead) | TrialPruned (학습 실패 시) |
| Tuner 파일 | tuner.py | dl_tuner.py | rl_tuner.py |
핵심 원칙: trial당 비용이 높을수록 trial 수를 줄이고, 대신 trial당 축소 학습 + Pruner(가능한 경우)로 보상한다.
ML은 50번 가볍게, DL은 20번 + Pruner로 효율적으로, RL은 10번 + 축소 데이터로 최소 비용으로 탐색한다.
알고리즘별 탐색 파라미터 총정리
| 알고리즘 | 파라미터 | 타입 | 범위 |
| 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) | 1e-4 ~ 1e-2 | |
| batch_size | categorical | [32, 64, 128] | |
| Transformer | 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) | 1e-4 ~ 1e-2 | |
| batch_size | categorical | [32, 64, 128] | |
| DQN | learning_rate | float (log) | 5e-5 ~ 1e-3 |
| buffer_size | categorical | [50K, 100K, 200K] | |
| batch_size | categorical | [32, 64, 128] | |
| gamma | float | 0.95 ~ 0.999 | |
| target_update_interval | categorical | [500, 1K, 2K] | |
| PPO | learning_rate | float (log) | 5e-5 ~ 1e-3 |
| n_steps | categorical | [1024, 2048, 4096] | |
| batch_size | categorical | [32, 64, 128] | |
| n_epochs | int | 3 ~ 15 | |
| gamma | float | 0.95 ~ 0.999 | |
| clip_range | float | 0.1 ~ 0.3 | |
| ent_coef | float (log) | 1e-3 ~ 5e-2 |
8. ml_config.yaml — 탐색 공간을 코드 밖으로
위에서 본 모든 탐색 공간은 Python 코드 안에 하드코딩되어 있지 않다. YAML 파일(app/ml/ml_config.yaml)에 선언되어 있고, 코드는 이 파일을 읽어서 동적으로 suggest_*를 호출한다.
YAML 구조: defaults + search_space
# app/ml/ml_config.yaml algorithms: lstm: model_class: ml.deep_learning.architectures.LSTMClassifier type: deep_learning defaults: # 튜닝하지 않는 고정값 seq_len: 20 hidden_size: 128 num_layers: 2 dropout: 0.3 bidirectional: true lr: 0.001 batch_size: 64 epochs: 100 patience: 10 grad_clip: 1.0 search_space: # Optuna가 탐색할 범위 seq_len: { type: int, low: 10, high: 40, step: 5 } hidden_size: { type: categorical, choices: [64, 128, 256] } num_layers: { type: int, low: 1, high: 3 } dropout: { type: float, low: 0.1, high: 0.5 } lr: { type: float, low: 0.0001, high: 0.01, log: true } batch_size: { type: categorical, choices: [32, 64, 128] }
RL 알고리즘도 같은 구조
# app/ml/ml_config.yaml — DQN, PPO dqn: type: reinforcement defaults: learning_rate: 0.0001 buffer_size: 100000 total_timesteps: 100000 # ... 기타 기본값 ... search_space: learning_rate: { type: float, low: 0.00005, high: 0.001, log: true } buffer_size: { type: categorical, choices: [50000, 100000, 200000] } batch_size: { type: categorical, choices: [32, 64, 128] } gamma: { type: float, low: 0.95, high: 0.999 } target_update_interval: { type: categorical, choices: [500, 1000, 2000] } ppo: type: reinforcement defaults: learning_rate: 0.0003 n_steps: 2048 clip_range: 0.2 # ... 기타 기본값 ... search_space: learning_rate: { type: float, low: 0.00005, high: 0.001, log: true } n_steps: { type: categorical, choices: [1024, 2048, 4096] } n_epochs: { type: int, low: 3, high: 15 } gamma: { type: float, low: 0.95, high: 0.999 } clip_range: { type: float, low: 0.1, high: 0.3 } ent_coef: { type: float, low: 0.001, high: 0.05, log: true }
YAML 기반 설정의 장점
1) 코드 변경 없이 탐색 범위 조절. "LSTM hidden_size에 512를 추가하고 싶다" → YAML에서 choices: [64, 128, 256, 512]로 바꾸면 끝이다. 코드를 건드릴 필요가 없다.
2) defaults와 search_space의 분리. defaults는 Optuna 없이 학습할 때의 기본값이다. Optuna 튜닝을 켜면 search_space의 범위에서 탐색하고, 튜닝을 끄면 defaults로 학습한다.
3) ML/DL/RL 모두 같은 YAML 구조. 알고리즘 타입이 달라도 defaults + search_space 구조는 동일하다. 새 알고리즘을 추가할 때도 YAML 블록 하나만 추가하면 된다.
정리: Alchemetric의 하이퍼파라미터 튜닝은 세 겹의 전략으로 구성된다.
1. 선언 — ml_config.yaml에 탐색 공간을 정의
2. 탐색 — 카테고리별 tuner가 비용에 맞게 trial 수, Pruner, 축소 학습을 조절
3. 적용 — study.best_params를 꺼내 본 학습에 사용
ML은 빠르니까 50번 가볍게. DL은 20번 + MedianPruner. RL은 10번 + 축소 데이터.
각각의 비용 특성에 맞춘 Optuna 전략이 프로젝트 전체의 튜닝 효율을 극대화한다.
'Project > 🚀 AI Finance 2026' 카테고리의 다른 글
| #31 [모듈 4-7] 모델 직렬화 (0) | 2026.04.06 |
|---|---|
| #29 [모듈4-5] RL 에이전트의 행동을 결정하는 보상 함수 설계 (0) | 2026.03.19 |
| #28 [모듈4-4] AI 에이전트가 스스로 매매 전략을 학습하는 법 (0) | 2026.03.19 |
| #27 [모듈4-3] CUDA, MPS, CPU - PyTorch 디바이스 감지와 혼합 정밀도 학습 (0) | 2026.03.19 |
| #26 [모듈4-2] DL Early Stopping & Overfitting (0) | 2026.03.18 |