목차
핵심 한 줄 정리
RNN은 이전 hidden state와 현재 입력을 같은 가중치로 반복 처리해 가변 길이 sequence를 모델링하며, BPTT로 시간축을 따라 학습하지만 순차 계산과 vanishing gradient라는 한계가 있어 LSTM과 현대적인 state space model로 발전했다.
반드시 기억할 개념
Sequence modeling의 형태
일반적인 image classifier는 하나의 고정된 크기 입력을 받아 하나의 고정된 크기 출력을 만든다. Sequence modeling에서는 입력이나 출력이 가변 길이 sequence가 될 수 있다.
| 형태 | 입력과 출력 | 예시 |
|---|---|---|
| one-to-one | 고정 입력 하나, 고정 출력 하나 | image classification |
| one-to-many | 고정 입력 하나, 가변 길이 출력 | image captioning |
| many-to-one | 가변 길이 입력, 출력 하나 | 여러 video frame을 이용한 video classification |
| many-to-many | 가변 길이 입력과 가변 길이 출력 | video captioning |
| synchronized many-to-many | 각 입력마다 출력 하나 | frame마다 수행하는 video classification |
Many-to-many에서는 입력 sequence와 출력 sequence의 길이가 반드시 같을 필요는 없다. RNN의 기본 구조는 각 time step의 입력마다 hidden state를 갱신하고 출력을 만드는 synchronized many-to-many 형태에서 가장 쉽게 볼 수 있으며, 입출력 위치를 바꾸면 다른 형태에도 적용할 수 있다.
RNN의 hidden state
RNN은 이전까지 처리한 정보를 hidden state에 저장한다. 현재 hidden state 는 현재 입력 와 직전 hidden state 로 계산한다.
여기서 는 time step이고 는 parameter 를 사용하는 state update function이다. 중요한 점은 time step마다 새로운 를 사용하는 것이 아니라 sequence 전체에서 같은 를 공유한다는 것이다.
RNN을 시간축으로 펼치면 다음과 같은 unrolled computational graph가 된다.
각 에는 해당 time step의 도 함께 들어온다. 따라서 를 계산하려면 이 먼저 필요하다.
출력 는 hidden state를 task의 output dimension으로 바꾸는 별도의 함수로 계산한다.
는 hidden state를 실제 출력으로 변환하는 학습 가능한 weight이다. 입력 차원을 , hidden dimension을 , 출력 차원을 라고 하면 shape는 다음과 같다.
는 sequence 처리를 시작하기 위한 initial hidden state이다. Zero vector를 사용할 수도 있고, 특정 값으로 직접 정하거나 학습 가능한 vector로 둘 수도 있다.
Vanilla RNN
Vanilla RNN은 보통 다음 식으로 hidden state를 갱신한다.
는 현재 입력을 hidden dimension으로 옮기고, 는 이전 hidden state를 현재 hidden state로 변환한다. 두 결과를 더한 뒤 activation function을 적용한다.
두 weight를 하나로 묶어 쓰기도 한다.
가장 단순한 output layer는 다음과 같다.
Vanilla RNN에서 를 자주 사용하는 이유는 출력 범위가 과 사이이고 zero-centered이기 때문이다.
같은 변환을 반복해도 activation이 무한히 커지는 것을 어느 정도 막을 수 있다. 다만 의 derivative는 대부분 보다 작으므로 긴 시간축을 역전파할 때 vanishing gradient의 원인이 된다.
%% title: Vanilla RNN을 시간축으로 펼친 구조
%% caption: 각 time step은 같은 parameter W를 공유하며, 이전 hidden state와 현재 입력으로 다음 hidden state를 만든다.
flowchart LR
h0["h₀"] --> cell1["RNN Cell<br/>shared W"]
x1["x₁"] --> cell1
cell1 --> h1["h₁"]
h1 --> cell2["RNN Cell<br/>shared W"]
x2["x₂"] --> cell2
cell2 --> h2["h₂"]
h2 --> cell3["RNN Cell<br/>shared W"]
x3["x₃"] --> cell3
cell3 --> h3["h₃"]
h1 --> y1["y₁"]
h2 --> y2["y₂"]
h3 --> y3["y₃"]
예시: 연속된 두 개의 1 찾기
RNN의 행렬 계산을 직접 보기 위해 다음과 같은 synchronized many-to-many 문제를 생각할 수 있다.
- 입력은 과 로 이루어진 sequence이다.
- 현재 값과 직전 값이 모두 이면 을 출력한다.
- 그 외에는 을 출력한다.
- 계산을 단순하게 보기 위해 대신 ReLU를 사용한다.
입력 sequence를 다음과 같이 두자.
원하는 출력은 다음과 같다.
출력을 결정하려면 현재 입력과 직전 입력을 알아야 한다. 계산을 간단하게 만들기 위해 항상 인 항까지 붙인 3차원 hidden state를 사용한다.
초기 상태는 앞에서 두 개의 을 본 것으로 간주해 다음처럼 둔다.
Hidden state는 다음 recurrence로 계산한다.
현재 입력을 hidden state의 첫 번째 성분에 넣는 는 column vector이다.
이전 hidden state의 첫 번째 성분을 두 번째 성분으로 옮기고 마지막 상수 을 유지하는 는 다음과 같다.
따라서 두 항을 더하면 원하는 hidden state가 만들어진다.
현재 값과 직전 값을 더하고 을 빼면 두 값이 모두 일 때만 양수가 된다. 이를 위한 output weight는 다음과 같다.
| 0 | 0 | -1 | 0 |
| 0 | 1 | 0 | 0 |
| 1 | 0 | 0 | 0 |
| 1 | 1 | 1 | 1 |
전체 sequence의 hidden state와 출력은 다음과 같이 변한다.
| 1 | 0 | 0 | |
| 2 | 1 | 0 | |
| 3 | 0 | 0 | |
| 4 | 1 | 0 | |
| 5 | 1 | 1 | |
| 6 | 1 | 1 |
이 예시에서는 hidden state의 의미와 weight를 손으로 정했지만, 실제 RNN에서는 더 큰 hidden state를 사용하고 , , 를 gradient descent로 학습한다.
Backpropagation Through Time
RNN을 unroll하면 하나의 weight가 시간축의 여러 위치에서 반복 사용되는 computational graph가 된다. 각 time step에 loss가 있는 many-to-many task라면 전체 loss는 다음과 같다.
같은 가 모든 time step에서 사용되므로 각 loss에서 shared weight로 흐르는 gradient도 모두 합쳐야 한다.
Computational graph에서는 time step마다 별도 weight가 있는 것처럼 local gradient를 계산할 수 있지만, 실제로 모두 같은 parameter를 가리키므로 최종 gradient는 하나로 누적된다. 이렇게 시간축으로 펼친 graph를 따라 수행하는 역전파가 Backpropagation Through Time, BPTT이다.
Many-to-one task에서는 마지막 hidden state만 이용해 하나의 loss를 만들 수 있다. Video 전체에 정보가 퍼져 있다면 모든 hidden state를 average pooling이나 max pooling한 뒤 하나의 출력을 만드는 방법도 있다.
One-to-many task에서는 첫 입력 이후 자리에 zero vector를 넣거나, 직전에 생성한 을 다음 입력으로 다시 넣을 수 있다. Autoregressive language model과 image captioning에서는 이전 output을 다음 입력으로 사용하는 방식이 흔하다.
Truncated BPTT
Sequence가 길어지면 forward activation과 gradient를 모든 time step에 대해 memory에 보관해야 한다. 이를 줄이기 위해 sequence를 고정 길이 window로 나누는 truncated BPTT를 사용한다.
첫 번째 chunk의 마지막 hidden state 값은 다음 chunk의 initial hidden state로 넘긴다.
다만 chunk 경계에서 computational graph는 detach한다.
Hidden state의 값은 이어지지만 다음 chunk의 gradient가 이전 chunk 전체로 역전파되지는 않는다. 이 방식은 memory를 크게 줄이는 대신 window보다 먼 dependency를 직접 학습하기 어려워진다. 전체 graph가 memory에 들어간다면 full BPTT가 더 정확하지만, 긴 sequence를 처리하려면 truncated BPTT가 필요한 경우가 많다.
%% title: Truncated BPTT의 Chunk 경계
%% caption: Hidden state는 다음 chunk로 전달하지만, 역전파 그래프는 경계에서 detach해 gradient가 이전 chunk로 흐르지 않게 한다.
flowchart LR
h0["h₀"] --> w1["Chunk 1<br/>t = 1 … K"]
w1 --> hk["hₖ"]
hk --> detach1["detach"]
detach1 --> w2["Chunk 2<br/>t = K+1 … 2K"]
w2 --> h2k["h₂ₖ"]
h2k --> detach2["detach"]
detach2 --> w3["Chunk 3<br/>t = 2K+1 … 3K"]
w1 -. "gradient" .-> h0
w2 -. "gradient" .-> detach1
w3 -. "gradient" .-> detach2
Character-level language model
Character-level language model은 현재까지의 문자를 보고 다음 문자를 예측한다. 입력 문자는 one-hot vector로 표현할 수 있다.
RNN은 와 로 를 만들고, output layer에서 다음 문자에 대한 logit과 probability를 계산한다.
학습 target은 현재 문자 다음에 실제로 등장한 문자 이다. 각 time step의 cross-entropy loss를 더하면 된다.
한 time step에서 정답 문자의 logit이 인데 다른 문자의 logit이 이라면 model은 잘못된 문자에 더 높은 확률을 준다. 이 경우 정답 확률이 낮아 해당 time step의 loss가 커진다.
Embedding layer
실제 구현에서는 one-hot vector를 그대로 넣기보다 embedding matrix에서 해당 문자의 row를 가져온다.
는 vocabulary에 있는 문자 또는 token 수이고 는 embedding dimension이다. One-hot vector와의 matrix multiplication으로도 같은 연산을 표현할 수 있다.
입력 종류와 embedding dimension을 모두 로 잡으면 는 가 되지만, 일반적으로 두 값이 같을 필요는 없다. Embedding은 discrete index를 dense vector로 바꾸며 다른 weight와 함께 학습된다.
Autoregressive decoding
Test time에는 이전에 생성한 문자를 다음 time step의 입력으로 다시 넣는다.
매번 가장 확률이 높은 문자를 고르는 방법은 greedy decoding이다.
Greedy decoding은 같은 initial input과 model이면 항상 같은 sequence를 만든다. 더 다양한 결과를 만들려면 softmax distribution에 따라 sampling할 수 있다. 예를 들어 두 문자의 확률이 각각 와 이면 그 비율을 반영해 다음 문자를 선택한다.
여러 candidate sequence를 유지하며 이후 probability까지 비교하는 beam search도 있다. Model이 probability를 계산하는 것과 그 distribution에서 실제 sequence를 선택하는 것은 서로 다른 단계이다.
Character-level RNN은 매우 작은 구현으로도 Shakespeare의 sonnet이나 Linux source code의 pattern을 학습할 수 있다. Training 초반에는 의미 없는 문자열을 만들지만 점차 word 형태, 문체, 괄호, indentation, keyword 같은 구조를 생성한다. 현대 language model은 character 대신 여러 character를 묶은 token을 주로 사용하지만, 이전 token으로 다음 token을 예측하는 autoregressive objective는 같다.
Hidden state의 해석
RNN의 hidden state 각 성분을 문자 위치와 함께 시각화하면 일부 cell이 무엇을 추적하는지 볼 수 있다. activation을 사용하는 경우 값은 부터 사이이며, activation 변화를 문자마다 색으로 표시할 수 있다.
학습된 character-level RNN에서는 다음과 같은 cell이 관찰되었다.
- quote가 시작되면 켜지고 닫는 quote가 나오면 꺼지는 cell
- line 길이와 newline에 가까워지는 정도를 추적하는 cell
- 현재 위치가
ifstatement 안인지 추적하는 cell - comment의 시작과 끝을 추적하는 cell
- code의 indentation과 nesting depth를 추적하는 cell
모든 hidden unit이 이렇게 명확하게 해석되는 것은 아니다. 그래도 RNN이 필요한 과거 정보를 hidden state의 특정 방향에 저장할 수 있음을 보여주는 예이다.
Multi-layer RNN
RNN도 여러 layer로 쌓을 수 있다. Layer 의 hidden state는 같은 layer의 이전 time step과 아래 layer의 현재 time step 출력에 의존한다.
첫 번째 layer에서는 실제 입력을 사용한다.
같은 layer 안에서는 time step마다 weight를 공유하지만 서로 다른 layer는 각각 별도의 weight를 갖는다. 시간축과 depth축을 함께 펼치면 grid 형태의 computational graph가 되며, 오른쪽 위의 state를 계산하려면 왼쪽과 아래쪽 state가 먼저 계산되어야 한다.
Vanishing gradient와 exploding gradient
Vanilla RNN의 pre-activation을 다음과 같이 두자.
한 time step의 hidden state Jacobian은 다음과 같다.
먼 과거의 hidden state로 gradient를 보내려면 이 Jacobian을 반복해서 곱해야 한다.
의 derivative는 최대가 이고 대부분의 영역에서 보다 작다.
보다 작은 값이 시간축을 따라 반복해서 곱해지면 gradient가 0에 가까워진다. 여기에 도 매번 곱해진다. 의 큰 singular value가 보다 훨씬 크면 특정 방향의 vector가 계속 커져 exploding gradient가 생길 수 있고, singular value가 작으면 계속 줄어들어 vanishing gradient가 심해진다.
Exploding gradient는 gradient clipping으로 완화할 수 있다.
는 허용할 gradient norm의 threshold이다. Gradient가 너무 크면 크기만 줄이고 방향은 유지한다. 반면 이미 거의 0이 된 gradient에서 장기 dependency 정보를 복구하는 것은 어렵기 때문에 vanishing gradient가 더 근본적인 문제이다.
LSTM
LSTM은 Vanilla RNN의 vanishing gradient와 장기 정보 손실을 줄이기 위한 recurrent architecture이다. Hidden state 외에 cell state 를 두고, gate를 통해 어떤 정보를 저장하고 잊고 출력할지 정한다.
입력 와 이전 hidden state 을 붙인 vector를 다음과 같이 둔다.
각 gate와 candidate value는 다음과 같다.
는 input gate, 는 forget gate, 는 output gate이고 는 새로 기록할 candidate value이다.
Cell state는 매 time step마다 반드시 새로운 변환을 거치지 않고 additive update로 이어진다. Forget gate가 정보를 완전히 지우지만 않는다면 gradient와 장기 정보를 Vanilla RNN보다 쉽게 전달할 수 있다.
LSTM이 vanishing gradient를 완전히 없애는 것은 아니다. 다만 필요한 정보를 얼마나 유지하고 버릴지 gate가 학습하고, 긴 dependency를 위한 비교적 직접적인 경로를 제공하므로 Vanilla RNN보다 안정적으로 학습된다.
이 구조는 ResNet의 skip connection과 비슷한 직관을 가진다. ResNet은 depth 방향으로 layer를 건너뛰는 경로를 만들고, LSTM은 time 방향으로 정보를 전달하는 cell state 경로를 만든다.
%% title: LSTM Cell의 Gate와 State 흐름
%% caption: Cell state cₜ는 forget gate로 유지할 정보를 결정하고 input gate로 새 정보를 더한다. Output gate는 현재 hidden state로 노출할 부분을 결정한다.
flowchart LR
z["zₜ = [hₜ₋₁, xₜ]"] --> fg["Forget gate<br/>fₜ"]
z --> ig["Input gate<br/>iₜ"]
z --> cand["Candidate<br/>gₜ"]
z --> og["Output gate<br/>oₜ"]
cprev["cₜ₋₁"] --> keep["⊙"]
fg --> keep
ig --> write["⊙"]
cand --> write
keep --> add(("+"))
write --> add
add --> ct["cₜ"]
ct --> tanh["tanh"]
tanh --> expose["⊙"]
og --> expose
expose --> ht["hₜ"]
RNN의 장점과 한계
RNN의 장점은 다음과 같다.
- architecture 자체에는 고정된 maximum context length가 없다.
- 과거 정보가 hidden state에 남아 있다면 오래전 time step의 정보를 사용할 수 있다.
- sequence가 길어져도 parameter 수는 증가하지 않는다.
- 모든 time step에서 같은 weight와 update rule을 사용한다.
- recurrent update의 계산량은 sequence length에 대해 선형으로 증가한다.
반면 다음 한계가 있다.
- 를 계산하려면 이 필요하므로 time step을 병렬 계산하기 어렵다.
- 긴 sequence를 학습하면 activation 저장에 필요한 memory와 계산 시간이 커진다.
- fixed-size hidden state에 과거 정보를 계속 압축하므로 sequence가 길어질수록 정보가 사라질 수 있다.
- 긴 시간축을 따라 backpropagation하면 gradient가 vanish하거나 explode할 수 있다.
RNN에 명시적인 context window가 없다는 것이 모든 과거 정보를 완벽히 유지한다는 뜻은 아니다. 임의 길이 sequence를 입력할 수 있는 것과 아주 먼 dependency를 정확히 기억하는 것은 다른 문제이다.
Computer vision에서의 활용
Image captioning
Image captioning은 image 하나에서 가변 길이 word sequence를 만드는 one-to-many task이다. 기본 구조는 visual encoder와 recurrent decoder의 결합이다.
ImageNet으로 pretrain한 CNN의 class label 대신 second-to-last layer의 feature 를 사용한다. 이 visual feature를 별도의 로 hidden state에 넣고, 이후에는 이전에 생성한 text와 hidden state로 다음 word를 생성한다.
Sequence는 start token에서 시작하고 end token이 sampling되면 끝난다. 당시 모델은 그럴듯한 caption을 만들었지만 training data의 co-occurrence에 의존한 오류도 보였다.
- 손을 오므려 물체를 든 모습 때문에 실제 phone을 mouse라고 판단했다.
- fur 소재 옷을 보고 cat을 들고 있다고 판단했다.
- beach가 보인다는 이유로 실제로 없는 surfboard를 생성했다.
- 공이 glove로 들어가는 장면을 catching이 아니라 throwing이라고 설명했다.
이런 오류는 특정 scene과 object 또는 action이 dataset에서 자주 함께 등장하기 때문에 생긴다. 현재 vision-language model에서도 비슷한 hallucination이 나타날 수 있다.
Visual question answering과 navigation
Visual question answering은 image와 question을 보고 answer를 만드는 task이다. Answer를 text sequence로 생성한다면 전체 answer probability는 다음과 같다.
여러 answer candidate 중 하나를 선택한다면 candidate를 class로 보는 classifier로 구성할 수도 있다. Answer가 네 개라면 answer 1부터 answer 4까지를 출력하는 four-way classification problem이 된다.
Visual dialogue는 image에 관한 여러 turn의 대화를 sequence로 처리한다. Visual navigation에서는 연속된 image observation을 입력받아 2차원 floor plan에서 목적지로 이동하기 위한 direction sequence를 출력한다.
RNN과 state space model
Transformer의 self-attention은 모든 token pair를 비교하므로 기본적으로 sequence length 에 대해 quadratic compute를 요구한다.
Recurrent model은 같은 state update를 time step마다 한 번씩 수행하므로 sequence length에 대해 linear하게 계산량이 증가한다.
RWKV와 Mamba 같은 현대 model은 recurrent model 또는 state space model의 linear scaling을 활용하면서 Transformer에 가까운 modeling 성능을 얻는 것을 목표로 한다.
RNN의 핵심인 state update는 사라진 개념이 아니다. Vanilla RNN의 정보 압축과 학습 불안정 문제를 개선한 형태로 긴 context를 효율적으로 처리하는 현대 sequence model에 다시 사용되고 있다.
과제에서 확인할 것
RNN forward pass의 shape
각 time step에서 다음 shape가 맞는지 확인한다.
모든 time step에서 같은 , , 를 공유하는지도 확인해야 한다.
BPTT의 gradient 누적
Backward pass에서는 time step마다 shared weight에 대한 gradient가 생긴다. 이를 덮어쓰지 않고 모두 더해야 한다.
Numerical gradient check로 , , , , 가 올바른지 확인하는 것이 좋다.
Sequence target과 loss
Character-level language model에서는 입력 sequence와 target sequence가 한 칸 어긋나 있어야 한다.
각 time step의 softmax loss를 합하거나 평균 내어 sequence loss를 만드는지 확인한다.
Embedding lookup
One-hot vector와 embedding matrix의 곱은 해당 row를 선택하는 것과 같다.
실제 구현에서는 큰 one-hot matrix를 만들지 않고 integer index로 row를 바로 가져오는 편이 효율적이다.
긴 sequence의 학습
긴 sequence에서는 gradient norm을 기록해 vanishing gradient와 exploding gradient가 어떻게 나타나는지 확인한다. Gradient clipping은 norm이 threshold를 넘을 때만 gradient 크기를 줄여야 한다.
Truncated BPTT를 사용한다면 chunk 사이에서 hidden state 값은 넘기되 computational graph는 detach되는지 확인한다.