[딥러닝 기초] Recurrent Neural Network (RNN)
Sequential data
Sequential data란, 순서가 있는 데이터를 말합니다. 앞의 정보가 뒤의 정보에 영향을 미치는 인과가 존재하는 것입니다.
예를 들면 사람이 하는 말, 기상 예보, 웹 로그 분석에 사용되는 데이터들을 말합니다.
이런 데이터들을 제대로 처리하기 위해선 모델로 하여금 순서에 대한 정보를 처리할 수 있게끔 하는 과정이 필요합니다.
일기 예보를 예로 들면, 지난 데이터들을 봤을 때 어느 시기에 온도가 하락했고, 어느 시기에 온도가 상승했는지를 알아야 올해에도 어느 시기에 온도가 하락하고 상승할 지를 예측할 수 있을 것입니다.
그러나 지금까지 살펴봤던 DNN이나 CNN 모델은 이런 순서에 대한 정보를 고려하는데 한계가 존재합니다.
위와 같이 DNN은 모든 뉴런끼리 연결하는 과정에서 데이터의 순서가 섞이게 됩니다. 이는 CNN에서도 마찬가지입니다.
따라서 이런 시간 정보를 좀 더 활용할 수 있는 방안이 없을까 해서 탄생한 것이 Recurrent Neural Network(RNN)입니다.
Recurrent Neurla Network (RNN)
RNN은 이런 순서 정보를 처리하기 위해 등장했습니다. 기본적으로 뉴런을 활용한다는 것은 DNN, CNN과 동일하지만, 이전 시퀀스 정보를 추론에 활용한다는 차별점을 가집니다.
좀 더 자세히는, RNN은 현재 시퀀스의 정보를 처리하는 DNN과 과거 시퀀스의 정보를 처리하는 DNN 으로 이루어져 있습니다. 현재 시퀀스의 출력 결과 $h_t$는 아래와 같이 계산됩니다.
$$h_t=tanh(x_tW^T_{ih}+b_{ih}+h_{t-1}W^T_{hh}+b_{hh})$$
현재 시점이 $t$라고 할 때, 과거의 정보 $h_{t-1}$와 현재 시퀀스 정보 $x_t$를 각각 뉴런으로 처리한 뒤, 그 값을 더하는 방식으로 과거 정보를 결합합니다. 합친 결과는 활성화 함수 tanh를 이용해 정규화 됩니다.
코드로 직접 구현한다면 아래와 같이 구현할 수 있습니다.
import torch.nn as nn
class RNN(nn.Module):
def __init__(self, input_dim, hidden_dim):
super(RNN, self).__init__()
self.seq_process = nn.Linear(input_dim, hidden_dim)
self.hid_process = nn.Linear(hidden_dim, hidden_dim)
self.tanh = nn.Tanh()
def forward(self, sequences):
h_t = torch.zeros(sequences.size(-1))
for x_t in sequences:
x = self.seq_process(x_t)
h = self.hid_process(h_t)
h_t = self.tanh(x+h)
return h_t
이전 시퀀스 정보가 존재하지 않는 맨 첫번째 시퀀스 데이터는 $h_{t-1}$을 0으로 초기화하여 연산을 수행합니다.
LSTM과 GRU
지금까지 살펴본 RNN은 시퀀스가 길어질수록 과거의 정보를 점차 잊게 된다는 단점을 가지고 있습니다.
RNN이 $h_t$를 출력하는 과정을 보면, 과거의 정보 $h_{t-1}$과 현재 시점의 정보 $x_t$를 1대1 비율로 합칩니다. 그러나 $h_{t-1}$에는 이미 0부터 t-1 시점까지의 정보가 결합되어 있는 상태이기 때문에 과거의 정보들은 사실 더 작은 비율로 반영되게 됩니다. 이런 현상은 시퀀스가 길어질수록 심해져서, 초기 시퀀스의 정보들은 거의 0에 가까운 비율만 반영되게 될 것입니다.
이런 문제를 해결하기 위해, RNN의 변형으로 LSTM과 GRU가 제시되었습니다. 이들의 주요 골자는, $h_{t-1}$과 $x_t$로부터 필요한 정보만 필터링하는 것입니다. 필터링을 통해 과거의 정보가 더 중요하다면 과거의 정보의 비율을 늘리고, 현재의 정보가 중요하면 현재 정보의 비율을 늘리는 식으로 필요한 정보가 유실되는 것을 막습니다.
LSTM
LSTM(Long Short Term Memory)은, cell state를 추가하여 성능을 보완합니다.
Cell state는 $h_{t-1}$과 $x_t$ 중에서 중요한 정보만 필터링하여 저장하는 역할을 합니다.
$h_{t-1}$과 $x_t$의 정보를 처리하는 방법은 아래와 같이 RNN과 같습니다.
$$info=\text{ActivateFunction}(W_hh_{t-1}+b_h+W_xh_{t-1}+b_x)$$
여기서 활성화 함수는 tanh와 sigmoid가 사용되는데, 여기서 sigmoid는 정보의 비율을 결정하는 역할을 합니다.
Sigmoid가 값의 범위를 0에서 1사이의 값으로 조정하는 특성을 이용하여 필요한 정보는 1에 가깝게, 필요없는 정보는 0에 가깝게 조정할 수 있습니다.
LSTM은 3개의 gate를 이용해 정보를 처리합니다.
1. Forget gate
Forget gate는 말 그대로 필요 없는 정보를 잊어버리는 역할을 수행합니다.
Sigmoid를 거친 $f_t$는 정보의 중요도를 결정하는 가중치와 같은 역할을 합니다. $f_t$와 $c_{t-1}$을 곱하면 $c_{t-1}$에서 중요한 정보는 1에 가까운 값이 곱해지면서 남게 되고, 중요하지 않은 정보는 0에 가까운 값이 곱해지면서 사라지게 됩니다.
2. Input gate
Input gate는 cell state에 input의 정보($x_t$)를 반영하는 역할을 수행합니다.
$h_{t-1}$과 $x_t$를 이용해 $g_t$와 가중치 $i_t$를 곱해 input의 정보에서 중요한 정보만 추출합니다. 이렇게 추출된 정보는 Forget gate를 거친 cell state의 값에 더해져 cell state의 값을 최종적으로 갱신하게 됩니다.
$$f_t=sigmoid(W_{hf}h_{t-1}+b_{hf}+W_{xf}x_t+b_{xf}) \\ g_t=tanh(W_{hg}h_{t-1}+b_{hg}+W_{xg}x_t+b_{xg}) \\ i_t=sigmoid(W_{hi}h_{t-1}+b_{hi}+W_{xi}x_t+b_{xi}) \\ c_t=g_t*i_t+c_{t-1}*f_t$$
3. Output gate
최종 output($h_t$)을 결정하는 역할을 수행합니다. Cell state와 $h_{t-1}$과 $x_t$의 정보를 결합한 가중치 $o_t$를 곱하여, 최종 결과를 출력합니다.
$$o_t=sigmoid(W_{ho}h_{t-1}+b_{ho}+W_{xo}x_t+b_{xo}) \\ h_t=tanh(c_t)*o_t$$
PyTorch로 직접 구현하자면 아래와 같이 구현할 수 있습니다.
class LSTM(nn.Module):
def __init__(self, input_dim, output_dim):
super(LSTM, self).__init__()
self.lins = [nn.Linear(input_dim, output_dim) for _ in range(8)]
self.sigmoid = nn.Sigmoid()
self.tanh = nn.Tanh()
def forward(self, sequences):
h_t = torch.zeros(sequences.size(-1))
c_t = torch.zeros(sequences.size(-1))
for x_t in sequences:
f = self.sigmoid(self.lins[0](h_t)+self.lins[1](x_t))
g = self.tanh(self.lins[2](h_t)+self.lins[3](x_t))
i = self.sigmoid(self.lins[4](h_t)+self.lins[5](x_t))
o = self.sigmoid(self.lins[6](h_t)+self.lins[7](x_t))
c_t = g * i + c_t * f
h_t = o * self.tanh(c_t)
return h_t, c_t
GRU
GRU(Gated Recurrent Unit)은 LSTM과 같이 cell state를 이용하진 않지만, 정보를 필터링하여 반영한다는 개념은 동일합니다.
역시 sigmoid를 이용해 가중치의 역할을 수행하며, 정보를 처리하는 과정이 LSTM보다 적고 가볍습니다.
$$r_t=sigmoid(W_{hr}h_{t-1}+b_{hr}+W_{xr}x_t+b_{xr}) \\ n_t=tanh\{(W_{hn}h_{t-1}+b_{hn})*r_t+W_{xn}x_t+b_{xn}\} \\ z_t=sigmoid(W_{hz}h_{t-1}+b_{hz}+W_{xz}x_t+b_{xz}) \\ h_t=z_th_{t-1}+(1-z_t)n_t$$
GRU는 아래와 같이 직접 구현할 수 있습니다.
class GRU(nn.Module):
def __init__(self, input_dim, output_dim):
super(GRU, self).__init__()
self.lins = [nn.Linear(input_dim, output_dim) for _ in range(6)]
self.sigmoid = nn.Sigmoid()
self.tanh = nn.Tanh()
def forward(self, sequences):
h_t = torch.zeros(sequences.size(-1))
for x_t in sequences:
r = self.sigmoid(self.lins[0](h_t)+self.lins[1](x_t))
n = self.tanh(self.lins[2](h_t)*r+self.lins[3](x_t))
z = self.sigmoid(self.lins[4](h_t)+self.lins[5](x_t))
h_t = z*h_t+(1-z)*n
return h_t
그래서 결과적으로 LSTM과 GRU 중에서 뭐가 좋냐라고 한다면, 정해진 답은 없습니다... 경우에 따라 GRU가 더 잘되는 경우도 있고 LSTM이 더 잘 되는 경우도 있습니다. RNN 구조를 활용하게 된다면 둘 다 실험해 보고 더 성능이 잘 나오는 레이어를 선택하는 것을 추천드립니다. 다만 LSTM보다 GRU가 조금 더 가볍긴 합니다.
RNN 레어어의 활용
지금까지 살펴 본 LSTM이나 GRU의 구조가 복잡해서 어떻게 쓰냐 싶을 수도 있습니다. 하지만 PyTorch나 Tensorflow에선 쉽게 사용할 수 있습니다.
import torch.nn as nn
rnn = nn.GRU( # nn.LSTM과 nn.RNN도 같음.
input_size: int, # 입력값(x)의 size
hidden_size: int, # hidden state(h)의 size
num_layers: int, # 레이어를 몇 개를 쌓을 것인가
bias: bool, # True면 bias를 추가함.
batch_first: bool, # True면, 입력 shape=(배치 크기, 시퀀스 길이, feature 크기)
# False면, 입력 shape=(시퀀스 길이, 배치 크기, feature 크기)
dropout: float, # Dropout rate를 설정한다.
bidirectional: bool, # True면 양방향 레이어로 설정한다.
)
import tensorflow as tf
# PyTorch와 달리 num_layers 옵션이 없고, 원한다면 직접 쌓아야 한다.
# Tensorflow는 input_dim은 자동으로 계산해주고,
# PyTorch의 batch_first=True와 같은 결과를 출력한다.
rnn = tf.keras.layers.LSTM(
units, # int, hidden_state dimension
use_bias, # bool, bias 사용 여부.
dropout, # dropout rate 설정.
)
# bidirectional한 RNN은 아래와 같이 구현한다.
bidirectional_rnn = tf.keras.layers.Bidirectional(
rnn, merge_mode="concat"
)
여기서 dropout과 bidirectional 부분은 처음 볼 것입니다.
dropout은 모델이 훈련셋 데이터를 외우는 현상(과적합)을 방지하기 위한 모델 규제 방식으로 다음에 다시 소개해드릴 예정입니다.
bidirectional은 RNN 레이어로 하여금 시퀀스를 정방향 뿐만 아니라 역방향으로도 보도록 하는 것입니다.
RNN에 Bidirectional을 사용하는 이유는 2가지 입니다.
첫번째로 모델이 초반 시퀀스의 정보를 잊는 현상을 완화하기 위함입니다. 역방향으로 시퀀스를 본다면 앞 부분의 시퀀스를 맨 마지막으로 보게 되면서 앞부분 정보들을 더 강하게 반영할 수 있게 됩니다.
두번째로는 맥락을 파악하는데 앞뒤 시퀀스 정보를 함께 봐야할 필요가 있는 경우가 있기 때문입니다. 예를 들어, "내일은 눈이 오나봐"라는 문장에서 '눈'의 의미를 파악할 때, 이를 정방향으로만 본다면 "내일은 눈"까지만 봐서는 '눈'이 하늘에서 내리는 눈인지 사람의 눈인지를 알기 어렵습니다. '눈'의 의미를 파악하기 위해선 그 뒤에 있는 '오나봐'라는 단어의 정보를 함께 활용할 수 있어야 합니다. 이럴 때 역방향 정보도 같이 고려하면 모델이 좀 더 잘 이해할 수 있을 것입니다.
실험
RNN 레이어의 효과를 알아보기 위해 문장 분류와 문장 생성 2가지 task를 실험해 봤습니다. 실험은 아래 링크의 간단한 챗봇 데이터셋을 활용했습니다.
https://github.com/songys/Chatbot_data
GitHub - songys/Chatbot_data: Chatbot_data_for_Korean
Chatbot_data_for_Korean. Contribute to songys/Chatbot_data development by creating an account on GitHub.
github.com
데이터셋의 구성은 아래와 같습니다.
질문(Q)과 답변(A), 해당 QA의 라벨(주제)을 담고 있는 csv파일입니다. 주제는 '일상', '이별', '사랑' 3가지로 분류되어 있습니다. 이를 활용해서 문장 분류와 문장 생성을 실험해 봤습니다.
학습 과정의 코드가 복잡할 수 있어서 여기선 결과만 보여드리겠습니다. 코드가 궁금하신 분들은 colab 노트북 참고하면 좋을 것 같습니다.
문장 분류
코드 : https://drive.google.com/file/d/1IUQ90kgWhnF2voFOoL74oDOpMhKkbw0_/view?usp=sharing
문장 분류는 질문과 답변이 주어졌을 때, 해당 QA의 주제를 모델이 분류하는 식으로 실험을 진행했습니다.
RNN을 이용해 처음부터 마지막 시퀀스까지 모두 훑어본 뒤 마지막으로 알맞은 분류를 예측하는 방식으로 훈련이 가능합니다.
결과는 다음과 같이 나왔습니다.
성능은 gru > dnn > lstm > rnn 순으로 나타났습니다. 문장은 시퀀스 데이터인데 어떻게 dnn이 lstm이나 gru보다 좋은 성능을 냈을까요?
완전 정확한 해석은 아닐 수 있지만, 문장을 분류하는데 시간 정보가 필요하지 않은 것일 수도 있습니다. 예를 들어 문장의 어느 시점에 등장했든 '좋아한다', '사랑' 등의 단어가 들어간다면 '이별'이나 '사랑'으로, 그렇지 않다면 '일상'으로 분류할 수 있을 것입니다. 이런 경우엔 굳이 순서 정보를 활용하지 않더라도 문장을 분류할 수 있을 것입니다.
여기서 얘기하고 싶은 것은 데이터가 시퀀스 데이터로 보인다고 해서 무조건 RNN 레이어가 더 우세한 것은 아니란 것입니다. (물론 여기서 GRU는 성능이 잘 나오긴 했지만) 데이터와 task를 잘 분석해서 정말 순서 정보가 필요한 task인지를 정확히 파악하는 것이 중요합니다.
문장 생성
코드 : https://drive.google.com/file/d/1H1QrXnlFMjZ-zOjBkM0lyTtukeHXcwN1/view?usp=sharing
다음으로 문장 생성을 시켜 봤습니다. 질문(Q)이 입력되었을 때, 모델이 올바른 답변(A)을 생성하도록 학습했습니다.
위 그림과 같이 t 시점의 토큰을 입력 받으면, 그 이전의 정보를 활용해 t+1 시점의 토큰을 예측하는 분류 문제로 텍스트 생성 모델을 학습시킬 수 있습니다.
Loss 그래프를 살펴보면, loss가 DNN > LSTM > RNN > GRU 순으로 큰 것을 확인할 수 있습니다.
특히 DNN 모델의 경우 학습되는 동안 다른 모델들에 비해 loss가 거의 줄어들지 않는데 학습이 잘 되지 않고 있다는 것을 알 수 있습니다.
모델에게 문장 생성을 직접 시켜보고 결과를 확인해 보겠습니다.
질문 : "어제 헤어졌어."
DNN : "그런 좋아하는 좋아하는 좋아하는 좋아하는 좋아하는 좋아하는 좋아하는 좋아하는 좋아하는 좋아하는 좋아하는 좋아하는 좋아하는 좋"
RNN : "그런 사람이 있을 거예요."
LSTM : "저랑 만나지 않는 것도 좋겠어요."
GRU : "마음이 아프네요."
질문 : "너무 더워서 짜증나."
DNN : "그런 좋아하는 좋아하는 좋아하는 좋아하는 좋아하는 좋아하는 좋아하는 좋아하는 좋아하는 좋아하는 좋아하는 좋아하는 좋아하"
RNN : "그런 사람이 있을 거예요."
LSTM : "잘 먹어도 좋을 거예요."
GRU : "아직 무뎌지네요."
어떤 모델이 가장 성능이 좋아보이나요? 일단 DNN과 나머지 모델의 차이는 확실한 것 같습니다.
DNN 모델은 정상적인 문장을 거의 만들어내지 못하는 반면에 RNN 종류들은 말이 되든 안되든 어느정도 문장을 생성해내는 모습을 보입니다.
문장 분류는 단어의 순서와 관계없이 특정 단어들의 존재만 신경 써도 되지만, 문장 생성은 확실히 글자의 순서를 반영하는 것이 성능에 큰 영향을 미치는 것 같습니다.
정리
이렇게 RNN 레이어에 대해 알아봤습니다. RNN은 딥러닝 모델로 하여금 순서 정보를 함께 고려할 수 있도록 하는 구조입니다.
RNN의 한계점은 시퀀스가 길어질수록 초기의 정보를 점점 잊어버린다는 것입니다. 이런 한계를 개선하기 위해 LSTM, GRU와 같은 구조들이 제시되었지만, 완전히 해결하지는 못했습니다. (이는 나중에 attention 구조를 이용해 극복해냅니다.) 그렇지만 이전의 DNN과 달리 순서 정보를 활용할 수 있다는 점은 분명한 메리트이고, 이를 실험을 통해 확인할 수 있었습니다.
이렇게 활용할 수 있는 무기가 DNN, CNN 외에 RNN까지 하나 더 늘어났습니다!