본문 바로가기

Data Science/밑바닥부터시작하는딥러닝(2)

[밑시딥] 게이트가 추가된 RNN, LSTM(Long-Short Term Memory)

반응형

🔊 해당 포스팅은 밑바닥부터 시작하는 딥러닝 2권의 교재 내용을 기반으로 자연어처리 딥러닝 신경망을 Tensorflow, Pytorch와 같은 딥러닝 프레임워크를 사용하지 않고 순수한 Numpy로 구현하면서 자연어 처리의 기초를 탄탄히 하고자 하는 목적 하에 게시되는 포스팅입니다. 내용은 주로 필자가 중요하다고 생각되는 내용 위주로 작성되었음을 알려드립니다.

 

밑바닥부터 시작하는 딥러닝(2)


저번 포스팅에서는 hidden state라고 불리는 '은닉 상태 벡터'라는 것을 활용해 과거의 기억을 유지하며 시계열(시퀀스) 데이터의 특징을 학습할 수 있었던 기본 RNN 신경망에 대해 알아보았다. 그리고 이 기본 RNN 신경망으로 다음에 나올 단어를 예측하는 언어 모델인 RNN LM(Language Model)을 구현해보기도 했다. 하지만 저번에 알아보았던 기본 RNN은 이론적으로 과거의 기억을 갖고 있는다고 했지만 실제 데이터를 막상 학습시켜보니 잘 학습을 하지 못하는 문제가 발생한다. 무슨 문제냐면 시간적으로 멀리 떨어져 있는 장기 의존(Long-Term Dependency) 관계를 잘 학습하지 못하게 된다.

(앞으로 포스팅을 소개하면서 '장기 의존 관계를 학습하지 못한다'라는 것을 과거의 먼 기억을 유지하지 못한다라는 텍스트로 대체하겠습니다. 최대한 전문적인 용어를 자제함으로써 이해를 수월하게 하기 위해 그렇게 하겠습니다!)

 

그래서 이번 포스팅에서는 기본 RNN 모델의 한계점을 극복하는 일명 '게이트'가 추가된 RNN인 LSTM(Long-Short Term Memory)모델에 대해 알아보도록 하자. 여기서 '게이트'가 바로 기존 RNN이 학습하지 못했던 '장기 의존' 관계를 학습할 수 있도록 해주는 역할을 한다.

1. 기본 RNN의 문제점

게이트가 추가된 RNN 모델인 LSTM에 대해 알아보기 전에 기본 RNN이 어떤 이유 때문에 과거의 먼 기억을 학습하지 못하는 것일까? 2가지 문제점이 존재하는데, 바로 기본 RNN의 역전파 과정에서 발생하는 기울기 폭발(Gradient Exploding)과 기울기 소실(Gradient Vanishing) 문제이다. 그러면 이러한 문제가 왜 발생하는 것일까?

 

우리는 1가지 예시로 기본 RNN 모델의 역전파 과정이 어떻게 동작하는지 다시 상기시켜보면서 문제점을 예상해보자. 기본 RNN 모델을 사용해서 아래와 같은 문제를 푼다고 가정해보자.

 

기본 RNN은 ?에 무슨 단어를 예측할 것인가?

 

? 칸에 들어갈 단어는 바로 Tom이다. 사람은 직관적으로 위 문장을 읽으면서 빈 칸에 Tom이 올 것이라고 확신할 수 있다. 그러면 기본 RNN 모델이 ? 칸에 들어갈 단어를 예측하기 위해서 수행하는 순전파, 역전파 과정을 도식화해보면 아래와 같다.

 

기본 RNN모델이 위 문제를 해결할 때 수행하는 순전파, 역전파 과정

 

위 그림의 구조를 활용해서 기본 RNN 모델은 문제의 정답인 'Tom'을 정확히 맞추기 위해 학습할 것이다. 그리고 학습할 때, 예측값과 정답 간의 차이를 비교하면서 그 차이값(손실함수값)을 작게 만드는 방향으로 기울기 값을 역방향(과거 방향)으로 전달하는 역전파 과정(빨간색 선)을 수행할 것이다. 여기서 '역방향으로 전달하고 있는 기울기' 값에는 그야말로 의미있는 정보가 인코딩되어 들어있어야 한다. 하지만 기본 RNN 모델아쉽게도 과거의 먼 시간으로 갈수록 전달되는 정보의 양이 점점 사라진다. 다시 말해, 역전파로 전달하는 기울기 값이 사라지는 기울기 소실 또는 기울기 값이 너무 커져서 파라미터 값이 터무니 없이 커지는 기울기 폭발 문제가 발생한다.

 

여기서 '기울기가 사라진다'는 것은 곧 기울기 = 0이 된다는 것이고 기울기가 0이 되면 파라미터 값들이 갱신되지 않는다는 것을 의미한다. 반면에 '기울기가 폭발한다'는 것은 곧 기울기의 값이 매우 커져서 파라미터 값들도 매우 큰 값으로 갱신되기 때문에 막말로 터무니없는 파라미터 값으로 바뀔 것이기 때문에 학습이 제대로 이루어지지 않는다는 것을 의미한다. 그러면 대체 기본 RNN의 어떤 문제 때문에 기울기 소실 또는 기울기 폭발하는 문제가 발생하는 것일까?

2. 기본 RNN의 기울기 소실과 폭발의 원인

그러면 기본 RNN 모델의 역전파 과정을 계산 그래프 그림으로 다시 살펴보면서 어떤 연산을 거치는지 보자.

 

기본 RNN 모델의 역전파 과정

 

위 그림을 보면 역전파 과정을 수행하면서 $tanh$ 연산, 2번의 $+$ 연산, 각 파라미터로 1번의 행렬 곱(dot) 연산을 거치는 것을 볼 수 있다. 결국 $tanh$, $+$, 행렬 곱(dot) 연산 3가지를 수행한다는 것이다. 기울기 소실 또는 폭발 문제는 분명히 이 3가지 연산 중에 발생할 것임이 분명하다.

 

결론부터 말하면 $tanh$ 연산과 행렬 곱 연산이 바로 범인(?)이다. 가장 간단한 $+$ 연산은 어차피 이전으로부터 흘러들어온 국소적인 미분값을 건드리지 않고 그대로 흘려보내기 때문에 기울기가 소실하거나 폭발할 여지가 없다. 그러면 이제 $tanh$ 연산과 행렬 곱 연산에서 기울기 소실 또는 폭발 문제가 어떻게 발생되는 것인지 하나씩 살펴보자. 먼저 $tanh$ 연산이다.

2-1. 기울기 소실을 발생시키는 $tanh$ 연산에서의 역전파

아래의 그림 속 그래프를 헷갈리지 않고 이해하기 위해 우리는 $x$에 $tanh$ 연산을 취해준 값인 $tanh(x)$를 $z$ 값으로 임의로 정의하고 시작하자. $z = tanh(x)$(이 때, $x$는 RNN 계층에 넣기 시작하는 입력 벡터 또는 은닉상태 벡터를 의미)

 

$tanh(x)$의 그래프와 $tanh(x)$의 미분 그래프

 

위 그래프를 이해하는 데 주의해야 할 점이 있다. 현재 두 개의 함수 즉, 두 개의 라인이 그려져 있는데, 그래프의 $x$축은 두 함수 모두 동일한 값인 입력(입력(단어) 벡터 또는 은닉상태 벡터)을 가리키지만 $y$ 축은 서로 다른 값을 의미한다. $tanh(x) = \frac{e^x + e^{-x}}{e^x - e^{-x}}$이다. 그리고 우리는 $z = tanh(x)$로 정의하기로 했다. 그리고 $tanh(x)$의 미분값은 저번 포스팅에서 배운 것처럼 1에서 $x$를 $tanh$ 연산 취해준 뒤의 제곱한 값을 뺀 것과 동일하다고 했다. 즉, $tanh(x)$의 미분 값은 $1 - z^2$이 된다.($tanh$에 대한 미분 값 계산 과정은 직전 포스팅을 참고하자)

 

이제 위 사실을 인지한 채 그래프를 살펴보자. 우리가 주목해야 할 그래프는 주황색 파선이다. 현재 주황색 라인의 $y$값은 기울기이고 기울기 값은 모두 0과 1의 값 사이에만 존재하는 것을 알 수 있다. 기울기 값이 0에 근접하는 것은 기울기가 소실될 가능성이 있는 상당히 위태위태(?)한 상태임을 알 수 있다.

 

또한 $x$값으로부터 멀어질수록 $y$인 기울기 값은 0에 수렴하게 된다. $x$값으로부터 멀어진다는 것은 $x$값을 계속 $tanh$ 연산을 반복해서 취해준다는 것을 의미한다. $tanh$ 연산을 반복해서 취해준다는 것은 곧 RNN 계층을 계속 순환해서 통과한다는 것을 의미한다. 그런데 우리는 $tanh$ 연산을 미분해줄 때 $tanh$ 연산을 취해준 값인 $z$를 이용한다는 것을 알고 있다. 이는 곧 역방향으로 RNN 계층을 계속 통과하면서 미분을 수행할수록 계속 $tanh$ 연산을 수행하게 되고 그에 따라 기울기인 $y$ 값은 0에 수렴하게 되고 이는 기울기가 소실하는 문제를 발생시킨다는 것이다!

2-2. 기울기 소실 또는 폭발을 발생시키는 행렬 곱(dot) 연산에서의 역전파

다음은 행렬 곱 연산에서의 역전파이다. 행렬 곱 연산에서의 역전파는 아래처럼 계산된다는 것을 배웠다.

 

노란색 칸이 행렬 곱(dot) 연산의 역전파

 

예전에 행렬 곱 연산의 역전파 과정에 대해 배웠던 적이 있다. 여기에서는 구체적인 역전파 과정을 소개하지는 않는다. 혹시 모른다면 이전 포스팅의 [3. Affine 계층]을 참고하자.

 

행렬 곱 연산을 거치고 난 후 각 파라미터에 대한 역전파 계산 값을 보면(빨간색 화살표와 빨간색 글씨) 순전파 시 자신과 dot 연산을 수행한 행렬의 전치 행렬(Transpose)을 곱해주는 것을 볼 수 있다. 위 그림을 예시로 들면, $h_{t-1}$라는 파라미터의 dot 연산의 역전파 식은 순전파 때 $h_{t-1}$와 dot 연산을 수행한 $W_{h}$의 전치(Transpose) 행렬을 곱해주는 것을 볼 수 있다. 왜 구체적으로 이렇게 되는지에 대해서는 윗 문단에서 첨부한 [3. Affine 계층] 링크를 참조하자. 

 

어쨌거나 위와 같이 dot 연산을 계속적으로 수행하게 된다는 것은 계속 $W_{h}$의 전치(Transpose) 행렬을 곱해주는 것이 된다. 즉, $W_h$라는 가중치를 매번 계속 제곱해주는 것이다. 이렇게 계속적으로 $W_h$ 가중치를 곱해주게 되면 기울기가 어떻게 변할까? 이에 대해서 간단하게 넘파이로 구현해 눈으로 직접 확인해보자.

 

아래의 소스코드는 아래 그림에서 초록색 부분에 대한 역전파를 간단하게 예시로 나타낸 것이다. 해당 코드로 기울기의 값이 어떻게 바뀌는지 살펴보자.

아래 소스코드는 초록색 네모칸을 간단하게 구현한 것

 

import numpy as np
import matplotlib.pyplot as plt
plt.rc('font', family='AppleGothic')

N = 2   # 배치 사이즈
H = 3   # 은닉 상태 벡터 차원 수
T = 20  # 시계열 길이

# h_t인 은닉상태 벡터의 기울기 값
dh = np.ones((N, H))

np.random.seed(3)

# h_t와 순전파 시 행렬 곱을 수행할 파라미터 W_h
Wh = np.random.randn(H, H)

norm_list = []
for t in range(T):
    dh = np.matmul(dh, Wh.T)
    #norm = dh
    norm = np.sqrt(np.sum(dh ** 2)) / N # 기울기 값을 하나의 Scala값으로 변환하기 위해 L2 norm 취해주기
    norm_list.append(norm)

print('* norm_list:\n', norm_list)
plt.plot(np.arange(len(norm_list)), norm_list)  # plt.plot(x, y)
plt.xticks([0, 4, 9, 14, 19], [1, 5, 10, 15, 20])
plt.xlabel('시간 크기(time step)')
plt.ylabel('기울기 합산한 후 L2 norm한 값')
plt.show()

 

위 소스코드에서 L2 norm(각 원소의 제곱 합 후 제곱근 취해줌)을 취해줌으로써 기울기 값을 하나의 스칼라 값으로 만들어 주었다. 왜냐하면 매번 행렬 곱(dot) 연산의 역전파를 수행할 때마다 기울기 값이 어떻게 변화하는지 나타내기 위해서다. 결과 그래프는 아래와 같다.

 

위 소스코드의 결과화면

 

norm_list 변수라는 리스트에 있는 값들이 순차적으로 dot 연산 역전파를 수행하면서 갱신되는 기울기 값이다. 점점 커짐을 알 수 있다. 이를 그래프로 그려보면 x축이 증가함에 따라 y값이 매우 커지는 지수함수 모양을 띈다. 결국 이는 행렬 곱(dot) 연산 역전파를 반복적으로 취해줌으로써 기울기 값이 폭발(발산)하게 된다는 것을 의미한다.

 

그러면 위 소스코드에서 난수 파라미터 값인 $W_h$의 표준편차를 0.5로 바꾸어보고 결과화면을 비교해보자.

(참고로 위 소스코드에서 사용한 np.random.randn 은 평균이 1, 표준편차가 0인 표준정규분포로부터 난수를 생성하는 함수로, 사용자가 평균이 $m$, 표준편차가 $k$인 분포로 바꾸고 싶다고 하면 간단하게 np.random.randn(N, H) * k + m 으로 정의해주면 된다. 이에 대한 설명 링크는 여기)

 

파라미터의 표준편차를 0.5로 바꾸었을 때, 행렬 곱 연산을 계속 수행할 때의 기울기 변화

 

norm_list 변수에 있는 값들을 보면 점점 감소하는 것을 볼 수 있다. 이를 그래프로 나타내면 지수적으로 감소하는 모양을 띈다. 결국 파라미터의 표준편차를 바꾼 뒤에는 기울기가 소실하는 문제가 발생하는 것을 볼 수 있다.

 

그러면 왜 행렬 곱 연산의 역전파를 계속 수행할 때, 이러한 기울기 폭발 또는 소실 문제가 발생하는 걸까? 그 이유는 행렬 곱 연산의 역전파를 수행할 때마다 (위 소스코드에서는) $W_h$를 계속 곱해주기 때문이다(구체적으로는 $W_h$의 전치행렬을 곱함) 만약 $W_h$ 값이 스칼라값이라고 한다면, 이 $W_h$값이 1보다 크면 기울기 값이 지수적으로 증가해 폭발하고 1보다 작으면 기울기 값이 지수적으로 감소해 소실하는 문제가 발생할 가능성이 높다.

 

그런데 $W_h$는 실질적으로 스칼라 값이 아닌 행렬이다. 행렬일 때는 행렬의 '특잇값'이 척도가 된다. 행렬 내의 여러 특잇값 중 가장 최대가 되는 특잇값1보다 크면 기울기가 폭발하고 1보다 작으면 기울기가 감소가능성이 높아진다. 이 때, 주의해야 할 점이 있다. 행렬 내 특잇값의 최대값이 1보다 크면 기울기 폭발, 1보다 작으면 기울기가 감소하는 상황이 발생할 가능성이 높다는 것이지 무조건 발생한다는 것은 아님을 꼭 주의하자.

3. 기울기 폭발에는 짤라내 대처하자, Gradient Clipping!

위 목차까지 해서 기본 RNN에서 발생하는 기울기 폭발 또는 기울기 소실이 무엇인지 알아보고 왜 발생하는 것 까지도 알아보았다. 그러면 이제 이 문제점들을 개선하는 해결법에 대해 알아보자! 먼저 기울기 폭발에 대한 대처 방법이다.

 

기울기 폭발의 대표적인 대처 방법으로는 기울기 클리핑(Gradient Clipping)이 있다. 아이디어는 매우 간단하다. 기울기가 폭발하려고 할 때, 인위적으로 폭발하려는 기울기를 짤라(Clip)내는 것이다. 이를 좀 더 소스코드 적으로 이야기하면, '기울기가 어떤 값 이상이 되었을 때는 기울기가 폭발하고 있는 거야' 에서 '어떤 값' 임곗값(threshold)으로 설정하고 기울기가 이 임곗값을 넘어가게 되면 그 기울기 값에 조치를 취해준다. 기울기 클리핑에 대한 수식은 아래와 같다.

$$ if\ \lVert \hat{g} \rVert \ge threshold: $$

$$ \hat{g} = \frac{threshold}{ \lVert \hat{g} \rVert} \hat{g}$$

위 수식에서 $\hat{g}$는 모든 파라미터에 대한 기울기를 하나로 모은 값을 의미한다. 예컨대, 어떤 신경망에서 $W_1, W_2$ 파라미터를 갖고 있다면 이 때의 $\hat{g}$는 $dW_1, dW_2$를 의미한다. 그리고 $\lVert \hat{g} \rVert$는 $\hat{g}$에 L2 norm를 적용한 값이다. 

 

위 미지수에 대한 의미를 알고난 후 다시 수식을 보자. 모든 파라미터의 기울기 값에 L2 norm을 적용한 값인 $\lVert \hat{g} \rVert$가 사전에 정의한 특정 임곗값 $threshold$보다 크거나 같을 때, 원본 기울기 값인 $\hat{g}$를 위와 같이 갱신시켜준다. 이를 넘파이 소스코드로 구현하면 아래와 같다.

 

import numpy as np

dW1 = np.random.rand(3, 3) * 10
dW2 = np.random.rand(3, 3) * 10
grads = [dW1, dW2]

threshold = 5.0

def clip_grads(grads, threshold):
    total_norm = 0
    for grad in grads:
        total_norm = np.sum(grad ** 2)
    total_norm = np.sqrt(total_norm)
    
    # total_norm >= threshold 에서 양 변을 total_norm으로 나누어주기 = rate
    rate = threshold / (total_norm + 1e6)
    if rate < 1:
        for grad in grads:
            grad *= rate
    return grads

res = clip_grads(grads, threshold)

4. 기울기 소실에는 게이트(Gate)로 대처하자, LSTM!

기울기 소실 문제를 예방하기 위해서는 기본 RNN 모델의 아키텍처를 다시 설계해야 한다. 그리고 다시 설계하는 과정에서 '게이트(gate)'를 추가해야 한다. 그리고 이 게이트를 추가한 RNN 모델이 바로 LSTM 모델이다. 기본 RNN 모델과 LSTM 모델의 전체 구조 차이점을 간략화한 그림으로 먼저 살펴보자.

 

기본 RNN vs LSTM 모델 구조 비교

 

LSTM 모델의 구조를 살펴보면 특이한 점이 있다. 바로 $c$라고 되어 있는 경로가 추가되었다는 점이다. 이 $c$는 기억 셀(또는 단순히 '셀')이라고 부르며 LSTM의 전용 기억 메커니즘이라고 할 수 있다. 기억 셀인 $c$는 위 구조에서 또 특이한 점을 발견할 수 있는데, 순환되는 LSTM 계층 내에서만 전파되고 $h_t$가 출력되는 방향으로는 출력되지 않는다.

 

기억 셀인 $c$는 다음 LSTM 계층으로만 전달된다

4-1. LSTM에서의 기억 셀($c$)게이트(Gate)의 역할

LSTM 구조를 본격적으로 이해하기 위해서 2가지에 대한 이해가 선행되어야 한다. 첫 번째는 방금 위에서 살펴보았던 LSTM에서만 존재하는 기억 셀 $c$에 대한 정의이다. 만약 기억 셀인 $c_t$가 있을 때, 이 $c_t$에는 과거로부터 $t$ 시각까지에 필요한 모든 정보가 저장되어 있다고 가정한다. 그리고 이 $c_t$는 이전 시각의 기억 셀인 $c_{t-1}$과 이전 시각의 은닉 상태 벡터인 $h_{t-1}$, 현재 시각의 입력 $x_t$가 '어떤 계산'을 통해서 계산이 된다. 아래처럼 말이다.

 

현재 시점 $t$의 기억 셀인 $c_t$를 계산하는 과정

 

여기서 '어떤 계산'이라는 것은 추후에 '게이트'라는 개념을 적용하면서 알아볼 것이다. 위 그림에서 한 가지 추가적으로 봐야할 점은 위에 '어떤 계산'을 통해서 계산된 현재 시점의 기억 셀인 $c_t$가 두 갈래로 분기(복제)된다는 것이다. 한 곳기억 셀 $c_t$를 그대로 다음 LSTM 계층으로 전달하고(그림 상에서 오른쪽의 빨간색 선) 나머지 한 곳$tanh$연산을 기억 셀에 취해주어서 현재 시점 $t$의 은닉 상태 벡터 $h_t$를 계산해 다음 LSTM 계층으로 전달하게 된다. 여기서 알 수 있는 사실 한 가지는 $c_t$의 각 원소에 단순히 $tanh$ 연산을 취해주는 것 뿐이기 때문에 결국은 $c_t$(기억 셀)와 $h_t$(은닉 상태 벡터)의 형상(shape)이 같다는 점이다.

 

다음은 LSTM의 핵심인 게이트의 기능이다. 게이트는 그야말로 닫고 열기가 가능한 '문'의 역할을 한다. 그런데 무엇을 닫고 여느냐? 바로 데이터가 흐르고 있는 곳의 문을 닫고 연다는 의미이다. 아래 비유 그림을 보면 이해가 될 것이다.

 

게이트는 데이터가 흐를 수 있도록 열고 닫는 기능을 한다

 

위 비유 그림에서는 게이트가 열려있거나 또는 닫혀있는 단 2가지의 경우만 존재하는 것 같다. 하지만 LSTM의 게이트는 위 2가지 경우만 있는 것이 아니라 조금만 게이트를 열거나 조금만 게이트를 닫거나 할 수도 있다. 아래처럼 말이다.

 

LSTM 게이트는 조금만 열 수도 있다!

 

위 그림처럼 LSTM의 게이트는 '어느 정도' 열지를 조절할 수가 있다. 이 말은 곧 다음으로 얼마만큼의 데이터를 흐르게 할지를 제어할 수 있다는 것이다. 여기서 '어느 정도 열지'열림 상태(openness) 또는 게이트 값이라고 부른다. 그래서 LSTM은 이 최적의 열림 상태를 결정하기 위해서 추가 파라미터를 두고 데이터를 학습하면서 이 파라미터들을 갱신해 나간다! 이러한 이유 때문에 아래에서 소개할 다양한 게이트에 파라미터가 추가되는 이유가 이 때문임을 기억하자. 

 

참고로 아래에서 다시 언급하겠지만 이렇게 열림 상태를 하나의 정량적인 값으로 변환하기 위해서 게이트의 활성함수로 시그모이드($\sigma$) 함수를 사용한다. 왜냐하면 시그모이드 함수는 출력값을 0.0 ~ 1.0 사이의 값으로 변환시켜주기 때문이다. 다시 말해, 흐르는 데이터를 모두 흘려보낼지(1.0 = 100%), 아니면 모두 차단해버릴지(0.0 = 0%) 그 사이의 실수 값으로 정해주는 것이다.

 

지금까지 해서 LSTM을 본격적으로 파헤쳐보기 위한 사전 지식을 모두 살펴보았다. 이제 LSTM의 게이트를 활용한 구조를 살펴보면서 차근차근 이해해보자. 그리고 동시에 게이트가 어떻게 기울기 소실을 예방할 수 있는지도 같이 머릿속에 두고 이해해 나가보자.

4-2. 기억 셀($c$)에서 얼마만큼 다음 은닉 상태 벡터($h$)로 전달할까?, Output 게이트!

이번 목차에서 알아볼 첫 번째 LSTM의 게이트는 아웃풋 게이트이다. 아웃풋 게이트는 아래 LSTM 구조 그림에서 노란색 네모칸에 해당하며 아웃풋 게이트에 들어가는 입력과 출력의 흐름빨간색 선으로 나타내었다.

LSTM의 Output 게이트

 

아직은 배우지 않았지만 위 그림에서 '어떤 계산'으로 인해 기억 셀 $c_t$가 계산되었다. 그리고 $c_t$는 두 갈래로 분기된다고 [4-1. 목차]에서 언급했었다. 그리고 $c_t$에 $tanh$ 연산을 취해준 값인 $h_t$를 다음 LSTM 계층으로 전달해준다고 했다. 하지만 이제는 그렇게 하지 않고 아웃풋 게이트가 계산한 $o$라는 값과 $tanh(c_t)$ 연산을 취해준 값을 곱해준 값을 최종적으로 $h_t$로 간주하고 다음 LSTM 계층으로 전달한다. 여기서 아웃풋 게이트가 계산한 $o$라는 값이 $tanh(c_t)$의 각 원소가 다음 은닉 상태 벡터에 얼마나 중요한지를 '조정'하는 것이다. 다시 말해, $o$라는 값이 게이트 값(열린 상태 값)을 의미한다.

 

그러면 '아웃풋 게이트가 계산하는 $o$값'이 어떻게 계산되는지 알아보자. 위 그림의 빨간색 선을 보면 알겠지만 아웃풋 게이트를 계산하기 위해서는 이전 LSTM 계층의 은닉 상태 벡터인 $h_{t-1}$와 현재 시점의 입력 벡터인 $x_t$가 관여한다. 아웃풋 게이트를 수식으로 나타내면 아래와 같다.

$$o = \sigma(x_t {W_x}^{(o)} + h_{t-1} {W_h}^{(o)} + b^{(o)})$$

수식에서 $o$는 Output을 의미한다. 각 게이트에 해당하는 파라미터라고 가시적으로 표시하기 위해 ${W_x}^{(o)}$ 이런 식으로 표기했다. 어쨌건 위 수식을 보면 아웃풋 게이트를 적용하면서 ${W_x}^{(o)}, {W_h}^{(o)},  b^{(o)}$ 라는 파라미터 3개가 추가되었다. 즉, 데이터를 학습하면서 이 3개의 파라미터를 갱신하면서 최적의 게이트 값(최적의 열림 상태)을 찾게 된다. 그리고 활성함수를 시그모이드($\sigma$)로 사용하여 최종 계산 값을 0.0 ~ 1.0 사이의 값으로 만들어 준다.

 

비유를 들어 정리해보자면, 만약 아웃풋 게이트 값이 0.5가 나왔다면 과거의 기억 정보를 가지고 있는 $tanh(c_t)$의 절반 정도만 다음 은닉 상태로 보내버려! 라는 의미가 된다.(단, 실질적으로 아웃풋 게이트의 값은 행렬의 형상이다. 지금은 단지 이해를 쉽게 하기 위해 스칼라 값으로 비유를 든 것)

 

아웃풋 게이트는 이게 끝이다. 한 가지 추가적으로 설명할 게 있는데, 아웃풋 게이트에서 계산된 값 $o$와 $tanh(c_t)$를 계산하는 $\times$ 연산 과정이다. 이 때, $\times$ 연산은 행렬 곱(dot) 연산이 아니라, 아다마르 곱($\odot$)을 의미한다. 아다마르 곱이란 동일한 형상의 두 행렬이 있을 때, 각 원소별로 곱한 것을 의미한다. 아래 그림을 보면 명확히 이해가 될 것이다.

 

아다마르 곱 예시

 

아웃풋 게이트의 계산 값인 $o$와 $tanh(c_t)$를 위의 아다마르 곱 연산을 활용해 최종 은닉 상태 벡터 $h_t$를 계산하게 된다. 여기서 또 한 가지 알 수 있는 부분은  $o$와 $tanh(c_t)$ 간의 아다마르 곱 연산을 활용한다는 것은 $o$와 $tanh(c_t)$가 서로 행렬 형상이 같을 것이라는 것도 추측할 수 있다.

$$h_t = o \odot tanh(c_t)$$

 

참고로 여기서 그러면 "게이트 활성화 함수로 시그모이드($\sigma$)가 아닌 $tanh$ 함수를 활용하거나 또는 기억 셀 $c_t$에 $tanh$ 연산이 아닌 시그모이드 함수를 사용하면 안되는가?" 라는 의문을 가질 수도 있다. 이에 대한 답변으로 책에서 소개하는데, $tanh$ 함수는 출력값이 -1.0 ~ 1.0 사이의 값으로 보통 그 수치 값 안에 인코딩된 '정보'의 강약 정도를 표시하는 것으로 해석한다고 한다. 그래서 과거부터 현재까지의 기억을 갖고 있는 기억 셀 $c_t$에게 $tanh$ 함수를 적용하는 것이다.

 

반대로 시그모이드 함수는 [4-1. 목차] 문단 마지막에 언급했던 것처럼 출력값을 0.0 ~ 1.0 사이의 값으로 만들어주며 데이터를 얼마만큼 통과시킬지를 정하는 비율로 자주 사용된다고 한다. 물론 의문을 제기하는 것처럼 시그모이드, $tanh$ 함수가 사용되는 곳을 뒤바꾸어서 모델링을 진행하는 것이 불가능한 것은 아니지만 이상적인 결과는 나오지 않을 것이기 때문에 LSTM을 연구하는 사람들이 기존의 방법대로 수행한 걸 것이다.

4-3. 기억 셀($c$)에서 불필요한 정보는 잊어버리자, Forget 게이트!

"망각은 더 나은 진전을 낳는다." 가끔 모든 것을 기억하려고 노력하다보면 오히려 아무것도 기억이 안나는 사태가 발생하는 것을 느낀다.(마치 면접 때 처럼..) 이러한 상황이 LSTM에서도 발생한다. 그래서 LSTM은 불필요한 정보는 과감히 잊어버리는 Forget(망각) 게이트를 사용한다. 

 

구체적으로 망각 게이트는 과거부터의 $t-1$ 시점까지의 정보를 갖고 있는 기억 셀인 $c_{t-1}$에 적용하는 게이트이다. 즉, 과거부터 지금까지의 기억 중에 불필요한 정보는 잊어버리자는 목적 하에 수행된다고 할 수 있다. 그러면 망각 게이트의 구조를 살펴보자.(망각 게이트는 아래 그림의 초록색 네모칸으로 표시했다)

 

LSTM의 Forget 게이트

 

위 그림을 보다시피 망각 게이트도 이전 시점의 은닉 상태 벡터인 $h_{t-1}$ 과 현재 시점의 입력 벡터인 $x_t$가 관여하는 것을 볼 수 있다. 그러면 망각 게이트의 수식을 살펴보자.

$$f = \sigma(x_t {W_x}^{(f)} + h_{t-1} {W_h}^{(f)} + b^{(f)})$$

수식을 보면 아웃풋 게이트랑 거의 유사하다. 망각 게이트도 최적의 게이트 값 즉, '열림 상태'를 최적으로 결정하기 위해 ${W_x}^{(f)}, {W_h}^{(f)}, b^{(f)}$ 라는 파라미터 3개를 추가로 두어 학습 데이터를 통해 갱신하게 된다. 그리고 망각 게이트값도 0.0 ~ 1.0 사이의 값으로 변환시키기 위해 시그모이드($\sigma$) 활성함수를 적용해준다.

 

이렇게 망각 게이트값인 $f$를 구해주었다. 그러면 이제 $f$를 기억 셀인 $c_{t-1}$에 활용해주어야 한다. 이 때도 아웃풋 게이트와 동일하게 아다마르 곱을 활용해 망각 게이트가 적용된 새로운 기억 셀인 $c_t$를 최종 계산하게 된다.

$$c_t = f \odot c_{t-1}$$

4-4. 이제는 새롭게 기억해야 할 정보를 추가하자, 새로운 기억 셀!

[4-3. 목차]에서 망각 게이트를 통해 과거의 기억들 중 불필요한 기억을 잊어버렸다. 그러면 이제 새롭게 기억해야 할 정보를 추가해주자. 참고로 이 새로운 기억 셀을 다른 책이나 문헌에서는 Main Gate라고도 정의한다. 하지만 이 책에서는 새로운 기억 셀을 하나의 게이트로 간주하지는 않는다. 그러므로 다른 문헌에서 Main Gate라고 언급한다면 이 새로운 기억 셀을 의미한다는 것을 알아두자.

 

LSTM의 새로운 기억 셀(Main 게이트)

 

새로운 기억 셀은 아웃풋, 망각 게이트와는 다르게 활성함수를 $tanh$ 함수를 사용한다. 왜냐하면 아웃풋, 망각 게이트는 '얼마나 잊어 버릴지 결정' 하는 느낌이지만 새로운 기억 셀은 새로운 정보를 흐르고 있는 기억 셀인 $c_t$ 에 추가해야 하는 느낌이기 때문이다. 이 '느낌'의 차이라는 것에 대해서는 위 [4-2. 목차] 마지막 두 문단에서 설명한 시그모이드 함수와 $tanh$  함수를 적용한 수치 값을 해석할 때의 차이점을 의미한다.

 

새로운 기억 셀도 마찬가지로 이전 시점의 은닉 상태 벡터인 $h_{t-1}$과 현재 시점의 입력 벡터인 $x_t$가 관여한다. 새로운 기억 셀인 $g$를 계산하는 수식은 아래와 같이 나타낸다.

$$g = tanh(x_t {W_x}^{(g)} + h_{t-1} {W_h}^{(g)} + b^{(g)})$$

위 수식으로 계산한 새로운 기억 셀인 $g$를 흐르고 있는 기억 셀 $c_t$에다가 이번에는 아다마르 곱이 아닌 단순히 더해줌(덧셈 연산)으로써 $c_t$ 값을 더 다양한 정보를 갖고 있는 기억 셀로 업데이트 시켜주는 셈이 된다.

4-5. 새로운 기억 셀 안에서도 적절히 취사선택하자, Input 게이트!

방금 [4-4. 목차]에서 새로운 정보를 추가하는 새로운 기억 셀에 대해 배웠다. 마지막으로 배울 인풋 게이트는 새로운 기억 셀에 대해 관여하는 게이트이다. 즉, 새로운 기억 셀 덕분에 추가되는 새로운 정보 중에서 '정말 필요한 정보'만을 가져가기 위한 게이트이다. 인풋 게이트의 구조를 살펴보자.

 

LSTM의 Input 게이트

 

그림을 보면 인풋 게이트값인 $i$를 회색 네모칸인 새로운 기억 셀의 결과값인 $g$에 곱해지는 것을 볼 수 있다. 그리고 인풋 게이트도 역시 이전 시점의 은닉 상태 벡터인 $h_{t-1}$과 현재 시점의 입력 벡터인 $x_t$가 관여한다. 이제 인풋 게이트의 수식을 살펴보자.

$$i = \sigma(x_t {W_x}^{(i)} + h_{t-1} {W_h}^{(i)} + b^{(i)})$$

인풋 게이트도 최적의 게이트 값 즉, '열림 상태'를 최적으로 결정하기 위해 ${W_x}^{(i)}, {W_h}^{(i)}, b^{(i)}$ 라는 파라미터 3개를 추가로 두어 학습 데이터를 통해 갱신하게 된다.  또한 아웃풋, 망각 게이트처럼 지나가고 있는 데이터의 흐름을 얼마나 열고 차단하기 위한 목적 하에 수행되므로 시그모이드($\sigma$) 활성함수를 사용한다. 인풋 게이트의 관점에서 '지나가고 있는 데이터의 흐름'이 바로 새로운 기억 셀의 결과값인 $g$가 된다. 즉, $g$가 갖고 있는 정보 중에서 얼마만큼의 정보를 다음으로 흘려 기억 셀인 $c_t$에 더해줄 것인지를 결정하는 셈이다!

 

그래서 인풋 게이트값인 $i$를 새로운 기억 셀의 결과값인 $g$와 아다마르 곱을 수행해준다. 수식의 틀은 아웃풋, 망각 게이트와 동일하다.

$$\hat{g} = i \odot {g}$$

위 수식의 $\hat{g}$는 $g$에 인풋 게이트 값 $i$를 적용한 새로운 $g$ 값을 의미한다. 이렇게 새로 업데이트된 $\hat{g}$를 흐르고 있는 기억 셀 $c_t$와 덧셈 연산을 하여 $c_t$를 업데이트 시켜준다.

4-6. 그래서 어떻게 LSTM이 기울기 소실을 막을 수 있는데?

지금까지 기울기 소실 문제를 예방할 수 있는 LSTM의 3개의 게이트(아웃풋, 망각, 인풋)와 새로운 기억 셀이라는 구조에 대해 살펴보았다. 이제 우리가 LSTM 구조를 살펴본 이유에 대해 다시 상기시켜보자. LSTM의 구조는 기울기 소실을 예방한다고 했다. 그렇다면 대체 이 구조들이 어떻게 기울기 소실 문제를 예방할까? 이를 이해하기 위해서 LSTM의 역전파 시, 기억 셀인 $c_t$ 의 기울기 흐름을 살펴보아야 한다.

 

기억 셀 $c$의 역전파 시, 기울기 흐름을 빨간색 선으로 표시했다

 

위 그림의 빨간색 선을 잘 살펴보면 오직 2개의 연산의 역전파만 수행하는 것을 볼 수 있다. 첫 번째는 빨간색 동그라미로 표시된 덧셈 연산이다. 덧셈 연산은 우리가 배웠던 것처럼 이전으로부터 흘러들어오는 국소적인 미분값을 건드리지 않고 그냥 그대로 흘려보낸다. 따라서 기울기의 변화가 일어나지 않기 때문에 기울기 소실이 발생할 수가 없다.

 

두 번째는 파란색 동그라미로 표시된 곱셈 연산이다. 구체적으로 말하면 원소별 곱인 아다마르 곱 연산이지만 아다마르 곱도 곱셈 연산과 동일하다. 어쨌건 행렬 곱(dot) 연산이 아니라는 점이다! 우리는 위 [2-2. 목차]에서 기본 RNN 모델의 기울기 소실 문제가 발생하는 원인 중 하나가 행렬 곱 연산의 역전파를 반복적으로 수행하기 때문이라는 것이라는 걸 배웠다. 이런 점으로 보아, LSTM은 어쨌거나 행렬 곱 연산 역전파를 수행하지 않기 때문에 적어도 기본 RNN과 동일한 원인으로 기울기 소실 문제가 발생하지는 않을 것이다.

 

그리고 한 가지 주목할 점은 역전파 계산에 관여하는 게이트 값들(+새로운 기억 셀), 그림 상에서는 $f$, $g$, $i$, $o$ 값들이 매 시각마다 달라지기 때문곱셈의 역전파를 수행할 때도 매번 다른 값을 가지고 곱셈 역전파를 수행하게 된다. 왜 게이트 값들이 매번 달라질까? 우리는 모든 게이트 값들이 학습 데이터를 통해 갱신되는 ${W_x}^{(gate)}, {W_h}^{(gate)}, b^{(gate)}$ 파라미터들을 활용해 최적의 게이트 값을 찾아간다고 했다. 그렇다면 최적의 게이트 값을 찾아가는 동안 게이트 값들은 매번 달라질 것이기 때문이다!

 

따라서 정리해보자면, LSTM의 구조는 역전파 과정에서 2가지 점 때문에 기울기 소실 문제를 예방할 수 있다. 첫 번째는 역전파 과정 시, 기울기의 흐름이 덧셈 노드를 통과한다는 것이다. 덧셈 노드는 기울기를 건드리지 않기 때문에 기울기 소실이 발생할 여지가 없다.

두 번째는 행렬 곱 연산이 아닌 곱셈 연산의 역전파를 사용하는 것 뿐만 아니라 역전파를 수행할 때마다 역전파 계산에 활용되는 각 게이트 값들($f, g, i, o$)이 매번 달라지기 때문이다. 그렇기 때문에 기울기 소실이 발생할 가능성이 낮아진다.

 

지금까지 LSTM의 구조를 하나씩 뜯어보면서 이해하고 왜 이러한 구조가 기울기 소실 문제를 예방하는지도 알아보았다. 마지막으로 LSTM의 각 구조를 수식으로 정리한 그림을 보고 마무리하자.

 

LSTM 구조의 최종 정리

4-7. LSTM을 넘파이로 구현하기

LSTM 구조를 알아봤으니 이제 넘파이로 LSTM을 구현해보자. 그런데 LSTM은 현재 하나의 게이트마다 3개의 파라미터를 추가적으로 가지고 있다. 3개씩 총 4개의 게이트니까 12개의 파라미터를 갖는다. 이것을 따로따로 계산할 수도 있지만 한 번에 결합해서 계산하기 위해 우리는 이 파라미터들을 Affine 변환을 해준다. Affine 변환이란, 행렬 변환과 평행 이동을 결합한 형태 즉, $xW_x + hW_h + b$ 형태로 바꾸어 주는 것을 의미한다. 그림으로 표시하면 다음과 같다.

 

LSTM 파라미터들을 Affine 변환해 하나로 묶기!

 

위 그림을 보면 마치 동일한 곱셈 계수를 앞으로 빼서 묶어주는 것처럼 행렬도 똑같이 수행할 수 있다. 위와 같은 방식으로 구현하게 되면 단 1번의 대량 행렬 계산으로 12개의 파라미터들을 한꺼번에 계산해줄 수 있다. 특히, 일반적인 행렬 라이브러리는 큰 행렬을 한꺼번에 계산하는 것이 각각을 따로 계산하는 것보다 훨씬 빠르기 때문에 이러한 Affine 변환을 사용하는 이유이다.

 

위와 같은 방식으로 구현하게 된다면 Affine 변환을 수행해준 후의 최종 행렬 형상은 다음과 같을 것이다.

 

Affine 변환 후의 최종 행렬 형상

 

위 그림에서 $N$은 데이터의 배치 사이즈이다. 모든 파라미터를 결합해 Affine 변환을 수행해주고 난 결과인 $A$ 행렬의 형상은 $(N, 4H)$가 된다.$A$ 행렬에서 $H$만큼 길이의 열 벡터가 바로 각 게이트의 값들을 의미한다. 위 방식을 사용해서 넘파이 소스코드로 LSTM 계층의 순전파까지 구현하면 아래와 같다.

 

class LSTM:
    """ 단일 LSTM 계층 클래스 구현
    
    Args:
        Wx: 4개의 게이트에서 각각 입력 벡터 Xt 에 곱해지는 파라미터 Wx 4개
        Wh: 4개의 게이트에서 각각 이전 은닉 상태 벡터 h_t-1 에 곱해지는 파라미터 Wh 4개
        b: 4개의 게이트에서 각각 더해지는 편향 b 파라미터 4개
    
    """
    def __init__(self, Wx, Wh, b):
        self.params = [Wx, Wh, b]
        self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]
        self.cache = None
        
        
    def forward(self, x, h_prev, c_prev):
        Wx, Wh, b = self.params
        N, H = h_prev.shape  # 은닉 상태 벡터 차원 수 (batch_size, 노드 수)
        
        # 총 4개의 게이트에서의 아핀 변환을 한 번에 계산
        A = np.matmul(x, Wx) + np.matmul(h_prev, Wh) + b
        
        # slicing 해서 각 게이트에 보내기
        f = A[:, :H]
        g = A[:, H:2*H]
        i = A[:, 2*H:3*H]
        o = A[:, 3*H:]
        
        f = sigmoid(f)
        g = np.tanh(g)
        i = sigmoid(i)
        o = sigmoid(o)
        
        c_next = f * c_prev + g * i
        h_next = np.tanh(c_next) * o
        
        self.cache = (x, h_prev, c_prev, f, g, i, o, c_next) # 역전파 시 사용할 데이터들 캐싱해두기
        return h_next, c_next

 

이제 대망의 LSTM의 역전파 과정을 구현해야 할 차례이다. 참고로 LSTM 모델도 역전파를 하는 방법은 기본 RNN처럼 Truncated BPTT 방식을 사용해 순환하는 LSTM 계층들을 역전파시킨다. Truncated BPTT는 순환하는 LSTM 계층들을 길~게 펼쳤다고 가정했을 때, 긴 신경망을 작은 신경망 단위로 잘른 후 역전파를 수행하는 개념이었다.(단, 순전파는 끊지 않고!) 자세한 개념은 이전 포스팅을 참조하자.(그래서 $T$ 길이의 시계열을 한 번에 처리하는 LSTM 계층을 구현한 TimeLSTM 클래스라는 소스코드를 보면 기본 RNN처럼 Truncated BPTT를 사용하는 것을 볼 수 있다)

 

하단에서 소개하는 LSTM의 역전파 과정은 단일 LSTM 계층 내에서 일어나는 역전파 계산이 어떻게 이루어지는지를 설명하는 것이다. 우선 Affine 변환을 활용했을 때의 순전파 과정을 그림으로 구조화해보면 아래와 같다. 우리는 아래의 그림에서 역전파를 직접 계산해 볼 것이다.

 

Affine 변환을 활용했을 때의 LSTM 순전파 과정

 

위 그림에서 Affine 변환을 활용했다는 것을 slice 라고 쓰여져 있는 부분에서 알아볼 수 있다. 즉, 위에서 Affine 변환을 수행한 후의 행렬인 $A$ 행렬에서 열 벡터를 $H$ 길이만큼 슬라이싱해서 각 게이트에 보낸다고 해서 'slice' 라고 이름을 붙였다. 마치 넘파이에서 슬라이싱으로 인덱싱 하는 것을 생각하면 된다.

 

그러면 역전파를 이제 직접 계산해보자. 2가지 파트로 순차적으로 계산할텐데, 첫 번째는 slice 연산 노드 이전인 4개의 게이트에 대한 역전파 과정이다. 아래 그림에서 표기를 간단하게 했다. 예를 들어, $dh_t$는 $h_t$에 대한 기울기 값을 의미한다.

 

slice 노드 이전까지의 역전파 과정

 

위 계산과정은 책에 나오지는 않고 필자가 그동안의 배운 역전파 계산 방식을 활용해서 직접 작성해보았다. 계산결과는 저자의 소스코드를 확인해보니 일치하는 것으로 보아 위 그림의 역전파 계산 과정은 올바른 과정이라는 것도 알린다. 

 

다음은 slice 연산에 대한 역전파 과정이다. 이는 책에서 잘 설명해준 그림이 있다. 한번 살펴보자.

 

slice 노드의 순전파, 역전파 과정

 

단순히 슬라이싱 해준 것을 반대로 결합해주면 된다. 이 때 결합해주기 위해서 넘파이의 np.hstack() 함수를 사용한다. hstack 함수는 Horizontal(가로) 방향으로 벡터를 결합해준다. 

 

이제 마지막으로는 Affine 변환을 할 때 사용한 행렬 곱(dot) 연산에 대한 역전파만 수행해주면 된다.(아래 그림의 노란색 네모칸) 행렬 곱 연산 역전파는 기본 RNN에서 구현했던 방식으로 똑같이 구현해주면 된다. 해당 포스팅의 [4-2. 목차]를 참고하자.

 

이제 Affine 변환 역전파만 수행해주면 LSTM 역전파는 끝!

 

그래서 위 과정을 모두 적용한 넘파이로 단일 LSTM 계층의 순전파, 역전파 과정을 구현한 소스코드는 다음과 같다.

 

# 총 4개의 게이트의 각 Wx, Wh, b 파라미터를 결합해 Affine 변환으로 한 번에 계산!
class LSTM:
    """ 단일 LSTM 계층 클래스 구현
    
    Args:
        Wx: 4개의 게이트에서 각각 입력 벡터 Xt 에 곱해지는 파라미터 Wx 4개
        Wh: 4개의 게이트에서 각각 이전 은닉 상태 벡터 h_t-1 에 곱해지는 파라미터 Wh 4개
        b: 4개의 게이트에서 각각 더해지는 편향 b 파라미터 4개
    
    """
    def __init__(self, Wx, Wh, b):
        self.params = [Wx, Wh, b]
        self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]
        self.cache = None
        
        
    def forward(self, x, h_prev, c_prev):
        Wx, Wh, b = self.params
        N, H = h_prev.shape  # 은닉 상태 벡터 차원 수 (batch_size, 노드 수)
        
        # 총 4개의 게이트에서의 아핀 변환을 한 번에 계산
        A = np.matmul(x, Wx) + np.matmul(h_prev, Wh) + b
        
        # slicing 해서 각 게이트에 보내기
        f = A[:, :H]
        g = A[:, H:2*H]
        i = A[:, 2*H:3*H]
        o = A[:, 3*H:]
        
        f = sigmoid(f)
        g = np.tanh(g)
        i = sigmoid(i)
        o = sigmoid(o)
        
        c_next = f * c_prev + g * i
        h_next = np.tanh(c_next) * o
        
        self.cache = (x, h_prev, c_prev, f, g, i, o, c_next) # 역전파 시 사용할 데이터들 캐싱해두기
        return h_next, c_next
    
    
    def backward(self, dh_next, dc_next):
        Wx, Wh, b = self.params
        x, h_prev, c_prev, f, g, i, o, c_next = self.cache
        #===============
        # 게이트 역전파 수행
        #===============
        tanh_c_next = np.tanh(c_next)
        
        ds = dh_next * o * (1 - tanh_c_next**2) + dc_next
        
        dc_prev = ds * f  # 이전 기억 셀의 기울기
        
        # output 게이트
        do = dh_next * tanh_c_next
        do *= o * (1 - o)
        # input 게이트
        di = ds * g
        di *= i * (1 - i)
        # 새로운 기억 셀(main 게이트)
        dg = ds * i
        dg *= (1 - g**2)
        # forget 게이트
        df = ds * c_prev
        df *= f * (1 - f)
        
        # 4개 게이트 기울기 가로로 결합, horizontal stack
        dA = np.hstack((df, dg, di, do))
        
        #=================================
        # Affine 변환(행렬 곱)에 대한 역전파 수행
        #=================================
        # 파라미터 기울기 계산
        dWx = np.matmul(x.T, dA)
        dWh = np.matmul(h_prev.T, dA)
        db = dA.sum(axis=0)
        
        self.grads[0][...] = dWx
        self.grads[1][...] = dWh
        self.grads[2][...] = db
        
        # 입력, 은닉상태 벡터 기울기 계싼
        dx = np.matmul(dA, Wx.T)
        dh_prev = np.matmul(dA, Wh.T)
        
        return dx, dh_prev, dc_prev

 

위 소스코드를 기반으로 LSTM 계층도 기본 RNN처럼 $T$개의 시계열 데이터를 한꺼번에 처리하는 LSTM 계층을 만들어볼 수 있다. 한 번에 처리하는 소스코드는 여기의 TimeLSTM 클래스에서 살펴볼 수 있다. 그리고 이렇게 만든 LSTM 계층으로 언어 모델을 생성하는 코드학습하는 코드도 살펴볼 수 있으니 순수한 넘파이로 구현하는 코드에 관심이 있다면 참고해보도록 하자.


지금까지 기본 RNN에서 발생하는 문제점인 기울기 폭발과 기울기 소실의 개념이 무엇이고 또 원인이 무엇인지도 알아보았다. 그리고 이를 극복하는 방안으로 기울기 클리핑과 게이트가 추가된 RNN 구조인 LSTM에 대해 알아보았다.

 

다음 포스팅에서는 게이트를 추가한 또 다른 RNN 모델인 GRU(Gated Recurrent Unit)에 대해 알아본다. 그리고 기본 RNN, LSTM, GRU와 같은 신경망 기반으로 만든 언어 모델에서 성능을 개선하기 위한 방법 몇 가지에 대해서도 알아보도록 하자.

 

참고로 책에서는 다음에 게시될 포스팅의 내용까지 포괄하여 담고 있으나, 제 블로그 포스팅에서는 모든 걸 다 담아내면 너무 길어질 것 같이에 두 편으로 나누어 게시하는 점 참고해 주세요!

반응형