Early Stopping & Overfitting
딥러닝 모델이 주가 데이터에서 과적합되지 않도록 막는 5가지 방법
딥러닝 모델은 표현력이 강력한 만큼, 주가 데이터처럼 노이즈가 많은 시계열에서 과적합(overfitting)에 빠지기 쉽다. 이 글에서는 AI_Finance(ALCHEMETRIC) 프로젝트의 LSTM · Transformer 학습 파이프라인에 적용된 5가지 정규화 기법 — Early Stopping, Dropout, Gradient Clipping, Learning Rate Scheduling, Weight Decay — 을 비유와 실제 코드로 설명한다.
목차
1. 과적합이란? — 시험 문제를 외우는 학생
비유: 기출문제만 외운 학생
시험 준비를 할 때, 기출문제의 답 번호를 외우는 학생이 있다. "3번 문제는 2번, 7번 문제는 4번." 이 학생은 기출 모의고사에서는 100점을 받지만, 본 시험에서는 맥을 못 춘다. 문제의 패턴이 아니라 특정 답을 외웠기 때문이다.
딥러닝의 과적합도 같은 현상이다. 모델이 훈련 데이터의 일반적인 규칙이 아니라 노이즈와 우연한 패턴까지 외워버리면, 훈련 손실(train loss)은 계속 낮아지지만 검증 손실(val loss)은 오히려 올라간다.
금융 데이터에서 과적합이 특히 심한 이유
주가 데이터는 과적합의 천국이다. 이유를 정리하면 다음과 같다.
| 특성 | 설명 | 과적합 위험 |
|---|---|---|
| 높은 노이즈 | 주가 변동의 대부분은 랜덤 워크에 가깝다 | 신호보다 노이즈를 학습 |
| 비정상성 | 시장 레짐이 수시로 바뀜 (상승장, 하락장, 횡보) | 과거 패턴이 미래에 반복되지 않음 |
| 제한된 데이터 | 일봉 기준 1년에 약 250개 데이터포인트 | 파라미터 수 대비 데이터 부족 |
| 다중공선성 | 기술적 지표 간 상관관계가 높음 | 중복된 정보를 과도하게 학습 |
2. Early Stopping — 더 나빠지기 전에 멈춘다
비유: 요리할 때 불 끄는 타이밍
스테이크를 구울 때 미디엄이 가장 맛있다면, 레어를 지나 미디엄에 도달했을 때 불을 꺼야 한다. 계속 구우면 웰던을 넘어 숯이 된다. Early Stopping은 모델 학습의 "불 끄는 타이밍"을 자동으로 잡아주는 기법이다.
작동 원리
매 에폭(epoch)마다 검증 데이터로 성능을 측정한다. 우리 프로젝트에서는 검증 F1 Score를 모니터링한다. F1이 개선되면 현재 모델 가중치를 저장하고, 개선이 없으면 카운터를 올린다. 카운터가 patience=10에 도달하면 학습을 중단하고, 저장해둔 최고 성능 가중치로 모델을 복원한다.
| 파라미터 | 값 | 의미 |
|---|---|---|
| patience | 10 | 10 에폭 연속 개선 없으면 중단 |
| monitor | val F1 Score | 정밀도와 재현율의 조화 평균 |
| restore | best_state_dict | 최고 성능 시점의 가중치를 복원 |
왜 loss가 아니라 F1을 모니터링할까? 주가 예측은 불균형 분류 문제(상승/하락 비율이 다름)이기 때문에, accuracy나 loss보다 F1 Score가 실전 성능을 더 정확히 반영한다.
프로젝트 코드
dl_trainer.py의 _train_loop 메서드에서 Early Stopping을 구현한다.
# 초기화
best_f1 = 0.0
best_state = None
patience_counter = 0
patience = params["patience"] # 10
for epoch in range(epochs):
# --- 학습 + 검증 생략 ---
val_metrics = self._evaluate(model, val_loader)
val_f1 = val_metrics["f1"]
if val_f1 > best_f1:
best_f1 = val_f1
# 현재 가중치를 CPU로 복사해서 저장
best_state = {k: v.cpu().clone()
for k, v in model.state_dict().items()}
patience_counter = 0
else:
patience_counter += 1
if patience_counter >= patience:
logger.info(f"Early stopping at epoch {epoch+1}")
break
# 학습 종료 후 최적 모델 복원
if best_state:
model.load_state_dict(best_state)
주목할 점은 v.cpu().clone()이다. 학습이 GPU에서 진행되더라도, 최적 가중치는 CPU 메모리에 복사해둔다. GPU 메모리에 모델 사본을 계속 유지하면 OOM(Out of Memory) 위험이 있기 때문이다.
3. Dropout — 뉴런을 랜덤으로 끈다
비유: 팀 과제에서 랜덤 결석
5명이 팀 과제를 하는데, 매번 랜덤으로 1~2명이 빠진다고 상상해보자. 처음에는 혼란스럽지만, 시간이 지나면 모든 팀원이 "누가 빠져도 돌아가는" 독립적인 역량을 갖추게 된다. 한 명에게 의존하는 구조가 사라지는 것이다.
Dropout은 학습 중 매 배치마다 뉴런의 일부를 확률적으로 비활성화한다. 이렇게 하면 특정 뉴런에 과도하게 의존하는 co-adaptation을 방지하고, 각 뉴런이 독립적으로 유용한 특징을 학습하도록 유도한다.
LSTM의 Dropout (0.3)
LSTM에서는 두 곳에 Dropout이 적용된다.
class LSTMClassifier(nn.Module):
def __init__(self, n_features, hidden_size=128,
num_layers=2, dropout=0.3, bidirectional=True):
super().__init__()
# (1) LSTM 레이어 간 Dropout
self.lstm = nn.LSTM(
input_size=n_features,
hidden_size=hidden_size,
num_layers=num_layers,
batch_first=True,
dropout=dropout if num_layers > 1 else 0.0,
bidirectional=bidirectional,
)
fc_in = hidden_size * (2 if bidirectional else 1)
# (2) Classifier head의 Dropout
self.classifier = nn.Sequential(
nn.Dropout(dropout), # 0.3
nn.Linear(fc_in, hidden_size),
nn.ReLU(),
nn.Dropout(dropout), # 0.3
nn.Linear(hidden_size, 2),
)
(1) LSTM 레이어 간 Dropout: nn.LSTM(dropout=0.3)은 LSTM 레이어가 2개 이상일 때, 레이어 사이의 출력에 30%의 Dropout을 적용한다. 단일 레이어일 때는 적용할 곳이 없으므로 0.0으로 설정한다.
(2) Classifier head Dropout: LSTM의 마지막 타임스텝 출력을 FC(Fully Connected) 레이어에 통과시키기 전후에 각각 Dropout을 건다. 총 2번이다.
Transformer의 Dropout (0.2)
Transformer는 LSTM보다 낮은 0.2를 사용한다. Self-Attention 구조 자체가 이미 모든 위치를 고르게 참조하기 때문에, 과도한 Dropout은 정보 손실을 유발할 수 있다.
class TransformerClassifier(nn.Module):
def __init__(self, n_features, d_model=128,
nhead=8, num_layers=3,
dim_feedforward=256, dropout=0.2):
super().__init__()
# (1) Positional Encoding에 Dropout
self.pos_encoder = PositionalEncoding(d_model, dropout=dropout)
# (2) TransformerEncoderLayer 내부 Dropout
# Self-Attention 출력 + FFN 출력에 적용
encoder_layer = nn.TransformerEncoderLayer(
d_model=d_model, nhead=nhead,
dim_feedforward=dim_feedforward,
dropout=dropout, # 0.2
batch_first=True,
activation="gelu",
)
# (3) Classifier head의 Dropout
self.classifier = nn.Sequential(
nn.LayerNorm(d_model),
nn.Linear(d_model, d_model // 2),
nn.GELU(),
nn.Dropout(dropout), # 0.2
nn.Linear(d_model // 2, 2),
)
4. Gradient Clipping — 학습 폭주 방지
비유: 자동차의 속도 제한기
고속도로에서 갑자기 내리막이 나오면 차가 급가속한다. 속도 제한기(limiter)는 최고 속도를 제한해서 사고를 막는다. Gradient Clipping도 마찬가지다. 역전파(backpropagation) 과정에서 그래디언트가 갑자기 폭발적으로 커지는 현상(gradient explosion)을 방지한다.
왜 RNN 계열에서 중요한가
LSTM이나 Transformer는 시퀀스 길이만큼 역전파 경로가 길어진다. 긴 경로를 따라 그래디언트가 곱해지면서, 특정 배치에서 값이 폭발할 수 있다. 그래디언트가 NaN이나 Inf가 되면, 그 시점부터 모든 가중치가 망가지고 학습이 완전히 파괴된다.
프로젝트 코드
grad_clip = params["grad_clip"] # 1.0
for X_batch, y_batch in train_loader:
optimizer.zero_grad()
logits = model(X_batch)
loss = criterion(logits, y_batch)
loss.backward()
# 모든 파라미터의 그래디언트 L2 norm을 max_norm 이하로 클리핑
nn.utils.clip_grad_norm_(model.parameters(), max_norm=grad_clip)
optimizer.step()
clip_grad_norm_은 모든 파라미터의 그래디언트를 하나의 벡터로 보고, 그 L2 norm이 max_norm=1.0을 초과하면 전체 그래디언트를 비례적으로 축소한다. 방향은 보존하되 크기만 제한하는 것이다.
수식으로 표현하면 다음과 같다.
total_norm = sqrt(sum(p.grad.norm(2)^2 for p in parameters))
if total_norm > max_norm:
scale = max_norm / total_norm
for p in parameters:
p.grad *= scale
5. Learning Rate Scheduling — 점점 조심스럽게
비유: 산 정상 근처에서는 천천히
등산할 때 산 아래에서는 성큼성큼 걸어도 되지만, 정상 근처 좁은 능선에서는 한 발짝씩 조심스럽게 걸어야 한다. Learning Rate도 마찬가지다. 초반에는 큰 스텝으로 빠르게 탐색하고, 최적점 근처에 도달하면 스텝 크기를 줄여서 정밀하게 수렴한다.
ReduceLROnPlateau
우리 프로젝트는 ReduceLROnPlateau를 사용한다. 특정 메트릭이 정체(plateau)되면 learning rate를 줄이는 방식이다.
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
optimizer,
mode="max", # F1은 높을수록 좋으므로 "max"
factor=0.5, # LR을 절반으로 줄임
patience=5, # 5 에폭 개선 없으면 발동
)
# 매 에폭 끝에서:
val_f1 = val_metrics["f1"]
scheduler.step(val_f1) # 스케줄러에 현재 F1 전달
| 파라미터 | 값 | 의미 |
|---|---|---|
| mode="max" | max | 모니터링 값이 커야 좋은 경우 (F1, accuracy) |
| factor=0.5 | 0.5 | 새 LR = 현재 LR x 0.5 |
| patience=5 | 5 | 5 에폭 연속 개선 없으면 LR 감소 |
LSTM의 초기 LR은 1e-3, Transformer는 5e-4로 시작한다. 학습이 진행되면서 5e-4 → 2.5e-4 → 1.25e-4처럼 단계적으로 줄어든다.
6. Weight Decay (L2 정규화) — 가중치에 벌금
비유: 이사 비용
짐이 많을수록 이사 비용이 비싸다. 정말 필요한 짐만 가져가고, 불필요한 짐은 버리게 된다. Weight Decay도 같은 원리다. 모델의 가중치가 클수록 벌금(penalty)을 부과해서, 가중치가 불필요하게 커지는 것을 억제한다.
L2 정규화 vs Weight Decay
전통적인 L2 정규화는 손실 함수에 lambda * ||w||^2 항을 추가한다. PyTorch의 AdamW는 이것을 가중치 갱신 단계에서 직접 적용하는 decoupled weight decay 방식을 사용한다. Adam의 적응적 학습률과 올바르게 결합되도록 설계된 것이다.
optimizer = torch.optim.AdamW(
model.parameters(),
lr=params["lr"],
weight_decay=1e-4, # 0.0001
)
# AdamW의 가중치 업데이트 (개념적):
# w_new = w_old - lr * (grad + weight_decay * w_old)
# ^^^^^^^^^^^^^^^^^^^^^^^^^^
# 가중치가 클수록 더 많이 감소
weight_decay=1e-4는 상당히 작은 값이다. 너무 크면 모델이 아무것도 학습하지 못하고(underfitting), 너무 작으면 정규화 효과가 없다. 1e-4는 딥러닝에서 가장 널리 사용되는 기본값 중 하나이다.
7. 5가지 방어막의 협업 — 학습 루프 전체 코드
지금까지 설명한 5가지 기법은 각각 독립적으로도 의미가 있지만, 함께 작동할 때 시너지를 낸다. 우리 프로젝트의 _train_loop 메서드 전체를 보면서 각 기법이 어느 시점에 개입하는지 확인해보자.
def _train_loop(self, model, train_loader, val_loader, params):
# [Weight Decay] AdamW 옵티마이저 — 가중치 벌금
optimizer = torch.optim.AdamW(
model.parameters(),
lr=params["lr"],
weight_decay=1e-4,
)
# [LR Scheduling] 성능 정체 시 LR 절반
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
optimizer, mode="max", factor=0.5, patience=5,
)
criterion = nn.CrossEntropyLoss()
best_f1 = 0.0
best_state = None
patience_counter = 0
for epoch in range(params["epochs"]): # max 100
# --- Train ---
model.train() # [Dropout] 활성화
for X_batch, y_batch in train_loader:
optimizer.zero_grad()
logits = model(X_batch)
loss = criterion(logits, y_batch)
loss.backward()
# [Gradient Clipping] 그래디언트 폭발 방지
nn.utils.clip_grad_norm_(
model.parameters(), max_norm=1.0
)
optimizer.step() # [Weight Decay] 여기서 적용됨
# --- Validate ---
val_metrics = self._evaluate(model, val_loader)
val_f1 = val_metrics["f1"]
scheduler.step(val_f1) # [LR Scheduling]
# [Early Stopping] F1 개선 여부 추적
if val_f1 > best_f1:
best_f1 = val_f1
best_state = {k: v.cpu().clone()
for k, v in model.state_dict().items()}
patience_counter = 0
else:
patience_counter += 1
if patience_counter >= 10:
break # [Early Stopping] 학습 중단
# 최적 모델 복원
if best_state:
model.load_state_dict(best_state)
return model, {"best_f1": best_f1}
시간 순서로 본 개입 지점
| 순서 | 기법 | 개입 시점 | 역할 |
|---|---|---|---|
| 1 | Dropout | Forward pass | 뉴런 비활성화로 co-adaptation 방지 |
| 2 | Gradient Clipping | Backward pass 후, step 전 | 그래디언트 크기 제한 |
| 3 | Weight Decay | optimizer.step() 내부 | 큰 가중치 페널티 |
| 4 | LR Scheduling | 에폭 끝 (검증 후) | 학습률 적응적 감소 |
| 5 | Early Stopping | 에폭 끝 (검증 후) | 과적합 진행 시 학습 종료 |
8. LSTM vs Transformer 정규화 비교
같은 정규화 기법이라도 LSTM과 Transformer에서 다르게 설정된다. 두 아키텍처의 특성이 다르기 때문이다.
| 항목 | LSTM | Transformer |
|---|---|---|
| Dropout 비율 | 0.3 | 0.2 |
| Dropout 적용 위치 | LSTM 레이어 간 + classifier head (x2) | Positional Encoding + Attention + FFN + classifier head |
| 추가 정규화 | 없음 | LayerNorm (classifier head 앞) |
| 초기 Learning Rate | 1e-3 | 5e-4 |
| Gradient Clipping | 1.0 | 1.0 |
| Weight Decay | 1e-4 | 1e-4 |
| Classifier 활성 함수 | ReLU | GELU |
| Pooling 방식 | 마지막 타임스텝 | Mean Pooling (전체 시퀀스 평균) |
왜 Transformer의 Dropout이 더 낮을까?
Transformer는 이미 여러 겹의 내장 정규화를 갖고 있다.
1) Multi-Head Attention: 8개의 헤드가 서로 다른 부분 공간을 독립적으로 학습한다. 이것 자체가 일종의 앙상블 효과를 낸다.
2) LayerNorm: 각 서브레이어(Attention, FFN) 후에 정규화가 적용된다. LSTM의 nn.LSTM에는 이런 내장 정규화가 없다.
3) Residual Connection: TransformerEncoderLayer 내부에 skip connection이 있어서, 그래디언트가 안정적으로 흐른다.
이런 내장 정규화가 이미 과적합을 어느 정도 억제하고 있으므로, 외부 Dropout을 0.3까지 올리면 오히려 underfitting(과소적합)이 발생할 수 있다. 따라서 0.2로 낮춘 것이다.
하이퍼파라미터 전체 비교
dl_trainer.py 상단의 DL_DEFAULTS 딕셔너리에 두 모델의 기본 설정이 정의되어 있다.
DL_DEFAULTS = {
"lstm": {
"seq_len": 20,
"hidden_size": 128,
"num_layers": 2,
"dropout": 0.3,
"bidirectional": True,
"lr": 1e-3,
"batch_size": 64,
"epochs": 100,
"patience": 10,
"grad_clip": 1.0,
},
"transformer": {
"seq_len": 20,
"d_model": 128,
"nhead": 8,
"num_layers": 3,
"dim_feedforward": 256,
"dropout": 0.2,
"lr": 5e-4,
"batch_size": 64,
"epochs": 100,
"patience": 10,
"grad_clip": 1.0,
},
}
'Project > 🚀 AI Finance 2026' 카테고리의 다른 글
| #28 [모듈4-4] AI 에이전트가 스스로 매매 전략을 학습하는 법 (0) | 2026.03.19 |
|---|---|
| #27 [모듈4-3] CUDA, MPS, CPU - PyTorch 디바이스 감지와 혼합 정밀도 학습 (0) | 2026.03.19 |
| #25 [모듈4-1] PyTorch & Deep Learning (0) | 2026.03.18 |
| #24 [모듈4] Deep Learning (0) | 2026.03.18 |
| #23 [모듈3-2] Optuna — 하이퍼파라미터 자동 튜닝의 모든 것 (0) | 2026.03.16 |