해당 글은 CS182: Deep Learning의 강의를 정리한 글입니다. 여기서 사용된 일부 슬라이드 이미지의 권리는 강의 원작자에게 있습니다.
이번 강의에서는 본격적으로 깊은 신경망(Deep neural network, DNN)에 대해 알아볼 것입니다. 먼저 머신러닝 모델을 그림으로 표현하는 법에 대해 배운 다음, 왜 깊은 신경망은 겹겹이 쌓아올린 모습을 하고 있는지를 알아볼 것입니다. 그리고 마지막으로 깊은 신경망 모델을 학습시키기 위한 역전파 알고리즘도 배울 겁니다.
모델을 그림으로 표현하기
왜 그림으로 표현해야 할까
우리는 지금까지 수학 식을 통해 모델을 표현했습니다. 하지만 데이터가 모델에서 어떻게 연산되는지 살펴보기에 수학 식은 불편합니다. 이를 위해 우리가 사용할 표기법은 계산 그래프 (Computation Graph) 입니다. 계산 그래프란 값이 어떤 순서로 연산되는지를 표현한 유향 그래프입니다.
위의 그림은 이전 시간에 배운 평균제곱오차를 표현한 계산 그래프입니다. 입력값, 파라미터, 라벨값이 표시된 노드에서 시작해 화살표대로 연산을 하면 회귀 모델의 손실값을 평균제곱오차로 구할 수 있습니다.
그렇다면 왜 수학식 대신 계산 그래프로 모델을 표현할까요? 이는 두가지 이유 때문입니다.
- 모델이 어떤 방식으로 데이터를 조작하는지 쉽게 파악할 수 있다
- 모델 가중치의 그래디언트를 어떻게 계산할지 빠르게 알아낼 수 있다
모델은 입력 데이터를 조작하는 방법에 따라 그 구조가 달라집니다. 즉, 모델의 구조를 파악한다는 것은 그 모델의 데이터 조작 방식을 알아낸다는 뜻이기도 합니다. 만약 수학식으로 모델을 표현했다면 이 모델이 다른 모델과 어느 부분이 달라졌는지, 그 부분이 어떤 역할을 할지 파악하는데 시간이 걸립니다. 하지만 모델을 계산 그래프와 같은 그림으로 표현했다면 우리는 빠르게 모델의 구조를 비교할 수 있습니다.
또 하나의 이유로 모델 가중치의 그래디언트를 계산할 때 도움이 되는데, 이는 뒤에서 자세하게 설명할 겁니다.
그럼 어떻게 그려야 할까
위에서 본 계산 그래프를 다시 보면 입력 데이터의 특성이 두개라는 것을 알 수 있습니다. 입력 데이터의 각 특성은 각각의 파라미터와 곱해진다는 것은 쉽게 파악이 가능합니다.
하지만 만약에 입력 데이터의 특성이 100개였다면 어땠을까요? 아마 아까와 같은 방식으로 그렸다면 세로로 긴 그래프가 그려질 겁니다. 모델은 단순히 입력 데이터와 파라미터를 각각 곱하는 단순한 연산을 수행하지만, 이를 그래프로 표현하면 오히려 모델의 구조를 파악하기 어려워집니다.
그렇기 때문에 모델의 계산 방식을 지나치게 자세하게 그리면 안됩니다. 모델에서 연산의 의도를 쉽게 알아볼 수 있도록 간단하게 그리는게 중요합니다. 이를 위해 써볼 수 있는 좋은 방법 중 하나가 벡터와 행렬을 사용하는 겁니다.
$x_1\theta_1+x_2\theta_2$는 간단하게 벡터 내적 $\vec{x}\cdot\vec{\theta}$로 표현할 수 있습니다. 이제 벡터 내적으로 계산 그래프를 단순화하니 이전보다 더 깔끔해졌습니다. 심지어 입력 벡터$\vec{x}$와 파라미터 벡터$\vec{\theta}$의 차원수가 어떻든 상관없이 이 그래프로 표현할 수 있습니다. 이처럼 벡터를 통해 모델의 계산 그래프를 단순화할 수 있다보니 기본적으로 대부분의 값을 벡터로 생각하고 벡터 표기($\vec{x}$에서 화살표)를 생략합니다.
이번에는 로지스틱 회귀 모델을 계산 그래프로 그려봅시다. 여기서도 입력 데이터와 각 라벨 별 파라미터를 벡터로 표현해 간결하게 표현했습니다. 하지만 로지스틱 회귀 모델을 그릴 때 신경써야 하는 부분이 두가지가 더 있습니다.
그 중 하나는 'NLL(Negative Likelihood Loss)을 어떻게 그릴까' 입니다. 로지스틱 회귀 모델은 NLL을 구하기 위해 softmax를 구한 다음 그 중 정답 라벨에 해당하는 확률값만 골라냅니다. 하지만 이를 그대로 그린다면 각 라벨마다 softmax값을 구하고 이 중 하나를 구하는 그래프를 그려야 합니다. 그렇게 되면 그래프 속 노드가 많아지고 구조가 복잡해질 겁니다. 이를 해결하기 위해 NLL식을 변형하는 겁니다.
$$
-\log p_\theta(y|x)=-x^T\theta_y+\log\sum_{y'}\exp(x^T\theta_{y'})
$$
softmax 값을 먼저 구하고 log 함수를 씌우는 방법 대신 식에서 softmax 계산까지 한번에 계산해 필요한 계산 수를 줄이면 됩니다. 계산에 들어가는 연산 수가 줄어드니 계산 그래프의 모습도 나름 간결해질 겁니다. 하지만 아직 해결하지 못한 문제가 하나 더 있습니다.
우리가 로지스틱 회귀 모델의 손실값을 구할 때, 정답 라벨에 해당하는 확률만 가져와 계산합니다. 하지만 이 방식으론 벡터 연산으로 표현할 수 없습니다. 벡터 연산에서 일부만 가져오는 연산자를 직접 제공하지 않기 때문입니다. 하지만 정답 라벨값을 원-핫 벡터로 표현한다면 이를 흉내낼 수 있습니다.
$$
\begin{align}
y = \left[{\begin{array}{cc}1\\0\end{array}}\right],\ \text{if}\ \text{label}=0\\
y = \left[{\begin{array}{cc}0\\1\end{array}}\right],\ \text{if}\ \text{label}=1\\
\end{align}
$$
원-핫 벡터란 해당하는 값의 차원만 1, 나머지는 0으로 채운 벡터를 말합니다. 이제 정답 라벨값을 원-핫 벡터로 표현하고 이를 라벨별 $x^T\cdot\theta$값을 쌓아놓은 벡터와 내적하면 정답 라벨값만 남게 됩니다. 이런 방식으로 정답 라벨에 해당하는 $x^T\cdot\theta_y$를 구할 수 있습니다.
이렇게 이진 분류를 수행하는 로지스틱 회귀 모델을 표현했습니다. 하지만 위 그래프는 우리가 선형 회귀 모델을 그릴 때 본 문제와 비슷한 문제를 안고 있습니다. 만약 분류할 라벨 수가 100개 이상이 되면 이를 다 노드로 표현해야 할까요?
우리가 로지스틱 회귀 모델의 구조에서 보고자 하는건 데이터와 각 라벨의 확률를 계산하는 파라미터가 각각 곱해진다는 사실이지, $x$가 $\theta_1,\theta_2,...,\theta_{100}$ 와 각각 곱해진다는 사실이 아닙니다.
물론 이 또한 방법이 있습니다. 우리는 이 연산을 행렬곱으로 표현할 수 있기 때문입니다. $\theta_1,\theta_2,...,\theta_{100}$을 행렬 하나로 표현하고 이를 입력 벡터와의 행렬곱으로 표현하면 우리는 그래프에서 각각의 확률을 표현해줄 필요가 없습니다.
문제가 한가지 더 있습니다. 우리가 손실 함수의 연산을 간단하게 표현하기 위해 softmax와 NLL을 하나로 합쳐서 계산했습니다. 그 덕분에 그려야할 노드가 줄긴 했지만 정작 모델이 수행하는 연산이 softmax와 NLL인지 알아볼 수 없게 되었습니다. 우리가 계산 그래프로 모델을 그리는 이유가 모델의 구조를 쉽게 파악하기 위함이지, 단순히 모델의 계산 순서을 알기 위함이 아닙니다. 이러한 시각으로 볼때 우리는 굳이 softmax의 연산을 풀어 쓰지 않고 그대로 하나의 연산으로 표현하면 됩니다.
파라미터를 행렬로 그리고 softmax 연산을 하나의 노드로 그리니 이전보다 훨씬 간단하게 그려졌습니다. 무엇보다 데이터가 어떻게 계산되는지 한눈에 알 수 있습니다.
여기서 더 나아가 봅시다. 위의 그래프를 살펴보면 파라미터 $\theta$와 정답 라벨값 $y$가 모두 한 연산에서만 사용된다는 것을 알 수 있습니다. $\theta$는 입력 데이터와의 벡터곱에만 사용되고, $y$는 손실값 계산에만 사용됩니다. 그렇다면 우리는 이들을 하나의 함수로 치환할 수 있습니다. 파라미터와의 벡터곱은 일종의 선형 변환이라 볼 수 있으므로 linear layer, 정답 라벨값을 통해 손실값을 계산하는 부분을 cross-entropy loss라 이름짓고 표현하면 다음처럼 그릴 수 있습니다.
이젠 모델의 계산 그래프가 일반적인 그래프의 모양보단 일종의 파이프라인이나 합성함수를 표현한 그림으로 보입니다. linear layer나 cross-entropy loss은 다른 모델에서도 많이 쓰이기 때문에 굳이 세부 모습을 그릴 필요가 없습니다.
신경망(Neural Network) 다이어그램
많은 연구원들이 신경망 모델의 구조를 표현할 때 위의 그래프처럼 그리게 됩니다. 신경망 모델을 그릴 때 보통 입력 데이터가 어떤 함수를 어떤 순서로 지나가는지를 표현하게 됩니다. 그리고 모델을 구성하는 각각의 함수를 레이어(layer)라고 부릅니다. 정리하면 신경망 모델은 입력 데이터가 순차적으로 지나갈 여러 레이어로 구성됩니다.
다만 실제로 논문에서 신경망을 표현하는 그림은 우리가 사용한 계산 그래프와는 조금 다른 방식으로 표현합니다. 가장 큰 특징으로는 레이어를 지나가는 입출력 데이터($x$와 $z$)의 크기를 표현합니다. 또한 파라미터와 손실 함수는 항상 들어가기 때문에 아예 신경망 그림에서 빼는 경우도 있습니다.
특성(Feature) 학습하기
새로운 특성을 찾아야 하는 이유
모델마다 성능이 십분 발휘되는 데이터 분포가 있습니다. 예를 들어, 로지스틱 회귀 모델은 위의 그림처럼 데이터 분포가 선형으로 분리 가능하다면 데이터 분류를 쉽게 해냅니다.
하지만 이처럼 선형으로 분리하기 어려운 데이터는 로지스틱 회귀로 분류할 수 없습니다. 그렇다면 이를 위해 새로운 모델을 개발해야 할까요?
굳이 그럴 필요는 없습니다. 새로운 모델 대신 새로운 특성을 찾으면 됩니다. $x_1^2, x_2^2, x_1x_2$처럼 기존 데이터에서 새로 특성을 만들고 이를 로지스틱 회귀 모델에게 전달하면 놀랍게도 분류하지 못했던 데이터도 잘 분류하게 됩니다. 이처럼 딥러닝이 유행하기 이전에는 머신러닝 모델이 쉽게 과제를 수행할 수 있도록 주어진 데이터에서 새로운 특성을 뽑아냈습니다. 이렇게 새로운 특성을 뽑아내는 과정은 특성 공학(feature engineering)이라고 따로 불릴 정도로 머신러닝 프로젝트에서 중요한 단계입니다.
새로운 특성을 학습을 통해 찾기
하지만 모든 과제마다 그에 맞는 특성을 찾아내는건 간단한 일이 아닙니다. 어떤 특성 공학이 주어진 과제에 적합한지는 실제로 실험해보지 않는 이상 알 수 없습니다. 이는 마치 머신러닝이 나오기 이전에 사람들이 직접 데이터간의 관계를 찾아 프로그램을 만들었던 일과 비슷해보입니다.
결과 라벨값을 예측하는 방법을 학습을 통해 찾았던 것처럼 새로운 특성도 학습을 통해 찾아볼 수 있지 않을까요? 모델이 새로운 규칙을 학습하기 위해 그 규칙에 파라미터를 추가했듯이, 새로운 특성을 학습하기 위해 특성에 파라미터를 추가해봅시다.
이렇게 입력 데이터 $x$에서 새로운 특성을 찾아내는 함수 $\phi$를 만들었습니다. 마치 각각의 특성을 이진 분류 문제로 바라보고 학습을 진행하는 겁니다. 그렇게 되면 새로 찾은 특성들은 입력 데이터를 각자의 비중과 선형결합한 결과가 됩니다. 이제 변형된 입력값 $\phi(x)$을 또다른 이진 분류 모델에게 전달하면 됩니다.
이제 모델은 더 복잡한 데이터에서 필요한 특성을 스스로 뽑아내 분류할 수 있게 되었습니다. 하지만 모델의 구조에서 보면 사실 기존 모델에 linear layer와 sigmoid라는 두 레이어가 추가됐을 뿐입니다. 간단히 말해, 모델이 더 복잡한 데이터를 처리하려면 특성을 뽑아내는 레이어를 추가하기만 하면 된다는 것입니다.
모델을 깊이 쌓는다는 의미
이제는 더 복잡한 데이터를 처리한다면 이제 linear layer와 sigmoid를 앞에 계속 추가하면 됩니다. 그러면 첫번째 레이어는 입력 이미지에서 어떤 특성을 뽑아낼 것이고, 그 다음 레이어는 추출된 특성들을 조합해 또다른 특성들을 얻어낼 겁니다. 그렇게 레이어를 거듭 지나가면서 추출된 특성들은 점점 추상적인 의미를 지니게 될 겁니다.
만약 주어진 이미지의 피사체가 어떤 동물인지 구분하는 모델이라면 첫번째 레이어는 이미지의 윤곽선만을 인식하지만, 두번째 레이어는 파악한 윤곽선들을 조합해 추상적인 도형이나 패턴을 읽게 되고, 그 다음 레이어는 눈이나 코, 입과 같은 동물의 일부분을 인식하게 됩니다. 이렇게 이미지나 텍스트, 음향 같은 복잡한 데이터도 레이어를 여러 층으로 쌓아 올린다면 사람처럼 추상적인 의미를 읽어낼 수 있게 됩니다. 이것이 딥러닝, 특히 신경망 모델이 주목을 받는 이유입니다.
참고로 위의 그림처럼 신경망에서 linear layer + sigmoid와 같은 방식으로 자주 쓰이는 레이어 조합들이 있습니다. 그래서 보통 linear layer와 sigmoid를 하나로 합쳐 sigmoid layer처럼 하나로 합쳐서 표현할 겁니다.
활성화 함수
그런데 여기서 의문이 하나 생깁니다. linear layer는 특성을 만들기 위해서 선형결합을 하는 곳이기 때문에 필요하지만, sigmoid는 왜 필요한건지 알 수 없어 보입니다. 그렇다면 sigmoid 함수 말고 다른 함수는 안될까요? 무엇보다 linear layer로만 넣으면 안될까요?
그렇다면 한번 sigmoid layer를 빼고 linear layer만 넣어봅시다.
$$
\begin{align}
z^{(1)}&=w^{(1)}x\\
a^{(1)}&=z^{(1)}\\
z^{(2)}&=w^{(2)}a^{(1)}\\
\hat{y}&=\text{softmax}(z^{(2)})\\
&=\text{softmax}(w^{(2)}a^{(1)})\\
&=\text{softmax}(w^{(2)}w^{(1)}x)\\
&=\text{softmax}(w'x)\\
\end{align}
$$
sigmoid layer를 빼보니 결국 하나의 linear layer로 합쳐진 모습을 볼 수 있습니다. 그렇다면 linear layer만 모델을 만든다면 결국 linear layer 하나와 성능이 동일하게 됩니다.
이는 sigmoid 대신 어떤 선형 함수를 넣더라도 똑같은 현상이 발생합니다. 이는 선형 함수의 특성때문에 발생합니다. 결국, 여러 레이어로 쌓아올려도 모델의 표현력을 유지하기 위해서는 linear layer 사이에 sigmoid와 같은 비선형 함수를 집어넣어야 합니다. 그리고 이렇게 추가된 비선형 함수를 활성화 함수(activation function) 라 부릅니다.
활성화 함수는 비선형 함수라면 어떤 것이든 사용해도 됩니다. 실제로 자주쓰이는 활성화 함수로 sigmoid, tanh, ReLU 등이 있습니다.
가중치와 편향
우리는 지금까지 linear layer에서 파라미터 $W$를 통해 입력값을 선형변환 했습니다. 하지만 이러한 방식에는 한가지 문제점이 있습니다.
$$
\hat{y}=W^{(3)}\sigma(W^{(2)}\sigma(W^{(1)}x))
$$
다음과 같은 신경망 모델을 생각해봅시다. 만약에 $W^{(1)}x$의 결과값이 0이라면 그 이후로 $W^{(2)}$와 $W^{(3)}$이 어떤 값이 되든 $y$는 0이 됩니다. 그렇다면 어떤 입력값이 들어가도 중간에 값이 0이 된다면 예측값과 손실값이 똑같이 됩니다. 그렇다면 제대로 예측하지 못할 뿐만 아니라 학습할 때 제대로된 피드백이 파라미터에 전달되지 않습니다.
이러한 문제를 해결하기 위해서는 보통 linear layer에 파라미터를 다음과 같이 추가합니다.
$$
\hat{y}=W^{(3)}\sigma(W^{(2)}\sigma(W^{(1)}x+b^{(1)})+b^{(2)})+b^{(3)}
$$
이전 모델과 비교해보면 linear layer에 $b$라는 파라미터가 추가되었습니다. 여기서 파라미터 $W$는 가중치(weights), 파라미터 $b$는 편향(bias)라고 부릅니다. 참고로 여기서 나온 편향은 lecture 02에서 본 Variance-Bias trade-off 와는 관련이 없습니다.
linear layer에 편향을 추가하면 가중치와 곱해져서 0이 나오더라도 그 다음 레이어에서 입력값 때문에 결과값이 0이 될 가능성이 줄어듭니다. 그러면 우리는 예측 결과값이 0이 나오더라도 좀 더 신뢰할 수 있으며 학습시에 파라미터에 명확한 피드백이 전달됩니다.
신경망 훈련하기
머신러닝 구성요소 살펴보기
이제 신경망 모델을 학습시켜보겠습니다. 이전 강의에서 살펴봤듯이 머신러닝 모델을 학습시키기 위해서는 모델, 손실 함수, 옵티마이져 세가지가 필요합니다. 여기서 모델은 우리가 앞에서 본 신경망 모델이 될 것이고, 손실 함수 또한 기존에 사용한 NLL을 그대로 사용할 겁니다. 그리고 옵티마이져도 이전 강의에서 본 확률적 경사 하강법을 사용할 겁니다.
하지만 여기서 문제가 발생합니다. 확률적 경사 하강법을 사용하려면 $\nabla\mathcal{L}(\theta)$를 구해야 합니다. 하지만 우리가 사용할 신경망 모델은 파라미터가 한 곳이 아니라 두 레이어에 존재합니다. 그렇다면 이런 깊은 신경망 모델에서는 $\nabla\mathcal{L}(\theta)$을 어떻게 구해야 할까요?
신경망에서 그래디언트를 계산하기
이전 쳅터에서 깊은 신경망 모델의 구조에 대해 설명했습니다. 신경망 모델은 일종의 여러 함수의 합성으로 볼 수 있습니다. linear layer를 함수$f$, sigmoid 함수를 $\sigma$라 표현한다면 다음과 같이 표현할 수 있습니다.
$$
p(y|x)=\text{softmax}(f^{(4)}(\sigma(f^{(3)}(\sigma(f^{(2)}(\sigma(f^{(1)}(x))))))))
$$
결국 합성 함수이니 각 $f$에 포함된 파라미터 $w$에 대한 그래디언트 $\frac{d\mathcal{L}}{dw}$를 미분의 연쇄법칙을 이용하면 구할 수 있습니다.
하지만 걱정되는 부분이 한가지 있습니다. 고등학교 미적분에서 연쇄법칙을 배우긴 하지만 이는 일변수 함수일 때입니다. 하지만 우리가 미분해야 하는 함수는 다변수 함수입니다.
다변수함수의 연쇄법칙
다변수함수의 연쇄법칙을 살펴보기 위해 몇가지를 가정해보겠습니다.
$$
\begin{align}
f&:\mathbb{R}^m\rightarrow\mathbb{R}\\
g&:\mathbb{R}^n\rightarrow\mathbb{R}^m\\
y&=g(x)\\
z&=f(y)=f(g(x))
\end{align}
$$
두 함수 $f$와 $g$의 정의역과 치역의 벡터로 선언되어 있습니다. 이는 다변수함수의 각 변수들에 이름을 붙이지 않고 한 벡터로 표기하기 위함입니다.
이제 $\frac{dz}{dx}$를 구해보겠습니다. 일단 $x$ 자체가 n차원의 벡터이기 때문에 $x$의 요소 하나하나에 대한 미분을 진행해야 할 겁니다.
그러면 벡터$x$의 $i$번째 요소 $x_i$에서 $z$까지 어떻게 연산되는지 확인해봅시다. 먼저 함수 $g$에서 보면, $y$를 계산하기 위해 $x$를 이용하기 때문에 $x_i$는 벡터 $y$의 각 요소 $y_j$에 영향을 줍니다. 그리고 함수 $f$에서 보면, $y_j$가 $z$계산을 위해 사용됩니다. 그렇다면 $\frac{dz}{dx_i}$를 계산하기 위해서는 벡터 $y$의 모든 요소$y_j$에 대한 $x_i$의 미분을 구하고 그 다음 $z$에 대한 $y_j$에 대한 미분을 각각 구해 합쳐주면 됩니다. 식으로 쓰면 다음과 같습니다.
$$
\begin{align}
\frac{dy}{dx_i}=\left[{\begin{array}{cc}
\frac{dy_1}{dx_i}\\
\frac{dy_2}{dx_i}\\
\vdots\\
\frac{dy_m}{dx_i}
\end{array}}\right],
\frac{dz}{dy}=\left[{\begin{array}{cc}
\frac{dz}{dy_1}\\
\frac{dz}{dy_2}\\
\vdots\\
\frac{dz}{dy_m}\\
\end{array}}\right]\\
\frac{dz}{dx_i}=\sum_{j=1}^m\frac{dy_j}{dx_i}\frac{dz}{dy_j}
=\frac{dy}{dx_i}^T\frac{dz}{dy}
\end{align}
$$
여기서 특이한 점은 미분계수를 벡터로 표현한 점입니다. 이는 각 정의역과 공역에 해당하는 벡터 요소끼리의 조합마다 미분을 한 결과를 보여줍니다. 이러한 행렬을 자코비안 행렬(Jacobian matrix) 라고 합니다. 이처럼 다변수함수의 미분계수는 자코비안 행렬로 표현합니다.
이제 다변수함수의 연쇄법칙은 우리가 알고있는 연쇄법칙을 그대로 적용하면 됩니다. $\frac{dz}{dx_i}$를 구하고 싶다면 $\frac{dy}{dx_i}$와 $\frac{dz}{dy}$를 구하고 이 둘을 행렬곱하면 됩니다.
참고로 우리가 구한건 $\frac{dz}{dx}$이 아니라 $\frac{dz}{dx_i}$입니다. 하지만 이 경우에도 위에서 본 방식을 그대로 적용합니다.
$$
\begin{align}
\frac{dy}{dx}=\left[{\begin{array}{ccccc}
\frac{dy_1}{dx_1} & \frac{dy_1}{dx_2} & \cdots & \frac{dy_1}{dx_n}\\
\frac{dy_2}{dx_1} & \frac{dy_2}{dx_2} & \cdots & \frac{dy_2}{dx_n}\\
\vdots & \vdots & \ddots & \vdots\\
\frac{dy_m}{dx_1} & \frac{dy_m}{dx_2} & \cdots & \frac{dy_m}{dx_n}
\end{array}}\right],
\frac{dz}{dy}=\left[{\begin{array}{cc}
\frac{dz}{dy_1}\\
\frac{dz}{dy_2}\\
\vdots\\
\frac{dz}{dy_m}\\
\end{array}}\right]\\
\frac{dz}{dx}=\frac{dy}{dx}^T\frac{dz}{dy}=
\left[{\begin{array}{ccccc}
\frac{dy_1}{dx_1}\frac{dz}{dy_1}+\frac{dy_2}{dx_1}\frac{dz}{dy_2}+\cdots+\frac{dy_m}{dx_1}\frac{dz}{dy_m}\\
\frac{dy_1}{dx_2}\frac{dz}{dy_1}+\frac{dy_2}{dx_2}\frac{dz}{dy_2}+\cdots+ \frac{dy_m}{dx_2}\frac{dz}{dy_m}\\
\vdots\\
\frac{dy_1}{dx_n}\frac{dz}{dy_1}+\frac{dy_2}{dx_n}\frac{dz}{dy_2}+\cdots+\frac{dy_m}{dx_n}\frac{dz}{dy_m}
\end{array}}\right]
\end{align}
$$
주의할 점으로 자코비안 행렬에서 행과 열 중 어느 부분이 정의역 부분인지는 표기하는 사람마다 다를 수 있습니다. 다만 행렬곱을 수행할 수 있도록 일관성있게 표기하기 때문에 잘 살펴보면 알 수 있습니다.
연쇄법칙으로 신경망 그래디언트 계산하기
이제 신경망의 각 레이어마다 존재하는 파라미터 $W^{(1)}$과 $W^{(2)}$에 대한 그래디언트를 구해봅시다. 우리는 다변수함수의 미분계수를 자코비안 행렬로 표기하면 우리가 알던 방식대로 연쇄법칙을 그대로 적용할 수 있다 라는 것을 알고 있습니다. 그렇다면 $\frac{d\mathcal{L}}{dW^{(1)}}$과 $\frac{d\mathcal{L}}{dW^{(2)}}$를 구할때도 위의 그림처럼 연쇄법칙을 적용하면 됩니다.
다변수함수의 그래디언트 계산할때 발생하는 문제
하지만 신경망의 그래디언트를 그대로 구하면 두가지의 문제가 발생합니다. 첫번째 문제로, 이대로 계산한다면 한번 그래디언트를 계산하는데 매우 오래 걸린다는 겁니다. $\frac{d\mathcal{L}}{dW^{(1)}}$ 계산을 예로 들어보겠습니다. $z^{(1)}$은 1차원, $W^{(1)}$은 2차원이기 때문에 $\frac{dz^{(1)}}{dW^{(1)}}$의 자코비안 행렬은 3차원이 됩니다. 그리고 $a^{(1)}$은 1차원이기 때문에 $\frac{da^{(1)}}{dz^{(1)}}$의 자코비안 행렬은 2차원이 될 겁니다. 그렇다면 $\frac{dz^{(1)}}{dW^{(1)}}\frac{da^{(1)}}{dz^{(1)}}$을 계산하는데 얼마나 걸릴까요? 먼저 내적하는데 $O(n)$, $\frac{dz^{(1)}}{dW^{(1)}}$에서 벡터 하나 고르는데 $O(n^2)$, $\frac{da^{(1)}}{dz^{(1)}}$에서 벡터 하나 고르는데 $O(n)$이 수행됩니다. 결국 $\frac{dz^{(1)}}{dW^{(1)}}\frac{da^{(1)}}{dz^{(1)}}$를 구하는데 총 $O(n^4)$정도 걸립니다. 심지어 이 계산의 결과물도 3차원이기 때문에 계속 행렬곱을 수행할 때마다 $O(n^4)$만큼의 시간이 필요합니다. 물론 GPU를 통해 행렬곱 연산을 한번에 수행할 수 있지만, 그럼에도 꽤 시간이 걸립니다.
또 다른 문제는 이미 구한 값을 또 한번 계산해야한다는 점입니다. $\frac{d\mathcal{L}}{dW^{(1)}}$와 $\frac{d\mathcal{L}}{dW^{(2)}}$ 값을 알기위해 다음과 같이 계산해야한다고 가정합니다.
$$
\begin{align}
\frac{d\mathcal{L}}{dW^{(1)}}=
\frac{dz^{(1)}}{dW^{(1)}}
\frac{da^{(2)}}{dz^{(1)}}
\frac{dz^{(2)}}{da^{(2)}}
\frac{da^{(3)}}{dz^{(2)}}
\frac{dz^{(3)}}{da^{(3)}}
\frac{d\mathcal{L}}{dz^{(3)}}\\
\frac{d\mathcal{L}}{dW^{(2)}}=
\frac{dz^{(2)}}{dW^{(2)}}
\frac{da^{(3)}}{dz^{(2)}}
\frac{dz^{(3)}}{da^{(3)}}
\frac{d\mathcal{L}}{dz^{(3)}}
\end{align}
$$
여기서 $\frac{d\mathcal{L}}{dW^{(1)}}$와 $\frac{d\mathcal{L}}{dW^{(2)}}$ 모두 $\frac{da^{(3)}}{dz^{(2)}}\frac{dz^{(3)}}{da^{(3)}}\frac{d\mathcal{L}}{dz^{(3)}}$가 들어가있다는 것을 알 수 있습니다. 그렇다면 실제로 컴퓨터로 계산할 때 $\frac{da^{(3)}}{dz^{(2)}}\frac{dz^{(3)}}{da^{(3)}}\frac{d\mathcal{L}}{dz^{(3)}}$을 계산한 결과값을 저장하고 이를 활용하면 더욱 효율적일 겁니다.
하지만 이를 실제로 구현해보면 오히려 비효율적임을 알 수 있습니다. 미분계수를 구할때 $\frac{dz^{(1)}}{dW^{(1)}}$나 $\frac{dz^{(2)}}{dW^{(2)}}$를 먼저 계산하고, 여기에 계속 값을 누적합니다. 이런 상황에서 공통부분을 동시에 계산하기 어렵습니다. $\frac{dz^{(1)}}{dW^{(1)}}\frac{da^{(2)}}{dz^{(1)}}\frac{dz^{(2)}}{da^{(2)}}$까지 계산한 상황에서 $\frac{da^{(3)}}{dz^{(2)}}$을 만나면 $\frac{dz^{(1)}}{dW^{(1)}}\frac{da^{(2)}}{dz^{(1)}}\frac{dz^{(2)}}{da^{(2)}}$값에 누적하면서 따로 저장을 해두어야 하기 때문입니다. 심지어 레이어가 깊으면 공통부분도 달라지니 이들을 따로 저장하는건 너무 비효율적입니다.
역전파 알고리즘
이 두가지 문제를 해결하는 방법은 매우 간단합니다. 그저 순서를 반대로 하면 됩니다.
먼저 한 파라미터에 대한 그래디언트를 계산할 때 입력단 레이어부터 계산하지 않고 손실 함수 부분부터 계산합니다. 다시 말해, $\frac{d\mathcal{L}}{dW^{(1)}}$을 계산할 때 $\frac{dz^{(1)}}{dW^{(1)}}\frac{da^{(2)}}{dz^{(1)}}$부터 오른쪽으로 계산하는게 아닌 $\frac{dz^{(3)}}{da^{(3)}}\frac{d\mathcal{L}}{dz^{(3)}}$부터 계산해 왼쪽으로 계산하는 겁니다. 이렇게 하면 결과값은 똑같지만 계산 속도에서 큰 이득을 볼 수 있습니다. $\frac{dz^{(3)}}{da^{(3)}}$은 2차원, $\frac{d\mathcal{L}}{dz^{(3)}}$은 1차원이기 때문에 한번 계산하는데 $O(n^2)$정도가 걸리고 결과값은 1차원 입니다. 맨 마지막에 $\frac{dz^{(1)}}{dW^{(1)}}$과 곱할 때 $O(n^3)$정도가 걸리는 것을 제외하면 모두 $O(n^2)$이니 이전 방식보다 더 빠르게 그래디언트를 계산할 수 있습니다.
그리고 입력단에서 가장 가까운 레이어의 파라미터부터 구하지 않고 손실 함수와 가장 가까운 파라미터부터 구합니다. 간단히 말해, $\frac{d\mathcal{L}}{dW^{(1)}}$, $\frac{d\mathcal{L}}{dW^{(2)}}$, $\frac{d\mathcal{L}}{dW^{(3)}}$ 순서로 구하는게 아니라 $\frac{d\mathcal{L}}{dW^{(3)}}$, $\frac{d\mathcal{L}}{dW^{(2)}}$, $\frac{d\mathcal{L}}{dW^{(1)}}$ 순으로 구하는 겁니다. 이렇게 하면 공통 부분을 먼저 구하고 다른 그래디언트를 계산할 때 이를 활용할 수 있게 됩니다.
이 두가지 방법을 이용해 효율적으로 깊은 신경망의 그래디언트를 구하는 알고리즘을 역전파 알고리즘(Backpropagation algorithm)입니다. 입력값이 첫 레이어에서 손실함수까지 도달하는 순방향과 반대 방향으로 그래디언트를 계산하기 때문에 붙은 이름입니다.
역전파 알고리즘은 다음 순서를 따릅니다.
- 손실 함수에서 시작해서 $\delta$를 초기화 합니다
- 다음 레이어로 건너가 $\delta$를 이용해 $\frac{d\mathcal{L}}{d\theta_f}$를 계산합니다
- $\delta$로 $\frac{d\mathcal{L}}{dx_f}$를 구하고, $\delta$에 덮어씁니다
- 입력단에 도달할 때까지 2~3을 반복합니다
다음은 역전파 설명을 위해 간략하게 그린 신경망의 계산 그래프 입니다. 역전파 알고리즘은 먼저 손실 함수에서 시작해 $\delta$를 초기화합니다. 이때 $\delta$값은 $\frac{d\mathcal{L}}{d\theta_n}$과 $\frac{d\mathcal{L}}{dx_n}$의 공통 부분인 $\frac{d\mathcal{L}}{da_n}$이 됩니다.
그런 다음 $\delta$값을 이용해 $\frac{d\mathcal{L}}{d\theta_n}$을 계산합니다. $\frac{d\mathcal{L}}{d\theta_n}$을 계산할 때 필요한 일부 값은 이미 구했으니 $\frac{a_n}{d\theta_n}$만 새로 구하고 $\delta$와 곱해주면 됩니다.
이제 다음 레이어 계산을 위해 $\delta$값을 업데이트합니다. 다음 레이어에서 공통부분은 $\frac{d\mathcal{L}}{dx_n}$이 됩니다. 이는 지금 가지고 있는 $\delta$에 $\frac{da_{n}}{dx_n}$과 활성화 함수의 미분계수인 $\frac{dx_n}{da_{n-1}}$을 곱해주기만 하면 구할 수 있습니다.
이제 다음 레이어에 도달한 다음 위의 과정을 똑같이 반복하면 됩니다. 이렇게 하면 연산 속도도 빠르면서 이미 계산한 값을 최대한 재활용하기 때문에 깊은 신경망은 역전파 알고리즘으로 그래디언트를 계산합니다.
신경망에서 역전파 알고리즘 적용해보기
이제 linear layer와 sigmoid에서 역전파 알고리즘이 어떻게 적용되는지 확인해봅시다.
Linear layer
먼저 $\frac{d\mathcal{L}}{d\theta_n}$을 계산해봅시다. linear layer는 파라미터가 $W$와 $b$ 두개 존재합니다. 그러니 linear layer에서는 $\frac{d\mathcal{L}}{dW^{(n)}}=\frac{da^{(n)}}{dW^{(n)}}\delta$와 $\frac{d\mathcal{L}}{db^{(n)}}=\frac{da^{(n)}}{db^{(n)}}\delta$를 구해야 합니다.
$$
a^{(n)}=W^{(n)}x^{(n)}+b^{(n)}=\sum_iW^{(n)}_ix^{(n)}+b^{(n)}_i=\sum_k\sum_iW^{(n)}_{ik}x^{(n)}k+b^{(n)}_i
$$
먼저 $\frac{d\mathcal{L}}{dW^{(n)}}$를 계산해봅시다. 만약 식이 벡터가 아니라 단일값으로 이루어졌다면 $\frac{da^{(n)}}{dW^{(n)}}=x^{(n)}$입니다. 하지만 우리가 구해야할 미분계수가 스칼라값이 아니라 자코비안 행렬이라면 다음과 같습니다.
$$
\begin{align}
a_i^{(n)}=W_i^{(n)}x^{(n)}+b_i^{(n)}=\sum_kW_{ik}^{(n)}x_k^{(n)}+b_i^{(n)}\\
\frac{da_i^{(n)}}{dW_{jk}^{(n)}}=\Bigg\{\begin{array}{ll}
0,&\text{if }j\neq i\\
x_k^{(n)},&\text{otherwise}
\end{array}
\end{align}
$$
자코비안 행렬로 구할때 이렇게 복잡해지는 이유는 $a^{(n)}$의 모든 요소를 계산할 때 $W^{(n)}$의 모든 요소가 관여하지 않았기 때문입니다.
실제로 행렬곱 연산을 보면 $x_1$은 $W_{1,1}$, $x_2$은 $W_{1,2}$, $x_3$은 $W_{1,3}$과 각각 곱해져 이들의 합으로만 $a_1$이 구해집니다. 즉, $a_1$ 연산에 기여한 $W$의 요소는 $W_{1,1}$, $W_{1,2}$, $W_{1,3}$ 세가지 뿐입니다. 그렇기 때문에 $\frac{da_1}{dW}$의 자코비안 행렬은 이 세가지 요소에 해당되는 부분만 값이 존재하며, 나머지는 0으로 채워져있습니다.
이러한 모습은 $\frac{da_2}{dW}$나 $\frac{da_3}{dW}$ 등을 구할 때도 마찬가지입니다. 연산에 기여한 파라미터만 미분계수로, 나머지는 0으로 채워집니다. 이렇게 구한 $\frac{da}{dW}$은 다음과 비슷한 모습을 가지게 됩니다.
$\frac{d\mathcal{L}}{db^{(n)}}$은 계산하기 훨씬 쉽습니다.
$$
\begin{align}
a_i^{(n)}=W_i^{(n)}x^{(n)}+b_i^{(n)}=\sum_kW_{ik}^{(n)}x_k^{(n)}+b_i^{(n)}\\
\frac{da_i^{n}}{db_j^{(n)}}=\text{Ind(i=j)},\frac{da^{n}}{db^{(n)}}=\text{I}
\end{align}
$$
$\frac{da^{(n)}}{db^{(n)}}$도 값이 존재하는 위치는 $\frac{da^{(n)}}{dW^{(n)}}$와 동일합니다. 다만 그 위치에 존재하는 값은 1이 됩니다. 그렇다면 $\frac{d\mathcal{L}}{db^{(n)}}$에 들어있는 값은 일부 위치에 $\delta$값이 그대로 들어있게 됩니다.
이제 $\delta$값을 업데이트 하기 위해 $\frac{da^{(n)}}{dx^{(n)}}$을 계산해야 합니다. $x^{(n)}$은 $a^{(n)}$의 모든 요소에 영향을 끼치고 있습니다. 다만 각 요소마다 같이 계산된 $W^{(n)}_{ik}$가 다릅니다. 그렇기에 $\frac{da^{(n)}}{dx^{(n)}}$는 $W^{(n)}$의 전치행렬이 됩니다.
$$
\begin{align}
a_i^{(n)}=W_i^{(n)}x^{(n)}+b_i^{(n)}=\sum_kW_{ik}^{(n)}x_k^{(n)}+b_i^{(n)}\\
\frac{da_i^{(n)}}{dx_k^{(n)}}=W^{(n)}_{ik},\frac{da^{(n)}}{dx^{(n)}}=(W^{(n)})^T
\end{align}
$$
Sigmoid function
sigmoid function은 파라미터가 없기 때문에 $\delta$값 업데이트만 수행하면 됩니다.
$$
\begin{align}
\sigma(a^{(n)}_i)=(1-\sigma(a^{(n)}_i))\sigma(a^{(n)}_i)\\
\frac{dx_j^{(n)}}{da_i^{(n-1)}}=\Bigg\{\begin{array}{ll}
(1-\sigma(a^{(n)}_i))\sigma(a^{(n)}_i)\delta,&\text{if }i=j\\
0,&\text{otherwise}
\end{array}
\end{align}
$$
sigmoid function은 벡터의 각 차원마다 따로 적용되기 때문에 대각선 위치에 있는 요소가 아니면 모두 0입니다. 그리고 대각선에 있는 값들은 $(1-\sigma(a^{(n)}_i))\sigma(a^{(n)}_i)\delta$로 구하면 됩니다.
ReLU function
추가적으로 ReLU function의 경우에는 어떻게 구하는지 알아봅시다. ReLU는 $x=0$에서 미분 불가능하므로 다음과 같은 방식으로 구합니다.
$$
\frac{dx_j^{(n)}}{da_i^{(n-1)}}=\Bigg\{\begin{array}{ll}
\text{Ind}(a_i^{(n-1)}\ge0),&\text{if }i=j\\
0,&\text{otherwise}
\end{array}
$$
ReLU는 $x$가 음수이면 0, 양수면 $x$값 그대로 나오는 함수이기 때문에 미분계수도 $x$가 음수였다면 0, 양수였다면 1을 주는 방식으로 계산합니다. 그리고 ReLU 또한 벡터의 차원마다 따로 적용되기에 대각선에 있는 값만 계산합니다.