행렬 미분

먼저 읽을 장 8.1 다변수 미분

신경망의 파라미터는 행렬이고, 손실은 그 행렬들의 스칼라 함수다. 가중치 행렬의 성분 하나하나로 편미분해 모을 수도 있지만, 성분이 수만 개를 넘으면 그렇게 유도한 식은 읽을 수도 구현할 수도 없다. 행렬 미분(matrix calculus)은 이 계산을 행렬 연산의 언어로 한 번에 하는 방법이다. 결과는 X.T @ (X @ w - y)처럼 그대로 코드가 되는 식이다.

이 장의 어려움은 계산보다 규약에 있다. 같은 미분을 교재마다 다른 shape으로 쓰고, 그 차이가 전치 하나로 나타난다. 이 장은 책 전체의 규약을 정하고, 미분형(differential)으로 gradient를 기계적으로 읽어 내는 방법을 익힌 뒤, 선형회귀·softmax cross-entropy·2층 MLP의 gradient를 손으로 유도한다. 8.4장의 역전파는 이 유도를 자동화한 것이다.

학습 목표

  • 행렬·벡터에 대한 미분을 유도한다
  • layout 규약을 일관되게 쓴다
  • 신경망 gradient를 손으로 유도한다

layout 규약

8.1장에서 스칼라 함수의 Jacobian은 1×n1 \times n 행이고 gradient는 그것을 전치한 n×1n \times 1 열이라고 했다. 둘은 같은 정보이지만 shape이 다르다. 교재와 논문은 둘 중 하나를 골라 "미분"이라 부르는데, 그 선택을 layout 규약이라 한다. 규약을 모르고 공식을 옮기면 전치가 하나씩 어긋난다.

두 가지 대표 규약이 있다. y∈Rm\mathbf{y} \in \mathbb{R}^m을 x∈Rn\mathbf{x} \in \mathbb{R}^n으로 미분한 ∂y/∂x\partial\mathbf{y}/\partial\mathbf{x}를

  • numerator layout은 분자(y\mathbf{y})의 차원을 행으로 놓아 m×nm \times n으로 쓴다. 8.1장의 Jacobian과 같다. 스칼라를 벡터로 미분하면 1×n1 \times n 행벡터가 된다.
  • denominator layout은 분모(x\mathbf{x})의 차원을 행으로 놓아 n×mn \times m으로 쓴다. Jacobian의 전치다. 스칼라를 벡터로 미분하면 n×1n \times 1 열벡터가 된다.

이 책의 규약은 다음 표로 고정한다(부록 A와 같다).

미분 이 책의 표기 shape 비고
벡터 y∈Rm\mathbf{y} \in \mathbb{R}^m를 벡터 x∈Rn\mathbf{x} \in \mathbb{R}^n로 Jacobian J=∂y/∂x\mathbf{J} = \partial\mathbf{y}/\partial\mathbf{x} m×nm \times n numerator layout
스칼라 LL을 벡터 x∈Rn\mathbf{x} \in \mathbb{R}^n로 gradient ∇xL\nabla_{\mathbf{x}} L n×1n \times 1 =J⊤= \mathbf{J}^\top, 열벡터
스칼라 LL을 행렬 W∈Rm×n\mathbf{W} \in \mathbb{R}^{m \times n}로 gradient ∇WL\nabla_{\mathbf{W}} L m×nm \times n W\mathbf{W}와 같은 shape

Jacobian은 numerator layout을 따르고, 스칼라의 gradient는 파라미터와 같은 shape을 따른다. 엄밀히는 두 규약을 섞어 쓰는 셈이지만, 머신러닝에서 가장 실용적인 조합이다. 경사하강 W←W−η∇WL\mathbf{W} \leftarrow \mathbf{W} - \eta\nabla_{\mathbf{W}}L이 성분별로 바로 맞고, PyTorch의 .grad도 파라미터와 같은 shape이다.

벡터를 행렬로 미분하는 경우(예: 층의 출력 Wx\mathbf{W}\mathbf{x}를 W\mathbf{W}로)는 결과가 3차원 배열이 되어 행렬로 쓸 수 없다. 이 책은 그런 대상을 직접 만들지 않는다. 손실이라는 스칼라까지 가는 경로에서 다음 절의 미분형을 쓰면 3차원 배열 없이 gradient를 얻는다. 8.4장의 역전파도 같은 이유로 Jacobian 전체를 만들지 않는다.

미분형(differential) 접근

성분별 편미분으로 행렬 gradient를 유도하면 첨자가 서너 개씩 붙고 실수하기 쉽다. 미분형은 "입력이 dXd\mathbf{X}만큼 변할 때 출력이 얼마나 변하는가"를 행렬식 그대로 추적한 뒤, 마지막에 gradient를 읽어 내는 방법이다.

정의. 스칼라 함수 L(X)L(\mathbf{X})의 미분형 dLdL은 8.1장의 1차 근사 L(X+dX)−L(X)L(\mathbf{X} + d\mathbf{X}) - L(\mathbf{X})에서 dXd\mathbf{X}에 대해 선형인 부분이다. 성분으로 쓰면 dL=∑ij∂L∂Xij dXijdL = \sum_{ij} \frac{\partial L}{\partial X_{ij}}\,dX_{ij}이고, 이것은 두 행렬의 성분별 곱의 합이므로 trace로 쓸 수 있다.

dL=tr⁡(G⊤dX)⟺∇XL=G.dL = \operatorname{tr}\big(\mathbf{G}^\top d\mathbf{X}\big) \quad \Longleftrightarrow \quad \nabla_{\mathbf{X}} L = \mathbf{G}.

여기서 tr⁡(A⊤B)=∑ijAijBij\operatorname{tr}(\mathbf{A}^\top\mathbf{B}) = \sum_{ij} A_{ij}B_{ij}를 썼다. 벡터에서는 dL=g⊤dxdL = \mathbf{g}^\top d\mathbf{x}이면 ∇xL=g\nabla_{\mathbf{x}}L = \mathbf{g}다. 그래서 절차는 두 단계다. (1) 규칙으로 dLdL을 계산하고, (2) tr⁡(G⊤dX)\operatorname{tr}(\mathbf{G}^\top d\mathbf{X}) 꼴로 정리해 G\mathbf{G}를 읽는다.

규칙. 일변수 미분 규칙이 행렬 곱의 순서를 지키는 형태로 그대로 성립한다.

d(XY)=(dX)Y+X(dY),d(X⊤)=(dX)⊤,dtr⁡(X)=tr⁡(dX),dA=0 (상수).d(\mathbf{X}\mathbf{Y}) = (d\mathbf{X})\mathbf{Y} + \mathbf{X}(d\mathbf{Y}), \qquad d(\mathbf{X}^\top) = (d\mathbf{X})^\top, \qquad d\operatorname{tr}(\mathbf{X}) = \operatorname{tr}(d\mathbf{X}), \qquad d\mathbf{A} = \mathbf{0}\ (\text{상수}).

정리 단계에서 쓰는 trace 성질은 셋이다. 스칼라는 자기 자신의 trace다(a=tr⁡(a)a = \operatorname{tr}(a)). 순환해도 같다(tr⁡(ABC)=tr⁡(CAB)\operatorname{tr}(\mathbf{A}\mathbf{B}\mathbf{C}) = \operatorname{tr}(\mathbf{C}\mathbf{A}\mathbf{B}), shape이 맞을 때). 전치해도 같다(tr⁡(A⊤)=tr⁡(A)\operatorname{tr}(\mathbf{A}^\top) = \operatorname{tr}(\mathbf{A})).

예: 한 층의 제곱오차. L=12∥Wx−y∥2L = \tfrac{1}{2}\lVert \mathbf{W}\mathbf{x} - \mathbf{y} \rVert^2을 W∈Rm×n\mathbf{W} \in \mathbb{R}^{m \times n}로 미분한다. 잔차를 r=Wx−y\mathbf{r} = \mathbf{W}\mathbf{x} - \mathbf{y}로 두면

dL=r⊤dr=r⊤(dW) x=tr⁡(xr⊤dW)=tr⁡((rx⊤)⊤dW)dL = \mathbf{r}^\top d\mathbf{r} = \mathbf{r}^\top (d\mathbf{W})\,\mathbf{x} = \operatorname{tr}\big(\mathbf{x}\mathbf{r}^\top d\mathbf{W}\big) = \operatorname{tr}\big((\mathbf{r}\mathbf{x}^\top)^\top d\mathbf{W}\big)

이다. 셋째 등호에서 스칼라를 trace로 쓰고 순환시켰다. 따라서 ∇WL=rx⊤\nabla_{\mathbf{W}}L = \mathbf{r}\mathbf{x}^\top이고, shape은 (m×1)(1×n)=m×n(m \times 1)(1 \times n) = m \times n으로 W\mathbf{W}와 같다. 출력 쪽 gradient와 입력의 외적이라는 이 모양은 모든 선형층의 가중치 gradient에서 반복된다.

역행렬과 행렬식. XX−1=I\mathbf{X}\mathbf{X}^{-1} = \mathbf{I}의 양변에 미분형을 취하면 (dX)X−1+X d(X−1)=0(d\mathbf{X})\mathbf{X}^{-1} + \mathbf{X}\,d(\mathbf{X}^{-1}) = \mathbf{0}이므로

d(X−1)=−X−1(dX) X−1d(\mathbf{X}^{-1}) = -\mathbf{X}^{-1}(d\mathbf{X})\,\mathbf{X}^{-1}

이다. 일변수의 d(1/x)=−dx/x2d(1/x) = -dx/x^2에 해당한다. 행렬식에 대해서는 Jacobi 공식 ddet⁡X=det⁡X tr⁡(X−1dX)d\det\mathbf{X} = \det\mathbf{X}\,\operatorname{tr}(\mathbf{X}^{-1}d\mathbf{X})가 성립하고, 따라서 dlog⁡det⁡X=tr⁡(X−1dX)d\log\det\mathbf{X} = \operatorname{tr}(\mathbf{X}^{-1}d\mathbf{X}), 즉 ∇Xlog⁡det⁡X=X−⊤\nabla_{\mathbf{X}}\log\det\mathbf{X} = \mathbf{X}^{-\top}이다. 2×2 행렬로 확인하면 det⁡X=X11X22−X12X21\det\mathbf{X} = X_{11}X_{22} - X_{12}X_{21}이므로 ∂det⁡X/∂X11=X22\partial\det\mathbf{X}/\partial X_{11} = X_{22}이고, 이것은 det⁡X (X−1)11\det\mathbf{X}\,(\mathbf{X}^{-1})_{11}과 같다. log⁡det⁡\log\det는 8.3장의 변수변환과 normalizing flow에서 다시 나온다.

자주 쓰는 항등식

다음 표는 미분형 규칙으로 모두 유도할 수 있다. 외우기보다 유도할 수 있어야 하고, 쓰기 전에 수치미분으로 확인하는 습관이 중요하다. 이 책의 규약(gradient는 미분 대상과 같은 shape)으로 쓴 것이다. a,b,A\mathbf{a}, \mathbf{b}, \mathbf{A}는 상수다.

함수 gradient 유도의 핵심
a⊤x\mathbf{a}^\top\mathbf{x} ∇x=a\nabla_{\mathbf{x}} = \mathbf{a} d=a⊤dxd = \mathbf{a}^\top d\mathbf{x}
x⊤Ax\mathbf{x}^\top\mathbf{A}\mathbf{x} ∇x=(A+A⊤)x\nabla_{\mathbf{x}} = (\mathbf{A} + \mathbf{A}^\top)\mathbf{x} x\mathbf{x}가 양쪽에 있어 항이 둘
∥Ax−b∥2\lVert\mathbf{A}\mathbf{x} - \mathbf{b}\rVert^2 ∇x=2A⊤(Ax−b)\nabla_{\mathbf{x}} = 2\mathbf{A}^\top(\mathbf{A}\mathbf{x} - \mathbf{b}) 잔차의 미분 후 전치
a⊤Xb\mathbf{a}^\top\mathbf{X}\mathbf{b} ∇X=ab⊤\nabla_{\mathbf{X}} = \mathbf{a}\mathbf{b}^\top tr⁡(ba⊤dX)\operatorname{tr}(\mathbf{b}\mathbf{a}^\top d\mathbf{X})
tr⁡(AX)\operatorname{tr}(\mathbf{A}\mathbf{X}) ∇X=A⊤\nabla_{\mathbf{X}} = \mathbf{A}^\top tr⁡(A dX)\operatorname{tr}(\mathbf{A}\,d\mathbf{X})
tr⁡(X⊤AX)\operatorname{tr}(\mathbf{X}^\top\mathbf{A}\mathbf{X}) ∇X=(A+A⊤)X\nabla_{\mathbf{X}} = (\mathbf{A} + \mathbf{A}^\top)\mathbf{X} 곱의 규칙
log⁡det⁡X\log\det\mathbf{X} ∇X=X−⊤\nabla_{\mathbf{X}} = \mathbf{X}^{-\top} Jacobi 공식
tr⁡(AX−1)\operatorname{tr}(\mathbf{A}\mathbf{X}^{-1}) ∇X=−X−⊤A⊤X−⊤\nabla_{\mathbf{X}} = -\mathbf{X}^{-\top}\mathbf{A}^\top\mathbf{X}^{-\top} 역행렬의 미분형

A\mathbf{A}가 대칭이면 둘째 줄은 2Ax2\mathbf{A}\mathbf{x}, 여섯째 줄은 2AX2\mathbf{A}\mathbf{X}로 줄어든다. 대칭을 가정한 공식을 대칭이 아닌 행렬에 쓰는 것이 흔한 실수다.

표의 세 줄을 중앙차분 gradient와 비교한다. num_grad는 입력과 같은 shape의 gradient를 돌려주므로, 이 책의 규약을 그대로 확인할 수 있다.

import numpy as np

def num_grad(f, X, eps=1e-6):
    """스칼라 함수 f의 X에 대한 gradient를 중앙차분으로. 결과 shape = X.shape"""
    G = np.zeros_like(X)
    for idx in np.ndindex(X.shape):
        E = np.zeros_like(X)
        E[idx] = eps
        G[idx] = (f(X + E) - f(X - E)) / (2 * eps)
    return G

rng = np.random.default_rng(0)
A = rng.normal(size=(3, 3))                       # (3, 3) 대칭이 아님
x = rng.normal(size=3)                            # (3,)
X = rng.normal(size=(3, 3)) + 3 * np.eye(3)       # (3, 3) det > 0

checks = {
    "x^T A x  -> (A + A^T) x": (num_grad(lambda v: v @ A @ v, x), (A + A.T) @ x),
    "tr(A X)  -> A^T        ": (num_grad(lambda M: np.trace(A @ M), X), A.T),
    "log det X -> X^{-T}    ": (num_grad(lambda M: np.log(np.linalg.det(M)), X), np.linalg.inv(X).T),
}
for name, (num, formula) in checks.items():
    print(f"{name}  최대 차이 < 1e-8: {np.abs(num - formula).max() < 1e-8}")
x^T A x  -> (A + A^T) x  최대 차이 < 1e-8: True
tr(A X)  -> A^T          최대 차이 < 1e-8: True
log det X -> X^{-T}      최대 차이 < 1e-8: True

신경망의 gradient

이제 같은 도구로 실제 모델의 gradient를 유도한다. 순서는 선형회귀, softmax cross-entropy, 2층 MLP다. 세 유도 모두 출력 쪽 gradient를 먼저 구하고, 층을 하나씩 거슬러 내려가며 Jacobian의 전치를 곱한다는 같은 구조를 가진다.

선형회귀. 설계행렬 X∈RN×D\mathbf{X} \in \mathbb{R}^{N \times D}(행이 샘플), 타깃 y∈RN\mathbf{y} \in \mathbb{R}^N, 가중치 w∈RD\mathbf{w} \in \mathbb{R}^D에 대해 L=12N∥Xw−y∥2L = \tfrac{1}{2N}\lVert\mathbf{X}\mathbf{w} - \mathbf{y}\rVert^2이다. 잔차 r=Xw−y\mathbf{r} = \mathbf{X}\mathbf{w} - \mathbf{y}로 두면 dL=1Nr⊤X dwdL = \tfrac{1}{N}\mathbf{r}^\top\mathbf{X}\,d\mathbf{w}이므로

∇wL=1NX⊤(Xw−y)(D×N)(N×1)=D×1.\nabla_{\mathbf{w}} L = \frac{1}{N}\mathbf{X}^\top(\mathbf{X}\mathbf{w} - \mathbf{y}) \qquad (D \times N)(N \times 1) = D \times 1.

이 gradient를 0으로 두면 정규방정식 X⊤Xw=X⊤y\mathbf{X}^\top\mathbf{X}\mathbf{w} = \mathbf{X}^\top\mathbf{y}가 나온다. 최소제곱해가 잔차를 X\mathbf{X}의 열공간에 직교하게 만든다는 사영의 관점(7.4장)과 같은 식이다.

N, D = 50, 3
Xd = rng.normal(size=(N, D))                      # (N, D) 설계행렬
y = Xd @ np.array([1.0, -2.0, 0.5]) + 0.1 * rng.normal(size=N)   # (N,)
w = np.zeros(D)                                   # (D,)

def mse(w):
    r = Xd @ w - y                                # (N,) 잔차
    return 0.5 * np.mean(r**2)

grad_w = Xd.T @ (Xd @ w - y) / N                  # (D, N)(N,) = (D,)
print("손 유도  :", grad_w.round(6))
print("수치미분 :", num_grad(mse, w).round(6))
손 유도  : [-1.283416  1.29817  -0.589093]
수치미분 : [-1.283416  1.29817  -0.589093]

softmax cross-entropy. 분류 모델은 logit z∈RK\mathbf{z} \in \mathbb{R}^K를 확률 pk=ezk/∑jezjp_k = e^{z_k}/\sum_j e^{z_j}로 바꾸고, one-hot 정답 y\mathbf{y}에 대해 L=−∑kyklog⁡pkL = -\sum_k y_k\log p_k를 최소화한다. log⁡pk=zk−log⁡∑jezj\log p_k = z_k - \log\sum_j e^{z_j}이므로

∂log⁡pk∂zi=δki−ezi∑jezj=δki−pi\frac{\partial \log p_k}{\partial z_i} = \delta_{ki} - \frac{e^{z_i}}{\sum_j e^{z_j}} = \delta_{ki} - p_i

이다. δki\delta_{ki}는 k=ik = i일 때 1, 아니면 0이다. 이를 손실에 넣으면

∂L∂zi=−∑kyk(δki−pi)=−yi+pi∑kyk=pi−yi,∇zL=p−y.\frac{\partial L}{\partial z_i} = -\sum_k y_k(\delta_{ki} - p_i) = -y_i + p_i\sum_k y_k = p_i - y_i, \qquad \nabla_{\mathbf{z}} L = \mathbf{p} - \mathbf{y}.

마지막 등호에 ∑kyk=1\sum_k y_k = 1을 썼다. 결과는 놀랍도록 단순하다. 예측 확률에서 정답을 뺀 것이다. 정답 클래스의 성분은 pc−1<0p_c - 1 < 0이라 그 logit을 올리고, 나머지 성분은 pk>0p_k > 0이라 낮춘다. 성분의 합은 1−1=01 - 1 = 0이다. 같은 결과를 softmax의 Jacobian ∂p/∂z=diag⁡(p)−pp⊤\partial\mathbf{p}/\partial\mathbf{z} = \operatorname{diag}(\mathbf{p}) - \mathbf{p}\mathbf{p}^\top과 ∂L/∂p=−y/p\partial L/\partial\mathbf{p} = -\mathbf{y}/\mathbf{p}(성분별 나눗셈)의 곱으로 유도해도 된다. 이 단순함 때문에 프레임워크는 softmax와 cross-entropy를 한 함수로 묶어 계산한다. 따로 계산하면 1/pk1/p_k에서 수치 문제가 생길 수 있다.

def softmax(z):
    e = np.exp(z - z.max())                       # 큰 logit에서의 overflow 방지
    return e / e.sum()

z = np.array([2.0, 1.0, -1.0, 0.5])               # (K,) logits
y_onehot = np.array([0.0, 1.0, 0.0, 0.0])         # (K,) 정답은 인덱스 1

ce = lambda z: -np.sum(y_onehot * np.log(softmax(z)))
p = softmax(z)
print("p - y    :", (p - y_onehot).round(6))
print("수치미분 :", num_grad(ce, z).round(6))
print("성분 합  :", f"{abs((p - y_onehot).sum()):.1e}")
p - y    : [ 0.60946  -0.775792  0.030343  0.135989]
수치미분 : [ 0.60946  -0.775792  0.030343  0.135989]
성분 합  : 2.8e-17

2층 MLP. 한 샘플에 대해 a=W1x+b1\mathbf{a} = \mathbf{W}_1\mathbf{x} + \mathbf{b}_1, h=ReLU(a)\mathbf{h} = \mathrm{ReLU}(\mathbf{a}), z=W2h+b2\mathbf{z} = \mathbf{W}_2\mathbf{h} + \mathbf{b}_2, L=CE(z,y)L = \mathrm{CE}(\mathbf{z}, \mathbf{y})이다. 가중치는 (출력 × 입력) shape이다. 출력 쪽부터 거슬러 내려간다.

gz=p−y,∇W2L=gzh⊤,∇b2L=gz,gh=W2⊤gz,ga=gh⊙1[a>0],∇W1L=gax⊤,∇b1L=ga.\begin{aligned} \mathbf{g}_z &= \mathbf{p} - \mathbf{y}, & \nabla_{\mathbf{W}_2} L &= \mathbf{g}_z\mathbf{h}^\top, & \nabla_{\mathbf{b}_2} L &= \mathbf{g}_z, \\ \mathbf{g}_h &= \mathbf{W}_2^\top\mathbf{g}_z, & \mathbf{g}_a &= \mathbf{g}_h \odot \mathbb{1}[\mathbf{a} > 0], & & \\ \nabla_{\mathbf{W}_1} L &= \mathbf{g}_a\mathbf{x}^\top, & \nabla_{\mathbf{b}_1} L &= \mathbf{g}_a. & & \end{aligned}

각 줄은 앞 절의 결과를 그대로 쓴다. 가중치 gradient는 "출력 쪽 gradient와 입력의 외적"이고, 선형층을 거슬러 갈 때는 가중치의 전치 W2⊤\mathbf{W}_2^\top을 곱하며(z\mathbf{z}의 h\mathbf{h}에 대한 Jacobian이 W2\mathbf{W}_2이므로 gradient는 그 전치로 전달된다), ReLU를 거슬러 갈 때는 켜진 뉴런의 gradient만 통과시킨다(ReLU의 Jacobian은 0과 1로 된 대각행렬이다).

batch로 쓰기. 코드에서는 샘플 NN개를 행으로 쌓은 X∈RN×D\mathbf{X} \in \mathbb{R}^{N \times D}를 쓰고, 순전파는 A=XW1⊤+1b1⊤\mathbf{A} = \mathbf{X}\mathbf{W}_1^\top + \mathbf{1}\mathbf{b}_1^\top이다. 손실이 batch 평균이면 출력 쪽 gradient는 GZ=(P−Y)/N\mathbf{G}_Z = (\mathbf{P} - \mathbf{Y})/N이고, 샘플별 외적의 합 ∑ngnhn⊤\sum_n \mathbf{g}_n\mathbf{h}_n^\top은 행렬 곱 GZ⊤H\mathbf{G}_Z^\top\mathbf{H}가 된다. bias gradient는 샘플 축으로 더한 것이다. 이 식을 그대로 구현하고 파라미터마다 중앙차분과 비교한다. 비교에는 상대오차 ∥g−gnum∥/(∥g∥+∥gnum∥)\lVert \mathbf{g} - \mathbf{g}_{\text{num}} \rVert / (\lVert \mathbf{g} \rVert + \lVert \mathbf{g}_{\text{num}} \rVert)를 쓴다. 값의 크기와 무관하게 판단할 수 있어서다(8.4장).

N, D, H, K = 8, 3, 5, 4
Xb = rng.normal(size=(N, D))                      # (N, D)
labels = rng.integers(0, K, size=N)               # (N,)
Y = np.eye(K)[labels]                             # (N, K) one-hot
P0 = {"W1": rng.normal(size=(H, D)), "b1": rng.normal(size=H),
      "W2": rng.normal(size=(K, H)), "b2": rng.normal(size=K)}

def forward(P):
    A1 = Xb @ P["W1"].T + P["b1"]                 # (N, H)
    Hh = np.maximum(A1, 0)                        # (N, H)
    Z = Hh @ P["W2"].T + P["b2"]                  # (N, K)
    Z = Z - Z.max(axis=1, keepdims=True)
    Pr = np.exp(Z) / np.exp(Z).sum(axis=1, keepdims=True)   # (N, K)
    return A1, Hh, Pr, -np.mean(np.sum(Y * np.log(Pr), axis=1))

A1, Hh, Pr, loss = forward(P0)
Gz = (Pr - Y) / N                                 # (N, K) dL/dZ
Ga = (Gz @ P0["W2"]) * (A1 > 0)                   # (N, H) dL/dA1
grads = {"W2": Gz.T @ Hh, "b2": Gz.sum(0), "W1": Ga.T @ Xb, "b1": Ga.sum(0)}
for k, g in grads.items():
    f = lambda v, k=k: forward({**P0, k: v})[3]
    g_num = num_grad(f, P0[k])
    rel = np.linalg.norm(g - g_num) / (np.linalg.norm(g) + np.linalg.norm(g_num))
    print(f"{k}: shape {str(g.shape):7}  상대오차 < 1e-7: {rel < 1e-7}")
W2: shape (4, 5)   상대오차 < 1e-7: True
b2: shape (4,)     상대오차 < 1e-7: True
W1: shape (5, 3)   상대오차 < 1e-7: True
b1: shape (5,)     상대오차 < 1e-7: True

모든 gradient의 shape이 해당 파라미터와 같고, 상대오차가 8.4장에서 올바른 구현의 기준으로 드는 10−710^{-7}보다 작다(실제 값은 10−1010^{-10} 안팎이며, 반올림 오차라서 실행 환경마다 조금씩 다르다). 행 벡터 batch에서는 Gz @ W2처럼 전치 없이 곱하는 것이 단일 샘플 식의 W2⊤gz\mathbf{W}_2^\top\mathbf{g}_z와 같다는 점에 주의한다. (W2⊤gz)⊤=gz⊤W2(\mathbf{W}_2^\top\mathbf{g}_z)^\top = \mathbf{g}_z^\top\mathbf{W}_2이기 때문이다. 단일 샘플 식과 batch 식 사이를 오갈 때 전치가 한 번씩 뒤집힌다.

손으로 유도한 gradient는 두 가지 쓸모가 있다. 첫째, 자동미분이 없는 환경(일부 시뮬레이션 코드, 임베디드 장치)에서 직접 구현할 수 있다. 둘째, 더 중요하게는 자동미분의 결과를 읽을 수 있게 된다. gradient가 0이 되는 이유(꺼진 ReLU, 포화된 softmax)나 커지는 이유(큰 가중치의 전치가 반복 곱해짐)를 식에서 바로 찾을 수 있다. 8.4장은 이 유도를 계산 그래프 위에서 자동으로 수행하는 방법이다.

실습

선형회귀와 2층 MLP의 gradient를 손으로 유도하고 NumPy로 구현한 뒤 수치미분으로 검증한다. 코드는 labs/calculus-and-autodiff/matrix-calculus/gradcheck_models.py이고 세 부분으로 되어 있다.

  1. 여러 크기·시드에서 선형회귀와 MLP의 손 유도 gradient를 중앙차분과 비교한다.
  2. 일부러 틀린 구현 두 가지를 같은 검사에 넣는다. ReLU 마스크를 빠뜨린 경우와, 은닉층 폭과 클래스 수가 같아(H=KH = K) shape이 우연히 맞는 상태에서 W2\mathbf{W}_2의 전치를 잘못 붙인 경우다.
  3. 손 유도 gradient만으로 3개 클래스의 나선형 데이터에 2층 MLP를 학습한다.

집필 시점의 실행 결과는 다음과 같다.

[1] 선형회귀: 손 유도 vs 중앙차분 (상대오차)
  N=  20 D=  2   w 1.3e-11   b 8.0e-12
  N= 100 D=  5   w 1.1e-10   b 5.2e-10
  N= 500 D= 10   w 1.5e-10   b 2.1e-09
[2] 2층 MLP: 손 유도 vs 중앙차분 (파라미터별 상대오차)
  N= 8 D=3 H= 5 K=4 정상             W1 2.3e-10  b1 1.9e-10  W2 7.8e-10  b2 1.5e-10
  N= 8 D=3 H= 5 K=4 no-mask        W1 3.0e-01  b1 4.2e-01  W2 7.8e-10  b2 1.5e-10
  N=16 D=4 H= 4 K=4 정상             W1 6.0e-10  b1 1.0e-09  W2 5.4e-10  b2 2.6e-10
  N=16 D=4 H= 4 K=4 no-mask        W1 3.6e-01  b1 5.3e-01  W2 5.4e-10  b2 2.6e-10
  N=16 D=4 H= 4 K=4 no-transpose   W1 8.5e-01  b1 9.5e-01  W2 5.4e-10  b2 2.6e-10
  N=32 D=6 H=10 K=3 정상             W1 7.3e-10  b1 5.2e-10  W2 3.5e-10  b2 1.9e-10
  N=32 D=6 H=10 K=3 no-mask        W1 4.1e-01  b1 3.4e-01  W2 3.5e-10  b2 1.9e-10
[3] 손 유도 gradient로 2층 MLP 학습 (3개 클래스, 2차원 입력)
  step    0  loss 1.1987  정확도 0.240
  step  500  loss 0.0503  정확도 0.997
  step 1000  loss 0.0338  정확도 0.997
  step 1500  loss 0.0274  정확도 0.997
  step 2000  loss 0.0239  정확도 0.997

결과를 읽는 법은 두 가지다.

  • 버그는 그 버그가 지나는 파라미터에서만 드러난다. 두 버그 모두 은닉층 아래(W1, b1)의 상대오차만 0.3–0.95로 키우고, 출력층(W2, b2)은 정상과 같다. gradient check를 파라미터별로 해야 버그의 위치를 좁힐 수 있다.
  • 전치 버그는 shape 검사를 통과한다. H=KH = K인 경우에만 실행되었고 오류 없이 틀린 값을 냈다. shape이 맞는다는 것은 정답의 필요조건일 뿐이다.

이어서 두 연습문제를 푼다.

실패 사례

  • 전치를 빠뜨려 shape이 맞지 않는 gradient. ∇WL\nabla_{\mathbf{W}}L을 xg⊤\mathbf{x}\mathbf{g}^\top으로 쓰면 shape이 W\mathbf{W}의 전치가 된다. 정사각 행렬이면 오류 없이 지나가므로 더 위험하다. 유도한 식마다 shape을 주석으로 적고, gradient의 shape이 파라미터와 같은지 확인한다.
  • 서로 다른 layout 규약의 공식을 섞는 것. denominator layout 교재의 Jacobian 공식(입력 × 출력)을 numerator layout의 chain rule(JgJf\mathbf{J}_g\mathbf{J}_f)에 넣으면 곱의 순서와 전치가 모두 어긋난다. 공식을 옮길 때는 출처의 규약을 먼저 확인하고, 작은 예제로 수치미분과 비교한다.
  • 대칭 행렬을 가정한 공식을 일반 행렬에 쓰는 것. ∇xx⊤Ax=2Ax\nabla_{\mathbf{x}}\mathbf{x}^\top\mathbf{A}\mathbf{x} = 2\mathbf{A}\mathbf{x}는 A\mathbf{A}가 대칭일 때만 맞다. 반대로 공분산처럼 대칭이라는 제약이 있는 행렬을 미분할 때는, 독립 성분이 줄어들어 성분별 gradient의 해석이 달라진다. 그런 경우는 대칭 행렬을 LL⊤\mathbf{L}\mathbf{L}^\top 같은 제약 없는 파라미터로 다시 쓰고 미분하는 편이 안전하다.
  • batch 평균의 1/N1/N을 빠뜨리는 것. 손실은 평균인데 gradient는 합으로 구현하면 gradient가 batch 크기만큼 커진다. 학습률이 batch 크기에 따라 달라 보이는 이상한 현상의 원인이 된다.

통과 기준

  • softmax cross-entropy의 gradient가 p−y\mathbf{p} - \mathbf{y}임을 유도하고, 2층 MLP의 모든 파라미터 gradient를 미분형 또는 chain rule로 유도한다.
  • 유도한 gradient를 NumPy로 구현해 파라미터별 상대오차가 10−610^{-6} 이하임을 중앙차분으로 확인한다.
  • 연습문제 두 개(softmax-ce-grad, mlp-backward)의 테스트를 통과한다.