Phase 10 - Lesson 03

Pipelines de Dados para Pré-Treinamento

This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.

O modelo é um espelho. Ele reflete quaisquer dados que você forneça a ele. Alimente-o com lixo, e ele refletirá lixo com perfeita fluência.

Tipo: Build Idiomas: Python Pré-requisitos: Fase 10, Lições 01-02 (Tokenizers, Building a Tokenizer) Tempo: ~90 minutos

Objetivos de Aprendizado

  • Construir um pipeline de dados em streaming que tokeniza, divide em partes (chunks), embaralha e agrupa em lotes (batches) terabytes de texto sem carregar tudo na memória
  • Implementar filtros de qualidade de dados (deduplicação, detecção de idioma, filtragem de conteúdo) usados em pipelines de pré-treinamento reais
  • Criar sequências de treinamento de comprimento fixo com máscaras de atenção adequadas e tratamento de limites de documentos
  • Analisar o desempenho (profile) do rendimento (throughput) do pipeline para garantir que o dataloader acompanhe a velocidade de treinamento da GPU

O Problema

Você tem um tokenizador. Agora você precisa de dados.

Não um conjunto de dados. Não um arquivo CSV. Terabytes de texto — limpos, deduplicados, filtrados por qualidade, tokenizados em sequências de comprimento fixo e servidos em lotes aleatórios rápido o suficiente para que seu cluster de 8 GPUs nunca espere pelo próximo lote.

A maioria das pessoas pensa que treinar um LLM tem a ver com a arquitetura do modelo. Não tem. O Llama 3 usou 15,6 trilhões de tokens. O GPT-3 usou 300 bilhões. O DeepSeek-V2 usou 8,1 trilhões. A arquitetura em todos os três é basicamente a mesma: blocos de transformers empilhados com camadas de atenção e feedforward. A diferença na qualidade da saída vem esmagadoramente dos dados.

O artigo da Chinchilla da DeepMind tornou isso preciso. Para um determinado orçamento de computação (compute budget), existe uma proporção ideal de parâmetros do modelo para tokens de treinamento. A Chinchilla mostrou que a maioria dos modelos em 2022 estava drasticamente subtreinada — eles tinham muitos parâmetros para a quantidade de dados que viam. Um modelo de parâmetros 70B treinado em 1,4 trilhão de tokens (ideal para Chinchilla) superou um modelo de 280B treinado em 300 bilhões de tokens (Gopher).

Seu pipeline de dados determina se seu modelo aprende linguagem ou aprende ruído.

O Conceito

De Onde Vêm os Dados

Todo grande modelo de linguagem é treinado em uma mistura de fontes. A composição exata é um segredo bem guardado pela maioria dos laboratórios, mas sabemos o suficiente para entender as categorias.

Fonte Tamanho Qualidade Usado Por
Common Crawl ~250 TB bruto Baixa (precisa de filtragem pesada) GPT-3, Llama, maioria dos modelos abertos
Wikipedia ~20 GB Alta Todos os principais LLMs
Código do GitHub ~1 TB+ Média (muitos duplicados, código morto) StarCoder, CodeLlama, DeepSeek-Coder
Livros (BookCorpus, Pile) ~100 GB Alta GPT-2, GPT-3, modelos iniciais
Artigos acadêmicos (arXiv, S2ORC) ~100 GB Alta para STEM Llama, Galactica
StackOverflow, Reddit ~100 GB Média Llama, Falcon
Web com curadoria (C4, RefinedWeb) ~5 TB Média-Alta (pré-filtrada) T5, Falcon

O Llama 3 divulgou sua mistura de dados: cerca de 50% de dados da web, 25% de código, 13% de livros e artigos acadêmicos, 8% de dados de matemática e 4% de dados da web multilíngues. O total foi de 15,6 trilhões de tokens de fontes que excedem 5 TB de texto bruto.

A proporção importa tanto quanto o tamanho total. Dados excessivos da web e o modelo se torna um papagaio do Reddit. Pouco código e ele não consegue programar. Pouca matemática e ele falha no racício. Acertar essa mistura é uma das partes mais difíceis do treinamento de um LLM, e não há fórmula — exige experimentação e avaliação.

Limpeza de Dados

Os dados brutos da web são imundos. Um dump típico do Common Crawl contém:

  • Tags HTML e JavaScript
  • Cabeçalhos, rodapés e menus de navegação padronizados (boilerplate)
  • Páginas duplicadas (exatas e quase duplicadas)
  • Spam gerado por máquina
  • Informações de identificação pessoal (PII)
  • Texto de baixa qualidade (listas de palavras-chave, spam de SEO)
  • Conteúdo que não é texto codificado como texto

Limpar isso não é opcional. É a diferença entre um modelo que gera parágrafos coerentes e outro que produz tags HTML misturadas com listagens de produtos.

graph TD
    A[Texto Bruto] --> B[Remoção de HTML]
    B --> C[Detecção de Idioma]
    C --> D[Filtro de Qualidade]
    D --> E[Deduplicação]
    E --> F[Remoção de PII]
    F --> G[Texto Limpo]

    style A fill:#1a1a2e,stroke:#e94560,color:#fff
    style B fill:#1a1a2e,stroke:#e94560,color:#fff
    style C fill:#1a1a2e,stroke:#e94560,color:#fff
    style D fill:#1a1a2e,stroke:#e94560,color:#fff
    style E fill:#1a1a2e,stroke:#e94560,color:#fff
    style F fill:#1a1a2e,stroke:#e94560,color:#fff
    style G fill:#1a1a2e,stroke:#e94560,color:#fff

Cada etapa elimina uma categoria de ruído:

Remoção de HTML (HTML stripping): Remove toda a marcação. Mantém apenas o conteúdo de texto visível. Bibliotecas como trafilatura ou readability extraem o conteúdo do artigo enquanto descartam navegação, anúncios e boilerplate.

Detecção de idioma: Use o modelo de identificação de idioma do fastText (lid.176.bin) para classificar cada documento. Filtre para seus idiomas de destino. Um documento classificado como inglês com menos de 0,8 de confiança provavelmente não é um inglês limpo.

Filtragem de qualidade: É aqui que fica interessante. O RefinedWeb (o conjunto de dados por trás do Falcon) usa um filtro baseado em perplexidade: treina-se um pequeno modelo de linguagem na Wikipedia e, em seguida, pontua-se cada documento. Alta perplexidade significa que o documento é diferente da Wikipedia — provavelmente spam, listas de palavras-chave ou conteúdo gerado por máquina. Documentos com perplexidade acima de um limite são removidos.

Deduplicação: A etapa de limpeza individual de maior impacto. O Common Crawl contém um número enorme de páginas duplicadas — avisos legais, avisos de cookies, termos de serviço. O treinamento em duplicados desperdiça computação e pode fazer com que o modelo memorize e repita passagens específicas palavra por palavra.

Remoção de PII: Nomes, endereços de e-mail, números de telefone, números de previdência social/CPF. Detecção baseada em regex para PII estruturado, modelos NER (Reconhecimento de Entidade Nomeada) para nomes no contexto.

Deduplicação com MinHash

A deduplicação exata é fácil: faça o hash de cada documento, remova os duplicados. Mas os quase-duplicados são o problema real. Duas cópias do mesmo artigo de notícias com anúncios ligeiramente diferentes ao seu redor são quase-duplicados. O conteúdo é 95% idêntico, mas byte a byte eles diferem.

MinHash + Locality-Sensitive Hashing (LSH) resolve isso de forma eficiente.

graph LR
    A[Documento] --> B[Shingling]
    B --> C[Assinatura MinHash]
    C --> D[Buckets LSH]
    D --> E[Pares Candidatos]
    E --> F[Similaridade de Jaccard]
    F --> G[Conjunto Deduplicado]

    style A fill:#1a1a2e,stroke:#e94560,color:#fff
    style B fill:#1a1a2e,stroke:#e94560,color:#fff
    style C fill:#1a1a2e,stroke:#e94560,color:#fff
    style D fill:#1a1a2e,stroke:#e94560,color:#fff
    style E fill:#1a1a2e,stroke:#e94560,color:#fff
    style F fill:#1a1a2e,stroke:#e94560,color:#fff
    style G fill:#1a1a2e,stroke:#e94560,color:#fff

A ideia:

  1. Shingling: Converter cada documento em um conjunto de n-grams (por exemplo, 5-grams de palavras ou caracteres). "the quick brown fox" com shingles de 3 palavras torna-se {"the quick brown", "quick brown fox"}.

  2. MinHash: Para o conjunto de shingles de cada documento, computar k valores de hash. Cada valor de hash é o hash mínimo de todos os shingles sob uma função de hash diferente. Isso cria uma "assinatura" de tamanho fixo que aproxima a similaridade de Jaccard entre quaisquer dois documentos.

  3. LSH: Agrupar documentos em buckets com base em bandas de sua assinatura MinHash. Documentos no mesmo bucket são candidatos a quase-duplicados. Isso evita comparar todos os pares — você compara apenas os candidatos.

  4. Verificar: Para cada par candidato, calcular a similaridade exata de Jaccard. Remover uma cópia se a similaridade exceder um limite (geralmente 0,8).

A equipe do Llama relatou a remoção de aproximadamente 38% de seus dados da web por meio de deduplicação. Esse não é um número pequeno. Mais de um terço do Common Crawl é conteúdo duplicado ou quase duplicado.

Empacotamento de Sequências (Sequence Packing)

Seu modelo espera sequências de entrada de comprimento fixo. Seus documentos têm comprimento variável. Alguns têm 50 tokens. Alguns têm 50.000 tokens.

Abordagem ingênua: preencher (pad) cada documento até o comprimento máximo da sequência. Isso desperdiça uma quantidade enorme de computação em tokens de preenchimento (padding) que não contribuem em nada para o aprendizado.

Melhor abordagem: empacotar vários documentos em uma única sequência, separados por tokens de fim de sequência (EOS). Uma sequência de 2048 tokens pode conter três documentos curtos concatenados com tokens [EOS] entre eles.

graph TD
    subgraph Empacotamento Ingênuo
        A1["Doc A (200 tokens)"] --> P1["[PAD] x 1848"]
        A2["Doc B (500 tokens)"] --> P2["[PAD] x 1548"]
        A3["Doc C (100 tokens)"] --> P3["[PAD] x 1948"]
    end

    subgraph Empacotamento Eficiente
        B1["Doc A (200) | Doc B (500) | Doc C (100) | Doc D (400) | Doc E (848)"]
    end

    style A1 fill:#1a1a2e,stroke:#e94560,color:#fff
    style A2 fill:#1a1a2e,stroke:#e94560,color:#fff
    style A3 fill:#1a1a2e,stroke:#e94560,color:#fff
    style P1 fill:#333,stroke:#666,color:#999
    style P2 fill:#333,stroke:#666,color:#999
    style P3 fill:#333,stroke:#666,color:#999
    style B1 fill:#1a1a2e,stroke:#16c784,color:#fff

A máscara de atenção deve ser definida corretamente. Tokens do Documento A não devem prestar atenção (attend) a tokens do Documento B dentro da mesma sequência empacotada. Isso requer uma máscara de atenção em bloco diagonal (block-diagonal attention mask).

Documentos longos são truncados ou divididos em blocos (chunks) nos limites da sequência. O ponto de divisão importa: dividir no meio da frase força o modelo a ver pensamentos incompletos. Alguns pipelines alinham divisões a limites de parágrafos ou frases sempre que possível.

A Lei de Escala de Chinchilla (Chinchilla Scaling Law)

Para um orçamento de computação fixo C (medido em FLOPs), o tamanho ideal do modelo N e o tamanho do conjunto de dados D seguem:

N_opt ~ C^0.5
D_opt ~ C^0.5

Na prática, isso significa que você deve escalar o tamanho do modelo e o tamanho do conjunto de dados de maneira aproximadamente igual. Um modelo com 10x mais parâmetros precisa de cerca de 10x mais tokens de treinamento para atingir a mesma perda (loss).

Modelo Parâmetros Tokens de Treinamento Ideal para Chinchilla?
GPT-3 175B 300B Não (subtreinado 3-4x)
Chinchilla 70B 1.4T Sim (por design)
Llama 2 70B 2T Sobretreinado (intencionalmente)
Llama 3 70B 15T Fortemente sobretreinado

O Llama 3 viola deliberadamente a lei de Chinchilla. A Meta descobriu que treinar em excesso (overtraining) com mais dados — muito além da proporção ideal de computação — produz modelos melhores para inferência. O custo extra de treinamento é pago uma vez, mas o modelo menor é mais barato de servir para sempre. Isso às vezes é chamado de abordagem de escala "ideal para inferência" (inference-optimal) e se tornou o padrão da indústria desde 2024.

Build It

Passo 1: Text Cleaning

Remova HTML, normalize espaços em branco, remova conteúdo que não seja de texto. Usaremos um texto de domínio público (Project Gutenberg) como nosso pequeno corpus.

import re

def clean_text(text):
    text = re.sub(r"<[^>]+>", "", text)
    text = re.sub(r"http\S+", "", text)
    text = re.sub(r"[^\x20-\x7E\n]", "", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    text = re.sub(r" {2,}", " ", text)
    return text.strip()

def quality_filter(text, min_words=50, max_ratio_caps=0.3, max_ratio_special=0.1):
    words = text.split()
    if len(words) < min_words:
        return False
    caps_ratio = sum(1 for w in words if w.isupper()) / len(words)
    if caps_ratio > max_ratio_caps:
        return False
    special_chars = sum(1 for c in text if not c.isalnum() and not c.isspace())
    if special_chars / max(len(text), 1) > max_ratio_special:
        return False
    return True

O filtro de qualidade detecta spam de SEO (TUDO EM MAIÚSCULAS), ruído gerado por máquina (alta proporção de caracteres especiais) e páginas de rascunho/esboço (muito curtas). Essas três verificações sozinhas removem uma quantidade surpreendente de lixo dos rastreamentos da web (web crawls).

Passo 2: Deduplicação MinHash

Implemente o MinHash do zero. Não são necessárias bibliotecas externas — apenas hashlib.

import hashlib
from collections import defaultdict

def get_shingles(text, k=5):
    words = text.lower().split()
    if len(words) < k:
        return set()
    return {" ".join(words[i:i+k]) for i in range(len(words) - k + 1)}

def minhash_signature(shingles, num_hashes=128):
    signature = []
    for i in range(num_hashes):
        min_hash = float("inf")
        for shingle in shingles:
            h = int(hashlib.sha256(f"{i}:{shingle}".encode()).hexdigest(), 16)
            min_hash = min(min_hash, h)
        signature.append(min_hash)
    return signature

def lsh_buckets(signature, bands=16):
    rows_per_band = len(signature) // bands
    buckets = []
    for b in range(bands):
        start = b * rows_per_band
        band_data = tuple(signature[start:start + rows_per_band])
        bucket_hash = hashlib.md5(str(band_data).encode()).hexdigest()
        buckets.append((b, bucket_hash))
    return buckets

def deduplicate(documents, threshold=0.8, num_hashes=128, bands=16):
    signatures = []
    shingle_sets = []
    for doc in documents:
        shingles = get_shingles(doc)
        shingle_sets.append(shingles)
        signatures.append(minhash_signature(shingles, num_hashes))

    bucket_map = defaultdict(list)
    for doc_idx, sig in enumerate(signatures):
        for band_id, bucket_hash in lsh_buckets(sig, bands):
            bucket_map[(band_id, bucket_hash)].append(doc_idx)

    duplicate_pairs = set()
    for bucket_docs in bucket_map.values():
        if len(bucket_docs) < 2:
            continue
        for i in range(len(bucket_docs)):
            for j in range(i + 1, len(bucket_docs)):
                duplicate_pairs.add((bucket_docs[i], bucket_docs[j]))

    removed = set()
    for i, j in duplicate_pairs:
        if i in removed or j in removed:
            continue
        s1, s2 = shingle_sets[i], shingle_sets[j]
        if not s1 or not s2:
            continue
        jaccard = len(s1 & s2) / len(s1 | s2)
        if jaccard >= threshold:
            removed.add(j)

    return [doc for idx, doc in enumerate(documents) if idx not in removed], len(removed)

Os parâmetros num_hashes=128 e bands=16 controlam o equilíbrio entre precisão e revocação (precision-recall tradeoff). Mais hashes fornecem estimativas de similaridade mais precisas. Mais bandas aumentam a revocação (capturam mais duplicados) ao custo de mais falsos positivos. Esses valores funcionam bem para textos típicos da web.

Passo 3: Tokenizar e Empacotar Sequências

Pegue o texto limpo e deduplicado, tokenize-o e empacote-o em sequências de comprimento fixo para treinamento.

def tokenize_corpus(documents, tokenizer):
    all_tokens = []
    for doc in documents:
        tokens = tokenizer.encode(doc)
        all_tokens.extend(tokens)
        all_tokens.append(tokenizer.eos_id)
    return all_tokens

def pack_sequences(token_ids, seq_length, pad_id=0):
    sequences = []
    attention_masks = []
    for i in range(0, len(token_ids), seq_length):
        seq = token_ids[i:i + seq_length]
        mask = [1] * len(seq)
        if len(seq) < seq_length:
            pad_count = seq_length - len(seq)
            seq = seq + [pad_id] * pad_count
            mask = mask + [0] * pad_count
        sequences.append(seq)
        attention_masks.append(mask)
    return sequences, attention_masks

Passo 4: DataLoader para Treinamento

Retorne (yield) lotes aleatórios de sequências empacotadas. Isso é o que o loop de treinamento consome.

import random

class PreTrainingDataLoader:
    def __init__(self, sequences, attention_masks, batch_size, shuffle=True):
        self.sequences = sequences
        self.attention_masks = attention_masks
        self.batch_size = batch_size
        self.shuffle = shuffle

    def __len__(self):
        return (len(self.sequences) + self.batch_size - 1) // self.batch_size

    def __iter__(self):
        indices = list(range(len(self.sequences)))
        if self.shuffle:
            random.shuffle(indices)
        for start in range(0, len(indices), self.batch_size):
            batch_idx = indices[start:start + self.batch_size]
            batch_seqs = [self.sequences[i] for i in batch_idx]
            batch_masks = [self.attention_masks[i] for i in batch_idx]
            yield batch_seqs, batch_masks

Passo 5: Estatísticas do Dataset

Calcule os números que importam: total de tokens, tokens únicos, taxa de compressão, distribuição do comprimento dos documentos.

from collections import Counter

def compute_statistics(documents, token_ids, sequences, tokenizer_vocab_size):
    total_chars = sum(len(d) for d in documents)
    total_tokens = len(token_ids)
    unique_tokens = len(set(token_ids))
    compression_ratio = total_chars / total_tokens

    doc_lengths = [len(d.split()) for d in documents]
    avg_doc_length = sum(doc_lengths) / max(len(doc_lengths), 1)
    max_doc_length = max(doc_lengths) if doc_lengths else 0
    min_doc_length = min(doc_lengths) if doc_lengths else 0

    token_counts = Counter(token_ids)
    top_tokens = token_counts.most_common(10)

    non_pad_tokens = sum(sum(1 for t in seq if t != 0) for seq in sequences)
    total_positions = sum(len(seq) for seq in sequences)
    utilization = non_pad_tokens / max(total_positions, 1)

    stats = {
        "total_documents": len(documents),
        "total_characters": total_chars,
        "total_tokens": total_tokens,
        "unique_tokens": unique_tokens,
        "vocab_utilization": unique_tokens / tokenizer_vocab_size,
        "compression_ratio": compression_ratio,
        "avg_doc_length_words": avg_doc_length,
        "max_doc_length_words": max_doc_length,
        "min_doc_length_words": min_doc_length,
        "num_sequences": len(sequences),
        "sequence_utilization": utilization,
        "top_10_tokens": top_tokens,
    }
    return stats

A taxa de compressão diz o quão eficiente é o tokenizador nesse corpus. O texto em inglês normalmente é comprimido para cerca de 3-4 caracteres por token. Se você vir 1,5 caractere por token, seu tokenizador está dividindo de forma muito agressiva. Se vir mais de 8, significa que ele aprendeu junções (merges) muito específicas do domínio.

A utilização da sequência diz quanto das suas sequências empacotadas é dados reais versus preenchimento (padding). Menos de 90% significa que seu empacotamento é ineficiente — você está desperdiçando computação em tokens de preenchimento.

Use-o

Compare com os Datasets do HuggingFace

Carregue o mesmo corpus por meio da biblioteca datasets do HuggingFace e compare a velocidade do pipeline.

from datasets import load_dataset
from transformers import AutoTokenizer

ds = load_dataset("wikitext", "wikitext-2-raw-v1", split="train")
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Meta-Llama-3-8B")

import time

start = time.time()
tokenized = ds.map(
    lambda x: tokenizer(x["text"], truncation=True, max_length=2048),
    batched=True,
    num_proc=4,
)
hf_time = time.time() - start
total_tokens = sum(len(t) for t in tokenized["input_ids"])
print(f"HuggingFace: {total_tokens:,} tokens in {hf_time:.2f}s ({total_tokens/hf_time:,.0f} tokens/sec)")

O pipeline do HuggingFace usa tokenizadores Rust por baixo dos panos e processamento paralelo em 4 núcleos. Seu pipeline em Python puro será de 10 a 50 vezes mais lento. Essa lacuna é a razão pela qual as equipes de produção usam tokenizadores compilados. O algoritmo é o mesmo. A linguagem de implementação é a diferença.

Envie (Ship It)

Esta lição produz um prompt para validar e depurar a qualidade dos dados em pipelines de treinamento de LLM. Consulte outputs/prompt-data-quality-checker.md.

Exercícios

  1. Fácil: Adicione detecção de idioma ao pipeline de limpeza usando uma heurística simples (análise do conjunto de caracteres). Filtre apenas para documentos em inglês e meça quantos documentos são removidos.
  2. Médio: Implemente deduplicação exata usando hashes SHA-256 juntamente com a quase-deduplicação do MinHash. Compare a quantidade de duplicados capturados por cada método em um corpus extraído da web (web-scraped corpus).
  3. Difícil: Construa um filtro de qualidade baseado em perplexidade. Treine um pequeno modelo de linguagem de bigramas em texto da Wikipedia, pontue cada documento por perplexidade e remova os 20% inferiores. Compare a qualidade da saída do modelo ao treinar com dados filtrados vs não filtrados.

Termos-Chave

Termo O que as pessoas dizem O que realmente significa
Common Crawl "A internet" Uma organização sem fins lucrativos que rastreia a web mensalmente — ~250 TB brutos, o ponto de partida para a maioria dos dados de treinamento de LLM
MinHash "Algum truque de hash" Uma técnica para estimar a similaridade de Jaccard entre conjuntos usando assinaturas de tamanho fixo — permite a detecção de quase-duplicados em escala
LSH "Locality-Sensitive Hashing" Um método para agrupar itens semelhantes no mesmo bucket — reduz as comparações de pares de O(n^2) para quase linear
Sequence packing "Concatenar documentos" Ajustar múltiplos documentos em sequências de comprimento fixo com máscaras de atenção adequadas — elimina o desperdício de preenchimento (padding)
Chinchilla scaling "Treinar com mais dados" Para um orçamento de computação fixo, o desempenho ideal requer o dimensionamento do tamanho do modelo e dos tokens de treinamento de forma aproximadamente igual
Fertility "Tokens por palavra" Número médio de tokens por palavra — 1,3 para inglês no GPT-4, maior para escritas não latinas
Data mixing "Escolher dados de treinamento" A proporção de código vs texto vs matemática vs dados multilíngues — não há fórmula, exige experimentação
Perplexity filter "Pontuação de qualidade" Usar um pequeno modelo de linguagem para pontuar documentos — perplexidade alta significa que o texto é diferente de dados de referência limpos
Deduplication "Remover cópias" Eliminar documentos exatos e quase duplicados — normalmente remove de 30% a 40% dos dados brutos da web
Attention mask "Quais tokens olhar" Uma máscara binária que impede a atenção através de limites de documentos em sequências empacotadas

Leituras Adicionais

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).