← Day 3 Phase 0 · Week 1 · Day 4 Day 5 →
Value로 캡슐화한다. 연산자 오버로딩으로
forward 실행 중 그래프가 자동 구축되게 한다 — 이게 PyTorch가 쓰는 define-by-run 패러다임이다.
각 노드는 자기 VJP를 클로저로 들고, 그래프의 위상 구조를 부모 집합으로 기억한다.
오늘은 forward + 그래프 구축 + 풍부한 연산집합까지. backward 오케스트레이션은 내일.
코드는 네가 쓴다 — 설계 결정과 힌트만 준다.
🎯 오늘의 목표
📖 개념 (수업)
두 가지 AD 설계 철학이 있다:
| define-and-run (정적) | define-by-run (동적) | |
|---|---|---|
| 그래프 구축 | 실행 전 한 번(컴파일) | forward 실행하며 매번 |
| 대표 | TF1, Theano, ONNX | PyTorch, autograd, micrograd |
| 장점 | 전역 최적화·배포 용이 | 제어흐름(if/loop) 자유, 디버깅 쉬움 |
| 단점 | 동적 구조 표현 어려움 | 매 실행 재구축 오버헤드 |
우리(그리고 PyTorch eager)는 define-by-run. c = a * b를 실행하는 순간,
새 노드 c가 만들어지며 자기 부모 {a, b}와 자기 VJP를 기록한다. 그래프는 forward의 부산물로 자라난다.
파이썬 if·for로 매번 다른 그래프를 만들 수 있는 유연함이 여기서 나온다(가변 길이 시퀀스 등).
torch.compile(2.x)와 JAX는 define-by-run의 유연함과 define-and-run의 최적화를
tracing/JIT로 절충한다 — 동적으로 추적해 정적 그래프를 뽑아 융합·최적화한다. Phase 3에서 torch.compile 속도 이득을 측정할 때 이 긴장을 다시 만난다.
각 Value 노드가 들고 다니는 것 — Day 3의 tape 항목을 객체로 흡수한 것이다:
| 필드 | 역할 | Day 3 대응 |
|---|---|---|
data | forward 값 | v.value |
grad | adjoint $\bar v$, 초기 0 | v.grad |
_prev | 부모 노드 집합(그래프 엣지) | tape의 inputs |
_backward | 이 노드의 VJP를 부모 grad에 누적하는 클로저 | op.vjp |
_op | 연산 라벨(디버그/시각화) | tape의 op |
핵심 설계 결정: 각 연산의 VJP를 그 연산이 노드를 만드는 시점에 클로저로 박아넣는다.
클로저는 그 시점의 self, other, out을 자유변수로 포획하므로,
나중에 out._backward()만 호출하면 올바른 VJP가 올바른 부모로 흘러간다. 곱셈을 예로:
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), '*')
def _backward():
# VJP of multiply: ū_self += out.grad * other.data
# ū_other += out.grad * self.data
# (Day 2 표의 'x⊙z' VJP, fan-out 대비 += 누적)
... # ← 네가 채운다
out._backward = _backward
return out
이 구조의 우아함: backward()(내일)는 그래프 구조를 전혀 몰라도 된다.
그냥 위상 역순으로 node._backward()를 호출하면, 각 노드가 자기 VJP를 알아서 적용한다.
관심사 분리: 노드는 "어떻게 미분하나"(local VJP), 오케스트레이터는 "어떤 순서로"(위상).
최고수준 답게 장난감 수준(+, *)을 넘어, 신경망에 필요한 연산을 처음부터 갖춘다. 각 연산의 forward와 VJP를 Day 2 표에서 가져와 구현하라:
| 연산 | forward | VJP (out.grad = ḡ) |
|---|---|---|
__add__ | $a+b$ | $\bar a{+}{=}\bar g,\ \bar b{+}{=}\bar g$ |
__mul__ | $ab$ | $\bar a{+}{=}\bar g\,b,\ \bar b{+}{=}\bar g\,a$ |
__pow__ (상수 n) | $a^n$ | $\bar a{+}{=}\bar g\,n a^{n-1}$ |
relu | $\max(0,a)$ | $\bar a{+}{=}\bar g\,[a>0]$ |
tanh | $\tanh a$ | $\bar a{+}{=}\bar g\,(1-\tanh^2 a)$ |
exp | $e^a$ | $\bar a{+}{=}\bar g\,e^a$ |
log | $\ln a$ | $\bar a{+}{=}\bar g\,/a$ |
__neg__, __sub__, __truediv__는 위들의 조합으로 정의하면 된다
($a-b = a+(-b)$, $a/b = a\cdot b^{-1}$). 이렇게 하면 VJP를 새로 짤 필요 없이 그래프가 자동으로 합성된다 —
원시연산을 최소화하고 나머지는 합성하는 것이 라이브러리 설계의 정석이다.
a * 2는 a.__mul__(2)로 잡히지만, 2 * a는 int.__mul__(a)가 먼저 시도돼 실패한다.
파이썬은 이때 reflected a.__rmul__(2)를 부른다. __radd__ = __add__,
__rmul__ = __mul__로 위임하면 좌우 어느 쪽이든 동작한다. 이런 디테일이 "라이브러리스럽게" 만든다.
exp는 큰 입력에서 overflow하고, log는 0 근처에서 $-\infty$.
지금은 스칼라라 큰 문제 없지만, Phase 1의 softmax/cross-entropy에서 이게 학습을 터뜨린다.
미리 습관을 들여라: softmax는 max를 빼서 계산($\mathrm{softmax}(x)=\mathrm{softmax}(x-\max x)$),
cross-entropy는 log-sum-exp trick으로 $\log\sum e^{x_i} = m + \log\sum e^{x_i-m}$. Day 5 도전 과제에서 다룬다.
✍️ 직접 해보기 (강도 ↑)
data, grad, _prev, _op, _backward 필드와 위 표의 7개 원시연산 + 4개 합성연산
(neg, sub, truediv, rsub 등)의 forward·그래프 연결을 구현하라. __radd__/__rmul__로 스칼라 혼합 지원.
_backward 본문은 클로저 틀만 두고 내일 채워도 되지만, VJP 식을 주석으로 미리 적어둬라.
a=Value(2); b=Value(-3); c=Value(10)에서 L = (a*b + c.exp()).relu() / a 같은 식을 만들고,
_prev를 재귀 순회해 노드 수·엣지 수·fan-out 노드를 출력하는 함수를 짜라.
a가 여러 곳에 나타나는지(fan-out) 확인.
_prev를 따라 DAG를 graphviz(또는 networkx)로 그려라. 각 노드에 data, _op를 라벨로.
Day 3에서 손으로 그린 그래프와 같은 위상인지 대조. (Karpathy 영상의 그 그림을 네 손으로 재현)
grad_fn 객체 그래프를, JAX는 함수 변환(grad)을 쓴다.
세 접근의 메모리·유연성·성능 트레이드오프를 한 단락으로 비교하라.
Value는 스칼라다. 왜 스칼라 AD가 실전에서 못 쓰이나를
연산 그래프 크기·파이썬 오버헤드·벡터화 부재 관점에서 정량적으로 논증하고, "텐서로 올려야 하는 이유"를 적어라(Week 2 동기).
✅ 스스로 확인
2 * a, a / 2, 2 - a 등 스칼라 혼합이 동작한다📝 오늘의 기록
docs/00_day04.md:
_backward 클로저들을 채우고, 명시적 위상정렬 + adjoint 전파로
backward()를 완성한다. torch.autograd.gradcheck 스타일의 검증기를 만들어
전 연산의 VJP를 복소스텝 미분과 대조한다. 그리고 softmax+cross-entropy를 수치 안정적으로 추가해
Phase 1의 손실함수를 미리 갖춘다.