지난 글에서 순환 신경망이 은닉 상태 하나로 과거를 들고 다니는 구조를 봤고, 그 구조가 긴 시퀀스에서 무너지는 것도 함께 봤다. 이 글은 그 고장을 고치는 세 가지 수선을 한자리에서 다룬다. LSTM과 GRU는 셀 안쪽을 바꾸고, 양방향 RNN은 셀은 그대로 둔 채 읽는 방향을 하나 더 늘린다.
셋을 한 편에 묶는 이유가 있다. 이들이 고치는 곳이 서로 다른 두 자리이기 때문이다. 하나는 한 스텝 안에서 상태를 어떻게 갱신하는가이고, 다른 하나는 시퀀스를 어느 방향으로 훑는가다. 두 축이 독립이라 BiLSTM도 BiGRU도 성립하고, 어느 쪽을 고를지도 따로 정한다. 셋을 차례로 배우면 세 개의 구조로 보이지만, 두 축으로 놓고 보면 고를 것은 두 번뿐이다.
순환 셀의 두 축
기울기 소실
표준 RNN 셀은 매 스텝 같은 계산을 되풀이한다.
문제는 학습할 때 드러난다. 손실을 로 미분하려면 사이에 낀 스텝을 모두 지나야 하고, 그 경로는 곱셈의 연속이다. 스텝마다 의 도함수와 가 한 번씩 곱해진다. 기울기 소실(vanishing gradient)은 이 곱이 0으로 수렴해 먼 과거가 학습 신호를 받지 못하는 현상이다.
숫자를 넣어 보면 얼마나 빨리 무너지는지 보인다. 스텝마다 곱해지는 값이 평균 0.5라고 하면 20스텝 뒤의 기울기는 이고, 50스텝이면 이다. 문장 하나가 보통 20~50 토큰이니, 표준 RNN은 문장 앞머리에서 온 신호를 사실상 못 받는다. 반대로 곱해지는 값이 1보다 크면 같은 속도로 폭발한다 — 이쪽은 기울기 클리핑으로 막을 수 있지만, 소실은 잘라 낼 값 자체가 없어서 못 막는다.
여기서 중요한 것은 곱해지는 값을 네트워크가 고르지 못한다는 점이다. 의 도함수는 활성값이 정해지면 따라 정해지고, 활성값이 포화되면 0에 붙는다. 무엇을 오래 기억하고 무엇을 금방 버릴지 데이터가 정할 방법이 구조 안에 없다.
게이트
게이트(gate)는 0과 1 사이의 값으로 이뤄진 벡터를 만들어 다른 벡터에 원소별로(elementwise) 곱하는 장치다. 시그모이드를 통과시켜 만들므로 값이 항상 0과 1 사이이고, 곱해지는 쪽에서 보면 「몇 퍼센트를 통과시킬 것인가」가 된다. 0이면 그 차원을 차단하고 1이면 그대로 흘린다. 원소별 곱을 로 적는다.
게이트의 값이 상수가 아니라 과 에서 계산된다는 것이 핵심이다. 곱해지는 값이 매 스텝, 그리고 차원마다 달라진다. 은닉 차원이 256이면 게이트도 256개의 숫자이고, 어떤 차원은 0.99로 오래 들고 가면서 다른 차원은 0.05로 금방 버릴 수 있다. 앞 절에서 「구조 안에 없다」고 한 손잡이가 바로 이것이다.
처리 방향
게이트가 셀 안쪽을 다룬다면, 다른 축은 셀 밖에 있다. 표준 RNN은 언제나 왼쪽에서 오른쪽으로 읽으므로 번째 토큰을 처리할 때 이후를 아직 모른다. 양방향 RNN은 셀 구조를 전혀 건드리지 않고 같은 셀을 두 벌 두어 반대 방향으로도 읽는다.
방향을 늘려도 셀은 자기가 뒤집힌 순서를 보고 있다는 것조차 모른다. Backward 방향의 셀에게 주어지는 것은 그저 또 하나의 시퀀스일 뿐이고, 셀 안쪽 계산은 한 줄도 달라지지 않는다. 바뀌는 것은 셀 밖의 배선뿐이다 — 어떤 순서로 입력을 먹일지, 그리고 나온 결과 둘을 어떻게 합칠지.
LSTM
셀 상태
LSTM(Long Short-Term Memory)은 1997년 Hochreiter와 Schmidhuber가 제안한 구조다. 가장 큰 변화는 게이트가 아니라 셀 상태(cell state) 다. RNN이 은닉 상태 하나만 들고 다녔다면 LSTM은 상태를 둘로 나눈다.
두 상태의 역할이 다르다. 는 셀 안에만 남는 기억이고, 는 밖으로 나가는 출력이다. 다음 층과 손실 계산에 쓰이는 것은 뿐이고, 는 다음 스텝의 자기 자신에게만 전달된다. 문장을 읽으며 주어가 단수인지 복수인지를 계속 들고 가야 하지만 지금 이 자리에서 내보낼 정보는 그것이 아닐 때, 둘을 나눠 두면 하나를 기억하면서 다른 하나를 출력할 수 있다.
그리고 가 지나가는 길에는 가 없다. 이전 셀 상태에 게이트를 곱하고 새 값을 더할 뿐이다. 뒤에서 볼 기울기 이야기가 전부 이 덧셈에서 나온다.
잊기·쓰기·읽기
한 스텝의 계산은 여섯 줄이다.
네 줄이 같은 입력 를 보고 있고 가중치만 다르다. 하는 일은 이렇게 갈린다. Forget 게이트 는 기존 기억 중 무엇을 남길지 정하고, Input 게이트 는 새로 만든 후보 중 무엇을 쓸지 정하며, Output 게이트 는 기억 중 무엇을 지금 내보낼지 정한다. 후보 만 시그모이드가 아니라 인 이유는 이것이 통과 비율이 아니라 값이기 때문이다. 셀 상태를 올릴 수도 내릴 수도 있어야 하므로 범위가 부터 이다.
한 차원만 떼어 숫자로 따라가 보자. 이전 셀 상태가 이고 , , 이면 새 셀 상태는 이다. 기존 기억의 90%를 그대로 흘리면서 새 정보를 30%만 섞었다. 여기에 를 걸면 밖으로 나가는 값은 다. 셀 안에는 2.1이 남아 있지만 내보낸 것은 0.485다 — 기억과 출력이 갈리는 자리가 이렇게 생긴다.
게이트 묶음 행렬곱
수식은 네 줄이지만 구현은 행렬곱 한 번으로 끝낸다. 네 게이트가 모두 같은 입력 를 보므로 출력 차원이 인 선형 층 하나를 두고 결과를 네 조각으로 자르면 된다. 수학적으로는 네 번 곱한 것과 완전히 같은 값이 나오지만 속도가 다르다. GPU는 행렬 하나를 메모리에서 불러오고 커널을 띄우는 데 고정 비용을 치르므로, 작은 곱셈 네 번은 그 비용을 네 번 낸다. 큰 곱셈 한 번이 훨씬 싸다.
import torch
import torch.nn as nn
class LSTMCell(nn.Module):
def __init__(self, input_size, hidden_size):
super().__init__()
d = hidden_size
self.W = nn.Linear(input_size + d, 4 * d) # i, f, g, o를 한 번에
def forward(self, x, state):
h, c = state
gates = self.W(torch.cat([h, x], dim=-1)).chunk(4, dim=-1)
i, f = torch.sigmoid(gates[0]), torch.sigmoid(gates[1])
g, o = torch.tanh(gates[2]), torch.sigmoid(gates[3])
c_new = f * c + i * g # 덧셈 — 기울기가 지나는 통로
return o * torch.tanh(c_new), c_new
자르는 순서는 우리가 정하기 나름이다. 어차피 그 자리의 가중치가 그 게이트를 학습할 뿐이라 로 잘라도 모델은 똑같이 돌아간다. 그래도 PyTorch의 nn.LSTM이 쓰는 에 맞춰 두는 편이 낫다. 뒤에서 편향을 손으로 덮어쓸 일이 나오는데, 규약이 두 개면 그때 반드시 헷갈린다.
파라미터 수도 여기서 나온다. 게이트 한 벌은 입력 쪽 가중치(은닉 크기 × 입력 크기)와 은닉 쪽 가중치(은닉 크기 × 은닉 크기), 그리고 편향으로 이뤄지고, 그런 벌이 넷이다. 표준 RNN의 정확히 4배다. 그래서 은닉 크기를 올리는 결정도 생각보다 비싸다 — 입력 128·은닉 256짜리 한 층이 395,264개인데 은닉만 512로 올리면 1,314,816개, 3.3배로 뛴다. 은닉 쪽 가중치가 으로 자라기 때문이고, 가 입력 크기보다 훨씬 커지면 이 비율이 4배에 수렴한다. 지금처럼 입력이 은닉의 절반쯤 되는 크기에서는 아직 항이 전체의 3분의 1을 차지해 4배까지 가지 않는다.
다층 LSTM
num_layers를 늘리면 LSTM이 세로로 쌓인다. 아래층의 매 스텝 출력이 위층의 입력이 되고, 위층은 그것을 다시 시간축으로 훑는다. 시간 방향의 깊이와 층 방향의 깊이가 따로 생기는 셈이라 표현력이 늘지만, 파라미터도 층마다 한 벌씩 늘어난다. 실무에서 2~3층을 넘기는 경우는 드물다 — 층을 더 쌓아 얻는 것보다 과적합이 빨리 온다.
lstm = nn.LSTM(input_size=128, hidden_size=256, num_layers=2,
batch_first=True, dropout=0.2)
x = torch.randn(32, 50, 128) # (batch, seq_len, features)
h0 = torch.zeros(2, 32, 256) # (num_layers, batch, hidden)
c0 = torch.zeros(2, 32, 256) # 셀 상태도 같은 모양으로 하나 더
out, (h_n, c_n) = lstm(x, (h0, c0))
# out: (32, 50, 256) — 모든 타임스텝의 최상위층 출력
# h_n: (2, 32, 256) — 층별 마지막 은닉 상태, h_n[-1]이 최상위층
# c_n: (2, 32, 256) — 층별 마지막 셀 상태
초기 상태를 생략하면 PyTorch가 0으로 채워 주므로 보통은 lstm(x)로 충분하다. 손으로 만들어야 하는 자리는 앞선 시퀀스의 마지막 상태를 이어받을 때다 — 긴 문서를 조각내 학습하는 truncated BPTT나, 대화 세션의 상태를 요청 사이에 들고 다니는 스트리밍 추론이 그렇다. 여기서 LSTM은 텐서를 언제나 두 개 만들어야 한다. 은닉 상태와 셀 상태가 따로 있기 때문이고, 뒤에서 GRU와 비교할 때 이 두 줄이 그대로 비교 대상이 된다.
반환값 셋의 쓰임도 갈린다. 문서 분류처럼 시퀀스 전체를 하나의 라벨로 줄이는 태스크는 out 전체가 아니라 h_n[-1]을 분류 헤드에 넣는다. 최상위층이 시퀀스를 끝까지 읽고 남긴 마지막 은닉 상태이므로, 문장 하나를 256차원 벡터 하나로 요약한 값이다. 여기에 nn.Linear(256, n_classes) 하나를 얹으면 분류기가 된다. 반대로 각 위치마다 출력이 필요한 태스크는 h_n을 버리고 out을 그대로 쓴다 — 뒤의 양방향 절에서 다시 나온다.
층을 쌓을수록 과적합이 빨라지므로 dropout 인자를 함께 올린다. 이 인자는 층과 층 사이에만 적용되고 시간 방향에는 걸리지 않는다는 점을 기억해 둔다. 그래서 num_layers=1에 dropout을 주면 PyTorch가 「아무 효과가 없다」는 경고만 내고 무시한다. 정규화 자체는 드롭아웃에서 따로 다뤘다.
기울기 경로
덧셈 경로
셀 상태 갱신식만 다시 본다. 에서 로 미분하면 게이트 자신이 에 의존하는 몫을 빼고 남는 주된 항은 이것뿐이다.
표준 RNN과 무엇이 다른지가 여기서 갈린다. RNN에서는 이전 상태가 안으로 들어갔기 때문에 미분이 활성함수의 도함수를 통과해야 했다. LSTM에서 이전 상태는 밖에서 게이트와 곱해진 뒤 새 값과 더해진다. 덧셈은 기울기를 그대로 양쪽으로 흘려보내므로, 셀 상태 경로에는 활성함수가 하나도 끼지 않는다. 잔차 연결이 깊은 신경망에서 하는 일과 같은 원리이고, 시간 축에서 먼저 쓰였다는 점만 다르다.
100스텝 뒤의 잔존 비율
여전히 곱셈이 반복되는 것은 맞다. 달라진 것은 곱해지는 값을 네트워크가 정한다는 점이다. 100스텝을 거슬러 갈 때 남는 비율을 세 경우로 계산해 보면 차이가 분명하다.
| 스텝마다 곱해지는 값 | 100스텝 뒤 | 뜻 |
|---|---|---|
| (RNN의 전형적 ) | 신호가 사라진다 | |
| 역시 사라진다 | ||
| 작지만 살아 있다 | ||
| 거의 그대로 |
LSTM이 마법을 부리는 것이 아니다. 를 1 가까이 유지하는 법을 학습으로 배울 수 있게 길을 열어 준 것이 전부다. 0.7로 두면 LSTM도 100스텝을 못 버틴다.
그리고 이 값이 차원마다 따로라는 점이 실제로는 더 중요하다. 은닉 차원이 256이면 100스텝을 버티는 차원과 다섯 스텝 만에 지워지는 차원이 한 셀 안에 공존할 수 있다. 문서 전체의 주제 같은 것은 앞쪽 차원 몇 개가 로 들고 가고, 바로 앞 단어의 품사 같은 것은 다른 차원이 로 금방 갈아 끼운다. 하나의 감쇠율로는 못 하던 일이다.
Forget 게이트 편향의 초기값
여기서 실무 손잡이가 하나 나온다. 를 1.0 근처로 초기화하는 것이 오랜 관례다. 편향을 0으로 두면 학습 초기에 라서 스텝마다 기억이 절반씩 지워진다. 첫 절에서 표준 RNN을 무너뜨렸던 그 이 그대로 돌아오는 것이다 — 게이트를 달아 놓고도 초기값 하나 때문에 같은 자리에 서 있는 셈이다. 긴 의존성을 배우기도 전에 기울기가 먼저 끊긴다. 이면 시작값이 이라 기억이 훨씬 오래 흐르고, 정말로 잊어야 하는 차원은 학습이 알아서 내린다.
PyTorch의 nn.LSTM은 모든 편향을 범위의 균등분포로 초기화하므로 사실상 0 근처에서 출발한다. 그래서 손으로 덮어써야 하는데, 이때 텐서가 어떻게 놓여 있는지를 정확히 알아야 한다. named_parameters()는 층마다 bias_ih_l0과 bias_hh_l0을 따로 내준다 — 한 벡터를 앞뒤로 자른 것이 아니라 별개의 파라미터 둘이고, 각각이 게이트 넷의 편향을 이어 붙인 개다. 아래 코드가 크기를 4로 나누는 것이 그 증거다. 8로 나누지 않는다.
순서가 함정이다. 공식 문서가 bias_ih_l0을 (b_ii|b_if|b_ig|b_io)로 적어 두었듯 PyTorch의 배치는 이고, 앞서 우리가 직접 만든 LSTMCell도 같은 규약으로 맞춰 두었다. Forget 게이트 자리는 맨 앞이 아니라 두 번째 개다. 여기를 [:d]로 잡으면 Forget이 아니라 Input 게이트 편향을 1.0으로 올리게 된다 — 오류는 안 나고, 새 정보를 무조건 많이 받아들이는 초기값이 되어 의도와 정반대로 움직인다.
for name, param in lstm.named_parameters():
if 'bias' in name: # bias_ih_l*, bias_hh_l* — 각각 4d개
d = param.size(0) // 4
param.data[d:2 * d].fill_(1.0) # [i, f, g, o] 중 두 번째가 forget
bias_ih와 bias_hh 둘 다 1.0으로 채우면 실효 편향이 2.0이 되어 에서 출발한다. 그것도 나쁘지 않지만 관례대로 1.0 근처를 원한다면 둘 중 하나만 채우거나 각각 0.5를 넣는다. 긴 시퀀스에서 학습이 도무지 안 붙을 때 학습률보다 먼저 볼 자리다.
GRU
셀 상태의 제거
2014년 Cho 등은 훨씬 단순한 구조로도 비슷한 성능이 난다는 것을 보였다. GRU(Gated Recurrent Unit)는 LSTM에서 두 가지를 덜어 낸다. 셀 상태를 없애 상태를 하나로 되돌리고, 게이트를 셋에서 둘로 줄인다.
게이트를 줄인 방식이 영리하다. LSTM에서 와 는 서로 독립이라 「다 잊고 다 새로 쓰기」도 「아무것도 안 잊고 다 더하기」도 가능했다. GRU는 이 둘을 하나의 값 로 묶어 잊는 만큼만 쓰게 만든다. 남기는 비율이 면 새로 쓰는 비율은 다. 자유도를 하나 버린 대신 파라미터 한 벌이 통째로 사라진다.
상태가 하나로 줄어든 것도 그 자체로 값이 있다. 앞에서 LSTM에 초기 상태를 넘기려고 h0과 c0 두 텐서를 만들었는데 GRU는 h0 하나로 끝난다. 스트리밍 추론에서 대화나 세션마다 들고 다닐 상태의 크기도 절반이 된다 — 세션 수만 개를 동시에 물고 있는 서버라면 그 절반이 그대로 메모리 절감이다. 코드가 짧아지는 것보다 이쪽이 실제 이득이다.
리셋과 업데이트
마지막 줄이 이 구조의 전부다. Update 게이트 가 1에 가까우면 이전 상태를 그대로 유지하고, 0에 가까우면 새 후보로 갈아 끼운다. LSTM으로 옮기면 가 Forget 게이트, 가 Input 게이트 자리다. 그리고 여기도 갱신식이 덧셈이므로 에 가 그대로 남는다 — 앞 절에서 본 경로가 GRU에도 똑같이 뚫려 있다.
Reset 게이트 는 자리가 다르다. 최종 갱신이 아니라 후보를 만드는 계산 안에서 이전 상태에 곱해진다. 하는 일을 문장으로 옮기면 「새 후보를 만들 때 과거를 얼마나 참고할까」다. 「안녕하세요, 반갑습니다」를 읽다가 「사과 가격이」가 나오는 자리를 생각해 보자. 이면 인사말의 기억을 무시하고 새 문맥부터 다시 시작하고, 이면 앞의 기억을 그대로 끌어와 새 입력과 섞는다.
두 게이트를 한 줄로 갈라 두면 이렇다. 는 무엇을 참고해 후보를 만들지 정하고, 는 그 후보를 얼마나 받아들일지 정한다. 참고와 채택이 나뉘어 있으므로, 과거를 무시하고 만든 후보를 조금만 받아들이는 조합도 가능하다.
구현과 반환값
class GRUCell(nn.Module):
def __init__(self, input_size, hidden_size):
super().__init__()
d = hidden_size
self.W_r = nn.Linear(input_size + d, d)
self.W_z = nn.Linear(input_size + d, d)
self.W = nn.Linear(input_size + d, d)
def forward(self, x, h):
c = torch.cat([h, x], dim=-1)
r = torch.sigmoid(self.W_r(c))
z = torch.sigmoid(self.W_z(c))
h_tilde = torch.tanh(self.W(torch.cat([r * h, x], dim=-1)))
return (1 - z) * h_tilde + z * h
gru = nn.GRU(input_size=128, hidden_size=256, num_layers=2,
batch_first=True, dropout=0.2)
out, h_n = gru(x) # 반환값이 둘뿐 — 셀 상태가 없다
# out: (32, 50, 256), h_n: (2, 32, 256)
nn.LSTM을 nn.GRU로 바꿀 때 가장 흔한 오류가 반환값 개수다. LSTM은 (h_n, c_n) 튜플을 두 번째로 돌려주지만 GRU는 h_n 하나를 그대로 돌려준다. 그런데 out, (h_n, c_n) = gru(x)가 늘 터지는 것은 아니라서 더 성가시다.
파이썬은 언패킹할 때 텐서를 0축으로 순회한다. 위 예제처럼 num_layers=2면 h_n의 첫 축이 2라 원소 둘로 깔끔하게 풀리고, h_n에는 층0의 상태 (32, 256)이, c_n에는 층1의 상태 (32, 256)이 들어앉는다. 오류는 나지 않는다. 셀 상태인 척하는 것이 사실은 다른 층의 은닉 상태이고, 모양이 맞으니 뒤의 코드도 그냥 돌아간다. 반대로 num_layers가 1이나 3이면 그 자리에서 언패킹이 터지므로 오히려 낫다 — 터지는 쪽은 1분이면 고치고, 조용히 통과하는 쪽은 성능이 애매하게 나쁜 이유를 며칠 찾게 만든다.
한 가지 더 주의할 것이 있다. 가 무엇을 남기는 비율인지가 구현마다 반대다. 위 수식과 PyTorch는 를 「이전 상태를 남기는 비율」로 쓰지만, 2014년 원논문은 를 「새 후보를 받아들이는 비율」로 두고 로 적었다. 학습되는 함수는 같고 부호만 뒤집히므로 성능 차이는 없지만, 게이트 값을 꺼내 시각화하거나 논문의 그림과 대조할 때는 어느 규약인지 먼저 확인해야 한다.
LSTM과 GRU 사이의 선택
LSTM과 GRU의 대응
| LSTM | GRU | |
|---|---|---|
| 상태 | , 둘 | 하나 |
| 게이트 | Forget · Input · Output | Reset · Update |
| 기억 유지 비율 | ||
| 새 정보 반영 비율 | (독립) | (연동) |
| 출력 조절 | 로 걸러 내보냄 | 상태를 그대로 내보냄 |
| 게이트 파라미터 벌수 | 4 | 3 |
가장 큰 기능 차이는 표의 마지막에서 둘째 줄이다. GRU에는 Output 게이트가 없어 내부 상태가 곧 출력이다. 「기억해 두되 지금은 안 내보내기」를 못 한다는 뜻이고, 앞에서 본 「셀 안에 2.1이 남아 있는데 내보낸 값은 0.485」 같은 분리가 GRU에는 없다.
파라미터 예산 배분
게이트 벌수가 4에서 3으로 줄었으니 파라미터도 정확히 4분의 3이다. input_size=128, hidden_size=256으로 한 층을 세우면 이렇게 나온다. PyTorch는 입력 쪽과 은닉 쪽 편향을 따로 두므로 게이트 한 벌에 개, 곧 98,816개다. LSTM은 네 벌이라 395,264개, GRU는 세 벌이라 296,448개 — 정확히 25% 적다.
이 여유를 어디에 쓸지가 실제 결정이다. 같은 395,264개 예산을 GRU에 주면 hidden_size를 300 언저리까지 키울 수 있다. 폭 대신 깊이에 써도 된다 — 은닉 크기는 256에 두고 층을 하나 더 쌓는 쪽이다. 어느 배분이 나은지는 태스크가 정한다. 표현해야 할 것이 한 스텝 안에서 복잡하면 폭이 낫고, 여러 단계를 거쳐 추상화해야 하면 깊이가 낫다는 것이 대략의 감이지만 결국은 돌려 봐야 안다.
정작 중요한 것은 배분 자체가 아니라 비교할 때 두 모델의 파라미터를 맞추지 않으면 구조를 비교한 것이 아니라 크기를 비교한 것이 된다는 점이다. 은닉 크기를 256으로 똑같이 두고 LSTM과 GRU를 붙이면 GRU 쪽이 10만 개 가까이 적은 모델이다. 그 상태에서 LSTM이 이겼다면 구조가 이긴 것인지 파라미터가 이긴 것인지 알 수 없다. GRU가 이겼다는 실험 결과를 볼 때도 가장 먼저 확인할 자리가 여기다.
선택 기준
| 상황 | 고르는 것 |
|---|---|
| 데이터가 작거나 실험을 빨리 돌려야 한다 | GRU |
| 아주 긴 의존성을 다룬다 (긴 문서, 악보, 장기 시계열) | LSTM |
| 임베디드·온디바이스, 스트리밍 추론 | GRU |
| 기억과 출력을 따로 조절할 이유가 있다 | LSTM |
| 일반적인 실무 NLP | Transformer를 먼저 검토 |
주의할 것은 이 표의 차이가 대개 크지 않다는 점이다. 2014~2015년의 비교 연구들은 태스크마다 승자가 갈렸고, 어느 한쪽이 일관되게 앞선다는 결론은 나오지 않았다. 그러니 둘 중 하나를 고르는 데 시간을 쓰는 대신 hidden_size와 층 수, 학습률을 먼저 맞추는 편이 낫다. 둘 다 돌려 보고 검증 지표로 정하는 것이 가장 빠르고, 시간이 하나뿐이면 GRU로 시작해 성능이 모자랄 때 LSTM으로 옮기는 순서가 무난하다. 파라미터가 적은 쪽이 실험 회전이 빠르기 때문이다.
양방향 RNN
정·역방향 은닉 상태
여기서 두 번째 축으로 넘어간다. 셀은 LSTM이든 GRU든 그대로 두고, 같은 셀을 두 벌 둔다. 하나는 왼쪽에서 오른쪽으로, 다른 하나는 오른쪽에서 왼쪽으로 시퀀스를 훑는다.
두 방향의 은닉 상태를 이어 붙여(concatenate) 각 타임스텝의 출력을 만든다. 은닉 크기가 면 출력은 다. 두 RNN은 가중치를 공유하지 않는다 — 완전히 별개의 파라미터를 갖는 두 모델이고, 다만 같은 입력 시퀀스를 반대 순서로 볼 뿐이다. 그래서 파라미터도 대략 두 배가 된다.
여기서 자주 오해하는 것이 있다. 두 방향이 중간에 정보를 주고받지 않는다는 점이다. Forward RNN은 끝까지 왼쪽만 보고, Backward RNN은 끝까지 오른쪽만 본다. 둘이 만나는 자리는 출력을 이어 붙이는 마지막 한 번뿐이다.
미래 문맥
왜 이런 것이 필요한지는 개체명 인식(NER, 문장 속 단어가 사람·기관·장소 중 무엇인지 태깅하는 작업)에서 가장 잘 보인다. 「Apple은 팀 쿡이 이끄는 회사다」에서 「Apple」이 과일인지 기업인지 판단하려면 뒤에 오는 「회사」를 봐야 한다. 단방향 RNN이 「Apple」을 처리하는 시점에는 그 단어가 아직 입력으로 들어오지도 않았다.
Backward RNN은 문장 끝에서부터 읽어 오므로 「Apple」 자리에 도착했을 때 이미 「회사」를 지나왔다. 두 방향을 이어 붙인 는 「이 토큰의 왼쪽에 무엇이 있었고 오른쪽에 무엇이 오는가」를 함께 담은 표현이 된다.
같은 사정이 품사 태깅, 문법 오류 검출, 음성 인식의 프레임 단위 분류에도 그대로 적용된다. 공통점은 입력 시퀀스 전체를 미리 갖고 있고, 각 위치마다 출력이 필요하다는 것이다. 이 두 조건이 양방향을 쓸 수 있는 자리를 정한다.
텐서 모양
PyTorch에서는 bidirectional=True 한 줄이면 된다. 대신 텐서 모양이 두 군데에서 달라지고, 그 둘을 헷갈리면 조용히 틀린 코드가 된다.
bilstm = nn.LSTM(input_size=128, hidden_size=128, num_layers=2,
batch_first=True, dropout=0.3, bidirectional=True)
out, (h_n, c_n) = bilstm(x)
# out: (32, 50, 256) ← hidden_size * 2, 시간 순서는 그대로
# h_n: (4, 32, 128) ← num_layers * 2, 방향이 층 축에 끼어든다
h_forward = h_n[0::2] # 짝수 인덱스가 forward
h_backward = h_n[1::2] # 홀수 인덱스가 backward
h_last = torch.cat([h_forward[-1], h_backward[-1]], dim=-1) # (32, 256)
out은 마지막 차원이 두 배가 되지만 시간 축은 그대로다. 각 위치의 앞쪽 개가 Forward, 뒤쪽 개가 Backward이고, Backward 쪽도 원래의 시간 순서에 맞춰 정렬되어 나온다. 그러니 뒤에 붙는 층은 입력 크기를 hidden_size * 2로 잡아야 한다. 여기가 첫 번째 함정이다 — 단방향에서 쓰던 nn.Linear(hid, n_labels)를 그대로 두면 차원이 안 맞아 바로 터진다.
두 번째는 조용해서 더 위험하다. h_n의 첫 축이 층 수의 두 배가 되면서 방향이 층 축에 끼어든다. 2층 양방향이면 [층0-forward, 층0-backward, 층1-forward, 층1-backward] 순이므로, 시퀀스 전체 요약을 얻으려고 h_n[-1]을 쓰면 최상위층 Backward 방향만 집어 든다. 정보의 절반을 버리고도 차원은 맞아서 학습이 그냥 돌아간다 — 성능이 애매하게 나쁠 뿐이다. 위 코드처럼 짝·홀 인덱스로 갈라 이어 붙여야 한다.
시퀀스 레이블링에서는 이 고민이 아예 없다. 각 위치마다 출력이 필요하므로 h_n 대신 out을 그대로 쓴다.
class BiLSTM_NER(nn.Module):
def __init__(self, vocab_size, emb_dim, hid, n_labels):
super().__init__()
self.emb = nn.Embedding(vocab_size, emb_dim, padding_idx=0)
self.lstm = nn.LSTM(emb_dim, hid, batch_first=True, bidirectional=True)
self.fc = nn.Linear(hid * 2, n_labels) # 2배 잊지 않기
def forward(self, x):
e = self.emb(x) # (B, T, emb_dim)
out, _ = self.lstm(e) # (B, T, hid*2)
return self.fc(out) # (B, T, n_labels) — 위치마다 태그 하나
출력 모양이 (B, T, n_labels)라는 것은 문장 하나에 라벨 하나가 아니라 토큰마다 라벨 하나가 나온다는 뜻이다. 개체명 인식에서 흔히 쓰는 형식이 BIO 태깅인데, 개체의 첫 토큰에 B-, 그 개체에 이어지는 토큰에 I-, 개체가 아닌 토큰에 O를 붙이는 방식이다. 「팀 쿡」이 사람 이름이면 「팀」에 B-PER, 「쿡」에 I-PER이 붙는다. 라벨이 정확히 위 모양으로 필요하고, 그래서 h_n이 아니라 out을 받는 것이다. 분류와 레이블링의 차이는 결국 반환값 둘 중 무엇을 쓰느냐 하나로 갈린다.
양방향의 제약
양방향의 제약은 하나이고 절대적이다. 미래 토큰을 봐야 하므로, 미래 토큰이 아직 없는 상황에서는 쓸 수 없다. 자기회귀 생성이 정확히 그 상황이다. 다음 단어를 만들어 내는 중인데 그 다음 단어를 미리 보고 계산할 수는 없다.
| 태스크 | 양방향 | 단방향 |
|---|---|---|
| NER · 품사 태깅 | 권장 | 가능 |
| 텍스트 분류 | 권장 | 가능 |
| Seq2Seq 인코더 | 권장 | 가능 |
| 텍스트 생성 | 불가 | 필수 |
| Seq2Seq 디코더 | 불가 | 필수 |
실시간 처리도 같은 이유로 막힌다. 음성이 들어오는 대로 자막을 붙이려면 문장이 끝나기 전에 출력이 나와야 하는데, Backward RNN은 시퀀스의 끝을 알아야 시작할 수 있다. 그래서 스트리밍 음성 인식은 단방향을 쓰거나, 뒤쪽 몇 프레임만 기다리는 제한된 양방향을 쓴다. 저지연이 요구사항이면 양방향은 후보에서 빠진다.
정리와 한계
선택 순서
정리하면 결정은 두 번이다. 먼저 셀을 고르고, 그다음 방향을 고른다. 셀은 파라미터 예산과 의존성 길이가 정하고, 방향은 태스크의 성질이 정한다 — 입력 전체를 미리 갖고 있는가, 각 위치마다 출력이 필요한가, 지연을 견딜 수 있는가.
두 결정이 섞이지 않는다는 점이 실무에서 편하다. 「BiGRU에서 BiLSTM으로 바꿔 보자」는 방향은 그대로 두고 셀만 갈아 끼우는 일이고, 코드로는 클래스 이름 하나와 반환값 언패킹만 달라진다.
순차 처리
게이트는 기울기 소실을 크게 완화했지만 순차 처리는 그대로 남았다. 길이 시퀀스는 여전히 번의 스텝을 순서대로 밟아야 한다. 를 계산하려면 이 먼저 나와야 하므로 시간 축을 GPU로 병렬화할 방법이 없고, 시퀀스가 길어질수록 학습 시간이 선형으로 늘어난다. 양방향은 두 방향을 동시에 돌릴 수 있어 병렬성이 조금 늘지만, 각 방향 안에서는 여전히 한 스텝씩이다.
이 벽을 넘은 것이 Transformer다. 순환을 버리고 어텐션으로 모든 위치를 한 번에 잇는다. BERT가 이름에 「Bidirectional」을 달고 있는 것도 우연이 아니다 — 이 절에서 본 「양쪽 문맥을 함께 본다」는 아이디어를 순환 없이, 따라서 병렬로 구현한 것이 그 모델이다. 반대 방향의 연구도 살아 있다. Mamba 같은 상태 공간 모델(State Space Model)은 순환 구조를 유지하면서 학습만은 병렬로 하는 길을 찾았고, 여기서 상태를 갱신하는 방식은 결국 게이트의 후손이다.
다음 걸음
지금까지 다룬 것은 모두 입력 하나에 출력 하나가 대응하는 구조였다. 각 토큰에 라벨을 붙이거나, 시퀀스 전체를 하나로 줄여 분류했다. 하지만 번역이나 요약은 입력과 출력의 길이가 아예 다르다. 「안녕하세요」 다섯 글자가 「Hello」 한 단어가 되는 자리에서는 위치를 하나씩 맞대는 방식이 성립하지 않는다.
다음 글에서는 RNN 둘을 직렬로 이어 이 문제를 푸는 구조를 본다. 앞의 RNN이 입력을 다 읽어 벡터 하나로 압축하고 뒤의 RNN이 그 벡터에서 출력을 하나씩 만들어 내는데, 이 글에서 본 양방향 RNN이 그 앞쪽 자리에 그대로 들어간다.
읽어주셔서 감사합니다. 😊

