이미지 로딩 중...
AI Generated
2025. 11. 8. · 3 Views
LLM 구현 6편 - Position Encoding과 Embedding
Transformer 기반 LLM의 핵심 구성 요소인 Position Encoding과 Embedding을 실무 관점에서 깊이 있게 다룹니다. 실제 구현 코드와 함께 왜 이 기술들이 필요한지, 어떻게 동작하는지 상세히 알아봅니다.
목차
- Token Embedding
- Position Encoding 기본 개념
- Sinusoidal Position Encoding
- Learned Position Embedding
- Rotary Position Embedding (RoPE)
- Embedding Layer 구현
- Position Encoding 시각화
- Absolute vs Relative Positioning
1. Token Embedding
시작하며
여러분이 "안녕하세요"라는 문장을 LLM에 입력했을 때, 모델은 이것을 어떻게 이해할까요? 컴퓨터는 텍스트를 직접 처리할 수 없습니다.
바로 이 문제를 해결하는 것이 Token Embedding입니다. 텍스트를 숫자 벡터로 변환하여 신경망이 처리할 수 있게 만드는 첫 번째 단계죠.
사실 이 과정이 없다면 LLM은 아무것도 할 수 없습니다. 모든 자연어 처리의 시작점이 바로 여기에 있습니다.
개요
간단히 말해서, Token Embedding은 단어나 서브워드를 고정된 크기의 밀집 벡터로 변환하는 과정입니다. 예를 들어, 어휘 크기가 50,000개이고 임베딩 차원이 512라면, 각 토큰은 512차원의 실수 벡터로 표현됩니다.
GPT-3의 경우 12,288차원을 사용하여 더욱 풍부한 의미 정보를 담습니다. 기존 원-핫 인코딩은 어휘 크기만큼의 희소 벡터를 만들었다면, 임베딩은 훨씬 작은 차원의 밀집 벡터로 의미를 압축합니다.
핵심은 학습을 통해 유사한 의미의 단어들이 벡터 공간에서 가까이 위치하게 된다는 것입니다. "왕"과 "여왕"의 벡터가 유사한 영역에 모이는 것처럼요.
이러한 속성이 모델이 의미를 이해하는 기반이 됩니다.
코드 예제
import torch
import torch.nn as nn
# 어휘 크기 50000, 임베딩 차원 512
vocab_size = 50000
embedding_dim = 512
# Embedding Layer 생성
token_embedding = nn.Embedding(vocab_size, embedding_dim)
# 토큰 ID 배열 (배치 크기 2, 시퀀스 길이 10)
token_ids = torch.randint(0, vocab_size, (2, 10))
# 임베딩 변환: [2, 10] -> [2, 10, 512]
embedded = token_embedding(token_ids)
print(f"입력 shape: {token_ids.shape}")
print(f"출력 shape: {embedded.shape}") # torch.Size([2, 10, 512])
설명
이것이 하는 일: Token Embedding은 각 토큰 ID를 조회 테이블(lookup table)처럼 사용하여 해당하는 벡터를 가져옵니다. 첫 번째로, nn.Embedding 레이어를 생성할 때 vocab_size × embedding_dim 크기의 가중치 행렬이 만들어집니다.
이 행렬의 각 행이 하나의 토큰을 표현하는 벡터입니다. 초기에는 랜덤 값으로 시작하지만, 학습을 통해 의미 있는 표현을 학습합니다.
그 다음으로, token_ids를 입력하면 각 ID에 해당하는 행을 가중치 행렬에서 선택합니다. 예를 들어 ID가 [5, 100, 230]이라면, 가중치 행렬의 5번째, 100번째, 230번째 행을 가져와 쌓습니다.
이 과정은 매우 효율적으로 GPU에서 병렬 처리됩니다. 마지막으로, 출력된 임베딩 벡터는 다음 레이어로 전달됩니다.
역전파 과정에서 이 임베딩 가중치도 함께 업데이트되어, 점점 더 의미 있는 표현을 학습하게 됩니다. 여러분이 이 코드를 사용하면 어떤 텍스트든 신경망이 처리할 수 있는 숫자 형태로 변환할 수 있습니다.
특히 대규모 코퍼스로 사전 학습된 임베딩은 단어 간 유사도, 유추 관계 등 풍부한 의미 정보를 담고 있어, 다양한 다운스트림 태스크에서 강력한 초기 표현으로 활용됩니다.
실전 팁
💡 임베딩 차원은 모델 크기와 성능의 트레이드오프입니다. 작은 모델은 128-256, 중간 모델은 512-768, 대형 LLM은 1024-12288을 사용합니다.
💡 padding_idx 파라미터를 설정하면 패딩 토큰의 그래디언트를 0으로 만들어 학습에서 제외할 수 있습니다: nn.Embedding(vocab_size, dim, padding_idx=0)
💡 사전 학습된 임베딩(Word2Vec, GloVe)을 초기값으로 사용하려면 embedding.weight.data.copy_(pretrained_vectors)로 로드하세요.
💡 메모리 효율을 위해 sparse=True 옵션을 사용하면 희소 그래디언트 업데이트로 학습 속도를 높일 수 있습니다.
💡 임베딩 벡터를 정규화(L2 norm)하면 코사인 유사도 계산이 내적으로 단순해져 추론 속도가 빨라집니다.
2. Position Encoding 기본 개념
시작하며
여러분이 "나는 사과를 먹었다"와 "사과를 나는 먹었다"를 LLM에 입력한다고 생각해보세요. 단어는 같지만 순서가 다르면 의미도 달라질 수 있습니다.
그런데 Transformer는 Self-Attention 메커니즘으로 인해 단어의 순서 정보를 자연스럽게 인식하지 못합니다. 모든 토큰을 동시에 처리하기 때문이죠.
바로 이 문제를 해결하기 위해 등장한 것이 Position Encoding입니다. 각 토큰의 위치 정보를 벡터로 만들어 임베딩에 더해주는 것이죠.
개요
간단히 말해서, Position Encoding은 시퀀스 내에서 각 토큰의 위치를 나타내는 벡터를 생성하는 기법입니다. Transformer는 RNN과 달리 순차적으로 처리하지 않기 때문에, 명시적으로 위치 정보를 주입해야 합니다.
"첫 번째 단어", "다섯 번째 단어" 같은 정보를 모델이 알 수 있게 하는 것이죠. 이것이 없다면 "I love you"와 "you love I"를 구분할 수 없습니다.
기존 RNN은 순서대로 처리하며 자연스럽게 위치를 인식했다면, Transformer는 병렬 처리를 위해 위치 정보를 별도로 인코딩합니다. 핵심은 두 가지입니다.
첫째, 각 위치마다 고유한 벡터를 생성해야 합니다. 둘째, 위치 간의 상대적 거리도 표현할 수 있어야 합니다.
예를 들어 "3번째와 5번째는 2칸 떨어져 있다"는 정보도 담아야 효과적입니다.
코드 예제
import torch
import math
def get_position_encoding(seq_len, d_model):
# 위치 인덱스 생성: [0, 1, 2, ..., seq_len-1]
position = torch.arange(seq_len).unsqueeze(1) # [seq_len, 1]
# 차원 인덱스 생성
div_term = torch.exp(torch.arange(0, d_model, 2) *
-(math.log(10000.0) / d_model))
# Position Encoding 행렬 생성
pe = torch.zeros(seq_len, d_model)
pe[:, 0::2] = torch.sin(position * div_term) # 짝수 인덱스
pe[:, 1::2] = torch.cos(position * div_term) # 홀수 인덱스
return pe # [seq_len, d_model]
# 시퀀스 길이 100, 모델 차원 512
pos_encoding = get_position_encoding(100, 512)
print(f"Position Encoding shape: {pos_encoding.shape}")
설명
이것이 하는 일: Position Encoding은 각 위치에 대해 sin과 cos 함수를 사용하여 고유한 패턴의 벡터를 생성합니다. 첫 번째로, position 텐서는 [0, 1, 2, ..., seq_len-1]의 위치 인덱스를 만듭니다.
unsqueeze(1)로 차원을 추가하여 브로드캐스팅이 가능하게 만들죠. 이 인덱스가 각 토큰의 절대적 위치를 나타냅니다.
그 다음으로, div_term을 계산합니다. 이것은 각 임베딩 차원마다 다른 주파수를 할당하는 역할입니다.
낮은 차원은 빠르게 변하고, 높은 차원은 천천히 변하는 삼각함수를 만들어냅니다. 10000이라는 값은 원 논문에서 실험적으로 정한 스케일링 상수입니다.
마지막으로, 짝수 인덱스에는 sin 함수를, 홀수 인덱스에는 cos 함수를 적용합니다. 이렇게 하면 각 위치는 고유한 "지문"을 가지게 되며, 인접한 위치들은 유사한 패턴을 보입니다.
이 속성이 모델이 상대적 위치 관계를 학습하는 데 도움을 줍니다. 여러분이 이 코드를 사용하면 임의 길이의 시퀀스에 대해 위치 정보를 인코딩할 수 있습니다.
학습 없이 고정된 함수로 생성되므로 메모리 효율적이며, 학습 시 본 적 없는 긴 시퀀스에도 일반화가 가능합니다. 또한 삼각함수의 주기성으로 인해 상대적 위치 관계를 선형 변환으로 표현할 수 있다는 수학적 장점도 있습니다.
실전 팁
💡 Position Encoding은 학습되지 않는 고정값이므로 register_buffer()로 등록하면 모델 저장 시 함께 저장되면서도 그래디언트 계산에서 제외됩니다.
💡 긴 시퀀스를 다룰 때는 max_len을 충분히 크게 설정하되, 실제 사용 시에는 [:seq_len]로 슬라이싱하여 필요한 만큼만 사용하세요.
💡 Sinusoidal 방식은 외삽(extrapolation)이 가능해 학습 시보다 긴 시퀀스도 처리할 수 있지만, Learned 방식은 max_len을 넘는 시퀀스는 처리 불가합니다.
💡 임베딩에 더하기 전에 sqrt(d_model)로 스케일링하는 것이 일반적입니다: embedded = token_emb * math.sqrt(d_model) + pos_enc
💡 디버깅 시 pos_encoding[0]과 pos_encoding[1]의 코사인 유사도를 확인하면 인접 위치가 유사한지 검증할 수 있습니다.
3. Sinusoidal Position Encoding
시작하며
여러분이 LLM을 학습시킬 때, 최대 512 토큰까지만 학습했는데 추론 시에 1024 토큰짜리 텍스트가 들어온다면 어떻게 될까요? Learned Position Embedding을 사용했다면 512번째 이후의 위치 벡터가 없어서 문제가 생깁니다.
하지만 Sinusoidal 방식은 이 문제를 우아하게 해결합니다. 삼각함수의 수학적 특성을 활용하여, 임의의 위치에 대해서도 일관된 인코딩을 생성할 수 있기 때문이죠.
원 Transformer 논문에서 제안된 이 방식은 지금도 많은 모델에서 사용됩니다.
개요
간단히 말해서, Sinusoidal Position Encoding은 sin과 cos 함수를 사용하여 각 위치를 고유하게 표현하는 방식입니다. 수식으로 표현하면 PE(pos, 2i) = sin(pos / 10000^(2i/d_model)), PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))입니다.
여기서 pos는 위치, i는 차원 인덱스입니다. 이 방식은 학습이 필요 없고 결정론적으로 계산됩니다.
기존 단순한 위치 인덱스(1, 2, 3, ...)를 사용하면 숫자가 계속 커져서 모델이 학습하기 어렵다면, 삼각함수는 -1에서 1 사이의 값으로 정규화됩니다. 핵심 특징은 세 가지입니다.
첫째, 각 차원마다 다른 주파수의 파동을 사용합니다. 낮은 차원은 빠르게, 높은 차원은 천천히 변합니다.
둘째, sin과 cos의 조합으로 위치 간 상대적 관계를 선형 변환으로 표현 가능합니다. 셋째, 학습 없이 무한한 길이로 확장 가능합니다.
이런 속성들이 Transformer의 성공에 큰 기여를 했습니다.
코드 예제
import torch
import math
import matplotlib.pyplot as plt
class SinusoidalPositionEncoding(torch.nn.Module):
def __init__(self, d_model, max_len=5000):
super().__init__()
# Position Encoding 미리 계산
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len).unsqueeze(1).float()
# 각 차원마다 다른 주파수 계산
div_term = torch.exp(torch.arange(0, d_model, 2).float() *
-(math.log(10000.0) / d_model))
# sin, cos 적용
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
# [max_len, d_model] -> [1, max_len, d_model]
pe = pe.unsqueeze(0)
self.register_buffer('pe', pe)
def forward(self, x):
# x: [batch, seq_len, d_model]
return x + self.pe[:, :x.size(1)]
# 사용 예시
pos_enc = SinusoidalPositionEncoding(d_model=512, max_len=1000)
x = torch.randn(2, 100, 512) # 배치 2, 시퀀스 100
output = pos_enc(x)
print(f"Output shape: {output.shape}")
설명
이것이 하는 일: Sinusoidal Position Encoding은 삼각함수의 주기성과 위상 차이를 활용하여 각 위치에 고유한 패턴을 부여합니다. 첫 번째로, __init__에서 최대 길이만큼의 Position Encoding을 미리 계산하여 버퍼에 저장합니다.
div_term 계산에서 exp와 log를 사용하는 이유는 기하급수적으로 감소하는 주파수를 만들기 위함입니다. 0번째 차원은 매 위치마다 크게 변하고, 마지막 차원은 거의 변하지 않는 패턴을 만듭니다.
그 다음으로, 짝수 차원에는 sin을, 홀수 차원에는 cos를 적용합니다. 이렇게 하는 이유는 수학적으로 흥미로운데, sin(A+B) = sin(A)cos(B) + cos(A)sin(B) 공식에 의해 위치 k+δ의 인코딩을 위치 k의 인코딩의 선형 변환으로 표현할 수 있습니다.
이것이 모델이 상대적 위치를 학습하는 데 도움을 줍니다. 마지막으로, forward에서는 입력 임베딩 x에 해당 길이만큼의 Position Encoding을 더합니다.
[:, :x.size(1)]로 슬라이싱하여 실제 시퀀스 길이만큼만 사용하므로, max_len보다 짧은 시퀀스도 효율적으로 처리됩니다. 여러분이 이 코드를 사용하면 학습 데이터보다 긴 시퀀스도 안정적으로 처리할 수 있습니다.
예를 들어 512 토큰으로 학습했지만 1024 토큰 추론도 가능합니다. 또한 register_buffer로 등록했기 때문에 모델을 GPU로 옮기면 자동으로 따라가며, 체크포인트 저장 시에도 포함되지만 옵티마이저는 업데이트하지 않습니다.
계산 비용도 초기화 시 한 번만 발생하므로 매우 효율적입니다.
실전 팁
💡 d_model은 반드시 짝수여야 합니다. 홀수면 마지막 차원이 sin만 적용되거나 cos만 적용되어 불균형이 생깁니다.
💡 10000이라는 상수는 하이퍼파라미터입니다. 더 긴 시퀀스를 다룬다면 100000 등 더 큰 값을 시도해볼 수 있습니다.
💡 Position Encoding을 시각화하면 저차원은 세로 줄무늬(빠른 변화), 고차원은 가로 줄무늬(느린 변화) 패턴을 보입니다: plt.imshow(pe.squeeze().T)
💡 ALiBi, RoPE 같은 최신 기법과 비교하면 Sinusoidal은 더 간단하지만 초장거리 의존성 포착에는 한계가 있습니다.
💡 Dropout을 Position Encoding 직후에 적용하면 정규화 효과가 있습니다: self.dropout = nn.Dropout(0.1)
4. Learned Position Embedding
시작하며
여러분은 혹시 "고정된 수식보다 학습으로 더 나은 표현을 찾을 수 있지 않을까?"라고 생각해본 적 있나요? Sinusoidal Position Encoding은 수학적으로 우아하지만, 데이터에서 최적의 위치 표현을 학습할 기회를 놓칩니다.
BERT, GPT 같은 많은 유명 모델들이 선택한 방식이 바로 Learned Position Embedding입니다. Token Embedding처럼 위치도 학습 가능한 파라미터로 만들어, 역전파를 통해 태스크에 최적화된 위치 표현을 찾는 것이죠.
개요
간단히 말해서, Learned Position Embedding은 각 위치를 나타내는 벡터를 학습 가능한 파라미터로 만드는 방식입니다. nn.Embedding을 사용하여 max_sequence_length × d_model 크기의 가중치 행렬을 생성하고, 각 위치 인덱스로 해당 벡터를 조회합니다.
BERT는 512개 위치를, GPT-3는 2048개 위치를 학습 가능한 임베딩으로 처리합니다. Sinusoidal 방식이 수학 함수로 고정된 패턴을 사용한다면, Learned 방식은 데이터로부터 패턴을 학습합니다.
초기에는 랜덤이지만, 학습이 진행되며 태스크에 유용한 위치 표현을 찾아냅니다. 핵심 장점은 데이터 의존적 최적화입니다.
특정 도메인(예: 코드, 대화, 문서)에서 위치가 가지는 의미를 반영할 수 있습니다. 예를 들어 대화 데이터에서는 첫 문장과 마지막 문장이 특별한 의미를 가질 수 있고, 이를 학습으로 포착할 수 있습니다.
단점은 max_len을 넘는 시퀀스를 처리할 수 없다는 것입니다.
코드 예제
import torch
import torch.nn as nn
class LearnedPositionEmbedding(nn.Module):
def __init__(self, max_len, d_model):
super().__init__()
# 학습 가능한 Position Embedding
# max_len개의 위치, 각각 d_model 차원 벡터
self.position_embedding = nn.Embedding(max_len, d_model)
self.max_len = max_len
def forward(self, x):
# x: [batch_size, seq_len, d_model]
batch_size, seq_len, d_model = x.shape
# 위치 인덱스 생성: [0, 1, 2, ..., seq_len-1]
positions = torch.arange(seq_len, device=x.device) # [seq_len]
# Position Embedding 조회
pos_emb = self.position_embedding(positions) # [seq_len, d_model]
# 브로드캐스팅하여 더하기
return x + pos_emb.unsqueeze(0) # [batch, seq_len, d_model]
# 사용 예시
model = LearnedPositionEmbedding(max_len=512, d_model=768)
x = torch.randn(4, 128, 768) # 배치 4, 시퀀스 128
output = model(x)
print(f"파라미터 수: {sum(p.numel() for p in model.parameters())}") # 512 * 768
설명
이것이 하는 일: Learned Position Embedding은 Token Embedding과 동일한 메커니즘으로 위치를 벡터화하되, 위치 인덱스로 조회한다는 점만 다릅니다. 첫 번째로, __init__에서 nn.Embedding(max_len, d_model)을 생성합니다.
이것은 max_len × d_model 크기의 가중치 행렬이며, 각 행이 하나의 위치를 표현합니다. 초기화는 기본적으로 정규 분포에서 샘플링되며, requires_grad=True로 설정되어 학습됩니다.
그 다음으로, forward에서 torch.arange(seq_len)으로 [0, 1, 2, ..., seq_len-1]의 위치 인덱스를 생성합니다. device=x.device로 입력과 같은 디바이스에 생성하는 것이 중요합니다.
GPU 텐서와 CPU 텐서를 섞으면 에러가 발생하기 때문이죠. 마지막으로, position_embedding(positions)로 각 위치의 벡터를 조회하고, unsqueeze(0)로 배치 차원을 추가한 뒤 브로드캐스팅으로 더합니다.
역전파 시 이 임베딩 가중치도 업데이트되어, 점점 더 태스크에 유용한 위치 표현을 학습합니다. 여러분이 이 코드를 사용하면 특정 도메인에 특화된 위치 정보를 학습할 수 있습니다.
BERT가 MLM 태스크에서, GPT가 autoregressive 생성에서 각각 다른 위치 패턴을 학습하는 것처럼요. 파라미터 수가 늘어나는 단점이 있지만(512 × 768 = 393,216개), 전체 모델 크기에 비하면 미미합니다.
학습 초기에는 Sinusoidal보다 성능이 낮을 수 있지만, 충분한 데이터와 학습으로 더 나은 표현을 찾는 경우가 많습니다.
실전 팁
💡 Position Embedding도 Token Embedding처럼 초기화 전략이 중요합니다. Xavier 또는 He 초기화를 시도해보세요: nn.init.xavier_uniform_(self.position_embedding.weight)
💡 max_len보다 긴 시퀀스를 처리하려면 외삽 기법이 필요합니다. 선형 보간, 마지막 위치 재사용 등의 방법이 있지만 성능 저하는 피할 수 없습니다.
💡 학습된 Position Embedding을 시각화하면 Sinusoidal과 달리 불규칙한 패턴을 보입니다. 이것이 데이터 의존적 학습의 증거입니다.
💡 Warmup 단계에서는 Position Embedding의 학습률을 낮추는 것이 안정적입니다. 초기에 너무 빠르게 변하면 Token Embedding 학습에 방해가 됩니다.
💡 BERT는 학습된 방식을, RoBERTa는 일부 변형을, GPT는 학습된 방식을 사용합니다. 모델 선택 시 참고하세요.
5. Rotary Position Embedding (RoPE)
시작하며
여러분이 최신 LLM 논문을 읽다 보면 "RoPE"라는 용어를 자주 접하게 됩니다. LLaMA, PaLM, GPT-NeoX 같은 최신 모델들이 모두 채택한 이 기법은 무엇일까요?
기존 Position Encoding의 한계를 극복하고, 상대적 위치 관계를 더 직접적으로 표현하는 혁신적인 방법입니다. Absolute position이 아닌 relative position을 회전 변환으로 인코딩한다는 독특한 아이디어죠.
특히 긴 문맥을 다루는 LLM에서 성능 향상이 두드러져, 2021년 이후 표준처럼 자리잡았습니다.
개요
간단히 말해서, RoPE(Rotary Position Embedding)는 복소 평면에서의 회전을 이용해 위치 정보를 쿼리와 키 벡터에 직접 인코딩하는 방식입니다. 핵심 아이디어는 위치 m의 벡터를 mθ만큼 회전시키는 것입니다.
두 벡터 간의 내적을 계산하면, 회전각의 차이인 (m-n)θ가 남아 상대적 위치만 반영됩니다. 이것이 Self-Attention에서 "몇 칸 떨어져 있는가"를 자연스럽게 인코딩합니다.
기존 Sinusoidal은 임베딩에 더했다면, RoPE는 Attention의 Q, K 벡터에 직접 적용합니다. Learned 방식이 절대 위치를 학습했다면, RoPE는 상대 위치를 기하학적으로 표현합니다.
RoPE의 장점은 여러 가지입니다. 첫째, 외삽 성능이 우수하여 긴 시퀀스에 강합니다.
둘째, 선형 복잡도로 효율적입니다. 셋째, 수학적으로 상대 위치를 정확히 인코딩합니다.
넷째, 추가 파라미터가 없습니다. 이런 이유로 100B+ 규모의 LLM들이 RoPE를 선호합니다.
코드 예제
import torch
import torch.nn as nn
def precompute_freqs_cis(dim, max_len, theta=10000.0):
# 주파수 계산
freqs = 1.0 / (theta ** (torch.arange(0, dim, 2).float() / dim))
# 위치 인덱스
t = torch.arange(max_len)
# 외적으로 모든 조합 생성: [max_len, dim//2]
freqs = torch.outer(t, freqs).float()
# 복소수로 변환 (cos + i*sin = e^(i*theta))
freqs_cis = torch.polar(torch.ones_like(freqs), freqs)
return freqs_cis
def apply_rotary_emb(x, freqs_cis):
# x: [batch, seq_len, n_heads, head_dim]
# 복소수로 변환하여 회전 적용
x_complex = torch.view_as_complex(x.float().reshape(*x.shape[:-1], -1, 2))
# 주파수와 곱하기 (회전)
freqs_cis = freqs_cis[:x.shape[1]].unsqueeze(0).unsqueeze(2)
x_rotated = x_complex * freqs_cis
# 다시 실수로 변환
x_out = torch.view_as_real(x_rotated).flatten(-2)
return x_out.type_as(x)
# 사용 예시
dim = 64 # head dimension
freqs = precompute_freqs_cis(dim, max_len=2048)
q = torch.randn(2, 512, 8, 64) # [batch, seq, heads, dim]
q_rotated = apply_rotary_emb(q, freqs)
설명
이것이 하는 일: RoPE는 각 차원 쌍을 복소수로 보고, 위치에 비례하는 각도만큼 회전시켜 위치 정보를 인코딩합니다. 첫 번째로, precompute_freqs_cis에서 각 차원 쌍마다 다른 주파수를 계산합니다.
Sinusoidal과 유사하게 기하급수적으로 감소하는 주파수를 사용하지만, 여기서는 회전각으로 해석됩니다. torch.outer로 위치와 주파수의 모든 조합을 만들어, 각 (위치, 차원) 쌍에 대한 회전각을 얻습니다.
그 다음으로, torch.polar로 복소수 e^(iθ)를 생성합니다. 이것이 회전 변환의 핵심입니다.
복소수 곱셈은 기하학적으로 회전을 의미하므로, x * e^(imθ)는 x를 mθ만큼 회전시킵니다. 두 벡터의 내적 계산 시 <x_m, x_n> = <x * e^(imθ), x * e^(inθ)> = <x, x> * e^(i(m-n)θ)가 되어, 상대 위치 m-n만 남습니다.
마지막으로, apply_rotary_emb에서 실제 회전을 적용합니다. 실수 벡터를 복소수로 변환(연속된 두 차원을 실부/허부로)하고, 미리 계산한 회전 복소수와 곱한 뒤, 다시 실수로 변환합니다.
이 과정은 수학적으로는 회전 행렬 곱셈과 동일하지만, 복소수 연산으로 더 효율적입니다. 여러분이 이 코드를 사용하면 LLaMA 같은 최신 LLM과 동일한 위치 인코딩을 구현할 수 있습니다.
특히 긴 문맥(4k, 8k 토큰 이상)에서 Sinusoidal이나 Learned 방식보다 성능이 우수합니다. 추가 파라미터가 없어 메모리 효율적이며, 사전 계산이 가능해 추론 속도도 빠릅니다.
Attention의 Q, K에만 적용하고 V에는 적용하지 않는 것도 특징입니다.
실전 팁
💡 RoPE는 head_dim이 짝수여야 합니다. 홀수면 마지막 차원을 회전시킬 쌍이 없어 에러가 발생합니다.
💡 긴 시퀀스로 외삽할 때는 base 값(여기서는 10000)을 늘리면 성능이 향상됩니다. LLaMA-2는 더 긴 컨텍스트를 위해 base를 조정했습니다.
💡 freqs_cis는 학습되지 않으므로 register_buffer로 등록하여 모델과 함께 저장하되 옵티마이저에서 제외하세요.
💡 Flash Attention과 결합하면 메모리와 속도 모두 최적화됩니다. RoPE는 Flash Attention 2에서 네이티브로 지원됩니다.
💡 디버깅 시 회전 전후의 벡터 노름이 보존되는지 확인하세요: torch.norm(q) == torch.norm(q_rotated). 회전은 길이를 보존해야 합니다.
6. Embedding Layer 구현
시작하며
여러분이 직접 Transformer를 구현한다면, Token Embedding과 Position Encoding을 어떻게 결합해야 할까요? 단순히 두 개를 더하면 될까요?
스케일링은 필요 없을까요? Dropout은 어디에 적용해야 할까요?
이런 실무적 질문들이 생깁니다. 이번 섹션에서는 실제 프로덕션 레벨의 Embedding Layer를 구현하며, 각 선택의 이유를 이해해봅시다.
개요
간단히 말해서, Embedding Layer는 Token Embedding과 Position Encoding을 결합하고, 정규화와 드롭아웃을 적용하는 모듈입니다. BERT, GPT 같은 실제 모델들은 단순히 더하기만 하는 것이 아니라, 스케일링, Layer Normalization, Dropout 등 여러 기법을 조합합니다.
예를 들어 Transformer 원 논문은 임베딩에 sqrt(d_model)을 곱하여 스케일을 조정합니다. 기본 구현이 nn.Embedding + position vector였다면, 프로덕션 구현은 안정성과 일반화를 위한 여러 장치를 포함합니다.
핵심 구성 요소는 다섯 가지입니다. 첫째, Token Embedding으로 단어를 벡터화합니다.
둘째, Position Encoding으로 위치 정보를 추가합니다. 셋째, 스케일링으로 그래디언트 크기를 조절합니다.
넷째, Dropout으로 과적합을 방지합니다. 다섯째, Layer Norm으로 안정적인 학습을 돕습니다.
이런 요소들의 조합이 robust한 임베딩 레이어를 만듭니다.
코드 예제
import torch
import torch.nn as nn
import math
class TransformerEmbedding(nn.Module):
def __init__(self, vocab_size, d_model, max_len, dropout=0.1, padding_idx=0):
super().__init__()
self.d_model = d_model
# Token Embedding
self.token_emb = nn.Embedding(vocab_size, d_model, padding_idx=padding_idx)
# Position Encoding (Learned 방식)
self.pos_emb = nn.Embedding(max_len, d_model)
# Dropout과 정규화
self.dropout = nn.Dropout(dropout)
self.layer_norm = nn.LayerNorm(d_model)
def forward(self, x):
# x: [batch_size, seq_len]
seq_len = x.size(1)
# Token Embedding with scaling
token_emb = self.token_emb(x) * math.sqrt(self.d_model)
# Position Embedding
positions = torch.arange(seq_len, device=x.device).unsqueeze(0)
pos_emb = self.pos_emb(positions)
# 결합
embeddings = token_emb + pos_emb
# 정규화 및 Dropout
embeddings = self.layer_norm(embeddings)
embeddings = self.dropout(embeddings)
return embeddings
# 사용 예시
embedding = TransformerEmbedding(vocab_size=30000, d_model=512, max_len=512)
input_ids = torch.randint(0, 30000, (4, 128)) # [batch=4, seq=128]
output = embedding(input_ids)
print(f"Output shape: {output.shape}") # [4, 128, 512]
설명
이것이 하는 일: TransformerEmbedding은 입력 토큰 ID를 받아 위치 정보가 포함된 정규화된 임베딩 벡터로 변환합니다. 첫 번째로, Token Embedding을 생성하고 sqrt(d_model)로 스케일링합니다.
이 스케일링이 필요한 이유는 Position Encoding과 더했을 때 임베딩의 정보가 희석되는 것을 방지하기 위함입니다. 원 논문에서는 이것이 학습 초기 안정성에 도움이 된다고 보고했습니다.
padding_idx를 설정하여 패딩 토큰은 항상 0 벡터로 유지하고 그래디언트도 받지 않습니다. 그 다음으로, Position Embedding을 조회하여 token_emb와 더합니다.
더하기 연산은 간단하지만 효과적입니다. 수학적으로는 연결(concatenation)도 가능하지만, 더하기가 파라미터 수를 절약하면서도 성능이 우수합니다.
두 벡터가 같은 공간에서 상호작용하며 풍부한 표현을 만듭니다. 마지막으로, Layer Normalization과 Dropout을 적용합니다.
Layer Norm은 각 샘플의 특성들을 정규화하여 학습을 안정화시킵니다. Dropout은 랜덤하게 일부 차원을 0으로 만들어 과적합을 방지합니다.
이 순서(Norm → Dropout)는 Post-LN 방식으로, BERT가 사용한 구조입니다. GPT는 Pre-LN을 사용하기도 합니다.
여러분이 이 코드를 사용하면 상용 수준의 임베딩 레이어를 구축할 수 있습니다. 학습 시 그래디언트가 폭발하거나 소실되는 것을 방지하고, 테스트 시 일반화 성능을 높입니다.
padding_idx 설정으로 배치 내 길이가 다른 시퀀스도 효율적으로 처리할 수 있습니다. 전체 Transformer 모델의 첫 번째 레이어로, 이후 Self-Attention과 FFN 레이어로 전달됩니다.
실전 팁
💡 스케일링 위치가 중요합니다. sqrt(d_model)를 곱한 후 Position을 더해야 합니다. 순서를 바꾸면 효과가 달라집니다.
💡 Layer Norm의 위치는 모델 구조에 따라 다릅니다. BERT는 Post-LN(Embedding 후), GPT는 Pre-LN(Attention 전)을 선호합니다.
💡 대규모 모델에서는 Dropout 비율을 0.1~0.3으로 설정합니다. 작은 데이터셋에서는 0.5까지 높일 수도 있습니다.
💡 메모리 절약을 위해 Token과 출력 레이어의 가중치를 공유할 수 있습니다: output_layer.weight = token_emb.weight (weight tying)
💡 사전 학습된 임베딩(Word2Vec 등)을 로드하려면: self.token_emb.weight.data.copy_(pretrained_weights)로 초기화하세요.
7. Position Encoding 시각화
시작하며
여러분은 Position Encoding이 실제로 어떤 패턴을 만드는지 궁금하지 않으신가요? 수식만으로는 직관적으로 이해하기 어렵습니다.
시각화를 통해 보면, Sinusoidal Position Encoding의 아름다운 파동 패턴, 각 차원마다 다른 주파수, 위치 간 유사도 등을 눈으로 확인할 수 있습니다. 이번 섹션에서는 matplotlib으로 Position Encoding을 시각화하여 내부 구조를 깊이 이해해봅시다.
개요
간단히 말해서, Position Encoding 시각화는 위치와 차원에 따른 인코딩 값의 변화를 히트맵이나 그래프로 표현하는 것입니다. 가장 일반적인 시각화는 (위치, 차원) 평면에 인코딩 값을 색상으로 표시하는 히트맵입니다.
저차원은 세로 줄무늬(빠른 변화), 고차원은 가로 줄무늬(느린 변화) 패턴이 나타납니다. 이것이 "각 차원마다 다른 주파수"를 시각적으로 보여줍니다.
텍스트 설명만으로는 추상적이었던 개념이, 시각화를 통해 구체적인 이미지가 됩니다. 디버깅과 이해 모두에 유용합니다.
시각화의 이점은 여러 가지입니다. 첫째, 구현이 올바른지 즉시 확인할 수 있습니다.
둘째, Sinusoidal과 Learned의 차이를 직관적으로 비교할 수 있습니다. 셋째, 위치 간 유사도를 코사인 유사도 행렬로 분석할 수 있습니다.
넷째, 논문이나 발표 자료에 사용하여 설명력을 높일 수 있습니다.
코드 예제
import torch
import math
import matplotlib.pyplot as plt
import seaborn as sns
def visualize_position_encoding(seq_len=100, d_model=128):
# Sinusoidal Position Encoding 생성
pe = torch.zeros(seq_len, d_model)
position = torch.arange(0, seq_len).unsqueeze(1).float()
div_term = torch.exp(torch.arange(0, d_model, 2).float() *
-(math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
# 1. 히트맵 시각화
plt.figure(figsize=(15, 5))
plt.subplot(1, 3, 1)
plt.imshow(pe.T, aspect='auto', cmap='RdBu')
plt.colorbar()
plt.xlabel('Position')
plt.ylabel('Dimension')
plt.title('Position Encoding Heatmap')
# 2. 특정 차원의 변화
plt.subplot(1, 3, 2)
plt.plot(pe[:, 0], label='Dim 0 (fast)')
plt.plot(pe[:, d_model//2], label=f'Dim {d_model//2} (medium)')
plt.plot(pe[:, -1], label=f'Dim {d_model-1} (slow)')
plt.xlabel('Position')
plt.ylabel('Value')
plt.legend()
plt.title('Position Encoding by Dimension')
# 3. 위치 간 코사인 유사도
plt.subplot(1, 3, 3)
similarity = torch.mm(pe, pe.T) / (torch.norm(pe, dim=1, keepdim=True) @ torch.norm(pe, dim=1, keepdim=True).T)
plt.imshow(similarity, cmap='viridis')
plt.colorbar()
plt.xlabel('Position')
plt.ylabel('Position')
plt.title('Cosine Similarity between Positions')
plt.tight_layout()
plt.savefig('position_encoding_visualization.png', dpi=150)
plt.show()
visualize_position_encoding()
설명
이것이 하는 일: 이 코드는 Sinusoidal Position Encoding을 세 가지 관점에서 시각화합니다. 첫 번째로, 히트맵 시각화는 전체 인코딩 행렬을 한눈에 보여줍니다.
x축이 위치, y축이 차원, 색상이 인코딩 값입니다. 빨강은 양수, 파랑은 음수를 나타내며, -1에서 1 사이의 값이 분포합니다.
저차원(위쪽)은 빠르게 진동하는 세로 줄무늬, 고차원(아래쪽)은 천천히 변하는 가로 패턴을 보입니다. 이것이 "기하급수적으로 감소하는 주파수"의 시각적 증거입니다.
그 다음으로, 특정 차원의 변화 그래프는 sin/cos 함수를 직접 보여줍니다. 0번째 차원은 매우 빠르게 진동(높은 주파수), 중간 차원은 중간 속도, 마지막 차원은 거의 변하지 않습니다(낮은 주파수).
이런 다양한 주파수 조합이 각 위치를 고유하게 식별하는 "지문"을 만듭니다. 마지막으로, 코사인 유사도 행렬은 위치 간 관계를 보여줍니다.
대각선은 자기 자신과의 유사도(1.0)입니다. 대각선 근처는 밝은 색(높은 유사도)으로, 인접한 위치들이 유사한 인코딩을 가짐을 의미합니다.
멀어질수록 어두워지며, 이것이 상대적 거리 정보를 담고 있다는 증거입니다. 여러분이 이 코드를 실행하면 Position Encoding의 내부 구조를 완전히 이해할 수 있습니다.
구현한 코드가 올바른지 검증하는 데도 유용합니다. 예를 들어 히트맵에서 이상한 패턴이 보이면 구현에 버그가 있다는 신호입니다.
또한 Learned Position Embedding을 시각화하면 Sinusoidal과 달리 불규칙한 패턴을 보여, 학습된 표현의 특성을 확인할 수 있습니다.
실전 팁
💡 더 긴 시퀀스(seq_len=1000)와 더 높은 차원(d_model=512)으로 시각화하면 패턴이 더 명확해집니다.
💡 Learned Position Embedding과 Sinusoidal을 나란히 시각화하면 둘의 차이를 직접 비교할 수 있습니다.
💡 학습 중 주기적으로 Position Embedding을 시각화하면 학습 진행 상황을 모니터링할 수 있습니다.
💡 특정 위치(예: 0번, 10번, 50번)의 벡터를 추출하여 PCA나 t-SNE로 2D 투영하면 위치 공간 구조를 파악할 수 있습니다.
💡 FFT(Fast Fourier Transform)를 적용하면 각 차원의 주파수 성분을 정량적으로 분석할 수 있습니다.
8. Absolute vs Relative Positioning
시작하며
여러분이 "나는 어제 영화를 봤고, 그 영화는 재미있었다"라는 문장을 처리한다고 생각해보세요. "그 영화"가 무엇을 가리키는지 이해하려면 몇 단어 떨어져 있는지가 중요합니다.
Absolute Position Encoding은 "5번째 토큰", "7번째 토큰" 같은 절대적 위치를 인코딩합니다. 하지만 실제로 중요한 것은 "2칸 떨어져 있다"는 상대적 거리 아닐까요?
이런 통찰에서 Relative Position Encoding이 탄생했고, 최신 연구에서는 둘을 비교하며 각각의 장단점을 논의합니다.
개요
간단히 말해서, Absolute Positioning은 각 토큰의 절대적 위치를, Relative Positioning은 토큰 간 상대적 거리를 인코딩하는 방식입니다. Absolute 방식(Sinusoidal, Learned)은 "이것은 10번째 단어다"라는 정보를 줍니다.
Relative 방식(T5, XLNet의 방법)은 "이것은 저것으로부터 3칸 떨어져 있다"라는 정보를 줍니다. 문맥 이해에는 후자가 더 자연스러울 수 있습니다.
전통적인 Transformer는 Absolute 방식을 사용했다면, 최근 모델들(T5, DeBERTa)은 Relative 방식을 채택하여 성능 향상을 보고했습니다. 각각의 장단점은 명확합니다.
Absolute는 구현이 간단하고 계산이 효율적입니다. 문서의 시작, 끝 같은 절대적 위치가 중요한 태스크에 유리합니다.
Relative는 문맥 이해에 더 직접적이며, 시퀀스 길이 변화에 강합니다. 하지만 구현이 복잡하고 메모리가 더 필요합니다.
RoPE는 둘의 장점을 결합하려는 시도로 볼 수 있습니다.
코드 예제
import torch
import torch.nn as nn
# Absolute Position Encoding (Learned)
class AbsolutePositionEmbedding(nn.Module):
def __init__(self, max_len, d_model):
super().__init__()
self.pos_emb = nn.Embedding(max_len, d_model)
def forward(self, x):
positions = torch.arange(x.size(1), device=x.device)
return self.pos_emb(positions)
# Relative Position Encoding (간소화된 T5 스타일)
class RelativePositionBias(nn.Module):
def __init__(self, n_heads, max_distance=128):
super().__init__()
self.n_heads = n_heads
self.max_distance = max_distance
# 상대 거리별 bias 학습
self.relative_bias = nn.Embedding(2 * max_distance + 1, n_heads)
def forward(self, seq_len):
# 상대 거리 행렬 생성: [seq_len, seq_len]
positions = torch.arange(seq_len)
relative_positions = positions.unsqueeze(0) - positions.unsqueeze(1)
# clipping
relative_positions = torch.clamp(relative_positions,
-self.max_distance, self.max_distance)
relative_positions += self.max_distance # 양수로 변환
# bias 조회: [seq_len, seq_len, n_heads]
bias = self.relative_bias(relative_positions)
# [n_heads, seq_len, seq_len]로 변환
return bias.permute(2, 0, 1)
# 비교 예시
abs_pos = AbsolutePositionEmbedding(max_len=512, d_model=512)
rel_pos = RelativePositionBias(n_heads=8, max_distance=128)
x = torch.randn(2, 100, 512)
abs_encoding = abs_pos(x) # [100, 512]
rel_bias = rel_pos(100) # [8, 100, 100]
print(f"Absolute shape: {abs_encoding.shape}")
print(f"Relative shape: {rel_bias.shape}")
설명
이것이 하는 일: Absolute와 Relative Position Encoding의 구현 방식과 출력 형태를 직접 비교합니다. 첫 번째로, Absolute Position Embedding은 [0, 1, 2, ..., seq_len-1]의 인덱스로 nn.Embedding을 조회합니다.
결과는 [seq_len, d_model] 형태의 벡터로, 각 위치가 고유한 d_model 차원 벡터를 가집니다. 이것을 Token Embedding에 더하여 사용하며, 모든 Attention Head가 같은 위치 정보를 공유합니다.
그 다음으로, Relative Position Bias는 먼저 상대 거리 행렬을 만듭니다. positions.unsqueeze(0) - positions.unsqueeze(1)로 모든 (i, j) 쌍에 대해 j-i를 계산합니다.
예를 들어 (3, 5) 위치는 +2, (5, 3)은 -2가 됩니다. 이것을 clipping하여 너무 먼 거리는 제한하고, Embedding으로 학습 가능한 bias를 조회합니다.
마지막으로, Relative Bias는 Attention Score에 직접 더해집니다. [n_heads, seq_len, seq_len] 형태로, 각 Head마다 다른 상대 위치 선호도를 학습할 수 있습니다.
예를 들어 어떤 Head는 인접 토큰(거리 1)에 높은 bias를, 다른 Head는 먼 거리에 높은 bias를 줄 수 있습니다. 이것이 더 풍부한 위치 표현을 가능하게 합니다.
여러분이 두 방식을 비교하면 태스크에 맞는 선택을 할 수 있습니다. 문서 분류 같이 절대 위치가 중요한 경우(예: 제목은 항상 첫 부분) Absolute가 유리합니다.
기계 번역이나 요약처럼 상대적 관계가 중요한 경우 Relative가 우수합니다. 메모리와 계산 비용을 고려하면, Absolute는 O(LD), Relative는 O(L²H)로 Relative가 더 비쌉니다(L=seq_len, D=d_model, H=n_heads).
하지만 성능 향상이 비용을 정당화하는 경우가 많습니다.
실전 팁
💡 T5는 Relative Position Bias를 Attention의 Logit에 더합니다: attention_scores = QK^T + relative_bias. 구현 시 이 순서를 지키세요.
💡 max_distance를 설정하여 메모리를 절약하세요. 128이면 충분한 경우가 많으며, 더 먼 거리는 같은 bias를 공유합니다.
💡 DeBERTa는 Disentangled Attention으로 content와 position을 분리하여 처리합니다. 더 복잡하지만 성능이 더 우수합니다.
💡 Relative 방식은 외삽이 자연스럽습니다. 학습 시 max_len=512였어도, 추론 시 1024에서 rel_pos(1024)를 호출하면 됩니다.
💡 두 방식을 결합할 수도 있습니다. Absolute를 임베딩에 더하고, Relative를 Attention Bias로 사용하는 하이브리드 접근도 연구되고 있습니다.