주간 리뷰 (Week 4): MLP와 PyTorch Training Loop를 처음부터 다시 쓰기
이번 주에 배운 신경망 학습 과정을 아무것도 보지 않고 재작성한다. 순전파, 손실 계산, 기울기 초기화, 역전파, 파라미터 갱신 순서를 코드로 연결하고 학습률과 배치 크기가 학습 결과에 미치는 영향을 비교한다. 막히는 부분은 디버깅 체크리스트로 원인을 좁힌다.
By 송민성
오늘의 목표
PyTorch Training Loop를 기억이 아닌 흐름으로 체화한다. 빈 파일에서 MLP와 학습 코드를 작성하고, 오류가 생기면 학습 단계별로 원인을 찾는다.
개념과 직관
오늘은 새 개념을 배우지 않는다. 이번 주 내용을 하나의 학습 순환으로 다시 연결한다.
MLP(Multi-Layer Perceptron, 여러 개의 완전연결층을 차례로 통과하는 신경망)는 입력 숫자를 받아 여러 층에서 변환한 뒤 예측값을 만든다.
학습 과정은 다음 순서로 반복된다.
- 입력을 모델에 넣어 예측한다.
- 예측과 정답의 차이인 손실(loss)을 계산한다.
- 이전 반복에서 남은 기울기(gradient)를 지운다.
- 역전파(backpropagation, 손실에 대한 각 파라미터의 기울기를 뒤에서부터 계산하는 과정)를 실행한다.
- 옵티마이저(optimizer, 기울기를 이용해 파라미터를 갱신하는 도구)가 모델을 수정한다.
그림으로 보면 다음과 같다.
입력 x
↓
MLP
↓
예측값
↓ 정답과 비교
손실
↓ backward()
각 파라미터의 기울기
↓ step()
수정된 파라미터
└──────── 다음 배치에서 다시 사용경사하강(gradient descent, 손실을 줄이는 방향으로 파라미터를 조금씩 옮기는 것)의 핵심은 다음 한 줄이다.
새 파라미터 = 현재 파라미터 - 학습률 × 기울기학습률(learning rate)은 한 번에 이동하는 크기다. 너무 크면 좋은 지점을 지나칠 수 있고, 너무 작으면 학습이 느리다.
배치(batch)는 한 번의 파라미터 갱신에 사용하는 데이터 묶음이다. 배치 크기가 작으면 자주 갱신하지만 방향이 흔들릴 수 있다. 크면 더 많은 데이터를 평균낸 방향으로 갱신하지만 한 에폭(epoch, 전체 학습 데이터를 한 번 사용하는 단위)의 갱신 횟수는 줄어든다.
코드로 직접 해보기
먼저 빈 파일에 아래 뼈대만 적고, 주석 부분을 기억으로 채운다.
for x, y in train_loader:
# 1. 예측
# 2. 손실 계산
# 3. 기울기 초기화
# 4. 역전파
# 5. 파라미터 갱신정답 순서는 다음과 같다.
for x, y in train_loader:
pred = model(x)
loss = loss_fn(pred, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()이제 실행 가능한 전체 코드를 새 파일에 직접 작성한다. 아래 코드는 두 숫자의 위치를 보고 0번 또는 1번 클래스를 예측한다.
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
torch.manual_seed(42)
# 데이터 만들기
x = torch.randn(1000, 2)
y = (x[:, 0] + x[:, 1] > 0).long()
train_x, test_x = x[:800], x[800:]
train_y, test_y = y[:800], y[800:]
train_data = TensorDataset(train_x, train_y)
test_data = TensorDataset(test_x, test_y)
train_loader = DataLoader(
train_data,
batch_size=32,
shuffle=True,
)
# MLP 만들기
model = nn.Sequential(
nn.Linear(2, 16),
nn.ReLU(),
nn.Linear(16, 2),
)
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
# 학습
for epoch in range(20):
model.train()
total_loss = 0.0
for batch_x, batch_y in train_loader:
pred = model(batch_x)
loss = loss_fn(pred, batch_y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
total_loss += loss.item() * len(batch_x)
average_loss = total_loss / len(train_data)
if epoch % 5 == 0:
print(f"epoch={epoch:2d}, loss={average_loss:.4f}")
# 평가
model.eval()
with torch.no_grad():
pred = model(test_x)
predicted_class = pred.argmax(dim=1)
accuracy = (predicted_class == test_y).float().mean()
print(f"test accuracy={accuracy.item():.3f}")코드를 실행한 뒤 다음 부분을 가리고 다시 쓴다.
1차: Training Loop만 재작성
2차: MLP 정의부터 재작성
3차: 데이터 준비부터 평가까지 전체 재작성model.train()은 모델을 학습 상태로 바꾼다. model.eval()은 평가 상태로 바꾼다. 현재 MLP에서는 두 상태의 결과가 같지만, 이후 상태에 따라 동작이 달라지는 층을 사용할 때 필요하다.
torch.no_grad()는 평가 중 기울기 계산을 끈다. 평가에서는 파라미터를 갱신하지 않으므로 계산 기록을 만들 필요가 없다.
loss.item()은 텐서에 담긴 손실값을 파이썬 숫자로 꺼낸다. 배치별 평균 손실에 배치 크기를 곱해 모두 더한 뒤 전체 데이터 수로 나누면 에폭 평균 손실을 구할 수 있다.
오늘의 실험
학습률과 배치 크기를 바꾸며 같은 실험을 재현한다.
학습률: 0.001, 0.01, 0.1, 1.0
배치 크기: 8, 32, 128각 조합을 한 번씩 실행하고 다음 표에 기록한다.
| 학습률 | 배치 크기 | 첫 에폭 손실 | 마지막 에폭 손실 | 테스트 정확도 | 관찰 |
|---|---|---|---|---|---|
| 0.001 | 8 | ||||
| 0.01 | 32 | ||||
| 0.1 | 32 | ||||
| 1.0 | 128 |
공정하게 비교하려면 매 실험 전에 다음 두 가지를 지킨다.
torch.manual_seed(42)를 다시 실행한다.- 매 실험마다 새 모델과 새 옵티마이저를 만든다.
기존 모델을 이어서 학습하면 시작 조건이 달라져 비교가 되지 않는다.
관찰할 항목은 세 가지다.
- 손실이 에폭마다 내려가는가
- 손실이 거의 변하지 않거나 크게 흔들리는가
- 학습 손실이 낮아졌을 때 테스트 정확도도 좋아지는가
정확한 숫자를 외우는 실험이 아니다. 학습률과 배치 크기를 바꾸면 학습 곡선과 최종 결과가 어떻게 달라지는지 설명하는 실험이다.
왜 이걸 배우나 (LLM / Post-training 연결)
LLM 사후학습(post-training, 사전학습된 모델을 목적에 맞게 추가로 학습하는 과정)도 구조는 같은 Training Loop를 사용한다. 실제 업무에서는 더 큰 모델과 데이터, 여러 장의 GPU를 사용하지만 입력 준비, 순전파, 손실 계산, 역전파, 갱신, 평가는 그대로 반복된다.
학습률과 배치 크기 실험을 정확히 재현하고 기록하는 능력은 LLM 학습 결과가 달라진 원인을 찾는 기본 작업이다.
초보자가 막히는 지점
loss.backward()를 호출했는데 모델이 변하지 않는다:optimizer.step()이 빠졌는지 확인한다.- 손실이 이상하게 누적된다: 반복마다
optimizer.zero_grad()를 호출했는지 확인한다. PyTorch는 기울기를 자동으로 덮어쓰지 않고 더한다. CrossEntropyLoss에서 자료형 오류가 난다: 정답은0,1같은 클래스 번호이며long자료형이어야 한다.- 출력 크기 오류가 난다: 입력 하나당 두 클래스 점수가 필요하므로 마지막 층은
nn.Linear(16, 2)다. - 정확도가 계속 바뀐다: 난수 시드를 고정하고, 조건마다 모델을 새로 만든다.
문제가 생기면 다음 순서로 확인한다.
print(batch_x.shape) # [배치 크기, 2]
print(batch_y.shape) # [배치 크기]
print(pred.shape) # [배치 크기, 2]
print(loss.item()) # 숫자 하나
print(batch_y.dtype) # torch.int64손실이 nan이면 학습률을 낮춰 본다. 손실이 전혀 줄지 않으면 zero_grad → backward → step 순서와 모델 출력 크기를 먼저 확인한다.
알고리즘 (병행 1시간)
이번 주에 틀린 문제를 새 답안을 보지 않고 다시 푼다.
각 문제당 다음 내용을 기록한다.
- 접근: 어떤 자료구조와 풀이 순서를 선택했는가
- 시간복잡도: 입력 크기가 커질 때 연산 횟수가 어떻게 늘어나는가
- 틀린 이유: 개념 부족, 조건 누락, 구현 실수, 시간복잡도 판단 실패 중 무엇이었는가
30분 동안 풀리지 않으면 이전 풀이 전체를 보지 말고, 막힌 조건 하나만 확인한 뒤 다시 시작한다. 마지막에는 정답 코드를 복사하지 않고 빈 파일에서 한 번 더 작성한다.