공부/밑바닥부터 시작하는 딥러닝

[밑시딥2] Chapter 5. 순환 신경망(RNN)

셩잇님 2024. 7. 23. 21:07
반응형

 

 

본 포스팅은 '밑바닥부터 시작하는 딥러닝2'를 읽고 공부 및 학습 내용을 정리한 글입니다. 언제든지 다시 참고할 수 있도록, 지식 공유보단 개인적인 복습을 목적으로 포스팅하였습니다.

 

 

5.1 확률과 언어 모델

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

 

 CBOW 모델을 복습하면 우리는 t 번째 단어를 '타킷', t-1, t+1을 '맥락'으로 취급하여 아래와 같이 수식으로 나타냈다.

 

 

 그런데 여태까지 맥락을 항상 '좌우 대칭'으로 생각했지만, 이번에는 맥락을 왼쪽으로만 한정지어 생각해보자.

 

 

 맥락을 왼쪽으로 한정지을 경우 위와 같은 수식으로 나타낼 수 있다. CBOW 모델의 학습으로 수행하는 일은 손실함수를 최소화하는 가중치 매개 변수를 찾는 것인데, 본래 목적은 맥락으로부터 타깃을 정확하게 추측하는 것이다. 그렇다면 '맥락으로부터 타깃을 추측하는 것'을 어디에 이용할 쑤 있을까? 바로 여기서 언어 모델이 등장한다.

 

5.1.2 언어 모델

 

 언어 모델은 단어 나열에 확률을 부여한다. 예를 들어 'you say goodbye'는 0.092의 높은 확률을 가지는 반면 'you say good die'는 0.00~32의 낮은 확률을 가지는 것이 일종의 언어 모델인 셈이다. 이러한 언어 모델은 새로운 문장을 생성하는 용도로 이용할 수 있고, 단어 순서의 자연스러움을 확률적으로 평가할 수 있어 다음으로 적합한 단어를 샘플링 할 수 있다.

 

 동시 확률은 여러 사건이 동시에 일어날 확률을 뜻하는데, p(w1, ... wm)의 확률은 아래와 같이 쓸 수 있다.

 

 

5.1.3 CBOW 모델을 언어 모델로?

 

 그렇다면 word2vec의 CBOW 모델을 억지로 언어 모델에 적용하면 어떻게 될까? 다음과 같은 수식을 얻을 수 있다.

 

 

 그러나 이럴 경우 문제가 생기는데, 아래와 같이 맥락이 긴 문장을 제대로 답하지 못하는 것이다. 따라서 이를 해결하기 위해 나타난 기법이 RNN이다.

 

CBOW는 이를 해결할 수 없다.

 

5.2 RNN이란

 RNN(Recurrent Neural Network)의 Recurrent는 라틴어로 몇 번이나 반복해서 일어나는 일을 뜻한다. 우리말로 번역하면 재발한다, 순환한다 등으로 번역된다. 따라서 RNN은 순환하는 신경망을 의미한다.

 

5.2.1 순환하는 신경망

 

 순환을 위해서는 '닫힌 경로'가 필요하다. 닫힌 경로가 존재해야 데이터가 같은 장소를 반복해서 왕래하기 때문이다. 따라서 RNN의 특징으로는 닫힌 경로가 있따는 것이다. 이를 표현하면 아래 이미지와 같다.

 

 

Xt의 t는 시간을 의미하고, 이는 시계열 데이터가 RNN 계층에 입력값으로 표현된 것을 알 수 있다. 그리고 그 입력에 대응되는 Ht가 출력값을 나타낸다. 

 

5.2.2 순환 구조 펼치기

 

RNN 계층을 순환 구조로 표현하면 아래와 같이 표현할 수 있다.

 

 

 그림을 보면 알 수 있듯 RNN 계층은 해당 계층의 입력과 1개 전의 RNN 계층으로부터의 출력을 입력값으로 받는다. 따라서 이 때 수행하는 계산의 수식은 다음과 같다.

 

 

5.2.3 BPTT

 

 RNN의 역전파를 BPTT라고 한다. 

 

 

 다만 역전파를 수행할 때 해결해야 할 문제가 하나 있는데, 이는 바로 순전파에서 사용한 긴 시계열 데이터이다. 시간 크기가 커지는 것에 비례하여 BPTT가 소비하는 자원도 증가하기 때문이다.

 

5.2.4 Truncated BPTT

 

 따라서 이를 해결하기 위해 적당한 길이로 끊어준다. 이를 Truncated BPTT라고 한다. 단 순전파의 연결은 반드시 그대로 유지한 채 '역전파'의 연결만 끊어야 한다. 아울러 적당한 길이로 끊어주지 않을 경우 기울기의 값이 점점 작아져 0이 되어 소멸 할 수 있기 때문에 적당한 길이로 끊어줘야 한다.

 

 

 적당한 길이로 끊어준 Truncated BPTT의 학습은 다음과 같다.

 

 

5.2.5 Truncated BPTT의 미니배치 학습

 

 이를 바탕으로 미니배치를 통한 학습을 할 경우 각 미니배치의 시작 위치를 오프셋으로 옮겨준 후 순서대로 제공하면 된다.

 

 

5.3 RNN 구현

 지금까지 구현한 RNN은 RNN의 전체 모습을 나타낸다. 하지만 우리가 구현해야 할 것은 가로 방향으로 구현된 신경망인데, 이를 모듈화 하기 위해 신경망을 '하나의 계층'으로 구현한다.

 

 

 이렇게 시간안에서 한 단계의 작업을 수행하는 계층을 RNN 계층이라 하고, T개 단계분의 작업을 한꺼번에 하는 것을 Time RNN 계층이라 한다.

 

5.3.1 RNN 계층 구현

 

 차근차근 RNN 처리 한 단계만 수행하는 RNN 클래스부터 구현해보도록 하자. 

 

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 계층의 계산 그래프는 아래와 같다.

 

 

5.3.2 Time RNN 계층 구현

 

 RNN을 구현했으니 Time RNN을 구현해보도록 하자 Time RNN은 T개의 RNN 계층으로 구성되는데, 이 때 T는 임의의 수로 설정할 수 있다. Time RNN은 아까 위에서 본 것처럼 아래 이미지와 같이 구성되어 있다.

 

 

 그리고 여기에서 RNN 계층의 은닉 상태 h를 인스턴스 변수로 유지하는데, 이 은닉 상태는 계층을 '인계' 받는 용도로 사용된다.

 

 

 이를 통해 인계를 받을지, 받지 않을지 정하는 stateful 변수가 생성되며 이를 통해 인수를 조정할 수 있다.

 

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

 

 그림으로 표현하면 다음과 같다.

 

 

 

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

 이번 장의 목표는 RNN을 통해 '언어 모델'을 구현하는 것이다. 이제 언어 모델을 구현해보도록 하자.

 

5.4.1 RNNLM의 전체 그림

 

 

RNN을 이용한 언어모델 RNNLM의 전체 그림은 위와 같다. 이제 입력데이터로 "You say goodbye and i say hello."로 테스트를 해보자.

 

 

 RNNLM은 지금까지 입력된 단어를 '기억'하고 이를 바탕으로 다음에 출현할 단어를 예측하는 것을 볼 수 있다.

 

5.4.2 Time 계층 구현

 

 Time RNN을 구현했던 것과 마찬가지로 Embedding, RNN, Affine Softmax를 모두 시계열 데이터를 추가하여 구현하도록 하자.

 

 

 

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

 

 

 

5.5.2 언어 모델의 평가

 

구현을 모두 완료했으면 이를 평가해야 하는데, 그에 앞서 언오 모델의 '평가 모델'에 관해 알아볼 것이다. 언어 모델은 주어진 과거 단어(정보)로부터 다음에 출현한 단어의 확률 분표를 출력하는데 이 때 언어 모델의 예측 성능을 평가하는 척도로 퍼플렉 서티를 자주 이용한다. 퍼플렉서티는 '확률의 역수'이다.

 

 

 오른쪽 모델인 모델 2는 정답인 'say'의 확률을 0.2라고 예측했는데 이 때의 퍼블렉서티는 1/0.2로 5의 값을 가진다. 모델 1은 1/0.8로 1.25를 가진다. 따라서 퍼블렉서티는 값이 작을수록 좋다는 것을 알 수 있다. 그렇다면 이를 어떻게 해석할 수 잏을까? 이는 '분기 수'로 해석할 수 있다. 분기수는 다음에 취할 수 있는 선택사항의 수를 뜻한다.

 

 이제 단일 데이터가 아닌 다중 데이터일 때는 어떻게 처리 될까?

 

 

 위의 이미지와 같이 처리되는 것을 알 수있다. 뭐가되었던 간에 퍼블렉서티가 작아질수록 분기 수가 줄어 좋은 모델이 된다.

 

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에서는 데이터를 순차적으로 읽고, 3에서는 퍼블렉서티를 계산한다. 계산한 퍼블렉서티는 위 이미지와 같이 구할 수 있다.

 

5.5.4 RNNLM의 Trainer 클래스

 

 생략

 

5.6 정리

 

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

* RNN의 순환 경로를 펼침으로써 다수의 RNN 계층이 연결된 신경망으로 해숙할 수 있고, 보통의 오차역전파법으로 학습할 수 있다. (=BPTT)

* 긴 시계열 데이터를 학습할 때에는 데이터를 적당한 길이씩 자르고 (이를 '블록'이라 한다.) 블록 단위로 BPTT에 의한 학습을 수행한다. (= Truncated BPTT)

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

* Truncated BPTT에서는 순전파의 연결을 유지하기 위해 데이터를 '순차적'으로 입력해야 한다.

* 언어 모델은 단어 시퀀스를 확률로 해석한다.

* RNN 계층을 이용한 조건부 언어 모델은 이론적으로 등장한 모든 단어의 정보를 기억할 수 있다.

 

5.6 소감

 

 실질적으로 파이썬을 통해 타이핑을 하면서 RNN을 학습하고 있지 않아, 구현하기 위해 수식을 계산하는 부분에 있어서 이해하는 것이 조금 어려웠다. RNN의 순환 구조를 펼치는 부분과 Time RNN의 순전파 구현에 있어서 식이 이해가 되지 않아 한참을 책을 살펴보았다. 가면 갈수록 조금씩 어려워지고 있는 것은 사실인 것 같다. 그럼에도 불구하고 조금씩 새로운 것을 알아가서 정말 기쁘다! 😎

 

 

반응형