이미지 로딩 중...
AI Generated
2025. 11. 24. · 3 Views
RNN 기초 및 순환 구조 완벽 가이드
시퀀스 데이터를 다루는 RNN의 핵심 개념부터 순환 구조의 작동 원리까지, 초급 개발자를 위한 친절한 가이드입니다. 실제 코드 예제와 함께 RNN의 기본기를 탄탄히 다져보세요.
목차
- RNN이란 무엇인가 - 시퀀스 데이터를 기억하는 신경망
- 순환 구조의 핵심 - 시간을 펼쳐보기
- 역전파의 시간 여행 - BPTT 이해하기
- 기울기 소실과 폭발 - RNN의 아킬레스건
- Many-to-Many, Many-to-One - RNN 아키텍처 패턴
- 양방향 RNN - 미래도 보는 신경망
- RNN 실전 구현 - PyTorch로 감정 분석
- 임베딩 이해하기 - 단어를 숫자로
1. RNN이란 무엇인가 - 시퀀스 데이터를 기억하는 신경망
시작하며
여러분이 채팅봇을 만들거나 번역 프로그램을 개발할 때 이런 생각을 해본 적 있나요? "컴퓨터가 어떻게 문장의 앞부분을 기억하면서 뒷부분을 이해할 수 있을까?" 일반적인 신경망은 마치 금붕어처럼 바로 직전에 본 것조차 기억하지 못합니다.
각 입력을 독립적으로 처리하기 때문에, "나는 학교에 간다"라는 문장에서 "나는"과 "간다"의 관계를 이해할 수 없죠. 바로 이럴 때 필요한 것이 RNN(Recurrent Neural Network, 순환 신경망)입니다.
RNN은 마치 우리 뇌처럼 이전 정보를 기억하면서 새로운 정보를 처리할 수 있는 특별한 신경망입니다.
개요
간단히 말해서, RNN은 "기억력을 가진 신경망"입니다. 일반 신경망에 시간의 개념을 추가하여, 이전 시점의 정보를 현재 시점에서 활용할 수 있도록 만든 구조입니다.
왜 RNN이 필요할까요? 우리가 다루는 많은 데이터는 순서가 중요합니다.
주식 가격의 변화, 날씨 예측, 음성 인식, 자연어 처리 같은 경우에 "순서"를 무시하면 의미가 완전히 달라집니다. 예를 들어, "개가 사람을 물었다"와 "사람이 개를 물었다"는 같은 단어지만 순서에 따라 의미가 정반대입니다.
기존 신경망은 각 입력을 독립적으로 처리했다면, RNN은 "은닉 상태(hidden state)"라는 메모리를 통해 이전 정보를 계속 전달합니다. 마치 릴레이 경주에서 바통을 넘기듯이, 각 시점마다 정보를 다음 시점으로 전달하는 것이죠.
RNN의 핵심 특징은 세 가지입니다. 첫째, 동일한 가중치를 모든 시점에서 공유합니다(파라미터 효율성).
둘째, 가변 길이의 시퀀스를 처리할 수 있습니다(유연성). 셋째, 이론적으로 무한한 과거 정보를 활용할 수 있습니다(장기 의존성).
이러한 특징들이 RNN을 시퀀스 데이터 처리의 강력한 도구로 만들어줍니다.
코드 예제
import numpy as np
# 간단한 RNN 셀 구현
class SimpleRNN:
def __init__(self, input_size, hidden_size):
# 입력 -> 은닉 상태 가중치
self.Wxh = np.random.randn(hidden_size, input_size) * 0.01
# 은닉 상태 -> 은닉 상태 가중치 (순환 연결)
self.Whh = np.random.randn(hidden_size, hidden_size) * 0.01
# 편향
self.bh = np.zeros((hidden_size, 1))
def forward(self, x, h_prev):
# 현재 입력과 이전 은닉 상태를 결합
# h_t = tanh(Wxh * x_t + Whh * h_{t-1} + bh)
h_next = np.tanh(np.dot(self.Wxh, x) + np.dot(self.Whh, h_prev) + self.bh)
return h_next
설명
이것이 하는 일: RNN 셀은 현재 시점의 입력과 이전 시점의 은닉 상태를 받아서, 새로운 은닉 상태를 만들어냅니다. 이 은닉 상태가 바로 "기억"의 역할을 하는 것이죠.
첫 번째로, 초기화 단계에서 세 가지 핵심 파라미터를 만듭니다. Wxh는 현재 입력을 처리하는 가중치, Whh는 이전 기억을 처리하는 가중치, bh는 편향입니다.
이 가중치들은 모든 시간 단계에서 공유됩니다. 즉, 1초일 때나 100초일 때나 같은 가중치를 사용하는 것이죠.
이것이 RNN의 핵심 아이디어입니다. 그 다음으로, forward 함수가 실행되면서 마법이 일어납니다.
np.dot(self.Wxh, x)는 현재 입력을 처리하고, np.dot(self.Whh, h_prev)는 이전 기억을 불러옵니다. 이 둘을 더한 후 tanh 활성화 함수를 통과시키면, 새로운 은닉 상태가 만들어집니다.
tanh는 -1에서 1 사이의 값을 출력하여 gradient를 안정적으로 유지합니다. 마지막으로, 생성된 h_next가 다음 시점으로 전달됩니다.
이 과정이 시퀀스의 끝까지 반복되면서, 처음부터 현재까지의 모든 정보가 누적됩니다. 예를 들어, "나는 학교에 간다"를 처리할 때, "나는"의 정보가 "학교에"를 처리할 때도 남아있고, "간다"를 처리할 때도 영향을 줍니다.
여러분이 이 코드를 사용하면 텍스트 생성, 감정 분석, 시계열 예측 등 다양한 순차 데이터 문제를 해결할 수 있습니다. 실무에서는 PyTorch나 TensorFlow의 내장 RNN을 사용하지만, 이 기본 구조를 이해하면 복잡한 모델도 쉽게 다룰 수 있습니다.
또한, RNN의 한계(기울기 소실)를 이해하고 LSTM이나 GRU 같은 발전된 모델로 나아갈 수 있는 기반이 됩니다.
실전 팁
💡 은닉 상태의 크기는 너무 작으면 정보 손실, 너무 크면 과적합이 발생합니다. 일반적으로 128~512 사이에서 시작하세요.
💡 가중치 초기화가 매우 중요합니다. 너무 크면 gradient exploding, 너무 작으면 gradient vanishing이 발생합니다. Xavier 또는 He 초기화를 사용하세요.
💡 첫 번째 은닉 상태(h_0)는 보통 0으로 초기화하지만, 학습 가능한 파라미터로 만들 수도 있습니다.
💡 디버깅할 때는 은닉 상태의 값을 출력해보세요. NaN이나 inf가 나타나면 gradient 문제입니다.
💡 실전에서는 배치 처리를 위해 입력 shape를 (batch_size, sequence_length, input_size)로 설계하세요.
2. 순환 구조의 핵심 - 시간을 펼쳐보기
시작하며
여러분이 RNN을 처음 공부할 때 가장 혼란스러운 부분이 무엇인가요? 바로 "같은 셀이 어떻게 여러 번 사용되는지" 이해하기 어렵다는 점입니다.
RNN 다이어그램을 보면 자기 자신으로 돌아가는 화살표가 있어서 마치 무한 루프처럼 보입니다. 이것이 정말 계속 돌고 도는 건가요?
아니면 뭔가 다른 의미일까요? 바로 이럴 때 필요한 것이 "시간 펼침(unfolding in time)" 개념입니다.
순환 구조를 시간 축으로 펼쳐보면, RNN의 작동 원리가 눈에 보이듯 명확해집니다.
개요
간단히 말해서, 순환 구조는 "같은 함수를 시간 순서대로 반복 적용하는 것"입니다. 마치 같은 도장을 여러 번 찍는 것처럼, 하나의 RNN 셀이 시퀀스의 각 시점마다 반복적으로 사용됩니다.
왜 이런 구조가 필요할까요? 메모리 효율성과 일반화 능력 때문입니다.
만약 10단어 문장을 처리하기 위해 10개의 서로 다른 신경망을 만든다면, 파라미터가 엄청나게 많아지고 학습도 어려워집니다. 하지만 하나의 셀을 재사용하면, 적은 파라미터로도 임의 길이의 시퀀스를 처리할 수 있습니다.
기존에는 순환 구조를 하나의 압축된 다이어그램으로 표현했다면, 이제는 시간 축으로 펼쳐서 각 시점마다 무슨 일이 일어나는지 명확하게 볼 수 있습니다. 이것을 "unfolded RNN" 또는 "unrolled RNN"이라고 부릅니다.
순환 구조의 핵심 특징은 세 가지입니다. 첫째, 가중치 공유(weight sharing)로 파라미터 효율성을 달성합니다.
둘째, 각 시점은 독립적인 계산 그래프로 표현할 수 있어 역전파가 가능합니다. 셋째, 시퀀스 길이에 관계없이 동일한 구조를 적용할 수 있습니다.
이러한 특징들이 RNN을 가변 길이 데이터 처리의 표준으로 만들어줍니다.
코드 예제
import numpy as np
# 시간 펼침을 시각화하는 RNN
def rnn_forward_sequence(inputs, Wxh, Whh, bh):
"""
inputs: 시퀀스 입력 [(x1), (x2), ..., (xT)]
반환: 모든 시점의 은닉 상태들
"""
hidden_states = []
h = np.zeros((Whh.shape[0], 1)) # 초기 은닉 상태
# 시간 축으로 펼치기 - 각 시점마다 같은 연산 반복
for t, x in enumerate(inputs):
# t 시점: h_t = tanh(Wxh*x_t + Whh*h_{t-1} + bh)
h = np.tanh(np.dot(Wxh, x) + np.dot(Whh, h) + bh)
hidden_states.append(h)
print(f"시점 {t}: 입력 shape {x.shape} -> 은닉 상태 shape {h.shape}")
return hidden_states
# 사용 예시
inputs = [np.random.randn(10, 1) for _ in range(5)] # 5개 시점, 각 입력 차원 10
Wxh = np.random.randn(20, 10) * 0.01 # 은닉 상태 차원 20
Whh = np.random.randn(20, 20) * 0.01
bh = np.zeros((20, 1))
states = rnn_forward_sequence(inputs, Wxh, Whh, bh)
설명
이것이 하는 일: 이 코드는 RNN의 순환 구조를 시간 축으로 펼쳐서 실행합니다. 하나의 셀(가중치 Wxh, Whh, bh)이 시퀀스의 각 요소를 순서대로 처리하면서 은닉 상태를 업데이트해나갑니다.
첫 번째로, 초기화 단계에서 hidden_states 리스트와 첫 은닉 상태 h를 준비합니다. h는 영벡터로 시작하는데, 이는 아직 아무것도 보지 못한 상태를 의미합니다.
이 초기 상태가 매우 중요한 이유는, 첫 번째 입력을 처리할 때 "과거"로 사용되기 때문입니다. 실무에서는 이를 학습 가능한 파라미터로 만들기도 합니다.
그 다음으로, for 루프가 시간 축을 따라 진행됩니다. 여기서 핵심은 "같은 가중치(Wxh, Whh, bh)를 매번 재사용한다"는 점입니다.
t=0일 때의 계산과 t=4일 때의 계산이 동일한 함수를 사용합니다. enumerate를 사용하여 현재 시점 t를 추적하면서, 각 입력 x를 처리합니다.
이전 시점의 h가 현재 시점의 입력으로 자연스럽게 전달되는 구조를 주목하세요. 세 번째 단계에서, 각 시점마다 계산된 은닉 상태를 리스트에 저장합니다.
이렇게 모든 시점의 은닉 상태를 저장하는 이유는 두 가지입니다. 첫째, 역전파(backpropagation through time)를 위해 필요합니다.
둘째, sequence-to-sequence 모델처럼 모든 시점의 출력이 필요한 경우가 있습니다. print 문은 디버깅용으로, 각 시점에서 데이터의 형태가 올바른지 확인할 수 있습니다.
여러분이 이 코드를 사용하면 RNN의 순환 구조가 실제로는 "반복문"임을 직관적으로 이해할 수 있습니다. 실무에서는 이 패턴이 모든 시퀀스 모델의 기초가 됩니다.
또한, 이 구조를 이해하면 BPTT(Backpropagation Through Time), gradient clipping, truncated backpropagation 같은 고급 기법도 쉽게 이해할 수 있습니다. 특히 긴 시퀀스를 처리할 때 발생하는 메모리 문제나 기울기 문제를 해결하는 기반이 됩니다.
실전 팁
💡 시퀀스가 길어질수록 메모리 사용량이 증가합니다. 실전에서는 truncated BPTT로 일정 길이마다 끊어서 학습하세요.
💡 은닉 상태를 모두 저장하지 않고 마지막 것만 사용하는 경우(many-to-one)는 메모리를 크게 절약할 수 있습니다.
💡 디버깅 시 중간 은닉 상태들을 시각화하면 정보가 어떻게 흐르는지 확인할 수 있습니다.
💡 배치 처리 시 각 샘플의 시퀀스 길이가 다를 수 있으니 padding과 masking을 적용하세요.
💡 순환 구조 때문에 병렬화가 어렵습니다. GPU를 효율적으로 사용하려면 배치 크기를 키우세요.
3. 역전파의 시간 여행 - BPTT 이해하기
시작하며
여러분이 RNN을 학습시킬 때 이런 의문이 들지 않나요? "순환 구조에서는 어떻게 역전파가 동작하는 거지?" 일반 신경망에서는 출력에서 입력으로 한 번만 거슬러 올라가면 됩니다.
하지만 RNN은 시간 축을 따라 여러 번 같은 가중치를 사용하는데, 이 가중치를 어떻게 업데이트해야 할까요? 바로 이럴 때 필요한 것이 BPTT(Backpropagation Through Time, 시간을 통한 역전파)입니다.
시간 축으로 펼쳐진 RNN을 마치 아주 깊은 신경망처럼 취급하여 역전파를 적용하는 기법입니다.
개요
간단히 말해서, BPTT는 "시간을 거슬러 올라가면서 그래디언트를 계산하는 방법"입니다. 현재 시점의 손실이 과거의 모든 시점에 영향을 미치므로, 역전파도 시간을 거슬러 올라가야 합니다.
왜 BPTT가 필요할까요? RNN의 가중치는 모든 시점에서 공유되기 때문에, 각 시점에서 발생한 그래디언트를 모두 합쳐야 정확한 업데이트를 할 수 있습니다.
예를 들어, "나는 학교에 간다"에서 "간다"를 예측하는 손실은 "나는", "학교에"를 처리했던 모든 과거 시점에 영향을 줘야 합니다. 기존 역전파는 한 방향으로만 진행되었다면, BPTT는 시간 축을 따라 과거로 흐르면서 그래디언트를 누적합니다.
마치 도미노가 뒤에서부터 앞으로 쓰러지듯이, 각 시점의 그래디언트가 이전 시점으로 전파됩니다. BPTT의 핵심 특징은 세 가지입니다.
첫째, 체인 룰(chain rule)을 시간 축으로 확장하여 적용합니다. 둘째, 같은 가중치에 대한 그래디언트를 시간 축을 따라 누적합니다.
셋째, 시퀀스가 길어질수록 기울기 소실/폭발 문제가 발생할 수 있습니다. 이러한 특성들을 이해하면 RNN 학습의 어려움과 해결책을 파악할 수 있습니다.
코드 예제
import numpy as np
def rnn_backward_step(dh_next, cache):
"""
한 시점에 대한 역전파
dh_next: 다음 시점에서 온 그래디언트
cache: forward pass에서 저장한 값들
"""
x, h_prev, h, Wxh, Whh = cache
# tanh의 역전파: dtanh = (1 - tanh^2) * dh
dtanh = (1 - h ** 2) * dh_next
# 편향의 그래디언트
dbh = dtanh
# 입력 가중치의 그래디언트
dWxh = np.dot(dtanh, x.T)
# 순환 가중치의 그래디언트
dWhh = np.dot(dtanh, h_prev.T)
# 이전 은닉 상태로 전파할 그래디언트
dh_prev = np.dot(Whh.T, dtanh)
# 입력으로 전파할 그래디언트
dx = np.dot(Wxh.T, dtanh)
return dx, dh_prev, dWxh, dWhh, dbh
# 전체 시퀀스에 대한 BPTT
def rnn_backward_sequence(dh_last, caches):
"""
dh_last: 마지막 시점의 손실 그래디언트
caches: 모든 시점의 forward cache
"""
# 그래디언트 누적 변수 초기화
dWxh_total = np.zeros_like(caches[0][3])
dWhh_total = np.zeros_like(caches[0][4])
dbh_total = np.zeros_like(caches[0][1])
dh_next = dh_last
# 시간을 거슬러 올라가면서 역전파
for t in reversed(range(len(caches))):
dx, dh_prev, dWxh, dWhh, dbh = rnn_backward_step(dh_next, caches[t])
# 그래디언트 누적 (핵심!)
dWxh_total += dWxh
dWhh_total += dWhh
dbh_total += dbh
dh_next = dh_prev # 다음 (과거) 시점으로 전달
return dWxh_total, dWhh_total, dbh_total
설명
이것이 하는 일: BPTT는 RNN의 손실을 최소화하기 위해 가중치를 어떻게 조정해야 할지 계산합니다. 마지막 시점에서 시작하여 첫 시점까지 거슬러 올라가면서, 각 시점에서 가중치의 영향을 계산하고 누적합니다.
첫 번째로, rnn_backward_step 함수는 한 시점에 대한 역전파를 수행합니다. cache에서 forward pass 때 저장한 값들(입력 x, 이전 은닉 상태 h_prev, 현재 은닉 상태 h, 가중치들)을 가져옵니다.
왜 이것들을 저장해야 할까요? 역전파는 forward pass의 계산 그래프를 거꾸로 따라가야 하기 때문입니다.
dtanh 계산에서 (1 - h**2)를 사용하는 것은 tanh 함수의 미분 공식입니다. 그 다음으로, 각 파라미터에 대한 그래디언트를 계산합니다.
dWxh는 입력-은닉 가중치, dWhh는 은닉-은닉 가중치, dbh는 편향의 그래디언트입니다. 이들은 모두 체인 룰을 적용하여 구합니다.
중요한 점은 dh_prev를 계산하는 부분입니다. 이것이 바로 "시간을 거슬러 올라가는" 핵심입니다.
현재 시점의 그래디언트가 이전 시점으로 전파되는 것이죠. 세 번째 단계에서, rnn_backward_sequence 함수는 전체 시퀀스에 대해 역전파를 수행합니다.
reversed(range(len(caches)))를 사용하여 시간을 거슬러 올라갑니다. 가장 중요한 부분은 "+=" 연산자입니다.
각 시점에서 계산된 그래디언트를 누적하는 것이죠. 왜냐하면 Wxh는 모든 시점에서 사용되었으므로, 모든 시점의 손실에 영향을 주었기 때문입니다.
네 번째로, dh_next = dh_prev를 통해 그래디언트를 이전 시점으로 전달합니다. 이 과정이 반복되면서 첫 시점까지 그래디언트가 전파됩니다.
하지만 여기서 문제가 발생합니다. 시퀀스가 길면 그래디언트가 여러 번 곱해지면서 너무 작아지거나(기울기 소실) 너무 커질 수 있습니다(기울기 폭발).
여러분이 이 코드를 사용하면 RNN이 어떻게 학습되는지 깊이 이해할 수 있습니다. 실무에서는 PyTorch의 autograd가 자동으로 처리하지만, 이 원리를 알면 학습이 잘 안 될 때 문제를 진단할 수 있습니다.
예를 들어, gradient clipping을 왜 사용하는지, LSTM이 왜 필요한지, truncated BPTT가 왜 메모리를 절약하는지 이해할 수 있습니다. 또한, 커스텀 RNN 셀을 만들거나 새로운 아키텍처를 실험할 때 필수적인 지식입니다.
실전 팁
💡 그래디언트를 누적할 때 초기화를 잊지 마세요. 이전 배치의 그래디언트가 남아있으면 학습이 망가집니다.
💡 긴 시퀀스에서는 gradient clipping을 필수로 사용하세요. norm이 일정 값을 넘으면 잘라냅니다.
💡 디버깅 시 각 시점의 그래디언트 norm을 출력하면 기울기 소실/폭발을 조기에 발견할 수 있습니다.
💡 Truncated BPTT는 일정 시점 이전으로는 역전파하지 않아 메모리와 계산량을 줄입니다. 보통 20~50 시점으로 자릅니다.
💡 가중치 초기화가 BPTT의 성공에 결정적입니다. Orthogonal initialization을 시도해보세요.
4. 기울기 소실과 폭발 - RNN의 아킬레스건
시작하며
여러분이 RNN으로 긴 문장을 학습시킬 때 이런 좌절을 겪어본 적 있나요? "분명 데이터도 많고 모델도 제대로 만들었는데, 왜 학습이 안 되는 거지?" 특히 50단어 이상의 긴 문장에서 첫 단어와 마지막 단어의 관계를 학습해야 할 때, RNN은 마치 기억상실증에 걸린 것처럼 행동합니다.
아니면 반대로 손실이 갑자기 NaN이 되면서 폭발해버리기도 합니다. 바로 이럴 때 만나는 것이 "기울기 소실(Vanishing Gradient)"과 "기울기 폭발(Exploding Gradient)" 문제입니다.
RNN의 가장 큰 약점이자, LSTM과 GRU가 탄생한 이유입니다.
개요
간단히 말해서, 기울기 소실/폭발은 "시간을 거슬러 올라갈수록 그래디언트가 사라지거나 폭발하는 현상"입니다. BPTT 과정에서 같은 값이 여러 번 곱해지면서 발생합니다.
왜 이 문제가 발생할까요? 수학적으로 보면, BPTT는 Whh(순환 가중치)를 시퀀스 길이만큼 반복해서 곱합니다.
Whh의 최대 고유값이 1보다 작으면 곱할수록 0에 수렴하고(기울기 소실), 1보다 크면 무한대로 발산합니다(기울기 폭발). 예를 들어, 0.9를 100번 곱하면 0.00003이 되고, 1.1을 100번 곱하면 13,780이 됩니다.
이것이 긴 시퀀스에서 RNN이 실패하는 핵심 이유입니다. 전통적인 신경망은 깊이가 고정되어 있어 이 문제가 덜했다면, RNN은 시퀀스 길이만큼 "깊어지기" 때문에 문제가 훨씬 심각합니다.
100단어 문장을 처리하면 100층짜리 신경망과 같은 깊이가 되는 것이죠. 기울기 문제의 핵심 특징은 세 가지입니다.
첫째, 소실되면 장기 의존성을 학습할 수 없습니다(과거 정보가 전달 안 됨). 둘째, 폭발하면 학습이 불안정해집니다(NaN, inf 발생).
셋째, tanh/sigmoid 같은 활성화 함수와 순환 가중치의 곱셈 특성이 원인입니다. 이 문제들을 이해하고 해결하는 것이 실전 RNN 사용의 핵심입니다.
코드 예제
import numpy as np
import matplotlib.pyplot as plt
def analyze_gradient_flow(sequence_length, W_value):
"""
시퀀스 길이에 따른 그래디언트 변화 분석
"""
gradients = []
# W를 단순화하여 스칼라로 가정 (실제로는 행렬)
W = W_value
# 시간을 거슬러 올라가면서 그래디언트 계산
gradient = 1.0 # 초기 그래디언트
for t in range(sequence_length):
# tanh 미분의 최대값 1로 가정
gradient *= W # 실제로는 W^T도 곱해짐
gradients.append(abs(gradient))
if t % 10 == 0:
print(f"시점 {t}: 그래디언트 크기 = {gradient:.6f}")
return gradients
# 기울기 소실 케이스 (W < 1)
print("=== 기울기 소실 (W=0.9) ===")
vanishing = analyze_gradient_flow(50, 0.9)
# 기울기 폭발 케이스 (W > 1)
print("\n=== 기울기 폭발 (W=1.1) ===")
exploding = analyze_gradient_flow(50, 1.1)
# 안정적인 케이스 (W ≈ 1)
print("\n=== 안정적 (W=1.0) ===")
stable = analyze_gradient_flow(50, 1.0)
# Gradient Clipping 시뮬레이션
def gradient_clipping(gradients, max_norm=5.0):
"""그래디언트 클리핑 적용"""
clipped = []
for g in gradients:
if g > max_norm:
clipped.append(max_norm)
else:
clipped.append(g)
return clipped
clipped_exploding = gradient_clipping([g for g in exploding], max_norm=5.0)
설명
이것이 하는 일: 이 코드는 RNN의 기울기 소실/폭발 문제를 시뮬레이션하여 시각적으로 보여줍니다. 순환 가중치 W의 값에 따라 그래디언트가 어떻게 변하는지 추적합니다.
첫 번째로, analyze_gradient_flow 함수는 시간을 거슬러 올라가면서 그래디언트의 변화를 계산합니다. 실제 RNN에서는 복잡한 행렬 연산이지만, 핵심 원리를 이해하기 위해 스칼라로 단순화했습니다.
gradient *= W를 반복하는 것이 BPTT의 본질입니다. 매 시점마다 W를 곱하면서 그래디언트가 누적됩니다.
그 다음으로, 세 가지 시나리오를 실험합니다. W=0.9일 때는 50 시점 후 그래디언트가 거의 0이 됩니다.
이것이 기울기 소실입니다. 첫 단어의 정보가 마지막까지 전달되지 못하는 것이죠.
W=1.1일 때는 반대로 폭발합니다. 50 시점 후 그래디언트가 천문학적 숫자가 되어 NaN으로 변합니다.
W=1.0일 때만 안정적으로 유지됩니다. 세 번째로, gradient_clipping 함수는 폭발 문제의 실전 해결책을 보여줍니다.
그래디언트의 크기(norm)가 임계값(보통 5.0)을 넘으면 잘라냅니다. 이렇게 하면 학습이 폭발하지 않으면서도 방향은 유지됩니다.
실무에서는 torch.nn.utils.clip_grad_norm_을 사용합니다. 네 번째로, 이 시뮬레이션에서 알 수 있는 중요한 인사이트가 있습니다.
소실 문제는 clipping으로 해결할 수 없습니다. 0에 가까운 값을 자르면 그냥 0이 되기 때문입니다.
소실 문제의 근본적 해결책은 LSTM이나 GRU처럼 구조 자체를 바꾸는 것입니다. 이들은 "gradient highway"를 만들어 그래디언트가 시간을 거슬러 잘 흐르도록 설계되었습니다.
여러분이 이 코드를 실행하면 RNN의 가장 큰 약점을 눈으로 확인할 수 있습니다. 실무에서 RNN 학습이 안 될 때, 이 지식을 활용하여 문제를 진단하세요.
손실이 줄어들지 않으면 기울기 소실, NaN이 나오면 기울기 폭발입니다. 또한, 이 문제를 이해하면 왜 실무에서 순수 RNN보다 LSTM/GRU를 사용하는지, 왜 attention mechanism이 필요한지, 왜 Transformer가 RNN을 대체했는지 이해할 수 있습니다.
특히 긴 문서 처리나 긴 대화 생성 같은 task에서 모델 선택의 근거가 됩니다.
실전 팁
💡 Gradient clipping은 필수입니다. PyTorch에서는 clip_grad_norm_(model.parameters(), max_norm=5.0)을 사용하세요.
💡 학습 중 그래디언트 norm을 로깅하세요. TensorBoard에 기록하면 폭발/소실을 조기에 발견할 수 있습니다.
💡 순환 가중치 Whh를 orthogonal matrix로 초기화하면 고유값이 1 근처로 유지되어 안정적입니다.
💡 tanh 대신 ReLU를 사용하면 소실이 줄지만, 폭발 위험이 커집니다. 신중히 선택하세요.
💡 긴 시퀀스(100+ 토큰)를 다룬다면 RNN 대신 Transformer나 최소한 LSTM을 사용하는 것을 강력히 권장합니다.
5. Many-to-Many, Many-to-One - RNN 아키텍처 패턴
시작하며
여러분이 RNN을 실제 문제에 적용하려고 할 때 이런 고민을 하게 됩니다. "내 문제는 입력이 여러 개고 출력도 여러 개인데, RNN을 어떻게 설계해야 하지?" 감정 분석은 문장 전체를 읽고 하나의 감정을 출력하고, 번역은 한 문장을 읽고 다른 문장을 생성하며, 동영상 분류는 프레임마다 레이블을 붙입니다.
모두 다른 입출력 구조를 가지고 있죠. 바로 이럴 때 필요한 것이 RNN 아키텍처 패턴입니다.
문제의 성격에 맞게 입출력 구조를 설계하는 템플릿들입니다.
개요
간단히 말해서, RNN 아키텍처 패턴은 "입력과 출력의 개수에 따라 RNN을 구성하는 방법"입니다. 크게 네 가지로 분류됩니다: one-to-one, one-to-many, many-to-one, many-to-many입니다.
왜 이런 패턴이 필요할까요? 실제 문제는 다양한 형태의 입출력을 가지기 때문입니다.
감정 분석(many-to-one)과 기계 번역(many-to-many)은 완전히 다른 구조가 필요합니다. 같은 RNN 셀을 사용하더라도 어떻게 연결하느냐에 따라 해결할 수 있는 문제가 달라집니다.
전통적인 신경망은 고정된 크기의 입력과 출력만 다뤘다면, RNN 패턴은 가변 길이 입출력을 유연하게 처리할 수 있습니다. 10단어 문장도, 100단어 문장도 같은 모델로 처리할 수 있는 것이죠.
RNN 패턴의 핵심 특징은 세 가지입니다. 첫째, many-to-one은 시퀀스를 하나의 벡터로 압축합니다(인코딩).
둘째, one-to-many는 하나의 벡터에서 시퀀스를 생성합니다(디코딩). 셋째, many-to-many는 시퀀스를 다른 시퀀스로 변환합니다(시퀀스 변환).
이 패턴들을 조합하면 seq2seq 같은 강력한 모델을 만들 수 있습니다.
코드 예제
import numpy as np
class RNNPatterns:
def __init__(self, input_size, hidden_size, output_size):
# 공통 파라미터
self.Wxh = np.random.randn(hidden_size, input_size) * 0.01
self.Whh = np.random.randn(hidden_size, hidden_size) * 0.01
self.Why = np.random.randn(output_size, hidden_size) * 0.01
self.bh = np.zeros((hidden_size, 1))
self.by = np.zeros((output_size, 1))
def many_to_one(self, inputs):
"""
감정 분석, 문장 분류 등
여러 입력 -> 하나의 출력
"""
h = np.zeros((self.Whh.shape[0], 1))
# 모든 입력을 읽으면서 은닉 상태 업데이트
for x in inputs:
h = np.tanh(np.dot(self.Wxh, x) + np.dot(self.Whh, h) + self.bh)
# 마지막 은닉 상태만 사용하여 출력
y = np.dot(self.Why, h) + self.by
return y
def many_to_many_synced(self, inputs):
"""
품사 태깅, 개체명 인식 등
여러 입력 -> 같은 길이의 여러 출력 (동기화)
"""
h = np.zeros((self.Whh.shape[0], 1))
outputs = []
# 각 시점마다 입력 처리하고 출력 생성
for x in inputs:
h = np.tanh(np.dot(self.Wxh, x) + np.dot(self.Whh, h) + self.bh)
y = np.dot(self.Why, h) + self.by
outputs.append(y)
return outputs
def many_to_many_seq2seq(self, inputs, output_length):
"""
기계 번역, 요약 등
여러 입력 -> 다른 길이의 여러 출력 (인코더-디코더)
"""
# 인코더: 입력 시퀀스를 읽어 컨텍스트 벡터 생성
h = np.zeros((self.Whh.shape[0], 1))
for x in inputs:
h = np.tanh(np.dot(self.Wxh, x) + np.dot(self.Whh, h) + self.bh)
context = h # 인코더의 마지막 은닉 상태
# 디코더: 컨텍스트에서 출력 시퀀스 생성
outputs = []
h = context
for _ in range(output_length):
# 이전 출력을 다음 입력으로 사용 (auto-regressive)
y = np.dot(self.Why, h) + self.by
outputs.append(y)
# 출력을 다음 입력으로 (teacher forcing 없이)
h = np.tanh(np.dot(self.Wxh, y) + np.dot(self.Whh, h) + self.bh)
return outputs
# 사용 예시
model = RNNPatterns(input_size=10, hidden_size=20, output_size=5)
# 감정 분석: 문장 -> 감정 레이블
inputs = [np.random.randn(10, 1) for _ in range(7)]
sentiment = model.many_to_one(inputs)
print(f"Many-to-One 출력 shape: {sentiment.shape}") # (5, 1)
# 품사 태깅: 단어들 -> 품사들
pos_tags = model.many_to_many_synced(inputs)
print(f"Many-to-Many (synced) 출력 개수: {len(pos_tags)}") # 7개
# 번역: 영어 문장 -> 한국어 문장
translation = model.many_to_many_seq2seq(inputs, output_length=5)
print(f"Many-to-Many (seq2seq) 출력 개수: {len(translation)}") # 5개
설명
이것이 하는 일: 이 코드는 RNN의 세 가지 주요 아키텍처 패턴을 구현합니다. 같은 RNN 셀을 사용하지만 입출력을 어떻게 연결하느냐에 따라 완전히 다른 기능을 수행합니다.
첫 번째로, many_to_one 패턴은 시퀀스 분류 문제에 사용됩니다. 모든 입력을 순차적으로 읽으면서 은닉 상태를 업데이트하지만, 출력은 마지막에 한 번만 생성합니다.
이 마지막 은닉 상태 h는 전체 시퀀스의 정보를 압축한 "요약 벡터"입니다. 감정 분석에서 "이 영화 정말 좋아요"라는 문장 전체를 읽고 나서 "긍정"이라는 하나의 레이블을 출력하는 것이죠.
스팸 필터링, 문서 분류, 의도 파악 등에 사용됩니다. 그 다음으로, many_to_many_synced 패턴은 각 입력에 대응하는 출력을 생성합니다.
입력 시퀀스 길이와 출력 시퀀스 길이가 같습니다. 각 시점마다 은닉 상태를 업데이트하고 즉시 출력을 만듭니다.
품사 태깅에서 "나는/대명사 학교에/명사 간다/동사"처럼 각 단어마다 레이블을 붙이는 경우입니다. 개체명 인식(NER), 동영상 프레임 분류, 음악 생성(각 시점마다 음표) 등에 활용됩니다.
세 번째로, many_to_many_seq2seq 패턴은 가장 복잡하지만 강력합니다. 인코더-디코더 구조로, 입력 길이와 출력 길이가 다를 수 있습니다.
인코더는 입력을 읽어 컨텍스트 벡터를 만들고, 디코더는 이 컨텍스트에서 출력을 생성합니다. 기계 번역에서 "I love you"(3단어)를 "나는 당신을 사랑합니다"(5단어)로 변환하는 것처럼 길이가 달라질 수 있습니다.
네 번째로, auto-regressive 생성 방식을 주목하세요. 디코더는 이전에 생성한 출력을 다음 입력으로 사용합니다.
실제로는 "teacher forcing"이라는 기법을 사용하는데, 학습 시에는 정답을 입력으로 주고, 추론 시에는 자신의 출력을 사용합니다. 이렇게 하면 학습이 안정적이면서도 추론 시 독립적으로 생성할 수 있습니다.
여러분이 이 패턴들을 이해하면 새로운 문제를 만났을 때 어떤 구조를 선택해야 할지 알 수 있습니다. 실무에서는 이 기본 패턴에 attention mechanism, bidirectional RNN, multi-layer stacking 등을 추가하여 더 강력한 모델을 만듭니다.
예를 들어, BERT는 bidirectional many-to-many, GPT는 autoregressive many-to-many입니다. 또한, 이 패턴들은 RNN뿐만 아니라 LSTM, GRU, Transformer에도 동일하게 적용됩니다.
아키텍처의 언어라고 할 수 있죠.
실전 팁
💡 Many-to-one에서 마지막 은닉 상태만 사용하면 정보 손실이 있을 수 있습니다. Attention을 추가하면 모든 시점을 활용할 수 있습니다.
💡 Seq2seq에서 teacher forcing 비율을 처음엔 높게(0.9), 점차 낮춰(0.5) 학습하면 안정적입니다.
💡 Many-to-many synced는 양방향 RNN(bidirectional)과 함께 사용하면 성능이 크게 향상됩니다.
💡 디코더의 초기 은닉 상태를 인코더의 마지막 상태로 설정하는 것 외에도, 학습 가능한 파라미터로 만들 수 있습니다.
💡 긴 시퀀스 번역에서는 인코더의 마지막 상태만으로 부족합니다. Attention mechanism이 필수입니다.
6. 양방향 RNN - 미래도 보는 신경망
시작하며
여러분이 문장의 품사를 태깅할 때 이런 상황을 만나봤나요? "bank"라는 단어가 "강둑"인지 "은행"인지 판단하려면 앞뒤 문맥을 모두 봐야 합니다.
일반 RNN은 왼쪽에서 오른쪽으로만 읽기 때문에, "bank"를 처리할 때 아직 뒤에 올 단어를 모릅니다. "I went to the bank of the river"에서 "of the river"를 보기 전에는 정확한 판단이 어렵죠.
바로 이럴 때 필요한 것이 양방향 RNN(Bidirectional RNN)입니다. 과거뿐만 아니라 미래의 정보도 활용하여 더 정확한 예측을 할 수 있게 해줍니다.
개요
간단히 말해서, 양방향 RNN은 "앞에서 뒤로 읽는 RNN과 뒤에서 앞으로 읽는 RNN을 동시에 사용하는 것"입니다. 두 방향의 은닉 상태를 결합하여 양방향 문맥을 모두 활용합니다.
왜 양방향이 필요할까요? 많은 자연어 처리 태스크에서 현재 단어의 의미는 앞뒤 문맥에 모두 의존합니다.
"She said, 'I love you'"에서 "said" 뒤에 따옴표가 나오는 것을 미리 알면 더 정확한 파싱이 가능합니다. 음성 인식에서도 현재 음소를 판단할 때 앞뒤 음소를 모두 고려하면 정확도가 크게 향상됩니다.
기존 단방향 RNN은 인과적(causal) 관계만 모델링했다면, 양방향 RNN은 전체 시퀀스를 볼 수 있습니다. 단, 이는 전체 시퀀스를 미리 알아야 한다는 제약이 있어 실시간 생성 태스크에는 사용할 수 없습니다.
양방향 RNN의 핵심 특징은 세 가지입니다. 첫째, forward RNN과 backward RNN이 독립적으로 동작합니다(각자의 파라미터).
둘째, 각 시점에서 두 은닉 상태를 연결(concatenate)하여 사용합니다. 셋째, 파라미터가 단방향의 약 2배가 되지만 성능 향상이 큽니다.
품사 태깅, NER, 번역의 인코더 등에서 거의 표준으로 사용됩니다.
코드 예제
import numpy as np
class BidirectionalRNN:
def __init__(self, input_size, hidden_size, output_size):
# Forward RNN 파라미터
self.Wxh_f = np.random.randn(hidden_size, input_size) * 0.01
self.Whh_f = np.random.randn(hidden_size, hidden_size) * 0.01
self.bh_f = np.zeros((hidden_size, 1))
# Backward RNN 파라미터 (별도!)
self.Wxh_b = np.random.randn(hidden_size, input_size) * 0.01
self.Whh_b = np.random.randn(hidden_size, hidden_size) * 0.01
self.bh_b = np.zeros((hidden_size, 1))
# 출력 층 (forward + backward 은닉 상태 결합)
self.Why = np.random.randn(output_size, hidden_size * 2) * 0.01
self.by = np.zeros((output_size, 1))
def forward(self, inputs):
"""
양방향 RNN의 forward pass
"""
T = len(inputs)
# Forward pass: 왼쪽 -> 오른쪽
h_forward = []
h_f = np.zeros((self.Whh_f.shape[0], 1))
for t in range(T):
h_f = np.tanh(
np.dot(self.Wxh_f, inputs[t]) +
np.dot(self.Whh_f, h_f) +
self.bh_f
)
h_forward.append(h_f)
# Backward pass: 오른쪽 -> 왼쪽
h_backward = []
h_b = np.zeros((self.Whh_b.shape[0], 1))
for t in reversed(range(T)):
h_b = np.tanh(
np.dot(self.Wxh_b, inputs[t]) +
np.dot(self.Whh_b, h_b) +
self.bh_b
)
h_backward.insert(0, h_b) # 앞에 삽입하여 순서 유지
# 각 시점에서 forward와 backward 은닉 상태 결합
outputs = []
for t in range(T):
h_combined = np.vstack([h_forward[t], h_backward[t]])
y = np.dot(self.Why, h_combined) + self.by
outputs.append(y)
return outputs, h_forward, h_backward
# 사용 예시
model = BidirectionalRNN(input_size=10, hidden_size=20, output_size=5)
inputs = [np.random.randn(10, 1) for _ in range(7)]
outputs, h_f, h_b = model.forward(inputs)
print(f"입력 시퀀스 길이: {len(inputs)}")
print(f"출력 시퀀스 길이: {len(outputs)}")
print(f"Forward 은닉 상태 shape: {h_f[0].shape}")
print(f"Backward 은닉 상태 shape: {h_b[0].shape}")
print(f"각 시점의 출력 shape: {outputs[0].shape}")
설명
이것이 하는 일: 양방향 RNN은 시퀀스를 두 번 처리합니다. 한 번은 앞에서 뒤로, 한 번은 뒤에서 앞으로 읽으면서 각각 독립적인 은닉 상태를 만들고, 이 둘을 결합하여 최종 출력을 생성합니다.
첫 번째로, 초기화 단계에서 두 세트의 파라미터를 만듭니다. Wxh_f, Whh_f는 forward용, Wxh_b, Whh_b는 backward용입니다.
이들은 완전히 독립적으로 학습됩니다. 왜 파라미터를 공유하지 않을까요?
Forward와 backward는 서로 다른 패턴을 학습해야 하기 때문입니다. Forward는 "원인 -> 결과"를, backward는 "결과 -> 원인"을 학습합니다.
그 다음으로, forward pass에서 왼쪽에서 오른쪽으로 시퀀스를 읽습니다. 이는 일반 RNN과 동일합니다.
t=0부터 t=T-1까지 순차적으로 처리하면서 h_forward 리스트에 은닉 상태를 저장합니다. 이때 h_forward[t]는 시점 0부터 t까지의 정보를 담고 있습니다.
세 번째로, backward pass에서 reversed(range(T))를 사용하여 오른쪽에서 왼쪽으로 읽습니다. 마지막 단어부터 시작하여 첫 단어까지 거꾸로 처리합니다.
h_backward.insert(0, h_b)를 사용하여 앞에 삽입하는 이유는, 나중에 forward와 시점을 맞추기 위함입니다. h_backward[t]는 시점 t부터 T-1까지의 정보를 담게 됩니다.
네 번째로, 각 시점에서 두 은닉 상태를 결합합니다. np.vstack으로 vertical stack, 즉 위아래로 쌓습니다.
h_forward[t]가 20차원이고 h_backward[t]도 20차원이면, h_combined는 40차원이 됩니다. 이 40차원 벡터는 "과거부터 현재까지의 정보"와 "현재부터 미래까지의 정보"를 모두 담고 있어, 매우 풍부한 표현이 됩니다.
여러분이 이 코드를 사용하면 품사 태깅, 개체명 인식(NER), 의존 파싱 같은 구조 예측 문제에서 성능을 크게 향상시킬 수 있습니다. 실무에서는 PyTorch의 nn.LSTM(bidirectional=True)을 사용하지만, 이 원리를 이해하면 모델의 동작을 정확히 알 수 있습니다.
단, 양방향 RNN은 전체 시퀀스를 미리 알아야 하므로, 실시간 텍스트 생성이나 온라인 번역에는 사용할 수 없습니다. 인코더에는 사용하지만 디코더에는 단방향을 사용하는 이유입니다.
또한, 파라미터가 2배가 되므로 학습 시간과 메모리도 약 2배 증가한다는 점을 고려해야 합니다.
실전 팁
💡 BERT, ELMo 같은 최신 모델들은 모두 양방향 구조를 사용합니다. 단방향은 이제 레거시입니다.
💡 Seq2seq 모델에서 인코더는 양방향, 디코더는 단방향을 사용하는 것이 표준 패턴입니다.
💡 h_combined를 만들 때 concatenate 대신 sum, average, gated combination을 시도해볼 수 있습니다.
💡 긴 시퀀스에서는 메모리 사용량이 단방향의 2배입니다. 배치 크기를 조절하세요.
💡 PyTorch에서는 output, (h_n, c_n) = lstm(input)의 output이 이미 양방향 결합된 것입니다. 마지막 차원이 hidden_size*2가 됩니다.
7. RNN 실전 구현 - PyTorch로 감정 분석
시작하며
여러분이 지금까지 배운 RNN 이론을 실제 문제에 적용하고 싶지 않나요? "이론은 알겠는데, 실제로 어떻게 코드를 짜야 하지?" 영화 리뷰를 읽고 긍정/부정을 판단하는 감정 분석기를 만든다고 생각해보세요.
데이터 전처리, 모델 정의, 학습 루프, 평가까지 모든 과정이 필요합니다. 바로 이럴 때 필요한 것이 실전 구현 경험입니다.
PyTorch를 사용하여 처음부터 끝까지 RNN 모델을 만들어보겠습니다.
개요
간단히 말해서, 실전 RNN 구현은 "이론을 실제 동작하는 코드로 변환하는 과정"입니다. 데이터 준비, 모델 정의, 학습, 평가의 전체 파이프라인을 다룹니다.
왜 실전 구현이 중요할까요? 이론만 아는 것과 직접 구현하는 것은 완전히 다른 차원의 이해입니다.
배치 처리, 패딩, 임베딩, 손실 함수 선택 등 실무에서 만나는 실제 문제들을 경험해야 합니다. 또한, 디버깅 능력과 하이퍼파라미터 튜닝 감각은 직접 해봐야 생깁니다.
기존 NumPy 구현은 교육용이었다면, PyTorch 구현은 실전 production 코드입니다. GPU 가속, 자동 미분, 모델 저장/로드, 효율적인 데이터 로딩 등 모든 실무 기능이 포함됩니다.
실전 구현의 핵심 요소는 네 가지입니다. 첫째, nn.Embedding으로 단어를 벡터로 변환합니다.
둘째, nn.RNN/LSTM/GRU를 사용하여 시퀀스를 처리합니다. 셋째, 적절한 손실 함수와 옵티마이저를 선택합니다.
넷째, 학습/검증 루프를 구현하여 과적합을 방지합니다. 이것들이 모여 실전 시스템이 됩니다.
코드 예제
import torch
import torch.nn as nn
import torch.optim as optim
class SentimentRNN(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_dim, output_dim):
super(SentimentRNN, self).__init__()
# 단어 임베딩: 단어 ID -> 밀집 벡터
self.embedding = nn.Embedding(vocab_size, embedding_dim)
# RNN 층 (LSTM이 더 좋지만 이해를 위해 RNN 사용)
self.rnn = nn.RNN(embedding_dim, hidden_dim, batch_first=True)
# 완전 연결 층: 은닉 상태 -> 출력 (긍정/부정)
self.fc = nn.Linear(hidden_dim, output_dim)
def forward(self, text):
# text shape: (batch_size, seq_length)
# 임베딩: (batch, seq_len) -> (batch, seq_len, embedding_dim)
embedded = self.embedding(text)
# RNN: (batch, seq_len, embed) -> output, hidden
# output: (batch, seq_len, hidden_dim)
# hidden: (1, batch, hidden_dim)
output, hidden = self.rnn(embedded)
# 마지막 은닉 상태 사용 (many-to-one)
# hidden: (1, batch, hidden) -> (batch, hidden)
hidden = hidden.squeeze(0)
# 분류: (batch, hidden) -> (batch, output_dim)
out = self.fc(hidden)
return out
# 하이퍼파라미터
VOCAB_SIZE = 10000 # 어휘 크기
EMBEDDING_DIM = 100 # 임베딩 차원
HIDDEN_DIM = 256 # 은닉 상태 차원
OUTPUT_DIM = 2 # 긍정/부정
LEARNING_RATE = 0.001
# 모델 초기화
model = SentimentRNN(VOCAB_SIZE, EMBEDDING_DIM, HIDDEN_DIM, OUTPUT_DIM)
# 손실 함수와 옵티마이저
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=LEARNING_RATE)
# 학습 루프 예시
def train_epoch(model, train_loader, criterion, optimizer):
model.train()
total_loss = 0
for batch in train_loader:
texts, labels = batch # (batch, seq_len), (batch,)
# Forward pass
predictions = model(texts) # (batch, 2)
loss = criterion(predictions, labels)
# Backward pass
optimizer.zero_grad()
loss.backward()
# Gradient clipping (중요!)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
optimizer.step()
total_loss += loss.item()
return total_loss / len(train_loader)
print(f"모델 파라미터 수: {sum(p.numel() for p in model.parameters()):,}")
설명
이것이 하는 일: 이 코드는 영화 리뷰 같은 텍스트를 읽고 긍정/부정을 판단하는 완전한 RNN 시스템을 구현합니다. PyTorch의 고수준 API를 사용하여 production-ready 코드를 만듭니다.
첫 번째로, SentimentRNN 클래스는 nn.Module을 상속합니다. 이것이 PyTorch 모델의 표준 패턴입니다.
__init__에서 세 개의 층을 정의합니다: Embedding(단어를 벡터로), RNN(시퀀스 처리), Linear(분류). vocab_size=10000은 가장 빈번한 1만 개 단어만 사용한다는 의미입니다.
나머지는 <UNK> 토큰으로 처리합니다. embedding_dim=100은 각 단어를 100차원 공간에 표현합니다.
그 다음으로, forward 메서드는 실제 계산을 정의합니다. text는 단어 ID의 시퀀스입니다.
예: [423, 12, 5671, ...]이 "This movie is great"를 나타냅니다. embedding을 거치면 각 ID가 100차원 벡터로 변환됩니다.
이 벡터들이 단어의 의미를 담고 있으며, 학습 과정에서 자동으로 최적화됩니다. 세 번째로, RNN 층을 통과합니다.
batch_first=True는 입력 형태가 (batch, seq, feature)임을 의미합니다(기본은 seq, batch, feature). output은 모든 시점의 은닉 상태, hidden은 마지막 은닉 상태입니다.
Many-to-one 패턴이므로 hidden만 사용합니다. squeeze(0)은 (1, batch, hidden)을 (batch, hidden)으로 만듭니다.
네 번째로, fc 층은 은닉 상태를 2차원 벡터로 변환합니다. [3.2, -1.5] 같은 로짓(logit)이 나오는데, 첫 번째 값이 크면 긍정, 두 번째가 크면 부정입니다.
CrossEntropyLoss는 내부적으로 softmax를 적용하므로, 우리는 로짓만 출력하면 됩니다. 다섯 번째로, train_epoch 함수는 실전 학습 루프입니다.
model.train()은 드롭아웃 등을 활성화합니다. optimizer.zero_grad()는 이전 배치의 그래디언트를 지웁니다(이걸 안 하면 그래디언트가 누적됩니다).
loss.backward()는 자동 미분으로 그래디언트를 계산합니다. clip_grad_norm_은 기울기 폭발을 방지합니다.
optimizer.step()은 실제로 가중치를 업데이트합니다. 여러분이 이 코드를 실행하면 실전 감정 분석 시스템을 만들 수 있습니다.
실무에서는 몇 가지를 추가해야 합니다: 데이터 로더 구현(torchtext 또는 커스텀), 검증 루프(과적합 체크), 모델 저장/로드(torch.save), 조기 종료(early stopping), 학습률 스케줄러(learning rate decay), 더 나은 모델(LSTM, 양방향, 다층), 사전 학습 임베딩(GloVe, Word2Vec) 등입니다. 또한, 실제 배포 시에는 모델을 ONNX로 변환하거나 TorchScript로 최적화하여 추론 속도를 높입니다.
실전 팁
💡 실전에서는 nn.RNN 대신 nn.LSTM이나 nn.GRU를 사용하세요. 긴 시퀀스에서 훨씬 좋습니다.
💡 임베딩 층을 사전 학습된 GloVe나 FastText로 초기화하면 적은 데이터로도 좋은 성능을 냅니다.
💡 nn.RNN(... dropout=0.5, num_layers=2)로 다층 RNN과 드롭아웃을 쉽게 추가할 수 있습니다.
💡 배치의 시퀀스 길이가 다르면 nn.utils.rnn.pack_padded_sequence를 사용하여 패딩을 무시하세요.
💡 학습 중 검증 정확도를 모니터링하고, 3 에폭 동안 개선이 없으면 조기 종료하세요.
8. 임베딩 이해하기 - 단어를 숫자로
시작하며
여러분이 RNN으로 텍스트를 처리하려 할 때 첫 번째 난관이 무엇인가요? "컴퓨터는 숫자만 이해하는데, 어떻게 '사과'나 '행복' 같은 단어를 입력하지?" 원-핫 인코딩을 사용하면 10,000개 단어 어휘에서 각 단어가 10,000차원 벡터가 됩니다.
대부분이 0이고 하나만 1인, 매우 비효율적인 표현이죠. 게다가 "좋다"와 "훌륭하다"가 완전히 다른 벡터로 표현되어 의미적 유사성을 전혀 담지 못합니다.
바로 이럴 때 필요한 것이 임베딩(Embedding)입니다. 단어를 저차원의 밀집 벡터로 변환하여, 의미가 비슷한 단어들이 가까운 공간에 위치하게 만드는 기법입니다.
개요
간단히 말해서, 임베딩은 "단어를 실수 벡터로 매핑하는 것"입니다. 각 단어가 100~300차원의 연속적인 벡터로 표현되어, 단어 간의 의미적 관계를 담을 수 있습니다.
왜 임베딩이 필요할까요? 세 가지 이유가 있습니다.
첫째, 차원 축소로 메모리와 계산 효율성을 높입니다(10,000 -> 100). 둘째, 의미적 유사성을 표현할 수 있습니다("왕" - "남자" + "여자" ≈ "여왕").
셋째, 학습 가능한 파라미터로 만들어 태스크에 맞게 최적화됩니다. 실무에서는 모든 NLP 모델의 첫 번째 층이 거의 항상 임베딩입니다.
기존 원-핫 인코딩은 희소하고(sparse) 의미를 담지 못했다면, 임베딩은 밀집되고(dense) 풍부한 의미를 표현합니다. "강아지"와 "고양이"는 원-핫에서는 아무 관계가 없지만, 임베딩에서는 가까운 벡터를 가집니다.
임베딩의 핵심 특징은 네 가지입니다. 첫째, lookup table로 구현되어 O(1) 시간에 조회됩니다.
둘째, 역전파를 통해 학습됩니다(처음엔 랜덤, 학습하며 의미를 획득). 셋째, 사전 학습된 임베딩(Word2Vec, GloVe)을 사용할 수 있습니다.
넷째, 단어뿐 아니라 문자, 품사, 개체 등 모든 이산 객체에 적용 가능합니다. 이것이 현대 NLP의 기초입니다.
코드 예제
import torch
import torch.nn as nn
import numpy as np
# 간단한 어휘 예시
vocab = {
'<PAD>': 0, # 패딩
'<UNK>': 1, # 미등록 단어
'좋다': 2,
'훌륭하다': 3,
'나쁘다': 4,
'끔찍하다': 5,
'영화': 6,
'음식': 7
}
vocab_size = len(vocab)
embedding_dim = 4 # 이해를 위해 작게 설정 (실전: 100~300)
# PyTorch 임베딩 층
embedding = nn.Embedding(vocab_size, embedding_dim)
# 단어 시퀀스: "영화 좋다"
word_ids = torch.LongTensor([vocab['영화'], vocab['좋다']])
# 임베딩 lookup
embedded = embedding(word_ids)
print("=== 임베딩 기본 사용 ===")
print(f"입력 단어 IDs: {word_ids}")
print(f"임베딩 결과 shape: {embedded.shape}") # (2, 4)
print(f"'영화' 임베딩:\n{embedded[0]}")
print(f"'좋다' 임베딩:\n{embedded[1]}")
# 배치 처리 예시
batch_sentences = torch.LongTensor([
[vocab['영화'], vocab['좋다'], vocab['<PAD>']], # "영화 좋다"
[vocab['음식'], vocab['훌륭하다'], vocab['<PAD>']] # "음식 훌륭하다"
])
batch_embedded = embedding(batch_sentences)
print(f"\n배치 임베딩 shape: {batch_embedded.shape}") # (2, 3, 4)
# 사전 학습된 임베딩 사용하기
print("\n=== 사전 학습 임베딩 로드 ===")
# 예: GloVe 벡터를 로드했다고 가정
pretrained_embeddings = np.random.randn(vocab_size, embedding_dim)
pretrained_embeddings[0] = 0 # PAD는 0 벡터로
# 새 임베딩 층에 로드
embedding_pretrained = nn.Embedding(vocab_size, embedding_dim)
embedding_pretrained.weight.data.copy_(torch.from_numpy(pretrained_embeddings))
# 임베딩 동결 (학습 안 함) - Fine-tuning 초기에 유용
embedding_pretrained.weight.requires_grad = False
print("사전 학습 임베딩 로드 완료")
print(f"학습 가능 파라미터: {embedding_pretrained.weight.requires_grad}")
# 임베딩 간 유사도 계산
def cosine_similarity(v1, v2):
"""코사인 유사도: -1(반대) ~ 1(같음)"""
return torch.dot(v1, v2) / (torch.norm(v1) * torch.norm(v2))
# '좋다'와 '훌륭하다'의 유사도
sim_positive = cosine_similarity(
embedding.weight[vocab['좋다']],
embedding.weight[vocab['훌륭하다']]
)
# '좋다'와 '나쁘다'의 유사도
sim_negative = cosine_similarity(
embedding.weight[vocab['좋다']],
embedding.weight[vocab['나쁘다']]
)
print(f"\n=== 의미적 유사도 ===")
print(f"'좋다' vs '훌륭하다': {sim_positive:.4f}")
print(f"'좋다' vs '나쁘다': {sim_negative:.4f}")
설명
이것이 하는 일: 임베딩은 단어 ID를 받아 대응하는 벡터를 반환하는 lookup table입니다. 내부적으로는 (vocab_size, embedding_dim) 크기의 행렬이고, 각 행이 한 단어의 임베딩입니다.
첫 번째로, vocab 딕셔너리는 단어를 정수 ID로 매핑합니다. <PAD>는 시퀀스 길이를 맞추기 위한 패딩 토큰이고, <UNK>는 어휘에 없는 단어를 대체합니다.
실전에서는 torchtext나 transformers 라이브러리가 자동으로 생성합니다. ID는 0부터 시작하며, 순서는 중요하지 않습니다(보통 빈도순으로 정렬).
그 다음으로, nn.Embedding(vocab_size, embedding_dim)은 (8, 4) 크기의 가중치 행렬을 생성합니다. 초기값은 랜덤입니다.
embedding(word_ids)를 호출하면, 각 ID에 해당하는 행을 가져옵니다. 예를 들어, ID 6('영화')이면 가중치 행렬의 6번째 행을 반환합니다.
이 연산은 미분 가능하여, 역전파로 학습됩니다. 세 번째로, 배치 처리를 봅시다.
batch_sentences는 (2, 3) 텐서입니다. 2개 문장, 각 3단어입니다.
임베딩을 거치면 (2, 3, 4)가 됩니다: 배치 크기 2, 시퀀스 길이 3, 임베딩 차원 4. 이것이 RNN의 입력 형태입니다.
길이가 다른 문장들은 짧은 것에 <PAD>를 추가하여 맞춥니다. 네 번째로, 사전 학습 임베딩 사용법입니다.
Word2Vec이나 GloVe는 거대한 코퍼스에서 학습된 범용 임베딩입니다. 이것을 초기값으로 사용하면 적은 데이터로도 좋은 성능을 냅니다.
weight.data.copy_로 값을 복사하고, requires_grad=False로 동결하면 파인튜닝을 제어할 수 있습니다. 처음엔 동결했다가 나중에 풀어주는 2단계 학습이 효과적입니다.
다섯 번째로, 코사인 유사도는 임베딩 품질을 평가하는 방법입니다. 잘 학습된 임베딩에서는 '좋다'와 '훌륭하다'의 유사도가 높고, '좋다'와 '나쁘다'는 낮습니다.
랜덤 초기화 상태에서는 의미 없지만, 학습 후에는 의미적 관계를 반영합니다. 유명한 예: vec('king') - vec('man') + vec('woman') ≈ vec('queen').
여러분이 이 코드를 사용하면 NLP 파이프라인의 첫 단계를 완성할 수 있습니다. 실무에서는 몇 가지를 추가합니다: 어휘 크기 제한(보통 상위 10,000~50,000 단어), 미등록 단어 처리(<UNK> 사용), 특수 토큰(<BOS>, <EOS>, <PAD>), 서브워드 토크나이제이션(BPE, SentencePiece), 문맥 임베딩(ELMo, BERT) 등입니다.
또한, 임베딩은 단어뿐 아니라 문자, 품사 태그, 개체 타입 등 모든 범주형 데이터에 사용할 수 있습니다. 추천 시스템에서 사용자/아이템 임베딩, 컴퓨터 비전에서 위치 임베딩 등 활용 범위가 매우 넓습니다.
실전 팁
💡 임베딩 차원은 vocab 크기의 네제곱근이 적당합니다. vocab=10000이면 √√10000 ≈ 100차원.
💡 처음 학습할 때는 임베딩을 동결하고, 나중에 풀어주는 2단계 학습이 안정적입니다.
💡 PAD 토큰의 임베딩은 0 벡터로 고정하고 학습하지 않는 것이 일반적입니다.
💡 GloVe나 FastText 같은 사전 학습 임베딩은 torchtext.vocab에서 쉽게 로드할 수 있습니다.
💡 임베딩을 시각화하려면 t-SNE나 PCA로 2D로 축소하여 플롯하세요. 비슷한 단어들이 모이는지 확인할 수 있습니다.
댓글 (0)
함께 보면 좋은 카드 뉴스
범주형 변수 시각화 완벽 가이드 Bar Chart와 Count Plot
데이터 분석에서 가장 기본이 되는 범주형 변수 시각화 방법을 알아봅니다. Matplotlib의 Bar Chart부터 Seaborn의 Count Plot까지, 실무에서 바로 활용할 수 있는 시각화 기법을 배워봅니다.
이변량 분석 완벽 가이드: 변수 간 관계 탐색
두 변수 사이의 관계를 분석하는 이변량 분석의 핵심 개념과 기법을 배웁니다. 상관관계, 산점도, 교차분석 등 데이터 분석의 필수 도구들을 실습과 함께 익혀봅시다.
단변량 분석 분포 시각화 완벽 가이드
데이터 분석의 첫걸음인 단변량 분석과 분포 시각화를 배웁니다. 히스토그램, 박스플롯, 밀도 그래프 등 다양한 시각화 방법을 초보자도 쉽게 이해할 수 있도록 설명합니다.
데이터 타입 변환 및 정규화 완벽 가이드
데이터 분석과 머신러닝에서 가장 기초가 되는 데이터 타입 변환과 정규화 기법을 배워봅니다. 실무에서 자주 마주치는 데이터 전처리 문제를 Python으로 쉽게 해결하는 방법을 알려드립니다.
이상치 탐지 및 처리 완벽 가이드
데이터 속에 숨어있는 이상한 값들을 찾아내고 처리하는 방법을 배워봅니다. 실무에서 자주 마주치는 이상치 문제를 Python으로 해결하는 다양한 기법을 소개합니다.