Deep Learning

[밑바닥부터 시작하는 딥러닝2] Chapter 6. 게이트가 추가된 RNN

sara2601 2024. 7. 22. 21:58
  • 5장의 RNN은 과거의 정보를 기억할 수 있지만, 시간적으로 멀리 떨어진 장기(long term) 의존관계를 잘 학습할 수 없다는 단점이 있다. 
  • 이를 보완하는 LSTM이나 GRU를 더 자주 사용하곤 하는데, '게이트' 구조를 활용해 시계열 데이터의 장기 의존 관계를 학습할 수 있다.
  • 이번 장에서는 LSTM, GRU 등의 게이트가 추가된 RNN 구조를 살펴보고자 한다. 

6.1 RNN의 문제점

6.1.1 RNN 복습

  • RNN 계층은 순환 경로를 가지고 있으며, 시계열 데이터 $\mathbf{x}_t$를 입력하면 은닉 상태 $\mathbf{h}_t$를 출력하며 여기에는 과거 정보가 저장되어있다. RNN은 바로 이전 시각의 은닉 상태를 이용하여 과거 정보를 계승할 수 있다. 

  • 이처럼 행렬의 곱과 합, 활성화 함수 tanh에 의한 변환 연산을 수행한다. 

6.1.2 기울기 소실 또는 기울기 폭발 (Vanishing gradient & Exploding gradient)

  • "Tom was watching TV in his room. Mary came into the room. Mary said hi to ?" 라는 문장에서 "?"=Tom을 올바르게 답하기 위해서는 RNN 계층의 은닉 상태에 "Tom이 방에서 TV를 보고 있음"과 "그 방에 Mary가 들어옴"이라는 정보가 인코딩되어있어야 한다.
  • RNNLM에서 기울기가 어떻게 전파되는지를 살펴보도록 하자. 

  • 정답 레이블이 "Tom"이라고 주어진 시점으로부터 과거 방향으로 기울기를 전달함으로써 장기 의존 관계를 학습할 수 있다.
  • 하지만 이 기울기가 중간에 사그라들면(거의 아무런 정보도 남지 않게 되면) 가중치 매개변수는 전혀 갱신되지 않아 장기 의존 관계를 학습할 수 없게 된다. 현재 단순한 RNN 구조로는 시간을 거슬러 갈수록 기울기가 작아지거나(기울기 소실), 혹은 커질 수 있다.(기울기 폭발)

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

  • 길이가 T인 시계열 데이터에서 시간 방향 기울기 역전파에 주목해 보면, 기울기는 차례로 tanh, +, +, MatMul 연산을 통과하게 된다. '+'의 역전파는 상류의 기울기를 그저 하류로 흘려보낼 뿐이기 때문에 기울기에는 영향을 주지 않는다. 그렇다면 tanh와 MatMul 연산의 역전파에서 어떤 일이 일어나는지 살펴보자.
  • tanh 역전파 : $y=tanh(x), \frac{\partial y}{\partial x} = 1-y^2$이다. 

  • 미분 그래프를 보면 값이 1.0 이하이고, x가 0에서 멀어질수록 점점 작아진다. 따라서 역전파에서 기울기가 T개의 tanh 노드를 지날 때 마다 값이 계속 작아지게 된다. 
  • 이를 ReLU로 바꾸면 기울기 소실을 줄일 수 있다. (max(0, x) -> x가 0 이상이면 역전파 시 상류의 기울기를 그대로 하류로 흘려보내기 때문)

  • MatMul 역전파 : 상류로부터 $\mathbf{dh}$ 기울기가 흘러들어온다고 가정하면 MatMul 노드에서의 역전파는 $\mathbf{dh W_h}^T$ 행렬곱으로 기울기를 계산한다. 그리고 같은 계산을 시계열 데이터의 시간 크기 T만큼 반복한다. 여기서 주목할 점은 이 행렬곱에서는 매번 동일한 가중치 $\mathbf{W_h}$가 사용된다는 것이다. 

  • 역전파의 MatMul 노드 수(T)만큼 dh를 갱신하고, 각 단계에서 dh 크기(norm)을 계산했다. 또 여기에서는 미니배치(N개)의 평균 L2 norm을 구해 그 값을 다음과 같이 plot 시켜 보았다. 보듯 기울기의 크기가 시간에 비례해 지수적으로 증가한다. 이를 기울기 폭발(exploding gradients)라 한다. 기울기 폭발이 일어나면 결국 overflow를 일으켜 Nan 값을 발생시켜 신경망 학습을 제대로 수행할 수 없게 된다. 
  • 만약 Wh의 초기값을 np.random.randn(H,H) * 0.5로 변경해서 동일한 실험을 수행할 경우 dh가 시간 크기에 비례해 지수적으로 감소하는 기울기 소실(Vanishing Gradient) 문제가 발생하게 된다. 기울기 소실이 일어나면 기울기가 매우 빠르게 작아져 가중치 매개변수가 더이상 갱신되지 않아, 장기 의존 관계를 학습할 수 없게 된다. 
  • Wh를 T번 반복해서 '곱'했기 때문에 이런 문제가 발생하게 된다. Wh가 1보다 크면 지수적으로 증가하고 Wh가 1보다 작으면 지수적으로 감소한다.
  • 만약 Wh가 스칼라가 아니라 '행렬'이라면 행렬의 '특잇값'이 척도가 된다. 특잇값은 데이터가 얼마나 퍼져있는지를 나타내는 척도로써 이것이 1보다 큰지 여부를 보면 기울기 크기가 어떻게 변할지 예측할 수 있다. 특잇값의 최댓값이 1보다 크면 지수적으로 증가하고 1보다 작으면 지수적으로 감소할 가능성이 높다고 예측할 수 있다. 

6.1.4 기울기 폭발 대책

  • 기울기 클리핑 (Gradient Clipping)

  • 신경망에서 사용되는 모든 매개변수에 대한 기울기를 하나로 처리한다고 가정하고, 이를 기호 $\matbf{\hat{g}}$로 표기했다. 그리고 threshold를 문턱값으로 설정한다. 이때 기울기의 L2 norm이 문턱값을 초과하면 두번째 줄의 수식과 같이 기울기를 수정한다. Gradient Clipping은 아래와 같이 간단하게 코드로 작성할 수 있다.
dW1 = np.random.randn(3, 3) * 10
dW2 = np.random.randn(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

6.2 기울기 소실과 LSTM 

  • 기울기 소실을 해결하려면 RNN 계층의 아키텍처를 근본부터 뜯어고쳐야 한다. 여기에서 등장하는 개념이 '게이트'이다. 

6.2.1 LSTM의 인터페이스

  • 앞으로 $tanh(\mathbf{h}_{t-1}\mathbf{W_h} + \mathbf{x}_t \mathbf{W_x} + \mathbf{b})$ 계산을 tanh라는 직사각형 노드 하나로 표현하기로 한다. 이 노드 안에 행렬 곱과 편향의 합, tanh 함수에 의한 변환을 모두 포함한다고 보자.

  • RNN과 달리, LSTM 계층의 인터페이스에는 $\mathbf{c}$ : 기억 셀(memory cell)이 존재한다. 
  • 기억 셀의 특징은 데이터를 자기 자신으로만(LSTM 계층 내에서만) 주고받는다는 것이다. 즉, LSTM 계층 내에서만 완결되고, 다른 계층으로는 출력하지 않는다. 반면 LSTM의 은닉상태 $\mathbf{h}$는 여전히 다른 계층(위쪽으로) 출력된다. 

6.2.2 LSTM 계층 조립하기 

  • "Understanding LSTM Networds" 블로그 참고해보기

  • 기억 셀 $\mathbf{c}_t$에는 과거로부터 $t$시각까지에 필요한 모든 정보가 저장되어있다고 가정한다. 이를 바탕으로, 외부 계층과 다음 시각의 LSTM에 은닉상태 $\mathbf{h}_t$를 출력한다. 이때 출력하는 $\mathbf{h}_t$는 기억 셀의 값을 tanh 함수로 변환한 값이 된다. 
  • 위 그림 6-12처럼 현재의 기억 셀 $\mathbf{c}_t$는 3개의 입력 $(\mathbf{c}_{t-1}, \mathbf{h}_{t-1}, \mathbf{x}_t)$로부터 '어떤 계산'을 수행해 구할 수 있으며 갱신된 $\mathbf{c}_t$를 사용해 은닉상태 $\mathbf{h}_t = tanh( \mathbf{c}_t)$를 계산한다.
  • '게이트'는 데이터의 흐름을 제어한다. LSTM에서 사용하는 게이트는 열기/닫기와 어느정도 열지(0.7, 0.3)를 모두 조절할 수 있다. 여기에서 '어느 정도'를 '열림 상태(openness)'라 부른다. 이때 중요한 것은 게이트를 얼마나 열까도 데이터로부터 자동으로 학습한다는 것이다. 

6.2.3 Output 게이트

  •  Output 게이트 : 다음 은닉상태 $\mathbf{h}_t$의 출력을 담당하는 게이트로, $tanh(\mathbf{c}_t)$에 게이트를 적용해 각 원소에 대해 '그것이 다음 시각의 은닉 상태에 얼마나 중요한가'를 조정한다. 
  • $$\mathbf{o} = \boldsymbol{\sigma} (\mathbf{x}_t \mathbf{W_x}^{(o)} + \mathbf{h}_{t-1} \mathbf{W_h}^{(o)} + \mathbf{b}^{(o)})$$
    입력 $\mathbf{x}_t$에는 가중치 $ \mathbf{W_x}^{(o)} $가, 이전 시각의 은닉 상태 $\mathbf{h}_{t-1}$에는 가중치 $\mathbf{W_h}^{(o)}$ 가 붙어있다. 이 행렬들의 곱과 편향을 모두 더한 다음 시그모이드 함수를 거쳐 출력 게이트의 출력 $\mathbf{o}$를 구한다. 마지막으로 이 $\mathbf{o}$와 $tanh{\mathbf{c}_t)$의 원소별 곱을 $\mathbf{h}_t$로 출력하는 것이다. 

  • 최종적으로 $\mathbf{h}_t = \mathbf{o} \bigodot tanh(\mathbf{c}_t)$ (아다마르 곱 : 원소별 곱) 계산으로 LSTM의 output 게이트를 계산할 수 있다. 

6.2.4 forget 게이트

  • $\mathbf{c}_{t-1}$의 기억 중 불필요한 기억을 잊게 해주는 forget gate를 추가할 수 있다.
  • $$\mathbf{f} = \boldsymbol{\sigma}(\mathbf{x}_t \mathbf{W}_x^{(f)} + \mathbf{h}_{t-1} \mathbf{W_h}^{(f)} + \mathbf{b}^{(f)})$$
  • 위의 식을 계산해서 구해진 $\mathbf{f}$를 사용해 $\mathbf{c}_t = \mathbf{f} \bigodot \mathbf{c}_{t-1}$을 계산한다.

6.2.5 새로운 기억 셀

  •  forget 게이트를 거치며 이전 시각의 기억 셀로부터 잊어야 할 기억을 삭제했다. 새로 기억해야할 정보를 기억 셀에 추가해야 한다. 그를 위해 다음 그림처럼 tanh 노드를 추가한다.

  • tanh 노드가 계산한 결과가 그림처럼 이전 시각의 기억 셀 $\mathbf{c}_{t-1}$에 더해진다. 기억 셀에 새로운 '정보'가 추가된 것이다. 이 tanh 노드는 '게이트'가 아니며, 새로운 정보를 기억 셀에 추가하는 것이 목적이다. 
  • $$\mathbf{g} = tanh(\mathbf{x}_t \mathbf{W}_x^{(g)} + \mathbf{h}_{t-1} \mathbf{W_h}^{(g)} + \mathbf{b}^{(g)})$$
    여기에서는 기억 셀에 추가하는 새로운 기억을 $\mathbf{g}$로 표기했고, 이것이 이전 시각의 기억 셀인 $\mathbf{c}_{t-1}$에 더해짐으로써 새로운 기억이 생겨난다.

6.2.6 input 게이트

  • Input gate : $\mathbf{g}$의 각 원소가 새로 추가되는 정보로써의 가치가 얼마나 큰지를 판단한다. 즉, input 게이트에 의해 가중된 정보가 새로 추가된다고 보면 된다.
  • $$\mathbf{i} = \boldsymbol{\sigma}(\mathbf{x}_t \mathbf{W}_x^{(i)} + \mathbf{h}_{t-1} \mathbf{W_h}^{(i)} + \mathbf{b}^{(i)})$$
  • 그런 다음 $\mathbf{i}, \mathbf{g}$의 원소별 곱 결과를 기억 셀에 추가한다. 

6.2.7 LSTM의 기울기 흐름

  • 다음은 기억 셀에만 집중하여, 그 역전파의 흐름을 그린 것이다. 이때 기억 셀의 역전파에서는 '+'와 'x' 노드만을 지나게 된다. '+'는 상류에서 전해지는 기울기를 그대로 흘릴 뿐이기 때문에 기울기 변화(감소)는 일어나지 않는다.
  • 'x' 노드 : 원소별 곱(아다마르 곱) 계산이며, 매 시각 다른 게이트 값을 이용해 원소별 곱을 계산한다. 즉, 매번 새로운 게이트 값을 이용하므로 곱셈의 효과가 누적되지 않아 기울기 소실이 일어나기 어려운 원리이다. 
  • 'x' 노드의 계산은 forget 게이트가 제어하며, 매 시각 다른 게이트 값을 출력한다. 그리고 forget gate가 '잊어야 한다'고 판단한 기억 셀의 원소에 대해선 그 기울기가 작아지게 되고, '잊어서는 안된다'고 판단한 원소에 대해서는 기울기가 약화되지 않은 채로 과거 방향으로 전해진다. 따라서 기억 셀의 기울기가 소실 없이 전파되리라 기대할 수 있다.
  • 이렇기 때문에 LSTM에서 기억 셀이 장기 의존 관계를 유지하리라 기대할 수 있으며 기울기 소실을 다소 방지할 수 있는 것이다.

6.3 LSTM 구현

  • 다음은 LSTM에서 수행하는 계산을 정리한 수식들이다.
    $$\begin{aligned} & \mathbf{f}=\sigma\left(\mathbf{x}_t \mathbf{W}_{\mathbf{x}}^{(\mathbf{f}}+\mathbf{h}_{t-1} \mathbf{W}_{\mathbf{h}}^{(\mathbf{)}}+\mathbf{b}^{(\mathbf{)})}\right) \\ & \mathbf{g}=\tanh \left(\mathbf{x}_t \mathbf{W}_{\mathbf{x}}^{(\mathbf{s})}+\mathbf{h}_{t-1} \mathbf{W}_{\mathbf{h}}^{(\mathbf{k})}+\mathbf{b}^{(\mathbf{)})}\right) \\ & \mathbf{i}=\sigma\left(\mathbf{x}_t \mathbf{W}_{\mathbf{x}}^{(\mathbf{)}}+\mathbf{h}_{t-1} \mathbf{W}_{\mathbf{h}}^{(\mathbf{h})}+\mathbf{b}^{(\mathbf{)})}\right) \\ & \mathbf{o}=\sigma\left(\mathbf{x}_t \mathbf{W}_{\mathbf{x}}^{(0)}+\mathbf{h}_{t-1} \mathbf{W}_{\mathbf{h}}^{(0)}+\mathbf{b}^{(0)}\right) \\ & \mathbf{c}_t=\mathbf{f} \odot \mathbf{c}_{t-1}+\mathbf{g} \odot \mathbf{i} \\ & \mathbf{h}_t=\mathbf{o} \odot \tanh (\mathbf{c})\end{aligned}$$
  • 아핀 변환(Affine Transformation) : 행렬 변환과 평행 이동(편향)을 결합한 형태, 즉 $\mathbf{x}\mathbf{W_x}+ \mathbf{h} \mathbf{W_h} + \mathbf{b}$ 형태의 식
  • 위의 4 수식에 포함된 개별적인 아핀 변환을 하나의 식으로 정리해 계산할 수 있다. 

  • 4개의 가중치를 하나로 모을 수 있고, 그러면 개별적으로 4번 수행하던 아핀 변환을 1회 계산으로 마칠 수 있다. 
  • 따라서 $\mathbf{W_x}, \mathbf{W_h}, \mathbf{b}$ 각각에 4개 분의 가중치가 포함되어 있다고 가정하고, 이때의 LSTM을 계산 그래프로 그려보면 다음과 같다.

  • 여기에서는 처음 4개분의 아핀 변환을 한번에 수행한다. 그리고 slice 노드를 통해 4조각으로 나누어 각 계산에 맞는 활성화함수를 거쳐 앞의 수식 계산을 수행한다. 
class LSTM:
  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

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

    # slice
    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 + i * g
    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
    
    def backward(self, dh_next, dc_next):
    Wx, Wh, b = self.params
    x, h_prev, c_prev, i, f, g, o, c_next = self.cache
    tanh_c_next = np.tanh(c_next)

    ds = dc_next + (dh_next * o) * (1 - tanh_c_next ** 2)
    dc_prev = ds * f

    di = ds * g
    df = ds * c_prev
    do = dh_next * tanh_c_next
    dg = ds * i

    di *= i * (1 - i)
    df *= f * (1 - f)
    do *= o * (1 - o)
    dg *= (1 - g ** 2)

    dA = np.hstack((df, dg, di, do))
    dWh = np.dot(h_prev.T, dA)
    dWx = np.dot(x.T, dA)
    db = dA.sum(axis=0)

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

    dx = np.dot(dA, Wx.T)
    dh_prev = np.dot(dA, Wh.T)
    
    return dx, dh_prev, dc_prev
  • __init__ : 초기화 인수로 가중치 매개변수 Wx와 Wh, b를 받으며 여기에는 4개분의 가중치가 담겨있다. cache는 순전파 때 중간 결과를 보관했다가 역전파 계산에 사용하려는 용도의 인스턴스 변수이다.
  • forwards(x, h_prev, c_prev) : 현 시각의 입력 x, 이전 시각의 은닉 상태, 이전 시각의 기억 셀을 인수로 받아 가장 먼저 아핀 변환을 수행한다. 이를 그림으로 살펴보자. 

  • 미니 배치 수 $N$, 입력 데이터의 차원 수 $D$, 기억셀과 은닉 상태의 차원 수 $H$일 때 위와 같이 계산되어 4개분의 아핀 변환 결과 A가 계산된다. A로부터 결과를 꺼낼 때에는 A[:, :H], A[:, H:2*H] 형태로 슬라이스해서 꺼내고 연산 노드에 분배하면 된다. 

  • 역전파 : slice 노드의 역전파를 살펴보면, slice 노드는 행렬을 4 조각으로 나누어 분배했기 때문에 역전파에서는 반대로 4개의 기울기를 결합해야 한다. 따라서 위 그림과 같이 slice 노드의 역전파에서는 4개의 행렬을 연결한다. numpy에서 np.hstack() 메서드를 사용하면 쉽게 처리할 수 있다. 

6.3.1 Time LSTM 구현

  • Truncated BPTT 에서 순전파의 흐름은 그대로 유지한 채로 역전파의 연결은 적당한 길이로 끊는다. 

  • 따라서 위처럼 은닉 상태와 기억 셀을 인스턴스 변수로 유지한다. 그렇게 다음 forward()가 호출되었을 때, 이전 시각의 은닉 상태(와 기억 셀)에서부터 시작할 수 있게 된다. 
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

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

    dxs = np.empty((N, T, D), dtype="f")
    dh, dc = 0, 0

    grads = [0, 0, 0]
    for t in reversed(range(T)):
      layer = self.layers[t]
      dx, dh, dc = layer.backward(dhs[:, t, :] + dh, dc)
      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

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

  def reset_state(self):
    self.h, self.c = None, None
  • TimeLSTM에서도 인수 stateful로 상태를 유지할지를 지정한다.

6.4 LSTM을 사용한 언어 모델

  • 5장에서의 TimeRNN 계층 -> Time LSTM 계층으로만 바꿔주면 모두 동일하다. 
class Rnnlm:
  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') # i, g, f, o
    lstm_Wh = (rn(H, 4 * H) / np.sqrt(H)).astype('f') # i, g, f, o
    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()

  def save_params(self, file_name ='Rnnlm.pkl'):
    with open(file_name, 'wb') as f:
      pickle.dump(self.params, f)
    
  def load_params(self. file_name='Rnnlm.pkl'):
    with open(file_name, 'rb') as f:
      self.params = pickle.load(f)
  • 이제 이 신경망을 사용해 PTB 데이터셋을 학습해 보자. 
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)

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

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

# 3. 매개변수 저장
model.save_params()
  • 이때 max_grad를 지정해 기울기 클리핑을 적용한다. 또 1.의 fit() 메서드에서 인수 eval_interval = 20은 20번째 반복마다 퍼플렉시티를 평가하라는 뜻이다. 데이터가 크기 때문에 모든 에폭이 아니라 20번 반복될 때 마다 평가하도록 구현되어있다. 2.에서는 학습이 끝난 후 테스트 데이터를 사용해 퍼플렉시티를 평가한다. 
def eval_perplexity(model, corpus, batch_size=10, time_size=35):
    print('퍼플렉서티 평가 중 ...')
    corpus_size = len(corpus)
    total_loss, loss_cnt = 0, 0
    max_iters = (corpus_size - 1) // (batch_size * time_size)
    jump = (corpus_size - 1) // batch_size

    for iters in range(max_iters):
        xs = np.zeros((batch_size, time_size), dtype=np.int32)
        ts = np.zeros((batch_size, time_size), dtype=np.int32)
        time_offset = iters * time_size
        offsets = [time_offset + (i * jump) for i in range(batch_size)]
        for t in range(time_size):
            for i, offset in enumerate(offsets):
                xs[i, t] = corpus[(offset + t) % corpus_size]
                ts[i, t] = corpus[(offset + t + 1) % corpus_size]

        try:
            loss = model.forward(xs, ts, train_flg=False)
        except TypeError:
            loss = model.forward(xs, ts)
        total_loss += loss

        sys.stdout.write('\r%d / %d' % (iters, max_iters))
        sys.stdout.flush()

    print('')
    ppl = np.exp(total_loss / max_iters)
    return ppl

  • 학습의 perplexity 추이를 보면, 총 4 에폭(1327 * 4회 반복) 학습을 수행했으며 perplexity가 순조롭게 낮아져 최종적으로는 100정도가 되었다. 테스트 데이터로 수행한 최종 평가는 약 136.07로 현재의 언어 모델이 다음에 나올 단어의 후보를 10,000개 중에서 136개 정도로 줄일때 까지 개선되었음을 알 수 있다. 

6.5 RNNLM 추가 개선

6.5.1 LSTM 계층 다층화

  • RNNLM의 성능을 높이기 위해 LSTM 계층을 깊게 쌓아 효과를 볼 수 있다. 위 그림은 LSTM 계층을 2 개 쌓은 모습이다. 이때 첫 번쨰 LSTM 계층의 은닉 상태가 두 번째 LSTM 계층에 입력된다. 이렇게 LSTM 계층을 몇 층 쌓아가며 더 복잡한 패턴을 학습할 수 있게 된다. 쌓는 층 수는 하이퍼파라미터이므로 처리할 문제의 복잡도/준비된 학습데이터의 양에 따라 적절하게 결정해야 한다.

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

  • LSTM 계층을 다층화하면 시계열 데이터의 복잡한 의존관계를 학습한 모델을 만들 수 있으나, 이는 종종 과적합(overfitting)을 일으킨다. 
  • 과적합을 억제하는 전통적인 방법으로 '훈련 데이터 양 늘리기', '모델 복잡도 줄이기' 등이 있으며, 모델의 복잡도에 페널티를 주는 정규화(regularization)의 방법 또한 존재한다.(ex-L2 정규화를 사용해 가중치 커질 경우 페널티 부과)
  • 드롭아웃(Dropout) : 훈련 시 계층의 뉴런 몇 개(ex- 50%)를 무작위로 무시하고 학습하는 방법도 일종의 정규화로 볼 수 있다.

  • 드롭아웃은 무작위로 뉴런을 선택해 앞 계층으로부터의 신호 전달을 막는다
class Dropout:
  def __init__(self, dropout_ratio=0.5):
    self.params, self.grads = [], []
    self.dropout_ratio = dropout_ratio
    self.mask = None
  
  def forward(self, x, train_flg=True):
    if train_flg:
      self.mask = np.random.rand(*x.shape) > self.dropout_ratio
      return x * self.mask
    else:
      return x * (1.0 - self.dropout_ratio)
    
  def backward(self, dout):
    return dout * self.mask
  • 드롭아웃 계층을 활성화 함수 뒤에 삽입하는 방법으로 과적합 억제에 기여할 수 있다.

  • RNN 모델에서는 Dropout 계층을 어디에 적용해야 할까? LSTM 계층의 시계열 방향으로 삽입할 경우 학습 시 시간이 흘러감에 따라 정보가 사라질 수 있다. 즉, 흐르는 시간에 비례해 드롭아웃 노이즈가 축적된다.
  • 따라서 위 그림 6-33과 같이 드롭아웃 계층을 깊이 방향 (상하 방향)으로 삽입하는 편이 선호된다. 이 경우는 시간 방향(좌우)으로 아무리 진행해도 정보를 잃지 않는다.
  • 이처럼 '일반적인 드롭아웃'은 시간 방향에 적합하지 않다. 따라서 시간 방향에 더 적합한 '변형 드롭아웃'(Variational Dropout)이 연구되어 시간방향으로 적용하는데에도 성공했다. 

  • 변형 드롭아웃에서는 같은 계층에 속한 드롭아웃들끼리 같은 mask를 공유한다. 이때 '마스크'란 데이터의 통과/차단을 결정하는 이진 형태의 무작위 패턴을 이른다. 같은 계층의 드롭아웃끼리 마스크를 공유함으로써 마스크가 '고정'되며, 그 결과 정보를 잃게 되는 방법도 '고정'되므로 정보가 지수적으로 손실되는 사태를 피할 수 있다.

6.5.3 가중치 공유(weight tying)

  • 가중치 공유 : 이처럼 Embedding 계층의 가중치와 Affine 계층의 가중치를 연결하는(공유하는) 기법. 이렇게 함으로써 학습하는 매개변수 수가 크게 줄어드는 동시에 정확도도 향상된다.
  • 직관적으로 가중치를 공유하면 학습해야 할 매개변수 수가 줄어들고, 그 결과 학습이 더 쉬워지기 때문이다. 또한 매개변수 수가 줄어듦은 과적합이 억제되는 이점이 있을 수 있다. 
  • 구현 : 어휘 수 - $V$, 은닉상태의 차원 수 - $H$. 그러면 Embedding 계층의 가중치는 형상이 $V \times H$이며, Affine 계층의 가중치의 형상은 $H \times V$가 된다. 따라서 Embedding 계층의 가중치를 전치하여 Affine 계층의 가중치로 설정하기만 하면 된다. 

6.5.4 개선된 RNNLM 구현

  • 여기에 총 3개의 개선점이 도입되었다. (1) LSTM 계층의 다층화, (2) 드롭아웃 사용, (3) 가중치 공유
class TimeDropout:
  def __init__(self, dropout_ratio=0.5):
    self.params, self.grads = [], []
    self.dropout_ratio = dropout_ratio
    self.mask = None
    self.train_flg = True
  
  def forward(self, xs):
    if self.train_flg:
      flg = np.random.randn(*xs.shape) > self.dropout_ratio
      scale = 1 / (1.0 - self.dropout_ratio)
      self.mask = flg.astype('float32') * scale
      return xs * self.mask
    else:
      return xs * (1.0 - self.dropout_ratio)
  
  def backward(self, dout):
    return dout * self.mask
class BetterRnnlm(BaseModel):
  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 1층
    lstm_Wx1 = (rn(D, 4 * H) / np.sqrt(D)).astype('f') # i, g, f, o
    lstm_Wh1 = (rn(H, 4 * H) / np.sqrt(H)).astype('f') # i, g, f, o
    lstm_b1 = np.zeros(4 * H).astype('f')
    # LSTM 2층
    lstm_Wx2 = (rn(D, 4 * H) / np.sqrt(D)).astype('f') # i, g, f, o
    lstm_Wh2 = (rn(H, 4 * H) / np.sqrt(H)).astype('f') # i, g, f, o
    lstm_b2 = np.zeros(4 * H).astype('f')
    # affine_W = (rn(H, V) / np.sqrt(H)).astype('f') -> Embedding 가중치 공유 예정. 
    affine_b = np.zeros(V).astype('f')

    # 3가지 개선!
    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) # 가중치 공유!
    ]
    self.loss_layer = TimeSoftmaxWithLoss()
    self.lstm_layers = [self.layers[2], self.layers[4]]
    self.dropout_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.dropout_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()

 

  • 마지막으로 해당 코드를 사용해 BetterRnnlm 클래스를 학습시켜보자. 여기에서 추가로, 매 에폭마다 검증 데이터로 perplexity를 평가하고 그 값이 나빠졌을 경우에만 학습률(learning rate)를 낮추는 방법을 도입해보자.
from common import config
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')

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')
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)
  • 매 에폭마다 검증 데이터로 퍼플렉시티를 평가하고 이것이 기존 퍼플렉시티보다 낮으면 학습률을 1/4로 줄인다. 이를 실행하면 최종 테스트 데이터 perplexity = 75.76 정도를 얻을 수 있다. 상당히 기존 모델 대비 향상되었음을 알 수 있다.

6.5.5 첨단 연구로 

  • PTB dataset에 대한 perplexity 연구 결과를 보면 52.8에 가깝게 낮아진 모델 (=AWD-LSTM-3layer LSTM(tied) + continuous cache pointer)도 존재함을 알 수 있다. 하지만 이 연구에서도 현 모델과 마찬가지로 LSTM을 여러 계층 쌓고, 변형 dropout과 DropConnect, 가중치 공유를 수행하는 등 공통적인 부분이 매우 많다.

6.6 정리

  • 단순한 RNN의 학습에서는 기울기 소실과 기울기 폭발이 문제가 된다.
  • 기울기 폭발에는 기울기 클리핑, 기울기 소실에는 게이트가 추가된 RNN(LSTM, GRU etc)이 효과적이다.
  • LSTM에는 input, forget, output gate 등 총 3개의 게이트가 있다.
  • 게이트에는 전용 가중치가 있으며, 시그모이드 함수를 사용해 0.0 ~ 1.0 사이의 실수를 출력한다.
  • 언어 모델 개선에는 LSTM 계층 다층화, 드롭아웃, 가중치 공유 등의 기법이 효과적이다. 
  • RNN의 정규화는 중요한 주제이며, 드롭아웃 기반의 다양한 기법이 제안되고 있다.