Large Language Models (LLMs) e embeddings

Fernando Moraes
6 min readApr 23, 2024

--

Esse será o primeiro artigo sobre LLMs e etc, cujo objetivo final é entender como os LLMs funcionam, de tal forma que possamos construir o conhecimento necessário para a criação de uma.

É importante reconhecer que, embora estejamos lidando com texto, os LLMs operam principalmente com números e operações matemáticas. Eles convertem texto em representações numéricas através de técnicas como tokenização e embedding, permitindo que operações matemáticas sejam aplicadas para treinar e executar as redes neurais. Essa transformação é essencial para que os modelos compreendam e processem o texto de entrada de maneira eficaz.

O conceito de converter texto em formato entendível pelo computador (vetores —ex: [ 0.1, 0.2, 0.6 ,… , 0.8 ] ) é frequentemente referenciado como embedding. É importante observar que diferentes formatos de dados exigem modelos de embedding distintos. Por exemplo, um modelo de embedding projetado para texto não seria adequado para embedding de dados de áudio ou vídeo.

Existem vários algoritmos e estruturas que foram desenvolvidos para gerar embeddings de palavras. Um dos mais populares é a abordagem Word2Vec.

No entanto, os LLMs geralmente produzem embeddings que fazem parte da camada de entrada e são atualizados durante o treinamento. A vantagem de otimizar os embeddings como parte do treinamento LLM em vez de usar Word2Vec é que os embeddings são otimizados para a tarefa e os dados específicos em questão.

"E como geramos os embeddings?"

Primeira etapa: tokenização

O texto que iremos aplicar o processo de tokenização será a obra A Revolução Portugueza: O 31 de Janeiro, de domínio público. Iremos salvar o texto em um arquivo chamado medium.txt.


with open("medium.txt", "r", encoding="utf-8") as f:
raw_text = f.read()
print("Número total de caracteres:", len(raw_text))

E para dividir um texto em caracteres iremos utilizar o comando re.split:

import re
text = "Ola, medium. Isto, é um teste."
result = re.split(r'([,.:;?_!"()\']|--|\s)', text)
result = [item for item in result if item.strip()]
print(result)

O resultado final será uma lista de palavras individuais, sem espaços em branco e com caracteres de pontuação:

['Ola', ',', 'medium', '.', 'Isto', ',', 'é', 'um', 'teste', '.']

Agora que temos um tokenizador básico funcionando, vamos aplicá-lo a obra em questão:

import re

# All together
with open("medium.txt", "r", encoding="utf-8") as f:
raw_text = f.read()

# New Part
preprocessed = re.split(r'([,.?_!"()\']|--|\s)', raw_text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
print(len(preprocessed))

A instrução print acima gera 48688, que é o número de tokens neste texto (sem espaços em branco).

Convertendo tokens em inteiro únicos

Para mapear os tokens gerados anteriormente em inteiro únicos, primeiro precisamos construir o que chamamos de vocabulário.

O vocabulário define como mapeamos cada token ÚNICO e caractere especial para um número inteiro ÚNICO.

Para isso vamos criar uma lista de todos os tokens exclusivos e classificá-los em ordem alfabética para determinar o tamanho do vocabulário:

import re

# All together
with open("medium.txt", "r", encoding="utf-8") as f:
raw_text = f.read()
print("Número total de caracteres:", len(raw_text))

preprocessed = re.split(r'([,.?_!"()\']|--|\s)', raw_text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]

# New Part
all_words = sorted(list(set(preprocessed)))
vocab_size = len(all_words)
print(vocab_size) #8941

vocab = {token:integer for integer,token in enumerate(all_words)}
for i, item in enumerate(vocab.items()):
print(item)

A saída será a seguinte:

('!', 0)
('"', 1)
("'", 2)
('(', 3)
(')', 4)
(',', 5)
('--', 6)
('.', 7)
...
('Soares', 1041)
('Sobre', 1042)
('Sociedade', 1043)
('Soldados', 1044)
('Solde-se', 1045)
('Sopas', 1046)
('Sou', 1047)
('Soube', 1048)
('Sousa', 1049)
('Sr', 1050)
('Subiram', 1051)
...

Como podemos ver, com base na saída acima, o dicionário contém 8941 tokens individuais associados a rótulos inteiros exclusivos. Nosso objetivo é aplicar esse vocabulário para converter novo texto em números inteiros e também precisaremos de uma maneira de transformar números inteiros em texto.

Para tal, vamos implementar uma classe em Python com o método encode, responsável por dividir o texto em tokens e realizar o mapeamento de string para inteiro por meio do vocabulário, e o método decode, responsável por realizar o mapeamento reverso de inteiro para string para converter os números inteiros de volta para texto.

import re

class TokenizerV1:
def __init__(self, vocab):
self.str_to_int = vocab
self.int_to_str = {i:s for s,i in vocab.items()}

def encode(self, text):
preprocessed = re.split(r'([,.?_!"()\']|--|\s)', text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
ids = [self.str_to_int[s] for s in preprocessed]
return ids

def decode(self, ids):
text = " ".join([self.int_to_str[i] for i in ids])
text = re.sub(r'\s+([,.?!"()\'])', r'\1', text)
return text

Usando a classe TokenizerV1 acima, podemos instanciar um novo objeto por meio de um vocabulário existente e utilizá-lo para codificar e decodificar um texto. Veja o exemplo abaixo:

import re

# All together
with open("medium.txt", "r", encoding="utf-8") as f:
raw_text = f.read()

preprocessed = re.split(r'([,.?_!"()\']|--|\s)', raw_text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]

# New Part
all_words = sorted(list(set(preprocessed)))
vocab_size = len(all_words)

vocab = {token:integer for integer,token in enumerate(all_words)}
tokenizer = TokenizerV1(vocab)

Com isso, implementamos um codificador capaz de codificar e decodificar um texto com base em um conjunto de treinamento.

Mas, e se utilizarmos nosso codificador um texto cujas palavras não estão contidas no texto de treinamento?

#Trecho da obra "Canção do Exílio" de Gonçalves Dias
text = "as aves que aqui gorjeiam"
tokenizer.encode(text)

Ops… ao executar o código acima iremos obter o seguinte erro:

KeyError: 'aves'

O problema é que a palavra “aves” não foi usada na obra A Revolução
Portugueza
. Portanto, não está contido no vocabulário.

Isto destaca a necessidade de considerar conjuntos de formação grande e diversificado para ampliar o vocabulário ao trabalhar com LLMs.

Adicionando tokens especiais

Modificaremos o vocabulário e o codificador que implementamos anteriormente para suportar dois novos tokens, <|unk|>, para quando encontrarmos uma palavra que não faz parte do vocabulário, e <|endoftext|>, para ser inserido entre textos não relacionados:

import re

class TokenizerV2:
def __init__(self, vocab):
self.str_to_int = vocab
self.int_to_str = { i:s for s,i in vocab.items()}

def encode(self, text):
preprocessed = re.split(r'([,.?_!"()\']|--|\s)', text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
preprocessed = [item if item in self.str_to_int
else "<|unk|>" for item in preprocessed]

ids = [self.str_to_int[s] for s in preprocessed]
return ids

def decode(self, ids):
text = " ".join([self.int_to_str[i] for i in ids])
text = re.sub(r'\s+([,.?!"()\'])', r'\1', text)
return text

# All together
with open("medium.txt", "r", encoding="utf-8") as f:
raw_text = f.read()

preprocessed = re.split(r'([,.?_!"()\']|--|\s)', raw_text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]

all_tokens = sorted(list(set(preprocessed)))

#Adding new tokens
all_tokens.extend(["<|endoftext|>", "<|unk|>"])

vocab = {token:integer for integer,token in enumerate(all_tokens)}

tokenizer = TokenizerV2(vocab)

Para testarmos nosso novo codificador, utilizaremos um texto simples formado a partir de duas frases independentes e não relacionadas:

text1 = "As aves que aqui gorjeiam"
text2 = "Brasil é pentacampeão mundial"
text = " <|endoftext|> ".join((text1, text2))
print(text)

#As aves que aqui gorjeiam <|endoftext|> Brasil é pentacampeão mundial
tokenizer = TokenizerV2(vocab)

#Encode
print(tokenizer.encode(text))
#[1877, 8942, 7102, 1804, 8942, 8941, 8942, 8937, 8942, 8942]

#Decode
print(tokenizer.decode(tokenizer.encode(text)))

#as <|unk|> que aqui <|unk|> <|endoftext|> <|unk|> é <|unk|> <|unk|>

É importante frisar que os modelos GPT usam apenas um token <|endoftext|>, eles não usam o token <|unk|> para palavras fora do vocabulário, em vez disso, os modelos GPT usam um codificador conhecido como "byte pair encoding", que divide as palavras desconhecidas em unidades de subpalavras. Clique aqui para saber mais sobre BPE .

Byte pair encoding (BPE)

Como a implementação do BPE pode ser relativamente complicada, usaremos uma biblioteca Python de código aberto existente chamada tiktoken, que implementa o algoritmo BPE de forma muito eficiente.

#!pip install tiktoken

from importlib.metadata import version
import tiktoken
print("tiktoken version:", version("tiktoken"))

#Instantiate the BPE tokenizer
tokenizer = tiktoken.get_encoding("gpt2")

#The usage of this tokenizer is similar to SimpleTokenizerV2
text = "As aves que aqui gorjeiam <|endoftext|> Brasil é pentacampeão mundial <|endoftext|>"
integers = tokenizer.encode(text, allowed_special={"<|endoftext|>"})
print(integers)

#[1722, 257, 1158, 8358, 14839, 72, 30344, 18015, 1789, 220, 50256, 39452, 346, 38251, 28145, 330, 321, 431, 28749, 27943, 498]

O codificador BPE que foi usado para treinar modelos como GPT-2, GPT-3 e o modelo original usado no ChatGPT, tem um tamanho total de vocabulário de 50.257 (lembrando que python vai do índice 0 até 50256), com <|endoftext|> sendo atribuído ao maior token ID.

E e e… vamos parando, já tem bastante coisa para compreender.

Na próxima falaremos sobre como pegar a saída do codificador e usar como entrada nos LLMs.

Ufa! Hoje foi longo.

Obrigado e até.

--

--