본문 바로가기

Deep Learning

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

5.1 확률과 언어 모델

5.1.1 word2vec을 확률 관점에서 바라보다

  • word2vec의 CBOW : 맥락 $w_{t-1}$과 $w_{t+1}$로부터 타깃 $w_t$를 추측하는 일을 다음 $P(w_t|w_{t-1}, w_{t+1}) 사후 확률을 모델링함으로써 수행한다. 이 부산물로써 단어의 의미가 인코딩된 '단어의 분산 표현'을 얻을 수 있다.
  • 그렇다면 본래 목적인 '맥락으로부터 타깃을 추측하는 것'은 곧 '언어모델'과 관련이 있다.

5.1.2 언어 모델

  • 언어 모델(Language Model)은 특정 단어 시퀀스에 대해 그 시퀀스가 일어날 가능성이 어느 정도인지를 확률로 평가한다. 이를 응용하면 기계 번역과 음성 인식, 그리고 새로운 문장을 생성하는 용도로도 이용 가능하다. 언어 모델은 단어 순서의 자연스러움을 확률적으로 평가할 수 있으므로 그 확률분포에 따라 다음으로 적합할 단어를 샘플링 할 수 있기 때문이다. 
  • 수식으로 표현해 보자. 단어가 $w_1, \cdots, w_m$ 순서로 출현할 동시 확률은 곱셈 정리와 사후확률을 사용해 다음과 같이 분해해 쓸 수 있다. 
    $$P(w_1, \cdots, w_m) = P(w_m|w_1, \cdots, w_{m-1}) P(w_{m-1}|w_1, \cdots, w_{m-2}) \cdots P(w_2|w_1) P(w_1) \\ = \prod_{t=1}^m P(w_t | w_1, \cdots, w_{t-1})$$
  • 곱셈정리를 사용하면, $m$개 단어의 동시 확률을 사후확률로 나타낸 것이 위와 같다는 것을 자연스럽게 이해할 수 있다. 
    $$P(w_1, \cdots, w_m) = P(w_1, \cdots, w_{m}) = P(A, w_m) = P(w_m | A) P(A) \\
    P(A) = P(w_1, \cdots, w_{m-2}, w_{m-1}) = P(A', w_{m-1}) = P(w_{m-1}|A')P(A')$$

  • 이때, 주목할 것은 이 사후 확률은 타깃 단어보다 왼쪽에 있는 모든 단어를 맥락(조건)으로 했을 때의 확률이라는 것이다. 
  • 최종적으로 정리하면, 우리의 목표는 $P(w_t|w_1, \cdots, w_{t-1})$이라는 사후확률(혹은 조건부 언어모델)을 얻어 언어모델의 동시확률을 구하는 것이다. 

5.1.3 CBOW 모델을 언어 모델로? 

  • word2vec의 CBOW 모델을 언어모델에 적용하면, 맥락의 크기를 왼쪽 2개의 단어라는 특정 값으로 한정하여 근사적으로 다음과 같이 나타낼 수 있다. : 
    $$P(w_1, \cdots, w_m) = \prod_{t=1}^m P(w_t | w_1, \cdots, w_{t-1}) \approx \prod_{t=1}^m P(w_t | w_{t-2}, w_{t-1})$$
  • 참고로 이는 통계학에서의 마코프 연쇄(Markov Chain) : 미래의 상태가 현재 상태에만 의존해 결정되는 상태의 개념을 사용한 결과이다. (직전 2개 단어에만 의존해 다음 단어가 정해지는 모델이므로 2중 마코프 연쇄)
  • 하지만 결국 몇개의 단어를 쓸지 고정하기 때문에 그 맥락보다 더 왼쪽에 있는 단어의 정보는 무시된다. 이는 다음과 같은 문제를 야기한다 : 

  • 맥락상 TOM이 나와야함에도 불구하고 정답을 구하려면 ?로부터 18번째나 앞에 나오는 'TOM'을 기억해야 한다. 하지만 이를 해결하기 위해 맥락 크기를 20이나 30까지 키워도 CBOW 모델에서는 맥락 안의 단어 순서가 무시된다는 한계가 여전히 존재한다. 

  • 왼쪽 그림과 같이 CBOW 모델의 은닉층에서는 단어 벡터들이 더해지므로 맥락의 단어 순서는 무시된다. 
  • 이상적으로는 맥락의 단어 순서도 고려한 오른쪽의 모델(맥락의 단어를 은닉층에서 연결)이 더 적절할 것이다. 실제 신경 확률론적 언어 모델에서 제안한 모델은 이 방식을 취한다. 하지만 연결방식을 취하면 맥락의 크기에 비례해 가중치 매개변수도 늘어나기 때문에 학습에 긍정적인 영향을 주지는 않는다. 
  • 이를 해결하기 위해 등장한 것이 RNN이다. RNN은 맥락이 아무리 길어도 그 맥락의 정보를 기억하는 메커니즘을 갖춰 긴 시계열 데이터에도 대응 가능하다.

5.2 RNN이란

5.2.1 순환하는 신경망

  • RNN의 특징은 순환하는 경로(닫힌 경로)가 있다는 것이며 이 순환 경로를 따라 데이터가 끊임없이 순환하며 과거의 정보를 기억함과 동시에 최신 데이터로 갱신될 수 있다. 

  • RNN 계층은 순환하는 경로를 포함한다. 이 순환 경로를 따라 데이터를 계층 안에서 순환시킬 수 있다. 또한 입력으로  $\mathbf{x}_t$ 벡터를 입력받는데, t는 시각을 뜻한다. 이는 시계열 데이터 $(\mathbf{x}_0, \mathbf{x}_1, \cdots, \mathbf{x}_t, \cdots)$가 RNN 계층에 입력됨을 표현한 것이다. 그 입력에 대응해 $(\mathbf{h}_0, \mathbf{h} _1, \cdots, \mathbf{h}_t, \cdots)$가 출력된다. 
  • 입력 : $\mathbf{x}_t$ : 벡터라고 가정한다. 문장(단어 순서)을 다루는 경우를 예로 들면, 각 단어의 분산 표현(단어 벡터)이 $\mathbf{x}_t$가 되며, 이 분산 표현이 순서대로 하나씩 RNN 계층에 입력된다. 

5.2.2 순환 구조 펼치기

  • RNN 계층의 순환 구조를 펼치면 위와 같이 오른쪽으로 성장하는 긴 신경망으로 변신시킬 수 있다. 다수의 RNN 계층 모두가 실제로는 '같은 계층'인 것이 지금까지 피드 포워드 신경망과 다른 점이다.
  • 각 시각의 RNN 계층은 그 계층으로의 입력과 1개 전의 RNN 계층으로부터의 출력을 받는다. 이 두 정보를 바탕으로 현 시각의 출력을 다음과 같이 계산한다 : 
    $$ \mathbf{h}_t = tanh( \mathbf{h}_{t-1} \mathbf{W_h} + \mathbf{x}_t \mathbf{W_x} + \mathbf{b})$$
  • 2개의 가중치 (1) 입력 $\mathbf{x}$를 출력 $\mathbf{h}$로 변환하기 위한 가중치 $\mathbf{W_x}$, (2) 1개의 RNN 출력을 다음 시각의 출력으로 변환하기 위한 가중치 $\mathbf{W_h}$, 그리고 편향 $\mathbf{b}$. 이때 $\mathbf{h}_{t-1}, \mathbf{x_t}$는 행벡터임.
  • 위 식에서는 행렬 곱을 계산하고 그 합을 tanh 함수(hyperbolic tangent)를 이용해 변환해 시각 $t$의 출력 $\matbf{h}_t$를 계산한다. 이 $\matbf{h}_t$는 다른 계층을 향해 위쪽으로 출력됨과 동시에, 다음 시각의 RNN 계층(자기 자신)을 향해 오른쪽으로도 출력된다. 
  • 식을 자세히 보면 현재의 출력($\mathbf{h}_t$)은 한 시각 이전의 출력 $\mathbf{h}_{t-1}$에 기초해 계산됨을 알 수 있다. 다른 관점으로 보면, RNN은 $\mathbf{h}$라는 '상태'를 가지고 있으며 식의 형태로 갱신된다고 해석할 수 있다. 그래서 RNN 계층을 '상태를 가지는 계층' 혹은 '메모리(기억력)가 있는 계층'이라고 한다. 이 RNN의 출력 $\mathbf{h}_t$를 은닉 상태(hidden state) 혹은 은닉 상태 벡터(hidden state vector) 라고 부르기로 한다. 

5.2.3 BPTT

  • 순환 구조를 펼친 후의 RNN에는 일반적인 오차역전파법을 적용할 수 있다. 이때 '시간 방향으로 펼친 신경망의 오차역전파법'이라는 뜻으로 BPTT(Backprapagation Through Time)이라고 한다. 
  • 긴 시계열 데이터 학습의 경우 시간 크기가 커지는 것에 비례해 BPTT가 소비하는 컴퓨팅 자원이 증가하는 문제가 있다. 또한 시간 크기가 커질 경우 역전파 시의 기울기가 불안정해지는 문제도 존재한다. 
  • BPTT를 이용해 기울기를 구하려면, 매 시각 RNN 계층의 중간 데이터를 메모리에 유지해두지 않으면 안된다. 따라서 시계열 데이터가 길어짐에 따라 (계산량 뿐만 아니라) 메모리 사용량도 증가하게 된다. 

5.2.4 Truncated BPTT

  • 큰 시계열 데이터를 취급할 때는 신경망 연결을 적당한 길이로 '끊는다'. 이 잘라낸 작은 신경망에서 오차역전파법을 수행하는 것이 Truncated BPTT라는 기법이다. 
  • Truncated BPTT : 순전파의 연결은 그대로 유지한 채로, '역전파'의 연결만 적당한 길이로 잘라내, 잘라낸 신경망 단위로 학습을 수행한다. 
  • ex) 길이가 1000인 시계열 데이터(NLP : 단어 1000개짜리 말뭉치)가 있다. 이를 RNN계층으로 펼치면 계층이 가로로 1000개가 늘어선 신경망이 된다. 이럴 경우 계산량과 메모리 사용량도 문제지만, 역전파 시 기울기가 조금씩 작어져서 이전 시각 $t$까지 도달하기 전에 0이 되어 소멸할 수도 있다. 이를 Gradient Vanishing 문제라 한다. 그런 이유로 다음 그림처럼 가로로 길게 뻗은 신경망의 역전파에서는 연결을 적당한 길이로 끊게 된다. 

  • 여기에서는 RNN 계층을 길이 10개 단위로 학습할 수 있도록 역전파의 연결을 끊었다. 이러면 이보다 미래의 데이터에 대해서는 생각할 필요가 없어지므로 각 블록 단위로 미래의 블록과는 독립적으로 오차역전파법을 완결시킬 수 있다. 
  • Truncated BPTT 방식으로 RNN을 학습시켜보자. 입력 데이터는 $(\mathbf{x}_0, \cdots, \mathbf{x}_9)$이다.

  • 보이듯이 먼저 순전파를 수행하고, 그 다음 역전파를 수행해 원하는 기울기를 구할 수 있다. 그 뒤, 다음 블록의 입력 데이터 $(\mathbf{x}_{10}, \cdots, \mathbf{x}_{19})$를 입력해 오차 역전파를 수행한다. 가장 중요한 점은 순전파 계산 시에는 앞의 은닉 상태인 $\mathbf{h}_9$가 전달되어 계속 연결된다는 점이다. 최종적으로 RNN 학습의 흐름은 다음과 같다 : 

  • 이처럼 Truncated BPTT에서는 이런 식으로 순전파의 연결을 유지하면서 블록 단위로 오차역전파법을 적용할 수 있다. 

5.2.5 Truncated BPTT의 미니배치 학습

  • ex) 길이가 1000인 시계열 데이터에 대해, 시각의 길이를 10개 단위로 잘라 Truncated BPTT로 학습한다고 해보자. 이때 미니배치의 수를 2개로 구성해 학습하려면 RNN 계층의 입력 데이터로, (1) 첫 번째 미니배치(샘플 데이터) 때는 처음부터 순서대로 데이터를 제공한다. (2) 두 번째 미니배치 때는 500번째의 데이터를 시작 위치로 정하고, 그 위치부터 다시 순서대로 데이터를 제공한다.(즉, 시작 위치를 500만큼 '옮겨'준다.)

  • RNN의 입력 데이터로 사용되는 첫 번째 미니배치 원소는 $(\mathbf{x}_{0}, \cdots, \mathbf{x}_{9})$, 두 번째 미니배치 원소는 $(\mathbf{x}_{500}, \cdots, \mathbf{x}_{509})$가 된다. 
  • 이처럼 미니배치 학습을 수행할 때는 각 미니배치의 시작 위치를 오프셋으로 옮겨준 뒤 순서대로 제공하면 된다. 또한 순서대로 입력하다 끝에 도달하면 다시 처음부터 입력하도록 한다. 

5.3 RNN 구현

  • 위와 같이 상하 방향의 입력과 출력을 각각 하나로 묶으면 옆으로 늘어선 일련의 계층을 하나의 계층으로 간주할 수 있다. 즉, $(\mathbf{x}_{0}, \cdots, \mathbf{x}_{T-1})$를 묶은 $\mathbf{xs}$를 입력하면 $(\mathbf{h}_{0}, \cdots, \mathbf{h}_{T-1})$을 묶은 $\mathbf{hs}$를 출력하는 단일 계층으로 볼 수 있다. 
  • 이때 Time RNN 계층 내에서 한 단계의 작업을 수행하는 계층을 'RNN 계층'이라 하고, T개 단계분의 작업을 한꺼번에 처리하는 계층을 'Time RNN'이라 부르겠다.

5.3.1 RNN 계층 구현

  • 한 단계만 수행하는 RNN 계층 class부터 구현해보자. RNN의 순전파는 다음 식과 같다 : 
    $$\mathbf{h}_t = tanh(\mathbf{h}_{t-1} \mathbf{W_h} + \mathbf{x}_t \mathbf{W_x}  + \mathbf{b})$$
  • 데이터를 미니배치로 모아 처리하기 위해 $\mathbf{x}_t$와 $\mathbf{h}_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.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
  • __ini__ : 가중치 2개와 편향 1개로 인수로 받아 저장하고, 각 매개변수에 대응하는 형태로 기울기를 초기화한 후 grads에 저장한다. 마지막으로 역전파 계산 시 사용할 중간 데이터를 담은 cache를 초기화한다. 
  • forward(x, h_prev) : 아래로부터의 입력 x와 왼쪽으로부터 넘어오는 입력 h_prev를 인수로 받아 식을 계산한다.

  • 순전파에서 수행하는 계산은 행렬곱 'MatMul'과 덧셈 '+', 그리고 'tanh'로 구성되어있다. 편향의 덧셈에서는 브로드캐스트가 일어나기 때문에 Repeat 노드를 이용한다.
  • 역전파에서는 순전파 때와 반대 방향으로 각 연산자의 역전파를 수행하기만 하면 된다. 

5.3.2 Time RNN 계층 구현

  • Time RNN 계층은 T개의 RNN 계층으로 구성된다. 여기에는 RNN 계층의 은닉 상태 $\mathbf{h}$를 인스턴스 변수로 유지한다. 이를 아래 그림처럼 은닉상태를 '인계'받는 용도로 이용한다. 

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

  def forward(self, xs):
    Wx, Wh, b = self.params
    N, T, D = xs.shape # mini-bach, time, input dimension
    D, H = Wx.shape # input dimension, hidden dimension

    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
  • __init__ : 가중치, 편향, stateful(=이전 RNN 은닉 상태를 인계받을지를 판단하는 변수)를 인수로 받음. Layers 인스턴스 변수의 경우 다수의 RNN 계층을 리스트로 저장하는 용도이며 h는 forward() 메서드를 불렀을 때의 마지막 RNN 계층의 은닉 상태를 저장하고, dh는 backward()를 불렀을 때 하나 앞 블록의 은닉 상태의 기울기를 저장한다.
  • stateful = True일 때는 아무리 긴 시계열 데이터더라도 Time RNN 계층의 순전파를 끊지 않고 전파한다는 의미이다. stateful = False일 때의 Time RNN 계층은 은닉 상태를 영행렬로 초기화하며 이를 '무상태'라고 한다. 
  • forwad(xs) : 아래로부터 입력 $xs$를 받는다. $\mathbf{xs}$는 T개 분량의 시계열 데이터를 하나로 모은 것으로써, $(N, T, D)$ = (미니배치 크기, T개 시계열 데이터, 입력 벡터의 차원 수)의 형상을 가진다.
    RNN 계층의 은닉 상태 h는 처음 호출 시 또는 stateful=False일 때 0행렬로 초기화된다. 처음 hs로 출력값을 담을 그릇을 준비한 뒤 T회 반복되는 for 문 안에서 RNN 계층을 생성하여 인스턴스 변수 layers에 추가하고 RNN 계층이 각 시각 t에서의 은닉 상태 h를 계산해 이를 hs의 해당 시각 값으로 설정한다.

  • backward(dhs) : 출력 층에서 전해지는 기울기 $\mathbf{dhs}$, 하류로 내보내는 기울기 $\mathbf{dxs}$. 우리는 Truncated BPTT를 수행하기 때문에 이 블록의 이전 시각의 역전파는 필요하지 않다. 단, 이전 시각의 은닉 상태의 기울기는 인스턴스 변수 dh에 저장해둔다. 

  • t번째 RNN 계층의 역전파 그림이 다음과 같다. 위로부터의 기울기 $\mathbf{dh}_t$와 '한 시각 뒤(미래) 계층'으로부터의 기울기 $\mathbf{dh}_{next}$가 전해진다. 이때 RNN 계층의 순전파에서는 출력이 2개로 분기되기 때문에 그 역전파에서는 각 기울기가 합산되어 전해진다. 따라서 역전파 시 RNN 계층에는 합산된 기울기 $(\mathbf{dh}_t + \mathbf{dh}_{next} )$가 입력된다. 
  • 하류로 흘려보낼 기울기를 담을 그릇인 dxs를 만들고, 순전파와는 반대 순으로 RNN 계층의 backward() 메서드를 호출해, 각 시각 $t$의 기울기 dx를 구해 dxs의 해당 인덱스(시각)에 저장한다. 가중치 매개변수에 대해서도 각 RNN 계층의 가중치 기울기를 합산해 최종 결과를 self.grads에 덮어쓴다. 
  • Time RNN 계층 안에는 여러 RNN 계층이 있지만 모두 똑같은 가중치(Wh, Wx, b)를 사용하고 있다. 따라서 Time RNN 계층의 최종 가중치의 기울기는 각 RNN 계층 가중치의 기울기를 모두 더한 게 된다. 

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

  • RNNLM : RNN을 사용한 언어 모델

5.4.1 RNNLM의 전체 그림 

  • 가장 아래 Embedding 계층은 단어 ID를 단어 벡터로 변환해 RNN 계층으로 입력한다.
  • RNN 계층은 은닉 상태를 다음 층으로 출력함과 동시에, 다음 시각의 RNN 계층으로 출력한다.
  • 위로 출력된 은닉 상태는 Affine 계층을 거쳐 Softmax 계층으로 전해진다. 
  • RNN은 과거의 정보를 응집된 은닉 상태 벡터로 저장해두고 더 위의 Affine 계층과 다음 시각의 RNN 계층으로 전달한다. 즉, 지금까지 입력된 단어들을 '기억'하고, 그 바탕으로 다음에 출현할 단어를 예측한다. 

5.4.2 Time 계층 구현

  • 시계열 데이터를 한꺼번에 처리하는 Time Embedding, Time Affine을 구현해 보자. 이는 아래 그림과 같다. 

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
  • Time Affine은 효율을 위해 행렬 계산으로 한꺼번에 처리하는 방식으로 짜여졌다.

  • $\mathbf{x}_0, \mathbf{x}_1$ 등의 데이터는 아래층부터 전해지는 '점수'이며 $\mathbf{t}_0, \mathbf{t}_1$ 등은 정답 레이블을 나타낸다. T개의 Softmax With Loss 계층 각각이 손실을 산출하고 그를 평균 낸 값이 최종 손실이 된다. 
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
  • Softmax with Loss : 미니배치에 해당하는 손실의 평균을 구했다. 즉, 데이터 N개짜리 미니배치에서 N개의 손실을 더해 다시 N으로 나눠 데이터 1개당 평균 손실을 구함
  • Time Softmax with Loss 계층도 시계열에 대한 평균을 구하는 것이므로 데이터 1개당 평균 손실을 구해 최종 출력으로 내보낸다.

5.5 RNNLM 학습과 평가

5.5.1 RNNLM 구현 

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()
  • __init__ : 각 layer에서 사용할 매개변수(가중치와 편향)를 초기화하고 필요한 계츠을 생성한다. 또 Truncated BPTT로 학습한다고 가정해 Time RNN 계층의 statefull = True로 설정해 이전 시각의 은닉 상태를 계승할 수 있게 하였다.
  • 또 RNN계층과 Affine 계층에서 'Xavier 초깃값'을 이용했다. (1/sqrt(n))
  • 이후 각 계층의 forward()와 backward() 메서드를 적절한 순서로 호출하면 끝이다. 

5.5.2 언어 모델의 평가

  • Perplexity(혼란도) : '확률의 역수'라고 볼 수 있다.
  • ex) 'you say goodbye and I say hello .' 말뭉치에서 모델 2개의 perplexity를 비교 : 

  • 모델 1 : "you" 단어 이후 정답 "say"의 예측 확률 = 0.8, perplexity = 1/0.8 = 1.25
    모델 2 : "you" 단어 이후 정답 "say"의 예측 확률 = 0.2, perplexity = 1/0.2 = 5
    따라서, perplexity가 작을수록 더 좋은 모델.
    해당 1.25, 5같은 값은 '분기수'로 해석 할 수 있다. 즉 다음에 취할 수 있는 선택사항의 수로 볼 수 있다.
  • 입력 데이터가 여러개 일 때 : $N$ = 데이터의 총 개수, $t_n$ = 원핫벡터로 나타낸 정답 레이블, $t_{nk}$ = n개째 데이터의 k번째 값, $y_{nk}$ = 확률 분포(신경망에서 Softmax의 출력)
    $$L = -\frac{1}{N} \sum_{n}\sum_{k}t_{nk}\log y_{nk} \\ perplexity = e^L$$
  • 사실 위 $L$ 식은 신경망의 손실을 뜻하며, 교차 엔트로피 오차와 완전히 같은 식이다. 

5.5.3 RNNLM의 학습 코드 

batch_size = 10
wordvec_size = 100
hidden_size = 100 # RNN의 은닉 상태 벡터의 원소 수
time_size = 5 # 시계열 데이터의 값
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)

# 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. epoch마다 perplexity 평가
  ppl = np.exp(total_loss / loss_count)
  print('| 에폭 %d | 퍼플렉시티 %.2f' % (epoch+1, ppl))
  ppl_list.append(float(ppl))
  total_loss, loss_count = 0, 0
  • 데이터 제공 방법 : Truncated BPTT 방식으로 학습을 수행함. 따라서 데이터는 순차적으로 주고 각각의 미니배치에서 데이터를 읽는 시작 위치를 조정해야 한다. 그래서 1번 소스코드에서 각 미니배치가 데이터를 읽기 시작하는 위치를 offsets에 저장한다. 이후 2번에서는 데이터를 순차적으로 읽는다. '그릇'인 batch_x와 batch_t를 준비하고 time_idx를 1씩 늘리면서 말뭉치에서 time_idx 위치의 데이터를 얻는다. 또한 1에서 계산한 offsets를 이용해 각 미니배치에서 offset을 추가한다. 또 말뭉치를 읽는 위치가 말뭉치 크기를 넘어설 경우 말뭉치의 처음으로 돌아와야 하기 때문에 말뭉치의 크기로 나눈 나머지를 인덱스로 사용한다.
  • 마지막으로 3번에서 perplexity를 계산한다. 

  • perplexity 추이를 보면 순조롭게 낮아짐을 알 수 있다. 하지만 현재 SimpleRNNLM으로는 큰 말뭉치에 대응할 수 없다.

5.5.4 RNNLM의 Trainer 클래스

class RnnlmTrainer:
    def __init__(self, model, optimizer):
        self.model = model
        self.optimizer = optimizer
        self.time_idx = None
        self.ppl_list = None
        self.eval_interval = None
        self.current_epoch = 0

    def get_batch(self, x, t, batch_size, time_size):
        batch_x = np.empty((batch_size, time_size), dtype='i')
        batch_t = np.empty((batch_size, time_size), dtype='i')

        data_size = len(x)
        jump = data_size // batch_size
        offsets = [i * jump for i in range(batch_size)]  # 배치에서 각 샘플을 읽기 시작하는 위치

        for time in range(time_size):
            for i, offset in enumerate(offsets):
                batch_x[i, time] = x[(offset + self.time_idx) % data_size]
                batch_t[i, time] = t[(offset + self.time_idx) % data_size]
            self.time_idx += 1
        return batch_x, batch_t

    def fit(self, xs, ts, max_epoch=10, batch_size=20, time_size=35,
            max_grad=None, eval_interval=20):
        data_size = len(xs)
        max_iters = data_size // (batch_size * time_size)
        self.time_idx = 0
        self.ppl_list = []
        self.eval_interval = eval_interval
        model, optimizer = self.model, self.optimizer
        total_loss = 0
        loss_count = 0

        start_time = time.time()
        for epoch in range(max_epoch):
            for iters in range(max_iters):
                batch_x, batch_t = self.get_batch(xs, ts, batch_size, time_size)

                # 기울기를 구해 매개변수 갱신
                loss = model.forward(batch_x, batch_t)
                model.backward()
                params, grads = remove_duplicate(model.params, model.grads)  # 공유된 가중치를 하나로 모음
                if max_grad is not None:
                    clip_grads(grads, max_grad)
                optimizer.update(params, grads)
                total_loss += loss
                loss_count += 1

                # 퍼플렉서티 평가
                if (eval_interval is not None) and (iters % eval_interval) == 0:
                    ppl = np.exp(total_loss / loss_count)
                    elapsed_time = time.time() - start_time
                    print('| 에폭 %d |  반복 %d / %d | 시간 %d[s] | 퍼플렉서티 %.2f'
                          % (self.current_epoch + 1, iters + 1, max_iters, elapsed_time, ppl))
                    self.ppl_list.append(float(ppl))
                    total_loss, loss_count = 0, 0

            self.current_epoch += 1

    def plot(self, ylim=None):
        x = numpy.arange(len(self.ppl_list))
        if ylim is not None:
            plt.ylim(*ylim)
        plt.plot(x, self.ppl_list, label='train')
        plt.xlabel('반복 (x' + str(self.eval_interval) + ')')
        plt.ylabel('퍼플렉서티')
        plt.show()

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)
  • RnnlmTrainer class를 쓰면 훨씬 간단하게 코드를 작성 가능하다. 

5.6 정리

  • RNN은 순환하는 경로가 있고, 이를 통해 내부에 '은닉 상태'를 기억할 수 있다.
  • RNN의 순환 경로를 펼침으로써 다수의 RNN 계층이 연결된 신경망으로 해석할 수 있으며, 보통의 오차역전파법으로 학습할 수 있다.(=BPTT)
  • 긴 시계열 데이터를 학습할 때는 적당한 길이씩 모으로, 블록 단위로 BPTT에 의한 학습을 수행한다.(= Truncated BPTT)
  • Truncated BPTT에서는 역전파의 연결만 끊는다
  • Truncated BPTT에서는 순전파의 연결을 유지하기 위해 데이터를 '순차적으로' 입력해야 한다.
  • 언어 모델은 단어 시퀀스를 확률로 해석한다.
  • RNN 계층을 이용한 조건부 언어 모델은 (이론적으로는) 그때까지 등장한 모든 단어의 정보를 기억할 수 있다.

다음 장에서는 RNN의 문제를 살펴보고 이를 대체하는 LSTM, GRU 등의 계층을 살펴보고자 한다.