챕터 10 - Recurrent Neural Networks
강의 링크: YouTube
• • •
1. 왜 RNN이 필요한가
지금까지 다룬 Vanilla NN이나 CNN은 구조 자체가 고정 크기 입력을 받아서 고정 크기 출력을 내놓는다. 이미지 하나를 넣으면 클래스 확률 벡터 하나가 나오는 구조, 즉 one-to-one이다. 그런데 실전에서는 이게 전부가 아니다. 이미지 한 장을 설명하는 문장을 생성해야 하거나, 긴 문장을 읽고 긍정/부정을 판단하거나, 영어 문장을 프랑스어 문장으로 번역해야 하는 경우가 있다. 이런 상황에서는 입력이나 출력의 길이가 가변적이라서 기존 NN 구조로는 감당이 안 된다.
RNN이 다루는 시나리오를 정리하면 크게 네 가지다.
- One-to-many: 고정 크기 입력 → 시퀀스 출력. 이미지 한 장을 받아서 설명 문장을 순서대로 생성하는 Image Captioning이 대표적이다.
- Many-to-one: 시퀀스 입력 → 고정 크기 출력. 영화 리뷰 텍스트를 읽고 긍정/부정을 판단하는 Sentiment Classification, 영상 프레임을 보고 동작을 분류하는 Action Recognition이 여기에 해당한다.
- Many-to-many (seq2seq): 시퀀스 입력 → 시퀀스 출력, 입력을 다 읽은 다음에야 출력. Machine Translation이 전형적인 예다.
- Many-to-many (동기화): 각 timestep마다 출력을 내보내는 구조. 영상에서 매 프레임을 실시간 분류하는 Video Classification per frame이 여기에 속한다.
▲ One-to-one부터 Many-to-many까지 RNN의 다양한 구조 유형 (출처: cs231n Lecture 10)
재미있는 점은 RNN이 시퀀스가 아닌 데이터를 굳이 시퀀스처럼 처리하는 데도 쓰인다는 거다. 이미지를 통째로 한 번에 보는 게 아니라 특정 위치에 시선을 순차적으로 옮기면서 여러 객체를 인식하거나(Visual Attention), 이미지를 왼쪽 위 픽셀부터 한 픽셀씩 순서대로 생성하기도 한다(DRAW). 고정 크기 입출력이라는 제약에서 벗어나는 게 핵심이다.
• • •
2. Vanilla RNN의 작동 원리
RNN의 핵심 아이디어는 내부 상태(hidden state)를 유지하는 거다. 시퀀스를 하나씩 읽으면서 그때그때 상태를 업데이트하고, 그 상태를 기반으로 출력을 내보낸다. 가장 단순한 형태인 Vanilla RNN에서 hidden state 업데이트 수식은 다음과 같다.
$$h_t = \tanh(W_{hh} h_{t-1} + W_{xh} x_t)$$\(h_t\)가 현재 timestep의 hidden state, \(h_{t-1}\)이 직전 timestep의 hidden state, \(x_t\)가 현재 입력이다. \(W_{hh}\)는 이전 hidden state를, \(W_{xh}\)는 현재 입력을 선형 변환하는 가중치 행렬이다. 이 둘의 합을 tanh에 통과시켜 새로운 hidden state를 만든다. 출력이 필요한 timestep에서는 hidden state에 가중치를 하나 더 곱해서 내보낸다.
$$y_t = W_{hy} h_t$$결정적으로 중요한 게 하나 있다. \(W_{hh}\), \(W_{xh}\), \(W_{hy}\) - 이 세 가중치 행렬은 모든 timestep에서 동일하게 공유된다. 같은 함수를 매 timestep에 반복 적용하는 구조니까 시퀀스 길이가 달라져도 모델 파라미터 수가 그에 맞게 늘어나지 않는다. 10개짜리 시퀀스든 1000개짜리 시퀀스든 동일한 W로 처리할 수 있다는 게 이 구조의 강점이다.
▲ Vanilla RNN의 핵심 수식. 동일한 가중치 W를 매 timestep에 반복 적용한다 (출처: cs231n Lecture 10)
• • •
3. Character-level Language Model
RNN이 구체적으로 어떻게 동작하는지 직관적으로 이해하기 가장 좋은 예제가 Character-level Language Model이다. 목표는 단순하다. 문자들의 시퀀스를 입력받아서 다음 문자가 무엇인지 예측하는 것이다.
예를 들어 어휘 집합이 {h, e, l, o} 4개뿐이고 학습 문자열이 “hello”라고 해보자. RNN은 “h”를 보면 “e”를 예측하고, “he”를 보면 “l”을 예측하고, “hel”을 보면 “l”을, “hell”을 보면 “o”를 예측하도록 훈련된다.
각 문자는 one-hot 벡터로 인코딩된다.
$$\text{"h"} = \begin{bmatrix}1 \\ 0 \\ 0 \\ 0 \end{bmatrix},\quad \text{"e"} = \begin{bmatrix}0 \\ 1 \\ 0 \\ 0 \end{bmatrix},\quad \text{"l"} = \begin{bmatrix}0 \\ 0 \\ 1 \\ 0 \end{bmatrix},\quad \text{"o"} = \begin{bmatrix}0 \\ 0 \\ 0 \\ 1 \end{bmatrix}$$이 벡터를 timestep마다 하나씩 RNN에 넣으면, RNN은 hidden state를 업데이트하면서 매 timestep마다 어휘 크기(4차원)의 logit 벡터를 출력한다. softmax를 씌우면 다음 문자에 대한 확률 분포가 되고, 정답 문자와 비교해서 cross-entropy loss를 계산한다.
▲ "hello" 예시로 본 Character-level Language Model. 각 timestep에서 logit 벡터를 출력하고 정답 문자와 비교한다 (출처: cs231n Lecture 10)
학습이 끝나면 임의의 문자를 시드로 주면 연쇄적으로 다음 문자를 예측해서 텍스트를 생성할 수 있다. 이때 생성 방식에는 두 가지가 있다. Argmax(Greedy)는 매 timestep마다 확률이 가장 높은 문자만 선택하는 방식이다. 결정론적이지만 항상 같은 문장만 만들고, 같은 구절이 무한 반복되는 루프에 빠질 수 있다. Sampling은 softmax가 만든 확률 분포에서 무작위로 샘플링한다. 높은 확률의 문자가 더 자주 뽑히되, 낮은 확률의 문자도 가끔 뽑힌다. 이 무작위성이 다채로운 텍스트 생성을 가능하게 한다.
실제로 이 방식을 Shakespeare 전집, Linux 커널 소스코드, LaTeX 논문들에 적용하면 내용은 엉터리지만 형식만큼은 꽤 잘 흉내 낸다. 더 흥미로운 건 학습 과정에서 내부 hidden state 안에 특정 역할을 전담하는 해석 가능한 뉴런이 자연스럽게 생겨난다는 거다. 아래 그림처럼 따옴표가 열려 있는지 닫혀 있는지를 추적하는 뉴런이 생겨나고, 줄 안에서 현재 위치를 추적하는 뉴런, if문 안에 있는지 추적하는 뉴런도 있었다. 명시적으로 가르친 게 아닌데 문자 예측이라는 단순한 목표만으로 이런 구조가 나타난다는 게 인상적이다.
▲ Shakespeare 텍스트에서 따옴표 열림/닫힘을 추적하는 해석 가능한 뉴런. 파란색이 따옴표 안, 빨간색이 따옴표 밖이다 (출처: cs231n Lecture 10)
• • •
4. Backpropagation Through Time
RNN의 학습은 어떻게 이루어질까. loss는 각 timestep마다 계산하고 전부 더한다. 그리고 이 loss의 gradient를 계산해서 가중치를 업데이트하는데, 이 과정을 Backpropagation Through Time(BPTT)이라 한다. 이름 그대로 시간 방향(과거 쪽)으로 gradient를 역전파한다.
▲ BPTT: 전체 시퀀스에 걸쳐 forward pass를 진행한 후, loss에서 시간 역방향으로 gradient를 전파한다 (출처: cs231n Lecture 10)
문제는 시퀀스가 길 때다. 1000개 timestep짜리 시퀀스라면 메모리도 많이 쓰고 계산도 느리다. 실용적인 대안이 Truncated BPTT다. 시퀀스를 청크(chunk) 단위로 잘라서 처리하는 방식이다. hidden state를 다음 청크로 계속 전달하되(forward in time forever), backpropagation은 현재 청크 범위 안에서만 수행(backpropagate for some smaller number of steps)한다. 정보의 연속성은 유지하면서 계산량은 청크 크기로 고정시킨다.
▲ Truncated BPTT: hidden state는 앞으로 계속 전달하되, gradient는 현재 청크 안에서만 역전파한다 (출처: cs231n Lecture 10)
• • •
5. Image Captioning: CNN과 RNN의 만남
RNN의 응용 중 가장 직관적인 예시 중 하나가 Image Captioning이다. 이미지 한 장을 입력받아서 그 이미지를 설명하는 문장을 만들어내는 태스크다. CNN이 이미지의 특성을 추출하고, RNN이 그 특성을 시작점으로 단어를 하나씩 생성한다.
구체적으로는 이렇게 동작한다. ImageNet으로 사전학습된 CNN에 이미지를 통과시켜서 feature 벡터를 꺼낸다. 이 벡터를 RNN의 첫 번째 hidden state에 주입한다. 그러면 RNN은 특수 토큰 <START>를 시작으로 첫 단어를 예측하고, 그 단어를 다시 다음 timestep의 입력으로 넣어서 두 번째 단어를 예측하고, 이걸 <END> 토큰이 나올 때까지 반복한다.
▲ CNN이 이미지 특성을 추출하면 RNN이 그 특성을 기반으로 단어 시퀀스를 생성한다 (출처: cs231n Lecture 10)
훈련 데이터로는 Microsoft COCO 데이터셋을 주로 쓴다. 이미지 하나에 사람이 직접 작성한 캡션이 여러 개씩 달려 있는 대규모 데이터셋이다.
이 방식을 한 단계 더 발전시킨 게 Image Captioning with Attention(Show, Attend and Tell)이다. 기본 방식은 이미지 전체를 벡터 하나로 압축해서 RNN에 주입하는데, Attention을 쓰면 RNN이 매 timestep마다 이미지의 특정 공간 위치에 집중할 수 있다. 매 단어를 생성할 때마다 어느 위치를 얼마나 참조할지를 동적으로 계산한다.
아래 그림은 “A bird flying over a body of water”를 생성하는 과정에서 각 단어를 생성할 때 모델이 어디에 시선을 두는지 보여준다. “bird”를 생성할 때는 새가 있는 부분에, “water”를 생성할 때는 수면 부분에 집중한다.
▲ Soft/Hard attention의 각 단어별 시선 집중 위치. 단어에 따라 이미지의 다른 영역에 집중한다 (출처: cs231n Lecture 10)
이 Attention 메커니즘은 이후 Transformer의 핵심 아이디어로 이어진다. RNN의 시대를 결국 Attention이 교체하는 흐름이 여기서 씨앗을 뿌린 셈이다.
• • •
6. Vanishing / Exploding Gradient 문제
Vanilla RNN을 학습할 때 가장 큰 걸림돌이 gradient 문제다. BPTT를 통해 backward pass를 진행하면, gradient가 매 timestep마다 \(W_{hh}^T\)를 곱하게 된다. timestep이 T개라면 \(W_{hh}^T\)를 T번 곱하는 셈인데, 이 반복 행렬 곱이 문제를 일으킨다.
\(W_{hh}\)의 특잇값(singular value)이 1보다 크면 gradient가 기하급수적으로 커진다. Exploding Gradient 문제다. 반대로 특잇값이 1보다 작으면 gradient가 기하급수적으로 작아진다. Vanishing Gradient 문제다. 먼 과거 timestep의 정보가 현재 gradient에 거의 기여하지 못하게 된다.
Exploding Gradient는 비교적 다루기 쉽다. Gradient Clipping이라는 기법을 쓰면 되는데, gradient의 L2 norm이 임계값을 넘으면 방향은 유지한 채 크기만 잘라버리는 방식이다.
$$\text{if } \|\nabla\| > \text{threshold}: \quad \nabla \leftarrow \frac{\text{threshold}}{\|\nabla\|} \cdot \nabla$$▲ Vanilla RNN의 gradient flow. 매 timestep마다 W와 tanh를 통과하며 gradient가 소멸하거나 폭발한다 (출처: cs231n Lecture 10)
Vanishing Gradient는 단순 클리핑으로 해결이 안 된다. gradient가 없는 거지 크기 문제가 아니기 때문이다. 모델 구조 자체를 바꿔야 한다. 그 해결책이 LSTM이다.
• • •
7. LSTM: 게이트로 기억을 제어하다
LSTM(Long Short-Term Memory)은 1997년 Hochreiter와 Schmidhuber가 제안한 구조다. Vanilla RNN이 하나의 hidden state만 갖는 것과 달리, LSTM은 두 가지 상태를 유지한다.
- Cell state \(c_t\): 장기 기억을 담당하는 메모리. 컨베이어 벨트처럼 정보가 시간을 따라 흘러가면서 게이트에 의해 조금씩 추가되거나 제거된다.
- Hidden state \(h_t\): 현재 timestep에서 외부로 노출되는 상태. 출력이나 다음 레이어로 전달된다.
4개의 게이트
LSTM에는 4개의 게이트가 있고, \(x_t\)와 \(h_{t-1}\)을 스택해서 가중치 행렬 하나로 한번에 계산한다.
$$\begin{pmatrix} i \\ f \\ o \\ g \end{pmatrix} = \begin{pmatrix} \sigma \\ \sigma \\ \sigma \\ \tanh \end{pmatrix} W \begin{pmatrix} h_{t-1} \\ x_t \end{pmatrix}$$i (Input gate): 현재 입력에서 얼마나 많은 정보를 cell state에 추가할지 결정한다. sigmoid를 써서 0~1 사이 값을 출력한다.
f (Forget gate): 이전 cell state에서 어떤 정보를 버릴지 결정한다. 0이면 완전히 잊고, 1이면 그대로 유지한다.
o (Output gate): cell state에 저장된 정보 중 얼마나 많은 부분을 현재 \(h_t\)로 내보낼지 결정한다.
g (Gate gate): 실제로 cell state에 더해질 후보 값이다. tanh를 써서 -1~1 사이 값으로 만든다.
Cell state와 Hidden state 업데이트
$$c_t = f \odot c_{t-1} + i \odot g$$ $$h_t = o \odot \tanh(c_t)$$\(\odot\)는 element-wise 곱이다. Cell state 업데이트에서 핵심은 덧셈 연산이다. Forget gate로 이전 기억의 일부를 지우고, Input gate와 Gate gate의 곱으로 새로운 정보를 더한다. 행렬 곱이 아니라 element-wise 덧셈이 중심이다.
▲ LSTM 내부 구조. x와 h를 스택해서 가중치 행렬 W로 4개 게이트를 한번에 계산한다 (출처: cs231n Lecture 10)
왜 LSTM은 gradient를 잘 전달하나
Vanilla RNN에서 gradient는 tanh와 \(W_{hh}^T\)를 timestep마다 반복해서 통과하면서 소멸했다. LSTM의 cell state는 이야기가 다르다. \(c_t = f \odot c_{t-1} + i \odot g\) 수식에서 보듯, cell state 간의 연결이 element-wise 덧셈이다. gradient를 이 경로로 역전파하면 forget gate \(f\)와의 element-wise 곱만 있을 뿐이다. 행렬 곱도 없고, tanh도 없다.
ResNet이 잔차 연결(skip connection)로 깊은 네트워크에서 gradient 문제를 완화한 것처럼, LSTM은 cell state의 덧셈 구조로 같은 문제를 시간 방향으로 해결한다. “Gradient highway”라고 표현하기도 한다.
▲ LSTM의 gradient highway. cell state(c)를 통해 gradient가 W 행렬 곱 없이 먼 과거까지 전달된다. ResNet의 skip connection과 같은 원리다 (출처: cs231n Lecture 10)
• • •
8. GRU와 Multilayer RNN
GRU (Gated Recurrent Unit)
GRU는 2014년 Cho et al.이 제안한 LSTM의 경량화 버전이다. LSTM의 cell state와 hidden state 두 가지를 하나로 합치고, 게이트 수도 4개에서 2개(reset gate, update gate)로 줄였다. 파라미터가 더 적으니 학습이 빠르고, 많은 태스크에서 LSTM과 비슷한 성능을 낸다. Greff et al. (2015)과 Jozefowicz et al. (2015)의 대규모 비교 연구에서도 특정 구조가 압도적으로 좋다는 결론은 나오지 않았다. 보통은 LSTM이 기본값이고, 빠른 실험이 필요할 때 GRU를 먼저 써본다.
Multilayer RNN
RNN도 쌓을 수 있다. 첫 번째 RNN의 hidden state 시퀀스를 두 번째 RNN의 입력으로, 두 번째 RNN의 hidden state를 세 번째 RNN의 입력으로 연결하는 식이다. 레이어가 깊어질수록 더 추상적인 시퀀스 표현을 학습하게 된다. 실제로는 2~4개 레이어를 쌓는 경우가 많다.
▲ Multilayer RNN과 LSTM의 수식. 레이어 l의 hidden state가 다음 레이어의 입력이 된다 (출처: cs231n Lecture 10)
이번 챕터를 한 줄로 요약하면 이렇다. RNN은 시퀀스 데이터를 다루는 유연한 도구인데, Vanilla RNN은 gradient 소멸 문제로 인해 긴 의존성을 학습하기 어렵다. LSTM이 cell state와 게이트 구조로 그 문제를 실질적으로 해결했고, 실전에서는 LSTM이 사실상 기본 선택지다. 그리고 이 챕터에서 등장한 Attention 아이디어가 이후 Transformer로 이어진다.
• • •
핵심 요약
| 개념 | 설명 |
|---|---|
| RNN 구조 유형 | One-to-one / One-to-many / Many-to-one / Many-to-many (seq2seq, 동기화) |
| Vanilla RNN | \(h_t = \tanh(W_{hh}h_{t-1} + W_{xh}x_t)\). 모든 timestep에서 가중치 공유 |
| BPTT | 시간 방향 역전파. Truncated BPTT로 긴 시퀀스를 청크 단위로 처리 |
| Image Captioning | CNN(특성 추출) + RNN(단어 생성). <START> / <END> 토큰으로 생성 제어 |
| Attention | 단어 생성 시 이미지 관련 위치에 동적 집중. Transformer의 핵심 아이디어 원형 |
| Exploding Gradient | Gradient Clipping으로 대응. norm 초과 시 방향 유지하고 크기만 자름 |
| Vanishing Gradient | \(W_{hh}^T\)의 반복 곱으로 gradient 소멸. LSTM의 cell state 구조로 해결 |
| LSTM | Cell state \(c_t\)와 4개 게이트(i, f, o, g)로 gradient highway 확보. \(c_t = f \odot c_{t-1} + i \odot g\) |
| GRU | LSTM 경량화. 2개 게이트, cell state 없이 hidden state만 사용 |
• • •
참고
- Fei-Fei Li, Justin Johnson, Serena Yeung - cs231n Lecture 10: Recurrent Neural Networks (Stanford, 2017)
- Hochreiter, S., & Schmidhuber, J. (1997). Long short-term memory. Neural computation, 9(8).
- Cho, K. et al. (2014). Learning Phrase Representations using RNN Encoder-Decoder. EMNLP.
- Xu, K. et al. (2015). Show, Attend and Tell: Neural Image Caption Generation with Visual Attention. ICML.
- Greff, K. et al. (2015). LSTM: A Search Space Odyssey.
- Karpathy, A. - The Unreasonable Effectiveness of Recurrent Neural Networks
- cs231n Course Notes: Recurrent Neural Networks
• • •
다음 포스팅: 챕터 11 - Detection and Segmentation
Object detection, semantic segmentation, instance segmentation을 다룬다. R-CNN 계열, YOLO, Faster R-CNN, FCN 등 핵심 아키텍처를 정리할 예정.