[LLM] 트랜스포머 구조 파헤치기 (1) - 텍스트를 임베딩 (Embedding)으로 변환하기
Large Language Model (이하 LLM)이라는 단어가 나올 수 있는 배경은 역시 트랜스포머 구조가 등장하고 나서부터다. 우리가 쉽게 ChatGPT를 사용하는 것과는 달리 LLM의 중추가 되는 트랜스포머 구조는 기
dusanbaek.tistory.com
[LLM] 트랜스포머 구조 파헤치기 (2) - 어텐션 (Attention) 이해하기
[LLM] 트랜스포머 구조 파헤치기 (1) - 텍스트를 임베딩 (Embedding)으로 변환하기Large Language Model (이하 LLM)이라는 단어가 나올 수 있는 배경은 역시 트랜스포머 구조가 등장하고 나서부터다. 우리가
dusanbaek.tistory.com
[LLM] 트랜스포머 구조 파헤치기 (3) - 정규화와 피드 포워드 층
[LLM] 트랜스포머 구조 파헤치기 (1) - 텍스트를 임베딩 (Embedding)으로 변환하기Large Language Model (이하 LLM)이라는 단어가 나올 수 있는 배경은 역시 트랜스포머 구조가 등장하고 나서부터다. 우리가
dusanbaek.tistory.com
이제 앞서 배운 내용들을 모두 종합하여 인코더 층을 구현해 보고, 인코더 층을 합쳐 전체 인코더를 코드로 나타내 보자. 이어서 디코더 구조를 살펴보고, 인코더 대비 뭐가 다른 부분인지 살펴보자.
인코더 층
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)
attn_output = self.attn(norm_x, norm_x, norm_x)
x = src + self.dropout1(attn_output) # 잔차 연결
# 피드 포워드
x = self.feed_forward(x)
return x
인코더 한 층에는 멀티 헤드 어텐션, 층 정규화, 드롭아웃, 피드 포워드 층이 포함되어 있다.
층 정규화 → 멀티 헤드 어텐션 → 드롭아웃 → 잔차 연결 → 피드 포워드
순서대로 진행하면 한 인코더 층의 연산이 모두 완료된다. 위 트랜스포머 구조와 다르다고 생각할 수 있는데, 그 이유는 초기 트랜스포머의 사후 정규화가 아닌 사전 정규화를 사용했고, 멀티 헤드 어텐션과 피드포워드 층 속에 세부적인 층이나 잔차연결, 정규화가 포함된 복잡한 구조로 되어 있기 때문이다.
인코더
이제 인코더 층까지 모두 포함하여 전체 인코더 구조를 살펴보자.
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
한 인코더는 여러 개의 인코더 층으로 구성되어 있다. 따라서 같은 인코더 층을 num_layers 만큼 복사하여 nn.ModuleList에 담아준다. 이어서 num_layers와 정규화 층을 저장해 준다. forward() 메서드에서 반복문을 이용하여 nn.ModuleList에 있는 모든 인코더 층을 입력 데이터가 통과하게 하여 인코더의 기능을 수행하고, 그 결과를 output으로 반환한다.
인과적 어텐션 연산
이제 이어서 디코더를 보자. 디코더에서는 인코더와 어텐션 연산이 다른데, 그 이유는 텍스트 전체의 의미를 파악하는 것이 아닌, 단어 뒤에 이어질 단어를 생성하는 데에 초점을 두기 때문이다. 뒤에 올 단어를 미리 학습에 사용할 수가 없고, 따라서 뒷부분은 마스킹 처리를 하고 연산을 해야 한다.
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)
인코더에서의 compute_attention() 메서드와 달라진 부분은 is_casual의 여부가 추가되었다는 점이다. 그 외에 코드는 모두 동일하다. 조건문 내에서 먼저 쿼리와 키의 길이를 가져온다. 쿼리와 키 텐서 차원이 (batch_size, n_heads, sequence_length, head_dim)로 구성되어 있으므로, 세 번째 차원에 해당하는 것이 쿼리와 키의 길이가 된다. 이어서 이 시퀀스의 길이를 바탕으로 텐서를 생성한다. 텐서는 모두 True로 이루어져 있지만, 뒤에 오는 .tril(diagonal=0)으로 인해 주대각선 아래만 True로 되어 있는 하삼각행렬이 생성된다. 이어서 temp_mask에서 False로 되어 있는 부분을 모두 -inf로 채움으로써 미래에 대한 정보를 지운다. 이 부분은 실제 softmax 연산에서 0이 되므로 어텐션 연산에서 제외됨을 알 수 있다.
크로스 어텐션이 포함된 디코더 층
디코더에서 쓰이는 어텐션 연산에 이어서 디코더 층을 살펴보자.
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() 메서드 부분에 특이한 코드 한 줄이 있다. 그것은 바로 크로스 어텐션 연산이다. 크로스 어텐션 연산은 왜 있는 걸까? 그 이유는 코드를 보면 알기 쉽다. 크로스 어텐션에서 쓰이는 멀티 헤드 어텐션 메서드를 보면 쿼리에는 디코더 입력 데이터가, 키와 값에는 인코더의 output 데이터가 들어간다. 두 번째 포스팅에서 다뤘던 쿼리, 키, 값에 대한 설명을 기억하는가? 맞다. 이렇게 쿼리와 키-값 쌍 간의 관계를 학습하여, 디코더가 인코더에서 중요한 정보만 참조하게끔 한다. 이로 인해, 디코더는 출력의 각 단어가 입력의 모든 단어와 문맥적으로 연결되어 번역, 생성 등의 작업에서 보다 정확한 예측을 할 수 있다.
디코더
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(tgt, src)
return output
그래서 결국 디코더의 구조는 다음과 같다. 인코더와 마찬가지로 디코더 또한 여러 디코더 층으로 이루어져 있으므로 방식은 동일하다.
'AI' 카테고리의 다른 글
[LLM] LLM Cache로 효율성 확보하기 with ChromaDB (0) | 2024.11.13 |
---|---|
[LLM] RAG로 Hallucination 방지하기 with Llama-index (7) | 2024.11.12 |
[LLM] 트랜스포머 구조 파헤치기 (3) - 정규화와 피드 포워드 층 (0) | 2024.11.07 |
[LLM] 트랜스포머 구조 파헤치기 (2) - 어텐션 (Attention) 이해하기 (2) | 2024.11.04 |
[LLM] OpenAI API (3) - 파이썬 코드로 호출해 보기 (2) | 2024.11.02 |