목차
2.1 트랜스포머 아키텍처란
2.2 텍스트를 임베딩으로 변환하기
2.3 어텐션 이해하기
2.4 정규화와 피드 포워드 층
2.5 인코더
2.6 디코더
2.7 BERT, GPT, T5 등 트랜스포머를 활용한 아키텍처
2.8 주요 사전 학습 매커니즘
2.9 정리
2.1 트랜스포머 아키텍처란
[RNN]
𝒙: 텍스트 토큰
- 모든 자연어 처리 연산의 기본 단위이고, 보통 단어보다 짧은 텍스트 단위이다(2.2절 참조).
- 지금은 단어와 같은 의미라고 생각하자.
h: 입력 토큰을 RNN 모델에 입력했을 때의 출력
- 입력을 병렬적으로 처리하지 못하는 구조
- 학습 속도가 느리고, 입력이 길어지면 먼저 입력한 토큰의 정보가 희석되면서 성능이 떨어진다는 문제.
- 성능을 높이기 위해 층을 깊이 쌓으면 그레이디언트 소실(gradient vanishing)이나 그레이디언트 증폭(gradient exploding)이 발생하며 학습이 불안정했다.
[트랜스포머]
셀프 어텐션(self-attention): 입력된 문장 내의 각 단어가 서로 어떤 관련이 있는지 계산해서 각 단어의 표현(representation)을 조정하는 역할을 한다.
트랜스포머 아키텍처의 장점
- 확장성: 더 깊은 모델을 만들어도 학습이 잘된다. 동일한 블록을 반복해 사용하기 때문에 확장이 용이하다.
- 효율성: 학습할 때 병렬 연산이 가능하기 때문에 학습 시간이 단축된다.
- 더 긴 입력 처리: 입력이 길어져도 성능이 거의 떨어지지 않는다.
[트랜스포머 아키텍처 구조]
인코더: 언어를 이해하는 역할
디코더: 언어를 생성하는 역할
영어를 한국어로 번역한다고 가정
[공통]
공통적으로 입력을 임베딩(embedding)층을 통해 숫자 집합인 임베딩으로 변환하고 위치 인코딩(positional encoding) 층에서 문장의 위치 정보를 더한다.
[인코더]
- 층 정규화(layer normalization), 멀티 헤드 어텐션(multi-head attention), 피드 포워드(feed forward) 층을 거치며 영어 문장을 이해하고 그 결과를 디코더로 전달한다.
[디코더]
- 층 정규화, 멀티 헤드 어텐션 연산을 수행하면서 크로스 어텐션 연산을 통해 인코더가 전달한 데이터를 출력과 함께 종합해서 피드 포워드 층을 거쳐 한국어 번역 결과를 생성한다.
2.2 텍스트를 임베딩으로 변환하기
텍스트를 모델에 입력할 수 있는 임베딩으로 변환하기 위해서는 크게 세 가지 과정을 거쳐야 한다.
1. 토큰화(tokenization): 텍스트를 적절한 단위로 잘라 숫자형 아이디를 부여
2. 토큰 아이디를 토큰 임베딩 층을 통해 여러 숫자 집합인 토큰 임베딩으로 변환한다.
3. 위치 인코딩 층을 통해 토큰의 위치 정보를 담고 있는 위치 임베딩을 추가해 최종적으로 모델에 입력할 임베딩을 만든다.
2.2.1 토큰화
한글은 작게는 자모(자음과 모음) 단위부터 크게는 단어 단위로 나눌 수 있다.
토큰화를 할 때는 어떤 토큰이 어떤 숫자 아이디로 연결됐는지 기록해 둔 사전을 만들어야 한다.
[토큰화 기준]
1. 큰 단위를 기준으로 토큰화하는 경우
장점: 텍스트의 의미가 잘 유지된다.
단점: 사전의 크기가 커진다. OOV 문제가 자주 발생한다.
* OOV(Out Of Vocabulary): 사전에 없는 단어. 이전에 본 적이 없는 새로운 단어는 잘 처리하지 못한다.
2. 작은 단위로 토큰화하는 경우
장점: 사전의 크기가 작고 OOV 문제를 줄일 수 있다.
단점: 텍스트의 의미가 유지되지 않는다.
3. 서브워드(subword) 토큰화: 데이터에 등장하는 빈도에 따라 토큰화 단위를 결정
- 자주 나오는 단어는 단어 단위 그대로 유지
- 가끔 나오는 단어는 더 작은 단위로 나눈다.
-> 텍스트의 의미를 최대한 유지하면서 사전의 크기는 작고 효율적으로 유지
* 한글의 경우 보통 음절과 단어 사이에서 토큰화된다.
[ 예제 2.1 - 토큰화 코드 ]
# 띄어쓰기 단위로 분리
input_text = "나는 최근 파리 여행을 다녀왔다"
input_text_list = input_text.split()
print("input_text_list: ", input_text_list)
# 토큰 -> 아이디 딕셔너리와 아이디 -> 토큰 딕셔너리 만들기
str2idx = {word:idx for idx, word in enumerate(input_text_list)}
idx2str = {idx:word for idx, word in enumerate(input_text_list)}
print("str2idx: ", str2idx)
print("idx2str: ", idx2str)
# 토큰을 토큰 아이디로 변환
input_ids = [str2idx[word] for word in input_text_list]
print("input_ids: ", input_ids)
# 출력 결과
# input_text_list: ['나는', '최근', '파리', '여행을', '다녀왔다']
# str2idx: {'나는': 0, '최근': 1, '파리': 2, '여행을': 3, '다녀왔다': 4}
# idx2str: {0: '나는', 1: '최근', 2: '파리', 3: '여행을', 4: '다녀왔다'}
# input_ids: [0, 1, 2, 3, 4]
* 실습 편의상 단어 단위로 토큰화를 한것이고 실제로는 서브워드 토큰화를 활용하므로 혼동하지 말것.
2.2.2 토큰 임베딩으로 변환하기
딥러닝 모델이 텍스트 데이터를 처리하기 위해서는...
-> 입력으로 들어오는 토큰과 토큰 사이의 관계를 계산할 수 있어야 한다.
-> 토큰과 토큰 사이의 관계를 계산하기 위해서는 토큰의 의미를 숫자로 나타낼 수 있어야 한다.
(앞서 토큰화에서 부여한 토큰 아이디는 하나의 숫자일 뿐이므로 토큰의 의미를 담을 수 없다.)
-> 의미를 담기 위해서는 최소 2개 이상의 숫자 집합인 벡터여야 한다.
[ 예제 2.2 - 토큰 아이디에서 벡터로 변환 ]
import torch
import torch.nn as nn
embedding_dim = 16
embed_layer = nn.Embedding(len(str2idx), embedding_dim)
input_embeddings = embed_layer(torch.tensor(input_ids)) # (5, 16)
input_embeddings = input_embeddings.unsqueeze(0) # (1, 5, 16)
input_embeddings.shape
# 출력 결과
# torch.Size([1, 5, 16])
파이토치(PyTorch)가 제공하는 nn.Embedding 클래스를 사용하면 토큰 아이디를 토큰 임베딩으로 변환할 수 있다.
출력 결과를 보면 1개의 문장이고 5개의 토큰이 있고 16차원의 임베딩이 생성됐음을 확인할 수 있다.
그렇다면 위의 코드는 임베딩 층(embed_layer)은 토큰의 의미를 담아 벡터로 변환하는 것일까?
-> 아직 아니다. 지금의 임베딩 층은 그저 입력 토큰 아이디(input_ids)를 16차원의 임의의 숫자 집합으로 바꿔줄 뿐이다.
-> 임베딩 층이 단어의 의미를 담기 위해서는 딥러닝 모델이 학습 데이터로 훈련되어야 한다.
딥러닝에서는 모델이 특정 작업을 잘 수행하도록 학습하는 과정에서 데이터의 의미를 잘 담은 임베딩을 만드는 방법도 함께 학습한다.
2.2.3 위치 인코딩
텍스트에서 순서는 매우 중요한 정보이기 때문에 추가해 줘야 하는데, 그 역할을 위치 인코딩이 담당한다.
[ 위치 인코딩 방식 ]
1. 수식을 통해 위치 정보를 추가하는 방식
2. 임베딩으로 위치 정보를 학습하는 방식
두 방식 모두 결국 모델로 추론을 수행하는 시점에서는 입력 토큰의 위치에 따라 고정된 임베딩을 더해주기 때문에, 이를 절대적 위치 인코딩(absolute position encoding)이라고 부른다.
[ 절대적 위치 인코딩 ]
- 장점: 간단하게 구현 가능
- 단점: 토큰과 토큰 사이의 상대적인 위치 정보는 활용 못하고, 학습 데이터에서 보기 어려웠던 긴 텍스트를 추론하는 경우에는 성능이 떨어진다.
그래서 최근에는 상대적 위치 인코딩(relative position encoding) 방식도 많이 활용된다. - 8.4.2절 참조
지금은 트랜스포머가 모든 입력 토큰을 동등하게 처리하기 때문에 입력으로 위치 정보를 함께 더해준다는 사실만 기억하면 충분하다.
[ 예제 2.3 절대적 위치 인코딩 ]
위치 정보를 학습하는 방식
embedding_dim = 16
max_position = 12
# 토큰 임베딩 층 생성
embed_layer = nn.Embedding(len(str2idx), embedding_dim)
# 위치 인코딩 층 생성
position_embed_layer = nn.Embedding(max_position, embedding_dim)
position_ids = torch.arange(len(input_ids), dtype=torch.long).unsqueeze(0)
position_encodings = position_embed_layer(position_ids)
token_embeddings = embed_layer(torch.tensor(input_ids)) # (5, 16)
token_embeddings = token_embeddings.unsqueeze(0) # (1, 5, 16)
# 토큰 임베딩과 위치 인코딩을 더해 최종 입력 임베딩 생성
input_embeddings = token_embeddings + position_encodings
input_embeddings.shape
# 출력 결과
# torch.Size([1, 5, 16])
새로운 임베딩 층을 하나 추가하고 위치 인덱스(position_ids)에 따라 임베딩을 더하도록 구현할 수 있다.
[정리]
값은 동일하지만 토큰 아이디는 사전(예제 2.1의 str2idx)에 저장된 토큰의 고유한 아이디를 의미하고 위치 아이디는 토큰의 위치를 의미한다. 예시 데이터이기에 같게 표현된 것이고 일반적으로는 같지 않다.
2.3 어텐션 이해하기
어텐션의 사전적 의미 '주의'
-> 텍스트 처리하는 관점에서는 입력한 텍스트에서 어떤 단어가 서로 관련되는지 '주의를 기울여' 파악한다.
Q. 어떻게 딥러닝 모델이 관련 있는 단어를 찾도록 만들 수 있을까?
2.3.1 사람이 글을 읽는 방법과 어텐션
어텐션: 사람이 단어 사이의 관계를 고민하는 과정을 딥러닝 모델이 수행할 수 있도록 모방한 연산
'파리'와 관련이 깊은 '여행을'과 '다녀왔다'에 주의를 기울여 '파리'가 도시라고 해석
Q. 사람이 자연스럽게 관련이 있는 단어를 찾고 그 맥락을 반영해 단어를 재해석하는 것처럼 어텐션 연산을 만들려면 어떻게 해야 할까?
-> 단어와 단어 사이의 관계를 계산해서 그 값에 따라 관련이 깊은 단어와 그렇지 않은 단어를 구분할 수 있어야 한다
-> 관련이 깊은 단어는 더 많이, 관련이 적은 단어는 더 적게 맥락을 반영해야 한다.
2.3.2 쿼리, 키, 값 이해하기
쿼리(query): 입력한 검색어
키(key): 문서가 가진 특징 ex) 문서의 제목, 본문, 저자 이름 등
값(value): 검색 엔진이 쿼리와 관련이 깊은 키를 가진 문서를 찾아 관련도순으로 정렬해서 문서를 제공할 때 문서
'파리'라는 쿼리로 ['나는', '최근', '파리', '여행을', '다녀왔다']라는 키 묶음에서 관련 있는 키를 찾았을 때, 그림 오른쪽과 같이 '파리'와 '여행을', '다녀왔다'가 적절히 섞인 값이 된다면, 사람이 단어를 재해석하는 과정을 모방할 수 있다.
입력 데이터에 따라 다른 결과를 얻기 위해서 관련도를 규칙이 아니라 데이터 자체에서 계산할 수 있어야 한다.
벡터와 벡터를 곱해 관계를 계산하면 그 관련도에 따라 주변 맥락을 반영할 수 있고, 문자열이 일치하지 않더라도 유사한 의미의 키로 저장된 정보를 검색할 수 있다.
하지만 임베딩을 직접 활용해 관련도를 계산하는 방식은 두 가지 문제가 발생할 수 있다.
1. 같은 단어끼리는 임베딩이 동일하므로 관련도가 크게 계산되면서 주변 맥락을 충분히 반영하지 못하는 경우가 발생할 수 있다.
2.토큰의 의미가 유사하거나 반대되는 경우처럼 직접적인 관련성을 띨때는 잘 작동하지만 문법에 의거해 토큰이 이어지는 경우처럼 간접적인 관련성은 반영되기 어려울 수 있다.
-> '나는' 토큰과 '최근' 토큰은 '다녀왔다' 토큰에 누가, 언제를 나타내는 문법 관계로 연결되지만 토큰 자체로 봤을 때는 서로 유사하거나 반대되는 경우가 아니므로 직접 계산해서는 관련성을 찾기 어렵다.
딥러닝에서는 어떤 기능이 잘하게 하고 싶을 때 가중치를 도입하고 학습 단계에서 업데이트되게 한다. 트랜스포머에서는 Wq, Wk 가중치를 통해 토큰과 토큰 사이의 관계를 계산하는 능력을 학습시킨 것이다.
트랜스포머에서는 값도 토큰 임베딩을 가중치(Wv)를 통해 변환한다. 이렇게 세 가지 가중치를 통해 내부적으로 토큰과 토큰 사이의 관계를 계산해서 적절히 주변 맥락을 반영하는 방법을 학습한다.
-> 쿼리와 키의 관계를 계산한 관련도 값과 토큰 임베딩을 값 가중치로 변환한 값을 가중합
2.3.3 코드로 보는 어텐션
[ 예제 2.4 쿼리, 키, 값 벡터를 만드는 nn.Linear 층 ]
head_dim = 16
# 쿼리, 키, 값을 계산하기 위한 변환
weight_q = nn.Linear(embedding_dim, head_dim)
weight_k = nn.Linear(embedding_dim, head_dim)
weight_v = nn.Linear(embedding_dim, head_dim)
# 변환 수행
querys = weight_q(input_embeddings) # (1, 5, 16)
keys = weight_k(input_embeddings) # (1, 5, 16)
values = weight_v(input_embeddings) # (1, 5, 16)
[ 예제 2.5 스케일 점곱 방식의 어텐션 ]
from math import sqrt
import torch.nn.functional as F
def compute_attention(querys, keys, values, is_causal=False):
dim_k = querys.size(-1) # 16
scores = querys @ keys.transpose(-2, -1) / sqrt(dim_k)
weights = F.softmax(scores, dim=-1)
return weights @ values
1. 쿼리와 키를 곱한다. 이때 분산이 커지는 것을 방지하기 위해 임베딩 차원 수(dim_k)의 제곱근으로 나눈다.
2. 쿼리와 키를 곱해 계산한 스코어(scores)를 합이 1이 되도록 소프트맥스(softmax)를 취해 가중치(weights)로 바꾼다.
3. 마지막으로 가중치와 값을 곱해 입력과 동일한 형태의 출력을 반환한다.
쿼리(q1)는 4개의 키와 각각 곱해 예제 2.5에서의 스코어를 계산한다. 스코어는 [2.2, 1.1, -1.7, 0.2]가 나왔는데, 이대로는 어떤 단어와의 관계를 얼마나 반영할지 명확히 정하기 어렵기 때문에 합을 1로 만들 수 있도록 소프트 맥스를 취한다.
마지막으로 그림 2.15에서 구한 가중치와 값을 곱한 후 더해서 주변 단어의 맥락을 반영한 하나의 값 임베딩으로 만든다.
그림 2.16에서 v1은 가중치가 0.672로 가장 크기 때문에 가장 많은 비중으로 섞이고, v3은 가중치가 0.013으로 가장 작기 때문에 가장 적은 비중으로 섞인다.
[ 예제 2.6 어텐션 연산의 입력과 출력 ]
print("원본 입력 형태: ", input_embeddings.shape)
after_attention_embeddings = compute_attention(querys, keys, values)
print("어텐션 적용 후 형태: ", after_attention_embeddings.shape)
# 원본 입력 형태: torch.Size([1, 5, 16])
# 어텐션 적용 후 형태: torch.Size([1, 5, 16])
어텐션을 거치고 나면 입력과 형태는 동일하면서 주변 토큰과의 관련도에 따라 값 벡터를 조합한 새로운 토큰 임베딩이 생성된다.
[ 예제 2.7 어텐션 연산을 수행하는 AttentionHead 클래스 ]
class AttentionHead(nn.Module):
def __init__(self, token_embed_dim, head_dim, is_causal=False):
super().__init__()
self.is_causal = is_causal
self.weight_q = nn.Linear(token_embed_dim, head_dim) # 쿼리 벡터 생성을 위한 선형 층
self.weight_k = nn.Linear(token_embed_dim, head_dim) # 키 벡터 생성을 위한 선형 층
self.weight_v = nn.Linear(token_embed_dim, head_dim) # 값 벡터 생성을 위한 선형 층
def forward(self, querys, keys, values):
outputs = compute_attention(
self.weight_q(querys), # 쿼리 벡터
self.weight_k(keys), # 키 벡터
self.weight_v(values), # 값 벡터
is_causal=self.is_causal
)
return outputs
attention_head = AttentionHead(embedding_dim, embedding_dim)
after_attention_embeddings = attention_head(input_embeddings, input_embeddings, input_embeddings)
2.3.4 멀티 헤드 어텐션
한 번에 하나의 어텐션 연산만 수행하는 게 아니라 여러 어텐션 연산을 동시에 적용하면 성능을 더 높일 수 있다 -> 멀티 헤드 어텐션
(a)는 하나의 어텐션 연산을 수행하는 스케일 점곱 어텐션
(b)는 동시에 헤드의 수(그림의 h)만큼의 어텐션 연산을 수행하는 멀티 헤드 어텐션
[ 예제 2.8 멀티 헤드 어텐션 구현 ]
class MultiheadAttention(nn.Module):
def __init__(self, token_embed_dim, d_model, n_head, is_causal=False):
super().__init__()
self.n_head = n_head
self.is_causal = is_causal
self.weight_q = nn.Linear(token_embed_dim, d_model)
self.weight_k = nn.Linear(token_embed_dim, d_model)
self.weight_v = nn.Linear(token_embed_dim, d_model)
self.concat_linear = nn.Linear(d_model, d_model)
def forward(self, querys, keys, values):
B, T, C = querys.size()
# 1
querys = self.weight_q(querys).view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
keys = self.weight_k(keys).view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
values = self.weight_v(values).view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
# 2
attention = compute_attention(querys, keys, values, self.is_causal)
# 3
output = attention.transpose(1, 2).contiguous().view(B, T, C)
# 4
output = self.concat_linear(output)
return output
n_head = 4
mh_attention = MultiheadAttention(embedding_dim, embedding_dim, n_head)
after_attention_embeddings = mh_attention(input_embeddings, input_embeddings, input_embeddings)
after_attention_embeddings.shape
# 출력 결과
# torch.Size([1, 5, 16])
1. 헤드의 수(코드의 n_head)만큼 연산을 수행하기 위해 쿼리, 키, 값을 n_head개로 쪼개고
2. 각각의 어텐션을 계산한 다음
3. 입력과 같은 형태로 다시 변환한다.
4. 마지막으로 선형층을 통과시키고 최종 결과를 반환한다.
코드와 그림 2.17(b)를 대응시켜 보면, 코드의 1은 그림에서 Q, K, V가 처음 통과하는 여러 선형 층에 대응되고,
2는 h번의 스케일 점곱 어텐션에, 3은 어텐션 결과를 연결하는 단계에, 4는 마지막 선형 층에 대응된다.
2.4 정규화와 피드 포워드 층
정규화: 딥러닝 모델에서 입력이 일정한 분포를 갖도록 만들어 학습이 안정적이고 빨라질 수 있도록 하는 기법
과거에는 배치 입력 데이터 사이에 정규화를 수행하는 배치 정규화(batch normalization)를 주로 사용했으나 트랜스포머 아키텍처에서는 특징 차원에서 정규화를 수행하는 층 정규화(layer normalization)를 사용한다.
어텐션 연산이 입력 단어 사이의 관계를 계산해 토큰 임베딩을 조정하는 역할을 한다면 전체 입력 문장을 이해하는 연산이 필요한데, 트랜스포머 아키텍처에서는 이를 위해 완전 연결 층(fully connected layer)인 피드 포워드 층을 사용한다.
2.4.1 층 정규화 이해하기
딥러닝 모델에 데이터를 입력할 때, 입력 데이터의 분포가 서로 다르면 모델의 학습이 잘되지 않기 때문에, 데이터를 정규화하여 입력하는 것이 중요하다. -> 모든 입력 변수가 비슷한 범위와 분포를 갖도록 조정.
정규화는 여러 데이터의 평균과 표준편차를 구해서 다음과 같은 식으로 계산한다.
norm_x = (x - 평균) / 표준편차
딥러닝에서는 평균과 표준편차를 구할 데이터를 어떻게 묶는지에 따라 크게 배치 정규화와 층 정규화로 구분한다. 일반적으로 이미지 처리에서는 배치 정규화를 사용하고, 자연어 처리에서는 층 정규화를 사용한다.
자연어 처리에서는 입력으로 들어가는 문장의 길이가 다양한데, 배치 정규화를 사용할 경우 정규화에 포함되는 데이터의 수가 제각각이라 정규화 효과를 보장하기 어렵다.
층 정규화는 각 토큰 임베딩의 평균과 표준편차를 구해 정규화를 수행한다. 문장별로 실제 데이터의 수가 다르더라도 각각의 토큰 임베딩별로 정규화를 수행하기 때문에 정규화 효과에 차이가 없다.
트랜스포머 아키텍처에서 층 정규화를 적용하는 순서에는 크게 두 가지 방식이 있다.
원 트랜스포머 논문에서는 (a)와 같이 어텐션과 피드 포워드 층 이후에 층 정규화를 적용했다. 이를 사후 정규화(post-norm)라고 부른다.
하지만 먼저 층 정규화를 적용하고 어텐션과 피드 포워드 층을 통과했을 때 학습이 더 안정적이라는 사실이 확인됐다. 이를 사전 정규화(pre-norm)라고 부른다.
[ 예제 2.9 층 정규화 코드 ]
norm = nn.LayerNorm(embedding_dim)
norm_x = norm(input_embeddings)
norm_x.shape # torch.Size([1, 5, 16])
norm_x.mean(dim=-1).data, norm_x.std(dim=-1).data
# (tensor([[ 2.2352e-08, -1.1176e-08, -7.4506e-09, -3.9116e-08, -1.8626e-08]]),
# tensor([[1.0328, 1.0328, 1.0328, 1.0328, 1.0328]]))
층 정규화는 파이토치가 제공하는 LayerNorm 클래스를 이용해 간단히 코드로 구현할 수 있다.
2.4.2 피드 포워드 층
피드 포워드 층(feed forward layer)은 데이터의 특징을 학습하는 완전 연결 층(fully connected layer)을 말한다. 멀티 헤드 어텐션이 단어 사이의 관계를 파악하는 역할이라면 피드 포워드 층은 입력 텍스트 전체를 이해하는 역할을 담당한다.
[ 예제 2.10 피드 포워드 층 코드 ]
class PreLayerNormFeedForward(nn.Module):
def __init__(self, d_model, dim_feedforward, dropout):
super().__init__()
self.linear1 = nn.Linear(d_model, dim_feedforward) # 선형 층 1
self.linear2 = nn.Linear(dim_feedforward, d_model) # 선형 층 2
self.dropout1 = nn.Dropout(dropout) # 드랍아웃 층 1
self.dropout2 = nn.Dropout(dropout) # 드랍아웃 층 2
self.activation = nn.GELU() # 활성 함수
self.norm = nn.LayerNorm(d_model) # 층 정규화
def forward(self, src):
x = self.norm(src)
x = x + self.linear2(self.dropout1(self.activation(self.linear1(x))))
x = self.dropout2(x)
return x
피드 포워드 층은 선형 층, 드롭아웃 층, 층 정규화, 활성함수로 구성된다. 임베딩의 차원을 동일하게 유지해야 쉽게 층을 쌓아 확장 가능하기 때문에 입력과 출력의 형태가 동일하도록 맞춘다. 일반적으로 d_model 차원에서 d_model보다 2~3배 큰 dim_feedforward 차원으로 확장했다가 다시 d_model로 변환한다.
2.5 인코더
안정적인 학습이 가능하도록 도와주는 잔차 연결(residual connection)이다. 잔차 연결은 화살표 모양 그대로 입력을 다시 더해주는 형태로 구현한다. 또한 그림에서 블록이 Ne번 반복된다고 표시되어 있는데, 트랜스포머 인코더는 그림의 인코더 블록을 반복해서 쌓아서 만든다.
[ 예제 2.11 인코더 층 ] - 하나의 인코더 층
class TransformerEncoderLayer(nn.Module):
def __init__(self, d_model, nhead, dim_feedforward, dropout):
super().__init__()
self.attn = MultiheadAttention(d_model, d_model, nhead) # 멀티 헤드 어텐션 클래스
self.norm1 = nn.LayerNorm(d_model) # 층 정규화
self.dropout1 = nn.Dropout(dropout) # 드랍아웃
self.feed_forward = PreLayerNormFeedForward(d_model, dim_feedforward, dropout) # 피드포워드
def forward(self, src):
norm_x = self.norm1(src) # 1
attn_output = self.attn(norm_x, norm_x, norm_x) # 2
x = src + self.dropout1(attn_output) # 3 잔차 연결
# 피드 포워드
x = self.feed_forward(x) # 4
return x
1. 입력인 src를 self.norm1을 통해 층 정규화
2. 멀티 헤드 어텐션 클래스(MultiheadAttention)를 인스턴스화한 self.attn을 통해 멀티 헤드 어텐션 연산을 수행
3. 잔차 연결을 위해 어텐션 결과에 드롭아웃을 취한 self.dropout1(attn_output)과 입력(src)을 더해준다.
4. 마지막으로 self.feed_forward(x)를 통해 피드 포워드 연산을 취한다.
[ 예제 2.12 인코더 구현 ] - 인코더 층(TransformerEncoderLayer)을 Ne번 반복
import copy
def get_clones(module, N):
return nn.ModuleList([copy.deepcopy(module) for i in range(N)])
class TransformerEncoder(nn.Module):
def __init__(self, encoder_layer, num_layers):
super().__init__()
self.layers = get_clones(encoder_layer, num_layers)
self.num_layers = num_layers
self.norm = norm
def forward(self, src):
output = src
for mod in self.layers:
output = mod(output)
return output
get_clones 함수는 입력한 모듈을 깊은 복사를 통해 N번 반복해 모듈 리스트에 담는다. TransformerEncoder 클래스에서는 인자로 전달받은 encoder_layer를 get_clones 함수를 통해 num_layers번 반복해 nn.ModuleList에 넣고 forward 메서드에서 for 문을 통해 순회하면서 인코더 층 연산을 반복 수행하도록 만든다.
2.6 디코더
디코더는 생성을 담당하는 부분으로, 사람이 글을 쓸 때 앞 단어부터 순차적으로 작성하는 것처럼 트랜스포머 모델도 앞에서 생성한 토큰을 기반으로 다음 토큰을 생성한다. 이렇게 순차적으로 생성해야 하는 특징을 인과적(causal) 또는 자기 회귀적(auto-regressive)이라고 말한다.
실제 텍스트를 생성할 때 디코더는 이전까지 생성한 텍스트만 확인할 수 있다. 그런데 학습할 때는 인코더와 디코더 모두 완성된 텍스트를 입력으로 받는다. 따라서 어텐션을 그대로 활용할 경우 미래 시점에 작성해야 하는 텍스트를 미리 확인하게 되는 문제가 생긴다. 이를 막기 위해 특정 시점에는 그 이전에 생성된 토큰까지만 확인할 수 있도록 마스크를 추가한다.
[ 예제 2.13 디코더에서 어텐션 연산(마스크 어텐션) ]
def compute_attention(querys, keys, values, is_causal=False):
dim_k = querys.size(-1) # 16
scores = querys @ keys.transpose(-2, -1) / sqrt(dim_k) # (1, 5, 5)
if is_causal:
query_length = querys.size(2)
key_length = keys.size(2)
temp_mask = torch.ones(query_length, key_length, dtype=torch.bool).tril(diagonal=0)
scores = scores.masked_fill(temp_mask == False, float("-inf"))
weights = F.softmax(scores, dim=-1) # (1, 5, 5)
return weights @ values # (1, 5, 16)
예제 2.5의 어텐션 코드에 is_causal 인자를 추가해서 디코터(인과적)인 경우 True로 설정해 마스크 연산을 추가할 수 있게 한다. 이 코드에서 가장 중요한 부분은 미래 시점의 토큰을 제거하기 위한 마스크(temp_mask)를 만드는 부분이다.
is_causal이 참일 때는 torch.ones로 모두 1인 행렬에 tril 함수를 취해 가운데 행렬과 같이 대각선 아래 부분만 1로 유지되고 나머지는 음의 무한대(-inf)로 변경해 마스크를 생성한다. 마스크를 어텐션 스코어 행렬에 곱하면 행렬의 대각선 아랫부분만 어텐션 스코어가 남고 위쪽은 음의 무한대가 된다. 가중치를 만들기 위해 소프트맥스를 취하는데, 이때 음의 무한대인 대각선 윗부분은 가중치가 0이 된다.
인코더와 디코더의 두 번째 차이는 크로스 어텐션(cross attention)이 있다는 것이다. 쿼리는 디코더의 잠재 상태를 사용하고 키와 값은 인코더의 결과를 사용한다.
[ 예제 2.14 크로스 어텐션이 포함된 디코더 층 ]
class TransformerDecoderLayer(nn.Module):
def __init__(self, d_model, nhead, dim_feedforward=2048, dropout=0.1):
super().__init__()
self.self_attn = MultiheadAttention(d_model, d_model, nhead)
self.multihead_attn = MultiheadAttention(d_model, d_model, nhead)
self.feed_forward = PreLayerNormFeedForward(d_model, dim_feedforward, dropout)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.dropout1 = nn.Dropout(dropout)
self.dropout2 = nn.Dropout(dropout)
def forward(self, tgt, encoder_output, is_causal=True):
# 셀프 어텐션 연산
x = self.norm1(tgt)
x = x + self.dropout1(self.self_attn(x, x, x, is_causal=is_causal))
# 크로스 어텐션 연산
x = self.norm2(x)
x = x + self.dropout2(self.multihead_attn(x, encoder_output, encoder_output))
# 피드 포워드 연산
x = self.feed_forward(x)
return x
인코더의 결과를 forward 메서드에 encoder_output이라는 이름의 인자로 넣을 수 있도록 했는데, self.multihead_attn(x, encoder_output, encoder_output)을 통해 크로스 어텐션 연산을 수행한다.
[ 예제 2.15 디코더 구현 ]
import copy
def get_clones(module, N):
return nn.ModuleList([copy.deepcopy(module) for i in range(N)])
class TransformerDecoder(nn.Module):
def __init__(self, decoder_layer, num_layers):
super().__init__()
self.layers = get_clones(decoder_layer, num_layers)
self.num_layers = num_layers
def forward(self, tgt, src):
output = tgt
for mod in self.layers:
output = mod(output, src)
return output
디코더는 인코더와 마찬가지로 디코더 층을 여러 번 쌓아 만든다.
2.7 BERT, GPT, T5 등 트랜스포머를 활용한 아키텍처
트랜스포머 아키텍처를 활용한 모델은 크게 세 가지 그룹으로 나눌 수 있는데,
1. 인코더만 활용해 자연어 이해(Natural Language Understanding, NLU) 작업에 집중한 그룹,
2. 디코더만 활용해 자연어 생성(Natural Language Generation, NLG) 작업에 집중한 그룹,
3. 인코더와 디코더를 모두 활용해 더 넓은 범위의 작업을 수행할 수 있도록 한 그룹
모델 그룹 | 대표 모델 | 장점 | 단점 |
인코더 | 구글의 BERT | - 양방향 이해를 통해 자연어 이해에서 일반적으로 디코더 모델 대비 높은 성능을 보임 - 입력에 대해 병렬 연산이 가능하므로 빠른 학습과 추론이 가능 - 다양한 작업에 대한 다운스트림 성능이 뛰어남 |
- 자연어 생성 작업에 부적합한 형태 - 컨텍스트의 길이가 제한적임 |
디코더 | OpenAI의 GPT | - 생성 작업에서 뛰어난 성능을 보임 - 비교적 긴 컨텍스트 길이에 대해서도 성능이 좋음 |
- 양방향이 아닌 단방향 방식이므로 자연어 이해 작업에서 비교적 성능이 낮음 - 모든 작업을 생성 작업으로 변환할 수 있으나 비효율적일 수 있음 |
인코더-디코더 | 메타의 BART, 구글의 T5 | - 생성과 이해 작업 모두에서 뛰어난 성능을 보임 - 이해 작업에서 양방향 방식을 사용할 수 있고 인코더의 결과를 디코더에서 활용할 수 있어 문맥을 반영한 생성 능력이 뛰어남 |
- 인코더와 디코더를 모두 활용하기 때문에 더 복잡함 - 학습에 더 많은 데이터와 컴퓨팅 자원이 필요함 |
2.7.1 인코더를 활용한 BERT
BERT는 그림 2.24의 왼쪽과 같이 입력 토큰의 일부를 마스크 토큰으로 대체하고 그 마스크 토큰을 맞추는 마스크 언어 모델링(Masked Language Modeling, MLM) 과제를 통해 사전 학습한다. 그림 2.24에서는 사전 학습된 BERT를 메일이 스팸인지 아닌지를 분류하는 텍스트 분류 작업으로 미세 조정했다. 하지만 BERT는 텍스트 분류뿐만 아니라, 토큰 분류(token classification), 질문 답변(question answering), 자연어 추론(natural language inference) 등 다양한 자연어 이해 작업에서 훌륭한 성능을 보인다.
LLM의 활용 과정에서도 사용자 발화의 의도 분류와 같이 효율적인 LLM 활용을 위한 연결 작업에는 자연어 이해 작업이 많이 사용되기 때문에 여전히 가장 사랑받는 모델군 중 하나이다.
2.7.2 디코더를 활용한 GPT
OpenAI에서 개발한 GPT는 트랜스포머 아키텍처 중 디코더만을 사용한다. 생성 작업의 경우 입력 토큰이나 이전까지 생성한 토큰만을 문맥으로 활용하는 인과적 언어 모델링(Causal Language Model, CLM)을 사용하기 때문에 양방향이 아닌 단방향 방식이다. GPT는 다음 토큰을 예측하는 방식으로 사전 학습을 수행한다. 디코더 모델 그룹은 확장이 용이하고 모든 자연어 처리 작업을 생성 작업으로 변환할 수 있기 때문에 OpenAI는 GPT 모델을확장하여 더 다양한 문제를 풀고자 했다.
OpenAI는 2020년에 발표한 신경망 언어 모델에서의 규모 법칙 논문에서 모델의 크기와 데이터의 크기가 커질수록 모델의 성능이 높아진다는 실험 결과를 발표했다.
실제로 OpenAI는 그림 2.25와 같이 2018년 1억 1,700만 개의 파라미터를 갖는 GPT-1 발표 이후로 2019년 파라미터가 15억 개인 GPT-2, 2020년에는 파라미터가 1,750억 개인 GPT-3를 발표했다. 매년 모델의 크기를 약 10배, 100배 키운 것이다. 앞으로 이 책에서 다루는 LLM(3장 제외)은 대부분 디코더만을 사용한 생성 모델이다.
GPT-1 | GPT-2 | GPT-3 | |
파라미터 수 | 1억 1,700만 개 | 15억 개 | 1,750억 개 |
디코더 층 수 | 12개 | 48개 | 96개 |
임베딩 차원 | 768 | 1600 | 12288 |
입력 토큰 수 | 512개 | 1024개 | 2048개 |
2.7.3 인코더와 디코더를 모두 사용하는 BART, T5
메타가 개발한 BART는 이전에 성공했던 BERT와 GPT의 장점을 결합한 모델이다. 그림 2.26과 같이 인코더-디코더 모델을 사전 학습하기 위해 입력 텍스트에 노이즈를 추가하고 노이즈가 제거된 결과를 생성하는 과제를 수행하도록 한다. BERT보다 다양한 사전 학습 과제를 도입했고 더 자유로운 변형(노이즈) 추가가 가능하다는 점에 차이가 있다.
구글이 개발한 T5는 모든 자연어 처리 작업이 결국 '텍스트에서 텍스트(Text to Text)로의 변환'이라는 아이디어를 바탕으로 한다. T5는 입력의 시작(prefix)에 과제 종류를 지정해서 하나의 모델에서 지정한 작업 종류에 따라 다양한 동작을 하도록 학습시킨 점이 특징이었다. 복잡도가 비교적 낮지만 생성 작업이어서 언어 모델을 활용해야 하는 경우 T5는 비용 효율적이면서도 높은 성능을 보여주기 때문에 최근에도 많이 활용되고 있다.
2.8 주요 사전 학습 메커니즘
2.8.1 인과적 언어 모델링
인과적 언어 모델링은 문장의 시작부터 끝까지 순차적으로 단어를 예측하는 방식이다. 이전에 등장한 단어들을 바탕으로 다음에 등장할 단어를 예측한다.
GPT 같은 생성 트랜스포머 모델에서는 인과적 언어 모델링을 핵심적인 학습 방법으로 사용한다.
2.8.2 마스크 언어 모델링
마스크 언어 모델링은 입력 단어의 일부를 마스크 처리하고 그 단어를 맞추는 작업으로 모델을 학습시킨다.
마스크 처리란, 그림 2.28과 같이 '먹고'를 [MASK]라는 특수 토큰으로 대체하는 것을 말한다. 마스크 처리를 하면 언어 모델은 입력이 무엇인지 알지 못하는 상황에서 그 토큰이 무엇인지 맞춰야 한다.
인과적 언어 모델링은 앞에서부터 뒤로 순차적으로 토큰을 생성하는데, 이 방식은 지금까지 생성한 문맥만 활용할 수 있다는 한계가 있다. 사람이 책을 읽을 때를 생각해보면, 독자는 책 내용을 이해할 때 순차적으로만 읽지 않는다. 잘 이해가 가지 않는 문장은 서로 단어가 어떻게 연결되는지 앞에서 뒤로, 뒤에서 앞으로 뜯어보면서 이해한다. 인과적 언어 모델링의 경우 단방향 예측이기 때문에 다음 단어 예측이라는 목표가 자연스럽게 정해지지만, 양방향 방식의 경우 새로운 작업 목표가 필요하다. BERT 연구팀은 그림과 같이 문장 사이에 토큰 일부를 마스크 처리해서 맞추는 방식을 사용했다.
2.9 정리
이번 장에서는 LLM의 기반이 되는 트랜스포머 아키텍처를 코드 레벨에서 구현해 보면서 세부적인 동작 방식을 살펴봤다. 트랜스포머 아키텍처는 크게 인코더와 디코더로 나눌 수 있고 인코더와 디코더는 멀티 헤드 어텐션과 층 정규화, 피드 포워드 층으로 구성됐다.
멀티 헤드 어텐션 - 단어 사이의 관계를 계산해 단어를 새롭게 조정하는 역할
피드 포워드 층 - 입력 문장 전체를 이해하는 역할
층 정규화 - 각 층의 입력 데이터 분포를 균일하게 만들어 딥러닝 모델이 원활하게 학습되도록 만듦
딥러닝 모델에서 사용하는 정규화 방식은 크게 배치 정규화와 층 정규화로 나눌 수 있고, 각각의 특징도 살펴봤다.
트랜스포머 아키텍처에 대해 알아본 후에는 인코더, 디코더, 인코더-디코더 형태로 모델 그룹을 나눠 각각의 특징과 대표적인 모델 예시를 살펴봤다. 인코더만을 사용한 구글의 BERT, 디코더만을 사용한 OpenAI의 GPT, 이코더와 디코더를 모두 사용한 메타의 BART와 구글의 T5가 있었다.
각각의 모델 그룹은 특징적인 학습 방법을 사용하는데,
인코더 모델 그룹 학습 - 마스크 언어 모델링
디코더 모델 그룹 학습 - 인과적 언어 모델링
최근의 LLM은 디코더만을 사용한 GPT 모델 그룹이고 인과적 언어 모델링을 통해 학습
최근 새롭게 공개되는 최고 성능의 LLM 모델은 대부분 허깅페이스(Huggingface) 트랜스포머 라이브러리로 사용할 수 있는 형태로 공개된다. 따라서 허깅페이스 팀을 중심으로 개발되는 트랜스포머 라이브러리는 LLM 모델을 연구하고 활용하기 위해 반드시 학습해야 한다. 이어지는 3장에서는 트랜스포머 모델을 쉽게 활용할 수 있도록 도와주는 허깅페이스 트랜스포머 라이브러리에 대해 알아본다.
'LLM을 활용한 실전 AI 애플리케이션 개발 > 1부 | LLM의 기초 뼈대 세우기' 카테고리의 다른 글
03 트랜스포머 모델을 다루기 위한 허깅페이스 트랜스포머 라이브러리 (0) | 2025.04.02 |
---|---|
01 LLM 지도 (1) | 2025.03.12 |