Large Language Model (이하 LLM)이라는 단어가 나올 수 있는 배경은 역시 트랜스포머 구조가 등장하고 나서부터다. 우리가 쉽게 ChatGPT를 사용하는 것과는 달리 LLM의 중추가 되는 트랜스포머 구조는 기존의 인공 신경망만큼 이해하기 쉬운 개념은 아니다. 따라서 다음에 두고두고 찾아볼 수 있게 최대한 쉽게 정리해보려 한다.
트랜스포머는 자연어 처리를 위한 모델이다. 자연어 처리를 위한 시도는 트랜스포머가 처음이 아닌데, 이전에는 RNN이나 LSTM 등을 사용한 seq2seq 모델이 있기도 하였다. 하지만 seq2seq 모델은 입력을 순차적으로 처리해야 하기 때문에 연산 속도가 느리다는 것, 그리고 입력이 길어질수록 성능이 떨어지거나 학습이 불안정하다는 단점이 있었다.
이를 해결한 것이 바로 트랜스포머 구조다. 문장 내의 각 단어가 서로 어떤 관련이 있는지 계산해서 각 단어의 표현을 조정하는 어텐션 (Attention)이라는 개념이 도입되었고, 확장성과 효율성 면에서 강한 장점을 보였다. 그래서 자연어 처리에는 어텐션이 필요하다! 논문의 제목이 Attention is All You Need 이 된 연유도 그러하다.
개요
트랜스포머는 인코더와 디코더로 구성되어 있다. 인코더에서 다룰 개념들이 디코더에서도 쓰이기 때문에 입력이 들어왔을 때 인코더에서 어떻게 처리되는지를 살펴보고, 이어서 디코더에서는 일부 다른 부분들에 대한 정보만 첨언하면서 구조 정리를 마칠까 한다.
임베딩 (Embedding)
라벨링 데이터를 바탕으로 학습을 할 때 흔히 인코딩(encoding)이라는 단어를 들어봤을 것이다. 수치형 데이터가 아니라 범주형 데이터라면, 컴퓨터는 우리의 데이터를 잘 이해할 수 없기 때문에 이 또한 숫자로 바꾸어 처리해주어야 한다. 자연어 처리에서 문장의 요소들, 즉 한국어 기준으로 자모나 음절, 단어 단위로 끊어질 수 있는 요소들을 이러한 숫자로 바꾸어주기 위해서는 인코딩이 필요하다. 그리고 인코딩을 위해서는 그 과정 속에서 토큰 임베딩 (Token Embedding)이 필요하다.
토큰화 (Tokenizing)
입력을 위해 나눠지는 요소 하나하나를 토큰이라고 하고, 입력 값을 토큰 단위로 변환하는 것을 토큰화 (tokenizing)라고 한다. OpenAI에서 유료로 제공하는 api는 입력값의 토큰 개수만큼 비용이 부과된다. 게다가 한국어는 영어보다 같은 뜻의 문장을 작성했을 때 더 많은 토큰이 쓰이기도 하니, 토큰의 개념을 정확히 알아 두어야 api 사용에 있어서 비용 절감이 가능할 것이다.
서브워드 토큰화
그렇다면 얼마나 작은 단위로 나눠야 모델의 정확도가 적정 수준에 도달할 수 있을까? 한국말을 기준으로 자음, 모음까지 다 나누는 경우부터 음절, 그리고 단어 단위로 나누는 것까지 토큰의 크기는 제각각이다.
만약 가장 큰 단위인 단어로 토큰화를 하면 어떨까? 텍스트의 의미가 잘 유지되지만 사전의 크기가 커지고, 새로운 단어를 잘 처리하지 못할 것이다. 이렇게 사전에 없는 단어는 보통 OOV (out of vocabulary)로 처리하는데, 이 비율이 높아지면 성능이 떨어진다.
만약 최소 단위로 변환하면 어떨까? 사전의 크기가 매우 줄어들 것이고, OOV의 문제도 해결되지만 너무 잘게 쪼개기 때문에 토큰이 가지고 있는 의미가 유지되기 힘들 것이다.
그래서 토큰화를 진행할 때 모든 요소를 하나의 단위로만 쪼개기보다는 상황에 맞게 어떤 부분은 자모, 어떤 부분은 음절, 그리고 어떤 부분은 단어 단위로 쪼갠다면 절충안을 잘 찾을 수 있을 것이다. 이를 서브워드 토큰화라고 하는데, 한국어의 경우 보통 음절과 단어 사이에서 토큰화된다고 한다.
토큰화
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)
하지만 서브워드 토큰화를 위해선 좀 더 복잡한 로직이 요구된다. 지금은 트랜스포머 구조를 살펴보기 위함이니, 일단은 문장을 공백(띄어쓰기 단위)으로 나누어 토큰화를 진행해 보았다. 그리고 각 토큰에게 토큰 아이디를 부여하고, 추후 액세스를 위해 변환을 위한 딕셔너리를 선언해 주었다.
토큰 임베딩
import torch
import torch.nn as nn
embedding_dim = 16 # 임베딩 차원을 16으로 설정
embed_layer = nn.Embedding(len(str2idx), embedding_dim) # 5개의 토큰을 16차원 벡터로 변환해주는 레이어 생성
input_embeddings = embed_layer(torch.tensor(input_ids)) # (5, 16) -> 이렇게 텐서(배열)로 바꿔서 넣는다
input_embeddings = input_embeddings.unsqueeze(0) # (1, 5, 16) -> 0번째 차원에 차원을 추가하여 입력 차원을 맞춰 준다
input_embeddings.shape
문장에 대한 정보를 잘 담는 벡터로 변환하기 위한 기초 과정이다. 토큰마다 여러 차원을 가진 벡터로 변환하면서 토큰의 의미 정보를 저장하게 되는데, 이번 예제에서는 그 차원을 16차원으로 해주었다.
토큰의 개수 역시 기존의 5개로 해주었는데, 이는 어차피 한 문장만 할 거라서. 만약 여러 문장을 넣을 거고 문장마다 토큰의 개수가 다르면 Padding 및 Truncation을 통해 모든 문장이 같은 토큰 개수를 가지도록 전처리 해주어야 한다.
이어서 unsqeeze(0)이 보인다. 이는 임베딩 벡터에 0번째에 새로운 차원을 추가해 주는 과정이다. 여기엔 이유가 있다. PyTorch에서 일반적인 데이터 형식은 (batch_size, sequence_length, embedding_dim)이다. 맞다. 입력 데이터의 차원을 맞춰주기 위해서다. 추가로 맨 첫 차원은 배치 크기 (batch size)를 의미하는데, 이는 모델이 입력을 배치 단위로 처리할 수 있게 해 준다. 지금과 같이 한 문장으로 하는 경우에는 1로 해주면 된다.
이렇게 하면 임베딩이 끝인가? 아니다. 지금은 단지 16차원의 임의의 숫자 집합으로 바뀌었을 뿐이다. 아직 임베딩 층을 거치는 모델이 훈련이 되지 않았으므로 유의미한 값이 아니다.
위치 인코딩
max_position = 12
# 위치 인코딩 층 생성
position_embed_layer = nn.Embedding(max_position, embedding_dim)
# 여기서 말하는 max_position은 모델이 학습할 수 있는 최대 시퀀스 길이를 뜻함.
# 이렇게 하면 최대 12 단어의 위치 정보를 처리할 수 있음.
# 위치 인코딩
position_ids = torch.arange(len(input_ids), dtype=torch.long).unsqueeze(0)
position_encodings = position_embed_layer(position_ids)
다음으로는 위치 인코딩이다. 토큰 임베딩은 토큰의 의미 정보를 담지만, 위치 정보를 담지 못한다. 따라서 위치 정보를 담는 벡터가 필요하다.
우리가 참고하여 실습하는 논문 [Attention is All You Need]에서는 삼각함수를 활용한 수식으로 위치에 대한 정보를 입력했다. 따라서 학습이 들어가지 않는다! 하지만 이후에는 위치 정보 또한 임베딩 층을 추가해 모델을 학습시키는 방식을 사용하였다. 이 두 방법 모두 어차피 추론을 시작할 때 입력 토큰의 위치에 따라 고정된 임베딩을 더해주므로 절대적 위치 인코딩이라고 한다. 절대적 위치 인코딩은 토큰과 토큰 사이의 상대적인 위치 정보를 활용하지 못해서 최근에는 상대적 위치 인코딩 방식을 사용하는 추세이다. 물론 예제는 절대적 위치 인코딩 방식으로 진행되었다.
한 문장이 몇개의 토큰으로 이루어져 있을진 모르겠지만 예제에서는 12로 하였다. 그리고 토큰 임베딩과 마찬가지로 입력 데이터의 규격을 맞춰서 인코딩을 완료해 준다.
첨언하자면 torch가 제공하는 arrange 함수는 0부터 첫 번째 파라미터 -1까지의 값을 순차적으로 갖는 벡터를 생성한다. 따라서 위 예제에서 position_ids의 차원은 (1, 시퀀스 길이)가 된다.
최종 입력 임베딩 생성
# 토큰 임베딩과 위치 인코딩을 더해 최종 입력 임베딩 생성
input_embeddings = token_embeddings + position_encodings
input_embeddings.shape
이렇게 만들어진 토큰 임베딩과 위치 인코딩을 단순히 더하여 최종 입력 임베딩을 생성한다. 두 벡터의 차원을 맞춰줬으니 가능한 일이다. 그런데 이렇게 더하기만 한다면 토큰들이 지니고 있는 의미 정보와 위치 정보가 희석되지 않을까? 하는 염려가 있을 수 있다. 경험적으로 얻어진 결론이지만, 그럴 일은 없다고 한다. 위치 인코딩 벡터의 값이 상대적으로 작아 미세한 조정이 있을 뿐이고 오히려 조화를 이룬다는 설명.
다음 글에서는 트랜스포머의 핵심 개념인 Attention 메커니즘을 이해하고, 어떻게 각 단어가 문맥 내의 다른 단어와 상호작용하는지 살펴보겠다. 또 앞으로도 부족한 설명이 있다면 조금씩 살을 붙여 보도록 하겠다.
'AI' 카테고리의 다른 글
[LLM] OpenAI API (3) - 파이썬 코드로 호출해 보기 (2) | 2024.11.02 |
---|---|
[LLM] OpenAI API (2) - Playground를 사용해 보자 (1) | 2024.11.01 |
[LLM] OpenAI API (1) - API key 발급하기 (0) | 2024.10.30 |
[EDA] 탐색적 데이터 분석 with Python (2) (3) | 2024.09.28 |
[EDA] 탐색적 데이터 분석 with Python (1) (6) | 2024.09.25 |