자연어처리(NLP) 11일차 (RNN)

정민수
21 min readJun 14, 2019

--

2019.06.14

출처 : https://wikidocs.net/book/2155

핵심키워드

  • RNN

순환 신경망(Recurrent Neural Network, RNN)

RNN(Recurrent Neural Network)은 시퀀스(Sequence) 모델이다. 즉, 입력과 출력을 시퀀스로 처리하는 모델이다. 번역기를 생각해보면 입력은 번역하고자 하는 문장. 즉, 시퀀스이다. 출력에 해당되는 번역된 문장 또한 시퀀스이다. 이러한 시퀀스 모델들을 처리하기 위해 고안된 모델들을 시퀀스 모델이라고 한다. 그 중에서도 RNN은 시퀀스 모델의 가장 대표적이고 기본적인 모델이다.

뒤에서 배우는 LSTM이나 GRU 또한 근본적으로 RNN에 속하므로, 이번 챕터를 제대로 이해해야 LSTM이나 GRU를 이해할 수 있다. 또한 이는 10챕터의 텍스트 분류, 11챕터의 태깅 작업, 12챕터의 기계 번역 작업을 이해할 수 있다.

참고로 순환 신경망과 재귀 신경망(Recursive Neural Network)은 전혀 다른 개념이다.

1. 순환 신경망(Recurrent Neural Network, RNN)

지금까지 배운 신경망들은 전부 은닉층에서 활성화 함수를 통해 나온 값은 오직 출력층 방향으로만 향했다. 이러한 신경망을 피드 포워드 신경망(Feed Forward Nuural Network)라고 한다. 입력층에서 출력층 방향으로만 향한다는 것이 당연한 소리처럼 보일 수 있겠지만, 꼭 그렇지만은 아닌 신경망들이 있다. RNN(Recurrent Neural Network) 또한 그 중 하나이다. RNN은 은닉층에서 활성화 함수를 통해 나온 결과값을 출력층 방향으로도 보내면서, 다시 은닉층의 다음 계산의 입력으로 보내는 특징을 가지고 있다.

이를 그리므로 표현하며 위와 같다. 실제로는 편향 b도 은닉층과 출력층의 입력으로 존재하지만 앞으로의 신경망의 그림에서 편향 b는 생략한다.

RNN에서는 은닉층에서 활성화 함수를 통해 결과를 내보내는 역할을 하는 노드(node)를 셀(cell)이라고 한다. 이 셀은 이전의 값을 기억하려고 하는 일종의 메모리 역할을 수행할 수 있으므로 이를 보통 메모리 셀 또는 RNN 셀이라고 표현한다.

은닉층의 메모리 셀은 각각의 시점(time-step)에서 바로 이전 시점에서의 은닉층의 메모리 셀에서 나온 값들을 계속해서 자신의 입력으로 보내는 재귀적 활동을 하고 있다. (앞으로는 현재 시점을 t로 표현하고, 이전 시점을 t-1, 다음 시점을 t+1와 같은 형식으로 표현한다.) 이는 현재 시점 t에서의 메모리 셀이 갖고 있는 값은 과거의 메모리 셀들의 값에 영향을 받은 것임을 의미한다.

그리고 메모리 셀이 다음 시점 t+1에서 다시 자신에게 보내는 이 값을 은닉 상태(hidden state)라고 한다. 다시 말해 현재 시점 t의 메모리 셀은 이전 시점 t-1에서의 메모리 셀에 보낸 은닉 상태값을 다시 계산을 위한 입력값으로 사용한다. 위의 그림에서 입력층의 xₜ 는 현재 시점 t에서의 입력값, 출력층의 yₜ는 현재 시점 t에서의 출력층의 출력값을 의미한다.

RNN을 표현할 때는 일반적으로 처음에 소개했던 그림처럼 화살표의 재귀 형태로 표현하기도 하지만, 위의 그림고 같이 재귀 화살표를 시점에 따라서 표현하기도 한다. 두 그림은 동일한 그림이므로 단지 화살표를 재귀의 형태로 표현하였느냐, 시점의 흐름에 따라서 표현하였느냐의 차이일 뿐 둘 다 동일한 RNN을 표현하고 있다.

위 그림은 입력과 출력의 길이에 따라서 달라지는 RNN의 다양한 형태를 보여준다. RNN은 입력 시퀀스와 출력 시퀀스의 길이가 달라도 상관없기 때문에 다양한 용도로 사용할 수 있다.

예를 들어 하나의 입력에 대해서 여러 개의 출력(one-to-many)의 모델은 하나의 사진 이미지 입력에 대해서 사진의 제목을 출력으로 내놓는 이미지 캡셔닝(Image Captioning) 작업에 사용할 수 있을 것이다. 사진의 제목은 단어들의 나열이므로 시퀀스의 형태를 가진 출력이다.

또한 다수의 입력에 대해서 하나의 출력(many-to-one)의 모델은 입력 데이터로부터 긍정적 감성인지 부정적 감성인지를 판별하는 감성 분류(Sentiment Classification), 또는 입력 데이터가 어떤 종류의 문서인지를 판별하는 문서 분류(Document Classification)에 사용할 수 있다. 이러한 예제들은 10챕터에서 배우는 텍스트 분류에서 배우게 된다.

그리고 RNN의 대표적인 용도인 다 대 다(many -to-many)이 모델의 경우에는 입력 문장으로부터 대답을 출력하는 챗봇과 입력 문장으로부터 번역된 문장을 출력하는 번역기, 또는 11챕터에서 배우는 개체명 인식이나 품사 태깅과 같은 작업 또한 이에 속한다.

이제 RNN의 은닉층, 출력층에 대한 수식을 정의해보자.

현재 시점 t에서의 은닉 상탱값을 hₜ라고 정의한다. 은닉층의 메모리 셀은 hₜ를 계산하기 위해서 총 두 개의 가중치 값을 갖게 된다. 하나는 입력층의 입력값을 위한 가중치 Wₓ이고, 하나는 이전 시점 t-1의 은닉 상태값인 h_(t-1)을 위한 가중치 Wₕ이다.

이를 식으로 표현하면 다음고 같다

RNN의 은닉층 연산은 실제로는 벡터와 행렬의 연산으로 이해할 수 있는데 각 벡터와 행렬의 크기를 추정해본다. 단어 벡터의 차원을 d라고 하고, 은닉 상태의 크기를 Dₕ라고 했을 때, 각 벡터와 행렬의 크기를 앞서 배운 텐서의 크기 표현 양식으로 표기하면 아래와 같다. 인공 신경망에서 사용되는 변수의 벡터와 행렬의 크기를 이해하고 있다면 Numpy로 인공 신경망 구현이 가능하다. 이는 아래에서 실습으로 진행한다.

배치 크기가 1이고, d와 Dₕ 두 값 모두를 4로 가정하였을 때, RNN의 은닉층 연산을 그림으로 표현하면 아래와 같다.

이 때 hₜ를 계산하기 위한 활성화 함수로는 주로 하이퍼볼릭탄젠트 함수(tanh)가 쓰이지만, ReLU로 바꿔 사용하는 시도도 있다. 첫 은닉 상태인 h₁을 계산하기 위해서는 이전 은닉 상태가 존재하지 않으므로 h₀의 값을 임의로 지정해주어야 하는데 0(0벡터)을 사용할 수도 있고, 임의로 정해줄 수도 있다.

출력층은 결과값인 yₜ를 계산하기 위한 활성화 함수로는 상황에 따라 다를텐데, 예를 들어서 이진 분류를 해야하는 경우라면 시그모이드 함수를 사용할 수 있고 다양한 카테고리 중에서 선택해야하는 문제라면 소프트맥스 함수를 사용하게 될 것이다.

위의 식에서 또 주목해야할 점은 각각의 가중치 Wₓ, Wₕ, Wy는 시점에 따라서 전혀 변화하지 않는다는 점이다. 즉, RNN의 (하나의 은닉층 내에서) 모든 시점에서 가중치 Wₓ, Wₕ, Wy는 동일하게 공유한다. 만약, 은닉층이 2개 이상일 경우에는 은닉층 2개의 가중치는 서로 다르다.

2. 양방향 순환 신경망(Bidirectional Recurrent Neural Network)

양방향 순환 신경망은 시점 t에서의 출력값을 예측할 때 이전 시점의 데이터뿐만 아니라, 이후 데이터로도 예측할 수 있다는 아이디어에 기반한다.

영어 빈칸 채우기 문제에 비유하여 보자.

Exercise is very effective at [         ] belly fat.1) reducing
2) increasing
3) multiplying

‘운동은 복부 지방을 [ ] 효과적이다’라는 영어 문장이고, 정답은 reducing(줄이는 것)이다. 그런데 위의 영어 빈 칸 채우기 문제를 잘 생각해보면 정답을 찾기 위해서는 이전에 나온 단어들만으로는 부족하다. 목적어인 belly fat(복부 지방)을 모르는 상태라면 정답을 결정하기가 어렵다.

즉, RNN이 과거 시점(time-step)의 데이터들을 참고해서, 찾고자하는 정답을 예측하지만 실제 문제에서는 과거 시점의 데이터만 고려하는 것이 아니라 향후 시점의 데이터에 힌트가 있는 경우도 많다. 그래서 이전 시점의 데이터뿐만 아니라, 이후 시점의 데이터도 힌트로 활용하기 위해서 고안된 것이 양방향 RNN이다.

양방향 RNN은 하나의 출력값을 예측하기 위해 기본적으로 두 개의 메모리 셀을 사용한다. 첫번째 메모리 셀은 앞에서 배운 것처럼 앞 시점의 은닉 상태(Forward States)를 전달받아 현재의 은닉 상태를 계산한다. 두번째 메모리 셀은 앞에서 배운 것과는 다르다. 앞 시점의 은닉 상태가 아니라 뒤 시점의 은닉 상태(Backward States)를 전달 받아 현재의 은닉 상태를 계산한다. 그리고 이 두개의 값 모두가 하나의 출력값을 예측하기 위해 사용된다.

앞서 RNN도 은닉층을 추가할 수 있다고 언급한 바 있다. 아래의 그림은 양방한 순환 신경망에서 은닉층이 추가되어 깊은(Deep) 양방향 순환 신경망의 모습을 보여준다.

이렇게 RNN에 은닉층을 추가하면, 학습할 수 있는 양이 많아지지만, 훈련 데이터 또한 많아야 한다. 즉, 많은 데이터가 필요하게 되므로 은닉층의 추가는 충분한 데이터가 있는지에 따라서 추가하는 것이 좋다. 양방한 RNN은 태깅 작업 챕터에서 사용된다.

이제 이러한 개념을 가지고 예제를 통해 실습을 해보자.

3. 케라스(Keras)로 RNN 구현하기

케라스로 RNN의 층. 정확히는 RNN의 은닉층을 추가하는 코드는 다음과 같다.

# 실제 RNN 은닉층을 추가하는 코드. 
model.add(SimpleRNN(hidden_size)) # 가장 간단한 형태

다른 인자를 사용할 때를 보자.

# 추가 인자를 사용할 때 
model.add(SimpleRNN(hidden_size, input_shape=(timesteps, input_dim)))
# 다른 표기
model.add(SimpleRNN(hidden_size, input_length=M, input_dim=N))
# 단, M과 N은 정수

hidden_size = 은닉 상태의 크기를 정의한다. 이는 simpleRNN이 은닉층에서 출력층으로 보내는 값의 크기(output_dim)와도 동일하다. 이 인자를 units(유닛의 수)라고 표현하기도 한다. 이 수치를 늘리면 보통 RNN의 용량(capacity)을 늘린다고 보면 되며, 중소형 모델의 경우 보통 128, 256, 512, 1024 등의 값을 가진다.

timesteps = 입력 시퀀스의 길이(input_length)라고 표현하기도 함. 시점의 수.

input_dim = 입력의 크기.

사실 인공 신경망에서 특징을 정의하는 것은 입력층과 출력층이 아니라, 은닉층이므로 앞으로는 은닉층 그 자체를 RNN이라고 명명하고 설명한다. RNN은 (batch_size, timesteps, input_dim) 크기의 3D 텐서를 입력으로 받는다. batch_size는 한 번에 학습하는 데이터의 개수를 말한다. 다만, 이러한 표현은 사람이나 문헌에 따라서, 또는 풀고자 하는 문제에 따라서 종종 다르게 기재되는데 위의 그림은 문제와 상황에 따라서 다르게 표현되는 입력 3D 텐서의 대표적인 표현들을 보여준다.

헷갈리지 말아야할 점은 위의 코드는 출력층까지 포함한 코드가 아니라 엄연히 은닉층에 대한 코드라는 점이다. 해당 코드가 리턴하는 결과값은 출력층의 결과가 아니라 하나의 은닉 상태 또는 정의하기에 따라 다수의 은닉 상태이다. 아래의 그림은 앞서 배운 출력층을 포함한 그림과 은닉층까지만 표현한 그림의 차이를 보여준다.

그렇다면 RNN은 위에서 설명한 입력 3D 텐서를 입력받아서 어떻게 은닉 상태를 출력할까? RNN은 사용자의 설정에 따라 두 가지 종류의 출력을 내보낸다. 메모리 셀의 최종 시점의 은닉 상태만을 리턴하고자 한다면 (batch_size, output_dim) 크기의 2D 텐서를 리턴한다. 하지만, 메모리 셀의 각 시점(time-step)의 은닉 상태값들을 모아서 전체 시퀀스를 리턴하고자 한다면 (batch_size, timesteps, ouput_dim) 크기의 3D 텐서를 리턴한다. 이는 RNN 층을 추가할 때, return_sequences 매개 변수에 True를 설정하여 설정이 가능하다. (output_dim은 앞서 코드에서 정의한 hidden_size의 값으로 설정된다.)

위의 그림은 timesteps=3일 때, return_sequences = True를 설정했을 때와 그렇지 않았을 때 어떤 차이가 있는지를 보여준다. return_sequences = True를 선택하면 메모리 셀이 모든 시점(time-step)에 대해서 은닉 상태값을 출력하며, 별도 기재하지 않거나 return_sequences=False로 선택할 경우에는 메모리 셀은 하나의 은닉 상태값만을 출력한다. 그리고 이 하나의 값은 메모리 셀의 마지막 시점(time-step)에서의 은닉 상태값이다.

뒤에서 배우는 LSTM이나 GRU도 내부 메커니즘은 다르지만 model.add()를 통해서 은닉층을 추가하는 코드는 사실상 SimpleRNN 코드와 같은 형태를 가지므로 참고하자. 이제 직접 실습을 통해 숫자를 기재하였을 때, 모델 내부적으로 출력 결과를 어떻게 정의하는지 보면서 RNN을 이해해보자.

from keras.models import Sequential
from keras.layers import SimpleRNN
model = Sequential()
model.add(SimpleRNN(3, input_shape=(2,10)))
# model.add(SimpleRNN(3, input_length=2, input_dim=10))와 동일함/
model.summary()
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
simple_rnn_2 (SimpleRNN) (None, 3) 42
=================================================================
Total params: 42
Trainable params: 42
Non-trainable params: 0
________________________________________________________________

출력값이 (batch_size, output_dim) 크기의 2D 텐서일 떄, output_dim은 hidden_size의 값인 3이다. 이 경우 batch_size를 현 단계에서는 알 수 없으므로 (None, 3)이 된다.

아래는 batch_size를 미리 정의하였을 경우, 출력의 크기가 어떻게 되는지 보여준다.

model = Sequential()
model.add(SimpleRNN(3, batch_input_shape=(8,2,10)))
model.summary()
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
simple_rnn_3 (SimpleRNN) (8, 3) 42
=================================================================
Total params: 42
Trainable params: 42
Non-trainable params: 0
________________________________________________________________

batch_size를 8로 기재하자, 출력의 크기가 (8, 3)이 된 것을 볼 수 있다. 이제 return_sequences 매개 변수에 True를 기재하여 출력값으로 (batch_size, timesteps, output_dim) 크기의 3D 텐서를 리턴하도록 모델을 만들어 보도록 하자.

model = Sequential()
model.add(SimpleRNN(3, batch_input_shape=(8, 2, 10), return_sequences=True))
model.summary()
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
simple_rnn_4 (SimpleRNN) (8, 2, 3) 42
=================================================================
Total params: 42
Trainable params: 42
Non-trainable params: 0
________________________________________________________________

출력의 크기가 (8, 2, 3)이 된 것을 확인할 수 있다.

4. 파이썬으로 RNN 구현하기

RNN 셀을 이해하기 위해서 직접 Numpy로 코드를 작성해보겠다. 앞서 RNN 셀에서 은닉 상태를 계산하는 식을 다음과 같이 정의하였다.

앞서 언급하였듯이 위의 식은 실제로 벡터와 행렬 연산으로 이루어진다. 각 변수의 벡터와 행렬의 크기를 이해하였다면 이를 Numpy로 구현 가능하다. 직접 RNN을 구현해보도록 한다.

# 아래의 코드는 의사 코드(Pseudocode)로 실제 동작하는 코드가 아니다.hidden_state_t = 0 # 초기 hidden_state 를 0벡터로 초기화
for input_t in input_length: # 각 timestep에 대해서 입력을 받는다.
output_t = tanh(input_t, hidden_state_t) # 각 timestep에 대해서 입력과 은닉 상태를 가지고 계산
hidden_state_t = output_t # 계산 결과는 현재 timestep의 은닉 상태가 된다.

우선 t 시점의 은닉 상태를 hidden_state_t라는 변수로 선언하였고, 입력 데이터의 길이를 input_length로 선언했다. 이 경우, 입력 데이터의 길이는 곧 총 시점의 수(timesteps)가 된다. 그리고 t 시점의 입력값을 input_t로 선언했다. 각 RNN 셀은 각 시점마다 input_t와 hidden_state_t(이전 상태의 은닉 상태)를 입력으로 활성화 함수인 하이퍼볼릭탄젠트 함수를 통해 현 시점의 hidden_state_t를 계산한다.

위의 코드는 이해를 돕기 위해서 (timesteps, input_dim) 크기의 2D 텐서를 입력으로 받았다고 가정하고 작성되었으나, 실제로 케라스에서는 (batch_size, timesteps, input_dim)의 크기의 3D 텐서를 입력으로 받는 것을 기억하자.

이제 RNN 셀이 아니라 RNN 층 자체를 구현해보자. 케라스로 비유하면 SimpleRNN을 구현하는 것이다.

import numpy as nptimesteps = 10
input_dim = 4
hidden_size = 8
# hidden state의 크기. 즉, hidden_size. 결과적으로 output_dim이 된다.
inputs = np.random.random((timesteps, input_dim))
# 입력에 해당되는 2D 텐서
hidden_state_t = np.zeros((hidden_size, ))
# 초기 은닉 상태는 0(벡터)로 초기화, 은닉 상태의 크기 hidden_size로 은닉 상태 만듬.
hidden_state_t
array([0., 0., 0., 0., 0., 0., 0., 0.])
Wx = np.random.random((hidden_size, input_dim))
# (8, 4) 크기의 2D 텐서 생성. 입력에 대한 가중치.
Wh = np.random.random((hidden_size, hidden_size))
# (8, 8) 크기의 2D 텐서 생성. 은닉 상태에 대한 가중치
b = np.random.random((hidden_size, ))
# (8, )크기의 1D 텐서 생성. 이 값은 bias 임.
print(np.shape(Wx))
print(np.shape(Wh))
print(np.shape(b))
(8, 4)
(8, 8)
(8,)
total_hidden_states = []# RNN
for input_t in inputs: # 각 시점에 따라서 입력값이 입력됨.
output_t = np.tanh(np.dot(Wx, input_t) + np.dot(Wh, hidden_state_t)+b)
# Wx * Xt + Wh * Ht-1 + b(bias)
total_hidden_states.append(list(output_t))
# 각 시점의 hidden state의 값을 계속해서 축적
print(np.shape(total_hidden_states))
# 각 시점 t별 RNN의 출력의 크기는 (timestep, output_dim)
hidden_state_t = output_t

total_hidden_states = np.stack(total_hidden_states, axis=0)
# 출력 시 값을 깔끔하게 하기 위함.
print(total_hidden_states)
# (timesteps, output_dim)의 크기. 이 경우 (10, 8)의 크기를 가지는 RNN 2D 텐서를 출력
(1, 8)
(2, 8)
(3, 8)
(4, 8)
(5, 8)
(6, 8)
(7, 8)
(8, 8)
(9, 8)
(10, 8)
[[0.93646037 0.86843131 0.79261245 0.98304653 0.97312617 0.59469036
0.61637029 0.96442909]
[0.9999964 0.99992672 0.9995054 0.99999944 0.99994911 0.9999767
0.99985392 0.99998516]
[0.99999648 0.99991793 0.99971593 0.9999992 0.99997999 0.99999556
0.99984872 0.99998874]
[0.99999888 0.99994918 0.99983867 0.99999986 0.9999874 0.99999771
0.99994573 0.99999647]
[0.9999966 0.99985143 0.99941126 0.99999895 0.99995823 0.99999412
0.9998688 0.99998034]
[0.9999993 0.99994494 0.9995957 0.99999982 0.99998686 0.99999519
0.99994388 0.99999138]
[0.99999897 0.99996659 0.99993153 0.99999989 0.99999264 0.99999864
0.99987739 0.99999656]
[0.99999748 0.99981742 0.99927514 0.99999909 0.99994581 0.99999426
0.99985333 0.99997178]
[0.99999906 0.99992839 0.99974262 0.99999981 0.99998155 0.99999716
0.99990593 0.99999092]
[0.99999949 0.99996681 0.99985971 0.99999992 0.99999275 0.99999779
0.99991641 0.99999483]]

--

--

정민수
정민수

No responses yet