본문 바로가기
Project/🚀 AI Finance 2026

#30 [모듈4-6] 딥러닝과 강화학습에서 Optuna를 쓰는 법은 ML과 다르다

by 로띠 2026. 3. 19.
하이퍼파라미터

Optuna — DL & RL Tuning

딥러닝과 강화학습에서 Optuna를 쓰는 법은 ML과 다르다

Data Collect Feature Eng. ML / DL / RL Hyperparameter Tuning Signal

이전 글(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_modelnhead개로 균등 분할한다. 따라서 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 전략이 프로젝트 전체의 튜닝 효율을 극대화한다.