개요

지난 글에서 텐서와 모델까지 만들었다. 이제 실제로 학습을 돌릴 차례다.

학습 루프 코드는 인터넷에 널려 있다. 복사해서 붙이면 돌아간다. 그런데 그렇게 쓰다 보면 반드시 이런 순간이 온다.

“이 세 줄 순서를 바꾸면 어떻게 되지?” “왜 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를 처음 붙였을 때 나는 반나절을 날렸는데, 알고 보니 진단 세 줄이면 끝나는 문제였다.

PyTorch 에러 유형

유형 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

지난 글에서 본 float32float64 충돌이다.


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이 실제로 무엇을 하는지 정리한다.

한줄 평

  • 지금까지의 이론과 실습을 무사히 마무리했다! 아직 남은 진도와 실습들과 남겨진 나에게 화이팅