Level 6: 시퀀스 모델 (RNN/LSTM)
📝

Level 6

양방향 RNN

Bidirectional RNN으로 양쪽 문맥 활용하기

40분
양방향 RNN 강의 영상
강의 영상 보기 (새 탭에서 재생)YouTube

📓Google Colab에서 실습하기

이 레슨은 PyTorch/GPU가 필요합니다. 노트북을 다운로드 후 Google Colab에서 열어주세요.

학습 내용

양방향 RNN (Bidirectional RNN)

학습 목표

이 레슨을 완료하면:

  • 양방향 RNN이 왜 필요한지 직관적으로 이해할 수 있다
  • 순방향과 역방향 처리가 어떻게 결합되는지 설명할 수 있다
  • 단방향 vs 양방향의 성능 차이를 체감할 수 있다
  • NumPy로 양방향 RNN의 핵심 원리를 실습할 수 있다
  • PyTorch에서 양방향 RNN/LSTM을 사용하는 법을 익힐 수 있다

핵심 메시지

"문장을 앞에서만 읽지 말고, 뒤에서도 읽어보세요!" 양방향 RNN은 과거 문맥과 미래 문맥을 동시에 활용하여 더 풍부한 이해를 만듭니다.


왜 양방향이 필요할까?

비유: 빈칸 채우기 시험 "나는 어제 ___에서 맛있는 파스타를 먹었다."

이 문장의 빈칸을 채우려면 앞쪽("나는 어제")도 보고, 뒤쪽("맛있는 파스타를 먹었다")도 봐야 합니다. "맛있는 파스타"라는 뒤쪽 정보가 있어야 "레스토랑"이라고 답할 수 있죠!

단방향 RNN은 앞쪽만 보고 답을 해야 하는 것과 같습니다. 양방향 RNN은 앞뒤 모두 보고 답할 수 있습니다.

단방향 RNN의 한계

일반 RNN은 왼쪽에서 오른쪽으로만 읽습니다.

단방향 RNN:
  "나는"  ->  "어제"  ->  "___"  ->  "에서"  ->  "파스타를"  ->  "먹었다"
   h1    ->   h2     ->   h3    ->   h4     ->    h5      ->    h6

h3 시점에서 빈칸을 예측할 때:
  - "나는", "어제"만 알고 있음 (왼쪽 문맥만!)
  - "파스타를 먹었다"는 아직 보지 못함

양방향 RNN의 해결책

두 개의 RNN을 동시에 돌립니다. 하나는 앞에서 뒤로, 다른 하나는 뒤에서 앞으로!

순방향:
  "나는" -> "어제" -> "___" -> "에서" -> "파스타를" -> "먹었다"
   h1->    h2->    h3->    h4->     h5->      h6->

역방향:
  "나는" <- "어제" <- "___" <- "에서" <- "파스타를" <- "먹었다"
   h1<-    h2<-    h3<-    h4<-     h5<-      h6<-

최종 출력 = [h3-> ; h3<-]  (두 방향의 정보를 합침!)

이제 빈칸 위치에서 앞뒤 문맥을 모두 활용할 수 있습니다.


양방향 RNN의 구조

전체 흐름

입력 시퀀스: x1 x2 x3 x4 x5 | | | | | 순방향 RNN: h1-> --> h2-> --> h3-> --> h4-> --> h5-> | | | | | 역방향 RNN: h1<- <-- h2<- <-- h3<- <-- h4<- <-- h5<- | | | | | 최종 출력: [h1->;h1<-] [h2->;h2<-] [h3->;h3<-] [h4->;h4<-] [h5->;h5<-]

핵심 포인트

구성 요소역할보는 범위
순방향 RNN왼쪽에서 오른쪽으로 읽기현재 위치의 왼쪽(과거) 문맥
역방향 RNN오른쪽에서 왼쪽으로 읽기현재 위치의 오른쪽(미래) 문맥
최종 출력두 방향 결합전체 문맥

중요: 순방향과 역방향 RNN은 완전히 독립적인 별개의 네트워크입니다. 파라미터(가중치)를 공유하지 않으며, 각각 따로 학습됩니다.


NumPy로 양방향 RNN 이해하기

실행해보기: 단방향 vs 양방향 직관 비교

단어 시퀀스를 앞에서만 읽을 때와 앞뒤로 읽을 때 정보가 어떻게 달라지는지 봅시다.

python
import numpy as np # 간단한 문장을 숫자 벡터로 표현 # "고양이가 매트 위에 앉았다" 를 5개 단어로 표현 np.random.seed(42) words = ["고양이가", "매트", "위에", "앉았다", "."] seq_len = len(words) input_dim = 4 # 각 단어의 벡터 차원 hidden_dim = 3 # 은닉 상태 차원 # 입력 벡터 (각 단어를 4차원 벡터로 표현) X = np.random.randn(seq_len, input_dim) # --- 순방향 RNN --- W_xh_fwd = np.random.randn(input_dim, hidden_dim) * 0.5 W_hh_fwd = np.random.randn(hidden_dim, hidden_dim) * 0.5 h_forward = np.zeros((seq_len, hidden_dim)) h_prev = np.zeros(hidden_dim) for t in range(seq_len): # 0, 1, 2, 3, 4 순서 h_prev = np.tanh(X[t] @ W_xh_fwd + h_prev @ W_hh_fwd) h_forward[t] = h_prev # --- 역방향 RNN --- W_xh_bwd = np.random.randn(input_dim, hidden_dim) * 0.5 W_hh_bwd = np.random.randn(hidden_dim, hidden_dim) * 0.5 h_backward = np.zeros((seq_len, hidden_dim)) h_prev = np.zeros(hidden_dim) for t in range(seq_len - 1, -1, -1): # 4, 3, 2, 1, 0 역순! h_prev = np.tanh(X[t] @ W_xh_bwd + h_prev @ W_hh_bwd) h_backward[t] = h_prev # --- 양방향 결합 --- h_bidirectional = np.concatenate([h_forward, h_backward], axis=1) print("=== 각 시점별 은닉 상태 크기 ===") print(f"순방향만: {h_forward.shape} (각 위치: {hidden_dim}차원)") print(f"역방향만: {h_backward.shape} (각 위치: {hidden_dim}차원)") print(f"양방향 결합: {h_bidirectional.shape} (각 위치: {hidden_dim*2}차원)") print() print("=== 위치별 정보 비교 ===") for t in range(seq_len): fwd_info = "왼쪽 문맥: " + " + ".join(words[:t+1]) bwd_info = "오른쪽 문맥: " + " + ".join(words[t:]) print(f"위치 {t} [{words[t]:5s}]:") print(f" 순방향 h: {np.round(h_forward[t], 2)} <- {fwd_info}") print(f" 역방향 h: {np.round(h_backward[t], 2)} <- {bwd_info}") print(f" 양방향 h: {np.round(h_bidirectional[t], 2)} <- 전체 문맥!") print()

실행해보기: 양방향이 실제로 도움되는 예

빈칸 채우기 문제에서 양방향이 얼마나 유리한지 시뮬레이션합니다.

python
import numpy as np np.random.seed(123) # 시나리오: 5개 단어 중 가운데(위치 2)를 예측하는 문제 # 순방향은 위치 0,1의 정보만, 양방향은 0,1,3,4의 정보 모두 활용 hidden_dim = 4 # 순방향: 위치 2에서의 은닉 상태 (위치 0, 1 정보만 담김) h_fwd_at_2 = np.array([0.5, -0.3, 0.8, 0.1]) # 왼쪽 문맥만 # 역방향: 위치 2에서의 은닉 상태 (위치 4, 3 정보만 담김) h_bwd_at_2 = np.array([-0.2, 0.7, 0.4, -0.6]) # 오른쪽 문맥만 # 양방향 결합 h_bi_at_2 = np.concatenate([h_fwd_at_2, h_bwd_at_2]) print("=== 빈칸 채우기 시뮬레이션 ===") print("문장: '나는 어제 ___ 에서 파스타를 먹었다'") print() print(f"빈칸 위치(2)에서의 표현:") print(f" 단방향 (앞만 봄): {h_fwd_at_2} (차원: {len(h_fwd_at_2)})") print(f" -> '나는', '어제' 정보만 있음") print(f" 양방향 (앞뒤 봄): {h_bi_at_2} (차원: {len(h_bi_at_2)})") print(f" -> '나는', '어제' + '에서', '파스타를', '먹었다' 정보 포함!") print() # 정보량 비교 (벡터 크기 = 정보의 풍부함을 대략 나타냄) info_fwd = np.linalg.norm(h_fwd_at_2) info_bi = np.linalg.norm(h_bi_at_2) print(f"정보량 비교 (벡터 크기):") print(f" 단방향: {info_fwd:.3f}") print(f" 양방향: {info_bi:.3f}") print(f" 양방향이 약 {info_bi/info_fwd:.1f}배 더 풍부한 정보!") print() # 결합 방식 설명 print("=== 양방향 출력 결합 방식들 ===") concat_result = np.concatenate([h_fwd_at_2, h_bwd_at_2]) sum_result = h_fwd_at_2 + h_bwd_at_2 avg_result = (h_fwd_at_2 + h_bwd_at_2) / 2 print(f"1. Concatenation (가장 일반적): 차원 = {len(concat_result)}") print(f" 결과: {np.round(concat_result, 2)}") print(f"2. Sum: 차원 = {len(sum_result)}") print(f" 결과: {np.round(sum_result, 2)}") print(f"3. Average: 차원 = {len(avg_result)}") print(f" 결과: {np.round(avg_result, 2)}")

양방향 RNN을 쓰면 좋은 경우 vs 쓰면 안 되는 경우

비유: 시험지 채점 vs 실시간 통역

시험지 채점(양방향 OK): 답안지 전체를 다 읽고 나서 채점하면 됩니다. 앞뒤 문맥을 모두 볼 수 있습니다.

실시간 통역(양방향 NO): 상대방이 아직 말을 안 한 부분은 볼 수 없습니다. 미래 정보를 사용할 수 없으므로 단방향만 가능합니다.

사용 가능 (전체 시퀀스 접근 가능)사용 불가 (미래 정보 없음)
텍스트 분류 (감성 분석)실시간 텍스트 생성
개체명 인식 (NER)실시간 번역 (스트리밍)
품사 태깅 (POS tagging)주가 예측 (미래 데이터 없음)
기계 독해 (질의응답)챗봇 응답 생성
음성 인식 (녹음 완료 후)실시간 음성 인식

규칙: "입력 시퀀스 전체를 미리 볼 수 있는가?"가 핵심입니다.


양방향 출력의 결합 방식

두 방향의 은닉 상태를 합치는 방법은 여러 가지입니다.

1. Concatenation (연결) - 가장 보편적

ht=[ht(fwd);ht(bwd)]h_t = [h_t^{(fwd)} ; h_t^{(bwd)}]

차원: hiddendimimes2hidden_dim imes 2

정보 손실이 없어서 가장 많이 사용합니다. 다만, 다음 레이어의 입력 차원이 2배가 됩니다.

2. Sum (합산)

ht=ht(fwd)+ht(bwd)h_t = h_t^{(fwd)} + h_t^{(bwd)}

차원: hiddendimhidden_dim (유지)

차원을 유지하고 싶을 때 사용합니다.

3. Average (평균)

ht=(ht(fwd)+ht(bwd))/2h_t = (h_t^{(fwd)} + h_t^{(bwd)}) / 2

차원: hiddendimhidden_dim (유지)

결합 방식출력 차원정보 보존주 사용처
Concatenationhidden * 2최대PyTorch 기본값
Sumhidden중간차원 유지 필요시
Averagehidden중간정규화 효과 원할 때

PyTorch에서 양방향 RNN/LSTM 사용

PyTorch에서는 bidirectional=True 한 줄만 추가하면 됩니다!

기본 사용법

import torch
import torch.nn as nn

# 단방향 LSTM
lstm_uni = nn.LSTM(input_size=10, hidden_size=20, batch_first=True,
                   bidirectional=False)

# 양방향 LSTM - bidirectional=True만 추가!
lstm_bi = nn.LSTM(input_size=10, hidden_size=20, batch_first=True,
                  bidirectional=True)

# 입력 데이터: (배치 32, 시퀀스 길이 15, 입력 차원 10)
x = torch.randn(32, 15, 10)

# 단방향 출력
out_uni, (h_uni, c_uni) = lstm_uni(x)
print(f"단방향 출력: {out_uni.shape}")   # (32, 15, 20)
print(f"단방향 h_n:  {h_uni.shape}")     # (1, 32, 20)

# 양방향 출력 - 차원이 2배!
out_bi, (h_bi, c_bi) = lstm_bi(x)
print(f"양방향 출력: {out_bi.shape}")    # (32, 15, 40)  <- 20*2=40!
print(f"양방향 h_n:  {h_bi.shape}")      # (2, 32, 20)   <- 2방향!

양방향 LSTM 텍스트 분류 모델

class BiLSTMClassifier(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim, num_classes):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim)

        self.lstm = nn.LSTM(
            input_size=embed_dim,
            hidden_size=hidden_dim,
            num_layers=2,
            batch_first=True,
            dropout=0.3,
            bidirectional=True    # 이 한 줄이 핵심!
        )

        # 양방향이므로 hidden_dim * 2
        self.fc = nn.Linear(hidden_dim * 2, num_classes)

    def forward(self, x):
        embedded = self.embedding(x)    # (batch, seq, embed_dim)
        output, (h_n, c_n) = self.lstm(embedded)

        # h_n shape: (num_layers * 2, batch, hidden_dim)
        # 마지막 레이어의 순방향/역방향 은닉 상태 추출
        h_forward = h_n[-2]   # 순방향 마지막 레이어
        h_backward = h_n[-1]  # 역방향 마지막 레이어

        # 연결하여 최종 표현 생성
        combined = torch.cat([h_forward, h_backward], dim=1)
        out = self.fc(combined)
        return out

model = BiLSTMClassifier(
    vocab_size=10000,
    embed_dim=128,
    hidden_dim=256,
    num_classes=2
)
print(f"모델 파라미터 수: {sum(p.numel() for p in model.parameters()):,}")

양방향의 파라미터와 계산 비용

비유: 직원 두 명 고용하기 양방향 RNN은 사실상 두 개의 독립적인 RNN을 운영하는 것입니다. 직원 한 명이 왼쪽부터 읽고, 다른 한 명이 오른쪽부터 읽는 것과 같죠. 그래서 비용(파라미터)도 거의 2배입니다!

비교 항목단방향양방향
RNN 개수1개2개
파라미터 수P약 2P
출력 차원hidden_dimhidden_dim * 2
학습 시간기준약 2배
정보 활용과거만과거 + 미래

실전 팁: 다층 양방향 LSTM

여러 층을 쌓을 때, 양방향 출력을 다음 층에 어떻게 전달하는지 이해하는 것이 중요합니다.

다층 양방향 LSTM (2층 예시):

Layer 2:  h1->(2) --> h2->(2) --> h3->(2)    <- 순방향 Layer 2
          h1<-(2) <-- h2<-(2) <-- h3<-(2)    <- 역방향 Layer 2
              ^           ^          ^
Layer 1:  [h1->;h1<-](1) [h2->;h2<-](1) [h3->;h3<-](1)  <- Layer 1 출력(결합됨)
              ^           ^          ^
          h1->(1) --> h2->(1) --> h3->(1)    <- 순방향 Layer 1
          h1<-(1) <-- h2<-(1) <-- h3<-(1)    <- 역방향 Layer 1
              ^           ^          ^
입력:        x1          x2         x3

PyTorch에서는 num_layers=2, bidirectional=True로 자동 처리됩니다.


핵심 요약

개념설명비유
단방향 RNN왼쪽에서 오른쪽으로만 읽음책을 처음부터 순서대로 읽기
양방향 RNN양쪽 방향으로 동시에 읽음빈칸 채우기 - 앞뒤 모두 보기
순방향 은닉과거 문맥 정보를 담음"지금까지 읽은 내용 요약"
역방향 은닉미래 문맥 정보를 담음"뒤에서부터 읽은 내용 요약"
Concatenation두 방향 출력을 이어 붙임두 요약을 합쳐 전체 이해

학습 체크리스트

  • 양방향 RNN이 두 개의 독립적인 RNN으로 구성됨을 이해했다
  • 빈칸 채우기 비유로 양방향의 필요성을 설명할 수 있다
  • 양방향을 쓸 수 있는 경우와 없는 경우를 구분할 수 있다
  • NumPy 코드에서 순방향/역방향 루프의 차이를 이해했다
  • PyTorch에서 bidirectional=True 사용법을 익혔다
  • 출력 차원이 2배가 되는 이유를 설명할 수 있다

다음 레슨 예고

다음 시간에는 시퀀스를 다른 시퀀스로 변환하는 Seq2Seq (Sequence-to-Sequence) 모델을 배웁니다. 번역기가 어떻게 작동하는지 알아봅시다!

레슨 정보

레벨
Level 6: 시퀀스 모델 (RNN/LSTM)
예상 소요 시간
40분
참고 영상
YouTube 링크

💡실습 환경 안내

이 레벨은 PyTorch/GPU가 필요하여 Google Colab 사용을 권장합니다.

Colab은 무료 GPU를 제공하여 PyTorch, CNN, Transformer 등을 실행할 수 있습니다.