본문 바로가기

2. Artificial Intelligence/밑바닥부터 시작하는 딥러닝

[밑바닥부터 시작하는 딥러닝2: Chapter 5] 순환 신경망(RNN)

728x90

본 포스팅은 밑바닥부터 시작하는 딥러닝2을 토대로 공부한 내용을 정리하기 위한 포스팅입니다.
해당 도서에 나오는 Source Code 및 자료는 GitHub를 참조하여 진행하였습니다.
https://github.com/WegraLee/deep-learning-from-scratch-2

5.0 학습 목표


지금까지 공부한 신경망은 feed forward 유형의 신경망이었다. feed forward란 흐름이 단방향인 신경망을 말한다. 다시 말해, 입력 신호가 다음 층으로 전달되고, 그 신호를 받은 층은 그다음 층으로 전달하고 .. 식으로 한 방향으로만 신호가 전달된다. 하지만 feed forward는 시계열 데이터의 성질을 충분히 학습할 수 없기 때문에 언어 모델에서는 주로 사용하지 않는다.
이번에 공부하는 Recurrent Neural Network(RNN)은 feed forward 신경망과는 달리 하나의 계층을 순환하여 학습하는 신경망으로 언어 모델에서 주로 사용된다. RNN이 어떻게 feed forward와 다른지를 중점적으로 생각하며 공부해보자.

5.1.2 언어 모델


언어 모델(Language Model)은 단어 나열에 확률을 부여한다. 특정한 단어의 시퀀스에 대해서, 그 시퀀스가 일어날 가능성이 어느 정도인지를 확률로 평가하는 것이다. 또한 언어 모델은 단어 순서의 자연스러움을 확률적으로 평가할 수 있으므로, 그 확률분포에 따라 다음으로 적합한 단어를 생성하는 용도로 사용할 수 있다. 언어 모델을 수식으로 쓰면 아래와 같다.

[식 5.4]

[식 5.4]는 사후 확률을 사용하여 동시 확률 P를 분해하여 쓴 것이다. [식 5.4]의 결과는 확률의 곱셈정리로부터 유도할 수 있다.

[식 5.6]

w_1, ... , w_m-1을 하나로 모아 A로 치환한다. 그러면 P(A, w_m)은 ’A가 일어난 확률‘과 ‘A가 일어난 후 w_m이 일어날 확률’을 곱한 값과 같다. 따라서 P(w_m | A)P(A)로 나타낼 수 있다.

[식 5.7]

이렇게 매번 사후 확률로 시퀀스를 분해해 가면 [식 5.4]를 유도할 수 있는 것이다. 여기에서 주목할 것은 이 사후 확률은 타깃 단어보다 왼쪽에 있는 모든 단어를 맥락(조건)으로 했을 때의 확률이라는 것이다.

위에서 얘기한 내용을 정리하면, [그림 5-3]으로 정리할 수 있다. 따라서 언어 모델의 목표는 P(w_t | w_1, w_2, ... , w_t-1)을 구하는 것이다.

그렇다면 CBOW 모델을 언어 모델로 사용해본다고 생각해보자. 그러면 맥락의 크기를 특정 값으로 한정하여 근사적으로 나타낼 수 있을것이다. 수식으로는 아래와 같다.

[식 5.8]

  하지만 CBOW는 window size의 크기를 특정 길이로 고정해야한다. 이렇게 될 경우 window size보다 왼쪽에 있는 단어의 정보가 무시된다는 문제가 발생한다. [그림 5-4]는 문제를 가시화한 것이다.

“?”에 들어갈 올바른 답은 “Tom”이여야 한다. 만약 CBOW의 window size가 10이었다면 이 “?”에 18번째 뒤에 있는 "Tom"을 출력하지 못할 것이다. 이 문제를 해결하기 위해 window size를 무한정 키울 수 있지만, CBOW 모델 자체의 한계가 존재한다. CBOW은 맥락 안의 단어 순서를 무시한다는 특징이 있다. 예컨대 CBOW 모델은 맥락으로 2개의 단어를 다루는 경우, 2개의 단어 벡터의 합이 은닉층에 온다.

[그림 5-5]의 왼쪽 그림과 같이 CBOW 모델의 은닉층에서는 단어 벡터들이 더해지므로 맥락의 단어 순서가 무시된다. (you, say)와 (say, you)라는 맥락을 똑같이 취급한다는 것을 의미한다.
[그림 5-5]의 오른쪽 그림은 맥락의 단어 순서를 고려하기 위해 맥락의 단어 벡터를 은닉층에서 연결하는 방식을 생각했지만, 은닉층의 가중치 매개변수가 늘어난 것을 확인할 수 있다. 이런 경우 맥락의 크기에 비례해 매개변수가 증가하기 때문에 바람직하지 않다.

  하지만 RNN은 맥락이 아무리 길더라도 그 맥락의 정보와 순서를 기억하는 메커니즘을 갖추고 있다. 그래서 RNN을 사용하면 아무리 긴 시계열 데이터에라도 대응할 수 있다.

5.2 RNN이란


RNN(Recurrent Neural Network)는 순환 신경망을 의미한다. RNN의 특징은 순환하는 경로가 있기 때문에 데이터는 이 경로를 따라 끊임없이 순환할 수 있다. 그리고 데이터가 순환되기 때문에 과거의 정보를 기억하는 동시에 최신 데이터로 갱신될 수 있다. RNN의 구조는 아래와 같다.

[그림 5-8]에서 보면 알 수 있듯, 각 시각의 RNN 계층은 그 계층으로의 입력과 1개 전의 RNN 계층으로부터의 출력을 받는다. 그리고 이 두 정보를 바탕으로 현 시각의 출력을 계산한다. 이때 수행하는 계산의 수식은 다음과 같다.

[식 5.9]

RNN에서는 가중치가 2개 있다. 하나는 입력 x를 출력 h로 변환하기 위한 가중치 W_x이고, 다른 하나는 1개의 RNN 출력을 다음 시각의 출력으로 변한하기 위한 가중치 W_h이다. 또한 편향 b도 있다.
[식 5.9]를 보면 현재의 출력 h_t는 한 시각 이전 출력 h_t-1에 기초해 계산됨을 알 수 있다. 다른 관점으로 보면, RNN은 h라는 ‘상태’를 가지고 있으며, [식 5.9]의 형태로 갱신된다고 해석할 수 있다. 그래서 RNN 계층을 ‘상태를 가지는 계층’ 혹은 ‘메모리가 있는 계층’이라고 한다.

5.2.4 Truncated BPTT


  RNN에도 일반적인 오차역전파법을 적용할 수 있다. 즉, 먼저 순전파를 수행하고, 이어서 역전파를 수행하여 원하는 기울기를 구할 수 있다. 여기서의 오차역전파법은 ‘시간 방향으로 펼친 신경망의 오차역전파법’이라는 뜻으로 BPTT(Backpropagation Through Time)라고 한다.
하지만 RNN은 큰 시계열 데이터를 취급하므로 backward를 할 때 신경망 연결을 적당한 길이로 끊는다. 그 이유는 시간 크기가 커지면 backward 시의 기울기가 불안정해지고 메모리 사용량도 증가하기 때문이다. 시간축 방향으로 너무 길어진 신경망을 적당한 지점에서 잘라내어 작은 신경망 여러 개로 만든다는 아이디어를 Truncated BPTT 기법이라 부른다.

Truncated BPTT에서 반드시 기억할 점은 backward의 연결은 끊어지지만, forward의 연결은 끊어지지 않는다는 점이다. 그러므로 RNN을 학습시킬 때는 forward가 연결된다는 점을 고려해야 한다. 데이터를 순서대로(sequential) 입력해야 한다는 뜻이다.


5.2.5 Truncated BPTT의 미니배치 학습

신경망을 학습시킬 때 미니배치 학습을 수행한다. 하지만 앞서 RNN은 forward에서 데이터를 반드시 순서대로 입력해야한다고 했다. 따라서 RNN은 배치 크기를 고려해 데이터의 시작 위치를 각 미니배치의 시작 위치로 옮겨줘야 한다.
예를 들어 corpus size가 1000인 시계열 데이터에 대해서, 시각의 길이를 10개 단위로 잘라 Truncated BPTT로 학습하는 경우를 생각해보도록 하자. 이때 미니배치의 수를 두 개로 구성해 학습을 진행하려고 한다면, 첫 번째 미니배치 때는 처음부터 순서대로 데이터를 제공하면 된다. 그리고 두 번째 미니배치 때는 500번째 데이터를 시작 위치로 정하고, 그 위치부터 다시 순서대로 데이터를 제공하는 것이다.

이처럼 미니배치 학습을 수행할 때는 각 미니배치의 시작 위치를 오프셋으로 옮겨준 후 순서대로 제공하면 된다. 또한 데이터를 순서대로 입력하다가 끝에 도달하면 다시 처음부터 입력하도록 한다. ‘데이터를 순서대로 제공하기’와 ‘미니배치별로 데이터를 제공하는 시작 위치를 옮기기’를 꼭 기억하자.

5.3 RNN 구현


[그림 5-17]처럼 길이가 T인 시계열 데이터를 입력받는다고 가정하고, 상하 방향의 입력과 출력을 각각 하나로 묶으면 옆으로 늘어선 일련의 계층을 하나의 계층으로 간주할 수 있다. 이때 Time RNN 계층 내에서 한 단계의 작업을 수행하는 계층을 'RNN 계층’이라 하고, T개 단계분의 작업을 한꺼번에 처리하는 계층을 ‘Time RNN 계층’이라 한다.

먼저 RNN의 한 단계를 처리하는 클래스를 RNN이란 이름으로 구현한다. 그리고 이 TimeRNN이란 이름의 클래스에서 for문으로 RNN을 T개 단계를 처리하도록 코드를 작성한다.

5.3.1 RNN 계층 구현


RNN의 forward의는 [식 5.10]과 같다.

[식 5.10] 행렬의 형상 확인을 진행해보도록 한다. 미니배치 크기가 N, 입력 벡터의 차원 수가 D, 은닉 상태 벡터의 차원 수가 H라면, 지금 계산에서의 형상 확인은 다음과 같이 해볼 수 있다.

[그림 5-18]에서 보듯, 행렬의 형상 확인을 수행함으로써 올바로 구현되었는지를 확인할 수 있다.

RNN의 계산 그래프는 [그림 5-20]와 같다. 참고로 편항 b의 덧셈에서 브로드캐스트가 일어나기 때문에 Repeat 노드를 이용한다.

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.matmul(h_prev, Wh) + np.matmul(x, Wx) + b
        h_next = np.tanh(t)
        
        self.cache = (x, h_prev, h_next)
        return h_next
    
    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.matmul(h_prev.T, dt)
        dh_prev = np.matmul(dt, Wh.T)
        dWx = np.matmul(x.T, dt)
        dx = np.matmul(dt, Wx.T)
        
        self.grads[0][...] = dWx
        self.grads[1][...] = dWh
        self.grads[2][...] = db
        
        return dx, dh_prev


RNN의 초기화 메서드는 가중치 2개와 편향 1개를 인수로 받는다. 여기에서는 인수로 받은 매개변수를 인스터늣 변수 params에 리스트로 저장한다. 그리고 각 매개변수에 대응하는 형태로 기울기를 초기화한 후 grads에 저장한다. 마지막으로 역전파 계산 시 사용하는 중간 데이터를 담을 cache를 None으로 초기화한다.

5.3.2 Time RNN 계층 구현


Time RNN 계층은 T개의 RNN 계층으로 구성된다. Time RNN은 RNN 계층의 은닉 상태 h를 인스턴스 변수로 유지한다. 이 변수를 [그림 5-22]처럼 은닉 상태를 ‘인계’받는 용도로 이용한다.

[그림 5-22]처럼 RNN 계층의 은닉 상태를 Time RNN 계층에서 관리하기로 한다. 다음은 Time RNN 계층의 코드이다.

class TimeRNN:
    def __init__(self, Wx, Wh, b, stateful = False):
        self.parmas = [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
    
    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
    
    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


TimeRNN의 순전파 메서드인 forward(xs)는 아래로부터 입력 xs를 받는다. xs는 T개 분량의 시계열 데이터를 하나로 모은 것이다. 따라서 미니배치 크기를 N, 입력 벡터의 차원 수를 D라고 하면, xs의 형상은 (N, T, D)가 된다.
이어서 총 T회 반복되는 for 문 안에서 RNN 계층을 생성하여 인스턴스 변수 layers에 추가한다. 그 사이에 RNN 계층이 각 시각 t의 은닉 상태 h를 계산하고, 이를 hs에 해당 인덱스(시각)의 값으로 설정한다.

Time RNN 계층의 역전파 계산 그래프는 [그림 5-23]과 같다.

[그림 5-23]과 같이, 상류에서부터 전해지는 기울기를 dhs로 쓰고, 하류로 내보내는 기울기를 dxs로 쓴다. Truncated BPTT를 수행하기 때문에 이 블록의 이전 시각 역전파는 피룡하지 않지만, 이전 시각의 은닉 상태 기울기는 인스턴스 변수 dh에 저장해 놓는다.

이때 t번째 RNN 계층에 주목하면, 그 역전파는 [그림 5-24]처럼 그릴 수 있다.

t번째 RNN 계층에서는 위로부터의 기울기 dh_t와 미래 계층으로부터의 기울기 dh_next가 전해진다. 여기서의 주의점은 RNN 계층의 forward에서는 출력이 2개로 분기된다는 것이다. forward 시 분기했을 경우, 그 backward에서는 각 기울기가 합산되어 전해진다. 따라서 backward 시 RNN 계층에서는 합산된 기울기 (dh_next + dh_t)가 입력된다.

5.4 시계열 데이터 처리 계층 구현


이번 절에서는 RNN을 사용하여 ‘언어 모델’을 구현해보도록 한다. 먼저, RNN Language Model의 계층 구성은 아래와 같다.

첫 번째 층은 Embedding 계층이다. 이 계층은 단어 ID를 단어의 분산 표현(단어 벡터)로 변환한다. 그리고 그 분산 표현이 RNN 계층으로 입력된다.
RNN 계층은 은닉 상태를 다음 층으로 출력함과 동시에, 다음 시각의 RNN 계층으로 출력한다. 그리고 RNN 계층이 위로 출력한 은닉 상태는 Affine 계층을 거쳐 Softmax 계층으로 전해진다.

입력 데이터로 "You say goodbye and I say hello."를 사용했을 때의 출력 결과를 보면 [그림 5-26]과 같다.

RNNLM은 지금까지 입력한 단어를 ’기억‘하고, 그것을 바탕으로 다음에 출현할 단어를 예측한다. RNN 계층이 과거에서 현재로 데이터를 계속 흘려보내줌으로써 과거의 정보를 인코딩해 저장할 수 있는 것이다.

그럼 시계열 데이터를 한꺼번에 처리하는 계층인 Time Embedding, Time Affine을 사용하여 RNNLM을 간단히 표현해보자.


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

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

class TimeEmbedding:
    def __init__(self, W):
        self.params = [W]
        self.grads = [np.zeros_like(W)]
        self.layers = None
        self.W = W

    def forward(self, xs):
        N, T = xs.shape
        V, D = self.W.shape

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

        for t in range(T):
            layer = Embedding(self.W)
            out[:, t, :] = layer.forward(xs[:, t])
            self.layers.append(layer)

        return out

    def backward(self, dout):
        N, T, D = dout.shape

        grad = 0
        for t in range(T):
            layer = self.layers[t]
            layer.backward(dout[:, t, :])
            grad += layer.grads[0]

        self.grads[0][...] = grad
        return None


class TimeAffine:
    def __init__(self, W, b):
        self.params = [W, b]
        self.grads = [np.zeros_like(W), np.zeros_like(b)]
        self.x = None

    def forward(self, x):
        N, T, D = x.shape
        W, b = self.params

        rx = x.reshape(N*T, -1)
        out = np.dot(rx, W) + b
        self.x = x
        return out.reshape(N, T, -1)

    def backward(self, dout):
        x = self.x
        N, T, D = x.shape
        W, b = self.params

        dout = dout.reshape(N*T, -1)
        rx = x.reshape(N*T, -1)

        db = np.sum(dout, axis=0)
        dW = np.dot(rx.T, dout)
        dx = np.dot(dout, W.T)
        dx = dx.reshape(*x.shape)

        self.grads[0][...] = dW
        self.grads[1][...] = db

        return dx


class TimeSoftmaxWithLoss:
    def __init__(self):
        self.params, self.grads = [], []
        self.cache = None
        self.ignore_label = -1

    def forward(self, xs, ts):
        N, T, V = xs.shape

        if ts.ndim == 3:  # 정답 레이블이 원핫 벡터인 경우
            ts = ts.argmax(axis=2)

        mask = (ts != self.ignore_label)

        # 배치용과 시계열용을 정리(reshape)
        xs = xs.reshape(N * T, V)
        ts = ts.reshape(N * T)
        mask = mask.reshape(N * T)

        ys = softmax(xs)
        ls = np.log(ys[np.arange(N * T), ts])
        ls *= mask  # ignore_label에 해당하는 데이터는 손실을 0으로 설정
        loss = -np.sum(ls)
        loss /= mask.sum()

        self.cache = (ts, ys, mask, (N, T, V))
        return loss

    def backward(self, dout=1):
        ts, ys, mask, (N, T, V) = self.cache

        dx = ys
        dx[np.arange(N * T), ts] -= 1
        dx *= dout
        dx /= mask.sum()
        dx *= mask[:, np.newaxis]  # ignore_labelㅇㅔ 해당하는 데이터는 기울기를 0으로 설정

        dx = dx.reshape((N, T, V))

        return dx


Time Affine 계층은 Affine 계층 T개를 이용하는 방식 대신 행렬 계산으로 한꺼번에 처리하는, 효율 좋은 방식으로 구현했다.

시계열 버전의 Softmax 계층을 구현할 때는 손실 오차를 구하는 Cross Entropy Error 계층도 함께 구현한다. 여기에서는 [그림 5-29]와 같은 구성의 Time Softmax with Loss 계층으로 구현하였다.

[그림 5-29]에서 입력되는 x 등의 데이터는 아래층에서부터 전해지는 score을 나타낸다. 또한, t 등의 데이터는 정답 label을 나타낸다. 그림에서 보듯, T개의 Softmax with Loss 계층 각각이 손실을 산출하고, 그 손실들을 합산해 평균한 값이 최종 손실이 된다. 수행하는 계산의 수식은 아래와 같다.

[식 5.11]

이 책의 Softmax with Loss 계층은 미니배치까지 고려하여 미니배치에 해당하는 손실의 평균을 구했다. 데이터 N개짜리 미니배치라면 그 N개의 손실을 더해 다시 N으로 나눠 데이터 1개당 평균 손실을 구한 것이다. 마찬가지로 Time Softmax with Loss 계층도 데이터 1개당 평균 손실을 구해 최종 출력으로 내보낸다.

5.5 RNNLM 학습과 평가


RNNLM을 구현하고 실제로 학습을 시켜보도록 하자. 앞으로 구현할 RNNLM에서 사용하는 신경망인 SimpleRnnlm의 계층 구성은 [그림 5-30]과 같다.

[그림 5-30과 같이 SimpleRnnlm 클래스는 4개의 Time 계층을 쌓은 신경망이다.

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


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

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


이 초기화 코드는 RNN 계층과 Affine 계층에서 ‘Xavier 초깃값’(사비에르 초깃값)을 이용했다. Xavier 초깃값에서는 이전 계층의 노드가 n개라면 표준편차가 1/sqrt(n)인 분포로 값들을 초기화 한다.(그림 5-31)

5.5.2 언어 모델의 평가


언어 모델은 주어진 과거 단어로부터 다음에 출현할 단어의 확률분포를 출력한다. 이때 언어 모델의 예측 성능을 평가하는 척도로 perplexity(혼란도)를 자주 이용한다. perplexity는 ‘확률의 역수’이다. 확률을 역수로 취하기 때문에 높은 확률일수록 perplexity 값은 감소하고, 낮은 확률일수록 perplexity는 증가한다. 따라서 perplexity는 작을수록 좋다. 반대로 확률 값은 직관적으로 'number of branches(분기 수)‘로 해석할 수 있다.
예를 들어 1.25의 확률(= 분기 수)를 갖는다는 것은 다음에 출현할 수 있는 단어의 후보를 1개 정도로 좁혔다는 뜻이 된다. 반면 나쁜 모델에서 구한 확률인 5.0은 다음에 출현할 후보가 아직 5개나 된다는 의미이다.

지금까지 입력 데이터가 하나일 때의 perplexity를 이야기했다. 그렇다면 입력 데이터가 여러 개일 때는 어떻게 되는지 아래 공식을 확인해보자.

[식 5.12]
[식 5.13]

N은 데이터의 총개수이다. t_n은 one hot vector로 나타낸 정답 label이며, t_nk는 n개째 데이터의 k번째 값을 의미한다. 그리고 y_nk는 확률분포를 나타낸다. 신경망의 손실인 L을 사용해 exp(L)을 계산한 값이 곧 perplexity이다.

5.5.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]
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)

# 미니배치의 각 샘플의 읽기 시작 위치를 계산
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):
        # 미니배치 취득
        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

    # 에폭마다 퍼플렉서티 평가
    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()


소스 코드.
# 미니배치의 각 샘플의 읽기 시작 위치를 계산
Truncated BPTT 방식으로 학습을 수행하기 때문에 데이터는 순차적으로 주고 각각의 미니배치에서 데이터를 읽는 시작 위치를 조정해야 한다. 이 코드 부분에서는 각 미니배치가 데이터를 읽기 시작하는 위치를 계산해 offsets에 저장한다. 다시 말해 이 offsets의 각 원소에 데이터를 읽는 시작 위치가 담기게 된다.

# 미니배치 취득
이 코드 부분에서는 데이터를 순차적으로 읽는다. 먼저 batch_x와 batch_t를 준비한 다음 변수 time_idx를 1씩 늘리면서, 말뭉치에서 time_idx 위치의 데이터를 얻는다. 만약 말뭉치를 읽는 위치가 말뭉치 크기를 넘어설 경우 처음으로 돌아오기 위해서 말뭉치 크기로 나눈 나머지를 인덱스로 사용한다.

# 에폭마다 perplexity 평가
이 코드 부분은 epoch마다 perplexity를 구하기 위해 epoch마다 손실의 평균을 구하고, 그 값을 사용해 perplexity를 구한다.

학습이 정상적으로 됐다면 [그림 5-33]처럼 perplexity가 감소하는 추이를 보일 것이다.

728x90