개념
신경망에 적용한 연쇄 법칙
Phase 01 Lesson 05에서 연쇄 법칙을 봤습니다. 빠르게 복습하면, y = f(g(x))라면 dy/dx = f'(g(x)) * g'(x)입니다. 사슬(chain)을 따라 도함수(derivative)를 곱합니다.
신경망에서 사슬은 입력에서 손실까지 이어지는 연산의 흐름(sequence)입니다. 각 층(layer)은 가중치를 적용하고 편향(bias)을 더한 뒤 활성화 함수(activation)를 통과시킵니다. 손실 함수(loss function)는 최종 출력을 정답(target)과 비교합니다. 역전파는 이 사슬을 거꾸로 추적하며 각 연산(operation)이 오차에 얼마나 기여했는지 계산합니다.
계산 그래프
모든 순전파는 그래프(graph)를 만듭니다. 각 노드(node)는 곱셈(multiply), 덧셈(add), 시그모이드 같은 연산입니다. 각 간선(edge)은 순방향으로 값(value)을 전달하고, 역방향으로 기울기를 전달합니다.
graph LR
x["x"] --> mul["*"]
w["w"] --> mul
mul -- "z1 = w*x" --> add["+"]
b["b"] --> add
add -- "z2 = z1 + b" --> sig["sigmoid"]
sig -- "a = sigmoid(z2)" --> loss["Loss"]
y["target"] --> loss
순전파에서는 값이 왼쪽에서 오른쪽으로 흐릅니다. x와 w가 z1 = w*x를 만들고, 여기에 b를 더해 z2를 만들며, 시그모이드가 활성값(activation) a를 만듭니다. 손실 함수는 a와 정답 y를 비교합니다.
역방향 패스에서는 기울기가 오른쪽에서 왼쪽으로 흐릅니다. dL/da에서 시작해 da/dz2와 곱하고, 그 결과가 dL/dz2가 됩니다. z2 = z1 + b이므로 dL/db와 dL/dz1로 나뉩니다. 그 다음 dL/dw = dL/dz1 * x, dL/dx = dL/dz1 * w가 됩니다.
그래프의 모든 노드는 역방향 패스에서 한 가지 일을 합니다. 위에서 내려온 기울기에 자신의 국소 도함수(local derivative)를 곱해 아래로 전달합니다.
순전파와 역방향 패스
graph TB
subgraph Forward["Forward Pass"]
direction LR
f1["Input x"] --> f2["z = Wx + b"]
f2 --> f3["a = sigmoid(z)"]
f3 --> f4["Loss = (a - y)^2"]
end
subgraph Backward["Backward Pass"]
direction RL
b4["dL/dL = 1"] --> b3["dL/da = 2(a-y)"]
b3 --> b2["dL/dz = dL/da * a(1-a)"]
b2 --> b1["dL/dW = dL/dz * x\ndL/db = dL/dz"]
end
Forward --> Backward
순전파는 z, a, 각 층의 입력 같은 중간 값(intermediate value)을 저장합니다. 역방향 패스는 기울기를 계산하기 위해 이 값들이 필요합니다. 이것이 역전파의 핵심 메모리-연산 절충(memory-computation tradeoff)입니다. 활성값을 저장하는 메모리를 쓰는 대신, 수백만 번의 순전파가 아니라 한 번의 역방향 패스로 기울기를 계산합니다.
신경망을 흐르는 기울기
3층 신경망에서는 기울기가 모든 층을 거쳐 사슬처럼 이어집니다.
graph RL
L["Loss"] -- "dL/da3" --> L3["Layer 3\na3 = sigmoid(z3)"]
L3 -- "dL/dz3 = dL/da3 * sigmoid'(z3)" --> L2["Layer 2\na2 = sigmoid(z2)"]
L2 -- "dL/dz2 = dL/da2 * sigmoid'(z2)" --> L1["Layer 1\na1 = sigmoid(z1)"]
L1 -- "dL/dz1 = dL/da1 * sigmoid'(z1)" --> I["Input"]
각 층에서 기울기는 시그모이드의 도함수와 곱해집니다. 시그모이드 도함수는 a * (1 - a)이고 최댓값은 a = 0.5일 때 0.25입니다. 3층을 지나면 기울기는 최대 0.25^3 = 0.0156이 됩니다. 10층을 지나면 0.25^10 = 0.000001입니다.
기울기 소실
이것이 기울기 소실 문제입니다. 시그모이드는 출력을 0과 1 사이로 압축합니다. 도함수는 항상 0.25보다 작습니다. 시그모이드 층을 충분히 많이 쌓으면 기울기는 거의 0이 됩니다. 초반 층은 거의 0에 가까운 기울기를 받으므로 잘 학습하지 못합니다.
sigmoid(z): Output range [0, 1]
sigmoid'(z): Max value 0.25 (at z = 0)
After 5 layers: gradient * 0.25^5 = 0.001x original
After 10 layers: gradient * 0.25^10 = 0.000001x original
이것이 깊은 시그모이드 신경망을 학습하기 어려운 이유입니다. 해결책인 ReLU와 변형들은 Lesson 04의 주제입니다. 지금은 역전파 자체가 제대로 작동하지만, 그것이 통과하는 활성화 함수가 문제를 만들 수 있다는 점을 이해하면 됩니다.
2층 신경망의 기울기 유도
입력 x, 시그모이드 은닉 층, 시그모이드 출력 층, 평균 제곱 오차(MSE) 손실을 가진 신경망을 생각합니다.
순전파:
z1 = W1 * x + b1
a1 = sigmoid(z1)
z2 = W2 * a1 + b2
a2 = sigmoid(z2)
L = (a2 - y)^2
역방향 패스:
dL/da2 = 2(a2 - y)
da2/dz2 = a2 * (1 - a2)
dL/dz2 = dL/da2 * da2/dz2 = 2(a2 - y) * a2 * (1 - a2)
dL/dW2 = dL/dz2 * a1
dL/db2 = dL/dz2
dL/da1 = dL/dz2 * W2
da1/dz1 = a1 * (1 - a1)
dL/dz1 = dL/da1 * da1/dz1
dL/dW1 = dL/dz1 * x
dL/db1 = dL/dz1
모든 기울기는 손실에서 거꾸로 따라온 국소 도함수의 곱(product)입니다. 역전파는 이것이 전부입니다.
만들어 보기
Step 1: Value 노드
계산에 등장하는 모든 숫자를 Value로 만듭니다. Value는 데이터(data), 기울기, 그리고 자신이 어떻게 만들어졌는지를 저장합니다.
class Value:
def __init__(self, data, children=(), op=''):
self.data = data
self.grad = 0.0
self._backward = lambda: None
self._children = set(children)
self._op = op
def __repr__(self):
return f"Value(data={self.data:.4f}, grad={self.grad:.4f})"
처음에는 기울기가 없으므로 0.0입니다. 역방향 함수(backward function)도 아직 없으므로 아무 일도 하지 않는 함수(no-op)로 둡니다. _children은 이 값을 만든 Value들을 추적하고, 나중에 그래프를 위상 정렬할 때 사용합니다.
Step 2: 역방향 함수를 가진 연산
각 연산은 새 Value를 만들고, 기울기가 자신을 통과해 어떻게 뒤로 흐를지 정의합니다.
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), '+')
def _backward():
self.grad += out.grad
other.grad += out.grad
out._backward = _backward
return out
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), '*')
def _backward():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
덧셈에서는 d(a+b)/da = 1, d(a+b)/db = 1입니다. 따라서 두 입력은 출력의 기울기를 그대로 받습니다.
곱셈에서는 d(a*b)/da = b, d(a*b)/db = a입니다. 각 입력은 다른 입력의 값과 출력 기울기를 곱한 값을 받습니다.
여기서 +=는 중요합니다. 하나의 Value가 여러 연산에 사용될 수 있기 때문입니다. 그 기울기는 모든 경로(path)에서 흘러온 기울기의 합입니다.
Step 3: 시그모이드와 손실
import math
def sigmoid(self):
x = self.data
x = max(-500, min(500, x))
s = 1.0 / (1.0 + math.exp(-x))
out = Value(s, (self,), 'sigmoid')
def _backward():
self.grad += (s * (1 - s)) * out.grad
out._backward = _backward
return out
시그모이드 도함수는 sigmoid(x) * (1 - sigmoid(x))입니다. 순전파에서 이미 s를 계산했으므로 재사용합니다.
def mse_loss(predicted, target):
diff = predicted + Value(-target)
return diff * diff
단일 출력의 MSE는 (predicted - target)^2입니다. 뺄셈은 부호를 뒤집은 Value와의 덧셈으로 표현합니다.
Step 4: 역방향 패스
위상 정렬은 올바른 순서로 노드를 처리하게 합니다. 어떤 노드의 기울기가 완전히 누적된 뒤에야 그 노드를 통해 아래로 전파합니다.
def backward(self):
topo = []
visited = set()
def build_topo(v):
if v not in visited:
visited.add(v)
for child in v._children:
build_topo(child)
topo.append(v)
build_topo(self)
self.grad = 1.0
for v in reversed(topo):
v._backward()
손실에서 시작합니다. dL/dL = 1이므로 self.grad = 1.0입니다. 정렬된 그래프를 거꾸로 걸으며 각 노드의 _backward가 기울기를 자식(child)으로 보냅니다.
Step 5: 층과 신경망
import random
class Neuron:
def __init__(self, n_inputs):
scale = (2.0 / n_inputs) ** 0.5
self.weights = [Value(random.uniform(-scale, scale)) for _ in range(n_inputs)]
self.bias = Value(0.0)
def __call__(self, x):
act = sum((wi * xi for wi, xi in zip(self.weights, x)), self.bias)
return act.sigmoid()
def parameters(self):
return self.weights + [self.bias]
class Layer:
def __init__(self, n_inputs, n_outputs):
self.neurons = [Neuron(n_inputs) for _ in range(n_outputs)]
def __call__(self, x):
out = [n(x) for n in self.neurons]
return out[0] if len(out) == 1 else out
def parameters(self):
params = []
for n in self.neurons:
params.extend(n.parameters())
return params
class Network:
def __init__(self, sizes):
self.layers = []
for i in range(len(sizes) - 1):
self.layers.append(Layer(sizes[i], sizes[i + 1]))
def __call__(self, x):
for layer in self.layers:
x = layer(x)
if not isinstance(x, list):
x = [x]
return x[0] if len(x) == 1 else x
def zero_grad(self):
for p in self.parameters():
p.grad = 0.0
Neuron은 입력을 받아 가중합(weighted sum)에 편향을 더하고 시그모이드를 적용합니다. 가중치 초기화(weight initialization)는 sqrt(2/n_inputs)로 크기를 조정(scale)합니다. 깊은 신경망에서 시그모이드가 너무 빨리 포화(saturation)되는 것을 줄이기 위해서입니다. Layer는 Neuron의 목록(list)입니다. Network는 Layer의 목록입니다. parameters() 메서드(method)는 갱신(update)할 학습 가능한 Value들을 모읍니다.
Step 6: XOR 학습
random.seed(42)
net = Network([2, 4, 1])
xor_data = [
([0.0, 0.0], 0.0),
([0.0, 1.0], 1.0),
([1.0, 0.0], 1.0),
([1.0, 1.0], 0.0),
]
learning_rate = 1.0
for epoch in range(1000):
total_loss = Value(0.0)
for inputs, target in xor_data:
x = [Value(i) for i in inputs]
pred = net(x)
loss = mse_loss(pred, target)
total_loss = total_loss + loss
net.zero_grad()
total_loss.backward()
for p in net.parameters():
p.data -= learning_rate * p.grad
if epoch % 100 == 0:
print(f"Epoch {epoch:4d} | Loss: {total_loss.data:.6f}")
print("\nXOR 결과:")
for inputs, target in xor_data:
x = [Value(i) for i in inputs]
pred = net(x)
print(f" {inputs} -> {pred.data:.4f} (기댓값 {target})")
손실이 줄어드는 것을 확인합니다. 무작위 예측(random prediction)에서 XOR을 맞추는 출력으로 바뀌는 과정은 전적으로 역전파가 기울기를 계산하고 가중치를 올바른 방향으로 살짝씩 밀어준(nudging) 결과입니다.
Step 7: 원 분류 문제
Lesson 02에서는 원 분류를 위해 가중치를 손으로 조정했습니다. 이제 신경망이 직접 학습하게 합니다.
random.seed(7)
def generate_circle_data(n=100):
data = []
for _ in range(n):
x1 = random.uniform(-1.5, 1.5)
x2 = random.uniform(-1.5, 1.5)
label = 1.0 if x1 * x1 + x2 * x2 < 1.0 else 0.0
data.append(([x1, x2], label))
return data
circle_data = generate_circle_data(80)
circle_net = Network([2, 8, 1])
learning_rate = 0.5
for epoch in range(2000):
random.shuffle(circle_data)
total_loss_val = 0.0
for inputs, target in circle_data:
x = [Value(i) for i in inputs]
pred = circle_net(x)
loss = mse_loss(pred, target)
circle_net.zero_grad()
loss.backward()
for p in circle_net.parameters():
p.data -= learning_rate * p.grad
total_loss_val += loss.data
if epoch % 200 == 0:
correct = 0
for inputs, target in circle_data:
x = [Value(i) for i in inputs]
pred = circle_net(x)
predicted_class = 1.0 if pred.data > 0.5 else 0.0
if predicted_class == target:
correct += 1
accuracy = correct / len(circle_data) * 100
print(f"Epoch {epoch:4d} | Loss: {total_loss_val:.4f} | Accuracy: {accuracy:.1f}%")
여기서는 온라인 확률적 경사 하강법(online SGD)을 사용합니다. 전체 배치(full batch)의 손실을 누적하지 않고 표본(sample)마다 가중치를 갱신합니다. 이렇게 하면 대칭(symmetry)이 더 빨리 깨지고 시그모이드의 포화를 피하는 데 도움이 됩니다. 매 에포크(epoch)마다 데이터를 섞으면(shuffle) 신경망이 순서를 외우는 것을 줄일 수 있습니다.
손으로 조정하지 않습니다. 신경망이 원형 결정 경계(circular decision boundary)를 직접 발견합니다. 이것이 역전파의 힘입니다. 신경망 구조(architecture), 손실 함수, 데이터를 정의하면 알고리즘(algorithm)이 가중치를 찾아냅니다.
사용하기
PyTorch는 위 모든 것을 몇 줄로 처리합니다. 핵심 아이디어는 같습니다. 자동 미분(autograd)은 순전파 중 계산 그래프를 만들고, 이를 거꾸로 추적해 기울기를 계산합니다.
import torch
import torch.nn as nn
model = nn.Sequential(
nn.Linear(2, 4),
nn.Sigmoid(),
nn.Linear(4, 1),
nn.Sigmoid(),
)
optimizer = torch.optim.SGD(model.parameters(), lr=1.0)
criterion = nn.MSELoss()
X = torch.tensor([[0,0],[0,1],[1,0],[1,1]], dtype=torch.float32)
y = torch.tensor([[0],[1],[1],[0]], dtype=torch.float32)
for epoch in range(1000):
pred = model(X)
loss = criterion(pred, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
print("PyTorch XOR 결과:")
with torch.no_grad():
for i in range(4):
pred = model(X[i])
print(f" {X[i].tolist()} -> {pred.item():.4f} (기댓값 {y[i].item()})")
loss.backward()는 여러분의 total_loss.backward()입니다. optimizer.step()은 직접 작성한 p.data -= lr * p.grad입니다. optimizer.zero_grad()는 net.zero_grad()입니다. PyTorch는 GPU 가속(acceleration), 혼합 정밀도(mixed precision), 기울기 체크포인팅(gradient checkpointing), 다양한 층을 처리하지만 역방향 패스의 원리는 동일합니다.
학습(training)은 순전파, 역방향 패스, 가중치 갱신을 실행합니다. 추론(inference)은 순전파만 실행합니다. 기울기도 갱신도 없습니다. Claude나 GPT 같은 API를 호출할 때는 추론을 실행하는 것입니다. 프롬프트(prompt)가 신경망을 순방향으로 통과하고 토큰(token)이 나옵니다. 가중치는 바뀌지 않습니다.
산출물 만들기
이 lesson의 산출물은 다음입니다.
outputs/prompt-gradient-debugger.md: 기울기 소실(vanishing gradient), 기울기 폭주(exploding gradient), NaN 기울기 같은 기울기 문제를 진단하는 재사용 프롬프트(prompt)
연습문제
Value 클래스(class)에 __sub__ 메서드를 추가합니다. a - b = a + (-1 * b)입니다. 이어서 __neg__ 메서드도 구현합니다. (a - b)^2 같은 간단한 식(expression)에 대해 손 계산(manual calculation)과 비교해 기울기가 맞는지 확인합니다.
Value에 relu 메서드를 추가합니다. 출력은 max(0, x)이고 도함수는 x > 0이면 1, 아니면 0입니다. 은닉 층에서 시그모이드 대신 ReLU를 사용해 XOR을 다시 학습합니다. 수렴 속도(convergence speed)를 비교합니다. 더 빠른 학습을 보게 될 것입니다. 이것은 Lesson 04의 예고입니다.
- 정수 거듭제곱(integer power)을 위한
__pow__ 메서드를 구현합니다. 이를 사용해 mse_loss를 (predicted - target) ** 2 식으로 바꿉니다. 기울기가 기존 구현과 일치하는지 확인합니다.
- 학습 루프에 기울기 클리핑(gradient clipping)을 추가합니다.
backward()를 호출한 뒤 모든 기울기를 [-1, 1]로 잘라냅니다(clip). 시그모이드를 쓰는 4개 이상 층의 깊은 신경망을 학습하고 클리핑 유무에 따른 손실 곡선(loss curve)을 비교합니다.
- XOR 학습 후 신경망의 모든 파라미터(parameter) 기울기를 출력하는 시각화(visualization)를 만듭니다. 어떤 층의 기울기가 가장 작은지 확인합니다. 이것은 개념 절(section)의 기울기 소실 문제를 보여 줍니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 역전파(Backpropagation) | "신경망이 학습하는 것" | 계산 그래프를 거꾸로 따라가며 연쇄 법칙으로 모든 가중치의 dL/dw를 계산하는 알고리즘 |
| 계산 그래프(Computational graph) | "신경망 구조" | 노드는 연산, 간선은 값과 기울기를 전달하는 유향 비순환 그래프(directed acyclic graph) |
| 연쇄 법칙(Chain rule) | "도함수를 곱한다" | y = f(g(x))일 때 dy/dx = f'(g(x)) * g'(x)인 역전파의 수학적 토대 |
| 기울기(Gradient) | "가장 가파르게 증가하는 방향" | 손실을 파라미터로 편미분한 값. 파라미터를 어떻게 바꿔야 손실이 줄어드는지 알려줌 |
| 기울기 소실(Vanishing gradient) | "깊은 신경망이 학습하지 않음" | 시그모이드처럼 포화되는 활성화 함수를 지나며 기울기가 지수적으로 작아지는 현상 |
| 순전파(Forward pass) | "신경망 실행" | 입력에서 출력을 계산하고 중간 값들을 저장하는 과정 |
| 역방향 패스(Backward pass) | "기울기 계산" | 계산 그래프를 역방향으로 순회하며 연쇄 법칙으로 기울기를 누적하는 과정 |
| 학습률(Learning rate) | "얼마나 빨리 배우는지" | 가중치 갱신의 보폭(step size)을 조절하는 스칼라(scalar) |
| 위상 정렬(Topological sort) | "올바른 순서" | 각 노드가 자신이 의존하는 모든 노드 뒤에 오도록 정렬하는 방식 |
| 자동 미분 엔진(Autograd) | "자동 미분(automatic differentiation)" | 순방향 계산 중 그래프를 만들고 기울기를 자동으로 계산하는 시스템 |
더 읽을거리