머신러닝 입문 (5) — 역전파를 손으로 계산하고 코드로 검증하기

4편까지의 모델은 층이 하나라 기울기를 종이에 한 줄로 적을 수 있었지만, 층을 쌓으면 손실이 합성함수가 되어 기울기를 연쇄법칙으로 뒤에서 앞으로 전달해야 한다. 이것이 역전파(backpropagation)다. 이 글에서는 은닉층 하나짜리 신경망의 역전파를 손으로 유도해 구현하고, 수치미분으로 유도를 검증한 뒤, 틀린 역전파가 어떻게 보이는지와 은닉층에 시그모이드를 쓰면 안 되는 이유까지 실측한다.

연쇄법칙을 층 순서 반대로

구조는 입력 → W1 → tanh → W2 → 시그모이드 → 로그손실이다. 각 단계의 미분을 뒤에서부터 곱해 나간다.

단계순전파역전파로 내려오는 값
출력p = σ(z2)dz2 = (p - y)/n — 시그모이드와 로그손실이 상쇄돼 이 형태가 된다
2층z2 = a1·W2 + b2dW2 = a1ᵀ·dz2, da1 = dz2·W2ᵀ
활성a1 = tanh(z1)dz1 = da1 * (1 - a1²)
1층z1 = X·W1 + b1dW1 = Xᵀ·dz1

시그모이드의 미분과 로그손실의 미분이 곱해지면서 깔끔하게 p - y만 남는 것이 로그손실을 쓰는 또 하나의 이유다.

#!/usr/bin/env python3
"""은닉층 하나짜리 신경망의 순전파와 역전파를 직접 구현한다."""
import numpy as np

def sigmoid(z):
    return 1.0 / (1.0 + np.exp(-np.clip(z, -500, 500)))

def init(n_in, n_hidden, seed=0):
    rng = np.random.default_rng(seed)
    return {
        "W1": rng.normal(0, np.sqrt(1.0 / n_in), (n_in, n_hidden)),
        "b1": np.zeros(n_hidden),
        "W2": rng.normal(0, np.sqrt(1.0 / n_hidden), (n_hidden, 1)),
        "b2": np.zeros(1),
    }

def forward(P, X):
    z1 = X @ P["W1"] + P["b1"]      # (n, h)
    a1 = np.tanh(z1)
    z2 = a1 @ P["W2"] + P["b2"]     # (n, 1)
    p  = sigmoid(z2).ravel()
    return p, {"z1": z1, "a1": a1, "z2": z2, "p": p}

def loss(P, X, y):
    p = np.clip(forward(P, X)[0], 1e-12, 1 - 1e-12)
    return float(-np.mean(y * np.log(p) + (1 - y) * np.log(1 - p)))

def backward(P, X, y, c):
    """연쇄법칙을 층 순서 반대로 적용한다."""
    n = len(y)
    # 로그손실 + 시그모이드를 합치면 dL/dz2 = (p - y)/n 으로 깔끔하게 떨어진다
    dz2 = ((c["p"] - y) / n).reshape(-1, 1)          # (n, 1)
    dW2 = c["a1"].T @ dz2                             # (h, 1)
    db2 = dz2.sum(0)
    da1 = dz2 @ P["W2"].T                             # (n, h)
    dz1 = da1 * (1 - c["a1"] ** 2)                    # tanh' = 1 - tanh^2
    dW1 = X.T @ dz1
    db1 = dz1.sum(0)
    return {"W1": dW1, "b1": db1, "W2": dW2, "b2": db2}

수치미분으로 유도를 검증한다

손으로 유도한 식은 파라미터 하나를 ±ε만큼 흔들어 손실 변화를 재는 중앙차분(수치미분)과 대조한다.

#!/usr/bin/env python3
"""손으로 유도한 기울기를 수치미분과 대조한다 (gradient check)."""
import numpy as np
from mlp import init, forward, backward, loss

rng = np.random.default_rng(1)
X = rng.normal(size=(7, 5))
y = (rng.random(7) < 0.5).astype(float)
P = init(5, 4)

analytic = backward(P, X, y, forward(P, X)[1])

EPS = 1e-6
print(f"{'파라미터':<8}{'해석적 기울기':>16}{'수치미분':>16}{'상대오차':>12}")
print("-" * 54)
worst = 0.0
for name in ("W1", "b1", "W2", "b2"):
    A = analytic[name]
    it = np.nditer(P[name], flags=["multi_index"])
    for _ in it:
        i = it.multi_index
        orig = P[name][i]
        P[name][i] = orig + EPS; lp = loss(P, X, y)
        P[name][i] = orig - EPS; lm = loss(P, X, y)
        P[name][i] = orig
        num = (lp - lm) / (2 * EPS)
        rel = abs(num - A[i]) / max(1e-12, abs(num) + abs(A[i]))
        worst = max(worst, rel)
        if i in ((0, 0), (0,)):
            print(f"{name + str(i):<8}{A[i]:>16.9f}{num:>16.9f}{rel:>12.2e}")
print(f"\n전체 {sum(P[n].size for n in P)}개 파라미터의 최대 상대오차: {worst:.2e}")
print("판정:", "일치 (1e-7 미만)" if worst < 1e-7 else "불일치 — 역전파 식을 다시 볼 것")
$ python3 gradcheck.py
파라미터             해석적 기울기            수치미분        상대오차
------------------------------------------------------
W1(0, 0)     0.009426543     0.009426543    5.58e-09
b1(0,)      -0.003363644    -0.003363644    1.47e-08
W2(0, 0)    -0.288152527    -0.288152527    7.37e-12
b2(0,)       0.067327680     0.067327680    4.03e-10

전체 29개 파라미터의 최대 상대오차: 1.47e-08
판정: 일치 (1e-7 미만)

29개 파라미터 전부에서 상대오차가 1.47e-08 이하다. 이 값이 1e-4대로 나오면 유도가 틀린 것이고, 1e-2 이상이면 확실히 버그다.

틀린 역전파도 손실은 내려간다

dz1에서 tanh 미분 (1 - a1²)을 빠뜨린 버전을 만들어, 정상 버전과 나란히 실제 데이터(4편과 같은 ratio < 0.30 분류)로 학습시킨다.

#!/usr/bin/env python3
"""tanh 미분을 빠뜨린 역전파도 손실은 내려간다 — 수치미분만이 잡아낸다."""
import numpy as np
from data2 import load
from mlp import init, forward, backward, loss

def backward_bug(P, X, y, c):
    n = len(y)
    dz2 = ((c["p"] - y) / n).reshape(-1, 1)
    dW2 = c["a1"].T @ dz2
    db2 = dz2.sum(0)
    da1 = dz2 @ P["W2"].T
    dz1 = da1                                   # 버그: * (1 - a1**2) 누락
    return {"W1": X.T @ dz1, "b1": dz1.sum(0), "W2": dW2, "b2": db2}

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)

print(f"{'스텝':>6}{'정상 학습손실':>14}{'버그 학습손실':>14}{'정상 평가':>10}{'버그 평가':>10}")
P_ok, P_bug = init(5, 2, 0), init(5, 2, 0)
for step in range(1, 4001):
    for P, bw in ((P_ok, backward), (P_bug, backward_bug)):
        g = bw(P, Z[tr], y[tr], forward(P, Z[tr])[1])
        for k in P:
            P[k] -= 0.5 * g[k]
    if step in (1, 10, 100, 1000, 4000):
        print(f"{step:>6}{loss(P_ok, Z[tr], y[tr]):>14.5f}{loss(P_bug, Z[tr], y[tr]):>14.5f}"
              f"{loss(P_ok, Z[te], y[te]):>10.5f}{loss(P_bug, Z[te], y[te]):>10.5f}")

# 같은 버그를 수치미분과 대조
rng = np.random.default_rng(1)
Xs, ys = rng.normal(size=(7, 5)), (rng.random(7) < 0.5).astype(float)
P = init(5, 4)
g = backward_bug(P, Xs, ys, forward(P, Xs)[1])
EPS, worst = 1e-6, {}
for name in P:
    it = np.nditer(P[name], flags=["multi_index"])
    for _ in it:
        i = it.multi_index; o = P[name][i]
        P[name][i] = o + EPS; lp = loss(P, Xs, ys)
        P[name][i] = o - EPS; lm = loss(P, Xs, ys)
        P[name][i] = o
        num = (lp - lm) / (2 * EPS)
        rel = abs(num - g[name][i]) / max(1e-12, abs(num) + abs(g[name][i]))
        worst[name] = max(worst.get(name, 0.0), rel)
print("\n버그 버전 gradient check — 파라미터별 최대 상대오차")
for name, v in worst.items():
    print(f"  {name:<3} {v:.2e}  {'불일치' if v > 1e-7 else '일치'}")
$ python3 buggy_backprop.py
    스텝       정상 학습손실       버그 학습손실     정상 평가     버그 평가
     1       0.59870       0.59719   0.59433   0.59211
    10       0.47443       0.46153   0.48615   0.46693
   100       0.38349       0.38736   0.37709   0.36932
  1000       0.32865       0.38158   0.31734   0.37948
  4000       0.31733       0.36948   0.31341   0.37585

버그 버전 gradient check — 파라미터별 최대 상대오차
  W1  1.00e+00  불일치
  b1  2.99e-01  불일치
  W2  2.52e-10  일치
  b2  4.03e-10  일치

버그 버전도 손실이 0.597에서 0.369까지 내려가고 초반에는 오히려 정상 버전보다 낮아서, 손실 곡선만 보면 버그를 알아챌 수 없다. gradient check는 틀린 곳이 W1·b1이라는 것까지 짚어준다.

실제 데이터로 학습시키기

4편과 같은 분류 문제(ratio < 0.30)에 은닉 뉴런 수만 바꿔 가며 돌린다.

#!/usr/bin/env python3
"""직접 구현한 역전파로 실제 데이터를 학습시켜 로지스틱 회귀와 비교한다."""
import numpy as np
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])
mu, sd = X[tr].mean(0), X[tr].std(0)
Z = (X - mu) / sd

def train(hidden, lr=0.5, steps=4000, seed=0):
    P = init(Z.shape[1], hidden, seed)
    for _ in range(steps):
        p, c = forward(P, Z[tr])
        g = backward(P, Z[tr], y[tr], c)
        for k in P:
            P[k] -= lr * g[k]
    acc = ((forward(P, Z[te])[0] >= 0.5) == y[te]).mean()
    return loss(P, Z[tr], y[tr]), loss(P, Z[te], y[te]), acc

print(f"{'은닉 뉴런':>9}{'학습 로그손실':>14}{'평가 로그손실':>14}{'평가 정확도':>12}")
print("-" * 49)
for h in (1, 2, 4, 8, 16, 32):
    ltr, lte, acc = train(h)
    print(f"{h:>9}{ltr:>14.5f}{lte:>14.5f}{acc:>12.4f}")
print(f"{'(로지스틱)':>9}{0.36925:>14.5f}{0.36673:>14.5f}{0.8257:>12.4f}")
$ python3 train_mlp.py
    은닉 뉴런       학습 로그손실       평가 로그손실      평가 정확도
-------------------------------------------------
        1       0.36423       0.36952      0.8440
        2       0.31733       0.31341      0.8349
        4       0.28651       0.34467      0.8165
        8       0.27753       0.32957      0.8257
       16       0.27498       0.32519      0.8349
       32       0.27854       0.32642      0.8349
   (로지스틱)       0.36925       0.36673      0.8257

은닉 뉴런 2개에서 평가 로그손실이 로지스틱 회귀의 0.3667에서 0.3134로 가장 좋아졌지만, 정확도는 0.8257에서 0.8349로 거의 그대로다. 4개부터는 학습 손실이 0.275~0.287로 더 내려가는 동안 평가 손실은 0.325~0.345로 도로 올라가는 전형적인 과적합이다(2단계에서 다룬다).

은닉층에 시그모이드를 쓰면 안 되는 이유

층을 열 개로 늘리고 활성함수만 바꿔서, 출력단에서 내려온 기울기가 앞쪽 층까지 얼마나 살아남는지 잰다.

#!/usr/bin/env python3
"""층을 깊게 쌓았을 때 앞쪽 층으로 갈수록 기울기가 얼마나 줄어드는지 잰다."""
import numpy as np

def sigmoid(z):
    return 1 / (1 + np.exp(-np.clip(z, -500, 500)))

ACT = {
    "sigmoid": (sigmoid, lambda a: a * (1 - a)),
    "tanh":    (np.tanh, lambda a: 1 - a ** 2),
    "relu":    (lambda z: np.maximum(z, 0), lambda a: (a > 0).astype(float)),
}

def layer_grad_norms(kind, depth=10, width=32, seed=0):
    fn, dfn = ACT[kind]
    rng = np.random.default_rng(seed)
    Ws = [rng.normal(0, np.sqrt(1.0 / width), (width, width)) for _ in range(depth)]
    x = rng.normal(size=(64, width))

    acts = [x]
    for W in Ws:
        acts.append(fn(acts[-1] @ W))
    delta = rng.normal(size=acts[-1].shape) / 64      # 출력단에서 내려온 기울기
    norms = []
    for i in reversed(range(depth)):
        delta = delta * dfn(acts[i + 1])
        norms.append(np.linalg.norm(acts[i].T @ delta))
        delta = delta @ Ws[i].T
    return norms[::-1]                                 # 입력쪽 층부터

print(f"{'층':>4}" + "".join(f"{k:>14}" for k in ACT))
print("-" * 46)
res = {k: layer_grad_norms(k) for k in ACT}
for i in range(10):
    print(f"{i + 1:>4}" + "".join(f"{res[k][i]:>14.3e}" for k in ACT))
print("\n1층 기울기 / 10층 기울기 비율")
for k in ACT:
    print(f"    {k:<8}: {res[k][0] / res[k][-1]:.3e}")
$ python3 vanish.py
   층       sigmoid          tanh          relu
----------------------------------------------
   1     1.757e-06     1.190e+00     1.560e-01
   2     3.265e-06     9.875e-01     1.550e-01
   3     1.048e-05     8.785e-01     1.644e-01
   4     6.571e-05     8.727e-01     1.593e-01
   5     2.736e-04     8.870e-01     1.602e-01
   6     1.168e-03     8.352e-01     1.307e-01
   7     3.946e-03     8.566e-01     9.721e-02
   8     1.677e-02     8.129e-01     1.053e-01
   9     9.080e-02     7.521e-01     1.160e-01
  10     5.153e-01     9.831e-01     1.702e-01

1층 기울기 / 10층 기울기 비율
    sigmoid : 3.411e-06
    tanh    : 1.211e+00
    relu    : 9.163e-01

시그모이드는 미분 최댓값이 0.25라 층마다 기울기가 4분의 1 이하로 줄어, 1층에 도달한 기울기가 10층의 3.4e-06 배다. 미분 최댓값이 1인 tanh(1.2배)와 ReLU(0.92배)는 거의 그대로 전달된다.

주의사항

  • 수치미분은 검증용이지 학습용이 아니다. 파라미터 하나당 순전파를 두 번 해야 해서 파라미터가 수백만 개면 계산이 끝나지 않는다. 위 예제도 29개라서 가능했다.
  • ε는 너무 작아도 틀린다. 1e-6 근처가 무난하고, 1e-10으로 내리면 부동소수점 반올림 오차가 차이보다 커져 검증이 오히려 실패한다.
  • 가중치를 0으로 초기화하면 학습이 안 된다. 모든 은닉 뉴런이 같은 기울기를 받아 영원히 같은 값으로 움직인다. 위 코드가 정규분포로 초기화하고 표준편차에 1/√n_in을 쓰는 이유이며, 초기화 방식 자체는 2단계 주제다.
  • 은닉 뉴런을 늘리는 것은 성능 개선이 아니다. 위 표에서 32개짜리는 2개짜리보다 평가 손실이 나빴다. 용량을 늘렸으면 정규화를 같이 붙여야 한다.
  • ReLU도 공짜는 아니다. 음수 입력에서 미분이 0이라 한번 음수 쪽에 머문 뉴런은 기울기를 받지 못하고 죽을 수 있다.

마무리

역전파는 연쇄법칙을 층 순서 반대로 적용한 것이고, 어려운 부분은 유도가 맞았는지 확인하는 일이다. 다음 편에서는 이 유도 자체를 자동화하는 자동미분(autograd)을 100줄 남짓으로 직접 만든다.

이 시리즈의 다른 글

참고

답글 남기기