이미지 로딩 중...
AI Generated
2025. 11. 24. · 7 Views
GPU 활용 및 학습 최적화 완벽 가이드
딥러닝 모델 학습 시 GPU를 효율적으로 활용하고, 학습 속도를 극대화하는 방법을 알아봅니다. 초급 개발자도 쉽게 따라할 수 있는 실전 최적화 기법을 다룹니다.
목차
- GPU 사용 설정하기 - 딥러닝의 첫 걸음
- 혼합 정밀도 학습 - 속도와 메모리를 동시에
- DataLoader 최적화 - 데이터 병목 해소하기
- Gradient Accumulation - 작은 GPU로 큰 배치 학습하기
- Learning Rate Scheduler - 학습률 동적 조정하기
- Gradient Clipping - 폭발하는 Gradient 방지하기
- Model Checkpointing - 학습 중간 저장 및 복구
- Early Stopping - 과적합 방지 자동 중단
1. GPU 사용 설정하기 - 딥러닝의 첫 걸음
시작하며
여러분이 처음으로 딥러닝 모델을 학습시키려고 할 때, 수 시간씩 걸리는 학습 시간에 좌절한 적 있나요? CPU로 학습하면 하루 종일 기다려도 끝나지 않는 상황을 경험하셨을 겁니다.
이런 문제는 딥러닝을 시작하는 모든 개발자들이 겪는 공통적인 어려움입니다. CPU는 순차적으로 연산을 처리하기 때문에, 수백만 개의 파라미터를 가진 신경망을 학습시키기에는 너무 느립니다.
바로 이럴 때 필요한 것이 GPU(Graphics Processing Unit) 활용입니다. GPU를 사용하면 병렬 연산을 통해 학습 속도를 10배에서 100배까지 향상시킬 수 있습니다.
개요
간단히 말해서, GPU는 수천 개의 작은 코어를 가지고 있어서 동시에 많은 계산을 처리할 수 있는 하드웨어입니다. 딥러닝 학습은 행렬 곱셈 같은 반복적인 연산을 엄청나게 많이 수행해야 합니다.
예를 들어, 1000x1000 크기의 행렬 곱셈을 하려면 100만 번의 곱셈이 필요한데, GPU는 이런 작업을 동시에 처리할 수 있습니다. 기존에는 CPU로 한 번에 하나씩 계산했다면, 이제는 GPU로 수천 개의 계산을 동시에 할 수 있습니다.
PyTorch나 TensorFlow 같은 딥러닝 프레임워크는 GPU를 쉽게 사용할 수 있도록 간단한 API를 제공합니다. 단 몇 줄의 코드만으로 모델과 데이터를 GPU로 옮길 수 있습니다.
이러한 간편함이 여러분의 개발 생산성을 크게 높여줍니다.
코드 예제
import torch
import torch.nn as nn
# GPU가 사용 가능한지 확인
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'사용 중인 디바이스: {device}')
# 모델 생성 및 GPU로 이동
model = nn.Sequential(
nn.Linear(784, 256),
nn.ReLU(),
nn.Linear(256, 10)
).to(device)
# 데이터도 GPU로 이동
x = torch.randn(32, 784).to(device)
output = model(x)
설명
이것이 하는 일: 이 코드는 여러분의 컴퓨터에 GPU가 있는지 확인하고, 신경망 모델과 학습 데이터를 GPU 메모리로 옮겨서 빠른 연산이 가능하도록 준비합니다. 첫 번째로, torch.cuda.is_available()은 NVIDIA GPU와 CUDA 드라이버가 제대로 설치되어 있는지 체크합니다.
이것은 마치 여러분이 요리하기 전에 가스레인지가 제대로 작동하는지 확인하는 것과 같습니다. GPU가 있으면 'cuda'를, 없으면 'cpu'를 device 변수에 저장합니다.
그 다음으로, .to(device) 메서드가 실행되면서 모델의 모든 가중치(weights)와 편향(bias)이 GPU 메모리로 복사됩니다. 내부적으로는 CPU 메모리에 있던 데이터를 GPU 메모리로 전송하는 과정이 일어납니다.
이것은 책상 위에 있던 재료를 요리하기 편한 조리대로 옮기는 것과 비슷합니다. 마지막으로, 입력 데이터 x도 .to(device)를 통해 GPU로 옮겨집니다.
이제 모델과 데이터가 모두 같은 장소(GPU)에 있으므로, forward pass 계산이 GPU에서 초고속으로 실행됩니다. 최종적으로 출력(output)도 GPU 메모리에 저장되어 다음 연산에 바로 사용될 수 있습니다.
여러분이 이 코드를 사용하면 CPU 대비 10배에서 100배 빠른 학습 속도를 얻을 수 있습니다. 특히 대규모 데이터셋이나 복잡한 모델을 다룰 때 시간과 비용을 크게 절약할 수 있고, 더 많은 실험을 빠르게 시도해볼 수 있어 모델 성능 개선에 집중할 수 있습니다.
실전 팁
💡 항상 모델과 데이터를 같은 device에 두세요. 하나는 CPU에, 하나는 GPU에 있으면 에러가 발생합니다.
💡 GPU 메모리가 부족하면 배치 크기를 줄이세요. 한 번에 처리하는 데이터 양을 32에서 16으로 줄이면 메모리 사용량이 절반으로 줄어듭니다.
💡 학습 전에 torch.cuda.empty_cache()로 GPU 메모리를 정리하면 메모리 부족 오류를 예방할 수 있습니다.
💡 nvidia-smi 명령어로 GPU 사용률과 메모리 상태를 실시간으로 모니터링하세요. 터미널에서 간단히 확인할 수 있습니다.
💡 여러 개의 GPU가 있다면 특정 GPU를 선택할 수 있습니다: torch.device('cuda:0')은 첫 번째 GPU, torch.device('cuda:1')은 두 번째 GPU를 의미합니다.
2. 혼합 정밀도 학습 - 속도와 메모리를 동시에
시작하며
여러분이 대형 모델을 학습시키다가 "CUDA out of memory" 오류를 본 적 있나요? GPU 메모리 부족은 딥러닝 개발자들이 가장 자주 마주치는 벽입니다.
이런 문제는 모델이 점점 커지면서 더욱 심각해집니다. 기본적으로 PyTorch는 32비트 부동소수점(float32)을 사용하는데, 이것은 메모리를 많이 차지합니다.
10억 개의 파라미터를 가진 모델은 기본적으로 4GB 이상의 메모리를 필요로 합니다. 바로 이럴 때 필요한 것이 혼합 정밀도 학습(Mixed Precision Training)입니다.
16비트 부동소수점(float16)과 32비트를 적절히 섞어 사용하면 메모리 사용량을 절반으로 줄이고 학습 속도는 2-3배 높일 수 있습니다.
개요
간단히 말해서, 혼합 정밀도 학습은 계산의 대부분을 16비트로 수행하고, 정밀도가 중요한 부분만 32비트로 처리하는 최적화 기법입니다. 딥러닝 학습에서는 대부분의 계산이 근사값으로도 충분합니다.
예를 들어, 가중치 업데이트 시 3.141592와 3.141이 큰 차이를 만들지 않습니다. 하지만 손실 함수 계산이나 가중치 누적 같은 경우에는 정밀도가 중요하므로 32비트를 사용합니다.
기존에는 모든 연산을 32비트로 했다면, 이제는 forward/backward pass는 16비트로, 최종 가중치 업데이트는 32비트로 할 수 있습니다. PyTorch의 Automatic Mixed Precision(AMP)은 이 모든 과정을 자동으로 처리해줍니다.
GradScaler는 16비트 연산 시 발생할 수 있는 언더플로우(매우 작은 값이 0으로 되는 현상)를 방지하기 위해 gradient를 적절히 스케일링합니다. 이러한 자동화가 여러분이 복잡한 수치 안정성 문제를 고민하지 않고도 최적화의 이점을 누릴 수 있게 해줍니다.
코드 예제
import torch
from torch.cuda.amp import autocast, GradScaler
model = MyModel().cuda()
optimizer = torch.optim.Adam(model.parameters())
scaler = GradScaler()
for epoch in range(epochs):
for batch in dataloader:
optimizer.zero_grad()
# 16비트 연산을 자동으로 수행
with autocast():
output = model(batch)
loss = criterion(output, target)
# Gradient 스케일링 및 역전파
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
설명
이것이 하는 일: 이 코드는 학습 과정에서 자동으로 적절한 연산을 16비트로 변환하고, gradient의 수치 안정성을 유지하면서 메모리와 속도를 최적화합니다. 첫 번째로, GradScaler 객체를 생성합니다.
이것은 gradient 값을 추적하고 필요할 때 스케일링(크기 조정)을 수행하는 도구입니다. 마치 음량 조절기처럼, gradient가 너무 작아서 사라지려고 하면 크게 만들어주고, 업데이트할 때는 다시 원래 크기로 되돌립니다.
그 다음으로, with autocast() 블록 안에서 실행되는 모든 연산이 자동으로 분석됩니다. PyTorch는 각 연산의 특성을 파악해서 행렬 곱셈 같은 연산은 16비트로, 손실 함수 계산은 32비트로 자동 변환합니다.
내부적으로는 텐서의 dtype을 동적으로 변경하면서도 사용자는 이를 신경 쓸 필요가 없습니다. 마지막으로, scaler.scale(loss).backward()가 gradient를 계산하되, 16비트의 작은 범위 문제를 해결하기 위해 gradient에 큰 수를 곱합니다.
그리고 scaler.step(optimizer)에서 실제 가중치를 업데이트할 때는 다시 원래 크기로 나누어 정확한 업데이트를 수행합니다. scaler.update()는 스케일 factor를 동적으로 조정해 최적의 성능을 유지합니다.
여러분이 이 코드를 사용하면 같은 GPU에서 2배 큰 배치 크기를 사용할 수 있고, 학습 시간은 30-50% 단축됩니다. 특히 NVIDIA V100, A100 같은 최신 GPU에서는 Tensor Core라는 특수 하드웨어가 16비트 연산을 가속화해서 더욱 큰 성능 향상을 체험할 수 있습니다.
실전 팁
💡 모든 모델이 혼합 정밀도로 이점을 보는 것은 아닙니다. CNN이나 Transformer 같은 대형 모델에서 효과가 큽니다.
💡 학습이 불안정하다면 scaler의 초기 scale 값을 조정해보세요: GradScaler(init_scale=2.**10)처럼 설정할 수 있습니다.
💡 배치 정규화(BatchNorm)는 자동으로 32비트로 처리되므로 수치 안정성을 걱정하지 않아도 됩니다.
💡 inference 시에도 autocast()를 사용하면 추론 속도를 높일 수 있습니다. 메모리도 적게 사용해 더 큰 배치로 처리 가능합니다.
💡 loss가 NaN이 되면 scaler가 자동으로 해당 step을 건너뛰고 scale을 줄입니다. 이는 학습 안정성을 크게 향상시킵니다.
3. DataLoader 최적화 - 데이터 병목 해소하기
시작하며
여러분이 GPU 사용률을 모니터링했을 때 50% 미만으로 나오는 경험을 해보셨나요? GPU는 빠른데 학습이 느리다면, 문제는 GPU가 아니라 데이터 공급입니다.
이런 문제는 특히 이미지나 비디오 같은 대용량 데이터를 다룰 때 자주 발생합니다. GPU는 이미 계산을 끝냈는데, CPU가 다음 배치를 준비하느라 GPU가 놀고 있는 상황이 생깁니다.
이것은 마치 빠른 요리사(GPU)가 재료(데이터)를 기다리며 시간을 낭비하는 것과 같습니다. 바로 이럴 때 필요한 것이 DataLoader 최적화입니다.
멀티프로세싱과 메모리 피닝을 활용하면 GPU가 항상 일할 수 있도록 데이터를 미리 준비할 수 있습니다.
개요
간단히 말해서, DataLoader는 학습 데이터를 효율적으로 불러오고 배치로 만들어서 모델에 공급하는 역할을 합니다. 데이터 로딩은 생각보다 복잡한 과정입니다.
디스크에서 파일을 읽고, 이미지를 디코딩하고, 전처리(크기 조정, 정규화 등)를 수행하고, 배치로 묶는 작업이 필요합니다. 예를 들어, ImageNet 데이터셋은 수백만 장의 이미지를 실시간으로 처리해야 하는데, 이를 순차적으로 하면 GPU가 대부분의 시간을 대기하게 됩니다.
기존에는 한 번에 하나씩 데이터를 처리했다면, 이제는 여러 워커(worker) 프로세스가 동시에 데이터를 준비할 수 있습니다. num_workers 매개변수는 데이터를 병렬로 불러올 프로세스 개수를 지정합니다.
pin_memory=True는 데이터를 CPU의 페이지 잠금 메모리에 올려서 GPU 전송 속도를 높입니다. prefetch_factor는 각 워커가 미리 준비할 배치 개수를 설정합니다.
이러한 옵션들이 조화롭게 작동하면 GPU가 쉬지 않고 계속 학습할 수 있게 됩니다.
코드 예제
from torch.utils.data import DataLoader, Dataset
# 커스텀 데이터셋
class MyDataset(Dataset):
def __init__(self, data):
self.data = data
def __getitem__(self, idx):
# 데이터 로딩 및 전처리
return self.data[idx]
def __len__(self):
return len(self.data)
# 최적화된 DataLoader 설정
dataloader = DataLoader(
dataset,
batch_size=64,
num_workers=4, # CPU 코어의 절반 정도 권장
pin_memory=True, # GPU 전송 속도 향상
prefetch_factor=2 # 미리 준비할 배치 수
)
설명
이것이 하는 일: 이 코드는 여러 프로세스를 사용해 데이터를 병렬로 준비하고, GPU로의 전송을 최적화해서 학습 과정에서 데이터 대기 시간을 최소화합니다. 첫 번째로, num_workers=4 설정은 4개의 별도 프로세스를 생성합니다.
각 프로세스는 독립적으로 디스크에서 데이터를 읽고 전처리를 수행합니다. 이것은 마치 4명의 주방 보조가 동시에 재료를 준비하는 것과 같습니다.
메인 프로세스(학습을 실행하는 프로세스)는 이 워커들이 준비한 데이터를 순서대로 받아 GPU로 보냅니다. 그 다음으로, pin_memory=True가 설정되면 데이터가 CPU의 페이지 잠금 메모리(pinned memory)에 할당됩니다.
일반 메모리는 운영체제가 디스크로 스왑할 수 있지만, 페이지 잠금 메모리는 항상 RAM에 고정되어 있습니다. 내부적으로 CUDA는 이런 메모리에서 GPU로 데이터를 전송할 때 DMA(Direct Memory Access)를 사용해 CPU 개입 없이 직접 전송합니다.
마지막으로, prefetch_factor=2는 각 워커가 현재 처리 중인 배치 외에 추가로 2개의 배치를 미리 준비하도록 합니다. GPU가 현재 배치로 학습하는 동안, 워커들은 이미 다음 배치들을 준비해둡니다.
최종적으로 GPU는 거의 대기 시간 없이 연속적으로 데이터를 받아 학습할 수 있습니다. 여러분이 이 코드를 사용하면 GPU 활용률이 50-60%에서 90% 이상으로 올라가는 것을 확인할 수 있습니다.
특히 이미지 전처리가 복잡하거나 데이터셋이 클 때 학습 시간을 30-40% 단축할 수 있고, 같은 시간에 더 많은 에폭을 학습해 더 나은 모델을 얻을 수 있습니다.
실전 팁
💡 num_workers는 CPU 코어 수의 절반 정도가 적당합니다. 너무 많으면 오히려 컨텍스트 스위칭 오버헤드로 느려집니다.
💡 Windows에서는 num_workers > 0일 때 if name == 'main': 블록 안에서 코드를 실행해야 멀티프로세싱 오류를 피할 수 있습니다.
💡 persistent_workers=True를 추가하면 에폭마다 워커를 재생성하지 않아 시작 시간이 빨라집니다.
💡 데이터셋이 작다면 num_workers=0으로 설정하세요. 멀티프로세싱 오버헤드가 오히려 더 느릴 수 있습니다.
💡 메모리 부족 시 prefetch_factor를 줄이거나 num_workers를 줄여보세요. 각 워커가 메모리를 추가로 사용하기 때문입니다.
4. Gradient Accumulation - 작은 GPU로 큰 배치 학습하기
시작하며
여러분이 논문에서 배치 크기 128로 학습했다는 걸 보고 따라 하려다가 메모리 부족 오류를 만난 적 있나요? GPU 메모리가 부족해서 큰 배치를 사용하지 못하는 것은 개인 연구자들의 가장 큰 고민입니다.
이런 문제는 최신 대형 모델일수록 더 심각합니다. BERT나 GPT 같은 모델은 배치 크기가 클수록 학습이 안정적이고 성능도 좋지만, 이를 위해서는 수십 GB의 GPU 메모리가 필요합니다.
하지만 대부분의 개발자는 8GB나 16GB GPU를 사용합니다. 바로 이럴 때 필요한 것이 Gradient Accumulation입니다.
작은 배치로 여러 번 forward/backward를 수행하고 gradient를 누적한 뒤 한 번에 업데이트하면, 큰 배치와 동일한 효과를 얻을 수 있습니다.
개요
간단히 말해서, Gradient Accumulation은 여러 개의 작은 배치로부터 계산된 gradient를 합쳐서 마치 큰 배치를 사용한 것처럼 만드는 기법입니다. 수학적으로 배치 크기 128의 gradient는 배치 크기 32를 4번 계산해서 평균 낸 것과 동일합니다.
예를 들어, 배치 128을 한 번에 처리할 메모리가 없다면, 배치 32를 4번 처리하고 gradient를 누적한 뒤 평균을 내면 됩니다. 최종 가중치 업데이트는 동일합니다.
기존에는 큰 메모리가 필요한 큰 배치를 한 번에 처리했다면, 이제는 작은 배치를 여러 번 처리하고 결과를 모아서 사용할 수 있습니다. 이 방법의 핵심은 optimizer.step()을 매 배치마다가 아니라 여러 배치를 처리한 후에 호출하는 것입니다.
optimizer.zero_grad()도 마찬가지로 업데이트 후에만 호출합니다. accumulation_steps 매개변수로 몇 번의 배치를 누적할지 조절할 수 있습니다.
이러한 간단한 수정만으로 제한된 GPU 메모리에서도 대형 모델을 효과적으로 학습할 수 있습니다.
코드 예제
model = MyModel().cuda()
optimizer = torch.optim.Adam(model.parameters())
accumulation_steps = 4 # 4번 누적 후 업데이트
optimizer.zero_grad()
for i, (inputs, labels) in enumerate(dataloader):
# Forward pass
outputs = model(inputs)
loss = criterion(outputs, labels)
# 누적할 배치 수로 나누기
loss = loss / accumulation_steps
loss.backward()
# accumulation_steps마다 업데이트
if (i + 1) % accumulation_steps == 0:
optimizer.step()
optimizer.zero_grad()
설명
이것이 하는 일: 이 코드는 제한된 GPU 메모리로 큰 배치 크기의 학습 효과를 얻기 위해, 작은 배치들의 gradient를 누적하고 일정 횟수마다 한 번씩 가중치를 업데이트합니다. 첫 번째로, accumulation_steps=4 설정은 4개의 미니배치 gradient를 모아서 한 번 업데이트하겠다는 의미입니다.
그리고 loss를 accumulation_steps로 나누는데, 이것은 매우 중요합니다. PyTorch의 backward()는 gradient를 누적하기 때문에, 나누지 않으면 gradient가 4배 커져서 학습이 불안정해집니다.
평균을 내기 위해 미리 loss를 나누는 것입니다. 그 다음으로, 매 iteration마다 loss.backward()가 호출되지만 optimizer.step()은 호출되지 않습니다.
backward()는 각 파라미터의 .grad 속성에 gradient를 계속 더합니다. 예를 들어, 첫 번째 배치에서 grad=0.1, 두 번째에서 0.15가 계산되면 .grad는 0.25가 됩니다.
이렇게 4번 누적하면 4개 배치의 평균 gradient가 저장됩니다. 마지막으로, (i + 1) % accumulation_steps == 0 조건이 True가 되는 시점(4번째, 8번째, 12번째...
iteration)에서 optimizer.step()이 실행되어 누적된 gradient로 가중치를 업데이트합니다. 그리고 즉시 optimizer.zero_grad()로 gradient를 0으로 초기화해서 다음 누적을 준비합니다.
최종적으로 실제 배치 크기가 32라면 effective batch size는 32 × 4 = 128이 됩니다. 여러분이 이 코드를 사용하면 8GB GPU에서도 논문에서 사용한 큰 배치 크기로 학습할 수 있습니다.
배치 크기가 커지면 학습이 안정적이고, 배치 정규화도 더 정확하게 작동하며, 일반화 성능도 향상되는 경우가 많습니다. 단, 같은 에폭을 완료하는 시간은 약간 늘어날 수 있지만 최종 모델 품질 향상이 이를 보상합니다.
실전 팁
💡 accumulation_steps를 늘리면 메모리는 절약되지만 학습 속도는 느려집니다. 적절한 균형을 찾으세요.
💡 배치 정규화를 사용한다면 실제 배치 크기(accumulation 전)가 너무 작으면 통계가 부정확해집니다. 최소 16 이상 유지하세요.
💡 learning rate는 effective batch size에 비례해서 조정해야 합니다. 배치가 2배 커지면 lr도 2배 늘리는 것이 일반적입니다.
💡 마지막 iteration이 accumulation_steps로 나누어떨어지지 않으면 마지막에 한 번 더 optimizer.step()을 호출해야 합니다.
💡 혼합 정밀도와 함께 사용하면 더욱 큰 효과를 봅니다. scaler.scale(loss)를 사용하되, loss를 accumulation_steps로 나누는 것을 잊지 마세요.
5. Learning Rate Scheduler - 학습률 동적 조정하기
시작하며
여러분이 모델을 학습시킬 때 초반엔 loss가 빠르게 떨어지다가 나중에는 거의 변화가 없는 경험을 해보셨나요? 고정된 learning rate로는 최적의 성능을 내기 어렵습니다.
이런 문제는 학습의 각 단계마다 필요한 learning rate가 다르기 때문입니다. 초반에는 빠르게 좋은 영역으로 이동해야 하므로 큰 learning rate가 필요하지만, 후반에는 최적점 근처에서 세밀하게 조정해야 하므로 작은 learning rate가 필요합니다.
하나의 고정값으로는 이 둘을 만족시킬 수 없습니다. 바로 이럴 때 필요한 것이 Learning Rate Scheduler입니다.
학습 진행에 따라 learning rate를 자동으로 조정하면 더 빠르고 안정적으로 좋은 성능에 도달할 수 있습니다.
개요
간단히 말해서, Learning Rate Scheduler는 에폭이나 iteration에 따라 learning rate를 미리 정의된 규칙에 따라 변경하는 도구입니다. Learning rate는 딥러닝에서 가장 중요한 하이퍼파라미터입니다.
너무 크면 최적점을 지나쳐 발산하고, 너무 작으면 학습이 느리거나 local minimum에 갇힙니다. 예를 들어, 초기에 lr=0.1로 시작해서 50 에폭 후 0.01로, 100 에폭 후 0.001로 줄이면 초반엔 빠르게 학습하고 후반엔 세밀하게 최적화할 수 있습니다.
기존에는 수동으로 learning rate를 바꾸거나 고정값을 사용했다면, 이제는 스케줄러가 자동으로 최적의 시점에 조정할 수 있습니다. PyTorch는 다양한 스케줄러를 제공합니다.
StepLR은 일정 에폭마다 lr을 감소시키고, CosineAnnealingLR은 코사인 함수처럼 부드럽게 감소시키며, ReduceLROnPlateau는 검증 loss가 개선되지 않으면 lr을 줄입니다. 이러한 다양한 옵션이 여러분의 모델과 데이터셋 특성에 맞는 최적의 학습 전략을 선택할 수 있게 해줍니다.
코드 예제
import torch.optim as optim
from torch.optim.lr_scheduler import CosineAnnealingLR
model = MyModel().cuda()
optimizer = optim.Adam(model.parameters(), lr=0.001)
# 코사인 어닐링 스케줄러
scheduler = CosineAnnealingLR(optimizer, T_max=100, eta_min=1e-6)
for epoch in range(100):
train_one_epoch(model, dataloader, optimizer)
# 에폭마다 learning rate 업데이트
scheduler.step()
# 현재 learning rate 확인
current_lr = optimizer.param_groups[0]['lr']
print(f'Epoch {epoch}, LR: {current_lr:.6f}')
설명
이것이 하는 일: 이 코드는 학습 과정에서 learning rate를 코사인 함수 형태로 점진적으로 감소시켜, 초반엔 빠르게 학습하고 후반엔 세밀하게 최적화하도록 합니다. 첫 번째로, CosineAnnealingLR 객체 생성 시 T_max=100은 100 에폭에 걸쳐 lr을 조정하겠다는 의미입니다.
eta_min=1e-6은 lr의 최소값을 지정합니다. 즉, 초기 lr=0.001에서 시작해서 코사인 곡선을 따라 점점 감소하다가 100 에폭째에 0.000001에 도달합니다.
코사인 함수의 특성상 초반에는 빠르게 감소하고 후반으로 갈수록 감소 속도가 느려집니다. 그 다음으로, 매 에폭이 끝날 때마다 scheduler.step()이 호출됩니다.
이 메서드는 내부적으로 현재 에폭 번호를 확인하고, 코사인 함수 공식에 대입해서 새로운 lr 값을 계산합니다. 그리고 optimizer.param_groups[0]['lr']에 이 값을 저장합니다.
param_groups는 optimizer가 관리하는 파라미터 그룹들의 리스트인데, 각 그룹은 자신만의 lr을 가질 수 있습니다. 마지막으로, 업데이트된 lr은 다음 에폭의 optimizer.step() 호출 시 적용됩니다.
예를 들어, 50 에폭째에 lr이 0.0005로 줄었다면, 다음 가중치 업데이트는 weight = weight - 0.0005 * gradient 형태로 이루어집니다. 최종적으로 학습이 진행될수록 더 작은 스텝으로 가중치를 조정하게 되어 최적점에 안정적으로 수렴합니다.
여러분이 이 코드를 사용하면 수동으로 lr을 조정하는 번거로움 없이 자동으로 최적의 학습 곡선을 얻을 수 있습니다. 특히 CosineAnnealing은 많은 논문에서 검증된 방법으로, 대부분의 경우 고정 lr보다 1-3% 높은 정확도를 달성합니다.
warm restart 같은 고급 기법과 결합하면 더욱 강력한 효과를 볼 수 있습니다.
실전 팁
💡 ReduceLROnPlateau를 사용하면 검증 loss 기준으로 자동 조정됩니다. scheduler.step(val_loss)처럼 loss를 전달하세요.
💡 여러 스케줄러를 결합할 수 있습니다. 예를 들어, WarmUp 후 CosineAnnealing을 적용하면 더 안정적입니다.
💡 scheduler.step()을 optimizer.step() 후에 호출해야 합니다. 순서가 바뀌면 첫 에폭의 lr이 잘못 적용됩니다.
💡 TensorBoard나 wandb로 lr 변화를 시각화하면 학습 과정을 더 잘 이해할 수 있습니다.
💡 transfer learning 시 backbone과 head에 다른 lr을 적용하려면 optimizer에 여러 param_groups를 만들고 각각 스케줄러를 적용하세요.
6. Gradient Clipping - 폭발하는 Gradient 방지하기
시작하며
여러분이 RNN이나 Transformer를 학습시키다가 갑자기 loss가 NaN이 되는 경험을 해보셨나요? 이것은 gradient가 너무 커져서 발생하는 gradient explosion 문제입니다.
이런 문제는 특히 순환 신경망이나 깊은 네트워크에서 자주 발생합니다. Backpropagation 과정에서 gradient가 여러 층을 거치면서 지수적으로 커질 수 있습니다.
예를 들어, 각 층에서 gradient에 1.1을 곱한다면 100층을 거치면 1.1^100 ≈ 13,780배가 됩니다. 이렇게 폭발한 gradient로 가중치를 업데이트하면 모델이 완전히 망가집니다.
바로 이럴 때 필요한 것이 Gradient Clipping입니다. Gradient의 크기를 제한하면 안정적인 학습이 가능하고, 특히 RNN 계열 모델에서는 필수적인 기법입니다.
개요
간단히 말해서, Gradient Clipping은 계산된 gradient의 크기(norm)가 특정 임계값을 넘으면 자동으로 스케일을 줄여서 안전한 범위로 만드는 기법입니다. Gradient의 크기는 모든 파라미터의 gradient를 벡터로 봤을 때의 L2 norm(유클리드 거리)으로 측정합니다.
예를 들어, 전체 gradient norm이 100인데 max_norm=1.0으로 설정했다면, 모든 gradient를 100으로 나누어 norm을 1.0으로 만듭니다. 이렇게 하면 방향은 유지하면서 크기만 줄일 수 있습니다.
기존에는 큰 gradient가 그대로 적용되어 학습이 불안정했다면, 이제는 안전한 범위 내에서만 업데이트할 수 있습니다. PyTorch의 torch.nn.utils.clip_grad_norm_() 함수는 모델의 모든 파라미터에 대해 자동으로 clipping을 수행합니다.
max_norm 매개변수로 허용할 최대 norm을 지정하고, norm_type으로 L1, L2 등을 선택할 수 있습니다. 이러한 간단한 한 줄 추가만으로 학습 안정성을 크게 향상시킬 수 있습니다.
코드 예제
import torch.nn.utils as utils
model = MyModel().cuda()
optimizer = torch.optim.Adam(model.parameters())
max_grad_norm = 1.0 # 최대 gradient norm
for epoch in range(epochs):
for batch in dataloader:
optimizer.zero_grad()
output = model(batch)
loss = criterion(output, target)
loss.backward()
# Gradient clipping 적용
utils.clip_grad_norm_(model.parameters(), max_grad_norm)
optimizer.step()
설명
이것이 하는 일: 이 코드는 역전파로 계산된 모든 gradient의 전체 크기를 확인하고, 설정된 임계값을 초과하면 자동으로 축소해서 학습 안정성을 보장합니다. 첫 번째로, loss.backward()가 실행되면 모든 파라미터의 .grad 속성에 gradient가 계산되어 저장됩니다.
이 시점에서는 아직 clipping이 적용되지 않은 원본 gradient입니다. 수만 개 또는 수억 개의 파라미터 각각이 자신의 gradient를 가지고 있습니다.
그 다음으로, clip_grad_norm_() 함수가 호출되면 내부적으로 모든 파라미터의 gradient를 하나의 거대한 벡터로 생각하고 L2 norm을 계산합니다. 수식으로는 sqrt(sum(p.grad^2 for all p))입니다.
예를 들어, 계산된 norm이 5.0이고 max_grad_norm이 1.0이라면, clip_coef = 1.0 / 5.0 = 0.2가 계산됩니다. 마지막으로, norm이 임계값을 초과한 경우 모든 파라미터의 gradient에 clip_coef를 곱합니다.
즉, 각 p.grad = p.grad * 0.2가 적용되어 전체 norm이 정확히 1.0이 됩니다. 방향은 그대로이지만 크기만 1/5로 줄어든 것입니다.
optimizer.step()은 이렇게 clipping된 안전한 gradient로 가중치를 업데이트합니다. 여러분이 이 코드를 사용하면 RNN, LSTM, Transformer 같은 모델을 학습할 때 NaN 오류를 거의 겪지 않게 됩니다.
특히 긴 시퀀스를 다루거나 깊은 네트워크를 학습할 때 필수적입니다. 안정적인 학습으로 더 높은 최종 성능에 도달할 수 있고, 하이퍼파라미터 튜닝도 훨씬 쉬워집니다.
실전 팁
💡 max_grad_norm 값은 보통 0.5~5.0 사이를 사용합니다. 1.0부터 시작해서 조정하세요.
💡 clipping이 너무 자주 발생하면 learning rate가 너무 큰 것일 수 있습니다. 둘을 함께 조정하세요.
💡 clip_grad_value_()도 있는데 이것은 각 gradient를 개별적으로 제한합니다. 보통 clip_grad_norm_()이 더 효과적입니다.
💡 clipping 발생 빈도를 로깅하면 학습 상태를 파악하는 데 도움이 됩니다. norm 값이 항상 max를 초과한다면 문제가 있는 것입니다.
💡 배치 정규화나 LayerNorm을 사용하면 gradient explosion을 어느 정도 완화할 수 있지만, clipping과 함께 사용하는 것이 가장 안전합니다.
7. Model Checkpointing - 학습 중간 저장 및 복구
시작하며
여러분이 3일 동안 모델을 학습시키다가 정전이나 서버 오류로 모든 것을 잃은 경험이 있나요? 긴 학습 시간이 필요한 딥러닝에서 중간 저장은 필수입니다.
이런 문제는 클라우드 GPU를 사용할 때 더욱 심각합니다. 시간당 비용을 내는데 갑작스러운 중단으로 모든 비용과 시간이 낭비될 수 있습니다.
또한 검증 성능이 가장 좋았던 시점의 모델을 저장하지 않으면, 마지막 에폭의 과적합된 모델만 남게 됩니다. 바로 이럴 때 필요한 것이 Model Checkpointing입니다.
주기적으로 모델 상태를 저장하고, 최고 성능 모델을 따로 보관하며, 중단 시점부터 학습을 재개할 수 있습니다.
개요
간단히 말해서, Checkpointing은 모델의 가중치, optimizer 상태, 현재 에폭 등 학습 재개에 필요한 모든 정보를 파일로 저장하는 것입니다. PyTorch 모델의 상태는 state_dict()로 얻을 수 있는데, 이것은 모든 파라미터의 이름과 값을 담은 딕셔너리입니다.
하지만 학습을 재개하려면 모델뿐 아니라 optimizer의 momentum이나 Adam의 moving average 같은 정보도 필요합니다. 예를 들어, 100 에폭째에서 중단되었다면 optimizer의 내부 상태도 그 시점 그대로 복원되어야 정확하게 학습을 이어갈 수 있습니다.
기존에는 학습이 끝난 후에만 모델을 저장했다면, 이제는 중간 과정을 모두 저장하고 언제든 최적의 시점으로 돌아갈 수 있습니다. Checkpoint에는 model.state_dict(), optimizer.state_dict(), 현재 epoch, 최고 검증 정확도 등을 저장합니다.
torch.save()로 저장하고 torch.load()로 불러올 수 있습니다. 최고 성능 모델은 별도로 저장해두면 과적합이 시작되기 전의 최적 모델을 확보할 수 있습니다.
이러한 체계적인 저장 전략이 안정적이고 효율적인 학습 환경을 만들어줍니다.
코드 예제
import torch
import os
def save_checkpoint(model, optimizer, epoch, loss, filepath):
checkpoint = {
'epoch': epoch,
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'loss': loss,
}
torch.save(checkpoint, filepath)
print(f'Checkpoint saved: {filepath}')
def load_checkpoint(model, optimizer, filepath):
checkpoint = torch.load(filepath)
model.load_state_dict(checkpoint['model_state_dict'])
optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
epoch = checkpoint['epoch']
loss = checkpoint['loss']
return epoch, loss
# 학습 루프에서 사용
best_loss = float('inf')
for epoch in range(start_epoch, num_epochs):
train_loss = train(model, dataloader)
# 매 에폭마다 저장
save_checkpoint(model, optimizer, epoch, train_loss,
f'checkpoint_epoch_{epoch}.pt')
# 최고 성능 모델 별도 저장
if train_loss < best_loss:
best_loss = train_loss
save_checkpoint(model, optimizer, epoch, train_loss,
'best_model.pt')
설명
이것이 하는 일: 이 코드는 학습 진행 상황을 완전히 저장하고 복원할 수 있는 체계를 구축해서, 언제든 중단된 지점부터 학습을 재개하거나 최고 성능 모델을 사용할 수 있게 합니다. 첫 번째로, save_checkpoint() 함수는 학습 상태를 딕셔너리로 묶어 파일로 저장합니다.
model.state_dict()는 모든 층의 가중치와 편향을 포함하는데, 예를 들어 {'conv1.weight': tensor(...), 'conv1.bias': tensor(...), ...} 형태입니다. optimizer.state_dict()는 Adam의 경우 각 파라미터의 momentum과 variance 같은 내부 상태를 저장합니다.
이 정보 없이는 학습을 재개해도 optimizer가 처음부터 다시 시작하게 됩니다. 그 다음으로, torch.save()는 이 딕셔너리를 pickle 형식으로 직렬화해서 디스크에 씁니다.
파일명에 epoch 번호를 포함시키면 여러 시점의 checkpoint를 관리할 수 있습니다. 내부적으로는 numpy array나 torch tensor 같은 복잡한 객체도 효율적으로 저장됩니다.
큰 모델은 수 GB 크기가 될 수 있으므로 저장 위치의 디스크 공간을 확인하세요. 마지막으로, 학습 중 검증 loss가 개선될 때마다 best_model.pt에 덮어쓰기합니다.
이렇게 하면 학습이 100 에폭 진행되더라도 과적합이 시작되기 전인 60 에폭째의 최고 성능 모델이 보존됩니다. load_checkpoint()로 불러올 때는 model.load_state_dict()와 optimizer.load_state_dict()를 순서대로 호출하면 정확히 저장 시점의 상태로 복원됩니다.
여러분이 이 코드를 사용하면 서버 문제나 예상치 못한 중단에도 안심할 수 있습니다. 클라우드 비용을 절약할 수 있고, 실험을 중단했다가 재개하기도 쉬우며, 항상 최고 성능 모델을 확보할 수 있습니다.
또한 여러 checkpoint를 비교 분석해서 학습 과정을 이해하는 데도 도움이 됩니다.
실전 팁
💡 매 에폭마다 저장하면 디스크가 금방 찹니다. 5 에폭마다 또는 검증 성능 개선 시에만 저장하세요.
💡 오래된 checkpoint는 자동으로 삭제하는 로직을 추가하세요. 최근 3개만 유지하는 식으로 관리하면 좋습니다.
💡 분산 학습 시에는 한 프로세스만 저장하도록 if rank == 0: 조건을 추가하세요. 여러 프로세스가 동시에 쓰면 파일이 손상될 수 있습니다.
💡 중요한 checkpoint는 클라우드 스토리지(S3, Google Cloud Storage)에 백업하세요. 로컬 디스크 장애에 대비할 수 있습니다.
💡 scheduler도 state_dict()를 저장해야 정확한 learning rate 스케줄을 유지할 수 있습니다. checkpoint에 추가하세요.
8. Early Stopping - 과적합 방지 자동 중단
시작하며
여러분이 검증 성능이 더 이상 개선되지 않는데도 계속 학습시켜서 과적합된 모델을 얻은 적 있나요? 언제 학습을 멈춰야 할지 판단하기는 어렵습니다.
이런 문제는 특히 복잡한 모델이나 작은 데이터셋에서 자주 발생합니다. 훈련 loss는 계속 감소하지만 검증 loss는 올라가기 시작하면, 모델이 훈련 데이터를 암기하고 있다는 신호입니다.
예를 들어, 50 에폭째에 검증 정확도가 최고였는데 100 에폭까지 학습하면 오히려 성능이 떨어집니다. 바로 이럴 때 필요한 것이 Early Stopping입니다.
검증 성능이 일정 기간 개선되지 않으면 자동으로 학습을 중단해서 시간과 자원을 절약하고 최적의 모델을 얻을 수 있습니다.
개요
간단히 말해서, Early Stopping은 검증 지표를 모니터링하다가 개선이 멈추면 학습을 자동으로 종료하는 기법입니다. 핵심 아이디어는 "인내심(patience)"입니다.
검증 성능이 한 번 나빠졌다고 바로 멈추지 않고, 일정 에폭(예: 10 에폭) 동안 기다립니다. 이 기간 내에 성능이 개선되면 계속하고, 그렇지 않으면 중단합니다.
예를 들어, patience=10이면 최고 성능 이후 10 에폭 동안 개선이 없을 때 멈춥니다. 기존에는 미리 정한 에폭 수만큼 무조건 학습했다면, 이제는 모델이 스스로 학습 완료 시점을 결정할 수 있습니다.
구현은 간단합니다. 최고 검증 성능과 그 이후 지나간 에폭 수를 추적합니다.
매 에폭마다 현재 성능이 최고보다 좋으면 카운터를 리셋하고, 그렇지 않으면 카운터를 증가시킵니다. 카운터가 patience를 초과하면 학습을 중단합니다.
이러한 간단한 로직이 과적합을 효과적으로 방지하고 학습 시간을 최적화합니다.
코드 예제
class EarlyStopping:
def __init__(self, patience=10, min_delta=0):
self.patience = patience
self.min_delta = min_delta
self.counter = 0
self.best_loss = None
self.early_stop = False
def __call__(self, val_loss):
if self.best_loss is None:
self.best_loss = val_loss
elif val_loss > self.best_loss - self.min_delta:
self.counter += 1
if self.counter >= self.patience:
self.early_stop = True
else:
self.best_loss = val_loss
self.counter = 0
# 학습 루프에서 사용
early_stopping = EarlyStopping(patience=10)
for epoch in range(max_epochs):
train_loss = train(model, train_loader)
val_loss = validate(model, val_loader)
early_stopping(val_loss)
if early_stopping.early_stop:
print(f"Early stopping at epoch {epoch}")
break
설명
이것이 하는 일: 이 코드는 검증 데이터의 성능을 지속적으로 모니터링하면서, 개선이 멈춘 것을 감지하면 학습을 자동으로 중단해서 최적의 모델을 보존하고 불필요한 학습을 방지합니다. 첫 번째로, EarlyStopping 클래스는 patience(인내 에폭 수)와 min_delta(의미 있는 개선으로 인정할 최소 변화량)를 파라미터로 받습니다.
예를 들어, patience=10, min_delta=0.001이면 검증 loss가 최소 0.001 이상 개선되지 않는 상태가 10 에폭 지속될 때 중단합니다. best_loss는 지금까지의 최고(최소) 검증 loss를 저장하고, counter는 개선 없이 지나간 에폭 수를 세는 카운터입니다.
그 다음으로, call 메서드가 매 에폭마다 현재 검증 loss로 호출됩니다. 첫 에폭이면 무조건 best_loss로 저장합니다.
이후 에폭에서는 val_loss > self.best_loss - self.min_delta 조건을 확인합니다. 즉, 현재 loss가 최고 loss보다 min_delta만큼도 개선되지 않았으면 counter를 1 증가시킵니다.
내부적으로 이 카운터가 patience에 도달하는 순간 early_stop 플래그가 True가 됩니다. 마지막으로, 만약 현재 loss가 최고 기록을 갱신하면(val_loss <= self.best_loss - self.min_delta) best_loss를 업데이트하고 counter를 0으로 리셋합니다.
이렇게 하면 일시적인 성능 저하는 무시하고 지속적인 정체만 감지합니다. 학습 루프에서는 early_stop이 True가 되면 break로 즉시 학습을 종료하고, 이전에 저장해둔 best_model.pt를 사용하면 됩니다.
여러분이 이 코드를 사용하면 과적합을 자동으로 방지하고 GPU 시간을 절약할 수 있습니다. 특히 클라우드 GPU를 시간당 비용으로 사용한다면 큰 비용 절감 효과가 있습니다.
또한 최적의 에폭 수를 수동으로 찾는 번거로움 없이 항상 좋은 성능을 얻을 수 있고, 여러 실험을 병렬로 돌릴 때도 각각 자동으로 최적 시점에 멈춥니다.
실전 팁
💡 patience는 너무 작으면 너무 일찍 멈추고, 너무 크면 효과가 없습니다. 전체 에폭의 10-20% 정도가 적당합니다.
💡 min_delta를 설정하면 노이즈로 인한 미세한 변동을 무시할 수 있습니다. 0.0001 정도면 충분합니다.
💡 Early stopping과 함께 best model checkpoint를 저장해야 합니다. 중단 시점이 아니라 최고 성능 시점의 모델이 필요합니다.
💡 검증 loss가 아니라 정확도를 기준으로 하려면 로직을 반대로 바꿔야 합니다(val_loss < self.best_loss 대신 val_acc > self.best_acc).
💡 Learning rate scheduler와 함께 사용할 때는 ReduceLROnPlateau로 먼저 lr을 줄여보고, 그래도 안 되면 early stop하는 전략이 효과적입니다.
댓글 (0)
함께 보면 좋은 카드 뉴스
범주형 변수 시각화 완벽 가이드 Bar Chart와 Count Plot
데이터 분석에서 가장 기본이 되는 범주형 변수 시각화 방법을 알아봅니다. Matplotlib의 Bar Chart부터 Seaborn의 Count Plot까지, 실무에서 바로 활용할 수 있는 시각화 기법을 배워봅니다.
이변량 분석 완벽 가이드: 변수 간 관계 탐색
두 변수 사이의 관계를 분석하는 이변량 분석의 핵심 개념과 기법을 배웁니다. 상관관계, 산점도, 교차분석 등 데이터 분석의 필수 도구들을 실습과 함께 익혀봅시다.
단변량 분석 분포 시각화 완벽 가이드
데이터 분석의 첫걸음인 단변량 분석과 분포 시각화를 배웁니다. 히스토그램, 박스플롯, 밀도 그래프 등 다양한 시각화 방법을 초보자도 쉽게 이해할 수 있도록 설명합니다.
데이터 타입 변환 및 정규화 완벽 가이드
데이터 분석과 머신러닝에서 가장 기초가 되는 데이터 타입 변환과 정규화 기법을 배워봅니다. 실무에서 자주 마주치는 데이터 전처리 문제를 Python으로 쉽게 해결하는 방법을 알려드립니다.
이상치 탐지 및 처리 완벽 가이드
데이터 속에 숨어있는 이상한 값들을 찾아내고 처리하는 방법을 배워봅니다. 실무에서 자주 마주치는 이상치 문제를 Python으로 해결하는 다양한 기법을 소개합니다.