이미지 로딩 중...
AI Generated
2025. 11. 8. · 3 Views
LLM 구현 2편 - Transformer 아키텍처 이해
Transformer의 핵심 구조인 Attention, Encoder-Decoder, Position Encoding을 실제 코드로 구현하며 이해합니다. GPT와 BERT의 기반이 되는 아키텍처를 파이썬으로 직접 만들어보면서 LLM의 동작 원리를 깊이 있게 학습합니다.
목차
- Self-Attention 메커니즘 - 문맥을 이해하는 핵심
- Positional Encoding - 단어 순서 정보 주입
- Multi-Head Attention - 다양한 관점에서 문맥 파악
- Feed-Forward Network - 비선형 변환으로 표현력 확장
- Layer Normalization과 Residual Connection - 안정적인 학습
- Transformer Encoder - 입력을 고차원 표현으로 변환
- Transformer Decoder - 순차적 생성을 위한 구조
- 전체 Transformer 아키텍처 통합
1. Self-Attention 메커니즘 - 문맥을 이해하는 핵심
시작하며
여러분이 "그것을 가져와"라는 문장을 번역할 때 "그것"이 무엇을 가리키는지 어떻게 판단하나요? 사람은 앞뒤 문맥을 자연스럽게 파악하지만, 기계는 이것이 매우 어렵습니다.
기존 RNN이나 LSTM은 순차적으로 단어를 처리하며 문맥을 이해하려 했지만, 긴 문장에서는 앞부분의 정보가 희석되는 문제가 있었습니다. 또한 병렬 처리가 불가능해 학습 속도가 매우 느렸죠.
바로 이럴 때 필요한 것이 Self-Attention입니다. 모든 단어가 문장 내 다른 모든 단어와의 관계를 동시에 계산하여, 어떤 단어에 집중해야 할지 스스로 학습합니다.
개요
간단히 말해서, Self-Attention은 문장 내 각 단어가 다른 모든 단어들과 얼마나 관련이 있는지를 계산하는 메커니즘입니다. 실무에서 번역 모델을 만들거나 챗봇을 개발할 때, 문맥 이해는 필수입니다.
예를 들어, "은행"이라는 단어가 "금융 기관"인지 "강가"인지는 주변 단어들을 보고 판단해야 합니다. Self-Attention은 이러한 판단을 자동으로 수행합니다.
기존 RNN이 단어를 하나씩 순차적으로 처리했다면, Self-Attention은 모든 단어를 동시에 보면서 관계를 파악합니다. 이는 병렬 처리가 가능하고 긴 의존성도 효과적으로 포착합니다.
핵심 특징은 Query, Key, Value라는 세 가지 벡터를 사용한다는 점입니다. Query는 "내가 찾는 정보", Key는 "내가 가진 정보", Value는 "실제 전달할 내용"을 의미하며, 이를 통해 각 단어의 중요도를 계산합니다.
코드 예제
import torch
import torch.nn as nn
import math
class SelfAttention(nn.Module):
def __init__(self, embed_dim, num_heads=8):
super().__init__()
self.embed_dim = embed_dim
self.num_heads = num_heads
self.head_dim = embed_dim // num_heads
# Query, Key, Value 생성을 위한 Linear 레이어
self.qkv_proj = nn.Linear(embed_dim, embed_dim * 3)
self.out_proj = nn.Linear(embed_dim, embed_dim)
def forward(self, x):
batch_size, seq_len, embed_dim = x.shape
# Q, K, V 계산
qkv = self.qkv_proj(x).reshape(batch_size, seq_len, 3, self.num_heads, self.head_dim)
q, k, v = qkv.permute(2, 0, 3, 1, 4) # [3, batch, heads, seq_len, head_dim]
# Attention scores 계산: Q * K^T / sqrt(d_k)
scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.head_dim)
attention_weights = torch.softmax(scores, dim=-1)
# Attention 적용: Attention * V
attention_output = torch.matmul(attention_weights, v)
attention_output = attention_output.transpose(1, 2).reshape(batch_size, seq_len, embed_dim)
return self.out_proj(attention_output)
설명
이것이 하는 일: Self-Attention은 입력 문장의 각 단어가 다른 단어들과 얼마나 관련이 있는지를 수치화하여, 중요한 단어에 더 집중하도록 만듭니다. 첫 번째로, qkv_proj를 통해 입력 임베딩에서 Query, Key, Value 세 가지 벡터를 생성합니다.
이는 마치 검색 엔진에서 검색어(Query), 인덱스(Key), 실제 문서(Value)를 분리하는 것과 유사합니다. 하나의 Linear 레이어로 한 번에 계산한 후 3개로 분리하여 효율성을 높입니다.
그 다음으로, Query와 Key의 내적을 계산하여 attention scores를 얻습니다. 이 점수가 높을수록 두 단어 간의 관련성이 높다는 의미입니다.
sqrt(head_dim)으로 나누는 이유는 벡터 차원이 클수록 내적 값이 커져서 gradient가 불안정해지는 것을 방지하기 위함입니다. 이후 softmax를 적용하여 확률 분포로 변환합니다.
마지막으로, 계산된 attention weights를 Value에 곱하여 최종 출력을 생성합니다. 이는 관련성이 높은 단어의 정보를 더 많이 가져오고, 관련성이 낮은 단어는 무시하는 효과를 만듭니다.
Multi-head를 사용하는 이유는 다양한 관점에서 관계를 파악하기 위함입니다. 여러분이 이 코드를 사용하면 문장의 긴 의존성(long-range dependency)을 효과적으로 포착할 수 있고, 병렬 처리가 가능해 학습 속도가 크게 향상됩니다.
또한 어떤 단어에 집중했는지 시각화할 수 있어 모델의 해석 가능성도 높아집니다.
실전 팁
💡 Multi-head Attention을 사용할 때는 head 개수를 embed_dim의 약수로 설정하세요. 일반적으로 8 또는 16이 적절하며, 너무 많으면 계산 비용만 증가합니다.
💡 Attention weights를 시각화하면 모델이 어떤 단어에 집중하는지 확인할 수 있습니다. 디버깅이나 모델 개선에 매우 유용한 정보입니다.
💡 메모리 효율을 위해 Flash Attention 같은 최적화 기법을 고려하세요. 특히 긴 시퀀스를 다룰 때 O(n²) 메모리 복잡도가 문제가 될 수 있습니다.
💡 실무에서는 Dropout을 attention weights에 적용하여 과적합을 방지합니다. 일반적으로 0.1~0.2 정도가 적절합니다.
💡 Key와 Query의 차원은 같아야 하지만, Value의 차원은 달라도 됩니다. 이를 활용해 출력 차원을 조절할 수 있습니다.
2. Positional Encoding - 단어 순서 정보 주입
시작하며
여러분이 "나는 너를 좋아한다"와 "너는 나를 좋아한다"라는 두 문장을 본다면, 단어는 같지만 의미가 완전히 다르다는 것을 알 수 있습니다. 단어의 순서가 의미를 결정하는 것이죠.
하지만 Self-Attention은 모든 단어를 동시에 처리하기 때문에, 단어의 순서 정보가 자연스럽게 손실됩니다. "I love you"와 "you love I"를 구분할 수 없게 되는 것입니다.
바로 이럴 때 필요한 것이 Positional Encoding입니다. 각 단어의 위치 정보를 수학적으로 인코딩하여 임베딩에 더해줌으로써, 순서를 인식할 수 있게 만듭니다.
개요
간단히 말해서, Positional Encoding은 단어의 위치를 숫자로 표현하여 모델이 순서를 이해할 수 있도록 하는 기법입니다. 자연어 처리에서 단어 순서는 핵심입니다.
특히 번역이나 요약 작업에서 순서가 바뀌면 의미가 완전히 달라집니다. 예를 들어, "개가 고양이를 쫓았다"와 "고양이가 개를 쫓았다"는 주어와 목적어가 바뀌면서 완전히 다른 상황을 나타냅니다.
기존 RNN은 순차적 처리로 자연스럽게 순서 정보를 가졌지만, Transformer는 병렬 처리를 위해 이를 포기했습니다. 따라서 명시적으로 위치 정보를 주입해야 합니다.
Positional Encoding의 핵심은 사인(sin)과 코사인(cos) 함수를 사용한다는 점입니다. 이는 학습 없이도 상대적 위치를 표현할 수 있고, 훈련 때 보지 못한 길이의 문장에도 대응할 수 있습니다.
또한 주기 함수 특성상 위치 간 관계를 선형 변환으로 표현할 수 있어 모델이 상대적 위치를 쉽게 학습합니다.
코드 예제
import torch
import math
class PositionalEncoding(nn.Module):
def __init__(self, d_model, max_len=5000):
super().__init__()
# 위치 인코딩 행렬 생성 [max_len, d_model]
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)
# 배치 차원 추가 [1, max_len, d_model]
pe = pe.unsqueeze(0)
self.register_buffer('pe', pe)
def forward(self, x):
# x: [batch_size, seq_len, d_model]
# 입력 임베딩에 위치 인코딩 더하기
seq_len = x.size(1)
return x + self.pe[:, :seq_len, :]
설명
이것이 하는 일: Positional Encoding은 각 위치마다 고유한 벡터를 생성하여, 모델이 "첫 번째 단어", "두 번째 단어" 같은 순서 정보를 파악할 수 있게 합니다. 첫 번째로, position과 div_term을 계산하여 각 위치와 차원에 대한 주파수를 결정합니다.
div_term은 차원이 증가할수록 주파수가 감소하도록 설계되어, 저차원은 빠르게 변하고 고차원은 천천히 변하는 패턴을 만듭니다. 이는 다양한 스케일의 위치 정보를 표현할 수 있게 합니다.
그 다음으로, 짝수 차원에는 sin 함수를, 홀수 차원에는 cos 함수를 적용합니다. 이렇게 하는 이유는 sin과 cos의 조합이 위치 간 선형 관계를 표현할 수 있기 때문입니다.
예를 들어, PE(pos+k)를 PE(pos)의 선형 결합으로 나타낼 수 있어, 모델이 상대적 위치를 쉽게 학습합니다. 마지막으로, 생성된 위치 인코딩을 register_buffer로 등록하여 모델 파라미터가 아닌 상수로 관리합니다.
forward에서는 입력 임베딩에 해당 길이만큼의 위치 인코딩을 더해줍니다. 이는 단어 의미(임베딩)와 위치 정보가 결합되어 최종 표현을 만듭니다.
여러분이 이 코드를 사용하면 추가 학습 파라미터 없이도 순서 정보를 효과적으로 인코딩할 수 있습니다. 또한 훈련 시 최대 길이를 넘는 시퀀스에도 외삽(extrapolation)이 가능하며, 사인/코사인의 주기성 덕분에 상대적 거리 정보도 보존됩니다.
실전 팁
💡 max_len은 예상되는 최대 시퀀스 길이보다 여유있게 설정하세요. 일반적으로 5000~10000이면 대부분의 경우를 커버할 수 있습니다.
💡 학습 가능한 Positional Embedding을 사용하는 것도 가능합니다. 고정된 시퀀스 길이를 다룬다면 nn.Embedding을 사용해 위치별로 학습하는 것이 더 좋을 수 있습니다.
💡 위치 인코딩을 더하기 전에 임베딩에 sqrt(d_model)을 곱하는 것이 원논문의 방식입니다. 이는 임베딩과 위치 인코딩의 스케일을 맞춰줍니다.
💡 ALiBi(Attention with Linear Biases) 같은 대안적 위치 인코딩 방법도 있습니다. 특히 매우 긴 시퀀스를 다룰 때 더 효과적일 수 있습니다.
3. Multi-Head Attention - 다양한 관점에서 문맥 파악
시작하며
여러분이 "Apple is a great company"라는 문장을 읽을 때, "Apple"을 기업으로 해석하기 위해 "company"라는 단어를 봅니다. 하지만 동시에 "great"도 보면서 긍정적 뉘앙스도 파악하죠.
우리는 여러 관점을 동시에 고려합니다. Single-head Attention은 하나의 관점으로만 문맥을 파악합니다.
이는 복잡한 언어 현상을 포착하기에는 제한적입니다. 문법적 관계, 의미적 유사성, 감정 등 다양한 측면을 동시에 봐야 합니다.
바로 이럴 때 필요한 것이 Multi-Head Attention입니다. 여러 개의 Attention을 병렬로 수행하여 각각 다른 측면의 관계를 학습하고, 이를 결합하여 풍부한 표현을 만듭니다.
개요
간단히 말해서, Multi-Head Attention은 여러 개의 Attention 메커니즘을 동시에 사용하여 다양한 종류의 관계를 포착하는 기법입니다. 실무에서 번역 모델을 만들 때, 어떤 head는 문법적 관계(주어-동사)를 학습하고, 다른 head는 의미적 관계(동의어, 반의어)를 학습합니다.
예를 들어, 기계 번역에서 한 head는 단어 정렬을, 다른 head는 문맥상 의미를 파악하는 식으로 역할이 분화됩니다. Single-head를 여러 번 실행하는 것과 달리, Multi-head는 차원을 분할하여 병렬로 처리합니다.
512 차원을 8개 head로 나누면 각 head는 64 차원을 담당하며, 전체 계산량은 비슷하면서도 표현력은 크게 향상됩니다. 핵심은 각 head가 독립적으로 학습되어 서로 다른 패턴을 발견한다는 점입니다.
일부 head는 국소적 관계(인접 단어)에, 다른 head는 전역적 관계(문장 양 끝)에 집중하는 식으로 자연스럽게 역할이 나뉩니다. 최종적으로 이들을 concatenate하고 선형 변환하여 통합된 표현을 얻습니다.
코드 예제
class MultiHeadAttention(nn.Module):
def __init__(self, d_model, num_heads, dropout=0.1):
super().__init__()
assert d_model % num_heads == 0
self.d_model = d_model
self.num_heads = num_heads
self.d_k = d_model // num_heads
# Q, K, V를 위한 Linear 레이어
self.W_q = nn.Linear(d_model, d_model)
self.W_k = nn.Linear(d_model, d_model)
self.W_v = nn.Linear(d_model, d_model)
self.W_o = nn.Linear(d_model, d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, query, key, value, mask=None):
batch_size = query.size(0)
# Linear 투영 후 head로 분할
Q = self.W_q(query).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
K = self.W_k(key).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
V = self.W_v(value).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
# Scaled Dot-Product Attention
scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k)
if mask is not None:
scores = scores.masked_fill(mask == 0, -1e9)
attention = torch.softmax(scores, dim=-1)
attention = self.dropout(attention)
# Attention 적용 후 head 합치기
output = torch.matmul(attention, V)
output = output.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model)
return self.W_o(output), attention
설명
이것이 하는 일: Multi-Head Attention은 입력을 여러 부분공간(subspace)으로 나누어 각각 독립적으로 Attention을 계산한 후, 결과를 합쳐서 다면적인 정보를 통합합니다. 첫 번째로, Query, Key, Value를 각각 선형 변환한 후 num_heads 개로 분할합니다.
view와 transpose를 사용해 [batch, seq_len, d_model]을 [batch, num_heads, seq_len, d_k]로 재구성합니다. 이렇게 하면 각 head가 전체 시퀀스를 보지만 더 낮은 차원(d_k)에서 작업하게 됩니다.
차원을 줄이면 각 head가 특정 측면에 집중할 수 있습니다. 그 다음으로, 각 head별로 독립적인 Scaled Dot-Product Attention을 수행합니다.
mask 파라미터는 패딩 토큰을 무시하거나 미래 정보를 차단하는 데 사용됩니다. -1e9 같은 큰 음수로 마스킹하면 softmax 후 거의 0이 되어 해당 위치를 무시하게 됩니다.
Dropout을 attention weights에 적용하여 과적합을 방지합니다. 마지막으로, 각 head의 출력을 다시 concatenate하여 원래 차원으로 복원하고, 최종 선형 변환(W_o)을 적용합니다.
이 과정에서 contiguous()를 호출하는 이유는 transpose 후 메모리가 연속적이지 않을 수 있기 때문입니다. W_o는 여러 head의 정보를 효과적으로 결합하는 역할을 합니다.
여러분이 이 코드를 사용하면 모델이 다양한 언어적 패턴을 동시에 학습할 수 있어 성능이 크게 향상됩니다. 연구에 따르면 일부 head는 구문 구조를, 다른 head는 공지시(coreference)를 학습하는 등 자연스럽게 특화됩니다.
Attention weights를 시각화하면 각 head가 무엇을 학습했는지 분석할 수 있어 모델 해석에도 유용합니다.
실전 팁
💡 num_heads는 일반적으로 8, 12, 16을 사용합니다. 너무 많으면 각 head의 차원(d_k)이 너무 작아져 표현력이 떨어질 수 있습니다.
💡 mask를 사용할 때는 dtype과 shape를 정확히 맞춰야 합니다. Broadcasting 규칙을 활용하면 [batch, 1, 1, seq_len] 형태로 효율적으로 사용할 수 있습니다.
💡 각 head가 무엇을 학습했는지 분석하려면 attention weights를 저장하고 시각화하세요. 특정 head를 제거했을 때 성능 변화를 보는 ablation study도 유용합니다.
💡 Grouped Query Attention(GQA)이나 Multi-Query Attention(MQA) 같은 변형도 있습니다. 추론 속도를 높이고 싶다면 이런 기법을 고려해보세요.
💡 실무에서는 KV cache를 사용하여 디코딩 시 중복 계산을 피합니다. 특히 생성 작업에서 큰 속도 향상을 얻을 수 있습니다.
4. Feed-Forward Network - 비선형 변환으로 표현력 확장
시작하며
여러분이 Attention을 통해 "이 단어는 저 단어와 관련이 있다"는 정보를 얻었다면, 이제 그 정보를 어떻게 활용할까요? 단순히 관계만 아는 것으로는 부족합니다.
더 복잡한 패턴을 추출해야 합니다. Attention만으로는 선형 결합에 가까운 연산만 수행합니다.
언어의 복잡한 비선형성(idioms, 은유, 중의적 표현 등)을 포착하기 어렵습니다. 더 깊은 특징 추출이 필요합니다.
바로 이럴 때 필요한 것이 Position-wise Feed-Forward Network입니다. 각 위치마다 독립적으로 비선형 변환을 적용하여, Attention이 찾은 관계를 더 추상적인 표현으로 변환합니다.
개요
간단히 말해서, Feed-Forward Network는 각 토큰 위치에 동일한 2층 신경망을 적용하여 표현을 더 풍부하게 만드는 컴포넌트입니다. 자연어 처리에서 단순한 선형 결합만으로는 복잡한 의미를 표현할 수 없습니다.
예를 들어, "kick the bucket"이라는 표현은 개별 단어의 의미와 다른 "죽다"라는 뜻을 가집니다. 이런 비선형적 의미 변화를 포착하려면 활성화 함수를 가진 신경망이 필요합니다.
Attention이 "어디를 볼지" 결정했다면, FFN은 "그 정보로 무엇을 할지" 결정합니다. 각 위치에서 독립적으로 작동하므로 완전히 병렬화가 가능합니다.
핵심 구조는 두 개의 선형 변환 사이에 ReLU나 GELU 같은 활성화 함수를 넣는 것입니다. 일반적으로 중간 차원은 입력 차원의 4배로 확장했다가 다시 줄입니다.
이 확장-축소 구조는 더 풍부한 특징 공간에서 작업할 수 있게 하며, 일종의 bottleneck을 통해 중요한 정보만 추출하는 효과를 냅니다.
코드 예제
class PositionwiseFeedForward(nn.Module):
def __init__(self, d_model, d_ff, dropout=0.1):
super().__init__()
# 첫 번째 Linear: d_model -> d_ff (확장)
self.linear1 = nn.Linear(d_model, d_ff)
# 두 번째 Linear: d_ff -> d_model (축소)
self.linear2 = nn.Linear(d_ff, d_model)
self.dropout = nn.Dropout(dropout)
# GELU 활성화 함수 (GPT와 BERT에서 사용)
self.activation = nn.GELU()
def forward(self, x):
# x: [batch_size, seq_len, d_model]
# 첫 번째 변환: 차원 확장 + 활성화
hidden = self.activation(self.linear1(x))
hidden = self.dropout(hidden)
# 두 번째 변환: 원래 차원으로 복원
output = self.linear2(hidden)
output = self.dropout(output)
return output
설명
이것이 하는 일: FFN은 Attention이 만든 표현을 더 추상적이고 유용한 특징으로 변환하는 일종의 특징 추출기입니다. 첫 번째로, linear1을 통해 차원을 크게 확장합니다(보통 d_model의 4배).
예를 들어 512차원을 2048차원으로 늘립니다. 이는 더 넓은 표현 공간에서 작업할 수 있게 하여, 다양한 특징 조합을 탐색할 수 있습니다.
GELU 활성화 함수는 ReLU보다 부드러운 비선형성을 제공하여 gradient flow가 더 좋습니다. 그 다음으로, Dropout을 적용하여 과적합을 방지합니다.
확장된 차원에서 일부 뉴런을 무작위로 끄면, 모델이 특정 뉴런에 과도하게 의존하지 않게 됩니다. 이는 일종의 앙상블 효과를 만들어 일반화 성능을 높입니다.
마지막으로, linear2를 통해 원래 차원으로 다시 줄입니다. 이 과정에서 확장된 공간에서 학습한 정보를 압축하여, 가장 중요한 특징만 남깁니다.
이 bottleneck 구조는 차원 축소와 특징 선택을 동시에 수행하는 효과가 있습니다. 여러분이 이 코드를 사용하면 Attention만으로는 불가능한 복잡한 함수 근사가 가능해집니다.
FFN은 전체 파라미터의 상당 부분을 차지하며(약 2/3), 모델의 표현력에 결정적 역할을 합니다. 실제로 FFN을 제거하면 성능이 급격히 떨어집니다.
실전 팁
💡 d_ff는 일반적으로 d_model의 4배로 설정하지만, 모델 크기와 태스크에 따라 조정할 수 있습니다. GPT-3는 4배보다 더 큰 비율을 사용합니다.
💡 GELU vs ReLU: GELU는 더 부드러운 gradient를 제공하지만 계산이 약간 느립니다. 실무에서는 GELU를 선호하는 추세입니다.
💡 SwiGLU 같은 최신 활성화 함수도 고려해보세요. LLaMA 같은 최신 모델들이 사용하며 성능 향상을 보여줍니다.
💡 각 위치에서 독립적으로 작동하므로, 구현 시 batch와 sequence 차원을 합쳐서 한 번에 처리하면 더 효율적입니다.
💡 Dropout rate는 모델 크기에 따라 조정하세요. 큰 모델일수록 더 높은 dropout(0.2~0.3)이 필요할 수 있습니다.
5. Layer Normalization과 Residual Connection - 안정적인 학습
시작하며
여러분이 깊은 신경망을 학습시킬 때, 층이 깊어질수록 gradient가 폭발하거나 소실되는 문제를 겪어본 적 있나요? 초기 층의 gradient가 거의 0이 되어 학습이 안 되는 현상은 매우 흔합니다.
Transformer는 Encoder와 Decoder를 합치면 수십 개의 층을 가질 수 있습니다. 이렇게 깊은 구조에서는 안정적인 학습이 필수입니다.
Normalization과 Skip Connection 없이는 제대로 학습되지 않습니다. 바로 이럴 때 필요한 것이 Layer Normalization과 Residual Connection입니다.
각 sub-layer의 출력을 정규화하고, 입력을 직접 출력에 더해줌으로써 gradient flow를 원활하게 만듭니다.
개요
간단히 말해서, Layer Normalization은 각 층의 출력을 평균 0, 분산 1로 정규화하고, Residual Connection은 입력을 출력에 바로 더해주는 기법입니다. 깊은 신경망 학습에서 가장 큰 문제는 internal covariate shift입니다.
각 층의 입력 분포가 계속 변하면서 학습이 불안정해집니다. 예를 들어, 앞 층의 파라미터가 조금만 바뀌어도 뒤 층은 완전히 다른 입력 분포를 받게 되어 학습이 느려집니다.
Batch Normalization이 배치 차원에서 정규화한다면, Layer Normalization은 특징 차원에서 정규화합니다. Transformer처럼 시퀀스 길이가 가변적인 경우 Layer Norm이 더 적합합니다.
Residual Connection(Skip Connection)은 입력 x를 출력 F(x)에 직접 더하여 x + F(x)를 만듭니다. 이렇게 하면 gradient가 skip connection을 통해 직접 흐를 수 있어 vanishing gradient 문제가 완화됩니다.
또한 모델이 identity mapping을 쉽게 학습할 수 있어, 최악의 경우에도 입력을 그대로 전달하는 안전장치가 됩니다.
코드 예제
class TransformerBlock(nn.Module):
def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
super().__init__()
# Multi-Head Attention
self.attention = MultiHeadAttention(d_model, num_heads, dropout)
# Feed-Forward Network
self.ffn = PositionwiseFeedForward(d_model, d_ff, dropout)
# Layer Normalization (2개 필요)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x, mask=None):
# Multi-Head Attention + Residual + LayerNorm
# Pre-LN 방식: Norm -> Sublayer -> Residual
attn_output, _ = self.attention(
self.norm1(x), self.norm1(x), self.norm1(x), mask
)
x = x + self.dropout(attn_output)
# Feed-Forward + Residual + LayerNorm
ffn_output = self.ffn(self.norm2(x))
x = x + self.dropout(ffn_output)
return x
설명
이것이 하는 일: Layer Norm과 Residual Connection은 깊은 네트워크의 학습을 안정화하고 가속화하는 핵심 기법입니다. 첫 번째로, Layer Normalization은 각 토큰의 특징 벡터를 독립적으로 정규화합니다.
nn.LayerNorm(d_model)은 마지막 차원에 대해 평균과 분산을 계산하여 정규화합니다. 학습 가능한 scale(gamma)과 shift(beta) 파라미터를 통해 모델이 필요시 정규화를 무시할 수도 있습니다.
이는 표현력을 유지하면서도 학습 안정성을 제공합니다. 그 다음으로, Pre-LN 구조를 사용합니다.
원래 Transformer 논문은 Post-LN(Sublayer -> Residual -> Norm)을 사용했지만, 최신 연구는 Pre-LN(Norm -> Sublayer -> Residual)이 더 안정적임을 보여줍니다. Pre-LN은 warm-up이 덜 필요하고 학습률에 덜 민감합니다.
마지막으로, Residual Connection을 통해 x = x + sublayer_output 형태로 입력을 직접 더합니다. 이는 gradient가 여러 층을 거치지 않고 직접 전파될 수 있는 경로를 제공합니다.
수학적으로 backpropagation 시 최소한 gradient가 1은 보장되어 vanishing 문제가 크게 완화됩니다. 여러분이 이 코드를 사용하면 매우 깊은 Transformer(24층, 48층 이상)도 안정적으로 학습할 수 있습니다.
Residual connection 덕분에 초기화에 덜 민감하고, Layer Norm 덕분에 학습률을 더 크게 설정할 수 있어 학습이 빨라집니다. 실제로 이 두 기법 없이는 Transformer를 제대로 학습하기 거의 불가능합니다.
실전 팁
💡 Pre-LN vs Post-LN: 깊은 모델(12층 이상)을 학습한다면 Pre-LN을 사용하세요. 더 안정적이고 warm-up 스케줄이 덜 필요합니다.
💡 LayerNorm의 epsilon 값(기본 1e-5)을 조정하면 수치 안정성을 개선할 수 있습니다. fp16 학습 시에는 1e-6 정도로 줄이는 것도 고려하세요.
💡 RMSNorm 같은 경량 정규화 기법도 있습니다. LLaMA에서 사용되며 Layer Norm보다 계산이 빠르면서도 비슷한 성능을 보입니다.
💡 Dropout은 Residual 전에 적용하세요. sub-layer 출력에만 Dropout을 적용하고 residual path는 그대로 두는 것이 일반적입니다.
💡 매우 깊은 모델에서는 Residual의 scale을 조정하는 것도 유용합니다. x = x + alpha * sublayer_output 형태로 alpha를 1/sqrt(depth)로 설정하는 방법도 있습니다.
6. Transformer Encoder - 입력을 고차원 표현으로 변환
시작하며
여러분이 문장을 이해할 때, 단어를 하나씩 보는 것이 아니라 전체 문맥을 파악하죠? "나는 은행에 갔다"에서 "은행"의 의미는 뒤에 오는 단어들을 보고 판단합니다.
번역, 분류, 요약 같은 NLP 태스크는 입력 문장 전체의 의미를 깊이 이해해야 합니다. 단순히 단어를 읽는 것을 넘어, 문법 구조, 개체 간 관계, 전체적인 의도까지 파악해야 합니다.
바로 이럴 때 필요한 것이 Transformer Encoder입니다. 여러 층의 Self-Attention과 Feed-Forward를 쌓아서 입력을 점진적으로 더 추상적이고 유용한 표현으로 변환합니다.
개요
간단히 말해서, Transformer Encoder는 입력 시퀀스를 받아 각 토큰을 문맥을 고려한 풍부한 벡터 표현으로 변환하는 컴포넌트입니다. 실무에서 문서 분류, 감정 분석, 개체명 인식 같은 태스크는 모두 Encoder 기반입니다.
BERT가 대표적인 Encoder-only 모델이며, 입력 텍스트의 양방향 문맥을 모두 활용하여 이해합니다. 예를 들어, 스팸 메일 분류기는 메일 전체를 읽고 종합적으로 판단해야 하는데, Encoder가 이를 가능하게 합니다.
Encoder는 여러 개의 동일한 층을 쌓은 구조입니다. 각 층은 Multi-Head Attention과 Feed-Forward Network로 구성되며, 입력이 층을 거칠수록 더 추상적인 특징을 학습합니다.
첫 번째 층은 단어 수준 패턴을, 중간 층은 구문 구조를, 마지막 층은 문장 전체의 의미를 포착하는 경향이 있습니다. 핵심은 Self-Attention을 사용한다는 점입니다.
입력 시퀀스의 모든 위치가 서로를 볼 수 있어 양방향 문맥을 완전히 활용합니다. 이는 문장 이해 태스크에서 매우 강력합니다.
코드 예제
class TransformerEncoder(nn.Module):
def __init__(self, vocab_size, d_model=512, num_heads=8,
num_layers=6, d_ff=2048, max_len=5000, dropout=0.1):
super().__init__()
# Token Embedding
self.embedding = nn.Embedding(vocab_size, d_model)
# Positional Encoding
self.pos_encoding = PositionalEncoding(d_model, max_len)
# N개의 Encoder Layer를 쌓음
self.layers = nn.ModuleList([
TransformerBlock(d_model, num_heads, d_ff, dropout)
for _ in range(num_layers)
])
self.dropout = nn.Dropout(dropout)
self.d_model = d_model
def forward(self, x, mask=None):
# x: [batch_size, seq_len] - 토큰 인덱스
# Embedding + Positional Encoding
x = self.embedding(x) * math.sqrt(self.d_model)
x = self.pos_encoding(x)
x = self.dropout(x)
# 각 Encoder Layer를 순차적으로 통과
for layer in self.layers:
x = layer(x, mask)
return x # [batch_size, seq_len, d_model]
설명
이것이 하는 일: Encoder는 raw 토큰 시퀀스를 받아서, 전체 문맥을 고려한 각 토큰의 의미 표현을 생성합니다. 첫 번째로, Token Embedding을 통해 각 단어 ID를 d_model 차원의 벡터로 변환합니다.
sqrt(d_model)을 곱하는 이유는 임베딩과 positional encoding의 스케일을 맞추기 위함입니다. 그 다음 Positional Encoding을 더하여 위치 정보를 주입합니다.
초기 Dropout은 과적합 방지와 regularization 역할을 합니다. 그 다음으로, 여러 층의 TransformerBlock을 순차적으로 통과시킵니다.
nn.ModuleList를 사용하면 각 층이 독립적인 파라미터를 가지면서도 효율적으로 관리됩니다. 각 층은 이전 층의 출력을 입력으로 받아 점진적으로 정제된 표현을 만듭니다.
6층이 기본이지만, BERT-base는 12층, BERT-large는 24층을 사용합니다. 마지막으로, 최종 출력은 [batch_size, seq_len, d_model] 형태로 각 토큰의 문맥화된 표현입니다.
이를 분류 태스크에 사용하려면 [CLS] 토큰의 표현을 추출하거나, 모든 토큰의 평균을 사용합니다. 토큰 레벨 태스크(개체명 인식)라면 각 토큰의 표현을 직접 사용합니다.
여러분이 이 코드를 사용하면 사전학습된 모델(BERT, RoBERTa 등)의 기반을 이해하고, 커스텀 Encoder를 만들 수 있습니다. 실무에서는 Hugging Face의 사전학습 모델을 fine-tuning하는 경우가 많지만, 특수한 도메인이나 언어에서는 처음부터 학습하는 것도 유용합니다.
실전 팁
💡 층 개수는 태스크와 데이터 크기에 따라 조정하세요. 적은 데이터에서는 6층, 충분한 데이터에서는 12~24층이 적절합니다.
💡 Padding 토큰을 마스킹하여 Attention 계산에서 제외하세요. mask = (x != pad_token_id).unsqueeze(1).unsqueeze(2) 형태로 만들 수 있습니다.
💡 Gradient checkpointing을 사용하면 메모리를 크게 절약할 수 있습니다. 깊은 모델을 학습할 때 유용하며, 속도는 약간 느려지지만 배치 크기를 늘릴 수 있습니다.
💡 Warm-up 학습률 스케줄을 사용하세요. 처음에는 작은 학습률로 시작해서 점진적으로 늘렸다가 줄이는 방식이 Transformer 학습에 효과적입니다.
💡 사전학습 시에는 Masked Language Modeling(MLM)이나 Next Sentence Prediction(NSP) 같은 태스크를 사용합니다. 이는 라벨 없는 대량의 텍스트로 학습할 수 있게 합니다.
7. Transformer Decoder - 순차적 생성을 위한 구조
시작하며
여러분이 번역을 할 때, 원문을 이해한 후 번역문을 한 단어씩 생성하죠? "I love you"를 "나는 너를 사랑한다"로 번역할 때, "나는"을 먼저 생성하고, 그 다음 "너를"을 생성합니다.
Encoder는 입력을 이해하는 역할만 하므로, 출력을 생성할 수 없습니다. 번역, 요약, 대화 생성처럼 텍스트를 순차적으로 만들어야 하는 태스크에는 다른 구조가 필요합니다.
바로 이럴 때 필요한 것이 Transformer Decoder입니다. 이미 생성한 단어들을 보면서 다음 단어를 예측하고, Encoder의 출력을 참조하여 입력 정보를 활용합니다.
개요
간단히 말해서, Transformer Decoder는 이미 생성된 토큰들을 기반으로 다음 토큰을 순차적으로 예측하는 컴포넌트입니다. 기계 번역, 텍스트 생성, 대화 시스템은 모두 Decoder 기반입니다.
GPT는 Decoder-only 모델로, 주어진 문맥에서 다음 단어를 예측하는 방식으로 학습합니다. 예를 들어, "오늘 날씨가"라는 입력에 "좋다", "맑다", "흐리다" 중 하나를 예측하는 식입니다.
Decoder는 Encoder와 비슷하지만 두 가지 중요한 차이가 있습니다. 첫째, Masked Self-Attention을 사용하여 미래 토큰을 볼 수 없게 합니다.
"나는 너를"까지 생성했을 때, "사랑한다"를 미리 볼 수 없어야 합니다. 둘째, Encoder-Decoder Attention이라는 추가 층이 있어 Encoder의 출력을 참조합니다.
핵심은 autoregressive 생성입니다. 이전에 생성한 토큰들이 다음 토큰의 입력이 되는 순환 구조로, 문장을 처음부터 끝까지 순차적으로 만들어냅니다.
학습 시에는 Teacher Forcing(정답을 입력으로 주기)을 사용하지만, 추론 시에는 자신이 생성한 토큰을 입력으로 사용합니다.
코드 예제
class TransformerDecoderLayer(nn.Module):
def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
super().__init__()
# Masked Self-Attention (미래 토큰 차단)
self.self_attention = MultiHeadAttention(d_model, num_heads, dropout)
# Encoder-Decoder Attention (Encoder 출력 참조)
self.cross_attention = MultiHeadAttention(d_model, num_heads, dropout)
# Feed-Forward Network
self.ffn = PositionwiseFeedForward(d_model, d_ff, dropout)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.norm3 = nn.LayerNorm(d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x, encoder_output, src_mask=None, tgt_mask=None):
# 1. Masked Self-Attention
normed_x = self.norm1(x)
self_attn_output, _ = self.self_attention(
normed_x, normed_x, normed_x, tgt_mask
)
x = x + self.dropout(self_attn_output)
# 2. Encoder-Decoder Cross-Attention
normed_x = self.norm2(x)
cross_attn_output, _ = self.cross_attention(
normed_x, encoder_output, encoder_output, src_mask
)
x = x + self.dropout(cross_attn_output)
# 3. Feed-Forward
normed_x = self.norm3(x)
ffn_output = self.ffn(normed_x)
x = x + self.dropout(ffn_output)
return x
설명
이것이 하는 일: Decoder는 이미 생성된 부분과 Encoder의 입력 이해를 결합하여, 다음에 올 가장 적절한 토큰을 예측합니다. 첫 번째로, Masked Self-Attention을 수행합니다.
tgt_mask는 현재 위치보다 뒤에 있는 토큰들을 -inf로 마스킹하여 softmax 후 0이 되게 합니다. 예를 들어, 3번째 토큰을 생성할 때는 1, 2번째 토큰만 볼 수 있고 4, 5번째는 볼 수 없습니다.
이는 학습과 추론의 일관성을 보장하며, 정보 누출을 방지합니다. 그 다음으로, Encoder-Decoder Cross-Attention을 수행합니다.
여기서 Query는 Decoder의 현재 상태에서 나오고, Key와 Value는 Encoder의 출력에서 나옵니다. 이는 "원문의 어느 부분을 참고해서 번역할지"를 결정하는 역할입니다.
예를 들어, "I love you"를 번역할 때 "사랑한다"를 생성하면서 "love"에 높은 attention을 줍니다. 마지막으로, Feed-Forward Network를 통과시켜 최종 표현을 만듭니다.
세 개의 sub-layer(Masked Self-Attn, Cross-Attn, FFN)가 각각 LayerNorm과 Residual Connection으로 연결되어 있습니다. 이 구조를 여러 층 쌓으면 점진적으로 더 정교한 생성이 가능해집니다.
여러분이 이 코드를 사용하면 번역, 요약, 질의응답 같은 sequence-to-sequence 태스크를 구현할 수 있습니다. Decoder는 Encoder의 이해와 자신의 생성 히스토리를 모두 활용하여 문맥에 맞는 자연스러운 출력을 만들어냅니다.
T5, BART 같은 모델이 이 Encoder-Decoder 구조를 사용합니다.
실전 팁
💡 Causal mask(미래 차단 마스크)는 torch.triu를 사용해 쉽게 만들 수 있습니다: torch.triu(torch.ones(seq_len, seq_len), diagonal=1).bool().
💡 Teacher Forcing ratio를 점진적으로 줄이는 Scheduled Sampling을 사용하면 exposure bias를 완화할 수 있습니다. 학습 초기에는 정답을 많이 주고, 후반에는 자신의 예측을 더 많이 사용하세요.
💡 Beam Search를 사용하면 Greedy decoding보다 더 좋은 결과를 얻을 수 있습니다. 일반적으로 beam size 4~8이 적절합니다.
💡 Cross-Attention weights를 시각화하면 alignment(어떤 원문 단어가 어떤 번역 단어에 대응되는지)를 볼 수 있어 디버깅에 유용합니다.
💡 추론 시 KV cache를 사용하세요. 이미 계산한 Key와 Value를 재사용하면 생성 속도가 크게 향상됩니다. GPT 같은 대형 모델에서는 필수적입니다.
8. 전체 Transformer 아키텍처 통합
시작하며
여러분이 지금까지 Attention, Encoder, Decoder 같은 개별 컴포넌트를 배웠다면, 이제 이들을 어떻게 조합하여 완전한 Transformer를 만드는지 궁금할 것입니다. 개별 부품을 이해하는 것과 전체 시스템을 구성하는 것은 다릅니다.
각 컴포넌트가 어떤 순서로 연결되고, 데이터가 어떻게 흐르며, 학습과 추론이 어떻게 다른지 알아야 실제로 사용할 수 있습니다. 바로 이럴 때 필요한 것이 전체 Transformer 아키텍처입니다.
Encoder와 Decoder를 연결하고, 입력 전처리부터 출력 생성까지 전체 파이프라인을 구성합니다.
개요
간단히 말해서, 완전한 Transformer는 입력 임베딩, Encoder 스택, Decoder 스택, 출력 선형 변환을 순차적으로 연결한 end-to-end 모델입니다. 실무에서 기계 번역 시스템을 만든다면, 원문을 Encoder로 인코딩하고, Decoder로 번역문을 생성하며, 최종 선형 층에서 각 위치마다 어휘 전체에 대한 확률 분포를 출력합니다.
예를 들어, 영어-한국어 번역기는 영어 문장을 받아 한국어 어휘에 대한 확률을 출력하고, 가장 높은 확률의 단어를 선택합니다. 원논문 "Attention is All You Need"의 구조는 6층 Encoder + 6층 Decoder입니다.
Encoder와 Decoder는 파라미터를 공유하지 않으며, 각자 독립적으로 학습됩니다. 입력과 출력의 임베딩은 공유할 수도 있고(weight tying), 따로 둘 수도 있습니다.
핵심은 학습과 추론의 차이입니다. 학습 시에는 정답 문장 전체를 Decoder에 입력으로 주고(Teacher Forcing), 각 위치에서 다음 토큰을 예측하도록 합니다.
추론 시에는 한 번에 한 토큰씩 생성하면서 autoregressive하게 진행합니다.
코드 예제
class Transformer(nn.Module):
def __init__(self, src_vocab_size, tgt_vocab_size, d_model=512,
num_heads=8, num_layers=6, d_ff=2048, max_len=5000, dropout=0.1):
super().__init__()
# Encoder
self.encoder = TransformerEncoder(
src_vocab_size, d_model, num_heads, num_layers, d_ff, max_len, dropout
)
# Decoder Embedding + Positional Encoding
self.tgt_embedding = nn.Embedding(tgt_vocab_size, d_model)
self.pos_encoding = PositionalEncoding(d_model, max_len)
# Decoder Layers
self.decoder_layers = nn.ModuleList([
TransformerDecoderLayer(d_model, num_heads, d_ff, dropout)
for _ in range(num_layers)
])
# 출력 선형 변환: d_model -> tgt_vocab_size
self.output_proj = nn.Linear(d_model, tgt_vocab_size)
self.dropout = nn.Dropout(dropout)
self.d_model = d_model
def forward(self, src, tgt, src_mask=None, tgt_mask=None):
# Encoder: 원문 인코딩
encoder_output = self.encoder(src, src_mask)
# Decoder: 타겟 임베딩 + Positional Encoding
tgt_embedded = self.tgt_embedding(tgt) * math.sqrt(self.d_model)
tgt_embedded = self.pos_encoding(tgt_embedded)
decoder_output = self.dropout(tgt_embedded)
# Decoder Layers를 순차적으로 통과
for layer in self.decoder_layers:
decoder_output = layer(decoder_output, encoder_output, src_mask, tgt_mask)
# 최종 출력: 각 위치에서 어휘 전체에 대한 로짓
logits = self.output_proj(decoder_output)
return logits # [batch_size, tgt_seq_len, tgt_vocab_size]
def generate(self, src, src_mask, max_len=100, start_token=1, end_token=2):
"""추론용: autoregressive 생성"""
encoder_output = self.encoder(src, src_mask)
batch_size = src.size(0)
# 시작 토큰으로 초기화
generated = torch.full((batch_size, 1), start_token, dtype=torch.long, device=src.device)
for _ in range(max_len):
tgt_mask = self._create_causal_mask(generated.size(1)).to(src.device)
# 현재까지 생성된 토큰으로 다음 토큰 예측
tgt_embedded = self.tgt_embedding(generated) * math.sqrt(self.d_model)
tgt_embedded = self.pos_encoding(tgt_embedded)
decoder_output = self.dropout(tgt_embedded)
for layer in self.decoder_layers:
decoder_output = layer(decoder_output, encoder_output, src_mask, tgt_mask)
# 마지막 위치의 로짓으로 다음 토큰 예측
next_token_logits = self.output_proj(decoder_output[:, -1, :])
next_token = torch.argmax(next_token_logits, dim=-1, keepdim=True)
generated = torch.cat([generated, next_token], dim=1)
# 종료 토큰 생성 시 중단
if (next_token == end_token).all():
break
return generated
def _create_causal_mask(self, size):
"""미래 토큰을 차단하는 마스크 생성"""
mask = torch.triu(torch.ones(size, size), diagonal=1).bool()
return ~mask # True를 볼 수 있는 위치로
설명
이것이 하는 일: 완전한 Transformer는 원문을 고차원 표현으로 변환하고, 그 정보를 활용하여 번역문을 한 토큰씩 생성하는 end-to-end 시스템입니다. 첫 번째로, forward 메서드는 학습용입니다.
원문과 타겟 문장을 모두 받아서 Encoder로 원문을 인코딩하고, Decoder로 타겟의 각 위치에서 다음 토큰을 예측합니다. Teacher Forcing을 사용하므로 정답 토큰을 입력으로 주지만, causal mask로 미래는 차단합니다.
출력 로짓에 대해 Cross-Entropy Loss를 계산하여 학습합니다. 그 다음으로, generate 메서드는 추론용입니다.
시작 토큰부터 시작해서 한 번에 한 토큰씩 생성하며, 각 단계에서 이전에 생성한 토큰들을 입력으로 사용합니다. 마지막 위치의 로짓에서 argmax(greedy decoding)로 다음 토큰을 선택하고, <EOS> 토큰이 나오거나 최대 길이에 도달하면 중단합니다.
마지막으로, 마스크 처리가 중요합니다. src_mask는 패딩 토큰을 무시하고, tgt_mask는 미래 토큰을 차단합니다.
_create_causal_mask는 하삼각 행렬 형태로 현재 위치 이전만 볼 수 있게 합니다. 이 마스크들이 올바르게 설정되지 않으면 모델이 제대로 학습되지 않거나 추론이 실패합니다.
여러분이 이 코드를 사용하면 기계 번역, 텍스트 요약, 질의응답 같은 seq2seq 태스크를 구현할 수 있습니다. 실무에서는 여기에 Label Smoothing, Warm-up Scheduler, Beam Search 같은 기법을 추가하여 성능을 더욱 높입니다.
이 아키텍처가 GPT(Decoder-only)와 BERT(Encoder-only)의 기반이 됩니다.
실전 팁
💡 Weight Tying: 입력/출력 임베딩과 output projection의 가중치를 공유하면 파라미터를 절약하고 성능도 향상될 수 있습니다. self.output_proj.weight = self.tgt_embedding.weight 형태로 설정하세요.
💡 Warm-up + Cosine Decay 학습률 스케줄을 사용하세요. 원논문은 특별한 스케줄을 제안했지만, 실무에서는 AdamW + Cosine이 더 안정적입니다.
💡 Label Smoothing을 적용하면 과신(overconfidence)을 방지하고 일반화 성능을 높일 수 있습니다. nn.CrossEntropyLoss의 label_smoothing 파라미터를 0.1 정도로 설정하세요.
💡 추론 시 Beam Search나 Nucleus Sampling을 사용하면 더 다양하고 자연스러운 결과를 얻을 수 있습니다. Greedy는 반복적이고 지루한 출력을 만들 수 있습니다.
💡 Mixed Precision Training(fp16)을 사용하면 메모리와 속도를 크게 개선할 수 있습니다. torch.cuda.amp를 활용하세요.