양방향 RNN은 어떻게 동작하는가
— 미래를 보는 대가
RNN에 "양방향"을 켜는 것은 코드 한 글자입니다 — bidirectional=True.
정확도는 거의 항상 오릅니다. 그런데 이 한 글자가 모델을 실시간에서 영구히 추방합니다.
앞에서부터 읽는 것과 뒤에서도 읽는 것이 정확히 무엇을 바꾸는지, 왜 음성 인식에서 그렇게 큰 차이를 만드는지,
그리고 Deep Speech 2가 서비스에 올리기 위해 양방향을 포기하고 대신 얹은 층 하나가 무엇인지
브라우저에서 직접 계산해 봅니다.
§1 방향이란 무엇인가 — 첫 글자는 언제 나오는가
순환층이 하는 일은 하나입니다 — 은닉 상태를 시간 축으로 나르기. 셀이 RNN이든 LSTM이든 GRU든 이 골격은 같습니다.
양방향은 여기에 반대 방향으로 도는 RNN을 하나 더 붙입니다. 같은 입력을 마지막 프레임부터 거꾸로 읽는 두 번째 순환층이고, 같은 시각의 두 출력을 합쳐 위층에 넘깁니다.
두 가지를 짚고 넘어갑시다.
- 두 방향은 서로를 보지 않습니다.
ht(f)는ht(b)를 참조하지 않고 그 반대도 마찬가지입니다. 참조하게 만들면h₁ → h₂ → h₁같은 순환이 생겨 계산 순서 자체가 정의되지 않습니다. 두 방향이 만나는 곳은 오직 출력을 합치는 지점입니다. - 역방향 패스는 마지막 프레임에서 시작합니다. 이 한 줄이 이 글의 나머지 전부를 결정합니다 —
hT(b)를 모르면hT−1(b)도 모르고, 따라서 맨 첫 글자y₁조차 입력이 끝나기 전에는 낼 수 없습니다.
말로 하면 잘 와닿지 않으니 직접 돌려 봅시다. 아래는 프레임이 한 칸씩 도착하는 동안 세 구조가 각각 언제 무엇을 내보낼 수 있는지를 재생합니다.
단방향으로 돌리면 출력이 입력을 한 칸 뒤에서 그대로 따라옵니다. 양방향으로 바꾸면 재생이 두 단계로 갈라집니다.
- 1단계 — 입력이 도착하는 동안. 정방향 줄만 전진하고 역방향 줄은 꿈쩍도 하지 않습니다.
그림을 그리다 만 것이 아닙니다.
ht(b) = f(xt, ht+1(b))를 계산하려면ht+1(b)가 있어야 하고, 그걸 따라 올라가면 맨 끝의hT(b)에 도달합니다. 즉 역방향은 마지막 프레임이 오기 전에는 첫 한 칸조차 계산할 수 없습니다. 빗금 친 칸이 그 뜻입니다. - 2단계 — 입력이 끝난 뒤. 이제야 역방향이
x12에서 출발해 오른쪽에서 왼쪽으로 쓸고 내려옵니다. 출력도 그 순서를 그대로 따라y12부터 거꾸로 확정되고, 첫 글자y₁이 맨 마지막에 나옵니다.
"발화가 끝나야 첫 글자가 나온다"는 말의 실제 모습이 이것입니다. 더 정확히는 발화가 끝나고, 역방향이 처음까지 되돌아온 뒤입니다.
§2 누가 무엇을 보는가 — 의존성 행렬
"지연"과 "문맥"은 같은 것의 앞면과 뒷면입니다. 한 장의 표로 동시에 볼 수 있어요 —
세로축이 출력 yt, 가로축이 입력 xs이고,
칸이 칠해져 있으면 그 출력이 그 입력을 본다는 뜻입니다.
세 모양이 각각 이렇게 생겼습니다.
- 단방향 = 하삼각행렬. 대각선 위쪽이 통째로 비어 있습니다. 미래를 못 봅니다.
- 양방향 = 꽉 찬 행렬. 모든 출력이 모든 입력을 봅니다. 대가가 위의 지연입니다.
- 단방향 + 행 합성곱 = 하삼각 + 폭 τ의 띠. 대각선 위로 정확히 τ칸만 더 칠해집니다. 양방향의 오른쪽 위 절반을 유한한 폭으로 잘라 흉내 내는 것입니다.
Transformer를 아신다면 첫 번째 그림이 익숙할 겁니다 — causal mask가 만드는 모양이 정확히 이 하삼각입니다. "단방향이냐 양방향이냐"는 RNN만의 문제가 아니라 시퀀스 모델 전체의 설계 축이고, 구현 수단만 다릅니다(§8).
| 모델 | 미래를 막는 수단 | 의존성 모양 |
|---|---|---|
| 단방향 RNN | 구조 자체 (ht−1만 받음) | 하삼각 |
| 양방향 RNN | 막지 않음 | 꽉 참 |
| GPT (디코더) | causal mask | 하삼각 |
| BERT (인코더) | 막지 않음 | 꽉 참 |
| 스트리밍 Conformer | 청크 단위 마스크 | 하삼각 + 띠 |
§3 왜 더 정확한가 — 증거는 늦게 도착한다
양방향이 정확한 이유는 한 문장으로 끝납니다 — 시퀀스에서 판별 근거가 뒤에 오는 경우가 흔하기 때문입니다.
음성이 특히 심합니다. 영어의 /t/와 /d/는 혀와 잇몸이 만드는 자리가 같고,
둘을 가르는 것은 파열 그 자체가 아니라 파열 뒤 성대가 떨리기 시작하는 시점(VOT, voice onset time)입니다.
즉 /t/냐 /d/냐의 증거는 그 자음 프레임이 아니라 다음 모음의 앞부분에 있습니다.
단방향 모델은 그 증거가 도착하기 전에 이미 답을 확정해야 합니다.
텍스트도 같습니다. "He went to the bank"의 bank가 은행인지 강둑인지는 뒤를 봐야 알고,
한국어 "감사"가 고마움인지 회계 감사인지도 마찬가지입니다.
/d/(파랑, 양수) 또는 /t/(주황, 음수) 쪽 로그 오즈 증거가 조금씩 있습니다.
판정 대상은 5번 프레임이고, 모델은 보이는 창 안의 증거만 더할 수 있습니다.
기본 설정(늦게 온다)에서는 τ = 3에서 판정이 뒤집힙니다.
τ = 0, 1, 2에서는 앞쪽의 약한 /t/ 증거가 이기고 있다가, VOT 구간이 창에 들어오는 순간 확률이 한 번에 넘어갑니다.
40 ms 프레임이라면 120 ms만 기다리면 됐다는 뜻입니다 — 발화가 끝날 때까지 기다릴 필요가 없습니다.
이것이 §6에서 볼 타협의 근거입니다.
"흩어져 있다" 설정도 꼭 눌러 보세요. τ를 늘리는 것이 늘 이득은 아닙니다 — τ = 1에서 오히려 틀린 쪽으로 넘어갔다가 τ = 2에서 돌아옵니다. 문맥을 조금 더 주는 것이 반드시 단조롭게 좋아지는 일이 아니라는 것, 그래서 τ가 튜닝 대상이라는 것을 보여 줍니다.
CTC 학습에서 특히 크다
음성 인식이 양방향을 유난히 선호하는 데는 이유가 하나 더 있습니다. CTC는 어느 프레임이 어느 글자에 대응하는지를 스스로 찾습니다. 뒤를 볼 수 있으면 "이 프레임은 다음에 올 글자의 준비 구간이었구나"를 알 수 있어 정렬이 뾰족해지고, 그래서 DS2 논문도 강제 정렬이 필요할 때 양방향 CTC 모델로 뽑은 정렬을 씁니다. 단방향 모델의 출력은 같은 이유로 정답보다 몇 프레임 늦게 뜨는 경향이 있습니다.
§4 두 방향을 어떻게 합치나 — concat이냐 sum이냐
같은 "양방향"이라도 구현이 갈리는 지점이 둘 있습니다. 합치는 방법과 입력 가중치를 공유하는가입니다.
PyTorch bidirectional=True | Deep Speech 2 식 (6) | |
|---|---|---|
| 합치는 방법 | 이어 붙이기 concat | 더하기 sum |
| 출력 차원 | 2H | H |
| 입력 가중치 | 방향마다 따로 (weight_ih_l0, weight_ih_l0_reverse) | 두 방향이 공유 (Wl 하나) |
| 순환 가중치 | 방향마다 따로 | 방향마다 따로 (Uf, Ub) |
둘 다 맞는 설계이고 맞교환이 다릅니다. concat은 두 방향의 정보를 섞지 않고 그대로 위층에 넘기지만, 위층의 입력 차원이 2배가 되어 위층 파라미터까지 함께 늘어납니다. sum은 차원을 유지해서 층을 깊게 쌓아도 비용이 폭발하지 않지만, 두 방향이 같은 공간을 나눠 쓰도록 강제합니다. 순환층을 7층씩 쌓는 DS2가 sum을 고른 것은 자연스러운 선택입니다.
직접 돌려 보면 알게 되는 것 — 양방향의 비용은 "정확히 2배"가 아닙니다. concat이면 첫 층만 2배 근처이고 위층들은 입력 차원까지 2배가 되어 2배보다 더 듭니다. sum이면 정확히 2배도 아닙니다(입력 가중치를 공유하면 2배보다 덜 듭니다).
§5 양방향으로 못 하는 것 — 언어 모델과 라벨 누수
"정확도가 오르는데 왜 안 쓰나"에는 지연 말고 답이 하나 더 있습니다. 과제에 따라서는 양방향이 애초에 성립하지 않습니다.
다음 토큰을 맞히는 언어 모델을 생각해 봅시다. 시각 t에서의 정답은 xt+1입니다.
그런데 역방향 패스의 ht(b)는 xt+1, xt+2, …를 이미 읽고 내려온 상태입니다.
정답: x₅ 를 맞혀야 한다
정방향 h₄⁽ᶠ⁾ ← x₁ x₂ x₃ x₄ ✅ 정답을 모른다
역방향 h₄⁽ᵇ⁾ ← x₄ x₅ x₆ x₇ … ❌ 정답 x₅ 가 이미 들어 있다
손실은 거의 0까지 떨어지고 학습 곡선은 아름다워집니다. 그리고 아무것도 배우지 못합니다. 모델이 찾은 최적해는 "역방향 상태에서 정답을 그대로 베껴 쓰기"이고, 생성 시점에는 미래 토큰이 없으니 그 능력은 통째로 증발합니다. 전형적인 라벨 누수(label leakage)입니다.
그래서 생성형 모델은 예외 없이 미래를 막습니다. Transformer의 causal mask가 하는 일이 정확히 이것이고, BERT가 다음 토큰 예측 대신 마스킹 복원(MLM)이라는 다른 과제를 고안해야 했던 이유도 이것입니다 — 양방향으로 보고 싶다면 맞혀야 할 것을 입력에서 지우는 수밖에 없습니다.
| 과제 | 양방향 가능? | 이유 |
|---|---|---|
| 품사 태깅 · 개체명 인식 | 가능 | 입력 전체가 주어지고, 정답은 입력이 아니라 라벨 |
| 오프라인 음성 인식 | 가능 | 녹음이 끝난 파일. 정확도를 위해 거의 항상 씀 |
| 문장 분류 · 인코더 | 가능 | 시퀀스를 한 벡터로 요약하는 일 |
| 실시간 음성 인식 | 불가 | 지연 — 발화가 끝나야 첫 글자 (§1) |
| 언어 모델 · 텍스트 생성 | 불가 | 라벨 누수 — 정답이 입력에 들어 있음 |
| seq2seq의 인코더 | 가능 | 입력 문장은 전부 주어짐. 실제로 양방향을 많이 씀 |
| seq2seq의 디코더 | 불가 | 한 토큰씩 만들어 내는 쪽이라 미래가 존재하지 않음 |
자주 하는 오해 셋
- "양방향은 미래를 예측한다" — 아닙니다. 이미 다 받아 놓은 시퀀스를 뒤에서 한 번 더 읽을 뿐입니다. 없는 입력을 만들어 내지 않습니다.
- "양방향이면 기울기 소멸이 덜하다" — 무관합니다. 각 방향은 여전히 자기 방향으로 T스텝을 통과합니다. 그건 LSTM·GRU가 푸는 문제입니다.
- "양방향은 층을 두 개 쌓은 것" — 아닙니다. 같은 층이 두 방향으로 도는 것이고, 두 방향은 위아래가 아니라 나란히 있습니다. 깊이는 그대로입니다.
§6 DS2의 타협 — 행 합성곱(lookahead convolution)
여기까지가 문제 설정입니다. 양방향은 정확하고, 실시간에는 못 씁니다. Deep Speech 2는 이 둘을 다 갖고 싶었고, 논문 3.7절에서 이렇게 풉니다.
연구용으로 최고 성적을 낸 모델은 양방향 순환층 7층입니다. 서비스에 올린 모델은 단방향 5층(은닉 2560) + 행 합성곱 한 층입니다.
순환층을 전부 단방향으로 되돌리면 §3에서 본 "뒤에 오는 증거"를 잃습니다. 그래서 순환 스택 맨 위에 딱 한 층, 미래 τ 칸만 훔쳐보는 층을 얹습니다. 이것이 행 합성곱(row convolution)이고, 같은 구조가 lookahead convolution이라는 이름으로도 불립니다.
이 식에서 가장 중요한 것은 i가 양변에 그대로 있다는 사실입니다.
채널 i는 자기 채널의 미래만 봅니다. 채널끼리 섞지 않아요.
요즘 말로 하면 depthwise 1D convolution이고, 이름이 "행" 합성곱인 것도 특징 행렬의 한 행(= 한 채널)씩 따로 처리하기 때문입니다.
왜 섞지 않았을까요. 섞었다면 W가 d × d × (τ+1)이 되어 파라미터가 정확히 d배로 늘어납니다.
d = 2560이면 5만 개짜리 층이 1억 3천만 개가 되어, 층 하나가 순환 스택 전체보다 커집니다.
아래 계산기에서 직접 확인해 보세요.
바뀐 것은 숫자가 아니라 지연의 종류입니다. 양방향의 지연은 발화 길이에 비례해서, 사용자가 길게 말할수록 더 오래 기다려야 합니다. 행 합성곱의 지연은 τ·프레임 간격으로 고정된 상수입니다 — DS2 배포 모델은 τ = 19 · 40 ms = 760 ms. 발화가 1초든 30초든 760 ms입니다.
K를 올려 보면 lookahead를 여러 층에 나눠 갖는 것이 공짜가 아니라는 것도 보입니다.
층마다 τ칸씩 엿보면 미래 문맥은 K·τ칸으로 더해지고, 지연도 같이 더해집니다.
τ는 층 하나의 설정값이 아니라 모델 전체가 쓰는 예산입니다. DS2가 한 층에 몰아넣은 이유이기도 하고요.
그래서 얼마나 손해였나
| 항목 | 연구용 시스템 | 배포 시스템 |
|---|---|---|
| 순환층 | 양방향 7층 | 단방향 5층(2560) + 행 합성곱 τ=19 |
| 중국어 CER (2,000 발화) | 5.81 | 6.10 |
| 첫 글자 지연 | 발화 길이에 비례 | 상수 760 ms |
| 수치 정밀도 | 32비트 | 16비트 |
정확도 손실은 상대 5%입니다. 그리고 논문은 여기서 한 줄 더 붙입니다 — 중국어에서는 이 근사가 최고의 양방향 모델보다 CER이 낮게 나온 경우도 있었다고요. 미래 전체가 항상 도움이 되는 것은 아니라는 §3의 "흩어져 있다" 설정과 같은 이야기입니다.
행 합성곱은 단방향 RNN을 쓸 때만 추가되는 층입니다. 양방향 모델에는 없습니다 — 이미 미래를 끝까지 보고 있으니 엿볼 것이 남아 있지 않으니까요. 행 합성곱은 "양방향의 미래 문맥"을 유한한 τ개로 잘라 흉내 내는 부품이고, 존재 이유 자체가 "양방향을 포기했기 때문"입니다.
§7 실무 메모 — 패딩이 역방향을 망가뜨린다
bidirectional=True는 한 글자지만, 그 뒤에 딸려 오는 함정은 한 글자가 아닙니다.
모양부터
rnn = nn.LSTM(input_size=D, hidden_size=H, num_layers=L, bidirectional=True)
out, (h_n, c_n) = rnn(x) # x: (T, N, D)
out.shape # (T, N, 2H) ← concat
out[..., :H] # 정방향 절반
out[..., H:] # 역방향 절반
h_n.shape # (L*2, N, H) ← 층 × 방향
h_n[-2], h_n[-1] # 마지막 층의 정방향 / 역방향 최종 상태
out[-1]을 씁니다.
그런데 out[-1]의 뒤쪽 절반은 역방향이 방금 첫 프레임을 읽은 상태입니다. 문장을 거의 읽지 않은 벡터예요.
쓰려면 torch.cat([h_n[-2], h_n[-1]], dim=-1)입니다 — 각 방향의 "끝까지 읽은" 상태를 모으는 것.
단방향에서 아무 문제 없던 코드가 양방향으로 바꾸는 순간 조용히 틀리기 시작하는 지점입니다.
그리고 패딩
길이가 다른 시퀀스를 배치로 묶으면 뒤를 0으로 채웁니다.
정방향은 실제 토큰부터 읽기 시작하니 그럭저럭 괜찮습니다. 역방향은 패딩부터 읽습니다.
pack_padded_sequence가 하는 일pack을 쓰지 않으면 역방향 RNN은 길이가 짧은 시퀀스일수록 더 많은 0을 먼저 먹고 실제 토큰에 도착합니다. 게이트가 0 입력에도 상태를 조금씩 바꾸기 때문에 이것은 "무해한 0"이 아닙니다 — 같은 문장이 배치 안의 다른 문장 길이에 따라 다른 표현을 갖게 됩니다. 재현성부터 깨집니다.
pack_padded_sequence는 각 시퀀스의 실제 길이를 알려 줘서 역방향이 진짜 마지막 토큰에서 시작하게 만듭니다.
단방향에서는 "있으면 좋은" 최적화지만, 양방향에서는 정확성 문제입니다.
한 줄짜리 점검 목록
- 가변 길이 배치 + 양방향 →
pack_padded_sequence는 선택이 아니라 필수 - 요약 벡터는
out[-1]이 아니라h_n[-2] ⊕ h_n[-1] - 다음 층 입력 차원은
H가 아니라2H(concat이므로) - 주의(attention)나 CTC를 얹을 때 출력 차원이 2배가 된 것을 반영했는지
- 추론 때 스트리밍을 할 계획이라면 학습 때부터 단방향으로 — 나중에 못 바꿉니다
§8 그 뒤 — BERT·GPT, 청크 어텐션, RNN-T
RNN은 대부분 Transformer로 대체됐지만, 이 글의 맞교환은 하나도 사라지지 않았습니다. 수단만 바뀌었어요.
| RNN 시대 | 지금 | 같은 점 |
|---|---|---|
| 단방향 RNN | causal mask를 쓴 디코더 (GPT) | 미래를 구조적으로 차단 — 생성이 가능한 유일한 형태 |
| 양방향 RNN | 마스크 없는 인코더 (BERT) | 전체 문맥. 대신 다음 토큰 예측을 못 해 MLM으로 학습 |
| 행 합성곱 (τ칸 엿보기) | 청크 단위 어텐션 · RNN-T의 right context | 미래를 상수만큼만 봐서 지연을 유한하게 고정 |
| 양방향 + 오프라인 디코딩 | Whisper 같은 오프라인 ASR 인코더 | 파일 전체를 보고 정확도를 취함 |
실시간 음성 인식 논문에서 "latency budget"이라는 말이 나오면 거의 항상 이 이야기입니다 — 미래를 몇 ms까지 기다릴 것인가. DS2의 τ = 19 · 40 ms = 760 ms가 그 예산을 정하는 가장 이른 사례 중 하나였습니다.
§9 정리 — 한 문장으로
양방향 RNN은 반대로 도는 RNN을 하나 더 붙여 모든 출력이 시퀀스 전체를 보게 만드는 것이고, 그 대가로 "입력이 끝나기 전에는 아무것도 내놓을 수 없는" 모델이 된다.
정리하면 이렇습니다.
| 단방향 | 양방향 | 단방향 + 행 합성곱 | |
|---|---|---|---|
| t가 보는 범위 | 1..t | 1..T | 1..t+τ |
| 첫 출력 | 즉시 | 입력이 끝난 뒤 | τ·간격 뒤 |
| 지연의 종류 | 없음 | 발화 길이 비례 | 상수 |
| 순환층 파라미터 | 1배 | 약 2배 (§4) | 1배 + (τ+1)·d |
| 언어 모델 | 가능 | 불가 (라벨 누수) | 불가 |
| 대표 사례 | 스트리밍 ASR, char-rnn | DS2 연구용 모델, 태깅 | DS2 배포 모델 |
그리고 헷갈리기 쉬운 것들:
- 양방향은 층을 두 개 쌓은 것이 아닙니다 — 같은 층이 나란히 두 방향으로 돕니다.
- 양방향의 두 방향은 서로를 참조하지 않습니다 — 만나는 곳은 출력을 합치는 지점뿐입니다.
- 양방향이 항상 이기는 것도 아닙니다 — DS2 중국어에서는 τ=19 근사가 더 나은 경우도 있었습니다.
- 행 합성곱은 단방향일 때만 붙습니다 — 양방향 모델에는 존재하지 않는 층입니다.
- 실무의 버그는 대개 수식이 아니라
2H와 패딩에서 납니다 (§7).