개요
지난 글에서 텐서와 모델까지 만들었다. 이제 실제로 학습을 돌릴 차례다.
학습 루프 코드는 인터넷에 널려 있다. 복사해서 붙이면 돌아간다. 그런데 그렇게 쓰다 보면 반드시 이런 순간이 온다.
“이 세 줄 순서를 바꾸면 어떻게 되지?” “왜 loss는 줄어드는데 정확도가 안 오르지?”
이번 글은 그 세 줄의 순서를 뜯어보고, 그다음 GPU를 쓰다 만나는 에러를 3분 만에 잡는 방법을 정리한다.
1. Dataset과 DataLoader
먼저 데이터를 배치로 나누는 도구부터다.
왜 배치로 나누나
데이터가 10만 개인데 한 번에 다 넣으면 두 가지가 터진다.
- 메모리가 감당을 못 한다
- 한 걸음 내딛는 데 너무 오래 걸린다
그래서 32개씩, 64개씩 잘라서 넣는다. 이 덩어리가 배치(batch)다.
만들기 — 3단계
import torch
from torch.utils.data import TensorDataset, DataLoader
# 1단계: NumPy → 텐서 (타입 지정 필수!)
X_train_t = torch.tensor(X_train_scaled, dtype=torch.float32)
y_train_t = torch.tensor(y_train, dtype=torch.long)
# 2단계: 문제와 정답을 짝지어 묶기
train_ds = TensorDataset(X_train_t, y_train_t)
# 3단계: 배치 단위로 꺼내주는 도구
train_loader = DataLoader(train_ds, batch_size=32, shuffle=True)
valid_loader = DataLoader(valid_ds, batch_size=32, shuffle=False)
Dataset과 DataLoader의 차이가 처음엔 헷갈렸다.
| 하는 일 | 비유 | |
|---|---|---|
| Dataset | 문제와 정답을 짝지어 보관 | 문제집 |
| DataLoader | 그걸 배치로 잘라서 꺼내줌 | 문제집을 하루치씩 나눠주는 사람 |
shuffle은 왜 학습만 True인가
train_loader = DataLoader(train_ds, batch_size=32, shuffle=True) # 섞는다
valid_loader = DataLoader(valid_ds, batch_size=32, shuffle=False) # 안 섞는다
학습할 때 섞는 이유 — 데이터가 정답 순서대로 정렬돼 있으면 한 배치가 통째로 같은 클래스가 된다. 그러면 모델이 “이번 배치는 다 A네” 같은 엉뚱한 규칙을 배운다. 섞으면 그 문제가 사라진다.
평가할 때 안 섞는 이유 — 섞어도 성능 결과는 같다. 다만 순서가 매번 바뀌면 결과를 비교하거나 디버깅하기가 불편하다. 굳이 섞을 이유가 없다.
2. 손실 함수와 옵티마이저
import torch.nn as nn
device = "cuda" if torch.cuda.is_available() else "cpu"
model = 내모델(입력차원, 32, 클래스수).to(device) # ⚠️ 모델을 먼저 옮긴다
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
순서 주의 — model.to(device)가 먼저
이 순서를 바꾸면 조용히 망가진다.
# ❌ 옵티마이저를 먼저 만들면
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
model = model.to(device) # 파라미터가 GPU로 복사되면서 옵티마이저가 붙잡은 것과 달라진다
옵티마이저는 “이 파라미터들을 내가 관리한다”고 등록해두는데, 그 후에 모델을 옮기면 등록된 것과 실제로 쓰이는 게 달라진다. 학습이 안 되는데 에러도 안 난다.
CrossEntropyLoss의 함정
criterion = nn.CrossEntropyLoss()
loss = criterion(logits, y_batch)
여기서 두 가지를 몰라서 헤맸다.
① 소프트맥스를 직접 넣으면 안 된다. CrossEntropyLoss는 내부에서 이미 소프트맥스를 적용한다. 모델 마지막에 nn.Softmax()를 붙이면 두 번 적용되어 학습이 이상해진다.
② 정답은 정수여야 한다. 원-핫이 아니라 클래스 번호 그대로 넣는다. [0, 2, 1, ...] 형태고 타입은 long이다.
옵티마이저 — Adam
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
model.parameters()는 “모델 안의 학습 가능한 값 전부”를 넘겨주는 것이다. 옵티마이저는 이걸 받아서 관리한다.
lr=0.001은 Adam의 관례적인 기본값이다. (Adam이 무엇인지는 SSAFY 시리즈의 경사 하강법 편에서 다뤘다.)
3. 학습 루프 — 순서가 왜 그런가
이제 핵심이다.
def train_one_epoch(model, loader, criterion, optimizer, device):
model.train() # 학습 모드
running_loss, correct, total = 0.0, 0, 0
for x_batch, y_batch in loader:
x_batch = x_batch.to(device) # 데이터를 장치로
y_batch = y_batch.to(device)
optimizer.zero_grad() # ① 기울기 초기화
logits = model(x_batch) # ② 순전파
loss = criterion(logits, y_batch) # ③ 손실 계산
loss.backward() # ④ 역전파
optimizer.step() # ⑤ 파라미터 업데이트
running_loss += loss.item() * x_batch.size(0)
preds = logits.argmax(dim=1)
correct += (preds == y_batch).sum().item()
total += y_batch.size(0)
return running_loss / total, correct / total
① optimizer.zero_grad() — 칠판 지우기
가장 헷갈리는 부분이고, 가장 많이 빼먹는 줄이다.
지난 글에서 봤듯 PyTorch는 .grad를 덮어쓰지 않고 계속 더한다. 지우지 않으면 이전 배치의 기울기가 그대로 쌓인다.
1번 배치 기울기: 5 → 누적 5
2번 배치 기울기: 3 → 누적 8 ← 이번 배치는 3인데 8만큼 움직인다
3번 배치 기울기: 4 → 누적 12 ← 점점 더 심해진다
엉뚱한 방향으로, 점점 더 크게 움직인다. 학습이 안 되는 정도가 아니라 발산해버린다.
매 시험마다 점수를 지우지 않고 계속 더하는 것과 같다. 이번 시험은 이번 것만 채점해야 한다.
위치는 어디여야 하나? loss.backward()보다 앞에만 있으면 된다. 반복문 맨 앞에 두든 순전파 직전에 두든 결과는 같다.
나는 반복문 맨 앞을 쓴다. “새 배치 시작 = 칠판 지우기”라는 흐름이 자연스럽고, 무엇보다 빼먹을 일이 없다.
② logits = model(x_batch) — 순전파
여기서 logits라는 이름이 낯설었다.
logits = 소프트맥스를 거치지 않은 raw 점수다. 클래스가 10개면 점수도 10개 나온다.
logits = [1.2, 3.8, 0.5, ...]확률이 아니라 점수라서 음수도 나오고 합이 1도 아니다.
CrossEntropyLoss가 내부에서 알아서 확률로 바꿔준다.
③ loss = criterion(logits, y_batch) — 채점
예측 점수와 정답을 비교해 손실을 계산한다. 결과는 숫자 하나(스칼라)다. 지난 글에서 .backward()가 스칼라에서만 동작한다고 했는데, 여기서 자연스럽게 충족된다.
④ loss.backward() — 기울기 계산
손으로 하던 미분 전체가 이 한 줄이다. 모든 파라미터의 .grad에 “어느 방향으로 얼마나 고쳐야 하는지”가 채워진다.
여기서는 아직 아무것도 안 바뀐다. 계산만 한 상태다.
⑤ optimizer.step() — 실제로 움직이기
드디어 값이 바뀌는 순간이다. ④에서 계산된 기울기와 Adam의 방식, 학습률을 종합해서 모든 가중치를 갱신한다.
순서 정리
① zero_grad() → 칠판 지우기
② model(x) → 예측하기
③ criterion() → 얼마나 틀렸나 재기
④ backward() → 어느 방향으로 고칠지 계산
⑤ step() → 실제로 한 걸음 내딛기
- ④ → ⑤는 절대 못 바꾼다. 기울기를 계산하지 않고 값을 고칠 수는 없다
- ①은 ④보다 앞이면 어디든 된다
지난 글의 손코딩 다섯 단계와 비교해보면 하는 일이 정확히 같다.
weight.grad.zero_()가optimizer.zero_grad()가 됐고,with torch.no_grad(): weight -= ...가optimizer.step()이 된 것뿐이다.그걸 알고 나니 순서를 외울 필요가 없어졌다.
통계를 기록할 때 주의할 것
running_loss += loss.item() * x_batch.size(0)
왜 배치 크기를 곱하나? loss는 그 배치의 평균이기 때문이다. 배치마다 크기가 다를 수 있으니(마지막 배치는 보통 작다) 평균 × 개수 = 합계로 되돌린 뒤, 마지막에 전체 개수로 나눠야 정확하다.
preds = logits.argmax(dim=1)
argmax(dim=1)은 “각 행에서 가장 큰 값의 위치”를 준다. 점수가 제일 높은 클래스 번호가 곧 예측이다.
그리고 .item()을 붙이는 이유가 있다. 텐서를 그대로 누적하면 계산 그래프가 계속 붙어 있어서 메모리가 샌다. 숫자만 꺼내야 한다.
4. 평가 함수
def evaluate(model, loader, criterion, device):
model.eval() # 평가 모드
running_loss, correct, total = 0.0, 0, 0
with torch.no_grad(): # 미분 기록 끄기
for x_batch, y_batch in loader:
x_batch = x_batch.to(device)
y_batch = y_batch.to(device)
logits = model(x_batch)
loss = criterion(logits, y_batch)
running_loss += loss.item() * x_batch.size(0)
correct += (logits.argmax(dim=1) == y_batch).sum().item()
total += y_batch.size(0)
return running_loss / total, correct / total
학습 함수와 비교하면 빠진 게 세 개다.
| 빠진 것 | 이유 |
|---|---|
zero_grad() |
기울기를 안 쓰니 지울 것도 없다 |
backward() |
미분할 필요가 없다 |
step() |
값을 고치면 안 된다 |
대신 model.eval()과 torch.no_grad()가 들어갔다. 지난 글에서 정리했듯 역할이 다른 두 가지라 둘 다 필요하다.
5. GPU를 쓸 때 만나는 에러 세 가지
여기부터는 실전이다. GPU를 처음 붙였을 때 나는 반나절을 날렸는데, 알고 보니 진단 세 줄이면 끝나는 문제였다.
유형 A — CUDA 자체가 안 켜짐
AssertionError: Torch not compiled with CUDA enabled
CPU 전용 PyTorch가 깔려 있다는 뜻이다. GPU가 있어도 소용없다.
print(torch.__version__) # 2.x.x+cpu ← +cpu면 CPU 전용
print(torch.cuda.is_available()) # False
버전 뒤에 +cpu가 붙어 있으면 확실하다. GPU 버전으로 다시 설치해야 하고, 설치 후 커널 재시작을 꼭 해야 한다. 이걸 안 해서 “분명 설치했는데 왜 False지?” 하고 또 시간을 썼다.
유형 B — 장치가 서로 다름
RuntimeError: Expected all tensors to be on the same device,
but found at least two devices, cuda:0 and cpu!
모델은 GPU에 있는데 데이터는 CPU에 있는 상황이다. 가장 흔한 에러다.
유형 C — 자료형이 다름
RuntimeError: mat1 and mat2 must have the same dtype
지난 글에서 본 float32와 float64 충돌이다.
6. 진단 세 줄 — 노려보지 말고 물어보기
여기가 이 글에서 가장 실용적인 부분이다.
에러 메시지를 노려보는 대신, 각 요소가 지금 어디 있는지 직접 물어보면 된다. 학습 루프 직전에 이 코드를 넣는다.
print("model 위치 :", next(model.parameters()).device)
for xb, yb in train_loader:
print("loader xb :", xb.device, xb.dtype)
print("loader yb :", yb.device, yb.dtype)
break
print("optimizer :", optimizer.param_groups[0]["params"][0].device)
next(model.parameters()).device가 “모델 안 첫 번째 가중치가 어디 있는지”를 알려준다. 이 한 줄이 유형 B 진단의 핵심이다.
결과 해석 — 여기서 또 한 번 속았다
model 위치 : cuda:0
loader xb : cpu torch.float32 ← 정상이다!
loader yb : cpu torch.int64 ← 정상이다!
optimizer : cuda:0
loader에서 나온 데이터가 cpu인 건 완전히 정상이다.
나는 이걸 보고 “원인 찾았다!”고 착각해서 DataLoader 설정을 뒤지느라 20분을 더 썼다.
DataLoader는 GPU로 옮기는 일을 아예 하지 않는다. 텐서는 항상 CPU에서 시작하고, 우리가
.to(device)를 부르기 전까지 CPU에 있는 게 맞다.DataLoader는 “물건을 포장해서 순서대로 정리해주는 창고 직원”이다. 포장과 정리는 잘하지만 “GPU라는 나라로 보내는 일”은 자기 담당이 아니다. 그건 학습 함수 안에서 우리가 한다.
진짜 봐야 할 곳은 여기다.
| 확인 위치 | cpu여도 되나 |
|---|---|
next(model.parameters()).device |
❌ cuda여야 한다 |
| DataLoader에서 막 꺼낸 데이터 | ✅ cpu가 정상 |
학습 함수 안, .to(device) 직후
|
❌ cuda여야 한다 |
| optimizer의 파라미터 | ❌ cuda여야 한다 |
실제 원인 순위
내가 겪은 순서대로다.
1위 — model.to(device)를 안 했다
model = 내모델(...) # ❌ CPU에 그대로
model = 내모델(...).to(device) # ✅
2위 — 학습 함수 안에서 .to(device)를 빼먹었다
모델은 옮겼는데 매 배치의 데이터를 안 옮긴 경우다. 이건 매번 해줘야 한다. DataLoader가 계속 CPU 텐서를 내놓기 때문이다.
3위 — 결과를 다시 안 담았다
x_batch.to(device) # ❌ 옮긴 결과가 버려진다
x_batch = x_batch.to(device) # ✅
지난 글의 forward 실수와 완전히 같은 형태다. .to()는 원본을 바꾸는 게 아니라 옮겨진 새 텐서를 돌려준다.
이 셋을 알고 나니 진단이 정말 3분으로 줄었다. “어디 있는지 물어보고, 없으면 옮기고, 옮긴 걸 담는다.”
습관 하나를 바꿨다
이 삽질에서 얻은 게 코드 지식보다 태도 쪽이 컸다.
그전까지 나는 에러가 나면 코드를 노려봤다. 화면을 위아래로 스크롤하면서 “어디가 잘못됐지” 하고 눈으로 찾았다. 그런데 그렇게 찾은 적이 거의 없었다.
지금은 먼저 물어본다. 지금 이 값이 어디 있는지, 무슨 타입인지, 모양이 뭔지. print 세 줄이면 나오는 걸 30분 동안 추측하고 있었던 것이다.
추측은 공짜처럼 느껴지지만 제일 비싸다. 확인은 3초면 끝나는데, 추측은 틀렸을 때 그만큼을 통째로 날린다.
그리고 진짜 무서웠던 건 “loader가 cpu네, 찾았다!”라고 착각했던 순간이다. 확인을 하긴 했는데 그 결과를 잘못 읽었다. 확인만큼이나 정상 상태가 무엇인지 아는 것도 중요하다는 걸 그때 알았다.
7. 전체 학습 코드
지금까지를 합치면 이렇게 된다.
best_valid_acc = 0.0
for epoch in range(1, EPOCHS + 1):
train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer, device)
valid_loss, valid_acc = evaluate(model, valid_loader, criterion, device)
print(f"[{epoch:2d}] train {train_loss:.4f}/{train_acc:.3f} | "
f"valid {valid_loss:.4f}/{valid_acc:.3f}")
if valid_acc > best_valid_acc: # 가장 좋았던 순간 저장
best_valid_acc = valid_acc
torch.save(model.state_dict(), "best_model.pt")
마지막 두 줄이 중요하다. 학습을 오래 돌리면 중간이 제일 좋고 끝은 오히려 나빠지는 경우가 흔하다. 과적합이 시작되기 때문이다.
그래서 검증 성능이 갱신될 때마다 저장해두고, 나중에 그 시점의 모델을 불러 쓴다.
model.load_state_dict(torch.load("best_model.pt"))
state_dict()는 모델의 값들만 저장한다. 구조는 코드에 있으니 값만 있으면 복원할 수 있다.
정리
- Dataset은 문제집, DataLoader는 하루치씩 나눠주는 사람
-
shuffle은 학습만 True — 순서로 인한 엉뚱한 규칙을 막는다 model.to(device)가 옵티마이저 생성보다 먼저-
CrossEntropyLoss는 소프트맥스를 내부에 품고 있다. 따로 붙이면 두 번 적용된다 - 학습 루프 다섯 단계
- zero_grad → forward → loss → backward → step
- ④ → ⑤는 못 바꾼다. ①은 ④보다 앞이면 된다
- 안 지우면 기울기가 쌓여서 엉뚱한 방향으로 크게 움직인다
- 평가에는
zero_grad·backward·step이 전부 빠진다. 대신eval()과no_grad() - 통계는
.item()으로 숫자만 꺼내 누적한다. 텐서째 더하면 메모리가 샌다 - GPU 에러는 노려보지 말고 물어본다 — 모델·데이터·옵티마이저의
.device를 찍어본다- DataLoader가 CPU 텐서를 주는 건 정상이다. 옮기는 건 우리 몫
-
.to(device)는 결과를 다시 담아야 한다
- 검증 성능이 가장 좋았던 시점을 저장해두고 나중에 불러 쓴다
다음 글에서
다음 글에서는 자연어 처리 라이브러리를 다룬다. 문장을 토큰으로 쪼개는 방법과, 그 토큰을 벡터로 바꾸는 nn.Embedding이 실제로 무엇을 하는지 정리한다.
한줄 평
- 지금까지의 이론과 실습을 무사히 마무리했다! 아직 남은 진도와 실습들과 남겨진 나에게 화이팅