이미지 로딩 중...
AI Generated
2025. 11. 11. · 2 Views
바닥부터 만드는 ChatGPT 6편 멀티헤드 어텐션 메커니즘 구현
ChatGPT의 핵심인 멀티헤드 어텐션 메커니즘을 바닥부터 직접 구현해봅니다. 트랜스포머의 가장 중요한 구성 요소를 Python과 NumPy로 이해하고, 실전에서 어떻게 작동하는지 깊이 있게 학습합니다.
목차
- 멀티헤드 어텐션이란 - 여러 관점에서 문맥 파악하기
- Query Key Value 생성 - 어텐션의 핵심 요소 만들기
- 스케일드 닷 프로덕트 어텐션 - 단어 간 관련성 계산하기
- Softmax 함수 구현 - 확률 분포로 변환하기
- 멀티헤드 출력 결합 - 여러 관점을 하나로 통합하기
- 마스킹 메커니즘 - 미래 정보 차단과 패딩 처리
- 위치 인코딩 - 단어 순서 정보 추가하기
- 전체 멀티헤드 어텐션 통합 - 완성된 구현
1. 멀티헤드 어텐션이란 - 여러 관점에서 문맥 파악하기
시작하며
여러분이 "The bank by the river is beautiful"이라는 문장을 읽을 때, "bank"가 은행인지 강둑인지 어떻게 판단하나요? 우리는 "river"라는 단어를 보고 자연스럽게 "bank"가 강둑을 의미한다는 것을 이해합니다.
하지만 AI 모델에게 이런 문맥 파악은 매우 어려운 문제입니다. 전통적인 RNN이나 LSTM은 문장을 순차적으로 처리하면서 문맥을 파악하려 했지만, 긴 문장에서는 초반 정보를 잊어버리는 문제가 있었습니다.
게다가 병렬 처리가 어려워 학습 속도도 느렸죠. 바로 이럴 때 필요한 것이 멀티헤드 어텐션입니다.
이 메커니즘은 단어 간의 관계를 여러 관점에서 동시에 파악하여, 문맥을 훨씬 더 정확하게 이해할 수 있게 해줍니다. ChatGPT가 놀라운 언어 이해 능력을 보이는 비밀이 바로 여기에 있습니다.
개요
간단히 말해서, 멀티헤드 어텐션은 하나의 문장을 여러 개의 "주의 집중 헤드"로 동시에 분석하는 메커니즘입니다. 마치 여러 명의 전문가가 같은 문장을 서로 다른 관점에서 분석하는 것과 같습니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 단어의 의미는 문맥에 따라 완전히 달라질 수 있기 때문입니다. 예를 들어, 번역 작업을 할 때 "apple"이 회사 이름인지 과일인지, "run"이 실행인지 달리기인지를 정확히 파악해야 합니다.
하나의 어텐션만으로는 이런 다양한 관계를 모두 포착하기 어렵습니다. 기존 단일 어텐션 메커니즘에서는 하나의 관점으로만 단어 간 관계를 계산했다면, 멀티헤드 어텐션은 8개 또는 12개의 헤드를 사용하여 동시에 여러 관점을 학습합니다.
각 헤드는 서로 다른 패턴을 학습하게 됩니다. 이 개념의 핵심 특징은 첫째, 병렬 처리가 가능하다는 점입니다.
모든 헤드가 동시에 계산되므로 GPU를 효율적으로 활용할 수 있습니다. 둘째, 다양한 언어적 패턴을 동시에 학습할 수 있습니다.
어떤 헤드는 구문 구조를, 어떤 헤드는 의미 관계를 학습하는 식입니다. 셋째, 장거리 의존성 문제를 효과적으로 해결합니다.
이러한 특징들이 중요한 이유는 현대 NLP 태스크가 단순히 문장을 이해하는 것을 넘어, 미묘한 뉘앙스와 복잡한 문맥을 파악해야 하기 때문입니다. GPT, BERT 같은 모든 최신 언어 모델이 멀티헤드 어텐션을 핵심 구성 요소로 사용하는 이유입니다.
코드 예제
import numpy as np
class MultiHeadAttention:
def __init__(self, d_model, num_heads):
# d_model: 임베딩 차원 (예: 512)
# num_heads: 어텐션 헤드 개수 (예: 8)
self.num_heads = num_heads
self.d_model = d_model
self.depth = d_model // num_heads # 각 헤드의 차원
# Q, K, V를 위한 가중치 행렬 초기화
self.W_q = np.random.randn(d_model, d_model) * 0.01
self.W_k = np.random.randn(d_model, d_model) * 0.01
self.W_v = np.random.randn(d_model, d_model) * 0.01
# 최종 출력을 위한 가중치
self.W_o = np.random.randn(d_model, d_model) * 0.01
설명
이것이 하는 일: 멀티헤드 어텐션은 입력 문장을 받아 여러 개의 어텐션 헤드로 분할한 후, 각 헤드가 독립적으로 문맥을 분석하고, 그 결과를 다시 합쳐서 최종 출력을 만들어냅니다. 첫 번째로, __init__ 메서드에서 초기화를 담당합니다.
d_model은 전체 임베딩 차원(보통 512나 768)이고, num_heads는 몇 개의 헤드로 분할할지 결정합니다. 예를 들어 512차원을 8개 헤드로 나누면 각 헤드는 64차원을 담당하게 됩니다.
이렇게 분할하는 이유는 각 헤드가 서로 다른 언어적 특징을 학습할 수 있도록 하기 위함입니다. 그 다음으로, 가중치 행렬 W_q, W_k, W_v를 초기화합니다.
이들은 각각 Query, Key, Value를 생성하는 선형 변환 행렬입니다. 어텐션 메커니즘의 핵심은 "어떤 단어에 주목할 것인가"를 결정하는 것인데, Query는 "찾고자 하는 것", Key는 "각 단어의 특징", Value는 "실제 정보"를 나타냅니다.
작은 값(0.01)으로 곱하는 이유는 초기 가중치가 너무 크면 학습이 불안정해지기 때문입니다. 마지막으로, W_o는 모든 헤드의 출력을 합친 후 최종 변환하는 행렬입니다.
각 헤드가 독립적으로 계산한 결과들을 단순히 이어붙이기만 하면 정보가 잘 통합되지 않습니다. 따라서 한 번 더 선형 변환을 거쳐 정보를 효과적으로 혼합합니다.
여러분이 이 코드를 사용하면 512차원 입력을 8개의 64차원 헤드로 분리하여 처리할 수 있는 기반을 마련하게 됩니다. 실무에서는 이를 통해 문장의 구문 구조, 의미 관계, 참조 관계 등을 동시에 학습할 수 있습니다.
또한 병렬 처리가 가능해져 대규모 데이터셋에서도 빠른 학습이 가능합니다.
실전 팁
💡 num_heads는 d_model의 약수여야 합니다. 예를 들어 d_model=512라면 num_heads는 8, 16 등이 가능합니다. 그렇지 않으면 depth가 정수가 되지 않아 오류가 발생합니다.
💡 헤드 개수는 보통 8개 또는 12개를 사용합니다. 너무 많으면 각 헤드의 차원이 작아져 표현력이 떨어지고, 너무 적으면 다양한 패턴을 학습하기 어렵습니다. 실험적으로 8개가 가장 균형잡힌 선택입니다.
💡 가중치 초기화는 매우 중요합니다. Xavier 초기화나 He 초기화를 사용하면 더 안정적인 학습이 가능합니다. np.random.randn(d_model, d_model) * np.sqrt(2.0 / d_model) 같은 방식을 고려해보세요.
💡 실제 구현에서는 PyTorch나 TensorFlow를 사용하세요. NumPy로 구조를 이해한 후에는 자동 미분과 GPU 가속을 지원하는 프레임워크로 전환하는 것이 효율적입니다.
💡 각 헤드가 무엇을 학습하는지 시각화해보세요. 어텐션 가중치를 히트맵으로 그려보면 어떤 헤드는 인접 단어를, 어떤 헤드는 장거리 의존성을 학습하는 것을 확인할 수 있습니다.
2. Query Key Value 생성 - 어텐션의 핵심 요소 만들기
시작하며
여러분이 도서관에서 특정 주제의 책을 찾을 때를 생각해보세요. 여러분의 머릿속에는 "찾고자 하는 내용"(Query)이 있고, 각 책에는 "제목과 키워드"(Key)가 있으며, 실제로 원하는 정보는 "책의 내용"(Value)에 담겨 있습니다.
여러분은 Query와 Key를 비교해서 가장 관련 있는 책을 찾고, 그 Value를 읽게 됩니다. 어텐션 메커니즘도 정확히 이와 같은 방식으로 작동합니다.
각 단어가 다른 단어들 중에서 "어디에 주목해야 할지"를 결정하려면, 먼저 모든 단어를 Query, Key, Value 세 가지 형태로 변환해야 합니다. 이 변환 과정이 바로 멀티헤드 어텐션의 첫 번째 단계이며, 이후 모든 계산의 기반이 됩니다.
올바른 Q, K, V를 생성하지 못하면 아무리 복잡한 어텐션 계산을 해도 의미 있는 결과를 얻을 수 없습니다.
개요
간단히 말해서, Query, Key, Value는 같은 입력을 서로 다른 관점으로 변환한 세 가지 표현입니다. 각각은 어텐션 계산에서 고유한 역할을 담당합니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 단어의 역할은 문맥에 따라 달라지기 때문입니다. 예를 들어, "The cat sat on the mat"에서 "cat"을 처리할 때, Query는 "나는 무엇에 대한 정보가 필요한가", Key는 "나는 어떤 특징을 가진 단어인가", Value는 "나는 어떤 정보를 제공할 수 있는가"를 나타냅니다.
이 세 가지를 분리함으로써 유연하고 강력한 어텐션 계산이 가능해집니다. 기존 방식에서는 단어 임베딩을 그대로 사용했다면, 이제는 학습 가능한 가중치 행렬을 통해 각 단어를 세 가지 다른 공간으로 투영합니다.
이렇게 하면 모델이 학습 과정에서 "어떤 정보가 Query로 적합한지", "어떤 특징이 Key로 유용한지"를 스스로 배울 수 있습니다. 핵심 특징은 첫째, 같은 입력에서 세 가지 다른 표현을 만들어낸다는 점입니다.
둘째, 각 변환은 학습 가능한 파라미터를 가지고 있어 데이터로부터 최적의 변환을 학습합니다. 셋째, 멀티헤드를 위해 전체 차원을 num_heads개로 분할합니다.
이러한 분할이 중요한 이유는 각 헤드가 독립적으로 서로 다른 언어적 패턴을 학습할 수 있게 하기 때문입니다. 하나의 거대한 어텐션보다 여러 개의 작은 어텐션이 더 다양한 정보를 포착할 수 있습니다.
코드 예제
def split_heads(self, x, batch_size):
# x shape: (batch_size, seq_len, d_model)
# 출력 shape: (batch_size, num_heads, seq_len, depth)
x = x.reshape(batch_size, -1, self.num_heads, self.depth)
return x.transpose(0, 2, 1, 3)
def forward_qkv(self, x):
# x: 입력 임베딩 (batch_size, seq_len, d_model)
batch_size = x.shape[0]
# Q, K, V 생성 - 선형 변환
Q = np.dot(x, self.W_q) # (batch_size, seq_len, d_model)
K = np.dot(x, self.W_k) # (batch_size, seq_len, d_model)
V = np.dot(x, self.W_v) # (batch_size, seq_len, d_model)
# 멀티헤드를 위해 분할
Q = self.split_heads(Q, batch_size) # (batch_size, num_heads, seq_len, depth)
K = self.split_heads(K, batch_size)
V = self.split_heads(V, batch_size)
return Q, K, V
설명
이것이 하는 일: 입력 문장의 각 단어를 Query, Key, Value 세 가지 표현으로 변환한 후, 멀티헤드 계산을 위해 적절한 형태로 재구성합니다. 첫 번째로, split_heads 메서드는 차원을 재구성하는 역할을 합니다.
(batch_size, seq_len, d_model) 형태의 텐서를 받아서, 먼저 reshape으로 (batch_size, seq_len, num_heads, depth)로 바꿉니다. 예를 들어 d_model=512, num_heads=8이면 각 단어의 512차원을 8개 헤드 × 64차원으로 나눕니다.
그 다음 transpose로 헤드 차원을 앞으로 가져와 (batch_size, num_heads, seq_len, depth)로 만듭니다. 이렇게 하는 이유는 각 헤드를 독립적으로 병렬 처리하기 위함입니다.
두 번째로, forward_qkv 메서드에서 실제 변환이 일어납니다. np.dot(x, self.W_q)는 입력 x와 가중치 행렬 W_q의 행렬 곱을 계산합니다.
이는 각 단어 벡터를 Query 공간으로 투영하는 선형 변환입니다. 예를 들어 "cat"이라는 단어의 512차원 임베딩이 W_q와 곱해져서 "이 단어가 찾고자 하는 정보"를 나타내는 새로운 512차원 벡터로 변환됩니다.
K와 V도 마찬가지로 각각의 가중치 행렬로 변환됩니다. 세 번째로, 생성된 Q, K, V를 각각 split_heads를 통해 멀티헤드 형태로 분할합니다.
이제 각 헤드는 전체 차원의 1/num_heads만을 담당하게 됩니다. 8개 헤드라면 각각 64차원씩 처리하는 것이죠.
중요한 점은 각 헤드가 서로 다른 가중치로 학습되므로, 같은 입력에서도 서로 다른 패턴을 포착하게 된다는 것입니다. 최종적으로, (batch_size, num_heads, seq_len, depth) 형태의 Q, K, V가 반환됩니다.
이 형태는 다음 단계인 어텐션 스코어 계산에 최적화되어 있습니다. 배치 내의 모든 문장, 모든 헤드, 모든 단어에 대해 병렬로 계산할 수 있는 구조입니다.
여러분이 이 코드를 사용하면 "The cat sat on the mat" 같은 문장의 각 단어가 8개의 서로 다른 Query, Key, Value 표현을 가지게 됩니다. 실무에서는 이를 통해 어떤 헤드는 주어-동사 관계를, 어떤 헤드는 명사-수식어 관계를 학습하는 식으로 다양한 언어 패턴을 자동으로 발견하게 됩니다.
실전 팁
💡 transpose 순서에 주의하세요. transpose(0, 2, 1, 3)은 두 번째와 세 번째 차원을 바꾸라는 의미입니다. 순서를 잘못 지정하면 차원이 꼬여서 디버깅이 매우 어려워집니다.
💡 batch_size는 동적으로 결정됩니다. x.shape[0]으로 배치 크기를 가져오는 이유는 학습 시와 추론 시 배치 크기가 다를 수 있기 때문입니다. 하드코딩하지 마세요.
💡 메모리 효율을 위해 in-place 연산을 고려하세요. NumPy에서는 큰 차이가 없지만, PyTorch로 전환할 때는 .view() 대신 .reshape()을 사용하면 필요할 때만 메모리를 복사합니다.
💡 Q, K, V의 차원은 항상 동일합니다. 어텐션 계산을 위해 세 개 모두 같은 d_model 차원을 가져야 합니다. 만약 encoder-decoder 구조에서 차원이 다르다면 추가 투영이 필요합니다.
💡 디버깅 시 shape를 항상 확인하세요. print(Q.shape) 같은 코드를 추가해서 각 단계에서 예상한 차원이 나오는지 검증하세요. 차원 불일치는 가장 흔한 오류입니다.
3. 스케일드 닷 프로덕트 어텐션 - 단어 간 관련성 계산하기
시작하며
여러분이 "The animal didn't cross the street because it was too tired"라는 문장을 읽을 때, "it"이 무엇을 가리키는지 어떻게 알 수 있나요? 우리는 자동으로 "animal"과 "tired"를 연결하여 "it"이 동물을 가리킨다는 것을 이해합니다.
이런 참조 해결은 언어 이해의 핵심입니다. 기계 학습 모델이 이런 관계를 파악하려면, 각 단어가 다른 모든 단어와 얼마나 관련 있는지를 수치로 계산해야 합니다.
단순히 거리만 보면 "street"이 더 가까워 보이지만, 의미적으로는 "animal"이 훨씬 관련성이 높습니다. 바로 이것이 스케일드 닷 프로덕트 어텐션이 하는 일입니다.
Query와 Key의 유사도를 계산하여 어떤 단어에 얼마나 주목해야 할지 결정하고, 그 가중치로 Value를 조합합니다. 이 메커니즘이 바로 트랜스포머가 문맥을 이해하는 핵심 원리입니다.
개요
간단히 말해서, 스케일드 닷 프로덕트 어텐션은 Query와 Key의 내적(dot product)을 계산하여 어텐션 가중치를 구하고, 이를 Value에 적용하여 문맥을 반영한 새로운 표현을 만드는 과정입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 고정된 임베딩만으로는 문맥을 반영할 수 없기 때문입니다.
예를 들어, "bank"라는 단어는 "river bank"와 "bank account"에서 완전히 다른 의미를 가집니다. 어텐션을 통해 주변 단어들의 정보를 가중 평균하면, 같은 "bank"라도 문맥에 따라 다른 표현을 얻게 됩니다.
기존 RNN 방식에서는 이전 hidden state를 순차적으로 전달했다면, 어텐션은 모든 단어에 직접 접근하여 필요한 정보를 가져옵니다. 이는 장거리 의존성 문제를 해결하고, 병렬 처리를 가능하게 합니다.
핵심 특징은 첫째, Q와 K의 내적으로 유사도를 측정한다는 점입니다. 두 벡터가 같은 방향을 가리키면 내적이 크고, 직교하면 0입니다.
둘째, sqrt(depth)로 나누어 스케일링합니다. 이는 차원이 클수록 내적 값이 커져서 softmax의 gradient가 소실되는 것을 방지합니다.
셋째, softmax로 확률 분포를 만들어 해석 가능성을 높입니다. 이러한 수학적 설계가 중요한 이유는 안정적인 학습과 해석 가능한 결과를 동시에 보장하기 때문입니다.
어텐션 가중치를 시각화하면 모델이 실제로 어떤 단어에 주목하는지 확인할 수 있어, 디버깅과 모델 이해에 큰 도움이 됩니다.
코드 예제
def scaled_dot_product_attention(self, Q, K, V, mask=None):
# Q, K, V shape: (batch_size, num_heads, seq_len, depth)
# 1. 어텐션 스코어 계산: Q와 K^T의 내적
# matmul의 마지막 두 차원에서 행렬곱 수행
scores = np.matmul(Q, K.transpose(0, 1, 3, 2)) # (..., seq_len, seq_len)
# 2. 스케일링: sqrt(depth)로 나누기
scores = scores / np.sqrt(self.depth)
# 3. 마스킹 (옵션): 특정 위치를 -inf로 설정
if mask is not None:
scores = scores + (mask * -1e9)
# 4. Softmax로 확률 분포 변환
attention_weights = self.softmax(scores) # (..., seq_len, seq_len)
# 5. Value와 가중합 계산
output = np.matmul(attention_weights, V) # (..., seq_len, depth)
return output, attention_weights
설명
이것이 하는 일: 각 단어가 문장 내 다른 모든 단어와 얼마나 관련 있는지 계산하고, 그 관련성에 비례하여 정보를 통합합니다. 첫 번째로, Q와 K의 전치 행렬을 곱합니다.
K.transpose(0, 1, 3, 2)는 마지막 두 차원만 바꿔서 (batch, num_heads, depth, seq_len) 형태를 만듭니다. 그 다음 Q(batch, num_heads, seq_len, depth)와 곱하면 (batch, num_heads, seq_len, seq_len) 형태가 됩니다.
이 행렬의 (i, j) 위치 값은 i번째 단어의 Query와 j번째 단어의 Key가 얼마나 유사한지를 나타냅니다. 예를 들어, "it"의 Query와 "animal"의 Key의 내적이 크면, "it"이 "animal"과 관련 있다는 의미입니다.
두 번째로, 스케일링을 수행합니다. depth가 64라면 sqrt(64)=8로 나눕니다.
이렇게 하지 않으면 차원이 클수록 내적 값이 매우 커져서 softmax 함수의 기울기가 거의 0이 되는 문제가 발생합니다. 예를 들어 내적 값이 100이면 softmax 후 거의 1이 되고, 다른 값들은 0에 가까워져 학습이 안 됩니다.
스케일링은 이를 방지합니다. 세 번째로, 마스킹을 적용합니다.
디코더에서 미래 단어를 참조하지 못하도록 하거나, 패딩 토큰을 무시하기 위해 사용합니다. 마스크된 위치에 매우 큰 음수(-1e9)를 더하면 softmax 후 0에 가까워집니다.
이는 "해당 위치를 보지 마라"는 의미입니다. 네 번째로, softmax를 적용하여 각 행이 확률 분포가 되도록 합니다.
즉, 각 단어에 대해 다른 모든 단어에 주목할 확률이 합이 1이 됩니다. 이제 어텐션 가중치는 해석 가능한 의미를 가집니다: "이 단어는 저 단어에 30% 주목하고, 다른 단어에 10% 주목한다" 같은 식입니다.
마지막으로, 이 가중치를 Value에 곱합니다. (seq_len, seq_len) 행렬과 (seq_len, depth) 행렬의 곱이므로 결과는 (seq_len, depth)입니다.
이는 각 단어의 새로운 표현으로, 주변 단어들의 Value를 어텐션 가중치 비율로 섞은 결과입니다. 여러분이 이 코드를 사용하면 "it"이라는 단어가 문맥에 따라 다른 표현을 가지게 됩니다.
"it was tired"라는 문맥에서는 "animal"의 정보를 많이 가져오고, "it was wide"라는 문맥에서는 "street"의 정보를 더 많이 가져오게 됩니다. 실무에서는 이를 통해 번역, 요약, 질의응답 등 모든 NLP 태스크에서 문맥 인식 능력을 크게 향상시킬 수 있습니다.
실전 팁
💡 transpose의 차원 순서를 정확히 이해하세요. (0, 1, 3, 2)는 배치와 헤드는 그대로 두고, 마지막 두 차원(depth, seq_len)만 바꾸라는 의미입니다. 잘못하면 완전히 다른 결과가 나옵니다.
💡 마스크는 additive 방식을 사용하세요. 곱셈 마스크(0과 1)보다 덧셈 마스크(0과 -inf)가 더 안정적입니다. softmax의 특성상 매우 작은 값은 자동으로 0이 됩니다.
💡 어텐션 가중치를 저장해두면 유용합니다. 모델이 잘못된 예측을 할 때 어텐션 맵을 시각화하면 어떤 단어에 잘못 주목했는지 파악할 수 있습니다.
💡 스케일링 없이 실험해보세요. sqrt(depth)로 나누는 것과 나누지 않는 것의 차이를 직접 확인하면 이 단계의 중요성을 체감할 수 있습니다. 특히 depth가 큰 경우 차이가 극명합니다.
💡 수치 안정성을 위해 작은 epsilon을 추가하세요. 실무에서는 scores / (np.sqrt(self.depth) + 1e-8) 같은 방식으로 0으로 나누는 것을 방지합니다.
4. Softmax 함수 구현 - 확률 분포로 변환하기
시작하며
여러분이 시험에서 90점, 85점, 70점을 받았다고 해봅시다. 이 점수들을 "상대적 중요도"로 바꾸려면 어떻게 해야 할까요?
단순히 비율로 나누면 음수 점수를 다룰 수 없고, 점수 차이가 제대로 반영되지 않습니다. Softmax는 이런 임의의 실수 값들을 확률 분포로 변환하는 함수입니다.
큰 값은 더 큰 확률로, 작은 값은 작은 확률로 매핑하되, 모든 값의 합이 정확히 1이 되도록 보장합니다. 또한 지수 함수를 사용하여 값 차이를 증폭시킵니다.
어텐션 메커니즘에서 softmax는 필수적입니다. 어텐션 스코어는 -10부터 50까지 다양한 범위를 가질 수 있는데, 이를 해석 가능한 확률로 바꿔야 "이 단어에 30% 주목한다"는 식의 직관적 이해가 가능해집니다.
개요
간단히 말해서, Softmax는 입력 벡터의 각 원소를 e의 거듭제곱으로 만든 후, 전체 합으로 나누어 확률 분포를 만드는 함수입니다. 출력은 항상 0과 1 사이의 값이며 합이 1입니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 신경망의 출력은 보통 임의의 실수 범위를 가지는데, 이를 분류 확률이나 어텐션 가중치로 사용하려면 확률 분포로 변환해야 하기 때문입니다. 예를 들어, 다중 클래스 분류에서 "고양이 확률 0.7, 개 확률 0.2, 새 확률 0.1"처럼 해석 가능한 출력을 만들어줍니다.
기존에 sigmoid 함수는 이진 분류에만 적합했다면, softmax는 다중 클래스나 어텐션처럼 여러 옵션 중 분포를 구할 때 사용됩니다. Sigmoid는 각 클래스를 독립적으로 처리하지만, softmax는 모든 클래스의 합이 1이 되도록 정규화합니다.
핵심 특징은 첫째, 출력이 항상 유효한 확률 분포라는 점입니다(모두 양수, 합=1). 둘째, 지수 함수로 인해 차이가 증폭됩니다.
예를 들어 [1, 2, 3]보다 [1, 2, 10]의 softmax가 마지막 원소에 훨씬 더 높은 확률을 부여합니다. 셋째, 미분 가능하여 역전파가 가능합니다.
이러한 특성들이 중요한 이유는 학습 과정에서 모델이 "확신 있는 예측"과 "불확실한 예측"을 구분할 수 있게 하기 때문입니다. 또한 cross-entropy loss와 결합하여 확률적 해석이 가능한 손실 함수를 만듭니다.
코드 예제
def softmax(self, x, axis=-1):
# x: 입력 배열, axis: softmax를 적용할 축
# 1. 수치 안정성을 위해 최댓값 빼기
# 지수 함수는 값이 크면 오버플로우 발생
x_max = np.max(x, axis=axis, keepdims=True)
x_shifted = x - x_max
# 2. 지수 함수 적용
exp_x = np.exp(x_shifted)
# 3. 합으로 나누어 정규화
sum_exp_x = np.sum(exp_x, axis=axis, keepdims=True)
softmax_x = exp_x / sum_exp_x
return softmax_x
설명
이것이 하는 일: 어텐션 스코어 같은 임의의 값들을 0과 1 사이의 확률로 변환하여, 각 위치에 얼마나 주목할지 정량화합니다. 첫 번째로, 수치 안정성을 위한 트릭을 사용합니다.
np.max(x, axis=axis, keepdims=True)로 각 행(또는 지정된 축)의 최댓값을 구하고, 모든 값에서 이를 뺍니다. 수학적으로 softmax(x) = softmax(x - c)이므로 결과는 같지만, 매우 중요한 효과가 있습니다.
예를 들어 x=[1000, 1001, 1002]라면 exp(1000)은 오버플로우가 발생하지만, x-1002=[-2, -1, 0]으로 만들면 exp(-2), exp(-1), exp(0)으로 안전하게 계산됩니다. keepdims=True는 브로드캐스팅을 위해 차원을 유지합니다.
두 번째로, 지수 함수를 적용합니다. np.exp(x_shifted)는 각 원소를 e의 거듭제곱으로 만듭니다.
이렇게 하면 모든 값이 양수가 되고, 큰 값과 작은 값의 차이가 증폭됩니다. 예를 들어 [0, 1, 2]는 [1, 2.718, 7.389]로 변환되어 원래 차이(1, 1)보다 지수 차이가 훨씬 큽니다.
이는 모델이 명확한 선택을 하도록 유도합니다. 세 번째로, 정규화를 수행합니다.
각 행의 지수 값을 모두 더한 후, 각 원소를 이 합으로 나눕니다. 이렇게 하면 자동으로 합이 1이 되는 확률 분포가 만들어집니다.
예를 들어 exp_x=[1, 2.718, 7.389]라면 합은 11.107이고, 각각을 나누면 [0.09, 0.24, 0.67]이 됩니다. 이제 "첫 번째 옵션 9%, 두 번째 24%, 세 번째 67%"로 해석할 수 있습니다.
axis=-1은 마지막 차원에 대해 softmax를 적용한다는 의미입니다. 어텐션 스코어가 (batch, num_heads, seq_len, seq_len) 형태라면, 마지막 차원(각 단어가 다른 단어들에 주목하는 분포)에 대해 정규화합니다.
여러분이 이 코드를 사용하면 어텐션 스코어 [-1.2, 0.5, 3.1, 0.8] 같은 값이 [0.02, 0.11, 0.74, 0.13] 같은 확률로 변환됩니다. 실무에서는 이를 통해 "이 단어는 세 번째 단어에 74% 주목한다"는 명확한 해석이 가능해집니다.
또한 gradient 기반 학습에서 확률적 손실 함수를 사용할 수 있게 됩니다.
실전 팁
💡 항상 최댓값을 빼는 것을 습관화하세요. 이는 softmax 구현의 표준 관행입니다. 결과는 같지만 수치 안정성이 극적으로 향상됩니다. 특히 학습 초기에 큰 값이 나올 때 중요합니다.
💡 axis 파라미터를 정확히 이해하세요. axis=-1은 마지막 차원, axis=1은 두 번째 차원입니다. 잘못 설정하면 엉뚱한 차원에 대해 정규화되어 의미 없는 결과가 나옵니다.
💡 온도(temperature) 파라미터를 추가해보세요. softmax(x/T)처럼 온도 T로 나누면 분포를 조절할 수 있습니다. T>1이면 더 균등한 분포, T<1이면 더 극단적인 분포가 됩니다. 생성 모델에서 창의성을 조절할 때 유용합니다.
💡 log-softmax를 고려하세요. 실제 딥러닝 프레임워크는 수치 안정성을 위해 log(softmax(x))를 직접 계산하는 함수를 제공합니다. cross-entropy loss와 함께 사용할 때 특히 유용합니다.
💡 그래디언트를 직접 계산해보세요. softmax의 미분은 softmax * (1 - softmax)인데, 이를 직접 구현해보면 역전파가 어떻게 작동하는지 깊이 이해할 수 있습니다.
5. 멀티헤드 출력 결합 - 여러 관점을 하나로 통합하기
시작하며
여러분이 여러 명의 전문가에게 같은 문서를 분석하게 했다고 상상해보세요. 한 전문가는 문법을, 다른 전문가는 의미를, 또 다른 전문가는 감정을 분석했습니다.
이제 이 다양한 관점의 분석 결과를 어떻게 종합할까요? 멀티헤드 어텐션도 마찬가지입니다.
8개 또는 12개의 헤드가 각각 독립적으로 어텐션을 계산했으니, 이제 이들을 하나의 통합된 표현으로 합쳐야 합니다. 단순히 이어붙이기만 하면 정보가 잘 융합되지 않습니다.
바로 이 단계가 멀티헤드 어텐션의 마지막 단계입니다. 여러 헤드의 출력을 연결(concatenate)한 후, 학습 가능한 가중치 행렬로 한 번 더 변환하여 최종 출력을 만듭니다.
이를 통해 각 헤드가 발견한 다양한 패턴들이 효과적으로 통합됩니다.
개요
간단히 말해서, 멀티헤드 출력 결합은 각 헤드의 어텐션 결과를 하나로 이어붙인 후, 선형 변환을 통해 원래 차원으로 되돌리는 과정입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 각 헤드가 독립적으로 학습한 정보를 단순히 나열하는 것보다, 이들 간의 상호작용을 학습하는 것이 더 효과적이기 때문입니다.
예를 들어, 어떤 헤드가 주어를 찾고 다른 헤드가 동사를 찾았다면, 이 둘을 연결하여 "주어-동사 일치" 같은 고차원 패턴을 학습할 수 있습니다. 기존 단일 어텐션에서는 이런 결합 과정이 없었다면, 멀티헤드에서는 concat + 선형 변환이라는 2단계 프로세스를 거칩니다.
먼저 concat으로 모든 정보를 모으고, 선형 변환으로 중요한 정보를 추출하고 불필요한 정보를 필터링합니다. 핵심 특징은 첫째, concatenation으로 정보 손실 없이 모든 헤드의 출력을 보존한다는 점입니다.
둘째, 선형 변환 W_o가 학습 가능하여 데이터로부터 최적의 결합 방법을 자동으로 학습합니다. 셋째, 출력 차원이 입력 차원과 같아 residual connection을 쉽게 적용할 수 있습니다.
이러한 설계가 중요한 이유는 트랜스포머의 깊은 층을 쌓을 수 있게 하기 때문입니다. 차원이 일정하게 유지되면 여러 층의 멀티헤드 어텐션을 연속적으로 적용하여 점점 더 복잡한 패턴을 학습할 수 있습니다.
코드 예제
def combine_heads(self, x, batch_size):
# x shape: (batch_size, num_heads, seq_len, depth)
# 출력: (batch_size, seq_len, d_model)
# 1. transpose로 헤드와 시퀀스 차원 바꾸기
x = x.transpose(0, 2, 1, 3) # (batch_size, seq_len, num_heads, depth)
# 2. reshape로 헤드 차원을 펼쳐서 d_model로 만들기
combined = x.reshape(batch_size, -1, self.d_model)
return combined
def forward(self, x):
batch_size = x.shape[0]
# Q, K, V 생성 및 분할
Q, K, V = self.forward_qkv(x)
# 각 헤드에 대해 스케일드 닷 프로덕트 어텐션 수행
attention_output, _ = self.scaled_dot_product_attention(Q, K, V)
# 헤드 결합
combined_output = self.combine_heads(attention_output, batch_size)
# 최종 선형 변환
final_output = np.dot(combined_output, self.W_o)
return final_output
설명
이것이 하는 일: 여러 헤드가 독립적으로 계산한 어텐션 결과를 하나의 통합된 문맥 표현으로 변환합니다. 첫 번째로, combine_heads 메서드에서 차원 재구성이 일어납니다.
입력은 (batch_size, num_heads, seq_len, depth) 형태인데, 먼저 transpose로 (batch_size, seq_len, num_heads, depth)로 바꿉니다. 이는 나중에 reshape할 때 올바른 순서로 연결되도록 하기 위함입니다.
예를 들어 8개 헤드가 각각 64차원이면, 각 단어에 대해 헤드1의 64차원 + 헤드2의 64차원 + ... 순서로 이어붙여져야 합니다.
두 번째로, reshape로 num_heads와 depth를 하나로 합칩니다. reshape(batch_size, -1, self.d_model)은 마지막 두 차원(num_heads=8, depth=64)을 하나로 펼쳐서 d_model=512로 만듭니다.
-1은 "자동으로 계산하라"는 의미로 seq_len이 들어갑니다. 이제 각 단어는 원래의 d_model 차원을 가지지만, 내용은 8개 헤드의 정보가 연결된 것입니다.
세 번째로, forward 메서드에서 전체 흐름이 실행됩니다. 먼저 Q, K, V를 생성하고 헤드로 분할한 후, 각 헤드에서 어텐션을 계산합니다.
이 결과를 combine_heads로 연결합니다. 여기까지는 단순 concatenation입니다.
네 번째로, 가장 중요한 단계인 최종 선형 변환을 수행합니다. np.dot(combined_output, self.W_o)는 연결된 출력에 학습 가능한 가중치 행렬 W_o를 곱합니다.
이 행렬은 (d_model, d_model) 크기로, 입력의 512차원을 다시 512차원으로 변환하지만, 이 과정에서 각 헤드의 정보를 효과적으로 혼합합니다. 예를 들어 어떤 헤드의 정보는 강조하고, 다른 헤드는 약화시키는 식의 학습이 일어납니다.
최종 출력은 (batch_size, seq_len, d_model) 형태로, 입력과 같은 차원입니다. 하지만 내용은 완전히 달라졌습니다.
각 단어는 이제 문맥 정보가 충분히 반영된 새로운 표현을 가지게 됩니다. 여러분이 이 코드를 사용하면 "The cat sat on the mat"의 각 단어가 주변 단어들의 정보를 통합한 풍부한 표현을 얻게 됩니다.
실무에서는 이를 feedforward network, layer normalization과 결합하여 완전한 트랜스포머 블록을 만들고, 이를 여러 층 쌓아서 GPT 같은 강력한 모델을 구축합니다.
실전 팁
💡 transpose와 reshape의 순서가 중요합니다. 만약 transpose 없이 바로 reshape하면 헤드들이 뒤섞인 순서로 연결됩니다. 항상 먼저 차원을 올바른 순서로 정렬한 후 reshape하세요.
💡 W_o 행렬의 중요성을 과소평가하지 마세요. 단순 concatenation만으로는 부족합니다. 이 추가 변환이 멀티헤드의 성능을 크게 좌우합니다. 초기화도 신중하게 하세요.
💡 residual connection을 추가하세요. output = final_output + x 같은 skip connection을 추가하면 gradient flow가 개선되고 학습이 안정화됩니다. 트랜스포머의 필수 요소입니다.
💡 차원을 항상 검증하세요. 각 단계에서 print(x.shape)로 확인하면 디버깅이 쉽습니다. 특히 batch_size=1로 테스트하면 차원 오류를 빨리 찾을 수 있습니다.
💡 드롭아웃을 고려하세요. 실전에서는 어텐션 가중치와 최종 출력에 드롭아웃을 적용하여 과적합을 방지합니다. 보통 0.1~0.3 비율을 사용합니다.
6. 마스킹 메커니즘 - 미래 정보 차단과 패딩 처리
시작하며
여러분이 시험을 볼 때 뒤쪽 문제의 답을 미리 보고 앞쪽 문제를 푼다면 공정하지 않겠죠? 언어 모델도 마찬가지입니다.
"The cat sat on the"까지 보고 다음 단어를 예측할 때, "mat"이라는 정답을 미리 보면 안 됩니다. 또한 배치 처리를 위해 여러 문장을 함께 처리할 때, 문장 길이가 다르면 짧은 문장에 패딩(padding)을 추가합니다.
하지만 이 패딩 토큰은 의미 없는 값이므로 어텐션 계산에 포함되어서는 안 됩니다. 바로 이런 문제를 해결하는 것이 마스킹입니다.
Look-ahead mask는 미래 토큰을 보지 못하게 하고, padding mask는 패딩 토큰을 무시하게 합니다. 이 두 가지 마스크가 없으면 모델이 제대로 학습될 수 없습니다.
개요
간단히 말해서, 마스킹은 어텐션 계산에서 특정 위치를 참조하지 못하도록 차단하는 기법입니다. 마스크된 위치의 어텐션 가중치를 거의 0으로 만듭니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 두 가지 중요한 이유가 있습니다. 첫째, 디코더에서 자동회귀(autoregressive) 생성을 구현하기 위해서는 각 위치가 자신보다 앞의 토큰만 볼 수 있어야 합니다.
예를 들어 "I love"까지 생성했을 때, 다음 단어를 예측하려면 "I", "love"만 참조하고 아직 생성되지 않은 미래 단어는 볼 수 없어야 합니다. 둘째, 배치 처리의 효율성을 위해 패딩을 사용하는데, 이 패딩은 실제 의미가 없으므로 제외해야 합니다.
기존 RNN에서는 순차적 처리 특성상 자연스럽게 미래를 볼 수 없었다면, 어텐션은 모든 위치에 동시에 접근하므로 명시적으로 마스킹이 필요합니다. 마스크가 없으면 모델이 "정답을 보고" 학습하게 되어 실제 생성 시 성능이 크게 떨어집니다.
핵심 특징은 첫째, 마스크는 매우 큰 음수(-1e9)를 더하는 방식으로 구현된다는 점입니다. Softmax 전에 적용되므로 softmax 후 거의 0이 됩니다.
둘째, look-ahead mask는 하삼각 행렬 형태를 가집니다. 셋째, padding mask는 문장별로 다른 길이를 처리할 수 있게 합니다.
이러한 기법이 중요한 이유는 학습과 추론 간의 일관성을 보장하기 때문입니다. 학습 시 미래를 보지 못하게 훈련시켜야, 추론 시에도 미래가 없는 상황에서 제대로 작동합니다.
코드 예제
def create_look_ahead_mask(seq_len):
# 상삼각 행렬을 1로, 하삼각을 0으로
# 그 후 1을 -1e9로 변환 (softmax 후 0이 되도록)
mask = np.triu(np.ones((seq_len, seq_len)), k=1)
return mask
def create_padding_mask(seq):
# seq: (batch_size, seq_len) - 패딩은 0으로 표시
# 패딩 위치를 1로, 실제 토큰을 0으로
mask = (seq == 0).astype(np.float32)
# (batch_size, 1, 1, seq_len) 형태로 브로드캐스팅 준비
return mask[:, np.newaxis, np.newaxis, :]
def apply_mask(scores, mask):
# scores: (batch, num_heads, seq_len, seq_len)
# mask: 같은 shape 또는 브로드캐스팅 가능한 shape
if mask is not None:
# 마스크된 위치에 큰 음수 더하기
scores = scores + (mask * -1e9)
return scores
설명
이것이 하는 일: 어텐션 스코어에 마스크를 적용하여 특정 위치를 참조하지 못하게 만들어, 모델이 올바른 조건에서 학습하고 추론하도록 합니다. 첫 번째로, create_look_ahead_mask는 디코더용 마스크를 생성합니다.
np.triu는 상삼각 행렬을 만드는 함수로, k=1은 대각선 위쪽만 1로 채우라는 의미입니다. 예를 들어 seq_len=4라면 [[0,1,1,1], [0,0,1,1], [0,0,0,1], [0,0,0,0]] 형태가 됩니다.
이는 첫 번째 토큰은 자기 자신만, 두 번째는 첫 번째와 자신만, 세 번째는 앞의 세 개만 볼 수 있다는 의미입니다. 1로 표시된 위치는 나중에 -1e9가 더해져 차단됩니다.
두 번째로, create_padding_mask는 패딩 토큰을 찾아 마스킹합니다. 입력 시퀀스에서 0인 위치(패딩으로 가정)를 1로, 실제 토큰을 0으로 표시합니다.
예를 들어 [5, 10, 3, 0, 0]이라는 시퀀스는 [0, 0, 0, 1, 1]로 변환됩니다. [:, np.newaxis, np.newaxis, :]는 차원을 추가하여 (batch, 1, 1, seq_len) 형태로 만듭니다.
이렇게 하면 어텐션 스코어 (batch, num_heads, seq_len, seq_len)와 브로드캐스팅될 때 모든 헤드와 모든 쿼리 위치에 동일하게 적용됩니다. 세 번째로, apply_mask에서 실제 마스킹이 적용됩니다.
mask * -1e9는 마스크가 1인 위치에 매우 큰 음수를 더하고, 0인 위치는 그대로 둡니다. 예를 들어 어텐션 스코어가 [0.5, 1.2, 0.8]이고 마스크가 [0, 1, 0]이라면, 결과는 [0.5, 1.2-1e9, 0.8] ≈ [0.5, -1e9, 0.8]이 됩니다.
이후 softmax를 거치면 exp(-1e9) ≈ 0이 되어 두 번째 위치의 어텐션 가중치가 거의 0이 됩니다. 왜 곱셈 마스크(0, 1)가 아니라 덧셈 마스크를 사용할까요?
Softmax 전에는 스코어가 임의의 값을 가지므로, 0을 곱해도 softmax 후 어떤 값이 나올지 예측하기 어렵습니다. 반면 -1e9를 더하면 softmax의 특성상 확실히 0에 가까워집니다.
여러분이 이 코드를 사용하면 GPT 같은 디코더 모델을 올바르게 훈련시킬 수 있습니다. 예를 들어 "I love deep learning"을 학습할 때, "love"를 예측할 때는 "I"만 보고, "deep"을 예측할 때는 "I love"만 봅니다.
실무에서는 이를 통해 학습과 추론의 일관성을 보장하여 모델 성능을 크게 향상시킵니다.
실전 팁
💡 -1e9 대신 -np.inf를 사용하지 마세요. 무한대는 gradient 계산에서 NaN을 발생시킬 수 있습니다. -1e9 정도면 충분히 작아서 softmax 후 0이 되면서도 안전합니다.
💡 look-ahead mask는 한 번만 생성하여 재사용하세요. 시퀀스 길이가 같다면 매번 생성할 필요 없이 캐싱해두면 효율적입니다.
💡 패딩 마스크와 look-ahead 마스크를 결합할 때는 maximum을 사용하세요. combined_mask = np.maximum(padding_mask, look_ahead_mask)처럼 하면 둘 중 하나라도 마스크하면 최종적으로 마스크됩니다.
💡 디버깅 시 마스크를 시각화하세요. matplotlib로 히트맵을 그려보면 올바른 삼각형 패턴이 나타나는지 확인할 수 있습니다.
💡 인코더에는 look-ahead mask가 필요 없습니다. BERT 같은 인코더 모델은 양방향 문맥을 사용하므로 padding mask만 필요합니다. GPT 같은 디코더는 둘 다 필요합니다.
7. 위치 인코딩 - 단어 순서 정보 추가하기
시작하며
여러분이 "Dog bites man"과 "Man bites dog"를 읽을 때, 두 문장이 완전히 다른 의미임을 알 수 있습니다. 그 이유는 단어의 순서 때문입니다.
하지만 어텐션 메커니즘은 순서와 무관하게 모든 단어를 동시에 처리합니다. RNN은 단어를 순차적으로 처리하므로 자연스럽게 순서 정보를 가졌지만, 트랜스포머는 병렬 처리를 위해 순서를 버렸습니다.
그렇다면 어떻게 순서 정보를 다시 주입할 수 있을까요? 바로 여기서 위치 인코딩(Positional Encoding)이 필요합니다.
각 위치마다 고유한 벡터를 생성하여 단어 임베딩에 더해줍니다. 이렇게 하면 같은 단어라도 문장의 어느 위치에 있는지에 따라 다른 표현을 가지게 됩니다.
개요
간단히 말해서, 위치 인코딩은 각 토큰의 위치를 나타내는 벡터를 생성하여 임베딩에 더하는 기법입니다. 이를 통해 순서 정보를 모델에 제공합니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 어텐션은 set 연산과 유사하여 순서를 구분하지 못하기 때문입니다. 예를 들어, "not bad"와 "bad not"이 같게 처리되면 의미가 완전히 달라집니다.
위치 인코딩이 없으면 모델은 문법 구조를 학습할 수 없고, 단순히 단어 가방(bag of words) 수준으로 작동합니다. 기존 방식에서 학습 가능한 위치 임베딩(BERT 방식)을 사용할 수도 있지만, 트랜스포머 원 논문에서는 사인/코사인 함수를 사용합니다.
이 방식의 장점은 학습 중 본 적 없는 긴 시퀀스에도 외삽(extrapolation)이 가능하다는 것입니다. 핵심 특징은 첫째, 각 위치마다 고유하고 결정적인 벡터를 생성한다는 점입니다.
둘째, 사인과 코사인의 주기적 특성으로 상대적 위치 관계를 학습할 수 있습니다. 셋째, 학습이 필요 없어 파라미터를 절약합니다.
이러한 설계가 중요한 이유는 모델이 "첫 번째 단어", "두 번째 단어" 같은 절대 위치뿐만 아니라 "3칸 떨어진 단어", "바로 앞 단어" 같은 상대 위치도 학습할 수 있게 하기 때문입니다.
코드 예제
def get_positional_encoding(seq_len, d_model):
# seq_len: 시퀀스 길이, d_model: 임베딩 차원
# 위치 인덱스 생성: (seq_len, 1)
position = np.arange(seq_len)[:, np.newaxis]
# 차원 인덱스 생성: (1, d_model)
div_term = np.exp(np.arange(0, d_model, 2) * -(np.log(10000.0) / d_model))
# 위치 인코딩 행렬 초기화
pos_encoding = np.zeros((seq_len, d_model))
# 짝수 인덱스: 사인 함수
pos_encoding[:, 0::2] = np.sin(position * div_term)
# 홀수 인덱스: 코사인 함수
pos_encoding[:, 1::2] = np.cos(position * div_term)
return pos_encoding
# 사용 예시
def add_positional_encoding(x):
seq_len, d_model = x.shape[1], x.shape[2]
pos_enc = get_positional_encoding(seq_len, d_model)
return x + pos_enc
설명
이것이 하는 일: 문장의 각 위치마다 다른 패턴의 벡터를 생성하여 단어 임베딩에 더해, 같은 단어라도 위치에 따라 구별되게 만듭니다. 첫 번째로, 위치 인덱스를 생성합니다.
np.arange(seq_len)[:, np.newaxis]는 [0, 1, 2, ..., seq_len-1]을 열 벡터로 만듭니다. 예를 들어 seq_len=5라면 [[0], [1], [2], [3], [4]] 형태가 됩니다.
이는 각 토큰의 절대 위치를 나타냅니다. 두 번째로, 주파수 항을 계산합니다.
div_term은 매우 중요한 부분입니다. np.arange(0, d_model, 2)는 [0, 2, 4, ..., d_model-2]를 만들고, 각각에 대해 exp(i * -log(10000) / d_model)을 계산합니다.
이는 10000^(-i/d_model)과 같습니다. 예를 들어 d_model=512라면 첫 번째 차원은 10000^0=1, 중간은 약 10000^0.5=100, 마지막은 10000^1=10000의 주기를 가집니다.
이렇게 다양한 주파수를 사용하는 이유는 짧은 거리와 긴 거리 패턴을 모두 포착하기 위함입니다. 세 번째로, 사인과 코사인을 번갈아 적용합니다.
pos_encoding[:, 0::2]는 0번째, 2번째, 4번째... 차원을 선택합니다(짝수 인덱스).
여기에 sin(position * div_term)를 할당합니다. position은 (seq_len, 1), div_term은 (d_model/2,)이므로 브로드캐스팅되어 (seq_len, d_model/2) 행렬이 됩니다.
각 (pos, dim) 위치의 값은 sin(pos / 10000^(dim/d_model))입니다. 마찬가지로 홀수 인덱스에는 코사인을 적용합니다.
왜 사인과 코사인을 함께 사용할까요? 사인만 사용하면 주기성 때문에 서로 다른 위치가 같은 값을 가질 수 있습니다.
하지만 sin과 cos를 쌍으로 사용하면 (sin(x), cos(x))는 각 x마다 고유한 점을 만들어냅니다. 이는 2D 원 위의 점과 같아서 절대 겹치지 않습니다.
네 번째로, 이렇게 생성된 위치 인코딩을 단어 임베딩에 더합니다. 곱셈이 아니라 덧셈을 사용하는 이유는 임베딩의 의미를 보존하면서 위치 정보를 주입하기 위함입니다.
예를 들어 "cat"의 임베딩이 [0.5, 0.3, ...]이고 위치 0의 인코딩이 [0.0, 1.0, ...]이라면, 최종 표현은 [0.5, 1.3, ...]이 되어 "첫 번째 위치의 cat"을 나타냅니다. 여러분이 이 코드를 사용하면 "dog"이라는 단어가 문장 시작에 있을 때와 끝에 있을 때 다른 표현을 가지게 됩니다.
실무에서는 이를 통해 문법 구조, 상대적 위치, 거리 정보 등을 모델이 학습할 수 있게 됩니다. 예를 들어 번역 모델은 "주어가 보통 문장 앞에 온다"는 패턴을 학습하게 됩니다.
실전 팁
💡 10000이라는 값은 경험적으로 결정된 것입니다. 너무 작으면 긴 시퀀스에서 주기가 반복되고, 너무 크면 짧은 거리 패턴을 포착하기 어렵습니다. 대부분의 경우 10000이 적절합니다.
💡 학습 가능한 위치 임베딩도 고려하세요. BERT는 learned positional embedding을 사용합니다. 최대 길이가 고정되어 있고 데이터가 충분하다면 이 방식이 더 나을 수 있습니다.
💡 상대적 위치 인코딩을 탐구하세요. 최신 연구에서는 절대 위치보다 상대 위치가 더 효과적인 경우가 많습니다. Transformer-XL, T5 등이 이를 사용합니다.
💡 위치 인코딩을 임베딩 층 직후에 추가하세요. 그 이후로는 모든 레이어가 위치 정보를 포함한 표현을 사용하게 됩니다.
💡 드롭아웃을 위치 인코딩 후에 적용하세요. x = x + pos_encoding 후 x = dropout(x)를 하면 과적합을 방지하면서 위치 정보를 보존합니다.
8. 전체 멀티헤드 어텐션 통합 - 완성된 구현
시작하며
여러분이 지금까지 배운 모든 조각들을 하나로 모을 시간입니다. Q/K/V 생성, 스케일드 닷 프로덕트 어텐션, 멀티헤드 분할, 마스킹, 출력 결합까지 - 이 모든 것이 어떻게 함께 작동하는지 완전한 그림을 그려봅시다.
실제로 코드를 작성할 때는 각 부분이 올바르게 연결되어야 합니다. 차원 불일치, 순서 오류, 마스크 적용 시점 등 놓치기 쉬운 부분이 많습니다.
제대로 작동하는 완전한 구현을 보면 이런 세부사항을 이해할 수 있습니다. 이번 섹션에서는 실제로 사용 가능한 전체 MultiHeadAttention 클래스를 구현하고, 간단한 테스트까지 진행해봅니다.
이를 통해 여러분은 직접 트랜스포머를 구축할 수 있는 능력을 갖추게 됩니다.
개요
간단히 말해서, 완전한 멀티헤드 어텐션은 이전에 배운 모든 구성 요소를 올바른 순서로 조합한 클래스입니다. 입력을 받아 문맥이 반영된 출력을 생성하는 완전한 모듈입니다.
왜 이 통합이 필요한지 실무 관점에서 설명하자면, 각 부분을 따로 이해하는 것과 전체를 구현하는 것은 다른 문제이기 때문입니다. 예를 들어, 인코더와 디코더에서 마스크를 다르게 적용해야 하고, 배치 처리를 고려해야 하며, 메모리 효율성도 생각해야 합니다.
기존 단편적인 코드들을 실제로 작동하는 하나의 클래스로 통합하면, PyTorch나 TensorFlow의 공식 구현과 비교할 수 있고, 실제 프로젝트에 적용할 수 있습니다. 또한 디버깅과 수정이 훨씬 쉬워집니다.
핵심 특징은 첫째, 모든 메서드가 하나의 클래스로 캡슐화되어 있다는 점입니다. 둘째, forward 메서드 하나로 전체 연산을 수행합니다.
셋째, 옵션 파라미터(mask 등)를 통해 다양한 사용 사례를 지원합니다. 이러한 설계가 중요한 이유는 재사용성과 확장성을 제공하기 때문입니다.
이 클래스를 여러 층 쌓아서 트랜스포머 인코더를 만들고, 디코더를 만들고, 최종적으로 GPT나 BERT 같은 모델을 구축할 수 있습니다.
코드 예제
class MultiHeadAttention:
def __init__(self, d_model, num_heads):
assert d_model % num_heads == 0, "d_model must be divisible by num_heads"
self.d_model = d_model
self.num_heads = num_heads
self.depth = d_model // num_heads
# 가중치 초기화 (Xavier 초기화)
self.W_q = np.random.randn(d_model, d_model) * np.sqrt(2.0 / d_model)
self.W_k = np.random.randn(d_model, d_model) * np.sqrt(2.0 / d_model)
self.W_v = np.random.randn(d_model, d_model) * np.sqrt(2.0 / d_model)
self.W_o = np.random.randn(d_model, d_model) * np.sqrt(2.0 / d_model)
def forward(self, x, mask=None):
batch_size = x.shape[0]
# 1. Q, K, V 생성 및 멀티헤드 분할
Q = np.dot(x, self.W_q)
K = np.dot(x, self.W_k)
V = np.dot(x, self.W_v)
Q = self.split_heads(Q, batch_size)
K = self.split_heads(K, batch_size)
V = self.split_heads(V, batch_size)
# 2. 스케일드 닷 프로덕트 어텐션
attention_output, attention_weights = self.scaled_dot_product_attention(Q, K, V, mask)
# 3. 헤드 결합
combined = self.combine_heads(attention_output, batch_size)
# 4. 최종 선형 변환
output = np.dot(combined, self.W_o)
return output, attention_weights
# (split_heads, combine_heads, scaled_dot_product_attention, softmax 메서드들...)
설명
이것이 하는 일: 단어 임베딩을 입력받아 Q/K/V 변환, 어텐션 계산, 헤드 결합, 최종 변환을 순차적으로 수행하여 문맥 정보가 통합된 새로운 표현을 반환합니다. 첫 번째로, __init__에서 중요한 검증을 수행합니다.
assert d_model % num_heads == 0은 d_model이 num_heads로 나누어떨어지는지 확인합니다. 512를 8로 나누면 64로 깔끔하지만, 500을 8로 나누면 62.5가 되어 정수가 아닙니다.
이런 경우 reshape에서 오류가 발생하므로 미리 차단합니다. 가중치 초기화는 Xavier 방식을 사용하여 sqrt(2/d_model)을 곱합니다.
이는 각 층을 지날 때 분산이 유지되도록 하여 학습을 안정화시킵니다. 두 번째로, forward 메서드에서 전체 과정이 진행됩니다.
먼저 배치 크기를 추출합니다. 그 다음 x(batch_size, seq_len, d_model)와 각 가중치 행렬을 곱하여 Q, K, V를 생성합니다.
이 세 개는 모두 같은 shape이지만 내용은 다릅니다. split_heads를 호출하여 (batch, num_heads, seq_len, depth) 형태로 변환합니다.
세 번째로, 핵심인 어텐션 계산을 수행합니다. scaled_dot_product_attention은 이전에 구현한 메서드로, Q와 K의 내적, 스케일링, 마스킹, softmax, V와의 곱셈을 모두 처리합니다.
반환값은 어텐션 출력과 가중치입니다. 가중치를 함께 반환하는 이유는 시각화나 디버깅에 유용하기 때문입니다.
네 번째로, combine_heads로 모든 헤드의 출력을 연결합니다. (batch, num_heads, seq_len, depth)가 (batch, seq_len, d_model)로 변환됩니다.
마지막으로 W_o와 곱하여 최종 출력을 만듭니다. 이 출력은 입력과 같은 shape이므로 residual connection(output + x)을 쉽게 적용할 수 있습니다.
전체 파이프라인을 정리하면: 입력 임베딩 → Q/K/V 생성 → 헤드 분할 → 각 헤드에서 어텐션 → 헤드 결합 → 최종 변환 → 출력. 이 과정에서 각 단어는 주변 단어들의 정보를 가중 평균하여 문맥이 반영된 새로운 표현을 얻게 됩니다.
여러분이 이 클래스를 사용하면 mha = MultiHeadAttention(512, 8) 후 output, weights = mha.forward(x, mask) 한 줄로 전체 멀티헤드 어텐션을 실행할 수 있습니다. 실무에서는 이를 기반으로 레이어 정규화, 드롭아웃, residual connection을 추가하여 완전한 트랜스포머 블록을 만듭니다.
그리고 이 블록을 6개 또는 12개 쌓아서 GPT-3 같은 대규모 언어 모델의 기초를 만듭니다.
실전 팁
💡 assert 문으로 입력을 검증하세요. d_model과 num_heads의 관계뿐만 아니라 입력 shape도 검증하면 디버깅이 쉬워집니다. assert len(x.shape) == 3 같은 체크를 추가하세요.
💡 중간 결과를 저장하여 디버깅하세요. 개발 중에는 self.Q = Q, self.attention_weights = attention_weights 같이 저장해두면 각 단계를 검사할 수 있습니다.
💡 테스트 케이스를 작성하세요. 작은 입력(예: batch=1, seq_len=3, d_model=8, heads=2)으로 수동 계산한 결과와 비교하면 구현이 정확한지 확인할 수 있습니다.
💡 PyTorch로 전환할 준비를 하세요. NumPy로 개념을 이해했다면, 같은 구조를 torch.nn.Module로 재구현하세요. nn.Linear를 사용하면 더 간단합니다.
💡 프로파일링으로 병목을 찾으세요. NumPy는 느릴 수 있으므로 time.time()으로 각 단계의 시간을 측정하세요. 보통 행렬 곱셈이 대부분의 시간을 차지합니다.