
안녕하세요, 오늘은 Attention을 최초로 고안한 바다나우 어텐션 논문을 리뷰해보겠습니다.
논문에 나온 모델 아키텍처의 수식을 이해하고, numpy로 구현해보면서 모델의 이해도를 높여보겠습니다.
기존의 Sequence-to-Sequence(seq2seq) 모델은 입력 문장 전체를 하나의 고정된 크기의 벡터로 압축한 후, 이를 기반으로 번역문을 생성하는 방식을 사용했습니다. 이러한 접근 방식에는 크게 두 가지 한계가 있었습니다.
예를 들어, "나는 어제 친구와 함께 맛있는 저녁을 먹었다"라는 문장을 번역할 때, 기존 seq2seq 모델은 이 모든 정보를 하나의 고정된 크기의 벡터에 담아야 했습니다. 이는 마치 A4 용지에 쓰여진 긴 문장을 포스트잇 크기로 압축하는 것과 비슷합니다.
이러한 한계를 극복하기 위해 인간의 번역 과정을 참고했습니다. 실제로 사람은 긴 문장을 번역할 때, 전체 문장을 한 번에 기억했다가 번역하지 않습니다.
대신, 다음과 같은 과정을 거칩니다:
1. 선택적 집중: 현재 번역하려는 부분에 집중
2. 문맥 참조: 필요할 때마다 원문을 다시 확인
3. 점진적 번역: 부분적으로 나누어 번역을 수행
예를 들어, "나는 어제 친구와 함께 맛있는 저녁을 먹었다"를 영어로 번역할 때:
1. "나는" → "I"
2. "어제" → "yesterday"
3. "친구와 함께" → "with my friend"
식으로 진행하며, 각 단계에서 필요한 문맥을 참조합니다.
이 논문에서는 다음과 같은 혁신적인 아이디어를 제시합니다:
동적인 Context 벡터
Alignment 모델
Bidirectional RNN
이러한 접근 방식의 장점:
인코더는 입력 문장의 각 단어를 양방향으로 처리하는 Bidirectional RNN으로 구성됩니다.
입력 처리
양방향 처리
상태 결합
입력 시퀀스 에 대해:
순방향 GRU:
역방향 GRU도 동일한 구조로 계산됩니다.
최종 인코더 출력:
여기서 [;]는 벡터 연결(concatenation)을 의미합니다.
디코더는 각 시점에서 어텐션 메커니즘을 통해 입력 시퀀스의 특정 부분에 집중하며 번역을 생성합니다. 기존 seq2seq와 달리, 디코더는 매 시점마다 다음과 같은 과정을 거칩니다:
어텐션 계산
상태 업데이트
출력 생성
시점 t에서의 디코더 동작은 다음 수식들로 표현됩니다:
Context 벡터 계산:
GRU 상태 업데이트:
출력 생성:
여기서:
어텐션 메커니즘은 디코더가 입력 시퀀스의 어느 부분에 집중할지 결정하는 핵심 컴포넌트입니다. 이는 다음 세 단계로 구성됩니다:
스코어 계산 (Alignment Model)
가중치 생성 (Softmax)
Context 벡터 생성 (Weighted Sum)
이 수식에서:
Attention Weights:
Context Vector:
이러한 방식으로, 모델은 입력 시퀀스의 모든 부분을 고려하면서도, 현재 번역에 가장 관련있는 부분에 더 높은 가중치를 부여할 수 있습니다.
먼저 모델의 기본이 되는 GRU 셀을 구현해보겠습니다.
class GRUCell:
def __init__(self, input_dim, hidden_dim):
# 가중치 초기화
self.Wz = np.random.randn(hidden_dim, input_dim) * 0.001
self.Uz = np.random.randn(hidden_dim, hidden_dim) * 0.001
self.bz = np.zeros((hidden_dim, 1))
self.Wr = np.random.randn(hidden_dim, input_dim) * 0.001
self.Ur = np.random.randn(hidden_dim, hidden_dim) * 0.001
self.br = np.zeros((hidden_dim, 1))
self.Wh = np.random.randn(hidden_dim, input_dim) * 0.001
self.Uh = np.random.randn(hidden_dim, hidden_dim) * 0.001
self.bh = np.zeros((hidden_dim, 1))
def forward(self, x, h_prev):
# GRU 셀의 순전파
z = sigmoid(np.dot(self.Wz, x) + np.dot(self.Uz, h_prev) + self.bz)
r = sigmoid(np.dot(self.Wr, x) + np.dot(self.Ur, h_prev) + self.br)
h_tilde = np.tanh(np.dot(self.Wh, x) + np.dot(self.Uh, (r * h_prev)) + self.bh)
h = (1 - z) * h_prev + z * h_tilde
return h
def sigmoid(x):
return 1 / (1 + np.exp(-x))
def softmax(x):
exp_x = np.exp(x - np.max(x))
return exp_x / exp_x.sum()
def tanh(x):
return np.tanh(x)
이제 앞서 구현한 컴포넌트들을 조합하여 전체 모델을 구현해보겠습니다.
class BahdanauNMT:
def __init__(self, input_vocab_size, output_vocab_size, hidden_dim):
self.hidden_dim = hidden_dim
self.encoder = BidirectionalEncoder(input_vocab_size, hidden_dim)
self.decoder = AttentionDecoder(hidden_dim, output_vocab_size)
self.attention = BahdanauAttention(hidden_dim)
def forward(self, x, y, teacher_forcing=True):
# 인코딩
encoder_states = self.encoder.forward(x)
# 디코딩 초기화
batch_size = len(x)
max_len = len(y)
decoder_state = np.zeros((batch_size, self.hidden_dim))
outputs = []
attentions = []
# 디코딩
for t in range(max_len):
if t == 0:
decoder_input = np.zeros((batch_size, self.output_dim))
elif teacher_forcing:
decoder_input = y[t-1]
else:
decoder_input = outputs[-1]
# 어텐션 및 디코딩
context, attention = self.attention.compute_attention(
encoder_states, decoder_state)
decoder_state = self.decoder.forward(
context, decoder_input, decoder_state)
# 출력 저장
outputs.append(decoder_state)
attentions.append(attention)
return np.array(outputs), np.array(attentions)
모델의 학습을 위해 크로스 엔트로피 손실 함수를 사용합니다.
def cross_entropy_loss(predictions, targets):
"""
predictions: (batch_size, vocab_size) - 소프트맥스 이전의 로짓값
targets: (batch_size,) - 정답 인덱스
"""
batch_size = len(targets)
# 소프트맥스 적용
probs = softmax(predictions)
# 각 샘플의 손실 계산
correct_logprobs = -np.log(probs[range(batch_size), targets])
# 배치의 평균 손실 반환
return np.mean(correct_logprobs)
모델 학습을 위해 Adam 최적화 알고리즘을 구현합니다.
class Adam:
def __init__(self, params, learning_rate=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8):
self.params = params
self.lr = learning_rate
self.beta1 = beta1
self.beta2 = beta2
self.epsilon = epsilon
# 모멘텀 초기화
self.m = {p: np.zeros_like(v) for p, v in params.items()}
self.v = {p: np.zeros_like(v) for p, v in params.items()}
self.t = 0
def step(self, grads):
self.t += 1
for param_name in self.params:
g = grads[param_name]
# 모멘텀 업데이트
self.m[param_name] = self.beta1 * self.m[param_name] + (1 - self.beta1) * g
self.v[param_name] = self.beta2 * self.v[param_name] + (1 - self.beta2) * g**2
# 편향 보정
m_hat = self.m[param_name] / (1 - self.beta1**self.t)
v_hat = self.v[param_name] / (1 - self.beta2**self.t)
# 파라미터 업데이트
self.params[param_name] -= self.lr * m_hat / (np.sqrt(v_hat) + self.epsilon)
학습된 모델을 사용하여 새로운 문장을 번역하는 과정을 구현합니다.
def translate(model, input_sequence, max_length=50):
# 인코딩
encoder_states = model.encoder.forward(input_sequence)
# 디코딩 초기화
decoder_state = np.zeros(model.hidden_dim)
output_sequence = []
attention_weights_history = []
# <START> 토큰으로 시작
current_token = START_TOKEN
# 디코딩
for _ in range(max_length):
# 어텐션 및 디코딩
context, attention_weights = model.attention.compute_attention(
encoder_states, decoder_state)
decoder_state = model.decoder.forward(
context, current_token, decoder_state)
# 다음 토큰 예측
output_probs = softmax(decoder_state)
predicted_token = np.argmax(output_probs)
# 결과 저장
output_sequence.append(predicted_token)
attention_weights_history.append(attention_weights)
# <END> 토큰이 나오면 종료
if predicted_token == END_TOKEN:
break
current_token = predicted_token
return output_sequence, attention_weights_history
간단한 한영 번역 태스크를 통해 구현한 모델을 검증해보겠습니다.
# 실험을 위한 간단한 데이터셋 생성
def create_sample_dataset():
source_sentences = [
"나는 학교에 간다",
"그녀는 책을 읽는다",
"그는 음악을 듣는다",
"우리는 공부를 한다"
]
target_sentences = [
"I go to school",
"She reads a book",
"He listens to music",
"We study"
]
return source_sentences, target_sentences
# 데이터 전처리
def preprocess_data(source_sentences, target_sentences):
# 간단한 토크나이저 구현
def build_vocab(sentences):
vocab = {'<PAD>': 0, '<START>': 1, '<END>': 2}
for sentence in sentences:
for word in sentence.split():
if word not in vocab:
vocab[word] = len(vocab)
return vocab
source_vocab = build_vocab(source_sentences)
target_vocab = build_vocab(target_sentences)
return source_vocab, target_vocab
# 학습 루프
def train(model, train_data, optimizer, num_epochs=100):
losses = []
for epoch in range(num_epochs):
epoch_loss = 0
for source, target in train_data:
# 순전파
outputs, attentions = model.forward(source, target)
# 손실 계산
loss = cross_entropy_loss(outputs, target)
epoch_loss += loss
# 역전파 및 최적화
grads = model.backward(outputs, target)
optimizer.step(grads)
losses.append(epoch_loss / len(train_data))
if epoch % 10 == 0:
print(f"Epoch {epoch}, Loss: {losses[-1]:.4f}")
return losses
학습된 모델의 어텐션 가중치를 시각화하여 모델이 어떤 단어에 주목하는지 확인해보겠습니다.
def visualize_attention(source_sentence, target_sentence, attention_weights):
plt.figure(figsize=(10, 8))
sns.heatmap(attention_weights,
xticklabels=source_sentence.split(),
yticklabels=target_sentence.split(),
cmap='viridis')
plt.xlabel('Source Words')
plt.ylabel('Target Words')
plt.title('Attention Weights Visualization')
plt.show()
# 예시 문장에 대한 어텐션 시각화
source = "나는 학교에 간다"
target = "I go to school"
_, attention_weights = model.translate(source)
visualize_attention(source, target, attention_weights)
실제 번역 결과를 통해 모델의 성능을 분석해보겠습니다.
def analyze_translations(model, test_sentences):
for source in test_sentences:
translation, _ = model.translate(source)
print(f"Source: {source}")
print(f"Translation: {' '.join(translation)}")
print("-" * 50)
test_sentences = [
"나는 공부를 한다",
"그녀는 음악을 듣는다",
"그는 책을 읽는다"
]
analyze_translations(model, test_sentences)
실험 결과, 우리가 구현한 Bahdanau Attention 모델의 추론 결과값입니다. 데이터를 넣지 않아서 그런지 확실히 낮은 수준을 보여주네요ㅎㅎ...


이 논문 리뷰를 통해 Bahdanau Attention의 핵심 개념과 구현 방법을 살펴보았습니다. numpy만을 사용한 구현을 통해 어텐션 메커니즘의 작동 원리를 더 깊이 이해할 수 있었습니다. 엄청난 성능을 보여주는 GPT에 도달하기까지, 이 trivial한 모델이 어떠한 발전을 이루어 가는지 앞으로의 논문들을 보면서 함께 따라가 봅시다!
코드
https://github.com/devyulbae/devyulbae.github.io/tree/master/_code/bahdanau_nmt.py