7 Tokenização e Vocabulários
Este capítulo detalha a ponte fundamental entre a linguagem humana e a representação numérica compreendida por redes neurais. Exploraremos a teoria da tokenização subword (subpalavra), implementaremos o algoritmo Byte-Pair Encoding (BPE) do zero e discutiremos a arquitetura de preparação de dados para treinamento de Large Language Models (LLMs).
7.1 Por Que Tokenizar é Necessário
Redes neurais não processam strings; elas processam tensores de números (ponto flutuante ou inteiros). O processo de converter texto em números é chamado de Tokenização.
Modelos de linguagem não processam texto diretamente como strings de caracteres. Eles operam em unidades discretas chamadas tokens. A tokenização é o processo de converter texto em uma sequência de tokens que o modelo pode processar matematicamente.
A granularidade da tokenização varia entre abordagens. Em um extremo, temos tokenização por caractere, onde cada caractere é um token. No outro extremo, temos tokenização por palavra, onde cada palavra é um token. A maioria dos LLMs modernos utiliza uma abordagem intermediária chamada tokenização por subpalavras, que divide palavras incomuns em unidades menores enquanto mantém palavras frequentes como tokens únicos.
7.2 O Algoritmo Byte Pair Encoding (BPE)
O algoritmo mais amplamente utilizado para tokenização em LLMs é o Byte Pair Encoding (BPE), ou alguma variante dele. Originalmente desenvolvido para compressão de dados, o BPE foi adaptado para tokenização de linguagem natural e oferece um equilíbrio excelente entre granularidade e tamanho de vocabulário.
O BPE funciona começando com um vocabulário de caracteres individuais e iterativamente mesclando o par de tokens mais frequente para criar novos tokens. O processo continua até que o vocabulário atinja um tamanho desejado. O resultado é um vocabulário que contém tanto caracteres quanto sequências de caracteres de tamanhos variados, das palavras inteiras mais frequentes às subpalavras menos frequentes.
Por exemplo, a palavra “desproporcionalidade” seria tokenizada em unidades menores como [“des”, “pro”, “por”, “cional”, “idade”], pois é improvável que apareça frequentemente como uma palavra única. Já a palavra “o” seria um único token, pois aparece com altíssima frequência.
7.3 Vocabulários Típicos e Seus Tamanhos
Os vocabulários utilizados em LLMs modernos variam em tamanho, tipicamente entre 30.000 e 200.000 tokens únicos. Vocabulários maiores capturam mais nuances, mas exigem mais memória e computação. Vocabulários menores são mais eficientes, mas podem forçar a divisão de muitas palavras em múltiplos tokens, aumentando a profundidade da sequência de entrada.
A tokenização por si só consome uma parte não trivial do tempo de inferência. Processar texto para produzir tokens e, inversamente, converter tokens de volta em textoReadable são operações que devem ser realizadas em cada interação com o modelo.
7.4 O Impacto da Tokenização na Geração
A forma como o texto é tokenizado tem implicações profundas para o comportamento do modelo. Diferentes idiomas tokenizam de formas radicalmente diferentes: idiomas como o inglês tokenizam relativamente bem em BPE, enquanto idiomas como o chinês ou idiomas com sistemas de escrita complexos podem requerer tokenizadores especializados para atingir eficiência comparável.
O número de tokens em um texto também impacta diretamente o custo computacional de processá-lo. Muitos serviços de API que utilizam LLMs cobram com base no número de tokens processados, refletindo o custo computacional real da tokenização e do processamento subsequente.
7.4.1 Dilema da Granularidade
Historicamente, existiam duas abordagens principais, ambas com limitações severas para modelos modernos:
- Tokenização por Palavra (Word-level):
- Divide o texto por espaços.
- Problema: O vocabulário se torna gigantesco (milhões de palavras possíveis). Palavras não vistas durante o treino tornam-se tokens
<UNK>(Unknown), perdendo informação semântica.
- Tokenização por Caractere (Character-level):
- Divide o texto em letras individuais.
- Problema: O vocabulário é pequeno, mas as sequências tornam-se extremamente longas, dificultando a modelagem de dependências de longo prazo e aumentando o custo computacional.
7.4.2 A Solução: Tokenização Subword (Subpalavra)
A abordagem moderna (utilizada pelo GPT-4, Llama 3, BERT) é a tokenização subword. Ela baseia-se no princípio de que palavras frequentes não devem ser divididas, mas palavras raras devem ser decompostas em unidades menores e significativas.
- Exemplo: A palavra “tokenização” pode ser dividida em
["token", "iza", "ção"]. - Benefício: Permite um vocabulário de tamanho fixo (ex: 32k a 100k tokens) com capacidade de representar virtualmente qualquer palavra através da composição de subunidades.
7.5 O Algoritmo Byte-Pair Encoding (BPE)
O BPE é um algoritmo iterativo que constrói um vocabulário fundindo os pares de caracteres (ou bytes) mais frequentes no corpus de treinamento.
7.5.1 2.1 Fluxo de Treinamento do BPE
Abaixo, o diagrama ilustra o ciclo de vida do treinamento de um tokenizador BPE:
flowchart TD
A[Corpus de Texto Bruto] --> B[Pré-tokenização]
B -->|Divisão por espaços/regras| C[Vocabulário Inicial]
C -->|Caracteres individuais| D{Loop de Treinamento}
D --> E[Contar Frequência de Pares Adjacentes]
E --> F[Identificar Par Mais Frequente]
F --> G[Criar Nova Regra de Fusão 'Merge']
G --> H[Atualizar Vocabulário e Corpus]
H --> I{Atingiu Tamanho Limite?}
I -- Não --> D
I -- Sim --> J[Vocabulário Final & Regras de Merge]
style D stroke:#f66,stroke-width:2px
style J stroke:#0f0,stroke-width:2px
7.5.2 2.2 Exemplo de Execução (Trace)
Suponha o corpus: ("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4)
- Base:
h, u, g, p, n, b - Iteração 1: O par
u, naparece em “pun” e “bun” (12 + 4 = 16 vezes). É o mais frequente.- Novo token:
un. - Vocabulário:
h, u, g, p, n, b, un.
- Novo token:
- Iteração 2: O par
u, gaparece em “hug” e “pug” (10 + 5 = 15 vezes).- Novo token:
ug. - Vocabulário:
h, u, g, p, n, b, un, ug.
- Novo token:
7.6 3. Implementação Prática: BPE do Zero em Python
A seguir, implementamos um BPE simplificado para demonstrar a lógica interna de manipulação de strings e contagem estatística.
import collections
import re
class SimpleBPE:
def __init__(self, vocab_size=300):
self.vocab_size = vocab_size
self.merges = {} # Armazena as regras de fusão
self.vocab = {} # Mapeamento token -> ID
def get_stats(self, vocab_counts):
"""Conta a frequência de todos os pares adjacentes."""
pairs = collections.defaultdict(int)
for word, freq in vocab_counts.items():
symbols = word.split()
for i in range(len(symbols) - 1):
pairs[symbols[i], symbols[i+1]] += freq
return pairs
def merge_vocab(self, pair, v_in):
"""Aplica a fusão do par mais frequente no vocabulário atual."""
v_out = {}
bigram = re.escape(' '.join(pair))
p = re.compile(r'(?<!\S)' + bigram + r'(?!\S)')
for word in v_in:
# Substitui 'a b' por 'ab'
w_out = p.sub(''.join(pair), word)
v_out[w_out] = v_in[word]
return v_out
def train(self, text):
# 1. Pré-tokenização simples (dividir por espaços) e contagem
word_freqs = collections.defaultdict(int)
for word in text.split():
# Adiciona espaços entre chars para representar divisão inicial
# Ex: "hello" -> "h e l l o"
word_freqs[" ".join(list(word))] += 1
num_merges = self.vocab_size - len(set("".join(text.split())))
print(f"Iniciando treinamento BPE para {num_merges} merges...")
# 2. Loop de Treinamento
for i in range(num_merges):
pairs = self.get_stats(word_freqs)
if not pairs:
break
# Encontra o par mais frequente
best = max(pairs, key=pairs.get)
self.merges[best] = "".join(best)
# Atualiza o vocabulário aplicando o merge
word_freqs = self.merge_vocab(best, word_freqs)
if i % 10 == 0:
print(f"Iteração {i}: Merge {best} -> {''.join(best)}")
# Construir vocabulário final (Token -> ID)
# Na prática, incluiríamos tokens especiais aqui
unique_tokens = set()
for word in word_freqs:
unique_tokens.update(word.split())
self.vocab = {token: i for i, token in enumerate(sorted(unique_tokens))}
print(f"Treinamento concluído. Tamanho do vocabulário: {len(self.vocab)}")
def encode(self, text):
"""Tokeniza um novo texto usando as regras aprendidas."""
# Esta é uma versão simplificada. Um encoder real aplicaria
# as regras de merge na mesma ordem em que foram aprendidas.
tokens = []
for word in text.split():
word_spaced = " ".join(list(word))
# Aplica merges iterativamente (ineficiente, apenas didático)
# Em produção, usa-se uma árvore ou hash map otimizado
for pair, merged in self.merges.items():
bigram = " ".join(pair)
if bigram in word_spaced:
word_spaced = word_spaced.replace(bigram, merged)
word_tokens = word_spaced.split()
ids = [self.vocab.get(t, self.vocab.get("<UNK>", 0)) for t in word_tokens]
tokens.extend(ids)
return tokens
# --- Exemplo de Uso ---
corpus = "low low low low low lower lower newest newest newest wider wider wider"
tokenizer = SimpleBPE(vocab_size=20) # Vocabulário pequeno para teste
tokenizer.train(corpus)
print("\nVocabulário:", tokenizer.vocab)
print("Encode 'lower newest':", tokenizer.encode("lower newest"))7.7 4. Preparação do Dataset de Treinamento
Ter um tokenizador é apenas o primeiro passo. Para treinar um LLM, os dados devem ser estruturados em tensores com tokens especiais e janelas de contexto.
7.7.1 4.1 Tokens Especiais
Um vocabulário robusto deve incluir tokens de controle que não aparecem no texto natural:
| Token | Significado | Função |
|---|---|---|
[PAD] |
Padding | Preenche sequências curtas para manter o tamanho fixo do lote (batch). Geralmente ID 0. |
[UNK] |
Unknown | Representa caracteres ou subpalavras que o modelo nunca viu. |
[BOS] |
Beginning of Sentence | Sinaliza o início de uma sequência (crucial para geração). |
[EOS] |
End of Sentence | Sinaliza onde a geração deve parar. |
[MASK] |
Mask | Usado em modelos tipo BERT para preenchimento de lacunas. |
7.7.2 4.2 Pipeline de Transformação: De Texto a Tensor
O processo final envolve normalização, tokenização e estruturação em janelas deslizantes (Sliding Windows) ou empacotamento.
sequenceDiagram
participant Raw as Texto Bruto
participant Norm as Normalizador
participant Tok as Tokenizador
participant Post as Pós-Processador
participant Tensor as Tensor Final
Raw->>Norm: "Olá, MUNDO!"
Note right of Norm: Unicode NFKC, Lowercase (opcional)
Norm->>Tok: "olá, mundo!"
Note right of Tok: Aplica BPE
Tok->>Post: [104, 2201, 15, 889, 2]
Note right of Post: Adiciona [BOS] e [EOS]
Post->>Tensor: [1, 104, 2201, 15, 889, 2, 3]
Note right of Tensor: Padding até Context Length (ex: 10)
Tensor->>Tensor: [1, 104, 2201, 15, 889, 2, 3, 0, 0, 0]
7.7.3 4.3 Estratégias de Contexto (Context Window)
Ao preparar o dataset, o texto contínuo deve ser fatiado para caber na janela de contexto do modelo (ex: 4096 tokens).
- Truncation: Cortar o texto se exceder o limite.
- Sliding Window: Criar exemplos sobrepostos.
- Exemplo: Texto
[A, B, C, D, E], Janela 3. - Amostra 1:
[A, B, C] - Amostra 2:
[B, C, D](Stride=1)
- Exemplo: Texto
- Packing: Concatenar múltiplos textos curtos em uma única sequência, separados por
[EOS], para maximizar a eficiência da GPU (evitando excesso de[PAD]).
7.8 5. Considerações de Arquitetura
Ao projetar este pipeline em produção:
- Normalização Unicode: Utilize a forma NFKC para garantir que caracteres visualmente idênticos tenham a mesma representação de bytes.
- Byte-Level BPE: Modelos como GPT-2/3/4 operam em nível de bytes UTF-8, não caracteres Unicode. Isso elimina o token
<UNK>, pois qualquer string pode ser decomposta em bytes. - Eficiência: A implementação Python acima é educativa. Em produção, utilize bibliotecas escritas em Rust (como Hugging Face
tokenizers) que paralelizam a contagem de pares e a aplicação de merges.