5편에서는 은닉층 하나짜리 신경망의 기울기를 손으로 유도했는데, 활성함수를 바꾸거나 층을 하나 더 얹으면 유도를 처음부터 다시 해야 한다. 프레임워크는 이 일을 자동미분(automatic differentiation)으로 대신한다. 이 글에서는 스칼라 autograd 엔진을 100줄 남짓으로 만들어 수치미분으로 검증하고, 그것만으로 신경망을 학습시킨 뒤, 흔한 구현 실수 세 가지와 텐서 단위로 동작해야 하는 이유를 실측한다.
연산할 때마다 그래프를 남긴다
값 하나를 객체로 감싸고 연산자를 오버로딩해, 결과 노드가 부모에게 기울기를 넘기는 방법을 클로저로 함께 저장한다. 위상 정렬로 노드를 늘어놓고 뒤에서부터 그 클로저를 호출하면 역전파가 된다.
| 연산 | 순전파 | 부모에게 넘기는 기울기 |
|---|---|---|
a + b | a.data + b.data | 양쪽에 out.grad 그대로 |
a * b | a.data * b.data | a에는 b.data * out.grad, 반대도 같음 |
a ** k | a.data ** k | k * a.data**(k-1) * out.grad |
tanh(a) | tanh(a.data) | (1 - t²) * out.grad |
기울기를 =가 아니라 +=로 누적하는 것이 중요하다. 같은 노드가 여러 곳에 쓰이면 경로마다 온 기울기를 더해야 한다.
"""스칼라 하나짜리 자동미분 엔진. 연산할 때마다 계산 그래프를 남긴다."""
import math
class Value:
__slots__ = ("data", "grad", "_backward", "_prev", "_op")
def __init__(self, data, _children=(), _op=""):
self.data = float(data)
self.grad = 0.0
self._backward = lambda: None # 이 노드가 부모에게 기울기를 넘기는 방법
self._prev = set(_children)
self._op = _op
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 __pow__(self, k):
out = Value(self.data ** k, (self,), f"**{k}")
def _backward():
self.grad += k * (self.data ** (k - 1)) * out.grad
out._backward = _backward
return out
def tanh(self):
t = math.tanh(self.data)
out = Value(t, (self,), "tanh")
def _backward():
self.grad += (1 - t * t) * out.grad
out._backward = _backward
return out
def exp(self):
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):
out = Value(math.log(self.data), (self,), "log")
def _backward():
self.grad += (1.0 / self.data) * out.grad
out._backward = _backward
return out
def backward(self):
"""위상 정렬로 노드를 늘어놓고 뒤에서부터 _backward를 호출한다."""
order, seen = [], set()
def build(v):
if v in seen:
return
seen.add(v)
for child in v._prev:
build(child)
order.append(v)
build(self)
self.grad = 1.0
for node in reversed(order):
node._backward()
# 편의 연산자
def __neg__(self): return self * -1
def __sub__(self, other): return self + (-(other if isinstance(other, Value) else Value(other)))
def __radd__(self, other): return self + other
def __rmul__(self, other): return self * other
def __truediv__(self, o): return self * (o if isinstance(o, Value) else Value(o)) ** -1
def __repr__(self): return f"Value(data={self.data:.6f}, grad={self.grad:.6f})"
def sigmoid(v):
return Value(1.0) / (Value(1.0) + (-v).exp())수치미분으로 검증하기
5편과 같은 방식이다. 여러 연산을 일부러 섞은 식을 만들고 중앙차분과 대조한다.
#!/usr/bin/env python3
"""자동미분이 내놓은 기울기를 수치미분과 대조한다."""
import math
from autograd import Value, sigmoid
def expr(a, b, c):
"""일부러 여러 연산을 섞은 식"""
return (a * b + c ** 2).tanh() * sigmoid(a - c) + (b * b + 1.0).log()
vals = (1.3, -0.7, 0.45)
a, b, c = (Value(v) for v in vals)
out = expr(a, b, c)
out.backward()
EPS = 1e-6
print(f"식의 값 = {out.data:.9f}\n")
print(f"{'변수':>4}{'자동미분':>16}{'수치미분':>16}{'상대오차':>12}")
print("-" * 48)
for i, (name, node) in enumerate(zip("abc", (a, b, c))):
up = list(vals); up[i] += EPS
down = list(vals); down[i] -= EPS
num = (expr(*(Value(v) for v in up)).data -
expr(*(Value(v) for v in down)).data) / (2 * EPS)
rel = abs(num - node.grad) / max(1e-12, abs(num) + abs(node.grad))
print(f"{name:>4}{node.grad:>16.9f}{num:>16.9f}{rel:>12.2e}")$ python3 autograd_check.py
식의 값 = -0.027944059
변수 자동미분 수치미분 상대오차
------------------------------------------------
a -0.436228342 -0.436228342 8.89e-11
b -0.366753616 -0.366753616 4.63e-11
c 0.524358142 0.524358142 3.61e-11
상대오차가 1e-10 수준이다. 이제 어떤 식을 조합해도 미분을 따로 유도할 필요가 없다.
이것만으로 신경망 학습시키기
5편에서 numpy로 손계산한 것과 같은 구조(입력 5 → 은닉 2 → 출력 1)를 Value만으로 짜고, 같은 스텝 수로 돌려 비교한다.
#!/usr/bin/env python3
"""자동미분만으로 신경망을 학습시키고, numpy 손계산 버전과 속도를 비교한다."""
import random, time
import numpy as np
from autograd import Value, sigmoid
from data2 import load
from mlp import init, forward, backward, loss
NAMES = ["entropy", "bigram", "ascii", "uniq", "run"]
ext, F, ratio, tr, te = load()
y = (ratio < 0.30).astype(float)
X = np.column_stack([F[n] for n in NAMES])
Z = (X - X[tr].mean(0)) / X[tr].std(0)
N_IN, HID, STEPS = len(NAMES), 2, 200
sub = tr[:80] # 스칼라 엔진이라 표본을 줄인다
# ---- 자동미분 버전 -------------------------------------------------
random.seed(0)
W1 = [[Value(random.gauss(0, 0.5)) for _ in range(HID)] for _ in range(N_IN)]
b1 = [Value(0.0) for _ in range(HID)]
W2 = [Value(random.gauss(0, 0.5)) for _ in range(HID)]
b2 = Value(0.0)
params = [w for row in W1 for w in row] + b1 + W2 + [b2]
def predict(x):
h = [sum((Value(x[i]) * W1[i][j] for i in range(N_IN)), b1[j]).tanh()
for j in range(HID)]
return sigmoid(sum((h[j] * W2[j] for j in range(HID)), b2))
t0 = time.perf_counter()
for step in range(STEPS):
total = Value(0.0)
for i in sub:
p = predict(Z[i])
total = total + (-(Value(y[i]) * p.log() + Value(1 - y[i]) * (Value(1.0) - p).log()))
L = total / len(sub)
for q in params:
q.grad = 0.0
L.backward()
for q in params:
q.data -= 0.5 * q.grad
auto_sec = time.perf_counter() - t0
auto_loss = L.data
# ---- numpy 손계산 버전 (5편) ---------------------------------------
P = init(N_IN, HID, seed=0)
t0 = time.perf_counter()
for step in range(STEPS):
p, c = forward(P, Z[sub])
g = backward(P, Z[sub], y[sub], c)
for k in P:
P[k] -= 0.5 * g[k]
np_sec = time.perf_counter() - t0
print(f"표본 {len(sub)}개 / 은닉 {HID} / {STEPS}스텝")
print(f" 자동미분(스칼라) : 최종 손실 {auto_loss:.5f} {auto_sec:8.3f}초")
print(f" numpy 손계산 : 최종 손실 {loss(P, Z[sub], y[sub]):.5f} {np_sec:8.3f}초")
print(f" 속도 차이 : {auto_sec / np_sec:.0f}배")
# 한 스텝에서 실제로 만들어진 노드 수를 센다
seen = set()
def count(v):
if id(v) in seen:
return
seen.add(id(v))
for ch in v._prev:
count(ch)
total = Value(0.0)
for i in sub:
pr = predict(Z[i])
total = total + (-(Value(y[i]) * pr.log() + Value(1 - y[i]) * (Value(1.0) - pr).log()))
count(total / len(sub))
print(f"\n한 스텝에서 만들어진 Value 노드: {len(seen):,}개 (표본 {len(sub)}개분 그래프)")$ python3 autograd_mlp.py
표본 80개 / 은닉 2 / 200스텝
자동미분(스칼라) : 최종 손실 0.30886 2.255초
numpy 손계산 : 최종 손실 0.30792 0.005초
속도 차이 : 478배
한 스텝에서 만들어진 Value 노드: 4,659개 (표본 80개분 그래프)
손실은 0.30886과 0.30792로 사실상 같은 지점에 도달했다. 유도를 한 줄도 하지 않았지만 수백 배 느리다(실행 시간은 실행마다 조금씩 다르다).
구현할 때 흔한 실수 세 가지
기울기를 =로 넘기는 경우, 스텝마다 기울기를 0으로 되돌리지 않는 경우, 재귀 위상 정렬이 깊은 그래프를 만나는 경우를 각각 재현한다.
#!/usr/bin/env python3
"""주의사항 세 가지를 실제로 재현한다."""
import sys
from autograd import Value
# 1) 기울기를 += 가 아니라 = 로 넘기면 — 같은 노드를 두 번 쓰는 식에서 틀린다
class BadValue(Value):
__slots__ = ()
def __mul__(self, other):
other = other if isinstance(other, Value) else BadValue(other)
out = BadValue(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
for cls in (Value, BadValue):
x = cls(3.0)
y = x * x # dy/dx = 2x = 6
y.backward()
print(f"[1] {cls.__name__:<9} y = x*x, x=3 → dy/dx = {x.grad}")
# 2) zero_grad를 빼먹으면 — 기울기가 스텝마다 쌓인다
print()
for reset in (True, False):
w = Value(0.0)
trace = []
for step in range(6):
L = (w - 1.0) ** 2 # 최소는 w = 1
if reset:
w.grad = 0.0
L.backward()
w.data -= 0.3 * w.grad
trace.append(f"{w.data:6.3f}")
print(f"[2] zero_grad {'O' if reset else 'X'}: w =", " ".join(trace))
# 3) 재귀 위상 정렬은 깊은 그래프에서 터진다
print()
print(f"[3] recursionlimit = {sys.getrecursionlimit()}")
for n in (500, 2000):
total = Value(0.0)
for i in range(n):
total = total + Value(1.0) # 노드가 한 줄로 n단 이어진다
try:
total.backward()
print(f" 덧셈 {n}번 이어진 그래프: backward 성공")
except RecursionError as e:
print(f" 덧셈 {n}번 이어진 그래프: RecursionError ({e})")$ python3 autograd_pitfalls.py
[1] Value y = x*x, x=3 → dy/dx = 6.0
[1] BadValue y = x*x, x=3 → dy/dx = 3.0
[2] zero_grad O: w = 0.600 0.840 0.936 0.974 0.990 0.996
[2] zero_grad X: w = 0.600 1.440 2.016 1.982 1.359 0.521
[3] recursionlimit = 1000
덧셈 500번 이어진 그래프: backward 성공
덧셈 2000번 이어진 그래프: RecursionError (maximum recursion depth exceeded)
x*x는 같은 노드가 두 경로로 쓰여 기울기가 3이 아니라 6이어야 하고, 기울기를 안 지우면 w가 이전 스텝 기울기까지 떠안아 2.016까지 넘어갔다. 재귀 구현은 덧셈이 2,000번 이어진 그래프에서 Python 재귀 한도(1,000)에 걸렸다.
왜 프레임워크는 텐서 단위로 동작하는가
위 학습에서 표본 80개짜리 문제에 스텝당 Value 노드가 4,659개 만들어졌고, 노드마다 파이썬 객체·클로저·집합이 딸려 온다. PyTorch나 JAX는 배열 하나를 노드 하나로 잡아 이 노드들을 열 개 남짓으로 묶고, 각 역전파를 BLAS 행렬곱 한 번으로 끝낸다.
| 이 글의 엔진 | PyTorch | |
|---|---|---|
| 노드 하나 | 스칼라 값 1개 | 텐서 1개 |
| 역전파 단위 | 파이썬 함수 호출 | BLAS 커널 호출 |
| 그래프 크기 | 표본 80개에 4,659노드 | 표본 수와 무관하게 층 수 규모 |
| 기울기 초기화 | q.grad = 0.0 직접 | optimizer.zero_grad() |
주의사항
- 기울기를 스텝마다 0으로 되돌려야 한다. PyTorch에서
zero_grad()를 빼먹었을 때와 같은 버그다(위 [2]). - 위상 정렬을 재귀로 구현하면 깊은 그래프에서
RecursionError가 난다(위 [3]). 실제 엔진은 명시적 스택을 쓴다. __slots__가 없으면 메모리가 훨씬 더 든다. 노드 하나마다 인스턴스 딕셔너리가 붙기 때문이다. 위 구현에__slots__를 넣은 이유다.- 수치미분 검증은 새 연산을 추가할 때마다 한다.
log나exp를 새로 넣고 나서 반드시autograd_check.py를 다시 돌려야 한다. - 이 엔진은 학습용이다. 브로드캐스팅, in-place 연산, 고차 미분, 메모리 재사용이 전부 빠져 있다.
마무리
여기까지가 1단계다. 규칙 대신 데이터로 파라미터를 찾는다는 아이디어에서 출발해 최소제곱, 경사하강, 로지스틱 회귀, 역전파를 거쳐 자동미분까지 numpy와 표준 라이브러리만으로 구현했다.
다음 단계는 “학습이 잘 되게 만드는 것들”로, 5편에서 은닉 뉴런을 늘리자 평가 손실이 도로 올라갔던 과적합부터 다룬다.
이 시리즈의 다른 글
- 머신러닝 입문 (1) — 규칙 기반 코드와 학습 기반 코드의 차이
- 머신러닝 입문 (2) — 선형회귀를 밑바닥부터 구현하기
- 머신러닝 입문 (3) — 경사하강법과 학습률
- 머신러닝 입문 (4) — 로지스틱 회귀와 이진 분류
- 머신러닝 입문 (5) — 역전파를 손으로 계산하고 코드로 검증하기
참고
- karpathy/micrograd — 같은 아이디어의 최소 구현
- PyTorch — Autograd mechanics
- Python 데이터 모델 — 연산자 오버로딩