딥러닝독학! (6) 순환신경망 RNN - 밑바닥부터시작하는딥러닝 2

이전 챕터 스킵

원래는 이 앞장에 개선된 word2vec을 공부하는 장이 있었는데,

나는 일단 RNN에서 어텐션까지 빨리 나가야하고 word2vec에 대한 전체개요는 확인한 것 같아서

지금은 내가 쓰지도 않을 word2vec을 더 공부하는 것보다 흐름을 빨리 파악하는 것이 나은것 같아

스킵하기로 하고 바로 RNN으로 넘어갔다. 


지금까지의 신경망은 피드포워드라는 유형의 신경망이다. 이는 흐름이 단방향인 신경망을 뜻함.

피드포워드 신경망은 구헝이 단순하여 구조를 이해하기 쉽고, 많은 문제에 응용할 수 있지만, 시계열 데이터를 잘 다루지 못한다. 이것이 순환 신경망 RNN의 등장 배경이다.

 

1. RNN이란

RNN의 'Recurrent'는 라틴어에서 온 말로 '몇번이나 반복해서 일어나는 일'을 뜻함

RNN을 직역하면 '순화하는 신경망'

 

1.1 순환하는 신경망

'순환한다'? = '반복해서 되돌아감'

즉, 어느 한 지점에서 시작한 것이, 시간을 지나 다시 원래 장소로 돌아오는 것, 그리고 이 과정을 반복하는 것이다.

순환하기 위해서는 '닫힌 경로'가 필요하다. 즉 '순환하는 경로'가 존재해야 같은 장소를 반복해 왕래할 수 있다.

 

RNN의 특징은 경로가 있다는 것! 데이터가 순환되기 때문에 과거의 정보를 기억하는 동시에 최신 데이터로 갱신될 수 있다.

 

RNN계층을 구체적으로 살펴보자.

위 그림처럼 RNN 계층은 순환하는 경로를 포함한다ㅏ. 이 순환 경로를 따라 데이터를 계층 안에서 순환시킨다.

$\mathbf{X}_t$에서 $t$는 시각을 뜻한다.

각 시각에 입력되는 $\mathbf{X}_t$는 벡터라고 가정한다. 

문장을 다루는 경우를 예로 든다면 각 단어의 분산표현이 $\mathbf{X}_t$가 되며, 이 분산표현이 순서대로 하나씩 RNN계층에 입력되는 것이다.

 

이 구조를 살펴보기 전에 RNN을 그리는 방식을 다음과 같이 변경

1.2 순환 구조 펼치기

순환구조를 펼치면 친숙한 신경망이미지로 변신할 수 있다.

RNN 계층의 순환구조를 펼침으로써 오른쪽으로 성장하는 긴 신경망으로 변신시켰다.

그런데 실제로 이 계층 모두는 '같은 계층'이다.

위 그림에서 보듯, 각 시각의 RNN계층은 그 계층으로의 입력과 1개 전의 RNN계층으로부터의 출력을 받는다.

그리고 이 두 정보를 바탕으로 현 시각의 출력을 계산한다.

RNN엔 두 가중치가 있다.

하나는 입력 $\mathbf{x}$를 출력 $\mathbf{h}$로 변환하기 위한 가중치 $\mathbf{W_x}$이고, 

다른 하나는 1개의 RNN 출력을 다음 시각의 출력으로 변환하기 위한 가중치 $\mathbf{W_h}$이다.

또한 편향 $\mathbf{b}$도 있으며 $\mathbf{h}_{t-1}$과 $\mathbf{x}_t$는 행 벡터이다.

 

위 식에선 행렬 곱을 계산하고, 그 값을 tanh함수(쌍곡탄젠트 함수)를 이용하여 변환하고 그 결과가 시각 t의 출력 $\mathbf{h}_t$가 된다.

이 $\mathbf{h}_t$는 다른 계층을 향해 위쪽으로 출력되는 동시에, 다음 시각의 RNN계층(자기자신)을 향해 오른쪽으로도 출력된다. 

 

현재의 출력은 한 시각 이전 출력에 기초해 계산되는데 이는 곧 RNN은 $\mathbf{h}$라는 '상태'를 가지고 있으며

위의 형태로 갱신된다고 해석할 수 있다. 그래서 '상태를 가지는 계층'혹은 '메모리가 있는 계층'이라고 한다.

RNN의 $\mathbf{h}$는 '상태'를 기억해 시각이 1스텝 진행될 때마다 갱신된다.
RNN의 출력 $\mathbf{h}_t$를 은닉상태 혹은 은닉상태 벡터라고 한다.

 

1.3 BPTT

순환 구조를 펼친 후의 RNN에는 오차역전파법을 적용할 수 있다. 

여기서의 오차역전파법은 '시간 방향으로 펼친 신경망의 오차역전파법'이란 뜻으로 BPTT라고 한다.

여기서 문제는 긴 시계열 데이터를 학습할 때이다. 시계열 데이터의 시간 크기가 커지는 것에 비례하여 BPTT 소비자원도 커짐.

또, 시간 크기가 커지면 역전파 시의 기울기가 불안정해진다.

 

1.4 Truncated BPTT

큰 시계열 데이터를 취급할 때는 신경망 연결을 적당한 길이로 '끊는다'. 

이 잘라낸 작은 신경망에서 오차역전파법을 수행한다.

제대로 구현하려면 '역전파'의 연결만 끊어야 한다. (순전파의 흐름은 끊어지지 않고 전파)

역전파의 연결을 잘라버리면, 그보다 미래의 데이터에 대해서는 생각할 필요가 없음.

 

단, 역전파의 연결은 끊어지지만 순전파의 연결은 끊어지지 않는다는 점을 기억해야 한다.

 

이제, Truncated BPTT 방식으로 RNN을 학습시킨다.

첫째로, 첫번째 블록 입력데이터 ($\mathbf{x}_0, \cdots, \mathbf{x}_9$)를 RNN 계층에 제공

먼저 순전파를 수행하고, 역전파를 수행한다. 이러헤 원하는 기울기를 구할 수 있다.

 

이어서, 다음 블록의 입력 데이터 ($\mathbf{x}_10, \cdots, \mathbf{x}_19$)를 입력해 오차역전파법을 수행

가장 중요한 것은 이번 순전파 계산에는 앞 블록의 마지막 은닉상태인 $\mathbf{h}_9$가 필요하다는 것이다.

이렇게 순전파는 계속 연결된다.

 

이어서 계속 3번째의 블록도 수행한다. 이때도 두 번째 블록의 마지막 은닉상태 $\mathbf{h}_19$를 이용한다.

이렇게 RNN 학습에서는 데이터를 순서대로 입력하며, 은닉 상태를 계승하면서 학습을 수행한다.

1.5 Truncated BPTT의 미니배치 학습

지금까지는 미니배치 수가 1일 때에 해당한다.

원래대로면 구체적인 배치 방시을 고려해 데이터를 순서대로 입력해야 한다. 

= 데이터를 주는 시작 위치를 각 미니배치의 시작 위치로 '옮겨줘야' 한다.

'옮긴다' 라는 의미
ex) 길이가 1000인 시계열 데이터에 대해서, 시각의 길이를 10개 단위로 잘라 Truncated BPTT로 학습하는 경우
     미니배치의 수를 두 개로 구성해 학습하려면 어떻게 해야될까?
RNN 계층의 입력 데이터로, 첫 번째 미니배치 때는 처음부터 순서대로 데이터를 재공한다. 
두번 째 미니배치 때는 500번째의 데이터를 시작 위치로 정하고, 그 위치부터 다시 순서대로 데이터를 제공한다.
(= 시작 위치를 500만큼 '옮겨'준다)

<주의점>

1. 데이터를 순서대로 제공하기

2. 미니배치별로 데이터를 제공하는 시작 위치를 옮기기

 

2. RNN 구현

가로 방향으로 성정한 신경망 + Truncated BPTT 방식 = 가로크기가 일정한 일련의 신경망

우리가 다룰 신경망은 길이가 $T$인 시계열 데이터를 받고 각 시각의 은닉 상태를 $T$개 출력한다.

모듈화를 생각해, 옆으로 성정한 신경망을 하나의 계층으로 구현.

상하 방향의 입력과 출력을 각각 하나로 묶으면 옆으로 늘어선 일련의 계층을 하나의 계층으로 간주할 수 있다. 

Time RNN 계층 내에서 한 단계의 작업을 수행하는 계층을 RNN계층이라 하고,

T개 단계분의 작업을 한꺼번에 처리하는 계층을 Time RNN 계층이라 한다.

 

2.1 RNN 계층 구현

한 단계만 수행하는 RNN 클래스부터 구현하자.

RNN의 순전파 식

여기서 우리는 데이터를 미니배치로 모아 처리한다. 따라서 $\mathbf{x}_t$에는 각 샘플 데이터를 행 방향에 저장.

미니배치 크기가 $N$, 입력 벡터의 차원 수가 $D$, 은닉 상태 벡터의 차원 수가 $H$라면 계산에서의 형상 확인

class RNN:
    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):
        Wx, Wh, b = self.params
        t = np.dot(h_prev, Wh) + np.dot(x, Wx) + b
        h_next = np.tanh(t)

        self.cache = (x, h_prev, h_next)
        return h_next

초기화 메서드는 

가중치 2개와 편향 1개를 인수로 받는다.
각 매개변수에 대응하는 형태로 기울기를 초기화 한 후 grads에 저장하고
역전파 계산 시 사용하는 중간 데이터를 담을 cache를 초기화

순전파인 forward(x, h_prev)에서는

인수 2개(아래로부터의 입력x, 왼쪽으로부터의 입력 h_prev)를 받는다.
그리고 식을 적용함.

 

RNN의 역전파는? 순전파 때와는 반대 방향으로 각 연산자의 역전파를 수행하기만 하면 된다.

    def backward(self, dh_next):
        Wx, Wh, b = self.params
        x, h_prev, h_next = self.cache

        dt = dh_next * (1 - h_next ** 2)
        db = np.sum(dt, axis=0)
        dWh = np.dot(h_prev.T, dt)
        dh_prev = np.dot(dt, Wh.T)
        dWx = np.dot(x.T, dt)
        dx = np.dot(dt, Wx.T)

        self.grads[0][...] = dWx
        self.grads[1][...] = dWh
        self.grads[2][...] = db

        return dx, dh_prev

다음 그림을 참고했다.

 

2.2 Time RNN 계층 구현

Time RNN 계층은 $T$개의 RNN 계층으로 구성된다. (RNN 계층 T개를 연결한 신경망)

RNN 계츠의 은닉상태를 보관하고, '인계'받는 용도로 이용한다.

class TimeRNN:
    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.dh = None, None
        self.stateful = stateful

    def set_state(self, h):
        self.h = h

    def reset_state(self):
        self.h = None

초기화 메서드는

가중치와 편향, stateful을 인수로 받는다. (이때 stateful은 은닉 상태를 인계받을지에 대한 여부)
인스턴스변수 layers은 다수의 RNN 계층을 리스트로 저장
인스턴스변수 h는 forward()메서드를 불렀을 때의 마지막 RNN 계층의 은닉상태 저장
dh는 backward()를 불렀을 때 하나 앞 블록의 은닉 상태의 기울기를 저장

stateful = True라면 RNN 계층은 '상태가 있다'고 말한다. 이는 곧, 은닉 상태를 유지한다는 뜻.

아무리 긴 시계열 데이터라도 순전파를 끊지 않고 전달한다는 의미다. 

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

        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')

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

        return hs

순전파 메서드인 forward(xs)는

아래로부터 입력 xs를 받는다.  (xs는 T개 분량의 시계열 데이터를 하나로 모은 것)
xs의 형상은 (미니배치크기 N, T, 입력벡터차원수 D)
h는 처음 호출 시 영행렬로 초기화
for문안에선 RNN 계층을 생성하여 인스턴스변수 layers에 추가
RNN 계층이 각 시각 t의 은닉상태 h를 계산하고 이를 hs에 해당 인덱스(시각)의 값으로 설정

 

역전파 구현은 상류(출력)에서부터 전해지는 기울기를 dhs로 쓰고, 하류로 내보내는 기울기를 dxs로  쓴다

Truncated BPTT를 수행하기 때문에 이 블록 해당 블록 이전 시각 역전파는 필요하지 않음.

단, 이전 시각의 은닉상태 기울기는 dh에 저장한다.

    def backward(self, dhs):
        Wx, Wh, b = self.params
        N, T, H = dhs.shape
        D, H = Wx.shape

        dxs = np.empty((N, T, D), dtype='f')
        dh = 0
        grads = [0, 0, 0]
        for t in reversed(range(T)):
            layer = self.layers[t]
            dx, dh = layer.backward(dhs[:, t, :] + dh)
            dxs[:, t, :] = dx

            for i, grad in enumerate(layer.grads):
                grads[i] += grad

        for i, grad in enumerate(grads):
            self.grads[i][...] = grad
        self.dh = dh

        return dxs

역전파 메소드에선

하류로 흘러보낼 기울기를 담을 그릇인 dxs를 만듦
순전파 때와는 반대 순서로 backward()를 호출하여 각 시각의 기울기 dx를 구해 dxs내 해당 인덱스(시각)에 저장
Time RNN 계층 안에는 RNN 계층이 여러개 있다.
그 RNN 계층들에서 똑같은 가중치를 사용하고 있다.
따라서 Time RNN 계층의 최종 가중치 기울기는 각 RNN 계층의 가중치 기울기를 모두 더한 값.

 

3. 시계열 데이터 처리 계층 구현

목표는 '언어모델'을 구현하는 것.

이번 절에서는 시계열 데이터를 처리하는 계층을 더 만들어본다.

RNN을 사용한 언어모델은 RNNLM이라 칭한다.

 

3.1 RNNLM 전체그림

왼쪽은 RNNLM의 계층 구성, 오른쪽은 이를 시간축으로 펼친 신경망

첫번째(하단)층은 임베딩 계층이다. 이 계층은 단어 ID를 단어의 분산표현(RNN의 입력)으로 변환한다. 

RNN계층은 은닉상태를 다음 층(위쪽)으로 출력함과 동시에 다음 시각의 RNN계층(오른쪽)으로 출력한다.

그러면 Affine계층을 거쳐 Softmax 계층으로 전해진다.

 

입력데이터는 단어 ID의 배열.

첫단어로 단어 ID가 0인 'you'가 입력된다. 이때, Softmax 확률분포를 보면 'say'에서 가장 높게 나옴

두번째 단어인 'say'를 입력하는 부분에선 'goodbye', 'hello'둘다 확률이 높음 (둘다 말이되니까)

여기서는 RNN 계층이 'you say'라는 맥락을 기억하고 있다는 것이다.

 

3.2 Time 계층 구현

시계열 데이터를 한꺼번에 처리하는 계층을 Time Embedding, Time Affine형태의 이름으로 구현한다.

Time Affine 계층은 Affine계층을 $T$개 준비해서, 각 시각의 데이터를 개별적으로 처리하면 된다.

Time Embedding 계층도 순전파 시에 $T$개의 Embedding 계층을 준비하고 각 Embedding 계층이 각 시각의 데이터를 처리한다.

Softmax계층을 구현할 때는 손실오차를 구하는 크로스엔트로피오차 계층도 함께 구현한다.

Time Softmax with Loss 계층의 그림은 다음과 같다.

$\mathbf{x}_0$이나 $\mathbf{x}_1$등의 데이터는 아래로부터 전해지는 '점수'를 나타낸다.

$\mathbf{t}_0$나 $\mathbf{t}_1$등의 데이터는 정답 레이블을 나타낸다.

$T$개의 Softmax with Loss 계층 각각이 손실을 산출한다. 그리고 그 손실들을 합산해 평균한 값이 최종 손실.

4. RNNLM 학습과 평가

4.1 RNNLM 구현

SimepleRnnlm이라는 이름의 클래스로 구현. 구성은 다음과 같다.

즉, SimpleRnnlm 클래스는 4개의 Time계층을 쌓은 신경망이다. 초기화 코드부터 보자.

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

        # 가중치 초기화
        embed_W = (rn(V, D) / 100).astype('f')
        rnn_Wx = (rn(D, H) / np.sqrt(D)).astype('f')
        rnn_Wh = (rn(H, H) / np.sqrt(H)).astype('f')
        rnn_b = np.zeros(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),
            TimeRNN(rnn_Wx, rnn_Wh, rnn_b, stateful=True),
            TimeAffine(affine_W, affine_b)
        ]
        self.loss_layer = TimeSoftmaxWithLoss()
        self.rnn_layer = self.layers[1]

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

초기화 메서드는

각 계층에서 사용하는 매개변수를 초기화하고 필요한 계층을 생성
(stateful=True인 이유는 Truncated BPTT로 학습한다고 가정)
초기화 코드는 RNN 계층과 Affine계층에서 Xaier초깃값을 이용

    def forward(self, xs, ts):
        for layer in self.layers:
            xs = layer.forward(xs)
        loss = self.loss_layer.forward(xs, 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.rnn_layer.reset_state()

나머지는 간단하다. 

각 계층에서 수전파와 역전파를 적절히 구현해뒀으므로 해당 계층의 메서드를 호출해주면 된다.

reset_state는 신경망의 상태를 초기화하는 편의 메서드이다.

 

4.2 언어 모델 평가

여기선 평차지표를 알아본다.

언어 모델은 주어진 과거 단어로부터 다음에 출현할 단어의 확률분포를 출력한다.

이 때 언어 모델의 예측 성능을 평가하는 척도로 퍼플렉서티를 자주 이용한다.

퍼플렉시티 = 확률의 역수
ex) 'you'가 주어질 때 'say'가 출현할 확률이 0.8이라면, 퍼플렉서티 = 1.25
퍼플렉시티는 작을수록 좋다.

퍼플렉서티 값은 분기 수로 해석할 수 있다. (분기 수 = 다음에 취할 수 있는 선택사항의 수)

좋은 모델은 정답 단어를 높은 확률로 예측하므로  퍼플렉서티 값이 작다.

 

입력 데이터가 여러개라면?

다음 공식에 따라 계산한다.

N 데이터의 총 개수
$t_n$ 원핫벡터로 나타낸 정답 레이블
$t_{nk}$ n개째 데이터의 k번째 값
$y_{nk}$ 확률분포(Softmax 출력)
L 신경망의 손실

4.3 RNNLM 학습 코드

# coding: utf-8
import sys
sys.path.append('..')
import matplotlib.pyplot as plt
import numpy as np
from common.optimizer import SGD
from dataset import ptb
from simple_rnnlm import SimpleRnnlm


# 하이퍼파라미터 설정
batch_size = 10
wordvec_size = 100
hidden_size = 100 # RNN의 은닉 상태 벡터의 원소 수
time_size = 5     # Truncated BPTT가 한 번에 펼치는 시간 크기
lr = 0.1
max_epoch = 100

# 학습 데이터 읽기(전체 중 1000개만)
corpus, word_to_id, id_to_word = ptb.load_data('train')
corpus_size = 1000
corpus = corpus[:corpus_size]
print(id_to_word)
vocab_size = int(max(corpus) + 1)

xs = corpus[:-1]  # 입력
ts = corpus[1:]   # 출력(정답 레이블)
data_size = len(xs)
print('말뭉치 크기: %d, 어휘 수: %d' % (corpus_size, vocab_size))

# 학습 시 사용하는 변수
max_iters = data_size // (batch_size * time_size)
time_idx = 0
total_loss = 0
loss_count = 0
ppl_list = []

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

# 미니배치의 각 샘플의 읽기 시작 위치를 계산 (1)
jump = (corpus_size - 1) // batch_size
offsets = [i * jump for i in range(batch_size)]

for epoch in range(max_epoch):
    for iter in range(max_iters):
        # 미니배치 취득 (2)
        batch_x = np.empty((batch_size, time_size), dtype='i')
        batch_t = np.empty((batch_size, time_size), dtype='i')
        for t in range(time_size):
            for i, offset in enumerate(offsets):
                batch_x[i, t] = xs[(offset + time_idx) % data_size]
                batch_t[i, t] = ts[(offset + time_idx) % data_size]
            time_idx += 1

        # 기울기를 구하여 매개변수 갱신
        loss = model.forward(batch_x, batch_t)
        model.backward()
        optimizer.update(model.params, model.grads)
        total_loss += loss
        loss_count += 1

    # 에폭마다 퍼플렉서티 평가 (3)
    ppl = np.exp(total_loss / loss_count)
    print('| 에폭 %d | 퍼플렉서티 %.2f'
          % (epoch+1, ppl))
    ppl_list.append(float(ppl))
    total_loss, loss_count = 0, 0

# 그래프 그리기
x = np.arange(len(ppl_list))
plt.plot(x, ppl_list, label='train')
plt.xlabel('epochs')
plt.ylabel('perplexity')
plt.show()

이제껏 신경망 학습과 다른점

1. 데이터 제공 방법

2. 퍼플렉서티 계산 부분

 

일단 '데이터 제공 방법'에 대해선 Truncated BPTT 방식으로 수행한다.

따라서 데이터는 순차적으로 주고 각 미니배치에서 데이터를 읽는 시작위치를 조정한다.

소스코드에서 (1)부분이 각 미니배치가 데이터를 읽기 시작하는 위치를 계산한다.

(2)에서는 데이터를 순차적으로 읽는다.

말뭉치를 읽는 위치가 크기를 넘어설 경우 말뭉치의 처음으로 돌아와야한다.

마지막으로 퍼플렉서티는 (3)부분에서 계산한다.

에폭마다의 퍼플렉서티를 구하기 위해 에폭마다 손실의 평균을 구하고, 그 값을 사용해 퍼플렉서티를 구한다.

다만, 이번엔 작은 말뭉치로 실험해서 큰 말뭉치에는 전혀 대응할 수 없다.

 

4.4 Trainer클래스

이 클래스는 방금 수행한 RNNLM을 클래스 안으로 숨겨준다.

전체 코드에서 일부만 발췌해보면 다음과 같다

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

trainer.fit(xs, ts, max_epoch, batch_size, time_size)

 

5. 정리

RNN은 순환경로가 있고, 내부에 '은닉상태'를 기억할 수 있다.

긴 시계열 데이터는 데이터를 적당한 길이씩 모으고 이 단위로 BPTT에 의한 학습을 수행

Truncated BPTT에서는 역전파의 연결만 끊는다.

언어 모델은 단어 시퀀스를 확률로 계산