Note 423 RNN, LSTM, GRU, Attention
순환 신경망(Recurrent Neural Network)를 사용하면 시계열과 같은 Sequential한 데이터를 잘 예측할 수 있다. 이번 포스팅에서는 언어모델과 순환신경망에 대해 정리해보자.
언어 모델(Language Model)
언어 모델은 문장과 같은 단어 시퀸스에서 각 단어의 확률을 예측하는 모델이다. 지난 포스팅에서 다룬 Word2Vec도 이 언어 모델 중 하나라고 할 수 있는데 예시로 주변 단어들을 기반으로 중심단어가 등장할 확률을 예측하는 CBoW를 들 수 있을 것이다. CBoW는 중심 단어 $ W_{t} $ 를 예측하기 위해 중심단어와 연속적으로 이루어져 있는 $ W_{t-1}, W_{t-2}, W_{t+1}, W_{t+2} $ 를 활용하는 언어 모델이다.
이 언어 모델은 어떠한 단어가 등장했을 때 다른 단어들이 등장할 확률 즉, 조건부 확률의 개념을 사용하여 표현할 수 있다. “He is a good man”이라는 문장이 각 단어들의 조건부 확률로 어떻게 표현되는지 수식으로 나타낼 수 있다.
예시) “He is a good man”
\[P("He", "is", "a", "good", "man") = P("He") * P("is" | "He") * P("a" | "He\;is") * P("good" | "He\;is\;a") * P("man" | "He\;is\;a\;good")\]통계적 언어 모델(Statistical Language Model, SLM)
SLM은 단어의 등장 횟수를 기반으로 조건부 확률을 계산한다.
위의 예시에서 첫번째 항인 $ P(“He”) $ 를 구해보면, 전체 문장에서 “He”가 들어간 문장을 계산한다. 만약 100개의 문장에서 10개의 문장에 “He”가 들어가 있으면,
\[P("He") = \frac{10}{100}\]다음으로, “He”로 시작하는 문장에서 “is”가 바로 뒤에 오는 문장의 빈도를 또 계산한다. 위에서 계산된 10개의 문장에서 8개의 문장에 “is”가 뒤따르면,
\[P("is" | "He") = \frac{8}{10}\]위와 같은 방법으로 SLM에서는 해당 문장이 생성될 확률을 각 단어의 확률값으로 계산할 수 있다. 하지만, 빈도로 확률을 계산하기 때문에 한 번도 등장하지 않은 단어에 대해서는 확률이 0이 되므로 적절한 문장을 생성해낼 수 없다는 한계점을 가지고 있다. 이렇게 실제로 사용되는 표현인데 말뭉치에 등장하는 않아서 많은 문장에 등장하지 못하게 되는 문제를 희소(Sparsity) 문제 라고 한다. SLM에서는 이를 해결하기 위해 N-gram이나 smoothing, back-off와 같은 방법이 사용된다고 한다.
신경망 언어 모델(Neural Langauge Model)
언어 모델은 통계적 언어 모델 외에도 신경망 언어 모델이란 것이 있다. 신경망 언어 모델은 Word2Vec, FastText와 같이 횟수기반대신 임베딩 벡터를 사용함으로써 말뭉치에 등장하지 않은 단어가 있더라도 의미나 문법적으로 유사한 단어라면 확률을 계산할 수 있게 된다. 비슷한 위치에 있는 단어는 비슷한 의미를 같는다는 분포 가설이 활용되는 것이다.
순환 신경망 (RNN, Recurrent Neural Network)
신경망 언어 모델에는 RNN이라는 것이 있다. RNN은 데이터의 순서에 따라 의미가 달라지는 Sequential Data를 잘 처리하기 위해 고안된 신경망이다. 이미지 데이터나 일반적인 데이터 테이블은 데이터들의 index가 바껴도 의미가 크게 달라지지 않는다(Non Sequential Data). 하지만 시계열, 자연어와 같은 Sequential Data는 index가 바뀌면 데이터들의 의미가 크게 달라진다. 문장에서 단어의 순서를 바꾸면 문법적으로 이상한 문장이 될 수 있고, 주식과 같은 시계열 데이터는 전날과 오늘의 데이터를 맘대로 바꿔서 해석하면 올바른 예측이라고 할 수 없다.
- RNN의 구조
이미지출처: https://zetawiki.com/wiki/%EB%B0%94%EB%8B%90%EB%9D%BC_RNN
위에 그림을 보면 RNN은 3개의 화살표로 이루어져있고 $ X_{t} $ 는 입력층, $ h_{t} $ 는 은닉층, $ o_{t} $ 는 출력층(결과값)이다. 가장 아래있는 화살표는 입력 벡터가 은닉층에 들어가는 화살표, 가장 위에 있는 화살표는 은닉층에서 나온 출력 벡터가 output으로 변환되는 화살표, 가운데 있는 화살표는 은닉층에서 나온 벡터를 다시 은닉층의 입력으로 사용하는 것을 나타낸다. 은닉층에서 나온 결과를 다시 은닉층에 전달하는 순환구조이기 때문에 순환 신경망이라는 이름이 붙여졌다. 이 순환을 time-step(time-step는 몇번째 단어인지)별로 풀어서 표현하면 오른쪽 항처럼 나타낼 수 있다.
- RNN 작동 원리
이미지출처: http://dprogrammer.org/rnn-lstm-gru
1) 이전 은닉층에서 넘어온 출력 벡터와 은닉층의 가중치 행렬을 곱한다. (처음에는 이전 은닉층의 값을 0으로 활용하고, 새로 입력된 정보가 추가되면 점점 업데이트 된다.)
2) 현재층에서 입력된 벡터를 입력층에서의 가중치 행렬과 곱하고, 이전 은닉층의 결과와 편향을 더한다.
3) 더해서 만들어진 출력벡터를 하이퍼볼릭탄젠트 활성화 함수를 지나 하나는 output으로, 하나는 다음 time-step으로 전달한다.
! output과 다음 은닉층에 전달된 결과는 같고 $ h_{t} $ 로 표현할 수 있다.
\[h_{t1} = tanh(h_{t-1} * W_{h} + x_{t} * W_{x} + b)\]위 과정을 반복하면 데이터의 순서 정보가 기억되기 때문에 RNN은 Sequential 데이터를 다룰때 많이 사용된다.
- RNN 종류
이미지출처: http://karpathy.github.io/2015/05/21/rnn-effectiveness/
1) one to one: 1개의 벡터를 받아 1개의 Sequential한 벡터를 반환한다. 이미지를 입력받아서 이 이미지를 설명하는 문장을 만들어내는 Image captioning에 사용될 수 있다.
2) many to one: 여러개의 Sequential한 벡터를 입력받아 1개의 벡터를 반환한다. 감성 분석, 스팸 분류 등에 사용될 수 있다.
3) many to many(1): 여러개의 Sequential한 벡터를 입력받아 여러개의 벡터를 반환한다. 여러 단어를 입력받아 번역 문장을 출력하는 기계 번역에 사용된다.
4) many to many(2): 여러개의 Sequential한 벡터를 입력받아 입력받는 즉시 여러 벡터를 반환한다. 비디오를 프레임별로 분류할 때 사용된다. 프레임을 입력받으면 바로바로 어떤 프레임인가를 분류하는 것.
- RNN의 장단점
RNN은 모델이 이론적으로 간단하고, 어떤 길이의 sequential 데이터도 처리할 수 있다는 장점이 있다. 하지만, 벡터가 time-step순으로 순차적으로 입력되기 때문에 GPU의 장점인 병렬화를 사용할 수 없다. 또한, 역전파 과정에서 활성화 함수인 tanh의 미분값을 사용하게 되는데 tanh의 미분값은 가중치가 -4 ~ 4 밖의 범위에서는 거의 0에 가까운 값을 나타내기 때문에 층이 깊어질수록 앞에 있는 층에는 손실 정보가 잘 전달되지 않는 기울기 소실(Vanishing Gradient)이 발생한다. 가중치값이 조금만 커도 층이 깊어질수록 그 가중치값이 제곱의 제곱의 제곱이 되어서 앞층에는 손실 정보가 과하게 전달될 수 있는 기울기 폭발(Exploding Gradient)이 발생할 수도 있다.
! 하이퍼볼릭탄젠트 함수를 사용하면 미분값이 0 ~ 1 사이의 값이기 때문에 기울기가 소실된다. 추가로, 역전파 과정에서는 체인룰에 의해 여러 가중치가 곱해지게 되는데 가중치가 1보다 작다면 앞층으로 갈수록 가중치 값이 너무 작아져서 기울기가 소실되고, 1보다 크다면 앞층으로 갈수록(여러번 곱해질수록) 값이 너무 커져서 기울기가 폭발한다.
하이퍼볼릭탄젠트 -> 기울기 소실 야기, 역전파 과정에서의 가중치 -> 기울기 소실 or 폭발 야기
RNN에서 발생할 수 있는 기울기 소실은 LSTM, GRU를 사용함으로써 해결할 수 있다.
LSTM(Long Short Term Memory)
LSTM은 RNN에서 발생하는 기울기 소실 문제를 해결하기 위해 역전파 과정에서 전달되는 기울기 정보를 조정하기 위한 Gate를 추가한 모델이다. 보통 RNN을 사용한다고 하면, 단순한 RNN(Vanilla RNN) 이 아니라 LSTM을 의미한다.
LSTM에서는 위의 그림에서 $ f_{t} $ 부분에 해당하는 forget gate, $ i_{t} $ 와 $ g_{t} $ 에 해당하는 input gate, $ o_{t} $ 에 해당하는 output Gate가 존재한다. forget gate는 이전 time-step의 정보를 얼마나 유지할 것인지, input gate에서는 현재 time-step에서 새로 입력된 정보를 얼만큼 반영할 것인지, output gate에서는 forget과 input gate 에서 계산된 출력 정보를 다음 은닉층에 얼만큼 넘겨줄지를 정하는 게이트이다.
LSTM은 RNN과 달리 hidden state 외에도 cell state($ C_{t} $) 라는게 추가 되었다. cell-state는 활성화 함수를 거치지 않아서 정보의 손실이 없기 때문에 이전 시퀸스의 정보를 완전히 잃지 않으면서 뒷쪽 시퀸스에 현재 정보를 얼만큼 전달할 것인지 결정할 수 있다.
- LSTM 작동 원리
1) forget gate
이전 은닉층의 출력값과 현재 입력층의 입력값을 forget gate의 가중치 행렬과 곱해서 더한다. 계산된 결과를 시그모이드 함수를 통과시켜서 이 정보를 얼만큼 기억할 것인지 정한다. 시그모이드 값이 0이면 이전 정보를 모두 잊고, 1이면 모두 기억한다. 이 활성화값을 기반으로 이전 cell state의 정보를 얼만큼 기억할지 결정한다.
2) input gate
이전 은닉층의 출력값과 현재 입력층의 입력값을 input gate의 가중치 행렬과 곱해서 더한다. 계산된 결과를 시그모이드 함수를 통과시켜서 input gate에서 발생할 정보를 얼만큼 반영할 것인지 정한다($ i_{t} $). 시그모이드 값은 1에서 가까울수록 정보를 많이 저장한다는 의미이다.
3) output gate
따로 gate를 분류하진 않았지만, 이렇게 과거를 위한 $ f_{t} $, 현재 정보를 위한 $ i_{t} $ 가 구해졌다면 이들을 각각의 알맞은 cell state와 원소곱해서 더한후 cell state를 업데이트 시킨다($ C_{t} $).
이전 cell_state는 forget gate와 곱하면 되지만, 입력과 곱해져야 할 cell_state는 새로 계산을 해야하는데 cell_state의 가중치 행렬과 이전 은닉층, 현재 입력된 층의 행렬을 곱하고 편향을 더한다. 또한, 일반 gate처럼 시그모이드를 지나는 것이 아니라 하이퍼볼릭탄젠트 함수를 지나게 해서 input_gate와 곱해질 새로운 cell_state를 구한다. 하이퍼볼릭탄젠트는 1과 -1의 범위 중 1에 가까우면 최대한 많은 현재 정보를 저장, -1에 가까울수록 최소한의 정보를 저장한다는 의미이다.
그 후 과거와 현재 정보에 각각 알맞은 cell_state를 내적하고 결과를 더해서 앞으로 사용될 새로운 cell_state를 구한다.
output gate에서는 이전 은닉층의 출력값과 현재 입력층의 입력값을 output gate의 가중치 행렬과 곱해서 더한다. 계산된 결과를 시그모이드 함수를 통과시키고 위에서 업데이트 된 현시점의 cell state에 하이퍼볼릭탄젠트 함수를 통과시켜서 두 결과를 내적하면 출력값과 다음층으로 전달되는 값($ h_{t} $)을 최종적으로 구할 수 있게 된다. output gate에서 발생한 정보($ o_{t} $)를 다음 hidden state에 얼만큼 전달할지 정한다($ h_{t} $).
GRU(Gated Recurrent Unit)
이미지출처: http://dprogrammer.org/rnn-lstm-gru
LSTM은 각 gate마다 사용되는 가중치가 다르기 때문에 역전파 과정에서 업데이트 될 가중치가 많아서 연산량이 많다. 따라서, GRU는 LSTM의 단점을 보완하기위해 gate의 수를 줄여서 연산량을 줄이는 방법을 사용하는 모델이다. GRU에서는 Foreget gate와 Input gate를 하나의 gate($ z_{t} $)에서 관리한다. 하나의 gate를 사용하고, 이 용량을 100%이라고 해보자. 이전 정보를 얼만큼 반영할지 정한다면(Forget gate), 이 게이트에서는 전체 용량에서 이전 정보를 뺀 값 만큼 현재 정보를 반영할 수 있게 된다.
예시) 100%에서 이전 정보는 30%만 반영(Forget gate) -> 현재 정보는 자동으로 70% 반영(Input gate)
과거와 현재 정보를 각각 다른 gate에서 관리해서 두 정보의 비율을 따로 정해주는 것이라 하나의 gate에서 관리하게 된다. 따라서 과거 정보를 계속 따로 들고다녀야 했던 LSTM에서의 cell-state가 사라지고 cell-state의 $ c_{t} $ 와 hidden-state의 $ h_{t} $ 가 하나의 벡터 $ h_{t} $ 로 통일 되었다.
$ z_{t} $ 가 1이면 forget 게이트가 열리고, input gate가 닫히고, 0이면 forget gate가 닫히고, input gate가 열린다. 즉 1에 가까울 수록 이전 정보를 더 많이 고려하는 것. 또한, GRU에서는 output gate가 없어지고 전체 상태 벡터인 $ h_{t} $ 가 각 time-step 에서 출력되며, 이전 상태의 $ h_{t-1} $ 중 어느 부분을 출력할 지 새롭게 제어하는 Gate인 $ r_{t} $ 가 추가 되었다.
GRU는 파라미터 개수가 LSTM에 비해 현저하게 감소한 것이지만, 오늘날은 컴퓨터가 많이 발전해서 굳이 기울기 소실 문제 해결 면에서 좀 더 불확실한 GRU를 쓸 필요가 없다. 과거에 컴퓨터가 과도한 연산량을 처리하기가 버거울 때는 GRU를 쓰는 것이 더 적합했지만, 현재는 복잡한 연산을 더 잘 수행할 수 있어서 파라미터 갯수를 줄여가면서까지 GRU를 사용할 필요는 없다고 한다.
LSTM과 GRU는 기울기 소실 문제를 개선했지만, 결국 Vanilla RNN처럼 time-step을 활용하여 문제를 해결하기 때문에 병렬화를 사용할 순 없다.
Attention
RNN이 가진 가장 큰 단점 중 하나는 기울기 소실과 연관되는 장기 의존성(Long-term dependency)이다. 기울기 소실은 역전파 과정에서 전달 되는 기울기의 값이 너무 작아서 앞층으로 갈수록 가중치가 거의 학습되지 않은 것을 의미하는데 이것은 앞층의 정보를 잘 사용할 수 없다는 뜻이 된다. 이 문제를 해결하기위해 LSTM과 GRU를 사용하면서 기울기의 정도를 조정할 수 있는데 여기서 문제는 고정된 길이의 hidden-state 벡터에 모든 단어의 의미를 다 담아야한다는 것이다. LSTM과 GRU가 아무리 장기 의존성을 개선했다 하더라도, NLP를 예시로 들어서 만약 문장이 매우 길어지면 모든 단어 정보(이전에 등장했던 단어들의 정보까지 모두)를 고정된 hidden-state 벡터에 담는 것이 어렵다.
Attention은 각 인코더의 time-step 마다 생성되는 hidden-state 벡터를 간직함으로 이 문제를 해결한다. 번역을 예시로 들면 번역하고자 하는 문장을 받았을 때 각 단어에 대해 여러개의 hidden state 벡터가 생성이 될 텐데 단어가 총 n개라면 이 n개의 hidden-state 정보를 모두 간직해서 모든 단어가 입력되면 n개의 hidden-state를 디코더에 모두 전달한다. 고정길이의 hidden-state에 모든 정보를 저장하지 않고, 각 time-step마다 정보를 저장해서 넘겨주기 때문에 장기 의존성의 문제를 해결할 수 있게 된다.
- Attention 작동원리
작동원리를 알기 전 Attention에서 사용되는 기본 개념들을 알아보자.
Query: 찾고자 하는 정보를 검색, 질문하는 것.(현재 내가 입력한 단어와 너가 가지고 있는 단어들 중에 비슷한 단어가 뭐야? 디코더가 인코더에게 물어봄.)
Key: Query에서 질문한 것과 가장 비슷한 키워드를 찾아준다. 답을 내는 것. (내가 가지고 있는 단어들 중 너가 물어본 거랑 비슷한 단어는 이것,이것,이것 이야. 인코더가 디코더에게 대답)
Value: Key에 대한 값, 의미를 보여준다. (비슷한 단어들의 의미(값)는 이거야. 인코더가 디코더에게 키에 알맞은 값을 알려줌)
Attetion의 작동원리는 다음과 같다. (한국어 -> 영어 번역 예시, Dot product attention)
이미지출처: https://medium.com/joonghoonc-datastudy/seq2seq-with-attention-133d091cf113
1) 인코더에서 RNN으로 각 단어의 정보를 hidden state에 저장한다. (time-step마다 정보 저장)
2) 모든 단어에 대한 정보 저장이 끝나면 모든 hidden state의 정보를 디코더에게 넘겨준다.
3) 디코더는 문장의 번역을 시작한다는 start 신호와 함께 쿼리인 hidden state의 벡터를 구한다.(젤 처음에는 인코더에서 마지막으로 등장한 단어의 hidden state값을 이전 hidden state 값 즉, $ h_{0} $ 으로 사용.)
4) 쿼리를 통해 질문을 하면, Key인 인코더에서 넘어온 hidden-state 벡터들와 Query인 디코더의 hidden-state 벡터를 각각 내적한다(유사도를 구함).
5) 유사도 점수가 계산되면 이 유사도 점수가 softmax 함수를 지나 확률값이 나온다. 이 확률값을 기반으로 확률이 높은 단어가 번역할 때 더 도움이 많이 되는 단어라는 것을 참고할 수 있다.
6) 이 확률값과 value에 해당하는 인코더에서 넘어온 hidden-state 벡터를 곱해서 단어의 의미를 나타내는 context vector를 만들어낸다.
7) 만들어진 context vector와 디코더의 hidden-state 벡터를 결합하여 하나의 vector를 만들고 가중치 행렬곱, 하이퍼볼릭탄젠트 연산을 한다.
8) 연산 후 최종값을 출력층의 입력으로 사용해서 softmax 함수를 지나 번역된 단어를 예측한다.
Attention에서는 꼭 Dot product를 사용해서 유사도를 계산할 필요는 없고, general, concat 등 다양한 방법이 있다.
또한 Attention의 Transformer을 사용하면 LSTM, GRU는 해결하지 못했던 병렬화를 적용할 수 있다.
! NLP에서는 데이터를 (batch_size, sequence length, embedding dim)으로 넣는다. (데이터 갯수, 문장의 길이, 임베딩 벡터 차원)
LSTM 코드예제
import pandas as pd
import tensorflow as tf
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
from tensorflow.keras.models import Sequential
from keras.layers import LSTM, Activation, Dense, Dropout, Input, Embedding
from tensorflow.keras.optimizers import RMSprop, Adam
from tensorflow.keras.preprocessing.sequence import pad_sequences
from keras.preprocessing.text import Tokenizer
from keras.preprocessing import sequence
from keras.callbacks import EarlyStopping
%matplotlib inline
np.random.seed(42)
tf.random.set_seed(42)
# 데이터는 이미 불러왔다고 가정.
#토큰화
t = Tokenizer(num_words=3000)
t.fit_on_texts(X_train)
#정수 인코딩
X_train_encoded = t.texts_to_sequences(X_train)
X_test_encoded = t.texts_to_sequences(X_test)
#패딩
X_train_padded = pad_sequences(X_train_encoded, maxlen = 400)
X_test_padded = pad_sequences(X_test_encoded, maxlen = 400)
#총 단어 갯수 + 패딩 고려
vocab_size = len(t.word_index) + 1
print(vocab_size)
#파라미터 정의
embedding_dim = 300
batch_size = 128
epochs = 10
val_split = 0.2
#모델 만들기
model = Sequential()
model.add(Embedding(vocab_size, embedding_dim, input_length = 400))
# recurrent_dropout은 일반적인 dropout으로 인해 과거의 정보가 모두 사라지는 것을 방지하기 위해 사용되고
# 현재의 time step에서 다른 connection으로 이동하는 부분에만 drop out을 하는 것이다.
# 예를 들어, 현재 time step에서 LSTM에서 하이퍼볼릭탄젠트 함수를 지난 벡터가 이전의 cell-state(t-1) 벡터와 결합하기 전에
# 즉, connection이 일어나는 부분에서 drop out이 일어나서 일부 노드를 종료시킴.
model.add(LSTM(300, dropout = 0.2, recurrent_dropout=0.2))
model.add(Dense(1, activation = 'sigmoid'))
model.compile(loss='binary_crossentropy',
optimizer='adam',
metrics=['accuracy'])
es = EarlyStopping(monitor = 'val_loss', min_delta=0.0001, patience = 3, verbose = 1)
model.summary()
model.evaluate(X_test_padded, y_test)