이미지 로딩 중...

AI 파인튜닝 하이퍼파라미터 튜닝 완벽 가이드 - 슬라이드 1/9
A

AI Generated

2025. 11. 9. · 2 Views

AI 파인튜닝 하이퍼파라미터 튜닝 완벽 가이드

파인튜닝 성능을 좌우하는 하이퍼파라미터 튜닝 기법을 실전 중심으로 다룹니다. Learning Rate부터 Batch Size, Optimizer 설정까지 실무에서 바로 활용할 수 있는 전략을 제공합니다.


목차

  1. Learning Rate 스케줄링
  2. Batch Size 최적화
  3. Optimizer 선택과 설정
  4. Warmup Steps 전략
  5. Weight Decay 튜닝
  6. Gradient Accumulation
  7. Early Stopping 전략
  8. Mixed Precision Training

1. Learning Rate 스케줄링

시작하며

여러분이 GPT 모델을 파인튜닝할 때 이런 상황을 겪어본 적 있나요? 처음에는 Loss가 빠르게 감소하다가 어느 순간부터 진동하거나 오히려 증가하는 현상.

심지어 학습이 완전히 발산해서 NaN 값이 나타나기도 합니다. 이런 문제는 대부분 Learning Rate 설정이 부적절할 때 발생합니다.

너무 높으면 학습이 불안정해지고, 너무 낮으면 최적점에 도달하는 데 너무 오래 걸리거나 아예 지역 최소값에 갇혀버립니다. 특히 사전학습된 대형 모델을 파인튜닝할 때는 더욱 민감합니다.

바로 이럴 때 필요한 것이 Learning Rate 스케줄링입니다. 학습 과정에서 동적으로 학습률을 조정하여 안정적이면서도 효율적인 수렴을 보장합니다.

개요

간단히 말해서, Learning Rate 스케줄링은 학습 단계에 따라 학습률을 체계적으로 변경하는 기법입니다. 실무에서 파인튜닝을 할 때 고정된 학습률만 사용하면 최적의 성능을 내기 어렵습니다.

초반에는 빠르게 학습하다가 후반부에는 세밀하게 조정해야 최상의 결과를 얻을 수 있습니다. 예를 들어, 의료 데이터셋으로 BERT를 파인튜닝할 때 적절한 스케줄링 없이는 일반화 성능이 10-15% 낮아질 수 있습니다.

기존에는 고정된 학습률로 에폭마다 수동으로 조정했다면, 이제는 자동으로 최적의 학습률 곡선을 따라갑니다. 핵심 스케줄링 전략으로는 Linear Decay(선형 감소), Cosine Annealing(코사인 곡선), Polynomial Decay(다항식 감소)가 있습니다.

이러한 전략들은 학습 초기의 빠른 수렴과 후기의 안정적인 미세조정을 동시에 가능하게 만듭니다.

코드 예제

from transformers import get_linear_schedule_with_warmup, AdamW

# Optimizer 설정
optimizer = AdamW(model.parameters(), lr=5e-5, weight_decay=0.01)

# 전체 학습 스텝 계산
total_steps = len(train_dataloader) * num_epochs
warmup_steps = int(0.1 * total_steps)  # 전체의 10%를 warmup으로

# Linear schedule with warmup
scheduler = get_linear_schedule_with_warmup(
    optimizer,
    num_warmup_steps=warmup_steps,
    num_training_steps=total_steps
)

# 학습 루프
for batch in train_dataloader:
    optimizer.zero_grad()
    loss = model(**batch).loss
    loss.backward()
    optimizer.step()
    scheduler.step()  # 매 배치마다 학습률 업데이트

설명

이것이 하는 일: Learning Rate 스케줄러는 학습 진행 상황에 따라 자동으로 학습률을 조정하여 초기에는 빠르게 학습하고 후기에는 세밀하게 최적화합니다. 첫 번째로, Warmup 단계에서는 학습률을 0에서 시작하여 설정한 최대값까지 점진적으로 증가시킵니다.

이는 사전학습된 모델의 가중치를 갑작스럽게 변경하지 않아 학습 초기의 불안정성을 방지합니다. 예를 들어 전체 스텝의 10%를 warmup으로 설정하면 처음 1000스텝 동안 0에서 5e-5까지 서서히 증가합니다.

그 다음으로, Linear Decay 단계가 실행되면서 최대 학습률에서 0까지 선형적으로 감소합니다. 이 과정에서 모델은 점점 더 작은 업데이트를 통해 세밀한 조정을 하게 됩니다.

내부적으로는 현재 스텝 번호를 기반으로 학습률을 계산하며, scheduler.step()이 호출될 때마다 optimizer의 learning rate가 업데이트됩니다. 마지막으로, 학습이 진행될수록 학습률이 감소하여 모델이 최적점 근처에서 안정적으로 수렴합니다.

최종적으로 학습이 끝날 때쯤에는 거의 0에 가까운 학습률로 미세한 조정만 이루어집니다. 여러분이 이 스케줄링을 사용하면 수렴 속도가 20-30% 빨라지고, 최종 성능도 2-5% 향상되는 효과를 얻을 수 있습니다.

특히 대형 모델일수록 학습 안정성이 크게 개선되며, 하이퍼파라미터 탐색 시간도 단축됩니다.

실전 팁

💡 Warmup 비율은 일반적으로 전체 스텝의 6-10%가 적당하며, 작은 데이터셋일수록 비율을 높여야 합니다

💡 Cosine Annealing은 Linear보다 부드러운 감소 곡선을 제공하여 NLP 태스크에서 더 나은 성능을 보이는 경우가 많습니다

💡 학습률 범위 테스트(LR Range Test)를 먼저 수행하여 최적의 초기 학습률을 찾는 것이 중요합니다

💡 MultiStepLR을 사용하면 특정 에폭에서만 학습률을 감소시켜 더 유연한 제어가 가능합니다

💡 Tensorboard나 wandb로 실시간 학습률 변화를 모니터링하면 문제를 조기에 발견할 수 있습니다


2. Batch Size 최적화

시작하며

여러분이 8GB GPU로 BERT 모델을 파인튜닝하려는데 batch size 32로 설정하자마자 "CUDA Out of Memory" 에러가 발생한 경험 있으시죠? 그래서 batch size를 4로 낮췄더니 이번에는 학습이 너무 느리고 불안정해집니다.

이런 딜레마는 모든 실무자가 겪는 문제입니다. Batch size가 크면 학습이 안정적이고 GPU를 효율적으로 사용하지만 메모리가 부족합니다.

반대로 작으면 메모리는 여유롭지만 학습이 불안정하고 느립니다. 특히 대형 모델을 다룰 때는 이 균형점을 찾는 것이 성능의 핵심입니다.

바로 이럴 때 필요한 것이 Batch Size 최적화 전략입니다. Gradient Accumulation과 결합하여 제한된 하드웨어에서도 큰 effective batch size를 구현할 수 있습니다.

개요

간단히 말해서, Batch Size는 한 번의 forward/backward pass에 처리되는 샘플 수이며, 메모리 사용량과 학습 안정성에 직접적인 영향을 미칩니다. 실무에서 적절한 batch size를 선택하는 것은 학습 시간, 메모리, 성능의 삼각 트레이드오프입니다.

너무 작으면 gradient 추정이 노이즈가 많아 학습이 불안정하고, 너무 크면 일반화 성능이 떨어질 수 있습니다. 예를 들어, 감정 분석 태스크에서 batch size 8과 64의 F1 스코어 차이가 3-7%까지 날 수 있습니다.

기존에는 하드웨어 제약에 맞춰 batch size를 선택했다면, 이제는 gradient accumulation으로 물리적 제약을 넘어설 수 있습니다. 핵심 원칙으로는 GPU 메모리의 80-90% 활용, 학습 안정성 유지, 그리고 적절한 learning rate 스케일링이 있습니다.

연구에 따르면 batch size를 2배 늘릴 때 learning rate도 √2배 증가시키는 것이 최적입니다.

코드 예제

# Gradient Accumulation을 활용한 효과적인 batch size 구현
physical_batch_size = 4  # GPU 메모리에 맞는 실제 batch size
accumulation_steps = 8   # 누적 스텝
effective_batch_size = physical_batch_size * accumulation_steps  # 32

model.train()
optimizer.zero_grad()

for i, batch in enumerate(train_dataloader):
    # Forward pass
    outputs = model(**batch)
    loss = outputs.loss / accumulation_steps  # 누적을 위해 나눔

    # Backward pass
    loss.backward()

    # Gradient accumulation 후 업데이트
    if (i + 1) % accumulation_steps == 0:
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
        optimizer.step()
        scheduler.step()
        optimizer.zero_grad()

설명

이것이 하는 일: Gradient Accumulation은 여러 미니배치의 gradient를 누적한 후 한 번에 파라미터를 업데이트하여, 제한된 메모리로도 큰 batch size의 효과를 냅니다. 첫 번째로, 물리적 batch size(4)로 forward pass를 수행하고 loss를 계산합니다.

여기서 핵심은 loss를 accumulation_steps로 나누는 것입니다. 이렇게 하면 나중에 gradient를 누적할 때 평균이 정확하게 계산됩니다.

예를 들어 8개 배치를 누적한다면 각 loss를 8로 나눠야 최종 gradient의 스케일이 올바릅니다. 그 다음으로, backward()를 호출하여 gradient를 계산하되, optimizer.step()은 바로 호출하지 않습니다.

대신 gradient가 자동으로 누적되도록 합니다. PyTorch는 기본적으로 backward()를 여러 번 호출하면 gradient가 합산되는 특성이 있습니다.

이 과정을 accumulation_steps만큼 반복합니다. 마지막으로, 설정한 누적 스텝에 도달하면 그때서야 gradient clipping을 적용하고 optimizer.step()으로 파라미터를 업데이트합니다.

그리고 optimizer.zero_grad()로 누적된 gradient를 초기화하여 다음 사이클을 준비합니다. 여러분이 이 기법을 사용하면 8GB GPU에서도 32, 64, 심지어 128의 effective batch size를 구현할 수 있습니다.

학습 안정성은 유지하면서 메모리 제약을 극복하는 실용적인 해법입니다. 다만 학습 시간은 약간 증가할 수 있습니다.

실전 팁

💡 Accumulation steps는 2의 거듭제곱(2, 4, 8, 16)으로 설정하면 계산이 효율적입니다

💡 Batch Normalization을 사용한다면 physical batch size가 너무 작으면 통계가 불안정해지므로 최소 4 이상을 유지하세요

💡 Learning rate는 effective batch size에 비례하여 조정해야 하며, linear scaling rule을 따르는 것이 일반적입니다

💡 메모리 사용량을 모니터링하려면 torch.cuda.memory_allocated()와 nvidia-smi를 활용하세요

💡 Mixed precision training(FP16)과 결합하면 batch size를 2배 더 늘릴 수 있습니다


3. Optimizer 선택과 설정

시작하며

여러분이 Transformer 모델을 파인튜닝할 때 어떤 optimizer를 선택하시나요? 많은 분들이 습관적으로 Adam을 사용하지만, 학습이 끝나고 보면 validation loss는 좋은데 test 성능이 기대 이하인 경우가 있습니다.

이런 과적합 문제는 optimizer 선택과 직접적인 관련이 있습니다. Adam은 빠르게 수렴하지만 일반화 성능이 떨어질 수 있고, SGD는 일반화는 좋지만 수렴이 느립니다.

특히 NLP 분야에서는 AdamW가 표준이 되었지만, 왜 그런지 제대로 이해하고 사용하는 경우는 드뭅니다. 바로 이럴 때 필요한 것이 각 optimizer의 특성을 이해하고 태스크에 맞게 선택하는 능력입니다.

Weight decay의 적용 방식부터 momentum 설정까지 세밀한 튜닝이 성능을 좌우합니다.

개요

간단히 말해서, Optimizer는 loss를 최소화하는 방향으로 모델 파라미터를 업데이트하는 알고리즘이며, 선택에 따라 수렴 속도와 최종 성능이 크게 달라집니다. 실무에서 대부분의 Transformer 파인튜닝은 AdamW를 사용합니다.

AdamW는 기존 Adam의 weight decay 버그를 수정한 버전으로, L2 정규화를 올바르게 적용하여 일반화 성능이 개선됩니다. 예를 들어, BERT 논문에서도 AdamW를 표준 optimizer로 사용하며, 대부분의 SOTA 모델들이 이를 따릅니다.

기존 Adam은 weight decay를 gradient에 추가했다면, AdamW는 파라미터 업데이트 시 직접 적용합니다. 핵심 설정으로는 learning rate(1e-5 ~ 5e-5), beta1(0.9), beta2(0.999), epsilon(1e-8), weight_decay(0.01)가 있습니다.

이러한 하이퍼파라미터들은 BERT, GPT 계열 모델에서 검증된 기본값이며, 대부분의 경우 잘 작동합니다.

코드 예제

from transformers import AdamW
import torch.optim as optim

# AdamW 설정 (권장)
optimizer = AdamW(
    model.parameters(),
    lr=5e-5,                    # Learning rate
    betas=(0.9, 0.999),         # Momentum 계수
    eps=1e-8,                   # 수치 안정성
    weight_decay=0.01           # L2 정규화 강도
)

# 특정 파라미터만 다르게 설정 (고급)
no_decay = ['bias', 'LayerNorm.weight']
optimizer_grouped_parameters = [
    {
        'params': [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)],
        'weight_decay': 0.01
    },
    {
        'params': [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)],
        'weight_decay': 0.0  # bias와 LayerNorm은 weight decay 제외
    }
]
optimizer = AdamW(optimizer_grouped_parameters, lr=5e-5)

설명

이것이 하는 일: AdamW optimizer는 adaptive learning rate와 올바른 weight decay를 결합하여 빠른 수렴과 좋은 일반화 성능을 동시에 제공합니다. 첫 번째로, 기본 AdamW 설정에서는 모든 파라미터에 동일한 하이퍼파라미터를 적용합니다.

learning rate 5e-5는 BERT 크기 모델에 적합한 값이며, betas는 gradient의 1차/2차 모멘트 이동평균 계수입니다. beta1=0.9는 gradient 방향의 부드러운 변화를, beta2=0.999는 gradient 크기의 안정적인 추정을 담당합니다.

그 다음으로, 고급 설정에서는 파라미터를 두 그룹으로 나눕니다. 일반 가중치에는 weight_decay=0.01을 적용하여 과적합을 방지하고, bias와 LayerNorm 파라미터에는 weight_decay=0.0을 설정합니다.

이는 bias와 normalization 파라미터는 정규화가 필요 없기 때문입니다. named_parameters()로 파라미터 이름을 확인하여 분류합니다.

마지막으로, optimizer는 매 step마다 각 파라미터의 gradient 히스토리를 기반으로 adaptive learning rate를 계산합니다. 자주 업데이트되는 파라미터는 learning rate가 낮아지고, 드물게 업데이트되는 파라미터는 높게 유지되어 효율적인 학습이 이루어집니다.

여러분이 이 설정을 사용하면 hyperparameter tuning에 드는 시간을 크게 줄일 수 있습니다. 대부분의 NLP 태스크에서 이 기본값이 잘 작동하며, 성능 향상이 필요할 때만 세밀한 조정을 시작하면 됩니다.

특히 파라미터별 차별적 설정은 대형 모델에서 1-3% 성능 향상을 가져옵니다.

실전 팁

💡 작은 데이터셋(< 10K)에서는 weight_decay를 0.1까지 높여 과적합을 더 강하게 방지하세요

💡 Learning rate가 너무 높으면 loss가 진동하고, 너무 낮으면 지역 최소값에 갇히므로 LR finder로 최적값을 찾으세요

💡 SGD with momentum은 Adam보다 느리지만 최종 성능이 더 좋을 수 있으니, 시간 여유가 있다면 비교 실험을 권장합니다

💡 Gradient clipping(max_norm=1.0)을 함께 사용하면 학습 안정성이 크게 향상됩니다

💡 8-bit optimizer(bitsandbytes)를 사용하면 메모리를 75% 절약하면서 성능은 거의 동일하게 유지할 수 있습니다


4. Warmup Steps 전략

시작하며

여러분이 사전학습된 모델을 처음 파인튜닝할 때 학습 초기 몇 백 스텝 동안 loss가 급격히 증가하거나 NaN이 발생한 경험 있으신가요? 분명 learning rate도 적절하게 설정했는데 학습이 불안정합니다.

이런 초기 불안정성은 사전학습과 파인튜닝 데이터의 분포 차이 때문에 발생합니다. 모델이 갑자기 새로운 분포의 데이터를 학습하면서 gradient가 폭발하거나 소실됩니다.

특히 learning rate가 처음부터 높으면 사전학습된 좋은 표현을 망가뜨릴 위험이 큽니다. 바로 이럴 때 필요한 것이 Warmup Steps 전략입니다.

학습 초기에 learning rate를 점진적으로 증가시켜 모델이 새로운 데이터에 부드럽게 적응하도록 돕습니다.

개요

간단히 말해서, Warmup은 학습 시작 시 learning rate를 0에서 목표값까지 서서히 증가시키는 기법으로, 학습 초기의 불안정성을 방지합니다. 실무에서 Transformer 모델을 파인튜닝할 때 warmup은 거의 필수입니다.

BERT, GPT, T5 등 모든 주요 모델의 공식 구현에서 warmup을 사용합니다. 일반적으로 전체 학습 스텝의 6-10%를 warmup으로 설정하며, 작은 데이터셋일수록 비율을 높입니다.

예를 들어, 1000개 샘플로 감정 분석을 학습한다면 warmup 비율을 10-15%로 설정하는 것이 좋습니다. 기존에는 고정된 learning rate로 시작했다면, 이제는 warmup으로 안전하게 진입합니다.

핵심 이점으로는 gradient 폭발 방지, 사전학습 가중치 보존, 그리고 더 나은 최종 성능이 있습니다. 연구에 따르면 warmup 사용 시 최종 성능이 1-3% 향상되고 학습 실패율이 50% 이상 감소합니다.

코드 예제

from transformers import get_linear_schedule_with_warmup

# 학습 설정
num_epochs = 3
train_batch_size = 16
total_steps = len(train_dataset) // train_batch_size * num_epochs

# Warmup steps 계산 (전체의 10%)
warmup_steps = int(0.1 * total_steps)

# Scheduler 생성
scheduler = get_linear_schedule_with_warmup(
    optimizer,
    num_warmup_steps=warmup_steps,
    num_training_steps=total_steps
)

# 학습 루프에서 사용
for epoch in range(num_epochs):
    for step, batch in enumerate(train_dataloader):
        outputs = model(**batch)
        loss = outputs.loss
        loss.backward()

        optimizer.step()
        scheduler.step()  # 매 스텝마다 LR 업데이트
        optimizer.zero_grad()

        # 현재 learning rate 확인
        current_lr = scheduler.get_last_lr()[0]

설명

이것이 하는 일: Warmup scheduler는 처음 N 스텝 동안 learning rate를 0에서 설정값까지 선형적으로 증가시킨 후, 나머지 스텝에서 점진적으로 감소시킵니다. 첫 번째로, 전체 학습 스텝 수를 계산합니다.

데이터셋 크기를 배치 크기로 나누고 에폭 수를 곱하면 됩니다. 예를 들어 10,000개 샘플, 배치 16, 에폭 3이면 총 1,875 스텝입니다.

이 값을 정확히 계산하는 것이 중요한데, scheduler가 이를 기반으로 learning rate 곡선을 그리기 때문입니다. 그 다음으로, warmup_steps를 전체의 10%로 설정합니다.

위 예시에서는 187.5 → 188 스텝입니다. 처음 188 스텝 동안 learning rate는 0에서 시작하여 5e-5(설정값)까지 선형적으로 증가합니다.

이 과정에서 모델은 작은 업데이트만 받아 사전학습된 표현이 급격히 변하지 않습니다. 마지막으로, warmup이 끝난 후에는 linear decay가 시작되어 learning rate가 점진적으로 0까지 감소합니다.

scheduler.step()을 매 배치마다 호출해야 정확한 스케줄을 따르며, get_last_lr()로 현재 learning rate를 확인하여 디버깅할 수 있습니다. 여러분이 warmup을 사용하면 학습 초기 실패율이 극적으로 감소합니다.

특히 대형 모델이나 작은 데이터셋에서 효과가 뚜렷하며, 실험 재현성도 크게 향상됩니다. Warmup 없이는 같은 설정으로 5번 실험해도 결과가 크게 달라질 수 있지만, warmup을 사용하면 안정적인 결과를 얻을 수 있습니다.

실전 팁

💡 데이터셋이 작을수록(< 5K) warmup 비율을 높이세요. 10-20%까지도 효과적입니다

💡 Learning rate가 클수록 warmup steps를 더 길게 설정하여 안정성을 확보하세요

💡 Constant warmup(일정 기간 후 고정)도 효과적이며, 특정 태스크에서는 linear보다 나을 수 있습니다

💡 Warmup 중에는 validation을 수행하지 말고, warmup 완료 후부터 평가를 시작하세요

💡 여러 learning rate를 실험할 때는 warmup steps를 고정하여 공정한 비교를 하세요


5. Weight Decay 튜닝

시작하며

여러분이 모델을 학습시킬 때 training loss는 계속 감소하는데 validation loss는 어느 순간부터 증가하는 과적합 현상을 겪어보셨을 겁니다. 데이터를 더 모으거나 dropout을 추가하는 것도 방법이지만, 가장 간단하고 효과적인 해결책은 바로 weight decay입니다.

과적합은 특히 작은 데이터셋에서 심각합니다. 모델이 학습 데이터의 노이즈까지 외워버려 새로운 데이터에 일반화하지 못합니다.

의료, 법률 등 도메인 특화 데이터는 수집이 어려워 이런 문제가 더 자주 발생합니다. 바로 이럴 때 필요한 것이 Weight Decay 튜닝입니다.

모델 가중치의 크기를 제한하여 과도하게 복잡한 패턴 학습을 방지하고 일반화 성능을 향상시킬 수 있습니다.

개요

간단히 말해서, Weight Decay는 L2 정규화의 한 형태로, 학습 중 가중치 값이 너무 커지지 않도록 페널티를 부여하는 기법입니다. 실무에서 weight decay는 과적합 방지의 핵심 도구입니다.

Dropout, data augmentation과 함께 사용하면 시너지 효과가 있으며, 특히 파인튜닝에서는 사전학습된 표현을 너무 크게 변경하지 않도록 돕습니다. 예를 들어, 1000개 샘플로 BERT를 파인튜닝할 때 weight decay 0.01을 사용하면 F1 스코어가 3-5% 향상될 수 있습니다.

기존 L2 정규화는 loss에 직접 추가했다면, AdamW의 weight decay는 파라미터 업데이트 시 적용됩니다. 핵심 설정값으로는 일반적으로 0.01이 기본이며, 과적합이 심하면 0.1까지, 데이터가 충분하면 0.001까지 낮출 수 있습니다.

Bias와 LayerNorm 파라미터에는 적용하지 않는 것이 일반적입니다.

코드 예제

from transformers import AdamW

# Bias와 LayerNorm에는 weight decay 제외
no_decay = ['bias', 'LayerNorm.weight', 'LayerNorm.bias']

# 파라미터를 두 그룹으로 분리
optimizer_grouped_parameters = [
    {
        'params': [p for n, p in model.named_parameters()
                   if not any(nd in n for nd in no_decay)],
        'weight_decay': 0.01  # 일반 가중치에는 적용
    },
    {
        'params': [p for n, p in model.named_parameters()
                   if any(nd in n for nd in no_decay)],
        'weight_decay': 0.0   # Bias와 LayerNorm은 제외
    }
]

optimizer = AdamW(optimizer_grouped_parameters, lr=5e-5)

# 작은 데이터셋에서는 더 강한 정규화
# 'weight_decay': 0.1  # 1000개 미만 샘플

설명

이것이 하는 일: Weight decay는 매 업데이트마다 가중치를 약간씩 감소시켜 모델이 지나치게 복잡한 표현을 학습하지 못하도록 제약합니다. 첫 번째로, 모델의 모든 파라미터를 이름으로 순회하며 두 그룹으로 분류합니다.

named_parameters()는 ('layer.0.weight', tensor(...)) 형태의 튜플을 반환하므로, 이름에 'bias'나 'LayerNorm'이 포함되어 있는지 확인합니다. List comprehension으로 효율적으로 필터링하며, any() 함수로 여러 조건을 한 번에 체크합니다.

그 다음으로, 일반 가중치 그룹에는 weight_decay=0.01을 설정합니다. 이는 매 업데이트마다 가중치에 0.99를 곱하는 것과 유사한 효과를 내며, 가중치가 계속 커지는 것을 방지합니다.

수식으로는 w = w - lr * gradient - lr * weight_decay * w로 표현됩니다. 마지막 항이 가중치를 감소시키는 정규화 항입니다.

마지막으로, bias와 LayerNorm 파라미터는 weight_decay=0.0으로 설정하여 정규화에서 제외합니다. 이는 이들 파라미터가 모델의 표현력보다는 학습 안정성과 관련되어 있어 제약할 필요가 없기 때문입니다.

이렇게 차별적으로 적용하면 성능이 1-2% 향상됩니다. 여러분이 이 설정을 사용하면 작은 데이터셋에서도 안정적인 일반화 성능을 얻을 수 있습니다.

Validation loss와 training loss의 간격이 줄어들고, 학습 곡선이 더 부드러워집니다. 특히 도메인 특화 태스크에서 데이터가 부족할 때 필수적인 기법입니다.

실전 팁

💡 데이터셋 크기에 따라 조정하세요: 1K 미만(0.1), 1K-10K(0.01), 10K 이상(0.001)

💡 Dropout과 함께 사용할 때는 weight decay를 약간 낮춰 과도한 정규화를 방지하세요

💡 Layer-wise decay를 적용하면 하위 레이어는 강하게, 상위 레이어는 약하게 정규화할 수 있습니다

💡 학습 중 가중치 분포를 모니터링하여 너무 작아지지 않는지 확인하세요

💡 Cross-validation으로 최적의 weight decay 값을 찾는 것이 가장 확실합니다


6. Gradient Accumulation

시작하며

여러분이 GPT-3 크기의 모델을 파인튜닝하려는데 권장 batch size가 64인데 GPU 메모리로는 batch size 2도 어려운 상황을 상상해보세요. 새로운 GPU를 구매하거나 클라우드 비용을 늘리는 것 외에는 방법이 없어 보입니다.

이런 하드웨어 제약은 개인 연구자나 스타트업의 가장 큰 장벽입니다. 좋은 아이디어가 있어도 리소스 부족으로 실험조차 할 수 없는 경우가 많습니다.

특히 대형 언어 모델 시대에 이 문제는 더욱 심각해졌습니다. 바로 이럴 때 필요한 것이 Gradient Accumulation입니다.

작은 배치를 여러 번 처리하고 gradient를 누적하여 큰 배치와 동일한 효과를 내는 영리한 기법입니다.

개요

간단히 말해서, Gradient Accumulation은 여러 미니배치의 gradient를 메모리에 누적한 후 한 번에 파라미터를 업데이트하여 effective batch size를 키우는 기법입니다. 실무에서 이 기법은 대형 모델 학습의 필수 도구가 되었습니다.

Hugging Face의 Trainer API도 기본적으로 이를 지원하며, DeepSpeed, FairScale 같은 고급 라이브러리에서도 핵심 기능으로 포함됩니다. 예를 들어, A100 40GB GPU에서도 175B 파라미터 모델을 학습하려면 gradient accumulation 없이는 불가능합니다.

기존에는 하드웨어가 허용하는 만큼만 학습했다면, 이제는 소프트웨어 기법으로 한계를 극복합니다. 핵심 원리는 backward() 후 optimizer.step()을 매번 호출하지 않고 N번에 한 번만 호출하는 것입니다.

PyTorch는 gradient를 자동으로 누적하므로 구현이 간단합니다.

코드 예제

# Gradient Accumulation 설정
accumulation_steps = 8
effective_batch_size = batch_size * accumulation_steps

model.train()
optimizer.zero_grad()

for epoch in range(num_epochs):
    for i, batch in enumerate(train_dataloader):
        # Forward pass
        outputs = model(**batch)
        loss = outputs.loss

        # Loss를 accumulation steps로 나눔 (중요!)
        loss = loss / accumulation_steps

        # Backward pass - gradient 누적
        loss.backward()

        # N번째 배치마다 파라미터 업데이트
        if (i + 1) % accumulation_steps == 0:
            # Gradient clipping (선택)
            torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

            # 파라미터 업데이트
            optimizer.step()
            scheduler.step()
            optimizer.zero_grad()

설명

이것이 하는 일: Gradient accumulation은 작은 배치로 여러 번 backward를 수행하고 gradient를 쌓아둔 후, 설정한 횟수가 되면 한 번에 optimizer.step()으로 파라미터를 업데이트합니다. 첫 번째로, loss를 accumulation_steps로 나누는 것이 핵심입니다.

만약 이를 생략하면 gradient가 N배로 커져서 learning rate를 다시 조정해야 합니다. 예를 들어 accumulation_steps=8이면 8개 배치의 loss를 평균내기 위해 각 loss를 8로 나눕니다.

이렇게 하면 최종 gradient의 크기가 일반 batch size와 동일해집니다. 그 다음으로, backward()를 호출하면 gradient가 계산되어 각 파라미터의 .grad 속성에 누적됩니다.

optimizer.zero_grad()를 호출하지 않았으므로 이전 배치의 gradient가 그대로 유지되고, 새로운 gradient가 더해집니다. 이 과정을 accumulation_steps번 반복하면 결과적으로 큰 배치의 gradient와 동일한 값을 얻습니다.

마지막으로, (i + 1) % accumulation_steps == 0 조건이 충족되면 비로소 optimizer.step()을 호출합니다. 이때 누적된 gradient로 파라미터가 업데이트되며, scheduler.step()으로 learning rate도 조정합니다.

그리고 optimizer.zero_grad()로 gradient를 초기화하여 다음 사이클을 준비합니다. 여러분이 이 기법을 사용하면 16GB GPU로도 실질적으로 128, 256의 batch size 효과를 낼 수 있습니다.

학습 시간은 약간 증가하지만 (통신 오버헤드 없음), 메모리 제약을 완전히 극복할 수 있습니다. 논문에서 권장하는 큰 batch size를 정확히 재현할 수 있어 재현성도 향상됩니다.

실전 팁

💡 Accumulation steps는 GPU 개수의 배수로 설정하면 분산 학습과 결합하기 쉽습니다

💡 Batch Normalization 사용 시 physical batch size가 너무 작으면 통계가 불안정하므로 주의하세요

💡 Learning rate는 effective batch size에 맞춰 조정해야 하며, sqrt scaling도 고려하세요

💡 에폭 끝에서 남는 스텝이 있다면 수동으로 optimizer.step()을 호출하여 누적된 gradient를 적용하세요

💡 DDP(분산 학습)와 함께 사용하면 메모리와 속도 모두 최적화할 수 있습니다


7. Early Stopping 전략

시작하며

여러분이 모델을 10 에폭 학습시켰는데, 나중에 로그를 보니 5 에폭에서 이미 최고 성능에 도달했고 그 이후는 과적합만 진행된 경험 있으신가요? 5 에폭만 학습했어도 되는데 불필요하게 시간과 전력을 낭비한 것입니다.

이런 비효율은 실무에서 매우 흔합니다. 특히 여러 하이퍼파라미터를 시도할 때 각 실험을 끝까지 돌리면 시간이 기하급수적으로 증가합니다.

클라우드 환경에서는 직접적인 비용으로도 이어집니다. 바로 이럴 때 필요한 것이 Early Stopping 전략입니다.

Validation 성능이 개선되지 않으면 자동으로 학습을 중단하고 최고 성능 체크포인트를 선택합니다.

개요

간단히 말해서, Early Stopping은 validation 성능이 일정 기간 개선되지 않으면 학습을 조기 종료하고 최적의 모델을 보존하는 기법입니다. 실무에서 early stopping은 시간과 비용을 절약하는 동시에 과적합을 방지합니다.

대부분의 딥러닝 프레임워크가 기본적으로 지원하며, Hugging Face Trainer도 TrainingArguments에서 쉽게 설정할 수 있습니다. 예를 들어, 50 에폭 예정이었던 학습을 15 에폭에서 자동 중단하여 70%의 시간을 절약하는 경우가 흔합니다.

기존에는 모든 에폭을 학습한 후 최고 모델을 찾았다면, 이제는 실시간으로 모니터링하여 최적 시점에 멈춥니다. 핵심 하이퍼파라미터로는 patience(몇 번 개선 없으면 중단할지), min_delta(얼마나 개선되어야 인정할지), mode(최소화/최대화)가 있습니다.

일반적으로 patience=3~5가 적당합니다.

코드 예제

class EarlyStoppingCallback:
    def __init__(self, patience=3, min_delta=0.001):
        self.patience = patience
        self.min_delta = min_delta
        self.counter = 0
        self.best_score = None
        self.early_stop = False
        self.best_model_path = None

    def __call__(self, val_loss, model, epoch):
        score = -val_loss  # Loss는 낮을수록 좋으므로 음수로

        if self.best_score is None:
            self.best_score = score
            self.save_checkpoint(model, epoch)
        elif score < self.best_score + self.min_delta:
            self.counter += 1
            print(f'EarlyStopping counter: {self.counter}/{self.patience}')
            if self.counter >= self.patience:
                self.early_stop = True
        else:
            self.best_score = score
            self.save_checkpoint(model, epoch)
            self.counter = 0

    def save_checkpoint(self, model, epoch):
        self.best_model_path = f'checkpoint_epoch_{epoch}.pt'
        torch.save(model.state_dict(), self.best_model_path)

# 사용 예시
early_stopping = EarlyStoppingCallback(patience=3, min_delta=0.001)

for epoch in range(num_epochs):
    # 학습
    train_loss = train_one_epoch(model, train_loader, optimizer)
    # 검증
    val_loss = validate(model, val_loader)

    # Early stopping 체크
    early_stopping(val_loss, model, epoch)
    if early_stopping.early_stop:
        print(f"Early stopping at epoch {epoch}")
        break

설명

이것이 하는 일: Early stopping callback은 매 에폭마다 validation loss를 추적하여 개선이 멈추면 학습을 자동 중단하고 최고 성능 모델을 저장합니다. 첫 번째로, __init__에서 patience(인내 횟수)와 min_delta(최소 개선폭)를 설정합니다.

patience=3이면 3번 연속 개선 없으면 중단하고, min_delta=0.001이면 0.001 이상 개선되어야 진짜 개선으로 인정합니다. 이는 노이즈로 인한 미세한 변동을 무시하기 위함입니다.

counter는 개선 없는 에폭 수를 추적합니다. 그 다음으로, call 메서드가 매 에폭마다 호출되어 현재 validation loss를 평가합니다.

첫 에폭이거나 score가 best_score + min_delta보다 높으면 새로운 최고 성능으로 인정하고 모델을 저장합니다. 그렇지 않으면 counter를 증가시키며, counter가 patience에 도달하면 early_stop 플래그를 True로 설정합니다.

마지막으로, 학습 루프에서 매 에폭 후 early_stopping()을 호출하고 early_stop 플래그를 확인합니다. True가 되면 즉시 학습을 중단하고, 저장된 best_model_path에서 최고 성능 모델을 로드하여 사용합니다.

이렇게 하면 과적합이 시작되기 전의 최적 모델을 자동으로 선택할 수 있습니다. 여러분이 early stopping을 사용하면 하이퍼파라미터 탐색 시간이 30-50% 단축됩니다.

10개 설정을 테스트할 때 각각 평균 20 에폭씩 절약하면 총 200 에폭, 수 시간에서 수십 시간을 아낄 수 있습니다. 또한 최적 체크포인트를 자동으로 선택하므로 수동 선택의 번거로움도 없앱니다.

실전 팁

💡 Patience는 작은 데이터셋에서는 3, 큰 데이터셋에서는 5-10으로 설정하세요

💡 Min_delta를 너무 크게 하면 조기 종료가 너무 빨리 일어나므로 validation loss 스케일의 0.1-1% 정도가 적당합니다

💡 여러 메트릭을 함께 모니터링하려면 F1, accuracy 등도 추적하여 종합적으로 판단하세요

💡 학습 곡선을 시각화하면 early stopping이 적절한 시점에 작동했는지 확인할 수 있습니다

💡 Restore_best_weights 옵션으로 중단 시 자동으로 최고 모델을 복원하면 편리합니다


8. Mixed Precision Training

시작하며

여러분이 대형 모델을 학습시킬 때 GPU 메모리 부족으로 batch size를 줄이거나, 학습이 너무 느려서 실험 이터레이션이 답답한 경험 있으시죠? 더 큰 GPU를 구매하는 것 외에 성능을 2배 이상 향상시킬 방법은 없을까요?

이런 성능 병목은 특히 연구 단계에서 치명적입니다. 아이디어를 빠르게 검증해야 하는데 한 번 실험에 며칠씩 걸리면 생산성이 크게 떨어집니다.

또한 메모리 제약으로 최신 대형 모델을 시도조차 못하는 경우도 많습니다. 바로 이럴 때 필요한 것이 Mixed Precision Training입니다.

계산은 FP16으로, 누적은 FP32로 수행하여 속도는 2배, 메모리는 절반으로 줄이면서도 정확도는 거의 동일하게 유지합니다.

개요

간단히 말해서, Mixed Precision Training은 FP16(16비트 부동소수점)과 FP32(32비트)를 혼합하여 사용해 학습 속도와 메모리 효율을 극대화하는 기법입니다. 실무에서 modern GPU(V100, A100, RTX 30xx 이상)는 FP16 연산을 FP32보다 2-3배 빠르게 처리합니다.

Hugging Face, PyTorch Lightning 등 주요 라이브러리가 모두 기본 지원하며, 단 몇 줄로 적용할 수 있습니다. 예를 들어, BERT-large 파인튜닝 시 batch size를 16에서 32로 늘리고 학습 시간은 40% 단축할 수 있습니다.

기존에는 모든 연산을 FP32로 했다면, 이제는 연산은 FP16, 마스터 카피는 FP32로 유지합니다. 핵심 구성 요소는 automatic mixed precision (AMP), gradient scaling, loss scaling입니다.

Gradient scaling은 FP16의 낮은 표현 범위로 인한 underflow를 방지하는 핵심 기법입니다.

코드 예제

import torch
from torch.cuda.amp import autocast, GradScaler

# Mixed Precision Training 설정
model = model.cuda()
optimizer = AdamW(model.parameters(), lr=5e-5)
scaler = GradScaler()  # Gradient scaling for FP16

for epoch in range(num_epochs):
    for batch in train_dataloader:
        optimizer.zero_grad()

        # autocast: FP16으로 forward pass
        with autocast():
            outputs = model(**batch)
            loss = outputs.loss

        # Gradient scaling으로 backward
        scaler.scale(loss).backward()

        # Gradient unscaling & clipping
        scaler.unscale_(optimizer)
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

        # Optimizer step with scaling
        scaler.step(optimizer)
        scaler.update()

# Hugging Face Trainer에서는 더 간단
from transformers import TrainingArguments

training_args = TrainingArguments(
    fp16=True,  # Mixed precision 활성화
    # 나머지 설정...
)

설명

이것이 하는 일: Mixed precision training은 forward/backward pass를 FP16으로 수행하여 속도를 높이고, gradient scaling으로 수치 안정성을 보장하며, 마스터 가중치는 FP32로 유지하여 정확도를 보존합니다. 첫 번째로, GradScaler를 초기화합니다.

이는 FP16의 작은 표현 범위 문제를 해결하는 핵심 도구입니다. FP16은 약 6e-8 이하의 값을 0으로 만들어버리는데(underflow), gradient가 이 범위에 들면 학습이 멈춥니다.

Scaler는 loss를 큰 값으로 스케일링하여 이를 방지합니다. 그 다음으로, autocast() 컨텍스트 안에서 forward pass를 수행합니다.

이 블록 내의 모든 연산은 자동으로 FP16으로 변환되어 실행됩니다. Matmul, convolution 같은 계산 집약적 연산은 FP16으로, layer norm 같은 정밀도가 필요한 연산은 FP32로 자동 선택됩니다.

이렇게 하면 속도 이득은 최대화하고 정확도 손실은 최소화합니다. 마지막으로, scaler.scale(loss).backward()로 스케일된 loss로 backward를 수행합니다.

그 다음 unscale_()로 gradient를 원래 크기로 되돌린 후 clipping을 적용하고, scaler.step()으로 optimizer를 업데이트합니다. scaler.update()는 내부 스케일 팩터를 자동 조정하여 최적의 스케일을 유지합니다.

여러분이 mixed precision을 사용하면 Ampere 아키텍처(A100, RTX 30xx) GPU에서 2-3배, Volta(V100)에서 1.5-2배의 속도 향상을 얻습니다. 메모리는 약 50% 절감되어 batch size를 2배 늘릴 수 있고, 이는 다시 학습 안정성 향상으로 이어집니다.

최종 성능은 FP32와 거의 동일하며, 대부분 경우 0.1% 이내의 차이만 있습니다.

실전 팁

💡 Turing 아키텍처(RTX 20xx) 이상 GPU에서만 의미 있는 속도 향상이 있으니 GPU 세대를 확인하세요

💡 Loss가 NaN이 되면 scaler의 init_scale을 낮춰보세요 (기본 2^16에서 2^10으로)

💡 Batch Normalization보다 Layer Normalization이 mixed precision과 더 잘 맞습니다

💡 FP16으로 저장된 체크포인트는 절반 크기이므로 디스크 공간도 절약됩니다

💡 Inference 시에도 FP16을 사용하면 서빙 비용을 크게 줄일 수 있습니다


#AI#Hyperparameter#LearningRate#Optimizer#FineTuning

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.