HOW AI WORKS

양방향 RNN은 어떻게 동작하는가
— 미래를 보는 대가

RNN에 "양방향"을 켜는 것은 코드 한 글자입니다 — bidirectional=True. 정확도는 거의 항상 오릅니다. 그런데 이 한 글자가 모델을 실시간에서 영구히 추방합니다. 앞에서부터 읽는 것과 뒤에서도 읽는 것이 정확히 무엇을 바꾸는지, 왜 음성 인식에서 그렇게 큰 차이를 만드는지, 그리고 Deep Speech 2가 서비스에 올리기 위해 양방향을 포기하고 대신 얹은 층 하나가 무엇인지 브라우저에서 직접 계산해 봅니다.

how-ai-works 시리즈 · 원전: Schuster & Paliwal, Bidirectional Recurrent Neural Networks (IEEE TSP, 1997) · Amodei et al., Deep Speech 2 (2015) 3.7절

차례
  1. 방향이란 무엇인가 — 첫 글자는 언제 나오는가
  2. 누가 무엇을 보는가 — 의존성 행렬
  3. 왜 더 정확한가 — 증거는 늦게 도착한다
  4. 두 방향을 어떻게 합치나 — concat이냐 sum이냐
  5. 양방향으로 못 하는 것 — 언어 모델과 라벨 누수
  6. DS2의 타협 — 행 합성곱(lookahead convolution)
  7. 실무 메모 — 패딩이 역방향을 망가뜨린다
  8. 그 뒤 — BERT·GPT, 청크 어텐션, RNN-T
  9. 정리 — 한 문장으로
  10. 이해도 퀴즈

§1 방향이란 무엇인가 — 첫 글자는 언제 나오는가

순환층이 하는 일은 하나입니다 — 은닉 상태를 시간 축으로 나르기. 셀이 RNN이든 LSTM이든 GRU든 이 골격은 같습니다.

ht = f(xt, ht−1) 단방향 — t 시점의 출력은 1..t 만 보고 만들어진다

양방향은 여기에 반대 방향으로 도는 RNN을 하나 더 붙입니다. 같은 입력을 마지막 프레임부터 거꾸로 읽는 두 번째 순환층이고, 같은 시각의 두 출력을 합쳐 위층에 넘깁니다.

ht(f) = f(xt, ht−1(f))   ·   ht(b) = f(xt, ht+1(b))   →   ht = ht(f) ⊕ ht(b) 양방향 — 두 개의 독립적인 RNN. ⊕는 이어 붙이기(concat) 또는 더하기(sum), §4

두 가지를 짚고 넘어갑시다.

말로 하면 잘 와닿지 않으니 직접 돌려 봅시다. 아래는 프레임이 한 칸씩 도착하는 동안 세 구조가 각각 언제 무엇을 내보낼 수 있는지를 재생합니다.

데모 1 · 시간 전개 재생기 — 언제 출력할 수 있는가
프레임이 왼쪽부터 한 칸씩 도착합니다(1단계). 양방향은 입력이 끝난 뒤 역방향 패스가 오른쪽에서 왼쪽으로 한 번 더 쓸고 내려가는 2단계가 있습니다. 초록 칸은 그 시점에 실제로 확정된 출력입니다.
진행: 입력 대기
도착한 입력 정방향 상태 역방향 상태 아직 계산 불가 행 합성곱이 엿본 칸 확정된 출력

두 단계의 한 칸은 시간 단위가 다릅니다 — 1단계는 프레임이 도착하는 실제 시간(예: 40 ms), 2단계는 계산 한 스텝입니다.

단방향으로 돌리면 출력이 입력을 한 칸 뒤에서 그대로 따라옵니다. 양방향으로 바꾸면 재생이 두 단계로 갈라집니다.

"발화가 끝나야 첫 글자가 나온다"는 말의 실제 모습이 이것입니다. 더 정확히는 발화가 끝나고, 역방향이 처음까지 되돌아온 뒤입니다.

"양방향은 느리다"가 아니라 "양방향은 기다려야 한다"입니다. 두 방향은 독립이라, 시퀀스가 이미 손에 있으면(학습·파일 처리) 정방향과 역방향을 동시에 돌립니다. 위 재생기가 2단계로 갈라지는 것은 입력이 실시간으로 도착할 때뿐이고, 7초 발화라면 지연의 거의 전부가 말이 끝나기를 기다리는 7,000 ms입니다 — GPU를 100배 빠르게 해도 그대로입니다. §6이 하드웨어가 아니라 구조를 바꾸는 이유예요.
순환층을 여러 층 쌓으면 더 나빠집니다. 2층의 역방향은 1층의 출력이 전부 나와야 시작할 수 있으니 이 왕복이 층마다 반복됩니다. DS2의 양방향 7층이 실시간에서 가망이 없었던 이유입니다. (실제 구현은 배치와 커널로 묶어 돌지만, "마지막 프레임 없이는 시작할 수 없다"는 의존 관계 자체는 어떻게 최적화해도 사라지지 않습니다.)
오프라인 과제에서는 이게 전혀 문제가 안 됩니다. 문장 하나를 통째로 넣고 개체명을 태깅하거나, 녹음 파일을 받아 자막을 만드는 일이라면 시퀀스는 이미 전부 손에 있으니까요. 양방향이 금지되는 것은 "입력이 흐르는 동안 출력해야 하는" 과제뿐입니다.

§2 누가 무엇을 보는가 — 의존성 행렬

"지연"과 "문맥"은 같은 것의 앞면과 뒷면입니다. 한 장의 표로 동시에 볼 수 있어요 — 세로축이 출력 yt, 가로축이 입력 xs이고, 칸이 칠해져 있으면 그 출력이 그 입력을 본다는 뜻입니다.

데모 2 · 의존성 행렬 — 칠해진 칸이 곧 문맥
칸을 클릭하면 그 관계가 어떤 경로로 생기는지(또는 왜 없는지) 설명합니다.

칸을 클릭해 보세요.

세 모양이 각각 이렇게 생겼습니다.

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가 은행인지 강둑인지는 뒤를 봐야 알고, 한국어 "감사"가 고마움인지 회계 감사인지도 마찬가지입니다.

데모 3 · 증거 도착 실험 — 얼마나 엿봐야 판정이 뒤집히나
프레임마다 /d/(파랑, 양수) 또는 /t/(주황, 음수) 쪽 로그 오즈 증거가 조금씩 있습니다. 판정 대상은 5번 프레임이고, 모델은 보이는 창 안의 증거만 더할 수 있습니다.

기본 설정(늦게 온다)에서는 τ = 3에서 판정이 뒤집힙니다. τ = 0, 1, 2에서는 앞쪽의 약한 /t/ 증거가 이기고 있다가, VOT 구간이 창에 들어오는 순간 확률이 한 번에 넘어갑니다. 40 ms 프레임이라면 120 ms만 기다리면 됐다는 뜻입니다 — 발화가 끝날 때까지 기다릴 필요가 없습니다. 이것이 §6에서 볼 타협의 근거입니다.

"흩어져 있다" 설정도 꼭 눌러 보세요. τ를 늘리는 것이 늘 이득은 아닙니다 — τ = 1에서 오히려 틀린 쪽으로 넘어갔다가 τ = 2에서 돌아옵니다. 문맥을 조금 더 주는 것이 반드시 단조롭게 좋아지는 일이 아니라는 것, 그래서 τ가 튜닝 대상이라는 것을 보여 줍니다.

이 데모의 모델은 "보이는 프레임의 로그 오즈를 더한다"는 장난감입니다. 실제 RNN은 증거를 더하는 게 아니라 비선형으로 섞고, 게이트로 지우기도 합니다. 다만 창 밖의 증거는 출력에 전혀 영향을 줄 수 없다는 성질만은 구조가 보장하는 것이라 그대로입니다 — 이 데모가 보여 주려는 것도 그 성질 하나입니다.

CTC 학습에서 특히 크다

음성 인식이 양방향을 유난히 선호하는 데는 이유가 하나 더 있습니다. CTC는 어느 프레임이 어느 글자에 대응하는지를 스스로 찾습니다. 뒤를 볼 수 있으면 "이 프레임은 다음에 올 글자의 준비 구간이었구나"를 알 수 있어 정렬이 뾰족해지고, 그래서 DS2 논문도 강제 정렬이 필요할 때 양방향 CTC 모델로 뽑은 정렬을 씁니다. 단방향 모델의 출력은 같은 이유로 정답보다 몇 프레임 늦게 뜨는 경향이 있습니다.

§4 두 방향을 어떻게 합치나 — concat이냐 sum이냐

같은 "양방향"이라도 구현이 갈리는 지점이 둘 있습니다. 합치는 방법과 입력 가중치를 공유하는가입니다.

PyTorch bidirectional=TrueDeep Speech 2 식 (6)
합치는 방법이어 붙이기 concat더하기 sum
출력 차원2HH
입력 가중치방향마다 따로 (weight_ih_l0, weight_ih_l0_reverse)두 방향이 공유 (Wl 하나)
순환 가중치방향마다 따로방향마다 따로 (Uf, Ub)
htl,f = g(Wl htl−1 + Ul,f ht−1l,f + bl)  ·  htl,b = g(Wl htl−1 + Ul,b ht+1l,b + bl)  ·  hl = hl,f + hl,b DS2 식 (6) — 같은 Wl이 양쪽에 쓰입니다. 그래서 순환층 하나가 3h²(W·Uf·Ub)

둘 다 맞는 설계이고 맞교환이 다릅니다. concat은 두 방향의 정보를 섞지 않고 그대로 위층에 넘기지만, 위층의 입력 차원이 2배가 되어 위층 파라미터까지 함께 늘어납니다. sum은 차원을 유지해서 층을 깊게 쌓아도 비용이 폭발하지 않지만, 두 방향이 같은 공간을 나눠 쓰도록 강제합니다. 순환층을 7층씩 쌓는 DS2가 sum을 고른 것은 자연스러운 선택입니다.

데모 4 · 파라미터 계산기 — 양방향은 정말 2배인가
순환층만 셉니다(합성곱·출력층 제외). 숫자를 바꾸면 층별 내역이 바로 다시 계산됩니다.

직접 돌려 보면 알게 되는 것 — 양방향의 비용은 "정확히 2배"가 아닙니다. concat이면 첫 층만 2배 근처이고 위층들은 입력 차원까지 2배가 되어 2배보다 더 듭니다. sum이면 정확히 2배도 아닙니다(입력 가중치를 공유하면 2배보다 덜 듭니다).

학습 속도는 파라미터 수만큼 느려지지 않습니다. 두 방향은 서로를 참조하지 않으므로 동시에 계산할 수 있고, GPU에서는 상당 부분 겹칩니다. 대신 역전파를 위해 두 방향의 중간 활성값을 전부 들고 있어야 해서 메모리는 확실히 2배입니다. 양방향에서 OOM이 나는 이유는 보통 파라미터가 아니라 이쪽입니다.

§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의 디코더불가한 토큰씩 만들어 내는 쪽이라 미래가 존재하지 않음
마지막 두 줄이 같은 모델 안에 공존한다는 점이 재밌습니다 — 인코더는 양방향, 디코더는 단방향. "양방향이냐 단방향이냐"는 모델의 속성이 아니라 그 부품이 맡은 일의 속성입니다.

자주 하는 오해 셋

§6 DS2의 타협 — 행 합성곱(lookahead convolution)

여기까지가 문제 설정입니다. 양방향은 정확하고, 실시간에는 못 씁니다. Deep Speech 2는 이 둘을 다 갖고 싶었고, 논문 3.7절에서 이렇게 풉니다.

연구용으로 최고 성적을 낸 모델은 양방향 순환층 7층입니다. 서비스에 올린 모델은 단방향 5층(은닉 2560) + 행 합성곱 한 층입니다.

순환층을 전부 단방향으로 되돌리면 §3에서 본 "뒤에 오는 증거"를 잃습니다. 그래서 순환 스택 맨 위에 딱 한 층, 미래 τ 칸만 훔쳐보는 층을 얹습니다. 이것이 행 합성곱(row convolution)이고, 같은 구조가 lookahead convolution이라는 이름으로도 불립니다.

rt,i = Σj=1..τ+1 Wi,j · ht+j−1, i DS2 식 (11) — 미래 τ칸 + 현재 1칸을 가중합. W의 크기는 d × (τ+1)

이 식에서 가장 중요한 것은 i가 양변에 그대로 있다는 사실입니다. 채널 i는 자기 채널의 미래만 봅니다. 채널끼리 섞지 않아요. 요즘 말로 하면 depthwise 1D convolution이고, 이름이 "행" 합성곱인 것도 특징 행렬의 한 행(= 한 채널)씩 따로 처리하기 때문입니다.

왜 섞지 않았을까요. 섞었다면 W가 d × d × (τ+1)이 되어 파라미터가 정확히 d배로 늘어납니다. d = 2560이면 5만 개짜리 층이 1억 3천만 개가 되어, 층 하나가 순환 스택 전체보다 커집니다. 아래 계산기에서 직접 확인해 보세요.

데모 5 · 지연·파라미터 예산 계산기
"첫 글자가 나오기까지"를 세 구조로 비교합니다. 순수한 구조적 지연만 세며, 계산 시간은 빼고 봅니다.

바뀐 것은 숫자가 아니라 지연의 종류입니다. 양방향의 지연은 발화 길이에 비례해서, 사용자가 길게 말할수록 더 오래 기다려야 합니다. 행 합성곱의 지연은 τ·프레임 간격으로 고정된 상수입니다 — DS2 배포 모델은 τ = 19 · 40 ms = 760 ms. 발화가 1초든 30초든 760 ms입니다.

K를 올려 보면 lookahead를 여러 층에 나눠 갖는 것이 공짜가 아니라는 것도 보입니다. 층마다 τ칸씩 엿보면 미래 문맥은 K·τ칸으로 더해지고, 지연도 같이 더해집니다. τ는 층 하나의 설정값이 아니라 모델 전체가 쓰는 예산입니다. DS2가 한 층에 몰아넣은 이유이기도 하고요.

그래서 얼마나 손해였나

항목연구용 시스템배포 시스템
순환층양방향 7층단방향 5층(2560) + 행 합성곱 τ=19
중국어 CER (2,000 발화)5.816.10
첫 글자 지연발화 길이에 비례상수 760 ms
수치 정밀도32비트16비트

정확도 손실은 상대 5%입니다. 그리고 논문은 여기서 한 줄 더 붙입니다 — 중국어에서는 이 근사가 최고의 양방향 모델보다 CER이 낮게 나온 경우도 있었다고요. 미래 전체가 항상 도움이 되는 것은 아니라는 §3의 "흩어져 있다" 설정과 같은 이야기입니다.

이 글에서 딱 하나만 가져간다면 이것입니다.
행 합성곱은 단방향 RNN을 쓸 때만 추가되는 층입니다. 양방향 모델에는 없습니다 — 이미 미래를 끝까지 보고 있으니 엿볼 것이 남아 있지 않으니까요. 행 합성곱은 "양방향의 미래 문맥"을 유한한 τ개로 잘라 흉내 내는 부품이고, 존재 이유 자체가 "양방향을 포기했기 때문"입니다.

→ 이 층 하나만 다루는 자료가 따로 있습니다: How Row Convolution Works — 식 (11)을 칸 클릭으로 전개하고, 세 구조를 실시간 재생으로 비교합니다.

§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으로 채웁니다. 정방향은 실제 토큰부터 읽기 시작하니 그럭저럭 괜찮습니다. 역방향은 패딩부터 읽습니다.

데모 6 · 패딩 오염 시뮬레이터 — pack_padded_sequence가 하는 일
길이가 다른 시퀀스 3개를 한 배치로 묶었습니다. ▶ 표시가 역방향 패스가 시작하는 칸입니다.
지금: 안 씀 (그냥 패딩 텐서)
실제 토큰 패딩 역방향이 읽어 버린 패딩 역방향 시작 칸

pack을 쓰지 않으면 역방향 RNN은 길이가 짧은 시퀀스일수록 더 많은 0을 먼저 먹고 실제 토큰에 도착합니다. 게이트가 0 입력에도 상태를 조금씩 바꾸기 때문에 이것은 "무해한 0"이 아닙니다 — 같은 문장이 배치 안의 다른 문장 길이에 따라 다른 표현을 갖게 됩니다. 재현성부터 깨집니다.

pack_padded_sequence는 각 시퀀스의 실제 길이를 알려 줘서 역방향이 진짜 마지막 토큰에서 시작하게 만듭니다. 단방향에서는 "있으면 좋은" 최적화지만, 양방향에서는 정확성 문제입니다.

한 줄짜리 점검 목록

§8 그 뒤 — BERT·GPT, 청크 어텐션, RNN-T

RNN은 대부분 Transformer로 대체됐지만, 이 글의 맞교환은 하나도 사라지지 않았습니다. 수단만 바뀌었어요.

RNN 시대지금같은 점
단방향 RNNcausal 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..t1..T1..t+τ
첫 출력즉시입력이 끝난 뒤τ·간격 뒤
지연의 종류없음발화 길이 비례상수
순환층 파라미터1배약 2배 (§4)1배 + (τ+1)·d
언어 모델가능불가 (라벨 누수)불가
대표 사례스트리밍 ASR, char-rnnDS2 연구용 모델, 태깅DS2 배포 모델

그리고 헷갈리기 쉬운 것들:

§10 이해도 퀴즈