Phase 10 - Lesson 04

Pré-Treinamento de um Mini GPT (124M Parâmetros)

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

O GPT-2 Small possui 124 milhões de parâmetros. Isso representa 12 camadas de transformer, 12 cabeças de atenção e embeddings de 768 dimensões. Você pode treiná-lo do zero em uma única GPU em poucas horas. A maioria das pessoas nunca faz isso. Elas usam checkpoints pré-treinados. Mas se você não treinar um por conta própria, você não entende de verdade o que está acontecendo dentro do modelo sobre o qual está construindo produtos.

Tipo: Construção Linguagens: Python (com numpy) Pré-requisitos: Phase 10, Lessons 01-03 (Tokenizers, Building a Tokenizer, Data Pipelines) Tempo: ~120 minutos

Objetivos de Aprendizagem

  • Implementar a arquitetura completa do GPT-2 (124M parâmetros) do zero: embeddings de tokens, embeddings posicionais, blocos de transformer e o cabeçalho do modelo de linguagem
  • Treinar um modelo GPT em um corpus de texto usando previsão do próximo token com perda de entropia cruzada
  • Implementar geração de texto autorregressiva com amostragem de temperatura e filtragem top-k/top-p
  • Monitorar as curvas de perda de treinamento e validar que o modelo aprende padrões de linguagem coerentes

O Problema

Você sabe o que é um transformer. Você leu os diagramas. Você consegue recitar "attention is all you need" e desenhar caixas rotuladas como "Multi-Head Attention" em um quadro branco.

Nada disso significa que você entende o que acontece quando um modelo gera texto.

Existem 124.438.272 parâmetros no GPT-2 Small (com compartilhamento de pesos/weight tying). Cada um deles foi definido executando um loop de treinamento: etapa forward, cálculo de perda, etapa backward, atualização de pesos. Doze blocos de transformer. Doze cabeças de atenção por bloco. Um espaço de embedding de 768 dimensões. Um vocabulário de 50.257 tokens. Toda vez que o modelo gera um token, todos os 124 milhões de parâmetros participam de uma única cadeia de multiplicação de matrizes que recebe uma sequência de IDs de tokens e produz uma distribuição de probabilidade sobre o próximo token.

Se você nunca construiu isso por conta própria, você está trabalhando com uma caixa preta. Você pode usar a API. Você pode fazer ajuste fino. Mas quando algo dá errado -- quando o modelo alucina, quando se repete, quando se recusa a seguir instruções -- você não tem um modelo mental do porquê.

Esta lição constrói o GPT-2 Small do zero. Não em PyTorch. Em numpy. Cada multiplicação de matrizes é visível. Cada gradiente é calculado pelo seu código. Você verá exatamente como 124 milhões de números conspiram para prever a próxima palavra.

O Conceito

A Arquitetura GPT

O GPT é um modelo de linguagem autorregressivo. "Autorregressivo" significa que ele gera um token de cada vez, cada um condicionado a todos os tokens anteriores. A arquitetura é uma pilha de blocos decodificadores de transformer.

Aqui está o grafo de computação completo, desde os IDs de tokens até as probabilidades do próximo token:

  1. Os IDs de tokens entram. Formato/Shape: (batch_size, seq_len).
  2. Busca de embedding de tokens. Cada ID mapeia para um vetor de 768 dimensões. Formato/Shape: (batch_size, seq_len, 768).
  3. Busca de embedding de posições. Cada posição (0, 1, 2, ...) mapeia para um vetor de 768 dimensões. Mesmo formato.
  4. Adiciona os embeddings de tokens + embeddings de posições.
  5. Passa por 12 blocos de transformer.
  6. Normalização de camada final (Layer normalization).
  7. Projeção linear para o tamanho do vocabulário. Formato/Shape: (batch_size, seq_len, vocab_size).
  8. Softmax para obter as probabilidades.

Esse é o modelo inteiro. Sem convoluções. Sem recorrência. Apenas embeddings, atenção, redes feedforward e normalizações de camada empilhados 12 vezes.

graph TD
    A["IDs de Tokens\n(batch, seq_len)"] --> B["Embeddings de Tokens\n(batch, seq_len, 768)"]
    A --> C["Embeddings de Posição\n(batch, seq_len, 768)"]
    B --> D["Adicionar"]
    C --> D
    D --> E["Bloco Transformer 1"]
    E --> F["Bloco Transformer 2"]
    F --> G["..."]
    G --> H["Bloco Transformer 12"]
    H --> I["Layer Norm"]
    I --> J["Cabeçalho Linear\n(768 -> 50257)"]
    J --> K["Softmax\nProbabilidades do próximo token"]

    style A fill:#1a1a2e,stroke:#e94560,color:#fff
    style B fill:#1a1a2e,stroke:#0f3460,color:#fff
    style C fill:#1a1a2e,stroke:#0f3460,color:#fff
    style D fill:#1a1a2e,stroke:#16213e,color:#fff
    style E fill:#1a1a2e,stroke:#e94560,color:#fff
    style F fill:#1a1a2e,stroke:#e94560,color:#fff
    style H fill:#1a1a2e,stroke:#e94560,color:#fff
    style I fill:#1a1a2e,stroke:#16213e,color:#fff
    style J fill:#1a1a2e,stroke:#0f3460,color:#fff
    style K fill:#1a1a2e,stroke:#51cf66,color:#fff

O Bloco Transformer

Cada um dos 12 blocos segue o mesmo padrão. Arquitetura pre-norm (o GPT-2 usa pre-norm, não post-norm como o transformer original):

  1. LayerNorm
  2. Multi-Head Self-Attention
  3. Conexão residual (adiciona a entrada de volta)
  4. LayerNorm
  5. Rede Feed-Forward (MLP)
  6. Conexão residual (adiciona a entrada de volta)

As conexões residuais são críticas. Sem elas, os gradientes desaparecem antes de atingirem o bloco 1 durante a retropropagação (backpropagation). Com elas, os gradientes podem fluir diretamente da perda para qualquer camada através do caminho de "atalho" (skip path). É por isso que você pode empilhar 12, 32 ou até 96 blocos (há rumores de que o GPT-4 usa 120).

Atenção: O Mecanismo Central

A autoatenção (self-attention) permite que cada token olhe para cada token anterior e decida quanta atenção dar a cada um. Aqui está a matemática.

Para cada posição de token, calcule três vetores a partir da entrada:

  • Query (Q): "O que estou procurando?"
  • Key (K): "O que eu contenho?"
  • Value (V): "Que informação eu carrego?"
Q = input @ W_q    (768 -> 768)
K = input @ W_k    (768 -> 768)
V = input @ W_v    (768 -> 768)

attention_scores = Q @ K^T / sqrt(d_k)
attention_scores = mask(attention_scores)   # causal mask: -inf for future positions
attention_weights = softmax(attention_scores)
output = attention_weights @ V

A máscara causal é o que torna o GPT autorregressivo. A posição 5 pode dar atenção às posições 0-5, mas não a 6, 7, 8 e assim por diante. Isso impede que o modelo "trapaceie" olhando para tokens futuros durante o treinamento.

A atenção multicabeça (multi-head attention) divide o espaço de 768 dimensões em 12 cabeças de 64 dimensões cada. Cada cabeça aprende um padrão de atenção diferente. Uma cabeça pode rastrear relações sintáticas (concordância sujeito-verbo). Outra pode rastrear similaridade semântica (sinônimos). Outra pode rastrear proximidade posicional (palavras próximas). As saídas de todas as 12 cabeças são concatenadas e projetadas de volta para 768 dimensões.

graph LR
    subgraph MultiHead["Atenção Multicabeça (12 cabeças)"]
        direction TB
        I["Entrada (768)"] --> S1["Dividir em 12 cabeças"]
        S1 --> H1["Cabeça 1\n(64 dims)"]
        S1 --> H2["Cabeça 2\n(64 dims)"]
        S1 --> H3["..."]
        S1 --> H12["Cabeça 12\n(64 dims)"]
        H1 --> C["Concat (768)"]
        H2 --> C
        H3 --> C
        H12 --> C
        C --> O["Projeção de Saída\n(768 -> 768)"]
    end

    subgraph SingleHead["Cada Cabeça Computa"]
        direction TB
        Q["Q = X @ W_q"] --> A["scores = Q @ K^T / 8"]
        K["K = X @ W_k"] --> A
        A --> M["Aplicar máscara causal"]
        M --> SM["Softmax"]
        SM --> MUL["weights @ V"]
        V["V = X @ W_v"] --> MUL
    end

    style I fill:#1a1a2e,stroke:#e94560,color:#fff
    style O fill:#1a1a2e,stroke:#e94560,color:#fff
    style Q fill:#1a1a2e,stroke:#0f3460,color:#fff
    style K fill:#1a1a2e,stroke:#0f3460,color:#fff
    style V fill:#1a1a2e,stroke:#0f3460,color:#fff

O escalonamento consiste na divisão por sqrt(d_k) -- sqrt(64) = 8. Sem ela, os produtos escalares tornam-se grandes para vetores de alta dimensão, empurrando o softmax para regiões onde os gradientes são quase zero. Esse foi um dos principais insights do artigo original "Attention Is All You Need".

KV Cache: Por que a Inferência é Rápida

Durante o treinamento, você processa a sequência inteira de uma vez. Durante a inferência, você gera um token de cada vez. Sem otimização, a geração do token N exige a computação da atenção para todos os N-1 tokens anteriores. Isso é O(N^2) por token gerado, ou O(N^3) no total para uma sequência de comprimento N.

O KV Cache resolve isso. Após computar K e V para cada token, armazene-os. Ao gerar o token N+1, você só precisa computar Q para o novo token e buscar os K e V em cache de todos os tokens anteriores. Isso reduz o custo por token de O(N) para O(1) para a computação de K e V. O cálculo do score de atenção ainda é O(N) porque você dá atenção a todas as posições anteriores, mas você evita multiplicações de matrizes redundantes na entrada.

Para o GPT-2 com 12 camadas e 12 cabeças, o KV cache armazena 2 (K + V) x 12 camadas x 12 cabeças x 64 dims = 18.432 valores por token. Para uma sequência de 1024 tokens, isso representa cerca de 75MB em FP32. Para o Llama 3 405B com 128 camadas, o KV cache para uma única sequência pode exceder 10GB. É por isso que a inferência de contexto longo é limitada pela memória (memory-bound).

Prefill vs Decode: Duas Fases de Inferência

Quando você envia um prompt para um LLM, a inferência acontece em duas fases distintas.

Prefill processa todo o seu prompt em paralelo. Todos os tokens são conhecidos, então o modelo pode computar a atenção para todas as posições simultaneamente. Esta fase é limitada pelo poder computacional (compute-bound) -- a GPU está realizando multiplicações de matrizes em sua capacidade máxima. Para um prompt de 1000 tokens em uma A100, o prefill leva cerca de 20-50ms.

Decode gera tokens um de cada vez. Cada novo token depende de todos os tokens anteriores. Esta fase é limitada pela largura de banda de memória (memory-bound) -- o gargalo é ler os pesos do modelo e o KV cache da memória da GPU, e não a matemática de matrizes em si. Os núcleos de computação da GPU ficam quase todos ociosos esperando pelas leituras de memória. Para o GPT-2, cada etapa de decodificação leva aproximadamente o mesmo tempo, independentemente de quantos FLOPs as multiplicações de matrizes exijam, porque a largura de banda de memória é a restrição.

Essa distinção é importante para sistemas de produção. O rendimento (throughput) de prefill escala com a computação da GPU (mais FLOPS = prefill mais rápido). O rendimento de decode escala com a largura de banda de memória (memória mais rápida = decode mais rápido). É por isso que a H100 da NVIDIA focou em melhorias de largura de banda de memória em relação à A100 -- isso acelera diretamente a geração de tokens.

graph LR
    subgraph Prefill["Fase 1: Prefill"]
        direction TB
        P1["Prompt completo\n(todos os tokens conhecidos)"] --> P2["Computação paralela\n(limitada por computação)"] --> P3["Constrói o KV Cache"]
    end

    subgraph Decode["Fase 2: Decode"]
        direction TB
        D1["Gerar token N"] --> D2["Ler KV Cache\n(limitado por memória)"] --> D3["Anexar ao KV Cache"] --> D4["Gerar token N+1"]
        D4 -.->|repetir| D1
    end

    Prefill --> Decode

    style P1 fill:#1a1a2e,stroke:#51cf66,color:#fff
    style P2 fill:#1a1a2e,stroke:#51cf66,color:#fff
    style P3 fill:#1a1a2e,stroke:#51cf66,color:#fff
    style D1 fill:#1a1a2e,stroke:#e94560,color:#fff
    style D2 fill:#1a1a2e,stroke:#e94560,color:#fff
    style D3 fill:#1a1a2e,stroke:#e94560,color:#fff
    style D4 fill:#1a1a2e,stroke:#e94560,color:#fff

O Loop de Treinamento

Treinar um LLM consiste na previsão do próximo token. Dados os tokens [0, 1, 2, ..., N-1], preveja os tokens [1, 2, 3, ..., N]. A função de perda é a entropia cruzada entre a distribuição de probabilidade prevista do modelo e o próximo token real.

Uma etapa de treinamento:

  1. Etapa Forward: Executar o lote por todos os 12 blocos. Obter logits (scores pré-softmax) para cada posição.
  2. Calcular Perda: Entropia cruzada entre os logits e os tokens alvo (a entrada deslocada por uma posição).
  3. Etapa Backward: Calcular os gradientes para todos os 124M parâmetros usando retropropagação.
  4. Etapa do Otimizador: Atualizar os pesos. O GPT-2 usa Adam com aquecimento de taxa de aprendizado (learning rate warmup) e decaimento de cosseno.

O cronograma de taxa de aprendizado (learning rate schedule) importa mais do que você imagina. O GPT-2 faz o aquecimento (warmup) de 0 até a taxa de aprendizado de pico nas primeiras 2.000 etapas, depois decai seguindo uma curva de cosseno. Começar com uma taxa de aprendizado alta faz o modelo divergir. Manter uma taxa alta constante causa oscilação no treinamento posterior. O padrão de warmup-then-decay é usado por todos os grandes LLMs.

GPT-2 Small: Os Números

Componente Formato Parâmetros
Embeddings de tokens (50257, 768) 38.597,376
Embeddings de posições (1024, 768) 786.432
Atenção por bloco (W_q, W_k, W_v, W_out) 4 x (768, 768) 2.359,296
FFN por bloco (up + down) (768, 3072) + (3072, 768) 4.718,592
LayerNorms por bloco (2x) 2 x 768 x 2 3.072
LayerNorm Final 768 x 2 1.536
Total por bloco 7.080,960
Total (12 blocos) 85.054,464 + 39.383,808 = 124.438,272

A projeção de saída (cabeçalho de logits) compartilha pesos com a matriz de embeddings de tokens. Isso é chamado de compartilhamento de pesos (weight tying) -- reduz a contagem de parâmetros em 38M e melhora o desempenho porque força o modelo a usar o mesmo espaço de representação para entrada e saída.

Construa

Passo 1: Camada de Embedding

Os embeddings de tokens mapeiam cada um dos 50.257 tokens possíveis para um vetor de 768 dimensões. Os embeddings de posições adicionam informações sobre onde cada token está na sequência. Os dois são somados.

import numpy as np

class Embedding:
    def __init__(self, vocab_size, embed_dim, max_seq_len):
        self.token_embed = np.random.randn(vocab_size, embed_dim) * 0.02
        self.pos_embed = np.random.randn(max_seq_len, embed_dim) * 0.02

    def forward(self, token_ids):
        seq_len = token_ids.shape[-1]
        tok_emb = self.token_embed[token_ids]
        pos_emb = self.pos_embed[:seq_len]
        return tok_emb + pos_emb

O desvio padrão de 0,02 para inicialização vem do artigo do GPT-2. Valores muito grandes farão com que as etapas forward iniciais produzam valores extremos que desestabilizam o treinamento. Valores muito pequenos farão com que as saídas iniciais sejam quase idênticas para todas as entradas, tornando inúteis os sinais de gradiente iniciais.

Passo 2: Autoatenção com Máscara Causal

Primeiro, atenção de cabeça única (single-head attention). A máscara causal define as posições futuras como menos infinito antes do softmax, garantindo que cada posição só possa prestar atenção a si mesma e às posições anteriores.

def attention(Q, K, V, mask=None):
    d_k = Q.shape[-1]
    scores = Q @ K.transpose(0, -1, -2 if Q.ndim == 4 else 1) / np.sqrt(d_k)
    if mask is not None:
        scores = scores + mask
    weights = np.exp(scores - scores.max(axis=-1, keepdims=True))
    weights = weights / weights.sum(axis=-1, keepdims=True)
    return weights @ V

A implementação do softmax subtrai o valor máximo antes da exponenciação. Sem isso, exp(numero_grande) causa estouro (overflow) para infinito. Este é um truque de estabilidade numérica que não altera a saída porque softmax(x - c) = softmax(x) para qualquer constante c.

Passo 3: Atenção Multicabeça

Divida a entrada de 768 dimensões em 12 cabeças de 64 dimensões cada. Cada cabeça calcula a atenção de forma independente. Concatene os resultados e projete de volta para 768 dimensões.

class MultiHeadAttention:
    def __init__(self, embed_dim, num_heads):
        self.num_heads = num_heads
        self.head_dim = embed_dim // num_heads
        self.W_q = np.random.randn(embed_dim, embed_dim) * 0.02
        self.W_k = np.random.randn(embed_dim, embed_dim) * 0.02
        self.W_v = np.random.randn(embed_dim, embed_dim) * 0.02
        self.W_out = np.random.randn(embed_dim, embed_dim) * 0.02

    def forward(self, x, mask=None):
        batch, seq_len, d = x.shape
        Q = (x @ self.W_q).reshape(batch, seq_len, self.num_heads, self.head_dim).transpose(0, 2, 1, 3)
        K = (x @ self.W_k).reshape(batch, seq_len, self.num_heads, self.head_dim).transpose(0, 2, 1, 3)
        V = (x @ self.W_v).reshape(batch, seq_len, self.num_heads, self.head_dim).transpose(0, 2, 1, 3)

        scores = Q @ K.transpose(0, 1, 3, 2) / np.sqrt(self.head_dim)
        if mask is not None:
            scores = scores + mask
        weights = np.exp(scores - scores.max(axis=-1, keepdims=True))
        weights = weights / weights.sum(axis=-1, keepdims=True)
        attn_out = weights @ V

        attn_out = attn_out.transpose(0, 2, 1, 3).reshape(batch, seq_len, d)
        return attn_out @ self.W_out

A dança do reshape-transpose-reshape é a parte mais confusa da atenção multicabeça. Aqui está o que acontece: o tensor de (batch, seq_len, 768) se torna (batch, seq_len, 12, 64), depois (batch, 12, seq_len, 64). Agora, cada uma das 12 cabeças tem sua própria matriz de (seq_len, 64) para rodar a atenção. Após a atenção, revertemos o processo: (batch, 12, seq_len, 64) torna-se (batch, seq_len, 12, 64) que torna-se (batch, seq_len, 768).

Passo 4: Bloco Transformer

Um bloco transformer completo: LayerNorm, atenção multicabeça com residual, LayerNorm, feedforward com residual.

class LayerNorm:
    def __init__(self, dim, eps=1e-5):
        self.gamma = np.ones(dim)
        self.beta = np.zeros(dim)
        self.eps = eps

    def forward(self, x):
        mean = x.mean(axis=-1, keepdims=True)
        var = x.var(axis=-1, keepdims=True)
        return self.gamma * (x - mean) / np.sqrt(var + self.eps) + self.beta


class FeedForward:
    def __init__(self, embed_dim, ff_dim):
        self.W1 = np.random.randn(embed_dim, ff_dim) * 0.02
        self.b1 = np.zeros(ff_dim)
        self.W2 = np.random.randn(ff_dim, embed_dim) * 0.02
        self.b2 = np.zeros(embed_dim)

    def forward(self, x):
        h = x @ self.W1 + self.b1
        h = np.maximum(0, h)  # GELU approximation: ReLU for simplicity
        return h @ self.W2 + self.b2


class TransformerBlock:
    def __init__(self, embed_dim, num_heads, ff_dim):
        self.ln1 = LayerNorm(embed_dim)
        self.attn = MultiHeadAttention(embed_dim, num_heads)
        self.ln2 = LayerNorm(embed_dim)
        self.ffn = FeedForward(embed_dim, ff_dim)

    def forward(self, x, mask=None):
        x = x + self.attn.forward(self.ln1.forward(x), mask)
        x = x + self.ffn.forward(self.ln2.forward(x))
        return x

A rede feedforward expande a entrada de 768 dimensões para 3.072 dimensões (4x), aplica uma não linearidade e depois projeta de volta para 768. Esse padrão de expansão e contração dá ao modelo uma representação interna "mais ampla" para trabalhar em cada posição. O GPT-2 usa a ativação GELU, mas aqui usamos ReLU por simplicidade -- a diferença é pequena para a compreensão da arquitetura.

Passo 5: Modelo GPT Completo

Empilhe 12 blocos de transformer. Adicione a camada de embedding na parte frontal e a projeção de saída na parte traseira.

class MiniGPT:
    def __init__(self, vocab_size=50257, embed_dim=768, num_heads=12,
                 num_layers=12, max_seq_len=1024, ff_dim=3072):
        self.embedding = Embedding(vocab_size, embed_dim, max_seq_len)
        self.blocks = [
            TransformerBlock(embed_dim, num_heads, ff_dim)
            for _ in range(num_layers)
        ]
        self.ln_f = LayerNorm(embed_dim)
        self.vocab_size = vocab_size
        self.embed_dim = embed_dim

    def forward(self, token_ids):
        seq_len = token_ids.shape[-1]
        mask = np.triu(np.full((seq_len, seq_len), -1e9), k=1)

        x = self.embedding.forward(token_ids)
        for block in self.blocks:
            x = block.forward(x, mask)
        x = self.ln_f.forward(x)

        logits = x @ self.embedding.token_embed.T
        return logits

    def count_parameters(self):
        total = 0
        total += self.embedding.token_embed.size
        total += self.embedding.pos_embed.size
        for block in self.blocks:
            total += block.attn.W_q.size + block.attn.W_k.size
            total += block.attn.W_v.size + block.attn.W_out.size
            total += block.ffn.W1.size + block.ffn.b1.size
            total += block.ffn.W2.size + block.ffn.b2.size
            total += block.ln1.gamma.size + block.ln1.beta.size
            total += block.ln2.gamma.size + block.ln2.beta.size
        total += self.ln_f.gamma.size + self.ln_f.beta.size
        return total

Observe o compartilhamento de pesos (weight tying): logits = x @ self.embedding.token_embed.T. A projeção de saída reutiliza a matriz de embeddings de tokens (transposta). Isso não é apenas um truque para economizar parâmetros. Significa que o modelo usa o mesmo espaço vetorial para entender tokens (embeddings) e prevê-los (saída).

Passo 6: Loop de Treinamento

Para uma execução de treinamento real em 124M parâmetros, você precisaria de uma GPU e do PyTorch. Este loop de treinamento demonstra a mecânica em um modelo pequeno executado em numpy puro. Usamos um modelo minúsculo (4 camadas, 4 cabeças, 128 dimensões) para torná-lo viável.

def cross_entropy_loss(logits, targets):
    batch, seq_len, vocab_size = logits.shape
    logits_flat = logits.reshape(-1, vocab_size)
    targets_flat = targets.reshape(-1)

    max_logits = logits_flat.max(axis=-1, keepdims=True)
    log_softmax = logits_flat - max_logits - np.log(
        np.exp(logits_flat - max_logits).sum(axis=-1, keepdims=True)
    )

    loss = -log_softmax[np.arange(len(targets_flat)), targets_flat].mean()
    return loss


def train_mini_gpt(text, vocab_size=256, embed_dim=128, num_heads=4,
                   num_layers=4, seq_len=64, num_steps=200, lr=3e-4):
    tokens = np.array(list(text.encode("utf-8")[:2048]))
    model = MiniGPT(
        vocab_size=vocab_size, embed_dim=embed_dim, num_heads=num_heads,
        num_layers=num_layers, max_seq_len=seq_len, ff_dim=embed_dim * 4
    )

    print(f"Model parameters: {model.count_parameters():,}")
    print(f"Training tokens: {len(tokens):,}")
    print(f"Config: {num_layers} layers, {num_heads} heads, {embed_dim} dims")
    print()

    for step in range(num_steps):
        start_idx = np.random.randint(0, max(1, len(tokens) - seq_len - 1))
        batch_tokens = tokens[start_idx:start_idx + seq_len + 1]

        input_ids = batch_tokens[:-1].reshape(1, -1)
        target_ids = batch_tokens[1:].reshape(1, -1)

        logits = model.forward(input_ids)
        loss = cross_entropy_loss(logits, target_ids)

        if step % 20 == 0:
            print(f"Step {step:4d} | Loss: {loss:.4f}")

    return model

A perda começa perto de ln(vocab_size) -- para um vocabulário de nível de byte de 256 tokens, isso é ln(256) = 5,55. Um modelo aleatório atribui probabilidade igual a cada token. À medida que o treinamento avança, a perda cai porque o modelo aprende a prever padrões comuns: "th" depois de "t", espaço depois de um ponto e assim por diante.

Na produção, você usaria o otimizador Adam com acúmulo de gradiente (gradient accumulation), aquecimento de taxa de aprendizado (learning rate warmup) e corte de gradiente (gradient clipping). O loop forward-pass-loss-backward-update é idêntico. O otimizador é mais sofisticado.

Passo 7: Geração de Texto

A geração usa o modelo treinado para prever um token de cada vez. Cada previsão é amostrada a partir da distribuição de saída (ou tomada de forma gananciosa/greedy como o argmax).

def generate(model, prompt_tokens, max_new_tokens=100, temperature=0.8):
    tokens = list(prompt_tokens)
    seq_len = model.embedding.pos_embed.shape[0]

    for _ in range(max_new_tokens):
        context = np.array(tokens[-seq_len:]).reshape(1, -1)
        logits = model.forward(context)
        next_logits = logits[0, -1, :]

        next_logits = next_logits / temperature
        probs = np.exp(next_logits - next_logits.max())
        probs = probs / probs.sum()

        next_token = np.random.choice(len(probs), p=probs)
        tokens.append(next_token)

    return tokens

A temperatura controla a aleatoriedade. A temperatura 1,0 usa a distribuição bruta. A temperatura 0,5 a acentua (mais determinística -- o modelo escolhe suas principais opções com mais frequência). A temperatura 1,5 a achata (mais aleatória -- tokens de baixa probabilidade ganham uma chance maior). A temperatura 0,0 é a decodificação gananciosa (sempre escolhe o token de maior probabilidade).

A janela tokens[-seq_len:] é necessária porque o modelo tem um comprimento máximo de contexto (1024 para o GPT-2). Quando você o excede, você deve descartar os tokens mais antigos. Esta é a "janela de contexto" de que todos falam.

Use

Demonstração Completa de Treinamento e Geração

corpus = """The transformer architecture has revolutionized natural language processing.
Attention mechanisms allow the model to focus on relevant parts of the input.
Self-attention computes relationships between all pairs of positions in a sequence.
Multi-head attention splits the representation into multiple subspaces.
Each attention head can learn different types of relationships.
The feedforward network provides nonlinear transformations at each position.
Residual connections enable gradient flow through deep networks.
Layer normalization stabilizes training by normalizing activations.
Position embeddings give the model information about token ordering.
The causal mask ensures autoregressive generation during training.
Pre-training on large text corpora teaches the model general language understanding.
Fine-tuning adapts the pre-trained model to specific downstream tasks."""

model = train_mini_gpt(corpus, num_steps=200)

prompt = list("The transformer".encode("utf-8"))
output_tokens = generate(model, prompt, max_new_tokens=100, temperature=0.8)
generated_text = bytes(output_tokens).decode("utf-8", errors="replace")
print(f"\nGenerated: {generated_text}")

Em um corpus pequeno com um modelo pequeno, o texto gerado será semi-coerente na melhor das hipóteses. Ele aprenderá alguns padrões no nível de byte a partir do texto de treinamento, mas não conseguirá generalizar da forma como o GPT-2 faz com 40GB de dados de treinamento e a arquitetura completa de 124M parâmetros. O objetivo não é a qualidade da saída. O objetivo é que você possa rastrear cada etapa: busca de embedding, cálculo de atenção, transformação feedforward, projeção de logit, softmax e amostragem. Cada operação é visível.

Envie

Esta lição produz outputs/prompt-gpt-architecture-analyzer.md -- um prompt que analisa as escolhas de arquitetura em qualquer modelo estilo GPT. Forneça a ele um model card ou relatório técnico e ele detalhará a alocação de parâmetros, o design de atenção e as decisões de escala.

Exercícios

  1. Modifique o modelo para usar 24 camadas e 16 cabeças em vez de 12/12. Conte os parâmetros. Como a duplicação da profundidade se compara à duplicação da largura (dimensão do embedding)?

  2. Implemente a função de ativação GELU (GELU(x) = x * 0,5 * (1 + erf(x / sqrt(2)))) e substitua a ReLU na rede feedforward. Execute o treinamento por 500 etapas com cada ativação e compare a perda final.

  3. Adicione um KV cache à função de geração. Armazene os tensores K e V de cada camada após a primeira etapa forward e reutilize-os para os tokens subsequentes. Meça a aceleração: gere 200 tokens com e sem o cache e compare o tempo real de relógio.

  4. Implemente a amostragem top-k (considere apenas os k tokens de maior probabilidade) e a amostragem top-p (amostragem de núcleo: considere o menor conjunto de tokens cuja probabilidade cumulativa exceda p). Compare a qualidade da saída na temperatura 0,8 com top-k=50 versus top-p=0,95.

  5. Construa um plotador de curva de perda de treinamento. Treine o modelo por 1000 etapas e plote a perda em relação à etapa. Identifique as três fases: descida inicial rápida (aprendendo bytes comuns), fase intermediária mais lenta (aprendendo padrões de bytes) e platô (sobreajuste/overfitting no corpus pequeno). O formato desta curva é o mesmo se você estiver treinando um modelo de 128 dimensões ou o GPT-4.

Termos-Chave

Termo O que as pessoas dizem O que realmente significa
Autorregresivo "Gera uma palavra de cada vez" Cada token de saída é condicionado a todos os tokens anteriores -- o modelo prevê P(token_n | token_0, ..., token_{n-1})
Máscara causal "Não pode ver o futuro" Uma matriz triangular superior de valores de -infinito que impede a atenção a posições futuras durante o treinamento
Atenção multicabeça "Múltiplos padrões de atenção" Divisão de Q, K, V em cabeças paralelas (por exemplo, 12 cabeças de 64 dimensões cada para o GPT-2) para que cada cabeça possa aprender diferentes tipos de relação
KV Cache "Cache para velocidade" Armazenamento dos tensores Key (Chave) e Value (Valor) computados a partir de tokens anteriores para evitar computação redundante durante a geração autorregresiva
Prefill "Processando o prompt" A primeira fase de inferência onde todos os tokens do prompt são processados em paralelo -- limitada por poder computacional nos FLOPS da GPU
Decode "Gerando tokens" A segunda fase de inferência onde os tokens são gerados um de cada vez -- limitada por memória na largura de banda da GPU
Compartilhamento de pesos (Weight tying) "Compartilhamento de embeddings" Uso da mesma matriz para embeddings de tokens de entrada e para o cabeçalho de projeção de saída -- economiza 38M de parâmetros no GPT-2
Conexão residual "Conexão de salto (Skip connection)" Adição da entrada diretamente à saída de uma subcamada (x + sublayer(x)) -- permite o fluxo de gradiente em redes profundas
Normalização de camada (Layer normalization) "Normalização de ativações" Normalização ao longo da dimensão de características (features) para média 0 e variância 1, com parâmetros aprendíveis de escala (scale) e viés (bias)
Perda de entropia cruzada "Quão erradas estão as previsões" -log(probabilidade atribuída ao próximo token correto), com média calculada sobre todas as posições -- o objetivo padrão de treinamento de LLMs

Leituras Adicionais

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