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

#26 [모듈4-2] DL Early Stopping & Overfitting

by 로띠 2026. 3. 18.
딥러닝

Early Stopping & Overfitting

딥러닝 모델이 주가 데이터에서 과적합되지 않도록 막는 5가지 방법

Data Collect Feature Eng. Deep Learning Signal

딥러닝 모델은 표현력이 강력한 만큼, 주가 데이터처럼 노이즈가 많은 시계열에서 과적합(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개 데이터포인트 파라미터 수 대비 데이터 부족
다중공선성 기술적 지표 간 상관관계가 높음 중복된 정보를 과도하게 학습
핵심: 금융 데이터에서 딥러닝을 쓰려면, 모델의 표현력을 의도적으로 제한하는 장치가 반드시 필요하다. 이것이 정규화(regularization)의 역할이다.

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) 위험이 있기 때문이다.

왜 epochs=100인데 patience=10일까? 최대 100 에폭까지 돌릴 수 있지만, 보통 30~50 에폭이면 Early Stopping이 발동한다. 100은 "혹시 모르니 넉넉하게" 설정한 상한선이다.

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),
        )
train vs eval 모드: Dropout은 model.train()일 때만 작동한다. model.eval()로 전환하면 모든 뉴런이 활성화되고, 출력이 자동으로 스케일링된다. 우리 코드의 _evaluate() 메서드 첫 줄이 model.eval()인 이유다.

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 normmax_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
AMP(Mixed Precision) 사용 시: 우리 코드는 CUDA GPU가 있으면 torch.amp.autocast를 사용한다. 이 경우 amp_scaler.unscale_(optimizer)를 먼저 호출한 뒤 클리핑해야 한다. 스케일링된 그래디언트를 원래 크기로 되돌린 후 비교해야 정확하기 때문이다.

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처럼 단계적으로 줄어든다.

patience 5 vs 10: Scheduler의 patience(5)는 Early Stopping의 patience(10)보다 작다. 즉, 먼저 LR을 줄여보고, 그래도 안 되면 학습을 중단하는 2단계 전략이다.

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는 딥러닝에서 가장 널리 사용되는 기본값 중 하나이다.

왜 Adam이 아니라 AdamW인가? 기존 torch.optim.Adamweight_decay를 넣어도 동작은 하지만, Adam의 적응적 학습률 때문에 L2 정규화가 왜곡된다. Loshchilov & Hutter (2019)의 논문에서 이를 지적하고 AdamW를 제안했다. 현재는 AdamW가 사실상 표준이다.

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 에폭 끝 (검증 후) 과적합 진행 시 학습 종료
협업 시나리오: 학습 초반에는 Dropout과 Gradient Clipping이 안정적인 학습을 보장한다. 중반에 성능이 정체되면 LR Scheduling이 발동해서 학습률을 절반으로 줄인다. 그래도 개선이 없으면 Early Stopping이 학습을 중단하고 최적 모델을 복원한다. Weight Decay는 이 전체 과정 동안 가중치가 과도하게 커지는 것을 꾸준히 억제한다.

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,
    },
}
정리: 과적합 방지는 단일 기법으로 해결되지 않는다. Early Stopping(학습 중단), Dropout(뉴런 비활성화), Gradient Clipping(그래디언트 제한), LR Scheduling(학습률 감소), Weight Decay(가중치 페널티) — 이 5가지가 서로 다른 시점에서 서로 다른 방식으로 모델을 견제하며, 금융 데이터의 노이즈 속에서 일반화 가능한 패턴만 학습하도록 돕는다.