인지야공

인지야공/인공 지능 공부 치트 시트 정리/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,N) 행렬 세로 벡터 (N,1)과 가로 벡터 (N,)을 빼면 두 축이 각각 늘어나 N×N 행렬이 되고, 원하던 원소별 뺄셈 N개 대신 N제곱 개의 값이 계산된다. (100, 1) - (100,) 는 에러가 아니라 (100, 100) 이다 (100, 1) - (100,) = (100, 100) — 원소 10,000개 올바른 MSE0.0078 브로드캐스팅된 MSE2.1380 272.7배로 부풀었다 — 그런데 에러는 없다 모든 예측을 모든 정답과 비교한 꼴이 된다

모델 출력이 (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.ModuleList771개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 있음없음
10.039710.03971
100.028300.23552
1000.016060.46329
4000.008802.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를 버리면서 잴 때마다 값이 달라진다. 에러가 나면 차라리 다행이다.


참고

표시는 이 브라우저에만 남는다. 서버로 가는 것은 없다.