Criando seu próprio vocabulário BPE no Tokenizers - Python

Entenda a codificação usada pelos modelos de linguagem e crie seu próprio vocabulário.


Fabio 01 Dec 2023 aplicadas python, IA, tokenizers, e llm

Tokenização

Ao longo da evolução das aplicações NLP várias formas de se representar um texto foram abordadas. Imagine a seguinte frase “Qual o sentido da vida?” como deveríamos representa-lá para ser usada em algum Modelo de Linguagem Natural? Um modelo de linguagem ‘lê’ uma parte do texto e prevê o próximo token, então a primeira abordagem talvez seja óbvia: Atribuir um número a cada letra e usar a sequência de números para representar a frase. Boa notícia, não precisamos atribuir número algum, eles já existem na codificação UTF-8 como vemos abaixo:

Qual o sentido da vida? 0x51 0x75 0x61 0x6c 0x20 0x6f 0x20 0x73 0x65 0x6e 0x74 0x69 0x64 0x6f 0x20 0x64 0x61 0x20 0x76 0x69 0x64 0x61 0x3f

Porém essa representação não funciona muito bem. Na entrada de exemplo “Qual o sentido d” na codificação de letras sugerida o próximo token poderá ser o “a”, “e” e “o” como nas frases “Qual o sentido de …” e “Qual o sentido do …” e porque não o “i” em “Qual o sentido disso …“, ou seja, a probabilidade do próximo token nessa codificação se diluí dificultando prever o próximo token. Imagine o espaço ” “ ou o artigo “a”, praticamente todas as letras/tokens tem a mesma probabilidade de ocorrerem depois deles.

Outro problema é o contexto. As redes neurais vão perdendo o contexto ao longo das previsões e uma codificação como essa que precise de muitas iterações para formar uma frase fará a rede perder o ‘tema do assunto’.

Outra forma sugerida para resolver esses problemas é atribuir um número as palavras (words) de um vocabulário como no exemplo abaixo:

Qual o sentido da vida? 0x13 0x03 0x123 0x10 0x54
“Qual “ 0x13
“o “ 0x03
“sentido “ 0x123
“da “ 0x10
“vida?” 0x54

Na tabela acima cada palavra+pontuação tem um número associado. São 5 tokens então serão 5 números representando a frase toda. Nessa representação a rede neural faz poucas iterações e não se perde no contexto. De bônus ocupa pouco espaço e consome menos CPU correto? Sim. Mas alguns problemas surgem. Enquanto na representação por letras qualquer palavra do vocabulário pode ser representada, precisaríamos de uma enorme quantidade de números para representar todas as palavras existentes. Normalmente um vocabulário útil desses teria alguns Gigas de tamanho. E o que acontece se alguma palavra não conhecida aparecer? Há quem tenha usado o token <UNK> para representar uma palavra desconhecida … deselegante não?

Byte-Pair Encoding (BPE)

Usado inicialmente como um algoritmo para comprimir textos ele achou seu sucesso nos modelos de linguagens localizando-se no entre-meio da representação por letras e por palavras. Na codificação BPE inicialmente todas as letras são incluídas, depois uma pesquisa em uma grande quantidade de textos determinará as palavras e RADICAIS de palavras mais frequentemente usadas que serão incluídas na representação.

Em português é extremamente comum o uso das preposições “do”, “da”, “em”; os artigos “um”, “uma”; os advérbios “não”, “sim”, “talvez”.

Aprendemos na escola a formação das palavras com os prefixos, sufixos, radicais e outras formas, partindo disso, os prefixos “íssimo”,”an”, “anti”, “endo”, “hiper” bem como os sufixos “ismo”, “ença”, “ário” e tantos outros são fortes candidatos a serem representados pelo BPE. Por sorte nossa lingua tem radicais muito comuns que também podem ser incluídos na representação.

Vejamos o exemplo da frase “Qual o sentido da vida?” na codificação BPE usada pelo GPT:

Token Index
“Qual” 46181
” o” 267
” sent” 1908
“ido” 17305
” da” 12379
” v” 410
“ida” 3755
”?” 30

Como se pode ver a palavra “sentido” foi quebrada em dois tokens “ sent” e “ido”, “vida” foi quebrado em “ v” e “ida”; faria mais sentido representar “vida” por “vida”? Sim, mas o vocabulário usado é do GPT-4 e apesar dele ser multilingual ele foi primariamente treinado para o inglês e com poucos textos em português, consequentemente ele tem mais radicais e palavras em inglês que em qualquer outra língua.

Como todas a letras estão incluídas qualquer palavra pode ser formada e como os radicais e as palavras mais usadas estão incluídas o contexto fica pequeno. Há um balanço entre flexibilização e tamanho.

Se quiser explorar mais a tokenização usada pelo ChatGPT use o site https://observablehq.com/@simonw/gpt-tokenizer exemplificado na imagem abaixo:

Treinando seu próprio vocabulário

Para treinar seu próprio vocabulário será necessário o módulo python tokenizers. Primeiramente instale o módulo via pip ou usando seu gerenciador de pacotes ( preferido ).

pip install tokenizers

Incluiremos os módulos necessários no cabeçalho do arquivo:

from tokenizers import Tokenizer, models, pre_tokenizers, decoders, trainers, processors
import glob

O glob é excelente para essas tarefas ao facilitar adicionar novos arquivos simplesmente jogando eles em um diretório.

# Initialize a tokenizer
tokenizer = Tokenizer(models.BPE())

# Customize pre-tokenization and decoding
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=True)
tokenizer.decoder = decoders.ByteLevel()
tokenizer.post_processor = processors.ByteLevel(trim_offsets=True)

# And then train
trainer = trainers.BpeTrainer(
    vocab_size=32000,
    min_frequency=4,
    initial_alphabet=pre_tokenizers.ByteLevel.alphabet()
)

Inicializamos o módulo com os parâmetros desejados. Os parâmetros mais importantes são

  • vocab_size=32000 - Diz qual o tamanho do vocabulário. Muito pequeno ajuda na velocidade da rede mas diminui a quantidade de palavras e radicais. O GPT usa tamanhos de 50k (~50.000) e 100k (~100.000) para seus vocabulários. Não esqueça que as letras serão incluídas, então o tamanho deve contar essas letras obrigatórias.
  • min_frequency=4 - Frequência minima para um token ser incluído. Se alguém fizer um treinamento com diálogos de internet é muito provável que palavras digitadas erradas apareçam e outras somente uma vez, ou os famigerados kkkkkkkkkk+. É melhor deixar palavras poucos usadas de fora. Porém se o vocab_size for pequeno e a quantidade de textos grande e com muitas palavras é provável que somente palavras com muita frequência sejam incluídas.
  • initial_alphabet=pre_tokenizers.ByteLevel.alphabet() - Lembra das letras automaticamente incluídas? Não é automático. Elas são incluídas aqui.

Como glob será usado …

l = glob.glob("./**/*.txt", recursive=True)

Que fará uma lista chamada “l” com todos os aquivos *.txt no diretório e sub-diretório atual.

specials = [
    '<|query|>',
    '<|answer|>',
    '<|endoftext|>',
    '<|code|>',
    '<|system|>',
    '<|hole|>'
]

Essa etapa é importante. A OpenAI usa muito o token <|endoftext|> para finalizar um texto e começar outro. Também é usado durante a inferência para informar que ela terminou. O token deve ser algo estranho aos textos das linguagens que ele suporta então foi escolhido esse formato estranho <|endoftext|>. Porém essa palavra tem 13 caracteres e ocuparia muito espaço bem como CPU para operá-la. Não seria melhor transformar esse token em um só número como é feito nas palavras e radicais mais usados?

Pois é isso que é feito nessa fase. Adiciona-se os specials tokens ou tokens especiais que serão usados para alguma finalidade.

Eu uso o <|query|>, <|endoftext|> e <|answer|> para montar datasets com perguntas e respostas como no exemplo:

<|query|>Quem descobriu o Brasil?<|answer|>Pedro Álvares Cabral<|endoftext|>

E o três tokens serão convertidos em um só número (cada) do que uma sequência de characteres.

Configurado os parâmetros agora é feito o processo de pesquisa e quando acabar o processo salvamos o *.json do vocabulário gerado para uso posterior.

tokenizer.add_special_tokens(specials)
tokenizer.train(l, trainer=trainer)

# And Save it
tokenizer.save("byte-level-bpe.tokenizer.32k.json", pretty=True)

O arquivo byte-level-bpe.tokenizer.32k.json deverá aparecer no diretório atual ao final do processo com o vocabulário gerado.

Usando o vocabulário gerado

Para carregar o arquivo o from_file do Tokenizer é usado. Para transformar um texto em números usa-se o tokenizer.encode(texto) que retornará na lista .ids. Para transformar uma sequência de números em texto novamente o decode é usado. Como se pode ver no exemplo abaixo:

import tokenizers
from tokenizers import Tokenizer
from tokenizers.models import BPE
tokenizer = Tokenizer.from_file("byte-level-bpe.tokenizer.32k.json")
encode = lambda s: tokenizer.encode(s).ids
decode = lambda l: tokenizer.decode(l)

A = encode("Qual o sentido da vida?")
B = decode(A)

print(A,B)

Programa completo

from tokenizers import Tokenizer, models, pre_tokenizers, decoders, trainers, processors
import glob
# Initialize a tokenizer
tokenizer = Tokenizer(models.BPE())

# Customize pre-tokenization and decoding
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=True)
tokenizer.decoder = decoders.ByteLevel()
tokenizer.post_processor = processors.ByteLevel(trim_offsets=True)

# And then train
trainer = trainers.BpeTrainer(
    vocab_size=32000,
    min_frequency=4,
    initial_alphabet=pre_tokenizers.ByteLevel.alphabet()
)

l = glob.glob("./**/*.txt", recursive=True)

specials = [
    '<|query|>',
    '<|answer|>',
    '<|endoftext|>',
    '<|code|>',
    '<|system|>',
    '<|hole|>'
]

tokenizer.add_special_tokens(specials)
tokenizer.train(l, trainer=trainer)

# And Save it
tokenizer.save("byte-level-bpe.tokenizer.32k.json", pretty=True)