메뉴
HN
Hacker News 36일 전

파이토치 학습 루프(PyTorch Training Loop) 완벽 가이드

IMP
8/10
핵심 요약

파이토치(PyTorch) 학습 루프를 구성하는 것은 간단해 보이지만, 코드의 순서가 조금만 어긋나도 메모리 초과(OOM)나 수렴 실패 등을 겪게 됩니다. 이 글은 올바른 학습 루프의 구조를 소개하고, 흔히 저지르는 치명적인 순서 실수들을 디버깅 방법과 함께 상세히 설명합니다.

번역된 본문

← 에세이 ← 에세이

파이토치(PyTorch) 학습 루프를 구성하는 것은 꽤 간단하지만, 모든 것을 올바른 위치와 순서에 배치하는 것은 생각보다 까다롭고 오류에 취약하게 느껴질 수 있습니다. 수많은 움직이는 부품들이 있으며, 가장 기본적인 오류들을 수정한 후에는 다른 실수들을 발견하기가 꽤 어려울 수 있습니다. 코드 한 줄이 잘못된 위치에 들어가면 학습이 수렴하지 않거나, 잘못된 결과가 나오거나, 과도한 메모리를 소비하게 됩니다. 아래 섹션에서는 각 작업을 순서대로 살펴보며, 각 부분을 정확히 어떻게 작성해야 하는지, 그리고 주의해야 할 모든 일반적인 실수들을 설명하겠습니다. 분산 학습, FSDP, 멀티 GPU 설정 등은 여기서 다루지 않으며, 향후 에세이에서 다루겠습니다. (위의 애니메이션은 합성 데이터(synthetic data)로 루프를 실행하고 각 에포크(epoch)마다 결정 경계(decision boundary)를 캡처하여 만들었습니다.)

전체 학습 루프

먼저 전체 학습 루프를 살펴보겠습니다. 아직 이해하거나 외울 필요는 없으며, 그저 전체적인 구조를 느껴보시면 됩니다.

1 import torch
2 import torch . nn as nn
3 from torch . utils . data import DataLoader , TensorDataset
4
5 # --- 데이터 ---
6 dataset = TensorDataset ( X_train , y_train )
7 loader = DataLoader ( dataset , batch_size = 64 , shuffle = True )
8
9 # --- 모델, 손실 함수, 옵티마이저 ---
10 model = MLP ( in_features = 2 , hidden = 128 , out_features = 3 )
11 criterion = nn . CrossEntropyLoss ( )
12 optimiser = torch . optim . Adam ( model . parameters ( ) , lr = 1e-3 )
13 scheduler = torch . optim . lr_scheduler . CosineAnnealingLR ( optimiser , T_max = 100 )
14
15 # --- 루프 ---
16 for epoch in range ( 100 ) :
17 model . train ( )
18 for X_batch , y_batch in loader :
19 optimiser . zero_grad ( )
20 logits = model ( X_batch )
21 loss = criterion ( logits , y_batch )
22 loss . backward ( )
23 torch . nn . utils . clip_grad_norm_ ( model . parameters ( ) , max_norm = 1.0 )
24 optimiser . step ( )
25 scheduler . step ( )
26
27 model . eval ( )
28 with torch . no_grad ( ) :
29 val_logits = model ( X_val )
30 val_loss = criterion ( val_logits , y_val )

이제 각 라인을 살펴보며 어떤 역할을 하는지, 그리고 코드가 어떻게 망가질 수 있는지 이해해 보겠습니다. 몇 가지 흔한 실수부터 시작하겠습니다.

요약 (TL;DR): 순서가 절대적으로 중요한 곳

다음은 배치 순서를 약간 실수하여 학습 루프를 망칠 수 있는 가장 흔한 실패 사례입니다. 이를 암기해야 하는 이유는 이들 중 어느 것도 예외(exception)를 발생시키지 않기 때문입니다. 시간이 지나면서 학습 실행 중 어떤 종류의 오류를 찾아야 하는지 감을 잡게 되겠지만, 처음 몇 번은 이 요약본이 큰 도움이 될 것입니다.

코드 라인 잘못된 위치 문제점 (무엇이 망가지는가)
model.to(device) optimiser = ... 이후 데이터 타입 변환(예: model.half())이 결합될 때 nn.Module.to()는 새로운 nn.Parameter 객체를 할당합니다. 옵티마이저는 버려진 원본을 참조하여 그것들에만 업데이트를 적용하게 됩니다.
optimiser.zero_grad() loss.backward() 이후 여러 배치의 기울기(gradient)가 누적됩니다. 업데이트 시 현재 배치만 단독으로 쓰이는 게 아니라 누적된 합산 값이 사용됩니다.
clip_grad_norm_() loss.backward() 이전 .grad가 비어 있습니다. 이 호출은 아무 일도 일어나지 않는(no-op) 상태가 됩니다.
clip_grad_norm_() optimiser.step() 이후 이미 적용된 기울기를 클리핑합니다. 아무런 효과가 없습니다.
scheduler.step() 배치 루프 내부 에포크당 한 번이 아니라 len(loader) 횟수만큼 학습률(LR)이 감소합니다.
model.eval() 이후 model.train() 생략 드롭아웃(Dropout)이 비활성화되고 배치 정규화(BatchNorm)가 고정됩니다. 오류 없이 평가(eval) 모드에서 학습이 진행됩니다.
검증 중 torch.no_grad() 생략 모든 검증 배치에서 Autograd(자동 미분) 그래프가 빌드됩니다. OOM(메모리 부족)이 발생할 때까지 메모리가 계속 증가합니다.
loss.item() 대신 loss 로깅 로깅 호출 기간 동안 계산 그래프가 메모리에 고정되어 해제되지 않습니다.

이제 이 각각에 대해 자세히 살펴보겠습니다.

데이터 파이프라인 관행

커스텀 파이토치 데이터셋 (쉬움 Q 344) __len____getitem__을 구현하여 커스텀 파이토치 데이터셋을 구현해 보세요.

파이토치의 데이터 파이프라인에는 데이터셋(Dataset)과 데이터로더(DataLoader)라는 두 부분이 있습니다. 데이터셋은 단순히 __len__(데이터셋에 몇 개의 요소가 있는지 반환)과 __getitem__(놀랍지 않게도 요소를 가져옵니다)을 구현하는 파이썬 객체일 뿐입니다.

원문 보기
원문 보기 (영어)
← essays ← essays Building a PyTorch training loop is fairly straightforward, but getting everything in the right place and in the right order can feel surprisingly fragile. There are loads of moving parts and after the most basic errors are fixed, most of the other mistakes can be pretty hard to spot. Training runs will fail to converge, produce incorrect results, or consume excessive memory if lines are misplaced. The sections below will go through each operation in sequence, explaining exactly how to write each section, and all the common mistakes to watch out for. Distributed training, FSDP, and multi-GPU setups are out of scope here, but we'll come back to that in a future essay. (The animation above was produced by running the loop on synthetic data and capturing the decision boundary at each epoch.) The complete loop Let's look, first of all, at the complete training loop. You don't need to understand or memorise it yet, just get a feel for the structure. 1 import torch 2 import torch . nn as nn 3 from torch . utils . data import DataLoader , TensorDataset 4 5 # --- data --- 6 dataset = TensorDataset ( X_train , y_train ) 7 loader = DataLoader ( dataset , batch_size = 64 , shuffle = True ) 8 9 # --- model, loss, optimiser --- 10 model = MLP ( in_features = 2 , hidden = 128 , out_features = 3 ) 11 criterion = nn . CrossEntropyLoss ( ) 12 optimiser = torch . optim . Adam ( model . parameters ( ) , lr = 1e-3 ) 13 scheduler = torch . optim . lr_scheduler . CosineAnnealingLR ( optimiser , T_max = 100 ) 14 15 # --- loop --- 16 for epoch in range ( 100 ) : 17 model . train ( ) 18 for X_batch , y_batch in loader : 19 optimiser . zero_grad ( ) 20 logits = model ( X_batch ) 21 loss = criterion ( logits , y_batch ) 22 loss . backward ( ) 23 torch . nn . utils . clip_grad_norm_ ( model . parameters ( ) , max_norm = 1.0 ) 24 optimiser . step ( ) 25 scheduler . step ( ) 26 27 model . eval ( ) 28 with torch . no_grad ( ) : 29 val_logits = model ( X_val ) 30 val_loss = criterion ( val_logits , y_val ) Now let's go through each line and understand what it does, and how not to break it. We'll start with some of the common mistakes. TL;DR Where the order really matters Here are some of the most common failures, and how you can break the training loop by getting the placement a little bit wrong. The reason to memorise these is that none of them will raise an exception, over time you'll get a sense for what kind of errors to look for in your training runs, but for the first few times this crib sheet will help you out. Line Wrong position What breaks model.to(device) After optimiser = ... When a dtype conversion is combined (e.g. model.half() ), nn.Module.to() allocates new nn.Parameter objects; the optimiser holds references to the discarded originals and applies updates to them instead. optimiser.zero_grad() After loss.backward() Gradients from multiple batches accumulate. Update uses their sum, not the current batch alone. clip_grad_norm_() Before loss.backward() .grad is empty. The call is a no-op. clip_grad_norm_() After optimiser.step() Clips gradients already applied. No effect. scheduler.step() Inside batch loop LR decays len(loader) times per epoch instead of once. Omit model.train() after model.eval() — Dropout disabled, BatchNorm frozen. The model trains in eval mode without error. Omit torch.no_grad() during validation — Autograd graph builds on every validation batch. Memory grows until OOM. Log loss instead of loss.item() — Pins the computation graph in memory for the duration of the logging call. Now let's go through each of these in detail. The data practice Custom PyTorch Dataset Easy Q 344 Implement a custom PyTorch Dataset with __len__ and __getitem__ . There are two parts to the data pipeline in PyTorch: the Dataset and the DataLoader . The Dataset is just a Python object that implements __len__ (how many elements are in the dataset) and __getitem__ (which, unsurprisingly, gets an item). It can be a simple wrapper around tensors, or it can load data from disk on demand. The DataLoader wraps a dataset and produces batches. Each pass through the full dataset is one epoch. With shuffle=True , examples are presented in a different order each epoch. 1 dataset = TensorDataset ( X_train , y_train ) 2 loader = DataLoader ( 3 dataset , 4 batch_size = 64 , 5 shuffle = True , 6 num_workers = 2 , 7 pin_memory = True , 8 persistent_workers = True , 9 ) practice PyTorch DataLoader Easy Q 342 Wire up a PyTorch DataLoader: batching, shuffling, and iterating. TensorDataset pairs input and label tensors by index. Indexing with dataset[i] returns (X[i], y[i]) . DataLoader calls __getitem__ repeatedly, collates the results into batches, and optionally hands work to background worker processes. num_workers spawns separate processes that prefetch batches in parallel with GPU compute. The main process blocks on .next() only if a batch is not yet ready. Zero workers means the main process does all loading, which often bottlenecks GPU utilisation on data-heavy tasks. Two to four workers is practical, but the right number depends on CPU count and I/O speed. pin_memory=True allocates batch tensors in pinned host memory. The GPU DMA engine can transfer directly from pinned memory without first copying through the kernel buffer, reducing host-to-device transfer time. It only helps when num_workers > 0 and you're transferring to CUDA. persistent_workers=True keeps worker processes alive between epochs. Without it, workers are respawned at the start of each epoch, adding fork overhead that becomes measurable at large worker counts. drop_last=True discards the final batch if it is smaller than batch_size . BatchNorm statistics computed from a batch of two or three samples are noisy so dropping the remainder avoids this. Small cost in terms of dropping the data, but it is often worth it for stability. Smaller batches produce noisier gradient estimates, which acts as implicit regularisation. Larger batches use more GPU memory but allow more parallelism. One of the most important efficiencies is knowing that powers of two align with tensor core tile sizes (typically 16×16 or 8×16 depending on dtype), so making sure batch sizes and layer dimensions are set to multiples of 8 or 16 is a good idea. .to(device) moves a tensor to the target device. For tensors, it is not in-place: it returns a new tensor and leaves the original unchanged. For example, X_batch.to('cuda') returns a new tensor on GPU; X_batch itself remains on CPU. Reproducibility Setting seeds before constructing the model and loader gives the same results on every run. This is essential in order to reproduce experiments, and make sure model behavior is deterministic. The main areas this impacts the model is in the data loader, and in the initialisation of the model weights. 1 import random 2 import numpy as np 3 4 def set_seed ( seed : int = 42 ) : 5 torch . manual_seed ( seed ) 6 torch . cuda . manual_seed_all ( seed ) 7 np . random . seed ( seed ) 8 random . seed ( seed ) 9 torch . backends . cudnn . deterministic = True 10 torch . backends . cudnn . benchmark = False 11 12 set_seed ( 42 ) torch.manual_seed seeds the CPU generator. torch.cuda.manual_seed_all seeds every GPU. NumPy and Python's random are independent RNGs that PyTorch does not touch, be careful of this, you might need another random seed for them. cudnn.deterministic = True forces cuDNN to use deterministic convolution algorithms. Some cuDNN kernels are non-deterministic by default for throughput. The deterministic alternatives are slightly slower, but practically it shouldn't matter much during development. cudnn.benchmark = False must be paired with deterministic = True . When benchmark = True , cuDNN profiles several algorithms per input shape and picks the fastest, a process that itself varies between runs. Fixing it to False makes sure you always get the same results. When num_workers >