개념
연쇄 법칙
y = f(g(x))라면 x에 대한 y의 도함수는 아래와 같습니다.
dy/dx = dy/dg * dg/dx = f'(g(x)) * g'(x)
연쇄(chain)를 따라 각 도함수를 곱합니다. 각 고리(link)는 자신의 국소 도함수(local derivative)를 제공합니다.
예: y = sin(x^2)
g(x) = x^2 g'(x) = 2x
f(g) = sin(g) f'(g) = cos(g)
dy/dx = cos(x^2) * 2x
더 깊은 합성에서는 연쇄가 길어집니다.
y = f(g(h(x)))
dy/dx = f'(g(h(x))) * g'(h(x)) * h'(x)
신경망의 각 층(layer)은 이 연쇄의 고리 하나에 해당합니다.
계산 그래프
계산 그래프(Computational Graphs)는 연쇄 법칙을 눈으로 볼 수 있게 만듭니다. 모든 연산은 노드(node)가 됩니다. 데이터는 그래프를 따라 앞으로 흐르고, 기울기는 뒤로 흐릅니다.
순전파 (값 계산):
graph TD
x1["x1 = 2"] --> mul["* (multiply)"]
x2["x2 = 3"] --> mul
mul -->|"a = 6"| add["+ (add)"]
b["b = 1"] --> add
add -->|"c = 7"| relu["relu"]
relu -->|"y = 7"| y["output y"]
역전파 (기울기 계산):
graph TD
dy["dy/dy = 1"] -->|"relu'(c)=1, c>0이므로"| dc["dy/dc = 1"]
dc -->|"dc/da = 1"| da["dy/da = 1"]
dc -->|"dc/db = 1"| db["dy/db = 1"]
da -->|"da/dx1 = x2 = 3"| dx1["dy/dx1 = 3"]
da -->|"da/dx2 = x1 = 2"| dx2["dy/dx2 = 2"]
역전파는 모든 노드에서 연쇄 법칙을 적용해 출력에서 입력 방향으로 기울기를 전파합니다.
순방향 모드와 역방향 모드
그래프를 통과하며 연쇄 법칙을 적용하는 방법은 두 가지입니다.
순방향 모드(Forward mode)는 입력에서 시작해 도함수를 앞으로 밀어 보냅니다. dx/dx = 1을 계산하고 각 연산을 따라 전파합니다. 입력이 적고 출력이 많을 때 좋습니다.
순방향 모드: dx/dx = 1을 시드로 두고 앞으로 전파
x = 2 (dx/dx = 1)
a = x^2 (da/dx = 2x = 4)
y = sin(a) (dy/dx = cos(a) * da/dx = cos(4) * 4 = -2.615)
역방향 모드(Reverse mode)는 출력에서 시작해 기울기를 뒤로 당깁니다. dy/dy = 1을 계산하고 각 연산을 역순으로 통과합니다. 입력이 많고 출력이 적을 때 좋습니다.
역방향 모드: dy/dy = 1을 시드로 두고 뒤로 전파
y = sin(a) (dy/dy = 1)
a = x^2 (dy/da = cos(a) = cos(4) = -0.654)
x = 2 (dy/dx = dy/da * da/dx = -0.654 * 4 = -2.615)
신경망에는 수백만 개 입력, 즉 가중치가 있고 출력은 보통 하나의 손실입니다. 역방향 모드는 모든 기울기를 한 번의 역전파로 계산합니다. 역전파(Backpropagation)가 역방향 모드를 사용하는 이유입니다.
| 모드 | 시드(Seed) | 진행 방향 | 적합한 상황 |
|---|
| 순방향 | dx_i/dx_i = 1 | 입력에서 출력으로 | 입력이 적고 출력이 많을 때 |
| 역방향 | dy/dy = 1 | 출력에서 입력으로 | 입력이 많고 출력이 적을 때 (예: 신경망) |
순방향 모드를 위한 쌍대수
순방향 모드는 쌍대수(Dual Numbers)로 우아하게 구현할 수 있습니다. 쌍대수는 a + b*epsilon 형태이며 epsilon^2 = 0입니다.
쌍대수: (값, 도함수)
(2, 1)은 값이 2이고 x에 대한 도함수가 1이라는 뜻입니다.
산술 규칙:
(a, a') + (b, b') = (a+b, a'+b')
(a, a') * (b, b') = (a*b, a'*b + a*b')
sin(a, a') = (sin(a), cos(a)*a')
입력 변수의 도함수를 1로 시드(seed)하면, 도함수가 모든 연산을 따라 자동으로 전파됩니다.
자동미분 엔진 만들기
자동미분 엔진에는 세 가지가 필요합니다.
- 값 감싸기(Value wrapping). 모든 숫자를 값과 기울기를 저장하는 객체로 감쌉니다.
- 그래프 기록(Graph recording). 모든 연산이 입력과 국소 기울기 함수(local gradient function)를 기록합니다.
- 역전파(Backward pass). 그래프를 위상 정렬한 뒤 역순으로 걸으며 각 노드에서 연쇄 법칙을 적용합니다.
PyTorch의 autograd도 정확히 이렇게 동작합니다. torch.Tensor 클래스는 값을 감싸고, requires_grad=True일 때 연산을 기록하며, .backward()를 호출하면 기울기를 계산합니다.
PyTorch 자동미분 내부 동작
PyTorch 코드를 아래처럼 작성한다고 해 보겠습니다.
x = torch.tensor(2.0, requires_grad=True)
y = x ** 2 + 3 * x + 1
y.backward()
print(x.grad)
PyTorch 내부에서는 다음 일이 일어납니다.
requires_grad=True를 가진 x에 대해 Tensor 노드를 만듭니다.
- 모든 연산(
**, *, +)은 새 노드를 만들고 역방향 함수(backward function)를 기록합니다.
y.backward()는 기록된 그래프를 따라 역방향 모드 자동미분을 실행합니다.
- 각 노드의
grad_fn은 국소 기울기를 계산하고 부모 노드로 전달합니다.
- 기울기는
.grad 속성(attribute)에 덮어쓰기가 아니라 더하기로 누적됩니다.
그래프는 동적 그래프(dynamic graph), 즉 실행 시 정의(define-by-run) 방식입니다. 순전파를 할 때마다 새 그래프가 만들어집니다. 그래서 PyTorch 모델 안에서 if/else나 반복문(loop) 같은 Python 제어 흐름(control flow)을 사용할 수 있습니다.
직접 만들기
Step 1: Value 클래스
class Value:
def __init__(self, data, children=(), op=''):
self.data = data
self.grad = 0.0
self._backward = lambda: None
self._prev = set(children)
self._op = op
def __repr__(self):
return f"Value(data={self.data:.4f}, grad={self.grad:.4f})"
모든 Value는 수치 데이터, 처음에는 0인 기울기, 역방향 함수, 그리고 자신을 만든 자식 노드 포인터를 저장합니다.
Step 2: 기울기 추적이 포함된 산술 연산
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
def relu(self):
out = Value(max(0, self.data), (self,), 'relu')
def _backward():
self.grad += (1.0 if out.data > 0 else 0.0) * out.grad
out._backward = _backward
return out
각 연산은 국소 기울기를 계산하고 상류 기울기(upstream gradient; out.grad)를 곱하는 클로저(closure)를 만듭니다. +=는 어떤 값이 여러 연산에서 사용되는 경우를 처리합니다.
Step 3: 역전파 구현
def backward(self):
topo = []
visited = set()
def build_topo(v):
if v not in visited:
visited.add(v)
for child in v._prev:
build_topo(child)
topo.append(v)
build_topo(self)
self.grad = 1.0
for v in reversed(topo):
v._backward()
위상 정렬은 각 노드의 기울기가 완전히 계산된 뒤 자식으로 전파되게 합니다. 시드 기울기는 1.0입니다. 즉 dy/dy = 1입니다.
Step 4: 완성도 있는 엔진을 위한 추가 연산
기본 Value 클래스는 덧셈, 곱셈, ReLU를 처리합니다. 실제 자동미분 엔진에는 더 많은 연산이 필요합니다. 신경망을 만들려면 아래 연산이 필요합니다.
def __neg__(self):
return self * -1
def __sub__(self, other):
return self + (-other)
def __radd__(self, other):
return self + other
def __rmul__(self, other):
return self * other
def __rsub__(self, other):
return other + (-self)
def __pow__(self, n):
out = Value(self.data ** n, (self,), f'**{n}')
def _backward():
self.grad += n * (self.data ** (n - 1)) * out.grad
out._backward = _backward
return out
def __truediv__(self, other):
return self * (other ** -1) if isinstance(other, Value) else self * (Value(other) ** -1)
def exp(self):
import math
e = math.exp(self.data)
out = Value(e, (self,), 'exp')
def _backward():
self.grad += e * out.grad
out._backward = _backward
return out
def log(self):
import math
out = Value(math.log(self.data), (self,), 'log')
def _backward():
self.grad += (1.0 / self.data) * out.grad
out._backward = _backward
return out
def tanh(self):
import math
t = math.tanh(self.data)
out = Value(t, (self,), 'tanh')
def _backward():
self.grad += (1 - t ** 2) * out.grad
out._backward = _backward
return out
각 연산이 필요한 이유:
| 연산 | 역방향 규칙 | 사용처 |
|---|
__sub__ | 덧셈과 부호 반전 재사용 | 손실 계산 (pred - target) |
__pow__ | n * x^(n-1) | 다항 활성화 함수, 평균제곱오차(MSE; error^2) |
__truediv__ | 곱셈과 pow(-1) 재사용 | 정규화, 학습률 스케일링 |
exp | exp(x) * 상류 | 소프트맥스, 로그우도 |
log | (1/x) * 상류 | 교차 엔트로피 손실, 로그 확률 |
tanh | (1 - tanh^2) * 상류 | 고전적인 활성화 함수 |
영리한 부분은 __sub__와 __truediv__가 기존 연산으로 정의된다는 점입니다. 연쇄 법칙이 그 아래 깔린 덧셈, 곱셈, 거듭제곱 연산을 통과해 합성되므로 기울기도 자동으로 맞게 계산됩니다.
Step 5: 처음부터 만드는 미니 MLP
완성된 Value 클래스가 있으면 신경망을 만들 수 있습니다. PyTorch도 NumPy도 쓰지 않습니다. Value와 연쇄 법칙만 사용합니다.
import random
class Neuron:
def __init__(self, n_inputs):
self.w = [Value(random.uniform(-1, 1)) for _ in range(n_inputs)]
self.b = Value(0.0)
def __call__(self, x):
act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
return act.tanh()
def parameters(self):
return self.w + [self.b]
class Layer:
def __init__(self, n_inputs, n_outputs):
self.neurons = [Neuron(n_inputs) for _ in range(n_outputs)]
def __call__(self, x):
return [n(x) for n in self.neurons]
def parameters(self):
return [p for n in self.neurons for p in n.parameters()]
class MLP:
def __init__(self, sizes):
self.layers = [Layer(sizes[i], sizes[i+1]) for i in range(len(sizes)-1)]
def __call__(self, x):
for layer in self.layers:
x = layer(x)
return x[0] if len(x) == 1 else x
def parameters(self):
return [p for layer in self.layers for p in layer.parameters()]
Neuron은 tanh(w1*x1 + w2*x2 + ... + b)를 계산합니다. Layer는 뉴런 리스트입니다. MLP는 층을 쌓습니다. 모든 가중치는 Value이므로 loss.backward()를 호출하면 모든 매개변수로 기울기가 전파됩니다.
XOR 학습:
random.seed(42)
model = MLP([2, 4, 1])
xs = [[0, 0], [0, 1], [1, 0], [1, 1]]
ys = [-1, 1, 1, -1]
for step in range(100):
preds = [model(x) for x in xs]
loss = sum((p - y) ** 2 for p, y in zip(preds, ys))
for p in model.parameters():
p.grad = 0.0
loss.backward()
lr = 0.05
for p in model.parameters():
p.data -= lr * p.grad
if step % 20 == 0:
print(f"step {step:3d} loss = {loss.data:.4f}")
print("\n학습 후 예측:")
for x, y in zip(xs, ys):
print(f" input={x} target={y:2d} pred={model(x).data:6.3f}")
이것이 micrograd입니다. 순수 Python으로 만든 자동미분 기반의 완전한 신경망 훈련 루프입니다. 상용 딥러닝 프레임워크(deep learning framework)도 대규모로는 같은 일을 합니다.
Step 6: 기울기 검증
자동미분이 올바른지 어떻게 알 수 있을까요? 수치 도함수와 비교합니다. 이를 기울기 검증(gradient checking)이라고 합니다.
def gradient_check(build_expr, x_val, h=1e-7):
x = Value(x_val)
y = build_expr(x)
y.backward()
autodiff_grad = x.grad
y_plus = build_expr(Value(x_val + h)).data
y_minus = build_expr(Value(x_val - h)).data
numerical_grad = (y_plus - y_minus) / (2 * h)
diff = abs(autodiff_grad - numerical_grad)
return autodiff_grad, numerical_grad, diff
복잡한 식(expression)으로 시험합니다.
def expr(x):
return (x ** 3 + x * 2 + 1).tanh()
ad, num, diff = gradient_check(expr, 0.5)
print(f"자동미분: {ad:.8f}")
print(f"수치 미분: {num:.8f}")
print(f"차이: {diff:.2e}")
새 연산을 구현할 때 기울기 검증은 필수입니다. 역전파에 버그가 있으면 수치 검증이 잡아 줍니다. 진지한 딥러닝 구현은 개발 중 기울기 검증을 실행합니다.
기울기 검증을 써야 할 때:
| 상황 | 기울기 검증을 할까? |
|---|
| 자동미분 엔진에 새 연산을 추가할 때 | 예, 항상 |
| 수렴하지 않는 훈련 루프를 디버깅(debug)할 때 | 예, 기울기를 먼저 확인 |
| 프로덕션 학습(production training) | 아니요, 너무 느림. 매개변수마다 순전파 2회 필요 |
| 자동미분 코드의 단위 테스트(unit test) | 예, 자동화 |
Step 7: 손계산과 비교하기
x1 = Value(2.0)
x2 = Value(3.0)
a = x1 * x2
b = a + Value(1.0)
y = b.relu()
y.backward()
print(f"y = {y.data}")
print(f"dy/dx1 = {x1.grad}")
print(f"dy/dx2 = {x2.grad}")
손으로 확인하면 y = relu(x1*x2 + 1)입니다. x1*x2 + 1 = 7 > 0이므로 ReLU는 항등 함수(identity)가 됩니다. dy/dx1 = x2 = 3, dy/dx2 = x1 = 2입니다. 엔진 결과와 일치합니다.
사용해보기
PyTorch와 비교하기
import torch
x1 = torch.tensor(2.0, requires_grad=True)
x2 = torch.tensor(3.0, requires_grad=True)
a = x1 * x2
b = a + 1.0
y = torch.relu(b)
y.backward()
print(f"PyTorch dy/dx1 = {x1.grad.item()}")
print(f"PyTorch dy/dx2 = {x2.grad.item()}")
기울기가 같습니다. 직접 만든 엔진과 PyTorch는 같은 수학, 즉 연쇄 법칙 기반 역방향 모드 자동미분을 사용하기 때문입니다.
더 복잡한 식
a = Value(2.0)
b = Value(-3.0)
c = Value(10.0)
f = (a * b + c).relu()
f.backward()
print(f"df/da = {a.grad}")
print(f"df/db = {b.grad}")
print(f"df/dc = {c.grad}")
산출물 만들기
이 강의의 검수 대상은 아래 두 가지입니다.
outputs/skill-autodiff.md: 자동미분 시스템을 만들고 디버깅할 때 사용할 수 있는 스킬(skill)
code/autodiff.py: 확장 가능한 최소 자동미분 엔진
여기서 만든 Value 클래스는 Phase 3의 신경망 훈련 루프를 위한 기반입니다.
연습문제
x ** n을 계산할 수 있도록 Value 클래스에 __pow__를 추가합니다. x=2에서 d/dx(x^3)이 12.0인지 확인합니다.
- 활성화 함수로
tanh를 추가합니다. tanh'(0) = 1, tanh'(2) = 0.0707에 가까운지 확인합니다.
- 단일 뉴런
y = relu(w1*x1 + w2*x2 + b)의 계산 그래프를 만듭니다. 다섯 개 기울기를 모두 계산하고 PyTorch와 비교합니다.
- 쌍대수를 사용해 순방향 모드 자동미분을 구현합니다.
Dual 클래스를 만들고 역방향 모드 엔진과 같은 도함수를 내는지 확인합니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 연쇄 법칙(Chain rule) | 도함수를 곱한다 | 합성 함수의 도함수는 각 함수의 국소 도함수를 올바른 지점에서 평가해 곱한 값이다 |
| 계산 그래프(Computational graph) | 신경망 도식 | 노드가 연산이고 간선(edge)이 값 또는 기울기를 운반하는 방향 비순환 그래프(directed acyclic graph; DAG) |
| 순방향 모드(Forward mode) | 도함수를 앞으로 밀기 | 입력에서 출력으로 도함수를 전파하는 자동미분. 입력 변수마다 한 번의 패스가 필요하다 |
| 역방향 모드(Reverse mode) | 역전파 | 출력에서 입력으로 기울기를 전파하는 자동미분. 출력 변수마다 한 번의 패스가 필요하다 |
| 자동미분 엔진(Autograd) | 자동 기울기 계산 | 값에 가해진 연산을 기록하고 그래프를 만든 뒤 연쇄 법칙으로 정확한 기울기를 계산하는 시스템 |
| 쌍대수(Dual numbers) | 값에 도함수를 덧붙임 | a + b*epsilon, epsilon^2 = 0 형태의 수. 산술 연산을 통과하며 도함수 정보를 함께 운반한다 |
| 위상 정렬(Topological sort) | 의존성 순서 | 모든 노드가 자신의 의존성 뒤에 오도록 그래프 노드를 정렬하는 것. 올바른 기울기 전파에 필요하다 |
| 기울기 누적(Gradient accumulation) | 더하고, 덮어쓰지 않기 | 하나의 값이 여러 연산으로 흘러가면 들어오는 모든 기울기 기여(contribution)를 합해야 한다 |
| 동적 그래프(Dynamic graph) | 실행 시 정의(define-by-run) | 순전파마다 계산 그래프를 다시 만드는 방식. PyTorch처럼 모델 안에서 Python 제어 흐름을 쓸 수 있다 |
| 기울기 검증(Gradient checking) | 수치 검증 | 자동미분 기울기를 유한차분 기울기와 비교해 역전파가 맞는지 확인하는 방법 |
| 다층 퍼셉트론(Multi-Layer Perceptron; MLP) | 다층 퍼셉트론 | 하나 이상의 은닉층(hidden layer)을 가진 신경망. 각 뉴런은 가중합에 편향을 더한 뒤 활성화 함수를 적용한다 |
| 뉴런(Neuron) | 가중합 + 활성화 | activation(w1*x1 + w2*x2 + ... + b)를 계산하는 기본 단위. 가중치와 편향은 학습 가능한 매개변수다 |
더 읽을거리