
📓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의 구조
전체 흐름
핵심 포인트
| 구성 요소 | 역할 | 보는 범위 |
|---|---|---|
| 순방향 RNN | 왼쪽에서 오른쪽으로 읽기 | 현재 위치의 왼쪽(과거) 문맥 |
| 역방향 RNN | 오른쪽에서 왼쪽으로 읽기 | 현재 위치의 오른쪽(미래) 문맥 |
| 최종 출력 | 두 방향 결합 | 전체 문맥 |
중요: 순방향과 역방향 RNN은 완전히 독립적인 별개의 네트워크입니다. 파라미터(가중치)를 공유하지 않으며, 각각 따로 학습됩니다.
NumPy로 양방향 RNN 이해하기
실행해보기: 단방향 vs 양방향 직관 비교
단어 시퀀스를 앞에서만 읽을 때와 앞뒤로 읽을 때 정보가 어떻게 달라지는지 봅시다.
pythonimport 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()
실행해보기: 양방향이 실제로 도움되는 예
빈칸 채우기 문제에서 양방향이 얼마나 유리한지 시뮬레이션합니다.
pythonimport 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 (연결) - 가장 보편적
차원:
정보 손실이 없어서 가장 많이 사용합니다. 다만, 다음 레이어의 입력 차원이 2배가 됩니다.
2. Sum (합산)
차원: (유지)
차원을 유지하고 싶을 때 사용합니다.
3. Average (평균)
차원: (유지)
| 결합 방식 | 출력 차원 | 정보 보존 | 주 사용처 |
|---|---|---|---|
| Concatenation | hidden * 2 | 최대 | PyTorch 기본값 |
| Sum | hidden | 중간 | 차원 유지 필요시 |
| Average | hidden | 중간 | 정규화 효과 원할 때 |
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_dim | hidden_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 등을 실행할 수 있습니다.