본문 바로가기

Deep Learning(강의 및 책)/밑바닥부터 시작하는 딥러닝 2

6. 게이트가 추가된 RNN

728x90

밑바닥부터 시작하는 딥러닝 2를 바탕으로 제작하였습니다.

 

6.1. RNN의 문제점

전에 본 RNN은 순환 경로를 포함하며 과거의 정보를 기억할 수 있었습니다. 하지만 시계열 데이터에서 시간적으로 멀리 떨어진, 장기 의존 관계를 잘 학습할 수 없습니다. 그래서 요즘에는 단순한 RNN 대신 LSTM이나 GRU라는 계층이 주로 쓰입니다. LSTM이나 GRU에는 '게이트'라는 구조가 더해져 있는데, 이 게이트 덕분에 시계열 데이터의 장기 의존 관계를 학습할 수 있습니다.

 

 ■ RNN 복습

 

RNN 계층은 순환 경로를 갖고 있습니다. 위 그림에서 보듯 RNN 계층은 시계열 데이터인 xt를 입력하면 ht를 출력합니다. 이 ht는 RNN 계층의 은닉 상태라고 하여, 과거 정보를 저장합니다. 이러한 은닉 상태를 이용해 과거 정보를 계승할 수 있게 됩니다.

 

 ■ 기울기 소실 또는 기울기 폭발

언어 모델은 주어진 단어들을 기초로 다음에 출현할 단어를 예측하는 일을 합니다.

"Tom was watching TV in his room. Mary came in to the room. Mary said hi to ?"

라는 문장이 있습니다. ?안에 들어갈 단어는 "Tom"입니다. 이 문제를 RNNLM이 올바르게 답하기 위해서는 현재 맥락에서 "Tom이 방에서 TV를 보고 있음"과 "그 방에 Mary가 들어옴"이란 정보를 기억해둬야 합니다. 즉, 이런 정보를 RNN 계층의 은닉 상태에 인코딩해 보관해둬야 합니다.

이 예를 RNNLM 학습의 관점에서 생각해보겠습니다.

 

여기에서는 정답 레이블로 "Tom"이라는 단어가 주어졌을 때, RNNLM에서 기울기가 어떻게 전파되는지를 살펴본 그림입니다. RNN 계층이 과거 방향으로 '의미 있는 기울기'를 전달함으로써 시간 방향의 의존 관계를 학습합니다. 이때 기울기는 학습해야 할 의미가 있는 정보가 들어 있고, 그것을 과거로 전달함으로써 장기 의존 관계를 학습합니다. 하지만 만약 이 기울기가 중간에 사그라들면(거의 아무런 정보도 남지 않게 되면) 가중치 매개변수는 전혀 갱신되지 않게 됩니다. 즉, 장기 의존 관계를 학습할 수 없게 됩니다.

 

 ■ 기울기 소실과 기울기 폭발의 원인

 

길이가 T인 시계열 데이터를 가정하여 T번째 정답 레이블로부터 전해지는 기울기가 어떻게 변하는지 살펴보겠습니다. 앞의 문제에 대입하면 T번째 정답 레이블이 "Tom"인 경우에 해당합니다. 이때 시간 방향 기울기에 주목하면 역전파로 전해지는 기울기는 차례로 'tanh', '+', 'MatMul' 연산을 통과합니다. '+'의 역전파는 상류에서 전해지는 기울기를 그대로 하류로 흘려보내기 때문에 기울기에 영향을 주지 않습니다.

'tanh'의 경우를 살펴보겠습니다. 먼저 tanh와 tanh의 미분된 경우를 그래프로 그려보겠습니다.

 

이와 같이 그릴 수 있습니다. 점선이 y = tanh(x)의 미분입니다. 값은 1.0 이하이고, x가 0으로부터 멀어질수록 작아집니다. 역전파에서는 기울기가 tanh 노드를 지날 때마다 값은 계속 작아진다는 뜻입니다. 그래서 tanh함수를 T번 통과하면 기울기도 T번 반복해서 줄어듭니다.

이번에는 MatMul 노드를 살펴보겠습니다. 일단 tanh 노드를 무시하고 MatMul연산만 살펴보겠습니다.

 

일단 상류로부터 dh라는 기울기가 흘러온다고 가정하겠습니다. 이때 MatMul 노드에서의 역전파는 dh(W_h^T)라는 행렬 곱(Wh의 역행렬과 dh의 행렬 곱)으로 기울기를 계산합니다. 그리고 같은 계산을 시계열 데이터의 시간 크기만큼 반복합니다. 여기서 주목할 점은 행렬 곱셈에서는 매번 같은 가중치인 W_h가 사용된다는 것입니다. 그럼 역전파 시 기울기는 MatMul 노드를 지날 때마다 어떻게 변하는지 살펴보겠습니다.

 

# coding: utf-8
import numpy as np
import matplotlib.pyplot as plt


N = 2   # 미니배치 크기
H = 3   # 은닉 상태 벡터의 차원 수
T = 20  # 시계열 데이터의 길이

dh = np.ones((N, H))

np.random.seed(3) # 재현할 수 있도록 난수의 시드 고정

Wh = np.random.randn(H, H)
#Wh = np.random.randn(H, H) * 0.5

norm_list = []
for t in range(T):
    dh = np.dot(dh, Wh.T)
    norm = np.sqrt(np.sum(dh**2)) / N
    norm_list.append(norm)

print(norm_list)

# 그래프 그리기
plt.plot(np.arange(len(norm_list)), norm_list)
plt.xticks([0, 4, 9, 14, 19], [1, 5, 10, 15, 20])
plt.xlabel('시간 크기(time step)')
plt.ylabel('노름(norm)')
plt.show()

 

실행 결과

이와 같은 그래프를 얻을 수 있습니다. 일단 코드 먼저 살펴보겠습니다. dh를 np.ones()로 초기화합니다. 그리고 역전파의 MatMul 노드 수(T)만큼 dh를 갱신하고, 각 단계에서 dh 크기를 norm_list에 추가합니다. 이 norm_list를 그래프로 그리면 옆에 나온 모습을 보입니다.

그래프에서 보듯 기울기의 크기는 시간에 비례해 지수적으로 증가합니다. 이것이 바로 기울기 폭발(exploding gradients)입니다. 이러한 기울기 폭발이 일어나면 결국 오버플로우를 일으켜 NaN(Not a Number)같은 값을 발생시킵니다.

 

 

 

 

 

그럼 이번에는 Wh의 값을 np.random.randn(H, H) * 0.5 로 초깃값을 설정해 실행시켜보겠습니다.

 

이번에는 기울기가 지수적으로 감소합니다. 이것이 기울기 소실(vanishing gradients)입니다. 기울기 소실이 일어나면 기울기가 매우 빠르게 작아집니다. 그리고 기울기가 일정 수준 이하로 작아지면 가중치 매개변수가 더 이상 갱신되지 않으므로, 장기 의존 관계를 학습할 수 없게 됩니다. Wh를 T번 반복해서 곱했기 때문에 기울기의 크기가 지수적으로 감소하거나 증가했습니다. Wh기 행렬인 경우 행렬의 '특잇값'(데이터가 얼마나 퍼져 있는지)이 척도가 됩니다. 이 특잇값(정확히는 여러 특잇값 중 최댓값)이 1보다 큰지 여부를 보면 기울기 크기가 어떻게 변할지 예측할 수 있습니다.

 

 

 

 

 

 

 ■ 기울기 폭발 대책

기울기 폭발의 대책으로 기울기 클리핑(gradients clipping)이라는 기법이 있습니다. 신경망에서 사용되는 모든 매개변수에 대한 기울기를 하나로 처리한다고 가정하고 코드로 살펴보겠습니다.

 

import numpy as np


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


def clip_grads(grads, max_norm):
    total_norm = 0
    for grad in grads:
        total_norm += np.sum(grad ** 2)
    total_norm = np.sqrt(total_norm)

    rate = max_norm / (total_norm + 1e-6)
    if rate < 1:
        for grad in grads:
            grad *= rate

 

이와 같이 작성할 수 있습니다. max_norm은 문턱 값이 됩니다. 모든 기울기의 L2 norm 계산을 하고 그 값이 max_norm보다 크다면 기울기에 max_norm / (L2 norm 계산 결과) 값을 곱해줍니다. 만약 max_norm보다 작다면 그대로 값을 유지합니다. 이와 같이 기울기의 값이 일정 크기보다 크다면 줄여주는 것이 기울기 클리핑입니다.

 

6.2. 기울기 소실과 LSTM

RNN 학습에서도 기울기 소실이 큰 문제인데 이를 해결하기 위해 '게이트'가 등장합니다. 게이트가 추가된 RNN은 대표적으로 LSTM과 GRU가 있습니다. 이번에는 LSTM에 대해 좀 더 집중적으로 살펴보겠습니다.

 

 ■ LSTM의 인터페이스

 

먼저 계산 그래프를 단순화하는 도법을 하나 도입하겠습니다. 위와 같이 행렬 계산 등을 하나의 직사각형 노드로 정리해 그리는 방식입니다. tanh(h_t-1W_h + w_tW_x + b) 계산을 tanh라는 직사각형 노드 하나로 그렸습니다. 이 직사각형 노드 안에 행렬 곱과 편향의 합, 그리고 tanh 함수에 의한 변환이 모두 포함된 것입니다.

 

LSTM의 인터페이스(입출력)를 RNN과 비교한 그림입니다. 그림에서 보듯 LSTM 계층의 인터페이스에는 c라는 경로가 있다는 차이가 있습니다. c를 기억 셀(혹은 단순히 '셀')이라 하며, LSTM 전용의 기억 메커니즘입니다. 기억 셀의 특징은 데이터를 자기 자신으로만(LSTM 계층 내에서만) 주고받는다는 것입니다. 즉, LSTM 계층 내에서만 완결되고, 다른 계층으로는 출력하지 않습니다. 반면, LSTM의 은닉 상태 h는 RNN 계층과 마찬가지로 다른 계층으로 출력됩니다.

 

 ■ LSTM 계층 조립하기

 

앞서 이야기한 것처럼, LSTM에는 기억 셀 c_t가 있습니다. 이 c_t에는 시각 t에서의 LSTM의 기억이 저장돼 있는데, 과거로부터 시각 t까지에 필요한 모든 정보가 저장돼 있다고 가정합니다. 그리고 필요한 정보를 모두 간직한 이 기억을 바탕으로, 외부 계층에 은닉 상태 h_t를 출력합니다. 이때 출력하는 h_t는 기억 셀의 값을 tanh 함수로 변환한 값입니다.

현재의 기억 셀 c_t는 3개의 입력(c_t-1, h_t-1, x_t)으로부터 '어떤 계산'을 수행하여 구할 수 있습니다. 여기서 핵심은 갱신된 c_t를 사용해 은닉 상태 h_t를 계산한다는 것입니다.

이쯤에서 '게이트'라는 기능에 대해 알아보겠습니다. 게이트란 우리말로는 '문'을 의미하는 단어입니다. 문은 열거나 닫을 수 있듯이, 게이트는 데이터의 흐름을 제어합니다.

 

마치 위 그림과 같이 물의 흐름을 멈추거나 배출하는 것이 게이트의 역할입니다. LSTM에서 사용하는 게이트는 '열기/닫기'뿐 아니라, 어느 정도 열지를 조절할 수 있습니다. 다시 말해 다음 단계로 흘려보낼 물의 양을 제어합니다. '어느 정도'를 '열림 상태'라 부르며,

 

이와 같이 0.7(70%)이나 0.2(20%)처럼 제어할 수 있습니다. 열림 상태는 0.0~1.0 사이의 실수로 나타냅니다(1.0은 완전 개방). 여기서 중요한 것은 '게이트를 얼마나 열까'라는 것도 데이터로부터 (자동으로) 학습한다는 점입니다.

 

 ■ output 게이트

전에는 기억 셀 c_t에 단순히 tanh함수를 적용했을 뿐이라고 설명했습니다. 이번 절에서는 tanh(c_t)에 게이트를 적용하는 걸 생각해보겠습니다. 즉, tanh(c_t)의 각 원소에 대해 '그것이 다음 시각의 은닉 상태에 얼마나 중요한가'를 조정합니다. 한편, 이 게이트는 다음 은닉 상태 h_t의 출력을 담당하는 게이트이므로 output 게이트(출력 게이트)라고 합니다.

output 게이트의 열림 상태는 입력 x_t와 이전 상태 h_t-1로부터 구합니다. 이때의 계산은

 

위 식과 같습니다. 여기서 사용하는 가중치 매개변수와 편향에는 output의 첫 글자인 o를 첨자로 추가했습니다. 입력 x_t에는 가중치 W_x가, 이전 시각의 은닉 상태 h_t-1에는 가중치 W_h가 붙어있습니다. 그리고 이 행렬들의 곱과 편향 b를 모두 더한 다음 시그모이드 함수를 거쳐 출력 게이트의 출력 o를 구합니다. 마지막으로 이 o와 tanh(c_t)의 원소별 곱을 h_t로 출력합니다.

 

위 그림으로 표현할 수 있습니다.

 

 ■ forget 게이트

c_t-1의 기억 중에서 불필요한 기억을 잊게 해주는 게이트를 forget 게이트(망각 게이트)라 합니다. forget 게이트를 LSTM 계층에 추가하면

 

이와 같은 계산 그래프로 그릴 수 있습니다. forget 게이트가 수행하는 일련의 계산을 시그마 노드로 표기했고 이 안에 forget 게이트 전용의 가중치 매개변수가 있습니다. forget 게이트가 수행하는 식은

 

이와 같습니다. 위 식을 실행하면 forget 게이트의 출력 f가 구해집니다. 그리고 이 f와 이전 기억 셀인 c_t-1과의 원소별 곱을 통해 c_t를 구합니다.

 

 ■ 새로운 기억 셀

forget 게이트를 거치면서 이전 시각의 기억 셀로부터 잊어야 할 기억이 삭제되었습니다. 이번에는 새로 기억해야 할 정보를 기억 셀에 추가하는 기능을 추가하겠습니다.

 

위 그림에서 보듯 tanh 노드가 계산한 결과가 이전 시각의 기억 셀 c_t-1에 더해집니다. 기억 셀에 새로운 '정보'가 추가된 것입니다. 이 tanh 노드는 '게이트'가 아니며, 새로운 '정보'를 기억 셀에 추가하는 것이 목적입니다. 따라서 활성화 함수로는 시그모이드 함수가 아닌 tanh 함수가 사용됩니다. 이 tanh 노드에서 수행하는 식은

 

이와 같습니다. 여기에서 기억 셀에 추가하는 새로운 기억을 g로 표현했습니다. 이 g가 이전 시각의 기억 셀인 c_t-1에 더해짐으로써 새로운 기억이 생성됩니다.

 

 ■ input 게이트

 

마지막으로 그림과 같이 g에 게이트를 하나 추가할 것입니다. 여기에서 새롭게 추가하는 게이트를 input 게이트(입력 게이트)라고 합니다. input 게이트는 g의 각 원소가 새로 추가되는 정보로써의 가치가 얼마나 큰지를 판단합니다. 새 정보를 무비판적으로 수용하는 게 아니라, 적절히 취사선택하는 것이 이 게이트의 역할입니다. 다른 관점에서 보면, input 게이트에 의해 가중된 정보가 새로 추가되는 셈입니다. 그림에서 input 게이트를 시그마로, 그 출력을 i로 표기했습니다. input 게이트가 수행하는 계산은

 

이와 같습니다. i와 g의 원소별 곱 결과를 기억 셀에 추가합니다. 이상이 LSTM 안에서 이루어지는 처리입니다.

 

 ■ LSTM의 기울기 흐름

LSTM 구조가 기울기 소실을 막아주는데 기 원리는 기억 셀 c의 역전파에 주목하면 알 수 있습니다.

 

위 그림은 기억 셀에만 집중하여, 그 역전파의 흐름을 그린 것입니다. 이때 기억 셀의 역전파에서는 '+'와 'x' 노드만을 지나게 됩니다. '+' 노드는 상류에서 전해지는 기울기를 그대로 흘릴 뿐입니다. 따라서 기울기 변화는 일어나지 않습니다. 남은 것은 'x'노드인데, 이 노드는 '행렬 곱'이 아닌 '원소별 곱(아마다르 곱)'을 계산합니다. 참고로, RNN에서는 똑같은 가중치 행렬을 사용하여 행렬 곱을 반복했고 그래서 기울기 소실이 일어났습니다. 하지만 이번 LSTM의 역전파에서는 행렬 곱이 아닌 원소별 곱이 이뤄지고, 매 시각 다른 게이트 값을 이용해 원소별 곱을 계산합니다. 이처럼 매번 새로운 게이트 값을 이용하므로 곱셈의 효과가 누적되지 않아 기울기 소실이 일어나지 않는 것입니다.

그리고 'x' 노드의 계산은 forget 게이트가 제어합니다. forget 게이트가 잊어야 한다고 판단한 기억 셀의 원소에 대해서는 그 기울기가 작아지는 것입니다. 반대로 forget 게이트가 잊어서는 안 된다고 판단한 원소에 대해서는 그 기울기가 약화되지 않은 채로 과거 방향으로 전해집니다. 따라서 기억 셀의 기울기가 소실 없이 전파됩니다. 이를 통해 장기 의존 관계를 유지할 수 있습니다.

 

6.3. LSTM 구현

먼저 한 단계만 처리하는 LSTM 클래스를 구현한 다음, 이어서 T개의 단계를 한꺼번에 처리하는 Time LSTM 클래스를 구현하겠습니다. 

 

일단 위 식들이 LSTM에서 수행하는 계산입니다. 여기서 주목할 부분은 상위 네 식에 포함된 아핀 변환입니다(여기에서의 '아핀 변환'이란 행렬 변환과 평행 이동(편향)을 결합한 형태, 즉 xW_x + hW_h + b 형태의 식을 가리킵니다). 네 수식에서는 아핀 변환을 개별적으로 수행하지만, 이를 하나의 식으로 정리해 계산할 수 있습니다.

 

위 그림에서 보듯 4개의 가중치를 하나로 모을 수 있고, 그렇게 하면 원래 개별적으로 총 4번을 수행하던 아핀 변환을 단 1회의 계산으로 끝마칠 수 있는데 이렇게 되면 계산 속도가 빨라집니다.

 

4개분의 가중치를 모아 한번에 계산하는 계산 그래프를 그리면 위와 같이 그릴 수 있습니다. 처음 4개분의 아핀 변환을 한꺼번에 수행하는 것을 볼 수 있습니다. 그리고 slice 노드를 통해 그 4개의 결과를 꺼냅니다. slice는 아핀 변환의 결과(행렬)를 균등하게 네 조각으로 나눠서 꺼내 주는 단순한 노드입니다. slice 노드 다음에는 활성화 함수(시그모이드 함수 또는 tanh 함수)를 거쳐 앞 절에서 설명한 계산을 수행합니다.

위를 토대로 LSTM 클래스를 구현하겠습니다. 먼저, LSTM 클래스의 초기화 코드를 살펴보겠습니다.

 

class LSTM:
    def __init__(self, Wx, Wh, b):
        '''

        Parameters
        ----------
        Wx: 입력 x에 대한 가중치 매개변수(4개분의 가중치가 담겨 있음)
        Wh: 은닉 상태 h에 대한 가장추 매개변수(4개분의 가중치가 담겨 있음)
        b: 편향(4개분의 편향이 담겨 있음)
        '''
        self.params = [Wx, Wh, b]
        self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]
        self.cache = None

 

초기화 인수는 가중치 매개변수인 Wx와 Wh, 그리고 편향을 뜻하는 b가 있습니다. 앞에서도 말한 것처럼 이 가중치에는 4개분의 가중치가 담겨있습니다. 이 인수들을 params에 할당하고, 이에 대응하는 형태로 기울기도 초기화합니다. cache는 순전파 때 중간 결과를 보관했다가 역전파 계산에 사용하려는 용도의 인스턴스 변수입니다.

이어서 순전파를 구현하겠습니다.

 

def forward(self, x, h_prev, c_prev):
    Wx, Wh, b = self.params
    N, H = h_prev.shape

    A = np.dot(x, Wx) + np.dot(h_prev, Wh) + b

    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 = o * np.tanh(c_next)

    self.cache = (x, h_prev, c_prev, i, f, g, o, c_next)
    return h_next, c_next

 

순전파는 forward(x, h_prev, c_prev) 메서드로 구현했습니다. 인수로는 현 시각의 입력 x, 이전 시각의 은닉 상태 h_prev, 이전 시각의 기억 셀 c_prev를 받습니다. 이 메서드에서는 가장 먼저 아핀 변환을 합니다. 이때 인스턴스 변수 Wx, Wh, h에는 각각 4개분의 매개변수가 저장되어 있습니다. 그림으로 표현하면

 

이와 같이 표현할 수 있습니다. 미니배치 수를 N, 입력 데이터의 차원 수를 D, 기억 셀과 은닉 상태의 차원 수를 모두 H로 표현했습니다. 그리고 계산 결과인 A에는 네 개분의 아핀 변환 결과가 저장됩니다. 따라서 이 결과로부터 데이터를 꺼낼 때는 A[:, :H]나 A[:, H:2*H] 형태로 슬라이스해서 꺼내고, 꺼낸 데이터를 다음 연산 노드에 분배합니다.

마지막으로 LSTM의 역전파를 구현하겠습니다. 일단 slice 노드 역전파에 대해서 살펴보겠습니다. slice 노드는 행렬을 네 조각으로 나눠서 분배했습니다. 따라서 그 역전파에서는 반대로 4개의 기울기를 결합해야 합니다.

 

이와 같은 그림으로 표현될 것입니다. slice 노드의 역전파에서는 4개의 행렬을 연결합니다. df, dg, di, do를 연결해서 dA를 만듭니다. 이를 numpy를 이용해 수행하려면 np.hstack() 메서드를 사용하면 됩니다. np.hstack()은 인수로 주어진 배열들을 가로로 연결합니다(세로로 연결은 np.vstack()을 이용하면 됩니다).

이를 이용해 역전파는 간단하게

 

dA = np.hstack((df, dg, di, do))

 

위 코드와 같이 작성하면 됩니다.

 

 ■ Time LSTM 구현

Time LSTM은 T개분의 시계열 데이터를 한꺼번에 처리하는 계층입니다.

 

위 그림과 같이 T개의 LSTM 계층을 처리합니다. 그런데 앞서 말한 것처럼 RNN에서는 Truncated BPTT를 수행했는데 이는 역전파의 연결을 적당한 길이로 끊지만, 순전파의 흐름은 그대로 유지합니다.

그러니

 

위 그림처럼 은닉 상태와 기억 셀을 인스턴스 변수로 유지하도록 하겠습니다. 이렇게 하여 다음번에 forward()가 불렸을 때, 이전 시각의 은닉 상태(와 기억 셀)에서부터 시작할 수 있습니다.

이제 Time LSTM을 구현하겠습니다.

 

class TimeLSTM:
    def __init__(self, Wx, Wh, b, stateful=False):
        self.params = [Wx, Wh, b]
        self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]
        self.layers = None

        self.h, self.c = None, None
        self.dh = None
        self.stateful = stateful

    def forward(self, xs):
        Wx, Wh, b = self.params
        N, T, D = xs.shape
        H = Wh.shape[0]

        self.layers = []
        hs = np.empty((N, T, H), dtype='f')

        if not self.stateful or self.h is None:
            self.h = np.zeros((N, H), dtype='f')
        if not self.stateful or self.c is None:
            self.c = np.zeros((N, H), dtype='f')

        for t in range(T):
            layer = LSTM(*self.params)
            self.h, self.c = layer.forward(xs[:, t, :], self.h, self.c)
            hs[:, t, :] = self.h

            self.layers.append(layer)

        return hs

 

LSTM은 은닉 상태 h와 함께 기억 셀 c도 이용하지만, TimeLSTM 클래스의 구현은 TimeRNN 클래스와 흡사합니다. 여기에서도 인수 stateful로 상태를 유지할지를 지정합니다.

 

6.4. LSTM을 사용한 언어모델

Time LSTM 계층까지 구현했으니, 언어 모델을 구현하겠습니다. 우리가 전에 구현한 Time RNN을 이용한 모델에서 Time RNN 계층을 Time LSTM으로 변경해주면 끝입니다.

 

# coding: utf-8
import sys
sys.path.append('..')
from common.time_layers import *
from common.base_model import BaseModel


class Rnnlm(BaseModel):
    def __init__(self, vocab_size=10000, wordvec_size=100, hidden_size=100):
        V, D, H = vocab_size, wordvec_size, hidden_size
        rn = np.random.randn

        # 가중치 초기화
        embed_W = (rn(V, D) / 100).astype('f')
        lstm_Wx = (rn(D, 4 * H) / np.sqrt(D)).astype('f')
        lstm_Wh = (rn(H, 4 * H) / np.sqrt(H)).astype('f')
        lstm_b = np.zeros(4 * H).astype('f')
        affine_W = (rn(H, V) / np.sqrt(H)).astype('f')
        affine_b = np.zeros(V).astype('f')

        # 계층 생성
        self.layers = [
            TimeEmbedding(embed_W),
            TimeLSTM(lstm_Wx, lstm_Wh, lstm_b, stateful=True),
            TimeAffine(affine_W, affine_b)
        ]
        self.loss_layer = TimeSoftmaxWithLoss()
        self.lstm_layer = self.layers[1]

        # 모든 가중치와 기울기를 리스트에 모은다.
        self.params, self.grads = [], []
        for layer in self.layers:
            self.params += layer.params
            self.grads += layer.grads

    def predict(self, xs):
        for layer in self.layers:
            xs = layer.forward(xs)
        return xs

    def forward(self, xs, ts):
        score = self.predict(xs)
        loss = self.loss_layer.forward(score, ts)
        return loss

    def backward(self, dout=1):
        dout = self.loss_layer.backward(dout)
        for layer in reversed(self.layers):
            dout = layer.backward(dout)
        return dout

    def reset_state(self):
        self.lstm_layer.reset_state()

 

Rnnlm 클래스에는 Softmax 계층 직전까지를 처리하는 predict() 메서드가 추가되었습니다. 그리고 매개변수 읽기/쓰기를 처리하는 load_params()와 save_params() 메서드도 추가되었습니다. 이제 이 신경망을 사용해 PTB 데이터 셋을 학습해보겠습니다.

 

# coding: utf-8
import sys
sys.path.append('..')
from common.optimizer import SGD
from common.trainer import RnnlmTrainer
from common.util import eval_perplexity
from dataset import ptb
from rnnlm import Rnnlm


# 하이퍼파라미터 설정
batch_size = 20
wordvec_size = 100
hidden_size = 100  # RNN의 은닉 상태 벡터의 원소 수
time_size = 35     # RNN을 펼치는 크기
lr = 20.0
max_epoch = 4
max_grad = 0.25

# 학습 데이터 읽기
corpus, word_to_id, id_to_word = ptb.load_data('train')
corpus_test, _, _ = ptb.load_data('test')
vocab_size = len(word_to_id)
xs = corpus[:-1]
ts = corpus[1:]

# 모델 생성
model = Rnnlm(vocab_size, wordvec_size, hidden_size)
optimizer = SGD(lr)
trainer = RnnlmTrainer(model, optimizer)

# 기울기 클리핑을 적용하여 학습
trainer.fit(xs, ts, max_epoch, batch_size, time_size, max_grad,
            eval_interval=20)
trainer.plot(ylim=(0, 500))

# 테스트 데이터로 평가
model.reset_state()
ppl_test = eval_perplexity(model, corpus_test)
print('테스트 퍼플렉서티: ', ppl_test)

# 매개변수 저장
model.save_params()

 

일단 RNNlmTrainer 클래스를 사용해 모델을 학습시켰습니다. RnnlmTrainer 클래스의 fit() 메서드는 모델의 기울기를 구해 모델의 매개변수를 갱신합니다. 이때 인수로 max_grad를 지정해 기울기 클리핑을 적용합니다. fit() 메서드에서 인수 eval_interval = 20은 20번째 반복마다 퍼플렉서티를 평가하라는 뜻입니다. 마지막으로 학습이 완료된 매개변수들을 파일로 저장합니다.

 

일단 위 그림은 실행 결과의 일부입니다. 매 20번째 반복의 퍼플렉서티 값이 출력되고 있습니다. 첫 번째 퍼플렉서티의 값이 10000.31인데, 이는 다음에 나올 수 있는 후보 단어 수를 10000개 정도로 좁혔다는 뜻입니다. 학습을 계속하면서 기대한 대로 퍼플렉서티 값이 좋아지고 있습니다.

 

6.5. RNNLM 추가 개선

 ■ LSTM 계층 다층화

RNNLM으로 정확한 모델을 만들고자 한다면 많은 경우 LSTM 계층을 깊게 쌓아 효과를 볼 수 있습니다.

 

지금까지 LSTM 계층을 1층만 사용했지만 이를 2층, 3층 식으로 여러 겹 쌓으면 언어 모델의 정확도가 향상될 것입니다. 위 그림은 LSTM을 2층으로 쌓아 RNNLM을 만든 그림입니다. 이때 첫 번째 LSTM 계층의 은닉 상태가 두 번째 LSTM 계층에 입력됩니다. 이런 방식으로 LSTM 계층을 몇 층이라도 쌓을 수 있으며, 그 결과 더 복잡한 패턴을 학습할 수 있게 됩니다. 참고로, PTB 데이터셋의 언어 모델에서는 LSTM의 층 수는 2~4 정도일 때 좋은 결과를 얻을 수 있습니다.

 

 ■ 드롭아웃에 의한 과적합 억제

LSTM 계층을 다층화하면 시계열 데이터의 복잡한 의존 관계를 학습할 수 있을 것이라 기대할 수 있습니다. 다르게 표현하면, 층을 깊게 쌓음으로써 표현력이 풍부한 모델을 만들 수 있다는 뜻입니다. 하지만 이런 모델은 과적합(overfitting)을 일으킵니다. 특히 RNN은 일반적인 피드포워드 신경망보다 쉽게 과적합을 일으킵니다.

과적합을 억제하는 일반적인 두 가지 방법이 있습니다. '훈련 데이터의 양 늘리기'와 '모델의 복잡도 줄이기'입니다. 그 외에는 모델의 복잡도에 페널티를 주는 정규화(normalization)도 효과적입니다. 또, 드롭아웃처럼 훈련 시 계층 내의 뉴런 몇 개를 무작위로 무시하고 학습하는 방법도 일종의 정규화입니다. 이번에는 드롭아웃에 대해 살펴보고 RNN에 적용해보겠습니다.

 

위 그림과 같이 드롭아웃은 무작위로 뉴런을 선택하여 선택한 뉴런을 무시합니다. 무시한다는 말은 그 앞 계층으로부터의 신호 전달을 막는다는 뜻입니다.

 

RNN을 사용한 모델에 dropout 계층을 시계열 방향으로 추가한 그림입니다. 이와 같은 방법으로 dropout 계층을 넣어버리면 시간이 흐름에 따라 정보가 사라질 수 있습니다. 즉, 흐르는 시간에 비례해 dropout에 의한 노이즈가 축적됩니다. 노이즈 축적을 고려하면, 시간축 방향으로의 dropout은 좋지 않습니다.

 

위 그림과 같이 dropout 계층을 깊이 방향(상하 방향)으로 삽입하는 방법이 좋습니다. 시간 방향으로 아무리 진행해도 정보를 잃지 않습니다. dropout이 시간축과는 독립적으로 깊이 방향에만 영향을 줍니다.

변형 드롭아웃(variational dropout)은 RNN의 시간 방향 정규화를 하는 방법입니다. 변형 드롭아웃은 깊이 방향은 물론 시간 방향에도 이용할 수 있어서 언어 모델의 정확도를 한 층 더 향상시킬 수 있습니다.

 

위 그림과 같은 구조입니다. 같은 계층에 속한 dropout들은 같은 mask를 공유합니다(위 그림에서 같은 색이 같은 mask를 공유하는 dropout). 여기서 말하는 mask란 데이터의 '통과/차단'을 결정하는 이진 형태의 무작위 패턴입니다. 같은 계층의 드롭아웃끼리 마스크를 공유함으로써 마스크가 고정됩니다. 그 결과 정보를 잃게 되는 방법도 고정되므로 일반적인 드롭아웃 때와 달리 정보가 지수적으로 손실되는 사태를 피할 수 있습니다.

 

 ■ 가중치 공유

가중치 공유(weight tying)는 언어 모델을 개선하는 간단한 트릭 중 하나입니다.

 

weight tying을 직역하면 '가중치를 연결한다'이지만, 실질적으로는 위 그림과 같이 가중치를 공유합니다. Embedding 계층의 가중치와 Affine 계층의 가중치를 연결(공유)하는 기법이 가중치 공유입니다. 두 계층이 가중치를 공유함으로써 학습하는 매개변수 수가 크게 줄어(매개변수가 줄어들면 overfitting을 억제할 수 있습니다) 드는 동시에 정확도도 향상되는 기술입니다.

어휘 수를 V로, LSTM의 은닉 상태의 차원 수를 H라 했을 때, Embedding 계층의 가중치는 형상이 VxH가 되고, Affine 계층의 가중치 형상은 HxV가 됩니다. 이때 가중치 공유를 적용하려면 Embedding 계층의 가중치를 전치하여 Affine 계층의 가중치로 설정하기만 하면 됩니다.

 

 ■ 개선된 RNNLM 구현

지금까지 알아본 기법을 사용해 BetterRnnlm이라는 클래스로 구현해보겠습니다.

 

먼저 신경망의 형태는 위와 같습니다. 우리가 사용한 개선점은 세 가지입니다.

  • LSTM 계층의 다층화(2층 적용)
  • 드롭아웃 사용(깊이 방향으로만 적용)
  • 가중치 공유(Embedding 계층과 Affine 계층에서 가중치 공유)

이제 이 세 가지 개선점을 도입한 클래스를 구현하겠습니다.

 

# coding: utf-8
import sys
sys.path.append('..')
from common.time_layers import *
from common.np import *  # import numpy as np
from common.base_model import BaseModel


class BetterRnnlm(BaseModel):
    '''
     LSTM 계층을 2개 사용하고 각 층에 드롭아웃을 적용한 모델이다.
     아래 [1]에서 제안한 모델을 기초로 하였고, [2]와 [3]의 가중치 공유(weight tying)를 적용했다.

     [1] Recurrent Neural Network Regularization (https://arxiv.org/abs/1409.2329)
     [2] Using the Output Embedding to Improve Language Models (https://arxiv.org/abs/1608.05859)
     [3] Tying Word Vectors and Word Classifiers (https://arxiv.org/pdf/1611.01462.pdf)
    '''
    def __init__(self, vocab_size=10000, wordvec_size=650,
                 hidden_size=650, dropout_ratio=0.5):
        V, D, H = vocab_size, wordvec_size, hidden_size
        rn = np.random.randn

        embed_W = (rn(V, D) / 100).astype('f')
        lstm_Wx1 = (rn(D, 4 * H) / np.sqrt(D)).astype('f')
        lstm_Wh1 = (rn(H, 4 * H) / np.sqrt(H)).astype('f')
        lstm_b1 = np.zeros(4 * H).astype('f')
        lstm_Wx2 = (rn(H, 4 * H) / np.sqrt(H)).astype('f')
        lstm_Wh2 = (rn(H, 4 * H) / np.sqrt(H)).astype('f')
        lstm_b2 = np.zeros(4 * H).astype('f')
        affine_b = np.zeros(V).astype('f')

        self.layers = [
            TimeEmbedding(embed_W),
            TimeDropout(dropout_ratio),
            TimeLSTM(lstm_Wx1, lstm_Wh1, lstm_b1, stateful=True),
            TimeDropout(dropout_ratio),
            TimeLSTM(lstm_Wx2, lstm_Wh2, lstm_b2, stateful=True),
            TimeDropout(dropout_ratio),
            TimeAffine(embed_W.T, affine_b)  # weight tying!!
        ]
        self.loss_layer = TimeSoftmaxWithLoss()
        self.lstm_layers = [self.layers[2], self.layers[4]]
        self.drop_layers = [self.layers[1], self.layers[3], self.layers[5]]

        self.params, self.grads = [], []
        for layer in self.layers:
            self.params += layer.params
            self.grads += layer.grads

    def predict(self, xs, train_flg=False):
        for layer in self.drop_layers:
            layer.train_flg = train_flg

        for layer in self.layers:
            xs = layer.forward(xs)
        return xs

    def forward(self, xs, ts, train_flg=True):
        score = self.predict(xs, train_flg)
        loss = self.loss_layer.forward(score, ts)
        return loss

    def backward(self, dout=1):
        dout = self.loss_layer.backward(dout)
        for layer in reversed(self.layers):
            dout = layer.backward(dout)
        return dout

    def reset_state(self):
        for layer in self.lstm_layers:
            layer.reset_state()

 

self.layer을 하나의 list 형태로 초기화할 때 세 가지 개선이 이뤄지고 있습니다. 구체적으로는 TimeLSTM 계층을 2개 겹치고, 사이사이에 TimeDropout 계층을 사용합니다. 그리고 TimeEmbedding 계층과 TimeAffine 계층에서 가중치를 공유합니다.

이제 이렇게 생성한 클래스를 학습시킬 차례입니다.

 

# coding: utf-8
import sys
sys.path.append('..')
from common import config
# GPU에서 실행하려면 아래 주석을 해제하세요(CuPy 필요).
# ==============================================
# config.GPU = True
# ==============================================
from common.optimizer import SGD
from common.trainer import RnnlmTrainer
from common.util import eval_perplexity, to_gpu
from dataset import ptb
from better_rnnlm import BetterRnnlm


# 하이퍼파라미터 설정
batch_size = 20
wordvec_size = 650
hidden_size = 650
time_size = 35
lr = 20.0
max_epoch = 40
max_grad = 0.25
dropout = 0.5

# 학습 데이터 읽기
corpus, word_to_id, id_to_word = ptb.load_data('train')
corpus_val, _, _ = ptb.load_data('val')
corpus_test, _, _ = ptb.load_data('test')

if config.GPU:
    corpus = to_gpu(corpus)
    corpus_val = to_gpu(corpus_val)
    corpus_test = to_gpu(corpus_test)

vocab_size = len(word_to_id)
xs = corpus[:-1]
ts = corpus[1:]

model = BetterRnnlm(vocab_size, wordvec_size, hidden_size, dropout)
optimizer = SGD(lr)
trainer = RnnlmTrainer(model, optimizer)

best_ppl = float('inf') # float('inf')는 양의 무한대 값을 의미
for epoch in range(max_epoch):
    trainer.fit(xs, ts, max_epoch=1, batch_size=batch_size,
                time_size=time_size, max_grad=max_grad)

    model.reset_state()
    ppl = eval_perplexity(model, corpus_val)
    print('검증 퍼플렉서티: ', ppl)

    if best_ppl > ppl:
        best_ppl = ppl
        model.save_params()
    else:
        lr /= 4.0
        optimizer.lr = lr

    model.reset_state()
    print('-' * 50)


# 테스트 데이터로 평가
model.reset_state()
ppl_test = eval_perplexity(model, corpus_test)
print('테스트 퍼플렉서티: ', ppl_test)

 

학습을 진행하면서 매 에폭마다 검증 데이터로 퍼플렉서티를 평가하고, 그 값이 기존 퍼플렉서티(best_ppl)보다 낮으면 학습률을 1/4로 줄입니다. 이를 위해 RnnlmTrainer 클래스의 fit() 메서드를 이용해 1 에폭분의 학습을 수행한 다음, 검증 데이터로 퍼플렉서티의 평가하는 처리를 for 문에서 반복합니다. 이 코드를 실행하면 퍼플렉서티가 순조롭게 낮아지다 종 75.76 정도를 얻습니다. 전에는 약 136이었음을 생각하면 상당히 개선된 것을 볼 수 있습니다.

'Deep Learning(강의 및 책) > 밑바닥부터 시작하는 딥러닝 2' 카테고리의 다른 글

8. 어텐션  (0) 2022.03.05
7. RNN을 사용한 문장 생성  (0) 2022.03.05
5. 순환 신경망(RNN)  (0) 2022.03.02
4. word2vec 속도 개선  (0) 2022.02.26
3. word2vec  (0) 2022.02.23