이미지 로딩 중...
AI Generated
2025. 11. 24. · 7 Views
Transformer 아키텍처 완벽 가이드
Transformer는 현대 AI의 핵심 기술입니다. Self-Attention 메커니즘부터 실제 구현까지, 초급 개발자도 이해할 수 있도록 쉽게 풀어서 설명합니다. 실무에서 바로 활용 가능한 코드와 팁을 제공합니다.
목차
- Self-Attention 메커니즘 - 문장 속 단어들이 서로를 바라보는 방법
- Multi-Head Attention - 여러 관점으로 문장 바라보기
- Positional Encoding - 단어의 순서 정보 주입하기
- Encoder Layer - 입력을 깊이 이해하기
- Decoder Layer - 출력을 단계적으로 생성하기
- Feed-Forward Network - 각 위치의 표현 변환하기
- Layer Normalization - 학습 안정화하기
- Residual Connection - 그래디언트 흐름 보장하기
- Masking - 불필요한 정보 차단하기
- Complete Transformer Model - 모든 것을 하나로
1. Self-Attention 메커니즘 - 문장 속 단어들이 서로를 바라보는 방법
시작하며
여러분이 "나는 강아지를 좋아해"라는 문장을 컴퓨터에게 이해시키려고 할 때 이런 고민을 해본 적 있나요? "좋아해"라는 단어가 정확히 무엇을 좋아하는지 어떻게 알 수 있을까요?
전통적인 RNN이나 LSTM은 순차적으로 단어를 하나씩 처리하면서 이전 정보를 기억하려고 합니다. 하지만 문장이 길어질수록 앞쪽 단어를 까먹는 문제가 생기죠.
마치 긴 이야기를 들을 때 처음 내용이 기억나지 않는 것처럼요. 바로 이럴 때 필요한 것이 Self-Attention입니다.
모든 단어가 동시에 다른 모든 단어를 한 번에 살펴보면서 관계를 파악할 수 있습니다.
개요
간단히 말해서, Self-Attention은 문장 속 각 단어가 다른 모든 단어와 얼마나 관련이 있는지 계산하는 메커니즘입니다. 왜 이것이 필요할까요?
실제 언어에서는 멀리 떨어진 단어들도 서로 연관되어 있습니다. "그 남자는 공원에 갔고, 그곳에서 산책을 했다"에서 "그곳"이 "공원"을 가리키는 것처럼요.
Self-Attention은 이런 장거리 의존성을 효과적으로 포착합니다. 기존 RNN이 왼쪽에서 오른쪽으로 순차적으로 처리했다면, Self-Attention은 모든 단어를 동시에 병렬로 처리합니다.
이는 훨씬 빠르고 정확합니다. Self-Attention의 핵심은 세 가지 벡터입니다: Query(질문), Key(열쇠), Value(값).
각 단어가 Query로 다른 단어들의 Key를 두드리고, 관련성이 높으면 그 단어의 Value 정보를 많이 가져옵니다. 마치 도서관에서 키워드로 책을 찾는 것과 비슷합니다.
코드 예제
import torch
import torch.nn as nn
class SelfAttention(nn.Module):
def __init__(self, embed_size, heads):
super(SelfAttention, self).__init__()
self.embed_size = embed_size # 임베딩 차원 (예: 512)
self.heads = heads # 헤드 개수 (예: 8)
self.head_dim = embed_size // heads # 각 헤드의 차원
# Query, Key, Value를 만드는 선형 변환
self.values = nn.Linear(self.head_dim, self.head_dim, bias=False)
self.keys = nn.Linear(self.head_dim, self.head_dim, bias=False)
self.queries = nn.Linear(self.head_dim, self.head_dim, bias=False)
self.fc_out = nn.Linear(heads * self.head_dim, embed_size)
def forward(self, values, keys, query, mask):
N = query.shape[0] # 배치 크기
value_len, key_len, query_len = values.shape[1], keys.shape[1], query.shape[1]
# Query, Key, Value 계산
values = self.values(values)
keys = self.keys(keys)
queries = self.queries(query)
# Attention 점수 계산: Query와 Key의 내적
energy = torch.einsum("nqhd,nkhd->nhqk", [queries, keys])
# 스케일링 및 소프트맥스로 확률 분포 생성
attention = torch.softmax(energy / (self.embed_size ** (1/2)), dim=3)
# Attention 가중치를 Value에 적용
out = torch.einsum("nhql,nlhd->nqhd", [attention, values])
return out
설명
이것이 하는 일: Self-Attention은 입력 문장의 각 단어에 대해 다른 모든 단어와의 관련성을 수치로 계산하고, 중요한 단어의 정보를 더 많이 반영합니다. 첫 번째 단계로, 각 단어를 Query, Key, Value 세 가지 벡터로 변환합니다.
이는 선형 변환(Linear layer)을 통해 이루어집니다. Query는 "나는 무엇을 찾고 있는가?", Key는 "나는 무엇을 제공하는가?", Value는 "내가 제공할 실제 정보"를 의미합니다.
이렇게 분리하는 이유는 검색과 정보 전달을 명확히 구분하기 위해서입니다. 두 번째로, Query와 Key의 내적(dot product)을 계산하여 attention score를 구합니다.
이 점수가 높을수록 두 단어가 서로 관련이 깊다는 의미입니다. 예를 들어 "좋아해"의 Query가 "강아지"의 Key와 높은 점수를 받으면, 이 둘이 의미적으로 연결되어 있음을 알 수 있습니다.
점수를 임베딩 차원의 제곱근으로 나누는 것은 그래디언트 안정성을 위한 스케일링입니다. 세 번째로, softmax 함수를 적용해 attention score를 확률 분포로 변환합니다.
이렇게 하면 모든 단어에 대한 가중치의 합이 1이 되어, 각 단어가 전체 문맥에서 얼마나 중요한지 비율로 표현됩니다. 마지막으로 이 가중치를 Value 벡터에 곱하여 최종 출력을 만듭니다.
여러분이 이 코드를 사용하면 긴 문장에서도 멀리 떨어진 단어 간의 관계를 정확히 파악할 수 있습니다. 번역, 요약, 질의응답 등 다양한 NLP 태스크에서 뛰어난 성능을 보입니다.
특히 병렬 처리가 가능해 GPU를 효율적으로 활용할 수 있으며, RNN 대비 10배 이상 빠른 학습 속도를 자랑합니다.
실전 팁
💡 embed_size는 헤드 개수로 나누어떨어지게 설정하세요. 보통 512차원에 8개 헤드를 사용합니다.
💡 긴 시퀀스에서는 메모리 사용량이 시퀀스 길이의 제곱에 비례해 증가합니다. 512 토큰 이상은 gradient checkpointing을 고려하세요.
💡 attention score를 시각화하면 모델이 어떤 단어에 집중하는지 볼 수 있어 디버깅에 유용합니다.
💡 mask 파라미터로 padding 토큰이나 미래 토큰을 가릴 수 있습니다. decoder에서는 미래를 보지 못하게 해야 합니다.
💡 학습 초기에는 attention이 고르게 분산되지만, 학습이 진행될수록 특정 단어에 집중하는 패턴이 나타납니다.
2. Multi-Head Attention - 여러 관점으로 문장 바라보기
시작하며
여러분이 그림을 볼 때 정면에서만 보시나요, 아니면 여러 각도에서 보시나요? 복잡한 조각품은 앞, 옆, 위에서 봐야 전체 모습을 이해할 수 있습니다.
문장도 마찬가지입니다. "사과를 먹는 남자"라는 문장을 분석할 때, 한 가지 관점만으로는 "누가 먹는지", "무엇을 먹는지", "언제 먹는지" 같은 다양한 정보를 모두 포착하기 어렵습니다.
단일 Self-Attention은 하나의 관점만 제공합니다. 바로 이럴 때 필요한 것이 Multi-Head Attention입니다.
여러 개의 attention 헤드가 동시에 서로 다른 관점에서 문장을 분석합니다.
개요
간단히 말해서, Multi-Head Attention은 여러 개의 Self-Attention을 병렬로 실행하여 다양한 관점에서 문장을 이해하는 메커니즘입니다. 왜 여러 헤드가 필요할까요?
실제 연구에서 각 헤드는 서로 다른 언어적 특징을 학습합니다. 어떤 헤드는 문법적 관계를, 어떤 헤드는 의미적 유사성을, 또 다른 헤드는 위치 정보를 포착합니다.
예를 들어 번역 태스크에서 한 헤드는 주어-동사 관계를, 다른 헤드는 형용사-명사 관계를 학습할 수 있습니다. 기존에 하나의 큰 attention을 사용했다면, 이제는 작은 attention 여러 개를 사용합니다.
각 헤드는 전체 차원의 일부만 사용하므로 계산 비용은 비슷하지만 표현력은 훨씬 풍부해집니다. Multi-Head Attention의 핵심은 분할과 결합입니다.
임베딩을 여러 조각으로 나누고, 각 조각마다 독립적인 attention을 수행한 후, 결과를 다시 합칩니다. 마치 여러 전문가가 각자의 관점에서 분석한 후 종합 의견을 내는 것과 같습니다.
코드 예제
import torch
import torch.nn as nn
class MultiHeadAttention(nn.Module):
def __init__(self, embed_size, heads):
super(MultiHeadAttention, self).__init__()
self.embed_size = embed_size # 전체 임베딩 차원
self.heads = heads # attention 헤드 개수
self.head_dim = embed_size // heads # 각 헤드의 차원
assert (self.head_dim * heads == embed_size), "Embed size needs to be divisible by heads"
# Query, Key, Value 생성을 위한 선형 변환
self.values = nn.Linear(embed_size, embed_size)
self.keys = nn.Linear(embed_size, embed_size)
self.queries = nn.Linear(embed_size, embed_size)
self.fc_out = nn.Linear(embed_size, embed_size)
def forward(self, values, keys, query, mask):
N = query.shape[0] # 배치 크기
value_len, key_len, query_len = values.shape[1], keys.shape[1], query.shape[1]
# 선형 변환 적용
values = self.values(values) # (N, value_len, embed_size)
keys = self.keys(keys)
queries = self.queries(query)
# 여러 헤드로 분할: (N, seq_len, embed_size) -> (N, seq_len, heads, head_dim)
values = values.reshape(N, value_len, self.heads, self.head_dim)
keys = keys.reshape(N, key_len, self.heads, self.head_dim)
queries = queries.reshape(N, query_len, self.heads, self.head_dim)
# Scaled Dot-Product Attention
energy = torch.einsum("nqhd,nkhd->nhqk", [queries, keys])
if mask is not None:
energy = energy.masked_fill(mask == 0, float("-1e20"))
# Softmax로 attention 가중치 계산
attention = torch.softmax(energy / (self.embed_size ** (1/2)), dim=3)
# Value에 attention 가중치 적용
out = torch.einsum("nhql,nlhd->nqhd", [attention, values]).reshape(
N, query_len, self.heads * self.head_dim
)
# 최종 선형 변환
out = self.fc_out(out)
return out
설명
이것이 하는 일: Multi-Head Attention은 입력을 여러 부분공간(subspace)으로 나누고, 각 부분공간에서 독립적으로 attention을 계산한 후 결과를 결합하여 풍부한 표현을 만듭니다. 첫 번째 단계로, 입력 임베딩을 선형 변환하여 Query, Key, Value를 생성합니다.
이때 각각의 크기는 원래 임베딩 크기와 동일합니다. 그런 다음 이들을 heads 개수만큼의 조각으로 나눕니다.
예를 들어 512차원을 8개 헤드로 나누면 각 헤드는 64차원을 담당합니다. 이렇게 분할하는 이유는 각 헤드가 서로 다른 표현 부분공간에서 패턴을 학습하도록 하기 위함입니다.
두 번째로, 각 헤드에서 독립적으로 Scaled Dot-Product Attention을 수행합니다. 이는 앞서 설명한 Self-Attention과 동일한 과정입니다.
torch.einsum을 사용하면 모든 헤드의 attention을 한 번에 병렬로 계산할 수 있어 매우 효율적입니다. mask가 제공되면 특정 위치(예: padding)의 attention을 -무한대로 설정하여 softmax 후 0이 되도록 만듭니다.
세 번째로, 모든 헤드의 출력을 concatenate하여 하나로 합칩니다. 8개 헤드 각각이 64차원 출력을 만들었다면, 이를 이어붙여 512차원으로 만듭니다.
마지막으로 최종 선형 변환(fc_out)을 통해 출력을 조정합니다. 이 레이어는 여러 헤드의 정보를 통합하고 다음 레이어에 적합한 형태로 변환하는 역할을 합니다.
여러분이 이 코드를 사용하면 복잡한 문맥 의존성을 다각도로 포착할 수 있습니다. 실제로 BERT는 12개 레이어에 각각 12개 헤드를, GPT-3는 96개 레이어에 각각 96개 헤드를 사용합니다.
헤드가 많을수록 더 세밀한 패턴을 학습하지만, 계산 비용도 증가하므로 적절한 균형이 필요합니다.
실전 팁
💡 헤드 개수는 보통 8, 12, 16을 사용합니다. 헤드가 너무 많으면 각 헤드의 차원이 작아져 표현력이 떨어질 수 있습니다.
💡 각 헤드가 무엇을 학습하는지 시각화하면 흥미로운 패턴을 발견할 수 있습니다. 일부 헤드는 다음 단어를, 일부는 이전 단어를 주로 봅니다.
💡 학습 안정성을 위해 attention 출력에 Dropout을 적용하는 것이 좋습니다. 보통 0.1 정도를 사용합니다.
💡 메모리 효율을 위해 Flash Attention 같은 최적화 기법을 사용하면 메모리를 크게 줄이면서 속도도 향상됩니다.
💡 Fine-tuning 시 특정 헤드만 선택적으로 학습하거나 pruning하여 모델 크기를 줄일 수 있습니다.
3. Positional Encoding - 단어의 순서 정보 주입하기
시작하며
여러분이 "개가 고양이를 쫓았다"와 "고양이가 개를 쫓았다"를 읽을 때, 의미가 완전히 다르다는 것을 알 수 있습니다. 순서가 바뀌면 전혀 다른 이야기가 되죠.
그런데 Self-Attention은 모든 단어를 동시에 처리하기 때문에 단어의 순서 정보가 자연스럽게 포함되지 않습니다. "개 고양이 쫓았다"나 "쫓았다 개 고양이"나 attention 계산 결과는 동일합니다.
이는 심각한 문제입니다. 바로 이럴 때 필요한 것이 Positional Encoding입니다.
각 위치마다 고유한 패턴을 만들어 단어 임베딩에 더해줌으로써 순서 정보를 주입합니다.
개요
간단히 말해서, Positional Encoding은 각 단어의 위치를 나타내는 벡터를 만들어 단어 임베딩에 더하는 기법입니다. 왜 이것이 필요할까요?
Transformer는 순차 처리가 없는 병렬 아키텍처이기 때문에 위치 정보를 명시적으로 제공해야 합니다. 만약 위치 정보가 없다면 "나는 학교에 간다"와 "간다 학교에 나는"을 구분할 수 없습니다.
Positional Encoding은 이 문제를 해결하여 단어의 순서와 상대적 위치를 모델이 인식하게 합니다. 기존 RNN은 순차 처리 자체가 위치 정보를 제공했지만, Transformer는 직접 인코딩해야 합니다.
단순히 0, 1, 2, 3... 같은 숫자를 쓸 수도 있지만, 이는 문장 길이에 따라 스케일이 달라지는 문제가 있습니다.
Positional Encoding의 핵심은 사인과 코사인 함수를 사용하는 것입니다. 각 위치에 대해 서로 다른 주파수의 사인/코사인 값을 조합하여 고유한 패턴을 만듭니다.
이는 마치 바코드처럼 각 위치를 식별할 수 있는 독특한 신호를 제공합니다.
코드 예제
import torch
import torch.nn as nn
import math
class PositionalEncoding(nn.Module):
def __init__(self, embed_size, max_len=5000):
super(PositionalEncoding, self).__init__()
# 최대 길이만큼의 위치 인코딩 생성
pe = torch.zeros(max_len, embed_size)
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
# 서로 다른 주파수를 가진 사인/코사인 함수 생성
div_term = torch.exp(torch.arange(0, embed_size, 2).float() *
(-math.log(10000.0) / embed_size))
# 짝수 차원에는 사인, 홀수 차원에는 코사인 적용
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
pe = pe.unsqueeze(0) # 배치 차원 추가
self.register_buffer('pe', pe) # 학습되지 않는 파라미터로 등록
def forward(self, x):
# 입력에 위치 인코딩 더하기
seq_len = x.size(1)
x = x + self.pe[:, :seq_len, :]
return x
# 사용 예시
embed_size = 512
max_len = 100
pos_encoder = PositionalEncoding(embed_size, max_len)
# 문장 임베딩 (batch_size=2, seq_len=10, embed_size=512)
word_embeddings = torch.randn(2, 10, embed_size)
output = pos_encoder(word_embeddings)
설명
이것이 하는 일: Positional Encoding은 각 위치(0, 1, 2, ...)에 대해 고유한 벡터를 생성하고, 이를 단어 임베딩과 더하여 위치 정보를 주입합니다. 첫 번째 단계로, 최대 시퀀스 길이만큼의 위치 인코딩 행렬을 미리 생성합니다.
각 위치 pos에 대해 임베딩 차원만큼의 값을 계산합니다. div_term은 서로 다른 주파수를 만들기 위한 항으로, 차원이 높아질수록 주파수가 낮아집니다.
이렇게 하면 낮은 차원은 빠르게 변하는 패턴을, 높은 차원은 천천히 변하는 패턴을 가지게 됩니다. 두 번째로, 각 위치와 차원 조합에 대해 사인 또는 코사인 값을 계산합니다.
짝수 차원(0, 2, 4, ...)에는 sin(pos / 10000^(2i/d))를, 홀수 차원(1, 3, 5, ...)에는 cos(pos / 10000^(2i/d))를 사용합니다. 사인과 코사인을 번갈아 사용하는 이유는 상대적 위치 관계를 선형 변환으로 표현할 수 있게 하기 위함입니다.
즉, PE(pos+k)를 PE(pos)의 선형 결합으로 나타낼 수 있습니다. 세 번째로, 생성된 위치 인코딩을 register_buffer로 등록합니다.
이는 모델의 파라미터가 아니라 상수로 취급되어 학습 중에 업데이트되지 않지만, 모델과 함께 저장되고 device 이동 시 함께 움직입니다. forward 메서드에서는 입력 시퀀스 길이만큼만 잘라서 단어 임베딩에 더합니다.
여러분이 이 코드를 사용하면 어떤 길이의 문장이든 각 위치를 고유하게 식별할 수 있습니다. 실제로 원본 Transformer 논문에서 제안된 이 방식은 매우 효과적이어서 대부분의 Transformer 모델이 사용합니다.
사인/코사인 함수의 주기적 특성 덕분에 학습 시 본 적 없는 긴 시퀀스도 어느 정도 처리할 수 있습니다(외삽 능력).
실전 팁
💡 max_len은 예상되는 최대 시퀀스 길이보다 넉넉하게 설정하세요. 보통 5000이면 충분하지만, 메모리는 거의 사용하지 않습니다.
💡 학습 가능한 positional embedding을 사용할 수도 있습니다(BERT 방식). 이는 더 유연하지만 학습 데이터의 최대 길이를 넘기 어렵습니다.
💡 위치 인코딩을 시각화하면 아름다운 줄무늬 패턴을 볼 수 있습니다. 각 차원마다 다른 주파수의 파동이 보입니다.
💡 최근 연구에서는 Rotary Position Embedding(RoPE) 같은 개선된 방법도 제안되었습니다. 더 나은 외삽 성능을 보입니다.
💡 위치 인코딩은 dropout 전에 추가하는 것이 일반적입니다. 순서는 Embedding -> Positional Encoding -> Dropout입니다.
4. Encoder Layer - 입력을 깊이 이해하기
시작하며
여러분이 외국어 문장을 번역할 때, 한 번 읽고 바로 이해되나요? 보통은 여러 번 읽으면서 단어 관계, 문법 구조, 문맥 등을 점진적으로 파악합니다.
Transformer의 Encoder도 마찬가지입니다. 한 층의 attention만으로는 복잡한 문장을 완전히 이해하기 어렵습니다.
"그 남자가 공원에서 만난 여자는 의사였다" 같은 문장은 주어-동사 관계, 관계절, 의미 등 여러 층위의 분석이 필요합니다. 바로 이럴 때 필요한 것이 여러 층의 Encoder Layer를 쌓는 것입니다.
각 층마다 점점 더 추상적이고 깊은 표현을 학습합니다.
개요
간단히 말해서, Encoder Layer는 Multi-Head Attention과 Feed-Forward Network를 결합한 하나의 처리 블록이며, 여러 개를 쌓아서 사용합니다. 왜 여러 층이 필요할까요?
실제 연구에서 하위 층은 문법과 구문을, 중간 층은 의미론적 관계를, 상위 층은 고수준의 추상적 개념을 학습하는 것으로 나타났습니다. 예를 들어 번역 모델에서 첫 번째 층은 품사를, 두 번째 층은 구문 구조를, 세 번째 층은 의미 관계를 포착합니다.
이러한 계층적 학습은 깊이 있는 이해를 가능하게 합니다. 기존 단일 층 모델은 표현력이 제한적이었습니다.
하지만 6~12개 층을 쌓으면 매우 복잡한 패턴도 학습할 수 있습니다. Encoder Layer의 핵심 구조는 두 개의 서브레이어입니다: Multi-Head Attention과 Position-wise Feed-Forward Network.
각 서브레이어 주위에는 Residual Connection과 Layer Normalization이 적용됩니다. 이는 깊은 네트워크의 학습을 안정화시킵니다.
코드 예제
import torch
import torch.nn as nn
class EncoderLayer(nn.Module):
def __init__(self, embed_size, heads, forward_expansion, dropout):
super(EncoderLayer, self).__init__()
# Multi-Head Attention 서브레이어
self.attention = MultiHeadAttention(embed_size, heads)
# Layer Normalization (Attention 후)
self.norm1 = nn.LayerNorm(embed_size)
# Feed-Forward Network
self.feed_forward = nn.Sequential(
nn.Linear(embed_size, forward_expansion * embed_size),
nn.ReLU(),
nn.Linear(forward_expansion * embed_size, embed_size)
)
# Layer Normalization (FFN 후)
self.norm2 = nn.LayerNorm(embed_size)
self.dropout = nn.Dropout(dropout)
def forward(self, value, key, query, mask):
# Multi-Head Self-Attention with Residual Connection
attention = self.attention(value, key, query, mask)
x = self.dropout(self.norm1(attention + query)) # Add & Norm
# Feed-Forward Network with Residual Connection
forward = self.feed_forward(x)
out = self.dropout(self.norm2(forward + x)) # Add & Norm
return out
# 사용 예시
encoder_layer = EncoderLayer(
embed_size=512,
heads=8,
forward_expansion=4, # FFN 중간 차원은 보통 4배
dropout=0.1
)
x = torch.randn(2, 10, 512) # (batch, seq_len, embed_size)
output = encoder_layer(x, x, x, mask=None)
설명
이것이 하는 일: Encoder Layer는 입력 시퀀스를 받아 self-attention으로 단어 간 관계를 파악하고, feed-forward network로 각 위치의 표현을 변환하여 더 풍부한 표현을 출력합니다. 첫 번째 서브레이어에서는 Multi-Head Self-Attention을 수행합니다.
여기서 query, key, value가 모두 같은 입력이기 때문에 "self"-attention이라고 부릅니다. Attention 결과에 원본 입력(query)을 더하는 Residual Connection을 적용합니다.
이는 깊은 네트워크에서 그래디언트가 잘 흐르도록 돕고, 각 층이 이전 표현과의 차이(잔차)만 학습하면 되도록 합니다. 그 다음 Layer Normalization을 적용하여 분포를 안정화시킵니다.
두 번째 서브레이어는 Position-wise Feed-Forward Network입니다. 이는 각 위치에 독립적으로 적용되는 2층 신경망입니다.
첫 번째 선형 변환에서 차원을 확장하고(보통 4배), ReLU 활성화 함수를 거친 후, 다시 원래 차원으로 축소합니다. 이 과정은 각 토큰의 표현을 비선형적으로 변환하여 더 복잡한 특징을 추출합니다.
마찬가지로 Residual Connection과 Layer Normalization이 적용됩니다. Dropout은 두 서브레이어의 출력에 모두 적용되어 과적합을 방지합니다.
Layer Normalization의 위치는 중요한데, 원본 논문은 서브레이어 후에 적용했지만(Post-LN), 최근에는 서브레이어 전에 적용하는 Pre-LN 방식이 학습 안정성 면에서 더 선호됩니다. 여러분이 이 코드를 사용하면 복잡한 언어 구조를 계층적으로 학습할 수 있습니다.
BERT는 12개 또는 24개의 Encoder Layer를, GPT-3는 96개를 사용합니다. 층이 깊을수록 더 추상적인 표현을 학습하지만, 메모리와 계산 비용이 증가하므로 태스크에 맞는 적절한 깊이를 선택해야 합니다.
실전 팁
💡 forward_expansion은 보통 4를 사용합니다. 이는 경험적으로 잘 작동하는 값입니다.
💡 Residual Connection 덕분에 100개 이상의 층도 학습 가능하지만, 실무에서는 6~24층이 가장 흔합니다.
💡 Pre-LN(서브레이어 전 normalization)과 Post-LN(후 normalization) 중 Pre-LN이 학습 초기 안정성이 더 좋습니다.
💡 각 층의 attention 패턴을 시각화하면 하위 층은 인접 단어를, 상위 층은 먼 단어를 더 많이 보는 경향이 있습니다.
💡 Fine-tuning 시 하위 층은 고정하고 상위 층만 학습하면 계산량을 줄이면서도 좋은 성능을 얻을 수 있습니다.
5. Decoder Layer - 출력을 단계적으로 생성하기
시작하며
여러분이 번역을 할 때, 원문을 이해한 후 번역문을 한 단어씩 생성합니다. "I love dogs"를 "나는 개를 좋아한다"로 번역할 때, "나는"을 먼저 쓰고, 그 다음 "개를", 그 다음 "좋아한다"를 씁니다.
Encoder가 입력을 이해하는 역할이라면, Decoder는 출력을 생성하는 역할입니다. 하지만 Decoder는 이미 생성한 단어들과 Encoder의 정보를 모두 고려해야 합니다.
또한 미래의 단어를 미리 보면 안 됩니다(그러면 학습이 의미가 없죠). 바로 이럴 때 필요한 것이 Decoder Layer입니다.
Encoder Layer와 유사하지만, Masked Self-Attention과 Encoder-Decoder Attention이라는 추가 메커니즘이 있습니다.
개요
간단히 말해서, Decoder Layer는 이전에 생성된 토큰들을 보면서(Masked Self-Attention), Encoder의 정보를 참조하여(Cross-Attention) 다음 토큰을 예측하는 레이어입니다. 왜 Encoder와 다른 구조가 필요할까요?
Decoder는 순차적 생성이라는 특수한 역할을 합니다. 학습 시에는 정답 시퀀스를 알고 있지만, 각 위치에서 이후 위치를 보지 못하게 막아야 합니다(teacher forcing).
추론 시에는 실제로 한 단어씩 생성하면서 이전 출력을 다음 입력으로 사용합니다. 예를 들어 "The cat"을 번역할 때, "그"를 생성한 후 "그"를 입력으로 사용하여 "고양이"를 생성합니다.
기존 Encoder는 양방향으로 모든 정보를 볼 수 있지만, Decoder는 과거만 볼 수 있어야 합니다. 또한 입력 문장의 정보도 참조해야 합니다.
Decoder Layer의 핵심은 세 개의 서브레이어입니다: Masked Self-Attention(이전 출력을 본다), Encoder-Decoder Attention(입력 문장을 본다), Feed-Forward Network(표현을 변환한다). 이 세 가지가 협력하여 문맥에 맞는 다음 단어를 예측합니다.
코드 예제
import torch
import torch.nn as nn
class DecoderLayer(nn.Module):
def __init__(self, embed_size, heads, forward_expansion, dropout):
super(DecoderLayer, self).__init__()
# Masked Self-Attention (이전 토큰들만 본다)
self.attention = MultiHeadAttention(embed_size, heads)
self.norm1 = nn.LayerNorm(embed_size)
# Encoder-Decoder Attention (Encoder 출력을 본다)
self.cross_attention = MultiHeadAttention(embed_size, heads)
self.norm2 = nn.LayerNorm(embed_size)
# Feed-Forward Network
self.feed_forward = nn.Sequential(
nn.Linear(embed_size, forward_expansion * embed_size),
nn.ReLU(),
nn.Linear(forward_expansion * embed_size, embed_size)
)
self.norm3 = nn.LayerNorm(embed_size)
self.dropout = nn.Dropout(dropout)
def forward(self, x, value, key, src_mask, trg_mask):
# 1. Masked Self-Attention (미래를 보지 못하게)
attention = self.attention(x, x, x, trg_mask)
query = self.dropout(self.norm1(attention + x))
# 2. Encoder-Decoder Cross-Attention
# Query는 decoder에서, Key와 Value는 encoder에서
attention = self.cross_attention(value, key, query, src_mask)
query = self.dropout(self.norm2(attention + query))
# 3. Feed-Forward Network
forward = self.feed_forward(query)
out = self.dropout(self.norm3(forward + query))
return out
# 사용 예시
decoder_layer = DecoderLayer(embed_size=512, heads=8, forward_expansion=4, dropout=0.1)
# Encoder 출력 (번역할 원문에 대한 표현)
enc_output = torch.randn(2, 15, 512) # (batch, src_len, embed_size)
# Decoder 입력 (현재까지 생성된 번역문)
dec_input = torch.randn(2, 10, 512) # (batch, trg_len, embed_size)
# 미래를 보지 못하게 하는 마스크 생성
trg_len = 10
trg_mask = torch.tril(torch.ones(trg_len, trg_len)).unsqueeze(0).unsqueeze(0)
output = decoder_layer(dec_input, enc_output, enc_output, src_mask=None, trg_mask=trg_mask)
설명
이것이 하는 일: Decoder Layer는 현재까지 생성된 출력 시퀀스와 Encoder의 입력 표현을 모두 활용하여, 다음에 올 토큰을 예측하기 위한 표현을 만듭니다. 첫 번째 서브레이어는 Masked Self-Attention입니다.
일반 Self-Attention과 비슷하지만, trg_mask라는 특별한 마스크를 사용합니다. 이 마스크는 하삼각 행렬(lower triangular matrix) 형태로, 각 위치가 자신과 이전 위치만 볼 수 있게 합니다.
예를 들어 3번째 단어는 0, 1, 2번째 단어만 볼 수 있고 4번째 이후는 볼 수 없습니다. torch.tril 함수로 쉽게 만들 수 있습니다.
이는 학습 시 정답을 미리 보는 것을 방지하고, 추론 시 실제 생성 과정을 모방합니다. 두 번째 서브레이어는 Encoder-Decoder Cross-Attention입니다.
여기서 Query는 Decoder의 현재 표현에서 오고, Key와 Value는 Encoder의 출력에서 옵니다. 이는 "내가 지금 생성하는 단어는 원문의 어느 부분을 참조해야 하는가?"를 결정합니다.
예를 들어 "개를" 생성할 때 원문의 "dogs"에 높은 attention을 줍니다. 이 메커니즘 덕분에 번역 시 source 문장과 target 문장을 정렬(alignment)할 수 있습니다.
세 번째 서브레이어는 Position-wise Feed-Forward Network로, Encoder와 동일합니다. 각 토큰의 표현을 비선형 변환하여 더 풍부하게 만듭니다.
모든 서브레이어에는 Residual Connection과 Layer Normalization이 적용됩니다. 여러분이 이 코드를 사용하면 기계 번역, 텍스트 요약, 대화 생성 등 sequence-to-sequence 태스크를 수행할 수 있습니다.
Decoder를 6~12개 쌓으면 복잡한 생성 패턴도 학습합니다. 실제로 GPT 모델은 Encoder 없이 Decoder만 사용하는 구조로, 수십억 개의 파라미터로 놀라운 텍스트 생성 능력을 보여줍니다.
실전 팁
💡 trg_mask는 매번 생성할 필요 없이 미리 만들어두고 재사용하면 효율적입니다.
💡 Cross-Attention의 가중치를 시각화하면 alignment matrix를 볼 수 있습니다. 번역 품질 분석에 유용합니다.
💡 학습 시 teacher forcing(정답 사용)과 추론 시 autoregressive 생성의 불일치가 문제가 될 수 있습니다. Scheduled sampling으로 완화 가능합니다.
💡 Beam search를 사용하면 greedy decoding보다 더 나은 출력을 생성할 수 있습니다. Beam size 5~10이 일반적입니다.
💡 메모리 효율을 위해 KV cache를 사용하면 추론 속도를 크게 높일 수 있습니다. 이전 단계의 Key, Value를 저장하여 재계산을 피합니다.
6. Feed-Forward Network - 각 위치의 표현 변환하기
시작하며
여러분이 단어의 의미를 이해한 후, 그것을 더 깊이 있게 해석하고 싶을 때가 있습니다. "사과"라는 단어를 보고 "과일", "빨간색", "달콤함" 같은 다양한 특징을 떠올리는 것처럼요.
Self-Attention은 단어 간 관계를 파악하는 데는 탁월하지만, 각 단어의 표현 자체를 풍부하게 만드는 데는 한계가 있습니다. Attention 후에도 각 토큰의 표현을 더 복잡하고 비선형적으로 변환할 필요가 있습니다.
바로 이럴 때 필요한 것이 Feed-Forward Network입니다. 각 위치에 독립적으로 적용되는 신경망으로, 표현을 더 풍부하게 만듭니다.
개요
간단히 말해서, Feed-Forward Network는 각 토큰 위치마다 동일하게 적용되는 2층 신경망으로, 표현의 차원을 확장했다가 다시 축소하면서 비선형 변환을 수행합니다. 왜 이것이 필요할까요?
Attention은 선형 변환의 조합이기 때문에 표현력에 제약이 있습니다. Feed-Forward Network의 ReLU 같은 비선형 활성화 함수는 더 복잡한 함수를 근사할 수 있게 해줍니다.
실제로 FFN은 각 토큰이 주변 문맥 정보를 고려한 후, 자신의 표현을 정제하고 강화하는 역할을 합니다. 예를 들어 "bank"라는 단어가 "river bank"인지 "savings bank"인지 attention으로 파악한 후, FFN이 해당 의미에 맞는 특징을 강조합니다.
기존에 단순한 선형 변환만 사용했다면, FFN은 중간에 차원을 크게 확장하여 더 많은 표현 공간을 제공합니다. 보통 4배로 확장합니다(512 -> 2048 -> 512).
FFN의 핵심은 Position-wise라는 점입니다. 즉, 모든 위치에 동일한 가중치가 적용되지만, 각 위치는 독립적으로 처리됩니다.
이는 합성곱 신경망의 1x1 convolution과 유사합니다.
코드 예제
import torch
import torch.nn as nn
class FeedForward(nn.Module):
def __init__(self, embed_size, forward_expansion=4, dropout=0.1):
super(FeedForward, self).__init__()
# 첫 번째 선형 변환: 차원 확장
self.fc1 = nn.Linear(embed_size, forward_expansion * embed_size)
# 비선형 활성화 함수
self.activation = nn.ReLU()
# 두 번째 선형 변환: 원래 차원으로 축소
self.fc2 = nn.Linear(forward_expansion * embed_size, embed_size)
# 정규화
self.dropout = nn.Dropout(dropout)
def forward(self, x):
# x shape: (batch_size, seq_len, embed_size)
# 차원 확장 및 활성화
x = self.activation(self.fc1(x)) # (batch, seq_len, 4*embed_size)
# Dropout 적용
x = self.dropout(x)
# 원래 차원으로 축소
x = self.fc2(x) # (batch, seq_len, embed_size)
return x
# 사용 예시
ffn = FeedForward(embed_size=512, forward_expansion=4, dropout=0.1)
# 입력: (batch_size=2, seq_len=10, embed_size=512)
x = torch.randn(2, 10, 512)
output = ffn(x)
print(f"Input shape: {x.shape}")
print(f"Output shape: {output.shape}")
# GELU 활성화 함수 사용 예시 (BERT, GPT에서 사용)
class FeedForwardGELU(nn.Module):
def __init__(self, embed_size, forward_expansion=4, dropout=0.1):
super(FeedForwardGELU, self).__init__()
self.net = nn.Sequential(
nn.Linear(embed_size, forward_expansion * embed_size),
nn.GELU(), # ReLU 대신 GELU 사용
nn.Dropout(dropout),
nn.Linear(forward_expansion * embed_size, embed_size)
)
def forward(self, x):
return self.net(x)
설명
이것이 하는 일: Feed-Forward Network는 attention으로 얻은 문맥 정보를 바탕으로 각 토큰의 표현을 더 복잡하고 의미 있게 변환합니다. 첫 번째 단계로, fc1 레이어가 임베딩 차원을 확장합니다.
예를 들어 512차원을 2048차원으로 늘립니다. 이렇게 차원을 확장하는 이유는 더 넓은 표현 공간을 제공하여 복잡한 패턴을 학습할 수 있게 하기 위함입니다.
높은 차원에서는 더 많은 특징 조합이 가능합니다. 이는 정보의 병목을 해소하고, 모델이 중간 단계에서 풍부한 표현을 만들 수 있게 합니다.
두 번째로, ReLU 활성화 함수가 비선형성을 도입합니다. ReLU는 max(0, x)로 간단하지만 효과적입니다.
최근 모델들은 GELU(Gaussian Error Linear Unit)를 더 많이 사용하는데, 이는 ReLU보다 부드러운 비선형성을 제공합니다. GELU는 x * Φ(x) 형태로, 입력의 누적 분포 함수를 사용합니다.
BERT, GPT-2, GPT-3 모두 GELU를 사용합니다. Dropout은 확장된 표현에 적용되어 과적합을 방지합니다.
세 번째로, fc2 레이어가 차원을 원래대로 축소합니다. 2048차원을 다시 512차원으로 줄입니다.
이 과정에서 중간 단계의 풍부한 정보를 압축하여 핵심적인 특징만 남깁니다. 최종 출력은 원래 임베딩과 같은 차원이므로 Residual Connection으로 더할 수 있습니다.
여러분이 이 코드를 사용하면 Transformer의 표현력을 크게 높일 수 있습니다. 실제로 FFN의 파라미터는 전체 Transformer 파라미터의 약 2/3를 차지합니다.
forward_expansion을 조정하여 모델 크기와 성능을 조절할 수 있습니다. 더 큰 expansion(예: 8)은 더 강력하지만 메모리를 많이 사용하고, 작은 expansion(예: 2)은 가볍지만 표현력이 제한됩니다.
실전 팁
💡 forward_expansion은 4가 표준이지만, 작은 모델에서는 2, 큰 모델에서는 8도 사용됩니다.
💡 GELU는 ReLU보다 일반적으로 약간 더 나은 성능을 보입니다. torch.nn.GELU()로 쉽게 사용 가능합니다.
💡 최근 연구에서는 GLU(Gated Linear Unit) 변형들이 더 좋은 결과를 보인다고 합니다. SwiGLU, GeGLU 등을 시도해보세요.
💡 FFN의 중간 활성화를 분석하면 특정 뉴런이 특정 개념(예: "부정적 감정", "시간 표현")에 반응하는 것을 발견할 수 있습니다.
💡 큰 모델에서는 fc1, fc2 레이어를 전문가 혼합(Mixture of Experts, MoE)으로 대체하여 파라미터 효율을 높이는 기법도 있습니다.
7. Layer Normalization - 학습 안정화하기
시작하며
여러분이 요리를 할 때, 재료의 양이 들쭉날쭉하면 맛이 일정하지 않습니다. 밀가루를 어떤 때는 100g, 어떤 때는 500g 넣으면 매번 다른 결과가 나오죠.
딥러닝에서도 비슷한 문제가 있습니다. 각 레이어의 출력값 분포가 학습 중에 계속 변하면(Internal Covariate Shift), 다음 레이어는 매번 다른 입력 분포에 적응해야 합니다.
이는 학습을 불안정하게 만들고 느리게 합니다. 바로 이럴 때 필요한 것이 Layer Normalization입니다.
각 샘플의 특징들을 정규화하여 안정적인 분포를 유지합니다.
개요
간단히 말해서, Layer Normalization은 각 샘플의 모든 특징에 대해 평균을 0, 분산을 1로 만드는 정규화 기법입니다. 왜 이것이 필요할까요?
깊은 신경망에서는 그래디언트 소실이나 폭발 문제가 발생하기 쉽습니다. Layer Norm은 활성화 값의 스케일을 일정하게 유지하여 이를 방지합니다.
또한 학습률을 크게 설정할 수 있게 하여 더 빠른 수렴을 가능하게 합니다. 예를 들어 Layer Norm 없이는 학습률 0.0001로 100 에폭이 필요한 모델이, Layer Norm으로는 학습률 0.001로 10 에폭만에 수렴할 수 있습니다.
Batch Normalization이 배치 차원에 걸쳐 정규화한다면, Layer Normalization은 특징 차원에 걸쳐 정규화합니다. 이는 배치 크기에 독립적이어서 작은 배치나 온라인 학습에도 효과적입니다.
Layer Normalization의 핵심은 각 샘플을 독립적으로 정규화한다는 점입니다. 평균과 분산을 계산하고, 이를 이용해 정규화한 후, 학습 가능한 gain과 bias를 적용합니다.
이 gain과 bias는 필요시 원래 분포를 복원할 수 있게 해줍니다.
코드 예제
import torch
import torch.nn as nn
class LayerNormalization(nn.Module):
def __init__(self, embed_size, eps=1e-6):
super(LayerNormalization, self).__init__()
# 학습 가능한 파라미터: gain(gamma), bias(beta)
self.gamma = nn.Parameter(torch.ones(embed_size))
self.beta = nn.Parameter(torch.zeros(embed_size))
# 수치 안정성을 위한 작은 값
self.eps = eps
def forward(self, x):
# x shape: (batch_size, seq_len, embed_size)
# 특징 차원에 걸쳐 평균과 분산 계산
mean = x.mean(dim=-1, keepdim=True) # (batch, seq_len, 1)
var = x.var(dim=-1, keepdim=True, unbiased=False) # (batch, seq_len, 1)
# 정규화: (x - mean) / sqrt(var + eps)
x_norm = (x - mean) / torch.sqrt(var + self.eps)
# 스케일과 시프트 적용
out = self.gamma * x_norm + self.beta
return out
# 사용 예시
layer_norm = LayerNormalization(embed_size=512)
x = torch.randn(2, 10, 512)
output = layer_norm(x)
print(f"Input mean: {x.mean():.4f}, std: {x.std():.4f}")
print(f"Output mean: {output.mean():.4f}, std: {output.std():.4f}")
# PyTorch 내장 LayerNorm 사용 예시
layer_norm_builtin = nn.LayerNorm(512)
output_builtin = layer_norm_builtin(x)
# RMSNorm 변형 (최근 모델에서 사용)
class RMSNorm(nn.Module):
def __init__(self, embed_size, eps=1e-6):
super(RMSNorm, self).__init__()
self.weight = nn.Parameter(torch.ones(embed_size))
self.eps = eps
def forward(self, x):
# 평균을 빼지 않고 RMS로만 정규화
rms = torch.sqrt(x.pow(2).mean(dim=-1, keepdim=True) + self.eps)
return self.weight * x / rms
설명
이것이 하는 일: Layer Normalization은 각 토큰의 모든 차원에 걸쳐 통계량을 계산하고 정규화하여, 레이어 간 활성화 값의 분포를 안정적으로 유지합니다. 첫 번째 단계로, 각 샘플의 각 토큰에 대해 embed_size 차원에 걸쳐 평균과 분산을 계산합니다.
dim=-1은 마지막 차원(특징 차원)을 의미하며, keepdim=True는 차원을 유지하여 브로드캐스팅이 가능하게 합니다. 예를 들어 (2, 10, 512) 입력에서 각 (배치, 위치)마다 512개 값의 평균과 분산을 구합니다.
unbiased=False는 분산 계산 시 n으로 나누어 PyTorch 기본 동작과 일치시킵니다. 두 번째로, 정규화를 수행합니다.
각 값에서 평균을 빼고 표준편차로 나눕니다. eps(epsilon)는 분모가 0이 되는 것을 방지하는 작은 상수입니다.
보통 1e-5나 1e-6을 사용합니다. 이 과정 후 각 토큰의 특징들은 평균 0, 분산 1의 표준 정규 분포를 따르게 됩니다.
이는 다음 레이어가 예측 가능한 입력 분포를 받게 해줍니다. 세 번째로, 학습 가능한 affine transformation을 적용합니다.
gamma(gain)로 곱하고 beta(bias)를 더합니다. 이 파라미터들은 학습 과정에서 최적화됩니다.
왜 정규화 후 다시 변환할까요? 정규화가 항상 최적은 아니기 때문입니다.
모델이 필요하다면 gamma와 beta를 조정하여 원래 분포를 어느 정도 복원할 수 있습니다. 예를 들어 gamma를 원래 표준편차로, beta를 원래 평균으로 학습하면 정규화를 무효화할 수 있습니다.
여러분이 이 코드를 사용하면 훨씬 안정적으로 깊은 Transformer를 학습할 수 있습니다. Layer Norm 없이는 수렴하지 않던 모델이 Layer Norm으로 잘 작동합니다.
최근에는 RMSNorm(Root Mean Square Normalization)이라는 변형도 인기입니다. 이는 평균을 빼는 과정을 생략하여 계산량을 줄이면서도 비슷한 성능을 냅니다.
LLaMA, GPT-NeoX 같은 최신 모델들이 RMSNorm을 사용합니다.
실전 팁
💡 eps 값은 보통 1e-6이 적당하지만, 16-bit 연산에서는 1e-5로 높여야 수치 안정성이 보장됩니다.
💡 Pre-LN(서브레이어 전)과 Post-LN(서브레이어 후) 중 Pre-LN이 깊은 모델에서 더 안정적입니다.
💡 Layer Norm의 gamma와 beta를 초기화할 때, gamma=1, beta=0이 표준이지만, 일부 연구에서는 다른 초기화도 시도합니다.
💡 추론 시에도 Layer Norm은 그대로 적용됩니다. Batch Norm과 달리 running statistics가 필요 없어 구현이 간단합니다.
💡 RMSNorm은 Layer Norm 대비 약 10-15% 빠르면서 성능은 비슷합니다. 큰 모델에서는 이 차이가 의미 있습니다.
8. Residual Connection - 그래디언트 흐름 보장하기
시작하며
여러분이 전화로 긴 메시지를 전달할 때, 여러 사람을 거치면 원래 내용이 왜곡되거나 사라지는 경험을 해보셨나요? 첫 번째 사람이 두 번째 사람에게, 두 번째 사람이 세 번째 사람에게 전달하다 보면 마지막 사람은 원래 메시지를 제대로 받지 못합니다.
딥러닝의 역전파도 비슷합니다. 그래디언트가 깊은 레이어를 통과하면서 점점 작아지거나(소실) 커지면(폭발) 학습이 어려워집니다.
특히 수십 개 레이어를 쌓으면 초기 레이어는 거의 학습되지 않습니다. 바로 이럴 때 필요한 것이 Residual Connection(Skip Connection)입니다.
레이어를 건너뛰는 직통 경로를 만들어 그래디언트가 잘 흐르게 합니다.
개요
간단히 말해서, Residual Connection은 레이어의 입력을 출력에 직접 더하는 기법으로, F(x) + x 형태를 만듭니다. 왜 이것이 필요할까요?
수학적으로 보면, 역전파 시 Residual Connection은 그래디언트에 직접 경로를 제공합니다. dL/dx = dL/dF * dF/dx + dL/dx처럼 두 번째 항이 항상 1의 그래디언트를 전달하므로, 아무리 깊어도 그래디언트가 소실되지 않습니다.
실제로 ResNet 논문에서 152층 네트워크를 성공적으로 학습했고, Transformer도 100층 이상 쌓을 수 있게 되었습니다. 기존에는 f(f(f(x)))처럼 함수를 계속 합성했다면, 이제는 x + f1(x) + f2(x) + ...
처럼 각 레이어가 원래 입력에 무언가를 더하는 형태입니다. 각 레이어는 전체 변환이 아니라 잔차(residual, 차이)만 학습하면 됩니다.
Residual Connection의 핵심은 항등 함수(identity mapping)입니다. 레이어가 필요 없다면 F(x) = 0을 학습하여 x만 전달하면 됩니다.
이는 레이어가 유용할 때만 기여하고, 불필요하면 무시되게 합니다.
코드 예제
import torch
import torch.nn as nn
class ResidualBlock(nn.Module):
def __init__(self, embed_size, heads, forward_expansion, dropout):
super(ResidualBlock, self).__init__()
# Multi-Head Attention 서브레이어
self.attention = MultiHeadAttention(embed_size, heads)
self.norm1 = nn.LayerNorm(embed_size)
# Feed-Forward Network 서브레이어
self.feed_forward = nn.Sequential(
nn.Linear(embed_size, forward_expansion * embed_size),
nn.ReLU(),
nn.Linear(forward_expansion * embed_size, embed_size)
)
self.norm2 = nn.LayerNorm(embed_size)
self.dropout = nn.Dropout(dropout)
def forward(self, x, mask):
# Residual Connection 1: Attention
# x가 원본 입력, attention(x)가 변환
attention_out = self.attention(x, x, x, mask)
x = x + self.dropout(attention_out) # Residual: 원본 + 변환
x = self.norm1(x)
# Residual Connection 2: Feed-Forward
ff_out = self.feed_forward(x)
x = x + self.dropout(ff_out) # Residual: 원본 + 변환
x = self.norm2(x)
return x
# 그래디언트 흐름 시각화 예시
def visualize_gradient_flow():
model = ResidualBlock(embed_size=512, heads=8, forward_expansion=4, dropout=0.1)
x = torch.randn(2, 10, 512, requires_grad=True)
output = model(x, mask=None)
# 임의의 손실 함수
loss = output.sum()
loss.backward()
# 입력의 그래디언트 확인
print(f"Input gradient norm: {x.grad.norm().item():.4f}")
print("Gradient flows successfully through residual connections!")
# Pre-LN vs Post-LN 비교
class PreLNResidualBlock(nn.Module):
"""최근 선호되는 Pre-LN 구조"""
def __init__(self, embed_size, heads, forward_expansion, dropout):
super(PreLNResidualBlock, self).__init__()
self.attention = MultiHeadAttention(embed_size, heads)
self.norm1 = nn.LayerNorm(embed_size)
self.feed_forward = nn.Sequential(
nn.Linear(embed_size, forward_expansion * embed_size),
nn.ReLU(),
nn.Linear(forward_expansion * embed_size, embed_size)
)
self.norm2 = nn.LayerNorm(embed_size)
self.dropout = nn.Dropout(dropout)
def forward(self, x, mask):
# Pre-LN: Normalization이 서브레이어 전에 온다
x = x + self.dropout(self.attention(self.norm1(x), self.norm1(x), self.norm1(x), mask))
x = x + self.dropout(self.feed_forward(self.norm2(x)))
return x
설명
이것이 하는 일: Residual Connection은 레이어를 건너뛰는 직통 경로를 만들어, 역전파 시 그래디언트가 감소 없이 초기 레이어까지 전달되도록 합니다. 첫 번째로, 수학적 원리를 이해해봅시다.
일반 레이어는 y = F(x)이지만, Residual Block은 y = F(x) + x입니다. 역전파 시 dL/dx = dL/dy * (dF/dx + I)가 되어, 항등 행렬 I가 항상 포함됩니다.
이는 그래디언트가 최소한 원본 그래디언트만큼은 전달된다는 의미입니다. 100개 레이어를 쌓아도 그래디언트는 각 레이어를 통과하며 곱셈이 아니라 덧셈으로 전파되므로 소실되지 않습니다.
두 번째로, 학습의 관점에서 보면 Residual Connection은 각 레이어가 잔차(residual, 차이)만 학습하게 합니다. 완전히 새로운 표현을 만드는 것이 아니라, 기존 표현에 작은 개선을 더하는 것입니다.
이는 학습을 훨씬 쉽게 만듭니다. 예를 들어 항등 함수 H(x) = x를 학습하려면, 일반 레이어는 복잡한 가중치 조합을 찾아야 하지만, Residual Block은 그냥 F(x) = 0만 학습하면 됩니다.
세 번째로, 실무에서의 구현을 봅시다. Post-LN 방식은 x + Norm(F(x))처럼 서브레이어 후에 정규화하고, Pre-LN 방식은 x + F(Norm(x))처럼 서브레이어 전에 정규화합니다.
원본 Transformer는 Post-LN을 사용했지만, GPT-2 이후 대부분의 모델은 Pre-LN을 선호합니다. Pre-LN이 초기화에 덜 민감하고 학습이 더 안정적이기 때문입니다.
특히 매우 깊은 모델(50층 이상)에서는 Pre-LN이 필수입니다. 여러분이 이 코드를 사용하면 매우 깊은 Transformer를 안정적으로 학습할 수 있습니다.
ResNet 논문 이후 Residual Connection은 거의 모든 깊은 아키텍처의 표준이 되었습니다. Vision Transformer, BERT, GPT 모두 사용합니다.
실제로 Residual Connection 없이는 6층 이상 쌓기 어렵지만, 있으면 100층도 가능합니다.
실전 팁
💡 Residual Connection을 위해서는 입력과 출력 차원이 같아야 합니다. 차원이 다르면 projection layer가 필요합니다.
💡 Dropout은 residual path가 아니라 서브레이어 출력에만 적용하세요. Residual path는 항상 열려있어야 합니다.
💡 Pre-LN이 Post-LN보다 학습 초기에 안정적이지만, 잘 튜닝된 Post-LN이 약간 더 나은 최종 성능을 보일 수도 있습니다.
💡 매우 깊은 모델(100층 이상)에서는 각 residual branch에 학습 가능한 스케일링 factor를 곱하는 기법도 있습니다(ReZero, FixUp).
💡 Residual Connection의 효과를 시각화하려면 학습 중 각 레이어의 그래디언트 norm을 모니터링하세요. 고른 분포가 보여야 합니다.
9. Masking - 불필요한 정보 차단하기
시작하며
여러분이 시험을 볼 때, 아직 풀지 않은 뒷 문제의 답을 미리 보면 안 되죠? 또는 빈칸이 있는 문장에서 빈칸만 맞춰야 할 때도 있습니다.
Transformer도 비슷한 상황이 있습니다. Decoder는 미래의 단어를 미리 보면 안 되고(그러면 학습이 의미 없죠), padding 토큰은 무시해야 합니다.
Self-Attention은 기본적으로 모든 위치를 보기 때문에, 특정 위치를 가려야 할 때가 있습니다. 바로 이럴 때 필요한 것이 Masking입니다.
Attention score를 계산할 때 특정 위치를 -무한대로 만들어 softmax 후 0이 되게 합니다.
개요
간단히 말해서, Masking은 attention 계산 시 특정 위치를 차단하여 모델이 보지 못하게 하는 기법입니다. 왜 이것이 필요할까요?
두 가지 주요 사용 사례가 있습니다. 첫째, Padding Mask는 배치 내 문장 길이가 다를 때 짧은 문장에 추가된 padding 토큰을 무시합니다.
Padding에 attention을 주면 의미 없는 정보가 섞여 성능이 떨어집니다. 둘째, Look-Ahead Mask(Causal Mask)는 Decoder가 현재와 이전 위치만 보고 미래를 보지 못하게 합니다.
이는 순차적 생성을 올바르게 모방합니다. 기존에 모든 위치를 동등하게 봤다면, 이제는 선택적으로 차단합니다.
Masking은 attention weight 계산 전에 적용되어 근본적으로 정보 흐름을 제어합니다. Masking의 핵심은 softmax 전에 -무한대(실제로는 -1e9 같은 큰 음수)를 더하는 것입니다.
Softmax는 exp 함수를 사용하므로 exp(-무한대) = 0이 되어, 해당 위치의 attention weight가 0이 됩니다.
코드 예제
import torch
import torch.nn as nn
import math
def create_padding_mask(seq, pad_token=0):
"""
Padding 토큰 위치를 마스킹
Args:
seq: (batch_size, seq_len) - 토큰 인덱스 시퀀스
pad_token: padding 토큰의 인덱스
Returns:
mask: (batch_size, 1, 1, seq_len) - attention에 사용할 마스크
"""
# padding 토큰 위치는 False(0), 실제 토큰은 True(1)
mask = (seq != pad_token).unsqueeze(1).unsqueeze(2)
return mask # (batch, 1, 1, seq_len)
def create_look_ahead_mask(size):
"""
미래 위치를 보지 못하게 하는 하삼각 마스크 생성
Args:
size: 시퀀스 길이
Returns:
mask: (size, size) - 하삼각 행렬
"""
# 하삼각 행렬: 대각선과 아래쪽만 1, 위쪽은 0
mask = torch.tril(torch.ones(size, size))
return mask # (seq_len, seq_len)
def apply_mask(attention_scores, mask):
"""
Attention score에 마스크 적용
Args:
attention_scores: (batch, heads, seq_len, seq_len)
mask: 마스크 텐서
Returns:
masked_scores: 마스킹된 attention scores
"""
if mask is not None:
# 마스크가 0인 위치를 매우 큰 음수로 설정
attention_scores = attention_scores.masked_fill(mask == 0, -1e9)
return attention_scores
# 사용 예시 1: Padding Mask
batch = torch.tensor([
[1, 2, 3, 4, 0, 0], # 실제 토큰 4개, padding 2개
[1, 2, 3, 0, 0, 0], # 실제 토큰 3개, padding 3개
])
padding_mask = create_padding_mask(batch, pad_token=0)
print("Padding Mask shape:", padding_mask.shape)
print("Padding Mask:\n", padding_mask[0, 0, 0]) # [1, 1, 1, 1, 0, 0]
# 사용 예시 2: Look-Ahead Mask
seq_len = 5
look_ahead_mask = create_look_ahead_mask(seq_len)
print("\nLook-Ahead Mask:\n", look_ahead_mask)
# [[1, 0, 0, 0, 0],
# [1, 1, 0, 0, 0],
# [1, 1, 1, 0, 0],
# [1, 1, 1, 1, 0],
# [1, 1, 1, 1, 1]]
# 사용 예시 3: 두 마스크 결합 (Decoder에서 사용)
def create_combined_mask(seq, pad_token=0):
"""Decoder용 결합 마스크: padding + look-ahead"""
seq_len = seq.size(1)
# Padding mask
padding_mask = create_padding_mask(seq, pad_token)
# Look-ahead mask
look_ahead_mask = create_look_ahead_mask(seq_len)
look_ahead_mask = look_ahead_mask.unsqueeze(0).unsqueeze(1) # (1, 1, seq_len, seq_len)
# 두 마스크의 교집합 (둘 다 1인 곳만 1)
combined_mask = padding_mask & look_ahead_mask
return combined_mask
# Attention with Masking 전체 예시
class MaskedAttention(nn.Module):
def __init__(self, embed_size, heads):
super(MaskedAttention, self).__init__()
self.embed_size = embed_size
self.heads = heads
self.head_dim = embed_size // heads
self.values = nn.Linear(embed_size, embed_size)
self.keys = nn.Linear(embed_size, embed_size)
self.queries = nn.Linear(embed_size, embed_size)
self.fc_out = nn.Linear(embed_size, embed_size)
def forward(self, values, keys, query, mask):
N = query.shape[0]
value_len, key_len, query_len = values.shape[1], keys.shape[1], query.shape[1]
values = self.values(values).reshape(N, value_len, self.heads, self.head_dim)
keys = self.keys(keys).reshape(N, key_len, self.heads, self.head_dim)
queries = self.queries(query).reshape(N, query_len, self.heads, self.head_dim)
# Attention scores
energy = torch.einsum("nqhd,nkhd->nhqk", [queries, keys])
# 마스크 적용 (핵심!)
if mask is not None:
energy = energy.masked_fill(mask == 0, float("-1e20"))
attention = torch.softmax(energy / (self.embed_size ** 0.5), dim=3)
out = torch.einsum("nhql,nlhd->nqhd", [attention, values]).reshape(
N, query_len, self.heads * self.head_dim
)
out = self.fc_out(out)
return out
설명
이것이 하는 일: Masking은 attention weight를 계산하기 전에 특정 위치의 score를 -무한대로 만들어, softmax 후 해당 위치의 가중치가 0이 되도록 합니다. 첫 번째 단계로, 마스크를 생성합니다.
Padding Mask의 경우, 입력 시퀀스에서 padding 토큰(보통 0)의 위치를 찾습니다. (seq != pad_token)은 True/False 불리언 텐서를 만들고, unsqueeze로 차원을 추가하여 attention score 텐서와 브로드캐스팅 가능하게 합니다.
Look-Ahead Mask는 torch.tril(하삼각 행렬)로 만듭니다. 각 행에서 자신과 이전 위치만 1, 이후 위치는 0입니다.
두 번째로, attention score에 마스크를 적용합니다. masked_fill 함수는 마스크가 0(또는 False)인 위치를 지정된 값(-1e9 또는 -1e20)으로 채웁니다.
왜 정확히 -무한대가 아니라 -1e20을 쓸까요? 부동소수점 연산의 안정성 때문입니다.
float("-inf")를 사용하면 NaN이 발생할 수 있습니다. -1e9나 -1e20은 충분히 작아서 softmax 후 사실상 0이 되지만, 수치적으로 안전합니다.
세 번째로, softmax를 적용하면 마스킹된 위치의 attention weight가 거의 0이 됩니다. exp(-1e20)은 사실상 0이므로, 해당 위치로부터 정보가 흐르지 않습니다.
Decoder에서는 두 마스크를 결합합니다: padding_mask & look_ahead_mask처럼 논리 AND 연산으로 둘 다 허용하는 위치만 1이 되게 합니다. 이렇게 하면 padding도 가리고 미래도 가립니다.
여러분이 이 코드를 사용하면 정확한 sequence-to-sequence 모델을 만들 수 있습니다. Masking 없이는 모델이 치팅하여 높은 학습 성능을 보이지만 실제 추론에서는 실패합니다.
실제로 Masking을 빼먹으면 학습 loss는 낮지만 생성 품질이 형편없는 모델이 됩니다. 이는 매우 흔한 버그이므로 주의하세요.
실전 팁
💡 Padding mask는 Encoder와 Decoder 모두에 필요하지만, Look-ahead mask는 Decoder에만 필요합니다.
💡 -1e9와 -1e20 중 어느 것을 쓸지는 데이터 타입에 따라 다릅니다. float32는 -1e9, float16은 -1e4 정도가 안전합니다.
💡 마스크 텐서를 매번 생성하지 말고 한 번 만들어 재사용하면 효율적입니다. register_buffer로 등록하세요.
💡 디버깅 시 attention weight를 시각화하여 마스크가 제대로 작동하는지 확인하세요. 마스킹된 위치는 흰색(0)으로 나타나야 합니다.
💡 Cross-attention(Encoder-Decoder attention)에서는 source의 padding만 마스킹하면 되고, look-ahead mask는 불필요합니다.
10. Complete Transformer Model - 모든 것을 하나로
시작하며
여러분이 지금까지 각 부품을 배웠다면, 이제 전체 자동차를 조립할 시간입니다. 엔진, 바퀴, 핸들을 따로 아는 것과 실제로 운전하는 것은 다르죠.
Transformer도 마찬가지입니다. Self-Attention, Positional Encoding, Encoder, Decoder를 개별적으로 이해했지만, 이들이 어떻게 협력하여 번역이나 텍스트 생성을 수행하는지 전체 그림을 보아야 합니다.
바로 이제 필요한 것이 완전한 Transformer 모델입니다. 입력 문장을 받아 출력 문장을 생성하는 end-to-end 시스템입니다.
개요
간단히 말해서, 완전한 Transformer는 Encoder Stack과 Decoder Stack을 연결하고, 임베딩, 위치 인코딩, 최종 선형 레이어를 포함하는 전체 아키텍처입니다. 왜 전체 구조를 이해해야 할까요?
각 컴포넌트가 언제 어떻게 사용되는지, 데이터가 어떤 경로로 흐르는지 알아야 실무에서 수정하고 디버깅할 수 있습니다. 예를 들어 번역 모델에서 source 문장 "I love dogs"가 어떻게 변환되어 target 문장 "나는 개를 좋아한다"가 생성되는지 전체 흐름을 추적할 수 있어야 합니다.
기존에 각 부품을 독립적으로 봤다면, 이제는 데이터 흐름을 따라갑니다: Embedding -> Positional Encoding -> Encoder Layers -> Decoder Layers -> Output Projection. Transformer의 핵심 아키텍처는 Encoder-Decoder 구조입니다.
Encoder는 입력을 이해하고, Decoder는 출력을 생성하며, 둘은 Cross-Attention으로 연결됩니다. 이는 기계 번역, 요약 등 많은 태스크의 기본 프레임워크입니다.
코드 예제
import torch
import torch.nn as nn
class Transformer(nn.Module):
def __init__(
self,
src_vocab_size, # 입력 언어 어휘 크기
trg_vocab_size, # 출력 언어 어휘 크기
src_pad_idx, # 입력 padding 인덱스
trg_pad_idx, # 출력 padding 인덱스
embed_size=512,
num_layers=6,
heads=8,
forward_expansion=4,
dropout=0.1,
max_length=100,
device="cuda"
):
super(Transformer, self).__init__()
# Encoder 임베딩
self.encoder_embedding = nn.Embedding(src_vocab_size, embed_size)
self.encoder_position = PositionalEncoding(embed_size, max_length)
# Encoder layers
self.encoder_layers = nn.ModuleList([
EncoderLayer(embed_size, heads, forward_expansion, dropout)
for _ in range(num_layers)
])
# Decoder 임베딩
self.decoder_embedding = nn.Embedding(trg_vocab_size, embed_size)
self.decoder_position = PositionalEncoding(embed_size, max_length)
# Decoder layers
self.decoder_layers = nn.ModuleList([
DecoderLayer(embed_size, heads, forward_expansion, dropout)
for _ in range(num_layers)
])
# 최종 출력 레이어
self.fc_out = nn.Linear(embed_size, trg_vocab_size)
self.dropout = nn.Dropout(dropout)
self.src_pad_idx = src_pad_idx
self.trg_pad_idx = trg_pad_idx
self.device = device
def make_src_mask(self, src):
"""Encoder용 padding 마스크"""
src_mask = (src != self.src_pad_idx).unsqueeze(1).unsqueeze(2)
# (N, 1, 1, src_len)
return src_mask.to(self.device)
def make_trg_mask(self, trg):
"""Decoder용 결합 마스크 (padding + look-ahead)"""
N, trg_len = trg.shape
# Look-ahead mask
trg_mask = torch.tril(torch.ones((trg_len, trg_len))).expand(
N, 1, trg_len, trg_len
)
return trg_mask.to(self.device)
def forward(self, src, trg):
"""
Args:
src: (N, src_len) - 입력 시퀀스
trg: (N, trg_len) - 출력 시퀀스 (학습 시)
Returns:
out: (N, trg_len, trg_vocab_size) - 각 위치의 단어 확률
"""
# 마스크 생성
src_mask = self.make_src_mask(src)
trg_mask = self.make_trg_mask(trg)
# Encoder
enc_src = self.dropout(
self.encoder_position(self.encoder_embedding(src))
)
for layer in self.encoder_layers:
enc_src = layer(enc_src, enc_src, enc_src, src_mask)
# Decoder
dec_trg = self.dropout(
self.decoder_position(self.decoder_embedding(trg))
)
for layer in self.decoder_layers:
dec_trg = layer(dec_trg, enc_src, enc_src, src_mask, trg_mask)
# 최종 출력
out = self.fc_out(dec_trg)
return out
# 사용 예시
if __name__ == "__main__":
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# 하이퍼파라미터
src_vocab_size = 10000 # 영어 어휘
trg_vocab_size = 8000 # 한국어 어휘
src_pad_idx = 0
trg_pad_idx = 0
# 모델 생성
model = Transformer(
src_vocab_size,
trg_vocab_size,
src_pad_idx,
trg_pad_idx,
embed_size=512,
num_layers=6,
heads=8,
forward_expansion=4,
dropout=0.1,
max_length=100,
device=device
).to(device)
# 예시 데이터
src = torch.randint(0, src_vocab_size, (2, 10)).to(device) # 배치 2, 길이 10
trg = torch.randint(0, trg_vocab_size, (2, 12)).to(device) # 배치 2, 길이 12
# Forward pass
output = model(src, trg[:, :-1]) # trg에서 마지막 토큰 제외 (teacher forcing)
print(f"Output shape: {output.shape}") # (2, 11, 8000)
print(f"Model parameters: {sum(p.numel() for p in model.parameters()):,}")
설명
이것이 하는 일: Transformer는 입력 시퀀스를 받아 Encoder로 표현을 만들고, Decoder가 이를 참조하여 한 토큰씩 출력 시퀀스를 생성합니다. 첫 번째 단계는 입력 처리입니다.
Source 시퀀스(예: 영어 문장)가 들어오면, 토큰 인덱스를 임베딩 벡터로 변환합니다. 예를 들어 [3, 45, 12, ...]가 각각 512차원 벡터가 됩니다.
그 다음 Positional Encoding을 더해 위치 정보를 주입하고, Dropout을 적용합니다. 동일한 과정이 Target 시퀀스(예: 한국어 문장)에도 적용됩니다.
이때 학습 시에는 정답 문장을 입력으로 사용하는 teacher forcing을 씁니다. 두 번째는 Encoder Stack 통과입니다.
임베딩된 source가 여러 Encoder Layer를 순차적으로 통과합니다. 각 레이어는 Self-Attention과 Feed-Forward Network로 점점 더 깊은 표현을 만듭니다.
예를 들어 첫 번째 레이어는 인접 단어 관계를, 세 번째 레이어는 구문 구조를, 여섯 번째 레이어는 의미론적 관계를 포착합니다. 최종 Encoder 출력은 입력 문장의 풍부한 문맥적 표현입니다.
세 번째는 Decoder Stack 통과입니다. 임베딩된 target이 여러 Decoder Layer를 통과하면서, 각 레이어에서 세 가지 작업을 수행합니다: (1) Masked Self-Attention으로 이전 생성 토큰들을 보고, (2) Cross-Attention으로 Encoder 출력을 참조하며, (3) Feed-Forward로 표현을 변환합니다.
예를 들어 "나는"을 생성할 때 source의 "I"에 집중하고, "개를"을 생성할 때는 "dogs"에 집중합니다. 마지막 단계는 출력 투사입니다.
Decoder의 최종 출력(512차원)을 선형 레이어로 어휘 크기(8000차원)로 변환합니다. 이는 각 위치에서 다음 단어에 대한 로짓(logit)이 됩니다.
Softmax를 적용하면 확률 분포가 되고, 가장 높은 확률의 단어를 선택합니다. 학습 시에는 이 로짓과 정답 토큰 간의 Cross-Entropy Loss를 계산합니다.
여러분이 이 코드를 사용하면 실제 작동하는 번역 모델을 만들 수 있습니다. 물론 실무에서는 더 많은 것들이 필요합니다: 데이터 로딩, 학습 루프, learning rate scheduling, checkpoint 저장, beam search 추론 등.
하지만 이 코드가 핵심 아키텍처의 완전한 구현입니다. 파라미터를 조정하여 작은 모델(2층, 256차원)부터 큰 모델(12층, 1024차원)까지 만들 수 있습니다.
실전 팁
💡 모델 크기는 태스크에 맞게 조정하세요. 번역은 6층이면 충분하지만, 언어 모델은 12층 이상이 좋습니다.
💡 학습 시 warm-up learning rate schedule을 사용하세요. 원본 논문은 4000 스텝 warm-up을 권장합니다.
💡 Label smoothing(0.1)을 사용하면 과적합을 줄이고 일반화 성능을 높입니다.
💡 추론 시 greedy decoding 대신 beam search(beam=5)를 사용하면 더 나은 번역을 얻을 수 있습니다.
💡 모델을 체크포인트로 자주 저장하세요. 큰 모델은 학습에 며칠이 걸릴 수 있습니다.
댓글 (0)
함께 보면 좋은 카드 뉴스
범주형 변수 시각화 완벽 가이드 Bar Chart와 Count Plot
데이터 분석에서 가장 기본이 되는 범주형 변수 시각화 방법을 알아봅니다. Matplotlib의 Bar Chart부터 Seaborn의 Count Plot까지, 실무에서 바로 활용할 수 있는 시각화 기법을 배워봅니다.
이변량 분석 완벽 가이드: 변수 간 관계 탐색
두 변수 사이의 관계를 분석하는 이변량 분석의 핵심 개념과 기법을 배웁니다. 상관관계, 산점도, 교차분석 등 데이터 분석의 필수 도구들을 실습과 함께 익혀봅시다.
단변량 분석 분포 시각화 완벽 가이드
데이터 분석의 첫걸음인 단변량 분석과 분포 시각화를 배웁니다. 히스토그램, 박스플롯, 밀도 그래프 등 다양한 시각화 방법을 초보자도 쉽게 이해할 수 있도록 설명합니다.
데이터 타입 변환 및 정규화 완벽 가이드
데이터 분석과 머신러닝에서 가장 기초가 되는 데이터 타입 변환과 정규화 기법을 배워봅니다. 실무에서 자주 마주치는 데이터 전처리 문제를 Python으로 쉽게 해결하는 방법을 알려드립니다.
이상치 탐지 및 처리 완벽 가이드
데이터 속에 숨어있는 이상한 값들을 찾아내고 처리하는 방법을 배워봅니다. 실무에서 자주 마주치는 이상치 문제를 Python으로 해결하는 다양한 기법을 소개합니다.