Recurrent Neural Networks
지금까지 단일 이미지에 대한 classification이나 localization 문제를 다루었다면, 이번에는 연속된 여러 데이터를 다루는 방법에 대해 알아보자. 비디오를 classification라는 문제나, 이미지에 대한 설명을 출력하는 image captioning과 같은 문제가 이에 해당되는데, 입력이나 출력의 길이가 정해져 있지 않은 경우 일반적인 일대일 방식의 뉴럴 네트워크로는 해결하기 어렵다. 이러한 문제들을 해결하기 위해 Recurrent Neural Networks(RNN)가 등장하였다.
RNN은 위와 같은 다이어그램으로 표현될 수 있다. 입력 시퀀스 $x$에 대해 출력 시퀀스 $y$를 출력하는 모델로, 입력과 출력 사이에는 hidden state $h$가 존재한다. RNN은 시간에 따라 변화하는 데이터를 다루기 때문에, 각 변수를 시간 $t$에 따라 $x_t$, $h_t$, $y_t$로 표현한다.
RNN은 현재 시점의 입력 데이터 $x_t$와 이전 시점의 hidden state $h_{t-1}$을 통해 현재 시점의 hidden state $h_t$를 계산하고, 이를 바탕으로 현재 시점의 출력 $y_t$를 계산하는 방식으로 동작한다. 이를 수식으로 나타내면 아래와 같다.
$h_t = f_W(h_{t-1}, x_t)$
이때 함수 $f_W$가 RNN에 해당하는 부분이다. 학습 파라미터로 가중치 행렬 $W$가 있다.
Vanilla RNN
Vanilla RNN은 가장 기본적인 형태의 RNN으로, hidden state를 계산하는 함수 $f_W$와 출력 $y$를 계산하는 과정이 아래와 같이 정의된다.
$h_t = \tanh(W_{hh}h_{t-1} + W_{xh}x_t + b_h)$
$y_t = W_{hy}h_t + b_y$
즉, vanilla RNN는 single FC layer와 tanh 함수로 구성된다. 파라미터로는 weight matrix $W_{hh}$, $W_{xh}$, $W_{hy}$와 bias $b_h$, $b_y$가 있다. Activation function으로 tanh를 사용하였는데, 이는 vanilla RNN이 개발되었을 당시에 더 좋은 activation function이 없었기 때문이다.
Computational Graph
Vanilla RNN의 computational graph는 위 그림과 같다. 이때 $h_0$은 전부 0으로 놓고 시작하거나 학습 파라미터로 두고 학습시키는 방법이 있는데, 두 방법 모두 성능에 큰 차이를 가져오지는 않는다. 이후 같은 weight matrix를 사용하는 동일한 함수 $f_W$를 여러 번 적용하게 되는데, 이를 통해 임의의 길이를 갖는 시퀀스를 처리할 수 있다.
각 인풋에 대응되는 아웃풋을 얻어야 하는 경우 위 그림과 같이 모든 hidden state $h_t$에 대해 출력 $y_t$를 계산하고, 각 출력에 대한 loss를 얻은 후 모두 더하여 최종 loss를 구하게 된다. 반면 최종적인 결과 $y_T$만 필요하다면 중간 결과물들은 생략하고 $y_T$에 대한 loss만을 계산하게 된다. 이 computational graph를 통해 $W$나 $h_0$에 대한 backpropagation을 수행할 수 있다.
이때 입력된 데이터들은 가중치 벡터 $W$에 그 정보가 점점 축적되기 때문에, 최종 hidden state $h_T$는 모든 인풋 데이터의 정보를 기반으로 계산된 결과라고 볼 수 있다.
기본 vanilla RNN을 응용한 버전으로 sequence to sequence(seq2seq) 문제가 있다. 임의의 길이를 갖는 시퀀스를 입력으로 받고, 또 다른 임의의 길이를 갖는 시퀀스를 출력으로 내보내게 된다. 번역과 같은 문제가 이에 해당하는데, 입력과 출력의 길이가 다를 수 있기 때문에 vanilla RNN으로는 해결하기 어렵다.
따라서 위 그림과 같이 many-to-one 구조와 one-to-many 구조를 결합하는 방식을 사용한다. 먼저 many-to-one을 이용해 임의의 인풋 시퀀스를 처리하여 하나의 hidden state $h_T$를 얻고, 이를 one-to-many 구조로 변환하여 출력 시퀀스를 생성한다.
Many-to-one과 one-to-many에서는 서로 다른 weight matrix $W$를 사용하는 것이 일반적이며, $h_T$가 두 번째 네트워크로 전달되는 과정은 여러 방식으로 구현될 수 있다.
Backpropagation Through Time
RNN의 학습은 backpropagation through time라는 방식으로 이루어진다. RNN의 최종 loss를 구하기 위해서는 모든 time step에 대한 loss를 더해야 하기 때문에, backpropagation을 위해서는 time step을 거슬러 올라가며 각 step에서의 gradient를 계산해야 한다. 즉 위 그림에서 오른쪽에서 왼쪽 방향으로 backpropagation이 진행된다.
그러나 이 방식은 긴 시퀀스를 사용할 경우 모든 element에 대해 forward pass와 backward pass를 진행해야 하기 때문에 연산량이나 메모리 사용량이 많아진다는 단점이 있다. 이를 해결하기 위해 Truncated backpropagation through time이라는 방법을 사용한다.
Truncated Backpropagation Through Time
Truncated backpropagation through time은 시퀀스를 작은 부분으로 나누어 각 부분이 끝날 때마다 gradient update를 진행하는 방법이다. 각 부분에 대해서만 update를 진행한다. 다음 부분으로 넘어갈 때는 이전 부분의 마지막 hidden state를 상수로 취급하여 초기 hidden state로 사용한다. 또한 다음 부분에서는 이전 부분에서 업데이트된 weight matrix를 사용한다.
Truncated backpropagation through time을 통해 아무리 긴 시퀀스를 사용하더라도 효율적으로 학습할 수 있다. 하지만 이 방식은 매 input에 대해 output이 출력되는 구조에서만 동작한다.
Vanilla RNN Gradient Flow
Vanilla RNN의 각 node 내부에서 gradient가 어떻게 backpropagation되는지 알아보자. 우선 hidden state를 계산하는 공식을 약간 수정해 보자.
$\begin{align*} h_t & = \tanh(W_{hh}h_{t-1} + W_{xh}x_t + b_h) \\ & = \tanh \left( \begin{pmatrix}W_{hh} & W_{xh}\end{pmatrix} \begin{pmatrix} h_{t-1} \\ x_t \end{pmatrix} + b_h \right) \\ & = \tanh \left( W \begin{pmatrix} h_{t-1} \\ x_t \end{pmatrix} + b_h \right) \end{align*}$
위와 같이 $h_{t-1}$과 $x_t$를 하나의 긴 1차원 벡터로 합치고, $W_{hh}$와 $W_{xh}$는 가로로 합쳐 하나의 weight matrix $W$로 나타낼 수 있다. 이렇게 되면 hidden state를 계산하는 과정을 아래의 다이어그램으로 나타낼 수 있다.
위의 다이어그램을 모아 전체 time step에 대한 gradient flow를 나타내면 아래와 같다.
위의 그림을 보면 각 step을 지날 때마다 tanh와 matrix multiplication을 거치는 것을 알 수 있다. 이때 tanh는 vanishing gradient를 야기할 수 있고, $W$가 너무 크거나 작으면 이 또한 exploding gradient나 vanishing gradient와 같은 문제를 불러올 수 있다.
Exploding gradient를 해결하기 위해서는 gradient clipping을 통해 weight matrix의 norm를 제한하는 방법이 있지만, vanishing gradient는 여전히 문제가 된다. 이로 인해 Vanilla RNN은 실제로 잘 사용되지 않고, 대신 LSTM과 같은 새로운 아키텍쳐가 등장하게 된다.
Long Short-Term Memory (LSTM)
LSTM은 vanilla RNN의 exploding gradient와 vanishing gradient 문제를 해결하기 위해 등장하였다. LSTM의 정의는 아래와 같다.
$\begin{pmatrix} i_t \\ f_t \\ o_t \\ g_t \end{pmatrix} = \begin{pmatrix} \sigma \\ \sigma \\ \sigma \\ \tanh \end{pmatrix} \left( W \begin{pmatrix} h_{t-1} \\ x_t \end{pmatrix} + b_h \right)$
$\begin{align*} c_t & = f_t \odot c_{t-1} + i_t \odot g_t \\ h_t & = o_t \odot \tanh(c_t) \end{align*}$
LSTM은 두 개의 state인 hidden state $h_t$와 cell state $c_t$를 사용하고 이를 계산하기 위해 4개의 gate $i_t$, $f_t$, $o_t$, $g_t$를 사용한다. Vanilla RNN에서는 $h_{t-1}$과 $x_t$를 이용해 $h_t$를 바로 계산했다면, LSTM은 4개의 gate 값을 거쳐서 state 값들을 구하는 것이다.
우선 각 gate를 계산하는 방법을 살펴보자. 먼저 weight matrix $W$는 기존보다 세로로 4배 길어졌다. 이는 4개의 gate에 해당하는 연산을 한 번에 하기 위해서이다. 계산된 $4h$ 길이의 벡터는 $h$씩 나누어져 서로 다른 gate의 계산에 사용된다. $i_t$, $f_t$, $o_t$는 이 벡터에 sigmoid 함수를 적용하고, $g_t$는 tanh 함수를 적용한다.
각 gate의 역할을 살펴보자.
- $i_t$: Input gate. 현재 시점의 input을 얼마나 받을지를 결정한다.
- $f_t$: Forget gate. 이전 시점의 cell state를 얼마나 잊을지를 결정한다.
- $o_t$: Output gate. 현재 시점의 cell state를 얼마나 많이 hidden state에 반영할지를 결정한다.
- $g_t$: Vanilla RNN에서의 hidden state와 계산 과정이 동일하다. 즉 현재의 입력 시퀀스와 이전 state들을 반영한 '정보'에 해당된다.
$i_t$, $f_t$, $o_t$는 sigmoid 함수의 결과이기 때문에 0과 1 사이의 값을 가지게 된다. 따라서 곱해지는 벡터의 element별로 그 요소를 유지할지 버릴지를 결정할 수 있는 것이다.
이제 각 state를 계산하는 방법을 살펴보자. 위 수식에서 $\odot$는 element-wise multiplication을 의미한다.
- $c_t$: LSTM 내부적으로만 전달되는 값이다. 이전 cell state $c_{t-1}$에 forget gate $f_t$를 곱해 이전 정보를 얼마나 잊을지를 결정하고, input gate $i_t$에 정보 $g_t$를 곱해 새로운 정보를 얼마나 받을지를 결정한다. 이 둘을 더하여 새로운 cell state $c_t$를 계산한다.
- $h_t$: 모델의 출력을 계산할 때 사용되는 값이다. Tanh와 output gate $o_t$를 이용해 cell state의 일부분이 반영된 hidden state $h_t$를 계산한다.
LSTM의 계산 과정을 다이어그램으로 나타내면 위와 같다.
LSTM의 gradient flow는 위와 같다. LSTM은 cell state를 통해 gradient가 전달되기 때문에 tanh나 matrix multiplication을 거치지 않는다. 따라서 exploding gradient뿐만 아니라 vanishing gradient 문제까지 해결된다. 질문: $h_t$를 거쳐서도 gradient가 전달되어야 하지 않는지?
Multilayer RNNs
Hidden state를 여러 층으로 쌓아 multilayer RNN을 만들 수 있다. 이때 각 층의 hidden state의 output이 다음 층의 hidden state의 input으로 사용된다. 이때 각 층마다 별도의 weight matrix를 사용한다. 이 외에도 다양한 방법으로 RNN 네트워크를 만들 수 있고, Neural Architecture Search(NAS) 또한 사용할 수 있다.
RNN results
Language Modeling
RNN을 이용해 풀 수 있는 문제 중 중요한 예시로 language modeling이 있다. Language modeling은 각 time step마다 글자가 하나씩 주어지고, 이 글자들의 시퀀스를 바탕으로 그 다음 step의 글자를 예측하는 문제이다. 예를 들어, 'h', 'e', 'l', 'o'의 4개의 글자가 주어지고 처음 4개의 글자가 'hell'일 때 그 다음 글자로 'o'가 올 것임을 예측할 수 있다. 이를 RNN 모델로 나타내면 아래와 같다.
우선 입력된 글자들은 one-hot encoding을 통해 벡터로 변환된다.
$h_t = \tanh(W_{hh}h_{t-1} + W_{xh}x_t)$
이후 위의 수식을 통해 hidden state를 계산하게 된다. 비슷한 방식으로 output layer까지 계산할 수 있다. 이때 Vanilla RNN의 경우 매 입력마다 출력을 내보내기 때문에, 이를 통해 다음 글자를 예측할 수 있다. 예를 들어 첫 번째 시퀀스의 경우 'h'를 입력으로 받아 'e'를 출력하게 된다.
이를 이용하여 train 시에는 각 time step마다 출력과 GT 사이의 loss를 계산하여 학습시킬 수 있고, test 시에는 한 step의 출력을 그 다음 step의 입력으로 사용하여 글자를 계속 이어나갈 수 있다.
앞서 언급했듯 RNN은 이전의 정보를 모두 활용하여 다음 글자를 예측하기 때문에 'h'를 이용하여 'e'를 예측하고, 'h'와 'e'를 모두 이용하여 'l'을 예측하는 식으로 동작한다.
Embedding Layer
위의 예시에서는 one-hot encoding을 통해 글자를 벡터로 변환했다. 하지만 이 방법을 이용해 hidden state를 계산할 경우, 불필요한 연산이 많아지게 된다.
$W_{xh}x_t = \begin{bmatrix} w_{11} & w_{12} & w_{13} & w_{14} \\ w_{21} & w_{22} & w_{23} & w_{24} \\ w_{31} & w_{32} & w_{33} & w_{34} \\ w_{41} & w_{42} & w_{43} & w_{44} \end{bmatrix} \begin{bmatrix} 1 \\ 0 \\ 0 \\ 0 \end{bmatrix} = \begin{bmatrix} w_{11} \\ w_{21} \\ w_{31} \\ w_{41} \end{bmatrix}$
그 이유는 위 수식을 보면 알 수 있는데, weight matrix에 one-hot encoding된 벡터를 곱하는 것은 해당 벡터가 나타내는 column을 그대로 가져오는 것과 동일하다. 따라서 column을 그대로 가져오는 연산을 별도의 layer로 분리하게 되는데, 이를 embedding layer라고 한다.
Embedding layer는 보통 위 그림과 같이 input layer와 hidden layer 사이에 위치한다. 또한 embedding layer를 사용함으로써 input을 one-hot encoding이 아닌, 단순히 어떤 column을 가져올지를 나타내는 정수로 표현할 수 있게 된다.
Interpreting Hidden Units
Hidden vector의 각 element는 각기 다른 feature를 검출하기 위해 학습된다. 이를 Input sequence의 각 글자에 대해 시각화하면 아래와 같다.
위와 같이 따옴표로 묶인 부분을 검출하는 부분이 있을 수 있다.
위와 같이 각 줄의 길이를 검출하는 부분이 있을 수 있다.
코드를 생성하는 모델의 경우 위와 같이 if문과 그 조건을 검출하는 부분이 있을 수 있다.
Image Captioning
CNN과 RNN을 결합하여 이미지에 대한 설명을 생성하는 image captioning 모델을 만들 수 있다. 이때 CNN은 transfer learning을 이용하여 이미지에 대한 feature vector를 추출하고, 이를 RNN에 입력하여 설명을 생성한다. 이때 이미지의 feature vector를 매 time step의 hidden state에 반영하기 위해 $W_{ih}$라는 새로운 weight matrix를 사용하여 수식에 포함시킨다.
매 time step마다 이미지의 feature vector를 반영하는 것이 아닌, initial hidden state 자체를 이미지의 feature를 이용하여 초기화하는 방법도 있는데 둘 모두 괜찮은 방법이다. 중요한 것은 hidden state를 계산하는 공식을 매 time step마다 동일하게 적용하는 것이다.
Summary
RNN은 시퀀스 데이터를 다루는 데 유용한 모델이다. Vanilla RNN은 vanishing gradient 문제를 가지고 있어 실제로 사용되지 않지만, LSTM과 같은 새로운 아키텍쳐를 사용하여 이 문제를 해결할 수 있다. RNN은 language modeling, image captioning과 같은 다양한 문제를 해결하는 데 사용될 수 있다. 하지만 완전히 새로운 개념이 등장하면서 RNN의 사용이 줄어들고 있는데, 이는 다음 강의에서 다룰 Attention이다.