들어가며
신경망을 처음 공부할 때 "역전파"라는 단어가 가장 어렵게 느껴졌다. 순전파는 데이터가 앞으로 흐르는 거니까 직관적으로 이해가 됐는데, 역전파는 왜 필요하고 어떻게 동작하는지 처음엔 막막했다. 웹 개발로 비유하면 순전파는 함수 호출 스택처럼 앞으로 실행되는 것이고, 역전파는 에러가 어디서 발생했는지 스택을 거슬러 올라가며 추적하는 것이다. 개념부터 코드까지 차근차근 정리해둔다.
순전파 (Forward Pass)
입력 데이터가 신경망을 통과해서 출력값을 만드는 과정이다.
입력 x → 레이어1 → 레이어2 → ... → 출력 ŷ → 손실 L
각 레이어에서 하는 일:
z = W @ x + b (선형 변환)
a = f(z) (활성화 함수)
import numpy as np
np.random.seed(42)
def relu(x):
return np.maximum(0, x)
def sigmoid(x):
return 1 / (1 + np.exp(-x))
# 2개 입력 → 3개 은닉 → 1개 출력 (이진 분류)
W1 = np.array([[0.1, 0.3],
[0.2, 0.4],
[0.5, 0.1]]) # (3, 2)
b1 = np.array([0.1, 0.2, 0.3])
W2 = np.array([[0.3, 0.5, 0.2]]) # (1, 3)
b2 = np.array([0.1])
x = np.array([1.0, 2.0])
y_true = 1.0
# 순전파
z1 = W1 @ x + b1
a1 = relu(z1)
z2 = W2 @ a1 + b2
a2 = sigmoid(z2)
y_pred = a2[0]
# Binary Cross-Entropy 손실
loss = -(y_true * np.log(y_pred + 1e-10) +
(1 - y_true) * np.log(1 - y_pred + 1e-10))
print("=== 순전파 ===")
print(f"z1: {z1.round(4)}")
print(f"a1: {a1.round(4)}")
print(f"y_pred: {y_pred:.4f}")
print(f"loss: {loss:.4f}")
역전파 (Backpropagation)가 필요한 이유
순전파로 예측값과 손실을 구했다. 이제 손실을 줄이려면 가중치를 어떻게 바꿔야 할까?
각 가중치에 대한 손실의 기울기(∂L/∂W)를 구해서 기울기 반대 방향으로 이동한다. → 경사하강법
가중치가 수백만 개인 신경망에서 모든 기울기를 구하려면 연쇄 법칙(Chain Rule)을 이용한다. 이것이 역전파다.
역전파의 흐름:
loss → a2 → z2 → W2, b2
↓
a1 → z1 → W1, b1
역전파 (Backward Pass)
출력층 역전파
# Binary Cross-Entropy + Sigmoid 합친 그래디언트:
# ∂L/∂z2 = y_pred - y_true
dL_dz2 = y_pred - y_true
print(f"dL/dz2: {dL_dz2:.4f}")
# z2 = W2 @ a1 + b2
# ∂L/∂W2 = dL_dz2 * a1
dL_dW2 = dL_dz2 * a1
dL_db2 = dL_dz2
print(f"dL/dW2: {dL_dW2.round(4)}")
print(f"dL/db2: {dL_db2:.4f}")
은닉층 역전파
# ∂L/∂a1 = dL_dz2 * W2
dL_da1 = dL_dz2 * W2.squeeze()
# ReLU 역전파: f'(x) = 1 if x > 0 else 0
dL_dz1 = dL_da1 * (z1 > 0).astype(float)
# ∂L/∂W1 = ∂L/∂z1 * x^T
dL_dW1 = np.outer(dL_dz1, x)
dL_db1 = dL_dz1
print(f"dL/dW1:\n{dL_dW1.round(4)}")
print(f"dL/db1: {dL_db1.round(4)}")
가중치 업데이트 (경사하강법)
lr = 0.1
W1_new = W1 - lr * dL_dW1
b1_new = b1 - lr * dL_db1
W2_new = W2 - lr * dL_dW2
b2_new = b2 - lr * dL_db2
# 업데이트 후 손실 확인
z1_new = W1_new @ x + b1_new
a1_new = relu(z1_new)
z2_new = W2_new @ a1_new + b2_new
y_pred_new = sigmoid(z2_new)[0]
loss_new = -(y_true * np.log(y_pred_new + 1e-10) +
(1 - y_true) * np.log(1 - y_pred_new + 1e-10))
print(f"업데이트 전 loss: {loss:.4f}")
print(f"업데이트 후 loss: {loss_new:.4f}") # 손실이 줄어야 함
전체 학습 루프
실제 학습은 순전파 → 역전파 → 가중치 업데이트를 반복한다.
np.random.seed(42)
W1 = np.random.randn(4, 3) * 0.1
b1 = np.zeros(3)
W2 = np.random.randn(3, 1) * 0.1
b2 = np.zeros(1)
X_train = np.array([
[0, 0, 0, 0],
[0, 1, 0, 1],
[1, 0, 1, 0],
[1, 1, 1, 1]
], dtype=float)
y_train = np.array([[0], [0], [0], [1]], dtype=float)
lr = 0.1
for epoch in range(1000):
# ===== 순전파 =====
z1 = X_train @ W1 + b1
a1 = relu(z1)
z2 = a1 @ W2 + b2
a2 = sigmoid(z2)
loss = -np.mean(
y_train * np.log(a2 + 1e-10) +
(1 - y_train) * np.log(1 - a2 + 1e-10)
)
# ===== 역전파 =====
n = len(X_train)
dL_dz2 = (a2 - y_train) / n
dL_dW2 = a1.T @ dL_dz2
dL_db2 = dL_dz2.sum(axis=0)
dL_da1 = dL_dz2 @ W2.T
dL_dz1 = dL_da1 * (z1 > 0)
dL_dW1 = X_train.T @ dL_dz1
dL_db1 = dL_dz1.sum(axis=0)
# ===== 가중치 업데이트 =====
W2 -= lr * dL_dW2
b2 -= lr * dL_db2
W1 -= lr * dL_dW1
b1 -= lr * dL_db1
if (epoch + 1) % 200 == 0:
print(f"Epoch {epoch+1:4d}: loss = {loss:.4f}")
계산 그래프로 이해하는 역전파
순전파 (왼쪽 → 오른쪽):
x → [W1,b1] → z1 → [ReLU] → a1 → [W2,b2] → z2 → [Sigmoid] → y_pred → [Loss] → L
역전파 (오른쪽 → 왼쪽):
x ← [W1,b1] ← z1 ← [ReLU] ← a1 ← [W2,b2] ← z2 ← [Sigmoid] ← y_pred ← [Loss] ← L
각 노드에서 하는 일:
순전파: 결과값 계산하고 저장 (나중에 역전파에서 쓰임)
역전파: 들어온 그래디언트 × 로컬 그래디언트 = 출력 그래디언트
PyTorch의 autograd
직접 역전파를 구현하지 않아도 된다. PyTorch가 자동으로 해준다.
import torch
import torch.nn as nn
torch.manual_seed(42)
model = nn.Sequential(
nn.Linear(2, 3), # W1, b1
nn.ReLU(),
nn.Linear(3, 1), # W2, b2
nn.Sigmoid()
)
criterion = nn.BCELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
X = torch.tensor([[0., 0.], [0., 1.], [1., 0.], [1., 1.]])
y = torch.tensor([[0.], [0.], [0.], [1.]])
for epoch in range(1000):
y_pred = model(X) # 순전파
loss = criterion(y_pred, y) # 손실 계산
optimizer.zero_grad() # 기울기 초기화
loss.backward() # 역전파 (자동)
optimizer.step() # 가중치 업데이트
if (epoch + 1) % 200 == 0:
print(f"Epoch {epoch+1}: loss = {loss.item():.4f}")
optimizer.zero_grad()가 왜 필요한가
처음 PyTorch 코드를 보면서 왜 매번 호출하는지 의아했다.
# PyTorch는 기본적으로 기울기를 누적한다
x = torch.tensor([2.0], requires_grad=True)
y = x ** 2
y.backward()
print(f"1번째: x.grad = {x.grad}") # 4.0
y = x ** 2
y.backward()
print(f"2번째: x.grad = {x.grad}") # 8.0 (누적됨!)
x.grad.zero_()
y = x ** 2
y.backward()
print(f"초기화 후: x.grad = {x.grad}") # 4.0
# 따라서 매 스텝마다 optimizer.zero_grad()를 호출해야 한다
정리
| 개념 | 설명 | 핵심 포인트 |
|---|---|---|
| 순전파 | 입력 → 출력 계산 | 각 레이어의 출력을 저장해둠 |
| 역전파 | 손실 → 각 가중치 기울기 계산 | 연쇄 법칙 반복 적용 |
| 학습 루프 | 순전파 → 손실 → 역전파 → 업데이트 반복 | 손실이 줄어드는 방향으로 수렴 |
| zero_grad() | 기울기 초기화 | PyTorch는 기울기를 누적하기 때문 |
| loss.backward() | 모든 기울기 자동 계산 | autograd가 연쇄 법칙 자동 처리 |
| optimizer.step() | 가중치 업데이트 | 기울기 반대 방향으로 이동 |
'딥러닝 > 딥러닝 기초 개념' 카테고리의 다른 글
| 과적합과 정규화 (Overfitting, Dropout, BatchNorm) (0) | 2026.05.15 |
|---|---|
| 손실 함수 (Loss Function) (0) | 2026.05.15 |
| 경사하강법과 최적화 (Gradient Descent, Adam, SGD) (0) | 2026.05.15 |
| 신경망이란? (퍼셉트론, 활성화 함수) (0) | 2026.05.13 |