← Day 4 Phase 0 · Week 1 · Day 5 Day 6 →

Day 5 — micrograd ② backward & gradcheck

엔진을 완성한다. _backward 클로저(VJP)를 채우고, 명시적 위상정렬 + adjoint 전파backward()를 구현한다. 그 다음 복소스텝 gradcheck로 전 연산을 머신정밀도로 검증하고, 수치 안정적 softmax + cross-entropy를 추가해 Phase 1의 손실을 미리 갖춘다. 완성되면 L.backward() 한 줄이 PyTorch의 그것과 의미상 동일해진다.

🎯 오늘의 목표

📖 개념 (수업)

1. backward() = 위상정렬 + adjoint 전파

Day 3 알고리즘을 객체 그래프에 옮긴다. 세 단계뿐이다:

def backward(self):
    # (1) 위상정렬: 자식을 먼저, 자기를 나중에 → topo는 leaf→root 순
    topo, visited = [], set()
    def build(v):
        if v not in visited:
            visited.add(v)
            for child in v._prev: build(child)
            topo.append(v)
    build(self)

    # (2) 루트 adjoint = 1
    self.grad = 1.0

    # (3) 위상 역순(root→leaf)으로 각 노드의 VJP 적용
    for v in reversed(topo):
        v._backward()

reversed(topo)가 root→leaf 순서를 보장하므로, 각 v._backward() 호출 시점에 v.grad(=adjoint)는 이미 완성돼 있다. 이게 Day 3에서 증명한 "위상 역순 정확성"의 구현이다. 파이썬 재귀깊이 한계가 걸리면 명시적 스택으로 바꾸면 된다(깊은 그래프 대비).

+= 와 zero의 불변식. 모든 VJP는 +=(fan-out 합, Day 3 증명). 따라서 backward()는 grad가 0에서 시작한다고 가정한다. 재호출 전 반드시 grad를 0으로 리셋해야 하며, 안 그러면 누적이 오염된다. PyTorch의 zero_grad()가 이 불변식의 강제다.

2. retain_graph와 재호출 — 미묘함

우리 micrograd는 backward 후에도 그래프(_prev, _backward 클로저)가 살아있어 재호출 가능하다. PyTorch는 기본적으로 backward 후 그래프를 해제한다(메모리 절약). 같은 그래프로 두 번 미분하려면 retain_graph=True가 필요한 이유다. 이 차이를 이해하면 PyTorch의 그 에러 메시지가 더는 미스터리가 아니다.

3. gradcheck — 정확성의 황금표준

파라미터 벡터 $\theta\in\mathbb{R}^n$에 대해, 해석적 gradient $g$와 수치 gradient $\tilde g$의 상대오차

$$\text{rel} = \frac{\|g - \tilde g\|_2}{\|g\|_2 + \|\tilde g\|_2 + \varepsilon}$$

를 본다. 중심차분이면 $\sim10^{-7}$, 복소스텝(해석함수만)이면 $\sim10^{-12}$ 이하가 정상. ReLU 같은 비매끈 지점은 복소스텝이 부적합하니 중심차분으로 검사하되, kink 근처는 피한다. 이게 PyTorch torch.autograd.gradcheck가 내부에서 하는 일이고, 새 연산을 추가할 때마다 돌려야 하는 습관이다.

4. 수치 안정적 cross-entropy

분류/언어모델 손실. 로짓 $z\in\mathbb{R}^K$, 정답 클래스 $t$에 대해

$$\mathrm{CE}(z,t) = -\log \mathrm{softmax}(z)_t = -z_t + \log\sum_{k} e^{z_k}$$

순진하게 $e^{z_k}$를 계산하면 큰 로짓에서 overflow. log-sum-exp trick: $m=\max_k z_k$로 두면

$$\log\sum_k e^{z_k} = m + \log\sum_k e^{z_k - m}$$

지수의 인자가 $\le 0$이라 overflow가 없고, 적어도 한 항이 $e^0=1$이라 underflow로 인한 $\log 0$도 없다. CE의 gradient는 놀랄 만큼 깔끔하다: $\partial\mathrm{CE}/\partial z = \mathrm{softmax}(z) - e_t$ (one-hot 차이). 이 우아함이 softmax+CE를 함께 묶는 이유이고, 직접 유도해보면 Day 2의 softmax VJP와 맞물린다.

심화 micrograd로 softmax를 원시 exp/log/div 합성으로 짜면 동작은 하지만, 중간 노드가 많아 비효율적이고 수치 안정 트릭을 그래프에 녹이기 까다롭다. 그래서 실전 프레임워크는 cross-entropy를 하나의 fused 원시연산으로 제공한다(안정적 forward + 닫힌형 VJP $\mathrm{softmax}(z)-e_t$). 과제에서 둘 다 구현해 정확도/안정성을 비교하라.

✍️ 직접 해보기 (강도 ↑)

과제 1 — VJP 전부 채우기 + backward(). Day 4의 11개 연산 _backward를 += 누적으로 구현하고, 위 의사코드로 backward() 완성. 파이썬 재귀깊이를 우회하는 명시적 스택 버전도 만들어 깊이 5000 체인에서 동작 확인.
과제 2 — gradcheck 하니스. 임의 식을 받아 모든 leaf의 micrograd grad를 (a) 중심차분, (b) 복소스텝과 대조하고 연산별 상대오차 표를 출력하는 검증기를 짜라. 매끈 연산은 복소스텝 $<10^{-10}$, ReLU는 중심차분 $<10^{-6}$ 달성 확인. 일부러 VJP에 버그(예: += 대신 =)를 심고 검증기가 잡는지 확인하라.
과제 3 — cross-entropy 두 버전. (a) 원시연산 합성 softmax→CE, (b) log-sum-exp fused CE(닫힌형 VJP). 큰 로짓(예: $z=[1000, 1001, 999]$)에서 (a)가 overflow/NaN 나고 (b)는 멀쩡함을 보여라. (b)의 grad가 $\mathrm{softmax}(z)-e_t$ 와 일치하는지 gradcheck.
과제 4 — fan-out 스트레스 테스트. $f(a) = a\cdot a\cdot a + a\cdot a + a$ 처럼 한 노드를 여러 번 쓰는 식들을 무작위 생성하고, micrograd grad가 항상 수치미분과 일치하는지 100케이스 자동 검증. += 누적이 빠짐없이 작동함을 통계로 확인.
도전 second-order. 우리 grad는 float라 미분 그래프가 다시 추적되지 않는다. grad도 Value가 되게 확장하면(또는 backward를 두 번) Hessian-vector product를 얻을 수 있다. 작은 2차 함수에서 $Hv$를 forward-over-reverse로 구해 해석값과 대조하라(Day 2 Pearlmutter 참조).

✅ 스스로 확인

📝 오늘의 기록

docs/00_day05.md:
너는 검증된 자동미분 엔진을 가졌다. gradcheck를 통과하는 reverse-mode AD + 안정적 CE. 이건 면접에서 "autograd 내부와 수치 안정성"을 동시에 말할 수 있는 드문 자산이다.
다음 (Day 6): 이 엔진으로 진짜 최적화를 한다. 초기화 이론(분산 보존, He/Xavier), SGD/모멘텀/Adam을 직접 구현하고, two-moons 같은 비선형 분리 문제를 학습시켜 결정경계와 손실 지형(loss landscape)을 시각화한다. zero_grad 함정, 학습률·초기화의 상호작용을 실험으로 해부한다.

← Day 4 Day 5 끝 Day 6 — 최적화 & 학습 →