연쇄 법칙과 자동미분

연쇄 법칙은 학습하는 모든 신경망 뒤에서 작동하는 엔진입니다.

유형: Build 언어: Python 선수 지식: Phase 1, Lesson 04 (Derivatives & Gradients) 예상 시간: 약 90분

학습 목표

  • 연산을 기록하고 역방향 모드 자동미분(reverse-mode autodiff)으로 기울기(gradient)를 계산하는 최소 자동미분 엔진(autograd engine), 즉 Value 클래스를 만듭니다.
  • 위상 정렬(topological sort)을 사용해 계산 그래프(computational graph)의 순전파(forward pass)와 역전파(backward pass)를 구현합니다.
  • 처음부터 만든 자동미분 엔진만으로 XOR 문제를 학습하는 다층 퍼셉트론(Multi-Layer Perceptron; MLP)을 구성하고 훈련합니다.
  • 수치적 유한차분(numerical finite differences)과 비교하는 기울기 검증(gradient checking)으로 자동미분이 올바른지 확인합니다.

문제

간단한 함수의 도함수(derivative)는 직접 계산할 수 있습니다. 하지만 신경망(neural network)은 간단한 함수 하나가 아닙니다. 행렬 곱(matrix multiply), 편향 더하기(bias addition), 활성화(activation), 또 한 번의 행렬 곱, 소프트맥스(softmax), 교차 엔트로피 손실(cross-entropy loss)처럼 수백 개의 함수가 서로 합성된 구조입니다. 출력은 함수의 함수의 함수입니다.

신경망을 학습하려면 손실(loss)을 모든 가중치(weight)에 대해 미분한 기울기가 필요합니다. 매개변수(parameter)가 수백만 개라면 이를 손으로 계산하는 것은 불가능합니다. 수치적으로 유한차분을 사용하는 방법도 너무 느립니다.

연쇄 법칙(Chain Rule)은 필요한 수학을 제공합니다. 자동미분(Automatic Differentiation)은 이를 실행 가능한 알고리즘으로 바꿉니다. 둘을 함께 쓰면 임의의 함수 합성을 통과하는 정확한 기울기를 단 한 번의 순전파에 비례하는 시간 안에 계산할 수 있습니다.

PyTorch, TensorFlow, JAX도 이 원리로 동작합니다. 이 강의에서는 그 작은 버전을 처음부터 만들어 봅니다.

사전 테스트

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

1.미적분에서 연쇄 법칙(chain rule)은 무엇을 말하나요?

2.계산 그래프(computational graph)는 무엇인가요?

0/2 답변 완료

개념

연쇄 법칙

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)하면, 도함수가 모든 연산을 따라 자동으로 전파됩니다.

자동미분 엔진 만들기

자동미분 엔진에는 세 가지가 필요합니다.

  1. 값 감싸기(Value wrapping). 모든 숫자를 값과 기울기를 저장하는 객체로 감쌉니다.
  2. 그래프 기록(Graph recording). 모든 연산이 입력과 국소 기울기 함수(local gradient function)를 기록합니다.
  3. 역전파(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)  # 7.0 = 2*x + 3 = 2*2 + 3

PyTorch 내부에서는 다음 일이 일어납니다.

  1. requires_grad=True를 가진 x에 대해 Tensor 노드를 만듭니다.
  2. 모든 연산(**, *, +)은 새 노드를 만들고 역방향 함수(backward function)를 기록합니다.
  3. y.backward()는 기록된 그래프를 따라 역방향 모드 자동미분을 실행합니다.
  4. 각 노드의 grad_fn은 국소 기울기를 계산하고 부모 노드로 전달합니다.
  5. 기울기는 .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) 재사용정규화, 학습률 스케일링
expexp(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()]

Neurontanh(w1*x1 + w2*x2 + ... + b)를 계산합니다. Layer는 뉴런 리스트입니다. MLP는 층을 쌓습니다. 모든 가중치는 Value이므로 loss.backward()를 호출하면 모든 매개변수로 기울기가 전파됩니다.

XOR 학습:

random.seed(42)
model = MLP([2, 4, 1])  # 입력 2개, 은닉 뉴런 4개, 출력 1개

xs = [[0, 0], [0, 1], [1, 0], [1, 1]]
ys = [-1, 1, 1, -1]  # tanh에 맞춰 -1/1로 표현한 XOR 패턴

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}")
# 차이는 1e-5보다 작아야 합니다.

새 연산을 구현할 때 기울기 검증은 필수입니다. 역전파에 버그가 있으면 수치 검증이 잡아 줍니다. 진지한 딥러닝 구현은 개발 중 기울기 검증을 실행합니다.

기울기 검증을 써야 할 때:

상황기울기 검증을 할까?
자동미분 엔진에 새 연산을 추가할 때예, 항상
수렴하지 않는 훈련 루프를 디버깅(debug)할 때예, 기울기를 먼저 확인
프로덕션 학습(production training)아니요, 너무 느림. 매개변수마다 순전파 2회 필요
자동미분 코드의 단위 테스트(unit test)예, 자동화

Step 7: 손계산과 비교하기

x1 = Value(2.0)
x2 = Value(3.0)
a = x1 * x2          # a = 6.0
b = a + Value(1.0)  # b = 7.0
y = b.relu()        # y = 7.0

y.backward()

print(f"y = {y.data}")          # 7.0
print(f"dy/dx1 = {x1.grad}")   # 3.0 (= x2)
print(f"dy/dx2 = {x2.grad}")   # 2.0 (= x1)

손으로 확인하면 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()}")  # 3.0
print(f"PyTorch dy/dx2 = {x2.grad.item()}")  # 2.0

기울기가 같습니다. 직접 만든 엔진과 PyTorch는 같은 수학, 즉 연쇄 법칙 기반 역방향 모드 자동미분을 사용하기 때문입니다.

더 복잡한 식

a = Value(2.0)
b = Value(-3.0)
c = Value(10.0)
f = (a * b + c).relu()  # relu(2*(-3) + 10) = relu(4) = 4

f.backward()
print(f"df/da = {a.grad}")  # -3.0 (= b)
print(f"df/db = {b.grad}")  #  2.0 (= a)
print(f"df/dc = {c.grad}")  #  1.0

산출물 만들기

이 강의의 검수 대상은 아래 두 가지입니다.

  • outputs/skill-autodiff.md: 자동미분 시스템을 만들고 디버깅할 때 사용할 수 있는 스킬(skill)
  • code/autodiff.py: 확장 가능한 최소 자동미분 엔진

여기서 만든 Value 클래스는 Phase 3의 신경망 훈련 루프를 위한 기반입니다.

연습문제

  1. x ** n을 계산할 수 있도록 Value 클래스에 __pow__를 추가합니다. x=2에서 d/dx(x^3)12.0인지 확인합니다.
  2. 활성화 함수로 tanh를 추가합니다. tanh'(0) = 1, tanh'(2) = 0.0707에 가까운지 확인합니다.
  3. 단일 뉴런 y = relu(w1*x1 + w2*x2 + b)의 계산 그래프를 만듭니다. 다섯 개 기울기를 모두 계산하고 PyTorch와 비교합니다.
  4. 쌍대수를 사용해 순방향 모드 자동미분을 구현합니다. 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)를 계산하는 기본 단위. 가중치와 편향은 학습 가능한 매개변수다

더 읽을거리

실습 코드

이 강의의 실습 코드 1개

autodiff
Code

산출물

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

skill-autodiff

Build, debug, and reason about automatic differentiation systems

Skill

확인 문제

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

1.PyTorch가 순방향 모드(forward-mode) 대신 역방향 모드 자동미분(reverse-mode autodiff), 즉 역전파(backpropagation)를 사용하는 이유는 무엇인가요?

2.`Value` 클래스로 만든 자동미분 엔진(autograd engine)에서 역방향 함수(backward function)가 기울기를 누적할 때 `=` 대신 `+=`를 쓰는 이유는 무엇인가요?

3.기울기 검증(gradient checking)은 무엇이며 언제 사용해야 하나요?

0/3 답변 완료