인지야공/인공 지능 공부 치트 시트 정리/13번째 글
PyTorch 치트시트를 다시 쓴다 — 에러가 나면 차라리 다행이다
실행:
python 딥러닝/cheatsheet/CH_pytorch.py(검증 환경: torch 2.8.0+cu129) 이 글의 수치와 에러 메시지는 전부 그 스크립트를 돌려 얻은 것이다.
PyTorch 치트시트는 인터넷에 넘친다. 문제는 대부분 1.x 시절에 쓰였고, 그때 맞던 줄이 지금은 에러가 나거나 — 더 나쁘게는 에러 없이 다른 뜻이 된다는 것이다.
에러가 나는 쪽은 사실 다행이다. 바로 알 수 있으니까. 이 글의 절반은 아무 말 없이 틀리는 자리에 쓴다. 그쪽이 훨씬 오래 사람을 잡는다.
1. 지금도 그대로인 것들 — 시작 세 줄과 학습 루프
먼저 안 바뀐 것부터. 모든 스크립트는 이 세 줄로 시작한다.
import torch, torch.nn as nn, torch.nn.functional as F
torch.manual_seed(42)
device = "cuda" if torch.cuda.is_available() else "cpu"
그리고 PyTorch의 전부라고 할 만한 여섯 줄. 순서가 곧 의미다.
model.train() # (1) Dropout/BN 켜기
for xb, yb in dl:
xb, yb = xb.to(device), yb.to(device)
pred = model(xb) # (2) 순전파
loss = loss_fn(pred, yb) # (3) 손실
optimizer.zero_grad() # (4) 기울기 초기화 ← 빼먹으면 아래 B4
loss.backward() # (5) 역전파
optimizer.step() # (6) 가중치 갱신
추론은 @torch.inference_mode() 를 쓴다. no_grad 보다 빠르고, 요즘은 이쪽이 기본이다.
@torch.inference_mode()
def evaluate(model, dl):
model.eval() # Dropout 끄기, BN 통계 고정 ← 빼먹으면 아래 B5
...
주요 레이어의 입출력 모양도 그대로다.
| 레이어 | 입력 | 출력 |
|---|---|---|
nn.Linear(in, out) | (*, in) | (*, out) |
nn.Conv2d(cin, cout, k) | (N, cin, H, W) | (N, cout, H', W') |
nn.LSTM(in, hid, batch_first=True) | (N, L, in) | (N, L, hid), (h, c) |
nn.MultiheadAttention(d, h, batch_first=True) | (N, L, d) | (N, L, d), 가중치 |
2. [A] 지금 치면 에러가 나는 줄들
하나씩 실제로 돌려 본 결과다.
| 치트시트에 흔히 나오는 줄 | 2.8에서 | 고치면 |
|---|---|---|
torch.solve(B, A) | RuntimeError — 제거됨 | torch.linalg.solve(A, B) |
torch.symeig(A) | RuntimeError — 제거됨 | torch.linalg.eigh(A) |
x.t().view(-1) | RuntimeError — 전치하면 메모리가 불연속 | .reshape(-1) |
torch.load(path) | UnpicklingError — 2.6부터 weights_only=True 가 기본 | 믿는 파일이면 weights_only=False 명시 |
x.T (3차원 이상) | 경고 — 축을 뒤집는 용도는 폐기 예정 | x.mT 또는 x.permute(...) |
torch.range(0, 5) | 경고 — 끝을 포함해 헷갈린다 | torch.arange(0, 6) |
torch.solve 와 torch.symeig 는 1.9에서 폐기 예고를 하고 지금은 아예 없다. 에러 메시지가
친절해서 금방 고칠 수 있다.
주의할 것은 torch.load 다. 2.6부터 기본값이 뒤집혀서, 체크포인트에 텐서가 아닌 것(설정 딕셔너리,
numpy 배열 등)이 섞여 있으면 예전에 잘 되던 코드가 지금은 열리지 않는다. 바꾼 이유가 보안이라는
점이 중요하다 — weights_only=False 는 그 파일의 코드를 실행할 수 있게 허락하는 것이므로,
내가 만든 파일에만 쓴다.
ckpt = torch.load("ckpt.pt", map_location=device, weights_only=False) # 내 파일일 때만
그리고 모델 객체를 통째로 torch.save(model) 하지 않는다. 클래스 정의에 묶여 이식성이 없다.
state_dict() 만 저장한다.
3. [B] 에러 없이 조용히 틀리는 자리들
여기부터가 본론이다.
B1. 모양이 다른 두 벡터를 빼면 (N, N) 이 된다
가장 자주, 가장 조용히 당하는 자리다.
모델 출력이 (N, 1) 이고 정답이 (N,) 인 상황은 회귀에서 늘 생긴다. 둘을 빼면 PyTorch는 친절하게
브로드캐스팅해서 모든 예측을 모든 정답과 비교한 (N, N) 을 만든다.
pred = model(x) # (100, 1)
true = y # (100,)
loss = ((pred - true) ** 2).mean() # 2.1380 ← (100,100) 의 평균
loss = ((pred.squeeze() - true) ** 2).mean() # 0.0078 ← 진짜 MSE
잘 맞히는 모델일수록 차이가 커진다. 실측에서 272.7배였다. 손실 곡선이 이상하게 안 내려가는데 원인을 못 찾겠으면 여기부터 본다.
예방: 손실을 계산하기 직전에
assert pred.shape == true.shape한 줄을 둔다. 또는nn.MSELoss()를 쓰면 모양이 다를 때 경고를 내 준다(그래도 막아 주지는 않는다).
B2. 레이어를 파이썬 리스트에 담으면 파라미터가 0개가 된다
class BadNet(nn.Module):
def __init__(self):
super().__init__()
self.layers = [nn.Linear(8, 64), nn.Linear(64, 3)] # 그냥 리스트
class GoodNet(nn.Module):
def __init__(self):
super().__init__()
self.layers = nn.ModuleList([nn.Linear(8, 64), nn.Linear(64, 3)])
| 파라미터 수 | state_dict() 키 | |
|---|---|---|
| 파이썬 리스트 | 0개 | 0개 |
nn.ModuleList | 771개 | 4개 |
순전파는 멀쩡히 돌아간다. 출력도 나온다. 그런데 model.parameters() 가 비어 있으니
옵티마이저가 아무것도 갱신하지 않고, .to("cuda") 도 그 레이어들을 옮기지 않으며,
state_dict() 에도 들어가지 않아 저장했다 불러오면 초기 상태로 돌아간다.
증상이 “학습이 전혀 안 된다”로 나타나서 학습률부터 의심하게 되는데, 원인은 여기다.
nn.ModuleList 나 nn.Sequential 을 쓰면 된다. 딕셔너리라면 nn.ModuleDict 다.
점검:
sum(p.numel() for p in model.parameters())를 학습 시작 전에 한 번 찍어 본다.
B3. CrossEntropyLoss 앞에 softmax 를 또 씌우기
옛 치트시트에 “마지막 층에 softmax” 라고 적혀 있는 것을 그대로 따라 하면 생긴다.
nn.CrossEntropyLoss 는 안에서 이미 log-softmax 를 한다.
| 정확도 | |
|---|---|
| 로짓을 그대로 넣음 | 95.0% |
| softmax 를 한 번 더 씌움 | 93.3% |
에러는 없고 학습도 된다. 그저 조금 덜 된다. 확률을 다시 확률로 눌러 값의 범위가 좁아지고 기울기가 작아지기 때문이다. 이 정도 차이는 “이 과제가 원래 어려운가 보다”로 넘어가기 딱 좋다.
기억할 규칙은 하나다 — CrossEntropyLoss 에는 로짓, NLLLoss 에는 log_softmax.
확률이 필요하면 손실이 아니라 예측할 때 softmax 를 씌운다.
B4. zero_grad() 를 빼먹으면 학습률이 내 손을 떠난다
.backward() 는 기울기를 덮어쓰지 않고 더한다. 그래서 zero_grad() 를 빼면 지나간
미니배치의 기울기가 계속 쌓인다. 한 스텝에 파라미터가 실제로 얼마나 움직이는지를 쟀다.
| 스텝 | zero_grad 있음 | 없음 |
|---|---|---|
| 1 | 0.03971 | 0.03971 |
| 10 | 0.02830 | 0.23552 |
| 100 | 0.01606 | 0.46329 |
| 400 | 0.00880 | 2.79746 |
있을 때는 수렴하면서 이동량이 줄어든다(0.040 → 0.009). 없을 때는 318배로 커진다. 한 스텝 이동량의 최댓값은 66.8배 차이였다.
그런데 최종 정확도는 94.7% 대 93.5% 다. 거의 같다.
이게 이 항목을 넣은 이유다. 겉으로는 멀쩡해 보인다. 문제는 lr=0.05 라고 적어 놓고 실제로는
스텝마다 전혀 다른 크기로 움직이고 있다는 것이고, 그러면 학습률도 배치 크기도 뜻을 잃는다.
스케줄러를 붙여도 의미가 없고, 결과가 배치 순서에 따라 달라져 재현도 되지 않는다.
참고: 기울기 누적은 일부러 쓰기도 한다(큰 배치를 흉내 낼 때). 그때는
zero_grad()를 N 스텝마다 부르고 손실을 N으로 나눈다. 의도한 누적과 빼먹은 누적은 다른 것이다.
B5. Dropout 을 쓰면서 eval() 을 안 부르기
| 정확도 | |
|---|---|
model.eval() | 96.0% (매번 같다) |
model.train() 인 채로 평가 | 90.5% / 91.0% / 93.0% / 90.5% / 92.0% |
5.5%p를 그냥 버리고, 게다가 잴 때마다 값이 다르다. Dropout이 평가 중에도 계속 뉴런을 끄고 있기 때문이다. BatchNorm이면 더 나쁘다 — 평가 배치의 통계로 정규화해 버려서 배치 구성에 따라 결과가 달라진다.
@torch.inference_mode() 데코레이터는 기울기만 끄지 eval() 을 대신하지 않는다. 둘 다 필요하다.
그리고 평가가 끝나면 model.train() 으로 되돌려야 한다.
B6. loss 를 .item() 없이 더하기
total += loss # 계산 그래프가 통째로 붙어 온다
total += loss.item() # float 하나만 더해진다
loss 는 그냥 숫자가 아니라 역전파에 쓸 계산 그래프를 달고 있는 텐서다. 그대로 누적하면
그래프가 전부 메모리에 남는다. 실측에서 누적된 값의 grad_fn 이 살아 있는 것을 확인했다.
에포크 하나를 돌면 배치 수만큼의 그래프가 쌓여 OOM 으로 죽는다. 그런데 죽는 자리가 손실 누적이
아니라 다음 순전파라서, 배치 크기를 줄이는 엉뚱한 처방을 하게 된다. 로그에 쓸 값은 .item(),
텐서로 모아야 하면 .detach() 다.
4. 한 문단 요약
옛 PyTorch 치트시트에서 torch.solve·torch.symeig 는 이미 제거됐고, torch.load 는 2.6부터
weights_only=True 가 기본이라 예전 체크포인트가 열리지 않는다. 다만 이런 것들은 에러가 나므로
금방 고친다. 정작 오래 잡는 것은 조용한 쪽이다 — (N,1) 과 (N,) 을 빼면 브로드캐스팅으로
(N,N) 이 되어 손실이 272.7배로 부풀고, 레이어를 파이썬 리스트에 담으면 파라미터가 0개가
되어 순전파는 되는데 학습만 안 되며, zero_grad() 를 빼먹으면 한 스텝 이동량이 66.8배로
벌어지는데 정확도는 94.7% 대 93.5%로 멀쩡해 보인다. eval() 을 안 부르면 5.5%p를 버리면서 잴
때마다 값이 달라진다. 에러가 나면 차라리 다행이다.