들어가며
CNN이 이미지의 공간적 구조를 다루는 도구라면, RNN은 시간적 순서가 있는 데이터를 다루는 도구다. 텍스트, 시계열 데이터, 음성 등 순서가 중요한 데이터에 사용된다. 웹 개발로 비유하면 RNN은 이전 API 응답 결과를 다음 요청에 활용하는 것처럼 이전 상태를 기억하면서 처리하는 구조다.
왜 RNN인가?
일반 신경망은 입력이 서로 독립적이라고 가정한다. 하지만 텍스트는 단어 순서가 의미를 결정한다.
"나는 밥을 먹었다"
"밥을 나는 먹었다"
→ 같은 단어지만 순서에 따라 의미가 달라짐
"I love you" → 긍정
"I don't love you" → 부정
→ "love"만 보면 모름, 앞뒤 맥락이 중요함
RNN 기본 구조
import numpy as np
class RNNCell:
def __init__(self, input_size, hidden_size):
self.W_x = np.random.randn(hidden_size, input_size) * 0.01
self.W_h = np.random.randn(hidden_size, hidden_size) * 0.01
self.b = np.zeros(hidden_size)
def forward(self, x, h_prev):
# h_t = tanh(W_x * x_t + W_h * h_{t-1} + b)
h_t = np.tanh(self.W_x @ x + self.W_h @ h_prev + self.b)
return h_t
# 시퀀스 처리
cell = RNNCell(input_size=4, hidden_size=8)
h = np.zeros(8) # 초기 은닉 상태
sequence = np.random.randn(5, 4)
for t in range(5):
h = cell.forward(sequence[t], h)
print(f"t={t}: h[0]={h[0]:.4f}")
# 마지막 은닉 상태 = 전체 시퀀스를 요약한 벡터
시간 흐름 →
x_0 → [RNN] → h_0 → [RNN] → h_1 → [RNN] → h_2 → h_T
↑ ↑ ↑
h_-1 h_0 h_1
같은 가중치 W_x, W_h를 모든 시간 단계에서 공유한다.
PyTorch에서 RNN
import torch
import torch.nn as nn
rnn = nn.RNN(
input_size=4,
hidden_size=8,
num_layers=2, # RNN 레이어 수
batch_first=True, # 입력: (batch, seq, feature)
dropout=0.2,
bidirectional=False
)
x = torch.randn(4, 10, 4) # (배치, 시퀀스, 특성)
h0 = torch.zeros(2, 4, 8) # (num_layers, batch, hidden)
output, h_n = rnn(x, h0)
print(f"output: {output.shape}") # (4, 10, 8) 각 시점의 출력
print(f"h_n: {h_n.shape}") # (2, 4, 8) 최종 은닉 상태
RNN의 문제점: 장기 의존성
"나는 어제 서울에서 맛있는 음식을 먹었고 오늘은 배가 아프다"
"나는"과 "배가 아프다" 사이의 관계를 학습해야 하는데, 시퀀스가 길수록 앞부분 정보가 점점 희미해진다. 이게 장기 의존성(Long-term Dependency) 문제다.
LSTM (Long Short-Term Memory)
RNN의 장기 의존성 문제를 해결하기 위해 1997년에 제안됐다. 셀 상태(Cell State)와 은닉 상태(Hidden State) 두 가지를 유지하고 세 개의 게이트로 정보 흐름을 제어한다.
LSTM 게이트 구현
class LSTMCell:
def __init__(self, input_size, hidden_size):
self.W = np.random.randn(4 * hidden_size, input_size + hidden_size) * 0.01
self.b = np.zeros(4 * hidden_size)
self.hidden_size = hidden_size
def sigmoid(self, x):
return 1 / (1 + np.exp(-x))
def forward(self, x, h_prev, c_prev):
combined = np.concatenate([h_prev, x])
gates = self.W @ combined + self.b
hs = self.hidden_size
f_t = self.sigmoid(gates[0*hs:1*hs]) # Forget gate: 무엇을 잊을지
i_t = self.sigmoid(gates[1*hs:2*hs]) # Input gate: 무엇을 기억할지
g_t = np.tanh(gates[2*hs:3*hs]) # Gate gate: 새로운 정보
o_t = self.sigmoid(gates[3*hs:4*hs]) # Output gate: 무엇을 출력할지
# 셀 상태 업데이트: 이전 정보 유지 + 새 정보 추가
c_t = f_t * c_prev + i_t * g_t
h_t = o_t * np.tanh(c_t)
return h_t, c_t
게이트의 직관적 이해
Forget Gate (f_t): "이전 정보 중 무엇을 잊을까?"
→ 0에 가까우면 이전 셀 상태 삭제
→ 1에 가까우면 이전 셀 상태 유지
→ 새로운 주제로 전환될 때 이전 문맥을 잊음
Input Gate (i_t): "새 정보 중 무엇을 기억할까?"
→ 0에 가까우면 새 정보 무시
→ 1에 가까우면 새 정보 저장
Gate Gate (g_t): "어떤 새로운 정보를 추가할까?"
→ tanh로 -1~1 범위의 새로운 후보값
Output Gate (o_t): "셀 상태 중 무엇을 출력할까?"
→ 현재 셀 상태에서 어떤 부분을 출력할지 결정
PyTorch에서 LSTM
lstm = nn.LSTM(
input_size=4,
hidden_size=128,
num_layers=2,
batch_first=True,
dropout=0.3,
bidirectional=False
)
x = torch.randn(4, 20, 4)
h0 = torch.zeros(2, 4, 128)
c0 = torch.zeros(2, 4, 128)
output, (h_n, c_n) = lstm(x, (h0, c0))
print(f"output: {output.shape}") # (4, 20, 128)
print(f"h_n: {h_n.shape}") # (2, 4, 128)
print(f"c_n: {c_n.shape}") # (2, 4, 128)
LSTM으로 텍스트 분류
class TextClassifierLSTM(nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_size,
num_layers, num_classes, dropout=0.3):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.lstm = nn.LSTM(
embed_dim, hidden_size,
num_layers=num_layers,
batch_first=True,
dropout=dropout if num_layers > 1 else 0,
bidirectional=True # 양방향 LSTM
)
self.fc = nn.Linear(hidden_size * 2, num_classes) # 양방향이면 *2
self.dropout = nn.Dropout(dropout)
def forward(self, x):
embedded = self.dropout(self.embedding(x))
output, (h_n, c_n) = self.lstm(embedded)
# 양방향: 순방향 + 역방향 마지막 레이어 합침
h_forward = h_n[-2]
h_backward = h_n[-1]
h_combined = torch.cat([h_forward, h_backward], dim=1)
return self.fc(self.dropout(h_combined))
model = TextClassifierLSTM(
vocab_size=20000, embed_dim=256,
hidden_size=128, num_layers=2, num_classes=5
)
x = torch.randint(0, 20000, (4, 100))
print(f"출력: {model(x).shape}") # (4, 5)
GRU (Gated Recurrent Unit)
LSTM의 간소화 버전이다. 게이트를 2개로 줄여서 파라미터가 적고 빠르다.
# PyTorch GRU
gru = nn.GRU(input_size=4, hidden_size=128, batch_first=True)
x = torch.randn(4, 20, 4)
output, h_n = gru(x)
print(f"GRU output: {output.shape}") # (4, 20, 128)
LSTM vs GRU 선택:
LSTM:
- 더 복잡한 장기 의존성에 유리
- 파라미터 더 많음 (학습 느림)
- 긴 시퀀스에서 일반적으로 더 좋음
GRU:
- 학습이 빠름 (파라미터 25% 적음)
- 짧은~중간 시퀀스에서 LSTM과 비슷한 성능
- 데이터가 적을 때 유리
현재 NLP: Transformer 기반 (BERT, GPT)
현재 시계열: LSTM/GRU 또는 Transformer 혼용
시계열 예측 예시
class TimeSeriesLSTM(nn.Module):
def __init__(self, input_size, hidden_size, num_layers, output_size):
super().__init__()
self.lstm = nn.LSTM(input_size, hidden_size, num_layers,
batch_first=True, dropout=0.2)
self.fc = nn.Linear(hidden_size, output_size)
self.hidden_size = hidden_size
self.num_layers = num_layers
def forward(self, x):
h0 = torch.zeros(self.num_layers, x.size(0), self.hidden_size)
c0 = torch.zeros(self.num_layers, x.size(0), self.hidden_size)
out, _ = self.lstm(x, (h0, c0))
# 마지막 시점의 출력으로 예측
return self.fc(out[:, -1, :])
# 주가 예측: 과거 30일 데이터로 다음날 가격 예측
model = TimeSeriesLSTM(
input_size=5, # 시가, 고가, 저가, 종가, 거래량
hidden_size=64,
num_layers=2,
output_size=1 # 다음날 종가
)
x = torch.randn(32, 30, 5) # 배치32, 과거30일, 특성5
print(f"예측값: {model(x).shape}") # (32, 1)
정리
| 개념 | 설명 | 핵심 포인트 |
|---|---|---|
| RNN | 이전 상태를 다음 입력에 활용 | 시퀀스 데이터 처리 |
| 장기 의존성 | 먼 시점 간 관계 학습 어려움 | RNN의 근본적 한계 |
| LSTM | 셀 상태 + 3개 게이트 | 장기 의존성 해결 |
| Forget Gate | 이전 정보를 잊는 비율 | 새 주제 전환 시 활용 |
| Input Gate | 새 정보 저장 비율 | 중요한 새 정보 저장 |
| Output Gate | 출력할 정보 선택 | 현재 시점 출력 결정 |
| GRU | LSTM 간소화 버전 | 빠르고 파라미터 적음 |
| Bidirectional | 양방향 처리 | 앞뒤 문맥 모두 활용 |
'딥러닝 > 딥러닝 아키텍쳐' 카테고리의 다른 글
| 사전학습 모델 활용 (Transfer Learning) (0) | 2026.05.16 |
|---|---|
| Transformer / Attention (0) | 2026.05.16 |
| CNN (합성곱 신경망) (0) | 2026.05.15 |