역전파를 처음부터 구현하기(Backpropagation from Scratch)

역전파(Backpropagation)는 학습을 가능하게 만드는 알고리즘입니다. 역전파가 없다면 신경망은 비싼 난수 생성기에 가깝습니다.

유형: Build

언어: Python

선수 지식: Lesson 03.02 다층 네트워크(Multi-Layer Networks)

소요 시간: 약 120분

학습 목표

  • 계산 그래프(computational graph)를 만들고 위상 정렬(topological sort)로 기울기(gradient)를 계산하는 Value 기반 자동 미분 엔진(autograd engine)을 구현합니다.
  • 연쇄 법칙(chain rule)을 사용해 덧셈, 곱셈, 시그모이드(sigmoid)의 역방향 패스(backward pass)를 유도합니다.
  • 직접 만든 역전파 엔진(backpropagation engine)만으로 XOR과 원 분류(circle classification) 문제에서 다층 신경망을 학습합니다.
  • 깊은 시그모이드 신경망(deep sigmoid network)에서 발생하는 기울기 소실(vanishing gradient) 문제를 확인하고 기울기가 지수적으로 줄어드는 이유를 설명합니다.

문제

신경망에 입력(input) 768개, 출력(output) 3072개를 가진 은닉 층(hidden layer)이 하나 있다고 해봅시다. 가중치(weight)만 2,359,296개입니다. 이 신경망이 잘못 예측했습니다. 어떤 가중치가 오차(error)를 만들었을까요?

각 가중치를 하나씩 아주 조금 바꾸고 순전파(forward pass)를 다시 실행해서 손실(loss)이 늘었는지 줄었는지 확인하면 그 가중치의 기울기를 얻을 수 있습니다. 하지만 230만 개의 가중치에 대해 이 작업을 하면 230만 번의 순전파가 필요합니다. 이것을 수천 단계(step), 수백만 데이터 포인트(data point)에 대해 반복하면 유용한 모델 하나를 학습하는 데 지질학적 시간이 걸립니다.

역전파는 이 문제를 풉니다. 순전파 한 번, 역방향 패스 한 번으로 모든 기울기를 계산합니다. 비결은 미적분학(calculus)의 연쇄 법칙을 계산 그래프에 체계적으로 적용하는 것입니다. 이 알고리즘이 딥러닝(deep learning)을 실용적으로 만들었습니다. 역전파가 없었다면 우리는 여전히 장난감 문제(toy problem)에 머물렀을 것입니다.

사전 테스트

2문제 · 이 강의를 시작하기 전에 얼마나 알고 있는지 확인해보세요

1.신경망 맥락에서 연쇄 법칙(chain rule)은 무엇인가요?

2.역전파(backpropagation)가 각 기울기(gradient)를 독립적으로 계산하는 것보다 효율적인 이유는 무엇인가요?

0/2 답변 완료

개념

신경망에 적용한 연쇄 법칙

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

순전파에서는 값이 왼쪽에서 오른쪽으로 흐릅니다. xwz1 = w*x를 만들고, 여기에 b를 더해 z2를 만들며, 시그모이드가 활성값(activation) a를 만듭니다. 손실 함수는 a와 정답 y를 비교합니다.

역방향 패스에서는 기울기가 오른쪽에서 왼쪽으로 흐릅니다. dL/da에서 시작해 da/dz2와 곱하고, 그 결과가 dL/dz2가 됩니다. z2 = z1 + b이므로 dL/dbdL/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)되는 것을 줄이기 위해서입니다. LayerNeuron의 목록(list)입니다. NetworkLayer의 목록입니다. 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)

연습문제

  1. Value 클래스(class)에 __sub__ 메서드를 추가합니다. a - b = a + (-1 * b)입니다. 이어서 __neg__ 메서드도 구현합니다. (a - b)^2 같은 간단한 식(expression)에 대해 손 계산(manual calculation)과 비교해 기울기가 맞는지 확인합니다.
  2. Valuerelu 메서드를 추가합니다. 출력은 max(0, x)이고 도함수는 x > 0이면 1, 아니면 0입니다. 은닉 층에서 시그모이드 대신 ReLU를 사용해 XOR을 다시 학습합니다. 수렴 속도(convergence speed)를 비교합니다. 더 빠른 학습을 보게 될 것입니다. 이것은 Lesson 04의 예고입니다.
  3. 정수 거듭제곱(integer power)을 위한 __pow__ 메서드를 구현합니다. 이를 사용해 mse_loss(predicted - target) ** 2 식으로 바꿉니다. 기울기가 기존 구현과 일치하는지 확인합니다.
  4. 학습 루프에 기울기 클리핑(gradient clipping)을 추가합니다. backward()를 호출한 뒤 모든 기울기를 [-1, 1]로 잘라냅니다(clip). 시그모이드를 쓰는 4개 이상 층의 깊은 신경망을 학습하고 클리핑 유무에 따른 손실 곡선(loss curve)을 비교합니다.
  5. 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)"순방향 계산 중 그래프를 만들고 기울기를 자동으로 계산하는 시스템

더 읽을거리

실습 코드

이 강의의 실습 코드 1개

main
Code

산출물

이 강의에서 생성된 프롬프트, 스킬, 코드 산출물 1개

prompt-gradient-debugger

Diagnose and fix gradient problems in neural networks -- vanishing gradients, exploding gradients, and NaN values

Prompt

확인 문제

3문제 · 모두 맞추면 완료 표시가 가능합니다

1.역방향 패스(backward pass)에서 기울기(gradient)를 누적할 때 '=' 대신 '+='를 사용하는 이유는 무엇인가요?

2.깊은 시그모이드 신경망(deep sigmoid network)에서 기울기 소실(vanishing gradient) 문제가 생기는 원인은 무엇인가요?

3.역방향 패스(backward pass)에서 위상 정렬(topological sort)이 중요한 이유는 무엇인가요?

0/3 답변 완료